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.
svelte-kit-vice/src/uix/sema/sounds.ts

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;
}

Powered by TurnKey Linux.