Fix silent chime: reuse one AudioContext, unlocked on first user gesture
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012o9j9RezxbZVKQMrB7oRLY
This commit is contained in:
1 parent
4424883496
commit
4a0f7960ff
1 file changed
+28
-4
@@ -14,12 +14,37 @@ interface Toast {
|
|||||||
href: string;
|
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. */
|
/** A short two-tone chime via the Web Audio API — no audio asset needed. */
|
||||||
function playChime() {
|
function playChime() {
|
||||||
|
const ctx = getAudioContext();
|
||||||
|
if (!ctx) return;
|
||||||
try {
|
try {
|
||||||
const AudioCtor = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
if (ctx.state === "suspended") ctx.resume();
|
||||||
if (!AudioCtor) return;
|
|
||||||
const ctx = new AudioCtor();
|
|
||||||
const now = ctx.currentTime;
|
const now = ctx.currentTime;
|
||||||
for (const [freq, start] of [[880, 0], [1175, 0.12]] as const) {
|
for (const [freq, start] of [[880, 0], [1175, 0.12]] as const) {
|
||||||
const osc = ctx.createOscillator();
|
const osc = ctx.createOscillator();
|
||||||
@@ -34,7 +59,6 @@ function playChime() {
|
|||||||
osc.start(now + start);
|
osc.start(now + start);
|
||||||
osc.stop(now + start + 0.25);
|
osc.stop(now + start + 0.25);
|
||||||
}
|
}
|
||||||
setTimeout(() => ctx.close(), 500);
|
|
||||||
} catch {
|
} catch {
|
||||||
// audio isn't critical to the notification — a silent toast still works
|
// audio isn't critical to the notification — a silent toast still works
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user