// Notification chime. Plays either a user-uploaded custom sound (if one // exists in IndexedDB) or the built-in two-tone synth chime. Throttled // so a burst of messages doesn't turn into a machine gun. // // Uses a single persistent AudioContext — a fresh one per notification // lands in the `suspended` state under Chromium's autoplay policy // whenever the user hasn't interacted recently, so the tone silently // never plays. // // The custom AudioBuffer is decoded eagerly: on module init and on any // upload/reset, kick the load so it's ready before the next incoming // message. Playback always awaits `ctx.resume()` before starting the // source so we don't race an async resume in a realtime-event context // that has no prior user gesture. import { getCustomNotificationSound, subscribeNotificationSoundChanges, } from './notificationSoundStorage'; let ctx: AudioContext | null = null; let lastPlay = 0; // Cached decoded custom sound. `null` = no custom upload (fall back to // synth). `undefined` = not yet loaded. `false` = decode failed. let customBuffer: AudioBuffer | null | undefined | false = undefined; let customLoadInFlight: Promise | null = null; function getCtx(): AudioContext | null { if (ctx) return ctx; const AudioCtx = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; if (!AudioCtx) return null; ctx = new AudioCtx(); return ctx; } async function loadCustomBuffer(c: AudioContext): Promise { try { const record = await getCustomNotificationSound(); if (!record) { customBuffer = null; return; } const arr = await record.blob.arrayBuffer(); const buf = await c.decodeAudioData(arr.slice(0)); customBuffer = buf; } catch (err: unknown) { console.warn('notification: custom sound decode failed', err); customBuffer = false; } } function kickLoad(): void { if (customLoadInFlight) return; const c = getCtx(); if (!c) return; customLoadInFlight = loadCustomBuffer(c).finally(() => { customLoadInFlight = null; }); } subscribeNotificationSoundChanges(() => { customBuffer = undefined; customLoadInFlight = null; kickLoad(); }); // Eager-load at module init so the buffer is ready before the first // notification fires. Safe to call at top level — decodeAudioData // doesn't need the context running. kickLoad(); function playCustom(c: AudioContext, buffer: AudioBuffer): void { const src = c.createBufferSource(); src.buffer = buffer; const gain = c.createGain(); gain.gain.value = 1; src.connect(gain); gain.connect(c.destination); src.start(); } function playSynthTone(c: AudioContext): void { const master = c.createGain(); master.connect(c.destination); master.gain.value = 0.12; const tones: { freq: number; delay: number }[] = [ { freq: 880, delay: 0 }, { freq: 1320, delay: 0.08 }, ]; for (const { freq, delay } of tones) { const osc = c.createOscillator(); const gain = c.createGain(); osc.type = 'sine'; osc.frequency.value = freq; osc.connect(gain); gain.connect(master); const t0 = c.currentTime + delay; gain.gain.setValueAtTime(0, t0); gain.gain.linearRampToValueAtTime(1, t0 + 0.015); gain.gain.exponentialRampToValueAtTime(0.001, t0 + 0.28); osc.start(t0); osc.stop(t0 + 0.3); } } export function playNotificationTone(): void { const now = Date.now(); if (now - lastPlay < 800) return; lastPlay = now; void (async () => { const c = getCtx(); if (!c) return; if (c.state === 'suspended') { try { await c.resume(); } catch { /* autoplay denial — silent this round */ return; } } // If the load hasn't finished yet (first notification after a cold // start, before the eager load completed) wait briefly for it so // we don't emit the synth tone over a user-configured custom clip. // Cap the wait so a never-resolving decode doesn't block a message. if (customBuffer === undefined && customLoadInFlight) { const timeout = new Promise((r) => window.setTimeout(r, 500)); await Promise.race([customLoadInFlight, timeout]); } if (customBuffer && typeof customBuffer !== 'boolean') { playCustom(c, customBuffer); return; } playSynthTone(c); })(); }