From 4b12468008913a41a7a29c479b4e0f357e6a5890 Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Fri, 18 Jul 2025 16:48:23 +0200 Subject: [PATCH] Add support for reading and writing RF64 WAVE files --- dev/convert.html | 6 ++-- docs/guide/output-formats.md | 3 ++ package-lock.json | 4 +-- package.json | 2 +- src/input-format.ts | 2 +- src/output-format.ts | 9 +++++ src/wave/riff-reader.ts | 15 ++++++++ src/wave/riff-writer.ts | 12 +++++-- src/wave/wave-demuxer.ts | 27 +++++++++++++-- src/wave/wave-muxer.ts | 66 +++++++++++++++++++++++++++++++----- 10 files changed, 124 insertions(+), 22 deletions(-) diff --git a/dev/convert.html b/dev/convert.html index 6f1a27c..9118417 100644 --- a/dev/convert.html +++ b/dev/convert.html @@ -21,7 +21,7 @@ chunked: true, chunkSize: 2**20 }); - const outputFormat = new Mediabunny.Mp4OutputFormat(); + const outputFormat = new Mediabunny.WavOutputFormat({ large: true }); const button = document.createElement('button'); button.textContent = 'Cancel'; @@ -38,8 +38,8 @@ target }), audio: { - codec: 'opus', - bitrate: 128000, + //codec: 'opus', + //bitrate: 128000, //numberOfChannels: 1, //sampleRate: 4000 //discard: true diff --git a/docs/guide/output-formats.md b/docs/guide/output-formats.md index cd871ec..cc116a8 100644 --- a/docs/guide/output-formats.md +++ b/docs/guide/output-formats.md @@ -227,8 +227,11 @@ const output = new Output({ The following options are available: ```ts type WavOutputFormatOptions = { + large?: boolean; onHeader?: (data: Uint8Array, position: number) => unknown; }; ``` +- `large`\ + When enabled, an RF64 file be written, allowing for file sizes to exceed 4 GiB, which is otherwise not possible for regular WAVE files. - `onHeader`\ Will be called once the file header is written. The header consists of the RIFF header, the format chunk, and the start of the data chunk (with a placeholder size of 0). \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 6300110..c6ae14b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mediabunny", - "version": "1.1.1", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mediabunny", - "version": "1.1.1", + "version": "1.2.0", "license": "MPL-2.0", "dependencies": { "@types/dom-mediacapture-transform": "^0.1.11", diff --git a/package.json b/package.json index 8ac74a2..da4720a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "mediabunny", "author": "Vanilagy", - "version": "1.1.1", + "version": "1.2.0", "description": "Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.", "type": "module", "main": "./dist/bundles/mediabunny.cjs", diff --git a/src/input-format.ts b/src/input-format.ts index 8ed94f8..54235e3 100644 --- a/src/input-format.ts +++ b/src/input-format.ts @@ -291,7 +291,7 @@ export class WaveInputFormat extends InputFormat { const riffReader = new RiffReader(input._mainReader); const riffType = riffReader.readAscii(4); - if (riffType !== 'RIFF' && riffType !== 'RIFX') { + if (riffType !== 'RIFF' && riffType !== 'RIFX' && riffType !== 'RF64') { return false; } diff --git a/src/output-format.ts b/src/output-format.ts index e26d182..5f22e3d 100644 --- a/src/output-format.ts +++ b/src/output-format.ts @@ -546,6 +546,12 @@ export class Mp3OutputFormat extends OutputFormat { * @public */ export type WavOutputFormatOptions = { + /** + * When enabled, an RF64 file be written, allowing for file sizes to exceed 4 GiB, which is otherwise not possible + * for regular WAVE files. + */ + large?: boolean; + /** * Will be called once the file header is written. The header consists of the RIFF header, the format chunk, and the * start of the data chunk (with a placeholder size of 0). @@ -565,6 +571,9 @@ export class WavOutputFormat extends OutputFormat { if (!options || typeof options !== 'object') { throw new TypeError('options must be an object.'); } + if (options.large !== undefined && typeof options.large !== 'boolean') { + throw new TypeError('options.large, when provided, must be a boolean.'); + } if (options.onHeader !== undefined && typeof options.onHeader !== 'function') { throw new TypeError('options.onHeader, when provided, must be a function.'); } diff --git a/src/wave/riff-reader.ts b/src/wave/riff-reader.ts index 58c5bd4..21ccee4 100644 --- a/src/wave/riff-reader.ts +++ b/src/wave/riff-reader.ts @@ -35,6 +35,21 @@ export class RiffReader { return view.getUint32(offset, this.littleEndian); } + readU64() { + let low: number; + let high: number; + + if (this.littleEndian) { + low = this.readU32(); + high = this.readU32(); + } else { + high = this.readU32(); + low = this.readU32(); + } + + return high * 0x100000000 + low; + } + readAscii(length: number) { const { view, offset } = this.reader.getViewAndOffset(this.pos, this.pos + length); this.pos += length; diff --git a/src/wave/riff-writer.ts b/src/wave/riff-writer.ts index cbfc102..d89512e 100644 --- a/src/wave/riff-writer.ts +++ b/src/wave/riff-writer.ts @@ -14,14 +14,20 @@ export class RiffWriter { constructor(private writer: Writer) {} + writeU16(value: number) { + this.helperView.setUint16(0, value, true); + this.writer.write(this.helper.subarray(0, 2)); + } + writeU32(value: number) { this.helperView.setUint32(0, value, true); this.writer.write(this.helper.subarray(0, 4)); } - writeU16(value: number) { - this.helperView.setUint16(0, value, true); - this.writer.write(this.helper.subarray(0, 2)); + writeU64(value: number) { + this.helperView.setUint32(0, value, true); + this.helperView.setUint32(4, Math.floor(value / 2 ** 32), true); + this.writer.write(this.helper); } writeAscii(text: string) { diff --git a/src/wave/wave-demuxer.ts b/src/wave/wave-demuxer.ts index b5a446f..b70a464 100644 --- a/src/wave/wave-demuxer.ts +++ b/src/wave/wave-demuxer.ts @@ -53,9 +53,13 @@ export class WaveDemuxer extends Demuxer { const actualFileSize = await this.metadataReader.reader.source.getSize(); const riffType = this.metadataReader.readAscii(4); - this.metadataReader.littleEndian = riffType === 'RIFF'; + this.metadataReader.littleEndian = riffType !== 'RIFX'; - const totalFileSize = Math.min(this.metadataReader.readU32() + 8, actualFileSize); + const isRf64 = riffType === 'RF64'; + + const outerChunkSize = this.metadataReader.readU32(); + + let totalFileSize = isRf64 ? actualFileSize : Math.min(outerChunkSize + 8, actualFileSize); const format = this.metadataReader.readAscii(4); if (format !== 'WAVE') { @@ -63,6 +67,9 @@ export class WaveDemuxer extends Demuxer { } this.metadataReader.pos = 12; + let chunksRead = 0; + let dataChunkSize: number | null = null; + while (this.metadataReader.pos < totalFileSize) { await this.metadataReader.reader.loadRange(this.metadataReader.pos, this.metadataReader.pos + 8); @@ -70,14 +77,28 @@ export class WaveDemuxer extends Demuxer { const chunkSize = this.metadataReader.readU32(); const startPos = this.metadataReader.pos; + if (isRf64 && chunksRead === 0 && chunkId !== 'ds64') { + throw new Error('Invalid RF64 file: First chunk must be "ds64".'); + } + if (chunkId === 'fmt ') { await this.parseFmtChunk(chunkSize); } else if (chunkId === 'data') { + dataChunkSize ??= chunkSize; + this.dataStart = this.metadataReader.pos; - this.dataSize = Math.min(chunkSize, totalFileSize - this.dataStart); + this.dataSize = Math.min(dataChunkSize, totalFileSize - this.dataStart); + } else if (chunkId === 'ds64') { + // File and data chunk sizes are defined in here instead + + const riffChunkSize = this.metadataReader.readU64(); + dataChunkSize = this.metadataReader.readU64(); + + totalFileSize = Math.min(riffChunkSize + 8, actualFileSize); } this.metadataReader.pos = startPos + chunkSize + (chunkSize & 1); // Handle padding + chunksRead++; } if (!this.audioInfo) { diff --git a/src/wave/wave-muxer.ts b/src/wave/wave-muxer.ts index c4bfb4e..878939f 100644 --- a/src/wave/wave-muxer.ts +++ b/src/wave/wave-muxer.ts @@ -18,10 +18,13 @@ import { assert } from '../misc'; export class WaveMuxer extends Muxer { private format: WavOutputFormat; + private isRf64: boolean; private writer: Writer; private riffWriter: RiffWriter; private headerWritten = false; private dataSize = 0; + private sampleRate: number | null = null; + private sampleCount = 0; constructor(output: Output, format: WavOutputFormat) { super(output); @@ -29,6 +32,7 @@ export class WaveMuxer extends Muxer { this.format = format; this.writer = output._writer; this.riffWriter = new RiffWriter(output._writer); + this.isRf64 = !!format._options.large; } async start() { @@ -58,13 +62,22 @@ export class WaveMuxer extends Muxer { assert(meta.decoderConfig); this.writeHeader(track, meta.decoderConfig); + this.sampleRate = meta.decoderConfig.sampleRate; this.headerWritten = true; } this.validateAndNormalizeTimestamp(track, packet.timestamp, packet.type === 'key'); + if (!this.isRf64 && this.writer.getPos() + packet.data.byteLength >= 2 ** 32) { + throw new Error( + 'Adding more audio data would exceed the maximum RIFF size of 4 GiB. To write larger files, use' + + ' RF64 by setting `large: true` in the WavOutputFormatOptions.', + ); + } + this.writer.write(packet.data); this.dataSize += packet.data.byteLength; + this.sampleCount += Math.round(packet.duration * this.sampleRate!); await this.writer.flush(); } finally { @@ -101,10 +114,26 @@ export class WaveMuxer extends Muxer { const blockSize = pcmInfo.sampleSize * channels; // RIFF header - this.riffWriter.writeAscii('RIFF'); - this.riffWriter.writeU32(0); // File size placeholder + this.riffWriter.writeAscii(this.isRf64 ? 'RF64' : 'RIFF'); + + if (this.isRf64) { + this.riffWriter.writeU32(0xffffffff); // Not used in RF64 + } else { + this.riffWriter.writeU32(0); // File size placeholder + } + this.riffWriter.writeAscii('WAVE'); + if (this.isRf64) { + this.riffWriter.writeAscii('ds64'); + this.riffWriter.writeU32(28); // Chunk size + this.riffWriter.writeU64(0); // RIFF size placeholder + this.riffWriter.writeU64(0); // Data size placeholder + this.riffWriter.writeU64(0); // Sample count placeholder + this.riffWriter.writeU32(0); // Table length + // Empty table + } + // fmt chunk this.riffWriter.writeAscii('fmt '); this.riffWriter.writeU32(16); // Chunk size @@ -117,7 +146,12 @@ export class WaveMuxer extends Muxer { // data chunk this.riffWriter.writeAscii('data'); - this.riffWriter.writeU32(0); // Data size placeholder + + if (this.isRf64) { + this.riffWriter.writeU32(0xffffffff); // Not used in RF64 + } else { + this.riffWriter.writeU32(0); // Data size placeholder + } if (this.format._options.onHeader) { const { data, start } = this.writer.stopTrackingWrites(); @@ -130,13 +164,27 @@ export class WaveMuxer extends Muxer { const endPos = this.writer.getPos(); - // Write file size - this.writer.seek(4); - this.riffWriter.writeU32(this.dataSize + 36); // File size - 8 + if (this.isRf64) { + // Write riff size + this.writer.seek(20); + this.riffWriter.writeU64(endPos - 8); - // Write data chunk size - this.writer.seek(40); - this.riffWriter.writeU32(this.dataSize); + // Write data size + this.writer.seek(28); + this.riffWriter.writeU64(this.dataSize); + + // Write sample count + this.writer.seek(36); + this.riffWriter.writeU64(this.sampleCount); + } else { + // Write file size + this.writer.seek(4); + this.riffWriter.writeU32(endPos - 8); + + // Write data chunk size + this.writer.seek(40); + this.riffWriter.writeU32(this.dataSize); + } this.writer.seek(endPos);