Implement PCM encoders

This commit is contained in:
Vanilagy
2025-01-05 15:58:20 +01:00
parent 37820f1d4c
commit 2625dc7662
8 changed files with 341 additions and 96 deletions
+1 -1
View File
@@ -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') {
+3 -51
View File
@@ -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<EncodedAudioSample, AudioDat
} else if (dataType === 'signed') {
this.readInputValue = (view, byteOffset) => 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);
}
+178 -39
View File
@@ -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() {
+32
View File
@@ -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 = <T, U>(
},
};
};
export const clamp = (value: number, min: number, max: number) => {
return Math.max(min, Math.min(max, value));
};
+120
View File
@@ -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;
};
+1 -1
View File
@@ -12,7 +12,7 @@ export abstract class Target {
/** @public */
export class BufferTarget extends Target {
buffer: Uint8Array | null = null;
buffer: ArrayBuffer | null = null;
/** @internal */
_createWriter() {
+1 -1
View File
@@ -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() {}