Files
mediabunny/src/media-sink.ts
T

1282 lines
37 KiB
TypeScript

import { parsePcmCodec, PCM_AUDIO_CODECS, PcmAudioCodec, VideoCodec, AudioCodec } from './codec';
import { CustomVideoDecoder, customVideoDecoders, CustomAudioDecoder, customAudioDecoders } from './custom-coder';
import { InputAudioTrack, InputTrack, InputVideoTrack } from './input-track';
import {
AnyIterable,
assert,
binarySearchLessOrEqual,
getInt24,
getUint24,
last,
mapAsyncGenerator,
promiseWithResolvers,
toAsyncIterator,
toDataView,
validateAnyIterable,
} from './misc';
import { EncodedPacket } from './packet';
import { fromAlaw, fromUlaw } from './pcm';
/** @public */
export type PacketRetrievalOptions = {
metadataOnly?: boolean;
};
const validatePacketRetrievalOptions = (options: PacketRetrievalOptions) => {
if (!options || typeof options !== 'object') {
throw new TypeError('options must be an object.');
}
if (options.metadataOnly !== undefined && typeof options.metadataOnly !== 'boolean') {
throw new TypeError('options.metadataOnly, when defined, must be a boolean.');
}
};
const validateTimestamp = (timestamp: number) => {
if (typeof timestamp !== 'number' || Number.isNaN(timestamp)) {
throw new TypeError('timestamp must be a number.'); // It can be non-finite, that's fine
}
};
/** @public */
export class EncodedPacketSink {
/** @internal */
_track: InputTrack;
constructor(track: InputTrack) {
if (!(track instanceof InputTrack)) {
throw new TypeError('track must be an InputTrack.');
}
this._track = track;
}
getFirstPacket(options: PacketRetrievalOptions = {}) {
validatePacketRetrievalOptions(options);
return this._track._backing.getFirstPacket(options);
}
getPacket(timestamp: number, options: PacketRetrievalOptions = {}) {
validateTimestamp(timestamp);
validatePacketRetrievalOptions(options);
return this._track._backing.getPacket(timestamp, options);
}
getNextPacket(packet: EncodedPacket, options: PacketRetrievalOptions = {}) {
if (!(packet instanceof EncodedPacket)) {
throw new TypeError('packet must be an EncodedPacket.');
}
validatePacketRetrievalOptions(options);
return this._track._backing.getNextPacket(packet, options);
}
getKeyPacket(timestamp: number, options: PacketRetrievalOptions = {}) {
validateTimestamp(timestamp);
validatePacketRetrievalOptions(options);
return this._track._backing.getKeyPacket(timestamp, options);
}
getNextKeyPacket(packet: EncodedPacket, options: PacketRetrievalOptions = {}) {
if (!(packet instanceof EncodedPacket)) {
throw new TypeError('packet must be an EncodedPacket.');
}
validatePacketRetrievalOptions(options);
return this._track._backing.getNextKeyPacket(packet, options);
}
packets(
startPacket?: EncodedPacket,
endTimestamp = Infinity,
options?: PacketRetrievalOptions,
): AsyncGenerator<EncodedPacket, void, unknown> {
const packetQueue: EncodedPacket[] = [];
let { promise: queueNotEmpty, resolve: onQueueNotEmpty } = promiseWithResolvers();
let { promise: queueDequeue, resolve: onQueueDequeue } = promiseWithResolvers();
let ended = false;
let terminated = false;
// This stores errors that are "out of band" in the sense that they didn't occur in the normal flow of this
// method but instead in a different context. This error should not go unnoticed and must be bubbled up to
// the consumer.
let outOfBandError = null as Error | null;
const timestamps: number[] = [];
// The queue should always be big enough to hold 1 second worth of packets
const maxQueueSize = () => Math.max(2, timestamps.length);
// The following is the "pump" process that keeps pumping packets into the queue
(async () => {
let packet = startPacket ?? await this.getFirstPacket(options);
while (packet && !terminated) {
if (packet.timestamp >= endTimestamp) {
break;
}
if (packetQueue.length > maxQueueSize()) {
({ promise: queueDequeue, resolve: onQueueDequeue } = promiseWithResolvers());
await queueDequeue;
continue;
}
packetQueue.push(packet);
onQueueNotEmpty();
({ promise: queueNotEmpty, resolve: onQueueNotEmpty } = promiseWithResolvers());
packet = await this.getNextPacket(packet, options);
}
ended = true;
onQueueNotEmpty();
})().catch((error: Error) => {
if (!outOfBandError) {
outOfBandError = error;
onQueueNotEmpty();
}
});
return {
async next() {
while (true) {
if (terminated) {
return { value: undefined, done: true };
} else if (outOfBandError) {
throw outOfBandError;
} else if (packetQueue.length > 0) {
const value = packetQueue.shift()!;
const now = performance.now();
timestamps.push(now);
while (timestamps.length > 0 && now - timestamps[0]! >= 1000) {
timestamps.shift();
}
onQueueDequeue();
return { value, done: false };
} else if (ended) {
return { value: undefined, done: true };
} else {
await queueNotEmpty;
}
}
},
async return() {
terminated = true;
onQueueDequeue();
onQueueNotEmpty();
return { value: undefined, done: true };
},
async throw(error) {
throw error;
},
[Symbol.asyncIterator]() {
return this;
},
};
}
}
export type WrappedMediaFrame<T extends VideoFrame | AudioData> = {
frame: T;
timestamp: number;
duration: number;
};
abstract class DecoderWrapper<
MediaFrame extends VideoFrame | AudioData,
WrappedFrame extends WrappedMediaFrame<MediaFrame> = WrappedMediaFrame<MediaFrame>,
> {
constructor(
public onFrame: (frame: WrappedFrame) => unknown,
public onError: (error: DOMException) => unknown,
) {}
abstract getDecodeQueueSize(): number;
abstract decode(packet: EncodedPacket): void;
abstract flush(): Promise<void>;
abstract close(): void;
}
/** @public */
export abstract class BaseMediaFrameSink<
MediaFrame extends VideoFrame | AudioData,
/** @internal */
WrappedFrame extends WrappedMediaFrame<MediaFrame> = WrappedMediaFrame<MediaFrame>,
> {
/** @internal */
abstract _createDecoder(
onFrame: (frame: WrappedFrame) => unknown,
onError: (error: DOMException) => unknown
): Promise<DecoderWrapper<MediaFrame>>;
/** @internal */
abstract _createPacketSink(): EncodedPacketSink;
/** @internal */
private _duplicateFrame(frame: WrappedFrame) {
return structuredClone(frame);
}
/** @internal */
protected mediaFramesInRange(
startTimestamp = 0,
endTimestamp = Infinity,
): AsyncGenerator<WrappedFrame, void, unknown> {
validateTimestamp(startTimestamp);
validateTimestamp(endTimestamp);
const MAX_QUEUE_SIZE = 8;
const frameQueue: WrappedFrame[] = [];
let firstFrameQueued = false;
let lastFrame: WrappedFrame | null = null;
let { promise: queueNotEmpty, resolve: onQueueNotEmpty } = promiseWithResolvers();
let { promise: queueDequeue, resolve: onQueueDequeue } = promiseWithResolvers();
let decoderIsFlushed = false;
let ended = false;
let terminated = false;
// This stores errors that are "out of band" in the sense that they didn't occur in the normal flow of this
// method but instead in a different context. This error should not go unnoticed and must be bubbled up to
// the consumer.
let outOfBandError = null as Error | null;
// The following is the "pump" process that keeps pumping packets into the decoder
(async () => {
const decoderError = new Error();
const decoder = await this._createDecoder((wrappedFrame) => {
onQueueDequeue();
if (wrappedFrame.timestamp >= endTimestamp) {
ended = true;
}
if (ended) {
wrappedFrame.frame.close();
return;
}
if (lastFrame) {
if (wrappedFrame.timestamp > startTimestamp) {
// We don't know ahead of time what the first first is. This is because the first first is the
// last first whose timestamp is less than or equal to the start timestamp. Therefore we need to
// wait for the first first after the start timestamp, and then we'll know that the previous
// first was the first first.
frameQueue.push(lastFrame);
firstFrameQueued = true;
} else {
lastFrame.frame.close();
}
}
if (wrappedFrame.timestamp >= startTimestamp) {
frameQueue.push(wrappedFrame);
firstFrameQueued = true;
}
lastFrame = firstFrameQueued ? null : wrappedFrame;
if (frameQueue.length > 0) {
onQueueNotEmpty();
({ promise: queueNotEmpty, resolve: onQueueNotEmpty } = promiseWithResolvers());
}
}, (error) => {
if (!outOfBandError) {
error.stack = decoderError.stack; // Provide a more useful stack trace
outOfBandError = error;
onQueueNotEmpty();
}
});
const packetSink = this._createPacketSink();
const keyPacket = await packetSink.getKeyPacket(startTimestamp) ?? await packetSink.getFirstPacket();
if (!keyPacket) {
return;
}
let currentPacket: EncodedPacket | null = keyPacket;
let packetsEndTimestamp = Infinity;
if (endTimestamp < Infinity) {
// When an end timestamp is set, we cannot simply use that for the packet iterator due to out-of-order
// frames (B-frames). Instead, we'll need to keep decoding packets until we get a frame that exceeds
// this end time. However, we can still put a bound on it: Since key frames are by definition never
// out of order, we can stop at the first key frame after the end timestamp.
const endPacket = await packetSink.getPacket(endTimestamp);
const endKeyPacket = !endPacket
? null
: endPacket.type === 'key' && endPacket.timestamp === endTimestamp
? endPacket
: await packetSink.getNextKeyPacket(endPacket);
if (endKeyPacket) {
packetsEndTimestamp = endKeyPacket.timestamp;
}
}
const packets = packetSink.packets(keyPacket, packetsEndTimestamp);
await packets.next(); // Skip the start packet as we already have it
while (currentPacket && !ended) {
if (frameQueue.length + decoder.getDecodeQueueSize() > MAX_QUEUE_SIZE) {
({ promise: queueDequeue, resolve: onQueueDequeue } = promiseWithResolvers());
await queueDequeue;
continue;
}
decoder.decode(currentPacket);
const packetResult = await packets.next();
if (packetResult.done) {
break;
}
currentPacket = packetResult.value;
}
await packets.return();
if (!terminated) await decoder.flush();
decoder.close();
if (!firstFrameQueued && lastFrame) {
frameQueue.push(lastFrame);
}
decoderIsFlushed = true;
onQueueNotEmpty(); // To unstuck the generator
})().catch((error: Error) => {
if (!outOfBandError) {
outOfBandError = error;
onQueueNotEmpty();
}
});
return {
async next() {
while (true) {
if (terminated) {
return { value: undefined, done: true };
} else if (outOfBandError) {
throw outOfBandError;
} else if (frameQueue.length > 0) {
const value = frameQueue.shift()!;
onQueueDequeue();
return { value, done: false };
} else if (!decoderIsFlushed) {
await queueNotEmpty;
} else {
return { value: undefined, done: true };
}
}
},
async return() {
terminated = true;
ended = true;
onQueueDequeue();
onQueueNotEmpty();
lastFrame?.frame.close();
for (const frame of frameQueue) {
frame.frame.close();
}
return { value: undefined, done: true };
},
async throw(error) {
throw error;
},
[Symbol.asyncIterator]() {
return this;
},
};
}
/** @internal */
protected mediaFramesAtTimestamps(
timestamps: AnyIterable<number>,
): AsyncGenerator<WrappedFrame | null, void, unknown> {
validateAnyIterable(timestamps);
const timestampIterator = toAsyncIterator(timestamps);
const timestampsOfInterest: number[] = [];
const MAX_QUEUE_SIZE = 8;
const frameQueue: (WrappedFrame | null)[] = [];
let { promise: queueNotEmpty, resolve: onQueueNotEmpty } = promiseWithResolvers();
let { promise: queueDequeue, resolve: onQueueDequeue } = promiseWithResolvers();
let decoderIsFlushed = false;
let terminated = false;
// This stores errors that are "out of band" in the sense that they didn't occur in the normal flow of this
// method but instead in a different context. This error should not go unnoticed and must be bubbled up to
// the consumer.
let outOfBandError = null as Error | null;
let lastUsedFrame = null as WrappedFrame | null;
const pushToQueue = (frame: WrappedFrame | null) => {
frameQueue.push(frame);
onQueueNotEmpty();
({ promise: queueNotEmpty, resolve: onQueueNotEmpty } = promiseWithResolvers());
};
// The following is the "pump" process that keeps pumping packets into the decoder
(async () => {
const decoderError = new Error();
const decoder = await this._createDecoder((wrappedFrame) => {
onQueueDequeue();
if (terminated) {
wrappedFrame.frame.close();
return;
}
let frameUsed = false;
while (
timestampsOfInterest.length > 0
&& wrappedFrame.timestamp - timestampsOfInterest[0]! > -1e-10 // Give it a little epsilon
) {
pushToQueue(this._duplicateFrame(wrappedFrame));
timestampsOfInterest.shift();
frameUsed = true;
}
if (frameUsed) {
lastUsedFrame?.frame.close();
lastUsedFrame = wrappedFrame;
} else {
wrappedFrame.frame.close();
}
}, (error) => {
if (!outOfBandError) {
error.stack = decoderError.stack; // Provide a more useful stack trace
outOfBandError = error;
onQueueNotEmpty();
}
});
const packetSink = this._createPacketSink();
let lastKeyPacket: EncodedPacket | null = null;
let lastPacket: EncodedPacket | null = null;
for await (const timestamp of timestampIterator) {
validateTimestamp(timestamp);
while (frameQueue.length + decoder.getDecodeQueueSize() > MAX_QUEUE_SIZE && !terminated) {
({ promise: queueDequeue, resolve: onQueueDequeue } = promiseWithResolvers());
await queueDequeue;
}
if (terminated) {
break;
}
const targetPacket = await packetSink.getPacket(timestamp);
if (!targetPacket) {
pushToQueue(null);
continue;
}
const keyPacket = await packetSink.getKeyPacket(timestamp);
if (!keyPacket) {
pushToQueue(null);
continue;
}
if (lastPacket && targetPacket.sequenceNumber < lastPacket.sequenceNumber) {
// We're going back in time with this one, let's flush and reset to an clean state
await decoder.flush();
timestampsOfInterest.length = 0;
}
if (
lastKeyPacket
&& keyPacket.sequenceNumber === lastKeyPacket.sequenceNumber
&& targetPacket.timestamp >= lastPacket!.timestamp
) {
assert(lastPacket);
if (
targetPacket.sequenceNumber === lastPacket.sequenceNumber
&& timestampsOfInterest.length === 0
) {
// Special case: We have a repeat packet, but the frame for that packet has already been
// decoded. Therefore, we need to push the frame here instead of in the decoder callback.
if (lastUsedFrame) {
pushToQueue(this._duplicateFrame(lastUsedFrame));
}
} else {
timestampsOfInterest.push(targetPacket.timestamp);
}
} else {
// The key packet has changed
lastKeyPacket = keyPacket;
lastPacket = keyPacket;
decoder.decode(keyPacket);
timestampsOfInterest.push(targetPacket.timestamp);
}
while (lastPacket.sequenceNumber < targetPacket.sequenceNumber) {
const nextPacket = await packetSink.getNextPacket(lastPacket);
assert(nextPacket);
lastPacket = nextPacket;
decoder.decode(nextPacket);
}
}
if (!terminated) {
await decoder.flush();
lastUsedFrame?.frame.close();
}
decoder.close();
decoderIsFlushed = true;
onQueueNotEmpty(); // To unstuck the generator
})().catch((error: Error) => {
if (!outOfBandError) {
outOfBandError = error;
onQueueNotEmpty();
}
});
return {
async next() {
while (true) {
if (terminated) {
return { value: undefined, done: true };
} else if (outOfBandError) {
throw outOfBandError;
} else if (frameQueue.length > 0) {
const value = frameQueue.shift();
assert(value !== undefined);
onQueueDequeue();
return { value, done: false };
} else if (!decoderIsFlushed) {
await queueNotEmpty;
} else {
return { value: undefined, done: true };
}
}
},
async return() {
terminated = true;
onQueueDequeue();
onQueueNotEmpty();
for (const frame of frameQueue) {
frame?.frame.close();
}
lastUsedFrame?.frame.close();
return { value: undefined, done: true };
},
async throw(error) {
throw error;
},
[Symbol.asyncIterator]() {
return this;
},
};
}
}
class VideoDecoderWrapper extends DecoderWrapper<VideoFrame> {
decoder: VideoDecoder | null = null;
customDecoder: CustomVideoDecoder | null = null;
lastCustomDecoderPromise = Promise.resolve();
customDecoderQueueSize = 0;
frameQueue: VideoFrame[] = [];
constructor(
onFrame: (frame: WrappedMediaFrame<VideoFrame>) => unknown,
onError: (error: DOMException) => unknown,
codec: VideoCodec,
decoderConfig: VideoDecoderConfig,
public timeResolution: number,
) {
super(onFrame, onError);
const frameHandler = (frame: VideoFrame) => {
// For correct B-frame handling, we don't just hand over the frames directly but instead add them to a
// queue, because we want to ensure frames are emitted in presentation order. We flush the queue each time
// we receive a frame with a timestamp larger than the highest we've seen so far, as we can sure that is
// not a B-frame. Typically, WebCodecs automatically guarantees that frames are emitted in presentation
// order, but some browsers (Safari) don't always follow this rule.
if (this.frameQueue.length > 0 && (frame.timestamp >= last(this.frameQueue)!.timestamp)) {
for (const frame of this.frameQueue) {
this.wrapAndEmitFrame(frame);
}
this.frameQueue.length = 0;
}
const insertionIndex = binarySearchLessOrEqual(
this.frameQueue,
frame.timestamp,
x => x.timestamp,
);
this.frameQueue.splice(insertionIndex + 1, 0, frame);
};
const MatchingCustomDecoder = customVideoDecoders.find(x => x.supports(codec, decoderConfig));
if (MatchingCustomDecoder) {
// @ts-expect-error "Can't create instance of abstract class 🤓"
this.customDecoder = new MatchingCustomDecoder() as CustomVideoDecoder;
this.customDecoder.codec = codec;
this.customDecoder.config = decoderConfig;
this.customDecoder.onFrame = frameHandler;
this.customDecoder.init();
} else {
this.decoder = new VideoDecoder({
output: frameHandler,
error: onError,
});
this.decoder.configure(decoderConfig);
}
}
wrapAndEmitFrame(frame: VideoFrame) {
// Round the microsecond timestamps to the time resolution
const timestamp = Math.round(frame.timestamp / 1e6 * this.timeResolution) / this.timeResolution;
const duration = Math.round((frame.duration ?? 0) / 1e6 * this.timeResolution) / this.timeResolution;
this.onFrame({
frame,
timestamp,
duration,
});
}
getDecodeQueueSize() {
if (this.customDecoder) {
return this.customDecoderQueueSize;
} else {
assert(this.decoder);
return this.decoder.decodeQueueSize;
}
}
decode(packet: EncodedPacket) {
if (this.customDecoder) {
this.customDecoderQueueSize++;
this.lastCustomDecoderPromise = this.lastCustomDecoderPromise.then(() => {
return this.customDecoder!.decode(packet);
});
void this.lastCustomDecoderPromise.then(() => this.customDecoderQueueSize--);
} else {
assert(this.decoder);
this.decoder.decode(packet.toEncodedVideoChunk());
}
}
async flush() {
if (this.customDecoder) {
await this.lastCustomDecoderPromise.then(() => this.customDecoder!.flush());
} else {
assert(this.decoder);
await this.decoder.flush();
}
for (const frame of this.frameQueue) {
this.wrapAndEmitFrame(frame);
}
this.frameQueue.length = 0;
}
close() {
if (this.customDecoder) {
void this.lastCustomDecoderPromise.then(() => this.customDecoder!.close());
} else {
assert(this.decoder);
this.decoder.close();
}
for (const frame of this.frameQueue) {
frame.close();
}
this.frameQueue.length = 0;
}
}
/** @public */
export type WrappedVideoFrame = {
frame: VideoFrame;
timestamp: number;
duration: number;
};
/** @public */
export class VideoFrameSink extends BaseMediaFrameSink<VideoFrame> {
/** @internal */
_videoTrack: InputVideoTrack;
constructor(videoTrack: InputVideoTrack) {
if (!(videoTrack instanceof InputVideoTrack)) {
throw new TypeError('videoTrack must be an InputVideoTrack.');
}
super();
this._videoTrack = videoTrack;
}
/** @internal */
async _createDecoder(
onFrame: (frame: WrappedMediaFrame<VideoFrame>) => unknown,
onError: (error: DOMException) => unknown,
) {
if (!(await this._videoTrack.canDecode())) {
throw new Error(
'This video track cannot be decoded by this browser. Make sure to check decodability before using'
+ ' a track.',
);
}
const codec = this._videoTrack.codec;
const decoderConfig = await this._videoTrack.getDecoderConfig();
const timeResolution = this._videoTrack.timeResolution;
assert(codec && decoderConfig);
return new VideoDecoderWrapper(onFrame, onError, codec, decoderConfig, timeResolution);
}
/** @internal */
_createPacketSink() {
return new EncodedPacketSink(this._videoTrack);
}
/** @internal */
_wrappedFrameToWrappedVideoFrame(frame: WrappedMediaFrame<VideoFrame>): WrappedVideoFrame {
return {
frame: frame.frame,
timestamp: frame.timestamp,
duration: frame.duration,
};
}
async getFrame(timestamp: number) {
validateTimestamp(timestamp);
for await (const frame of this.mediaFramesAtTimestamps([timestamp])) {
return frame && this._wrappedFrameToWrappedVideoFrame(frame);
}
throw new Error('Internal error: Iterator returned nothing.');
}
frames(startTimestamp = 0, endTimestamp = Infinity) {
return mapAsyncGenerator(
this.mediaFramesInRange(startTimestamp, endTimestamp),
frame => this._wrappedFrameToWrappedVideoFrame(frame),
);
}
framesAtTimestamps(timestamps: AnyIterable<number>) {
return mapAsyncGenerator(
this.mediaFramesAtTimestamps(timestamps),
frame => frame && this._wrappedFrameToWrappedVideoFrame(frame),
);
}
}
/** @public */
export type WrappedCanvas = {
canvas: HTMLCanvasElement;
timestamp: number;
duration: number;
};
/** @public */
export class CanvasSink {
/** @internal */
_videoTrack: InputVideoTrack;
/** @internal */
_dimensions?: { width: number; height: number };
/** @internal */
_videoFrameSink: VideoFrameSink;
constructor(videoTrack: InputVideoTrack, dimensions?: { width: number; height: number }) {
if (!(videoTrack instanceof InputVideoTrack)) {
throw new TypeError('videoTrack must be an InputVideoTrack.');
}
if (dimensions && typeof dimensions !== 'object') {
throw new TypeError('dimensions, when defined, must be an object.');
}
if (dimensions && (!Number.isInteger(dimensions.width) || dimensions.width <= 0)) {
throw new TypeError('dimensions.width must be a positive integer.');
}
if (dimensions && (!Number.isInteger(dimensions.height) || dimensions.height <= 0)) {
throw new TypeError('dimensions.height must be a positive integer.');
}
this._videoTrack = videoTrack;
this._dimensions = dimensions;
this._videoFrameSink = new VideoFrameSink(videoTrack);
}
/** @internal */
_videoFrameToWrappedCanvas(frame: WrappedVideoFrame): WrappedCanvas {
const width = this._dimensions?.width ?? this._videoTrack.displayWidth;
const height = this._dimensions?.height ?? this._videoTrack.displayHeight;
const rotation = this._videoTrack.rotation;
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const context = canvas.getContext('2d', { alpha: false });
assert(context);
context.translate(width / 2, height / 2);
context.rotate(rotation * Math.PI / 180);
context.translate(-width / 2, -height / 2);
const [imageWidth, imageHeight] = rotation % 180 === 0 ? [width, height] : [height, width];
context.drawImage(frame.frame, (width - imageWidth) / 2, (height - imageHeight) / 2, imageWidth, imageHeight);
const result = {
canvas,
timestamp: frame.timestamp,
duration: frame.duration,
};
frame.frame.close();
return result;
}
async getCanvas(timestamp: number) {
validateTimestamp(timestamp);
const frame = await this._videoFrameSink.getFrame(timestamp);
return frame && this._videoFrameToWrappedCanvas(frame);
}
canvases(startTimestamp = 0, endTimestamp = Infinity) {
return mapAsyncGenerator(
this._videoFrameSink.frames(startTimestamp, endTimestamp),
frame => this._videoFrameToWrappedCanvas(frame),
);
}
canvasesAtTimestamps(timestamps: AnyIterable<number>) {
return mapAsyncGenerator(
this._videoFrameSink.framesAtTimestamps(timestamps),
frame => frame && this._videoFrameToWrappedCanvas(frame),
);
}
}
class AudioDecoderWrapper extends DecoderWrapper<AudioData> {
decoder: AudioDecoder | null = null;
customDecoder: CustomAudioDecoder | null = null;
lastCustomDecoderPromise = Promise.resolve();
customDecoderQueueSize = 0;
constructor(
onData: (data: WrappedMediaFrame<AudioData>) => unknown,
onError: (error: DOMException) => unknown,
codec: AudioCodec,
decoderConfig: AudioDecoderConfig,
) {
super(onData, onError);
const dataHandler = (data: AudioData) => {
const sampleRate = decoderConfig.sampleRate;
// Round the microsecond timestamps to the sample rate
const timestamp = Math.round(data.timestamp / 1e6 * sampleRate) / sampleRate;
const duration = Math.round(data.duration / 1e6 * sampleRate) / sampleRate;
onData({
frame: data,
timestamp,
duration,
});
};
const MatchingCustomDecoder = customAudioDecoders.find(x => x.supports(codec, decoderConfig));
if (MatchingCustomDecoder) {
// @ts-expect-error "Can't create instance of abstract class 🤓"
this.customDecoder = new MatchingCustomDecoder() as CustomAudioDecoder;
this.customDecoder.codec = codec;
this.customDecoder.config = decoderConfig;
this.customDecoder.onData = dataHandler;
this.customDecoder.init();
} else {
this.decoder = new AudioDecoder({
output: dataHandler,
error: onError,
});
this.decoder.configure(decoderConfig);
}
}
getDecodeQueueSize() {
if (this.customDecoder) {
return this.customDecoderQueueSize;
} else {
assert(this.decoder);
return this.decoder.decodeQueueSize;
}
}
decode(packet: EncodedPacket) {
if (this.customDecoder) {
this.customDecoderQueueSize++;
this.lastCustomDecoderPromise = this.lastCustomDecoderPromise.then(() => {
return this.customDecoder!.decode(packet);
});
void this.lastCustomDecoderPromise.then(() => this.customDecoderQueueSize--);
} else {
assert(this.decoder);
this.decoder.decode(packet.toEncodedAudioChunk());
}
}
flush() {
if (this.customDecoder) {
return this.lastCustomDecoderPromise.then(() => this.customDecoder!.flush());
} else {
assert(this.decoder);
return this.decoder.flush();
}
}
close() {
if (this.customDecoder) {
void this.lastCustomDecoderPromise.then(() => this.customDecoder!.close());
} else {
assert(this.decoder);
this.decoder.close();
}
}
}
// There are a lot of PCM variants not natively supported by the browser and by AudioData. Therefore we need a simple
// decoder that maps any input PCM format into a PCM format supported by the browser.
class PcmAudioDecoderWrapper extends DecoderWrapper<AudioData> {
codec: PcmAudioCodec;
inputSampleSize: 1 | 2 | 3 | 4;
readInputValue: (view: DataView, byteOffset: number) => number;
outputSampleSize: 1 | 2 | 4;
outputFormat: 'u8' | 's16' | 's32' | 'f32';
writeOutputValue: (view: DataView, byteOffset: number, value: number) => void;
// Internal state to accumulate a precise current timestamp based on audio durations, not the (potentially
// inaccurate) sample timestamps.
currentTimestamp: number | null = null;
constructor(
onData: (data: WrappedMediaFrame<AudioData>) => unknown,
onError: (error: DOMException) => unknown,
public decoderConfig: AudioDecoderConfig,
) {
super(onData, onError);
assert((PCM_AUDIO_CODECS as readonly string[]).includes(decoderConfig.codec));
this.codec = decoderConfig.codec as PcmAudioCodec;
const { dataType, sampleSize, littleEndian } = parsePcmCodec(this.codec);
this.inputSampleSize = sampleSize;
switch (sampleSize) {
case 1: {
if (dataType === 'unsigned') {
this.readInputValue = (view, byteOffset) => view.getUint8(byteOffset) - 2 ** 7;
} else if (dataType === 'signed') {
this.readInputValue = (view, byteOffset) => view.getInt8(byteOffset);
} else if (dataType === 'ulaw') {
this.readInputValue = (view, byteOffset) => fromUlaw(view.getUint8(byteOffset));
} else if (dataType === 'alaw') {
this.readInputValue = (view, byteOffset) => fromAlaw(view.getUint8(byteOffset));
} else {
assert(false);
}
}; break;
case 2: {
if (dataType === 'unsigned') {
this.readInputValue = (view, byteOffset) => view.getUint16(byteOffset, littleEndian) - 2 ** 15;
} else if (dataType === 'signed') {
this.readInputValue = (view, byteOffset) => view.getInt16(byteOffset, littleEndian);
} else {
assert(false);
}
}; break;
case 3: {
if (dataType === 'unsigned') {
this.readInputValue = (view, byteOffset) => getUint24(view, byteOffset, littleEndian) - 2 ** 23;
} else if (dataType === 'signed') {
this.readInputValue = (view, byteOffset) => getInt24(view, byteOffset, littleEndian);
} else {
assert(false);
}
}; break;
case 4: {
if (dataType === 'unsigned') {
this.readInputValue = (view, byteOffset) => view.getUint32(byteOffset, littleEndian) - 2 ** 31;
} else if (dataType === 'signed') {
this.readInputValue = (view, byteOffset) => view.getInt32(byteOffset, littleEndian);
} else if (dataType === 'float') {
this.readInputValue = (view, byteOffset) => view.getFloat32(byteOffset, littleEndian);
} else {
assert(false);
}
}; break;
}
switch (sampleSize) {
case 1: {
if (dataType === 'ulaw' || dataType === 'alaw') {
this.outputSampleSize = 2;
this.outputFormat = 's16';
this.writeOutputValue = (view, byteOffset, value) => view.setInt16(byteOffset, value, true);
} else {
this.outputSampleSize = 1;
this.outputFormat = 'u8';
this.writeOutputValue = (view, byteOffset, value) => view.setUint8(byteOffset, value + 2 ** 7);
}
}; break;
case 2: {
this.outputSampleSize = 2;
this.outputFormat = 's16';
this.writeOutputValue = (view, byteOffset, value) => view.setInt16(byteOffset, value, true);
}; break;
case 3: {
this.outputSampleSize = 4;
this.outputFormat = 's32';
// From https://www.w3.org/TR/webcodecs:
// AudioData containing 24-bit samples SHOULD store those samples in s32 or f32. When samples are
// stored in s32, each sample MUST be left-shifted by 8 bits.
this.writeOutputValue = (view, byteOffset, value) => view.setInt32(byteOffset, value << 8, true);
}; break;
case 4: {
this.outputSampleSize = 4;
if (dataType === 'float') {
this.outputFormat = 'f32';
this.writeOutputValue = (view, byteOffset, value) => view.setFloat32(byteOffset, value, true);
} else {
this.outputFormat = 's32';
this.writeOutputValue = (view, byteOffset, value) => view.setInt32(byteOffset, value, true);
}
}; break;
};
}
getDecodeQueueSize() {
return 0;
}
decode(packet: EncodedPacket) {
const inputView = toDataView(packet.data);
const numberOfFrames = packet.byteLength / this.decoderConfig.numberOfChannels / this.inputSampleSize;
const outputBufferSize = numberOfFrames * this.decoderConfig.numberOfChannels * this.outputSampleSize;
const outputBuffer = new ArrayBuffer(outputBufferSize);
const outputView = new DataView(outputBuffer);
for (let i = 0; i < numberOfFrames * this.decoderConfig.numberOfChannels; i++) {
const inputIndex = i * this.inputSampleSize;
const outputIndex = i * this.outputSampleSize;
const value = this.readInputValue(inputView, inputIndex);
this.writeOutputValue(outputView, outputIndex, value);
}
const preciseDuration = numberOfFrames / this.decoderConfig.sampleRate;
if (this.currentTimestamp === null || Math.abs(packet.timestamp - this.currentTimestamp) >= preciseDuration) {
// We need to sync with the packet timestamp again
this.currentTimestamp = packet.timestamp;
}
const preciseTimestamp = this.currentTimestamp;
this.currentTimestamp += preciseDuration;
const audioData = new AudioData({
format: this.outputFormat,
data: outputBuffer,
numberOfChannels: this.decoderConfig.numberOfChannels,
sampleRate: this.decoderConfig.sampleRate,
numberOfFrames,
timestamp: 1e6 * preciseTimestamp,
});
// Since all other decoders are async, we'll make this one behave async as well
queueMicrotask(() => this.onFrame({
frame: audioData,
timestamp: preciseTimestamp,
duration: preciseDuration,
}));
}
async flush() {
// Do nothing
}
close() {
// Do nothing
}
}
/** @public */
export type WrappedAudioData = {
data: AudioData;
timestamp: number;
duration: number;
};
/** @public */
export class AudioDataSink extends BaseMediaFrameSink<AudioData> {
/** @internal */
_audioTrack: InputAudioTrack;
constructor(audioTrack: InputAudioTrack) {
if (!(audioTrack instanceof InputAudioTrack)) {
throw new TypeError('audioTrack must be an InputAudioTrack.');
}
super();
this._audioTrack = audioTrack;
}
/** @internal */
async _createDecoder(
onData: (data: WrappedMediaFrame<AudioData>) => unknown,
onError: (error: DOMException) => unknown,
) {
if (!(await this._audioTrack.canDecode())) {
throw new Error(
'This audio track cannot be decoded by this browser. Make sure to check decodability before using'
+ ' a track.',
);
}
const codec = this._audioTrack.codec;
const decoderConfig = await this._audioTrack.getDecoderConfig();
assert(codec && decoderConfig);
if ((PCM_AUDIO_CODECS as readonly string[]).includes(decoderConfig.codec)) {
return new PcmAudioDecoderWrapper(onData, onError, decoderConfig);
} else {
return new AudioDecoderWrapper(onData, onError, codec, decoderConfig);
}
}
/** @internal */
_wrappedFrameToWrappedAudioData(frame: WrappedMediaFrame<AudioData>): WrappedAudioData {
return {
data: frame.frame,
timestamp: frame.timestamp,
duration: frame.duration,
};
}
/** @internal */
_createPacketSink() {
return new EncodedPacketSink(this._audioTrack);
}
async getData(timestamp: number) {
validateTimestamp(timestamp);
for await (const data of this.mediaFramesAtTimestamps([timestamp])) {
return data && this._wrappedFrameToWrappedAudioData(data);
}
throw new Error('Internal error: Iterator returned nothing.');
}
data(startTimestamp = 0, endTimestamp = Infinity) {
return mapAsyncGenerator(
this.mediaFramesInRange(startTimestamp, endTimestamp),
data => this._wrappedFrameToWrappedAudioData(data),
);
}
dataAtTimestamps(timestamps: AnyIterable<number>) {
return mapAsyncGenerator(
this.mediaFramesAtTimestamps(timestamps),
data => data && this._wrappedFrameToWrappedAudioData(data),
);
}
}
/** @public */
export type WrappedAudioBuffer = {
buffer: AudioBuffer;
timestamp: number;
duration: number;
};
/** @public */
export class AudioBufferSink {
/** @internal */
_audioDataSink: AudioDataSink;
constructor(audioTrack: InputAudioTrack) {
if (!(audioTrack instanceof InputAudioTrack)) {
throw new TypeError('audioTrack must be an InputAudioTrack.');
}
this._audioDataSink = new AudioDataSink(audioTrack);
}
/** @internal */
_audioDataToWrappedArrayBuffer(data: WrappedAudioData): WrappedAudioBuffer {
const audioBuffer = new AudioBuffer({
numberOfChannels: data.data.numberOfChannels,
length: data.data.numberOfFrames,
sampleRate: data.data.sampleRate,
});
// All user agents are required to support conversion to f32-planar
const dataBytes = new Float32Array(data.data.allocationSize({ planeIndex: 0, format: 'f32-planar' }) / 4);
for (let i = 0; i < data.data.numberOfChannels; i++) {
data.data.copyTo(dataBytes, { planeIndex: i, format: 'f32-planar' });
audioBuffer.copyToChannel(dataBytes, i);
}
const result = {
buffer: audioBuffer,
timestamp: data.timestamp,
duration: data.duration,
};
data.data.close();
return result;
}
async getBuffer(timestamp: number) {
validateTimestamp(timestamp);
const data = await this._audioDataSink.getData(timestamp);
return data && this._audioDataToWrappedArrayBuffer(data);
}
buffers(startTimestamp = 0, endTimestamp = Infinity) {
return mapAsyncGenerator(
this._audioDataSink.data(startTimestamp, endTimestamp),
data => this._audioDataToWrappedArrayBuffer(data),
);
}
buffersAtTimestamps(timestamps: AnyIterable<number>) {
return mapAsyncGenerator(
this._audioDataSink.dataAtTimestamps(timestamps),
data => data && this._audioDataToWrappedArrayBuffer(data),
);
}
}