/// Sound-cue synthesis for recording state transitions (brief item B.1 /// #20). Uses the Web Audio API directly rather than shipping WAV /// assets — keeps the Tauri binary the same size, sidesteps packaging /// concerns, and gives the three cues a coherent sound signature we /// can tweak without touching binary files. /// /// The three cues are pitched as an ascending / descending pattern /// that maps the mental model: start = go (rising C), stop = pause /// (falling), complete = resolve (major third chord). const START_FREQS = [523.25]; // C5 const STOP_FREQS = [392.0]; // G4 const COMPLETE_FREQS = [523.25, 659.25, 783.99]; // C5 E5 G5 major chord const DEFAULT_VOLUME = 0.15; // 15% — well below system notification chirp volume const TONE_DURATION_S = 0.14; const ATTACK_S = 0.01; const RELEASE_S = 0.06; let cachedCtx: AudioContext | null = null; function getAudioContext(): AudioContext | null { if (typeof window === "undefined") return null; if (cachedCtx && cachedCtx.state !== "closed") { return cachedCtx; } const Ctor = (window as typeof window & { webkitAudioContext?: typeof AudioContext }) .AudioContext ?? (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; if (!Ctor) return null; try { cachedCtx = new Ctor(); return cachedCtx; } catch { return null; } } function playTone(frequency: number, startOffsetS: number, volume: number): void { const ctx = getAudioContext(); if (!ctx) return; // Browsers suspend AudioContext until a user gesture; Tauri's webview // inherits the same behaviour. Resume best-effort; if it fails silently // we'd rather lose a cue than throw. if (ctx.state === "suspended") ctx.resume().catch(() => {}); const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.type = "sine"; osc.frequency.setValueAtTime(frequency, ctx.currentTime + startOffsetS); const t0 = ctx.currentTime + startOffsetS; gain.gain.setValueAtTime(0, t0); gain.gain.linearRampToValueAtTime(volume, t0 + ATTACK_S); gain.gain.setValueAtTime(volume, t0 + TONE_DURATION_S - RELEASE_S); gain.gain.linearRampToValueAtTime(0, t0 + TONE_DURATION_S); osc.connect(gain).connect(ctx.destination); osc.start(t0); osc.stop(t0 + TONE_DURATION_S + 0.02); } function playChord(frequencies: number[], volume: number, stagger = 0): void { for (let i = 0; i < frequencies.length; i += 1) { playTone(frequencies[i], i * stagger, volume); } } function resolveVolume(userVolume: number | undefined, enabled: boolean): number { if (!enabled) return 0; const v = typeof userVolume === "number" ? userVolume : DEFAULT_VOLUME; if (!Number.isFinite(v) || v <= 0) return 0; // Any value >1 means a scale drift somewhere upstream (e.g. a 0–100 // slider leaking through unscaled). Clamping to 1 would blast the // user at full volume; fall back to DEFAULT_VOLUME instead. if (v > 1) return DEFAULT_VOLUME; return v; } export function playStartCue(volume?: number, enabled = true): void { const v = resolveVolume(volume, enabled); if (v === 0) return; playChord(START_FREQS, v); } export function playStopCue(volume?: number, enabled = true): void { const v = resolveVolume(volume, enabled); if (v === 0) return; playChord(STOP_FREQS, v); } export function playCompleteCue(volume?: number, enabled = true): void { const v = resolveVolume(volume, enabled); if (v === 0) return; // Slightly staggered arpeggio for the "done" cue — feels resolved // rather than alarm-like. playChord(COMPLETE_FREQS, v, 0.05); } export const DEFAULT_SOUND_VOLUME = DEFAULT_VOLUME;