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 <[email protected]>
This commit is contained in:
Jonny Burger
2026-03-19 13:43:28 +00:00
committed by GitHub
co-authored by Vanilagy
parent 0d50526ff6
commit 31d86331ec
5 changed files with 146 additions and 40 deletions
+7
View File
@@ -303,9 +303,16 @@ const output = new Output({
The following options are available: The following options are available:
```ts ```ts
type FlacOutputFormatOptions = { type FlacOutputFormatOptions = {
appendOnly?: boolean;
onFrame?: (data: Uint8Array, position: number) => unknown; 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`\ - `onFrame`\
Will be called for each FLAC frame that is written. Will be called for each FLAC frame that is written.
+29 -4
View File
@@ -283,13 +283,38 @@ export class FlacDemuxer extends Demuxer {
// --> 6 bytes // --> 6 bytes
const minimumHeaderLength = 6; const minimumHeaderLength = 6;
// If we read everything in readFlacFrameHeader, we read 16 bytes // 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 const maximumSliceLength
= this.audioInfo.maximumFrameSize + maximumHeaderSize; = effectiveMaxFrameSize + maximumHeaderLength;
const slice = await this.reader.requestSliceRange( const slice = await this.reader.requestSliceRange(
startPos, startPos,
this.audioInfo.minimumFrameSize, maximumHeaderLength,
maximumSliceLength, maximumSliceLength,
); );
@@ -312,7 +337,7 @@ export class FlacDemuxer extends Demuxer {
// The next sync word is expected at earliest when `minimumFrameSize` is reached, // The next sync word is expected at earliest when `minimumFrameSize` is reached,
// we can skip over anything before that // we can skip over anything before that
slice.filePos = startPos + this.audioInfo.minimumFrameSize; slice.filePos = startPos + effectiveMinFrameSize;
while (true) { while (true) {
// Reached end of the file, packet is over // Reached end of the file, packet is over
+70 -36
View File
@@ -50,6 +50,10 @@ export class FlacMuxer extends Muxer {
this.writer = output._writer; this.writer = output._writer;
this.format = format; this.format = format;
if (this.format._options.appendOnly) {
this.writer.ensureMonotonicity = true;
}
} }
async start() { async start() {
@@ -165,7 +169,9 @@ export class FlacMuxer extends Muxer {
} }
writeVorbisCommentAndPictureBlock() { 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)) { if (metadataTagsAreEmpty(this.output._metadataTags)) {
this.metadataWritten = true; this.metadataWritten = true;
return; return;
@@ -233,6 +239,30 @@ export class FlacMuxer extends Muxer {
descriptionBitstream.skipBits(103 + 64); descriptionBitstream.skipBits(103 + 64);
const bitsPerSample = descriptionBitstream.readBits(5) + 1; const bitsPerSample = descriptionBitstream.readBits(5) + 1;
this.bitsPerSample = bitsPerSample; 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) { if (!this.metadataWritten) {
@@ -251,8 +281,10 @@ export class FlacMuxer extends Muxer {
readCodedNumber(slice); // num readCodedNumber(slice); // num
const blockSize = readBlockSize(slice, blockSizeOrUncommon); const blockSize = readBlockSize(slice, blockSizeOrUncommon);
this.blockSizes.push(blockSize); if (!this.format._options.appendOnly) {
this.frameSizes.push(packet.data.length); this.blockSizes.push(blockSize);
this.frameSizes.push(packet.data.length);
}
const startPos = this.writer.getPos(); const startPos = this.writer.getPos();
this.writer.write(packet.data); this.writer.write(packet.data);
@@ -274,43 +306,45 @@ export class FlacMuxer extends Muxer {
async finalize(): Promise<void> { async finalize(): Promise<void> {
const release = await this.mutex.acquire(); const release = await this.mutex.acquire();
let minimumBlockSize = Infinity; if (!this.format._options.appendOnly) {
let maximumBlockSize = 0; let minimumBlockSize = Infinity;
let minimumFrameSize = Infinity; let maximumBlockSize = 0;
let maximumFrameSize = 0; let minimumFrameSize = Infinity;
let totalSamples = 0; let maximumFrameSize = 0;
for (let i = 0; i < this.blockSizes.length; i++) { let totalSamples = 0;
minimumFrameSize = Math.min(minimumFrameSize, this.frameSizes[i]!); for (let i = 0; i < this.blockSizes.length; i++) {
maximumFrameSize = Math.max(maximumFrameSize, this.frameSizes[i]!); minimumFrameSize = Math.min(minimumFrameSize, this.frameSizes[i]!);
maximumBlockSize = Math.max(maximumBlockSize, this.blockSizes[i]!); maximumFrameSize = Math.max(maximumFrameSize, this.frameSizes[i]!);
totalSamples += this.blockSizes[i]!; maximumBlockSize = Math.max(maximumBlockSize, this.blockSizes[i]!);
totalSamples += this.blockSizes[i]!;
// Excluding the last frame from block size calculation // Excluding the last frame from block size calculation
// https://www.rfc-editor.org/rfc/rfc9639.html#name-streaminfo // https://www.rfc-editor.org/rfc/rfc9639.html#name-streaminfo
// "The minimum block size (in samples) used in the stream, excluding the last block." // "The minimum block size (in samples) used in the stream, excluding the last block."
const isLastFrame = i === this.blockSizes.length - 1; const isLastFrame = i === this.blockSizes.length - 1;
if (isLastFrame) { if (isLastFrame) {
continue; 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(); release();
} }
} }
+10
View File
@@ -928,6 +928,13 @@ export class AdtsOutputFormat extends OutputFormat {
* @public * @public
*/ */
export type FlacOutputFormatOptions = { 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. * Will be called for each FLAC frame that is written.
* *
@@ -951,6 +958,9 @@ export class FlacOutputFormat extends OutputFormat {
if (!options || typeof options !== 'object') { if (!options || typeof options !== 'object') {
throw new TypeError('options must be an 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(); super();
+30
View File
@@ -254,3 +254,33 @@ test('can re-mux a .flac', async () => {
expect(otherInputDecoderConfig).toEqual(otherOutputDecoderConfig); 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,
]));
});