FLAC container support (#95)

* add a test

* recognize as input format

* scaffold flac demuxer

* implement getting metadata

* Implement mime type

* read all metadata + deduplicate stubs

* Read first packet

* copyright headers

* read the first packet

* Get entire first packet, work on advancing

* iterate over all samples

* testable with bun

* stub out metadata support

* parse descriptive metadata

* All in 1 file

seems more appropriate to the philosophy

* some parameters are not needed anymore all within 1 class

* skip over bytes we are sure are not the syncword

* run prettier

* timestamp is determined based on passed blocks, not maximumBlockSize

* no binary search needed!

* simplifications

* Finish demuxer reading sequentially

* more tests + add a file with a seektable

* don't throw if (this.audioInfo.minimumBlockSize !== this.audioInfo.maximumBlockSize

* Add docs

* Update format compatibility table

* Simplification

* confirm conversion is working

* Finish

* Resolve TODO comment

* Support images (read-only)

* Returning description as Uint8Array

* Cleanup of demuxer

* Misc renames

* Object on same line

* Resolve first batch of comments

* Throw errors on corrupt blocks, correctly use requestSlice()

* Fix description field

* Explain why last frame is a bit shorter

* Add FLAC to README

* Put track backings below demuxer

* convert to methods

* Reorder container checking

* getBlockSize() -> readBlockSize()

* bitStream -> bitstream

* better naming for bytes

* use .skip()

* Don't return blockSize twice in readFlacFrameHeader

* Use enum + switch to distinguish Flac block types

* Use else-if

* Use else-if

* Compressed switch statement

* readCodedNumber

* Update flac-misc.ts

* We don't need the bits variable at all

* reorder functions in flac-demuxer

* Handle gracefully not being able to load another sample

* Update flac-demuxer.ts

* blockingbit null

* use async instead of promise.resolve

* use binary search

* Replace recursion with while loop

* Add mutex to getPacket()

* Load more data not in getPacketAtIndex, but outside

* Apply suggestion from @Vanilagy

Co-authored-by: David P. <[email protected]>

* computeDuration() reads last packet

* Update flac-demuxer.ts

* share vorbis comment reading logic

* reuse vorbis comment writing logic, set vendor always to "Mediabunny"

* flush after writign

* Use FileSlice.tempFromBytes

* assert !== null

* fixing nitpicks

* apply suggestions

* fix ogg

* We are now muxing images

* apply suggestion

* Update src/flac/flac-muxer.ts

Co-authored-by: David P. <[email protected]>

* apply suggestion

* readSampleRate()

* seek outside writeHeader()

* mention vorbis metadata +  add to metadatatags comment

* should be able to -> can

* Add test in metadata tags

* Add test for packets being byte identical after remuxing

* compare to null

* no casting to uint8array

* make test pass

* Update flac-muxer.ts

* Call validateAudioChunkMetadata() and validateAndNormalizeTimestamp()

* `onFrame` option

* emit frames using onFrame

* Run prettier over files

* Fix FLAC PICTURE block logic, small other changes

* Update docs

* Remove .only modifier

* fix remuxing and add test

* Don't throw error if parsing fails in header, since hitting a syncword might just be coincidential

* Fix remaining type errors

---------

Co-authored-by: David P. <[email protected]>
This commit is contained in:
Jonny Burger
2025-09-18 21:05:09 +02:00
committed by GitHub
co-authored by David P.
parent 7664d48f70
commit d426d36386
21 changed files with 2098 additions and 379 deletions
+695
View File
@@ -0,0 +1,695 @@
/*!
* Copyright (c) 2025-present, Vanilagy and contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { FlacBlockType, readVorbisComments } from '../codec-data';
import { Demuxer } from '../demuxer';
import { Input } from '../input';
import { InputAudioTrack, InputAudioTrackBacking } from '../input-track';
import { PacketRetrievalOptions } from '../media-sink';
import {
assert,
AsyncMutex,
binarySearchLessOrEqual,
Bitstream,
textDecoder,
UNDETERMINED_LANGUAGE,
} from '../misc';
import { EncodedPacket, PLACEHOLDER_DATA } from '../packet';
import {
FileSlice,
readBytes,
Reader,
readU24Be,
readU32Be,
readU8,
} from '../reader';
import { MetadataTags } from '../tags';
import {
calculateCrc8,
readBlockSize,
getBlockSizeOrUncommon,
readCodedNumber,
readSampleRate,
getSampleRateOrUncommon,
} from './flac-misc';
type FlacAudioInfo = {
numberOfChannels: number;
sampleRate: number;
totalSamples: number;
minimumBlockSize: number;
maximumBlockSize: number;
minimumFrameSize: number;
maximumFrameSize: number;
description: Uint8Array;
};
type Sample = {
blockOffset: number;
blockSize: number;
byteOffset: number;
byteSize: number;
};
type NextFlacFrameResult = {
num: number;
blockSize: number;
sampleRate: number;
size: number;
isLastFrame: boolean;
};
export class FlacDemuxer extends Demuxer {
reader: Reader;
loadedSamples: Sample[] = []; // All samples from the start of the file to lastLoadedPos
metadataPromise: Promise<void> | null = null;
track: InputAudioTrack | null = null;
metadataTags: MetadataTags = {};
audioInfo: FlacAudioInfo | null = null;
lastLoadedPos: number | null = null;
blockingBit: number | null = null;
readingMutex = new AsyncMutex();
lastSampleLoaded = false;
constructor(input: Input) {
super(input);
this.reader = input._reader;
}
override async computeDuration(): Promise<number> {
await this.readMetadata();
assert(this.track);
return this.track.computeDuration();
}
override async getMetadataTags(): Promise<MetadataTags> {
await this.readMetadata();
return this.metadataTags;
}
async getTracks() {
await this.readMetadata();
assert(this.track);
return [this.track];
}
async getMimeType() {
return 'audio/flac';
}
async readMetadata() {
let currentPos = 4; // Skip 'fLaC'
return (this.metadataPromise ??= (async () => {
while (
this.reader.fileSize === null
|| currentPos < this.reader.fileSize
) {
const sizeSlice = await this.reader.requestSlice(currentPos, 4);
currentPos += 4;
if (sizeSlice === null) {
throw new Error(
`Metadata block at position ${currentPos} is too small! Corrupted file.`,
);
}
assert(sizeSlice);
const byte = readU8(sizeSlice); // first bit: isLastMetadata, remaining 7 bits: metaBlockType
const size = readU24Be(sizeSlice);
const isLastMetadata = (byte & 0x80) !== 0;
const metaBlockType = byte & 0x7f;
switch (metaBlockType) {
case FlacBlockType.STREAMINFO: {
// Parse streaminfo block
// https://www.rfc-editor.org/rfc/rfc9639.html#section-8.2
const streamInfoBlock = await this.reader.requestSlice(
currentPos,
size,
);
assert(streamInfoBlock);
if (streamInfoBlock === null) {
throw new Error(
`StreamInfo block at position ${currentPos} is too small! Corrupted file.`,
);
}
const streamInfoBytes = readBytes(streamInfoBlock, 34);
const bitstream = new Bitstream(streamInfoBytes);
const minimumBlockSize = bitstream.readBits(16);
const maximumBlockSize = bitstream.readBits(16);
const minimumFrameSize = bitstream.readBits(24);
const maximumFrameSize = bitstream.readBits(24);
const sampleRate = bitstream.readBits(20);
const numberOfChannels = bitstream.readBits(3) + 1;
bitstream.readBits(5); // bitsPerSample - 1
const totalSamples = bitstream.readBits(36);
// https://www.w3.org/TR/webcodecs-flac-codec-registration/#audiodecoderconfig-description
// description is required, and has to be the following:
// 1. The bytes 0x66 0x4C 0x61 0x43 ("fLaC" in ASCII)
// 2. A metadata block (called the STREAMINFO block) as described in section 7 of [FLAC]
// 3. Optionaly (sic) other metadata blocks, that are not used by the specification
bitstream.skipBits(16 * 8); // md5 hash
const description = new Uint8Array(42);
// 1. "fLaC"
description.set(new Uint8Array([0x66, 0x4c, 0x61, 0x43]), 0);
// 2. STREAMINFO block
description.set(new Uint8Array([128, 0, 0, 34]), 4);
// 3. Other metadata blocks
description.set(streamInfoBytes, 8);
this.audioInfo = {
numberOfChannels,
sampleRate,
totalSamples,
minimumBlockSize,
maximumBlockSize,
minimumFrameSize,
maximumFrameSize,
description,
};
this.track = new InputAudioTrack(new FlacAudioTrackBacking(this));
break;
}
case FlacBlockType.VORBIS_COMMENT: {
// Parse vorbis comment block
// https://www.rfc-editor.org/rfc/rfc9639.html#name-vorbis-comment
const vorbisCommentBlock = await this.reader.requestSlice(
currentPos,
size,
);
assert(vorbisCommentBlock);
readVorbisComments(
vorbisCommentBlock.bytes.subarray(
vorbisCommentBlock.start,
vorbisCommentBlock.end,
),
this.metadataTags,
);
break;
}
case FlacBlockType.PICTURE: {
// Parse picture block
// https://www.rfc-editor.org/rfc/rfc9639.html#name-picture
const pictureBlock = await this.reader.requestSlice(
currentPos,
size,
);
assert(pictureBlock);
const pictureType = readU32Be(pictureBlock);
const mediaTypeLength = readU32Be(pictureBlock);
const mediaType = textDecoder.decode(
readBytes(pictureBlock, mediaTypeLength),
);
const descriptionLength = readU32Be(pictureBlock);
const description = textDecoder.decode(
readBytes(pictureBlock, descriptionLength),
);
pictureBlock.skip(4 + 4 + 4 + 4); // Skip width, height, color depth, number of indexed colors
const dataLength = readU32Be(pictureBlock);
const data = readBytes(pictureBlock, dataLength);
this.metadataTags.images ??= [];
this.metadataTags.images.push({
data,
mimeType: mediaType,
// https://www.rfc-editor.org/rfc/rfc9639.html#table13
kind:
pictureType === 3
? 'coverFront'
: pictureType === 4
? 'coverBack'
: 'unknown',
description,
});
break;
}
default:
break;
}
currentPos += size;
if (isLastMetadata) {
this.lastLoadedPos = currentPos;
break;
}
}
})());
}
async readNextFlacFrame({
startPos,
isFirstPacket,
}: {
startPos: number;
isFirstPacket: boolean;
}): Promise<NextFlacFrameResult | null> {
assert(this.audioInfo);
// we expect that there are at least `minimumFrameSize` bytes left in the file
// Ideally we also want to validate the next header is valid
// to throw out an accidential sync word
// The shortest valid FLAC header I can think of, based off the code
// of readFlacFrameHeader:
// 4 bytes used for bitstream from syncword to bit depth
// 1 byte coded number
// (uncommon values, no bytes read)
// 1 byte crc
// --> 6 bytes
const minimumHeaderLength = 6;
// If we read everything in readFlacFrameHeader, we read 16 bytes
const maximumHeaderSize = 16;
const maximumSliceLength
= this.audioInfo.maximumFrameSize + maximumHeaderSize;
const slice = await this.reader.requestSliceRange(
startPos,
this.audioInfo.minimumFrameSize,
maximumSliceLength,
);
if (!slice) {
return null;
}
const frameHeader = this.readFlacFrameHeader({
slice,
isFirstPacket: isFirstPacket,
});
if (!frameHeader) {
return null;
}
// We don't know exactly how long the packet is, we only know the `minimumFrameSize` and `maximumFrameSize`
// The packet is over if the next 2 bytes are the sync word followed by a valid header
// or the end of the file is reached
// 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;
while (true) {
// Reached end of the file, packet is over
if (slice.filePos > slice.end - minimumHeaderLength) {
return {
num: frameHeader.num,
blockSize: frameHeader.blockSize,
sampleRate: frameHeader.sampleRate,
size: slice.end - startPos,
isLastFrame: true,
};
}
const nextByte = readU8(slice);
if (nextByte === 0xff) {
const byteAfterNextByte = readU8(slice);
const expected = this.blockingBit === 1 ? 0b1111_1001 : 0b1111_1000;
if (byteAfterNextByte !== expected) {
slice.skip(-1);
continue;
}
slice.skip(-2);
const lengthIfNextFlacFrameHeaderIsLegit = slice.filePos - startPos;
const nextIsLegit = this.readFlacFrameHeader({
slice,
isFirstPacket: false,
});
if (!nextIsLegit) {
slice.skip(-1);
continue;
}
return {
num: frameHeader.num,
blockSize: frameHeader.blockSize,
sampleRate: frameHeader.sampleRate,
size: lengthIfNextFlacFrameHeaderIsLegit,
isLastFrame: false,
};
}
}
}
readFlacFrameHeader({
slice,
isFirstPacket,
}: {
slice: FileSlice;
isFirstPacket: boolean;
}) {
// In this function, generally it is not safe to throw errors.
// We might end up here because we stumbled upon a syncword,
// but the data might not actually be a FLAC frame, it might be random bitstream
// data, in that case we should return null and continue.
const startOffset = slice.filePos;
// https://www.rfc-editor.org/rfc/rfc9639.html#section-9.1
// Each frame MUST start on a byte boundary and start with the 15-bit frame
// sync code 0b111111111111100. Following the sync code is the blocking strategy
// bit, which MUST NOT change during the audio stream.
const bytes = readBytes(slice, 4);
const bitstream = new Bitstream(bytes);
const bits = bitstream.readBits(15);
if (bits !== 0b111111111111100) {
// This cannot be a valid FLAC frame, must start with the syncword
return null;
}
if (this.blockingBit === null) {
assert(isFirstPacket);
const newBlockingBit = bitstream.readBits(1);
this.blockingBit = newBlockingBit;
} else if (this.blockingBit === 1) {
assert(!isFirstPacket);
const newBlockingBit = bitstream.readBits(1);
if (newBlockingBit !== 1) {
// This cannot be a valid FLAC frame, expected 1 but got 0
return null;
}
} else if (this.blockingBit === 0) {
assert(!isFirstPacket);
const newBlockingBit = bitstream.readBits(1);
if (newBlockingBit !== 0) {
// This cannot be a valid FLAC frame, expected 0 but got 1
return null;
}
} else {
throw new Error('Invalid blocking bit');
}
const blockSizeOrUncommon = getBlockSizeOrUncommon(bitstream.readBits(4));
if (!blockSizeOrUncommon) {
// This cannot be a valid FLAC frame, the syncword was just coincidental
return null;
}
assert(this.audioInfo);
const sampleRateOrUncommon = getSampleRateOrUncommon(
bitstream.readBits(4),
this.audioInfo.sampleRate,
);
if (!sampleRateOrUncommon) {
// This cannot be a valid FLAC frame, the syncword was just coincidental
return null;
}
bitstream.readBits(4); // channel count
bitstream.readBits(3); // bit depth
const reservedZero = bitstream.readBits(1); // reserved zero
if (reservedZero !== 0) {
// This cannot be a valid FLAC frame, the syncword was just coincidental
return null;
}
const num = readCodedNumber(slice);
const blockSize = readBlockSize(slice, blockSizeOrUncommon);
const sampleRate = readSampleRate(slice, sampleRateOrUncommon);
if (sampleRate === null) {
// This cannot be a valid FLAC frame, the syncword was just coincidental
return null;
}
const size = slice.filePos - startOffset;
const crc = readU8(slice);
slice.skip(-size);
slice.skip(-1);
const crcCalculated = calculateCrc8(readBytes(slice, size));
if (crc !== crcCalculated) {
// Maybe this wasn't a FLAC frame at all, the syncword was just coincidentally
// in the bitstream
return null;
}
return { num, blockSize, sampleRate };
}
async advanceReader() {
await this.readMetadata();
assert(this.lastLoadedPos !== null);
assert(this.audioInfo);
const startPos = this.lastLoadedPos;
const frame = await this.readNextFlacFrame({
startPos,
isFirstPacket: this.loadedSamples.length === 0,
});
if (!frame) {
// Unexpected case, failed to read next FLAC frame
// handling gracefully
this.lastSampleLoaded = true;
return;
}
const lastSample = this.loadedSamples[this.loadedSamples.length - 1];
const blockOffset = lastSample
? lastSample.blockOffset + lastSample.blockSize
: 0;
const sample: Sample = {
blockOffset,
blockSize: frame.blockSize,
byteOffset: startPos,
byteSize: frame.size,
};
this.lastLoadedPos = this.lastLoadedPos + frame.size;
this.loadedSamples.push(sample);
if (frame.isLastFrame) {
this.lastSampleLoaded = true;
return;
}
}
}
class FlacAudioTrackBacking implements InputAudioTrackBacking {
constructor(public demuxer: FlacDemuxer) {}
getId() {
return 1;
}
getCodec() {
return 'flac' as const;
}
getInternalCodecId(): string | number | Uint8Array | null {
return null;
}
getNumberOfChannels() {
assert(this.demuxer.audioInfo);
return this.demuxer.audioInfo.numberOfChannels;
}
async computeDuration() {
const lastPacket = await this.getPacket(Infinity, { metadataOnly: true });
return (lastPacket?.timestamp ?? 0) + (lastPacket?.duration ?? 0);
}
getSampleRate() {
assert(this.demuxer.audioInfo);
return this.demuxer.audioInfo.sampleRate;
}
getName(): string | null {
return null;
}
getLanguageCode() {
return UNDETERMINED_LANGUAGE;
}
getTimeResolution() {
assert(this.demuxer.audioInfo);
return this.demuxer.audioInfo.sampleRate;
}
async getFirstTimestamp() {
return 0;
}
async getDecoderConfig(): Promise<AudioDecoderConfig | null> {
assert(this.demuxer.audioInfo);
return {
codec: 'flac' as const,
numberOfChannels: this.demuxer.audioInfo.numberOfChannels,
sampleRate: this.demuxer.audioInfo.sampleRate,
description: this.demuxer.audioInfo.description,
};
}
async getPacket(
timestamp: number,
options: PacketRetrievalOptions,
): Promise<EncodedPacket | null> {
assert(this.demuxer.audioInfo);
if (timestamp < 0) {
throw new Error('Timestamp cannot be negative');
}
const release = await this.demuxer.readingMutex.acquire();
try {
while (true) {
const packetIndex = binarySearchLessOrEqual(
this.demuxer.loadedSamples,
timestamp,
x => x.blockOffset / this.demuxer.audioInfo!.sampleRate,
);
if (packetIndex === -1) {
await this.demuxer.advanceReader();
continue;
}
const packet = this.demuxer.loadedSamples[packetIndex]!;
const sampleTimestamp
= packet.blockOffset / this.demuxer.audioInfo.sampleRate;
const sampleDuration
= packet.blockSize / this.demuxer.audioInfo.sampleRate;
if (sampleTimestamp + sampleDuration <= timestamp) {
if (this.demuxer.lastSampleLoaded) {
return this.getPacketAtIndex(
this.demuxer.loadedSamples.length - 1,
options,
);
}
await this.demuxer.advanceReader();
continue;
}
return this.getPacketAtIndex(packetIndex, options);
}
} finally {
release();
}
}
async getNextPacket(
packet: EncodedPacket,
options: PacketRetrievalOptions,
): Promise<EncodedPacket | null> {
const release = await this.demuxer.readingMutex.acquire();
try {
const nextIndex = packet.sequenceNumber + 1;
if (
this.demuxer.lastSampleLoaded
&& nextIndex >= this.demuxer.loadedSamples.length
) {
return null;
}
// Ensure the next sample exists
while (
nextIndex >= this.demuxer.loadedSamples.length
&& !this.demuxer.lastSampleLoaded
) {
await this.demuxer.advanceReader();
}
return this.getPacketAtIndex(nextIndex, options);
} finally {
release();
}
}
getKeyPacket(
timestamp: number,
options: PacketRetrievalOptions,
): Promise<EncodedPacket | null> {
return this.getPacket(timestamp, options);
}
getNextKeyPacket(
packet: EncodedPacket,
options: PacketRetrievalOptions,
): Promise<EncodedPacket | null> {
return this.getNextPacket(packet, options);
}
async getPacketAtIndex(
sampleIndex: number,
options: PacketRetrievalOptions,
): Promise<EncodedPacket | null> {
const rawSample = this.demuxer.loadedSamples[sampleIndex];
if (!rawSample) {
return null;
}
let data: Uint8Array;
if (options.metadataOnly) {
data = PLACEHOLDER_DATA;
} else {
const slice = await this.demuxer.reader.requestSlice(
rawSample.byteOffset,
rawSample.byteSize,
);
if (!slice) {
return null; // Data didn't fit into the rest of the file
}
data = readBytes(slice, rawSample.byteSize);
}
assert(this.demuxer.audioInfo);
const timestamp = rawSample.blockOffset / this.demuxer.audioInfo.sampleRate;
const duration = rawSample.blockSize / this.demuxer.audioInfo.sampleRate;
return new EncodedPacket(
data,
'key',
timestamp,
duration,
sampleIndex,
rawSample.byteSize,
);
}
async getFirstPacket(
options: PacketRetrievalOptions,
): Promise<EncodedPacket | null> {
// Ensure the next sample exists
while (
this.demuxer.loadedSamples.length === 0
&& !this.demuxer.lastSampleLoaded
) {
await this.demuxer.advanceReader();
}
return this.getPacketAtIndex(0, options);
}
}
+164
View File
@@ -0,0 +1,164 @@
/*!
* Copyright (c) 2025-present, Vanilagy and contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { assert, assertNever, Bitstream } from '../misc';
import { FileSlice, readBytes, readU16Be, readU8 } from '../reader';
type BlockSizeOrUncommon = number | 'uncommon-u16' | 'uncommon-u8';
type SampleRateOrUncommon =
| number
| 'uncommon-u8'
| 'uncommon-u16'
| 'uncommon-u16-10';
// https://www.rfc-editor.org/rfc/rfc9639.html#name-block-size-bits
export const getBlockSizeOrUncommon = (bits: number): BlockSizeOrUncommon | null => {
if (bits === 0b0000) {
return null;
} else if (bits === 0b0001) {
return 192;
} else if (bits >= 0b0010 && bits <= 0b0101) {
return 144 * 2 ** bits;
} else if (bits === 0b0110) {
return 'uncommon-u8';
} else if (bits === 0b0111) {
return 'uncommon-u16';
} else if (bits >= 0b1000 && bits <= 0b1111) {
return 2 ** bits;
} else {
return null;
}
};
// https://www.rfc-editor.org/rfc/rfc9639.html#name-sample-rate-bits
export const getSampleRateOrUncommon = (
sampleRateBits: number,
streamInfoSampleRate: number,
): SampleRateOrUncommon | null => {
switch (sampleRateBits) {
case 0b0000: return streamInfoSampleRate;
case 0b0001: return 88200;
case 0b0010: return 176400;
case 0b0011: return 192000;
case 0b0100: return 8000;
case 0b0101: return 16000;
case 0b0110: return 22050;
case 0b0111: return 24000;
case 0b1000: return 32000;
case 0b1001: return 44100;
case 0b1010: return 48000;
case 0b1011: return 96000;
case 0b1100: return 'uncommon-u8';
case 0b1101: return 'uncommon-u16';
case 0b1110: return 'uncommon-u16-10';
default: return null;
}
};
// https://www.rfc-editor.org/rfc/rfc9639.html#name-coded-number
export const readCodedNumber = (fileSlice: FileSlice): number => {
let ones = 0;
const bitstream1 = new Bitstream(readBytes(fileSlice, 1));
while (bitstream1.readBits(1) === 1) {
ones++;
}
if (ones === 0) {
return bitstream1.readBits(7);
}
const bitArray: number[] = [];
const extraBytes = ones - 1;
const bitstream2 = new Bitstream(readBytes(fileSlice, extraBytes));
const firstByteBits = 8 - ones - 1;
for (let i = 0; i < firstByteBits; i++) {
bitArray.unshift(bitstream1.readBits(1));
}
for (let i = 0; i < extraBytes; i++) {
for (let j = 0; j < 8; j++) {
const val = bitstream2.readBits(1);
if (j < 2) {
continue;
}
bitArray.unshift(val);
}
}
const encoded = bitArray.reduce((acc, bit, index) => {
return acc | (bit << index);
}, 0);
return encoded;
};
export const readBlockSize = (
slice: FileSlice,
blockSizeBits: BlockSizeOrUncommon,
) => {
if (blockSizeBits === 'uncommon-u16') {
return readU16Be(slice) + 1;
} else if (blockSizeBits === 'uncommon-u8') {
return readU8(slice) + 1;
} else if (typeof blockSizeBits === 'number') {
return blockSizeBits;
} else {
assertNever(blockSizeBits);
assert(false);
}
};
export const readSampleRate = (
slice: FileSlice,
sampleRateOrUncommon: SampleRateOrUncommon,
) => {
if (sampleRateOrUncommon === 'uncommon-u16') {
return readU16Be(slice);
}
if (sampleRateOrUncommon === 'uncommon-u16-10') {
return readU16Be(slice) * 10;
}
if (sampleRateOrUncommon === 'uncommon-u8') {
return readU8(slice);
}
if (typeof sampleRateOrUncommon === 'number') {
return sampleRateOrUncommon;
}
return null;
};
// https://www.rfc-editor.org/rfc/rfc9639.html#section-9.1.1
export const calculateCrc8 = (data: Uint8Array) => {
const polynomial = 0x07; // x^8 + x^2 + x^1 + x^0
let crc = 0x00; // Initialize CRC to 0
for (const byte of data) {
crc ^= byte; // XOR byte into least significant byte of crc
for (let i = 0; i < 8; i++) {
// For each bit in the byte
if ((crc & 0x80) !== 0) {
// If the leftmost bit (MSB) is set
crc = (crc << 1) ^ polynomial; // Shift left and XOR with polynomial
} else {
crc <<= 1; // Just shift left
}
crc &= 0xff; // Ensure CRC remains 8-bit
}
}
return crc;
};
+320
View File
@@ -0,0 +1,320 @@
/*!
* Copyright (c) 2025-present, Vanilagy and contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { validateAudioChunkMetadata } from '../codec';
import { createVorbisComments, FlacBlockType } from '../codec-data';
import {
assert,
Bitstream,
textEncoder,
toDataView,
toUint8Array,
} from '../misc';
import { Muxer } from '../muxer';
import { Output, OutputAudioTrack } from '../output';
import { FlacOutputFormat } from '../output-format';
import { EncodedPacket } from '../packet';
import { FileSlice, readBytes } from '../reader';
import { AttachedImage, metadataTagsAreEmpty } from '../tags';
import { Writer } from '../writer';
import {
readBlockSize,
getBlockSizeOrUncommon,
readCodedNumber,
} from './flac-misc';
const FLAC_HEADER = new Uint8Array([0x66, 0x4c, 0x61, 0x43]); // 'fLaC'
const STREAMINFO_SIZE = 38;
const STREAMINFO_BLOCK_SIZE = 34;
export class FlacMuxer extends Muxer {
private writer: Writer;
private metadataWritten = false;
private blockSizes: number[] = [];
private frameSizes: number[] = [];
private sampleRate: number | null = null;
private channels: number | null = null;
private bitsPerSample: number | null = null;
private format: FlacOutputFormat;
constructor(output: Output, format: FlacOutputFormat) {
super(output);
this.writer = output._writer;
this.format = format;
}
async start() {
this.writer.write(FLAC_HEADER);
}
writeHeader({
bitsPerSample,
minimumBlockSize,
maximumBlockSize,
minimumFrameSize,
maximumFrameSize,
sampleRate,
channels,
totalSamples,
}: {
minimumBlockSize: number;
maximumBlockSize: number;
minimumFrameSize: number;
maximumFrameSize: number;
sampleRate: number;
channels: number;
bitsPerSample: number;
totalSamples: number;
}) {
assert(this.writer.getPos() === 4);
const hasMetadata = !metadataTagsAreEmpty(this.output._metadataTags);
const headerBitstream = new Bitstream(new Uint8Array(4));
headerBitstream.writeBits(1, Number(!hasMetadata)); // isLastMetadata
headerBitstream.writeBits(7, FlacBlockType.STREAMINFO); // metaBlockType = streaminfo
headerBitstream.writeBits(24, STREAMINFO_BLOCK_SIZE); // size
this.writer.write(headerBitstream.bytes);
const contentBitstream = new Bitstream(new Uint8Array(18));
contentBitstream.writeBits(16, minimumBlockSize);
contentBitstream.writeBits(16, maximumBlockSize);
contentBitstream.writeBits(24, minimumFrameSize);
contentBitstream.writeBits(24, maximumFrameSize);
contentBitstream.writeBits(20, sampleRate);
contentBitstream.writeBits(3, channels - 1);
contentBitstream.writeBits(5, bitsPerSample - 1);
// Bitstream operations are only safe until 32bit, breaks when using 36 bits
// Splitting up into writing 4 0 bits and then 32 bits is safe
// This is safe for audio up to (2 ** 32 / 44100 / 3600) -> 27 hours
// Not implementing support for more than 32 bits now
if (totalSamples >= 2 ** 32) {
throw new Error('This muxer only supports writing up to 2 ** 32 samples');
}
contentBitstream.writeBits(4, 0);
contentBitstream.writeBits(32, totalSamples);
this.writer.write(contentBitstream.bytes);
// The MD5 hash is calculated from decoded audio data, but we do not have access
// to it here. We are allowed to set 0:
// "A value of 0 signifies that the value is not known."
// https://www.rfc-editor.org/rfc/rfc9639.html#name-streaminfo
this.writer.write(new Uint8Array(16));
}
writePictureBlock(picture: AttachedImage) {
// Header size:
// 4 bytes: picture type
// 4 bytes: media type length
// x bytes: media type
// 4 bytes: description length
// y bytes: description
// 1 bytes: width
// 1 bytes: height
// 1 bytes: color depth
// 1 bytes: number of indexed colors
// 4 bytes: picture data length
// z bytes: picture data
// Total: 20 + x + y + z
const headerSize
= 32
+ picture.mimeType.length
+ (picture.description?.length ?? 0)
+ picture.data.length;
const header = new Uint8Array(headerSize);
let offset = 0;
const dataView = toDataView(header);
dataView.setUint32(
offset,
picture.kind === 'coverFront' ? 3 : picture.kind === 'coverBack' ? 4 : 0,
);
offset += 4;
dataView.setUint32(offset, picture.mimeType.length);
offset += 4;
header.set(textEncoder.encode(picture.mimeType), 8);
offset += picture.mimeType.length;
dataView.setUint32(offset, picture.description?.length ?? 0);
offset += 4;
header.set(textEncoder.encode(picture.description ?? ''), offset);
offset += picture.description?.length ?? 0;
offset += 4 + 4 + 4 + 4; // setting width, height, color depth, number of indexed colors to 0
dataView.setUint32(offset, picture.data.length);
offset += 4;
header.set(picture.data, offset);
offset += picture.data.length;
assert(offset === headerSize);
const headerBitstream = new Bitstream(new Uint8Array(4));
headerBitstream.writeBits(1, 0); // Last metadata block -> false, will be continued by vorbis comment
headerBitstream.writeBits(7, FlacBlockType.PICTURE); // Type -> Picture
headerBitstream.writeBits(24, headerSize);
this.writer.write(headerBitstream.bytes);
this.writer.write(header);
}
writeVorbisCommentAndPictureBlock() {
this.writer.seek(STREAMINFO_SIZE + FLAC_HEADER.byteLength);
if (metadataTagsAreEmpty(this.output._metadataTags)) {
this.metadataWritten = true;
return;
}
const pictures = this.output._metadataTags.images ?? [];
for (const picture of pictures) {
this.writePictureBlock(picture);
}
const vorbisComment = createVorbisComments(
new Uint8Array(0),
this.output._metadataTags,
false,
);
const headerBitstream = new Bitstream(new Uint8Array(4));
headerBitstream.writeBits(1, 1); // Last metadata block -> true
headerBitstream.writeBits(7, FlacBlockType.VORBIS_COMMENT); // Type -> Vorbis comment
headerBitstream.writeBits(24, vorbisComment.length);
this.writer.write(headerBitstream.bytes);
this.writer.write(vorbisComment);
this.metadataWritten = true;
}
async getMimeType() {
return 'audio/flac';
}
async addEncodedVideoPacket() {
throw new Error('FLAC does not support video.');
}
async addEncodedAudioPacket(
track: OutputAudioTrack,
packet: EncodedPacket,
meta?: EncodedAudioChunkMetadata,
): Promise<void> {
const release = await this.mutex.acquire();
validateAudioChunkMetadata(meta);
assert(meta);
assert(meta.decoderConfig);
assert(meta.decoderConfig.description);
try {
this.validateAndNormalizeTimestamp(
track,
packet.timestamp,
packet.type === 'key',
);
if (this.sampleRate === null) {
this.sampleRate = meta.decoderConfig.sampleRate;
}
if (this.channels === null) {
this.channels = meta.decoderConfig.numberOfChannels;
}
if (this.bitsPerSample === null) {
const descriptionBitstream = new Bitstream(
toUint8Array(meta.decoderConfig.description),
);
// skip 'fLaC' + block size + frame size + sample rate + number of channels
// See demuxer for the exact structure
descriptionBitstream.skipBits(103 + 64);
const bitsPerSample = descriptionBitstream.readBits(5) + 1;
this.bitsPerSample = bitsPerSample;
}
if (!this.metadataWritten) {
this.writeVorbisCommentAndPictureBlock();
}
const slice = FileSlice.tempFromBytes(packet.data);
readBytes(slice, 2);
const bytes = readBytes(slice, 2);
const bitstream = new Bitstream(bytes);
const blockSizeOrUncommon = getBlockSizeOrUncommon(bitstream.readBits(4));
if (blockSizeOrUncommon === null) {
throw new Error('Invalid FLAC frame: Invalid block size.');
}
readCodedNumber(slice); // num
const blockSize = readBlockSize(slice, blockSizeOrUncommon);
this.blockSizes.push(blockSize);
this.frameSizes.push(packet.data.length);
const startPos = this.writer.getPos();
this.writer.write(packet.data);
if (this.format._options.onFrame) {
this.format._options.onFrame(packet.data, startPos);
}
await this.writer.flush();
} finally {
release();
}
}
override addSubtitleCue(): Promise<void> {
throw new Error('FLAC does not support subtitles.');
}
async finalize(): Promise<void> {
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]!;
// 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]!);
}
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();
}
}