diff --git a/docs/guide/supported-formats-and-codecs.md b/docs/guide/supported-formats-and-codecs.md index e28fa3c..6448e96 100644 --- a/docs/guide/supported-formats-and-codecs.md +++ b/docs/guide/supported-formats-and-codecs.md @@ -40,6 +40,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) +- `'eac3'` - Dolby Digital Plus (E-AC-3) - `'pcm-u8'` - 8-bit unsigned PCM - `'pcm-s8'` - 8-bit signed PCM - `'pcm-s16'` - 16-bit little-endian signed PCM @@ -75,6 +77,8 @@ Not all codecs can be used with all containers. The following table specifies th | `'mp3'` | ✓ | ✓ | ✓ | | | ✓ | | | | ✓ | | `'vorbis'` | ✓ | ✓ | ✓ | ✓ | ✓ | | | | | | | `'flac'` | ✓ | ✓ | ✓ | | | | | | ✓ | | +| `'ac3'`[^2] | ✓ | ✓ | ✓ | | | | | | | ✓ | +| `'eac3'`[^2] | ✓ | ✓ | ✓ | | | | | | | ✓ | | `'pcm-u8'` | | ✓ | ✓ | | | | ✓ | | | | | `'pcm-s8'` | | ✓ | | | | | | | | | | `'pcm-s16'` | ✓ | ✓ | ✓ | | | | ✓ | | | | @@ -89,11 +93,12 @@ 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]: AC-3 and E-AC-3 are not natively supported by WebCodecs. To encode or decode these codecs, you must provide a [custom coder](#custom-coders). +[^3]: WebVTT can only be written, not read. ## Querying codec encodability diff --git a/src/codec-data.ts b/src/codec-data.ts index 2680ab5..685948b 100644 --- a/src/codec-data.ts +++ b/src/codec-data.ts @@ -2643,3 +2643,367 @@ export const createVorbisComments = (headerBytes: Uint8Array, tags: MetadataTags return commentHeader; }; + +// ============================================================================ +// AC-3 / E-AC-3 Parsing +// Reference: ETSI TS 102 366 V1.4.1 +// ============================================================================ + +/** Sample rates indexed by fscod (Table 4.1) */ +export const AC3_SAMPLE_RATES = [48000, 44100, 32000] as const; + +/** + * 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] as const; + +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]); + +/** E-AC-3 reduced sample rates for fscod2 per ATSC A/52:2018 */ +const EAC3_REDUCED_SAMPLE_RATES = [24000, 22050, 16000] as const; + +/** Number of audio blocks per syncframe, indexed by numblkscod */ +export const EAC3_NUMBLKS_TABLE = [1, 2, 3, 6] as const; + +/** + * 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..947168d 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') { @@ -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) { @@ -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/encode.ts b/src/encode.ts index 4d3c3d9..861a5d3 100644 --- a/src/encode.ts +++ b/src/encode.ts @@ -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/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 951b728..37eee33 100644 --- a/src/isobmff/isobmff-demuxer.ts +++ b/src/isobmff/isobmff-demuxer.ts @@ -27,6 +27,11 @@ import { FlacBlockType, HevcDecoderConfigurationRecord, Vp9CodecInfo, + parseEac3Config, + getEac3SampleRate, + getEac3ChannelCount, + AC3_SAMPLE_RATES, + AC3_ACMOD_CHANNEL_COUNTS, } from '../codec-data'; import { Demuxer } from '../demuxer'; import { Input } from '../input'; @@ -952,6 +957,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}').`); } @@ -1463,6 +1472,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 f9d8a39..ad1295a 100644 --- a/src/matroska/matroska-demuxer.ts +++ b/src/matroska/matroska-demuxer.ts @@ -1084,6 +1084,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/mpeg-ts/mpeg-ts-demuxer.ts b/src/mpeg-ts/mpeg-ts-demuxer.ts index 82fee7b..b9810da 100644 --- a/src/mpeg-ts/mpeg-ts-demuxer.ts +++ b/src/mpeg-ts/mpeg-ts-demuxer.ts @@ -19,16 +19,25 @@ import { VideoCodec, } from '../codec'; import { + AC3_ACMOD_CHANNEL_COUNTS, + AC3_SAMPLE_RATES, + 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'; @@ -266,7 +275,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 +328,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 +498,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.'); @@ -1686,6 +1786,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..4135920 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, @@ -151,11 +153,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, @@ -373,7 +398,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; } @@ -695,7 +720,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 +750,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..8e7b635 100644 --- a/src/output-format.ts +++ b/src/output-format.ts @@ -292,7 +292,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 +304,7 @@ export class Mp4OutputFormat extends IsobmffOutputFormat { 'pcm-f32be', 'pcm-f64', 'pcm-f64be', + ...SUBTITLE_CODECS, ]; } @@ -1032,7 +1034,7 @@ 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)), ]; } diff --git a/test/browser/mpeg-ts-muxing.test.ts b/test/browser/mpeg-ts-muxing.test.ts index 5bb2728..eee9dc6 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 }, diff --git a/test/node/ac3.test.ts b/test/node/ac3.test.ts new file mode 100644 index 0000000..08e3fbb --- /dev/null +++ b/test/node/ac3.test.ts @@ -0,0 +1,124 @@ +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'; + +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); +}); 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/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