mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-09-27 10:53: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,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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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