diff --git a/README.md b/README.md index 9dfb362..3d4e17a 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ Mediabunny is a JavaScript library for reading, writing, and converting media fi Core features include: -- **Wide format support**: Read and write MP4, MOV, WebM, MKV, WAVE, MP3, Ogg, ADTS +- **Wide format support**: Read and write MP4, MOV, WebM, MKV, WAVE, MP3, Ogg, ADTS, FLAC - **Built-in encoding & decoding**: Supports 25+ video, audio, and subtitle codecs, hardware-accelerated using the WebCodecs API - **High precision**: Fine-grained, microsecond-accurate reading and writing operations - **Conversion API**: Easy-to-use API with features such as transmuxing, transcoding, resizing, rotation, cropping, resampling, trimming, and more diff --git a/dev/convert.html b/dev/convert.html index 4d6702e..89cea90 100644 --- a/dev/convert.html +++ b/dev/convert.html @@ -24,7 +24,7 @@ chunked: true, chunkSize: 2**20 }); - const outputFormat = new Mediabunny.Mp4OutputFormat({}); + const outputFormat = new Mediabunny.FlacOutputFormat({}); const button = document.createElement('button'); button.textContent = 'Cancel'; diff --git a/docs/guide/input-formats.md b/docs/guide/input-formats.md index 352283a..828ce20 100644 --- a/docs/guide/input-formats.md +++ b/docs/guide/input-formats.md @@ -30,6 +30,8 @@ import { MP3, // MP3 input format singleton WAVE, // WAVE input format singleton OGG, // Ogg input format singleton + ADTS, // ADTS input format singleton + FLAC, // FLAC input format singleton } from 'mediabunny'; ``` @@ -76,6 +78,8 @@ In addition to singletons, input format classes are structured hierarchically: - `Mp3InputFormat` - `WaveInputFormat` - `OggInputFormat` + - `AdtsInputFormat` + - `FlacInputFormat` This means you can also perform input format checks using `instanceof` instead of `===` comparisons. For example: ```ts diff --git a/docs/guide/introduction.md b/docs/guide/introduction.md index db03b55..f687db8 100644 --- a/docs/guide/introduction.md +++ b/docs/guide/introduction.md @@ -12,7 +12,7 @@ Here's a long list of stuff this library does: - Converting media files - Hardware-accelerated decoding & encoding (via the WebCodecs API) - Support for multiple video, audio and subtitle tracks -- Read & write support for many container formats (.mp4, .mov, .webm, .mkv, .mp3, .wav, .ogg, .aac), including variations such as MP4 with Fast Start, fragmented MP4, or streamable Matroska +- Read & write support for many container formats (.mp4, .mov, .webm, .mkv, .mp3, .wav, .ogg, .aac, .flac), including variations such as MP4 with Fast Start, fragmented MP4, or streamable Matroska - Support for 25 different codecs - Lazy, optimized, on-demand file reading - Input and output streaming, arbitrary file size support diff --git a/docs/guide/output-formats.md b/docs/guide/output-formats.md index 2f2e325..79e7ec7 100644 --- a/docs/guide/output-formats.md +++ b/docs/guide/output-formats.md @@ -267,4 +267,25 @@ type AdtsOutputFormatOptions = { }; ``` - `onFrame`\ - Will be called for each ADTS frame that is written. \ No newline at end of file + Will be called for each ADTS frame that is written. + +## FLAC + +This output format creates FLAC (.flac) files. +```ts +import { Output, FlacOutputFormat } from 'mediabunny'; + +const output = new Output({ + format: new FlacOutputFormat(options), + // ... +}); +``` + +The following options are available: +```ts +type FlacOutputFormatOptions = { + onFrame?: (data: Uint8Array, position: number) => unknown; +}; +``` +- `onFrame`\ + Will be called for each FLAC frame that is written. \ No newline at end of file diff --git a/docs/guide/supported-formats-and-codecs.md b/docs/guide/supported-formats-and-codecs.md index 5b9e08c..50648e8 100644 --- a/docs/guide/supported-formats-and-codecs.md +++ b/docs/guide/supported-formats-and-codecs.md @@ -12,6 +12,7 @@ Mediabunny supports many commonly used media container formats, all of which are - MP3 (.mp3) - WAVE (.wav) - ADTS (.aac) +- FLAC (.flac) ## Codecs @@ -61,33 +62,33 @@ Mediabunny ships with built-in decoders and encoders for all audio PCM codecs, m Not all codecs can be used with all containers. The following table specifies the supported codec-container combinations: -| | .mp4 | .mov | .mkv | .webm[^1] | .ogg | .mp3 | .wav | .aac | -|:--------------:|:--------:|:-----:|:-----:|:---------:|:-----:|:-----:|:-----:|:-----:| -| `'avc'` | ✓ | ✓ | ✓ | | | | | | -| `'hevc'` | ✓ | ✓ | ✓ | | | | | | -| `'vp8'` | ✓ | ✓ | ✓ | ✓ | | | | | -| `'vp9'` | ✓ | ✓ | ✓ | ✓ | | | | | -| `'av1'` | ✓ | ✓ | ✓ | ✓ | | | | | -| `'aac'` | ✓ | ✓ | ✓ | | | | | ✓ | -| `'opus'` | ✓ | ✓ | ✓ | ✓ | ✓ | | | | -| `'mp3'` | ✓ | ✓ | ✓ | | | ✓ | | | -| `'vorbis'` | ✓ | ✓ | ✓ | ✓ | ✓ | | | | -| `'flac'` | ✓ | ✓ | ✓ | | | | | | -| `'pcm-u8'` | | ✓ | ✓ | | | | ✓ | | -| `'pcm-s8'` | | ✓ | | | | | | | -| `'pcm-s16'` | ✓ | ✓ | ✓ | | | | ✓ | | -| `'pcm-s16be'` | ✓ | ✓ | ✓ | | | | | | -| `'pcm-s24'` | ✓ | ✓ | ✓ | | | | ✓ | | -| `'pcm-s24be'` | ✓ | ✓ | ✓ | | | | | | -| `'pcm-s32'` | ✓ | ✓ | ✓ | | | | ✓ | | -| `'pcm-s32be'` | ✓ | ✓ | ✓ | | | | | | -| `'pcm-f32'` | ✓ | ✓ | ✓ | | | | ✓ | | -| `'pcm-f32be'` | ✓ | ✓ | | | | | | | -| `'pcm-f64'` | ✓ | ✓ | ✓ | | | | | | -| `'pcm-f64be'` | ✓ | ✓ | | | | | | | -| `'ulaw'` | | ✓ | | | | | ✓ | | -| `'alaw'` | | ✓ | | | | | ✓ | | -| `'webvtt'`[^2] | (✓) | | (✓) | (✓) | | | | | +| | .mp4 | .mov | .mkv | .webm[^1] | .ogg | .mp3 | .wav | .aac | .flac | +|:--------------:|:--------:|:-----:|:-----:|:---------:|:-----:|:-----:|:-----:|:-----:|:-----:| +| `'avc'` | ✓ | ✓ | ✓ | | | | | | | +| `'hevc'` | ✓ | ✓ | ✓ | | | | | | | +| `'vp8'` | ✓ | ✓ | ✓ | ✓ | | | | | | +| `'vp9'` | ✓ | ✓ | ✓ | ✓ | | | | | | +| `'av1'` | ✓ | ✓ | ✓ | ✓ | | | | | | +| `'aac'` | ✓ | ✓ | ✓ | | | | | ✓ | | +| `'opus'` | ✓ | ✓ | ✓ | ✓ | ✓ | | | | | +| `'mp3'` | ✓ | ✓ | ✓ | | | ✓ | | | | +| `'vorbis'` | ✓ | ✓ | ✓ | ✓ | ✓ | | | | | +| `'flac'` | ✓ | ✓ | ✓ | | | | | | ✓ | +| `'pcm-u8'` | | ✓ | ✓ | | | | ✓ | | | +| `'pcm-s8'` | | ✓ | | | | | | | | +| `'pcm-s16'` | ✓ | ✓ | ✓ | | | | ✓ | | | +| `'pcm-s16be'` | ✓ | ✓ | ✓ | | | | | | | +| `'pcm-s24'` | ✓ | ✓ | ✓ | | | | ✓ | | | +| `'pcm-s24be'` | ✓ | ✓ | ✓ | | | | | | | +| `'pcm-s32'` | ✓ | ✓ | ✓ | | | | ✓ | | | +| `'pcm-s32be'` | ✓ | ✓ | ✓ | | | | | | | +| `'pcm-f32'` | ✓ | ✓ | ✓ | | | | ✓ | | | +| `'pcm-f32be'` | ✓ | ✓ | | | | | | | | +| `'pcm-f64'` | ✓ | ✓ | ✓ | | | | | | | +| `'pcm-f64be'` | ✓ | ✓ | | | | | | | | +| `'ulaw'` | | ✓ | | | | | ✓ | | | +| `'alaw'` | | ✓ | | | | | ✓ | | | +| `'webvtt'`[^2] | (✓) | | (✓) | (✓) | | | | | | [^1]: WebM only supports a small subset of the codecs supported by Matroska. However, this library can technically read all codecs from a WebM that are supported by Matroska. diff --git a/src/codec-data.ts b/src/codec-data.ts index 9ebf360..f9673e5 100644 --- a/src/codec-data.ts +++ b/src/codec-data.ts @@ -11,15 +11,21 @@ import { InputVideoTrack } from './input-track'; import { assert, assertNever, + base64ToBytes, Bitstream, + bytesToBase64, + keyValueIterator, getUint24, last, readExpGolomb, readSignedExpGolomb, + textDecoder, + textEncoder, toDataView, toUint8Array, } from './misc'; import { EncodedPacket, PacketType } from './packet'; +import { MetadataTags } from './tags'; // References for AVC/HEVC code: // ISO 14496-15 @@ -1572,3 +1578,342 @@ export const determineVideoPacketType = async ( }; } }; + +export enum FlacBlockType { + STREAMINFO = 0, + VORBIS_COMMENT = 4, + PICTURE = 6, +} + +export const readVorbisComments = (bytes: Uint8Array, metadataTags: MetadataTags) => { + // https://datatracker.ietf.org/doc/html/rfc7845#section-5.2 + + const commentView = toDataView(bytes); + let commentPos = 0; + + const vendorStringLength = commentView.getUint32(commentPos, true); + commentPos += 4; + + const vendorString = textDecoder.decode( + bytes.subarray(commentPos, commentPos + vendorStringLength), + ); + commentPos += vendorStringLength; + + if (vendorStringLength > 0) { + // Expose the vendor string in the raw metadata + metadataTags.raw ??= {}; + metadataTags.raw['vendor'] ??= vendorString; + } + + const listLength = commentView.getUint32(commentPos, true); + commentPos += 4; + + // Loop over all metadata tags + for (let i = 0; i < listLength; i++) { + const stringLength = commentView.getUint32(commentPos, true); + commentPos += 4; + + const string = textDecoder.decode( + bytes.subarray(commentPos, commentPos + stringLength), + ); + commentPos += stringLength; + + const separatorIndex = string.indexOf('='); + if (separatorIndex === -1) { + continue; + } + + const key = string.slice(0, separatorIndex).toUpperCase(); + const value = string.slice(separatorIndex + 1); + + metadataTags.raw ??= {}; + metadataTags.raw[key] ??= value; + + switch (key) { + case 'TITLE': { + metadataTags.title ??= value; + }; break; + + case 'DESCRIPTION': { + metadataTags.description ??= value; + }; break; + + case 'ARTIST': { + metadataTags.artist ??= value; + }; break; + + case 'ALBUM': { + metadataTags.album ??= value; + }; break; + + case 'ALBUMARTIST': { + metadataTags.albumArtist ??= value; + }; break; + + case 'COMMENT': { + metadataTags.comment ??= value; + }; break; + + case 'LYRICS': { + metadataTags.lyrics ??= value; + }; break; + + case 'TRACKNUMBER': { + const parts = value.split('/'); + const trackNum = Number.parseInt(parts[0]!, 10); + const tracksTotal = parts[1] && Number.parseInt(parts[1], 10); + + if (Number.isInteger(trackNum) && trackNum > 0) { + metadataTags.trackNumber ??= trackNum; + } + if (tracksTotal && Number.isInteger(tracksTotal) && tracksTotal > 0) { + metadataTags.tracksTotal ??= tracksTotal; + } + }; break; + + case 'TRACKTOTAL': { + const tracksTotal = Number.parseInt(value, 10); + if (Number.isInteger(tracksTotal) && tracksTotal > 0) { + metadataTags.tracksTotal ??= tracksTotal; + } + }; break; + + case 'DISCNUMBER': { + const parts = value.split('/'); + const discNum = Number.parseInt(parts[0]!, 10); + const discsTotal = parts[1] && Number.parseInt(parts[1], 10); + + if (Number.isInteger(discNum) && discNum > 0) { + metadataTags.discNumber ??= discNum; + } + if (discsTotal && Number.isInteger(discsTotal) && discsTotal > 0) { + metadataTags.discsTotal ??= discsTotal; + } + }; break; + + case 'DISCTOTAL': { + const discsTotal = Number.parseInt(value, 10); + if (Number.isInteger(discsTotal) && discsTotal > 0) { + metadataTags.discsTotal ??= discsTotal; + } + }; break; + + case 'DATE': { + const date = new Date(value); + if (!Number.isNaN(date.getTime())) { + metadataTags.date ??= date; + } + }; break; + + case 'GENRE': { + metadataTags.genre ??= value; + }; break; + + case 'METADATA_BLOCK_PICTURE': { + // https://datatracker.ietf.org/doc/rfc9639/ Section 8.8 + const decoded = base64ToBytes(value); + + const view = toDataView(decoded); + const pictureType = view.getUint32(0, false); + const mediaTypeLength = view.getUint32(4, false); + const mediaType = String.fromCharCode(...decoded.subarray(8, 8 + mediaTypeLength)); // ASCII + const descriptionLength = view.getUint32(8 + mediaTypeLength, false); + const description = textDecoder.decode(decoded.subarray( + 12 + mediaTypeLength, + 12 + mediaTypeLength + descriptionLength, + )); + const dataLength = view.getUint32(mediaTypeLength + descriptionLength + 28); + const data = decoded.subarray( + mediaTypeLength + descriptionLength + 32, + mediaTypeLength + descriptionLength + 32 + dataLength, + ); + + metadataTags.images ??= []; + metadataTags.images.push({ + data, + mimeType: mediaType, + kind: pictureType === 3 ? 'coverFront' : pictureType === 4 ? 'coverBack' : 'unknown', + name: undefined, + description: description || undefined, + }); + }; break; + } + } +}; + +export const createVorbisComments = (headerBytes: Uint8Array, tags: MetadataTags, writeImages: boolean) => { + // https://datatracker.ietf.org/doc/html/rfc7845#section-5.2 + + const commentHeaderParts: Uint8Array[] = [ + headerBytes, + ]; + + const vendorString = 'Mediabunny'; + const encodedVendorString = textEncoder.encode(vendorString); + + let currentBuffer = new Uint8Array(4 + encodedVendorString.length); + let currentView = new DataView(currentBuffer.buffer); + currentView.setUint32(0, encodedVendorString.length, true); + currentBuffer.set(encodedVendorString, 4); + + commentHeaderParts.push(currentBuffer); + + const writtenTags = new Set(); + const addCommentTag = (key: string, value: string) => { + const joined = `${key}=${value}`; + const encoded = textEncoder.encode(joined); + + currentBuffer = new Uint8Array(4 + encoded.length); + currentView = new DataView(currentBuffer.buffer); + + currentView.setUint32(0, encoded.length, true); + currentBuffer.set(encoded, 4); + + commentHeaderParts.push(currentBuffer); + writtenTags.add(key); + }; + + for (const { key, value } of keyValueIterator(tags)) { + switch (key) { + case 'title': { + addCommentTag('TITLE', value); + }; break; + + case 'description': { + addCommentTag('DESCRIPTION', value); + }; break; + + case 'artist': { + addCommentTag('ARTIST', value); + }; break; + + case 'album': { + addCommentTag('ALBUM', value); + }; break; + + case 'albumArtist': { + addCommentTag('ALBUMARTIST', value); + }; break; + + case 'genre': { + addCommentTag('GENRE', value); + }; break; + + case 'date': { + const rawVersion = tags.raw?.['DATE'] ?? tags.raw?.['date']; + if (rawVersion && typeof rawVersion === 'string') { + addCommentTag('DATE', rawVersion); + } else { + addCommentTag('DATE', value.toISOString().slice(0, 10)); + } + }; break; + + case 'comment': { + addCommentTag('COMMENT', value); + }; break; + + case 'lyrics': { + addCommentTag('LYRICS', value); + }; break; + + case 'trackNumber': { + addCommentTag('TRACKNUMBER', value.toString()); + }; break; + + case 'tracksTotal': { + addCommentTag('TRACKTOTAL', value.toString()); + }; break; + + case 'discNumber': { + addCommentTag('DISCNUMBER', value.toString()); + }; break; + + case 'discsTotal': { + addCommentTag('DISCTOTAL', value.toString()); + }; break; + + case 'images': { + // For example, in .flac, we put the pictures in a different section, + // not in the Vorbis comment header. + if (!writeImages) { + break; + } + for (const image of value) { + // https://datatracker.ietf.org/doc/rfc9639/ Section 8.8 + const pictureType = image.kind === 'coverFront' ? 3 : image.kind === 'coverBack' ? 4 : 0; + const encodedMediaType = new Uint8Array(image.mimeType.length); + + for (let i = 0; i < image.mimeType.length; i++) { + encodedMediaType[i] = image.mimeType.charCodeAt(i); + } + + const encodedDescription = textEncoder.encode(image.description ?? ''); + + const buffer = new Uint8Array( + 4 // Picture type + + 4 // MIME type length + + encodedMediaType.length // MIME type + + 4 // Description length + + encodedDescription.length // Description + + 16 // Width, height, color depth, number of colors + + 4 // Picture data length + + image.data.length, // Picture data + ); + const view = toDataView(buffer); + + view.setUint32(0, pictureType, false); + view.setUint32(4, encodedMediaType.length, false); + buffer.set(encodedMediaType, 8); + view.setUint32(8 + encodedMediaType.length, encodedDescription.length, false); + buffer.set(encodedDescription, 12 + encodedMediaType.length); + // Skip a bunch of fields (width, height, color depth, number of colors) + view.setUint32( + 28 + encodedMediaType.length + encodedDescription.length, image.data.length, false, + ); + buffer.set( + image.data, + 32 + encodedMediaType.length + encodedDescription.length, + ); + + const encoded = bytesToBase64(buffer); + addCommentTag('METADATA_BLOCK_PICTURE', encoded); + } + }; break; + + case 'raw': { + // Handled later + }; break; + + default: assertNever(key); + } + } + + if (tags.raw) { + for (const key in tags.raw) { + const value = tags.raw[key] ?? tags.raw[key.toLowerCase()]; + if (key === 'vendor' || value == null || writtenTags.has(key)) { + continue; + } + + if (typeof value === 'string') { + addCommentTag(key, value); + } + } + } + + const listLengthBuffer = new Uint8Array(4); + toDataView(listLengthBuffer).setUint32(0, writtenTags.size, true); + commentHeaderParts.splice(2, 0, listLengthBuffer); // Insert after the header and vendor section + + // Merge all comment header parts into a single buffer + const commentHeaderLength = commentHeaderParts.reduce((a, b) => a + b.length, 0); + const commentHeader = new Uint8Array(commentHeaderLength); + + let pos = 0; + for (const part of commentHeaderParts) { + commentHeader.set(part, pos); + pos += part.length; + } + + return commentHeader; +}; diff --git a/src/flac/flac-demuxer.ts b/src/flac/flac-demuxer.ts new file mode 100644 index 0000000..e5b8fcd --- /dev/null +++ b/src/flac/flac-demuxer.ts @@ -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 | 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 { + await this.readMetadata(); + assert(this.track); + return this.track.computeDuration(); + } + + override async getMetadataTags(): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + return this.getPacket(timestamp, options); + } + + getNextKeyPacket( + packet: EncodedPacket, + options: PacketRetrievalOptions, + ): Promise { + return this.getNextPacket(packet, options); + } + + async getPacketAtIndex( + sampleIndex: number, + options: PacketRetrievalOptions, + ): Promise { + 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 { + // Ensure the next sample exists + while ( + this.demuxer.loadedSamples.length === 0 + && !this.demuxer.lastSampleLoaded + ) { + await this.demuxer.advanceReader(); + } + + return this.getPacketAtIndex(0, options); + } +} diff --git a/src/flac/flac-misc.ts b/src/flac/flac-misc.ts new file mode 100644 index 0000000..f1b9e7a --- /dev/null +++ b/src/flac/flac-misc.ts @@ -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; +}; diff --git a/src/flac/flac-muxer.ts b/src/flac/flac-muxer.ts new file mode 100644 index 0000000..fdda1d0 --- /dev/null +++ b/src/flac/flac-muxer.ts @@ -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 { + 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 { + throw new Error('FLAC does not support subtitles.'); + } + + async finalize(): Promise { + 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(); + } +} diff --git a/src/index.ts b/src/index.ts index 4662fc6..2dc2c2f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,6 +21,8 @@ export { OutputFormat, AdtsOutputFormat, AdtsOutputFormatOptions, + FlacOutputFormat, + FlacOutputFormatOptions, IsobmffOutputFormat, IsobmffOutputFormatOptions, MkvOutputFormat, @@ -130,6 +132,7 @@ export { QuickTimeInputFormat, WaveInputFormat, WebMInputFormat, + FlacInputFormat, ALL_FORMATS, ADTS, MATROSKA, @@ -139,6 +142,7 @@ export { QTFF, WAVE, WEBM, + FLAC, } from './input-format'; export { Input, diff --git a/src/input-format.ts b/src/input-format.ts index 01aa838..6d9c258 100644 --- a/src/input-format.ts +++ b/src/input-format.ts @@ -28,6 +28,7 @@ import { WaveDemuxer } from './wave/wave-demuxer'; import { MAX_FRAME_HEADER_SIZE, MIN_FRAME_HEADER_SIZE, readFrameHeader } from './adts/adts-reader'; import { AdtsDemuxer } from './adts/adts-demuxer'; import { readAscii } from './reader'; +import { FlacDemuxer } from './flac/flac-demuxer'; /** * Base class representing an input media file format. @@ -394,6 +395,37 @@ export class OggInputFormat extends InputFormat { return 'application/ogg'; } } +/** + * FLAC file format. + * + * Do not instantiate this class; use the {@link FLAC} singleton instead. + * + * @group Input formats + * @public + */ +export class FlacInputFormat extends InputFormat { + /** @internal */ + async _canReadInput(input: Input) { + let slice = input._reader.requestSlice(0, 4); + if (slice instanceof Promise) slice = await slice; + if (!slice) return false; + + return readAscii(slice, 4) === 'fLaC'; + } + + get name() { + return 'FLAC'; + } + + get mimeType() { + return 'audio/flac'; + } + + /** @internal */ + _createDemuxer(input: Input): Demuxer { + return new FlacDemuxer(input); + } +} /** * ADTS file format. @@ -492,10 +524,17 @@ export const OGG = new OggInputFormat(); */ export const ADTS = new AdtsInputFormat(); +/** + * FLAC input format singleton. + * @group Input formats + * @public + */ +export const FLAC = new FlacInputFormat(); + /** * List of all input format singletons. If you don't need to support all input formats, you should specify the * formats individually for better tree shaking. * @group Input formats * @public */ -export const ALL_FORMATS: InputFormat[] = [MP4, QTFF, MATROSKA, WEBM, WAVE, OGG, MP3, ADTS]; +export const ALL_FORMATS: InputFormat[] = [MP4, QTFF, MATROSKA, WEBM, WAVE, OGG, FLAC, MP3, ADTS]; diff --git a/src/isobmff/isobmff-demuxer.ts b/src/isobmff/isobmff-demuxer.ts index 8222348..d526f7d 100644 --- a/src/isobmff/isobmff-demuxer.ts +++ b/src/isobmff/isobmff-demuxer.ts @@ -24,6 +24,7 @@ import { AvcDecoderConfigurationRecord, extractAv1CodecInfoFromPacket, extractVp9CodecInfoFromPacket, + FlacBlockType, HevcDecoderConfigurationRecord, Vp9CodecInfo, } from '../codec-data'; @@ -1451,7 +1452,7 @@ export class IsobmffDemuxer extends Demuxer { const type = flagAndType & BLOCK_TYPE_MASK; // It's a STREAMINFO block; let's extract the actual sample rate and channel count - if (type === 0) { + if (type === FlacBlockType.STREAMINFO) { slice.skip(10); // Extract sample rate and channel count diff --git a/src/ogg/ogg-demuxer.ts b/src/ogg/ogg-demuxer.ts index 2edadc0..1675f9f 100644 --- a/src/ogg/ogg-demuxer.ts +++ b/src/ogg/ogg-demuxer.ts @@ -7,7 +7,7 @@ */ import { OPUS_SAMPLE_RATE } from '../codec'; -import { parseModesFromVorbisSetupPacket, parseOpusIdentificationHeader } from '../codec-data'; +import { parseModesFromVorbisSetupPacket, parseOpusIdentificationHeader, readVorbisComments } from '../codec-data'; import { Demuxer } from '../demuxer'; import { Input } from '../input'; import { InputAudioTrack, InputAudioTrackBacking } from '../input-track'; @@ -16,12 +16,10 @@ import { MetadataTags } from '../tags'; import { assert, AsyncMutex, - base64ToBytes, binarySearchLessOrEqual, findLast, last, roundToPrecision, - textDecoder, toDataView, UNDETERMINED_LANGUAGE, } from '../misc'; @@ -223,7 +221,7 @@ export class OggDemuxer extends Demuxer { modeBlockflags: parseModesFromVorbisSetupPacket(thirdPacket.data).modeBlockflags, }; - this.readVorbisComments(secondPacket.data.subarray(7)); // Skip header type and 'vorbis' + readVorbisComments(secondPacket.data.subarray(7), this.metadataTags); // Skip header type and 'vorbis' } async readOpusMetadata(firstPacket: Packet, bitstream: LogicalBitstream) { @@ -255,163 +253,7 @@ export class OggDemuxer extends Demuxer { preSkip: header.preSkip, }; - this.readVorbisComments(secondPacket.data.subarray(8)); // Skip 'OpusTags' - } - - readVorbisComments(bytes: Uint8Array) { - // https://datatracker.ietf.org/doc/html/rfc7845#section-5.2 - - const commentView = toDataView(bytes); - let commentPos = 0; - - const vendorStringLength = commentView.getUint32(commentPos, true); - commentPos += 4; - - const vendorString = textDecoder.decode( - bytes.subarray(commentPos, commentPos + vendorStringLength), - ); - commentPos += vendorStringLength; - - if (vendorStringLength > 0) { - // Expose the vendor string in the raw metadata - this.metadataTags.raw ??= {}; - this.metadataTags.raw['vendor'] ??= vendorString; - } - - const listLength = commentView.getUint32(commentPos, true); - commentPos += 4; - - // Loop over all metadata tags - for (let i = 0; i < listLength; i++) { - const stringLength = commentView.getUint32(commentPos, true); - commentPos += 4; - - const string = textDecoder.decode( - bytes.subarray(commentPos, commentPos + stringLength), - ); - commentPos += stringLength; - - const separatorIndex = string.indexOf('='); - if (separatorIndex === -1) { - continue; - } - - const key = string.slice(0, separatorIndex).toUpperCase(); - const value = string.slice(separatorIndex + 1); - - this.metadataTags.raw ??= {}; - this.metadataTags.raw[key] ??= value; - - switch (key) { - case 'TITLE': { - this.metadataTags.title ??= value; - }; break; - - case 'DESCRIPTION': { - this.metadataTags.description ??= value; - }; break; - - case 'ARTIST': { - this.metadataTags.artist ??= value; - }; break; - - case 'ALBUM': { - this.metadataTags.album ??= value; - }; break; - - case 'ALBUMARTIST': { - this.metadataTags.albumArtist ??= value; - }; break; - - case 'COMMENT': { - this.metadataTags.comment ??= value; - }; break; - - case 'LYRICS': { - this.metadataTags.lyrics ??= value; - }; break; - - case 'TRACKNUMBER': { - const parts = value.split('/'); - const trackNum = Number.parseInt(parts[0]!, 10); - const tracksTotal = parts[1] && Number.parseInt(parts[1], 10); - - if (Number.isInteger(trackNum) && trackNum > 0) { - this.metadataTags.trackNumber ??= trackNum; - } - if (tracksTotal && Number.isInteger(tracksTotal) && tracksTotal > 0) { - this.metadataTags.tracksTotal ??= tracksTotal; - } - }; break; - - case 'TRACKTOTAL': { - const tracksTotal = Number.parseInt(value, 10); - if (Number.isInteger(tracksTotal) && tracksTotal > 0) { - this.metadataTags.tracksTotal ??= tracksTotal; - } - }; break; - - case 'DISCNUMBER': { - const parts = value.split('/'); - const discNum = Number.parseInt(parts[0]!, 10); - const discsTotal = parts[1] && Number.parseInt(parts[1], 10); - - if (Number.isInteger(discNum) && discNum > 0) { - this.metadataTags.discNumber ??= discNum; - } - if (discsTotal && Number.isInteger(discsTotal) && discsTotal > 0) { - this.metadataTags.discsTotal ??= discsTotal; - } - }; break; - - case 'DISCTOTAL': { - const discsTotal = Number.parseInt(value, 10); - if (Number.isInteger(discsTotal) && discsTotal > 0) { - this.metadataTags.discsTotal ??= discsTotal; - } - }; break; - - case 'DATE': { - const date = new Date(value); - if (!Number.isNaN(date.getTime())) { - this.metadataTags.date ??= date; - } - }; break; - - case 'GENRE': { - this.metadataTags.genre ??= value; - }; break; - - case 'METADATA_BLOCK_PICTURE': { - // https://datatracker.ietf.org/doc/rfc9639/ Section 8.8 - const decoded = base64ToBytes(value); - - const view = toDataView(decoded); - const pictureType = view.getUint32(0, false); - const mediaTypeLength = view.getUint32(4, false); - const mediaType = String.fromCharCode(...decoded.subarray(8, 8 + mediaTypeLength)); // ASCII - const descriptionLength = view.getUint32(8 + mediaTypeLength, false); - const description = textDecoder.decode(decoded.subarray( - 12 + mediaTypeLength, - 12 + mediaTypeLength + descriptionLength, - )); - const dataLength = view.getUint32(mediaTypeLength + descriptionLength + 28); - const data = decoded.subarray( - mediaTypeLength + descriptionLength + 32, - mediaTypeLength + descriptionLength + 32 + dataLength, - ); - - this.metadataTags.images ??= []; - this.metadataTags.images.push({ - data, - mimeType: mediaType, - kind: pictureType === 3 ? 'coverFront' : pictureType === 4 ? 'coverBack' : 'unknown', - name: undefined, - description: description || undefined, - }); - }; break; - } - } + readVorbisComments(secondPacket.data.subarray(8), this.metadataTags); // Skip 'OpusTags' } async readPacket(startPage: Page, startSegmentIndex: number): Promise { diff --git a/src/ogg/ogg-muxer.ts b/src/ogg/ogg-muxer.ts index 8b7de06..91ee535 100644 --- a/src/ogg/ogg-muxer.ts +++ b/src/ogg/ogg-muxer.ts @@ -7,15 +7,11 @@ */ import { OPUS_SAMPLE_RATE, validateAudioChunkMetadata } from '../codec'; -import { parseModesFromVorbisSetupPacket, parseOpusIdentificationHeader } from '../codec-data'; +import { createVorbisComments, parseModesFromVorbisSetupPacket, parseOpusIdentificationHeader } from '../codec-data'; import { assert, - assertNever, - bytesToBase64, - keyValueIterator, promiseWithResolvers, setInt64, - textEncoder, toDataView, toUint8Array, } from '../misc'; @@ -199,7 +195,7 @@ export class OggMuxer extends Muxer { commentHeaderHeader[5] = 0x69; // 'i' commentHeaderHeader[6] = 0x73; // 's' - const commentHeader = this.createVorbisComments(commentHeaderHeader); + const commentHeader = createVorbisComments(commentHeaderHeader, this.output._metadataTags, true); trackData.packetQueue.push({ data: identificationHeader, @@ -239,7 +235,7 @@ export class OggMuxer extends Muxer { const commentHeaderHeaderView = toDataView(commentHeaderHeader); commentHeaderHeaderView.setUint32(0, 0x4f707573, false); // 'Opus' commentHeaderHeaderView.setUint32(4, 0x54616773, false); // 'Tags' - const commentHeader = this.createVorbisComments(commentHeaderHeader); + const commentHeader = createVorbisComments(commentHeaderHeader, this.output._metadataTags, true); trackData.packetQueue.push({ data: identificationHeader, @@ -259,177 +255,6 @@ export class OggMuxer extends Muxer { } } - createVorbisComments(headerBytes: Uint8Array) { - // https://datatracker.ietf.org/doc/html/rfc7845#section-5.2 - - const tags = this.output._metadataTags; - const commentHeaderParts = [ - headerBytes, - ]; - - let vendorString = ''; - if (typeof tags.raw?.['vendor'] === 'string') { - vendorString = tags.raw?.['vendor']; - } - const encodedVendorString = textEncoder.encode(vendorString); - - let currentBuffer = new Uint8Array(4 + encodedVendorString.length); - let currentView = new DataView(currentBuffer.buffer); - currentView.setUint32(0, encodedVendorString.length, true); - currentBuffer.set(encodedVendorString, 4); - - commentHeaderParts.push(currentBuffer); - - const writtenTags = new Set(); - const addCommentTag = (key: string, value: string) => { - const joined = `${key}=${value}`; - const encoded = textEncoder.encode(joined); - - currentBuffer = new Uint8Array(4 + encoded.length); - currentView = new DataView(currentBuffer.buffer); - - currentView.setUint32(0, encoded.length, true); - currentBuffer.set(encoded, 4); - - commentHeaderParts.push(currentBuffer); - writtenTags.add(key); - }; - - for (const { key, value } of keyValueIterator(tags)) { - switch (key) { - case 'title': { - addCommentTag('TITLE', value); - }; break; - - case 'description': { - addCommentTag('DESCRIPTION', value); - }; break; - - case 'artist': { - addCommentTag('ARTIST', value); - }; break; - - case 'album': { - addCommentTag('ALBUM', value); - }; break; - - case 'albumArtist': { - addCommentTag('ALBUMARTIST', value); - }; break; - - case 'genre': { - addCommentTag('GENRE', value); - }; break; - - case 'date': { - addCommentTag('DATE', value.toISOString().slice(0, 10)); - }; break; - - case 'comment': { - addCommentTag('COMMENT', value); - }; break; - - case 'lyrics': { - addCommentTag('LYRICS', value); - }; break; - - case 'trackNumber': { - addCommentTag('TRACKNUMBER', value.toString()); - }; break; - - case 'tracksTotal': { - addCommentTag('TRACKTOTAL', value.toString()); - }; break; - - case 'discNumber': { - addCommentTag('DISCNUMBER', value.toString()); - }; break; - - case 'discsTotal': { - addCommentTag('DISCTOTAL', value.toString()); - }; break; - - case 'images': { - for (const image of value) { - // https://datatracker.ietf.org/doc/rfc9639/ Section 8.8 - const pictureType = image.kind === 'coverFront' ? 3 : image.kind === 'coverBack' ? 4 : 0; - const encodedMediaType = new Uint8Array(image.mimeType.length); - - for (let i = 0; i < image.mimeType.length; i++) { - encodedMediaType[i] = image.mimeType.charCodeAt(i); - } - - const encodedDescription = textEncoder.encode(image.description ?? ''); - - const buffer = new Uint8Array( - 4 // Picture type - + 4 // MIME type length - + encodedMediaType.length // MIME type - + 4 // Description length - + encodedDescription.length // Description - + 16 // Width, height, color depth, number of colors - + 4 // Picture data length - + image.data.length, // Picture data - ); - const view = toDataView(buffer); - - view.setUint32(0, pictureType, false); - view.setUint32(4, encodedMediaType.length, false); - buffer.set(encodedMediaType, 8); - view.setUint32(8 + encodedMediaType.length, encodedDescription.length, false); - buffer.set(encodedDescription, 12 + encodedMediaType.length); - // Skip a bunch of fields (width, height, color depth, number of colors) - view.setUint32( - 28 + encodedMediaType.length + encodedDescription.length, image.data.length, false, - ); - buffer.set( - image.data, - 32 + encodedMediaType.length + encodedDescription.length, - ); - - const encoded = bytesToBase64(buffer); - addCommentTag('METADATA_BLOCK_PICTURE', encoded); - } - }; break; - - case 'raw': { - // Handled later - }; break; - - default: assertNever(key); - } - } - - if (tags.raw) { - for (const key in tags.raw) { - const value = tags.raw[key]; - if (key === 'vendor' || value == null || writtenTags.has(key)) { - continue; - } - - if (typeof value === 'string') { - addCommentTag(key, value); - } - } - } - - const listLengthBuffer = new Uint8Array(4); - toDataView(listLengthBuffer).setUint32(0, writtenTags.size, true); - commentHeaderParts.splice(2, 0, listLengthBuffer); // Insert after the header and vendor section - - // Merge all comment header parts into a single buffer - const commentHeaderLength = commentHeaderParts.reduce((a, b) => a + b.length, 0); - const commentHeader = new Uint8Array(commentHeaderLength); - - let pos = 0; - for (const part of commentHeaderParts) { - commentHeader.set(part, pos); - pos += part.length; - } - - return commentHeader; - } - async addEncodedAudioPacket(track: OutputAudioTrack, packet: EncodedPacket, meta?: EncodedAudioChunkMetadata) { const release = await this.mutex.acquire(); diff --git a/src/output-format.ts b/src/output-format.ts index da562ee..4dd43cd 100644 --- a/src/output-format.ts +++ b/src/output-format.ts @@ -18,6 +18,7 @@ import { VIDEO_CODECS, VideoCodec, } from './codec'; +import { FlacMuxer } from './flac/flac-muxer'; import { IsobmffMuxer } from './isobmff/isobmff-muxer'; import { MatroskaMuxer } from './matroska/matroska-muxer'; import { MediaSource } from './media-source'; @@ -827,3 +828,74 @@ export class AdtsOutputFormat extends OutputFormat { return false; } } + +/** + * FLAC-specific output options. + * @group Output formats + * @public + */ +export type FlacOutputFormatOptions = { + /** + * Will be called for each FLAC frame that is written. + * + * @param data - The raw bytes. + * @param position - The byte offset of the data in the file. + */ + onFrame?: (data: Uint8Array, position: number) => unknown; +}; + +/** + * FLAC file format. + * @group Output formats + * @public + */ +export class FlacOutputFormat extends OutputFormat { + /** @internal */ + _options: FlacOutputFormatOptions; + + /** Creates a new {@link FlacOutputFormat} configured with the specified `options`. */ + constructor(options: FlacOutputFormatOptions = {}) { + if (!options || typeof options !== 'object') { + throw new TypeError('options must be an object.'); + } + + super(); + + this._options = options; + } + + /** @internal */ + _createMuxer(output: Output) { + return new FlacMuxer(output, this); + } + + /** @internal */ + get _name() { + return 'FLAC'; + } + + getSupportedTrackCounts(): TrackCountLimits { + return { + video: { min: 0, max: 0 }, + audio: { min: 1, max: 1 }, + subtitle: { min: 0, max: 0 }, + total: { min: 1, max: 1 }, + }; + } + + get fileExtension() { + return '.flac'; + } + + get mimeType() { + return 'audio/flac'; + } + + getSupportedCodecs(): MediaCodec[] { + return ['flac']; + } + + get supportsVideoRotationMetadata() { + return false; + } +} diff --git a/src/tags.ts b/src/tags.ts index 825d18a..3b0803e 100644 --- a/src/tags.ts +++ b/src/tags.ts @@ -16,6 +16,7 @@ * - For MP3 files, the metadata refers to the ID3v2 or ID3v1 tags. * - For Ogg files, there is no global metadata so instead, the metadata refers to the combined metadata of all tracks, * in Vorbis-style comment headers. + * - For FLAC files, the metadata lives in Vorbis style in the Vorbis comment block. * - For WAVE files, the metadata refers to the chunks within the RIFF INFO chunk. * - For ADTS files, there is no metadata. * @@ -69,7 +70,9 @@ export type MetadataTags = { * - Ogg: The key-value string pairs from the Vorbis-style comment header (see RFC 7845, Section 5.2). * Additionally, the `'vendor'` key refers to the vendor string within this header. * - WAVE: The individual metadata chunks within the RIFF INFO chunk. Values are always ISO 8859-1 strings. - */ + * - FLAC: The key-value string pairs from the vorbis metadata block (see RFC 9639, Section D.2.3). + * Additionally, the `'vendor'` key refers to the vendor string within this header. + */ raw?: Record; }; diff --git a/test/browser/flac.test.ts b/test/browser/flac.test.ts new file mode 100644 index 0000000..6bb17b3 --- /dev/null +++ b/test/browser/flac.test.ts @@ -0,0 +1,68 @@ +import { expect, test } from 'vitest'; +import { Input } from '../../src/input.js'; +import { BufferSource, UrlSource } from '../../src/source.js'; +import { FLAC, WAVE } from '../../src/input-format.js'; +import { AudioSampleSink } from '../../src/media-sink.js'; +import { assert } from '../../src/misc.js'; +import { Output } from '../../src/output.js'; +import { WavOutputFormat } from '../../src/output-format.js'; +import { BufferTarget } from '../../src/target.js'; +import { Conversion } from '../../src/conversion.js'; + +test('can decode samples from a FLAC file', async () => { + const input = new Input({ + source: new UrlSource('/sample.flac'), + formats: [FLAC], + }); + const track = await input.getPrimaryAudioTrack(); + assert(track); + + const sink = new AudioSampleSink(track); + + const sample = await sink.getSample(1); + assert(sample); + expect(sample.timestamp).toBe(0.9287981859410431); +}); + +test('can convert a .flac to .wav', async () => { + const input = new Input({ + source: new UrlSource('/sample.flac'), + formats: [FLAC], + }); + const output = new Output({ + format: new WavOutputFormat(), + target: new BufferTarget(), + }); + + const conversion = await Conversion.init({ input, output }); + await conversion.execute(); + + const buffer = output.target.buffer; + assert(buffer); + + const outputAsInput = new Input({ + source: new BufferSource(buffer), + formats: [WAVE], + }); + + const inputTrack = await input.getPrimaryAudioTrack(); + assert(inputTrack); + + const outputTrack = await outputAsInput.getPrimaryAudioTrack(); + assert(outputTrack); + + const duration = await outputTrack.computeDuration(); + expect(duration).toBe(19.71428571428571); + const tags = await outputAsInput.getMetadataTags(); + expect(tags.raw).toEqual({ + IART: 'Samples Files', + ICRD: '2020-01-01', + IGNR: 'Ambient', + INAM: 'The Happy Meeting', + IPRD: 'Samples files', + ITRK: '4', + }); + expect(inputTrack.sampleRate).toBe(outputTrack.sampleRate); + expect(inputTrack.numberOfChannels).toBe(outputTrack.numberOfChannels); + expect(inputTrack.timeResolution).toBe(outputTrack.timeResolution); +}); diff --git a/test/node/flac.test.ts b/test/node/flac.test.ts new file mode 100644 index 0000000..c7e6000 --- /dev/null +++ b/test/node/flac.test.ts @@ -0,0 +1,256 @@ +import { expect, test } from 'vitest'; +import path from 'node:path'; +import { assert, toUint8Array } from '../../src/misc.js'; +import { Input } from '../../src/input.js'; +import { BufferSource, FilePathSource } from '../../src/source.js'; +import { ALL_FORMATS, FLAC } from '../../src/input-format.js'; +import { EncodedPacketSink } from '../../src/media-sink.js'; +import { Output } from '../../src/output.js'; +import { BufferTarget } from '../../src/target.js'; +import { FlacOutputFormat } from '../../src/output-format.js'; +import { Conversion } from '../../src/conversion.js'; + +const __dirname = new URL('.', import.meta.url).pathname; + +test('can loop over all samples', async () => { + const filePath = path.join(__dirname, '..', 'public/sample.flac'); + const input = new Input({ + source: new FilePathSource(filePath), + formats: ALL_FORMATS, + }); + + const track = await input.getPrimaryAudioTrack(); + assert(track); + expect(await track.computeDuration()).toEqual(19.71428571428571); + expect(await track.getDecoderConfig()).toEqual({ + codec: 'flac', + numberOfChannels: 2, + sampleRate: 44100, + description: new Uint8Array([ + 102, 76, 97, 67, 128, 0, 0, 34, 16, 0, 16, 0, 0, 6, 45, 0, 37, 173, 10, + 196, 66, 240, 0, 13, 68, 24, 85, 22, 231, 0, 113, 139, 185, 1, 33, 54, + 155, 80, 241, 191, 203, 112, + ]), + }); + expect(await track.getCodecParameterString()).toEqual('flac'); + expect(track.timeResolution).toEqual(44100); + expect(await input.getMimeType()).toEqual('audio/flac'); + + const sink = new EncodedPacketSink(track); + let samples = 0; + let lastSampleTimestamp = 0; + for await (const sample of sink.packets()) { + samples++; + lastSampleTimestamp = sample.timestamp; + if (sample.sequenceNumber === 212) { + // Last frame is a bit shorter + // due it having a custom block size and the duration not being a multiple of the frame size + expect(sample.duration).toEqual(0.023764172335600908); + } else { + expect(sample.duration).toEqual(0.09287981859410431); + } + } + expect(samples).toBe(213); + expect(lastSampleTimestamp).toBe(19.690521541950112); +}); + +test('can do random access', async () => { + const filePath = path.join(__dirname, '..', 'public/sample.flac'); + const input = new Input({ + source: new FilePathSource(filePath), + formats: ALL_FORMATS, + }); + + const track = await input.getPrimaryAudioTrack(); + assert(track); + const packetSink = new EncodedPacketSink(track); + + const packet = await packetSink.getPacket(10); + assert(packet); + expect(packet.timestamp).toBe(9.93814058956916); + expect(packet.data.byteLength).toBe(8345); + expect(packet.sequenceNumber).toBe(107); + expect(packet.duration).toBe(0.09287981859410431); + + const nextPacket = await packetSink.getNextPacket(packet); + assert(nextPacket); + expect(nextPacket.timestamp).toBe(10.031020408163265); + expect(nextPacket.data.byteLength).toBe(8988); + expect(nextPacket.sequenceNumber).toBe(108); + expect(nextPacket.duration).toBe(0.09287981859410431); + + const priorPacket = await packetSink.getPacket(3); + assert(priorPacket); + expect(priorPacket.timestamp).toBe(2.972154195011338); + expect(priorPacket.data.byteLength).toBe(6877); + expect(priorPacket.sequenceNumber).toBe(32); + expect(priorPacket.duration).toBe(0.09287981859410431); +}); + +test('can get metadata-only packets', async () => { + const filePath = path.join(__dirname, '..', 'public/sample.flac'); + const input = new Input({ + source: new FilePathSource(filePath), + formats: ALL_FORMATS, + }); + + const track = await input.getPrimaryAudioTrack(); + assert(track); + const packetSink = new EncodedPacketSink(track); + + const packet = await packetSink.getPacket(10, { metadataOnly: true }); + assert(packet); + expect(packet.timestamp).toBe(9.93814058956916); + expect(packet.isMetadataOnly).toBe(true); + expect(packet.sequenceNumber).toBe(107); + expect(packet.duration).toBe(0.09287981859410431); +}); + +test('can get metadata', async () => { + const filePath = path.join(__dirname, '..', 'public/sample.flac'); + const input = new Input({ + source: new FilePathSource(filePath), + formats: ALL_FORMATS, + }); + + const { images: inputImages, ...descriptiveMetadata } = await input.getMetadataTags(); + + expect(inputImages![0]!.data).toHaveLength(4852); + expect(inputImages![0]!.data.slice(0, 8)).toEqual(new Uint8Array([ + 137, 80, 78, 71, 13, 10, 26, 10, + ])); + expect(inputImages![0]!.mimeType).toBe('image/png'); + expect(inputImages![0]!.description).toBe('Album cover'); + expect(inputImages![0]!.kind).toBe('coverFront'); + + expect(descriptiveMetadata).toEqual({ + title: 'The Happy Meeting', + date: new Date('2020'), + album: 'Samples files', + artist: 'Samples Files', + trackNumber: 4, + genre: 'Ambient', + raw: { + ALBUM: 'Samples files', + ARTIST: 'Samples Files', + DATE: '2020', + ENCODER: 'Lavf58.76.100', + GENRE: 'Ambient', + TITLE: 'The Happy Meeting', + TRACKNUMBER: '4', + vendor: 'Lavf58.76.100', + }, + }); +}); + +test('can re-mux a .flac', async () => { + const filePath = path.join(__dirname, '..', 'public/sample.flac'); + const input = new Input({ + source: new FilePathSource(filePath), + formats: ALL_FORMATS, + }); + + let framesWritten = 0; + + const output = new Output({ + format: new FlacOutputFormat({ + onFrame() { + framesWritten++; + }, + }), + target: new BufferTarget(), + }); + + const conversion = await Conversion.init({ input, output }); + await conversion.execute(); + + expect(framesWritten).toBe(213); + + const buffer = output.target.buffer; + assert(buffer); + + const outputAsInput = new Input({ + source: new BufferSource(buffer), + formats: [FLAC], + }); + + const outputTrack = await outputAsInput.getPrimaryAudioTrack(); + assert(outputTrack); + + const inputTrack = await input.getPrimaryAudioTrack(); + assert(inputTrack); + expect(inputTrack.sampleRate).toBe(outputTrack.sampleRate); + expect(inputTrack.numberOfChannels).toBe(outputTrack.numberOfChannels); + expect(inputTrack.timeResolution).toBe(outputTrack.timeResolution); + + const outputMetadataTags = await outputAsInput.getMetadataTags(); + + const inputMetadataTags = await input.getMetadataTags(); + expect(inputMetadataTags.images).toHaveLength(1); + expect(Object.keys(outputMetadataTags)).toEqual([ + 'images', + 'raw', + 'title', + 'date', + 'album', + 'artist', + 'trackNumber', + 'genre', + ]); + + const { images: inputImages, ...otherInputMetadataTags } = inputMetadataTags; + + expect(outputMetadataTags).toEqual({ + ...otherInputMetadataTags, + raw: { + ...otherInputMetadataTags.raw, + vendor: 'Mediabunny', + }, + images: inputImages, + }); + + const inputPacketSink = new EncodedPacketSink(inputTrack); + const outputPacketSink = new EncodedPacketSink(outputTrack); + let packets = 0; + let timestamp = 0; + for await (const packet of outputPacketSink.packets()) { + packets++; + timestamp = packet.timestamp; + } + + expect(packets).toBe(213); + expect(timestamp).toBe(19.690521541950112); + + // Test that packets are byte-identical + const inputPacket = await inputPacketSink.getPacket(10); + const outputPacket = await outputPacketSink.getPacket(10); + + assert(inputPacket); + assert(outputPacket); + + const { data: inputPacketData, ...otherInputPacket } = inputPacket; + const { data: outputPacketData, ...otherOutputPacket } = outputPacket; + + expect(otherInputPacket).toEqual(otherOutputPacket); + expect(inputPacketData).toEqual(outputPacketData); + + const inputDecoderConfig = await inputTrack.getDecoderConfig(); + const outputDecoderConfig = await outputTrack.getDecoderConfig(); + assert(inputDecoderConfig); + assert(outputDecoderConfig); + + const { description: inputDescription, ...otherInputDecoderConfig } = inputDecoderConfig; + const { description: outputDescription, ...otherOutputDecoderConfig } = outputDecoderConfig; + + assert(inputDescription); + assert(outputDescription); + + const inputArray = toUint8Array(inputDescription); + const outputArray = toUint8Array(outputDescription); + + const inputWithoutCrc = inputArray.slice(0, -16); + const outputWithoutCrc = outputArray.slice(0, -16); + expect(inputWithoutCrc).toEqual(outputWithoutCrc); + + expect(otherInputDecoderConfig).toEqual(otherOutputDecoderConfig); +}); diff --git a/test/node/metadata-tags.test.ts b/test/node/metadata-tags.test.ts index dcf94a4..9a62663 100644 --- a/test/node/metadata-tags.test.ts +++ b/test/node/metadata-tags.test.ts @@ -1,6 +1,7 @@ import { expect, test } from 'vitest'; import { Output } from '../../src/output.js'; import { + FlacOutputFormat, MkvOutputFormat, MovOutputFormat, Mp3OutputFormat, @@ -34,10 +35,18 @@ const createDummyAudioTrack = (codec: AudioCodec, output: Output) => { data[2] = 224; data[3] = 100; - // Opus description - const description = new Uint8Array([ - 79, 112, 117, 115, 72, 101, 97, 100, 1, 2, 56, 1, 68, 172, 0, 0, 0, 0, 0, - ]); + const description = codec === 'flac' + ? new Uint8Array([ + 102, 76, 97, 67, 128, 0, 0, 34, 16, 0, + 16, 0, 0, 6, 45, 0, 37, 173, 10, 196, + 66, 240, 0, 13, 68, 24, 85, 22, 231, 0, + 113, 139, 185, 1, 33, 54, 155, 80, 241, 191, + 203, 112, + ]) + // Opus description + : new Uint8Array([ + 79, 112, 117, 115, 72, 101, 97, 100, 1, 2, 56, 1, 68, 172, 0, 0, 0, 0, 0, + ]); await source.add( new EncodedPacket(data, 'key', 0, 1), @@ -299,7 +308,7 @@ test('Read and write metadata, Ogg', async () => { output.setMetadataTags({ ...songMetadata, raw: { - vendor: 'mediabunny corp', + vendor: 'Mediabunny', COMPOSER: 'Hans Zimmer', }, }); @@ -336,7 +345,57 @@ test('Read and write metadata, Ogg', async () => { expect(readTags.images![0]!.description).toEqual(songMetadata.images![0]!.description); expect(readTags.images![0]!.name).toBeUndefined(); // Can't be contained in Vorbis-style metadata - expect(readTags.raw!['vendor']).toBe('mediabunny corp'); + expect(readTags.raw!['vendor']).toBe('Mediabunny'); + expect(readTags.raw!['COMPOSER']).toBe('Hans Zimmer'); +}); + +test('Read and write metadata, FLAC', async () => { + const output = new Output({ + format: new FlacOutputFormat(), + target: new BufferTarget(), + }); + + output.setMetadataTags({ + ...songMetadata, + raw: { + vendor: 'Mediabunny', + COMPOSER: 'Hans Zimmer', + }, + }); + + const dummyTrack = createDummyAudioTrack('flac', output); + + await output.start(); + await dummyTrack.addPacket(); + await output.finalize(); + + const input = new Input({ + source: new BufferSource(output.target.buffer!), + formats: ALL_FORMATS, + }); + + const readTags = await input.getMetadataTags(); + + expect(readTags.title).toBe(songMetadata.title); + expect(readTags.description).toBe(songMetadata.description); + expect(readTags.artist).toBe(songMetadata.artist); + expect(readTags.album).toBe(songMetadata.album); + expect(readTags.albumArtist).toBe(songMetadata.albumArtist); + expect(readTags.comment).toBe(songMetadata.comment); + expect(readTags.lyrics).toBe(songMetadata.lyrics); + expect(readTags.trackNumber).toBe(songMetadata.trackNumber); + expect(readTags.tracksTotal).toBe(songMetadata.tracksTotal); + expect(readTags.discNumber).toBe(songMetadata.discNumber); + expect(readTags.discsTotal).toBe(songMetadata.discsTotal); + expect(readTags.date).toEqual(readTags.date); + expect(readTags.images).toHaveLength(1); + expect(readTags.images![0]!.data).toEqual(coverArt); + expect(readTags.images![0]!.mimeType).toEqual('image/jpeg'); + expect(readTags.images![0]!.kind).toEqual('coverFront'); + expect(readTags.images![0]!.description).toEqual(songMetadata.images![0]!.description); + expect(readTags.images![0]!.name).toBeUndefined(); // Can't be contained in Vorbis-style metadata + + expect(readTags.raw!['vendor']).toBe('Mediabunny'); expect(readTags.raw!['COMPOSER']).toBe('Hans Zimmer'); }); diff --git a/test/public/sample.flac b/test/public/sample.flac new file mode 100644 index 0000000..7a44c93 Binary files /dev/null and b/test/public/sample.flac differ