From 31d86331ec4cf6865db56111ac0bcb0c41919a72 Mon Sep 17 00:00:00 2001 From: Jonny Burger Date: Thu, 19 Mar 2026 14:43:28 +0100 Subject: [PATCH] FLAC: Add `appendOnly` option for FlaxMuxer, fix FlacDemuxer reading it (#325) * implement in FlacOutputFormatOptions * implement in flac-muxer.ts * Fix demuxer: Handle minimumFrameSize = 0, maxiumFrameSize = 0 * add a test * Update flac-demuxer.ts * Update flac.test.ts * clean up comments for minima and maxima * Add FlacOutputFormatOptions.appendOnly to docs --------- Co-authored-by: Vanilagy <1696106+Vanilagy@users.noreply.github.com> --- docs/guide/output-formats.md | 7 +++ src/flac/flac-demuxer.ts | 33 +++++++++-- src/flac/flac-muxer.ts | 106 +++++++++++++++++++++++------------ src/output-format.ts | 10 ++++ test/node/flac.test.ts | 30 ++++++++++ 5 files changed, 146 insertions(+), 40 deletions(-) diff --git a/docs/guide/output-formats.md b/docs/guide/output-formats.md index e4831be..32980a1 100644 --- a/docs/guide/output-formats.md +++ b/docs/guide/output-formats.md @@ -303,9 +303,16 @@ const output = new Output({ The following options are available: ```ts type FlacOutputFormatOptions = { + appendOnly?: boolean; + onFrame?: (data: Uint8Array, position: number) => unknown; }; ``` +- `appendOnly`\ + Configures the output to only append new data at the end, useful for live-streaming the file as it's being created. When enabled, the STREAMINFO block will not be finalized with accurate min/max block sizes, frame sizes, or total sample count, so don't use this option when you want to write out a clean file for later use. + ::: info + This option ensures [append-only writing](#append-only-writing). + ::: - `onFrame`\ Will be called for each FLAC frame that is written. diff --git a/src/flac/flac-demuxer.ts b/src/flac/flac-demuxer.ts index adffadf..c0c0a20 100644 --- a/src/flac/flac-demuxer.ts +++ b/src/flac/flac-demuxer.ts @@ -283,13 +283,38 @@ export class FlacDemuxer extends Demuxer { // --> 6 bytes const minimumHeaderLength = 6; // If we read everything in readFlacFrameHeader, we read 16 bytes - const maximumHeaderSize = 16; + const maximumHeaderLength = 16; + + // The shortest valid FLAC frame per RFC 9639: + // 6 bytes header (see minimumHeaderLength above) + // 2 bytes subframe (constant subframe with minimum bit depth, + // padded to byte boundary) + // 2 bytes footer (CRC-16) + // --> 10 bytes + const minimumFrameLength = 10; + + // The longest valid FLAC frame per RFC 9639: + // https://www.rfc-editor.org/rfc/rfc9639.html#name-prediction + // https://www.rfc-editor.org/rfc/rfc9639.html#name-frame-structure + // maximumBlockSize * numberOfChannels * 4 bytes (max 32 bps verbatim) + // + 16 bytes header (see maximumHeaderSize above) + // + 2 bytes footer (CRC-16) + const maximumFrameLength = this.audioInfo.maximumBlockSize + * this.audioInfo.numberOfChannels + * 4 + + maximumHeaderLength + + 2; + + // Per RFC 9639, a value of 0 means "unknown" for frame sizes. + const effectiveMinFrameSize = this.audioInfo.minimumFrameSize || minimumFrameLength; + const effectiveMaxFrameSize = this.audioInfo.maximumFrameSize || maximumFrameLength; + const maximumSliceLength - = this.audioInfo.maximumFrameSize + maximumHeaderSize; + = effectiveMaxFrameSize + maximumHeaderLength; const slice = await this.reader.requestSliceRange( startPos, - this.audioInfo.minimumFrameSize, + maximumHeaderLength, maximumSliceLength, ); @@ -312,7 +337,7 @@ export class FlacDemuxer extends Demuxer { // The next sync word is expected at earliest when `minimumFrameSize` is reached, // we can skip over anything before that - slice.filePos = startPos + this.audioInfo.minimumFrameSize; + slice.filePos = startPos + effectiveMinFrameSize; while (true) { // Reached end of the file, packet is over diff --git a/src/flac/flac-muxer.ts b/src/flac/flac-muxer.ts index 83cdfe9..b52c582 100644 --- a/src/flac/flac-muxer.ts +++ b/src/flac/flac-muxer.ts @@ -50,6 +50,10 @@ export class FlacMuxer extends Muxer { this.writer = output._writer; this.format = format; + + if (this.format._options.appendOnly) { + this.writer.ensureMonotonicity = true; + } } async start() { @@ -165,7 +169,9 @@ export class FlacMuxer extends Muxer { } writeVorbisCommentAndPictureBlock() { - this.writer.seek(STREAMINFO_SIZE + FLAC_HEADER.byteLength); + if (!this.format._options.appendOnly) { + this.writer.seek(STREAMINFO_SIZE + FLAC_HEADER.byteLength); + } if (metadataTagsAreEmpty(this.output._metadataTags)) { this.metadataWritten = true; return; @@ -233,6 +239,30 @@ export class FlacMuxer extends Muxer { descriptionBitstream.skipBits(103 + 64); const bitsPerSample = descriptionBitstream.readBits(5) + 1; this.bitsPerSample = bitsPerSample; + + if (this.format._options.appendOnly) { + // Write STREAMINFO immediately since we can't seek back later. + this.writeHeader({ + // https://www.rfc-editor.org/rfc/rfc9639.html#name-streaminfo + // Per RFC 9639, min/max block sizes can be looser than + // actual values, so we use the full valid range (16–65535). + // "The actual max block size MAY be smaller than what's + // listed, and the actual min (excluding last block) MAY be + // larger. This is because the encoder has to write these + // fields before receiving any input audio data and cannot + // know beforehand what block sizes it will use." + minimumBlockSize: 16, + maximumBlockSize: 65535, + // https://www.rfc-editor.org/rfc/rfc9639.html#name-streaminfo + // "A value of 0 signifies that the value is not known." + minimumFrameSize: 0, + maximumFrameSize: 0, + sampleRate: this.sampleRate, + channels: this.channels, + bitsPerSample: this.bitsPerSample, + totalSamples: 0, + }); + } } if (!this.metadataWritten) { @@ -251,8 +281,10 @@ export class FlacMuxer extends Muxer { readCodedNumber(slice); // num const blockSize = readBlockSize(slice, blockSizeOrUncommon); - this.blockSizes.push(blockSize); - this.frameSizes.push(packet.data.length); + if (!this.format._options.appendOnly) { + this.blockSizes.push(blockSize); + this.frameSizes.push(packet.data.length); + } const startPos = this.writer.getPos(); this.writer.write(packet.data); @@ -274,43 +306,45 @@ export class FlacMuxer extends Muxer { async finalize(): Promise { const release = await this.mutex.acquire(); - let minimumBlockSize = Infinity; - let maximumBlockSize = 0; - let minimumFrameSize = Infinity; - let maximumFrameSize = 0; - let totalSamples = 0; - for (let i = 0; i < this.blockSizes.length; i++) { - minimumFrameSize = Math.min(minimumFrameSize, this.frameSizes[i]!); - maximumFrameSize = Math.max(maximumFrameSize, this.frameSizes[i]!); - maximumBlockSize = Math.max(maximumBlockSize, this.blockSizes[i]!); - totalSamples += this.blockSizes[i]!; + if (!this.format._options.appendOnly) { + let minimumBlockSize = Infinity; + let maximumBlockSize = 0; + let minimumFrameSize = Infinity; + let maximumFrameSize = 0; + let totalSamples = 0; + for (let i = 0; i < this.blockSizes.length; i++) { + minimumFrameSize = Math.min(minimumFrameSize, this.frameSizes[i]!); + maximumFrameSize = Math.max(maximumFrameSize, this.frameSizes[i]!); + maximumBlockSize = Math.max(maximumBlockSize, this.blockSizes[i]!); + totalSamples += this.blockSizes[i]!; - // Excluding the last frame from block size calculation - // https://www.rfc-editor.org/rfc/rfc9639.html#name-streaminfo - // "The minimum block size (in samples) used in the stream, excluding the last block." - const isLastFrame = i === this.blockSizes.length - 1; - if (isLastFrame) { - continue; + // Excluding the last frame from block size calculation + // https://www.rfc-editor.org/rfc/rfc9639.html#name-streaminfo + // "The minimum block size (in samples) used in the stream, excluding the last block." + const isLastFrame = i === this.blockSizes.length - 1; + if (isLastFrame) { + continue; + } + minimumBlockSize = Math.min(minimumBlockSize, this.blockSizes[i]!); } - minimumBlockSize = Math.min(minimumBlockSize, this.blockSizes[i]!); + + assert(this.sampleRate !== null); + assert(this.channels !== null); + assert(this.bitsPerSample !== null); + + this.writer.seek(4); + this.writeHeader({ + minimumBlockSize, + maximumBlockSize, + minimumFrameSize, + maximumFrameSize, + sampleRate: this.sampleRate, + channels: this.channels, + bitsPerSample: this.bitsPerSample, + totalSamples, + }); } - assert(this.sampleRate !== null); - assert(this.channels !== null); - assert(this.bitsPerSample !== null); - - this.writer.seek(4); - this.writeHeader({ - minimumBlockSize, - maximumBlockSize, - minimumFrameSize, - maximumFrameSize, - sampleRate: this.sampleRate, - channels: this.channels, - bitsPerSample: this.bitsPerSample, - totalSamples, - }); - release(); } } diff --git a/src/output-format.ts b/src/output-format.ts index adef3a9..1608cf8 100644 --- a/src/output-format.ts +++ b/src/output-format.ts @@ -928,6 +928,13 @@ export class AdtsOutputFormat extends OutputFormat { * @public */ export type FlacOutputFormatOptions = { + /** + * Configures the output to only append new data at the end, useful for live-streaming the file as it's being + * created. When enabled, the STREAMINFO block will not be finalized with accurate min/max block sizes, frame sizes, + * or total sample count, so don't use this option when you want to write out a clean file for later use. + */ + appendOnly?: boolean; + /** * Will be called for each FLAC frame that is written. * @@ -951,6 +958,9 @@ export class FlacOutputFormat extends OutputFormat { if (!options || typeof options !== 'object') { throw new TypeError('options must be an object.'); } + if (options.appendOnly !== undefined && typeof options.appendOnly !== 'boolean') { + throw new TypeError('options.appendOnly, when provided, must be a boolean.'); + } super(); diff --git a/test/node/flac.test.ts b/test/node/flac.test.ts index a7efd4e..f31e19c 100644 --- a/test/node/flac.test.ts +++ b/test/node/flac.test.ts @@ -254,3 +254,33 @@ test('can re-mux a .flac', async () => { expect(otherInputDecoderConfig).toEqual(otherOutputDecoderConfig); }); + +test('appendOnly writes correct STREAMINFO header', async () => { + const filePath = path.join(__dirname, '..', 'public/sample.flac'); + using input = new Input({ + source: new FilePathSource(filePath), + formats: ALL_FORMATS, + }); + + const target = new BufferTarget(); + const output = new Output({ + format: new FlacOutputFormat({ appendOnly: true }), + target, + }); + + const conversion = await Conversion.init({ input, output }); + await conversion.execute(); + + assert(target.buffer); + const bytes = new Uint8Array(target.buffer); + + // STREAMINFO: min_block=16, max_block=65535, min_frame=0, max_frame=0 + expect(bytes.slice(8, 18)).toEqual(new Uint8Array([ + // minimum_block_size=16 + 0x00, 0x10, + // maximum_block_size=65535 + 0xFF, 0xFF, + // minimum_frame_size=0 + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + ])); +});