Skip to content
233 linesCodeBlameRaw
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 */
19import { useSyncExternalStore } from "react";
20
21import { DEFAULT_SOUND_SETTINGS, type SoundCue, type SoundSet, type SoundSettings, type SoundSettingsChange } from "@g1t/contracts/sounds";
22
23import { 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`). */
28export 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. */
38export 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
99let port: SoundPort = new WebAudioPort();
100
101/** Installs another way to play cues (the desktop app's native one). Returns the one it replaced. */
102export function setSoundPort(next: SoundPort): SoundPort {
103 const before = port;
104 port = next;
105 return before;
106}
107
108export function soundPort(): SoundPort {
109 return port;
110}
111
112// ── Settings ─────────────────────────────────────────────────────────────
113
114let settings: SoundSettings = DEFAULT_SOUND_SETTINGS;
115const listeners = new Set<() => void>();
116
117function 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. */
122export 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. */
127export 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
133function subscribe(listener: () => void): () => void {
134 listeners.add(listener);
135 return () => listeners.delete(listener);
136}
137
138export 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 */
146export 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. */
165export type SoundEvent = { cue: SoundCue; set: SoundSet; volume: number; at: number; how: "play" | "preview" | "silent" };
166
167const LOG_KEPT = 20;
168const log: SoundEvent[] = [];
169
170function 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. */
183export 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. */
188export 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. */
200export 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
205export function canPlay(): boolean {
206 return port.canPlay();
207}
208
209// ── Allowing sound ───────────────────────────────────────────────────────
210
211const GESTURES = ["pointerdown", "keydown", "touchstart"] as const;
212let 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 */
219export 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}