Recording audio in the browser

A page asks for the microphone, gets a stream, and makes a file of it. Two APIs do the work, and they give different files. Every figure here runs in your browser, the recordings on your own microphone; nothing is sent anywhere.

not in use

1Asking

getUserMedia asks. The browser shows its prompt, and the promise waits for the answer: a MediaStream with an audio track, or an error.1 Ask from a press, once the reader knows why. A prompt the moment a page opens has no context, and is often blocked.2

const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
const [track] = stream.getAudioTracks()

Only a secure context may ask: a page served over HTTPS, or from localhost. Anywhere else navigator.mediaDevices is undefined.1,3 A page in an iframe may ask only if the frame allows it, <iframe allow="microphone">; without that the reader is never asked.1,3

The error names the cause. NotAllowedError: the reader, a setting or a policy said no. NotFoundError: no input meets the request. NotReadableError: permission was given, but the system, the browser or the hardware could not open the device. OverconstrainedError: no input meets an exact constraint, and its constraint says which.3

Secure context
–
getUserMedia
–
In a frame
–
Permission
–
The answer
not asked yet
This page, as your browser sees it. The permission is navigator.permissions.query({ name: 'microphone' }); Allow the microphone, above, asks.

2What the browser does to the sound

Between the microphone and the page a browser may run three processors, each a constraint the page can switch:1

Chrome turns all three on unless asked otherwise, and echoCancellation: false alone turns the other two off with it; name all three.4 They help a voice on a call. A take for editing or for music wants the sound as the microphone heard it: the level you sang at, the room around a note. Switch them here, speak, then go quiet: noise suppression lowers the floor between words, gain control brings a whisper and a shout closer than you made them. The table reads back what the track did.

Ask for
PropertyKnownAskedGot

Every value here is a request. A bare value, or { ideal }, is a wish, met as nearly as the device can. min, max and exact are demands: a device that cannot meet one fails the call with OverconstrainedError. A property the browser does not know is ignored, even an exact one.1 Firefox 153 knows no sampleRate, so { exact: 16000 } passes there without a word. Check getSupportedConstraints(), and read back track.getSettings(). Whatever rate the track has, an AudioContext resamples it to its own.5

const stream = await navigator.mediaDevices.getUserMedia({ audio: {
  echoCancellation: false, noiseSuppression: false, autoGainControl: false,
  channelCount: 1, sampleRate: 48000   // wishes: read back getSettings()
} })

3Which microphone

enumerateDevices() lists the inputs and outputs. Until the page has opened a microphone it shows at most one device of each kind, with no name and no id, so a page cannot read the hardware before asking.1 After, it lists every input by name. Ask for one with deviceId: { exact: id }; devicechange fires when one is plugged in or out; an input and an output of the same device share a groupId. Once allowed, the picker above reopens the stream from the input you choose.

KindLabeldeviceIdgroupId

Your devices, as this page may see them now. The input in use is marked.

4MediaRecorder: the browser's own file

new MediaRecorder(stream) encodes the stream as it comes and hands the file over in blobs.6 It is the shortest way to a recording, and the format is the browser's choice: Chromium writes WebM with Opus, Firefox Ogg with Opus, Safari MP4 with AAC.7,8,9 Ask for another with { mimeType }; MediaRecorder.isTypeSupported() says whether this browser can make it. Yours says:

TypeRecordable here

None of the three records WAV, MP3 or FLAC. Their recorders write WebM, Ogg, MP4 and Matroska, nothing else,7,8,9 and on 14 October 2026 Chromium 151, Firefox 153 and WebKit 26.5 all said no to audio/wav, audio/mpeg and audio/flac. Lossless comes only inside those containers: PCM in WebM from Chromium, PCM or ALAC in MP4 from Safari 18.4 on.9 Chromium has also written MP4 since Chrome 126, its AAC only in builds with proprietary codecs.7 For a WAV, an MP3 or a FLAC, take the samples yourself (5) and write or encode them.

const recorder = new MediaRecorder(stream), parts = []
recorder.ondataavailable = e => parts.push(e.data)
recorder.onstop = () => save(new Blob(parts, { type: parts[0].type }))
recorder.start()
setTimeout(() => recorder.stop(), 3000)
Bitrate

The size over the time is the bitrate you got, beside the one audioBitsPerSecond reports; they need not agree. The last line is what an <audio> element makes of the file's length. Decoding the blob gives the true one, and the waveform is drawn from it.

5Every sample: an AudioWorklet

For WAV, FLAC or MP3, or for samples to measure, take them yourself. Route the stream into an AudioContext and on to an AudioWorkletNode. Its processor runs on the audio thread, once for every block of 128 frames, and copies each one out.5 A busy page delays the messages; it does not lose them. ScriptProcessorNode did this on the main thread; it is deprecated in favour of the worklet.5

// tap.js, on the audio thread
registerProcessor('tap', class extends AudioWorkletProcessor {
  process([input]) {
    if (input[0]) this.port.postMessage(input[0].slice())
    return true
  }
})

// the page
await context.audioWorklet.addModule('tap.js')
const tap = new AudioWorkletNode(context, 'tap', { numberOfOutputs: 0, channelCount: 1, channelCountMode: 'explicit' })
tap.port.onmessage = e => blocks.push(e.data)
context.createMediaStreamSource(stream).connect(tap)

Posting every block is 375 messages a second at 48 kHz; the figure's processor gathers 4,096 frames and hands the buffer over without copying it. The cost is memory, since you keep what you record: 48,000 Hz mono, a minute: 11.5 MB as float in memory, 5.8 MB as 16-bit WAV, 8.6 MB as 24-bit. The audio recorder works this way, and hands the samples to an encoder for MP3, FLAC or Opus.

WAV

6The bytes in front

A WAV file is a RIFF header and then the samples, little-endian, channels interleaved.10,11 For integer PCM the header is 44 bytes, every field worked out from four numbers: rate, channels, bits and frames.12

ByteFieldValueAs stored

function wavHeader(rate, channels, bits, frames) {
  const bytes = bits / 8, data = frames * channels * bytes
  const h = new DataView(new ArrayBuffer(44))
  const text = (at, s) => [...s].forEach((c, i) => h.setUint8(at + i, c.charCodeAt(0)))
  text(0, 'RIFF'); h.setUint32(4, 36 + data, true); text(8, 'WAVE')
  text(12, 'fmt '); h.setUint32(16, 16, true); h.setUint16(20, 1, true)
  h.setUint16(22, channels, true); h.setUint32(24, rate, true)
  h.setUint32(28, rate * channels * bytes, true); h.setUint16(32, channels * bytes, true)
  h.setUint16(34, bits, true); text(36, 'data'); h.setUint32(40, data, true)
  return h.buffer
}

The two sizes are known only at the end, so a recorder that writes as it goes leaves them zero and fills them in on stop; a file cut off before that still holds every sample, and its length gives the sizes back. A reader should walk the chunks rather than skip 44 bytes: other files carry more before the samples.11 Float is not PCM in the format's sense: tag 3, an fmt chunk two bytes longer for its cbSize, and a fact chunk with the length in frames, 58 bytes in all.10,11 The sizes are 32-bit, so a WAV holds at most 4 GiB, about 6.2 hours of 48 kHz 16-bit stereo; RF64 makes them 64-bit.13

7Latency

Sound reaches a recording late and leaves the speakers late. Web Audio reports the output side. baseLatency is the context's own buffering on the way to the audio system; outputLatency estimates the rest of the way, until the speaker makes the sound. The latencyHint a context is made with sets the first: 'interactive', the default, asks for the lowest that does not glitch, 'playback' for the least power.5

latencyHintbaseLatency · framesoutputLatency · frames
Press Read the latencies: three contexts are made, run for 0.4 s in silence, and closed.

Web Audio has nothing for the input side: from the microphone through the converter, the driver and the system's buffers to your processor. A track's latency setting is the target its configuration aims at, not a measurement.1 The Media Capture Extensions draft adds track.stats.latency, the input latency as measured, in milliseconds;14 Chromium 151 reports it.

To place a take against what was playing, for an overdub or a measurement, measure the whole round trip: play a click, record it back, and find how far it moved by cross-correlation.15 A loopback cable from output to input measures the device. A speaker and the microphone add the air between them, and need echo cancellation off, since it is made to remove exactly that sound.1 The latency test does this on your device; room measurement does it with a sweep and reports the latency beside the room's response.

8Surviving a crash

A recorder that keeps the take in memory loses all of it when the tab goes: a crash, a reload, a phone reclaiming memory. Give start() a timeslice and MediaRecorder hands over a chunk that often;6 write each one to storage as it comes. The chunks end to end are the file, the first carrying its header.

recorder.ondataavailable = async e => save(take, n++, await e.data.arrayBuffer())
recorder.start(1000)   // a chunk every second
document.addEventListener('visibilitychange', () => document.hidden && recorder.requestData())

IndexedDB takes each chunk in a transaction, and a reload keeps every one that completed. With { durability: 'strict' } the browser may wait for the disk itself before it reports one complete, against a power cut or a system crash.16 The origin private file system's createWritable() is no safer: until close() the file keeps its old contents, so a take cut off mid-way never reaches it. A sync access handle, in a worker, writes in place.17

When the page is hidden, requestData() hands over what has come so far: on a phone, hidden is often the last event a page sees before it is frozen, discarded or closed.18 A track also goes muted while its source cannot give it data, and ended when it is gone;1 the log shows each as it comes.

    Record, switch to another tab and back, then reload this page while it still records. The take comes back here, above the chunks, from IndexedDB. A take stopped normally is read back from storage into one file, and the storage cleared.

    Chunks cost Chromium's WebM its length. Written live, its header goes out before the length is known, so the joined file has no duration and no cues. Since Chrome 140 a recording without a timeslice is written seekable; one in timeslices still is not.19 An <audio> element then reports Infinity until it has read to the end, as the line above may show; decoding the file gives its length.

    9Letting go

    While a page may use the microphone, and while a track is live, the browser must show it, each time either starts or stops: look at this tab.1 Stopping the MediaRecorder does not end capture, nor does closing the AudioContext. Stopping the track does, and every clone of it.

    stream.getTracks().forEach(track => track.stop())
    Track
    none
    MediaRecorder
    none
    AudioContext
    none
    Open all three, then end them one at a time and watch this tab's microphone mark. A disabled track sends silence; once every track on the device is disabled, muted or stopped, the browser should let the device go within 3 seconds, and take it back when one is enabled.1

    10With audio

    The audio library takes the AudioWorklet's path in one call. record() opens the microphone with the three processors off and keeps every block, as 16-bit samples, until stop();20 the take is then a sound like any other, to edit and encode.

    let take = audio()
    take.record()                        // { device, sampleRate, channels, monitor }
    // …
    take.stop()
    take.trim().highpass(80).normalize(-1)
    let bytes = await take.encode('wav') // or 'flac', 'mp3', 'opus'

    The playground below runs in a frame of its own and cannot take this page's recordings, so the same steps run on a built-in sample. Change them and listen.

    audio('handpan.wav')
      .trim()
      .highpass(80)
      .normalize(-1)
    After a take: silence trimmed from the ends, rumble under 80 Hz taken out, the peak brought to −1 dBFS.
    1. W3C, Media Capture and Streams: mediaDevices only in a secure context; the microphone policy, allowed to the page itself by default (§14); constraints, ideal and required, and unknown ones ignored (§11); the processing, rate, channel and latency properties (§4.3.8); devices before capture, at most one of each kind with empty ids and labels (§9.2.1); muted and disabled tracks, and the device let go within 3 s (§4.3.1); indicators the user agent must show (§15).
    2. M. Gaunt, Permission UX, web.dev: not to ask as a site opens; asked with no context, users block.
    3. MDN, MediaDevices: getUserMedia(): exceptions, secure contexts, permissions policy for iframes.
    4. Chromium, audio_processing.h (echo cancellation, noise suppression and gain control on by default) and media_stream_constraints_util_audio.cc (with echo cancellation off, the others default off); seen in Chromium 151.
    5. W3C, Web Audio API: a MediaStream resampled to the context's rate; the render quantum, 128 frames by default; ScriptProcessorNode deprecated for AudioWorkletNode; baseLatency, outputLatency, latencyHint.
    6. W3C, MediaStream Recording: start(timeslice), requestData(), isTypeSupported(); the container and codecs left to the user agent.
    7. Chromium, media_recorder_handler.cc: WebM with Opus by default, PCM in WebM, Matroska, MP4; MP4 container support for MediaRecorder, Chrome 126.
    8. Mozilla, MediaRecorder.cpp: audio/ogg; codecs=opus by default, Ogg or WebM, Opus only.
    9. WebKit, MediaRecorder API (2020): MP4 with AAC; WebKit features in Safari 18.4 (2025): WebM with Opus, and lossless ALAC or PCM in MP4.
    10. IBM & Microsoft, Multimedia Programming Interface and Data Specifications 1.0 (1991): RIFF, the WAVE form, Intel byte order, the fact chunk.
    11. P. Kabal, Audio File Format Specifications: WAVE, McGill University: format codes, the extended fmt chunk of non-PCM formats, the fact chunk of float files, the unsafe 44-byte assumption.
    12. Microsoft, WAVEFORMATEX structure: the fields' names and how block align and bytes a second follow from the rest.
    13. EBU, Tech 3306, RF64: an extended file format for audio (2007, 2018).
    14. W3C, Media Capture Extensions (draft): MediaStreamTrackAudioStats, input latency in milliseconds.
    15. Android Open Source Project, Measure audio latency: round-trip latency through a loopback, found by correlation.
    16. W3C, Indexed Database API 3.0: transactions and their durability hint.
    17. WHATWG, File System: createWritable() applied on close; createSyncAccessHandle(), in place, in dedicated workers.
    18. P. Walton, Page Lifecycle API, Chrome for Developers: hidden, frozen, discarded; MDN, visibilitychange.
    19. Chromium, issue 40482588 (formerly 642012), “MediaRecorder: consider producing seekable WebM files”: fixed for recordings without a timeslice in 742e276678eb, Chrome 140.
    20. audio, Playback / Recording; @audio/mic, browser.js: getUserMedia with the three processors off, an AudioWorklet taking every block.