Chrome Web Audio: resume() before fetch/decode
If your unlock handler awaits fetch or decodeAudioData before await context.resume(), Chrome can reject resume() even right after a tap. Fix the await order; scroll is not a gesture.
TL;DR
- Wrong: first
awaitin the tap handler isfetch/decodeAudioData, thencontext.resume(). - Right: create
AudioContextsync (no await), then first await =resume(), then load/decode, then play. - Scroll-driven UI sound still needs a real gesture path (
pointerdown,click, keys, touch). Do not usewheel/scrollas your unlock.
Context: scroll-linked typewriter ticks on this site (nickouv.com), MP3s decoded once into AudioBuffers. Same bug pattern applies to any UI sound that lazy-loads samples inside the gesture handler.
Symptom
Console on first tap:
The AudioContext was not allowed to start. It must be resumed (or created) after a user gesture on the page.
Stack often points at resume(). You did call resume() from code triggered by the tap. It fails because by then the gesture chain no longer counts: something async ran first and broke the association Chrome expects.
Why it happens
performUnlock was effectively:
awaitnetwork +decodeAudioDatafor every sample (slow on cold cache).await context.resume().- Play audio.
Step 1 returns control to the browser. When step 2 runs milliseconds later, Chrome treats resume() as not tied to the original activation. Policy details: Chrome autoplay — Web Audio, Web Audio autoplay.
Correct unlock shape
1private async performUnlock(): Promise<boolean> {
2 const context = this.getOrCreateContext(); // sync: new AudioContext + gain only
3 if (!context) return false;
4
5 // First await in this chain MUST be resume when suspended
6 if (context.state === "suspended") {
7 await context.resume();
8 }
9
10 await this.loadBuffers(); // fetch + decodeAudioData here
11
12 // optional: tiny BufferSource warmup, then set unlocked + notify listeners
13 return true;
14}preload() may run earlier (decode without resume()). Only the gesture-gated path needs this ordering.
Scroll + React
- Drive ticks from scroll only after
unlocked; gateunlock({ fromUserGesture: true })so scroll handlers cannot call it blindly. - If words advance before unlock finishes, subscribe to “unlocked” and re-run playback logic so early reveals are not silent forever.
Implementation: src/lib/typewriter-sound.ts, src/hooks/use-scroll-typewriter-sound.ts in this repo.
Rules
Do
- Create
AudioContextsynchronously on the gesture path (noawaitbefore it). - Make the first
awaitin that handlerawait context.resume()whenstate === "suspended". - Run
fetch/decodeAudioDataafterresume(). - Optionally preload buffers without calling
resume()until gesture. - Subscribe / replay playback when unlock completes if UI moved ahead.
Don't
awaitnetwork or decode beforeresume()inside the same gesture handler.- Rely on
wheel/scrollalone to unlock Web Audio. - Drop sounds that were "requested" while buffers were still loading unless you replay after unlock.
References
- MDN: AudioContext.resume()
- Chrome: user activation (why async gaps matter)