Building a Hearing Test With the Web Audio API
On Hearingly we ran hearing assessments in the browser with Next.js and the Web Audio API. Playing a tone takes a few lines. Playing the right tone, in the right ear, at a level you can trust, is where the work starts.
Here are the parts of the API that matter for this kind of app, and the part no API can solve.
Start audio from a user gesture
Browsers don't let pages play audio on their own. MDN's rule is simple: create or resume the context from inside a user gesture, which normally means a click. A context created outside one starts suspended.
const ctx = new AudioContext({ latencyHint: "interactive" });
startButton.addEventListener("click", async () => {
if (ctx.state === "suspended") await ctx.resume();
runTest();
});That start button is useful anyway. It's the natural place to ask the person to put on headphones and find a quiet room.
Play a tone in one ear, without a click
A hearing test needs a sine wave at a set frequency, in one ear, at a set level. Connect an oscillator to a gain node and a stereo panner. Then schedule the level on the audio clock instead of using setTimeout, which isn't precise enough.
function playTone(
ctx: AudioContext,
frequency: number,
levelDb: number,
ear: "left" | "right",
) {
const osc = new OscillatorNode(ctx, { type: "sine", frequency });
const gain = new GainNode(ctx, { gain: 0 });
const pan = new StereoPannerNode(ctx, { pan: ear === "left" ? -1 : 1 });
osc.connect(gain).connect(pan).connect(ctx.destination);
const peak = 10 ** (levelDb / 20);
const fade = 0.03;
const length = 1;
const start = ctx.currentTime + 0.05;
gain.gain.setValueAtTime(0, start);
gain.gain.linearRampToValueAtTime(peak, start + fade);
gain.gain.setValueAtTime(peak, start + length - fade);
gain.gain.linearRampToValueAtTime(0, start + length);
osc.start(start);
osc.stop(start + length);
}The short fades matter. If a sine wave starts or stops abruptly, the speaker jumps and you hear a click. In a hearing test that's worse than annoying, because the person may respond to the click and not the tone.
Know what your decibels mean
The levelDb above is relative to the loudest signal the browser can output. A value of -40 means a gain of 0.01. It says nothing about how loud the sound is at the ear.
That's the real difficulty. A web page can't read the device's volume setting, and it doesn't know which headphones are plugged in. The same gain can be quiet on one setup and loud on another.
So a serious browser test needs a calibration step, and it needs to treat results as a screening, not a clinical audiogram. Design that step before anything else. Everything downstream depends on it.
Measure latency when timing matters
If you record how quickly someone responds, remember that the sound reaches them later than you scheduled it. The outputLatency property estimates that delay, and MDN notes the value varies with the platform and hardware. It became widely available in 2025, so check for it before relying on it.
const delay = ctx.outputLatency ?? ctx.baseLatency ?? 0;Test on real devices
On Hearingly, latency and cross-browser behaviour took as much care as the audio code. Run your test on real phones and laptops, with wired and Bluetooth headphones, before you trust a single result.