A procedural Web Audio module can pass construction checks and still produce silence. Measure the delivered signal and verify cleanup in a real browser.
Creating an AudioContext does not prove that a user can hear a score. The context can run while the graph sends silence through a muted master gain, a scheduler can stop after one note, or a scene change can leave old voices playing in the background.
I used a small procedural score to define a better browser test. The module synthesized tones and filtered noise, changed its arrangement for eight scene modes, stored mute and volume settings, and suspended itself when the document was hidden. The test checked the signal at the output boundary, then checked the two states that source-level tests often miss: silence and disposal.
A running AudioContext proves very little
The first proof harness had a simple driver: import the audio module, create an engine, click a start button, and inspect its state. The first run failed because the title screen covered the button. That was a test-driver failure, not an audio failure. I moved the test-only control above the page layer and ran the proof again.
That distinction matters. A test can fail before it reaches the code under test. It can also pass after it creates the expected objects without proving that the browser delivered a signal. The useful boundary is the audio graph after application controls have changed the signal.
Schedule notes against the audio clock
The engine uses a short look-ahead scheduler. A timer wakes the JavaScript code often, but each oscillator receives a start time from AudioContext.currentTime. The scheduler fills a small window instead of trying to play a note when the timer fires.
const now = context.currentTime;
while (nextTime < now + 0.15) {
scheduleNote(nextTime, scene);
nextTime += eighthNote;
}
The scene changes the pace, level, rhythm, and octave. The score data supplies the scale, progression, melody, and voice. Each short source is tracked so the engine can disconnect it when the source ends. This keeps a scene switch from turning into a growing collection of live nodes.
Measure after the master gain
The graph is small:
oscillator or noise -> envelope -> master gain -> analyser -> destination
The analyser belongs after the master gain. If the user mutes the score or sets its volume to zero, the test must see silence at the same point where the destination receives it. Measuring an oscillator before that gain would test the wrong contract.
The engine reads time-domain samples and calculates RMS and peak values.
analyser.getFloatTimeDomainData(samples);
let sum = 0;
let peak = 0;
for (const value of samples) {
sum += value * value;
peak = Math.max(peak, Math.abs(value));
}
const rms = Math.sqrt(sum / samples.length);
One sample can fall inside a rest. The browser proof waits for a signal, collects several readings after each scene change, and uses the highest RMS and peak from that short window. That prevents a quiet arrangement from failing because the analyser happened to sample between notes.
Test every scene and both kinds of silence
The proof visits all eight scene modes. It checks that the engine is running, that the reported scene matches the requested scene, that RMS is above a small threshold, and that the peak stays below the clipping limit used by the harness.
Then it checks the inverse behavior:
- Toggle mute and require zero RMS.
- Restore mute, set volume to zero, and require zero RMS again.
- Restore volume and dispose the engine.
The browser result recorded nonzero RMS in every scene. Mute and zero volume both produced zero RMS. These checks test the user-facing gain contract, including the internal muted flag.
Dispose voices, nodes, and listeners
Audio state has more owners than the score timer. The engine registers a click handler, a keyboard handler, and a visibility handler. It also owns the timer, active sources, the master gain, the analyser, and the AudioContext.
The disposal path clears the timer, removes the event listeners, stops and disconnects active sources, disconnects the master and analyser, and closes the context. The proof requires a closed context, zero active voices, and a disposed flag after cleanup.
That last assertion catches a class of bugs that a screenshot cannot show. A page can look correct while an old world continues to schedule notes after the user has moved to a new one.
Separate signal proof from listening review
The checks prove objective behavior. They do not decide whether a melody sounds good, whether a mix feels balanced on headphones, or whether every device handles the same output level well. Those questions need a listening review.
The checks also cover the audio module boundary. They do not prove that every episode passes the complete application flow. Keep full-world integration, gesture unlock, scene wiring, and subjective review as separate release checks.
The useful rule is simple: test the signal where the user receives it, test silence as deliberately as sound, and make cleanup observable. A running AudioContext is only the beginning of that proof.