From 6ddbc7e9819d0dd26560e232ac601f512cc64d2d Mon Sep 17 00:00:00 2001 From: Oleg Date: Fri, 7 Aug 2026 13:54:35 +0000 Subject: [PATCH] Keep re-unlocking audio, not just once, and actually await resume() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sound stopped again after the previous fix — likely Chrome's power-saving auto-suspend re-suspending an already-unlocked AudioContext after a stretch of no audio activity, which is a separate mechanism from the autoplay-gesture policy the earlier fix addressed. The unlock listeners were {once: true}, so they'd already fired and detached themselves, leaving nothing to re-resume the context once the browser suspended it again. Now the listeners stay attached for the whole session (a resume() call on an already-running context is a harmless no-op, so this costs nothing), and playChime() actually awaits resume() before scheduling the oscillators instead of assuming it completes synchronously. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_012o9j9RezxbZVKQMrB7oRLY --- src/app/(admin)/ticket-toasts.tsx | 32 ++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/src/app/(admin)/ticket-toasts.tsx b/src/app/(admin)/ticket-toasts.tsx index 55a113e..049c48e 100644 --- a/src/app/(admin)/ticket-toasts.tsx +++ b/src/app/(admin)/ticket-toasts.tsx @@ -14,13 +14,16 @@ interface Toast { href: string; } -// A single, reused AudioContext — created fresh per chime, an AudioContext -// starts "suspended" unless made/resumed inside a real user gesture (click, -// keypress, ...), and a chime triggered by an incoming SSE message is a -// network callback, not a gesture. It fails *silently*: start()/stop() don't -// throw, nothing plays. Reusing one context that's unlocked by the page's -// first real click/keydown avoids that — once resumed, it stays running for -// the rest of the session. +// A single, reused AudioContext. Two separate browser behaviors fight it: +// (1) autoplay policy — a context created/resumed outside a real user +// gesture starts "suspended" and stays silent; (2) power-saving +// auto-suspend — Chrome can suspend an already-unlocked context again +// after a stretch of no audio activity, independent of (1). Neither fails +// loudly: start()/stop() never throw, the chime just doesn't sound. +// Fix: keep the unlock listeners attached for the whole session (not just +// the first click) so any later click also re-resumes a context the +// browser suspended for power-saving, and actually await resume() before +// scheduling a chime instead of assuming it completes synchronously. let sharedAudioContext: AudioContext | null = null; function getAudioContext(): AudioContext | null { @@ -33,18 +36,21 @@ function getAudioContext(): AudioContext | null { if (typeof window !== "undefined") { const unlockAudio = () => { - getAudioContext()?.resume(); + const ctx = getAudioContext(); + if (ctx?.state === "suspended") ctx.resume(); }; - window.addEventListener("pointerdown", unlockAudio, { once: true }); - window.addEventListener("keydown", unlockAudio, { once: true }); + window.addEventListener("pointerdown", unlockAudio); + window.addEventListener("keydown", unlockAudio); } /** A short two-tone chime via the Web Audio API — no audio asset needed. */ -function playChime() { +async function playChime() { const ctx = getAudioContext(); if (!ctx) return; try { - if (ctx.state === "suspended") ctx.resume(); + if (ctx.state === "suspended") await ctx.resume(); + if (ctx.state !== "running") return; // still not unlocked by a user gesture yet — nothing to do + const now = ctx.currentTime; for (const [freq, start] of [[880, 0], [1175, 0.12]] as const) { const osc = ctx.createOscillator(); @@ -78,7 +84,7 @@ export function TicketToasts({ currentUser }: { currentUser: { id: string; role: const pushToast = useCallback((title: string, body: string, href: string) => { const id = `${Date.now()}-${idCounter.current++}`; setToasts((prev) => [...prev, { id, title, body, href }]); - playChime(); + void playChime(); setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 6_000); }, []);