Keep re-unlocking audio, not just once, and actually await resume()
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012o9j9RezxbZVKQMrB7oRLY
This commit is contained in:
1 parent
e8bfbe4168
commit
6ddbc7e981
1 file changed
+19
-13
@@ -14,13 +14,16 @@ interface Toast {
|
|||||||
href: string;
|
href: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A single, reused AudioContext — created fresh per chime, an AudioContext
|
// A single, reused AudioContext. Two separate browser behaviors fight it:
|
||||||
// starts "suspended" unless made/resumed inside a real user gesture (click,
|
// (1) autoplay policy — a context created/resumed outside a real user
|
||||||
// keypress, ...), and a chime triggered by an incoming SSE message is a
|
// gesture starts "suspended" and stays silent; (2) power-saving
|
||||||
// network callback, not a gesture. It fails *silently*: start()/stop() don't
|
// auto-suspend — Chrome can suspend an already-unlocked context again
|
||||||
// throw, nothing plays. Reusing one context that's unlocked by the page's
|
// after a stretch of no audio activity, independent of (1). Neither fails
|
||||||
// first real click/keydown avoids that — once resumed, it stays running for
|
// loudly: start()/stop() never throw, the chime just doesn't sound.
|
||||||
// the rest of the session.
|
// 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;
|
let sharedAudioContext: AudioContext | null = null;
|
||||||
|
|
||||||
function getAudioContext(): AudioContext | null {
|
function getAudioContext(): AudioContext | null {
|
||||||
@@ -33,18 +36,21 @@ function getAudioContext(): AudioContext | null {
|
|||||||
|
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
const unlockAudio = () => {
|
const unlockAudio = () => {
|
||||||
getAudioContext()?.resume();
|
const ctx = getAudioContext();
|
||||||
|
if (ctx?.state === "suspended") ctx.resume();
|
||||||
};
|
};
|
||||||
window.addEventListener("pointerdown", unlockAudio, { once: true });
|
window.addEventListener("pointerdown", unlockAudio);
|
||||||
window.addEventListener("keydown", unlockAudio, { once: true });
|
window.addEventListener("keydown", unlockAudio);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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() {
|
async function playChime() {
|
||||||
const ctx = getAudioContext();
|
const ctx = getAudioContext();
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
try {
|
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;
|
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();
|
||||||
@@ -78,7 +84,7 @@ export function TicketToasts({ currentUser }: { currentUser: { id: string; role:
|
|||||||
const pushToast = useCallback((title: string, body: string, href: string) => {
|
const pushToast = useCallback((title: string, body: string, href: string) => {
|
||||||
const id = `${Date.now()}-${idCounter.current++}`;
|
const id = `${Date.now()}-${idCounter.current++}`;
|
||||||
setToasts((prev) => [...prev, { id, title, body, href }]);
|
setToasts((prev) => [...prev, { id, title, body, href }]);
|
||||||
playChime();
|
void playChime();
|
||||||
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 6_000);
|
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 6_000);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user