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 | */ | |
| 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 | } |