From 7aa0fa465e63791fc32f0cc465dce6aa1ca084cd Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Tue, 27 Jan 2026 14:32:16 +0100 Subject: [PATCH] Add support for ID3 metadata in ADTS, make MPEG-TS demuxer more resilient --- CLAUDE.md | 4 +- src/adts/adts-demuxer.ts | 64 ++++++++++++++++++- src/adts/adts-muxer.ts | 7 ++- src/input-format.ts | 28 ++++++--- src/metadata.ts | 3 +- src/mpeg-ts/mpeg-ts-demuxer.ts | 105 ++++++++++++++++++-------------- test/node/metadata-tags.test.ts | 56 +++++++++++++++++ 7 files changed, 208 insertions(+), 59 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8b7c465..83413a7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,4 +1,6 @@ - Prefer functions declared using const, not using the function keyword - Code style is tab indent with semicolons - Mediabunny core code is contained in src/, extensions are in packages/*/, website is in docs/ -- Tests: Prefer fewer, longer test files over many small ones. Test files should be named after the general catergory of thing that is being tested, not after any individual single test. \ No newline at end of file +- Tests: Prefer fewer, longer test files over many small ones. Test files should be named after the general catergory of thing that is being tested, not after any individual single test. +- Avoid ifs without a {} block. So no if (cond) return;, always do if (cond) { return; } +- `type` instead of `interface` for object types \ No newline at end of file diff --git a/src/adts/adts-demuxer.ts b/src/adts/adts-demuxer.ts index aaade92..18af576 100644 --- a/src/adts/adts-demuxer.ts +++ b/src/adts/adts-demuxer.ts @@ -8,9 +8,15 @@ import { aacChannelMap, aacFrequencyTable, AudioCodec } from '../codec'; import { Demuxer } from '../demuxer'; +import { + ID3_V2_HEADER_SIZE, + parseId3V2Tag, + readId3V2Header, +} from '../id3'; import { Input } from '../input'; import { InputAudioTrack, InputAudioTrackBacking } from '../input-track'; import { PacketRetrievalOptions } from '../media-sink'; +import { DEFAULT_TRACK_DISPOSITION, MetadataTags } from '../metadata'; import { assert, AsyncMutex, @@ -20,7 +26,6 @@ import { } from '../misc'; import { EncodedPacket, PLACEHOLDER_DATA } from '../packet'; import { readBytes, Reader } from '../reader'; -import { DEFAULT_TRACK_DISPOSITION } from '../metadata'; import { AdtsFrameHeader, MIN_ADTS_FRAME_HEADER_SIZE, @@ -43,6 +48,7 @@ export class AdtsDemuxer extends Demuxer { metadataPromise: Promise | null = null; firstFrameHeader: AdtsFrameHeader | null = null; loadedSamples: Sample[] = []; + metadataTags: MetadataTags | null = null; tracks: InputAudioTrack[] = []; @@ -73,6 +79,26 @@ export class AdtsDemuxer extends Demuxer { } async advanceReader() { + if (this.lastLoadedPos === 0) { + // Skip all ID3v2 tags at the start of the file + while (true) { + let slice = this.reader.requestSlice(this.lastLoadedPos, ID3_V2_HEADER_SIZE); + if (slice instanceof Promise) slice = await slice; + + if (!slice) { + this.lastSampleLoaded = true; + return; + } + + const id3V2Header = readId3V2Header(slice); + if (!id3V2Header) { + break; + } + + this.lastLoadedPos = slice.filePos + id3V2Header.size; + } + } + let slice = this.reader.requestSliceRange( this.lastLoadedPos, MIN_ADTS_FRAME_HEADER_SIZE, @@ -135,7 +161,41 @@ export class AdtsDemuxer extends Demuxer { } async getMetadataTags() { - return {}; // No tags in this one + const release = await this.readingMutex.acquire(); + + try { + await this.readMetadata(); + + if (this.metadataTags) { + return this.metadataTags; + } + + this.metadataTags = {}; + let currentPos = 0; + + while (true) { + let headerSlice = this.reader.requestSlice(currentPos, ID3_V2_HEADER_SIZE); + if (headerSlice instanceof Promise) headerSlice = await headerSlice; + if (!headerSlice) break; + + const id3V2Header = readId3V2Header(headerSlice); + if (!id3V2Header) { + break; + } + + let contentSlice = this.reader.requestSlice(headerSlice.filePos, id3V2Header.size); + if (contentSlice instanceof Promise) contentSlice = await contentSlice; + if (!contentSlice) break; + + parseId3V2Tag(contentSlice, id3V2Header, this.metadataTags); + + currentPos = headerSlice.filePos + id3V2Header.size; + } + + return this.metadataTags; + } finally { + release(); + } } } diff --git a/src/adts/adts-muxer.ts b/src/adts/adts-muxer.ts index 4724c9d..194f9de 100644 --- a/src/adts/adts-muxer.ts +++ b/src/adts/adts-muxer.ts @@ -7,6 +7,8 @@ */ import { parseAacAudioSpecificConfig, validateAudioChunkMetadata } from '../codec'; +import { Id3V2Writer } from '../id3'; +import { metadataTagsAreEmpty } from '../metadata'; import { assert, Bitstream, toUint8Array } from '../misc'; import { Muxer } from '../muxer'; import { Output, OutputAudioTrack } from '../output'; @@ -30,7 +32,10 @@ export class AdtsMuxer extends Muxer { } async start() { - // Nothing needed here + if (!metadataTagsAreEmpty(this.output._metadataTags)) { + const id3Writer = new Id3V2Writer(this.writer); + id3Writer.writeId3V2Tag(this.output._metadataTags); + } } async getMimeType() { diff --git a/src/input-format.ts b/src/input-format.ts index 210107c..f5c52e9 100644 --- a/src/input-format.ts +++ b/src/input-format.ts @@ -266,7 +266,6 @@ export class Mp3InputFormat extends InputFormat { if (!slice) return false; let currentPos = 0; - let id3V2HeaderFound = false; while (true) { let slice = input._reader.requestSlice(currentPos, ID3_V2_HEADER_SIZE); @@ -278,7 +277,6 @@ export class Mp3InputFormat extends InputFormat { break; } - id3V2HeaderFound = true; currentPos = slice.filePos + id3V2Header.size; } @@ -287,11 +285,6 @@ export class Mp3InputFormat extends InputFormat { return false; } - if (id3V2HeaderFound) { - // If there was an ID3v2 tag at the start, we can be pretty sure this is MP3 by now - 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 @@ -441,8 +434,23 @@ export class FlacInputFormat extends InputFormat { export class AdtsInputFormat extends InputFormat { /** @internal */ async _canReadInput(input: Input) { + let currentPos = 0; + + while (true) { + let slice = input._reader.requestSlice(currentPos, ID3_V2_HEADER_SIZE); + if (slice instanceof Promise) slice = await slice; + if (!slice) break; + + const id3V2Header = readId3V2Header(slice); + if (!id3V2Header) { + break; + } + + currentPos = slice.filePos + id3V2Header.size; + } + let slice = input._reader.requestSliceRange( - 0, + currentPos, MIN_ADTS_FRAME_HEADER_SIZE, MAX_ADTS_FRAME_HEADER_SIZE, ); @@ -454,8 +462,10 @@ export class AdtsInputFormat extends InputFormat { return false; } + currentPos += firstHeader.frameLength; + slice = input._reader.requestSliceRange( - firstHeader.frameLength, + currentPos, MIN_ADTS_FRAME_HEADER_SIZE, MAX_ADTS_FRAME_HEADER_SIZE, ); diff --git a/src/metadata.ts b/src/metadata.ts index 9954cc5..5fe983e 100644 --- a/src/metadata.ts +++ b/src/metadata.ts @@ -17,7 +17,7 @@ * - For Ogg files, there is no global metadata so instead, the metadata refers to the combined metadata of all tracks, * in Vorbis-style comment headers. * - For WAVE files, the metadata refers to the chunks within the RIFF INFO chunk. - * - For ADTS files, there is no metadata. + * - For ADTS files, the metadata refers to the ID3v2 tags. * - For FLAC files, the metadata lives in Vorbis style in the Vorbis comment block. * - For MPEG-TS files, metadata tags are currently not supported. * @@ -70,6 +70,7 @@ export type MetadataTags = { * values. Additionally, all attached files (such as font files) are included here, where the key corresponds to * the FileUID and the value is an {@link AttachedFile}. * - MP3: The ID3v2 tags, or a single `'TAG'` key with the contents of the ID3v1 tag. + * - ADTS: The ID3v2 tags. * - Ogg: The key-value string pairs from the Vorbis-style comment header (see RFC 7845, Section 5.2). * Additionally, the `'vendor'` key refers to the vendor string within this header. * - WAVE: The individual metadata chunks within the RIFF INFO chunk. Values are always ISO 8859-1 strings. diff --git a/src/mpeg-ts/mpeg-ts-demuxer.ts b/src/mpeg-ts/mpeg-ts-demuxer.ts index 7061da8..82fee7b 100644 --- a/src/mpeg-ts/mpeg-ts-demuxer.ts +++ b/src/mpeg-ts/mpeg-ts-demuxer.ts @@ -63,8 +63,6 @@ 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'; -const MISSING_PES_PACKET_ERROR = 'No PES packet found where one was expected.'; - type ElementaryStream = { demuxer: MpegTsDemuxer; pid: number; @@ -191,6 +189,26 @@ export class MpegTsDemuxer extends Demuxer { const BYTES_BEFORE_SECTION_LENGTH = 3; const BITS_IN_CRC_32 = 32; // Duh + // Some streams don't contain a PAT for some reason, so we must do some guesswork to figure out where + // the PMT is. + let isProbablyProgramMap = false; + if (!hasProgramMap && section.pid !== 0) { + const isPesPacket + = section.payload[0] === 0x00 && section.payload[1] === 0x00 && section.payload[2] === 0x01; + + if (!isPesPacket) { + // Assume it's a PSI + + const bitstream = new Bitstream(section.payload); + const pointerField = bitstream.readAlignedByte(); + + bitstream.skipBits(8 * pointerField); + + const tableId = bitstream.readBits(8); + isProbablyProgramMap = tableId === 0x02; // 0x02 == TS_program_map_section + } + } + if (section.pid === 0 && !hasProgramAssociationTable) { const bitstream = new Bitstream(section.payload); const pointerField = bitstream.readAlignedByte(); @@ -220,7 +238,7 @@ export class MpegTsDemuxer extends Demuxer { } hasProgramAssociationTable = true; - } else if (section.pid === programMapPid && !hasProgramMap) { + } else if ((section.pid === programMapPid || isProbablyProgramMap) && !hasProgramMap) { const bitstream = new Bitstream(section.payload); const pointerField = bitstream.readAlignedByte(); @@ -291,6 +309,7 @@ export class MpegTsDemuxer extends Demuxer { 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. + console.warn(`Unsupported stream_type 0x${streamType.toString(16)}; ignoring stream.`); } } @@ -433,10 +452,11 @@ export class MpegTsDemuxer extends Demuxer { currentPos += this.packetStride; } - if (!hasProgramAssociationTable) { - throw new Error('No Program Association Table found in the file.'); - } if (!hasProgramMap) { + if (!hasProgramAssociationTable) { + throw new Error('No Program Association Table found in the file.'); + } + throw new Error('No Program Map Table found in the file.'); } @@ -674,6 +694,10 @@ type PesPacket = PesPacketHeader & { }; const readPesPacketHeader = (section: Section): PesPacketHeader | null => { + if (section.payload.byteLength < 3) { + return null; + } + const bitstream = new Bitstream(section.payload); const startCodePrefix = bitstream.readBits(24); @@ -994,7 +1018,9 @@ export abstract class MpegTsTrackBacking implements InputTrackBacking { } const pesPacketHeader = readPesPacketHeader(section); - return pesPacketHeader; + if (pesPacketHeader) { + return pesPacketHeader; + } } currentPos += demuxer.packetStride; @@ -1152,17 +1178,16 @@ export abstract class MpegTsTrackBacking implements InputTrackBacking { const section = await demuxer.readSection(currentPos, false); if (section) { const nextPesHeader = readPesPacketHeader(section); - if (!nextPesHeader) { - throw new Error(MISSING_PES_PACKET_ERROR); - } - if (nextPesHeader.pts > searchPts) { - break outer; - } + if (nextPesHeader) { + if (nextPesHeader.pts > searchPts) { + break outer; + } - currentPesHeader = nextPesHeader; - maybeInsertReferencePacket(this.elementaryStream, nextPesHeader); + currentPesHeader = nextPesHeader; + maybeInsertReferencePacket(this.elementaryStream, nextPesHeader); - break; + break; + } } } @@ -1185,12 +1210,10 @@ export abstract class MpegTsTrackBacking implements InputTrackBacking { const section = await demuxer.readSection(pos, false); if (section) { const header = readPesPacketHeader(section); - if (!header) { - throw new Error(MISSING_PES_PACKET_ERROR); + if (header) { + currentPesHeader = header; + break; } - - currentPesHeader = header; - break; } } @@ -1265,13 +1288,10 @@ export abstract class MpegTsTrackBacking implements InputTrackBacking { const section = await demuxer.readSection(currentPos, false); if (section) { pesHeader = readPesPacketHeader(section); - if (!pesHeader) { - throw new Error(MISSING_PES_PACKET_ERROR); + if (pesHeader) { + maybeInsertReferencePacket(this.elementaryStream, pesHeader); + break; } - - maybeInsertReferencePacket(this.elementaryStream, pesHeader); - - break; } } @@ -1298,12 +1318,10 @@ export abstract class MpegTsTrackBacking implements InputTrackBacking { const section = await demuxer.readSection(pos, false); if (section) { const header = readPesPacketHeader(section); - if (!header) { - throw new Error(MISSING_PES_PACKET_ERROR); + if (header) { + startPesHeader = header; + break; } - - startPesHeader = header; - break; } } @@ -1759,23 +1777,20 @@ class PacketReadingContext { } if (packetHeader.pid === this.pid) { - break; + const nextSection = await this.demuxer.readSection(currentPos, true); + if (!nextSection) { + return; + } + + const nextPesPacket = readPesPacket(nextSection); + if (nextPesPacket) { + pesPacket = nextPesPacket; + break; + } } currentPos += this.demuxer.packetStride; } - - const nextSection = await this.demuxer.readSection(currentPos, true); - if (!nextSection) { - return; - } - - const nextPesPacket = readPesPacket(nextSection); - if (!nextPesPacket) { - throw new Error(MISSING_PES_PACKET_ERROR); - } - - pesPacket = nextPesPacket; } this.pesPackets.push(pesPacket); diff --git a/test/node/metadata-tags.test.ts b/test/node/metadata-tags.test.ts index d9e50c2..af68b7b 100644 --- a/test/node/metadata-tags.test.ts +++ b/test/node/metadata-tags.test.ts @@ -1,6 +1,7 @@ import { expect, test } from 'vitest'; import { Output } from '../../src/output.js'; import { + AdtsOutputFormat, FlacOutputFormat, MkvOutputFormat, MovOutputFormat, @@ -478,6 +479,61 @@ test('Read and write metadata, FLAC', async () => { expect(readTags.raw!['COMPOSER']).toBe('Hans Zimmer'); }); +test('Read and write metadata, ADTS', async () => { + const originalInput = new Input({ + source: new FilePathSource(path.join(__dirname, '../public/sample3.aac')), + formats: ALL_FORMATS, + }); + + const output = new Output({ + format: new AdtsOutputFormat(), + target: new BufferTarget(), + }); + + const conversion = await Conversion.init({ + input: originalInput, + output, + tags: { + ...songMetadata, + raw: { + TXXY: 'ID3v2 goated', + }, + }, + }); + await conversion.execute(); + + using input = new Input({ + source: new BufferSource(output.target.buffer!), + formats: ALL_FORMATS, + }); + + const readTags = await input.getMetadataTags(); + + // ID3v2 is goated, so pretty much everything was copied: + expect(readTags.title).toBe(songMetadata.title); + expect(readTags.description).toBe(songMetadata.description); + expect(readTags.artist).toBe(songMetadata.artist); + expect(readTags.album).toBe(songMetadata.album); + expect(readTags.albumArtist).toBe(songMetadata.albumArtist); + expect(readTags.comment).toBe(songMetadata.comment); + expect(readTags.lyrics).toBe(songMetadata.lyrics); + expect(readTags.trackNumber).toBe(songMetadata.trackNumber); + expect(readTags.tracksTotal).toBe(songMetadata.tracksTotal); + expect(readTags.discNumber).toBe(songMetadata.discNumber); + expect(readTags.discsTotal).toBe(songMetadata.discsTotal); + expect(readTags.date).toEqual(readTags.date); + expect(readTags.images).toHaveLength(1); + expect(readTags.images![0]!.data).toEqual(coverArt); + expect(readTags.images![0]!.mimeType).toEqual('image/jpeg'); + expect(readTags.images![0]!.kind).toEqual('coverFront'); + expect(readTags.images![0]!.description).toEqual(songMetadata.images![0]!.description); + expect(readTags.images![0]!.name).toBeUndefined(); // Can't be contained in ID3v2 + + expect(readTags.raw!['TIT2']).toBe(songMetadata.title); + expect(readTags.raw!['APIC']).instanceOf(Uint8Array); + expect(readTags.raw!['TXXY']).toBe('ID3v2 goated'); +}); + test('Read and write metadata, WAVE', async () => { const output = new Output({ format: new WavOutputFormat(),