Lab note · from Meridian 7
AudioContext was not allowed to start: make the click the UI
Chrome creates an AudioContext in the "suspended" state if the page hasn't had a user gesture yet, logs "The AudioContext was not allowed to start", and leaves a resume() called before the gesture pending. Create the context, or call resume(), inside a click or keydown handler. Meridian 7 makes that rule its first screen: a dead monitor with a power button, whose click handler creates the context before the machine boots.
Symptoms
The naive version sets audio up when the page loads. This is the probe we ran, reduced:
// runs on load, before anyone has clicked
const ctx = new AudioContext();
ctx.resume().then(() => console.log("running")); // still pending seconds later
In headless Chrome 153 with its default autoplay policy:
- The console showed, word for word: "The AudioContext was not allowed to start. It must be resumed (or created) after a user gesture on the page. https://developer.chrome.com/blog/autoplay/#web_audio"
ctx.statewas"suspended"at load and still"suspended"three seconds later, with an oscillator started on it andctx.currentTimestuck at0.- The
resume()promise hadn't settled 4.5 seconds later. A click didn't wake the context either, because nothing calledresume()from inside the click.
It can also look intermittent. Chrome allows sound if "the user has interacted with the domain", if the desktop Media Engagement Index threshold has been crossed, or if the site is installed as a PWA (Chrome autoplay policy). Reloads differ too. In our test, when the page itself called location.reload() after a click, the new document already reported navigator.userActivation.hasBeenActive === true, and a context created at load started "running". A reload started by the browser (page.reload()) carried nothing over.
Why it happens
The Web Audio spec leaves the decision to the browser: a context is "allowed to start" if the user agent allows the transition from suspended to running, and "a user agent may disallow this initial transition, and to allow it only when the AudioContext's relevant global object has sticky activation" (Web Audio API, §1.2). For resume(), the spec says: "If the context is not allowed to start, append promise to [[pending promises]] and [[pending resume promises]] and abort these steps." That is why the promise just waits.
Chrome has applied this since Chrome 71: "If an AudioContext is created before the document receives a user gesture, it will be created in the 'suspended' state, and you will need to call resume() after the user gesture" (Chrome autoplay policy).
A gesture means an activation-triggering input event: a trusted keydown (not Esc), mousedown, pointerdown from a mouse, pointerup from anything else, or touchend (HTML spec). Sticky activation never resets for that page. In our test, a context created 500 ms after the click, outside any handler, started "running".
The fix
Meridian 7 never creates the context at load. js/core/audio.js creates it lazily:
var ctx = null, master = null;
function init() {
if (ctx) return ctx;
var AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return null;
ctx = new AC();
master = ctx.createGain();
master.gain.value = M7.store.get('volume');
// ...analyser and noise buffer
return ctx;
}
function resume() { if (ctx && ctx.state === 'suspended') ctx.resume(); }
The first thing on screen is #power-screen, and js/core/boot.js wires its button:
btn.addEventListener('click', function () {
M7.audio.init();
M7.audio.resume();
M7.audio.setVolume(M7.store.get('volume'));
M7.audio.play('disk');
pw.classList.add('spinning');
sequence(); // POST, splash, then the chime
});
/* Keyboard also powers on, once the user has interacted. */
document.addEventListener('keydown', function once(e) {
if (pw.hidden) return;
if (e.key === 'Enter' || e.key === ' ') {
document.removeEventListener('keydown', once);
btn.click();
}
});
Why this works:
new AudioContext()runs while a trusted click is being handled, so the context is allowed to start. It read"running"inside the handler.- For Enter or Space, the
keydownis itself the activation, andbtn.click()dispatches the click synchronously inside it. That path read"running"too. - Every sound goes through
play(), which callsinit()andresume()first, so a context that had somehow been created early would get anotherresume()with the next sound. - Restart calls
location.reload(), which in Chrome can carry the activation over, but the new document starts on the power screen regardless, so nothing depends on it.
The design plan states the intent: clicking the power button "is the user gesture that unlocks WebAudio. (Authentic and required by browsers.)" There is no "click to enable sound" banner, because the machine is off until you turn it on.
How to check you've fixed it
- The console. The warning is gone, and
ctx.stateis"running"after the first gesture. - Don't test it with
page.evaluate(). Playwright runs evaluated code as a user gesture: playwright-core 1.63.0 sendsuserGesture: truewithRuntime.callFunctionOn. A context created fromevaluate()came back"running", andnavigator.userActivation.hasBeenActivewas alreadytruebefore any click, so the naive code passes. Measure from a page script and drive the page with trusted input:
await page.addInitScript(() => {
addEventListener('load', () => {
window.__atLoad = new AudioContext().state; // no gesture yet
});
});
await page.goto(url);
await page.click('#power-button'); // trusted input via CDP
console.log(await page.evaluate(() => [window.__atLoad, window.M7.audio.ctx.state]));
- Check your launch flags. With
--autoplay-policy=no-user-gesture-required, the naive context started at load, so a test run with that flag can't catch this.
Notes
One token set, and the --ink bug
css/themes.css gives each of the six themes the same 24 custom properties, under the rule "Nothing else in the system may hardcode a color." Windows, menus, fields, icons (js/core/icons.js fills with var(--ink), var(--accent) and others) and the canvas desktop pattern read those tokens, so a theme switch repaints them together.
The README records the first CRT bug: --ink was both the 1px outline colour and the text colour, which disappears once the chrome is dark, so text got --text and --text-dim. The public history starts after that split, so we measured it. In Phosphor, --ink on --chrome is 1.40:1 and --text is 9.79:1; in Amber, 1.30:1 against 9.98:1. In Oyster both tokens hold the same value, so the light themes couldn't show the bug.
The rule isn't universal: css/system.css and css/apps.css have 64 lines with a hex or rgba() colour. Most sit on surfaces outside the themes on purpose (the power screen, POST, the CRT overlay, the theme swatches), but Sweeper's number colours are hardcoded too (.sw-cell[data-n="1"] { color: #2f6fa8; }). Against open cells they measure 1.15–1.79:1 in Phosphor and 1.08–1.69:1 in Amber, against 3.88–10.73:1 in Oyster: the failure the rule prevents, exactly where it was skipped.
Caveats
- Versions. Meridian 7 has no dependencies and no build step: classic ES5-style scripts under one
M7global. We tested in Chrome for Testing 153.0.8010.12 (headless shell) with playwright-core 1.63.0, not in Firefox or Safari. MDN documents a Firefox preference,media.autoplay.block-webaudio, that when true lets audio contexts play only after sticky activation (MDN autoplay guide). - The earlier context doesn't wake up by itself. A gesture makes resuming possible, but a context created before it stays suspended until something calls
resume(). - Instant boot still needs the button. The Control Panel's Instant mode skips POST and the splash, not the power screen.
More lab notes
- Karplus-Strong with tanh saturation never decays: normalise by drivetanh(drive·x)/tanh(drive) has slope drive/tanh(drive) > 1 at zero, so a Karplus-Strong loop using it self-oscillates. Divide by drive instead.
- Anthropic API key in an iOS app binary: move it to the KeychainA Swift string literal ships in the app binary, readable with strings. Store a user-supplied key in the Keychain with a ThisDeviceOnly class instead.
- AXIsProcessTrusted false after rebuild: ad-hoc signing and TCCAn ad-hoc signature's designated requirement is the build's cdhash, so changed code no longer matches its Accessibility grant. Sign with a stable identity.
- CGWindowListCreateImage unavailable in macOS 15: use SCStreamCGWindowListCreateImage is deprecated in macOS 14 and a compile error from a macOS 15 target. Anchor still uses it; here is the SCStream replacement.