import { ooAudioBus } from "./oo-audio-bus"; import { readOoSoundEnabled } from "./oo-sound-pref"; export type OoVocalReaction = "ouh" | "mhm" | "aaah" | "mm" | "nod-down" | "nod-up" | "hm"; const COOLDOWN_MIN_MS = 8_000; const COOLDOWN_MAX_MS = 12_000; let lastPlayedAt = 0; let nextCooldownMs = COOLDOWN_MIN_MS; function prefersReducedMotion(): boolean { if (typeof window === "undefined" || !window.matchMedia) return false; return window.matchMedia("(prefers-reduced-motion: reduce)").matches; } function randomCooldown(): number { return COOLDOWN_MIN_MS + Math.random() * (COOLDOWN_MAX_MS - COOLDOWN_MIN_MS); } function vocalFormant( ctx: AudioContext, destination: AudioNode, baseHz: number, startAt: number, duration: number, gainPeak: number, ) { const osc = ctx.createOscillator(); const gain = ctx.createGain(); const filter = ctx.createBiquadFilter(); filter.type = "bandpass"; filter.frequency.value = baseHz * 2.4; filter.Q.value = 4.5; osc.type = "triangle"; osc.frequency.setValueAtTime(baseHz, startAt); gain.gain.setValueAtTime(0.0001, startAt); gain.gain.exponentialRampToValueAtTime(gainPeak, startAt + 0.025); gain.gain.exponentialRampToValueAtTime(0.0001, startAt + duration); osc.connect(filter); filter.connect(gain); gain.connect(destination); osc.start(startAt); osc.stop(startAt + duration + 0.02); } function playReactionOnContext(ctx: AudioContext, reaction: OoVocalReaction) { const master = ooAudioBus.sfxDestination(ctx); const t0 = ctx.currentTime + 0.01; switch (reaction) { case "ouh": vocalFormant(ctx, master, 280, t0, 0.14, 0.055); vocalFormant(ctx, master, 360, t0 + 0.06, 0.1, 0.04); break; case "mhm": vocalFormant(ctx, master, 180, t0, 0.12, 0.042); vocalFormant(ctx, master, 200, t0 + 0.1, 0.08, 0.032); break; case "aaah": vocalFormant(ctx, master, 320, t0, 0.18, 0.048); vocalFormant(ctx, master, 400, t0 + 0.08, 0.14, 0.038); break; case "mm": vocalFormant(ctx, master, 160, t0, 0.16, 0.04); break; case "hm": vocalFormant(ctx, master, 175, t0, 0.09, 0.038); break; case "nod-down": vocalFormant(ctx, master, 165, t0, 0.07, 0.036); break; case "nod-up": vocalFormant(ctx, master, 205, t0, 0.1, 0.038); vocalFormant(ctx, master, 245, t0 + 0.08, 0.12, 0.032); break; default: break; } } class OoVocalsEngine { /** State-change vocal — respects cooldown and reduced-motion. No loops. */ react(reaction: OoVocalReaction) { if (!readOoSoundEnabled()) return; if (prefersReducedMotion()) return; if (typeof document !== "undefined" && document.visibilityState === "hidden") return; const now = Date.now(); if (now - lastPlayedAt < nextCooldownMs) return; const ctx = ooAudioBus.ensureContext(); if (!ctx) return; const run = () => { try { playReactionOnContext(ctx, reaction); lastPlayedAt = Date.now(); nextCooldownMs = randomCooldown(); } catch { // Ignore playback failures. } }; if (ctx.state === "suspended") { void ctx .resume() .then(run) .catch(() => undefined); return; } run(); } /** Nod: mh on down, Mhh on up — single cooldown window. */ reactNod() { if (!readOoSoundEnabled()) return; if (prefersReducedMotion()) return; if (typeof document !== "undefined" && document.visibilityState === "hidden") return; const now = Date.now(); if (now - lastPlayedAt < nextCooldownMs) return; const ctx = ooAudioBus.ensureContext(); if (!ctx) return; const run = () => { try { const t0 = ctx.currentTime + 0.01; const master = ooAudioBus.sfxDestination(ctx); vocalFormant(ctx, master, 165, t0, 0.07, 0.036); vocalFormant(ctx, master, 205, t0 + 0.09, 0.1, 0.038); vocalFormant(ctx, master, 245, t0 + 0.17, 0.12, 0.032); lastPlayedAt = Date.now(); nextCooldownMs = randomCooldown(); } catch { // Ignore playback failures. } }; if (ctx.state === "suspended") { void ctx .resume() .then(run) .catch(() => undefined); return; } run(); } reactForEvent(kind: "need-you" | "success" | "nod" | "curious") { switch (kind) { case "need-you": this.react("ouh"); break; case "success": this.react("hm"); break; case "nod": this.reactNod(); break; case "curious": this.react("mm"); break; default: break; } } } export const ooVocals = new OoVocalsEngine(); export function resetOoVocalsCooldownForTests(): void { lastPlayedAt = 0; nextCooldownMs = COOLDOWN_MIN_MS; }