Files
gerbeur/src/utils/sound.ts
khannurien b313f70d31
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 46s
v3: chat mentions stop going missing, and live events get a light sound — the server skips a mention notification for anyone with the chatbox open, on the grounds that they're reading it, but "open" also meant a chatbox left open in a background tab, and the same modal cleared the unread badge on every arrival, so a mention there left no bell, no badge and no trace. Reading the chat now means open and on screen: the focus flag is re-announced on every visibilitychange, and the unread count only clears while the tab is visible. A reconnect with the chatbox open refetches the latest page, since whatever was said while the socket was down never reached the live buffer. The server's mention regex excluded a backslash and the letter w rather than word characters (\\w inside a class), so an email address mentioned its domain; it now matches the autocomplete's rule. On top of that, sounds synthesised with Web Audio — a soft blip for chat you aren't looking at, throttled, and a two-note chime for notifications and for a mention you're reading live, which gets no notification of its own — claimed through localStorage so several tabs chime once, with an on/off toggle under a new Notifications section in settings. The tab title carries the unread count, composed with the page title in useDocumentTitle so navigation doesn't drop it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-22 19:25:19 +00:00

124 lines
3.6 KiB
TypeScript

// 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);
}
}