@audio/encode-mp4
v1.2.0
Published
Encode PCM audio samples to MP4/M4A (ISO BMFF) — pure JS muxer, remuxer and iTunes-tag writer
Readme
@audio/encode-mp4

Pure-JS MP4/M4A/MOV muxer, remuxer, and iTunes-tag writer
npm install @audio/encode-mp4import mp4 from '@audio/encode-mp4'
import { mux } from '@audio/encode-mp4/mux'
import { remux } from '@audio/encode-mp4/remux'
import { writeMeta } from '@audio/encode-mp4/meta'No wasm, no codec knowledge beyond wrapping pre-encoded access units in ISO/IEC 14496-12 (ISOBMFF) boxes. mux() wraps AAC, ALAC, Opus, FLAC, MP3, or raw PCM samples into a complete .m4a/.mp4 file (moov before mdat, so it streams/plays progressively). remux() swaps or strips the audio track of an existing MP4/MOV without touching video. writeMeta() rewrites iTunes-style tags in place. The default export drives a codec encoder end to end for the m4a umbrella format. decode-mp4 (this ecosystem's demuxer) and decode-aac/meta are the two things every box this package writes is checked against.
mux(track, opts) → Uint8Array
let file = mux({
codec: 'aac', // 'aac' | 'alac' | 'opus' | 'flac' | 'mp3' | 'pcm'
sampleRate: 44100,
channels: 2,
samples, // Uint8Array[] — one access unit per entry (raw AAC AU, no ADTS)
config: audioSpecificConfig,
durations: 1024, // per-AU duration in `timescale` units — a number or per-AU array
priming: 2112, // encoder delay, PCM samples — edit list + iTunSMPB (aac)
}, { meta: { title: 'Song' }, chapters: [{ time: 0, title: 'Intro' }] })| track field | Default | |
|---|---|---|
| codec | — | aac | alac | opus | flac | mp3 | pcm (required) |
| sampleRate, channels | — | required |
| samples | — | Uint8Array[], one access unit per entry (required) |
| durations | codec-specific | AAC 1024; ALAC frame length from the cookie; Opus per-packet (TOC); FLAC per-frame (header); MP3 1152/576; PCM computed from chunk length |
| timescale | sampleRate (Opus: 48000) | sample-table timescale |
| config | — | AAC: AudioSpecificConfig bytes. ALAC: 24-byte ALACSpecificConfig. Opus: { preSkip, outputGain?, channelMappingFamily?, channelMappingTable? }. FLAC: 34-byte STREAMINFO. PCM: { bits, float?, be? }. MP3: unused |
| priming, padding | 0 | encoder delay / trailing pad, PCM samples — written as an edit-list media-time offset, and (AAC) as iTunSMPB |
| bitrate | — | bits/sec, written into esds/btrt |
| opts field | Default | |
|---|---|---|
| brand | 'M4A ' | 'M4A ' | 'isom' | 'mp42' | 'qt ' — qt writes sowt/in24/fl32 QuickTime PCM atoms instead of ipcm/fpcm |
| meta | — | same keys writeMeta reads back — see below |
| chapters | — | [{ time (s), title }] — Nero chpl |
| creationTime | now | Date |
Sample tables are compact: stts run-length compressed, stsz uses the constant-size shortcut when every AU is the same length, stsc/stco chunk every ~1s or 128 AUs (whichever comes first), co64 only when an offset would exceed 4GB. Everything is written into one preallocated, growable buffer — no per-box array spreads — so muxing a 1-hour AAC track (~155k AUs) takes ~12ms.
remux(src, audio, opts) → Uint8Array
let strippedVideo = remux(videoBytes, null) // drop the audio track
let dubbed = remux(videoBytes, { codec: 'opus', ... }) // replace it (track object, mux() shape)
let dubbed2 = remux(videoBytes, mux({ codec: 'alac', ... })) // or a Uint8Array mux() already producedVideo (and any other non-audio track — subtitles, timecode) is copied through unchanged; only its stco/co64 chunk offsets are rewritten (stco→co64 if the new layout pushes an offset past 4GB). A single fresh mdat interleaves video and audio chunks by presentation time so the result still plays progressively. Handles 64-bit box sizes, uuid boxes, mdat before moov, and multiple mdats. Throws a clear error on a fragmented MP4 (moof) — this is a whole-file muxer, not a fragmented-MP4 writer.
writeMeta(bytes, { meta, chapters }) → Uint8Array
Rewrites moov/udta/meta/ilst (+ chpl for chapters) in an existing MP4/M4A. Moov is kept first — not relocated to the end — so when it grows, every track's chunk offsets are shifted by the same delta (only udta's length changes; nothing else in moov does). meta keys are the exact inverse of decode-aac/meta's parseMeta: title artist album albumartist composer genre year track disc comment lyrics copyright bpm key isrc publisher software pictures. key/isrc/publisher are written as ---- freeform atoms (the com.apple.iTunes convention documented by mutagen/AtomicParsley) — no shipped parser in this ecosystem reads them back yet, so they're write-only.
mp4(opts) → { encode(channels), flush(), free() }
let enc = await mp4({ sampleRate: 44100, channels: 2, codec: 'flac' })
enc.encode(channelData) // buffers; always returns an empty Uint8Array
let file = await enc.flush()
enc.free()Drives @audio/encode-aac (AAC, browser-only — needs AudioEncoder), @audio/encode-opus/core (Opus), @audio/encode-flac (FLAC), @audio/encode-mp3 (MP3), or plain interleaving (PCM), then mux()s the result. Whole-file container: encode() buffers, flush() returns the complete file (same shape as @audio/encode-qoa). codec defaults to 'aac' when globalThis.AudioEncoder exists (browser), else 'flac'. AAC's encoder delay isn't reported by WebCodecs, so priming defaults to 2112 samples — the standard AAC-LC value every LC encoder from FAAC to FDK-AAC uses; override it if you know better. encode() split into any number of chunks produces byte-identical PCM output and equivalent (SNR-consistent) lossy output to a single whole-buffer call.
Not done here: mux() doesn't trim MP3/Opus encoder delay and padding for you the way mp4() does (from LAME's Info tag, from the Opus encoder's lookahead): pass priming/padding yourself if you need bit-exact gapless MP3/Opus from mux() (Opus's own pre-skip is handled automatically by mux(), since it's intrinsic to every Opus packet stream, not an encoder-specific unknown). stco→co64 upgrade during writeMeta isn't implemented — it throws instead of writing a wrong offset.
Use when: writing .m4a/.mp4 output in the browser or Node without wasm; swapping the audio track of a video file (dubbing, replacing a soundtrack) without re-encoding video; tagging M4A files.
Spec references
- ISO/IEC 14496-12 — ISO Base Media File Format (box structure, sample tables, edit lists)
- ISO/IEC 14496-14 — MP4 file format
- ISO/IEC 14496-1 §8.3 —
esdsdescriptors (ES_Descriptor/DecoderConfigDescriptor/SLConfigDescriptor) - ISO/IEC 23003-5 — PCM in ISOBMFF (
ipcm/fpcm/pcmC) - Opus in ISOBMFF —
Opussample entry +dOps - FLAC in ISOBMFF —
fLaCsample entry +dfLa - QuickTime File Format — sound sample description versions 0/1/2,
sowt/in24/fl32/enda - iTunes metadata atoms — mutagen.mp4, AtomicParsley docs
- Nero chapters (
chpl) — format per ffmpeglibavformat/movenc.cmov_write_chpl_tag
Streaming output
stream: true writes a fragmented file (ISO/IEC 14496-12 §8.8) as it encodes: ftyp + moov (sample entry, empty tables, mvex, tags and chapters) once the first units exist, then moof + mdat per second of media. Only the current fragment's units are held. head() after flush() returns the init segment rebuilt with what only the end knows (FLAC's STREAMINFO), or null.
License
ॐ
