diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index b2b2155..31b076b 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -59,7 +59,7 @@ jobs:
- name: Upload build artifacts
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: gh release upload ${{ github.event.release.tag_name }} dist/bundles/mediabunny.cjs dist/bundles/mediabunny.min.cjs dist/bundles/mediabunny.mjs dist/bundles/mediabunny.min.mjs dist/mediabunny.d.ts packages/mp3-encoder/dist/bundles/mediabunny-mp3-encoder.js packages/mp3-encoder/dist/bundles/mediabunny-mp3-encoder.min.js packages/mp3-encoder/dist/bundles/mediabunny-mp3-encoder.mjs packages/mp3-encoder/dist/bundles/mediabunny-mp3-encoder.min.mjs packages/mp3-encoder/dist/mediabunny-mp3-encoder.d.ts
+ run: gh release upload ${{ github.event.release.tag_name }} dist/bundles/mediabunny.cjs dist/bundles/mediabunny.min.cjs dist/bundles/mediabunny.mjs dist/bundles/mediabunny.min.mjs dist/mediabunny.d.ts packages/mp3-encoder/dist/bundles/mediabunny-mp3-encoder.js packages/mp3-encoder/dist/bundles/mediabunny-mp3-encoder.min.js packages/mp3-encoder/dist/bundles/mediabunny-mp3-encoder.mjs packages/mp3-encoder/dist/bundles/mediabunny-mp3-encoder.min.mjs packages/mp3-encoder/dist/mediabunny-mp3-encoder.d.ts packages/ac3/dist/bundles/mediabunny-ac3.js packages/ac3/dist/bundles/mediabunny-ac3.min.js packages/ac3/dist/bundles/mediabunny-ac3.mjs packages/ac3/dist/bundles/mediabunny-ac3.min.mjs packages/ac3/dist/mediabunny-ac3.d.ts
- name: Publish Mediabunny to npm
run: npm publish --access public
diff --git a/.gitignore b/.gitignore
index a359208..289f126 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,4 +6,5 @@ node_modules
/docs/api
*.tsbuildinfo
-packages/mp3-encoder/dist
\ No newline at end of file
+packages/mp3-encoder/dist
+packages/ac3/dist
\ No newline at end of file
diff --git a/dev/convert.html b/dev/convert.html
index 3778784..0609f8d 100644
--- a/dev/convert.html
+++ b/dev/convert.html
@@ -2,9 +2,11 @@
+
+
+
+
+
+# AAC codec registration
+
+## Description
+
+The Advanced Audio Coding (AAC) audio codec, specified in [ISO/IEC 14496-3](https://www.iso.org/standard/76383.html).
+
+An AAC bitstream can have either of two formats:
+- _AAC_ (raw), where packets contain raw AAC frames (syntax element `raw_data_block()`) as defined in [ISO/IEC 14496-3](https://www.iso.org/standard/76383.html) Section 4.4.2.1. Here, codec metadata is provided out-of-band.
+- _ADTS_, where packets contain ADTS frames as defined in [ISO/IEC 14496-3](https://www.iso.org/standard/76383.html) Section 1.A.3.2. Here, codec metadata is provided in-band in each ADTS frame header.
+
+All packets within the bitstream must have the same format.
+
+## Codec ID
+
+```ts
+'aac'
+```
+
+## `EncodedPacket` data
+
+If the bitstream is in the _AAC_ (raw) format, the packet's data must be a raw AAC frame (syntax element `raw_data_block()`) as defined in [ISO/IEC 14496-3](https://www.iso.org/standard/76383.html) Section 4.4.2.1.
+
+If the bitstream is in the _ADTS_ format, the packet's data must be an ADTS frame as defined in [ISO/IEC 14496-3](https://www.iso.org/standard/76383.html) Section 1.A.3.2.
+
+## `EncodedPacket` type
+
+The packet's type is always `'key'`.
+
+## `AudioDecoderConfig` codec string
+
+The following fully qualified codec strings are recognized:
+
+- `'mp4a.40.2'` — MPEG-4 AAC-LC
+- `'mp4a.40.02'` — MPEG-4 AAC-LC (leading zero for Aud-OTI compatibility)
+- `'mp4a.40.5'` — MPEG-4 HE-AAC v1 (AAC-LC + SBR)
+- `'mp4a.40.05'` — MPEG-4 HE-AAC v1 (leading zero for Aud-OTI compatibility)
+- `'mp4a.40.29'` — MPEG-4 HE-AAC v2 (AAC-LC + SBR + PS)
+- `'mp4a.67'` — MPEG-2 AAC-LC
+
+## `AudioDecoderConfig` description
+
+If the bitstream is in the _AAC_ (raw) format, `description` must be an `AudioSpecificConfig` as defined in [ISO/IEC 14496-3](https://www.iso.org/standard/76383.html) Section 1.6.2.1.
+
+If the bitstream is in the _ADTS_ format, `description` must be undefined.
diff --git a/docs/codec-registry/ac3.md b/docs/codec-registry/ac3.md
new file mode 100644
index 0000000..ea0ea54
--- /dev/null
+++ b/docs/codec-registry/ac3.md
@@ -0,0 +1,35 @@
+
+
+
+
+# AC-3 codec registration
+
+## Description
+
+The Dolby Digital (AC-3) audio codec, specified in [ETSI TS 102 366](https://www.etsi.org/deliver/etsi_ts/102300_102399/102366/01.04.01_60/ts_102366v010401p.pdf).
+
+## Codec ID
+
+```ts
+'ac3'
+```
+
+## `EncodedPacket` data
+
+The packet's data must be a sync frame (syntactic element `syncframe()`) as defined in Section 4.3 of [ETSI TS 102 366](https://www.etsi.org/deliver/etsi_ts/102300_102399/102366/01.04.01_60/ts_102366v010401p.pdf), beginning with the sync word `0x0B77`.
+
+## `EncodedPacket` type
+
+The packet's type is always `'key'`.
+
+## `AudioDecoderConfig` codec string
+
+```ts
+'ac-3'
+```
+
+## `AudioDecoderConfig` description
+
+`description` is not used for this codec.
diff --git a/docs/codec-registry/alaw.md b/docs/codec-registry/alaw.md
new file mode 100644
index 0000000..40a5f3d
--- /dev/null
+++ b/docs/codec-registry/alaw.md
@@ -0,0 +1,35 @@
+
+
+
+
+# A-law PCM codec registration
+
+## Description
+
+The A-law companded PCM audio codec, specified in [ITU-T G.711](https://www.itu.int/rec/T-REC-G.711) Tables 1a and 1b.
+
+## Codec ID
+
+```ts
+'alaw'
+```
+
+## `EncodedPacket` data
+
+The packet's data must be a sequence of bytes of arbitrary length (divisible by the channel count), where each byte is an A-law encoded PCM sample. For multichannel audio, samples from different channels are interleaved.
+
+## `EncodedPacket` type
+
+The packet's type is always `'key'`.
+
+## `AudioDecoderConfig` codec string
+
+```ts
+'alaw'
+```
+
+## `AudioDecoderConfig` description
+
+`description` is not used for this codec.
diff --git a/docs/codec-registry/av1.md b/docs/codec-registry/av1.md
new file mode 100644
index 0000000..a0fa961
--- /dev/null
+++ b/docs/codec-registry/av1.md
@@ -0,0 +1,33 @@
+
+
+
+
+# AV1 codec registration
+
+## Description
+
+The AOMedia Video 1 (AV1) video codec, specified in the [AV1 Bitstream & Decoding Process Specification](https://aomediacodec.github.io/av1-spec/).
+
+## Codec ID
+
+```ts
+'av1'
+```
+
+## `EncodedPacket` data
+
+The packet's data must comply with the low-overhead bitstream format as defined in Section 5 of the [AV1 Bitstream & Decoding Process Specification](https://aomediacodec.github.io/av1-spec/).
+
+## `EncodedPacket` type
+
+If the packet's type is `'key'`, then the packet is expected to contain a frame with `frame_type` of `KEY_FRAME`, as defined in Section 6.8.2 of the [AV1 Bitstream & Decoding Process Specification](https://aomediacodec.github.io/av1-spec/).
+
+## `VideoDecoderConfig` codec string
+
+The full codec string begins with the prefix `'av01.'`, with a variable-length suffix as specified in Section 5 of the [AV1 Codec ISO Media File Format Binding](https://aomediacodec.github.io/av1-isobmff/).
+
+## `VideoDecoderConfig` description
+
+`description` is not used for this codec.
diff --git a/docs/codec-registry/avc.md b/docs/codec-registry/avc.md
new file mode 100644
index 0000000..620be2e
--- /dev/null
+++ b/docs/codec-registry/avc.md
@@ -0,0 +1,41 @@
+
+
+
+
+# AVC (H.264) codec registration
+
+## Description
+
+The Advanced Video Coding (H.264) video codec, specified in [Rec. ITU-T H.264](https://www.itu.int/rec/T-REC-H.264) / [ISO/IEC 14496-10](https://www.iso.org/standard/87574.html).
+
+An AVC bitstream can have either of two formats:
+- _Canonical_ (length-prefixed), as defined in [ISO/IEC 14496-15](https://www.iso.org/standard/89118.html) Section 5.3.2. Here, video parameter sets (SPS/PPS) are provided out-of-band.
+- _Annex B_, as defined in [Rec. ITU-T H.264](https://www.itu.int/rec/T-REC-H.264) Annex B. Here, video parameter sets (SPS/PPS) must be provided in-band in the respective NALUs.
+
+All packets within the bitstream must have the same format.
+
+## Codec ID
+
+```ts
+'avc'
+```
+
+## `EncodedPacket` data
+
+The packet's data must be an access unit as defined in [Rec. ITU-T H.264](https://www.itu.int/rec/T-REC-H.264) Section 7.4.1.2, in either _canonical_ or _Annex B_ format.
+
+## `EncodedPacket` type
+
+If the packet's type is `'key'`, then the packet is expected to contain a primary coded picture from which decoding can begin. Additionally, if the bitstream's format is _Annex B_, then this packet is also expected to contain the necessary video parameter sets to initialize the decoder.
+
+## `VideoDecoderConfig` codec string
+
+The full codec string begins with the prefix `'avc1.'` or `'avc3.'`, with a suffix of 6 characters as described respectively in Section 3.4 of [RFC 6381](https://www.rfc-editor.org/rfc/rfc6381) and Section 5.4.1 of [ISO/IEC 14496-15](https://www.iso.org/standard/89118.html).
+
+## `VideoDecoderConfig` description
+
+If the bitstream is in the _canonical_ (length-prefixed) format, `description` must be an `AVCDecoderConfigurationRecord` as defined in [ISO/IEC 14496-15](https://www.iso.org/standard/89118.html) Section 5.3.3.1.
+
+If the bitstream is in the _Annex B_ format, `description` must be undefined.
\ No newline at end of file
diff --git a/docs/codec-registry/eac3.md b/docs/codec-registry/eac3.md
new file mode 100644
index 0000000..b2910e9
--- /dev/null
+++ b/docs/codec-registry/eac3.md
@@ -0,0 +1,35 @@
+
+
+
+
+# E-AC-3 codec registration
+
+## Description
+
+The Dolby Digital Plus (E-AC-3) audio codec, specified in [ETSI TS 102 366](https://www.etsi.org/deliver/etsi_ts/102300_102399/102366/01.04.01_60/ts_102366v010401p.pdf).
+
+## Codec ID
+
+```ts
+'eac3'
+```
+
+## `EncodedPacket` data
+
+The packet's data must be a sync frame as defined in Section E.1.2.0 of [ETSI TS 102 366](https://www.etsi.org/deliver/etsi_ts/102300_102399/102366/01.04.01_60/ts_102366v010401p.pdf), beginning with the sync word `0x0B77`.
+
+## `EncodedPacket` type
+
+The packet's type is always `'key'`.
+
+## `AudioDecoderConfig` codec string
+
+```ts
+'ec-3'
+```
+
+## `AudioDecoderConfig` description
+
+`description` is not used for this codec.
diff --git a/docs/codec-registry/flac.md b/docs/codec-registry/flac.md
new file mode 100644
index 0000000..f6ee25c
--- /dev/null
+++ b/docs/codec-registry/flac.md
@@ -0,0 +1,35 @@
+
+
+
+
+# FLAC codec registration
+
+## Description
+
+The Free Lossless Audio Codec (FLAC), specified in the [FLAC Format Specification](https://xiph.org/flac/format.html).
+
+## Codec ID
+
+```ts
+'flac'
+```
+
+## `EncodedPacket` data
+
+The packet's data must be a FLAC frame as described in the [FLAC Format Specification](https://xiph.org/flac/format.html).
+
+## `EncodedPacket` type
+
+The packet's type is always `'key'`.
+
+## `AudioDecoderConfig` codec string
+
+```ts
+'flac'
+```
+
+## `AudioDecoderConfig` description
+
+`description` must contain the bytes `0x66 0x4C 0x61 0x43` (the ASCII string `'fLaC'`), followed by a `STREAMINFO` metadata block as defined in Section 7 of the [FLAC Format Specification](https://xiph.org/flac/format.html).
diff --git a/docs/codec-registry/hevc.md b/docs/codec-registry/hevc.md
new file mode 100644
index 0000000..901f2c5
--- /dev/null
+++ b/docs/codec-registry/hevc.md
@@ -0,0 +1,41 @@
+
+
+
+
+# HEVC (H.265) codec registration
+
+## Description
+
+The High Efficiency Video Coding (H.265) video codec, specified in [Rec. ITU-T H.265](https://www.itu.int/rec/T-REC-H.265) / [ISO/IEC 23008-2](https://www.iso.org/standard/75484.html).
+
+An HEVC bitstream can have either of two formats:
+- _Canonical_ (length-prefixed), as defined in [ISO/IEC 14496-15](https://www.iso.org/standard/89118.html) Section 8.3.2. Here, video parameter sets (VPS/SPS/PPS) are provided out-of-band.
+- _Annex B_, as defined in [Rec. ITU-T H.265](https://www.itu.int/rec/T-REC-H.265) Annex B. Here, video parameter sets (VPS/SPS/PPS) must be provided in-band in the respective NALUs.
+
+All packets within the bitstream must have the same format.
+
+## Codec ID
+
+```ts
+'hevc'
+```
+
+## `EncodedPacket` data
+
+The packet's data must be an access unit as defined in [Rec. ITU-T H.265](https://www.itu.int/rec/T-REC-H.265) Section 7.4.2.4, containing exactly one base layer coded picture, in either _canonical_ or _Annex B_ format.
+
+## `EncodedPacket` type
+
+If the packet's type is `'key'`, then the packet is expected to contain an IDR, CRA, or BLA picture. Additionally, if the bitstream's format is _Annex B_, then this packet is also expected to contain the necessary video parameter sets to initialize the decoder.
+
+## `VideoDecoderConfig` codec string
+
+The full codec string begins with the prefix `'hev1.'` or `'hvc1.'`, with a variable-length suffix of four dot-separated fields as specified in Section E.3 of [ISO/IEC 14496-15](https://www.iso.org/standard/89118.html).
+
+## `VideoDecoderConfig` description
+
+If the bitstream is in the _canonical_ (length-prefixed) format, `description` must be an `HEVCDecoderConfigurationRecord` as defined in [ISO/IEC 14496-15](https://www.iso.org/standard/89118.html) Section 8.3.3.1.
+
+If the bitstream is in the _Annex B_ format, `description` must be undefined.
diff --git a/docs/codec-registry/mp3.md b/docs/codec-registry/mp3.md
new file mode 100644
index 0000000..1f74a09
--- /dev/null
+++ b/docs/codec-registry/mp3.md
@@ -0,0 +1,35 @@
+
+
+
+
+# MP3 codec registration
+
+## Description
+
+The MP3 audio codec (MPEG-1/2 Audio Layer III), specified in [ISO/IEC 13818-3](https://www.iso.org/standard/26797.html).
+
+## Codec ID
+
+```ts
+'mp3'
+```
+
+## `EncodedPacket` data
+
+The packet's data must be an MP3 frame as described in Section 2.4.2.2 of [ISO/IEC 13818-3](https://www.iso.org/standard/26797.html).
+
+## `EncodedPacket` type
+
+The packet's type is always `'key'`.
+
+## `AudioDecoderConfig` codec string
+
+```ts
+'mp3'
+```
+
+## `AudioDecoderConfig` description
+
+`description` is not used for this codec.
diff --git a/docs/codec-registry/opus.md b/docs/codec-registry/opus.md
new file mode 100644
index 0000000..a091b9f
--- /dev/null
+++ b/docs/codec-registry/opus.md
@@ -0,0 +1,35 @@
+
+
+
+
+# Opus codec registration
+
+## Description
+
+The Opus audio codec, specified in [RFC 6716](https://www.rfc-editor.org/rfc/rfc6716).
+
+## Codec ID
+
+```ts
+'opus'
+```
+
+## `EncodedPacket` data
+
+The packet's data must be an Opus packet as described in Section 3 of [RFC 6716](https://www.rfc-editor.org/rfc/rfc6716).
+
+## `EncodedPacket` type
+
+The packet's type is always `'key'`.
+
+## `AudioDecoderConfig` codec string
+
+```ts
+'opus'
+```
+
+## `AudioDecoderConfig` description
+
+If present, `description` must be an Identification Header as defined in Section 5.1 of [RFC 7845](https://www.rfc-editor.org/rfc/rfc7845).
diff --git a/docs/codec-registry/overview.md b/docs/codec-registry/overview.md
new file mode 100644
index 0000000..691a8a9
--- /dev/null
+++ b/docs/codec-registry/overview.md
@@ -0,0 +1,26 @@
+# Mediabunny Codec Registry
+
+The Mediabunny Codec Registry formalizes the precise definitions of all video and audio codecs supported by Mediabunny. More specifically, for any given codec, it describes the format that `EncodedPacket`, `VideoDecoderConfig` and `AudioDecoderConfig` must adhere to. All packets coming out of or going into Mediabunny are expected to adhere to this registry.
+
+The registry is an extension of the [WebCodecs Codec Registry](https://www.w3.org/TR/webcodecs-codec-registry/). Mediabunny's registry matches that of WebCodecs for all codecs supported by both.
+
+## Video codecs
+
+- [AVC (H.264)](./avc)
+- [HEVC (H.265)](./hevc)
+- [VP8](./vp8)
+- [VP9](./vp9)
+- [AV1](./av1)
+
+## Audio codecs
+
+- [AAC](./aac)
+- [Opus](./opus)
+- [MP3](./mp3)
+- [Vorbis](./vorbis)
+- [FLAC](./flac)
+- [AC-3](./ac3)
+- [E-AC-3](./eac3)
+- [Linear PCM](./pcm)
+- [μ-law PCM](./ulaw)
+- [A-law PCM](./alaw)
diff --git a/docs/codec-registry/pcm.md b/docs/codec-registry/pcm.md
new file mode 100644
index 0000000..a06a9b5
--- /dev/null
+++ b/docs/codec-registry/pcm.md
@@ -0,0 +1,44 @@
+
+
+
+
+# Linear PCM codecs registration
+
+## Description
+
+A family of linear pulse-code modulation (PCM) audio codecs of various bit depths and byte orders.
+
+## Codec IDs
+
+| Codec ID | Description |
+| --- | --- |
+| `'pcm-u8'` | Unsigned 8-bit integer |
+| `'pcm-s8'` | Signed 8-bit integer |
+| `'pcm-s16'` | Signed 16-bit integer, little-endian |
+| `'pcm-s16be'` | Signed 16-bit integer, big-endian |
+| `'pcm-s24'` | Signed 24-bit integer, little-endian |
+| `'pcm-s24be'` | Signed 24-bit integer, big-endian |
+| `'pcm-s32'` | Signed 32-bit integer, little-endian |
+| `'pcm-s32be'` | Signed 32-bit integer, big-endian |
+| `'pcm-f32'` | 32-bit float, little-endian |
+| `'pcm-f32be'` | 32-bit float, big-endian |
+| `'pcm-f64'` | 64-bit float, little-endian |
+| `'pcm-f64be'` | 64-bit float, big-endian |
+
+## `EncodedPacket` data
+
+The packet's data must be a sequence of bytes of arbitrary length (divisible by the size of one frame), with each sample occupying the number of bits defined by the codec ID. For multichannel audio, samples from different channels are interleaved.
+
+## `EncodedPacket` type
+
+The packet's type is always `'key'`.
+
+## `AudioDecoderConfig` codec string
+
+The codec string is the same as the codec ID (e.g. `'pcm-s16'`, `'pcm-f32'`).
+
+## `AudioDecoderConfig` description
+
+`description` is not used for this codec.
diff --git a/docs/codec-registry/ulaw.md b/docs/codec-registry/ulaw.md
new file mode 100644
index 0000000..beb1146
--- /dev/null
+++ b/docs/codec-registry/ulaw.md
@@ -0,0 +1,35 @@
+
+
+
+
+# μ-law PCM codec registration
+
+## Description
+
+The μ-law companded PCM audio codec, specified in [ITU-T G.711](https://www.itu.int/rec/T-REC-G.711) Tables 2a and 2b.
+
+## Codec ID
+
+```ts
+'ulaw'
+```
+
+## `EncodedPacket` data
+
+The packet's data must be a sequence of bytes of arbitrary length (divisible by the channel count), where each byte is a μ-law encoded PCM sample. For multichannel audio, samples from different channels are interleaved.
+
+## `EncodedPacket` type
+
+The packet's type is always `'key'`.
+
+## `AudioDecoderConfig` codec string
+
+```ts
+'ulaw'
+```
+
+## `AudioDecoderConfig` description
+
+`description` is not used for this codec.
diff --git a/docs/codec-registry/vorbis.md b/docs/codec-registry/vorbis.md
new file mode 100644
index 0000000..9867d9c
--- /dev/null
+++ b/docs/codec-registry/vorbis.md
@@ -0,0 +1,35 @@
+
+
+
+
+# Vorbis codec registration
+
+## Description
+
+The Vorbis audio codec, specified in the [Vorbis I Specification](https://xiph.org/vorbis/doc/Vorbis_I_spec.html).
+
+## Codec ID
+
+```ts
+'vorbis'
+```
+
+## `EncodedPacket` data
+
+The packet's data must be an audio packet as described in Section 4.3 of the [Vorbis I Specification](https://xiph.org/vorbis/doc/Vorbis_I_spec.html).
+
+## `EncodedPacket` type
+
+The packet's type is always `'key'`.
+
+## `AudioDecoderConfig` codec string
+
+```ts
+'vorbis'
+```
+
+## `AudioDecoderConfig` description
+
+`description` must contain Vorbis codec setup data in Xiph extradata format: the `page_segments` field, followed by the `segment_table` field, followed by the three Vorbis header packets (identification header, comments header, and setup header) as defined in Section 4.2 of the [Vorbis I Specification](https://xiph.org/vorbis/doc/Vorbis_I_spec.html).
diff --git a/docs/codec-registry/vp8.md b/docs/codec-registry/vp8.md
new file mode 100644
index 0000000..13e8c90
--- /dev/null
+++ b/docs/codec-registry/vp8.md
@@ -0,0 +1,35 @@
+
+
+
+
+# VP8 codec registration
+
+## Description
+
+The VP8 video codec, specified in [RFC 6386](https://www.rfc-editor.org/rfc/rfc6386).
+
+## Codec ID
+
+```ts
+'vp8'
+```
+
+## `EncodedPacket` data
+
+The packet's data must be a frame as described in Section 4 and Annex A of [RFC 6386](https://www.rfc-editor.org/rfc/rfc6386).
+
+## `EncodedPacket` type
+
+If the packet's type is `'key'`, then the packet is expected to contain a frame where `key_frame` is `true`, as defined in Section 19.1 of [RFC 6386](https://www.rfc-editor.org/rfc/rfc6386).
+
+## `VideoDecoderConfig` codec string
+
+```ts
+'vp8'
+```
+
+## `VideoDecoderConfig` description
+
+`description` is not used for this codec.
diff --git a/docs/codec-registry/vp9.md b/docs/codec-registry/vp9.md
new file mode 100644
index 0000000..e3d98b9
--- /dev/null
+++ b/docs/codec-registry/vp9.md
@@ -0,0 +1,33 @@
+
+
+
+
+# VP9 codec registration
+
+## Description
+
+The VP9 video codec, specified in the [VP9 Bitstream & Decoding Process Specification](https://storage.googleapis.com/downloads.webmproject.org/docs/vp9/vp9-bitstream-specification-v0.6-20160331-draft.pdf).
+
+## Codec ID
+
+```ts
+'vp9'
+```
+
+## `EncodedPacket` data
+
+The packet's data must be a frame as described in Section 6 of the [VP9 Bitstream & Decoding Process Specification](https://storage.googleapis.com/downloads.webmproject.org/docs/vp9/vp9-bitstream-specification-v0.6-20160331-draft.pdf).
+
+## `EncodedPacket` type
+
+If the packet's type is `'key'`, then the packet is expected to contain a frame with `frame_type` of `KEY_FRAME`, as defined in Section 7.2 of the [VP9 Bitstream & Decoding Process Specification](https://storage.googleapis.com/downloads.webmproject.org/docs/vp9/vp9-bitstream-specification-v0.6-20160331-draft.pdf).
+
+## `VideoDecoderConfig` codec string
+
+The full codec string begins with the prefix `'vp09.'`, with a variable-length suffix as specified in the [VP Codec ISO Media File Format Binding](https://www.webmproject.org/vp9/mp4/).
+
+## `VideoDecoderConfig` description
+
+`description` is not used for this codec.
diff --git a/docs/guide/converting-media-files.md b/docs/guide/converting-media-files.md
index 7c92198..b2a43aa 100644
--- a/docs/guide/converting-media-files.md
+++ b/docs/guide/converting-media-files.md
@@ -367,6 +367,19 @@ If only `start` is set, the clip will run until the end of the input file. If on
Note that when using the trimming defaults, the resulting media file will always begin at timestamp 0. If your input file has a start time offset (like is common with MPEG-TS files) and you want to retain that, use `trim: { start: 0 }` to ensure timestamps don't get shifted.
+---
+
+You can even use negative trimming values to offset the start of the media:
+```ts
+const conversion = await Conversion.init({
+ // ...
+ trim: {
+ start: -2, // Two seconds of no media data (freeze frame / silence) at the start
+ },
+ // ...
+});
+```
+
## Metadata tags
By default, any [descriptive metadata tags](../api/MetadataTags.md) of the input will be copied to the output. If you want to further control the metadata tags written to the output, you can use the `tags` options:
diff --git a/docs/guide/extensions/ac3.md b/docs/guide/extensions/ac3.md
new file mode 100644
index 0000000..e3167e5
--- /dev/null
+++ b/docs/guide/extensions/ac3.md
@@ -0,0 +1,33 @@
+# @mediabunny/ac3
+
+Browsers have no support for AC-3 (Dolby Digital) or E-AC-3 (Dolby Digital Plus) in their WebCodecs implementations. This extension package provides both a decoder and encoder for use with Mediabunny, allowing you to decode and encode these codecs directly in the browser. It is implemented using Mediabunny's [custom coder API](../supported-formats-and-codecs#custom-coders) and uses a fast, size-optimized WASM build of [FFmpeg](https://ffmpeg.org/)'s AC-3 and E-AC-3 coders under the hood.
+
+
+ GitHub page
+
+
+
+## Installation
+
+This library peer-depends on Mediabunny. Install both using npm:
+```bash
+npm install mediabunny @mediabunny/ac3
+```
+
+Alternatively, directly include them using a script tag:
+```html
+
+
+```
+
+This will expose the global objects `Mediabunny` and `MediabunnyAc3`. Use `mediabunny-ac3.d.ts` to provide types for these globals. You can download the built distribution files from the [releases page](https://github.com/Vanilagy/mediabunny/releases).
+
+## Usage
+
+```ts
+import { registerAc3Decoder, registerAc3Encoder } from '@mediabunny/ac3';
+
+registerAc3Decoder();
+registerAc3Encoder();
+```
+That's it - Mediabunny now uses the registered AC-3/E-AC-3 decoder and encoder automatically.
diff --git a/docs/guide/media-sources.md b/docs/guide/media-sources.md
index 31caa1f..3c0596b 100644
--- a/docs/guide/media-sources.md
+++ b/docs/guide/media-sources.md
@@ -74,7 +74,7 @@ type VideoEncodingConfig = {
- `bitrateMode`: Can be used to control constant vs. variable bitrate.
- `latencyMode`: The latency mode as specified by the WebCodecs API. Browsers default to `quality`. Media stream-driven video sources will automatically use the `realtime` setting.
- `keyFrameInterval`: The maximum interval in seconds between two adjacent key frames. Defaults to 5 seconds. More frequent key frames improve seeking behavior but increase file size. When using multiple video tracks, this value should be set to the same value for all tracks.
-- `fullCodecString`: Allows you to optionally specify the full codec string used by the video encoder, as specified in the [WebCodecs Codec Registry](https://www.w3.org/TR/webcodecs-codec-registry/). For example, you may set it to `'avc1.42001f'` when using AVC. Keep in mind that the codec string must still match the codec specified in `codec`. If you don't set this field, a codec string will be generated automatically.
+- `fullCodecString`: Allows you to optionally specify the full codec string used by the video encoder, as specified in the [Mediabunny Codec Registry](/codec-registry/overview). For example, you may set it to `'avc1.42001f'` when using AVC. Keep in mind that the codec string must still match the codec specified in `codec`. If you don't set this field, a codec string will be generated automatically.
- `hardwareAcceleration`: A hint that configures the hardware acceleration method of this codec. This is best left on `'no-preference'`.
- `scalabilityMode`: An encoding scalability mode identifier as defined by [WebRTC-SVC](https://w3c.github.io/webrtc-svc/#scalabilitymodes*).
- `contentHint`: An encoding video content hint as defined by [mst-content-hint](https://w3c.github.io/mst-content-hint/#video-content-hints).
@@ -104,7 +104,7 @@ type AudioEncodingConfig = {
- `codec`: The [audio codec](./supported-formats-and-codecs#audio-codecs) used for encoding. Can be omitted for uncompressed PCM codecs.
- `bitrate`: The target number of bits per second. Alternatively, this can be a [subjective quality](#subjective-qualities).
- `bitrateMode`: Can be used to control constant vs. variable bitrate.
-- `fullCodecString`: Allows you to optionally specify the full codec string used by the audio encoder, as specified in the [WebCodecs Codec Registry](https://www.w3.org/TR/webcodecs-codec-registry/). For example, you may set it to `'mp4a.40.2'` when using AAC. Keep in mind that the codec string must still match the codec specified in `codec`. If you don't set this field, a codec string will be generated automatically.
+- `fullCodecString`: Allows you to optionally specify the full codec string used by the audio encoder, as specified in the [Mediabunny Codec Registry](/codec-registry/overview). For example, you may set it to `'mp4a.40.2'` when using AAC. Keep in mind that the codec string must still match the codec specified in `codec`. If you don't set this field, a codec string will be generated automatically.
- `onEncodedPacket`: Called for each successfully encoded packet. Useful for determining encoding progress.
- `onEncoderConfig`: Called when the internal encoder config, as used by the WebCodecs API, is created. You can use this to introspect the full codec string.
@@ -240,7 +240,7 @@ await packetSource.add(firstPacket, {
});
```
-`codec`, `codedWidth`, and `codedHeight` are required for all codecs, whereas `description` is required for some codecs. Additional fields, such as `colorSpace`, are optional. The [WebCodecs Codec Registry](https://www.w3.org/TR/webcodecs-codec-registry/) specifies the formats of `codec` and `description` for each video codec, which you must adhere to.
+`codec`, `codedWidth`, and `codedHeight` are required for all codecs, whereas `description` is required for some codecs. Additional fields, such as `colorSpace`, are optional. The [Mediabunny Codec Registry](/codec-registry/overview) specifies the formats of `codec` and `description` for each video codec, which you **must** adhere to.
#### B-frames
@@ -397,7 +397,7 @@ await packetSource.add(firstPacket, {
});
```
-`codec`, `numberOfChannels`, and `sampleRate` are required for all codecs, whereas `description` is required for some codecs. The [WebCodecs Codec Registry](https://www.w3.org/TR/webcodecs-codec-registry/) specifies the formats of `codec` and `description` for each audio codec, which you must adhere to.
+`codec`, `numberOfChannels`, and `sampleRate` are required for all codecs, whereas `description` is required for some codecs. The [Mediabunny Codec Registry](/codec-registry/overview) specifies the formats of `codec` and `description` for each audio codec, which you must adhere to.
## Subtitle sources
diff --git a/docs/guide/packets-and-samples.md b/docs/guide/packets-and-samples.md
index 8b37d3c..e16d1c2 100644
--- a/docs/guide/packets-and-samples.md
+++ b/docs/guide/packets-and-samples.md
@@ -94,6 +94,8 @@ constructor(
);
```
+When creating a packet for a given codec, you *must* adhere to the data format specified in the [Mediabunny Codec Registry](/codec-registry/overview).
+
::: info
You probably won't ever need to set `sequenceNumber` or `byteLength` in the constructor.
:::
diff --git a/docs/guide/reading-media-files.md b/docs/guide/reading-media-files.md
index 6e3faab..a3e1a9e 100644
--- a/docs/guide/reading-media-files.md
+++ b/docs/guide/reading-media-files.md
@@ -129,7 +129,7 @@ track.codec; // => MediaCodec | null
```
This field is `null` when the track's codec couldn't be recognized or is not supported by Mediabunny. See [Codecs](./supported-formats-and-codecs#codecs) for the full list of supported codecs. When Mediabunny doesn't recognize the format, you can still use the `internalCodecId` field to figure out the codec of the track, although its format depends on the container format used and is not homogenized by Mediabunny.
-You can also extract the full codec parameter string from the track, as specified in the [WebCodecs Codec Registry](https://www.w3.org/TR/webcodecs-codec-registry/):
+You can also extract the full codec parameter string from the track, as specified in the [Mediabunny Codec Registry](/codec-registry/overview):
```ts
await track.getCodecParameterString(); // => 'avc1.42001f'
```
@@ -506,6 +506,9 @@ type UrlSourceOptions = {
// in memory. Defaults to 8 MiB.
maxCacheSize?: number;
+ // The maximum number of parallel requests to use for fetching. Defaults to 2.
+ parallelism?: number;
+
// Used to provide a custom fetch function
fetchFn?: typeof fetch;
};
diff --git a/docs/guide/supported-formats-and-codecs.md b/docs/guide/supported-formats-and-codecs.md
index e28fa3c..dbaf533 100644
--- a/docs/guide/supported-formats-and-codecs.md
+++ b/docs/guide/supported-formats-and-codecs.md
@@ -21,6 +21,8 @@ Mediabunny supports a wide range of video, audio, and subtitle codecs. More spec
The availability of the codecs provided by the WebCodecs API depends on the browser and thus cannot be guaranteed by this library. Mediabunny provides [special utility functions](#querying-codec-encodability) to check which codecs are able to be encoded. You can also specify [custom coders](#custom-coders) to provide your own encoder/decoder implementation if the browser doesn't support the codec natively.
+For precise definitions of each codec including the corresponding packet format, please refer to the [Mediabunny Codec Registry](/codec-registry/overview).
+
::: info
Mediabunny ships with built-in decoders and encoders for all audio PCM codecs, meaning they are always supported.
:::
@@ -40,6 +42,8 @@ Mediabunny ships with built-in decoders and encoders for all audio PCM codecs, m
- `'mp3'` - MP3
- `'vorbis'` - Vorbis
- `'flac'` - Free Lossless Audio Codec (FLAC)
+- `'ac3'` - Dolby Digital (AC-3) [^1]
+- `'eac3'` - Dolby Digital Plus (E-AC-3) [^1]
- `'pcm-u8'` - 8-bit unsigned PCM
- `'pcm-s8'` - 8-bit signed PCM
- `'pcm-s16'` - 16-bit little-endian signed PCM
@@ -55,6 +59,8 @@ Mediabunny ships with built-in decoders and encoders for all audio PCM codecs, m
- `'ulaw'` - μ-law PCM
- `'alaw'` - A-law PCM
+[^1]: AC-3 and E-AC-3 are not natively supported by WebCodecs. To encode or decode these codecs, you can use the [`@mediabunny/ac3`](./extensions/ac3) extension package, or provide your own [custom coder](#custom-coders).
+
### Subtitle codecs
- `'webvtt'` - WebVTT
@@ -63,7 +69,7 @@ Mediabunny ships with built-in decoders and encoders for all audio PCM codecs, m
Not all codecs can be used with all containers. The following table specifies the supported codec-container combinations:
-| | .mp4 | .mov | .mkv | .webm[^1] | .ogg | .mp3 | .wav | .aac | .flac | .ts |
+| | .mp4 | .mov | .mkv | .webm[^2] | .ogg | .mp3 | .wav | .aac | .flac | .ts |
|:--------------:|:--------:|:-----:|:-----:|:---------:|:-----:|:-----:|:-----:|:-----:|:-----:|:-----:|
| `'avc'` | ✓ | ✓ | ✓ | | | | | | | ✓ |
| `'hevc'` | ✓ | ✓ | ✓ | | | | | | | ✓ |
@@ -75,6 +81,8 @@ Not all codecs can be used with all containers. The following table specifies th
| `'mp3'` | ✓ | ✓ | ✓ | | | ✓ | | | | ✓ |
| `'vorbis'` | ✓ | ✓ | ✓ | ✓ | ✓ | | | | | |
| `'flac'` | ✓ | ✓ | ✓ | | | | | | ✓ | |
+| `'ac3'` | ✓ | ✓ | ✓ | | | | | | | ✓ |
+| `'eac3'` | ✓ | ✓ | ✓ | | | | | | | ✓ |
| `'pcm-u8'` | | ✓ | ✓ | | | | ✓ | | | |
| `'pcm-s8'` | | ✓ | | | | | | | | |
| `'pcm-s16'` | ✓ | ✓ | ✓ | | | | ✓ | | | |
@@ -89,11 +97,11 @@ Not all codecs can be used with all containers. The following table specifies th
| `'pcm-f64be'` | ✓ | ✓ | | | | | | | | |
| `'ulaw'` | | ✓ | | | | | ✓ | | | |
| `'alaw'` | | ✓ | | | | | ✓ | | | |
-| `'webvtt'`[^2] | (✓) | | (✓) | (✓) | | | | | | |
+| `'webvtt'`[^3] | (✓) | | (✓) | (✓) | | | | | | |
-[^1]: WebM only supports a small subset of the codecs supported by Matroska. However, this library can technically read all codecs from a WebM that are supported by Matroska.
-[^2]: WebVTT can only be written, not read.
+[^2]: WebM only supports a small subset of the codecs supported by Matroska. However, this library can technically read all codecs from a WebM that are supported by Matroska.
+[^3]: WebVTT can only be written, not read.
## Querying codec encodability
diff --git a/docs/index.md b/docs/index.md
index e7f4fc0..6ded5c5 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -113,6 +113,7 @@ const sponsors = {
{ image: 'https://avatars.githubusercontent.com/u/84167135', name: 'Memenome', url: 'https://github.com/memenome' },
{ image: 'https://avatars.githubusercontent.com/u/5913254', name: 'Brandon McConnell', url: 'https://github.com/brandonmcconnell' },
{ image: 'https://avatars.githubusercontent.com/u/9549394', name: 'studnitz', url: 'https://github.com/studnitz' },
+ { image: 'https://avatars.githubusercontent.com/u/4714175', name: 'Phoomparin Mano', url: 'https://github.com/heypoom' },
{ image: 'https://avatars.githubusercontent.com/u/504909', name: 'Hirbod', url: 'https://github.com/hirbod' },
{ image: 'https://avatars.githubusercontent.com/u/2698271', name: 'Matthew Gardner', url: 'https://github.com/spheric' },
{ image: 'https://avatars.githubusercontent.com/u/5475819', name: 'AJ Funk', url: 'https://github.com/AJFunk' },
@@ -122,6 +123,8 @@ const sponsors = {
{ image: 'https://avatars.githubusercontent.com/u/38181164', name: 'wcw', url: 'https://github.com/asd55667' },
{ image: 'https://avatars.githubusercontent.com/u/1836701', name: 'Bean Deng', url: 'https://github.com/HADB' },
{ image: 'https://avatars.githubusercontent.com/u/255616819', name: 'cronischarles-del', url: 'https://github.com/cronischarles-del' },
+ { image: 'https://avatars.githubusercontent.com/u/37973863', name: 'Gomi', url: 'https://github.com/gxy5202' },
+ { image: 'https://avatars.githubusercontent.com/u/36898190', name: 'jepcd', url: 'https://github.com/jepcd' },
{ image: 'https://avatars.githubusercontent.com/u/63088713', name: 'taf2000', url: 'https://github.com/taf2000' },
{ image: 'https://avatars.githubusercontent.com/u/58149663', name: 'H7GhosT', url: 'https://github.com/H7GhosT' },
{ image: 'https://avatars.githubusercontent.com/u/91711202', name: 'ihasq', url: 'https://github.com/ihasq' },
@@ -130,7 +133,6 @@ const sponsors = {
{ image: 'https://avatars.githubusercontent.com/u/3709646', name: 'Rodrigo Belfiore', url: 'https://github.com/roprgm' },
{ image: 'https://avatars.githubusercontent.com/u/31102694', name: 'Aiden Liu', url: 'https://github.com/aidenlx' },
{ image: 'https://avatars.githubusercontent.com/u/41021374', name: 'arthco', url: 'https://github.com/arthtyagi' },
- { image: 'https://avatars.githubusercontent.com/u/36898190', name: 'alakhpc', url: 'https://github.com/alakhpc' },
{ image: 'https://avatars.githubusercontent.com/u/5907357', name: 'Harvey Zhao', url: 'https://github.com/zhw2590582' },
],
};
diff --git a/docs/public/sponsors/gling.svg b/docs/public/sponsors/gling.svg
index 3a86f18..a37c4f1 100644
--- a/docs/public/sponsors/gling.svg
+++ b/docs/public/sponsors/gling.svg
@@ -1,5 +1,24 @@
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 5704365..fa924e2 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -38,6 +38,8 @@ export default tseslint.config(
'dist-docs',
'packages/mp3-encoder/dist',
'packages/mp3-encoder/build',
+ 'packages/ac3/dist',
+ 'packages/ac3/build',
'eslint.config.mjs',
'docs/.vitepress/cache',
'test/public',
diff --git a/package-lock.json b/package-lock.json
index 2a64c6d..a075aa1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "mediabunny",
- "version": "1.31.0",
+ "version": "1.34.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mediabunny",
- "version": "1.31.0",
+ "version": "1.34.2",
"license": "MPL-2.0",
"workspaces": [
"packages/*"
@@ -1413,6 +1413,10 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@mediabunny/ac3": {
+ "resolved": "packages/ac3",
+ "link": true
+ },
"node_modules/@mediabunny/mp3-encoder": {
"resolved": "packages/mp3-encoder",
"link": true
@@ -7739,9 +7743,9 @@
}
},
"node_modules/mediabunny": {
- "version": "1.30.1",
- "resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.30.1.tgz",
- "integrity": "sha512-FRmQAUoRqYoSd84mBKK0A4HVYEJWHhnqw6gM88mPsVfsm0ki5OG008v35yHsiEh1YcXj06d4ZkPrU0VFgUb9Og==",
+ "version": "1.34.0",
+ "resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.34.0.tgz",
+ "integrity": "sha512-mjn/7QVEPbPEakuNKD8OIPDhaoe2NlgWVyyqftuLVWvsgLol7g2MQzXWCjx523CP8Y9jE2H09hbYCuTwSI7pSQ==",
"license": "MPL-2.0",
"peer": true,
"workspaces": [
@@ -12063,9 +12067,24 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "packages/ac3": {
+ "name": "@mediabunny/ac3",
+ "version": "1.34.2",
+ "license": "MPL-2.0",
+ "devDependencies": {
+ "@types/emscripten": "^1.40.1"
+ },
+ "funding": {
+ "type": "individual",
+ "url": "https://github.com/sponsors/Vanilagy"
+ },
+ "peerDependencies": {
+ "mediabunny": "^1.0.0"
+ }
+ },
"packages/mp3-encoder": {
"name": "@mediabunny/mp3-encoder",
- "version": "1.31.0",
+ "version": "1.34.2",
"license": "MPL-2.0",
"devDependencies": {
"@types/emscripten": "^1.40.1"
diff --git a/package.json b/package.json
index a1aa3ae..b1213d2 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "mediabunny",
"author": "Vanilagy",
- "version": "1.31.0",
+ "version": "1.34.2",
"description": "Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.",
"type": "module",
"workspaces": [
@@ -29,18 +29,19 @@
},
"sideEffects": false,
"scripts": {
- "build": "./build.sh",
+ "build": "./scripts/build.sh",
"watch": "tsx scripts/bundle.ts --watch",
"lint": "eslint .",
- "test": "npx vitest --run",
- "test-node": "npm run test node/",
- "test-browser": "npm run test browser/",
- "check": "rm -rf dist/modules && tsc -p src --stripInternal false && tsc -p packages/mp3-encoder/src --noEmit && tsc -p tsconfig.vitest.json --noEmit && tsc -p scripts --noEmit && tsc -p tsconfig.vite.json --noEmit",
+ "test": "npm run pre-test && npx vitest --run",
+ "test-node": "npm run pre-test && npm run test node/",
+ "test-browser": "npm run pre-test && npm run test browser/",
+ "pre-test": "tsx scripts/bundle.ts",
+ "check": "./scripts/check.sh",
"check-docblocks": "tsx scripts/check-docblocks.ts dist/mediabunny.d.ts",
"docs:dev": "vitepress dev docs",
"docs:build": "npm run build && npm run docs:generate && vitepress build docs && npm run examples:build && cp dist/mediabunny.d.ts dist-docs/",
"docs:preview": "vitepress preview docs",
- "docs:generate": "tsx scripts/generate-api-docs.ts src/index.ts packages/mp3-encoder/src/index.ts docs/api-config.json",
+ "docs:generate": "tsx scripts/generate-api-docs.ts src/index.ts packages/mp3-encoder/src/index.ts packages/ac3/src/index.ts docs/api-config.json",
"dev": "vite",
"examples:build": "vite build",
"fix-build-import-paths": "tsx scripts/add-import-extensions.ts",
diff --git a/packages/ac3/.gitattributes b/packages/ac3/.gitattributes
new file mode 100644
index 0000000..72ef19a
--- /dev/null
+++ b/packages/ac3/.gitattributes
@@ -0,0 +1 @@
+build/* linguist-generated
diff --git a/packages/ac3/LICENSE b/packages/ac3/LICENSE
new file mode 100644
index 0000000..d0a1fa1
--- /dev/null
+++ b/packages/ac3/LICENSE
@@ -0,0 +1,373 @@
+Mozilla Public License Version 2.0
+==================================
+
+1. Definitions
+--------------
+
+1.1. "Contributor"
+ means each individual or legal entity that creates, contributes to
+ the creation of, or owns Covered Software.
+
+1.2. "Contributor Version"
+ means the combination of the Contributions of others (if any) used
+ by a Contributor and that particular Contributor's Contribution.
+
+1.3. "Contribution"
+ means Covered Software of a particular Contributor.
+
+1.4. "Covered Software"
+ means Source Code Form to which the initial Contributor has attached
+ the notice in Exhibit A, the Executable Form of such Source Code
+ Form, and Modifications of such Source Code Form, in each case
+ including portions thereof.
+
+1.5. "Incompatible With Secondary Licenses"
+ means
+
+ (a) that the initial Contributor has attached the notice described
+ in Exhibit B to the Covered Software; or
+
+ (b) that the Covered Software was made available under the terms of
+ version 1.1 or earlier of the License, but not also under the
+ terms of a Secondary License.
+
+1.6. "Executable Form"
+ means any form of the work other than Source Code Form.
+
+1.7. "Larger Work"
+ means a work that combines Covered Software with other material, in
+ a separate file or files, that is not Covered Software.
+
+1.8. "License"
+ means this document.
+
+1.9. "Licensable"
+ means having the right to grant, to the maximum extent possible,
+ whether at the time of the initial grant or subsequently, any and
+ all of the rights conveyed by this License.
+
+1.10. "Modifications"
+ means any of the following:
+
+ (a) any file in Source Code Form that results from an addition to,
+ deletion from, or modification of the contents of Covered
+ Software; or
+
+ (b) any new file in Source Code Form that contains any Covered
+ Software.
+
+1.11. "Patent Claims" of a Contributor
+ means any patent claim(s), including without limitation, method,
+ process, and apparatus claims, in any patent Licensable by such
+ Contributor that would be infringed, but for the grant of the
+ License, by the making, using, selling, offering for sale, having
+ made, import, or transfer of either its Contributions or its
+ Contributor Version.
+
+1.12. "Secondary License"
+ means either the GNU General Public License, Version 2.0, the GNU
+ Lesser General Public License, Version 2.1, the GNU Affero General
+ Public License, Version 3.0, or any later versions of those
+ licenses.
+
+1.13. "Source Code Form"
+ means the form of the work preferred for making modifications.
+
+1.14. "You" (or "Your")
+ means an individual or a legal entity exercising rights under this
+ License. For legal entities, "You" includes any entity that
+ controls, is controlled by, or is under common control with You. For
+ purposes of this definition, "control" means (a) the power, direct
+ or indirect, to cause the direction or management of such entity,
+ whether by contract or otherwise, or (b) ownership of more than
+ fifty percent (50%) of the outstanding shares or beneficial
+ ownership of such entity.
+
+2. License Grants and Conditions
+--------------------------------
+
+2.1. Grants
+
+Each Contributor hereby grants You a world-wide, royalty-free,
+non-exclusive license:
+
+(a) under intellectual property rights (other than patent or trademark)
+ Licensable by such Contributor to use, reproduce, make available,
+ modify, display, perform, distribute, and otherwise exploit its
+ Contributions, either on an unmodified basis, with Modifications, or
+ as part of a Larger Work; and
+
+(b) under Patent Claims of such Contributor to make, use, sell, offer
+ for sale, have made, import, and otherwise transfer either its
+ Contributions or its Contributor Version.
+
+2.2. Effective Date
+
+The licenses granted in Section 2.1 with respect to any Contribution
+become effective for each Contribution on the date the Contributor first
+distributes such Contribution.
+
+2.3. Limitations on Grant Scope
+
+The licenses granted in this Section 2 are the only rights granted under
+this License. No additional rights or licenses will be implied from the
+distribution or licensing of Covered Software under this License.
+Notwithstanding Section 2.1(b) above, no patent license is granted by a
+Contributor:
+
+(a) for any code that a Contributor has removed from Covered Software;
+ or
+
+(b) for infringements caused by: (i) Your and any other third party's
+ modifications of Covered Software, or (ii) the combination of its
+ Contributions with other software (except as part of its Contributor
+ Version); or
+
+(c) under Patent Claims infringed by Covered Software in the absence of
+ its Contributions.
+
+This License does not grant any rights in the trademarks, service marks,
+or logos of any Contributor (except as may be necessary to comply with
+the notice requirements in Section 3.4).
+
+2.4. Subsequent Licenses
+
+No Contributor makes additional grants as a result of Your choice to
+distribute the Covered Software under a subsequent version of this
+License (see Section 10.2) or under the terms of a Secondary License (if
+permitted under the terms of Section 3.3).
+
+2.5. Representation
+
+Each Contributor represents that the Contributor believes its
+Contributions are its original creation(s) or it has sufficient rights
+to grant the rights to its Contributions conveyed by this License.
+
+2.6. Fair Use
+
+This License is not intended to limit any rights You have under
+applicable copyright doctrines of fair use, fair dealing, or other
+equivalents.
+
+2.7. Conditions
+
+Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
+in Section 2.1.
+
+3. Responsibilities
+-------------------
+
+3.1. Distribution of Source Form
+
+All distribution of Covered Software in Source Code Form, including any
+Modifications that You create or to which You contribute, must be under
+the terms of this License. You must inform recipients that the Source
+Code Form of the Covered Software is governed by the terms of this
+License, and how they can obtain a copy of this License. You may not
+attempt to alter or restrict the recipients' rights in the Source Code
+Form.
+
+3.2. Distribution of Executable Form
+
+If You distribute Covered Software in Executable Form then:
+
+(a) such Covered Software must also be made available in Source Code
+ Form, as described in Section 3.1, and You must inform recipients of
+ the Executable Form how they can obtain a copy of such Source Code
+ Form by reasonable means in a timely manner, at a charge no more
+ than the cost of distribution to the recipient; and
+
+(b) You may distribute such Executable Form under the terms of this
+ License, or sublicense it under different terms, provided that the
+ license for the Executable Form does not attempt to limit or alter
+ the recipients' rights in the Source Code Form under this License.
+
+3.3. Distribution of a Larger Work
+
+You may create and distribute a Larger Work under terms of Your choice,
+provided that You also comply with the requirements of this License for
+the Covered Software. If the Larger Work is a combination of Covered
+Software with a work governed by one or more Secondary Licenses, and the
+Covered Software is not Incompatible With Secondary Licenses, this
+License permits You to additionally distribute such Covered Software
+under the terms of such Secondary License(s), so that the recipient of
+the Larger Work may, at their option, further distribute the Covered
+Software under the terms of either this License or such Secondary
+License(s).
+
+3.4. Notices
+
+You may not remove or alter the substance of any license notices
+(including copyright notices, patent notices, disclaimers of warranty,
+or limitations of liability) contained within the Source Code Form of
+the Covered Software, except that You may alter any license notices to
+the extent required to remedy known factual inaccuracies.
+
+3.5. Application of Additional Terms
+
+You may choose to offer, and to charge a fee for, warranty, support,
+indemnity or liability obligations to one or more recipients of Covered
+Software. However, You may do so only on Your own behalf, and not on
+behalf of any Contributor. You must make it absolutely clear that any
+such warranty, support, indemnity, or liability obligation is offered by
+You alone, and You hereby agree to indemnify every Contributor for any
+liability incurred by such Contributor as a result of warranty, support,
+indemnity or liability terms You offer. You may include additional
+disclaimers of warranty and limitations of liability specific to any
+jurisdiction.
+
+4. Inability to Comply Due to Statute or Regulation
+---------------------------------------------------
+
+If it is impossible for You to comply with any of the terms of this
+License with respect to some or all of the Covered Software due to
+statute, judicial order, or regulation then You must: (a) comply with
+the terms of this License to the maximum extent possible; and (b)
+describe the limitations and the code they affect. Such description must
+be placed in a text file included with all distributions of the Covered
+Software under this License. Except to the extent prohibited by statute
+or regulation, such description must be sufficiently detailed for a
+recipient of ordinary skill to be able to understand it.
+
+5. Termination
+--------------
+
+5.1. The rights granted under this License will terminate automatically
+if You fail to comply with any of its terms. However, if You become
+compliant, then the rights granted under this License from a particular
+Contributor are reinstated (a) provisionally, unless and until such
+Contributor explicitly and finally terminates Your grants, and (b) on an
+ongoing basis, if such Contributor fails to notify You of the
+non-compliance by some reasonable means prior to 60 days after You have
+come back into compliance. Moreover, Your grants from a particular
+Contributor are reinstated on an ongoing basis if such Contributor
+notifies You of the non-compliance by some reasonable means, this is the
+first time You have received notice of non-compliance with this License
+from such Contributor, and You become compliant prior to 30 days after
+Your receipt of the notice.
+
+5.2. If You initiate litigation against any entity by asserting a patent
+infringement claim (excluding declaratory judgment actions,
+counter-claims, and cross-claims) alleging that a Contributor Version
+directly or indirectly infringes any patent, then the rights granted to
+You by any and all Contributors for the Covered Software under Section
+2.1 of this License shall terminate.
+
+5.3. In the event of termination under Sections 5.1 or 5.2 above, all
+end user license agreements (excluding distributors and resellers) which
+have been validly granted by You or Your distributors under this License
+prior to termination shall survive termination.
+
+************************************************************************
+* *
+* 6. Disclaimer of Warranty *
+* ------------------------- *
+* *
+* Covered Software is provided under this License on an "as is" *
+* basis, without warranty of any kind, either expressed, implied, or *
+* statutory, including, without limitation, warranties that the *
+* Covered Software is free of defects, merchantable, fit for a *
+* particular purpose or non-infringing. The entire risk as to the *
+* quality and performance of the Covered Software is with You. *
+* Should any Covered Software prove defective in any respect, You *
+* (not any Contributor) assume the cost of any necessary servicing, *
+* repair, or correction. This disclaimer of warranty constitutes an *
+* essential part of this License. No use of any Covered Software is *
+* authorized under this License except under this disclaimer. *
+* *
+************************************************************************
+
+************************************************************************
+* *
+* 7. Limitation of Liability *
+* -------------------------- *
+* *
+* Under no circumstances and under no legal theory, whether tort *
+* (including negligence), contract, or otherwise, shall any *
+* Contributor, or anyone who distributes Covered Software as *
+* permitted above, be liable to You for any direct, indirect, *
+* special, incidental, or consequential damages of any character *
+* including, without limitation, damages for lost profits, loss of *
+* goodwill, work stoppage, computer failure or malfunction, or any *
+* and all other commercial damages or losses, even if such party *
+* shall have been informed of the possibility of such damages. This *
+* limitation of liability shall not apply to liability for death or *
+* personal injury resulting from such party's negligence to the *
+* extent applicable law prohibits such limitation. Some *
+* jurisdictions do not allow the exclusion or limitation of *
+* incidental or consequential damages, so this exclusion and *
+* limitation may not apply to You. *
+* *
+************************************************************************
+
+8. Litigation
+-------------
+
+Any litigation relating to this License may be brought only in the
+courts of a jurisdiction where the defendant maintains its principal
+place of business and such litigation shall be governed by laws of that
+jurisdiction, without reference to its conflict-of-law provisions.
+Nothing in this Section shall prevent a party's ability to bring
+cross-claims or counter-claims.
+
+9. Miscellaneous
+----------------
+
+This License represents the complete agreement concerning the subject
+matter hereof. If any provision of this License is held to be
+unenforceable, such provision shall be reformed only to the extent
+necessary to make it enforceable. Any law or regulation which provides
+that the language of a contract shall be construed against the drafter
+shall not be used to construe this License against a Contributor.
+
+10. Versions of the License
+---------------------------
+
+10.1. New Versions
+
+Mozilla Foundation is the license steward. Except as provided in Section
+10.3, no one other than the license steward has the right to modify or
+publish new versions of this License. Each version will be given a
+distinguishing version number.
+
+10.2. Effect of New Versions
+
+You may distribute the Covered Software under the terms of the version
+of the License under which You originally received the Covered Software,
+or under the terms of any subsequent version published by the license
+steward.
+
+10.3. Modified Versions
+
+If you create software not governed by this License, and you want to
+create a new license for such software, you may create and use a
+modified version of this License if you rename the license and remove
+any references to the name of the license steward (except to note that
+such modified license differs from this License).
+
+10.4. Distributing Source Code Form that is Incompatible With Secondary
+Licenses
+
+If You choose to distribute Source Code Form that is Incompatible With
+Secondary Licenses under the terms of this version of the License, the
+notice described in Exhibit B of this License must be attached.
+
+Exhibit A - Source Code Form License Notice
+-------------------------------------------
+
+ This Source Code Form is subject to the terms of the Mozilla Public
+ License, v. 2.0. If a copy of the MPL was not distributed with this
+ file, You can obtain one at https://mozilla.org/MPL/2.0/.
+
+If it is not possible or desirable to put the notice in a particular
+file, then You may include the notice in a location (such as a LICENSE
+file in a relevant directory) where a recipient would be likely to look
+for such a notice.
+
+You may add additional accurate notices of copyright ownership.
+
+Exhibit B - "Incompatible With Secondary Licenses" Notice
+---------------------------------------------------------
+
+ This Source Code Form is "Incompatible With Secondary Licenses", as
+ defined by the Mozilla Public License, v. 2.0.
diff --git a/packages/ac3/README.md b/packages/ac3/README.md
new file mode 100644
index 0000000..cc8be67
--- /dev/null
+++ b/packages/ac3/README.md
@@ -0,0 +1,108 @@
+# @mediabunny/ac3
+
+[](https://www.npmjs.com/package/@mediabunny/ac3)
+[](https://bundlephobia.com/package/@mediabunny/ac3)
+[](https://www.npmjs.com/package/@mediabunny/ac3)
+[](https://discord.gg/hmpkyYuS4U)
+
+
+
+
+
+Browsers have no support for AC-3 (Dolby Digital) or E-AC-3 (Dolby Digital Plus) in their WebCodecs implementations. This extension package provides both a decoder and encoder for use with [Mediabunny](https://github.com/Vanilagy/mediabunny), allowing you to decode and encode these codecs directly in the browser. It is implemented using Mediabunny's [custom coder API](https://mediabunny.dev/guide/supported-formats-and-codecs#custom-coders) and uses a fast, size-optimized WASM build of [FFmpeg](https://ffmpeg.org/)'s AC-3 and E-AC-3 coders under the hood.
+
+> This package, like the rest of Mediabunny, is enabled by its [sponsors](https://mediabunny.dev/#sponsors) and their donations. If you've derived value from this package, please consider [leaving a donation](https://github.com/sponsors/Vanilagy)! 💘
+
+## Installation
+
+This library peer-depends on Mediabunny. Install both using npm:
+```bash
+npm install mediabunny @mediabunny/ac3
+```
+
+Alternatively, directly include them using a script tag:
+```html
+
+
+```
+
+This will expose the global objects `Mediabunny` and `MediabunnyAc3`. Use `mediabunny-ac3.d.ts` to provide types for these globals. You can download the built distribution files from the [releases page](https://github.com/Vanilagy/mediabunny/releases).
+
+## Usage
+
+```ts
+import { registerAc3Decoder, registerAc3Encoder } from '@mediabunny/ac3';
+
+registerAc3Decoder();
+registerAc3Encoder();
+```
+That's it - Mediabunny now uses the registered AC-3/E-AC-3 decoder and encoder automatically.
+
+## Building and development
+
+For simplicity, all built WASM artifacts are included in the repo, since these rarely change. However, here are the instructions for building them from scratch:
+
+[Install Emscripten](https://emscripten.org/docs/getting_started/downloads.html) and clone [FFmpeg](https://github.com/FFmpeg/FFmpeg). Then, from the Mediabunny root and with Emscripten sourced in:
+
+```bash
+export FFMPEG_PATH=/path/to/ffmpeg
+export MEDIABUNNY_ROOT=$PWD
+
+# Build FFmpeg
+cd $FFMPEG_PATH
+emmake make distclean
+emconfigure ./configure \
+ --target-os=none \
+ --arch=x86_32 \
+ --enable-cross-compile \
+ --disable-asm \
+ --disable-x86asm \
+ --disable-inline-asm \
+ --disable-programs \
+ --disable-doc \
+ --disable-debug \
+ --disable-all \
+ --disable-everything \
+ --disable-autodetect \
+ --disable-pthreads \
+ --disable-runtime-cpudetect \
+ --enable-avcodec \
+ --enable-decoder=ac3 \
+ --enable-decoder=eac3 \
+ --enable-encoder=ac3 \
+ --enable-encoder=eac3 \
+ --cc="emcc" \
+ --cxx=em++ \
+ --ar=emar \
+ --ranlib=emranlib \
+ --extra-cflags="-DNDEBUG -Oz -flto -msimd128" \
+ --extra-ldflags="-Oz -flto"
+emmake make
+
+# Compile the bridge between JavaScript and FFmpeg's API
+cd $MEDIABUNNY_ROOT/packages/ac3
+emcc src/bridge.c \
+ $FFMPEG_PATH/libavcodec/libavcodec.a \
+ $FFMPEG_PATH/libavutil/libavutil.a \
+ -I$FFMPEG_PATH \
+ -s MODULARIZE=1 \
+ -s EXPORT_ES6=1 \
+ -s SINGLE_FILE=1 \
+ -s ALLOW_MEMORY_GROWTH=1 \
+ -s ENVIRONMENT=web,worker \
+ -s FILESYSTEM=0 \
+ -s MALLOC=emmalloc \
+ -s SUPPORT_LONGJMP=0 \
+ -s EXPORTED_RUNTIME_METHODS=cwrap,HEAPU8 \
+ -s EXPORTED_FUNCTIONS=_malloc,_free \
+ -msimd128 \
+ -flto \
+ -Oz \
+ -o build/ac3.js
+```
+
+This generates `build/ac3.js`, which contains both the JavaScript "glue code" as well as the compiled WASM inlined.
+
+### Building the package
+
+Then, the complete JavaScript package can be built alongside the rest of Mediabunny by running `npm run build` in Mediabunny's root.
diff --git a/packages/ac3/api-extractor.json b/packages/ac3/api-extractor.json
new file mode 100644
index 0000000..461488c
--- /dev/null
+++ b/packages/ac3/api-extractor.json
@@ -0,0 +1,37 @@
+{
+ "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
+ "mainEntryPointFilePath": "dist/modules/src/index.d.ts",
+ "bundledPackages": [],
+ "compiler": {},
+ "apiReport": {
+ "enabled": false
+ },
+ "docModel": {
+ "enabled": false
+ },
+ "dtsRollup": {
+ "enabled": true,
+ "untrimmedFilePath": "dist/mediabunny-ac3.d.ts"
+ },
+ "tsdocMetadata": {
+ "enabled": false
+ },
+ "messages": {
+ "compilerMessageReporting": {
+ "default": {
+ "logLevel": "warning"
+ }
+ },
+ "extractorMessageReporting": {
+ "default": {
+ "logLevel": "warning"
+ }
+ },
+ "tsdocMessageReporting": {
+ "default": {
+ "logLevel": "warning"
+ }
+ }
+ },
+ "newlineKind": "lf"
+}
diff --git a/packages/ac3/build/ac3.js b/packages/ac3/build/ac3.js
new file mode 100644
index 0000000..777bd8a
Binary files /dev/null and b/packages/ac3/build/ac3.js differ
diff --git a/packages/ac3/package.json b/packages/ac3/package.json
new file mode 100644
index 0000000..6e3eda5
--- /dev/null
+++ b/packages/ac3/package.json
@@ -0,0 +1,56 @@
+{
+ "name": "@mediabunny/ac3",
+ "author": "Vanilagy",
+ "version": "1.34.2",
+ "description": "AC-3 and E-AC-3 (Dolby Digital) decoder and encoder extension for Mediabunny, based on FFmpeg.",
+ "main": "./dist/bundles/mediabunny-ac3.mjs",
+ "module": "./dist/bundles/mediabunny-ac3.mjs",
+ "types": "./dist/modules/src/index.d.ts",
+ "exports": {
+ "types": "./dist/modules/src/index.d.ts",
+ "import": "./dist/bundles/mediabunny-ac3.mjs",
+ "require": "./dist/bundles/mediabunny-ac3.mjs"
+ },
+ "files": [
+ "README.md",
+ "package.json",
+ "LICENSE",
+ "dist",
+ "src"
+ ],
+ "sideEffects": false,
+ "license": "MPL-2.0",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/Vanilagy/mediabunny.git",
+ "directory": "packages/ac3"
+ },
+ "bugs": {
+ "url": "https://github.com/Vanilagy/mediabunny/issues"
+ },
+ "homepage": "https://mediabunny.dev/guide/extensions/ac3",
+ "funding": {
+ "type": "individual",
+ "url": "https://github.com/sponsors/Vanilagy"
+ },
+ "peerDependencies": {
+ "mediabunny": "^1.0.0"
+ },
+ "devDependencies": {
+ "@types/emscripten": "^1.40.1"
+ },
+ "keywords": [
+ "ac3",
+ "eac3",
+ "dolby",
+ "dolby-digital",
+ "encoding",
+ "decoding",
+ "codec",
+ "mediabunny",
+ "ffmpeg",
+ "browser",
+ "wasm",
+ "polyfill"
+ ]
+}
diff --git a/packages/ac3/src/bridge.c b/packages/ac3/src/bridge.c
new file mode 100644
index 0000000..21973f2
--- /dev/null
+++ b/packages/ac3/src/bridge.c
@@ -0,0 +1,289 @@
+/*!
+ * Copyright (c) 2026-present, Vanilagy and contributors
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ */
+
+#include
+#include
+#include
+#include "libavcodec/avcodec.h"
+#include "libavutil/opt.h"
+#include "libavutil/channel_layout.h"
+
+typedef struct {
+ AVCodecContext *codec_ctx;
+ AVPacket *packet;
+ AVFrame *frame;
+} DecoderContext;
+
+EMSCRIPTEN_KEEPALIVE
+DecoderContext *init_decoder(int codec_id) {
+ enum AVCodecID av_codec_id = codec_id == 0 ? AV_CODEC_ID_AC3 : AV_CODEC_ID_EAC3;
+
+ const AVCodec *codec = avcodec_find_decoder(av_codec_id);
+ if (!codec) return NULL;
+
+ AVCodecContext *codec_ctx = avcodec_alloc_context3(codec);
+ if (!codec_ctx) return NULL;
+
+ if (avcodec_open2(codec_ctx, codec, NULL) < 0) {
+ avcodec_free_context(&codec_ctx);
+ return NULL;
+ }
+
+ AVPacket *packet = av_packet_alloc();
+ if (!packet) {
+ avcodec_free_context(&codec_ctx);
+ return NULL;
+ }
+
+ AVFrame *frame = av_frame_alloc();
+ if (!frame) {
+ av_packet_free(&packet);
+ avcodec_free_context(&codec_ctx);
+ return NULL;
+ }
+
+ DecoderContext *ctx = malloc(sizeof(DecoderContext));
+ if (!ctx) {
+ av_frame_free(&frame);
+ av_packet_free(&packet);
+ avcodec_free_context(&codec_ctx);
+ return NULL;
+ }
+
+ ctx->codec_ctx = codec_ctx;
+ ctx->packet = packet;
+ ctx->frame = frame;
+
+ return ctx;
+}
+
+EMSCRIPTEN_KEEPALIVE
+uint8_t *configure_decode_packet(DecoderContext *ctx, int size) {
+ if (av_new_packet(ctx->packet, size) < 0) {
+ return NULL;
+ }
+
+ return ctx->packet->data;
+}
+
+EMSCRIPTEN_KEEPALIVE
+int decode_packet(DecoderContext *ctx, int pts) {
+ ctx->packet->pts = pts;
+ int ret = avcodec_send_packet(ctx->codec_ctx, ctx->packet);
+ av_packet_unref(ctx->packet);
+ if (ret < 0) return ret;
+
+ ret = avcodec_receive_frame(ctx->codec_ctx, ctx->frame);
+ if (ret < 0) return ret;
+
+ return 0;
+}
+
+EMSCRIPTEN_KEEPALIVE
+int get_decoded_format(DecoderContext *ctx) {
+ return ctx->frame->format;
+}
+
+EMSCRIPTEN_KEEPALIVE
+uint8_t *get_decoded_plane_ptr(DecoderContext *ctx, int plane) {
+ return ctx->frame->data[plane];
+}
+
+EMSCRIPTEN_KEEPALIVE
+int get_decoded_channels(DecoderContext *ctx) {
+ return ctx->frame->ch_layout.nb_channels;
+}
+
+EMSCRIPTEN_KEEPALIVE
+int get_decoded_sample_rate(DecoderContext *ctx) {
+ return ctx->frame->sample_rate;
+}
+
+EMSCRIPTEN_KEEPALIVE
+int get_decoded_sample_count(DecoderContext *ctx) {
+ return ctx->frame->nb_samples;
+}
+
+EMSCRIPTEN_KEEPALIVE
+int get_decoded_pts(DecoderContext *ctx) {
+ return (int)ctx->frame->pts;
+}
+
+EMSCRIPTEN_KEEPALIVE
+void flush_decoder(DecoderContext *ctx) {
+ avcodec_send_packet(ctx->codec_ctx, NULL);
+ while (avcodec_receive_frame(ctx->codec_ctx, ctx->frame) == 0) {}
+ avcodec_flush_buffers(ctx->codec_ctx);
+}
+
+EMSCRIPTEN_KEEPALIVE
+void close_decoder(DecoderContext *ctx) {
+ av_frame_free(&ctx->frame);
+ av_packet_free(&ctx->packet);
+ avcodec_free_context(&ctx->codec_ctx);
+ free(ctx);
+}
+
+typedef struct {
+ AVCodecContext *codec_ctx;
+ AVPacket *packet;
+ AVFrame *frame;
+ float *input_buffer;
+ int input_buffer_size;
+ int encoded_pts;
+ int encoded_duration;
+} EncoderContext;
+
+EMSCRIPTEN_KEEPALIVE
+EncoderContext *init_encoder(int codec_id, int channels, int sample_rate, int bitrate) {
+ enum AVCodecID av_codec_id = codec_id == 0 ? AV_CODEC_ID_AC3 : AV_CODEC_ID_EAC3;
+
+ const AVCodec *codec = avcodec_find_encoder(av_codec_id);
+ if (!codec) return NULL;
+
+ AVCodecContext *codec_ctx = avcodec_alloc_context3(codec);
+ if (!codec_ctx) return NULL;
+
+ codec_ctx->sample_fmt = AV_SAMPLE_FMT_FLTP;
+ codec_ctx->sample_rate = sample_rate;
+ codec_ctx->bit_rate = bitrate;
+ codec_ctx->time_base = (AVRational){1, sample_rate};
+
+ AVChannelLayout layout;
+ av_channel_layout_default(&layout, channels);
+ av_channel_layout_copy(&codec_ctx->ch_layout, &layout);
+ av_channel_layout_uninit(&layout);
+
+ if (avcodec_open2(codec_ctx, codec, NULL) < 0) {
+ avcodec_free_context(&codec_ctx);
+ return NULL;
+ }
+
+ AVPacket *packet = av_packet_alloc();
+ if (!packet) {
+ avcodec_free_context(&codec_ctx);
+ return NULL;
+ }
+
+ AVFrame *frame = av_frame_alloc();
+ if (!frame) {
+ av_packet_free(&packet);
+ avcodec_free_context(&codec_ctx);
+ return NULL;
+ }
+
+ // The frame has a fixed format, so let's create it now:
+ frame->format = AV_SAMPLE_FMT_FLTP;
+ frame->sample_rate = sample_rate;
+ frame->nb_samples = codec_ctx->frame_size;
+ av_channel_layout_copy(&frame->ch_layout, &codec_ctx->ch_layout);
+
+ if (av_frame_get_buffer(frame, 0) < 0) {
+ av_frame_free(&frame);
+ av_packet_free(&packet);
+ avcodec_free_context(&codec_ctx);
+ return NULL;
+ }
+
+ EncoderContext *ctx = malloc(sizeof(EncoderContext));
+ if (!ctx) {
+ av_frame_free(&frame);
+ av_packet_free(&packet);
+ avcodec_free_context(&codec_ctx);
+ return NULL;
+ }
+
+ ctx->codec_ctx = codec_ctx;
+ ctx->packet = packet;
+ ctx->frame = frame;
+ ctx->input_buffer = NULL;
+ ctx->input_buffer_size = 0;
+ ctx->encoded_pts = 0;
+ ctx->encoded_duration = 0;
+
+ return ctx;
+}
+
+EMSCRIPTEN_KEEPALIVE
+int get_encoder_frame_size(EncoderContext *ctx) {
+ return ctx->codec_ctx->frame_size;
+}
+
+EMSCRIPTEN_KEEPALIVE
+float *get_encode_input_ptr(EncoderContext *ctx, int size) {
+ if (ctx->input_buffer_size < size) {
+ free(ctx->input_buffer);
+ ctx->input_buffer = malloc(size);
+ if (!ctx->input_buffer) {
+ ctx->input_buffer_size = 0;
+ return NULL;
+ }
+ ctx->input_buffer_size = size;
+ }
+ return ctx->input_buffer;
+}
+
+EMSCRIPTEN_KEEPALIVE
+int encode_frame(EncoderContext *ctx, int pts) {
+ int channels = ctx->codec_ctx->ch_layout.nb_channels;
+ int frame_size = ctx->frame->nb_samples;
+
+ ctx->frame->pts = pts;
+
+ // Deinterleave f32 input into the frame's f32-planar planes
+ float *input = ctx->input_buffer;
+ for (int ch = 0; ch < channels; ch++) {
+ float *plane = (float *)ctx->frame->data[ch];
+ for (int i = 0; i < frame_size; i++) {
+ plane[i] = input[i * channels + ch];
+ }
+ }
+
+ int ret = avcodec_send_frame(ctx->codec_ctx, ctx->frame);
+ if (ret < 0) return ret;
+
+ ret = avcodec_receive_packet(ctx->codec_ctx, ctx->packet);
+ if (ret < 0) return ret;
+
+ ctx->encoded_pts = ctx->packet->pts;
+ ctx->encoded_duration = ctx->packet->duration;
+
+ return ctx->packet->size;
+}
+
+EMSCRIPTEN_KEEPALIVE
+void flush_encoder(EncoderContext *ctx) {
+ avcodec_send_frame(ctx->codec_ctx, NULL);
+ while (avcodec_receive_packet(ctx->codec_ctx, ctx->packet) == 0) {
+ av_packet_unref(ctx->packet);
+ }
+}
+
+EMSCRIPTEN_KEEPALIVE
+uint8_t *get_encoded_data(EncoderContext *ctx) {
+ return ctx->packet->data;
+}
+
+EMSCRIPTEN_KEEPALIVE
+int get_encoded_pts(EncoderContext *ctx) {
+ return ctx->encoded_pts;
+}
+
+EMSCRIPTEN_KEEPALIVE
+int get_encoded_duration(EncoderContext *ctx) {
+ return ctx->encoded_duration;
+}
+
+EMSCRIPTEN_KEEPALIVE
+void close_encoder(EncoderContext *ctx) {
+ free(ctx->input_buffer);
+ av_frame_free(&ctx->frame);
+ av_packet_free(&ctx->packet);
+ avcodec_free_context(&ctx->codec_ctx);
+ free(ctx);
+}
diff --git a/packages/ac3/src/codec.worker.ts b/packages/ac3/src/codec.worker.ts
new file mode 100644
index 0000000..7224b15
--- /dev/null
+++ b/packages/ac3/src/codec.worker.ts
@@ -0,0 +1,306 @@
+/*!
+ * Copyright (c) 2026-present, Vanilagy and contributors
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ */
+
+import createModule from '../build/ac3';
+import type { WorkerCommand, WorkerResponse, WorkerResponseData } from './shared';
+
+type ExtendedEmscriptenModule = EmscriptenModule & {
+ cwrap: typeof cwrap;
+};
+
+let module: ExtendedEmscriptenModule;
+let modulePromise: Promise | null = null;
+
+let initDecoderFn: (codecId: number) => number;
+let configureDecodePacket: (ctx: number, size: number) => number;
+let decodePacket: (ctx: number, pts: number) => number;
+let getDecodedFormat: (ctx: number) => number;
+let getDecodedPlanePtr: (ctx: number, plane: number) => number;
+let getDecodedChannels: (ctx: number) => number;
+let getDecodedSampleRate: (ctx: number) => number;
+let getDecodedSampleCount: (ctx: number) => number;
+let getDecodedPts: (ctx: number) => number;
+let flushDecoderFn: (ctx: number) => void;
+let closeDecoderFn: (ctx: number) => void;
+
+let initEncoderFn: (codecId: number, channels: number, sampleRate: number, bitrate: number) => number;
+let getEncoderFrameSize: (ctx: number) => number;
+let getEncodeInputPtr: (ctx: number, size: number) => number;
+let encodeFrameFn: (ctx: number, pts: number) => number;
+let flushEncoderFn: (ctx: number) => void;
+let getEncodedData: (ctx: number) => number;
+let getEncodedPts: (ctx: number) => number;
+let getEncodedDuration: (ctx: number) => number;
+let closeEncoderFn: (ctx: number) => void;
+
+const codecToId = (codec: string) => codec === 'ac3' ? 0 : 1;
+
+const ensureModule = async () => {
+ if (!module) {
+ if (modulePromise) {
+ // If we don't do this we can have a race condition
+ return modulePromise;
+ }
+
+ modulePromise = createModule() as Promise;
+ module = await modulePromise;
+ modulePromise = null;
+
+ initDecoderFn = module.cwrap('init_decoder', 'number', ['number']);
+ configureDecodePacket = module.cwrap('configure_decode_packet', 'number', ['number', 'number']);
+ decodePacket = module.cwrap('decode_packet', 'number', ['number', 'number']);
+ getDecodedFormat = module.cwrap('get_decoded_format', 'number', ['number']);
+ getDecodedPlanePtr = module.cwrap('get_decoded_plane_ptr', 'number', ['number', 'number']);
+ getDecodedChannels = module.cwrap('get_decoded_channels', 'number', ['number']);
+ getDecodedSampleRate = module.cwrap('get_decoded_sample_rate', 'number', ['number']);
+ getDecodedSampleCount = module.cwrap('get_decoded_sample_count', 'number', ['number']);
+ getDecodedPts = module.cwrap('get_decoded_pts', 'number', ['number']);
+ flushDecoderFn = module.cwrap('flush_decoder', null, ['number']);
+ closeDecoderFn = module.cwrap('close_decoder', null, ['number']);
+
+ initEncoderFn = module.cwrap('init_encoder', 'number', ['number', 'number', 'number', 'number']);
+ getEncoderFrameSize = module.cwrap('get_encoder_frame_size', 'number', ['number']);
+ getEncodeInputPtr = module.cwrap('get_encode_input_ptr', 'number', ['number', 'number']);
+ encodeFrameFn = module.cwrap('encode_frame', 'number', ['number', 'number']);
+ flushEncoderFn = module.cwrap('flush_encoder', null, ['number']);
+ getEncodedData = module.cwrap('get_encoded_data', 'number', ['number']);
+ getEncodedPts = module.cwrap('get_encoded_pts', 'number', ['number']);
+ getEncodedDuration = module.cwrap('get_encoded_duration', 'number', ['number']);
+ closeEncoderFn = module.cwrap('close_encoder', null, ['number']);
+ }
+};
+
+const initDecoder = async (codec: string) => {
+ await ensureModule();
+
+ const ctx = initDecoderFn(codecToId(codec));
+ if (ctx === 0) {
+ throw new Error('Failed to initialize AC3 decoder.');
+ }
+
+ return { ctx, frameSize: 0 };
+};
+
+// Keys are AVSampleFormat enum values
+const AV_FORMAT_MAP: Record = {
+ 0: { format: 'u8', bytesPerSample: 1, planar: false },
+ 1: { format: 's16', bytesPerSample: 2, planar: false },
+ 2: { format: 's32', bytesPerSample: 4, planar: false },
+ 3: { format: 'f32', bytesPerSample: 4, planar: false },
+ 5: { format: 'u8-planar', bytesPerSample: 1, planar: true },
+ 6: { format: 's16-planar', bytesPerSample: 2, planar: true },
+ 7: { format: 's32-planar', bytesPerSample: 4, planar: true },
+ 8: { format: 'f32-planar', bytesPerSample: 4, planar: true },
+};
+
+const decode = (ctx: number, encodedData: ArrayBuffer, timestamp: number) => {
+ const bytes = new Uint8Array(encodedData);
+
+ const dataPtr = configureDecodePacket(ctx, bytes.length);
+ if (dataPtr === 0) {
+ throw new Error('Failed to configure decode packet.');
+ }
+
+ module.HEAPU8.set(bytes, dataPtr);
+
+ const ret = decodePacket(ctx, timestamp);
+ if (ret < 0) {
+ throw new Error(`Decode failed with error code ${ret}.`);
+ }
+
+ const avFormat = getDecodedFormat(ctx);
+ const info = AV_FORMAT_MAP[avFormat];
+ if (!info) {
+ throw new Error(`Unsupported AVSampleFormat: ${avFormat}`);
+ }
+
+ const channels = getDecodedChannels(ctx);
+ const sampleRate = getDecodedSampleRate(ctx);
+ const sampleCount = getDecodedSampleCount(ctx);
+ const pts = getDecodedPts(ctx);
+
+ let pcmData: ArrayBuffer;
+ if (info.planar) {
+ const planeSize = sampleCount * info.bytesPerSample;
+ const buffer = new Uint8Array(planeSize * channels);
+
+ for (let ch = 0; ch < channels; ch++) {
+ const ptr = getDecodedPlanePtr(ctx, ch);
+ buffer.set(module.HEAPU8.subarray(ptr, ptr + planeSize), ch * planeSize);
+ }
+
+ pcmData = buffer.buffer;
+ } else {
+ const totalSize = sampleCount * channels * info.bytesPerSample;
+ const ptr = getDecodedPlanePtr(ctx, 0);
+ pcmData = module.HEAPU8.slice(ptr, ptr + totalSize).buffer;
+ }
+
+ return { pcmData, format: info.format, channels, sampleRate, sampleCount, pts };
+};
+
+const initEncoder = async (
+ codec: string,
+ numberOfChannels: number,
+ sampleRate: number,
+ bitrate: number,
+) => {
+ await ensureModule();
+
+ const ctx = initEncoderFn(codecToId(codec), numberOfChannels, sampleRate, bitrate);
+ if (ctx === 0) {
+ throw new Error('Failed to initialize AC3 encoder.');
+ }
+
+ return { ctx, frameSize: getEncoderFrameSize(ctx) };
+};
+
+const encode = (ctx: number, audioData: ArrayBuffer, timestamp: number) => {
+ const audioBytes = new Uint8Array(audioData);
+
+ const inputPtr = getEncodeInputPtr(ctx, audioBytes.length);
+ if (inputPtr === 0) {
+ throw new Error('Failed to allocate encoder input buffer.');
+ }
+ module.HEAPU8.set(audioBytes, inputPtr);
+
+ const bytesWritten = encodeFrameFn(ctx, timestamp);
+ if (bytesWritten < 0) {
+ throw new Error(`Encode failed with error code ${bytesWritten}.`);
+ }
+
+ const ptr = getEncodedData(ctx);
+ const encodedData = module.HEAPU8.slice(ptr, ptr + bytesWritten).buffer;
+ const pts = getEncodedPts(ctx);
+ const duration = getEncodedDuration(ctx);
+
+ return { encodedData, pts, duration };
+};
+
+const flushEncoder = (ctx: number) => {
+ flushEncoderFn(ctx);
+};
+
+const onMessage = (data: { id: number; command: WorkerCommand }) => {
+ const { id, command } = data;
+
+ const handleCommand = async (): Promise => {
+ try {
+ let result: WorkerResponseData;
+ const transferables: Transferable[] = [];
+
+ switch (command.type) {
+ case 'init-decoder': {
+ const { ctx, frameSize } = await initDecoder(command.data.codec);
+ result = { type: command.type, ctx, frameSize };
+ }; break;
+
+ case 'decode': {
+ const decoded = decode(command.data.ctx, command.data.encodedData, command.data.timestamp);
+ result = {
+ type: command.type,
+ pcmData: decoded.pcmData,
+ format: decoded.format,
+ channels: decoded.channels,
+ sampleRate: decoded.sampleRate,
+ sampleCount: decoded.sampleCount,
+ pts: decoded.pts,
+ };
+ transferables.push(decoded.pcmData);
+ }; break;
+
+ case 'flush-decoder': {
+ flushDecoderFn(command.data.ctx);
+ result = { type: command.type };
+ }; break;
+
+ case 'close-decoder': {
+ closeDecoderFn(command.data.ctx);
+ result = { type: command.type };
+ }; break;
+
+ case 'init-encoder': {
+ const { ctx, frameSize } = await initEncoder(
+ command.data.codec,
+ command.data.numberOfChannels,
+ command.data.sampleRate,
+ command.data.bitrate,
+ );
+ result = { type: command.type, ctx, frameSize };
+ }; break;
+
+ case 'encode': {
+ const encoded = encode(
+ command.data.ctx,
+ command.data.audioData,
+ command.data.timestamp,
+ );
+ result = {
+ type: command.type,
+ encodedData: encoded.encodedData,
+ pts: encoded.pts,
+ duration: encoded.duration,
+ };
+ transferables.push(encoded.encodedData);
+ }; break;
+
+ case 'flush-encoder': {
+ flushEncoder(command.data.ctx);
+ result = { type: command.type };
+ }; break;
+
+ case 'close-encoder': {
+ closeEncoderFn(command.data.ctx);
+ result = { type: command.type };
+ }; break;
+ }
+
+ const response: WorkerResponse = {
+ id,
+ success: true,
+ data: result,
+ };
+ sendMessage(response, transferables);
+ } catch (error: unknown) {
+ const response: WorkerResponse = {
+ id,
+ success: false,
+ error,
+ };
+ sendMessage(response);
+ }
+ };
+
+ void handleCommand();
+};
+
+const sendMessage = (data: unknown, transferables?: Transferable[]) => {
+ if (parentPort) {
+ parentPort.postMessage(data, transferables ?? []);
+ } else {
+ self.postMessage(data, { transfer: transferables ?? [] });
+ }
+};
+
+let parentPort: {
+ postMessage: (data: unknown, transferables?: Transferable[]) => void;
+ on: (event: string, listener: (data: never) => void) => void;
+} | null = null;
+
+if (typeof self === 'undefined') {
+ const workerModule = 'worker_threads';
+ // eslint-disable-next-line @stylistic/max-len
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-member-access
+ parentPort = require(workerModule).parentPort;
+}
+
+if (parentPort) {
+ parentPort.on('message', onMessage);
+} else {
+ self.addEventListener('message', event => onMessage(event.data as { id: number; command: WorkerCommand }));
+}
diff --git a/packages/ac3/src/decoder.ts b/packages/ac3/src/decoder.ts
new file mode 100644
index 0000000..73cc9fe
--- /dev/null
+++ b/packages/ac3/src/decoder.ts
@@ -0,0 +1,70 @@
+/*!
+ * Copyright (c) 2026-present, Vanilagy and contributors
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ */
+
+import {
+ CustomAudioDecoder,
+ AudioCodec,
+ AudioSample,
+ EncodedPacket,
+ registerDecoder,
+} from 'mediabunny';
+import { sendCommand } from './worker-client';
+
+class Ac3Decoder extends CustomAudioDecoder {
+ private ctx = 0;
+
+ static override supports(codec: AudioCodec): boolean {
+ return codec === 'ac3' || codec === 'eac3';
+ }
+
+ async init() {
+ const result = await sendCommand({
+ type: 'init-decoder',
+ data: { codec: this.codec },
+ });
+ this.ctx = result.ctx;
+ }
+
+ async decode(packet: EncodedPacket) {
+ const encodedData = packet.data.slice().buffer;
+ const timestamp = Math.round(packet.timestamp * this.config.sampleRate);
+
+ const result = await sendCommand({
+ type: 'decode',
+ data: { ctx: this.ctx, encodedData, timestamp },
+ }, [encodedData]);
+
+ const sample = new AudioSample({
+ data: result.pcmData,
+ format: result.format,
+ numberOfChannels: result.channels,
+ sampleRate: result.sampleRate,
+ timestamp: result.pts / result.sampleRate,
+ });
+ this.onSample(sample);
+ }
+
+ async flush() {
+ await sendCommand({ type: 'flush-decoder', data: { ctx: this.ctx } });
+ }
+
+ close() {
+ void sendCommand({ type: 'close-decoder', data: { ctx: this.ctx } });
+ }
+}
+
+/**
+ * Registers AC-3 and E-AC-3 decoders, which Mediabunny will then use automatically when applicable. Make sure to call
+ * this function before starting any decoding task.
+ *
+ * @group \@mediabunny/ac3
+ * @public
+ */
+export const registerAc3Decoder = () => {
+ registerDecoder(Ac3Decoder);
+};
diff --git a/packages/ac3/src/encoder.ts b/packages/ac3/src/encoder.ts
new file mode 100644
index 0000000..1f567a0
--- /dev/null
+++ b/packages/ac3/src/encoder.ts
@@ -0,0 +1,191 @@
+/*!
+ * Copyright (c) 2026-present, Vanilagy and contributors
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ */
+
+import {
+ CustomAudioEncoder,
+ AudioCodec,
+ AudioSample,
+ EncodedPacket,
+ registerEncoder,
+} from 'mediabunny';
+import { sendCommand } from './worker-client';
+import { assert } from './shared';
+import { AC3_SAMPLE_RATES, EAC3_REDUCED_SAMPLE_RATES } from '../../../shared/ac3-misc';
+
+class Ac3Encoder extends CustomAudioEncoder {
+ private ctx = 0;
+ private encoderFrameSize = 0;
+ private sampleRate = 0;
+ private numberOfChannels = 0;
+ private chunkMetadata: EncodedAudioChunkMetadata = {};
+
+ // Accumulate interleaved f32 samples until we have a full frame
+ private pendingBuffer = new Float32Array(2 ** 16);
+ private pendingFrames = 0;
+ private nextSampleTimestampInSamples: number | null = null;
+ private nextPacketTimestampInSamples: number | null = null;
+
+ static override supports(codec: AudioCodec, config: AudioEncoderConfig): boolean {
+ const sampleRates = codec === 'eac3'
+ ? [...AC3_SAMPLE_RATES, ...EAC3_REDUCED_SAMPLE_RATES]
+ : AC3_SAMPLE_RATES;
+
+ return (codec === 'ac3' || codec === 'eac3')
+ && config.numberOfChannels >= 1
+ && config.numberOfChannels <= 8
+ && sampleRates.includes(config.sampleRate);
+ }
+
+ async init() {
+ assert(this.config.bitrate);
+ this.sampleRate = this.config.sampleRate;
+ this.numberOfChannels = this.config.numberOfChannels;
+
+ const result = await sendCommand({
+ type: 'init-encoder',
+ data: {
+ codec: this.codec,
+ numberOfChannels: this.config.numberOfChannels,
+ sampleRate: this.config.sampleRate,
+ bitrate: this.config.bitrate,
+ },
+ });
+
+ this.ctx = result.ctx;
+ this.encoderFrameSize = result.frameSize;
+
+ this.resetInternalState();
+ }
+
+ private resetInternalState() {
+ this.pendingFrames = 0;
+ this.nextSampleTimestampInSamples = null;
+ this.nextPacketTimestampInSamples = null;
+
+ this.chunkMetadata = {
+ decoderConfig: {
+ codec: this.codec === 'ac3' ? 'ac-3' : 'ec-3',
+ numberOfChannels: this.config.numberOfChannels,
+ sampleRate: this.config.sampleRate,
+ },
+ };
+ }
+
+ async encode(audioSample: AudioSample) {
+ if (this.nextSampleTimestampInSamples === null) {
+ this.nextSampleTimestampInSamples = Math.round(audioSample.timestamp * this.sampleRate);
+ this.nextPacketTimestampInSamples = this.nextSampleTimestampInSamples;
+ }
+
+ const channels = this.numberOfChannels;
+ const incomingFrames = audioSample.numberOfFrames;
+
+ // Extract interleaved f32 data
+ const totalBytes = audioSample.allocationSize({ format: 'f32', planeIndex: 0 });
+ const audioBytes = new Uint8Array(totalBytes);
+ audioSample.copyTo(audioBytes, { format: 'f32', planeIndex: 0 });
+ const incomingData = new Float32Array(audioBytes.buffer);
+
+ const requiredSamples = (this.pendingFrames + incomingFrames) * channels;
+ if (requiredSamples > this.pendingBuffer.length) {
+ let newSize = this.pendingBuffer.length;
+ while (newSize < requiredSamples) {
+ newSize *= 2;
+ }
+ const newBuffer = new Float32Array(newSize);
+ newBuffer.set(this.pendingBuffer.subarray(0, this.pendingFrames * channels));
+ this.pendingBuffer = newBuffer;
+ }
+ this.pendingBuffer.set(incomingData, this.pendingFrames * channels);
+ this.pendingFrames += incomingFrames;
+
+ while (this.pendingFrames >= this.encoderFrameSize) {
+ await this.encodeOneFrame();
+ }
+ }
+
+ async flush() {
+ // Pad remaining samples with silence to fill a full frame
+ if (this.pendingFrames > 0) {
+ const channels = this.numberOfChannels;
+ const frameSize = this.encoderFrameSize;
+ const usedSamples = this.pendingFrames * channels;
+ const frameSamples = frameSize * channels;
+
+ this.pendingBuffer.fill(0, usedSamples, frameSamples);
+ this.pendingFrames = frameSize;
+
+ await this.encodeOneFrame();
+ }
+
+ await sendCommand({ type: 'flush-encoder', data: { ctx: this.ctx } });
+
+ this.resetInternalState();
+ }
+
+ close() {
+ void sendCommand({ type: 'close-encoder', data: { ctx: this.ctx } });
+ }
+
+ private async encodeOneFrame() {
+ assert(this.nextSampleTimestampInSamples !== null);
+ assert(this.nextPacketTimestampInSamples !== null);
+
+ const channels = this.numberOfChannels;
+ const frameSize = this.encoderFrameSize;
+ const frameSamples = frameSize * channels;
+
+ const frameData = this.pendingBuffer.slice(0, frameSamples);
+
+ // Shift remaining using copyWithin
+ this.pendingFrames -= frameSize;
+ if (this.pendingFrames > 0) {
+ this.pendingBuffer.copyWithin(0, frameSamples, frameSamples + this.pendingFrames * channels);
+ }
+
+ const audioData = frameData.buffer;
+ const result = await sendCommand({
+ type: 'encode',
+ data: {
+ ctx: this.ctx,
+ audioData,
+ timestamp: this.nextSampleTimestampInSamples,
+ },
+ }, [audioData]);
+
+ this.nextSampleTimestampInSamples += frameSize;
+
+ // We always get exactly one packet because we encode the correct frame size
+ const packet = new EncodedPacket(
+ new Uint8Array(result.encodedData),
+ 'key',
+ this.nextPacketTimestampInSamples / this.sampleRate,
+ result.duration / this.sampleRate,
+ );
+
+ this.nextPacketTimestampInSamples += result.duration;
+
+ this.onPacket(
+ packet,
+ this.chunkMetadata,
+ );
+
+ this.chunkMetadata = {};
+ }
+}
+
+/**
+ * Registers AC-3 and E-AC-3 encoders, which Mediabunny will then use automatically when applicable. Make sure to call
+ * this function before starting any encoding task.
+ *
+ * @group \@mediabunny/ac3
+ * @public
+ */
+export const registerAc3Encoder = () => {
+ registerEncoder(Ac3Encoder);
+};
diff --git a/packages/ac3/src/index.ts b/packages/ac3/src/index.ts
new file mode 100644
index 0000000..79ebf7f
--- /dev/null
+++ b/packages/ac3/src/index.ts
@@ -0,0 +1,21 @@
+/*!
+ * Copyright (c) 2026-present, Vanilagy and contributors
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ */
+
+const AC3_LOADED_SYMBOL = Symbol.for('@mediabunny/ac3 loaded');
+if ((globalThis as Record)[AC3_LOADED_SYMBOL]) {
+ console.error(
+ '[WARNING]\n@mediabunny/ac3 was loaded twice.'
+ + ' This will likely cause the encoder/decoder not to work correctly.'
+ + ' Check if multiple dependencies are importing different versions of @mediabunny/ac3,'
+ + ' or if something is being bundled incorrectly.',
+ );
+}
+(globalThis as Record)[AC3_LOADED_SYMBOL] = true;
+
+export { registerAc3Decoder } from './decoder';
+export { registerAc3Encoder } from './encoder';
diff --git a/packages/ac3/src/shared.ts b/packages/ac3/src/shared.ts
new file mode 100644
index 0000000..ba8d578
--- /dev/null
+++ b/packages/ac3/src/shared.ts
@@ -0,0 +1,103 @@
+/*!
+ * Copyright (c) 2026-present, Vanilagy and contributors
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ */
+
+export type WorkerCommand = {
+ type: 'init-decoder';
+ data: {
+ codec: string;
+ };
+} | {
+ type: 'decode';
+ data: {
+ ctx: number;
+ encodedData: ArrayBuffer;
+ timestamp: number;
+ };
+} | {
+ type: 'flush-decoder';
+ data: {
+ ctx: number;
+ };
+} | {
+ type: 'close-decoder';
+ data: {
+ ctx: number;
+ };
+} | {
+ type: 'init-encoder';
+ data: {
+ codec: string;
+ numberOfChannels: number;
+ sampleRate: number;
+ bitrate: number;
+ };
+} | {
+ type: 'encode';
+ data: {
+ ctx: number;
+ audioData: ArrayBuffer;
+ timestamp: number;
+ };
+} | {
+ type: 'flush-encoder';
+ data: {
+ ctx: number;
+ };
+} | {
+ type: 'close-encoder';
+ data: {
+ ctx: number;
+ };
+};
+
+export type WorkerResponseData = {
+ type: 'init-decoder';
+ ctx: number;
+ frameSize: number;
+} | {
+ type: 'decode';
+ pcmData: ArrayBuffer;
+ format: AudioSampleFormat;
+ channels: number;
+ sampleRate: number;
+ sampleCount: number;
+ pts: number;
+} | {
+ type: 'flush-decoder';
+} | {
+ type: 'close-decoder';
+} | {
+ type: 'init-encoder';
+ ctx: number;
+ frameSize: number;
+} | {
+ type: 'encode';
+ encodedData: ArrayBuffer;
+ pts: number;
+ duration: number;
+} | {
+ type: 'flush-encoder';
+} | {
+ type: 'close-encoder';
+};
+
+export type WorkerResponse = {
+ id: number;
+} & ({
+ success: true;
+ data: WorkerResponseData;
+} | {
+ success: false;
+ error: unknown;
+});
+
+export function assert(x: unknown): asserts x {
+ if (!x) {
+ throw new Error('Assertion failed.');
+ }
+}
diff --git a/packages/ac3/src/worker-client.ts b/packages/ac3/src/worker-client.ts
new file mode 100644
index 0000000..d79e1ae
--- /dev/null
+++ b/packages/ac3/src/worker-client.ts
@@ -0,0 +1,69 @@
+/*!
+ * Copyright (c) 2026-present, Vanilagy and contributors
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ */
+
+import { assert, type WorkerCommand, type WorkerResponse, type WorkerResponseData } from './shared';
+// @ts-expect-error An esbuild plugin handles this, TypeScript doesn't need to understand
+import createWorker from './codec.worker';
+
+let workerPromise: Promise | null;
+let nextMessageId = 0;
+const pendingMessages = new Map void;
+ reject: (reason?: unknown) => void;
+}>();
+
+export const sendCommand = async (
+ command: WorkerCommand & { type: T },
+ transferables?: Transferable[],
+) => {
+ const worker = await ensureWorker();
+
+ return new Promise((resolve, reject) => {
+ const id = nextMessageId++;
+ pendingMessages.set(id, {
+ resolve: resolve as (value: WorkerResponseData) => void,
+ reject,
+ });
+
+ if (transferables) {
+ worker.postMessage({ id, command }, transferables);
+ } else {
+ worker.postMessage({ id, command });
+ }
+ });
+};
+
+const ensureWorker = () => {
+ return workerPromise ??= (async () => {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-call
+ const worker = (await createWorker()) as Worker;
+
+ const onMessage = (data: WorkerResponse) => {
+ const pending = pendingMessages.get(data.id);
+ assert(pending !== undefined);
+
+ pendingMessages.delete(data.id);
+ if (data.success) {
+ pending.resolve(data.data);
+ } else {
+ pending.reject(data.error);
+ }
+ };
+
+ if (worker.addEventListener) {
+ worker.addEventListener('message', event => onMessage(event.data as WorkerResponse));
+ } else {
+ const nodeWorker = worker as unknown as {
+ on: (event: string, listener: (data: never) => void) => void;
+ };
+ nodeWorker.on('message', onMessage);
+ }
+
+ return worker;
+ })();
+};
diff --git a/packages/mp3-encoder/src/tsconfig.json b/packages/ac3/tsconfig.json
similarity index 56%
rename from packages/mp3-encoder/src/tsconfig.json
rename to packages/ac3/tsconfig.json
index 8e70282..27a7ef1 100644
--- a/packages/mp3-encoder/src/tsconfig.json
+++ b/packages/ac3/tsconfig.json
@@ -1,7 +1,8 @@
{
- "extends": "../../../tsconfig.json",
+ "extends": "../../tsconfig.json",
"compilerOptions": {
- "outDir": "../dist/modules",
+ "composite": true,
+ "outDir": "./dist/modules",
"declaration": true,
"declarationMap": true,
"stripInternal": true,
@@ -10,14 +11,14 @@
"module": "nodenext",
"allowJs": true,
"paths": {
- "mediabunny": ["../../../src/index.ts"],
+ "mediabunny": ["../../src/index.ts"],
},
},
"include": [
- "**/*",
- "../../../shared/**/*"
+ "./src/**/*",
+ "./build/**/*",
],
"references": [
- { "path": "../../../src" }
+ { "path": "../../src" }
]
-}
\ No newline at end of file
+}
diff --git a/packages/ac3/tsdoc.json b/packages/ac3/tsdoc.json
new file mode 100644
index 0000000..51d29d3
--- /dev/null
+++ b/packages/ac3/tsdoc.json
@@ -0,0 +1,4 @@
+{
+ "$schema": "https://developer.microsoft.com/json-schemas/tsdoc/v0/tsdoc.schema.json",
+ "extends": ["../../tsdoc.json"]
+}
diff --git a/packages/mp3-encoder/README.md b/packages/mp3-encoder/README.md
index 14178c5..f79acd4 100644
--- a/packages/mp3-encoder/README.md
+++ b/packages/mp3-encoder/README.md
@@ -3,6 +3,7 @@
[](https://www.npmjs.com/package/@mediabunny/mp3-encoder)
[](https://bundlephobia.com/package/@mediabunny/mp3-encoder)
[](https://www.npmjs.com/package/@mediabunny/mp3-encoder)
+[](https://discord.gg/hmpkyYuS4U)
@@ -102,8 +103,6 @@ The WASM build itself is a performance-optimized, SIMD-enabled build of LAME 3.1
## Building and development
-Building this library is done using the build commands in the [Mediabunny root](https://github.com/Vanilagy/mediabunny).
-
For simplicity, all built WASM artifacts are included in the repo, since these rarely change. However, here are the instructions for building them from scratch:
### Prerequisites
@@ -148,7 +147,7 @@ emcc src/lame-bridge.c build/libmp3lame.a \
-o build/lame.js
```
-This generates `build/lame.js`, which contains both the JavaScript "glue code" as well as the compiled WASM encoded with Base64.
+This generates `build/lame.js`, which contains both the JavaScript "glue code" as well as the compiled WASM inlined.
### Building the package
diff --git a/packages/mp3-encoder/package.json b/packages/mp3-encoder/package.json
index bbcde74..8457f35 100644
--- a/packages/mp3-encoder/package.json
+++ b/packages/mp3-encoder/package.json
@@ -1,7 +1,7 @@
{
"name": "@mediabunny/mp3-encoder",
"author": "Vanilagy",
- "version": "1.31.0",
+ "version": "1.34.2",
"description": "MP3 encoder extension for Mediabunny, based on LAME.",
"main": "./dist/bundles/mediabunny-mp3-encoder.mjs",
"module": "./dist/bundles/mediabunny-mp3-encoder.mjs",
diff --git a/packages/mp3-encoder/src/encode.worker.ts b/packages/mp3-encoder/src/encode.worker.ts
index e5745b9..ee24db8 100644
--- a/packages/mp3-encoder/src/encode.worker.ts
+++ b/packages/mp3-encoder/src/encode.worker.ts
@@ -146,7 +146,10 @@ const onMessage = (data: { id: number; command: WorkerCommand }) => {
command.data.sampleRate,
command.data.bitrate,
);
- result = { success: true };
+ result = {
+ type: command.type,
+ success: true,
+ };
}; break;
case 'encode': {
@@ -154,13 +157,19 @@ const onMessage = (data: { id: number; command: WorkerCommand }) => {
command.data.audioData,
command.data.numberOfFrames,
);
- result = { encodedData };
+ result = {
+ type: command.type,
+ encodedData,
+ };
transferables.push(encodedData);
}; break;
case 'flush': {
const flushedData = flush();
- result = { flushedData };
+ result = {
+ type: command.type,
+ flushedData,
+ };
transferables.push(flushedData);
}; break;
}
diff --git a/packages/mp3-encoder/src/index.ts b/packages/mp3-encoder/src/index.ts
index 1519a19..ad32601 100644
--- a/packages/mp3-encoder/src/index.ts
+++ b/packages/mp3-encoder/src/index.ts
@@ -80,6 +80,12 @@ class Mp3Encoder extends CustomAudioEncoder {
},
});
+ this.resetInternalState();
+ }
+
+ private resetInternalState() {
+ this.currentBufferOffset = 0;
+ this.currentTimestamp = null;
this.chunkMetadata = {
decoderConfig: {
codec: 'mp3',
@@ -119,15 +125,15 @@ class Mp3Encoder extends CustomAudioEncoder {
},
}, [audioData]);
- assert('encodedData' in result);
this.digestOutput(new Uint8Array(result.encodedData));
}
async flush() {
const result = await this.sendCommand({ type: 'flush' });
- assert('flushedData' in result);
this.digestOutput(new Uint8Array(result.flushedData));
+
+ this.resetInternalState();
}
close() {
@@ -171,9 +177,7 @@ class Mp3Encoder extends CustomAudioEncoder {
const duration = header.audioSamplesInFrame / header.sampleRate;
this.onPacket(new EncodedPacket(data, 'key', this.currentTimestamp, duration), this.chunkMetadata);
- if (this.currentTimestamp === 0) {
- this.chunkMetadata = {}; // Mimic WebCodecs-like behavior
- }
+ this.chunkMetadata = {}; // Mimic WebCodecs-like behavior
this.currentTimestamp += duration;
pos += header.totalSize;
@@ -186,13 +190,16 @@ class Mp3Encoder extends CustomAudioEncoder {
}
}
- private sendCommand(
- command: WorkerCommand,
+ private sendCommand(
+ command: WorkerCommand & { type: T },
transferables?: Transferable[],
) {
- return new Promise((resolve, reject) => {
+ return new Promise((resolve, reject) => {
const id = this.nextMessageId++;
- this.pendingMessages.set(id, { resolve, reject });
+ this.pendingMessages.set(id, {
+ resolve: resolve as (value: WorkerResponseData) => void,
+ reject,
+ });
assert(this.worker);
diff --git a/packages/mp3-encoder/src/shared.ts b/packages/mp3-encoder/src/shared.ts
index d407003..526dac1 100644
--- a/packages/mp3-encoder/src/shared.ts
+++ b/packages/mp3-encoder/src/shared.ts
@@ -24,10 +24,13 @@ export type WorkerCommand = {
};
export type WorkerResponseData = {
+ type: 'init';
success: boolean;
} | {
+ type: 'encode';
encodedData: ArrayBuffer;
} | {
+ type: 'flush';
flushedData: ArrayBuffer;
};
diff --git a/packages/mp3-encoder/tsconfig.json b/packages/mp3-encoder/tsconfig.json
new file mode 100644
index 0000000..bf2e94f
--- /dev/null
+++ b/packages/mp3-encoder/tsconfig.json
@@ -0,0 +1,25 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "composite": true,
+ "outDir": "./dist/modules",
+ "declaration": true,
+ "declarationMap": true,
+ "stripInternal": true,
+ "noEmit": false,
+ "moduleResolution": "nodenext",
+ "module": "nodenext",
+ "allowJs": true,
+ "paths": {
+ "mediabunny": ["../../src/index.ts"],
+ },
+ },
+ "include": [
+ "./src/**/*",
+ "./build/**/*",
+ "../../shared/**/*",
+ ],
+ "references": [
+ { "path": "../../src" }
+ ]
+}
diff --git a/packages/mp3-encoder/tsdoc.json b/packages/mp3-encoder/tsdoc.json
new file mode 100644
index 0000000..51d29d3
--- /dev/null
+++ b/packages/mp3-encoder/tsdoc.json
@@ -0,0 +1,4 @@
+{
+ "$schema": "https://developer.microsoft.com/json-schemas/tsdoc/v0/tsdoc.schema.json",
+ "extends": ["../../tsdoc.json"]
+}
diff --git a/build.sh b/scripts/build.sh
similarity index 78%
rename from build.sh
rename to scripts/build.sh
index e1d5e58..c0595fe 100755
--- a/build.sh
+++ b/scripts/build.sh
@@ -6,13 +6,15 @@ set -e
# Clear the stuff from last build
rm -rf dist
rm -rf packages/mp3-encoder/dist
+rm -rf packages/ac3/dist
# Ensure license headers on all source files
tsx scripts/ensure-license-headers.ts
# Type check & generate .js and .d.ts files
tsc -p src
-tsc -p packages/mp3-encoder/src
+tsc -p packages/mp3-encoder
+tsc -p packages/ac3
# So that the resulting files use valid ESM imports with file extension. This only runs for the core Mediabunny as only
# it ships the individual files to npm (for tree shaking, because it's large)
@@ -24,14 +26,17 @@ tsx scripts/bundle.ts
# Declaration file rollup and checks
api-extractor run
api-extractor run -c packages/mp3-encoder/api-extractor.json
+api-extractor run -c packages/ac3/api-extractor.json
# Checks that all symbols are documented
tsx scripts/check-docblocks.ts dist/mediabunny.d.ts
tsx scripts/check-docblocks.ts packages/mp3-encoder/dist/mediabunny-mp3-encoder.d.ts
+tsx scripts/check-docblocks.ts packages/ac3/dist/mediabunny-ac3.d.ts
# Checks that API docs are generatable
npm run docs:generate -- --dry
# Appends stuff to the declaration files to register the global variables these libraries expose
echo 'export as namespace Mediabunny;' >> dist/mediabunny.d.ts
-echo 'export as namespace MediabunnyMp3Encoder;' >> packages/mp3-encoder/dist/mediabunny-mp3-encoder.d.ts
\ No newline at end of file
+echo 'export as namespace MediabunnyMp3Encoder;' >> packages/mp3-encoder/dist/mediabunny-mp3-encoder.d.ts
+echo 'export as namespace MediabunnyAc3;' >> packages/ac3/dist/mediabunny-ac3.d.ts
\ No newline at end of file
diff --git a/scripts/bundle.ts b/scripts/bundle.ts
index 5ca0c3d..c1a23d2 100644
--- a/scripts/bundle.ts
+++ b/scripts/bundle.ts
@@ -113,9 +113,41 @@ const mp3EncoderVariants = await createVariants(
},
);
+const ac3Variants = await createVariants(
+ 'packages/ac3/src/index.ts',
+ 'MediabunnyAc3',
+ 'packages/ac3/dist/bundles/mediabunny-ac3',
+ 'js', // The bundles are purely for the browser, not for Node (due to the peer dependecy)
+ {
+ plugins: [
+ PluginExternalGlobal.externalGlobalPlugin({
+ mediabunny: 'Mediabunny',
+ }),
+ inlineWorkerPlugin({
+ define: {
+ 'import.meta.url': '""',
+ },
+ legalComments: 'none',
+ }),
+ ],
+ },
+ {
+ external: ['mediabunny'],
+ plugins: [
+ inlineWorkerPlugin({
+ define: {
+ 'import.meta.url': '""',
+ },
+ legalComments: 'none',
+ }),
+ ],
+ },
+);
+
const contexts = [
...mediabunnyVariants,
...mp3EncoderVariants,
+ ...ac3Variants,
];
if (process.argv[2] === '--watch') {
diff --git a/scripts/check.sh b/scripts/check.sh
new file mode 100755
index 0000000..41aaef3
--- /dev/null
+++ b/scripts/check.sh
@@ -0,0 +1,16 @@
+#!/bin/bash
+set -e
+
+rm -rf dist/modules
+tsc -p src --stripInternal false
+
+tsc -p packages/mp3-encoder --noEmit
+
+rm -rf packages/ac3/dist/modules
+tsc -p packages/ac3
+
+tsc -p tsconfig.vitest.json --noEmit
+
+tsc -p scripts --noEmit
+
+tsc -p tsconfig.vite.json --noEmit
diff --git a/scripts/ensure-license-headers.ts b/scripts/ensure-license-headers.ts
index 231f427..37720ed 100644
--- a/scripts/ensure-license-headers.ts
+++ b/scripts/ensure-license-headers.ts
@@ -36,6 +36,7 @@ const checkDirectory = (dirPath: string) => {
checkDirectory(path.join(__dirname, '..', 'src'));
checkDirectory(path.join(__dirname, '..', 'packages', 'mp3-encoder', 'src'));
+checkDirectory(path.join(__dirname, '..', 'packages', 'ac3', 'src'));
checkDirectory(path.join(__dirname, '..', 'shared'));
if (missingFiles.length > 0) {
diff --git a/shared/ac3-misc.ts b/shared/ac3-misc.ts
new file mode 100644
index 0000000..fa3145f
--- /dev/null
+++ b/shared/ac3-misc.ts
@@ -0,0 +1,13 @@
+/*!
+ * Copyright (c) 2026-present, Vanilagy and contributors
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ */
+
+/** Sample rates indexed by fscod (Table 4.1) */
+export const AC3_SAMPLE_RATES = [48000, 44100, 32000];
+
+/** E-AC-3 reduced sample rates for fscod2 per ATSC A/52:2018 */
+export const EAC3_REDUCED_SAMPLE_RATES = [24000, 22050, 16000];
diff --git a/src/adts/adts-muxer.ts b/src/adts/adts-muxer.ts
index 194f9de..0cadd8a 100644
--- a/src/adts/adts-muxer.ts
+++ b/src/adts/adts-muxer.ts
@@ -62,10 +62,7 @@ export class AdtsMuxer extends Muxer {
const description = meta?.decoderConfig?.description;
- // From the WebCodecs Codec Registry:
- // "If description is present, it is assumed to a AudioSpecificConfig as defined in [iso14496-3] section
- // 1.6.2.1, Table 1.15, and the bitstream is assumed to be in aac.
- // If the description is not present, the bitstream is assumed to be in adts format."
+ // Follows from the Mediabunny Codec Registry:
this.inputIsAdts = !description;
if (!this.inputIsAdts) {
diff --git a/src/codec-data.ts b/src/codec-data.ts
index 2680ab5..5f2ffb5 100644
--- a/src/codec-data.ts
+++ b/src/codec-data.ts
@@ -28,6 +28,7 @@ import {
} from './misc';
import { PacketType } from './packet';
import { MetadataTags } from './metadata';
+import { AC3_SAMPLE_RATES, EAC3_REDUCED_SAMPLE_RATES } from '../shared/ac3-misc';
// References for AVC/HEVC code:
// ISO 14496-15
@@ -2643,3 +2644,361 @@ export const createVorbisComments = (headerBytes: Uint8Array, tags: MetadataTags
return commentHeader;
};
+
+// ============================================================================
+// AC-3 / E-AC-3 Parsing
+// Reference: ETSI TS 102 366 V1.4.1
+// ============================================================================
+
+/**
+ * Channel counts indexed by acmod (Table 4.3).
+ * Does NOT include LFE - add lfeon to get total channel count.
+ */
+export const AC3_ACMOD_CHANNEL_COUNTS = [2, 1, 2, 3, 3, 4, 4, 5];
+
+export interface Ac3FrameInfo {
+ /** Sample rate code */
+ fscod: number;
+ /** Bitstream ID */
+ bsid: number;
+ /** Bitstream mode */
+ bsmod: number;
+ /** Audio coding mode */
+ acmod: number;
+ /** LFE channel on */
+ lfeon: number;
+ /** Bit rate code (0-18, maps to bitrate via Table F.4.1) */
+ bitRateCode: number;
+}
+
+/**
+ * Parse an AC-3 syncframe to extract BSI (Bit Stream Information) fields.
+ * Section 4.3
+ */
+export const parseAc3SyncFrame = (data: Uint8Array): Ac3FrameInfo | null => {
+ if (data.length < 7) {
+ return null;
+ }
+
+ // Check sync word (0x0B77)
+ if (data[0] !== 0x0B || data[1] !== 0x77) {
+ return null;
+ }
+
+ const bitstream = new Bitstream(data);
+ bitstream.skipBits(16); // sync word
+ bitstream.skipBits(16); // crc1
+
+ const fscod = bitstream.readBits(2);
+ if (fscod === 3) {
+ return null; // Reserved, invalid
+ }
+
+ const frmsizecod = bitstream.readBits(6);
+ const bsid = bitstream.readBits(5);
+
+ // Verify this is AC-3
+ if (bsid > 8) {
+ return null;
+ }
+
+ const bsmod = bitstream.readBits(3);
+ const acmod = bitstream.readBits(3);
+
+ // Skip cmixlev (center downmix level) if three front channels are in use (L, C, R).
+ if ((acmod & 0x1) !== 0 && acmod !== 0x1) {
+ bitstream.skipBits(2);
+ }
+
+ // Skip surmixlev (surround downmix level) if surround channels are in use.
+ if ((acmod & 0x4) !== 0) {
+ bitstream.skipBits(2);
+ }
+
+ // Skip dsurmod if stereo (acmod === 2)
+ if (acmod === 0x2) {
+ bitstream.skipBits(2);
+ }
+
+ const lfeon = bitstream.readBits(1);
+ const bitRateCode = Math.floor(frmsizecod / 2);
+
+ return { fscod, bsid, bsmod, acmod, lfeon, bitRateCode };
+};
+
+/**
+ * AC-3 frame sizes in bytes, indexed by [3 * frmsizecod + fscod].
+ * fscod: 0=48kHz, 1=44.1kHz, 2=32kHz
+ * Values are 16-bit words * 2 (to convert to bytes).
+ * Table 4.13
+ */
+export const AC3_FRAME_SIZES = [
+ // frmsizecod, [48kHz, 44.1kHz, 32kHz] in bytes
+ 64 * 2, 69 * 2, 96 * 2,
+ 64 * 2, 70 * 2, 96 * 2,
+ 80 * 2, 87 * 2, 120 * 2,
+ 80 * 2, 88 * 2, 120 * 2,
+ 96 * 2, 104 * 2, 144 * 2,
+ 96 * 2, 105 * 2, 144 * 2,
+ 112 * 2, 121 * 2, 168 * 2,
+ 112 * 2, 122 * 2, 168 * 2,
+ 128 * 2, 139 * 2, 192 * 2,
+ 128 * 2, 140 * 2, 192 * 2,
+ 160 * 2, 174 * 2, 240 * 2,
+ 160 * 2, 175 * 2, 240 * 2,
+ 192 * 2, 208 * 2, 288 * 2,
+ 192 * 2, 209 * 2, 288 * 2,
+ 224 * 2, 243 * 2, 336 * 2,
+ 224 * 2, 244 * 2, 336 * 2,
+ 256 * 2, 278 * 2, 384 * 2,
+ 256 * 2, 279 * 2, 384 * 2,
+ 320 * 2, 348 * 2, 480 * 2,
+ 320 * 2, 349 * 2, 480 * 2,
+ 384 * 2, 417 * 2, 576 * 2,
+ 384 * 2, 418 * 2, 576 * 2,
+ 448 * 2, 487 * 2, 672 * 2,
+ 448 * 2, 488 * 2, 672 * 2,
+ 512 * 2, 557 * 2, 768 * 2,
+ 512 * 2, 558 * 2, 768 * 2,
+ 640 * 2, 696 * 2, 960 * 2,
+ 640 * 2, 697 * 2, 960 * 2,
+ 768 * 2, 835 * 2, 1152 * 2,
+ 768 * 2, 836 * 2, 1152 * 2,
+ 896 * 2, 975 * 2, 1344 * 2,
+ 896 * 2, 976 * 2, 1344 * 2,
+ 1024 * 2, 1114 * 2, 1536 * 2,
+ 1024 * 2, 1115 * 2, 1536 * 2,
+ 1152 * 2, 1253 * 2, 1728 * 2,
+ 1152 * 2, 1254 * 2, 1728 * 2,
+ 1280 * 2, 1393 * 2, 1920 * 2,
+ 1280 * 2, 1394 * 2, 1920 * 2,
+];
+
+/** Number of samples per AC-3 syncframe (always 1536) */
+export const AC3_SAMPLES_PER_FRAME = 1536;
+
+/**
+ * AC-3 registration_descriptor for MPEG-TS.
+ * Section A.2.3
+ */
+export const AC3_REGISTRATION_DESCRIPTOR = new Uint8Array([0x05, 0x04, 0x41, 0x43, 0x2d, 0x33]);
+
+/** E-AC-3 registration_descriptor for MPEG-TS/ */
+export const EAC3_REGISTRATION_DESCRIPTOR = new Uint8Array([0x05, 0x04, 0x45, 0x41, 0x43, 0x33]);
+
+/** Number of audio blocks per syncframe, indexed by numblkscod */
+export const EAC3_NUMBLKS_TABLE = [1, 2, 3, 6];
+
+/**
+ * E-AC-3 independent substream info.
+ * Each independent substream represents a separate audio program.
+ */
+export interface Eac3SubstreamInfo {
+ /** Sample rate code */
+ fscod: number;
+ /** Sample rate code 2 (ATSC A/52:2018) */
+ fscod2: number | null;
+ /** Bitstream ID */
+ bsid: number;
+ /** Bitstream mode */
+ bsmod: number;
+ /** Audio coding mode */
+ acmod: number;
+ /** LFE channel on */
+ lfeon: number;
+ /** Number of dependent substreams */
+ numDepSub: number;
+ /** Channel locations for dependent substreams */
+ chanLoc: number;
+}
+
+/**
+ * E-AC-3 decoder configuration (dec3 box contents).
+ */
+export interface Eac3FrameInfo {
+ /** Data rate in kbps */
+ dataRate: number;
+ /** Independent substreams */
+ substreams: Eac3SubstreamInfo[];
+}
+
+/**
+ * Parse an E-AC-3 syncframe to extract BSI fields.
+ * Section E.1.2
+ */
+export const parseEac3SyncFrame = (data: Uint8Array): Eac3FrameInfo | null => {
+ if (data.length < 6) {
+ return null;
+ }
+
+ // Check sync word (0x0B77)
+ if (data[0] !== 0x0B || data[1] !== 0x77) {
+ return null;
+ }
+
+ const bitstream = new Bitstream(data);
+ bitstream.skipBits(16); // sync word
+
+ const strmtyp = bitstream.readBits(2);
+ bitstream.skipBits(3); // substreamid
+
+ // Only parse independent substreams (strmtyp 0 or 2)
+ if (strmtyp !== 0 && strmtyp !== 2) {
+ return null;
+ }
+
+ const frmsiz = bitstream.readBits(11);
+ const fscod = bitstream.readBits(2);
+
+ let fscod2 = 0;
+ let numblkscod: number;
+
+ if (fscod === 3) {
+ // fscod2 enables reduced sample rates (24/22.05/16 kHz) per ATSC A/52:2018
+ fscod2 = bitstream.readBits(2);
+ numblkscod = 3; // Implicitly 6 blocks when fscod=3
+ } else {
+ numblkscod = bitstream.readBits(2);
+ }
+
+ const acmod = bitstream.readBits(3);
+ const lfeon = bitstream.readBits(1);
+ const bsid = bitstream.readBits(5);
+
+ // Verify this is E-AC-3
+ if (bsid < 11 || bsid > 16) {
+ return null;
+ }
+
+ // Calculate data rate: ((frmsiz + 1) * fs) / (numblks * 16)
+ const numblks = EAC3_NUMBLKS_TABLE[numblkscod]!;
+ let fs: number;
+ if (fscod < 3) {
+ fs = AC3_SAMPLE_RATES[fscod]! / 1000;
+ } else {
+ fs = EAC3_REDUCED_SAMPLE_RATES[fscod2]! / 1000;
+ }
+ const dataRate = Math.round(((frmsiz + 1) * fs) / (numblks * 16));
+
+ // These fields require parsing beyond the first frame.
+ // Defaults are correct for almost all content.
+ const bsmod = 0;
+ const numDepSub = 0;
+ const chanLoc = 0;
+
+ const substream: Eac3SubstreamInfo = {
+ fscod,
+ fscod2,
+ bsid,
+ bsmod,
+ acmod,
+ lfeon,
+ numDepSub,
+ chanLoc,
+ };
+
+ return {
+ dataRate,
+ substreams: [substream],
+ };
+};
+
+/**
+ * Parse a dec3 box to extract E-AC-3 parameters.
+ * Section F.6
+ */
+export const parseEac3Config = (data: Uint8Array): Eac3FrameInfo | null => {
+ if (data.length < 2) {
+ return null;
+ }
+
+ const bitstream = new Bitstream(data);
+
+ const dataRate = bitstream.readBits(13);
+ const numIndSub = bitstream.readBits(3);
+
+ const substreams: Eac3SubstreamInfo[] = [];
+
+ for (let i = 0; i <= numIndSub; i++) {
+ // Check we have enough data for this substream
+ // Each substream needs at least 24 bits (3 bytes) without dependent subs
+ if (Math.ceil(bitstream.pos / 8) + 3 > data.length) {
+ break;
+ }
+
+ const fscod = bitstream.readBits(2);
+ const bsid = bitstream.readBits(5);
+ bitstream.skipBits(1); // reserved
+ bitstream.skipBits(1); // asvc
+ const bsmod = bitstream.readBits(3);
+ const acmod = bitstream.readBits(3);
+ const lfeon = bitstream.readBits(1);
+ bitstream.skipBits(3); // reserved
+ const numDepSub = bitstream.readBits(4);
+
+ let chanLoc = 0;
+
+ if (numDepSub > 0) {
+ chanLoc = bitstream.readBits(9);
+ } else {
+ bitstream.skipBits(1); // reserved
+ }
+
+ substreams.push({
+ fscod,
+ fscod2: null,
+ bsid,
+ bsmod,
+ acmod,
+ lfeon,
+ numDepSub,
+ chanLoc,
+ });
+ }
+
+ if (substreams.length === 0) {
+ return null;
+ }
+
+ return { dataRate, substreams };
+};
+
+/**
+ * Get sample rate from E-AC-3 config.
+ * See ATSC A/52:2018 for handling fscod2.
+ */
+export const getEac3SampleRate = (config: Eac3FrameInfo): number | null => {
+ const sub = config.substreams[0];
+ assert(sub);
+
+ if (sub.fscod < 3) {
+ return AC3_SAMPLE_RATES[sub.fscod]!;
+ } else if (sub.fscod2 !== null && sub.fscod2 < 3) {
+ return EAC3_REDUCED_SAMPLE_RATES[sub.fscod2]!;
+ }
+
+ return null;
+};
+
+/**
+ * Get channel count from E-AC-3 config (first independent substream only).
+ */
+export const getEac3ChannelCount = (config: Eac3FrameInfo): number => {
+ const sub = config.substreams[0];
+ assert(sub);
+
+ let channels = AC3_ACMOD_CHANNEL_COUNTS[sub.acmod]! + sub.lfeon;
+
+ // Add channels from dependent substreams
+ if (sub.numDepSub > 0) {
+ const CHAN_LOC_COUNTS = [2, 2, 1, 1, 2, 2, 2, 1, 1];
+
+ for (let bit = 0; bit < 9; bit++) {
+ if (sub.chanLoc & (1 << (8 - bit))) {
+ channels += CHAN_LOC_COUNTS[bit]!;
+ }
+ }
+ }
+
+ return channels;
+};
diff --git a/src/codec.ts b/src/codec.ts
index 2317ead..b7086f6 100644
--- a/src/codec.ts
+++ b/src/codec.ts
@@ -70,6 +70,8 @@ export const NON_PCM_AUDIO_CODECS = [
'mp3',
'vorbis',
'flac',
+ 'ac3',
+ 'eac3',
] as const;
/**
* List of known audio codecs, ordered by encoding preference.
@@ -528,6 +530,10 @@ export const buildAudioCodecString = (codec: AudioCodec, numberOfChannels: numbe
return 'vorbis';
} else if (codec === 'flac') {
return 'flac';
+ } else if (codec === 'ac3') {
+ return 'ac-3';
+ } else if (codec === 'eac3') {
+ return 'ec-3';
} else if ((PCM_AUDIO_CODECS as readonly string[]).includes(codec)) {
return codec;
}
@@ -573,6 +579,10 @@ export const extractAudioCodecString = (trackInfo: {
return 'vorbis';
} else if (codec === 'flac') {
return 'flac';
+ } else if (codec === 'ac3') {
+ return 'ac-3';
+ } else if (codec === 'eac3') {
+ return 'ec-3';
} else if (codec && (PCM_AUDIO_CODECS as readonly string[]).includes(codec)) {
return codec;
}
@@ -742,6 +752,10 @@ export const inferCodecFromCodecString = (codecString: string): MediaCodec | nul
return 'vorbis';
} else if (codecString === 'flac') {
return 'flac';
+ } else if (codecString === 'ac-3' || codecString === 'ac3') {
+ return 'ac3';
+ } else if (codecString === 'ec-3' || codecString === 'eac3') {
+ return 'eac3';
} else if (codecString === 'ulaw') {
return 'ulaw';
} else if (codecString === 'alaw') {
@@ -819,7 +833,7 @@ export const validateVideoChunkMetadata = (metadata: EncodedVideoChunkMetadata |
if (!VALID_VIDEO_CODEC_STRING_PREFIXES.some(prefix => metadata.decoderConfig!.codec.startsWith(prefix))) {
throw new TypeError(
'Video chunk metadata decoder configuration codec string must be a valid video codec string as specified in'
- + ' the WebCodecs Codec Registry.',
+ + ' the Mediabunny Codec Registry.',
);
}
if (!Number.isInteger(metadata.decoderConfig.codedWidth) || metadata.decoderConfig.codedWidth! <= 0) {
@@ -933,7 +947,9 @@ export const validateVideoChunkMetadata = (metadata: EncodedVideoChunkMetadata |
}
};
-const VALID_AUDIO_CODEC_STRING_PREFIXES = ['mp4a', 'mp3', 'opus', 'vorbis', 'flac', 'ulaw', 'alaw', 'pcm'];
+const VALID_AUDIO_CODEC_STRING_PREFIXES = [
+ 'mp4a', 'mp3', 'opus', 'vorbis', 'flac', 'ulaw', 'alaw', 'pcm', 'ac-3', 'ec-3',
+];
export const validateAudioChunkMetadata = (metadata: EncodedAudioChunkMetadata | undefined) => {
if (!metadata) {
@@ -954,7 +970,7 @@ export const validateAudioChunkMetadata = (metadata: EncodedAudioChunkMetadata |
if (!VALID_AUDIO_CODEC_STRING_PREFIXES.some(prefix => metadata.decoderConfig!.codec.startsWith(prefix))) {
throw new TypeError(
'Audio chunk metadata decoder configuration codec string must be a valid audio codec string as specified in'
- + ' the WebCodecs Codec Registry.',
+ + ' the Mediabunny Codec Registry.',
);
}
if (!Number.isInteger(metadata.decoderConfig.sampleRate) || metadata.decoderConfig.sampleRate <= 0) {
@@ -1051,6 +1067,18 @@ export const validateAudioChunkMetadata = (metadata: EncodedAudioChunkMetadata |
+ ' adhere to the format described in https://www.w3.org/TR/webcodecs-flac-codec-registration/.',
);
}
+ } else if (metadata.decoderConfig.codec.startsWith('ac-3') || metadata.decoderConfig.codec.startsWith('ac3')) {
+ // AC3-specific validation
+
+ if (metadata.decoderConfig.codec !== 'ac-3') {
+ throw new TypeError('Audio chunk metadata decoder configuration codec string for AC-3 must be "ac-3".');
+ }
+ } else if (metadata.decoderConfig.codec.startsWith('ec-3') || metadata.decoderConfig.codec.startsWith('eac3')) {
+ // EAC3-specific validation
+
+ if (metadata.decoderConfig.codec !== 'ec-3') {
+ throw new TypeError('Audio chunk metadata decoder configuration codec string for EC-3 must be "ec-3".');
+ }
} else if (
metadata.decoderConfig.codec.startsWith('pcm')
|| metadata.decoderConfig.codec.startsWith('ulaw')
diff --git a/src/conversion.ts b/src/conversion.ts
index 151e644..0a4ac13 100644
--- a/src/conversion.ts
+++ b/src/conversion.ts
@@ -564,11 +564,11 @@ export class Conversion {
if (options.trim !== undefined && (!options.trim || typeof options.trim !== 'object')) {
throw new TypeError('options.trim, when provided, must be an object.');
}
- if (options.trim?.start !== undefined && (!Number.isFinite(options.trim.start) || options.trim.start < 0)) {
- throw new TypeError('options.trim.start, when provided, must be a non-negative number.');
+ if (options.trim?.start !== undefined && (!Number.isFinite(options.trim.start))) {
+ throw new TypeError('options.trim.start, when provided, must be a finite number.');
}
- if (options.trim?.end !== undefined && (!Number.isFinite(options.trim.end) || options.trim.end < 0)) {
- throw new TypeError('options.trim.end, when provided, must be a non-negative number.');
+ if (options.trim?.end !== undefined && (!Number.isFinite(options.trim.end))) {
+ throw new TypeError('options.trim.end, when provided, must be a finite number.');
}
if (
options.trim?.start !== undefined
@@ -607,7 +607,7 @@ export class Conversion {
// those out by default.
0,
);
- this._endTimestamp = this._options.trim?.end ?? Infinity;
+ this._endTimestamp = Math.max(this._options.trim?.end ?? Infinity, this._startTimestamp);
const inputTracks = await this.input.getTracks();
const outputTrackCounts = this.output.format.getSupportedTrackCounts();
@@ -773,6 +773,13 @@ export class Conversion {
`\nThe @mediabunny/mp3-encoder extension package provides support for encoding MP3.`,
);
}
+
+ if (codecs.includes('ac3') || codecs.includes('eac3')) {
+ elements.push(
+ '\nThe @mediabunny/ac3 extension package provides support'
+ + ' for encoding and decoding AC-3/E-AC-3.',
+ );
+ }
} else {
elements.push('\nCheck the discardedTracks field for more info.');
}
@@ -1326,7 +1333,8 @@ export class Conversion {
let sampleRate = trackOptions.sampleRate ?? originalSampleRate;
let needsResample = numberOfChannels !== originalNumberOfChannels
|| sampleRate !== originalSampleRate
- || firstTimestamp < this._startTimestamp;
+ || firstTimestamp < this._startTimestamp
+ || (firstTimestamp > this._startTimestamp && !this.output.format.supportsTimestampedMediaData);
let audioCodecs = this.output.format.getSupportedAudioCodecs();
if (
diff --git a/src/encode.ts b/src/encode.ts
index 4d3c3d9..165dc3b 100644
--- a/src/encode.ts
+++ b/src/encode.ts
@@ -127,7 +127,7 @@ export type VideoEncodingAdditionalOptions = {
*/
latencyMode?: 'quality' | 'realtime';
/**
- * The full codec string as specified in the WebCodecs Codec Registry. This string must match the codec
+ * The full codec string as specified in the Mediabunny Codec Registry. This string must match the codec
* specified in `codec`. When not set, a fitting codec string will be constructed automatically by the library.
*/
fullCodecString?: string;
@@ -280,7 +280,7 @@ export type AudioEncodingAdditionalOptions = {
/** Configures the bitrate mode. */
bitrateMode?: 'constant' | 'variable';
/**
- * The full codec string as specified in the WebCodecs Codec Registry. This string must match the codec
+ * The full codec string as specified in the Mediabunny Codec Registry. This string must match the codec
* specified in `codec`. When not set, a fitting codec string will be constructed automatically by the library.
*/
fullCodecString?: string;
@@ -375,6 +375,8 @@ export class Quality {
opus: 64000, // 64kbps base for Opus
mp3: 160000, // 160kbps base for MP3
vorbis: 64000, // 64kbps base for Vorbis
+ ac3: 384000, // 384kbps base for AC-3
+ eac3: 192000, // 192kbps base for E-AC-3
};
const baseBitrate = baseRates[codec as keyof typeof baseRates];
diff --git a/src/input-format.ts b/src/input-format.ts
index fa437d2..c9ef78e 100644
--- a/src/input-format.ts
+++ b/src/input-format.ts
@@ -21,14 +21,14 @@ import {
} from './matroska/ebml';
import { MatroskaDemuxer } from './matroska/matroska-demuxer';
import { Mp3Demuxer } from './mp3/mp3-demuxer';
-import { FRAME_HEADER_SIZE } from '../shared/mp3-misc';
+import { FRAME_HEADER_SIZE, getXingOffset, INFO, XING } from '../shared/mp3-misc';
import { ID3_V2_HEADER_SIZE, readId3V2Header } from './id3';
import { readNextMp3FrameHeader } from './mp3/mp3-reader';
import { OggDemuxer } from './ogg/ogg-demuxer';
import { WaveDemuxer } from './wave/wave-demuxer';
import { MAX_ADTS_FRAME_HEADER_SIZE, MIN_ADTS_FRAME_HEADER_SIZE, readAdtsFrameHeader } from './adts/adts-reader';
import { AdtsDemuxer } from './adts/adts-demuxer';
-import { readAscii, readBytes } from './reader';
+import { readAscii, readBytes, readU32Be } from './reader';
import { FlacDemuxer } from './flac/flac-demuxer';
import { MpegTsDemuxer } from './mpeg-ts/mpeg-ts-demuxer';
import { TS_PACKET_SIZE } from './mpeg-ts/mpeg-ts-misc';
@@ -261,10 +261,6 @@ export class WebMInputFormat extends MatroskaInputFormat {
export class Mp3InputFormat extends InputFormat {
/** @internal */
async _canReadInput(input: Input) {
- let slice = input._reader.requestSlice(0, 10);
- if (slice instanceof Promise) slice = await slice;
- if (!slice) return false;
-
let currentPos = 0;
while (true) {
@@ -285,6 +281,21 @@ export class Mp3InputFormat extends InputFormat {
return false;
}
+ const firstHeader = firstResult.header;
+ const xingOffset = getXingOffset(firstHeader.mpegVersionId, firstHeader.channel);
+
+ let slice = input._reader.requestSlice(firstResult.startPos + xingOffset, 4);
+ if (slice instanceof Promise) slice = await slice;
+ if (!slice) return false;
+
+ const word = readU32Be(slice);
+ const isXing = word === XING || word === INFO;
+
+ if (isXing) {
+ // Gotta be MP3
+ return true;
+ }
+
currentPos = firstResult.startPos + firstResult.header.totalSize;
// Fine, we found one frame header, but we're still not entirely sure this is MP3. Let's check if we can find
@@ -294,7 +305,6 @@ export class Mp3InputFormat extends InputFormat {
return false;
}
- const firstHeader = firstResult.header;
const secondHeader = secondResult.header;
// In a well-formed MP3 file, we'd expect these two frames to share some similarities:
diff --git a/src/isobmff/isobmff-boxes.ts b/src/isobmff/isobmff-boxes.ts
index c1061d9..63330ba 100644
--- a/src/isobmff/isobmff-boxes.ts
+++ b/src/isobmff/isobmff-boxes.ts
@@ -20,6 +20,7 @@ import {
UNDETERMINED_LANGUAGE,
assertNever,
keyValueIterator,
+ Bitstream,
} from '../misc';
import {
AudioCodec,
@@ -43,7 +44,7 @@ import {
IsobmffVideoTrackData,
Sample,
} from './isobmff-muxer';
-import { parseOpusIdentificationHeader } from '../codec-data';
+import { parseAc3SyncFrame, parseEac3SyncFrame, parseOpusIdentificationHeader } from '../codec-data';
import { MetadataTags, RichImageData } from '../metadata';
export class IsobmffBoxWriter {
@@ -907,6 +908,79 @@ const pcmC = (trackData: IsobmffAudioTrackData) => {
]);
};
+/** AC3SpecificBox */
+const dac3 = (trackData: IsobmffAudioTrackData) => {
+ const frameInfo = parseAc3SyncFrame(trackData.info.firstPacket.data);
+ if (!frameInfo) {
+ throw new Error(
+ 'Couldn\'t extract AC-3 frame info from the audio packet. '
+ + 'Ensure the packets contain valid AC-3 sync frames (as specified in ETSI TS 102 366).',
+ );
+ }
+
+ const bytes = new Uint8Array(3);
+ const bitstream = new Bitstream(bytes);
+
+ bitstream.writeBits(2, frameInfo.fscod);
+ bitstream.writeBits(5, frameInfo.bsid);
+ bitstream.writeBits(3, frameInfo.bsmod);
+ bitstream.writeBits(3, frameInfo.acmod);
+ bitstream.writeBits(1, frameInfo.lfeon);
+ bitstream.writeBits(5, frameInfo.bitRateCode);
+ bitstream.writeBits(5, 0); // reserved
+
+ return box('dac3', [...bytes]);
+};
+
+/** EC3SpecificBox */
+const dec3 = (trackData: IsobmffAudioTrackData) => {
+ const frameInfo = parseEac3SyncFrame(trackData.info.firstPacket.data);
+ if (!frameInfo) {
+ throw new Error(
+ 'Couldn\'t extract E-AC-3 frame info from the audio packet. '
+ + 'Ensure the packets contain valid E-AC-3 sync frames (as specified in ETSI TS 102 366).',
+ );
+ }
+
+ // Calculate size
+ let totalBits = 16; // header: data_rate (13) + num_ind_sub (3)
+ for (const sub of frameInfo.substreams) {
+ totalBits += 23; // fixed fields per substream
+ if (sub.numDepSub > 0) {
+ totalBits += 9; // chan_loc
+ } else {
+ totalBits += 1; // reserved
+ }
+ }
+ const size = Math.ceil(totalBits / 8);
+
+ const bytes = new Uint8Array(size);
+ const bitstream = new Bitstream(bytes);
+
+ bitstream.writeBits(13, frameInfo.dataRate);
+ bitstream.writeBits(3, frameInfo.substreams.length - 1); // num_ind_sub
+
+ for (const sub of frameInfo.substreams) {
+ bitstream.writeBits(2, sub.fscod);
+ bitstream.writeBits(5, sub.bsid);
+ bitstream.writeBits(1, 0); // reserved
+ bitstream.writeBits(1, 0); // asvc = 0
+ bitstream.writeBits(3, sub.bsmod);
+ bitstream.writeBits(3, sub.acmod);
+ bitstream.writeBits(1, sub.lfeon);
+ bitstream.writeBits(3, 0); // reserved
+ bitstream.writeBits(4, sub.numDepSub);
+
+ if (sub.numDepSub > 0) {
+ bitstream.writeBits(9, sub.chanLoc);
+ } else {
+ bitstream.writeBits(1, 0); // reserved
+ }
+ }
+
+ return box('dec3', [...bytes]);
+};
+
export const subtitleSampleDescription = (
compressionType: string,
trackData: IsobmffSubtitleTrackData,
@@ -1606,6 +1680,8 @@ const audioCodecToBoxName = (codec: AudioCodec, isQuickTime: boolean): string =>
case 'alaw': return 'alaw';
case 'pcm-u8': return 'raw ';
case 'pcm-s8': return 'sowt';
+ case 'ac3': return 'ac-3';
+ case 'eac3': return 'ec-3';
}
// Logic diverges here
@@ -1645,6 +1721,8 @@ const audioCodecToConfigurationBox = (codec: AudioCodec, isQuickTime: boolean) =
case 'opus': return dOps;
case 'vorbis': return esds;
case 'flac': return dfLa;
+ case 'ac3': return dac3;
+ case 'eac3': return dec3;
}
// Logic diverges here
diff --git a/src/isobmff/isobmff-demuxer.ts b/src/isobmff/isobmff-demuxer.ts
index 22447c8..c81ab00 100644
--- a/src/isobmff/isobmff-demuxer.ts
+++ b/src/isobmff/isobmff-demuxer.ts
@@ -27,6 +27,10 @@ import {
FlacBlockType,
HevcDecoderConfigurationRecord,
Vp9CodecInfo,
+ parseEac3Config,
+ getEac3SampleRate,
+ getEac3ChannelCount,
+ AC3_ACMOD_CHANNEL_COUNTS,
} from '../codec-data';
import { Demuxer } from '../demuxer';
import { Input } from '../input';
@@ -87,6 +91,7 @@ import {
readAscii,
} from '../reader';
import { DEFAULT_TRACK_DISPOSITION, MetadataTags, RichImageData, TrackDisposition } from '../metadata';
+import { AC3_SAMPLE_RATES } from '../../shared/ac3-misc';
type InternalTrack = {
id: number;
@@ -946,6 +951,10 @@ export class IsobmffDemuxer extends Demuxer {
track.info.codec = 'ulaw';
} else if (lowercaseBoxName === 'alaw') {
track.info.codec = 'alaw';
+ } else if (lowercaseBoxName === 'ac-3') {
+ track.info.codec = 'ac3';
+ } else if (lowercaseBoxName === 'ec-3') {
+ track.info.codec = 'eac3';
} else {
console.warn(`Unsupported audio codec (sample entry type '${sampleBoxInfo.name}').`);
}
@@ -1457,6 +1466,51 @@ export class IsobmffDemuxer extends Demuxer {
track.info.codecDescription = description;
}; break;
+ case 'dac3': { // AC3SpecificBox
+ const track = this.currentTrack;
+ if (!track) {
+ break;
+ }
+ assert(track.info?.type === 'audio');
+
+ const bytes = readBytes(slice, 3);
+ const bitstream = new Bitstream(bytes);
+
+ const fscod = bitstream.readBits(2);
+ bitstream.skipBits(5 + 3); // Skip bsid and bsmod
+ const acmod = bitstream.readBits(3);
+ const lfeon = bitstream.readBits(1);
+
+ if (fscod < 3) {
+ track.info.sampleRate = AC3_SAMPLE_RATES[fscod]!;
+ }
+
+ track.info.numberOfChannels = AC3_ACMOD_CHANNEL_COUNTS[acmod]! + lfeon;
+ }; break;
+
+ case 'dec3': { // EC3SpecificBox
+ const track = this.currentTrack;
+ if (!track) {
+ break;
+ }
+ assert(track.info?.type === 'audio');
+
+ const bytes = readBytes(slice, boxInfo.contentSize);
+ const config = parseEac3Config(bytes);
+
+ if (!config) {
+ console.warn('Invalid dec3 box contents, ignoring.');
+ break;
+ }
+
+ const sampleRate = getEac3SampleRate(config);
+ if (sampleRate !== null) {
+ track.info.sampleRate = sampleRate;
+ }
+
+ track.info.numberOfChannels = getEac3ChannelCount(config);
+ }; break;
+
case 'stts': {
const track = this.currentTrack;
if (!track) {
diff --git a/src/isobmff/isobmff-muxer.ts b/src/isobmff/isobmff-muxer.ts
index d78b8fe..5074a1d 100644
--- a/src/isobmff/isobmff-muxer.ts
+++ b/src/isobmff/isobmff-muxer.ts
@@ -111,6 +111,7 @@ export type IsobmffTrackData = {
* ADTS-wrapped data.
*/
requiresAdtsStripping: boolean;
+ firstPacket: EncodedPacket;
};
} | {
track: OutputSubtitleTrack;
@@ -429,8 +430,9 @@ export class IsobmffMuxer extends Muxer {
!this.isFragmented
&& (PCM_AUDIO_CODECS as readonly string[]).includes(track.source._codec),
requiresAdtsStripping,
+ firstPacket: packet,
},
- timescale: meta.decoderConfig.sampleRate,
+ timescale: decoderConfig.sampleRate,
samples: [],
sampleQueue: [],
timestampProcessingQueue: [],
diff --git a/src/matroska/ebml.ts b/src/matroska/ebml.ts
index e764e81..ba1b736 100644
--- a/src/matroska/ebml.ts
+++ b/src/matroska/ebml.ts
@@ -744,6 +744,8 @@ export const CODEC_STRING_MAP: Partial> = {
'opus': 'A_OPUS',
'vorbis': 'A_VORBIS',
'flac': 'A_FLAC',
+ 'ac3': 'A_AC3',
+ 'eac3': 'A_EAC3',
'pcm-u8': 'A_PCM/INT/LIT',
'pcm-s16': 'A_PCM/INT/LIT',
'pcm-s16be': 'A_PCM/INT/BIG',
diff --git a/src/matroska/matroska-demuxer.ts b/src/matroska/matroska-demuxer.ts
index fb3eb74..c7c8f26 100644
--- a/src/matroska/matroska-demuxer.ts
+++ b/src/matroska/matroska-demuxer.ts
@@ -1078,6 +1078,12 @@ export class MatroskaDemuxer extends Demuxer {
} else if (codecIdWithoutSuffix === CODEC_STRING_MAP.flac) {
this.currentTrack.info.codec = 'flac';
this.currentTrack.info.codecDescription = this.currentTrack.codecPrivate;
+ } else if (codecIdWithoutSuffix === CODEC_STRING_MAP.ac3) {
+ this.currentTrack.info.codec = 'ac3';
+ this.currentTrack.info.codecDescription = this.currentTrack.codecPrivate;
+ } else if (codecIdWithoutSuffix === CODEC_STRING_MAP.eac3) {
+ this.currentTrack.info.codec = 'eac3';
+ this.currentTrack.info.codecDescription = this.currentTrack.codecPrivate;
} else if (this.currentTrack.codecId === 'A_PCM/INT/LIT') {
if (this.currentTrack.info.bitDepth === 8) {
this.currentTrack.info.codec = 'pcm-u8';
diff --git a/src/media-source.ts b/src/media-source.ts
index 362733c..a65a9da 100644
--- a/src/media-source.ts
+++ b/src/media-source.ts
@@ -227,7 +227,6 @@ class VideoEncoderWrapper {
* So, we keep track of the encoder error and throw it as soon as we get the chance.
*/
private error: Error | null = null;
- private errorNeedsNewStack = true;
constructor(private source: VideoSource, private encodingConfig: VideoEncodingConfig) {}
@@ -413,7 +412,6 @@ class VideoEncoderWrapper {
}
private ensureEncoder(videoSample: VideoSample) {
- const encoderError = new Error();
this.ensureEncoderPromise = (async () => {
const encoderConfig = buildVideoEncoderConfig({
width: videoSample.codedWidth,
@@ -448,7 +446,6 @@ class VideoEncoderWrapper {
void this.muxer!.addEncodedVideoPacket(this.source._connectedTrack!, packet, meta)
.catch((error) => {
this.error ??= error;
- this.errorNeedsNewStack = false;
});
};
@@ -520,10 +517,11 @@ class VideoEncoderWrapper {
void this.muxer!.addEncodedVideoPacket(this.source._connectedTrack!, packet, meta)
.catch((error) => {
this.error ??= error;
- this.errorNeedsNewStack = false;
});
};
+ const stack = new Error('Encoding error').stack;
+
this.encoder = new VideoEncoder({
output: (chunk, meta) => {
if (!this.alphaEncoder) {
@@ -560,13 +558,15 @@ class VideoEncoderWrapper {
}
},
error: (error) => {
- error.stack = encoderError.stack; // Provide a more useful stack trace
+ error.stack = stack; // Provide a more useful stack trace, the default one sucks
this.error ??= error;
},
});
this.encoder.configure(encoderConfig);
if (this.encodingConfig.alpha === 'keep') {
+ const stack = new Error('Encoding error').stack;
+
// We need to encode alpha as well, which we do with a separate encoder
this.alphaEncoder = new VideoEncoder({
// We ignore the alpha chunk's metadata
@@ -594,7 +594,7 @@ class VideoEncoderWrapper {
}
},
error: (error) => {
- error.stack = encoderError.stack; // Provide a more useful stack trace
+ error.stack = stack; // Provide a more useful stack trace
this.error ??= error;
},
});
@@ -652,10 +652,6 @@ class VideoEncoderWrapper {
checkForEncoderError() {
if (this.error) {
- if (this.errorNeedsNewStack) {
- this.error.stack = new Error().stack; // Provide an even more useful stack trace
- }
-
throw this.error;
}
}
@@ -1351,7 +1347,6 @@ class AudioEncoderWrapper {
* So, we keep track of the encoder error and throw it as soon as we get the chance.
*/
private error: Error | null = null;
- private errorNeedsNewStack = true;
constructor(private source: AudioSource, private encodingConfig: AudioEncodingConfig) {}
@@ -1546,7 +1541,6 @@ class AudioEncoderWrapper {
}
private ensureEncoder(audioSample: AudioSample) {
- const encoderError = new Error();
this.ensureEncoderPromise = (async () => {
const { numberOfChannels, sampleRate } = audioSample;
@@ -1582,7 +1576,6 @@ class AudioEncoderWrapper {
void this.muxer!.addEncodedAudioPacket(this.source._connectedTrack!, packet, meta)
.catch((error) => {
this.error ??= error;
- this.errorNeedsNewStack = false;
});
};
@@ -1603,6 +1596,8 @@ class AudioEncoderWrapper {
);
}
+ const stack = new Error('Encoding error').stack;
+
this.encoder = new AudioEncoder({
output: (chunk, meta) => {
// WebKit emits an invalid description for AAC (https://bugs.webkit.org/show_bug.cgi?id=302253),
@@ -1637,11 +1632,10 @@ class AudioEncoderWrapper {
void this.muxer!.addEncodedAudioPacket(this.source._connectedTrack!, packet, meta)
.catch((error) => {
this.error ??= error;
- this.errorNeedsNewStack = false;
});
},
error: (error) => {
- error.stack = encoderError.stack; // Provide a more useful stack trace
+ error.stack = stack; // Provide a more useful stack trace
this.error ??= error;
},
});
@@ -1782,10 +1776,6 @@ class AudioEncoderWrapper {
checkForEncoderError() {
if (this.error) {
- if (this.errorNeedsNewStack) {
- this.error.stack = new Error().stack; // Provide an even more useful stack trace
- }
-
throw this.error;
}
}
diff --git a/src/mpeg-ts/mpeg-ts-demuxer.ts b/src/mpeg-ts/mpeg-ts-demuxer.ts
index dff51d8..f8a6939 100644
--- a/src/mpeg-ts/mpeg-ts-demuxer.ts
+++ b/src/mpeg-ts/mpeg-ts-demuxer.ts
@@ -19,16 +19,24 @@ import {
VideoCodec,
} from '../codec';
import {
+ AC3_ACMOD_CHANNEL_COUNTS,
+ AC3_SAMPLES_PER_FRAME,
AvcDecoderConfigurationRecord,
AvcNalUnitType,
extractAvcDecoderConfigurationRecord,
extractHevcDecoderConfigurationRecord,
extractNalUnitTypeForAvc,
extractNalUnitTypeForHevc,
+ EAC3_NUMBLKS_TABLE,
+ getEac3ChannelCount,
+ getEac3SampleRate,
HevcDecoderConfigurationRecord,
HevcNalUnitType,
+ parseAc3SyncFrame,
parseAvcSps,
+ parseEac3SyncFrame,
parseHevcSps,
+ AC3_FRAME_SIZES,
} from '../codec-data';
import { Demuxer } from '../demuxer';
import { Input } from '../input';
@@ -62,6 +70,10 @@ import { FRAME_HEADER_SIZE as MP3_FRAME_HEADER_SIZE, readMp3FrameHeader } from '
import { EncodedPacket, PacketType, PLACEHOLDER_DATA } from '../packet';
import { FileSlice, readBytes, Reader, readU16Be, readU32Be, readU8 } from '../reader';
import { buildMpegTsMimeType, MpegTsStreamType, TIMESCALE, TS_PACKET_SIZE } from './mpeg-ts-misc';
+import { AC3_SAMPLE_RATES } from '../../shared/ac3-misc';
+
+// Resources:
+// ISO/IEC 13818-1
type ElementaryStream = {
demuxer: MpegTsDemuxer;
@@ -266,7 +278,20 @@ export class MpegTsDemuxer extends Demuxer {
bitstream.skipBits(6);
const esInfoLength = bitstream.readBits(10);
- bitstream.skipBits(8 * esInfoLength);
+ // Check ES descriptors to detect AC-3/E-AC-3 in System B
+ const esInfoEndPos = bitstream.pos + 8 * esInfoLength;
+ let hasAc3Descriptor = false;
+ let hasEac3Descriptor = false;
+ while (bitstream.pos < esInfoEndPos) {
+ const descriptorTag = bitstream.readBits(8);
+ const descriptorLength = bitstream.readBits(8);
+ if (descriptorTag === 0x6a) {
+ hasAc3Descriptor = true;
+ } else if (descriptorTag === 0x7a || descriptorTag === 0xcc) {
+ hasEac3Descriptor = true;
+ }
+ bitstream.skipBits(8 * descriptorLength);
+ }
let info: ElementaryStream['info'] | null = null;
@@ -306,6 +331,46 @@ export class MpegTsDemuxer extends Demuxer {
};
}; break;
+ case MpegTsStreamType.AC3_SYSTEM_A: {
+ info = {
+ type: 'audio',
+ codec: 'ac3',
+ aacCodecInfo: null,
+ numberOfChannels: -1,
+ sampleRate: -1,
+ };
+ }; break;
+
+ case MpegTsStreamType.EAC3_SYSTEM_A: {
+ info = {
+ type: 'audio',
+ codec: 'eac3',
+ aacCodecInfo: null,
+ numberOfChannels: -1,
+ sampleRate: -1,
+ };
+ }; break;
+
+ case MpegTsStreamType.PRIVATE_DATA: {
+ if (hasEac3Descriptor) {
+ info = {
+ type: 'audio',
+ codec: 'eac3',
+ aacCodecInfo: null,
+ numberOfChannels: -1,
+ sampleRate: -1,
+ };
+ } else if (hasAc3Descriptor) {
+ info = {
+ type: 'audio',
+ codec: 'ac3',
+ aacCodecInfo: null,
+ numberOfChannels: -1,
+ sampleRate: -1,
+ };
+ }
+ }; break;
+
default: {
// If we don't recognize the codec, we don't surface the track at all. This is because
// we can't determine its metadata and also have no idea how to packetize its data.
@@ -436,6 +501,44 @@ export class MpegTsDemuxer extends Demuxer {
elementaryStream.info.numberOfChannels = result.header.channel === 3 ? 1 : 2;
elementaryStream.info.sampleRate = result.header.sampleRate;
+ elementaryStream.initialized = true;
+ } else if (elementaryStream.info.codec === 'ac3') {
+ const frameInfo = parseAc3SyncFrame(pesPacket.data);
+ if (!frameInfo) {
+ throw new Error(
+ 'Invalid AC-3 audio stream; could not read sync frame from first packet.',
+ );
+ }
+
+ if (frameInfo.fscod === 3) {
+ throw new Error(
+ 'Invalid AC-3 audio stream; reserved sample rate code found in first packet.',
+ );
+ }
+
+ elementaryStream.info.numberOfChannels
+ = AC3_ACMOD_CHANNEL_COUNTS[frameInfo.acmod]! + frameInfo.lfeon;
+ elementaryStream.info.sampleRate = AC3_SAMPLE_RATES[frameInfo.fscod]!;
+
+ elementaryStream.initialized = true;
+ } else if (elementaryStream.info.codec === 'eac3') {
+ const frameInfo = parseEac3SyncFrame(pesPacket.data);
+ if (!frameInfo) {
+ throw new Error(
+ 'Invalid E-AC-3 audio stream; could not read sync frame from first packet.',
+ );
+ }
+
+ const sampleRate = getEac3SampleRate(frameInfo);
+ if (sampleRate === null) {
+ throw new Error(
+ 'Invalid E-AC-3 audio stream; reserved sample rate code found in first packet.',
+ );
+ }
+
+ elementaryStream.info.numberOfChannels = getEac3ChannelCount(frameInfo);
+ elementaryStream.info.sampleRate = sampleRate;
+
elementaryStream.initialized = true;
} else {
throw new Error('Unhandled.');
@@ -1674,6 +1777,91 @@ const markNextPacket = async (context: PacketReadingContext) => {
} else {
context.seekTo(possibleHeaderStartPos + 1);
}
+ } else if (codec === 'ac3') {
+ if (byte !== 0x0b) {
+ continue;
+ }
+
+ context.skip(-1);
+ const possibleSyncPos = context.currentPos;
+
+ // Need at least 5 bytes for sync word + CRC + fscod/frmsizecod
+ let remaining = context.ensureBuffered(5);
+ if (remaining instanceof Promise) remaining = await remaining;
+
+ if (remaining < 5) {
+ return;
+ }
+
+ const headerBytes = context.readBytes(5);
+
+ // Verify sync word (0x0B77)
+ if (headerBytes[0] !== 0x0b || headerBytes[1] !== 0x77) {
+ context.seekTo(possibleSyncPos + 1);
+ continue;
+ }
+
+ const fscod = headerBytes[4]! >> 6;
+ const frmsizecod = headerBytes[4]! & 0x3f;
+
+ if (fscod === 3 || frmsizecod > 37) {
+ // Invalid
+ context.seekTo(possibleSyncPos + 1);
+ continue;
+ }
+
+ const frameSize = AC3_FRAME_SIZES[3 * frmsizecod + fscod];
+ assert(frameSize !== undefined);
+
+ context.seekTo(possibleSyncPos);
+
+ remaining = context.ensureBuffered(frameSize);
+ if (remaining instanceof Promise) remaining = await remaining;
+
+ const duration = Math.round(
+ AC3_SAMPLES_PER_FRAME * TIMESCALE / elementaryStream.info.sampleRate,
+ );
+ return context.supplyPacket(remaining, duration);
+ } else if (codec === 'eac3') {
+ if (byte !== 0x0b) {
+ continue;
+ }
+
+ context.skip(-1);
+ const possibleSyncPos = context.currentPos;
+
+ // Need at least 5 bytes for E-AC-3 header parsing (sync word + frmsiz + fscod/numblkscod)
+ let remaining = context.ensureBuffered(5);
+ if (remaining instanceof Promise) remaining = await remaining;
+
+ if (remaining < 5) {
+ return;
+ }
+
+ const headerBytes = context.readBytes(5);
+
+ if (headerBytes[0] !== 0x0b || headerBytes[1] !== 0x77) {
+ context.seekTo(possibleSyncPos + 1);
+ continue;
+ }
+
+ const frmsiz = ((headerBytes[2]! & 0x07) << 8) | headerBytes[3]!;
+ const frameSize = (frmsiz + 1) * 2;
+ const fscod = headerBytes[4]! >> 6;
+ const numblkscod = fscod === 3 ? 3 : (headerBytes[4]! >> 4) & 0x03;
+ const numblks = EAC3_NUMBLKS_TABLE[numblkscod]!;
+
+ context.seekTo(possibleSyncPos);
+
+ remaining = context.ensureBuffered(frameSize);
+ if (remaining instanceof Promise) remaining = await remaining;
+
+ // Duration = numblks * 256 samples per block
+ const samplesPerFrame = numblks * 256;
+ const duration = Math.round(
+ samplesPerFrame * TIMESCALE / elementaryStream.info.sampleRate,
+ );
+ return context.supplyPacket(remaining, duration);
} else {
throw new Error('Unhandled.');
}
diff --git a/src/mpeg-ts/mpeg-ts-misc.ts b/src/mpeg-ts/mpeg-ts-misc.ts
index 85a0fd4..d19d062 100644
--- a/src/mpeg-ts/mpeg-ts-misc.ts
+++ b/src/mpeg-ts/mpeg-ts-misc.ts
@@ -13,6 +13,9 @@ export const enum MpegTsStreamType {
MP3_MPEG1 = 0x03,
MP3_MPEG2 = 0x04,
AAC = 0x0f,
+ AC3_SYSTEM_A = 0x81,
+ EAC3_SYSTEM_A = 0x87,
+ PRIVATE_DATA = 0x06,
AVC = 0x1b,
HEVC = 0x24,
}
diff --git a/src/mpeg-ts/mpeg-ts-muxer.ts b/src/mpeg-ts/mpeg-ts-muxer.ts
index b0774bc..e4977cc 100644
--- a/src/mpeg-ts/mpeg-ts-muxer.ts
+++ b/src/mpeg-ts/mpeg-ts-muxer.ts
@@ -9,11 +9,13 @@
import { parseAacAudioSpecificConfig, validateAudioChunkMetadata, validateVideoChunkMetadata } from '../codec';
import { buildAdtsHeaderTemplate, writeAdtsFrameLength } from '../adts/adts-misc';
import {
+ AC3_REGISTRATION_DESCRIPTOR,
AvcDecoderConfigurationRecord,
AvcNalUnitType,
concatNalUnitsInAnnexB,
deserializeAvcDecoderConfigurationRecord,
deserializeHevcDecoderConfigurationRecord,
+ EAC3_REGISTRATION_DESCRIPTOR,
extractNalUnitTypeForAvc,
extractNalUnitTypeForHevc,
HevcDecoderConfigurationRecord,
@@ -23,12 +25,15 @@ import {
} from '../codec-data';
import { assert, Bitstream, promiseWithResolvers, setUint24, toDataView, toUint8Array } from '../misc';
import { Muxer } from '../muxer';
-import { Output, OutputAudioTrack, OutputVideoTrack } from '../output';
+import { Output, OutputAudioTrack, OutputTrack, OutputVideoTrack } from '../output';
import { MpegTsOutputFormat } from '../output-format';
import { EncodedPacket } from '../packet';
import { Writer } from '../writer';
import { buildMpegTsMimeType, MpegTsStreamType, TIMESCALE, TS_PACKET_SIZE } from './mpeg-ts-misc';
+// Resources:
+// ISO/IEC 13818-1
+
const PAT_PID = 0x0000;
const PMT_PID = 0x1000;
const FIRST_TRACK_PID = 0x0100;
@@ -45,6 +50,7 @@ type MpegTsTrackData = {
streamType: MpegTsStreamType;
streamId: number;
codecString: string;
+ timestampProcessingQueue: QueuedPacket[];
packetQueue: QueuedPacket[];
inputIsAnnexB: boolean | null;
inputIsAdts: boolean | null;
@@ -57,7 +63,8 @@ type MpegTsTrackData = {
type QueuedPacket = {
data: Uint8Array;
- timestamp: number;
+ presentationTimestamp: number;
+ decodeTimestamp: number | null;
isKeyframe: boolean;
};
@@ -75,9 +82,6 @@ export class MpegTsMuxer extends Muxer {
private videoTrackIndex = 0;
private audioTrackIndex = 0;
- private pesHeaderBuffer = new Uint8Array(14);
- private pesHeaderView = toDataView(this.pesHeaderBuffer);
- private ptsBitstream = new Bitstream(this.pesHeaderBuffer.subarray(9, 14));
private adaptationFieldBuffer = new Uint8Array(184);
private payloadBuffer = new Uint8Array(184);
@@ -122,6 +126,7 @@ export class MpegTsMuxer extends Muxer {
streamType,
streamId,
codecString: meta.decoderConfig.codec,
+ timestampProcessingQueue: [],
packetQueue: [],
inputIsAnnexB: null,
inputIsAdts: null,
@@ -151,11 +156,34 @@ export class MpegTsMuxer extends Muxer {
assert(meta?.decoderConfig);
const codec = track.source._codec;
- assert(codec === 'aac' || codec === 'mp3');
+ assert(codec === 'aac' || codec === 'mp3' || codec === 'ac3' || codec === 'eac3');
+
+ let streamType: MpegTsStreamType;
+ let streamId: number;
+
+ switch (codec) {
+ case 'aac': {
+ streamType = MpegTsStreamType.AAC;
+ streamId = AUDIO_STREAM_ID_BASE + this.audioTrackIndex++;
+ }; break;
+
+ case 'mp3': {
+ streamType = MpegTsStreamType.MP3_MPEG1;
+ streamId = AUDIO_STREAM_ID_BASE + this.audioTrackIndex++;
+ }; break;
+
+ case 'ac3': {
+ streamType = MpegTsStreamType.AC3_SYSTEM_A;
+ streamId = 0xbd;
+ }; break;
+
+ case 'eac3': {
+ streamType = MpegTsStreamType.EAC3_SYSTEM_A;
+ streamId = 0xbd;
+ }; break;
+ }
- const streamType = codec === 'aac' ? MpegTsStreamType.AAC : MpegTsStreamType.MP3_MPEG1;
const pid = FIRST_TRACK_PID + this.trackDatas.length;
- const streamId = AUDIO_STREAM_ID_BASE + this.audioTrackIndex++;
const newTrackData: MpegTsTrackData = {
track,
@@ -163,6 +191,7 @@ export class MpegTsMuxer extends Muxer {
streamType,
streamId,
codecString: meta.decoderConfig.codec,
+ timestampProcessingQueue: [],
packetQueue: [],
inputIsAnnexB: null,
inputIsAdts: null,
@@ -200,13 +229,16 @@ export class MpegTsMuxer extends Muxer {
const preparedData = this.prepareVideoPacket(trackData, packet, meta);
- trackData.packetQueue.push({
+ if (packet.type === 'key') {
+ await this.flushTimestampQueue(trackData);
+ }
+
+ trackData.timestampProcessingQueue.push({
data: preparedData,
- timestamp,
+ presentationTimestamp: timestamp,
+ decodeTimestamp: null,
isKeyframe: packet.type === 'key',
});
-
- await this.interleavePackets();
} finally {
release();
}
@@ -230,13 +262,16 @@ export class MpegTsMuxer extends Muxer {
const preparedData = this.prepareAudioPacket(trackData, packet, meta);
- trackData.packetQueue.push({
+ if (packet.type === 'key') {
+ await this.flushTimestampQueue(trackData);
+ }
+
+ trackData.timestampProcessingQueue.push({
data: preparedData,
- timestamp,
+ presentationTimestamp: timestamp,
+ decodeTimestamp: null,
isKeyframe: packet.type === 'key',
});
-
- await this.interleavePackets();
} finally {
release();
}
@@ -373,7 +408,7 @@ export class MpegTsMuxer extends Muxer {
): Uint8Array {
const codec = (trackData.track as OutputAudioTrack).source._codec;
- if (codec === 'mp3') {
+ if (codec === 'mp3' || codec === 'ac3' || codec === 'eac3') {
// We're good
return packet.data;
}
@@ -419,6 +454,28 @@ export class MpegTsMuxer extends Muxer {
return true;
}
+ private async flushTimestampQueue(trackData: MpegTsTrackData, alsoInterleave = true) {
+ if (trackData.timestampProcessingQueue.length === 0) {
+ return;
+ }
+
+ const sortedTimestamps = trackData.timestampProcessingQueue
+ .map(packet => packet.presentationTimestamp)
+ .sort((a, b) => a - b);
+
+ for (let i = 0; i < trackData.timestampProcessingQueue.length; i++) {
+ const queuedPacket = trackData.timestampProcessingQueue[i]!;
+ queuedPacket.decodeTimestamp = sortedTimestamps[i]!;
+ trackData.packetQueue.push(queuedPacket);
+ }
+
+ trackData.timestampProcessingQueue.length = 0;
+
+ if (alsoInterleave) {
+ await this.interleavePackets();
+ }
+ }
+
private async interleavePackets(isFinalCall = false) {
if (!this.tablesWritten) {
if (!this.allTracksAreKnown() && !isFinalCall) {
@@ -444,10 +501,10 @@ export class MpegTsMuxer extends Muxer {
if (
trackData.packetQueue.length > 0
- && trackData.packetQueue[0]!.timestamp < minTimestamp
+ && trackData.packetQueue[0]!.presentationTimestamp < minTimestamp
) {
trackWithMinTimestamp = trackData;
- minTimestamp = trackData.packetQueue[0]!.timestamp;
+ minTimestamp = trackData.packetQueue[0]!.presentationTimestamp;
}
}
@@ -501,10 +558,14 @@ export class MpegTsMuxer extends Muxer {
}
private writePesPacket(trackData: MpegTsTrackData, queuedPacket: QueuedPacket) {
- const pesView = this.pesHeaderView;
+ const includeDts = trackData.track.type === 'video';
+ const headerDataLength = includeDts ? 10 : 5;
+ const pesHeaderBuffer = new Uint8Array(9 + headerDataLength);
+ const pesView = toDataView(pesHeaderBuffer);
+ const ptsDtsBitstream = new Bitstream(pesHeaderBuffer.subarray(9));
setUint24(pesView, 0, 0x000001, false); // packet_start_code_prefix
- this.pesHeaderBuffer[3] = trackData.streamId; // stream_id
+ pesHeaderBuffer[3] = trackData.streamId; // stream_id
const pesPacketLength = trackData.track.type === 'video'
? 0 // Unbounded
@@ -514,20 +575,33 @@ export class MpegTsMuxer extends Muxer {
// '10' marker, PES_scrambling_control=0, PES_priority=0,
// data_alignment_indicator=1, copyright=0, original_or_copy=0
pesView.setUint8(6, 0x84);
- pesView.setUint8(7, 0x80); // PTS_DTS_flags=10 (PTS only), other flags=0
- pesView.setUint8(8, 5); // PES_header_data_length (5 bytes for PTS)
+ pesView.setUint8(7, includeDts ? 0xC0 : 0x80); // PTS_DTS_flags, other flags=0
+ pesView.setUint8(8, headerDataLength); // PES_header_data_length
- const pts = Math.round(queuedPacket.timestamp * TIMESCALE);
- this.ptsBitstream.pos = 0;
- this.ptsBitstream.writeBits(4, 0b0010); // marker
- this.ptsBitstream.writeBits(3, (pts >>> 30) & 0x7); // PTS[32:30]
- this.ptsBitstream.writeBits(1, 1); // marker_bit
- this.ptsBitstream.writeBits(15, (pts >>> 15) & 0x7FFF); // PTS[29:15]
- this.ptsBitstream.writeBits(1, 1); // marker_bit
- this.ptsBitstream.writeBits(15, pts & 0x7FFF); // PTS[14:0]
- this.ptsBitstream.writeBits(1, 1); // marker_bit
+ const pts = Math.round(queuedPacket.presentationTimestamp * TIMESCALE);
+ ptsDtsBitstream.pos = 0;
+ ptsDtsBitstream.writeBits(4, includeDts ? 0b0011 : 0b0010); // marker
+ ptsDtsBitstream.writeBits(3, (pts >>> 30) & 0x7); // PTS[32:30]
+ ptsDtsBitstream.writeBits(1, 1); // marker_bit
+ ptsDtsBitstream.writeBits(15, (pts >>> 15) & 0x7FFF); // PTS[29:15]
+ ptsDtsBitstream.writeBits(1, 1); // marker_bit
+ ptsDtsBitstream.writeBits(15, pts & 0x7FFF); // PTS[14:0]
+ ptsDtsBitstream.writeBits(1, 1); // marker_bit
- const totalLength = this.pesHeaderBuffer.length + queuedPacket.data.length;
+ if (includeDts) {
+ assert(queuedPacket.decodeTimestamp !== null);
+
+ const dts = Math.round(queuedPacket.decodeTimestamp * TIMESCALE);
+ ptsDtsBitstream.writeBits(4, 0b0001);
+ ptsDtsBitstream.writeBits(3, (dts >>> 30) & 0x7); // DTS[32:30]
+ ptsDtsBitstream.writeBits(1, 1); // marker_bit
+ ptsDtsBitstream.writeBits(15, (dts >>> 15) & 0x7FFF); // DTS[29:15]
+ ptsDtsBitstream.writeBits(1, 1); // marker_bit
+ ptsDtsBitstream.writeBits(15, dts & 0x7FFF); // DTS[14:0]
+ ptsDtsBitstream.writeBits(1, 1); // marker_bit
+ }
+
+ const totalLength = pesHeaderBuffer.length + queuedPacket.data.length;
let offset = 0;
let isFirstTsPacket = true;
@@ -568,13 +642,13 @@ export class MpegTsMuxer extends Muxer {
const payload = this.payloadBuffer.subarray(0, payloadSize);
let payloadOffset = 0;
- if (offset < this.pesHeaderBuffer.length) {
- const headerBytes = Math.min(this.pesHeaderBuffer.length - offset, payloadSize);
- payload.set(this.pesHeaderBuffer.subarray(offset, offset + headerBytes), 0);
+ if (offset < pesHeaderBuffer.length) {
+ const headerBytes = Math.min(pesHeaderBuffer.length - offset, payloadSize);
+ payload.set(pesHeaderBuffer.subarray(offset, offset + headerBytes), 0);
payloadOffset = headerBytes;
}
- const dataStart = Math.max(0, offset - this.pesHeaderBuffer.length);
+ const dataStart = Math.max(0, offset - pesHeaderBuffer.length);
const dataEnd = dataStart + (payloadSize - payloadOffset);
if (payloadOffset < payloadSize) {
payload.set(queuedPacket.data.subarray(dataStart, dataEnd), payloadOffset);
@@ -633,13 +707,18 @@ export class MpegTsMuxer extends Muxer {
}
// eslint-disable-next-line @typescript-eslint/no-misused-promises
- override async onTrackClose() {
+ override async onTrackClose(track: OutputTrack) {
const release = await this.mutex.acquire();
if (this.allTracksAreKnown()) {
this.allTracksKnown.resolve();
}
+ const trackData = this.trackDatas.find(x => x.track === track);
+ if (trackData) {
+ await this.flushTimestampQueue(trackData, false);
+ }
+
await this.interleavePackets();
release();
@@ -650,6 +729,10 @@ export class MpegTsMuxer extends Muxer {
this.allTracksKnown.resolve();
+ for (const trackData of this.trackDatas) {
+ await this.flushTimestampQueue(trackData, false);
+ }
+
await this.interleavePackets(true);
release();
@@ -695,7 +778,18 @@ const PAT_SECTION = new Uint8Array(16);
}
const buildPmt = (trackDatas: MpegTsTrackData[]) => {
- const sectionLength = 9 + trackDatas.length * 5 + 4;
+ let totalEsBytes = 0;
+ for (const trackData of trackDatas) {
+ totalEsBytes += 5;
+
+ if (trackData.streamType === MpegTsStreamType.AC3_SYSTEM_A) {
+ totalEsBytes += AC3_REGISTRATION_DESCRIPTOR.length;
+ } else if (trackData.streamType === MpegTsStreamType.EAC3_SYSTEM_A) {
+ totalEsBytes += EAC3_REGISTRATION_DESCRIPTOR.length;
+ }
+ }
+
+ const sectionLength = 9 + totalEsBytes + 4;
const section = new Uint8Array(3 + sectionLength - 4);
const view = toDataView(section);
@@ -714,8 +808,21 @@ const buildPmt = (trackDatas: MpegTsTrackData[]) => {
section[offset++] = trackData.streamType; // stream_type
view.setUint16(offset, 0xE000 | (trackData.pid & 0x1FFF), false); // reserved=111, elementary_PID
offset += 2;
- view.setUint16(offset, 0xF000, false); // reserved=1111, ES_info_length=0
- offset += 2;
+
+ if (trackData.streamType === MpegTsStreamType.AC3_SYSTEM_A) {
+ view.setUint16(offset, 0xF000 | AC3_REGISTRATION_DESCRIPTOR.length, false);
+ offset += 2;
+ section.set(AC3_REGISTRATION_DESCRIPTOR, offset);
+ offset += AC3_REGISTRATION_DESCRIPTOR.length;
+ } else if (trackData.streamType === MpegTsStreamType.EAC3_SYSTEM_A) {
+ view.setUint16(offset, 0xF000 | EAC3_REGISTRATION_DESCRIPTOR.length, false);
+ offset += 2;
+ section.set(EAC3_REGISTRATION_DESCRIPTOR, offset);
+ offset += EAC3_REGISTRATION_DESCRIPTOR.length;
+ } else {
+ view.setUint16(offset, 0xF000, false); // reserved=1111, ES_info_length=0
+ offset += 2;
+ }
}
const crc = computeMpegTsCrc32(section);
diff --git a/src/output-format.ts b/src/output-format.ts
index d9dee08..adef3a9 100644
--- a/src/output-format.ts
+++ b/src/output-format.ts
@@ -74,6 +74,13 @@ export abstract class OutputFormat {
abstract getSupportedTrackCounts(): TrackCountLimits;
/** Whether this output format supports video rotation metadata. */
abstract get supportsVideoRotationMetadata(): boolean;
+ /**
+ * Whether this output format's tracks store timestamped media data. When `true`, the timestamps of added packets
+ * will be respected, allowing things like gaps in media data or non-zero start times. When `false`, the format's
+ * media data implicitly starts at zero and follows an implicit sequential timing from there, using the intrinsic
+ * durations of the media data.
+ */
+ abstract get supportsTimestampedMediaData(): boolean;
/** Returns a list of video codecs that this output format can contain. */
getSupportedVideoCodecs() {
@@ -258,6 +265,10 @@ export abstract class IsobmffOutputFormat extends OutputFormat {
return true;
}
+ get supportsTimestampedMediaData() {
+ return true;
+ }
+
/** @internal */
_createMuxer(output: Output) {
return new IsobmffMuxer(output, this);
@@ -292,7 +303,8 @@ export class Mp4OutputFormat extends IsobmffOutputFormat {
return [
...VIDEO_CODECS,
...NON_PCM_AUDIO_CODECS,
- // These are supported via ISO/IEC 23003-5
+
+ // These are supported via ISO/IEC 23003-5:
'pcm-s16',
'pcm-s16be',
'pcm-s24',
@@ -303,6 +315,7 @@ export class Mp4OutputFormat extends IsobmffOutputFormat {
'pcm-f32be',
'pcm-f64',
'pcm-f64be',
+
...SUBTITLE_CODECS,
];
}
@@ -488,6 +501,10 @@ export class MkvOutputFormat extends OutputFormat {
// While it technically does support it with ProjectionPoseRoll, many players appear to ignore this value
return false;
}
+
+ get supportsTimestampedMediaData() {
+ return true;
+ }
}
/**
@@ -624,6 +641,10 @@ export class Mp3OutputFormat extends OutputFormat {
get supportsVideoRotationMetadata() {
return false;
}
+
+ get supportsTimestampedMediaData() {
+ return false;
+ }
}
/**
@@ -722,6 +743,10 @@ export class WavOutputFormat extends OutputFormat {
get supportsVideoRotationMetadata() {
return false;
}
+
+ get supportsTimestampedMediaData() {
+ return false;
+ }
}
/**
@@ -813,6 +838,10 @@ export class OggOutputFormat extends OutputFormat {
get supportsVideoRotationMetadata() {
return false;
}
+
+ get supportsTimestampedMediaData() {
+ return false;
+ }
}
/**
@@ -887,6 +916,10 @@ export class AdtsOutputFormat extends OutputFormat {
get supportsVideoRotationMetadata() {
return false;
}
+
+ get supportsTimestampedMediaData() {
+ return false;
+ }
}
/**
@@ -958,6 +991,10 @@ export class FlacOutputFormat extends OutputFormat {
get supportsVideoRotationMetadata() {
return false;
}
+
+ get supportsTimestampedMediaData() {
+ return false;
+ }
}
/**
@@ -1032,11 +1069,15 @@ export class MpegTsOutputFormat extends OutputFormat {
getSupportedCodecs(): MediaCodec[] {
return [
...VIDEO_CODECS.filter(codec => ['avc', 'hevc'].includes(codec)),
- ...AUDIO_CODECS.filter(codec => ['aac', 'mp3'].includes(codec)),
+ ...AUDIO_CODECS.filter(codec => ['aac', 'mp3', 'ac3', 'eac3'].includes(codec)),
];
}
get supportsVideoRotationMetadata() {
return false;
}
+
+ get supportsTimestampedMediaData() {
+ return true;
+ }
}
diff --git a/src/packet.ts b/src/packet.ts
index 2fe7269..36901f4 100644
--- a/src/packet.ts
+++ b/src/packet.ts
@@ -56,7 +56,10 @@ export class EncodedPacket {
/** Creates a new {@link EncodedPacket} from raw bytes and timing information. */
constructor(
- /** The encoded data of this packet. */
+ /**
+ * The encoded data of this packet. For any given codec, this data must adhere to the format specified in the
+ * Mediabunny Codec Registry.
+ */
public readonly data: Uint8Array,
/** The type of this packet. */
public readonly type: PacketType,
diff --git a/src/source.ts b/src/source.ts
index 07de065..6a98a5f 100644
--- a/src/source.ts
+++ b/src/source.ts
@@ -355,6 +355,9 @@ export type UrlSourceOptions = {
/** The maximum number of bytes the cache is allowed to hold in memory. Defaults to 64 MiB. */
maxCacheSize?: number;
+ /** The maximum number of parallel requests to use for fetching. Defaults to 2. */
+ parallelism?: number;
+
/**
* A WHATWG-compatible fetch function. You can use this field to polyfill the `fetch` function, add missing
* features, or use a custom implementation.
@@ -415,6 +418,9 @@ export class UrlSource extends Source {
) {
throw new TypeError('options.maxCacheSize, when provided, must be a non-negative number.');
}
+ if (options.parallelism !== undefined && (!Number.isInteger(options.parallelism) || options.parallelism < 1)) {
+ throw new TypeError('options.parallelism, when provided, must be a positive number.');
+ }
if (options.fetchFn !== undefined && typeof options.fetchFn !== 'function') {
throw new TypeError('options.fetchFn, when provided, must be a function.');
// Won't bother validating this function beyond this
@@ -426,11 +432,13 @@ export class UrlSource extends Source {
this._options = options;
this._getRetryDelay = options.getRetryDelay ?? DEFAULT_RETRY_DELAY;
+ // Most files in the real-world have a single sequential access pattern, but having two in parallel can
+ // also happen
+ const DEFAULT_PARALLELISM = 2;
+
this._orchestrator = new ReadOrchestrator({
maxCacheSize: options.maxCacheSize ?? (64 * 2 ** 20 /* 64 MiB */),
- // Most files in the real-world have a single sequential access pattern, but having two in parallel can
- // also happen
- maxWorkerCount: 2,
+ maxWorkerCount: options.parallelism ?? DEFAULT_PARALLELISM,
runWorker: this._runWorker.bind(this),
prefetchProfile: PREFETCH_PROFILES.network,
});
diff --git a/src/wave/wave-demuxer.ts b/src/wave/wave-demuxer.ts
index 45febf7..95966ad 100644
--- a/src/wave/wave-demuxer.ts
+++ b/src/wave/wave-demuxer.ts
@@ -15,7 +15,7 @@ import { DEFAULT_TRACK_DISPOSITION, MetadataTags } from '../metadata';
import { assert, UNDETERMINED_LANGUAGE } from '../misc';
import { EncodedPacket, PLACEHOLDER_DATA } from '../packet';
import { readAscii, readBytes, Reader, readU16, readU32, readU64 } from '../reader';
-import { parseId3V2Tag, readId3V2Header } from '../id3';
+import { ID3_V2_HEADER_SIZE, parseId3V2Tag, readId3V2Header } from '../id3';
export enum WaveFormat {
PCM = 0x0001,
@@ -286,10 +286,15 @@ export class WaveDemuxer extends Demuxer {
const id3V2Header = readId3V2Header(slice);
if (id3V2Header) {
- // Extract the content portion (skip the 10-byte header)
- const contentSlice = slice.slice(startPos + 10, id3V2Header.size);
+ // Clamp to the available data in case the ID3 header claims more than the WAV chunk provides
+ // https://github.com/Vanilagy/mediabunny/issues/300
+ const availableSize = size - ID3_V2_HEADER_SIZE;
+ id3V2Header.size = Math.min(id3V2Header.size, availableSize);
- parseId3V2Tag(contentSlice, id3V2Header, this.metadataTags);
+ if (id3V2Header.size > 0) {
+ const contentSlice = slice.slice(startPos + ID3_V2_HEADER_SIZE, id3V2Header.size);
+ parseId3V2Tag(contentSlice, id3V2Header, this.metadataTags);
+ }
}
}
@@ -411,6 +416,8 @@ class WaveAudioTrackBacking implements InputAudioTrackBacking {
packetIndex: number,
options: PacketRetrievalOptions,
): Promise {
+ assert(packetIndex >= 0);
+
assert(this.demuxer.audioInfo);
const startOffset = packetIndex * PACKET_SIZE_IN_FRAMES * this.demuxer.audioInfo.blockSizeInBytes;
if (startOffset >= this.demuxer.dataSize) {
@@ -451,7 +458,7 @@ class WaveAudioTrackBacking implements InputAudioTrackBacking {
this.demuxer.lastKnownPacketIndex = Math.max(
packetIndex,
- timestamp,
+ this.demuxer.lastKnownPacketIndex,
);
return new EncodedPacket(
@@ -475,6 +482,9 @@ class WaveAudioTrackBacking implements InputAudioTrackBacking {
timestamp * this.demuxer.audioInfo.sampleRate / PACKET_SIZE_IN_FRAMES,
(this.demuxer.dataSize - 1) / (PACKET_SIZE_IN_FRAMES * this.demuxer.audioInfo.blockSizeInBytes),
));
+ if (packetIndex < 0) {
+ return null;
+ }
const packet = await this.getPacketAtIndex(packetIndex, options);
if (packet) {
diff --git a/test/browser/mpeg-ts-muxing.test.ts b/test/browser/mpeg-ts-muxing.test.ts
index 5bb2728..5568162 100644
--- a/test/browser/mpeg-ts-muxing.test.ts
+++ b/test/browser/mpeg-ts-muxing.test.ts
@@ -16,7 +16,7 @@ test('MPEG-TS output format', async () => {
expect(format.mimeType).toBe('video/MP2T');
expect(format.fileExtension).toBe('.ts');
expect(format.supportsVideoRotationMetadata).toBe(false);
- expect(format.getSupportedCodecs()).toEqual(['avc', 'hevc', 'aac', 'mp3']);
+ expect(format.getSupportedCodecs()).toEqual(['avc', 'hevc', 'aac', 'mp3', 'ac3', 'eac3']);
expect(format.getSupportedTrackCounts()).toEqual({
video: { min: 0, max: 16 },
audio: { min: 0, max: 32 },
@@ -70,7 +70,7 @@ test('MPEG-TS muxing with AVC and AAC', async () => {
started = true;
const fps = 30;
- const duration = 5;
+ const duration = 2;
const frameCount = fps * duration;
const frameDuration = 1 / fps;
@@ -164,9 +164,9 @@ test('MPEG-TS muxing with AVC and AAC', async () => {
}
// Check that seeking works
- const middlePacket = await videoSink.getPacket(2.5);
+ const middlePacket = await videoSink.getPacket(1);
assert(middlePacket);
- expect(middlePacket.timestamp).toBeCloseTo(2.5);
+ expect(middlePacket.timestamp).toBeCloseTo(1);
expect(videoPacketCount).toBe(frameCount);
@@ -189,9 +189,9 @@ test('MPEG-TS muxing with AVC and AAC', async () => {
}
// Check that seeking works
- const audioMiddlePacket = await audioSink.getPacket(2.5);
+ const audioMiddlePacket = await audioSink.getPacket(1);
assert(audioMiddlePacket);
- expect(audioMiddlePacket.timestamp).toBeCloseTo(2.5);
+ expect(audioMiddlePacket.timestamp).toBeCloseTo(1, 1);
expect(audioPacketCount).toBeGreaterThan(0);
@@ -199,8 +199,8 @@ test('MPEG-TS muxing with AVC and AAC', async () => {
const videoDuration = await videoTrack.computeDuration();
const audioDuration = await audioTrack.computeDuration();
- expect(videoDuration).toBeCloseTo(5, 1);
- expect(audioDuration).toBeCloseTo(5, 1);
+ expect(videoDuration).toBeCloseTo(duration, 1);
+ expect(audioDuration).toBeCloseTo(duration, 1);
});
test('MPEG-TS muxing with HEVC and MP3', async () => {
@@ -703,12 +703,14 @@ test('MPEG-TS muxing with StreamTarget', async () => {
const frameDuration = 1 / fps;
for (let i = 0; i < frameCount; i++) {
- await videoSource.add(i * frameDuration, frameDuration);
+ await videoSource.add(i * frameDuration, frameDuration, {
+ keyFrame: true, // Otherwise all packets get written at once due to the DTS logic
+ });
}
await output.finalize();
- expect(chunks.length).toBe(frameCount);
+ expect(chunks).toHaveLength(frameCount);
const buffer = new Uint8Array(nextPos);
nextPos = 0;
diff --git a/test/node/ac3.test.ts b/test/node/ac3.test.ts
new file mode 100644
index 0000000..0989b89
--- /dev/null
+++ b/test/node/ac3.test.ts
@@ -0,0 +1,327 @@
+import { expect, test } from 'vitest';
+import { Input } from '../../src/input.js';
+import { BufferSource, FilePathSource } from '../../src/source.js';
+import path from 'node:path';
+import { ALL_FORMATS } from '../../src/input-format.js';
+import { Output } from '../../src/output.js';
+import { MpegTsOutputFormat } from '../../src/output-format.js';
+import { BufferTarget } from '../../src/target.js';
+import { Conversion } from '../../src/conversion.js';
+import { AC3_REGISTRATION_DESCRIPTOR, EAC3_REGISTRATION_DESCRIPTOR } from '../../src/codec-data.js';
+import { canEncode } from '../../src/encode.js';
+import { AudioSampleSink, EncodedPacketSink } from '../../src/media-sink.js';
+import { AudioSampleSource } from '../../src/media-source.js';
+import { Mp4OutputFormat } from '../../src/output-format.js';
+import { AudioSample } from '../../src/sample.js';
+import { registerAc3Decoder, registerAc3Encoder } from '@mediabunny/ac3';
+
+const __dirname = new URL('.', import.meta.url).pathname;
+
+test('reads AC-3 from MP4', async () => {
+ using input = new Input({
+ source: new FilePathSource(path.join(__dirname, '..', 'public/ac3.mp4')),
+ formats: ALL_FORMATS,
+ });
+
+ const audioTrack = (await input.getPrimaryAudioTrack())!;
+ const decoderConfig = (await audioTrack.getDecoderConfig())!;
+
+ expect(audioTrack.codec).toBe('ac3');
+ expect(decoderConfig.description).toBeUndefined();
+});
+
+test('reads E-AC-3 from MP4', async () => {
+ using input = new Input({
+ source: new FilePathSource(path.join(__dirname, '..', 'public/eac3.mp4')),
+ formats: ALL_FORMATS,
+ });
+
+ const audioTrack = (await input.getPrimaryAudioTrack())!;
+ const decoderConfig = (await audioTrack.getDecoderConfig())!;
+
+ expect(audioTrack.codec).toBe('eac3');
+ expect(decoderConfig.description).toBeUndefined();
+});
+
+test('reads and writes AC-3 in MPEG-TS', async () => {
+ using originalInput = new Input({
+ source: new FilePathSource(path.join(__dirname, '..', 'public/ac3.ts')),
+ formats: ALL_FORMATS,
+ });
+
+ const originalAudioTrack = (await originalInput.getPrimaryAudioTrack())!;
+
+ expect(originalAudioTrack.codec).toBe('ac3');
+ expect(originalAudioTrack.internalCodecId).toBe(0x81);
+
+ const output = new Output({
+ format: new MpegTsOutputFormat(),
+ target: new BufferTarget(),
+ });
+
+ const conversion = await Conversion.init({ input: originalInput, output });
+ await conversion.execute();
+
+ using newInput = new Input({
+ source: new BufferSource(output.target.buffer!),
+ formats: ALL_FORMATS,
+ });
+
+ const newAudioTrack = (await newInput.getPrimaryAudioTrack())!;
+
+ expect(newAudioTrack.codec).toBe('ac3');
+ expect(newAudioTrack.internalCodecId).toBe(0x81);
+ expect(newAudioTrack.numberOfChannels).toBe(originalAudioTrack.numberOfChannels);
+ expect(newAudioTrack.sampleRate).toBe(originalAudioTrack.sampleRate);
+
+ // Verify registration_descriptor is present
+ const buffer = new Uint8Array(output.target.buffer!);
+ let found = false;
+ for (let i = 0; i < buffer.length - AC3_REGISTRATION_DESCRIPTOR.length; i++) {
+ if (AC3_REGISTRATION_DESCRIPTOR.every((byte, j) => buffer[i + j] === byte)) {
+ found = true;
+ break;
+ }
+ }
+ expect(found).toBe(true);
+});
+
+test('reads and writes E-AC-3 in MPEG-TS', async () => {
+ using originalInput = new Input({
+ source: new FilePathSource(path.join(__dirname, '..', 'public/eac3.ts')),
+ formats: ALL_FORMATS,
+ });
+
+ const originalAudioTrack = (await originalInput.getPrimaryAudioTrack())!;
+
+ expect(originalAudioTrack.codec).toBe('eac3');
+ expect(originalAudioTrack.internalCodecId).toBe(0x87);
+
+ const output = new Output({
+ format: new MpegTsOutputFormat(),
+ target: new BufferTarget(),
+ });
+
+ const conversion = await Conversion.init({ input: originalInput, output });
+ await conversion.execute();
+
+ using newInput = new Input({
+ source: new BufferSource(output.target.buffer!),
+ formats: ALL_FORMATS,
+ });
+
+ const newAudioTrack = (await newInput.getPrimaryAudioTrack())!;
+
+ expect(newAudioTrack.codec).toBe('eac3');
+ expect(newAudioTrack.internalCodecId).toBe(0x87);
+ expect(newAudioTrack.numberOfChannels).toBe(originalAudioTrack.numberOfChannels);
+ expect(newAudioTrack.sampleRate).toBe(originalAudioTrack.sampleRate);
+
+ // Verify registration_descriptor is present
+ const buffer = new Uint8Array(output.target.buffer!);
+ let found = false;
+ for (let i = 0; i < buffer.length - EAC3_REGISTRATION_DESCRIPTOR.length; i++) {
+ if (EAC3_REGISTRATION_DESCRIPTOR.every((byte, j) => buffer[i + j] === byte)) {
+ found = true;
+ break;
+ }
+ }
+ expect(found).toBe(true);
+});
+
+test('Custom coder registration', async () => {
+ using ac3Input = new Input({
+ source: new FilePathSource(path.join(__dirname, '..', 'public/ac3.mp4')),
+ formats: ALL_FORMATS,
+ });
+ using eac3Input = new Input({
+ source: new FilePathSource(path.join(__dirname, '..', 'public/eac3.mp4')),
+ formats: ALL_FORMATS,
+ });
+
+ const ac3Track = (await ac3Input.getPrimaryAudioTrack())!;
+ const eac3Track = (await eac3Input.getPrimaryAudioTrack())!;
+
+ // Before registration, nothing can decode or encode AC3/EAC3
+ expect(await ac3Track.canDecode()).toBe(false);
+ expect(await eac3Track.canDecode()).toBe(false);
+ expect(await canEncode('ac3')).toBe(false);
+ expect(await canEncode('eac3')).toBe(false);
+
+ registerAc3Decoder();
+ registerAc3Encoder();
+
+ // After registration, both should work
+ expect(await ac3Track.canDecode()).toBe(true);
+ expect(await eac3Track.canDecode()).toBe(true);
+ expect(await canEncode('ac3')).toBe(true);
+ expect(await canEncode('eac3')).toBe(true);
+});
+
+test('AC-3 decoding', async () => {
+ registerAc3Decoder();
+
+ using input = new Input({
+ source: new FilePathSource(path.join(__dirname, '..', 'public/ac3.mp4')),
+ formats: ALL_FORMATS,
+ });
+
+ const track = (await input.getPrimaryAudioTrack())!;
+ const { packetCount } = await track.computePacketStats();
+ const sink = new AudioSampleSink(track);
+
+ let sampleCount = 0;
+ let nextTimestamp = 0;
+
+ for await (using sample of sink.samples()) {
+ expect(sample.timestamp).toBeCloseTo(nextTimestamp);
+ expect(sample.duration).toBe(0.032);
+ expect(sample.format).toBe('f32-planar');
+ expect(sample.numberOfChannels).toBe(track.numberOfChannels);
+ expect(sample.sampleRate).toBe(track.sampleRate);
+
+ nextTimestamp += sample.duration;
+ sampleCount++;
+ }
+
+ expect(sampleCount).toBe(packetCount);
+});
+
+test('E-AC-3 decoding', async () => {
+ registerAc3Decoder();
+
+ using input = new Input({
+ source: new FilePathSource(path.join(__dirname, '..', 'public/eac3.mp4')),
+ formats: ALL_FORMATS,
+ });
+
+ const track = (await input.getPrimaryAudioTrack())!;
+ const { packetCount } = await track.computePacketStats();
+ const sink = new AudioSampleSink(track);
+
+ let sampleCount = 0;
+ let nextTimestamp = 0;
+
+ for await (using sample of sink.samples()) {
+ expect(sample.timestamp).toBeCloseTo(nextTimestamp);
+ expect(sample.duration).toBe(0.032);
+ expect(sample.format).toBe('f32-planar');
+ expect(sample.numberOfChannels).toBe(track.numberOfChannels);
+ expect(sample.sampleRate).toBe(track.sampleRate);
+
+ nextTimestamp += sample.duration;
+ sampleCount++;
+ }
+
+ expect(sampleCount).toBe(packetCount);
+});
+
+const createSineWave = (sampleRate: number, channels: number, durationSeconds: number) => {
+ const totalFrames = sampleRate * durationSeconds;
+ const data = new Float32Array(totalFrames * channels);
+
+ for (let i = 0; i < totalFrames; i++) {
+ const value = Math.sin(2 * Math.PI * 440 * i / sampleRate);
+ for (let ch = 0; ch < channels; ch++) {
+ data[i * channels + ch] = value;
+ }
+ }
+
+ return data;
+};
+
+test('AC-3 encoding', async () => {
+ registerAc3Encoder();
+
+ const sampleRate = 48000;
+ const channels = 2;
+ const durationSeconds = 2;
+ const data = createSineWave(sampleRate, channels, durationSeconds);
+
+ const output = new Output({
+ format: new Mp4OutputFormat(),
+ target: new BufferTarget(),
+ });
+
+ const audioSource = new AudioSampleSource({ codec: 'ac3', bitrate: 192000 });
+ output.addAudioTrack(audioSource);
+
+ await output.start();
+ await audioSource.add(new AudioSample({
+ data,
+ format: 'f32',
+ numberOfChannels: channels,
+ sampleRate,
+ timestamp: 0,
+ }));
+ audioSource.close();
+ await output.finalize();
+
+ using input = new Input({
+ source: new BufferSource(output.target.buffer!),
+ formats: ALL_FORMATS,
+ });
+
+ const track = (await input.getPrimaryAudioTrack())!;
+ expect(track.codec).toBe('ac3');
+ expect(track.sampleRate).toBe(sampleRate);
+ expect(track.numberOfChannels).toBe(channels);
+
+ const sink = new EncodedPacketSink(track);
+ let packetCount = 0;
+ for await (const packet of sink.packets()) {
+ expect(packet.type).toBe('key');
+ packetCount++;
+ }
+ expect(packetCount).toBeGreaterThan(0);
+
+ expect(await track.computeDuration()).toBeCloseTo(2, 1);
+});
+
+test('E-AC-3 encoding', async () => {
+ registerAc3Encoder();
+
+ const sampleRate = 48000;
+ const channels = 2;
+ const durationSeconds = 2;
+ const data = createSineWave(sampleRate, channels, durationSeconds);
+
+ const output = new Output({
+ format: new Mp4OutputFormat(),
+ target: new BufferTarget(),
+ });
+
+ const audioSource = new AudioSampleSource({ codec: 'eac3', bitrate: 192000 });
+ output.addAudioTrack(audioSource);
+
+ await output.start();
+ await audioSource.add(new AudioSample({
+ data,
+ format: 'f32',
+ numberOfChannels: channels,
+ sampleRate,
+ timestamp: 0,
+ }));
+ audioSource.close();
+ await output.finalize();
+
+ using input = new Input({
+ source: new BufferSource(output.target.buffer!),
+ formats: ALL_FORMATS,
+ });
+
+ const track = (await input.getPrimaryAudioTrack())!;
+ expect(track.codec).toBe('eac3');
+ expect(track.sampleRate).toBe(sampleRate);
+ expect(track.numberOfChannels).toBe(channels);
+
+ const sink = new EncodedPacketSink(track);
+ let packetCount = 0;
+ for await (const packet of sink.packets()) {
+ expect(packet.type).toBe('key');
+ packetCount++;
+ }
+ expect(packetCount).toBeGreaterThan(0);
+
+ expect(await track.computeDuration()).toBeCloseTo(2, 1);
+});
diff --git a/test/node/mpeg-ts-demuxing.test.ts b/test/node/mpeg-ts-demuxing.test.ts
index ea85a38..62349b9 100644
--- a/test/node/mpeg-ts-demuxing.test.ts
+++ b/test/node/mpeg-ts-demuxing.test.ts
@@ -9,6 +9,7 @@ import { assert } from '../../src/misc.js';
import { EncodedPacketSink } from '../../src/media-sink.js';
import { EncodedPacket } from '../../src/packet.js';
import { MpegTsDemuxer } from '../../src/mpeg-ts/mpeg-ts-demuxer.js';
+import { MpegTsStreamType } from '../../src/mpeg-ts/mpeg-ts-misc.js';
const __dirname = new URL('.', import.meta.url).pathname;
@@ -612,6 +613,146 @@ test('MPEG-TS with MP3 audio', async () => {
expect(count).toBeGreaterThan(0);
});
+test('MPEG-TS with AC-3 audio (System A)', async () => {
+ using input = new Input({
+ source: new FilePathSource(path.join(__dirname, '../public/ac3.ts')),
+ formats: ALL_FORMATS,
+ });
+
+ const audioTrack = await input.getPrimaryAudioTrack();
+ assert(audioTrack);
+
+ expect(audioTrack.codec).toBe('ac3');
+ expect(audioTrack.internalCodecId).toBe(MpegTsStreamType.AC3_SYSTEM_A);
+
+ const audioDecoderConfig = await audioTrack.getDecoderConfig();
+ expect(audioDecoderConfig).toEqual({
+ codec: 'ac-3',
+ numberOfChannels: 6,
+ sampleRate: 48000,
+ });
+
+ const sink = new EncodedPacketSink(audioTrack);
+
+ const firstPacket = await sink.getFirstPacket();
+ assert(firstPacket);
+
+ let count = 0;
+ for await (const packet of sink.packets()) {
+ expect(packet.data[0]).toBe(0x0b);
+ expect(packet.data[1]).toBe(0x77);
+ expect(packet.type).toBe('key');
+ count++;
+ }
+
+ expect(count).toBeGreaterThan(0);
+});
+
+test('MPEG-TS with AC-3 audio (System B)', async () => {
+ using input = new Input({
+ source: new FilePathSource(path.join(__dirname, '../public/ac3-system-b.ts')),
+ formats: ALL_FORMATS,
+ });
+
+ const audioTrack = await input.getPrimaryAudioTrack();
+ assert(audioTrack);
+
+ expect(audioTrack.codec).toBe('ac3');
+ expect(audioTrack.internalCodecId).toBe(MpegTsStreamType.PRIVATE_DATA);
+
+ const audioDecoderConfig = await audioTrack.getDecoderConfig();
+ expect(audioDecoderConfig).toEqual({
+ codec: 'ac-3',
+ numberOfChannels: 6,
+ sampleRate: 48000,
+ });
+
+ const sink = new EncodedPacketSink(audioTrack);
+
+ const firstPacket = await sink.getFirstPacket();
+ assert(firstPacket);
+
+ let count = 0;
+ for await (const packet of sink.packets()) {
+ expect(packet.data[0]).toBe(0x0b);
+ expect(packet.data[1]).toBe(0x77);
+ expect(packet.type).toBe('key');
+ count++;
+ }
+
+ expect(count).toBeGreaterThan(0);
+});
+
+test('MPEG-TS with E-AC-3 audio (System A)', async () => {
+ using input = new Input({
+ source: new FilePathSource(path.join(__dirname, '../public/eac3.ts')),
+ formats: ALL_FORMATS,
+ });
+
+ const audioTrack = await input.getPrimaryAudioTrack();
+ assert(audioTrack);
+
+ expect(audioTrack.codec).toBe('eac3');
+ expect(audioTrack.internalCodecId).toBe(MpegTsStreamType.EAC3_SYSTEM_A);
+
+ const audioDecoderConfig = await audioTrack.getDecoderConfig();
+ expect(audioDecoderConfig).toEqual({
+ codec: 'ec-3',
+ numberOfChannels: 6,
+ sampleRate: 48000,
+ });
+
+ const sink = new EncodedPacketSink(audioTrack);
+
+ const firstPacket = await sink.getFirstPacket();
+ assert(firstPacket);
+
+ let count = 0;
+ for await (const packet of sink.packets()) {
+ expect(packet.data[0]).toBe(0x0b);
+ expect(packet.data[1]).toBe(0x77);
+ expect(packet.type).toBe('key');
+ count++;
+ }
+
+ expect(count).toBeGreaterThan(0);
+});
+
+test('MPEG-TS with E-AC-3 audio (System B)', async () => {
+ using input = new Input({
+ source: new FilePathSource(path.join(__dirname, '../public/eac3-system-b.ts')),
+ formats: ALL_FORMATS,
+ });
+
+ const audioTrack = await input.getPrimaryAudioTrack();
+ assert(audioTrack);
+
+ expect(audioTrack.codec).toBe('eac3');
+ expect(audioTrack.internalCodecId).toBe(MpegTsStreamType.PRIVATE_DATA);
+
+ const audioDecoderConfig = await audioTrack.getDecoderConfig();
+ expect(audioDecoderConfig).toEqual({
+ codec: 'ec-3',
+ numberOfChannels: 6,
+ sampleRate: 48000,
+ });
+
+ const sink = new EncodedPacketSink(audioTrack);
+
+ const firstPacket = await sink.getFirstPacket();
+ assert(firstPacket);
+
+ let count = 0;
+ for await (const packet of sink.packets()) {
+ expect(packet.data[0]).toBe(0x0b);
+ expect(packet.data[1]).toBe(0x77);
+ expect(packet.type).toBe('key');
+ count++;
+ }
+
+ expect(count).toBeGreaterThan(0);
+});
+
test('MPEG-TS partial reading', async () => {
const fullPath = path.join(__dirname, '../public/193039199_mp4_h264_aac_fhd_7.ts');
const buffer = await fs.promises.readFile(fullPath);
diff --git a/test/node/read-wav.test.ts b/test/node/read-wav.test.ts
new file mode 100644
index 0000000..87f6f79
--- /dev/null
+++ b/test/node/read-wav.test.ts
@@ -0,0 +1,24 @@
+import { test } from 'vitest';
+import path from 'node:path';
+import { ALL_FORMATS, EncodedPacketSink, Input, FilePathSource } from '../../src/index.js';
+
+const __dirname = new URL('.', import.meta.url).pathname;
+
+test('Should handle WAV file with oversized ID3 chunk', async () => {
+ const filePath = path.join(__dirname, '..', 'public/oversized-id3.wav');
+ using input = new Input({
+ source: new FilePathSource(filePath),
+ formats: ALL_FORMATS,
+ });
+
+ const track = await input.getPrimaryAudioTrack();
+ if (!track) {
+ throw new Error('No audio track found');
+ }
+
+ const sink = new EncodedPacketSink(track);
+
+ for await (const packet of sink.packets()) {
+ void packet;
+ }
+});
diff --git a/test/public/ac3-system-b.ts b/test/public/ac3-system-b.ts
new file mode 100644
index 0000000..20a2fde
Binary files /dev/null and b/test/public/ac3-system-b.ts differ
diff --git a/test/public/ac3.mkv b/test/public/ac3.mkv
new file mode 100644
index 0000000..3b9a226
Binary files /dev/null and b/test/public/ac3.mkv differ
diff --git a/test/public/ac3.mp4 b/test/public/ac3.mp4
new file mode 100644
index 0000000..4dc638e
Binary files /dev/null and b/test/public/ac3.mp4 differ
diff --git a/test/public/ac3.ts b/test/public/ac3.ts
new file mode 100644
index 0000000..38b82c1
Binary files /dev/null and b/test/public/ac3.ts differ
diff --git a/test/public/eac3-system-b.ts b/test/public/eac3-system-b.ts
new file mode 100644
index 0000000..462911e
Binary files /dev/null and b/test/public/eac3-system-b.ts differ
diff --git a/test/public/eac3.mkv b/test/public/eac3.mkv
new file mode 100644
index 0000000..b9fb219
Binary files /dev/null and b/test/public/eac3.mkv differ
diff --git a/test/public/eac3.mp4 b/test/public/eac3.mp4
new file mode 100644
index 0000000..aa31d2a
Binary files /dev/null and b/test/public/eac3.mp4 differ
diff --git a/test/public/eac3.ts b/test/public/eac3.ts
new file mode 100644
index 0000000..fb00fef
Binary files /dev/null and b/test/public/eac3.ts differ
diff --git a/test/public/oversized-id3.wav b/test/public/oversized-id3.wav
new file mode 100644
index 0000000..6996ea3
Binary files /dev/null and b/test/public/oversized-id3.wav differ
diff --git a/tsconfig.vite.json b/tsconfig.vite.json
index e40e718..18c8e14 100644
--- a/tsconfig.vite.json
+++ b/tsconfig.vite.json
@@ -7,6 +7,7 @@
"noEmit": false,
"paths": {
"mediabunny": ["./src/index.ts"],
+ "@mediabunny/ac3": ["./packages/ac3/src/index.ts"],
},
"types": ["vite/client"]
},
diff --git a/tsconfig.vitest.json b/tsconfig.vitest.json
index 7a24606..7101888 100644
--- a/tsconfig.vitest.json
+++ b/tsconfig.vitest.json
@@ -4,9 +4,13 @@
"moduleResolution": "nodenext",
"module": "NodeNext",
"composite": true,
- "noEmit": false
+ "noEmit": false,
+ "paths": {
+ "mediabunny": ["./src/index.ts"],
+ "@mediabunny/ac3": ["./packages/ac3/src/index.ts"],
+ },
},
"include": ["vitest.config.ts", "./test/**/*"],
"exclude": ["./test/public/**/*"],
- "references": [{ "path": "./src" }]
+ "references": [{ "path": "./src" }, { "path": "./packages/ac3" }]
}
diff --git a/vitest.config.ts b/vitest.config.ts
index 33a53df..3623ed8 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -1,11 +1,19 @@
///
import { defineConfig } from 'vitest/config';
+import path from 'path';
export default defineConfig({
+ resolve: {
+ alias: {
+ 'mediabunny': path.resolve(__dirname, './src/index.ts'),
+ '@mediabunny/ac3': path.resolve(__dirname, './packages/ac3/dist/bundles/mediabunny-ac3.mjs'),
+ },
+ },
test: {
projects: [
{
+ extends: true,
test: {
name: 'node',
root: 'test',
@@ -14,6 +22,7 @@ export default defineConfig({
},
},
{
+ extends: true,
test: {
name: 'browser',
root: 'test',