// Light UI sounds for live notifications and chat, synthesised with Web Audio // so there's no asset to ship or cache. Two voices: a single soft blip for a // chat message, and a two-note rising chime for a notification (a mention, a // follow, an upvote…). const STORAGE_KEY = "sounds"; // Shared across tabs: the id of the last event that chimed, so a user with the // site open in several tabs hears one chime per event rather than one per tab. const CLAIM_KEY = "sounds-last-claim"; // A burst of chat messages shouldn't machine-gun blips; one per window is // enough to say "the chat is moving". const CHAT_THROTTLE = 4_000; export type ChimeKind = "chat" | "notify"; export function readSoundsEnabled(): boolean { try { return localStorage.getItem(STORAGE_KEY) !== "off"; } catch { return true; } } export function writeSoundsEnabled(enabled: boolean): void { try { localStorage.setItem(STORAGE_KEY, enabled ? "on" : "off"); } catch { // Private mode / blocked storage: the choice just won't persist. } } let ctx: AudioContext | null = null; let lastChatAt = 0; function audio(): AudioContext | null { if (ctx) return ctx; try { ctx = new AudioContext(); } catch { return null; } return ctx; } // Browsers keep an AudioContext suspended until the page sees a user gesture. // Resume it on the first one so a chime arriving later — possibly while the // tab is in the background — can actually be heard. function unlock() { const ac = audio(); if (ac?.state === "suspended") void ac.resume(); globalThis.removeEventListener("pointerdown", unlock); globalThis.removeEventListener("keydown", unlock); } globalThis.addEventListener("pointerdown", unlock); globalThis.addEventListener("keydown", unlock); function claim(eventId: string): boolean { try { if (localStorage.getItem(CLAIM_KEY) === eventId) return false; localStorage.setItem(CLAIM_KEY, eventId); } catch { // No storage: every tab chimes, which is the lesser evil. } return true; } function tone( ac: AudioContext, freq: number, start: number, duration: number, peak: number, ) { const osc = ac.createOscillator(); const gain = ac.createGain(); osc.type = "sine"; osc.frequency.setValueAtTime(freq, start); // Fast attack, exponential tail: a struck, glassy note rather than a beep. gain.gain.setValueAtTime(0.0001, start); gain.gain.exponentialRampToValueAtTime(peak, start + 0.012); gain.gain.exponentialRampToValueAtTime(0.0001, start + duration); osc.connect(gain).connect(ac.destination); osc.start(start); osc.stop(start + duration + 0.02); } function render(kind: ChimeKind) { const ac = audio(); if (!ac || ac.state !== "running") return; const now = ac.currentTime; if (kind === "chat") { tone(ac, 1046.5, now, 0.14, 0.05); // C6 } else { tone(ac, 659.25, now, 0.22, 0.07); // E5 tone(ac, 987.77, now + 0.09, 0.32, 0.06); // B5 } } /** * Plays a chime for a live event, unless sounds are off, another tab already * chimed for the same event, or (for chat) one played a moment ago. */ export function playChime(kind: ChimeKind, eventId: string): void { if (!readSoundsEnabled()) return; if (kind === "chat") { const now = Date.now(); if (now - lastChatAt < CHAT_THROTTLE) return; lastChatAt = now; } if (!claim(`${kind}:${eventId}`)) return; render(kind); } /** Plays a chime unconditionally — used to preview it from the settings. */ export function previewChime(kind: ChimeKind): void { const ac = audio(); if (!ac) return; if (ac.state === "suspended") { void ac.resume().then(() => render(kind)); } else { render(kind); } }