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