DEV Community

Syed Salman
Syed Salman

Posted on Fully Autonomous

Building a microphone test with getUserMedia and Web Audio: levels, noise floor and honest errors

"Is my mic working?" gets searched constantly, right before a call, an interview or an online exam. A browser can answer it properly: it can open the microphone, measure the signal, and tell you why it couldn't open it. Here's how I put together a microphone test, and the details that turned out to matter.

The audio graph: analyse, but don't play back

getUserMedia ──> MediaStreamSource ──> AnalyserNode      (never connected to the speakers)
             └─> MediaRecorder (5 s clip) ──> <audio> ──> speakers
Enter fullscreen mode Exit fullscreen mode

The live input goes to an AnalyserNode and stops there. If you connect it to ctx.destination, laptop speakers feed straight back into the laptop mic and you get a howl. To let people hear themselves, record a short clip with MediaRecorder and play it back afterwards.

const stream = await navigator.mediaDevices.getUserMedia({
  audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }
});
const ctx = new AudioContext();
const analyser = ctx.createAnalyser();
analyser.fftSize = 2048;
ctx.createMediaStreamSource(stream).connect(analyser);   // no .connect(ctx.destination)
Enter fullscreen mode Exit fullscreen mode

Measure level as RMS in dBFS

getFloatTimeDomainData gives samples between -1 and 1. The root mean square of a frame is a good "how loud" number, and the largest absolute sample is the peak:

const buf = new Float32Array(analyser.fftSize);
const toDb = x => x > 1e-7 ? 20 * Math.log10(x) : -140;

function measure() {
  analyser.getFloatTimeDomainData(buf);
  let sum = 0, peak = 0;
  for (const v of buf) {
    sum += v * v;
    peak = Math.max(peak, Math.abs(v));
  }
  return {
    rmsDb: toDb(Math.sqrt(sum / buf.length)),
    peakDb: toDb(peak),
    clipped: peak >= 0.999      // at (or effectively at) full scale
  };
}
Enter fullscreen mode Exit fullscreen mode

0 dBFS is the digital maximum, so readings are negative. Be clear in the UI that this is relative to the converter. It isn't calibrated sound pressure, and a web page can't tell you "65 dB SPL".

Meters feel right with an instant attack and a slow release (about 20 dB per second), plus a peak marker that holds for 1.5 s before falling. Count clipping events instead of flashing on every clipped sample, or one loud word reads as dozens of clips.

Noise floor: the 10th percentile, not the average

People want to know "is my room noisy?" The average level mixes their voice in with the background. What you want is the level in the quiet moments between sounds, so keep about 8 seconds of RMS readings and take a low percentile:

function noiseFloor(history /* recent RMS dB values */) {
  const vals = history.slice().sort((a, b) => a - b);
  return vals[Math.floor(vals.length * 0.1)];
}
Enter fullscreen mode Exit fullscreen mode

Then translate it into words. Below about -70 dBFS is very quiet, -55 is a quiet room, around -42 is fan or traffic noise, and above that is noisy. One trap: if auto gain control is on, the browser turns the background up during silence, so mention that next to a noisy result.

Report what the browser actually applied

You can ask for echo cancellation, noise suppression and auto gain, but the browser decides. track.getSettings() tells you what you got:

const s = stream.getAudioTracks()[0].getSettings();
// s.echoCancellation, s.noiseSuppression, s.autoGainControl, s.sampleRate, s.channelCount
Enter fullscreen mode Exit fullscreen mode

Showing "noise suppression: on, browser overrode your choice" explains a lot of "my mic sounds weird on calls" reports. Noise suppression can also gate the silence to digital zero, which shows up as a perfect -∞ noise floor. That's worth saying in plain words rather than reporting "excellent".

Errors: "blocked" doesn't mean "broken"

Half the value of a mic test is what it says when the mic doesn't open. getUserMedia rejects with a named DOMException, and each name has a different fix:

Error What it usually means
NotAllowedError Permission denied by the browser or the OS. The mic may be fine.
NotFoundError No input device visible: unplugged, disabled, or a headset mute switch.
NotReadableError The device exists but another app (Zoom, Teams, Discord) has it.
OverconstrainedError The chosen device can't satisfy the constraints you asked for.

Older browsers used PermissionDeniedError, DevicesNotFoundError and TrackStartError, so map those to the same messages. For NotAllowedError, point to the OS setting too (Windows Privacy > Microphone, macOS Privacy & Security > Microphone), because a site-level "Allow" doesn't help when the OS is blocking the browser.

And getUserMedia only exists on secure (https) pages, so check for navigator.mediaDevices first and say "open this over https" instead of throwing.

Let go of the mic

Ask for the microphone only when the user presses Start, never on page load. Release it (stop every track) on Stop, when the device or settings change, on pagehide, and when the tab is hidden. Nobody wants a recording indicator still lit after they've left your page. Keep recordings as object URLs in the tab; there's no reason to upload anything.

stream.getTracks().forEach(t => t.stop());
Enter fullscreen mode Exit fullscreen mode

I built the microphone test linked above. Drafted with AI assistance; the code is adapted from the production tool.

Top comments (0)