Test Audio With AnalyserNode, Not the Screen — Why All 17 Demos Looked Silent in a Background Tab
The drawing stops, the sound doesn't. Measuring the peak also catches clipping
Test audio code with an AnalyserNode, not the screen. In a background tab the drawing stops but the sound keeps going. Measure the peak and you learn not just "is there sound" but also "is it clipping." The ten-line snippet below is enough to check audio demos automatically.
All 17 came back silent
I built 17 runnable boxes for a learning app and tried to confirm with browser automation that each one made sound. At first I read the oscilloscope canvas and checked "was a waveform drawn." Result: all 17 silent. Clicking them by hand, every one of them played.
The automated tab had document.visibilityState = "hidden", a background tab. Here's what happens there.
| In a background tab | |
|---|---|
requestAnimationFrame |
paused, so the canvas oscilloscope never draws |
Turbo.visit |
page swap waits on rAF, so turbo:load never fires |
Tone.getDraw().schedule |
rAF based, doesn't run |
setInterval |
slowed to about once per second |
AudioContext |
keeps running. Sound plays |
Only the drawing side stopped. The sound side was fine. Judging from the screen was always going to be wrong.
The check
Intercept the analyser the page creates, hold on to it, and read the largest absolute value in its time-domain data.
const orig = BaseAudioContext.prototype.createAnalyser
BaseAudioContext.prototype.createAnalyser = function () {
const a = orig.call(this)
window.__lastAnalyser = a
return a
}
const peak = () => {
const buf = new Float32Array(__lastAnalyser.fftSize)
__lastAnalyser.getFloatTimeDomainData(buf)
return buf.reduce((m, v) => Math.max(m, Math.abs(v)), 0)
}
// 0 means silence, above 1 means clipping
If the page has no analyser, create one for testing and insert it before the output. Inject this before the page's own scripts run, or there's nothing to intercept.
What the numbers showed
Local Chrome, background tab (2026-09-19). By canvas, all 17 were silent (wrong). By AnalyserNode, the measured level matched the volume in the code: gain 0.2 → peak 0.2, 0.3 → 0.3, 0.15 → 0.15.
As a bonus, three cases of clipping showed up. None of them were obvious by ear.
Hand-written 16-step drum machine: 1.17. Kick 0.8 + snare 0.4 + hat 0.2 landed on the same beat
Tone.js Synth at default volume (0dB): 0.99. Right on the edge
Tone.js bass + kick: 1.23
All fixed by turning the volume down. In digital audio anything beyond ±1 gets cut off, and that's the crackle you hear. Drums, where several sounds hit at the same instant, go over especially easily.
Compared with other ways
| Method | Background tab | Clipping | Note |
|---|---|---|---|
| A person listens | – | easy to miss | 0.99 vs 1.17 is hard to hear |
| Read the canvas waveform | wrong | can't tell | rAF stops |
| AnalyserNode peak | works | catches it | page needs an analyser, or you insert one |
When you first write an E2E test for sound, screenshot comparison is the obvious idea. Audio should be measured on the audio side.
Not verified yet
The AudioContext was running even though the button was clicked by script. Probably because a real click had happened earlier (sticky activation: once the user has interacted, the permission sticks). I haven't checked whether it reaches running without any real click, whether the same check works in headless Playwright, or whether rendering with OfflineAudioContext could put this into CI.
If you want automated tests around a web feature that plays audio, feel free to talk to negabaro. This check caught three real bugs for us.
Key Points
In a background tab rAF pauses, so judging by a canvas waveform reads as silence
Intercept createAnalyser and read the max absolute value from getFloatTimeDomainData
Peak 0 is silence, above 1 is clipping. Overlapping hits like drums trigger it often