UNPKG

mediabunny

Version:

Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.

375 lines (297 loc) 9.96 kB
/*! * Copyright (c) 2026-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 { Muxer } from '../muxer'; import { Output, OutputAudioTrack } from '../output'; import { parsePcmCodec, PcmAudioCodec, validateAudioChunkMetadata } from '../codec'; import { WaveFormat } from './wave-demuxer'; import { RiffWriter } from './riff-writer'; import { Writer } from '../writer'; import { EncodedPacket } from '../packet'; import { WavOutputFormat } from '../output-format'; import { assert, assertNever, isIso88591Compatible, keyValueIterator } from '../misc'; import { MetadataTags, metadataTagsAreEmpty } from '../metadata'; import { Id3V2Writer } from '../id3'; 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; private riffSizePos: number | null = null; private dataSizePos: number | null = null; private ds64RiffSizePos: number | null = null; private ds64DataSizePos: number | null = null; private ds64SampleCountPos: number | null = null; constructor(output: Output, format: WavOutputFormat) { super(output); this.format = format; this.isRf64 = !!format._options.large; } async start() { const release = await this.mutex.acquire(); this.writer = await this.output._getRootWriter(false); this.riffWriter = new RiffWriter(this.writer); // No writing needed here - we'll write the header with the first sample release(); } async getMimeType() { return 'audio/wav'; } async addEncodedVideoPacket() { throw new Error('WAVE does not support video.'); } async addEncodedAudioPacket( track: OutputAudioTrack, packet: EncodedPacket, meta?: EncodedAudioChunkMetadata, ) { const release = await this.mutex.acquire(); try { if (!this.headerWritten) { validateAudioChunkMetadata(meta); assert(meta); assert(meta.decoderConfig); this.writeHeader(track, meta.decoderConfig); this.sampleRate = meta.decoderConfig.sampleRate; this.headerWritten = true; } this.validateTimestamp(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 { release(); } } async addSubtitleCue() { throw new Error('WAVE does not support subtitles.'); } private writeHeader(track: OutputAudioTrack, config: AudioDecoderConfig) { if (this.format._options.onHeader) { this.writer.startTrackingWrites(); } let format: WaveFormat; const codec = track.source._codec; const pcmInfo = parsePcmCodec(codec as PcmAudioCodec); if (pcmInfo.dataType === 'ulaw') { format = WaveFormat.MULAW; } else if (pcmInfo.dataType === 'alaw') { format = WaveFormat.ALAW; } else if (pcmInfo.dataType === 'float') { format = WaveFormat.IEEE_FLOAT; } else { format = WaveFormat.PCM; } const channels = config.numberOfChannels; const sampleRate = config.sampleRate; const blockSize = pcmInfo.sampleSize * channels; // RIFF header this.riffWriter.writeAscii(this.isRf64 ? 'RF64' : 'RIFF'); if (this.isRf64) { this.riffWriter.writeU32(0xffffffff); // Not used in RF64 } else { this.riffSizePos = this.writer.getPos(); 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.ds64RiffSizePos = this.writer.getPos(); this.riffWriter.writeU64(0); // RIFF size placeholder this.ds64DataSizePos = this.writer.getPos(); this.riffWriter.writeU64(0); // Data size placeholder this.ds64SampleCountPos = this.writer.getPos(); 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 this.riffWriter.writeU16(format); this.riffWriter.writeU16(channels); this.riffWriter.writeU32(sampleRate); this.riffWriter.writeU32(sampleRate * blockSize); // Bytes per second this.riffWriter.writeU16(blockSize); this.riffWriter.writeU16(8 * pcmInfo.sampleSize); // Metadata tags if (!metadataTagsAreEmpty(this.output._metadataTags)) { const metadataFormat = this.format._options.metadataFormat ?? 'info'; if (metadataFormat === 'info') { this.writeInfoChunk(this.output._metadataTags); } else if (metadataFormat === 'id3') { this.writeId3Chunk(this.output._metadataTags); } else { assertNever(metadataFormat); } } // data chunk this.riffWriter.writeAscii('data'); if (this.isRf64) { this.riffWriter.writeU32(0xffffffff); // Not used in RF64 } else { this.dataSizePos = this.writer.getPos(); this.riffWriter.writeU32(0); // Data size placeholder } if (this.format._options.onHeader) { const { data, start } = this.writer.stopTrackingWrites(); this.format._options.onHeader(data, start); } } private writeInfoChunk(metadata: MetadataTags) { const startPos = this.writer.getPos(); this.riffWriter.writeAscii('LIST'); this.riffWriter.writeU32(0); // Size placeholder this.riffWriter.writeAscii('INFO'); const writtenTags = new Set<string>(); const writeInfoTag = (tag: string, value: string) => { if (!isIso88591Compatible(value)) { // No Unicode supported here console.warn(`Didn't write tag '${tag}' because '${value}' is not ISO 8859-1-compatible.`); return; } const size = value.length + 1; // +1 for null terminator const bytes = new Uint8Array(size); for (let i = 0; i < value.length; i++) { bytes[i] = value.charCodeAt(i); } this.riffWriter.writeAscii(tag); this.riffWriter.writeU32(size); this.writer.write(bytes); // Add padding byte if size is odd if (size & 1) { this.writer.write(new Uint8Array(1)); } writtenTags.add(tag); }; for (const { key, value } of keyValueIterator(metadata)) { switch (key) { case 'title': { writeInfoTag('INAM', value); writtenTags.add('INAM'); }; break; case 'artist': { writeInfoTag('IART', value); writtenTags.add('IART'); }; break; case 'album': { writeInfoTag('IPRD', value); writtenTags.add('IPRD'); }; break; case 'trackNumber': { const string = metadata.tracksTotal !== undefined ? `${value}/${metadata.tracksTotal}` : value.toString(); writeInfoTag('ITRK', string); writtenTags.add('ITRK'); }; break; case 'genre': { writeInfoTag('IGNR', value); writtenTags.add('IGNR'); }; break; case 'date': { writeInfoTag('ICRD', value.toISOString().slice(0, 10)); writtenTags.add('ICRD'); }; break; case 'comment': { writeInfoTag('ICMT', value); writtenTags.add('ICMT'); }; break; case 'albumArtist': case 'discNumber': case 'tracksTotal': case 'discsTotal': case 'description': case 'lyrics': case 'images': { // Not supported in RIFF INFO }; break; case 'raw': { // Handled later }; break; default: assertNever(key); } } if (metadata.raw) { for (const key in metadata.raw) { const value = metadata.raw[key]; if (value == null || key.length !== 4 || writtenTags.has(key)) { continue; } if (typeof value === 'string') { writeInfoTag(key, value); } } } const endPos = this.writer.getPos(); const chunkSize = endPos - startPos - 8; this.writer.seek(startPos + 4); this.riffWriter.writeU32(chunkSize); this.writer.seek(endPos); // Add padding byte if chunk size is odd if (chunkSize & 1) { this.writer.write(new Uint8Array(1)); } } private writeId3Chunk(metadata: MetadataTags) { const startPos = this.writer.getPos(); // Write RIFF chunk header this.riffWriter.writeAscii('ID3 '); this.riffWriter.writeU32(0); // Size placeholder const id3Writer = new Id3V2Writer(this.writer); const id3TagSize = id3Writer.writeId3V2Tag(metadata); const endPos = this.writer.getPos(); // Update RIFF chunk size this.writer.seek(startPos + 4); this.riffWriter.writeU32(id3TagSize); this.writer.seek(endPos); // Add padding byte if chunk size is odd if (id3TagSize & 1) { this.writer.write(new Uint8Array(1)); } } async finalize() { const release = await this.mutex.acquire(); const endPos = this.writer.getPos(); if (this.isRf64) { // Write riff size assert(this.ds64RiffSizePos !== null); this.writer.seek(this.ds64RiffSizePos); this.riffWriter.writeU64(endPos - 8); // Write data size assert(this.ds64DataSizePos !== null); this.writer.seek(this.ds64DataSizePos); this.riffWriter.writeU64(this.dataSize); // Write sample count assert(this.ds64SampleCountPos !== null); this.writer.seek(this.ds64SampleCountPos); this.riffWriter.writeU64(this.sampleCount); } else { // Write file size assert(this.riffSizePos !== null); this.writer.seek(this.riffSizePos); this.riffWriter.writeU32(endPos - 8); // Write data chunk size assert(this.dataSizePos !== null); this.writer.seek(this.dataSizePos); this.riffWriter.writeU32(this.dataSize); } release(); } }