Fix Conversion API process callbacks being called *before* other transformations (fixes #403), restructure Conversion API to make full use of in-source transformations, add AudioSample.trim()

This commit is contained in:
Vanilagy
2026-06-15 18:30:16 +02:00
parent 0da1107858
commit 8b9a1acf16
7 changed files with 369 additions and 238 deletions
+14 -6
View File
@@ -91,13 +91,16 @@
//const startTime = await primaryTrack.getFirstTimestamp();
//console.log(startTime)
let ctx = null;
const canvas = new OffscreenCanvas(1280, 720);
const ctx = canvas.getContext('2d');
//let ctx = null;
let conversion = await Mediabunny.Conversion.init({
input,
output,
audio: {
codec: 'aac',
forceTranscode: true,
//codec: 'aac',
//forceTranscode: true,
//forceTranscode: true,
//sampleFormat: 's16',
},
@@ -126,7 +129,10 @@
},
*/
video: {
discard: true,
process: (sample) => {
sample.draw(ctx, 0, 0, 1280, 720);
return new Mediabunny.VideoSample(canvas, { timestamp: sample.timestamp, duration: sample.duration });
},
},
tags: {} ?? {
title: 'Bigggy',
@@ -146,8 +152,10 @@
}
},
trim: {
start: 300.14984567374756 - 100,
end: 310.1548298151939 - 100,
start: -2,
end: 10,
//start: 300.14984567374756 - 100,
//end: 310.1548298151939 - 100,
//end: 10,
//start: startTime,
//end: startTime + 2,
+108 -215
View File
@@ -14,6 +14,7 @@ import {
VideoCodec,
} from './codec';
import {
AudioEncodingConfig,
getEncodableAudioCodecs,
getFirstEncodableVideoCodec,
Quality,
@@ -50,17 +51,14 @@ import { Output, OutputTrackGroup, TrackType } from './output';
import { Mp4OutputFormat } from './output-format';
import {
AudioSample,
audioSampleToInterleavedFormat,
clampCropRectangle,
CropRectangle,
toInterleavedAudioFormat,
validateCropRectangle,
VideoSample,
VideoSampleResource,
} from './sample';
import { MetadataTags, validateMetadataTags } from './metadata';
import { NullTarget } from './target';
import { AudioResampler } from './resample';
/**
* The options for media file conversion.
@@ -1342,6 +1340,10 @@ export class Conversion {
encodingConfig.transform.frameRate = trackOptions.frameRate;
}
if (trackOptions.process) {
encodingConfig.transform.process = trackOptions.process;
}
if (needsRerender) {
outputTrackRotation = 0; // Since the rotation is baked into the output
@@ -1353,6 +1355,12 @@ export class Conversion {
encodingConfig.transform.alpha = alpha;
}
// We need to do this because `process` can emit new timestamps
let lastSampleTimestamp: number | null = null;
encodingConfig.onEncodedSample = (sample) => {
lastSampleTimestamp = sample.timestamp;
};
const source = new VideoSampleSource(encodingConfig);
videoSource = source;
@@ -1370,7 +1378,14 @@ export class Conversion {
const adjustedSampleTimestamp = Math.max(sample.timestamp - this._startTimestamp, 0);
sample.setTimestamp(adjustedSampleTimestamp);
await this._registerVideoSample(trackOptions, outputTrackId, source, sample);
this._reportProgress(outputTrackId, sample.timestamp + sample.duration);
await source.add(sample);
if (lastSampleTimestamp !== null) {
if (this._synchronizer.shouldWait(outputTrackId, lastSampleTimestamp)) {
await this._synchronizer.wait(lastSampleTimestamp);
}
}
sample.close();
}
@@ -1403,69 +1418,6 @@ export class Conversion {
this._outputOwnTrackGroups.push(ownGroup);
}
/** @internal */
async _registerVideoSample(
trackOptions: ConversionVideoOptions,
outputTrackId: number,
source: VideoSampleSource,
sample: VideoSample,
) {
if (this._canceled) {
return;
}
this._reportProgress(outputTrackId, sample.timestamp + sample.duration);
let finalSamples: VideoSample[];
if (!trackOptions.process) {
finalSamples = [sample];
} else {
let processed = trackOptions.process(sample);
if (processed instanceof Promise) processed = await processed;
if (!Array.isArray(processed)) {
processed = processed === null ? [] : [processed];
}
finalSamples = processed.map((x) => {
if (x instanceof VideoSample) {
return x;
}
if (typeof VideoFrame !== 'undefined' && x instanceof VideoFrame) {
return new VideoSample(x);
}
// Calling the VideoSample constructor here will automatically handle input validation for us
// (it throws for any non-legal argument).
return new VideoSample(x as CanvasImageSource, {
timestamp: sample.timestamp,
duration: sample.duration,
});
});
}
try {
for (const finalSample of finalSamples) {
if (this._canceled) {
break;
}
await source.add(finalSample);
if (this._synchronizer.shouldWait(outputTrackId, finalSample.timestamp)) {
await this._synchronizer.wait(finalSample.timestamp);
}
}
} finally {
for (const finalSample of finalSamples) {
if (finalSample !== sample) {
finalSample.close();
}
}
}
}
/** @internal */
async _processAudioTrack(track: InputAudioTrack, trackOptions: ConversionAudioOptions, outputTrackId: number) {
const sourceCodec = await track.getCodec();
@@ -1487,16 +1439,18 @@ export class Conversion {
let numberOfChannels = trackOptions.numberOfChannels ?? originalNumberOfChannels;
let sampleRate = trackOptions.sampleRate ?? originalSampleRate;
let needsResample = numberOfChannels !== originalNumberOfChannels
|| sampleRate !== originalSampleRate
|| firstTimestamp < this._startTimestamp
|| (firstTimestamp > this._startTimestamp && !this.output.format.supportsTimestampedMediaData);
const needsTrimming = firstTimestamp < this._startTimestamp;
const needsPadding = firstTimestamp > this._startTimestamp && !this.output.format.supportsTimestampedMediaData;
let audioCodecs = this.output.format.getSupportedAudioCodecs();
if (
!trackOptions.forceTranscode
&& !trackOptions.bitrate
&& !needsResample
&& numberOfChannels === originalNumberOfChannels
&& sampleRate === originalSampleRate
&& !needsTrimming
&& !needsPadding
&& audioCodecs.includes(sourceCodec)
&& (!trackOptions.codec || trackOptions.codec === sourceCodec)
&& !trackOptions.process
@@ -1589,7 +1543,6 @@ export class Conversion {
.find(codec => (NON_PCM_AUDIO_CODECS as readonly string[]).includes(codec));
if (nonPcmCodec) {
// We are able to encode using a non-PCM codec, but it'll require resampling
needsResample = true;
codecOfChoice = nonPcmCodec;
numberOfChannels = FALLBACK_NUMBER_OF_CHANNELS;
sampleRate = FALLBACK_SAMPLE_RATE;
@@ -1607,44 +1560,86 @@ export class Conversion {
return;
}
if (needsResample) {
audioSource = this._resampleAudio(
track,
trackOptions,
outputTrackId,
codecOfChoice,
numberOfChannels,
sampleRate,
bitrate,
);
} else {
const source = new AudioSampleSource({
codec: codecOfChoice,
bitrate,
});
audioSource = source;
const encodingConfig: AudioEncodingConfig = {
codec: codecOfChoice,
bitrate,
transform: {
sampleFormat: trackOptions.sampleFormat,
process: trackOptions.process,
},
};
assert(encodingConfig.transform);
this._trackPromises.push((async () => {
await this._started;
if (numberOfChannels !== originalNumberOfChannels) {
encodingConfig.transform.numberOfChannels = numberOfChannels;
}
if (sampleRate !== originalSampleRate) {
encodingConfig.transform.sampleRate = sampleRate;
}
const sink = new AudioSampleSink(track);
for await (const sample of sink.samples(undefined, this._endTimestamp)) {
if (this._canceled) {
sample.close();
return;
}
let lastSampleTimestamp: number | null = null;
encodingConfig.onEncodedSample = (sample) => {
lastSampleTimestamp = sample.timestamp;
};
// Offset the timestamp as needed
sample.setTimestamp(sample.timestamp - this._startTimestamp);
const source = new AudioSampleSource(encodingConfig);
audioSource = source;
await this._registerAudioSample(trackOptions, outputTrackId, source, sample);
this._trackPromises.push((async () => {
await this._started;
if (needsPadding) {
const paddingLength = firstTimestamp - this._startTimestamp;
const paddingLengthSamples = Math.round(paddingLength * originalSampleRate);
const silentSample = new AudioSample({
data: new Float32Array(paddingLengthSamples * originalNumberOfChannels),
format: 'f32-planar',
numberOfChannels: originalNumberOfChannels,
sampleRate: originalSampleRate,
timestamp: 0,
});
await this._registerAudioSample(silentSample, source, outputTrackId, () => lastSampleTimestamp);
}
const sink = new AudioSampleSink(track);
for await (let sample of sink.samples(this._startTimestamp, this._endTimestamp)) {
if (this._canceled) {
sample.close();
return;
}
source.close();
this._synchronizer.closeTrack(outputTrackId);
})());
}
let startFrame = 0;
let endFrame = sample.numberOfFrames;
if (sample.timestamp < this._startTimestamp) {
startFrame = Math.round((this._startTimestamp - sample.timestamp) * sample.sampleRate);
}
if (sample.timestamp + sample.duration > this._endTimestamp) {
endFrame = Math.round((this._endTimestamp - sample.timestamp) * sample.sampleRate);
}
if (startFrame > 0 || endFrame < sample.numberOfFrames) {
// Trim the sample if it sticks out of the trim region on either end
const trimmedSample = sample.trim(startFrame, endFrame);
sample.close();
sample = trimmedSample;
if (sample.numberOfFrames === 0) {
sample.close();
continue;
}
}
// Offset the timestamp as needed
sample.setTimestamp(sample.timestamp - this._startTimestamp);
await this._registerAudioSample(sample, source, outputTrackId, () => lastSampleTimestamp);
}
source.close();
this._synchronizer.closeTrack(outputTrackId);
})());
}
let ownGroup: OutputTrackGroup | null = null;
@@ -1670,126 +1665,24 @@ export class Conversion {
/** @internal */
async _registerAudioSample(
trackOptions: ConversionAudioOptions,
outputTrackId: number,
sample: AudioSample,
source: AudioSampleSource,
inputSample: AudioSample,
outputTrackId: number,
getLastSampleTimestamp: () => number | null,
) {
if (this._canceled) {
return;
}
let sample = inputSample;
if (
trackOptions.sampleFormat !== undefined
&& toInterleavedAudioFormat(sample.format) !== trackOptions.sampleFormat
) {
// Do a sample format conversion
sample = audioSampleToInterleavedFormat(sample, trackOptions.sampleFormat);
}
this._reportProgress(outputTrackId, sample.timestamp + sample.duration);
let finalSamples: AudioSample[];
if (!trackOptions.process) {
finalSamples = [sample];
} else {
let processed = trackOptions.process(sample);
if (processed instanceof Promise) processed = await processed;
await source.add(sample);
sample.close();
if (!Array.isArray(processed)) {
processed = processed === null ? [] : [processed];
}
if (!processed.every(x => x instanceof AudioSample)) {
throw new TypeError(
'The audio process function must return an AudioSample, null, or an array of AudioSamples.',
);
}
finalSamples = processed;
}
try {
for (const finalSample of finalSamples) {
if (this._canceled) {
break;
}
await source.add(finalSample);
if (this._synchronizer.shouldWait(outputTrackId, finalSample.timestamp)) {
await this._synchronizer.wait(finalSample.timestamp);
}
}
} finally {
if (sample !== inputSample) {
sample.close();
}
for (const finalSample of finalSamples) {
if (finalSample !== inputSample) {
finalSample.close();
}
const lastSampleTimestamp = getLastSampleTimestamp();
if (lastSampleTimestamp !== null) {
if (this._synchronizer.shouldWait(outputTrackId, lastSampleTimestamp)) {
await this._synchronizer.wait(lastSampleTimestamp);
}
}
}
/** @internal */
_resampleAudio(
track: InputAudioTrack,
trackOptions: ConversionAudioOptions,
outputTrackId: number,
codec: AudioCodec,
targetNumberOfChannels: number,
targetSampleRate: number,
bitrate: number | Quality,
) {
const source = new AudioSampleSource({
codec,
bitrate,
});
this._trackPromises.push((async () => {
await this._started;
const resampler = new AudioResampler({
targetNumberOfChannels,
targetSampleRate,
startTime: this._startTimestamp,
endTime: this._endTimestamp,
onSample: async (sample) => {
assert(sample.timestamp >= this._startTimestamp);
sample.setTimestamp(sample.timestamp - this._startTimestamp);
await this._registerAudioSample(trackOptions, outputTrackId, source, sample);
sample.close();
},
});
const sink = new AudioSampleSink(track);
const iterator = sink.samples(this._startTimestamp, this._endTimestamp);
for await (const sample of iterator) {
if (this._canceled) {
sample.close();
return;
}
await resampler.add(sample);
sample.close();
}
await resampler.finalize();
source.close();
this._synchronizer.closeTrack(outputTrackId);
})());
return source;
}
/** @internal */
_reportProgress(trackId: number, endTimestamp: number) {
if (!this._computeProgress) {
+10
View File
@@ -74,6 +74,8 @@ export type VideoEncodingConfig = {
* WebCodecs API, is created.
*/
onEncoderConfig?: (config: VideoEncoderConfig) => unknown;
/** Called right before a sample is passed to the encoder. */
onEncodedSample?: (sample: VideoSample) => unknown;
} & VideoEncodingAdditionalOptions;
/**
@@ -232,6 +234,9 @@ export const validateVideoEncodingConfig = (config: VideoEncodingConfig) => {
if (config.onEncoderConfig !== undefined && typeof config.onEncoderConfig !== 'function') {
throw new TypeError('config.onEncoderConfig, when provided, must be a function.');
}
if (config.onEncodedSample !== undefined && typeof config.onEncodedSample !== 'function') {
throw new TypeError('config.onEncodedSample, when provided, must be a function.');
}
validateVideoEncodingAdditionalOptions(config.codec, config);
};
@@ -382,6 +387,8 @@ export type AudioEncodingConfig = {
* WebCodecs API, is created.
*/
onEncoderConfig?: (config: AudioEncoderConfig) => unknown;
/** Called right before a sample is passed to the encoder. */
onEncodedSample?: (sample: AudioSample) => unknown;
} & AudioEncodingAdditionalOptions;
/**
@@ -462,6 +469,9 @@ export const validateAudioEncodingConfig = (config: AudioEncodingConfig) => {
if (config.onEncoderConfig !== undefined && typeof config.onEncoderConfig !== 'function') {
throw new TypeError('config.onEncoderConfig, when provided, must be a function.');
}
if (config.onEncodedSample !== undefined && typeof config.onEncodedSample !== 'function') {
throw new TypeError('config.onEncodedSample, when provided, must be a function.');
}
validateAudioEncodingAdditionalOptions(config.codec, config);
};
+20 -2
View File
@@ -265,6 +265,7 @@ class VideoEncoderWrapper {
* So, we keep track of the encoder error and throw it as soon as we get the chance.
*/
private error: Error | null = null;
private closed = false;
private lastMuxerPromise: Promise<void> = Promise.resolve();
@@ -429,6 +430,8 @@ class VideoEncoderWrapper {
return new VideoSample(x);
}
// Calling the VideoSample constructor here will automatically handle input validation for us
// (it throws for any non-legal argument).
return new VideoSample(x as CanvasImageSource, {
timestamp: videoSample.timestamp,
duration: videoSample.duration,
@@ -457,6 +460,10 @@ class VideoEncoderWrapper {
}
assert(this.encoderInitialized);
if (this.closed) {
break;
}
const keyFrameInterval = this.encodingConfig.keyFrameInterval ?? 2;
const multipleOfKeyFrameInterval = Math.floor(sampleToEncode.timestamp / keyFrameInterval);
@@ -474,6 +481,8 @@ class VideoEncoderWrapper {
};
this.lastMultipleOfKeyFrameInterval = multipleOfKeyFrameInterval;
this.encodingConfig.onEncodedSample?.(sampleToEncode);
if (this.customEncoder) {
this.customEncoderQueueSize++;
@@ -843,6 +852,8 @@ class VideoEncoderWrapper {
await this.padFrameRate(alignedEnd);
}
this.closed = true;
this.frameRateLastSample?.close();
this.frameRateLastSample = null;
@@ -2033,6 +2044,7 @@ class AudioEncoderWrapper {
*/
private error: Error | null = null;
private lastMuxerPromise: Promise<void> = Promise.resolve();
private closed = false;
constructor(private source: AudioSource, private encodingConfig: AudioEncodingConfig) {}
@@ -2070,8 +2082,6 @@ class AudioEncoderWrapper {
?? audioSample.numberOfChannels,
targetSampleRate: config.transform!.sampleRate
?? audioSample.sampleRate,
startTime: audioSample.timestamp,
endTime: Infinity,
onSample: async (sample) => {
await this.processAndEncode(sample, true);
},
@@ -2162,6 +2172,10 @@ class AudioEncoderWrapper {
}
assert(this.encoderInitialized);
if (this.closed) {
return;
}
// Handle padding of gaps with silence to avoid audio drift over time, like in
// https://github.com/Vanilagy/mediabunny/issues/176
// TODO An open question is how encoders deal with the first AudioData having a non-zero timestamp, and with
@@ -2197,6 +2211,8 @@ class AudioEncoderWrapper {
}
}
this.encodingConfig.onEncodedSample?.(audioSample);
if (this.customEncoder) {
this.customEncoderQueueSize++;
@@ -2536,6 +2552,8 @@ class AudioEncoderWrapper {
}
this.resampler = null;
this.closed = true;
if (this.customEncoder) {
if (!forceClose) {
void this.customEncoderCallSerializer.call(() => this.customEncoder!.flush());
+10 -15
View File
@@ -20,41 +20,32 @@ export class AudioResampler {
targetSampleRate: number;
sourceNumberOfChannels: number | null = null;
targetNumberOfChannels: number;
endTime: number;
startTime: number | null = null;
onSample: (sample: AudioSample) => Promise<void>;
bufferSizeInFrames: number;
bufferSizeInSamples: number;
outputBuffer: Float32Array;
/** Start frame of current buffer */
bufferStartFrame: number;
bufferStartFrame = 0;
/** The highest index written to in the current buffer */
maxWrittenFrame: number | null = null;
channelMixer!: (sourceData: Float32Array, sourceFrameIndex: number, targetChannelIndex: number) => number;
tempSourceBuffer!: Float32Array;
timestampOffset: number;
constructor(options: {
targetSampleRate: number;
targetNumberOfChannels: number;
startTime: number;
endTime: number;
onSample: (sample: AudioSample) => Promise<void>;
}) {
this.targetSampleRate = options.targetSampleRate;
this.targetNumberOfChannels = options.targetNumberOfChannels;
this.endTime = options.endTime;
this.onSample = options.onSample;
this.bufferSizeInFrames = Math.floor(this.targetSampleRate * 5.0); // 5 seconds
this.bufferSizeInSamples = this.bufferSizeInFrames * this.targetNumberOfChannels;
this.outputBuffer = new Float32Array(this.bufferSizeInSamples);
this.bufferStartFrame = Math.floor(options.startTime * this.targetSampleRate);
// Set to ensure that if the buffer start frame lands on a fractional sample, that the first timestamp still
// comes out as exactly startTime
this.timestampOffset = options.startTime - this.bufferStartFrame / this.targetSampleRate;
}
/**
@@ -186,6 +177,7 @@ export class AudioResampler {
// they see fit.
this.sourceSampleRate = audioSample.sampleRate;
this.sourceNumberOfChannels = audioSample.numberOfChannels;
this.startTime = audioSample.timestamp;
// Pre-allocate temporary buffer for source data
this.tempSourceBuffer = new Float32Array(this.sourceSampleRate * this.sourceNumberOfChannels);
@@ -193,6 +185,8 @@ export class AudioResampler {
this.doChannelMixerSetup();
}
assert(this.startTime !== null);
const requiredSamples = audioSample.numberOfFrames * audioSample.numberOfChannels;
this.ensureTempBufferSize(requiredSamples);
@@ -201,8 +195,8 @@ export class AudioResampler {
const sourceView = new Float32Array(this.tempSourceBuffer.buffer, 0, sourceDataSize / 4);
audioSample.copyTo(sourceView, { planeIndex: 0, format: 'f32' });
const inputStartTime = audioSample.timestamp;
const inputEndTime = Math.min(audioSample.timestamp + audioSample.duration, this.endTime);
const inputStartTime = audioSample.timestamp - this.startTime;
const inputEndTime = inputStartTime + audioSample.duration;
// Compute which output frames are affected by this sample
const outputStartFrame = Math.floor(inputStartTime * this.targetSampleRate);
@@ -266,17 +260,18 @@ export class AudioResampler {
return; // Nothing to finalize
}
assert(this.startTime !== null);
const samplesWritten = (this.maxWrittenFrame + 1) * this.targetNumberOfChannels;
const outputData = new Float32Array(samplesWritten);
outputData.set(this.outputBuffer.subarray(0, samplesWritten));
const timestampSeconds = this.bufferStartFrame / this.targetSampleRate;
const audioSample = new AudioSample({
format: 'f32',
sampleRate: this.targetSampleRate,
numberOfChannels: this.targetNumberOfChannels,
timestamp: timestampSeconds + this.timestampOffset,
timestamp: this.startTime + this.bufferStartFrame / this.targetSampleRate,
data: outputData,
});
+68
View File
@@ -2712,6 +2712,74 @@ export class AudioSample implements Disposable {
}
}
/**
* Returns a new {@link AudioSample} containing only the frames in the range [startSample, endSample). Both bounds
* must lie within this sample's range of frames. The returned sample's timestamp is shifted to match the start of
* the trimmed section.
*/
trim(startSample: number, endSample = this.numberOfFrames) {
if (!Number.isInteger(startSample) || startSample < 0) {
throw new TypeError('startSample must be a non-negative integer.');
}
if (!Number.isInteger(endSample) || endSample < 0) {
throw new TypeError('endSample must be a non-negative integer.');
}
if (startSample > this.numberOfFrames) {
throw new RangeError('startSample out of range.');
}
if (endSample > this.numberOfFrames) {
throw new RangeError('endSample out of range.');
}
if (endSample < startSample) {
throw new RangeError('endSample must not be less than startSample.');
}
if (this._closed) {
throw new Error('AudioSample is closed.');
}
const frameCount = endSample - startSample;
const bytesPerSample = getBytesPerSample(this.format);
let data: Uint8Array;
if (formatIsPlanar(this.format)) {
const planeSize = frameCount * bytesPerSample;
data = new Uint8Array(planeSize * this.numberOfChannels);
if (frameCount > 0) {
// Copy plane-by-plane
for (let i = 0; i < this.numberOfChannels; i++) {
this.copyTo(data.subarray(i * planeSize, (i + 1) * planeSize), {
planeIndex: i,
format: this.format,
frameOffset: startSample,
frameCount,
});
}
}
} else {
// Trivial
data = new Uint8Array(frameCount * this.numberOfChannels * bytesPerSample);
if (frameCount > 0) {
this.copyTo(data, {
planeIndex: 0,
format: this.format,
frameOffset: startSample,
frameCount,
});
}
}
return new AudioSample({
data,
format: this.format,
sampleRate: this.sampleRate,
numberOfChannels: this.numberOfChannels,
timestamp: this.timestamp + startSample / this.sampleRate,
});
}
/**
* Closes this audio sample, releasing held resources. Audio samples should be closed as soon as they are not
* needed anymore.
+139
View File
@@ -0,0 +1,139 @@
import { expect, test } from 'vitest';
import { AudioSample } from '../../src/sample.js';
const SAMPLE_RATE = 48000;
const NUM_CHANNELS = 2;
const NUM_FRAMES = 10;
const TIMESTAMP = 1;
const cellValue = (frame: number, channel: number) => frame * 10 + channel;
const makeInterleavedSample = () => {
const data = new Int16Array(NUM_FRAMES * NUM_CHANNELS);
for (let f = 0; f < NUM_FRAMES; f++) {
for (let c = 0; c < NUM_CHANNELS; c++) {
data[f * NUM_CHANNELS + c] = cellValue(f, c);
}
}
return new AudioSample({
data,
format: 's16',
sampleRate: SAMPLE_RATE,
numberOfChannels: NUM_CHANNELS,
timestamp: TIMESTAMP,
});
};
const makePlanarSample = () => {
const data = new Float32Array(NUM_FRAMES * NUM_CHANNELS);
for (let c = 0; c < NUM_CHANNELS; c++) {
for (let f = 0; f < NUM_FRAMES; f++) {
data[c * NUM_FRAMES + f] = cellValue(f, c);
}
}
return new AudioSample({
data,
format: 'f32-planar',
sampleRate: SAMPLE_RATE,
numberOfChannels: NUM_CHANNELS,
timestamp: TIMESTAMP,
});
};
const readCell = (sample: AudioSample, frame: number, channel: number) => {
if (sample.format === 's16') {
const out = new Int16Array(sample.numberOfFrames * sample.numberOfChannels);
sample.copyTo(out, { planeIndex: 0, format: 's16' });
return out[frame * sample.numberOfChannels + channel];
} else {
const out = new Float32Array(sample.numberOfFrames);
sample.copyTo(out, { planeIndex: channel, format: 'f32-planar' });
return out[frame];
}
};
for (const [label, makeSample] of [
['interleaved', makeInterleavedSample],
['planar', makePlanarSample],
] as const) {
test(`trim, normal case (${label})`, () => {
using sample = makeSample();
using trimmed = sample.trim(3, 8);
expect(trimmed).not.toBe(sample);
expect(trimmed.format).toBe(sample.format);
expect(trimmed.sampleRate).toBe(SAMPLE_RATE);
expect(trimmed.numberOfChannels).toBe(NUM_CHANNELS);
expect(trimmed.numberOfFrames).toBe(5);
expect(trimmed.duration).toBeCloseTo(5 / SAMPLE_RATE);
expect(trimmed.timestamp).toBeCloseTo(TIMESTAMP + 3 / SAMPLE_RATE);
// The trimmed frame i corresponds to the original frame i + 3
for (let f = 0; f < trimmed.numberOfFrames; f++) {
for (let c = 0; c < NUM_CHANNELS; c++) {
expect(readCell(trimmed, f, c)).toBe(cellValue(f + 3, c));
}
}
// The original sample must be untouched
expect(sample.numberOfFrames).toBe(NUM_FRAMES);
expect(readCell(sample, 3, 0)).toBe(cellValue(3, 0));
});
test(`trim, full range is an independent copy (${label})`, () => {
using sample = makeSample();
using trimmed = sample.trim(0, NUM_FRAMES);
expect(trimmed.numberOfFrames).toBe(NUM_FRAMES);
expect(trimmed.timestamp).toBeCloseTo(TIMESTAMP);
for (let f = 0; f < NUM_FRAMES; f++) {
for (let c = 0; c < NUM_CHANNELS; c++) {
expect(readCell(trimmed, f, c)).toBe(cellValue(f, c));
}
}
});
test(`trim, pathological zero-sample case (${label})`, () => {
using sample = makeSample();
// Empty range in the middle
using empty = sample.trim(4, 4);
expect(empty.numberOfFrames).toBe(0);
expect(empty.duration).toBe(0);
expect(empty.format).toBe(sample.format);
expect(empty.numberOfChannels).toBe(NUM_CHANNELS);
expect(empty.timestamp).toBeCloseTo(TIMESTAMP + 4 / SAMPLE_RATE);
// Empty range right at the end (startSample === numberOfFrames) is still valid
using emptyAtEnd = sample.trim(NUM_FRAMES, NUM_FRAMES);
expect(emptyAtEnd.numberOfFrames).toBe(0);
expect(emptyAtEnd.timestamp).toBeCloseTo(TIMESTAMP + NUM_FRAMES / SAMPLE_RATE);
});
test(`trim, illegal params (${label})`, () => {
using sample = makeSample();
// Non-integer
expect(() => sample.trim(1.5, 5)).toThrow(TypeError);
expect(() => sample.trim(1, 5.5)).toThrow(TypeError);
// Negative
expect(() => sample.trim(-1, 5)).toThrow(TypeError);
expect(() => sample.trim(0, -1)).toThrow(TypeError);
// Out of range
expect(() => sample.trim(0, NUM_FRAMES + 1)).toThrow(RangeError);
expect(() => sample.trim(NUM_FRAMES + 1, NUM_FRAMES + 1)).toThrow(RangeError);
// End before start
expect(() => sample.trim(6, 3)).toThrow(RangeError);
});
}
test('trim, throws on a closed sample', () => {
const sample = makeInterleavedSample();
sample.close();
expect(() => sample.trim(0, 5)).toThrow('AudioSample is closed.');
});