From 92a384f418ad1ff6c77f84b2f4e0d35104c9c7cf Mon Sep 17 00:00:00 2001 From: Brad Isbell Date: Thu, 18 Jun 2026 11:40:43 -0500 Subject: [PATCH] Added ID3v2 support for FLAC files. (Fixes #417) (#418) * Added ID3v2 support for FLAC files. (Fixes #417) * Fix ID3v2 header size calculation, adjust ID3v2 logic for FLAC files --------- Co-authored-by: Vanilagy <1696106+Vanilagy@users.noreply.github.com> --- src/flac/flac-demuxer.ts | 30 ++++++++++++++-- src/id3.ts | 21 ++++++----- src/input-format.ts | 18 +++++++++- src/metadata.ts | 6 ++-- test/node/flac.test.ts | 57 ++++++++++++++++++++++++++++++ test/node/mpeg-ts-demuxing.test.ts | 2 +- 6 files changed, 117 insertions(+), 17 deletions(-) diff --git a/src/flac/flac-demuxer.ts b/src/flac/flac-demuxer.ts index 0edc26c..47887fa 100644 --- a/src/flac/flac-demuxer.ts +++ b/src/flac/flac-demuxer.ts @@ -37,6 +37,7 @@ import { getSampleRateOrUncommon, } from './flac-misc'; import { Bitstream } from '../../shared/bitstream'; +import { ID3_V2_HEADER_SIZE, parseId3V2Tag, readId3V2Header } from '../id3'; type FlacAudioInfo = { numberOfChannels: number; @@ -102,9 +103,34 @@ export class FlacDemuxer extends Demuxer { } async readMetadata() { - let currentPos = 4; // Skip 'fLaC' - return (this.metadataPromise ??= (async () => { + // Read all ID3v2 tags at the start of the file + let currentPos = 0; + while (true) { + let headerSlice = this.reader.requestSlice(currentPos, ID3_V2_HEADER_SIZE); + if (headerSlice instanceof Promise) headerSlice = await headerSlice; + + if (!headerSlice) { + this.lastSampleLoaded = true; + return; + } + + const id3V2Header = readId3V2Header(headerSlice); + if (!id3V2Header) { + break; + } + + let contentSlice = this.reader.requestSlice(headerSlice.filePos, id3V2Header.size); + if (contentSlice instanceof Promise) contentSlice = await contentSlice; + assert(contentSlice); + + parseId3V2Tag(contentSlice, id3V2Header, this.metadataTags); + + currentPos = headerSlice.filePos + id3V2Header.size; + } + + currentPos += 4; // Skip 'fLaC' + while ( this.reader.fileSize === null || currentPos < this.reader.fileSize diff --git a/src/id3.ts b/src/id3.ts index 39094db..bc2d8fa 100644 --- a/src/id3.ts +++ b/src/id3.ts @@ -152,7 +152,11 @@ export const readId3V2Header = (slice: FileSlice): Id3V2Header | null => { return null; } - const size = decodeSynchsafe(sizeRaw); + let size = decodeSynchsafe(sizeRaw); + + if (flags & Id3V2HeaderFlags.Footer) { + size += ID3_V2_HEADER_SIZE; + } return { majorVersion, revision, flags, size }; }; @@ -165,12 +169,12 @@ export const parseId3V2Tag = (slice: FileSlice, header: Id3V2Header, tags: Metad return; } - const bytes = readBytes(slice, header.size); - const reader = new Id3V2Reader(header, bytes); + const dataSize = (header.flags & Id3V2HeaderFlags.Footer) + ? header.size - ID3_V2_HEADER_SIZE + : header.size; - if (header.flags & Id3V2HeaderFlags.Footer) { - reader.removeFooter(); - } + const bytes = readBytes(slice, dataSize); + const reader = new Id3V2Reader(header, bytes); if ((header.flags & Id3V2HeaderFlags.Unsynchronisation) && header.majorVersion === 3) { reader.ununsynchronizeAll(); @@ -463,11 +467,6 @@ export class Id3V2Reader { this.view = new DataView(this.bytes.buffer); } - removeFooter() { - this.bytes = this.bytes.subarray(0, this.bytes.length - ID3_V2_HEADER_SIZE); - this.view = new DataView(this.bytes.buffer); - } - readBytes(length: number) { const slice = this.bytes.subarray(this.pos, this.pos + length); this.pos += length; diff --git a/src/input-format.ts b/src/input-format.ts index 28eeb7e..b61adeb 100644 --- a/src/input-format.ts +++ b/src/input-format.ts @@ -446,7 +446,23 @@ export class OggInputFormat extends InputFormat { export class FlacInputFormat extends InputFormat { /** @internal */ async _canReadInput(input: Input) { - let slice = input._reader.requestSlice(0, 4); + let currentPos = 0; + + // There might be ID3v2 headers at the start, skip 'em + 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.requestSlice(currentPos, 4); if (slice instanceof Promise) slice = await slice; if (!slice) return false; diff --git a/src/metadata.ts b/src/metadata.ts index d01246f..73e679d 100644 --- a/src/metadata.ts +++ b/src/metadata.ts @@ -20,7 +20,8 @@ import { isRecordStringString } from './misc'; * in Vorbis-style comment headers. * - For WAVE files, the metadata refers to the chunks within the RIFF INFO chunk. * - 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 FLAC files, the metadata lives in Vorbis style in the Vorbis comment block, or sometimes in ID3v2 tags at the + * start of the file. * - For MPEG-TS files, metadata tags are currently not supported. * * @group Metadata tags @@ -78,7 +79,8 @@ export type MetadataTags = { * 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. * - FLAC: The key-value string pairs from the vorbis metadata block (see RFC 9639, Section D.2.3). - * Additionally, the `'vendor'` key refers to the vendor string within this header. + * Additionally, the `'vendor'` key refers to the vendor string within this header. If ID3v2 tags appear at the + * start of the file, their content is stored just like for MP3. * - MPEG-TS: Not supported. */ raw?: Record | null>; diff --git a/test/node/flac.test.ts b/test/node/flac.test.ts index df3742c..861ded3 100644 --- a/test/node/flac.test.ts +++ b/test/node/flac.test.ts @@ -1,5 +1,6 @@ import { expect, test } from 'vitest'; import path from 'node:path'; +import fs from 'node:fs/promises'; import { assert, toUint8Array } from '../../src/misc.js'; import { Input } from '../../src/input.js'; import { BufferSource, FilePathSource } from '../../src/source.js'; @@ -284,3 +285,59 @@ test('appendOnly writes correct STREAMINFO header', async () => { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, ])); }); + +test('can read a FLAC file with leading ID3v2 tags', async () => { + const createId3V23TitleTag = (title: string) => { + const titleBytes = new TextEncoder().encode(title); + const frame = new Uint8Array(11 + titleBytes.length); + frame.set([0x54, 0x49, 0x54, 0x32]); // TIT2 + frame[7] = 1 + titleBytes.length; + frame[10] = 0; // ISO-8859-1 + frame.set(titleBytes, 11); + + const tag = new Uint8Array(10 + frame.length); + tag.set([0x49, 0x44, 0x33, 0x03, 0x00, 0x00]); // ID3v2.3 + tag[9] = frame.length; + tag.set(frame, 10); + + return tag; + }; + + const concatenateBytes = (...chunks: Uint8Array[]) => { + const result = new Uint8Array(chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0)); + let offset = 0; + + for (const chunk of chunks) { + result.set(chunk, offset); + offset += chunk.byteLength; + } + + return result; + }; + + using input = new Input({ + source: new BufferSource(concatenateBytes( + createId3V23TitleTag('First tag'), + createId3V23TitleTag('Second tag'), + await fs.readFile(new URL('../public/sample.flac', import.meta.url)), + )), + formats: ALL_FORMATS, + }); + + expect(await input.canRead()).toBe(true); + expect(await input.getFormat()).toBe(FLAC); + + const track = await input.getPrimaryAudioTrack(); + assert(track); + expect(await track.getDurationFromMetadata()).toEqual(19.714285714285715); + + const firstPacket = await new EncodedPacketSink(track).getPacket(0); + assert(firstPacket); + expect(firstPacket.sequenceNumber).toBe(0); + expect(firstPacket.timestamp).toBe(0); + + const metadataTags = await input.getMetadataTags(); + expect(metadataTags.title).toBe('First tag'); + expect(metadataTags.raw!['TIT2']).toBe('First tag'); + expect(metadataTags.raw!['TITLE']).toBe('The Happy Meeting'); +}); diff --git a/test/node/mpeg-ts-demuxing.test.ts b/test/node/mpeg-ts-demuxing.test.ts index 25d2f94..01e1a9b 100644 --- a/test/node/mpeg-ts-demuxing.test.ts +++ b/test/node/mpeg-ts-demuxing.test.ts @@ -354,7 +354,7 @@ test('MPEG-TS seeking race condition test', async () => { } }); -test('MPEG-TS video key packets', async () => { +test('MPEG-TS video key packets', { timeout: 10_000 }, async () => { for (let i = 0; i < 2; i++) { using input = new Input({ source: new FilePathSource(path.join(__dirname, '../public/trim-buck-bunny-ffmpeg.ts')),