Skip to content

Assert sound is playing

Use case: a notification chime, a media player, a game. The regression you fear is silence — the element “plays” but no sound comes out (muted track, broken codec, autoplay policy, volume 0).

Headless browsers have no speakers, and Playwright has no “is sound coming out” API. expect(el).toHaveJSProperty('paused', false) passes even when the output is silent — paused === false only means the element wants to play.

captureStream() clones a media element’s output as a MediaStream; an AnalyserNode then gives you the waveform. Non-zero RMS energy = audible signal:

import { test, expect } from '@playwright/test';
async function audioRms(page, selector: string) {
return page.evaluate(async (sel) => {
const el = document.querySelector(sel) as HTMLMediaElement;
const stream = (el as any).captureStream?.() ?? (el as any).mozCaptureStream();
const ctx = new AudioContext();
const analyser = ctx.createAnalyser();
ctx.createMediaStreamSource(stream).connect(analyser);
await new Promise((r) => setTimeout(r, 300));
const data = new Float32Array(analyser.fftSize);
analyser.getFloatTimeDomainData(data);
return Math.sqrt(data.reduce((s, v) => s + v * v, 0) / data.length);
}, selector);
}
test('notification chime is audible', async ({ page }) => {
await page.goto('/inbox');
await page.getByRole('button', { name: 'Send' }).click();
await expect.poll(() => audioRms(page, '#chime')).toBeGreaterThan(0.01);
});

Assert on what is playing, not just that it plays

Section titled “Assert on what is playing, not just that it plays”

Frequency data distinguishes a chime from a buzz — useful when several sounds share one element:

const dominantHz = await page.evaluate(() => {
const analyser = (window as any).__analyser; // set up as above
const bins = new Uint8Array(analyser.frequencyBinCount);
analyser.getByteFrequencyData(bins);
const peak = bins.indexOf(Math.max(...bins));
return (peak * analyser.context.sampleRate) / 2 / bins.length;
});
expect(dominantHz).toBeGreaterThan(800); // the chime is ~880 Hz
expect(dominantHz).toBeLessThan(950);

If sound comes from AudioContext directly (games, synths), capture the app’s context via an init script and connect your analyser to its destination:

await page.addInitScript(() => {
const Orig = window.AudioContext;
window.AudioContext = class extends Orig {
constructor(...args: any[]) {
super(...args);
(window as any).__audioCtx = this;
}
};
});

Then create the analyser inside page.evaluate, connecting __audioCtx.destination’s inputs through it.

  • Launch Chromium with --autoplay-policy=no-user-gesture-required or the context may be suspended before any user gesture.
  • captureStream() on media elements is not implemented in WebKit — run these assertions on Chromium/Firefox, or route through Web Audio.
  • Muted elements (el.muted = true) still produce energy in captureStream() on some browsers — assert el.muted === false separately if muting is the bug you’re hunting.