"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
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)
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
};
}
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)];
}
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
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());
I built the microphone test linked above. Drafted with AI assistance; the code is adapted from the production tool.
Top comments (0)