Skip to content
233 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Chat has sounds. A short warm cue plays for a message in a conversation you have open but aren't looking at, a direct message, a mention of you and an agent finishing something for you, with a soft tick for sending that is off until you turn it on; never for your own messages, a muted conversation, a conversation you are looking at in a window that has the front, or while you have paused notifications, and never twice for one message or more than once in a second and a half. The cues are made by the browser itself in two sets, Soft and Bright, so nothing is downloaded; the Sounds and notifications section of your notification settings turns them on and off, picks the set and the volume, plays each one, and offers desktop notifications for when the window is behind; Do not disturb is the one pause everyone sees, set from the chat sidebar's bell, the settings, or your avatar menu, now with a two-hour choice, and the top bar's bell shows a dot while it's on. Your choices are kept with your account. The sound layer sits behind one small interface so the desktop app can play them natively later. The chat and notifications guides say the rules.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}