mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-10-03 05:43:50 +02:00
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:
+14
-6
@@ -91,13 +91,16 @@
|
|||||||
//const startTime = await primaryTrack.getFirstTimestamp();
|
//const startTime = await primaryTrack.getFirstTimestamp();
|
||||||
//console.log(startTime)
|
//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({
|
let conversion = await Mediabunny.Conversion.init({
|
||||||
input,
|
input,
|
||||||
output,
|
output,
|
||||||
audio: {
|
audio: {
|
||||||
codec: 'aac',
|
//codec: 'aac',
|
||||||
forceTranscode: true,
|
//forceTranscode: true,
|
||||||
//forceTranscode: true,
|
//forceTranscode: true,
|
||||||
//sampleFormat: 's16',
|
//sampleFormat: 's16',
|
||||||
},
|
},
|
||||||
@@ -126,7 +129,10 @@
|
|||||||
},
|
},
|
||||||
*/
|
*/
|
||||||
video: {
|
video: {
|
||||||
discard: true,
|
process: (sample) => {
|
||||||
|
sample.draw(ctx, 0, 0, 1280, 720);
|
||||||
|
return new Mediabunny.VideoSample(canvas, { timestamp: sample.timestamp, duration: sample.duration });
|
||||||
|
},
|
||||||
},
|
},
|
||||||
tags: {} ?? {
|
tags: {} ?? {
|
||||||
title: 'Bigggy',
|
title: 'Bigggy',
|
||||||
@@ -146,8 +152,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
trim: {
|
trim: {
|
||||||
start: 300.14984567374756 - 100,
|
start: -2,
|
||||||
end: 310.1548298151939 - 100,
|
end: 10,
|
||||||
|
//start: 300.14984567374756 - 100,
|
||||||
|
//end: 310.1548298151939 - 100,
|
||||||
//end: 10,
|
//end: 10,
|
||||||
//start: startTime,
|
//start: startTime,
|
||||||
//end: startTime + 2,
|
//end: startTime + 2,
|
||||||
|
|||||||
+108
-215
@@ -14,6 +14,7 @@ import {
|
|||||||
VideoCodec,
|
VideoCodec,
|
||||||
} from './codec';
|
} from './codec';
|
||||||
import {
|
import {
|
||||||
|
AudioEncodingConfig,
|
||||||
getEncodableAudioCodecs,
|
getEncodableAudioCodecs,
|
||||||
getFirstEncodableVideoCodec,
|
getFirstEncodableVideoCodec,
|
||||||
Quality,
|
Quality,
|
||||||
@@ -50,17 +51,14 @@ import { Output, OutputTrackGroup, TrackType } from './output';
|
|||||||
import { Mp4OutputFormat } from './output-format';
|
import { Mp4OutputFormat } from './output-format';
|
||||||
import {
|
import {
|
||||||
AudioSample,
|
AudioSample,
|
||||||
audioSampleToInterleavedFormat,
|
|
||||||
clampCropRectangle,
|
clampCropRectangle,
|
||||||
CropRectangle,
|
CropRectangle,
|
||||||
toInterleavedAudioFormat,
|
|
||||||
validateCropRectangle,
|
validateCropRectangle,
|
||||||
VideoSample,
|
VideoSample,
|
||||||
VideoSampleResource,
|
VideoSampleResource,
|
||||||
} from './sample';
|
} from './sample';
|
||||||
import { MetadataTags, validateMetadataTags } from './metadata';
|
import { MetadataTags, validateMetadataTags } from './metadata';
|
||||||
import { NullTarget } from './target';
|
import { NullTarget } from './target';
|
||||||
import { AudioResampler } from './resample';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The options for media file conversion.
|
* The options for media file conversion.
|
||||||
@@ -1342,6 +1340,10 @@ export class Conversion {
|
|||||||
encodingConfig.transform.frameRate = trackOptions.frameRate;
|
encodingConfig.transform.frameRate = trackOptions.frameRate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (trackOptions.process) {
|
||||||
|
encodingConfig.transform.process = trackOptions.process;
|
||||||
|
}
|
||||||
|
|
||||||
if (needsRerender) {
|
if (needsRerender) {
|
||||||
outputTrackRotation = 0; // Since the rotation is baked into the output
|
outputTrackRotation = 0; // Since the rotation is baked into the output
|
||||||
|
|
||||||
@@ -1353,6 +1355,12 @@ export class Conversion {
|
|||||||
encodingConfig.transform.alpha = alpha;
|
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);
|
const source = new VideoSampleSource(encodingConfig);
|
||||||
videoSource = source;
|
videoSource = source;
|
||||||
|
|
||||||
@@ -1370,7 +1378,14 @@ export class Conversion {
|
|||||||
const adjustedSampleTimestamp = Math.max(sample.timestamp - this._startTimestamp, 0);
|
const adjustedSampleTimestamp = Math.max(sample.timestamp - this._startTimestamp, 0);
|
||||||
sample.setTimestamp(adjustedSampleTimestamp);
|
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();
|
sample.close();
|
||||||
}
|
}
|
||||||
@@ -1403,69 +1418,6 @@ export class Conversion {
|
|||||||
this._outputOwnTrackGroups.push(ownGroup);
|
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 */
|
/** @internal */
|
||||||
async _processAudioTrack(track: InputAudioTrack, trackOptions: ConversionAudioOptions, outputTrackId: number) {
|
async _processAudioTrack(track: InputAudioTrack, trackOptions: ConversionAudioOptions, outputTrackId: number) {
|
||||||
const sourceCodec = await track.getCodec();
|
const sourceCodec = await track.getCodec();
|
||||||
@@ -1487,16 +1439,18 @@ export class Conversion {
|
|||||||
|
|
||||||
let numberOfChannels = trackOptions.numberOfChannels ?? originalNumberOfChannels;
|
let numberOfChannels = trackOptions.numberOfChannels ?? originalNumberOfChannels;
|
||||||
let sampleRate = trackOptions.sampleRate ?? originalSampleRate;
|
let sampleRate = trackOptions.sampleRate ?? originalSampleRate;
|
||||||
let needsResample = numberOfChannels !== originalNumberOfChannels
|
|
||||||
|| sampleRate !== originalSampleRate
|
const needsTrimming = firstTimestamp < this._startTimestamp;
|
||||||
|| firstTimestamp < this._startTimestamp
|
const needsPadding = firstTimestamp > this._startTimestamp && !this.output.format.supportsTimestampedMediaData;
|
||||||
|| (firstTimestamp > this._startTimestamp && !this.output.format.supportsTimestampedMediaData);
|
|
||||||
|
|
||||||
let audioCodecs = this.output.format.getSupportedAudioCodecs();
|
let audioCodecs = this.output.format.getSupportedAudioCodecs();
|
||||||
if (
|
if (
|
||||||
!trackOptions.forceTranscode
|
!trackOptions.forceTranscode
|
||||||
&& !trackOptions.bitrate
|
&& !trackOptions.bitrate
|
||||||
&& !needsResample
|
&& numberOfChannels === originalNumberOfChannels
|
||||||
|
&& sampleRate === originalSampleRate
|
||||||
|
&& !needsTrimming
|
||||||
|
&& !needsPadding
|
||||||
&& audioCodecs.includes(sourceCodec)
|
&& audioCodecs.includes(sourceCodec)
|
||||||
&& (!trackOptions.codec || trackOptions.codec === sourceCodec)
|
&& (!trackOptions.codec || trackOptions.codec === sourceCodec)
|
||||||
&& !trackOptions.process
|
&& !trackOptions.process
|
||||||
@@ -1589,7 +1543,6 @@ export class Conversion {
|
|||||||
.find(codec => (NON_PCM_AUDIO_CODECS as readonly string[]).includes(codec));
|
.find(codec => (NON_PCM_AUDIO_CODECS as readonly string[]).includes(codec));
|
||||||
if (nonPcmCodec) {
|
if (nonPcmCodec) {
|
||||||
// We are able to encode using a non-PCM codec, but it'll require resampling
|
// We are able to encode using a non-PCM codec, but it'll require resampling
|
||||||
needsResample = true;
|
|
||||||
codecOfChoice = nonPcmCodec;
|
codecOfChoice = nonPcmCodec;
|
||||||
numberOfChannels = FALLBACK_NUMBER_OF_CHANNELS;
|
numberOfChannels = FALLBACK_NUMBER_OF_CHANNELS;
|
||||||
sampleRate = FALLBACK_SAMPLE_RATE;
|
sampleRate = FALLBACK_SAMPLE_RATE;
|
||||||
@@ -1607,44 +1560,86 @@ export class Conversion {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (needsResample) {
|
const encodingConfig: AudioEncodingConfig = {
|
||||||
audioSource = this._resampleAudio(
|
codec: codecOfChoice,
|
||||||
track,
|
bitrate,
|
||||||
trackOptions,
|
transform: {
|
||||||
outputTrackId,
|
sampleFormat: trackOptions.sampleFormat,
|
||||||
codecOfChoice,
|
process: trackOptions.process,
|
||||||
numberOfChannels,
|
},
|
||||||
sampleRate,
|
};
|
||||||
bitrate,
|
assert(encodingConfig.transform);
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const source = new AudioSampleSource({
|
|
||||||
codec: codecOfChoice,
|
|
||||||
bitrate,
|
|
||||||
});
|
|
||||||
audioSource = source;
|
|
||||||
|
|
||||||
this._trackPromises.push((async () => {
|
if (numberOfChannels !== originalNumberOfChannels) {
|
||||||
await this._started;
|
encodingConfig.transform.numberOfChannels = numberOfChannels;
|
||||||
|
}
|
||||||
|
if (sampleRate !== originalSampleRate) {
|
||||||
|
encodingConfig.transform.sampleRate = sampleRate;
|
||||||
|
}
|
||||||
|
|
||||||
const sink = new AudioSampleSink(track);
|
let lastSampleTimestamp: number | null = null;
|
||||||
for await (const sample of sink.samples(undefined, this._endTimestamp)) {
|
encodingConfig.onEncodedSample = (sample) => {
|
||||||
if (this._canceled) {
|
lastSampleTimestamp = sample.timestamp;
|
||||||
sample.close();
|
};
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Offset the timestamp as needed
|
const source = new AudioSampleSource(encodingConfig);
|
||||||
sample.setTimestamp(sample.timestamp - this._startTimestamp);
|
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();
|
sample.close();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
source.close();
|
let startFrame = 0;
|
||||||
this._synchronizer.closeTrack(outputTrackId);
|
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;
|
let ownGroup: OutputTrackGroup | null = null;
|
||||||
@@ -1670,126 +1665,24 @@ export class Conversion {
|
|||||||
|
|
||||||
/** @internal */
|
/** @internal */
|
||||||
async _registerAudioSample(
|
async _registerAudioSample(
|
||||||
trackOptions: ConversionAudioOptions,
|
sample: AudioSample,
|
||||||
outputTrackId: number,
|
|
||||||
source: AudioSampleSource,
|
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);
|
this._reportProgress(outputTrackId, sample.timestamp + sample.duration);
|
||||||
|
|
||||||
let finalSamples: AudioSample[];
|
await source.add(sample);
|
||||||
if (!trackOptions.process) {
|
sample.close();
|
||||||
finalSamples = [sample];
|
|
||||||
} else {
|
|
||||||
let processed = trackOptions.process(sample);
|
|
||||||
if (processed instanceof Promise) processed = await processed;
|
|
||||||
|
|
||||||
if (!Array.isArray(processed)) {
|
const lastSampleTimestamp = getLastSampleTimestamp();
|
||||||
processed = processed === null ? [] : [processed];
|
if (lastSampleTimestamp !== null) {
|
||||||
}
|
if (this._synchronizer.shouldWait(outputTrackId, lastSampleTimestamp)) {
|
||||||
|
await this._synchronizer.wait(lastSampleTimestamp);
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @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 */
|
/** @internal */
|
||||||
_reportProgress(trackId: number, endTimestamp: number) {
|
_reportProgress(trackId: number, endTimestamp: number) {
|
||||||
if (!this._computeProgress) {
|
if (!this._computeProgress) {
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ export type VideoEncodingConfig = {
|
|||||||
* WebCodecs API, is created.
|
* WebCodecs API, is created.
|
||||||
*/
|
*/
|
||||||
onEncoderConfig?: (config: VideoEncoderConfig) => unknown;
|
onEncoderConfig?: (config: VideoEncoderConfig) => unknown;
|
||||||
|
/** Called right before a sample is passed to the encoder. */
|
||||||
|
onEncodedSample?: (sample: VideoSample) => unknown;
|
||||||
} & VideoEncodingAdditionalOptions;
|
} & VideoEncodingAdditionalOptions;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -232,6 +234,9 @@ export const validateVideoEncodingConfig = (config: VideoEncodingConfig) => {
|
|||||||
if (config.onEncoderConfig !== undefined && typeof config.onEncoderConfig !== 'function') {
|
if (config.onEncoderConfig !== undefined && typeof config.onEncoderConfig !== 'function') {
|
||||||
throw new TypeError('config.onEncoderConfig, when provided, must be a 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);
|
validateVideoEncodingAdditionalOptions(config.codec, config);
|
||||||
};
|
};
|
||||||
@@ -382,6 +387,8 @@ export type AudioEncodingConfig = {
|
|||||||
* WebCodecs API, is created.
|
* WebCodecs API, is created.
|
||||||
*/
|
*/
|
||||||
onEncoderConfig?: (config: AudioEncoderConfig) => unknown;
|
onEncoderConfig?: (config: AudioEncoderConfig) => unknown;
|
||||||
|
/** Called right before a sample is passed to the encoder. */
|
||||||
|
onEncodedSample?: (sample: AudioSample) => unknown;
|
||||||
} & AudioEncodingAdditionalOptions;
|
} & AudioEncodingAdditionalOptions;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -462,6 +469,9 @@ export const validateAudioEncodingConfig = (config: AudioEncodingConfig) => {
|
|||||||
if (config.onEncoderConfig !== undefined && typeof config.onEncoderConfig !== 'function') {
|
if (config.onEncoderConfig !== undefined && typeof config.onEncoderConfig !== 'function') {
|
||||||
throw new TypeError('config.onEncoderConfig, when provided, must be a 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);
|
validateAudioEncodingAdditionalOptions(config.codec, config);
|
||||||
};
|
};
|
||||||
|
|||||||
+20
-2
@@ -265,6 +265,7 @@ class VideoEncoderWrapper {
|
|||||||
* So, we keep track of the encoder error and throw it as soon as we get the chance.
|
* So, we keep track of the encoder error and throw it as soon as we get the chance.
|
||||||
*/
|
*/
|
||||||
private error: Error | null = null;
|
private error: Error | null = null;
|
||||||
|
private closed = false;
|
||||||
|
|
||||||
private lastMuxerPromise: Promise<void> = Promise.resolve();
|
private lastMuxerPromise: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
@@ -429,6 +430,8 @@ class VideoEncoderWrapper {
|
|||||||
return new VideoSample(x);
|
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, {
|
return new VideoSample(x as CanvasImageSource, {
|
||||||
timestamp: videoSample.timestamp,
|
timestamp: videoSample.timestamp,
|
||||||
duration: videoSample.duration,
|
duration: videoSample.duration,
|
||||||
@@ -457,6 +460,10 @@ class VideoEncoderWrapper {
|
|||||||
}
|
}
|
||||||
assert(this.encoderInitialized);
|
assert(this.encoderInitialized);
|
||||||
|
|
||||||
|
if (this.closed) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
const keyFrameInterval = this.encodingConfig.keyFrameInterval ?? 2;
|
const keyFrameInterval = this.encodingConfig.keyFrameInterval ?? 2;
|
||||||
const multipleOfKeyFrameInterval = Math.floor(sampleToEncode.timestamp / keyFrameInterval);
|
const multipleOfKeyFrameInterval = Math.floor(sampleToEncode.timestamp / keyFrameInterval);
|
||||||
|
|
||||||
@@ -474,6 +481,8 @@ class VideoEncoderWrapper {
|
|||||||
};
|
};
|
||||||
this.lastMultipleOfKeyFrameInterval = multipleOfKeyFrameInterval;
|
this.lastMultipleOfKeyFrameInterval = multipleOfKeyFrameInterval;
|
||||||
|
|
||||||
|
this.encodingConfig.onEncodedSample?.(sampleToEncode);
|
||||||
|
|
||||||
if (this.customEncoder) {
|
if (this.customEncoder) {
|
||||||
this.customEncoderQueueSize++;
|
this.customEncoderQueueSize++;
|
||||||
|
|
||||||
@@ -843,6 +852,8 @@ class VideoEncoderWrapper {
|
|||||||
await this.padFrameRate(alignedEnd);
|
await this.padFrameRate(alignedEnd);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.closed = true;
|
||||||
|
|
||||||
this.frameRateLastSample?.close();
|
this.frameRateLastSample?.close();
|
||||||
this.frameRateLastSample = null;
|
this.frameRateLastSample = null;
|
||||||
|
|
||||||
@@ -2033,6 +2044,7 @@ class AudioEncoderWrapper {
|
|||||||
*/
|
*/
|
||||||
private error: Error | null = null;
|
private error: Error | null = null;
|
||||||
private lastMuxerPromise: Promise<void> = Promise.resolve();
|
private lastMuxerPromise: Promise<void> = Promise.resolve();
|
||||||
|
private closed = false;
|
||||||
|
|
||||||
constructor(private source: AudioSource, private encodingConfig: AudioEncodingConfig) {}
|
constructor(private source: AudioSource, private encodingConfig: AudioEncodingConfig) {}
|
||||||
|
|
||||||
@@ -2070,8 +2082,6 @@ class AudioEncoderWrapper {
|
|||||||
?? audioSample.numberOfChannels,
|
?? audioSample.numberOfChannels,
|
||||||
targetSampleRate: config.transform!.sampleRate
|
targetSampleRate: config.transform!.sampleRate
|
||||||
?? audioSample.sampleRate,
|
?? audioSample.sampleRate,
|
||||||
startTime: audioSample.timestamp,
|
|
||||||
endTime: Infinity,
|
|
||||||
onSample: async (sample) => {
|
onSample: async (sample) => {
|
||||||
await this.processAndEncode(sample, true);
|
await this.processAndEncode(sample, true);
|
||||||
},
|
},
|
||||||
@@ -2162,6 +2172,10 @@ class AudioEncoderWrapper {
|
|||||||
}
|
}
|
||||||
assert(this.encoderInitialized);
|
assert(this.encoderInitialized);
|
||||||
|
|
||||||
|
if (this.closed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Handle padding of gaps with silence to avoid audio drift over time, like in
|
// Handle padding of gaps with silence to avoid audio drift over time, like in
|
||||||
// https://github.com/Vanilagy/mediabunny/issues/176
|
// 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
|
// 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) {
|
if (this.customEncoder) {
|
||||||
this.customEncoderQueueSize++;
|
this.customEncoderQueueSize++;
|
||||||
|
|
||||||
@@ -2536,6 +2552,8 @@ class AudioEncoderWrapper {
|
|||||||
}
|
}
|
||||||
this.resampler = null;
|
this.resampler = null;
|
||||||
|
|
||||||
|
this.closed = true;
|
||||||
|
|
||||||
if (this.customEncoder) {
|
if (this.customEncoder) {
|
||||||
if (!forceClose) {
|
if (!forceClose) {
|
||||||
void this.customEncoderCallSerializer.call(() => this.customEncoder!.flush());
|
void this.customEncoderCallSerializer.call(() => this.customEncoder!.flush());
|
||||||
|
|||||||
+10
-15
@@ -20,41 +20,32 @@ export class AudioResampler {
|
|||||||
targetSampleRate: number;
|
targetSampleRate: number;
|
||||||
sourceNumberOfChannels: number | null = null;
|
sourceNumberOfChannels: number | null = null;
|
||||||
targetNumberOfChannels: number;
|
targetNumberOfChannels: number;
|
||||||
endTime: number;
|
startTime: number | null = null;
|
||||||
onSample: (sample: AudioSample) => Promise<void>;
|
onSample: (sample: AudioSample) => Promise<void>;
|
||||||
|
|
||||||
bufferSizeInFrames: number;
|
bufferSizeInFrames: number;
|
||||||
bufferSizeInSamples: number;
|
bufferSizeInSamples: number;
|
||||||
outputBuffer: Float32Array;
|
outputBuffer: Float32Array;
|
||||||
/** Start frame of current buffer */
|
/** Start frame of current buffer */
|
||||||
bufferStartFrame: number;
|
bufferStartFrame = 0;
|
||||||
/** The highest index written to in the current buffer */
|
/** The highest index written to in the current buffer */
|
||||||
maxWrittenFrame: number | null = null;
|
maxWrittenFrame: number | null = null;
|
||||||
channelMixer!: (sourceData: Float32Array, sourceFrameIndex: number, targetChannelIndex: number) => number;
|
channelMixer!: (sourceData: Float32Array, sourceFrameIndex: number, targetChannelIndex: number) => number;
|
||||||
tempSourceBuffer!: Float32Array;
|
tempSourceBuffer!: Float32Array;
|
||||||
timestampOffset: number;
|
|
||||||
|
|
||||||
constructor(options: {
|
constructor(options: {
|
||||||
targetSampleRate: number;
|
targetSampleRate: number;
|
||||||
targetNumberOfChannels: number;
|
targetNumberOfChannels: number;
|
||||||
startTime: number;
|
|
||||||
endTime: number;
|
|
||||||
onSample: (sample: AudioSample) => Promise<void>;
|
onSample: (sample: AudioSample) => Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
this.targetSampleRate = options.targetSampleRate;
|
this.targetSampleRate = options.targetSampleRate;
|
||||||
this.targetNumberOfChannels = options.targetNumberOfChannels;
|
this.targetNumberOfChannels = options.targetNumberOfChannels;
|
||||||
this.endTime = options.endTime;
|
|
||||||
this.onSample = options.onSample;
|
this.onSample = options.onSample;
|
||||||
|
|
||||||
this.bufferSizeInFrames = Math.floor(this.targetSampleRate * 5.0); // 5 seconds
|
this.bufferSizeInFrames = Math.floor(this.targetSampleRate * 5.0); // 5 seconds
|
||||||
this.bufferSizeInSamples = this.bufferSizeInFrames * this.targetNumberOfChannels;
|
this.bufferSizeInSamples = this.bufferSizeInFrames * this.targetNumberOfChannels;
|
||||||
|
|
||||||
this.outputBuffer = new Float32Array(this.bufferSizeInSamples);
|
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.
|
// they see fit.
|
||||||
this.sourceSampleRate = audioSample.sampleRate;
|
this.sourceSampleRate = audioSample.sampleRate;
|
||||||
this.sourceNumberOfChannels = audioSample.numberOfChannels;
|
this.sourceNumberOfChannels = audioSample.numberOfChannels;
|
||||||
|
this.startTime = audioSample.timestamp;
|
||||||
|
|
||||||
// Pre-allocate temporary buffer for source data
|
// Pre-allocate temporary buffer for source data
|
||||||
this.tempSourceBuffer = new Float32Array(this.sourceSampleRate * this.sourceNumberOfChannels);
|
this.tempSourceBuffer = new Float32Array(this.sourceSampleRate * this.sourceNumberOfChannels);
|
||||||
@@ -193,6 +185,8 @@ export class AudioResampler {
|
|||||||
this.doChannelMixerSetup();
|
this.doChannelMixerSetup();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assert(this.startTime !== null);
|
||||||
|
|
||||||
const requiredSamples = audioSample.numberOfFrames * audioSample.numberOfChannels;
|
const requiredSamples = audioSample.numberOfFrames * audioSample.numberOfChannels;
|
||||||
this.ensureTempBufferSize(requiredSamples);
|
this.ensureTempBufferSize(requiredSamples);
|
||||||
|
|
||||||
@@ -201,8 +195,8 @@ export class AudioResampler {
|
|||||||
const sourceView = new Float32Array(this.tempSourceBuffer.buffer, 0, sourceDataSize / 4);
|
const sourceView = new Float32Array(this.tempSourceBuffer.buffer, 0, sourceDataSize / 4);
|
||||||
audioSample.copyTo(sourceView, { planeIndex: 0, format: 'f32' });
|
audioSample.copyTo(sourceView, { planeIndex: 0, format: 'f32' });
|
||||||
|
|
||||||
const inputStartTime = audioSample.timestamp;
|
const inputStartTime = audioSample.timestamp - this.startTime;
|
||||||
const inputEndTime = Math.min(audioSample.timestamp + audioSample.duration, this.endTime);
|
const inputEndTime = inputStartTime + audioSample.duration;
|
||||||
|
|
||||||
// Compute which output frames are affected by this sample
|
// Compute which output frames are affected by this sample
|
||||||
const outputStartFrame = Math.floor(inputStartTime * this.targetSampleRate);
|
const outputStartFrame = Math.floor(inputStartTime * this.targetSampleRate);
|
||||||
@@ -266,17 +260,18 @@ export class AudioResampler {
|
|||||||
return; // Nothing to finalize
|
return; // Nothing to finalize
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assert(this.startTime !== null);
|
||||||
|
|
||||||
const samplesWritten = (this.maxWrittenFrame + 1) * this.targetNumberOfChannels;
|
const samplesWritten = (this.maxWrittenFrame + 1) * this.targetNumberOfChannels;
|
||||||
|
|
||||||
const outputData = new Float32Array(samplesWritten);
|
const outputData = new Float32Array(samplesWritten);
|
||||||
outputData.set(this.outputBuffer.subarray(0, samplesWritten));
|
outputData.set(this.outputBuffer.subarray(0, samplesWritten));
|
||||||
|
|
||||||
const timestampSeconds = this.bufferStartFrame / this.targetSampleRate;
|
|
||||||
const audioSample = new AudioSample({
|
const audioSample = new AudioSample({
|
||||||
format: 'f32',
|
format: 'f32',
|
||||||
sampleRate: this.targetSampleRate,
|
sampleRate: this.targetSampleRate,
|
||||||
numberOfChannels: this.targetNumberOfChannels,
|
numberOfChannels: this.targetNumberOfChannels,
|
||||||
timestamp: timestampSeconds + this.timestampOffset,
|
timestamp: this.startTime + this.bufferStartFrame / this.targetSampleRate,
|
||||||
data: outputData,
|
data: outputData,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
* Closes this audio sample, releasing held resources. Audio samples should be closed as soon as they are not
|
||||||
* needed anymore.
|
* needed anymore.
|
||||||
|
|||||||
@@ -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.');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user