DEV Community

Cover image for Building a Cancellable Web Audio Tone Sequence (and the CSS Bug That Hid Nothing)
clearwaterfromspeakers
clearwaterfromspeakers

Posted on

Building a Cancellable Web Audio Tone Sequence (and the CSS Bug That Hid Nothing)

I recently added a small feature to a browser bass test: a "step-down" that plays 150, 120, 100, 80, 63, 50, 40, 31.5, 25 and 20 Hz in turn, while you press I can't hear it at the first step you lose. It sounds like ten lines of code. It turned into a nice little lesson in cancellation, clicks and one CSS gotcha.

1. Sequencing tones with async/await

The simplest version is a loop that creates an oscillator per step and waits:

const wait = (s) => new Promise((r) => setTimeout(r, s * 1000));

for (const frequency of steps) {
  const osc = ctx.createOscillator();
  osc.frequency.value = frequency;
  osc.connect(ctx.destination);
  osc.start();
  osc.stop(ctx.currentTime + 2);
  await wait(2.4);          // 2 s tone + 0.4 s gap
}
Enter fullscreen mode Exit fullscreen mode

It works until the user does anything: presses Stop, starts a different tone, or hides the tab. await has no built-in cancellation, so the loop happily keeps going and plays the next step over whatever the user asked for.

2. Cancel with a generation counter

Instead of reaching for AbortController, I used the simplest pattern I know: a counter. Every stop increments it. Every running sequence remembers the value it started with and bails out as soon as they differ.

let generation = 0;

function stop(message = 'Sound stopped') {
  generation += 1;          // invalidates every running sequence
  fadeOutActiveTone();
  status.textContent = message;
}

async function stepDown() {
  stop('Preparing sound…');
  const token = generation;

  for (let i = 0; i < steps.length; i += 1) {
    if (token !== generation) return;   // someone pressed Stop
    playTone(steps[i]);
    status.textContent = `Playing ${steps[i]} Hz, step ${i + 1} of ${steps.length}`;
    await wait(toneSeconds + gapSeconds);
  }
  if (token !== generation) return;
  status.textContent = `You heard every step down to ${steps.at(-1)} Hz`;
}
Enter fullscreen mode Exit fullscreen mode

The nice property: every way of stopping goes through stop(), so there is exactly one place to get right. The Stop button, starting another tone, the "I can't hear it" button and visibilitychange all call it:

document.addEventListener('visibilitychange', () => {
  if (document.hidden) stop('Sound stopped because the tab was hidden.');
});
Enter fullscreen mode Exit fullscreen mode

"I can't hear it" is just stop() with a computed message. The last step you heard is the one before the current one:

function missedStep() {
  if (stepIndex < 0) return;
  stop(stepIndex === 0
    ? `You did not hear ${steps[0]} Hz. Check the output route and volume.`
    : `Last step you heard: ${steps[stepIndex - 1]} Hz`);
}
Enter fullscreen mode Exit fullscreen mode

3. No clicks: ramp the gain, never jump it

Starting or stopping a sine wave abruptly produces an audible click, because the waveform jumps from some value to zero in one sample. At bass frequencies, on a subwoofer, that click is very noticeable.

Each tone goes through a GainNode with an exponential ramp in and out:

function playTone(frequency, seconds = 2, peak = 0.05, ramp = 0.08) {
  const now = ctx.currentTime;
  const osc = ctx.createOscillator();
  const gain = ctx.createGain();
  osc.type = 'sine';
  osc.frequency.setValueAtTime(frequency, now);

  gain.gain.setValueAtTime(0.0001, now);
  gain.gain.exponentialRampToValueAtTime(peak, now + ramp);
  gain.gain.setValueAtTime(peak, now + seconds - ramp);
  gain.gain.exponentialRampToValueAtTime(0.0001, now + seconds);

  osc.connect(gain).connect(ctx.destination);
  osc.start(now);
  osc.stop(now + seconds);
  active = { osc, gain };
}
Enter fullscreen mode Exit fullscreen mode

Two details:

  • exponentialRampToValueAtTime cannot ramp to or from 0 (it's a ratio), so the floor is 0.0001.
  • Stopping early needs the same care. Cancel the scheduled automation, pin the current value, then ramp down:
function fadeOutActiveTone() {
  if (!active) return;
  const now = ctx.currentTime;
  active.gain.gain.cancelScheduledValues(now);
  active.gain.gain.setValueAtTime(Math.max(active.gain.gain.value, 0.0001), now);
  active.gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.08);
  active.osc.stop(now + 0.08);
  active = null;
}
Enter fullscreen mode Exit fullscreen mode

Without setValueAtTime(current), the ramp would start from wherever the old schedule thought the value was, and you get a jump.

The peak gain is deliberately low (0.05). This is a listening check. It should never be the loudest thing in the room, and the UI says to start with the volume low and not to raise it to chase the lowest steps.

4. The bug: hidden didn't hide

"I can't hear it" should only appear while the sequence runs, so the button starts with hidden and the code toggles button.hidden. In the browser, it was visible all the time.

The cause: the button also had a .button class with display: inline-flex. The hidden attribute is implemented by the user-agent stylesheet as [hidden] { display: none }, and any author CSS that sets display wins over it. So hidden was set correctly, but did nothing.

The fix is one scoped rule:

.test-buttons [hidden] { display: none !important; }
Enter fullscreen mode Exit fullscreen mode

It's a classic trap, and a good argument for a global [hidden] { display: none !important; } in any design system that styles buttons as flex containers.

5. Testing audio UI without speakers

Unit tests covered the config: the steps descend from 150 Hz to 20 Hz. But the interesting behaviour is timing and state. So I drove the real page in headless Chrome with Playwright, using the installed Chrome and an autoplay flag so AudioContext starts without a gesture:

const browser = await chromium.launch({
  channel: 'chrome',
  args: ['--autoplay-policy=no-user-gesture-required'],
});
const page = await browser.newPage();
await page.goto('http://localhost:4322/bass-test/');

const status = () => page.locator('[data-bass-status]').textContent();
await page.click('[data-bass-stepdown]');
await page.waitForTimeout(5100);
console.log(await status());          // "Playing 100 Hz, step 3 of 10"

await page.click('[data-bass-stepdown-miss]');
console.log(await status());          // "Last step you heard: 120 Hz"
await page.waitForTimeout(3000);
console.log(await status());          // unchanged: the loop really stopped
Enter fullscreen mode Exit fullscreen mode

That run is what caught the hidden bug. It also confirmed the three exit paths (miss, Stop, another tone) all end the sequence, and that nothing resumed after cancellation. You can't hear anything in headless mode, but you can assert everything the user would see.

What the result means (and doesn't)

The last step you hear depends on the speaker, its enclosure, the room, device processing, the volume and your ears. So the page frames it as a comparison between setups, not a measurement or a hearing test. Phone and laptop speakers stopping well above subwoofer range is expected, not a fault.

If you want to try it, the step-down is on the SpeakerEject bass test. It needs no account and no microphone.

Top comments (0)