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.
278 lines
7.9 KiB
278 lines
7.9 KiB
import type { SemaSignatureOverride, SoundSignature } from './channels';
|
|
|
|
export type SoundOverride = NonNullable<SemaSignatureOverride['sound']>;
|
|
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>;
|
|
|
|
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 },
|
|
'form.commit.soft': { gain: 0.05 },
|
|
'form.commit.subtle': { gain: 0.03 },
|
|
/**
|
|
* Silent-by-default for high-frequency toggles (switch, toggle,
|
|
* toggle-group). 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 toggles emit a perceptual cue while everyday on/off
|
|
* stays mute. Avoids fatigue without erasing evaluative loading.
|
|
*/
|
|
'form.toggle.silent': { gain: { op: 'add', value: -0.3 } },
|
|
/**
|
|
* Silent-by-default for tooltips. 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 tooltips 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.
|
|
* Tooltip is auxiliary presence, not signal; the silence is its
|
|
* canonical signature.
|
|
*/
|
|
'tooltip.silent': { gain: { op: 'add', value: -0.2 } },
|
|
'tabs.select.soft': { gain: 0.04 }
|
|
} 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;
|
|
}
|