deniz.in

Markets

Weather

Loading weather

· via dev.to (home feed)

iOS Safari fails .mov audio over a two-byte version field in the container

A dev.to write-up traces 100% .mov decode failures on iOS Safari to a two-byte version field in QuickTime's audio sample entry, fixed by rebuilding the container in the browser without re-encoding.

iOS Safari fails .mov audio over a two-byte version field in the container

A failure pattern too clean to ignore

A browser-based transcription tool that runs Whisper locally through transformers.js noticed something odd in its analytics: every single .mov file coming from mobile users failed to decode, while desktop sessions had never logged one .mov failure. According to a write-up on dev.to, the author reproduced the problem on an iPhone 17 Pro simulator running iOS 18.7 with Safari 26.5. The identical AAC audio track, encoded the same way, decoded without issue as an .mp4 but threw an EncodingError from AudioContext.decodeAudioData when wrapped as a .mov. Chromium handled both files. That ruled out the codec and pointed straight at the container.

Two bytes inside moov

The obvious suspect — the four-character brand in the ftyp box, qt versus isom — turned out to be innocent. Rewriting the brand changed nothing, because Safari does not consult it. The real difference sits deeper in the box tree, at moov, then trak, mdia, minf, stbl and finally stsd, the sample description that tells a decoder how the audio was encoded.

Both files carry an mp4a entry, but not the same one. QuickTime marks its entry as version 1, writes a compression ID of -2, appends 16 bytes of version-1 extension data, nests the esds elementary stream descriptor inside a wave box, and adds a chan channel-layout box. Standard MP4 writes version 0 with a compression ID of 0 and places esds directly under mp4a.

The author's conclusion: iOS Safari's decodeAudioData only accepts version 0 audio sample entries, while Chromium accepts both. The version field is a uint16, so two bytes decide whether the file plays at all — and explain why desktop users never saw the bug.

Rebuilding the container instead of re-encoding

Because the AAC bitstream is already valid, nothing needs transcoding. The fix scans top-level box headers to locate moov without reading mdat, parses the audio track's sample tables (stsd, stts, stsc, stsz, stco or co64), copies the audio samples out through an 8 MB sliding window, and writes a fresh audio-only MP4 whose sample description is pinned to version 0 before handing it to the browser's native decoder.

This sidesteps two heavyweight alternatives: no multi-megabyte ffmpeg.wasm download to fix a container-level issue, and no WebCodecs, which would have inherited AudioDecoder's iOS 17+ version floor.

Parsing has traps worth noting. The esds lookup must scan all siblings and then recurse, or a wave-nested descriptor can shadow the one you want. Child-box offsets also shift by entry version — 16 extra bytes for version 1, 36 for version 2, which carries its real sample rate in a float64. Misjudge those offsets and the parser starts reading garbage as box headers.

The memory bug discovered along the way

The old code read the entire file into memory, and mobile uploads in the tool's logs averaged 161.9 MB — roughly 99% of it video frames irrelevant to a transcript. Decoding that into an equally long PCM buffer risked tripping iOS Safari's memory limits. Header-only scanning plus the sliding window caps peak usage at around 8 MB plus the audio itself.

The reported numbers: a 158.6 MB, 15-minute .mov that failed natively yielded a 13.88 MB audio track in 378 ms and decoded in 476 ms, for 854 ms end to end. The output was verified as non-silent and length-accurate (900.023 s mapped to 900.1 s). Smaller test files extracted in single-digit milliseconds, and .m4a and .mp4 inputs showed no regression.

A fallback-first design

Every step returns null the moment reality stops matching assumptions — fragmented MP4, a PCM track inside a .mov, or a .mov with no audio track at all — and the caller falls back to the original direct-decode path. The guiding rule: a module built to rescue guaranteed failures must never make a working case worse.

Why it matters

Container tolerance is far less documented than codec support, so when a format works in one browser and fails in another, the container deserves suspicion before the codec. The post is also a useful counterweight to the ffmpeg.wasm reflex: when the bitstream is already valid, the job is container surgery, not media processing. And reading a whole file just to decode it is a bug on mobile even when it does not crash today. For TranscriptSnap, the tool in question, Whisper runs entirely on-device — which is exactly why a Safari container quirk became the application's problem instead of a server's, and why any browser-native audio or video tooling can expect the same class of bugs. After the fix landed, the team removed its "large .mov files may fail" warning banner, since file size no longer correlates with any real risk.

  • #ios-safari
  • #web-audio
  • #mp4
  • #container-format
  • #browser-compatibility