Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

241 linesCodeBlameRaw
1/**
2 * The sound layer: named cues (`message`, `direct`, `mention`, `sent`,
3 * 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(
140 subscribe,
141 () => settings,
142 () => DEFAULT_SOUND_SETTINGS,
143 );
144}
145
146/**
147 * Saves a change with the account (`/-/notify`, intent `sounds`): shown at
148 * once, put back if it does not save. Returns the settings as kept, or null.
149 */
150export async function saveSoundSettings(change: SoundSettingsChange): Promise<SoundSettings | null> {
151 const before = settings;
152 applySoundSettings({ ...settings, ...change, sound_cues: { ...settings.sound_cues, ...change.sound_cues } });
153 try {
154 const response = await fetch("/-/notify", {
155 method: "POST",
156 headers: { "content-type": "application/json" },
157 body: JSON.stringify({ intent: "sounds", change }),
158 });
159 if (!response.ok) throw new Error(`status ${response.status}`);
160 const saved = (await response.json()) as SoundSettings;
161 applySoundSettings(saved);
162 return saved;
163 } catch (error) {
164 console.error("sounds: the settings did not save", error);
165 applySoundSettings(before);
166 return null;
167 }
168}
169
170// ── Playing ──────────────────────────────────────────────────────────────
171
172/** What happened, for the harness and for anyone debugging: the last few cues. */
173export type SoundEvent = { cue: SoundCue; set: SoundSet; volume: number; at: number; how: "play" | "preview" | "silent" };
174
175const LOG_KEPT = 20;
176const log: SoundEvent[] = [];
177
178function record(event: SoundEvent): void {
179 log.push(event);
180 if (log.length > LOG_KEPT) log.shift();
181 if (typeof window !== "undefined") {
182 try {
183 window.dispatchEvent(new CustomEvent("g1t:sound", { detail: event }));
184 } catch {
185 // Nothing listening.
186 }
187 }
188}
189
190/** The last cues played (or not, `silent`, when audio was not allowed yet), oldest first. */
191export function recentSounds(): readonly SoundEvent[] {
192 return log;
193}
194
195/** Plays a cue from the person's set at their volume. The caller has already decided it should sound. */
196export function play(cue: SoundCue): void {
197 const { sound_set: set, sound_volume: volume } = settings;
198 if (volume <= 0) {
199 record({ cue, set, volume, at: Date.now(), how: "silent" });
200 return;
201 }
202 const how = port.canPlay() ? "play" : "silent";
203 record({ cue, set, volume, at: Date.now(), how });
204 port.play(cue, set, volume);
205}
206
207/** From the settings page: a cue from any set, at the volume being chosen. */
208export function preview(cue: SoundCue, set: SoundSet = settings.sound_set, volume: number = settings.sound_volume): void {
209 record({ cue, set, volume, at: Date.now(), how: "preview" });
210 port.preview(cue, set, volume);
211}
212
213export function canPlay(): boolean {
214 return port.canPlay();
215}
216
217// ── Allowing sound ───────────────────────────────────────────────────────
218
219const GESTURES = ["pointerdown", "keydown", "touchstart"] as const;
220let unlocking = false;
221
222/**
223 * From the first click or key on the page, audio is allowed: the context is
224 * made then, so a cue a second later plays. Mounted once by the root
225 * (components/notifications/live-notifications.tsx). Returns how to stop.
226 */
227export function startSounds(): () => void {
228 if (typeof window === "undefined" || unlocking) return () => {};
229 unlocking = true;
230 const unlock = () => {
231 const current = port;
232 if (current instanceof WebAudioPort) current.unlock();
233 if (current.canPlay()) stop();
234 };
235 const stop = () => {
236 for (const name of GESTURES) window.removeEventListener(name, unlock, { capture: true });
237 unlocking = false;
238 };
239 for (const name of GESTURES) window.addEventListener(name, unlock, { capture: true, passive: true });
240 return stop;
241}