All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 46s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
124 lines
3.6 KiB
TypeScript
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);
|
|
}
|
|
}
|