import { ooAudioBus } from "./oo-audio-bus"; import { readOoSoundEnabled, writeOoSoundEnabled } from "./oo-sound-pref"; export type OoSoundCue = | "app-start" | "interact" | "success-resolve" | "need-you" | "work-complete" | "listening-start" | "listening-stop" | "speaking-start" | "speaking-done" | "attention" | "message-sent" | "handoff" | "group-spark"; function prefersReducedMotion(): boolean { if (typeof window === "undefined" || !window.matchMedia) return false; return window.matchMedia("(prefers-reduced-motion: reduce)").matches; } function tone( ctx: AudioContext, destination: AudioNode, frequency: number, startAt: number, duration: number, options: { type?: OscillatorType; gain?: number; attack?: number; release?: number; } = {}, ) { const osc = ctx.createOscillator(); const gain = ctx.createGain(); const peak = options.gain ?? 0.08; const attack = options.attack ?? 0.012; const release = options.release ?? Math.min(duration * 0.55, 0.18); osc.type = options.type ?? "sine"; osc.frequency.setValueAtTime(frequency, startAt); gain.gain.setValueAtTime(0.0001, startAt); gain.gain.exponentialRampToValueAtTime(peak, startAt + attack); gain.gain.exponentialRampToValueAtTime(0.0001, startAt + duration - release); osc.connect(gain); gain.connect(destination); osc.start(startAt); osc.stop(startAt + duration); } function sweep( ctx: AudioContext, destination: AudioNode, fromHz: number, toHz: number, startAt: number, duration: number, options: { gain?: number; type?: OscillatorType } = {}, ) { const osc = ctx.createOscillator(); const gain = ctx.createGain(); const peak = options.gain ?? 0.05; osc.type = options.type ?? "sine"; osc.frequency.setValueAtTime(fromHz, startAt); osc.frequency.exponentialRampToValueAtTime(Math.max(toHz, 1), startAt + duration * 0.85); gain.gain.setValueAtTime(0.0001, startAt); gain.gain.exponentialRampToValueAtTime(peak, startAt + 0.02); gain.gain.exponentialRampToValueAtTime(0.0001, startAt + duration); osc.connect(gain); gain.connect(destination); osc.start(startAt); osc.stop(startAt + duration); } function playCueOnContext(ctx: AudioContext, cue: OoSoundCue) { const master = ooAudioBus.sfxDestination(ctx); const t0 = ctx.currentTime + 0.01; switch (cue) { case "app-start": sweep(ctx, master, 523.25, 659.25, t0, 0.14, { gain: 0.038, type: "sine" }); sweep(ctx, master, 659.25, 493.88, t0 + 0.1, 0.18, { gain: 0.032, type: "triangle" }); break; case "interact": tone(ctx, master, 1046.5, t0, 0.06, { gain: 0.018, attack: 0.003, release: 0.04, type: "triangle", }); break; case "success-resolve": case "work-complete": // mint → gold resolve (~270 ms) sweep(ctx, master, 523.25, 659.25, t0, 0.12, { gain: 0.042, type: "sine" }); sweep(ctx, master, 659.25, 783.99, t0 + 0.1, 0.22, { gain: 0.036, type: "triangle" }); tone(ctx, master, 880, t0 + 0.2, 0.28, { gain: 0.03, type: "sine" }); break; case "need-you": sweep(ctx, master, 220, 330, t0, 0.1, { gain: 0.042, type: "triangle" }); tone(ctx, master, 277.18, t0 + 0.09, 0.12, { gain: 0.035, release: 0.08 }); break; case "listening-start": tone(ctx, master, 220, t0, 0.22, { gain: 0.045, type: "triangle" }); tone(ctx, master, 330, t0 + 0.04, 0.28, { gain: 0.035 }); break; case "listening-stop": tone(ctx, master, 330, t0, 0.18, { gain: 0.03, release: 0.12 }); break; case "speaking-start": tone(ctx, master, 440, t0, 0.2, { gain: 0.04, type: "sine" }); break; case "speaking-done": tone(ctx, master, 523.25, t0, 0.24, { gain: 0.035 }); tone(ctx, master, 392, t0 + 0.08, 0.3, { gain: 0.028, release: 0.14 }); break; case "attention": tone(ctx, master, 277.18, t0, 0.16, { gain: 0.05, type: "triangle" }); tone(ctx, master, 233.08, t0 + 0.12, 0.22, { gain: 0.045 }); break; case "message-sent": tone(ctx, master, 880, t0, 0.1, { gain: 0.022, attack: 0.004, release: 0.06 }); break; case "handoff": tone(ctx, master, 349.23, t0, 0.2, { gain: 0.04 }); tone(ctx, master, 466.16, t0 + 0.1, 0.24, { gain: 0.035, type: "triangle" }); break; case "group-spark": tone(ctx, master, 587.33, t0, 0.11, { gain: 0.032, attack: 0.004, type: "triangle" }); tone(ctx, master, 739.99, t0 + 0.06, 0.14, { gain: 0.028, type: "sine" }); tone(ctx, master, 880, t0 + 0.12, 0.1, { gain: 0.022, release: 0.05 }); break; default: break; } } class OoSoundEngine { private enabled = readOoSoundEnabled(); isEnabled(): boolean { return this.enabled; } setEnabled(next: boolean) { this.enabled = next; writeOoSoundEnabled(next); } play(cue: OoSoundCue) { if (!this.enabled) return; if (prefersReducedMotion()) return; if (typeof document !== "undefined" && document.visibilityState === "hidden") return; const ctx = ooAudioBus.ensureContext(); if (!ctx) return; const run = () => { try { playCueOnContext(ctx, cue); } catch { // Ignore playback failures (autoplay policy, suspended context). } }; if (ctx.state === "suspended") { void ctx .resume() .then(run) .catch(() => undefined); return; } run(); } } export const ooSound = new OoSoundEngine();