Replacing MediaRecorder with WebCodecs and mp4-muxer: A Timeslice Audio-Glitch Postmortem

If you moved a recorder from MediaRecorder to AudioEncoder plus a muxer and the audio started clicking, drifting, or going silent on one browser, the instinct is to blame the encoder. In most cases I would look somewhere else first. The timeslice model and the WebCodecs model disagree about what a chunk is, and the glitch usually lives in that disagreement.

This is a diagnostic postmortem, not a report of one incident. I have no bench log to publish here, so treat the failure modes below as a checklist to run against your own rig. The claims about API behavior are sourced; the ordering and the recommendations are mine.

What a timeslice chunk actually promises

MediaRecorder.start(timeslice) takes an optional duration in milliseconds. With it, the recorder emits separate chunks of roughly that duration instead of one large blob (MDN, MediaRecorder). That is the whole contract, and it is narrower than most people assume.

The individual blobs are not required to be playable on their own. Only the concatenation of all blobs from a completed recording must be playable. The maintainer of extendable-media-recorder states this plainly in issue #638, quoting the MediaStream Recording spec: “When multiple Blobs are returned (because of timeslice or requestData()), the individual Blobs need not be playable, but the combination of all the Blobs from a completed recording MUST be playable.”

So a timesliced WAV stream has a header on the first chunk and raw payload after it. A timesliced WebM stream has container state spread across chunks. If you ever tried to play chunk three in isolation and got noise, that was not a bug. It was the contract.

Why the migration changes the unit of output

WebCodecs does not hand you a stream of blobs that concatenate into a file. It hands you EncodedAudioChunk objects, each carrying its own timestamp, duration, and type, and it explicitly does not write containers. MDN’s WebCodecs overview says the API “does not provide a built-in way to read EncodedVideoChunk objects from a video file, or write them to a playable video file”; a muxing library has to place the bytes according to the container spec.

That is the category error at the heart of most of these migrations. You are no longer producing fragments of one stream. You are producing self-describing samples and asking a muxer to assemble them. Every glitch class below follows from treating the new output as if it inherited the old concatenation contract.

Layer 1: the encoder queue, and why flush() is not a clock

WebCodecs runs on an asynchronous processing model. configure(), encode(), decode(), and flush() append control messages to a queue; reset() and close() synchronously abort pending work (MDN, WebCodecs overview). The spec describes the same machinery in its codec processing model: a control message queue, a parallel codec work queue, and a codec saturated state that buffers further encode() calls until the implementation catches up.

The tempting move when you want timeslice-like output is to call flush() every N milliseconds and treat the resolved promise as a chunk boundary. Do not. MDN is direct about this: flush() “should generally only be called once all desired work is queued — it is not intended to force progress at regular intervals. Calling it unnecessarily will affect encoder quality and cause decoders to require the next input to be a key frame.”

For audio the key-frame consequence is less dramatic than for video, but the quality consequence is real: you are resetting the encoder’s internal state on a timer. If you need periodic output, drive it from the output callback and watch encodeQueueSize for backpressure. The dequeue event fires when that queue shrinks (MDN, AudioEncoder). That is your pacing signal, not flush().

Layer 2: timestamps and the gap that is not silence

WebCodecs presentation timestamps are integers in microseconds, and the spec is careful about their origin: it “is defined by the media itself and does not necessarily correspond to wall-clock time, nor is it guaranteed to be shared across different media sources or tracks” (W3C WebCodecs). For live capture, the spec notes the timestamp typically reflects capture time relative to a monotonic clock, so preserving relative deltas reproduces capture timing.

That last sentence is the trap. Preserving deltas means a gap in your capture timeline becomes a gap in timestamps. It does not mean the gap becomes silence in the file. The mp4-muxer maintainer addressed exactly this in issue #70: “This is better than having a ‘gap’ in the audio chunks in the final encoded media, since that’s just kinda… weird. And I wouldn’t count on all media players dealing with that in the same way; perhaps some simply contract the silence. So, better to encode silence explicitly!”

In the same thread he explains why a later segment cannot be scheduled backwards: “There’s no way for the encoder to ‘go back in time’, so the best it can do is to start the next audio at 12 seconds.” If your recorder pauses and resumes, or if you stitch two captures, render the silence through an OfflineAudioContext and encode it as real samples. A timestamp gap is a hint to the muxer; encoded silence is a fact in the file.

Layer 3: the AAC decoder-config trap on WebKit

This is the one that produces the most confusing symptom: a file that plays fine in Chromium and is silent in QuickTime or iOS. The AAC codec registration defines the contract. If AudioDecoderConfig.description is present, it “is assumed to be an AudioSpecificConfig” and the bitstream is assumed to be in aac format; if absent, the bitstream is assumed to be adts (W3C AAC codec registration). The same registration defines AacBitstreamFormat with values aac and adts, where aac carries metadata at configuration time and adts carries it in every frame.

A third-party problem report on goodturn.ai describes a failure mode worth knowing about, though I have not independently reproduced it and it is not a maintainer statement. The report says mp4-muxer embeds decoderConfig.description verbatim as the DecoderSpecificInfo inside the esds box. Chromium supplies the bare 2-byte AudioSpecificConfig; WebKit reportedly supplies the CoreAudio “magic cookie,” a full ES_Descriptor beginning with 0x03 and roughly 39 bytes long. The result is an esds double-wrap that decoders misparse, showing up in ffprobe as a garbage sample rate and zero channels, and playing as silence.

The reported fix is to ignore the provided description for AAC and synthesize the 2-byte AAC-LC AudioSpecificConfig from your encoder config, passing it with the first chunk. The report gives the bit layout as (2 << 11) | (freq_index << 7) | (channels << 3), big-endian, with freq_index indexing the standard sample-rate table, and notes that Chromium’s own description is byte-identical to this synthesis. Treat that as a reported workaround to verify on your target engines, not as a spec-mandated procedure. The lesson that does generalize: “it works in Chromium” is not a portability test for WebCodecs output.

Layer 4: the muxer you are probably reading about is deprecated

mp4-muxer’s repository now opens with a deprecation notice: it “has been deprecated in favor of Mediabunny, which entirely supersedes it,” and it “is no longer being maintained and will not receive any new features or bug fixes” (Vanilagy/mp4-muxer). If you are starting this work today, target Mediabunny. If you are debugging an existing mp4-muxer pipeline, the postmortem still applies, but mark that path as legacy in your own notes.

The migration guide lists changes that matter for exactly the glitch classes above. Media characteristics such as channel count and sample rate “must no longer be specified anywhere – they are deduced automatically.” And firstTimestampBehavior “no longer exists. Timestamp behavior is now much more permissive, allowing tracks to start with a non-zero timestamp.” If your old code was papering over a timestamp problem with that option, the migration will surface it.

A reproduction checklist, in diagnostic order

Work top to bottom. Fixing a lower layer first produces a patch that looks right and fails on the next engine.

  1. Confirm the symptom class. Is the file silent, clicking, drifting, or truncated? Silent points at decoder config. Clicking points at chunk boundaries or timestamp discontinuities. Drift points at sample-rate or duration mismatches.
  2. Inspect the container before touching code. Run ffprobe on the output and read the audio stream line: sample rate, channel count, codec. A track reported as 0 channels is a config problem, not an encoder problem.
  3. Log the first chunk’s decoderConfig.description as bytes. Two bytes starting with a plausible AudioSpecificConfig is the Chromium shape. A longer buffer starting 0x03 is the WebKit shape described above.
  4. Log every chunk’s timestamp and duration. Look for non-monotonic values, zero durations, and gaps larger than one frame. A gap is a decision you made, not an accident.
  5. Audit your flush() calls. If any of them fire on a timer, remove them and re-test. Pacing belongs in the output callback and encodeQueueSize.
  6. Check for encoded silence. If your capture can pause, confirm the resumed segment is preceded by real silent samples, not a timestamp jump.
  7. Test on the engines you ship to. At minimum Chromium and WebKit, and on iOS if that is in scope. A file that passes ffprobe on one engine can still fail a CoreAudio parse on another.

When to stay on MediaRecorder

MediaRecorder is Baseline widely available and has been across browsers since April 2021 (MDN, MediaRecorder). AudioEncoder is not: MDN marks it “Limited availability” and notes it “is not Baseline because it does not work in some of the most widely-used browsers” (AudioEncoder).

My recommendation, stated as a recommendation: if you need a single file at the end of a recording and you do not need per-chunk control, stay on MediaRecorder. The timeslice contract is annoying but it is a contract, and the browser handles container state for you. Move to WebCodecs when you need something MediaRecorder cannot give you: per-frame or per-sample processing, custom bitrate adaptation, mixing before encoding, or output that feeds a live pipeline rather than a download. If you make that move, budget for a cross-engine verification step. The AAC description difference is not the only place engines diverge, and the muxer deprecation means the library you pick today should be the maintained one.

Questions I expect

Can I just concatenate the timesliced MediaRecorder blobs and call it done?

Yes, for a completed recording. The spec requires the combination to be playable. It does not require any individual blob to be, so do not build features that assume chunk-level playability.

Is calling flush() per timeslice actually harmful for audio?

MDN’s warning is about encoder quality and decoder key-frame requirements, and it is framed generally rather than audio-specific. The safer reading is that flush() is an end-of-work signal. Use the output callback for pacing.

Do I need to encode silence, or can I leave a timestamp gap?

The muxer maintainer’s guidance is to encode silence explicitly because players may contract or mishandle gaps. That is guidance, not a normative rule, but it is the safer default for files that leave your origin.

Should I still use mp4-muxer?

Not for new work. It is deprecated and unmaintained. The migration guide maps the old API onto Mediabunny, and the MP4 multiplexer there is described as producing “better, more correct MP4 files.”

Why does my file play in Chrome but not on my iPhone?

Check the AAC description bytes first. The reported WebKit magic-cookie behavior is the most likely culprit for a silent track that ffprobe describes as zero channels, but verify with your own logs before changing the encoder config.