diff --git a/dev/demux.html b/dev/demux.html index 1e754ee..d381167 100644 --- a/dev/demux.html +++ b/dev/demux.html @@ -46,7 +46,7 @@ const target = new Metamuxer.BufferTarget(); const output = new Metamuxer.Output({ - format: new Metamuxer.MkvOutputFormat(), + format: new Metamuxer.WaveOutputFormat(), target }); @@ -59,10 +59,10 @@ const decoderConfig = await audioTrack.getDecoderConfig(); const sampleSource = new Metamuxer.EncodedAudioSampleSource(await audioTrack.getCodec()); - const audioDataSource = new Metamuxer.AudioDataSource({ codec: 'vorbis', bitrate: 128e3 }); + const audioDataSource = new Metamuxer.AudioDataSource({ codec: 'pcm-s16', bitrate: 128e3 }); const videoSampleSource = new Metamuxer.EncodedVideoSampleSource(await videoTrack.getCodec()); output.addAudioTrack(audioDataSource ?? sampleSource); - output.addVideoTrack(videoSampleSource); + //output.addVideoTrack(videoSampleSource); output.start(); @@ -85,10 +85,12 @@ //console.log(sample) await audioDataSource.digest(data); } + /* for await (const sample of new Metamuxer.EncodedVideoSampleDrain(videoTrack).samples()) { //console.log(sample) await videoSampleSource.digest(sample, { decoderConfig: videoDecoderConfig }); } + */ await output.finalize(); @@ -102,7 +104,7 @@ a.click(); URL.revokeObjectURL(url); } - download(new Blob([target.buffer]), 'converted.mkv'); + download(new Blob([target.buffer]), 'converted.wav'); document.body.textContent = performance.now() - start; diff --git a/src/index.ts b/src/index.ts index dc90203..3cfc1b4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,7 @@ export { MkvOutputFormatOptions, WebMOutputFormat, WebMOutputFormatOptions, + WaveOutputFormat, } from './output-format'; export { VideoEncodingConfig, @@ -53,11 +54,13 @@ export { Mp4InputFormat, QuickTimeInputFormat as MovInputFormat, MatroskaInputFormat, + WaveInputFormat, ALL_FORMATS, MP4, QTFF, MATROSKA, WEBM, + WAVE, } from './input-format'; export { Input, InputOptions } from './input'; export { InputTrack, InputVideoTrack, InputAudioTrack, SampleStats } from './input-track'; diff --git a/src/output-format.ts b/src/output-format.ts index c75177e..63e3ab7 100644 --- a/src/output-format.ts +++ b/src/output-format.ts @@ -3,6 +3,7 @@ import { IsobmffMuxer } from './isobmff/isobmff-muxer'; import { MatroskaMuxer } from './matroska/matroska-muxer'; import { Muxer } from './muxer'; import { Output } from './output'; +import { WaveMuxer } from './wave/wave-muxer'; /** @public */ export abstract class OutputFormat { @@ -197,3 +198,26 @@ export class WebMOutputFormat extends MkvOutputFormat { return ''; } } + +/** @public */ +export class WaveOutputFormat extends OutputFormat { + /** @internal */ + _createMuxer(output: Output) { + return new WaveMuxer(output); + } + + /** @internal */ + _getName() { + return 'WAVE'; + } + + getSupportedCodecs() { + return WaveOutputFormat.getSupportedCodecs(); + } + + static getSupportedCodecs(): MediaCodec[] { + return [ + 'pcm-u8', 'pcm-s16', 'pcm-s24', 'pcm-s32', 'pcm-f32', 'ulaw', 'alaw', + ]; + } +} diff --git a/src/wave/wave-muxer.ts b/src/wave/wave-muxer.ts new file mode 100644 index 0000000..1477f53 --- /dev/null +++ b/src/wave/wave-muxer.ts @@ -0,0 +1,144 @@ +import { Muxer } from '../muxer'; +import { Output, OutputAudioTrack } from '../output'; +import { EncodedAudioSample } from '../sample'; +import { parsePcmCodec, PcmAudioCodec } from '../codec'; +import { WaveFormat } from './wave-demuxer'; +import { Writer } from '../writer'; + +export class WaveMuxer extends Muxer { + private riffWriter: RiffWriter; + private headerWritten = false; + private dataSize = 0; + + constructor(output: Output) { + super(output); + this.riffWriter = new RiffWriter(output._writer); + } + + async start() { + // Nothing needed here - we'll write the header with the first sample + } + + async addEncodedVideoSample() { + throw new Error('WAVE format does not support video.'); + } + + async addEncodedAudioSample( + track: OutputAudioTrack, + sample: EncodedAudioSample, + meta?: EncodedAudioChunkMetadata, + ) { + const release = await this.mutex.acquire(); + + try { + if (!this.headerWritten) { + if (!meta?.decoderConfig) { + throw new Error('Decoder config is required for first audio sample.'); + } + + this.writeHeader(track, meta.decoderConfig); + this.headerWritten = true; + } + + this.validateAndNormalizeTimestamp(track, sample.timestamp, sample.type === 'key'); + + if (sample.data) { + this.output._writer.write(sample.data); + this.dataSize += sample.data.byteLength; + } + + await this.output._writer.flush(); + } finally { + release(); + } + } + + async addSubtitleCue() { + throw new Error('WAVE format does not support subtitles.'); + } + + private writeHeader(track: OutputAudioTrack, config: AudioDecoderConfig) { + const codec = track.source._codec; + let format: WaveFormat; + let sampleSize: number; + + const pcmInfo = parsePcmCodec(codec as PcmAudioCodec); + + if (pcmInfo.dataType === 'ulaw') { + format = WaveFormat.MULAW; + sampleSize = 1; + } else if (pcmInfo.dataType === 'alaw') { + format = WaveFormat.ALAW; + sampleSize = 1; + } else { + format = pcmInfo.dataType === 'float' ? WaveFormat.IEEE_FLOAT : WaveFormat.PCM; + sampleSize = pcmInfo.sampleSize; + } + + const channels = config.numberOfChannels; + const sampleRate = config.sampleRate; + const blockSize = sampleSize * channels; + + this.riffWriter.writeHeader(format, channels, sampleRate, blockSize, sampleSize); + } + + async finalize() { + this.riffWriter.patchSizes(this.dataSize); + } +} + +class RiffWriter { + private helper = new Uint8Array(8); + private helperView = new DataView(this.helper.buffer); + + constructor(private writer: Writer) {} + + 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)); + } + + writeAscii(text: string) { + this.writer.write(new TextEncoder().encode(text)); + } + + writeHeader(format: WaveFormat, channels: number, sampleRate: number, blockSize: number, bytesPerSample: number) { + // RIFF header + this.writeAscii('RIFF'); + this.writeU32(0); // File size placeholder + this.writeAscii('WAVE'); + + // fmt chunk + this.writeAscii('fmt '); + this.writeU32(16); // Chunk size + this.writeU16(format); + this.writeU16(channels); + this.writeU32(sampleRate); + this.writeU32(sampleRate * blockSize); // Bytes per second + this.writeU16(blockSize); + this.writeU16(8 * bytesPerSample); + + // data chunk + this.writeAscii('data'); + this.writeU32(0); // Data size placeholder + } + + patchSizes(dataSize: number) { + const currentPos = this.writer.getPos(); + + // Write file size + this.writer.seek(4); + this.writeU32(dataSize + 36); // File size - 8 + + // Write data chunk size + this.writer.seek(40); + this.writeU32(dataSize); + + this.writer.seek(currentPos); + } +}