mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-09-27 19:03:46 +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();
|
||||
//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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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