From 4a0f7960ff7c70351bdecbe5110ce54f81d2bff3 Mon Sep 17 00:00:00 2001 From: Oleg Date: Fri, 7 Aug 2026 12:26:25 +0000 Subject: [PATCH] Fix silent chime: reuse one AudioContext, unlocked on first user gesture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Toast now works (confirmed after the event-bus fix), but the sound never played. Cause: a fresh AudioContext created outside a real user gesture (click/keypress) starts in "suspended" state per browser autoplay policy — an SSE message arriving is a network callback, not a gesture, so every chime was silently creating a context that never actually produced sound (start()/stop() don't throw on a suspended context, so nothing surfaced this in testing without watching for it specifically). Now a single AudioContext is created once and reused, resumed on the page's first pointerdown/keydown — after that one real interaction it stays running for the rest of the session, so every subsequent chime actually plays. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_012o9j9RezxbZVKQMrB7oRLY --- src/app/(admin)/ticket-toasts.tsx | 32 +++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/src/app/(admin)/ticket-toasts.tsx b/src/app/(admin)/ticket-toasts.tsx index d40fa76..55a113e 100644 --- a/src/app/(admin)/ticket-toasts.tsx +++ b/src/app/(admin)/ticket-toasts.tsx @@ -14,12 +14,37 @@ 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. +let sharedAudioContext: AudioContext | null = null; + +function getAudioContext(): AudioContext | null { + if (typeof window === "undefined") return null; + const AudioCtor = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; + if (!AudioCtor) return null; + if (!sharedAudioContext) sharedAudioContext = new AudioCtor(); + return sharedAudioContext; +} + +if (typeof window !== "undefined") { + const unlockAudio = () => { + getAudioContext()?.resume(); + }; + window.addEventListener("pointerdown", unlockAudio, { once: true }); + window.addEventListener("keydown", unlockAudio, { once: true }); +} + /** A short two-tone chime via the Web Audio API — no audio asset needed. */ function playChime() { + const ctx = getAudioContext(); + if (!ctx) return; try { - const AudioCtor = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; - if (!AudioCtor) return; - const ctx = new AudioCtor(); + if (ctx.state === "suspended") ctx.resume(); const now = ctx.currentTime; for (const [freq, start] of [[880, 0], [1175, 0.12]] as const) { const osc = ctx.createOscillator(); @@ -34,7 +59,6 @@ function playChime() { osc.start(now + start); osc.stop(now + start + 0.25); } - setTimeout(() => ctx.close(), 500); } catch { // audio isn't critical to the notification — a silent toast still works }