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.
161 lines
5.2 KiB
161 lines
5.2 KiB
import type { SemaSignatureOverride, Silent, SoundSignature } from './channels';
|
|
import type { SoundVoice } from '$sound';
|
|
|
|
/**
|
|
* A tuning is a DELTA over a family's base signature — never an absence.
|
|
* `Silent` is excluded on purpose: silence is `SILENT`, a value the resolver
|
|
* honours by dropping the channel, and a catalogue entry that resolved to
|
|
* silence would be the arithmetic this design replaced.
|
|
*/
|
|
export type SoundOverride = Exclude<NonNullable<SemaSignatureOverride['sound']>, Silent>;
|
|
|
|
/** 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 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 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 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;
|
|
}
|