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

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

Powered by TurnKey Linux.