import type { MinuteRange } from "./quiet-hours.js"; import { isQuietTime } from "./quiet-hours.js"; 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" | "routine_start" | "routine_completed_quiet" | "routine_changed" | "routine_failure" | "voice_listen" | "voice_bargein"; export interface ToneStep { kind: "tone"; frequency: number; delayMs: number; durationMs: number; gain: number; wave: "sine" | "triangle"; attackMs?: number; releaseMs?: number; } export interface SweepStep { kind: "sweep"; fromHz: number; toHz: number; delayMs: number; durationMs: number; gain: number; wave: "sine" | "triangle"; } export type SoundStep = ToneStep | SweepStep; export interface SoundCueRecipe { steps: SoundStep[]; totalDurationMs: number; } /** Normalized recipe registry for all cross-platform earcons */ export const CUE_RECIPES: Record = { "app-start": { steps: [ { kind: "sweep", fromHz: 523.25, toHz: 659.25, delayMs: 0, durationMs: 140, gain: 0.038, wave: "sine", }, { kind: "sweep", fromHz: 659.25, toHz: 493.88, delayMs: 100, durationMs: 180, gain: 0.032, wave: "triangle", }, ], totalDurationMs: 280, }, interact: { steps: [ { kind: "tone", frequency: 1046.5, delayMs: 0, durationMs: 60, gain: 0.018, wave: "triangle", attackMs: 3, releaseMs: 40, }, ], totalDurationMs: 60, }, "success-resolve": { steps: [ { kind: "sweep", fromHz: 523.25, toHz: 659.25, delayMs: 0, durationMs: 120, gain: 0.042, wave: "sine", }, { kind: "sweep", fromHz: 659.25, toHz: 783.99, delayMs: 100, durationMs: 220, gain: 0.036, wave: "triangle", }, { kind: "tone", frequency: 880, delayMs: 200, durationMs: 280, gain: 0.03, wave: "sine" }, ], totalDurationMs: 480, }, "work-complete": { steps: [ { kind: "sweep", fromHz: 523.25, toHz: 659.25, delayMs: 0, durationMs: 120, gain: 0.042, wave: "sine", }, { kind: "sweep", fromHz: 659.25, toHz: 783.99, delayMs: 100, durationMs: 220, gain: 0.036, wave: "triangle", }, { kind: "tone", frequency: 880, delayMs: 200, durationMs: 280, gain: 0.03, wave: "sine" }, ], totalDurationMs: 480, }, "need-you": { steps: [ { kind: "sweep", fromHz: 220, toHz: 330, delayMs: 0, durationMs: 100, gain: 0.042, wave: "triangle", }, { kind: "tone", frequency: 277.18, delayMs: 90, durationMs: 120, gain: 0.035, wave: "sine", releaseMs: 80, }, ], totalDurationMs: 210, }, "listening-start": { steps: [ { kind: "tone", frequency: 220, delayMs: 0, durationMs: 220, gain: 0.045, wave: "triangle" }, { kind: "tone", frequency: 330, delayMs: 40, durationMs: 280, gain: 0.035, wave: "sine" }, ], totalDurationMs: 320, }, voice_listen: { steps: [ { kind: "tone", frequency: 220, delayMs: 0, durationMs: 220, gain: 0.045, wave: "triangle" }, { kind: "tone", frequency: 330, delayMs: 40, durationMs: 280, gain: 0.035, wave: "sine" }, ], totalDurationMs: 320, }, "listening-stop": { steps: [ { kind: "tone", frequency: 330, delayMs: 0, durationMs: 180, gain: 0.03, wave: "sine", releaseMs: 120, }, ], totalDurationMs: 180, }, "speaking-start": { steps: [ { kind: "tone", frequency: 440, delayMs: 0, durationMs: 200, gain: 0.04, wave: "sine" }, ], totalDurationMs: 200, }, "speaking-done": { steps: [ { kind: "tone", frequency: 523.25, delayMs: 0, durationMs: 240, gain: 0.035, wave: "sine" }, { kind: "tone", frequency: 392, delayMs: 80, durationMs: 300, gain: 0.028, wave: "sine", releaseMs: 140, }, ], totalDurationMs: 380, }, voice_bargein: { steps: [ { kind: "sweep", fromHz: 523.25, toHz: 261.63, delayMs: 0, durationMs: 50, gain: 0.032, wave: "sine", }, ], totalDurationMs: 50, }, attention: { steps: [ { kind: "tone", frequency: 277.18, delayMs: 0, durationMs: 160, gain: 0.05, wave: "triangle", }, { kind: "tone", frequency: 233.08, delayMs: 120, durationMs: 220, gain: 0.045, wave: "sine" }, ], totalDurationMs: 340, }, "message-sent": { steps: [ { kind: "tone", frequency: 880, delayMs: 0, durationMs: 100, gain: 0.022, wave: "sine", attackMs: 4, releaseMs: 60, }, ], totalDurationMs: 100, }, handoff: { steps: [ { kind: "tone", frequency: 349.23, delayMs: 0, durationMs: 200, gain: 0.04, wave: "sine" }, { kind: "tone", frequency: 466.16, delayMs: 100, durationMs: 240, gain: 0.035, wave: "triangle", }, ], totalDurationMs: 340, }, "group-spark": { steps: [ { kind: "tone", frequency: 587.33, delayMs: 0, durationMs: 110, gain: 0.032, wave: "triangle", attackMs: 4, }, { kind: "tone", frequency: 739.99, delayMs: 60, durationMs: 140, gain: 0.028, wave: "sine" }, { kind: "tone", frequency: 880, delayMs: 120, durationMs: 100, gain: 0.022, wave: "sine", releaseMs: 50, }, ], totalDurationMs: 220, }, routine_start: { steps: [ { kind: "tone", frequency: 392, delayMs: 0, durationMs: 80, gain: 0.025, wave: "sine", attackMs: 5, releaseMs: 30, }, { kind: "tone", frequency: 523.25, delayMs: 60, durationMs: 100, gain: 0.022, wave: "sine", attackMs: 5, releaseMs: 40, }, ], totalDurationMs: 160, }, routine_completed_quiet: { steps: [ { kind: "tone", frequency: 659.25, delayMs: 0, durationMs: 100, gain: 0.018, wave: "sine", attackMs: 6, releaseMs: 50, }, { kind: "tone", frequency: 783.99, delayMs: 50, durationMs: 120, gain: 0.015, wave: "sine", attackMs: 6, releaseMs: 60, }, ], totalDurationMs: 170, }, routine_changed: { steps: [ { kind: "tone", frequency: 440, delayMs: 0, durationMs: 90, gain: 0.035, wave: "triangle", attackMs: 4, releaseMs: 40, }, { kind: "tone", frequency: 554.37, delayMs: 70, durationMs: 120, gain: 0.03, wave: "sine", attackMs: 4, releaseMs: 50, }, ], totalDurationMs: 190, }, routine_failure: { steps: [ { kind: "tone", frequency: 329.63, delayMs: 0, durationMs: 120, gain: 0.038, wave: "triangle", attackMs: 5, releaseMs: 50, }, { kind: "tone", frequency: 261.63, delayMs: 90, durationMs: 160, gain: 0.032, wave: "triangle", attackMs: 5, releaseMs: 70, }, ], totalDurationMs: 250, }, }; export interface SoundDriver { play(cue: OoSoundCue): void | Promise; stopAll?(): void; isSupported(): boolean; } export class CoreSoundEngine { private enabled = true; private driver: SoundDriver | null = null; private quietRange: MinuteRange | null = null; private timeZone: string | null = null; constructor(driver?: SoundDriver) { if (driver) this.driver = driver; } setDriver(driver: SoundDriver): void { this.driver = driver; } isEnabled(): boolean { return this.enabled; } setEnabled(next: boolean): void { this.enabled = next; } setQuietHours(range: MinuteRange | null, timeZone?: string | null): void { this.quietRange = range; this.timeZone = timeZone ?? null; } isQuietNow(): boolean { return isQuietTime(this.quietRange, this.timeZone, new Date()); } play(cue: OoSoundCue): void { if (!this.enabled) return; if (!CUE_RECIPES[cue]) return; // Routine sounds respect quiet hours strictly if ( (cue === "routine_start" || cue === "routine_completed_quiet" || cue === "routine_changed" || cue === "routine_failure") && this.isQuietNow() ) { return; } try { void this.driver?.play(cue); } catch { // Best-effort playback } } }