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() {}