You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
338 lines
11 KiB
338 lines
11 KiB
import type { SemaSignatureOverride, SoundSignature } from './channels';
|
|
import type { SoundVoice } from '$sound';
|
|
|
|
export type SoundOverride = NonNullable<SemaSignatureOverride['sound']>;
|
|
|
|
/** Name sema's voice registers under in the sound orchestrator. */
|
|
export const SEMA_SOUND_VOICE_NAME = 'sema';
|
|
|
|
/**
|
|
* Sema's synthesis voice — the perceptual calibration its vocabulary was tuned
|
|
* against (note durations, hold windows, the audible distance between intents):
|
|
* the second oscillator at a fifth mixed at 0.3, the envelope clamps (attack
|
|
* ≤ 15 % of the note, release ≤ 40 %), the ±400-cent contour sweep and the
|
|
* roughness→AM mapping.
|
|
*
|
|
* Moved here from the art's baked-in constants (redesign 2026-07-31, D-SR.3 in
|
|
* `PLAN-sound-redesign.md`): the engine owns the synthesis MACHINERY, the
|
|
* consumer owns the CALIBRATION. Today it matches the engine's default voice
|
|
* value-for-value, so registering it changes nothing audible — it establishes
|
|
* ownership.
|
|
*/
|
|
export const SEMA_SOUND_VOICE: SoundVoice = {
|
|
interval: { ratio: 1.5, mix: 0.3 },
|
|
envelope: { maxAttackFraction: 0.15, maxReleaseFraction: 0.4 },
|
|
contour: { sweepCents: 400 },
|
|
am: { threshold: 0.2, baseHz: 30, slopeHz: 150, depthScale: 0.5 }
|
|
};
|
|
export type SoundDirection = SoundSignature['contour'] extends infer T
|
|
? Extract<T, 'ascending' | 'descending'>
|
|
: never;
|
|
|
|
export interface SynthSoundDefinition {
|
|
kind: 'synth';
|
|
signature: SoundSignature;
|
|
}
|
|
|
|
export interface SampleSoundDefinition {
|
|
kind: 'sample';
|
|
sampleUrl: string;
|
|
fallback: SoundSignature;
|
|
preload?: boolean;
|
|
}
|
|
|
|
export type SoundDefinition = SynthSoundDefinition | SampleSoundDefinition;
|
|
|
|
export interface DragSoundParams {
|
|
value01: number;
|
|
velocity01: number;
|
|
direction: SoundDirection;
|
|
}
|
|
|
|
const synth = (signature: SoundSignature): SynthSoundDefinition => ({
|
|
kind: 'synth',
|
|
signature
|
|
});
|
|
|
|
export const sample = (
|
|
sampleUrl: string,
|
|
fallback: SoundSignature,
|
|
opts: { preload?: boolean } = {}
|
|
): SampleSoundDefinition => ({
|
|
kind: 'sample',
|
|
sampleUrl,
|
|
fallback,
|
|
preload: opts.preload
|
|
});
|
|
|
|
export const SOUND_LIBRARY = {
|
|
'handle.pickup.air': synth({
|
|
pitch: 1080,
|
|
centroid: 5200,
|
|
roughness: 0.004,
|
|
attack: 18,
|
|
decay: 120,
|
|
duration: 118,
|
|
contour: 'ascending',
|
|
gain: 0.045
|
|
}),
|
|
'handle.release.soft': synth({
|
|
pitch: 920,
|
|
centroid: 4600,
|
|
roughness: 0.004,
|
|
attack: 18,
|
|
decay: 140,
|
|
duration: 136,
|
|
contour: 'descending',
|
|
gain: 0.04
|
|
}),
|
|
'handle.snap.chime': synth({
|
|
pitch: 1040,
|
|
centroid: 5400,
|
|
roughness: 0.005,
|
|
attack: 16,
|
|
decay: 130,
|
|
duration: 128,
|
|
contour: 'arc',
|
|
gain: 0.05
|
|
}),
|
|
'overlay.whoosh': sample('/sounds/whoosh.wav', {
|
|
pitch: 640,
|
|
centroid: 1800,
|
|
roughness: 0.03,
|
|
attack: 20,
|
|
decay: 180,
|
|
duration: 180,
|
|
contour: 'ascending',
|
|
gain: 0.12
|
|
}),
|
|
'surface.pop': sample('/sounds/pop.wav', {
|
|
pitch: 900,
|
|
centroid: 4200,
|
|
roughness: 0.02,
|
|
attack: 3,
|
|
decay: 70,
|
|
duration: 80,
|
|
contour: 'bell',
|
|
gain: 0.1
|
|
}),
|
|
'notification.ping': sample('/sounds/ping.wav', {
|
|
pitch: 960,
|
|
centroid: 3000,
|
|
roughness: 0.05,
|
|
attack: 4,
|
|
decay: 120,
|
|
duration: 160,
|
|
contour: 'arc',
|
|
gain: 0.12
|
|
}),
|
|
'notification.ding': sample('/sounds/ding.wav', {
|
|
pitch: 1180,
|
|
centroid: 3600,
|
|
roughness: 0.03,
|
|
attack: 4,
|
|
decay: 150,
|
|
duration: 180,
|
|
contour: 'ascending',
|
|
gain: 0.1
|
|
}),
|
|
'alert.error': sample('/sounds/error.wav', {
|
|
pitch: 520,
|
|
centroid: 1400,
|
|
roughness: 0.45,
|
|
attack: 4,
|
|
decay: 180,
|
|
duration: 220,
|
|
contour: 'descending',
|
|
gain: 0.18
|
|
})
|
|
} as const satisfies Record<string, SoundDefinition>;
|
|
|
|
/**
|
|
* Tuning catalogue — parametric deltas over a family's base signature.
|
|
*
|
|
* NAMING LAW (2026-08-05): the FIRST segment is the sema family whose base
|
|
* signature the tuning modifies — never a component, never a UI category.
|
|
* A tuning is a delta on a family, so the family is the only axis that is
|
|
* both true and mechanically checkable (`SEMA_FAMILIES` is a closed set;
|
|
* a component name is not). The tail is free prose describing the resulting
|
|
* SIGNATURE, deliberately NOT the triggering event: `commit.silent` is
|
|
* applied to a slider's `commit-set` in `media-player.ts` as legitimately as
|
|
* to a switch's `commit-toggle`, because what it names is the silence, not
|
|
* the gesture.
|
|
*
|
|
* Enforced by `sounds-grammar.test.ts`. Born from a real drift: `form.*`
|
|
* entered on 2026-05-19 naming seven form controls truthfully, was extended
|
|
* to menus and trees on 2026-05-26 (`2d562f378`, the very commit that wrote
|
|
* D.5/D.6) and reached 50 packs — one of which was the Form. No guard could
|
|
* see it because none looked at these keys.
|
|
*/
|
|
export const SOUND_TUNINGS = {
|
|
'emerge.soft': { gain: 0.08 },
|
|
'emerge.medium': { gain: 0.1 },
|
|
'emerge.strong': { gain: 0.15 },
|
|
'emerge.exit': { contour: 'descending', pitch: { op: 'add', value: -120 } },
|
|
'emerge.exit.deep': { contour: 'descending', pitch: { op: 'add', value: -150 } },
|
|
'emerge.exit.soft': {
|
|
contour: 'descending',
|
|
pitch: { op: 'add', value: -120 },
|
|
gain: 0.05
|
|
},
|
|
'emerge.dismiss.passive': { gain: 0.06 },
|
|
'commit.soft': { gain: 0.05 },
|
|
'commit.subtle': { gain: 0.03 },
|
|
'commit.select.soft': { gain: 0.04 },
|
|
/**
|
|
* Restrained but plainly audible commit — the toggles' voice (switch,
|
|
* toggle, toggle-group; author directive 2026-08-05, superseding the
|
|
* silence D.5 gave them in May). Sits between `commit.soft` (0.05) and the
|
|
* family base (0.3), mirroring the `emerge.soft/medium/strong` ladder: a
|
|
* flip is heard without competing with a button press (contact base 0.25).
|
|
*/
|
|
'commit.medium': { gain: 0.1 },
|
|
/**
|
|
* Silent-by-default for high-frequency commits (switch, toggle,
|
|
* toggle-group; also the player's scrubber and volume `commit-set`).
|
|
* Subtracts the `family.commit.base.sound.gain` (0.3), so
|
|
* neutral/affirm/risk/loss land at gain 0 (silent). Threat (+0.1)
|
|
* and fulfill (+0.05) intent deltas still surface — destructive or
|
|
* culminating commits emit a perceptual cue while everyday on/off
|
|
* stays mute. Avoids fatigue without erasing evaluative loading.
|
|
*/
|
|
'commit.silent': { gain: { op: 'add', value: -0.3 } },
|
|
/**
|
|
* Silent-by-default emergence, the tooltip's canonical signature.
|
|
* Tooltips reveal on hover and would "puff" on every mouse cross if the
|
|
* family emerge gain (0.2) reached audible output. Subtracts the family
|
|
* base so neutral emergences land at gain 0. Intent threat (+0.1) and
|
|
* fulfill (+0.05) still surface — a tooltip used as an error / success
|
|
* hint can still be heard. Auxiliary presence is not signal.
|
|
*/
|
|
'emerge.silent': { gain: { op: 'add', value: -0.2 } },
|
|
/**
|
|
* Silent-by-default for contact inside sound-emitting components (the
|
|
* media player's transport buttons — signed 2026-07-31, D-AP2.7 v2): the
|
|
* component's own output IS audio, so a UI tick competes with the work by
|
|
* definition (D.7). Subtracts the contact family base (0.25) so the press
|
|
* lands at gain 0; intent deltas still surface, same shape as
|
|
* `commit.silent` / `emerge.silent`.
|
|
*/
|
|
'contact.silent': { gain: { op: 'add', value: -0.25 } }
|
|
} as const satisfies Record<string, SoundOverride>;
|
|
|
|
export type SoundName = keyof typeof SOUND_LIBRARY;
|
|
export type SoundTuningName = keyof typeof SOUND_TUNINGS;
|
|
|
|
export function sound(name: SoundName, overrides?: Partial<SoundSignature>): SoundSignature {
|
|
return resolveSoundDefinition(SOUND_LIBRARY[name], overrides);
|
|
}
|
|
|
|
export function resolveSoundDefinition(
|
|
definition: SoundDefinition,
|
|
overrides?: Partial<SoundSignature>
|
|
): SoundSignature {
|
|
const base =
|
|
definition.kind === 'sample'
|
|
? { ...definition.fallback, sampleUrl: definition.sampleUrl }
|
|
: definition.signature;
|
|
return mergeSound(base, overrides);
|
|
}
|
|
|
|
export function soundTuning(name: SoundTuningName, overrides?: SoundOverride): SoundOverride {
|
|
return mergeSoundOverride(SOUND_TUNINGS[name], overrides);
|
|
}
|
|
|
|
export function soundSampleUrls(names?: readonly SoundName[]): string[] {
|
|
const selected = names ?? (Object.keys(SOUND_LIBRARY) as SoundName[]);
|
|
const urls: string[] = [];
|
|
for (const name of selected) {
|
|
const definition = (SOUND_LIBRARY as Record<string, SoundDefinition>)[name];
|
|
if (definition.kind === 'sample' && definition.preload !== false) {
|
|
urls.push(definition.sampleUrl);
|
|
}
|
|
}
|
|
return urls;
|
|
}
|
|
|
|
export function resolveHandleDragSound(params: DragSoundParams): SoundSignature {
|
|
const value01 = clamp(params.value01, 0, 1);
|
|
const velocity01 = clamp(params.velocity01, 0, 1);
|
|
return {
|
|
pitch: lerp(960, 1500, value01),
|
|
gain: clamp(0.012 + velocity01 * 0.018, 0.012, 0.03),
|
|
centroid: lerp(5200, 8200, velocity01),
|
|
duration: 96,
|
|
contour: params.direction,
|
|
attack: 26,
|
|
decay: 90,
|
|
roughness: 0.002
|
|
};
|
|
}
|
|
|
|
export function resolveSliderDragSound(params: DragSoundParams): SoundSignature {
|
|
const value01 = clamp(params.value01, 0, 1);
|
|
const velocity01 = clamp(params.velocity01, 0, 1);
|
|
return {
|
|
pitch: Math.round(lerp(760, 1280, value01) + velocity01 * 120),
|
|
centroid: Math.round(lerp(3600, 6800, velocity01)),
|
|
roughness: 0.003,
|
|
attack: 8,
|
|
decay: 44,
|
|
duration: 46,
|
|
contour: params.direction,
|
|
gain: Number((0.04 + velocity01 * 0.04).toFixed(3))
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Splitter handle drag sound resolver.
|
|
*
|
|
* Splitters move whole panels, not a thumb on a track. The gesture has
|
|
* more body and lower kinetic register than a slider, so the curve is
|
|
* tuned to read as a heavier, thicker timbre:
|
|
*
|
|
* - Lower pitch range (480..820 Hz vs slider's 760..1280) — "thicker"
|
|
* - Lower centroid ceiling (2400..4800 vs 3600..6800) — less brilliant
|
|
* - Slightly more roughness (0.006) — woody texture, not metallic
|
|
* - Slightly longer envelope (attack 16, decay 72, duration 76) —
|
|
* the surface being moved has perceptual mass
|
|
* - Modest gain floor (0.025) climbing with velocity — splitters can
|
|
* drag for long swipes, gain ceiling stays restrained
|
|
*
|
|
* `value01` = current handle position in `[0, 1]` (size of the panel
|
|
* before the handle as a fraction of total container size). `velocity01`
|
|
* = normalized motion speed computed by soma against frame deltas.
|
|
*/
|
|
export function resolveSplitterDragSound(params: DragSoundParams): SoundSignature {
|
|
const value01 = clamp(params.value01, 0, 1);
|
|
const velocity01 = clamp(params.velocity01, 0, 1);
|
|
return {
|
|
pitch: Math.round(lerp(480, 820, value01) + velocity01 * 90),
|
|
centroid: Math.round(lerp(2400, 4800, velocity01)),
|
|
roughness: 0.006,
|
|
attack: 16,
|
|
decay: 72,
|
|
duration: 76,
|
|
contour: params.direction,
|
|
gain: Number((0.025 + velocity01 * 0.03).toFixed(3))
|
|
};
|
|
}
|
|
|
|
function mergeSound(base: SoundSignature, overrides?: Partial<SoundSignature>): SoundSignature {
|
|
const next = structuredClone(base);
|
|
return overrides ? { ...next, ...overrides } : next;
|
|
}
|
|
|
|
function mergeSoundOverride(base: SoundOverride, overrides?: SoundOverride): SoundOverride {
|
|
const next = structuredClone(base);
|
|
return overrides ? { ...next, ...structuredClone(overrides) } : next;
|
|
}
|
|
|
|
function clamp(value: number, min: number, max: number): number {
|
|
return Math.min(Math.max(value, min), max);
|
|
}
|
|
|
|
function lerp(a: number, b: number, t: number): number {
|
|
return a + (b - a) * t;
|
|
}
|