diff --git a/dev/demux.html b/dev/demux.html index 509d08e..c86d370 100644 --- a/dev/demux.html +++ b/dev/demux.html @@ -57,7 +57,7 @@ const decoderConfig = await audioTrack.getDecoderConfig(); const sampleSource = new Metamuxer.EncodedAudioSampleSource(await audioTrack.getCodec()); - const audioDataSource = new Metamuxer.AudioDataSource({ codec: 'aac', bitrate: 128e3 }); + const audioDataSource = new Metamuxer.AudioDataSource({ codec: 'alaw', bitrate: 128e3 }); const videoSampleSource = new Metamuxer.EncodedVideoSampleSource(await videoTrack.getCodec()); output.addAudioTrack(audioDataSource ?? sampleSource); output.addVideoTrack(videoSampleSource); @@ -66,13 +66,15 @@ const videoDecoderConfig = await videoTrack.getDecoderConfig(); + /* for await (const sample of drain.samples()) { //sample.timestamp *= 2; //console.log(sample); //console.log(sample) await sampleSource.digest(sample, { decoderConfig }); - }*/ + } + */ for await (const { data } of new Metamuxer.AudioDataDrain(audioTrack).data()) { //console.log(sample) await audioDataSource.digest(data); @@ -94,7 +96,7 @@ a.click(); URL.revokeObjectURL(url); } - //download(new Blob([target.buffer]), 'converted.mov'); + download(new Blob([target.buffer]), 'converted.mov'); document.body.textContent = performance.now() - start; diff --git a/src/codec.ts b/src/codec.ts index f33d1fc..d75ccc3 100644 --- a/src/codec.ts +++ b/src/codec.ts @@ -914,7 +914,7 @@ export const canEncodeAudio = async (codec: AudioCodec, { numberOfChannels = 2, } if ((PCM_CODECS as readonly string[]).includes(codec)) { - return false; // TODO write encoder + return true; // Because we encode these ourselves } if (typeof AudioEncoder === 'undefined') { diff --git a/src/media-drain.ts b/src/media-drain.ts index 1eb5be5..7d2c4ce 100644 --- a/src/media-drain.ts +++ b/src/media-drain.ts @@ -11,6 +11,7 @@ import { toAsyncIterator, validateAnyIterable, } from './misc'; +import { fromAlaw, fromUlaw } from './pcm'; import { EncodedAudioSample, EncodedVideoSample } from './sample'; /** @public */ @@ -890,58 +891,9 @@ class PcmAudioDecoderWrapper extends DecoderWrapper view.getInt8(byteOffset); } else if (dataType === 'ulaw') { - // https://github.com/dystopiancode/pcm-g711/blob/master/pcm-g711/g711.c - this.readInputValue = (view, byteOffset) => { - const MULAW_BIAS = 33; - let sign = 0; - let position = 0; - - // Get byte and invert - let number = ~view.getUint8(byteOffset); - - // Handle sign - if (number & 0x80) { - number &= ~(1 << 7); - sign = -1; - } - - // Calculate position - position = ((number & 0xF0) >> 4) + 5; - - // Reconstruct linear value - const decoded = ((1 << position) | ((number & 0x0F) << (position - 4)) - | (1 << (position - 5))) - MULAW_BIAS; - - return (sign === 0) ? decoded : -decoded; - }; + this.readInputValue = (view, byteOffset) => fromUlaw(view.getUint8(byteOffset)); } else if (dataType === 'alaw') { - this.readInputValue = (view, byteOffset) => { - let sign = 0x00; - let position = 0; - - // Get byte and XOR with 0x55 - let number = view.getUint8(byteOffset) ^ 0x55; - - // Handle sign - if (number & 0x80) { - number &= ~(1 << 7); - sign = -1; - } - - // Calculate position - position = ((number & 0xF0) >> 4) + 4; - - // Reconstruct linear value - let decoded = 0; - if (position !== 4) { - decoded = ((1 << position) | ((number & 0x0F) << (position - 4)) - | (1 << (position - 5))); - } else { - decoded = (number << 1) | 1; - } - - return (sign === 0) ? decoded : -decoded; - }; + this.readInputValue = (view, byteOffset) => fromAlaw(view.getUint8(byteOffset)); } else { assert(false); } diff --git a/src/media-source.ts b/src/media-source.ts index 72ee77c..6ed857a 100644 --- a/src/media-source.ts +++ b/src/media-source.ts @@ -5,17 +5,20 @@ import { buildVideoCodecString, getAudioEncoderConfigExtension, getVideoEncoderConfigExtension, + parsePcmCodec, PCM_CODECS, + PcmAudioCodec, SUBTITLE_CODECS, SubtitleCodec, VIDEO_CODECS, VideoCodec, } from './codec'; import { OutputAudioTrack, OutputSubtitleTrack, OutputTrack, OutputVideoTrack } from './output'; -import { assert } from './misc'; +import { assert, clamp, setInt24, setUint24 } from './misc'; import { Muxer } from './muxer'; import { SubtitleParser } from './subtitles'; import { EncodedAudioSample, EncodedVideoSample } from './sample'; +import { toAlaw, toUlaw } from './pcm'; /** @public */ export abstract class MediaSource { @@ -31,23 +34,23 @@ export abstract class MediaSource { /** @internal */ _ensureValidDigest() { if (!this._connectedTrack) { - throw new Error('Cannot call digest without connecting the source to an output track.'); + throw new Error('Source is not connected to an output track.'); } if (this._connectedTrack.output._canceled) { - throw new Error('Cannot call digest after output has been canceled.'); + throw new Error('Output has been canceled.'); } if (!this._connectedTrack.output._started) { - throw new Error('Cannot call digest before output has been started.'); + throw new Error('Output has not started.'); } if (this._connectedTrack.output._finalizing) { - throw new Error('Cannot call digest after output has started finalizing.'); + throw new Error('Output is finalizing.'); } if (this._closed) { - throw new Error('Cannot call digest after source has been closed.'); + throw new Error('Source is closed.'); } } @@ -126,7 +129,7 @@ export type VideoEncodingConfig = { bitrate: number; latencyMode?: VideoEncoderConfig['latencyMode']; keyFrameInterval?: number; - onEncodedChunk?: (chunk: EncodedVideoChunk, meta: EncodedVideoChunkMetadata | undefined) => unknown; + onEncodedSample?: (chunk: EncodedVideoSample, meta: EncodedVideoChunkMetadata | undefined) => unknown; onEncodingError?: (error: Error) => unknown; }; @@ -149,7 +152,7 @@ const validateVideoEncodingConfig = (config: VideoEncodingConfig) => { ) { throw new TypeError('config.keyFrameInterval, when provided, must be a non-negative number.'); } - if (config.onEncodedChunk !== undefined && typeof config.onEncodedChunk !== 'function') { + if (config.onEncodedSample !== undefined && typeof config.onEncodedSample !== 'function') { throw new TypeError('config.onEncodedChunk, when provided, must be a function.'); } if (config.onEncodingError !== undefined && typeof config.onEncodingError !== 'function') { @@ -215,12 +218,10 @@ class VideoEncoderWrapper { this.encoder = new VideoEncoder({ output: (chunk, meta) => { - this.encodingConfig.onEncodedChunk?.(chunk, meta); - void this.muxer!.addEncodedVideoSample( - this.source._connectedTrack!, - EncodedVideoSample.fromEncodedVideoChunk(chunk), - meta, - ); + const sample = EncodedVideoSample.fromEncodedVideoChunk(chunk); + + this.encodingConfig.onEncodedSample?.(sample, meta); + void this.muxer!.addEncodedVideoSample(this.source._connectedTrack!, sample, meta); }, error: this.encodingConfig.onEncodingError ?? (error => console.error('VideoEncoder error:', error)), }); @@ -420,7 +421,7 @@ export class EncodedAudioSampleSource extends AudioSource { export type AudioEncodingConfig = { codec: AudioCodec; bitrate?: number; - onEncodedChunk?: (chunk: EncodedAudioChunk, meta: EncodedAudioChunkMetadata | undefined) => unknown; + onEncodedSample?: (chunk: EncodedAudioSample, meta: EncodedAudioChunkMetadata | undefined) => unknown; onEncodingError?: (error: Error) => unknown; }; @@ -443,11 +444,16 @@ const validateAudioEncodingConfig = (config: AudioEncodingConfig) => { }; class AudioEncoderWrapper { + private encoderInitialized = false; private encoder: AudioEncoder | null = null; private muxer: Muxer | null = null; private lastNumberOfChannels: number | null = null; private lastSampleRate: number | null = null; + private isPcmEncoder = false; + private outputSampleSize: number | null = null; + private writeOutputValue: ((view: DataView, byteOffset: number, value: number) => void) | null = null; + constructor(private source: AudioSource, private encodingConfig: AudioEncodingConfig) { validateAudioEncodingConfig(encodingConfig); } @@ -473,44 +479,177 @@ class AudioEncoderWrapper { } this.ensureEncoder(audioData); - assert(this.encoder); + assert(this.encoderInitialized); - this.encoder.encode(audioData); + if (this.isPcmEncoder) { + await this.doPcmEncoding(audioData); + } else { + assert(this.encoder); + this.encoder.encode(audioData); - if (this.encoder.encodeQueueSize >= 4) { - await new Promise(resolve => this.encoder!.addEventListener('dequeue', resolve, { once: true })); + if (this.encoder.encodeQueueSize >= 4) { + await new Promise(resolve => this.encoder!.addEventListener('dequeue', resolve, { once: true })); + } + + await this.muxer!.mutex.currentPromise; // Allow the writer to apply backpressure + } + } + + private async doPcmEncoding(audioData: AudioData) { + assert(this.outputSampleSize); + assert(this.writeOutputValue); + + // All user agents are required to support conversion to f32-planar + const allocationSize = audioData.allocationSize(({ planeIndex: 0, format: 'f32-planar' })); + const floats = new Float32Array(allocationSize / Float32Array.BYTES_PER_ELEMENT); + + const channelCount = audioData.numberOfChannels; + const outputSize = audioData.numberOfFrames * channelCount * this.outputSampleSize; + const outputBuffer = new ArrayBuffer(outputSize); + const outputView = new DataView(outputBuffer); + + for (let i = 0; i < channelCount; i++) { + audioData.copyTo(floats, { planeIndex: i, format: 'f32-planar' }); + for (let j = 0; j < floats.length; j++) { + // Write it interleaved... interleavedly? + this.writeOutputValue(outputView, (j * channelCount + i) * this.outputSampleSize, floats[j]!); + } } - await this.muxer!.mutex.currentPromise; // Allow the writer to apply backpressure + const sample = new EncodedAudioSample( + new Uint8Array(outputBuffer), + 'key', + audioData.timestamp / 1e6, + audioData.duration / 1e6, + ); + const meta: EncodedAudioChunkMetadata = { + decoderConfig: { + codec: this.encodingConfig.codec, + numberOfChannels: audioData.numberOfChannels, + sampleRate: audioData.sampleRate, + }, + }; + + this.encodingConfig.onEncodedSample?.(sample, meta); + await this.muxer!.addEncodedAudioSample(this.source._connectedTrack!, sample, meta); // With backpressure } private ensureEncoder(audioData: AudioData) { - if (this.encoder) { + if (this.encoderInitialized) { return; } - this.encoder = new AudioEncoder({ - output: (chunk, meta) => { - this.encodingConfig.onEncodedChunk?.(chunk, meta); - void this.muxer!.addEncodedAudioSample( - this.source._connectedTrack!, - EncodedAudioSample.fromEncodedAudioChunk(chunk), - meta, - ); - }, - error: this.encodingConfig.onEncodingError ?? (error => console.error('AudioEncoder error:', error)), - }); + if ((PCM_CODECS as readonly string[]).includes(this.encodingConfig.codec)) { + this.initPcmEncoder(); + } else { + this.encoder = new AudioEncoder({ + output: (chunk, meta) => { + const sample = EncodedAudioSample.fromEncodedAudioChunk(chunk); - this.encoder.configure({ - codec: buildAudioCodecString(this.encodingConfig.codec, audioData.numberOfChannels, audioData.sampleRate), - numberOfChannels: audioData.numberOfChannels, - sampleRate: audioData.sampleRate, - bitrate: this.encodingConfig.bitrate, - ...getAudioEncoderConfigExtension(this.encodingConfig.codec), - }); + this.encodingConfig.onEncodedSample?.(sample, meta); + void this.muxer!.addEncodedAudioSample(this.source._connectedTrack!, sample, meta); + }, + error: this.encodingConfig.onEncodingError ?? (error => console.error('AudioEncoder error:', error)), + }); + + this.encoder.configure({ + codec: buildAudioCodecString( + this.encodingConfig.codec, + audioData.numberOfChannels, + audioData.sampleRate, + ), + numberOfChannels: audioData.numberOfChannels, + sampleRate: audioData.sampleRate, + bitrate: this.encodingConfig.bitrate, + ...getAudioEncoderConfigExtension(this.encodingConfig.codec), + }); + } assert(this.source._connectedTrack); this.muxer = this.source._connectedTrack.output._muxer; + + this.encoderInitialized = true; + } + + private initPcmEncoder() { + this.isPcmEncoder = true; + + const codec = this.encodingConfig.codec as PcmAudioCodec; + const { dataType, sampleSize, littleEndian } = parsePcmCodec(codec); + + this.outputSampleSize = sampleSize; + + // All these functions receive a float sample as input and map it into the desired format + + switch (sampleSize) { + case 1: { + if (dataType === 'unsigned') { + this.writeOutputValue = (view, byteOffset, value) => + view.setUint8(byteOffset, clamp((value + 1) * 127.5, 0, 255)); + } else if (dataType === 'signed') { + this.writeOutputValue = (view, byteOffset, value) => { + view.setInt8(byteOffset, clamp(Math.round(value * 128), -128, 127)); + }; + } else if (dataType === 'ulaw') { + this.writeOutputValue = (view, byteOffset, value) => { + const int16 = clamp(Math.floor(value * 32767), -32768, 32767); + view.setUint8(byteOffset, toUlaw(int16)); + }; + } else if (dataType === 'alaw') { + this.writeOutputValue = (view, byteOffset, value) => { + const int16 = clamp(Math.floor(value * 32767), -32768, 32767); + view.setUint8(byteOffset, toAlaw(int16)); + }; + } else { + assert(false); + } + }; break; + case 2: { + if (dataType === 'unsigned') { + this.writeOutputValue = (view, byteOffset, value) => + view.setUint16(byteOffset, clamp((value + 1) * 32767.5, 0, 65535), littleEndian); + } else if (dataType === 'signed') { + this.writeOutputValue = (view, byteOffset, value) => + view.setInt16(byteOffset, clamp(Math.round(value * 32767), -32768, 32767), littleEndian); + } else { + assert(false); + } + }; break; + case 3: { + if (dataType === 'unsigned') { + this.writeOutputValue = (view, byteOffset, value) => + setUint24(view, byteOffset, clamp((value + 1) * 8388607.5, 0, 16777215), littleEndian); + } else if (dataType === 'signed') { + this.writeOutputValue = (view, byteOffset, value) => + setInt24( + view, + byteOffset, + clamp(Math.round(value * 8388607), -8388608, 8388607), + littleEndian, + ); + } else { + assert(false); + } + }; break; + case 4: { + if (dataType === 'unsigned') { + this.writeOutputValue = (view, byteOffset, value) => + view.setUint32(byteOffset, clamp((value + 1) * 2147483647.5, 0, 4294967295), littleEndian); + } else if (dataType === 'signed') { + this.writeOutputValue = (view, byteOffset, value) => + view.setInt32( + byteOffset, + clamp(Math.round(value * 2147483647), -2147483648, 2147483647), + littleEndian, + ); + } else if (dataType === 'float') { + this.writeOutputValue = (view, byteOffset, value) => + view.setFloat32(byteOffset, value, littleEndian); + } else { + assert(false); + } + } + } } async flush() { diff --git a/src/misc.ts b/src/misc.ts index a71cdd8..5bfd2b3 100644 --- a/src/misc.ts +++ b/src/misc.ts @@ -269,6 +269,34 @@ export const getInt24 = (view: DataView, byteOffset: number, littleEndian: boole return getUint24(view, byteOffset, littleEndian) << 8 >> 8; }; +export const setUint24 = (view: DataView, byteOffset: number, value: number, littleEndian: boolean) => { + // Ensure the value is within 24-bit unsigned range (0 to 16777215) + value = value >>> 0; // Convert to unsigned 32-bit + value = value & 0xFFFFFF; // Mask to 24 bits + + if (littleEndian) { + view.setUint8(byteOffset, value & 0xFF); + view.setUint8(byteOffset + 1, (value >>> 8) & 0xFF); + view.setUint8(byteOffset + 2, (value >>> 16) & 0xFF); + } else { + view.setUint8(byteOffset, (value >>> 16) & 0xFF); + view.setUint8(byteOffset + 1, (value >>> 8) & 0xFF); + view.setUint8(byteOffset + 2, value & 0xFF); + } +}; + +export const setInt24 = (view: DataView, byteOffset: number, value: number, littleEndian: boolean) => { + // Ensure the value is within 24-bit signed range (-8388608 to 8388607) + value = clamp(value, -8388608, 8388607); + + // Convert negative values to their 24-bit representation + if (value < 0) { + value = (value + 0x1000000) & 0xFFFFFF; + } + + setUint24(view, byteOffset, value, littleEndian); +}; + /** * Calls a function on each value spat out by an async generator. The reason for writing this manually instead of * using a generator function is that the generator function queues return() calls - here, we forward them immediately. @@ -297,3 +325,7 @@ export const mapAsyncGenerator = ( }, }; }; + +export const clamp = (value: number, min: number, max: number) => { + return Math.max(min, Math.min(max, value)); +}; diff --git a/src/pcm.ts b/src/pcm.ts new file mode 100644 index 0000000..b317b74 --- /dev/null +++ b/src/pcm.ts @@ -0,0 +1,120 @@ +// https://github.com/dystopiancode/pcm-g711/blob/master/pcm-g711/g711.c +export const toUlaw = (s16: number) => { + const MULAW_MAX = 0x1FFF; + const MULAW_BIAS = 33; + + let number = s16; + let mask = 0x1000; + let sign = 0; + let position = 12; + let lsb = 0; + + // Handle negative numbers + if (number < 0) { + number = -number; + sign = 0x80; + } + + // Add bias + number += MULAW_BIAS; + + // Clip to maximum + if (number > MULAW_MAX) { + number = MULAW_MAX; + } + + // Find position of first 1 in the number + for (; ((number & mask) !== mask && position >= 5); mask >>= 1, position--) { + // Empty loop body - all work done in condition + } + + // Extract least significant bits + lsb = (number >> (position - 4)) & 0x0f; + + // Combine sign, position and lsb, then invert all bits + return ~(sign | ((position - 5) << 4) | lsb) & 0xFF; +}; + +export const fromUlaw = (u8: number) => { + const MULAW_BIAS = 33; + let sign = 0; + let position = 0; + + // Get byte and invert + let number = ~u8; + + // Handle sign + if (number & 0x80) { + number &= ~(1 << 7); + sign = -1; + } + + // Calculate position + position = ((number & 0xF0) >> 4) + 5; + + // Reconstruct linear value + const decoded = ((1 << position) | ((number & 0x0F) << (position - 4)) + | (1 << (position - 5))) - MULAW_BIAS; + + return (sign === 0) ? decoded : -decoded; +}; + +export const toAlaw = (s16: number) => { + const ALAW_MAX = 0xFFF; + let mask = 0x800; + let sign = 0; + let position = 11; + let lsb = 0; + + let number = s16; + + // Handle negative numbers + if (number < 0) { + number = -number; + sign = 0x80; + } + + // Clip to maximum + if (number > ALAW_MAX) { + number = ALAW_MAX; + } + + // Find position of first 1 in the number + for (; ((number & mask) !== mask && position >= 5); mask >>= 1, position--) { + // Empty loop body - all work done in condition + } + + // Extract least significant bits + lsb = (number >> ((position === 4) ? 1 : (position - 4))) & 0x0f; + + // Combine sign, position and lsb, then XOR with 0x55 + return (sign | ((position - 4) << 4) | lsb) ^ 0x55; +}; + +export const fromAlaw = (u8: number) => { + let sign = 0x00; + let position = 0; + + // Get byte and XOR with 0x55 + let number = u8 ^ 0x55; + + // Handle sign + if (number & 0x80) { + number &= ~(1 << 7); + sign = -1; + } + + // Calculate position + position = ((number & 0xF0) >> 4) + 4; + + // Reconstruct linear value + let decoded = 0; + if (position !== 4) { + decoded = ((1 << position) | ((number & 0x0F) << (position - 4)) + | (1 << (position - 5))); + } else { + decoded = (number << 1) | 1; + } + + return (sign === 0) ? decoded : -decoded; +}; diff --git a/src/target.ts b/src/target.ts index 743a889..5101ef1 100644 --- a/src/target.ts +++ b/src/target.ts @@ -12,7 +12,7 @@ export abstract class Target { /** @public */ export class BufferTarget extends Target { - buffer: Uint8Array | null = null; + buffer: ArrayBuffer | null = null; /** @internal */ _createWriter() { diff --git a/src/writer.ts b/src/writer.ts index 361e469..2bb6301 100644 --- a/src/writer.ts +++ b/src/writer.ts @@ -103,7 +103,7 @@ export class BufferTargetWriter extends Writer { async finalize() { this.ensureSize(this.pos); - this.target.buffer = this.bytes.subarray(0, Math.max(this.maxPos, this.pos)); + this.target.buffer = this.buffer.slice(0, Math.max(this.maxPos, this.pos)); } async close() {}