mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-09-27 10:53:50 +02:00
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:
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user