| 1 | /** |
| 2 | * The sound layer: named cues (`message`, `direct`, `mention`, |
| 3 | * `agent_done`, `sent`, and `call`, reserved), the person's sound settings |
| 4 | * as the page knows them, and a `SoundPort` that plays a cue. |
| 5 | * |
| 6 | * The default port is Web Audio: each cue of a set is rendered once from |
| 7 | * lib/sound-synth.ts into an AudioBuffer and reused, nothing is downloaded, |
| 8 | * and the context is made and resumed on the first click or key (browsers |
| 9 | * allow sound only after a gesture), so a cue that arrives before anyone |
| 10 | * has touched the page is kept as a note in the log, not an error. Nothing |
| 11 | * here throws: a browser with no audio plays nothing. |
| 12 | * |
| 13 | * The port is one interface on purpose: the desktop app (Tauri, loading |
| 14 | * this same web code) swaps in a native implementation with |
| 15 | * `setSoundPort`, so cues play through the system mixer and respect the |
| 16 | * system's own do-not-disturb, and nothing else in the app changes. Which |
| 17 | * cue plays when is lib/chat-sounds.ts; lib/sound-events.ts runs it. |
| 18 | */ |
| 19 | import { useSyncExternalStore } from "react"; |
| 20 | |
| 21 | import { DEFAULT_SOUND_SETTINGS, type SoundCue, type SoundSet, type SoundSettings, type SoundSettingsChange } from "@g1t/contracts/sounds"; |
| 22 | |
| 23 | import { cueSpec, renderCue, volumeGain } from "./sound-synth"; |
| 24 | |
| 25 | // ── The port ───────────────────────────────────────────────────────────── |
| 26 | |
| 27 | /** Where a cue goes to be heard. One implementation at a time (`setSoundPort`). */ |
| 28 | export interface SoundPort { |
| 29 | /** Whether a sound could be heard now: audio is there and has been allowed. */ |
| 30 | canPlay(): boolean; |
| 31 | /** Plays a cue from a set at a volume (0–100). Never throws. */ |
| 32 | play(cue: SoundCue, set: SoundSet, volume: number): void; |
| 33 | /** The same, from the settings page: a set the person is choosing, now, whatever is on screen. */ |
| 34 | preview(cue: SoundCue, set: SoundSet, volume: number): void; |
| 35 | } |
| 36 | |
| 37 | /** Web Audio: the browser's own, used unless the desktop app installs its port. */ |
| 38 | export class WebAudioPort implements SoundPort { |
| 39 | private context: AudioContext | null = null; |
| 40 | private readonly buffers = new Map<string, AudioBuffer>(); |
| 41 | |
| 42 | /** The context, made on first use; null where the browser has none. */ |
| 43 | private audio(): AudioContext | null { |
| 44 | if (this.context) return this.context; |
| 45 | try { |
| 46 | if (typeof window === "undefined" || typeof AudioContext === "undefined") return null; |
| 47 | this.context = new AudioContext(); |
| 48 | } catch { |
| 49 | return null; |
| 50 | } |
| 51 | return this.context; |
| 52 | } |
| 53 | |
| 54 | /** Makes the context and resumes it: called from a user gesture, so the browser allows it. */ |
| 55 | unlock(): void { |
| 56 | const context = this.audio(); |
| 57 | if (context && context.state === "suspended") void context.resume().catch(() => {}); |
| 58 | } |
| 59 | |
| 60 | canPlay(): boolean { |
| 61 | const context = this.audio(); |
| 62 | return !!context && context.state === "running"; |
| 63 | } |
| 64 | |
| 65 | private buffer(context: AudioContext, cue: SoundCue, set: SoundSet): AudioBuffer { |
| 66 | const key = `${set}:${cue}:${context.sampleRate}`; |
| 67 | let buffer = this.buffers.get(key); |
| 68 | if (!buffer) { |
| 69 | const samples = renderCue(cueSpec(set, cue), context.sampleRate); |
| 70 | buffer = context.createBuffer(1, Math.max(1, samples.length), context.sampleRate); |
| 71 | buffer.copyToChannel(samples, 0); |
| 72 | this.buffers.set(key, buffer); |
| 73 | } |
| 74 | return buffer; |
| 75 | } |
| 76 | |
| 77 | play(cue: SoundCue, set: SoundSet, volume: number): void { |
| 78 | try { |
| 79 | const context = this.audio(); |
| 80 | if (!context) return; |
| 81 | if (context.state === "suspended") void context.resume().catch(() => {}); |
| 82 | const source = context.createBufferSource(); |
| 83 | source.buffer = this.buffer(context, cue, set); |
| 84 | const gain = context.createGain(); |
| 85 | gain.gain.value = volumeGain(volume); |
| 86 | source.connect(gain).connect(context.destination); |
| 87 | source.start(); |
| 88 | } catch { |
| 89 | // No audio here: nothing lost. |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | preview(cue: SoundCue, set: SoundSet, volume: number): void { |
| 94 | this.unlock(); |
| 95 | this.play(cue, set, volume); |
| 96 | } |
| 97 | } |
| 98 | |
| 99 | let port: SoundPort = new WebAudioPort(); |
| 100 | |
| 101 | /** Installs another way to play cues (the desktop app's native one). Returns the one it replaced. */ |
| 102 | export function setSoundPort(next: SoundPort): SoundPort { |
| 103 | const before = port; |
| 104 | port = next; |
| 105 | return before; |
| 106 | } |
| 107 | |
| 108 | export function soundPort(): SoundPort { |
| 109 | return port; |
| 110 | } |
| 111 | |
| 112 | // ── Settings ───────────────────────────────────────────────────────────── |
| 113 | |
| 114 | let settings: SoundSettings = DEFAULT_SOUND_SETTINGS; |
| 115 | const listeners = new Set<() => void>(); |
| 116 | |
| 117 | function notify(): void { |
| 118 | for (const listener of listeners) listener(); |
| 119 | } |
| 120 | |
| 121 | /** The settings as the page has them: the root loader's with the page, then as saved here. */ |
| 122 | export function soundSettings(): SoundSettings { |
| 123 | return settings; |
| 124 | } |
| 125 | |
| 126 | /** The root hands the person's settings over with the page, so the first event already respects them. */ |
| 127 | export function applySoundSettings(next: SoundSettings | null | undefined): void { |
| 128 | if (!next) return; |
| 129 | settings = { ...DEFAULT_SOUND_SETTINGS, ...next, sound_cues: { ...DEFAULT_SOUND_SETTINGS.sound_cues, ...next.sound_cues } }; |
| 130 | notify(); |
| 131 | } |
| 132 | |
| 133 | function subscribe(listener: () => void): () => void { |
| 134 | listeners.add(listener); |
| 135 | return () => listeners.delete(listener); |
| 136 | } |
| 137 | |
| 138 | export function useSoundSettings(): SoundSettings { |
| 139 | return useSyncExternalStore(subscribe, () => settings, () => DEFAULT_SOUND_SETTINGS); |
| 140 | } |
| 141 | |
| 142 | /** |
| 143 | * Saves a change with the account (`/-/notify`, intent `sounds`): shown at |
| 144 | * once, put back if it does not save. Returns the settings as kept, or null. |
| 145 | */ |
| 146 | export async function saveSoundSettings(change: SoundSettingsChange): Promise<SoundSettings | null> { |
| 147 | const before = settings; |
| 148 | applySoundSettings({ ...settings, ...change, sound_cues: { ...settings.sound_cues, ...change.sound_cues } }); |
| 149 | try { |
| 150 | const response = await fetch("/-/notify", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ intent: "sounds", change }) }); |
| 151 | if (!response.ok) throw new Error(`status ${response.status}`); |
| 152 | const saved = (await response.json()) as SoundSettings; |
| 153 | applySoundSettings(saved); |
| 154 | return saved; |
| 155 | } catch (error) { |
| 156 | console.error("sounds: the settings did not save", error); |
| 157 | applySoundSettings(before); |
| 158 | return null; |
| 159 | } |
| 160 | } |
| 161 | |
| 162 | // ── Playing ────────────────────────────────────────────────────────────── |
| 163 | |
| 164 | /** What happened, for the harness and for anyone debugging: the last few cues. */ |
| 165 | export type SoundEvent = { cue: SoundCue; set: SoundSet; volume: number; at: number; how: "play" | "preview" | "silent" }; |
| 166 | |
| 167 | const LOG_KEPT = 20; |
| 168 | const log: SoundEvent[] = []; |
| 169 | |
| 170 | function record(event: SoundEvent): void { |
| 171 | log.push(event); |
| 172 | if (log.length > LOG_KEPT) log.shift(); |
| 173 | if (typeof window !== "undefined") { |
| 174 | try { |
| 175 | window.dispatchEvent(new CustomEvent("g1t:sound", { detail: event })); |
| 176 | } catch { |
| 177 | // Nothing listening. |
| 178 | } |
| 179 | } |
| 180 | } |
| 181 | |
| 182 | /** The last cues played (or not, `silent`, when audio was not allowed yet), oldest first. */ |
| 183 | export function recentSounds(): readonly SoundEvent[] { |
| 184 | return log; |
| 185 | } |
| 186 | |
| 187 | /** Plays a cue from the person's set at their volume. The caller has already decided it should sound. */ |
| 188 | export function play(cue: SoundCue): void { |
| 189 | const { sound_set: set, sound_volume: volume } = settings; |
| 190 | if (volume <= 0) { |
| 191 | record({ cue, set, volume, at: Date.now(), how: "silent" }); |
| 192 | return; |
| 193 | } |
| 194 | const how = port.canPlay() ? "play" : "silent"; |
| 195 | record({ cue, set, volume, at: Date.now(), how }); |
| 196 | port.play(cue, set, volume); |
| 197 | } |
| 198 | |
| 199 | /** From the settings page: a cue from any set, at the volume being chosen. */ |
| 200 | export function preview(cue: SoundCue, set: SoundSet = settings.sound_set, volume: number = settings.sound_volume): void { |
| 201 | record({ cue, set, volume, at: Date.now(), how: "preview" }); |
| 202 | port.preview(cue, set, volume); |
| 203 | } |
| 204 | |
| 205 | export function canPlay(): boolean { |
| 206 | return port.canPlay(); |
| 207 | } |
| 208 | |
| 209 | // ── Allowing sound ─────────────────────────────────────────────────────── |
| 210 | |
| 211 | const GESTURES = ["pointerdown", "keydown", "touchstart"] as const; |
| 212 | let unlocking = false; |
| 213 | |
| 214 | /** |
| 215 | * From the first click or key on the page, audio is allowed: the context is |
| 216 | * made then, so a cue a second later plays. Mounted once by the root |
| 217 | * (components/notifications/live-notifications.tsx). Returns how to stop. |
| 218 | */ |
| 219 | export function startSounds(): () => void { |
| 220 | if (typeof window === "undefined" || unlocking) return () => {}; |
| 221 | unlocking = true; |
| 222 | const unlock = () => { |
| 223 | const current = port; |
| 224 | if (current instanceof WebAudioPort) current.unlock(); |
| 225 | if (current.canPlay()) stop(); |
| 226 | }; |
| 227 | const stop = () => { |
| 228 | for (const name of GESTURES) window.removeEventListener(name, unlock, { capture: true }); |
| 229 | unlocking = false; |
| 230 | }; |
| 231 | for (const name of GESTURES) window.addEventListener(name, unlock, { capture: true, passive: true }); |
| 232 | return stop; |
| 233 | } |