A soundtrack that's on by default, without an AudioContext until the first gesture
My portfolio has an ambient soundtrack. I wanted it on by default: the site is
a 3D map of space, and the music is part of it. A tray in the corner has one
AUDIO control that turns everything off, and that choice is saved.
"On by default" collides with the browser. No page can start audible sound
before the visitor has interacted with it. So the real question is not how to
autoplay. It's what the page does in the seconds between "the visitor wants
music" and "the browser allows music".
The symptom
The first version did the obvious thing. On entry, build the audio graph, call
play(), and if the browser says NotAllowedError, wait for a click.
It worked. But Chrome printed this on every page load:
The AudioContext was not allowed to start.
I counted 168 of those warnings in 174 page loads.
The graph was being built only so the browser could refuse it.
Why the obvious fix doesn't work
The obvious fix is "don't start on entry, start on click". That kills the
default. The visitor would have to find the speaker icon to hear anything, and
the tray would show OFF while the saved preference says ON.
The other obvious fix is to catch the refusal and stay quiet. The warning is
printed when the AudioContext is created in a page without activation,
before any promise rejects. There is nothing to catch.
What I did: an armed state
The player has six states. One of them is new:
/** `armed`: on by default, waiting for the browser's first user gesture. */ type Playback = "off" | "loading" | "playing" | "armed" | "paused" | "error"; armed means "the visitor wants sound, and the browser hasn't allowed it yet".
The tray shows it as ON, because what it shows is the intent, not the wait.
The browser can tell you whether a gesture has happened, through
navigator.userActivation. If it says no, the player goes straight to armed
and doesn't touch Web Audio at all:
startDefault() { // ...the saved "off" returns early... this.wanted = true; if (this.mustWaitForGesture()) { this.update({ playback: "armed" }); return; } void this.play(false); } /** True while the browser says no gesture has happened yet and no graph exists. */ private mustWaitForGesture(): boolean { return this.context === null && typeof navigator !== "undefined" && navigator.userActivation?.hasBeenActive === false; } The AudioContext is only created inside play(), and play() runs from the
first gesture. So the graph is built inside the activation, as a click handler
would do it:
this.prepare(); // new AudioContext(), new Audio(), gain at 0 // Both calls happen within the click's user activation, including iOS. await Promise.all([this.context!.resume(), this.media!.play()]); The gain starts at 0 and ramps to the visitor's volume over 1.2 seconds, so
the first gesture never produces a jump.
The unit test checks the thing I actually care about: no graph before the
gesture, one graph after it.
it("before any gesture it arms itself without building an AudioContext (no console warning)", async () => { vi.stubGlobal("navigator", { ...navigator, userActivation: { hasBeenActive: false, isActive: false } }); player.startDefault(); await Promise.resolve(); await Promise.resolve(); expect(player.getSnapshot().playback).toBe("armed"); expect(AudioContext).not.toHaveBeenCalled(); expect(Audio).not.toHaveBeenCalled(); // The first real gesture builds the graph inside the activation and plays. vi.stubGlobal("navigator", { ...navigator, userActivation: { hasBeenActive: true, isActive: true } }); player.resumeWanted(); await Promise.resolve(); await Promise.resolve(); expect(AudioContext).toHaveBeenCalledTimes(1); expect(player.getSnapshot().playback).toBe("playing"); }); There was one more door. pageshow fires right after load, and its handler
also called play(). Same warning, through a different path. It now asks the
same mustWaitForGesture() first.
My mistake: the first tap was lost
The control listened for pointerdown and keydown. On a desktop that's
fine. On a phone, the music only started on the second tap.
A touch doesn't activate the page on pointerdown. It activates on
pointerup or touchend. So the first tap called play() too early, got
refused, and the player went back to armed. The second tap's pointerdown
came after the first tap's activation, so it worked.
The fix listens to every event that can be the activating one, in the capture
phase on window, so no other handler can swallow it:
const UNLOCK_EVENTS = ["pointerdown", "pointerup", "touchend", "keydown", "click"] as const; for (const type of UNLOCK_EVENTS) window.addEventListener(type, unlock, { capture: true, passive: true }); unlock only calls resumeWanted(), which does nothing once the music is
really playing. Wheel and pointer movement are not in the list: they never
count as activation, so no code can make them sound. Someone who reads the
page by scrolling with the wheel stays armed until their first click or key.
The quiet bug behind it
While testing that, I found a worse case. Sometimes the <audio> element
fired playing, but the AudioContext was still suspended. Sound was
flowing into a stopped graph. That's silence, and the old code marked it as
playing. The tray said ON, nothing sounded, and the next gesture thought
there was nothing to do.
Now playing only counts when the context is running:
media.onplaying = () => { if (!this.wanted || this.hidden) { media.pause(); return; } // Frames flowing into a suspended graph make no sound: still waiting. if (context.state !== "running") { this.update({ playback: "armed" }); return; } this.update({ playback: "playing" }); this.level(1.2); }; And resumeWanted() retries when the state says playing but the context
doesn't:
resumeWanted() { if (!this.wanted || this.hidden) return; const silent = this.context !== null && this.context.state !== "running"; if (this.state.playback !== "playing" || silent) void this.play(false); } Making the wait visible
armed showing ON had its own problem. People saw ON, heard nothing, and
assumed it was broken. So while the player is armed, the tray says "Click to
listen" ("Tap to listen" on touch screens) and the ON label pulses softly. It
goes away as soon as the music plays or the panel opens.
What isn't solved
A new visitor always needs one gesture. That's the browser's rule and it's a
good one. The only way to hear the music without a click is for the visitor to
allow sound for the site in their browser settings.
Where navigator.userActivation isn't available, mustWaitForGesture()
returns false and the old path runs: try to play, get refused, go to
armed. It still works, but the warning can come back there.
And the site still has two AudioContexts: one for the soundtrack and one for
the sound effects bus. One control drives both, but it's two graphs to keep in
sync.
A question
How do you show "sound is on, but waiting for you" without it looking broken?
A pulsing label is my answer for now, and I'm not sure it's the best one.
You can hear it (after one click) at
jonasjavier.dev.
I'm Jonás Javier Encarnación, a full-stack developer in Santo Domingo,
Dominican Republic. I wrote this post with help from an AI assistant; the code,
the bug and the decisions are from my project.