mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-10-09 16:53:47 +02:00
Implement custom audio resampler for conversion
This commit is contained in:
+271
-2
@@ -11,9 +11,275 @@
|
|||||||
progress.max = 1;
|
progress.max = 1;
|
||||||
document.body.append(progress);
|
document.body.append(progress);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Naive audio resampler using linear interpolation between samples
|
||||||
|
* Much faster but lower quality than windowed sinc - causes aliasing and imaging artifacts
|
||||||
|
* @param {AudioBuffer} inputBuffer - The input audio buffer
|
||||||
|
* @param {number} targetSampleRate - The desired output sample rate
|
||||||
|
* @returns {AudioBuffer} - New resampled audio buffer
|
||||||
|
*/
|
||||||
|
function naiveResample(inputBuffer, targetSampleRate) {
|
||||||
|
const inputSampleRate = inputBuffer.sampleRate;
|
||||||
|
const ratio = targetSampleRate / inputSampleRate;
|
||||||
|
const inputLength = inputBuffer.length;
|
||||||
|
const outputLength = Math.floor(inputLength * ratio);
|
||||||
|
|
||||||
|
// Create output buffer
|
||||||
|
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
const outputBuffer = audioContext.createBuffer(
|
||||||
|
inputBuffer.numberOfChannels,
|
||||||
|
outputLength,
|
||||||
|
targetSampleRate
|
||||||
|
);
|
||||||
|
|
||||||
|
// Process each channel independently
|
||||||
|
for (let channel = 0; channel < inputBuffer.numberOfChannels; channel++) {
|
||||||
|
const inputData = inputBuffer.getChannelData(channel);
|
||||||
|
const outputData = outputBuffer.getChannelData(channel);
|
||||||
|
|
||||||
|
naiveResampleChannel(inputData, outputData, inputSampleRate, targetSampleRate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return outputBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Naive resample a single channel using linear interpolation
|
||||||
|
*/
|
||||||
|
function naiveResampleChannel(inputData, outputData, inputSampleRate, targetSampleRate) {
|
||||||
|
const inputLength = inputData.length;
|
||||||
|
const outputLength = outputData.length;
|
||||||
|
|
||||||
|
for (let n = 0; n < outputLength; n++) {
|
||||||
|
// Calculate the corresponding position in the input signal
|
||||||
|
const inputPosition = n * inputSampleRate / targetSampleRate;
|
||||||
|
|
||||||
|
// Get the floor and ceiling indices
|
||||||
|
const lowerIndex = Math.floor(inputPosition);
|
||||||
|
const upperIndex = Math.ceil(inputPosition);
|
||||||
|
|
||||||
|
// Handle edge cases
|
||||||
|
if (lowerIndex >= inputLength - 1) {
|
||||||
|
// At or past the end - just use the last sample
|
||||||
|
outputData[n] = inputData[inputLength - 1];
|
||||||
|
} else if (lowerIndex < 0) {
|
||||||
|
// Before the start - use first sample (shouldn't happen with our calculation)
|
||||||
|
outputData[n] = inputData[0];
|
||||||
|
} else if (lowerIndex === upperIndex) {
|
||||||
|
// Exact sample alignment - no interpolation needed
|
||||||
|
outputData[n] = inputData[lowerIndex];
|
||||||
|
} else {
|
||||||
|
// Linear interpolation between floor and ceil samples
|
||||||
|
const fraction = inputPosition - lowerIndex;
|
||||||
|
const lowerSample = inputData[lowerIndex];
|
||||||
|
const upperSample = inputData[upperIndex];
|
||||||
|
|
||||||
|
// Linear interpolation: lerp(a, b, t) = a + t * (b - a)
|
||||||
|
outputData[n] = lowerSample + fraction * (upperSample - lowerSample);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resample an AudioBuffer to a new sample rate using windowed sinc interpolation
|
||||||
|
* @param {AudioBuffer} inputBuffer - The input audio buffer
|
||||||
|
* @param {number} targetSampleRate - The desired output sample rate
|
||||||
|
* @param {number} windowSize - Half-width of the sinc window (default: 6)
|
||||||
|
* @returns {AudioBuffer} - New resampled audio buffer
|
||||||
|
*/
|
||||||
|
function resampleAudioBuffer(inputBuffer, targetSampleRate, windowSize = 1) {
|
||||||
|
const inputSampleRate = inputBuffer.sampleRate;
|
||||||
|
const ratio = targetSampleRate / inputSampleRate;
|
||||||
|
const inputLength = inputBuffer.length;
|
||||||
|
const outputLength = Math.floor(inputLength * ratio);
|
||||||
|
|
||||||
|
// Scale window size for anti-aliasing when downsampling
|
||||||
|
const effectiveWindowSize = windowSize * Math.max(1, inputSampleRate / targetSampleRate);
|
||||||
|
|
||||||
|
// Create output buffer
|
||||||
|
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
const outputBuffer = audioContext.createBuffer(
|
||||||
|
inputBuffer.numberOfChannels,
|
||||||
|
outputLength,
|
||||||
|
targetSampleRate
|
||||||
|
);
|
||||||
|
|
||||||
|
// Process each channel independently
|
||||||
|
for (let channel = 0; channel < inputBuffer.numberOfChannels; channel++) {
|
||||||
|
const inputData = inputBuffer.getChannelData(channel);
|
||||||
|
const outputData = outputBuffer.getChannelData(channel);
|
||||||
|
|
||||||
|
resampleChannel(inputData, outputData, inputSampleRate, targetSampleRate, effectiveWindowSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
return outputBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resample a single channel of audio data
|
||||||
|
*/
|
||||||
|
function resampleChannel(inputData, outputData, inputSampleRate, targetSampleRate, windowSize) {
|
||||||
|
const inputLength = inputData.length;
|
||||||
|
const outputLength = outputData.length;
|
||||||
|
|
||||||
|
for (let n = 0; n < outputLength; n++) {
|
||||||
|
// Current output time in input sample units
|
||||||
|
const inputTime = n * inputSampleRate / targetSampleRate;
|
||||||
|
|
||||||
|
let sum = 0;
|
||||||
|
const windowRadius = Math.ceil(windowSize);
|
||||||
|
|
||||||
|
// Convolve with windowed sinc kernel
|
||||||
|
for (let k = -windowRadius; k <= windowRadius; k++) {
|
||||||
|
const inputIndex = Math.floor(inputTime) + k;
|
||||||
|
|
||||||
|
// Handle edges with zero padding
|
||||||
|
if (inputIndex < 0 || inputIndex >= inputLength) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Time difference for sinc calculation
|
||||||
|
const timeDiff = inputTime - inputIndex;
|
||||||
|
|
||||||
|
// Calculate windowed sinc weight
|
||||||
|
const weight = windowedSinc(timeDiff, windowSize);
|
||||||
|
|
||||||
|
sum += inputData[inputIndex] * weight;
|
||||||
|
}
|
||||||
|
|
||||||
|
outputData[n] = sum;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Windowed sinc function using Kaiser window
|
||||||
|
* @param {number} x - Input value
|
||||||
|
* @param {number} windowSize - Window size parameter
|
||||||
|
* @returns {number} - Windowed sinc value
|
||||||
|
*/
|
||||||
|
function windowedSinc(x, windowSize) {
|
||||||
|
if (Math.abs(x) > windowSize) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sinc function
|
||||||
|
let sincValue;
|
||||||
|
if (Math.abs(x) < 1e-10) {
|
||||||
|
sincValue = 1; // lim(x->0) sinc(x) = 1
|
||||||
|
} else {
|
||||||
|
const piX = Math.PI * x;
|
||||||
|
sincValue = Math.sin(piX) / piX;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kaiser window (beta = 8 for good balance of main lobe width vs side lobe suppression)
|
||||||
|
const beta = 8;
|
||||||
|
const windowValue = kaiserWindow(x / windowSize, beta);
|
||||||
|
|
||||||
|
return sincValue * windowValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kaiser window function
|
||||||
|
* @param {number} n - Normalized position (-1 to 1)
|
||||||
|
* @param {number} beta - Kaiser beta parameter
|
||||||
|
* @returns {number} - Window value
|
||||||
|
*/
|
||||||
|
function kaiserWindow(n, beta) {
|
||||||
|
if (Math.abs(n) > 1) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const arg = beta * Math.sqrt(1 - n * n);
|
||||||
|
return modifiedBesselI0(arg) / modifiedBesselI0(beta);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modified Bessel function of the first kind, order 0
|
||||||
|
* Using series approximation
|
||||||
|
*/
|
||||||
|
function modifiedBesselI0(x) {
|
||||||
|
let sum = 1;
|
||||||
|
let term = 1;
|
||||||
|
const xSquaredOver4 = (x * x) / 4;
|
||||||
|
|
||||||
|
for (let k = 1; k < 50; k++) {
|
||||||
|
term *= xSquaredOver4 / (k * k);
|
||||||
|
sum += term;
|
||||||
|
|
||||||
|
if (term < 1e-12) break; // Convergence check
|
||||||
|
}
|
||||||
|
|
||||||
|
return sum;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example usage:
|
||||||
|
// const resampledBuffer = resampleAudioBuffer(originalBuffer, 44100);
|
||||||
|
|
||||||
|
// For testing - create a simple test signal
|
||||||
|
function createTestBuffer(sampleRate = 48000, duration = 1, frequency = 440) {
|
||||||
|
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
const length = Math.floor(sampleRate * duration);
|
||||||
|
const buffer = audioContext.createBuffer(1, length, sampleRate);
|
||||||
|
const data = buffer.getChannelData(0);
|
||||||
|
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
data[i] = Math.sin(2 * Math.PI * frequency * i / sampleRate) * 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test example:
|
||||||
|
// const testBuffer = createTestBuffer(48000, 1, 440);
|
||||||
|
// const resampled = resampleAudioBuffer(testBuffer, 44100);
|
||||||
|
// console.log(`Original: ${testBuffer.sampleRate}Hz, ${testBuffer.length} samples`);
|
||||||
|
// console.log(`Resampled: ${resampled.sampleRate}Hz, ${resampled.length} samples`);
|
||||||
|
|
||||||
fileInput.addEventListener('change', async () => {
|
fileInput.addEventListener('change', async () => {
|
||||||
const file = fileInput.files[0];
|
const file = fileInput.files[0];
|
||||||
|
|
||||||
|
/*
|
||||||
|
const context = new AudioContext();
|
||||||
|
const buffer = await context.decodeAudioData(await file.arrayBuffer());
|
||||||
|
|
||||||
|
const resampled = naiveResample(buffer, 16000);
|
||||||
|
console.log(resampled)
|
||||||
|
|
||||||
|
const node = context.createBufferSource();
|
||||||
|
node.buffer = resampled;
|
||||||
|
node.connect(context.destination);
|
||||||
|
node.start();
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
const cursedOutput = new Metamuxer.Output({
|
||||||
|
format: new Metamuxer.WavOutputFormat(),
|
||||||
|
target: new Metamuxer.BufferTarget()
|
||||||
|
});
|
||||||
|
const cursedSource = new Metamuxer.AudioBufferSource({codec: 'pcm-s16'});
|
||||||
|
cursedOutput.addAudioTrack(cursedSource);
|
||||||
|
|
||||||
|
await cursedOutput.start();
|
||||||
|
|
||||||
|
await cursedSource.add(resampled);
|
||||||
|
await cursedOutput.finalize();
|
||||||
|
|
||||||
|
console.log(cursedOutput.target.buffer);
|
||||||
|
download(new Blob([cursedOutput.target.buffer]), 'cursed.wav')
|
||||||
|
*/
|
||||||
|
|
||||||
|
//return;
|
||||||
|
|
||||||
const source = new Metamuxer.BlobSource(file);
|
const source = new Metamuxer.BlobSource(file);
|
||||||
const target = new Metamuxer.BufferTarget() ?? new Metamuxer.StreamTarget(new WritableStream({
|
const target = new Metamuxer.BufferTarget() ?? new Metamuxer.StreamTarget(new WritableStream({
|
||||||
write: console.log
|
write: console.log
|
||||||
@@ -21,7 +287,7 @@
|
|||||||
chunked: true,
|
chunked: true,
|
||||||
chunkSize: 2**20
|
chunkSize: 2**20
|
||||||
});
|
});
|
||||||
const outputFormat = new Metamuxer.Mp4OutputFormat();
|
const outputFormat = new Metamuxer.WavOutputFormat();
|
||||||
|
|
||||||
const button = document.createElement('button');
|
const button = document.createElement('button');
|
||||||
button.textContent = 'Cancel';
|
button.textContent = 'Cancel';
|
||||||
@@ -38,6 +304,8 @@
|
|||||||
target
|
target
|
||||||
}),
|
}),
|
||||||
audio: {
|
audio: {
|
||||||
|
numberOfChannels: 1,
|
||||||
|
sampleRate: 16000
|
||||||
//discard: true
|
//discard: true
|
||||||
//forceReencode: true,
|
//forceReencode: true,
|
||||||
},
|
},
|
||||||
@@ -66,7 +334,8 @@
|
|||||||
},
|
},
|
||||||
*/
|
*/
|
||||||
video: {
|
video: {
|
||||||
width: 640
|
discard: true,
|
||||||
|
//width: 640
|
||||||
//forceReencode: true,
|
//forceReencode: true,
|
||||||
//rotate: 90
|
//rotate: 90
|
||||||
//width: 720 ?? 2160,
|
//width: 720 ?? 2160,
|
||||||
|
|||||||
+294
-81
@@ -12,14 +12,12 @@ import {
|
|||||||
import { Input } from './input';
|
import { Input } from './input';
|
||||||
import { InputAudioTrack, InputTrack, InputVideoTrack } from './input-track';
|
import { InputAudioTrack, InputTrack, InputVideoTrack } from './input-track';
|
||||||
import {
|
import {
|
||||||
AudioBufferSink,
|
|
||||||
AudioSampleSink,
|
AudioSampleSink,
|
||||||
CanvasSink,
|
CanvasSink,
|
||||||
EncodedPacketSink,
|
EncodedPacketSink,
|
||||||
VideoSampleSink,
|
VideoSampleSink,
|
||||||
} from './media-sink';
|
} from './media-sink';
|
||||||
import {
|
import {
|
||||||
AudioBufferSource,
|
|
||||||
AudioEncodingConfig,
|
AudioEncodingConfig,
|
||||||
AudioSource,
|
AudioSource,
|
||||||
EncodedVideoPacketSource,
|
EncodedVideoPacketSource,
|
||||||
@@ -31,7 +29,7 @@ import {
|
|||||||
} from './media-source';
|
} from './media-source';
|
||||||
import { assert, clamp, normalizeRotation, promiseWithResolvers, Rotation } from './misc';
|
import { assert, clamp, normalizeRotation, promiseWithResolvers, Rotation } from './misc';
|
||||||
import { Output, TrackType } from './output';
|
import { Output, TrackType } from './output';
|
||||||
import { VideoSample } from './sample';
|
import { AudioSample, VideoSample } from './sample';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The options for media file conversion.
|
* The options for media file conversion.
|
||||||
@@ -782,18 +780,14 @@ export class Conversion {
|
|||||||
this.utilizedTracks.push(track);
|
this.utilizedTracks.push(track);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** @internal */
|
||||||
* Resamples the audio by decoding it, playing it onto an OfflineAudioContext and encoding the
|
|
||||||
* resulting AudioBuffer.
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
_resampleAudio(
|
_resampleAudio(
|
||||||
track: InputAudioTrack,
|
track: InputAudioTrack,
|
||||||
codec: AudioCodec,
|
codec: AudioCodec,
|
||||||
targetNumberOfChannels: number,
|
targetNumberOfChannels: number,
|
||||||
targetSampleRate: number,
|
targetSampleRate: number,
|
||||||
) {
|
) {
|
||||||
const source = new AudioBufferSource({
|
const source = new AudioSampleSource({
|
||||||
codec,
|
codec,
|
||||||
bitrate: this._options.audio?.bitrate ?? QUALITY_HIGH,
|
bitrate: this._options.audio?.bitrate ?? QUALITY_HIGH,
|
||||||
onEncodedPacket: packet => this._reportProgress(track.id, packet.timestamp + packet.duration),
|
onEncodedPacket: packet => this._reportProgress(track.id, packet.timestamp + packet.duration),
|
||||||
@@ -802,89 +796,33 @@ export class Conversion {
|
|||||||
this._trackPromises.push((async () => {
|
this._trackPromises.push((async () => {
|
||||||
await this._started;
|
await this._started;
|
||||||
|
|
||||||
const trackDuration = Math.min(
|
const resampler = new AudioResampler({
|
||||||
await track.computeDuration() - this._startTimestamp,
|
sourceNumberOfChannels: track.numberOfChannels,
|
||||||
this._endTimestamp - this._startTimestamp,
|
sourceSampleRate: track.sampleRate,
|
||||||
);
|
targetNumberOfChannels,
|
||||||
const totalFrameCount = Math.round(trackDuration * targetSampleRate);
|
targetSampleRate,
|
||||||
const maxChunkLength = 5 * targetSampleRate;
|
startTime: this._startTimestamp,
|
||||||
|
endTime: this._endTimestamp,
|
||||||
let currentContextStartFrame = 0;
|
onSample: sample => source.add(sample),
|
||||||
let currentContext: OfflineAudioContext | null = new OfflineAudioContext({
|
|
||||||
length: Math.min(totalFrameCount - currentContextStartFrame, maxChunkLength),
|
|
||||||
numberOfChannels: targetNumberOfChannels,
|
|
||||||
sampleRate: targetSampleRate,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const sink = new AudioBufferSink(track);
|
const sink = new AudioSampleSink(track);
|
||||||
const iterator = sink.buffers(this._startTimestamp, this._endTimestamp);
|
const iterator = sink.samples(this._startTimestamp, this._endTimestamp); // Todo make sure timestamps work
|
||||||
|
|
||||||
for await (const { buffer, timestamp, duration } of iterator) {
|
for await (const sample of iterator) {
|
||||||
if (this._synchronizer.shouldWait(track.id, timestamp)) {
|
if (this._synchronizer.shouldWait(track.id, sample.timestamp)) {
|
||||||
await this._synchronizer.wait(timestamp);
|
await this._synchronizer.wait(sample.timestamp);
|
||||||
}
|
}
|
||||||
|
|
||||||
const offsetTimestamp = timestamp - this._startTimestamp;
|
|
||||||
const endTimestamp = offsetTimestamp + duration;
|
|
||||||
|
|
||||||
// while loop, as a single source buffer may span multiple audio contexts
|
|
||||||
while (currentContext) {
|
|
||||||
const currentContextStartTime = currentContextStartFrame / targetSampleRate;
|
|
||||||
const currentContextEndTime
|
|
||||||
= (currentContextStartFrame + currentContext.length) / targetSampleRate;
|
|
||||||
|
|
||||||
if (offsetTimestamp < currentContextEndTime) {
|
|
||||||
// The buffer lies within the context, let's play it
|
|
||||||
const node = currentContext.createBufferSource();
|
|
||||||
node.buffer = buffer;
|
|
||||||
node.connect(currentContext.destination);
|
|
||||||
|
|
||||||
if (offsetTimestamp < currentContextStartTime) {
|
|
||||||
node.start(0, currentContextStartTime - offsetTimestamp);
|
|
||||||
} else {
|
|
||||||
node.start(offsetTimestamp - currentContextStartTime);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (endTimestamp >= currentContextEndTime) {
|
|
||||||
// Render the audio
|
|
||||||
const renderedBuffer = await currentContext.startRendering();
|
|
||||||
|
|
||||||
if (this._canceled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await source.add(renderedBuffer);
|
|
||||||
|
|
||||||
currentContextStartFrame += currentContext.length;
|
|
||||||
|
|
||||||
const newLength = Math.min(
|
|
||||||
totalFrameCount - currentContextStartFrame,
|
|
||||||
maxChunkLength,
|
|
||||||
);
|
|
||||||
currentContext = newLength > 0
|
|
||||||
? new OfflineAudioContext({
|
|
||||||
length: newLength,
|
|
||||||
numberOfChannels: targetNumberOfChannels,
|
|
||||||
sampleRate: targetSampleRate,
|
|
||||||
})
|
|
||||||
: null;
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentContext) {
|
|
||||||
const renderedBuffer = await currentContext.startRendering();
|
|
||||||
|
|
||||||
if (this._canceled) {
|
if (this._canceled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await source.add(renderedBuffer);
|
await resampler.add(sample);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await resampler.finalize();
|
||||||
|
|
||||||
await source.close();
|
await source.close();
|
||||||
this._synchronizer.closeTrack(track.id);
|
this._synchronizer.closeTrack(track.id);
|
||||||
})());
|
})());
|
||||||
@@ -971,3 +909,278 @@ class TrackSynchronizer {
|
|||||||
this.computeMinAndMaybeResolve();
|
this.computeMinAndMaybeResolve();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility class to handle audio resampling, handling both sample rate resampling as well as channel up/downmixing.
|
||||||
|
* The advantage over doing this manually rather than using OfflineAudioContext to do it for us is the artifact-free
|
||||||
|
* handling of putting multiple resampled audio samples back to back, which produces flaky results using
|
||||||
|
* OfflineAudioContext.
|
||||||
|
*/
|
||||||
|
export class AudioResampler {
|
||||||
|
sourceSampleRate: number;
|
||||||
|
targetSampleRate: number;
|
||||||
|
sourceNumberOfChannels: number;
|
||||||
|
targetNumberOfChannels: number;
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
onSample: (sample: AudioSample) => Promise<void>;
|
||||||
|
|
||||||
|
bufferSizeInFrames: number;
|
||||||
|
bufferSizeInSamples: number;
|
||||||
|
outputBuffer: Float32Array;
|
||||||
|
/** Start frame of current buffer */
|
||||||
|
bufferStartFrame: number;
|
||||||
|
/** The highest index written to in the current buffer */
|
||||||
|
maxWrittenFrame: number;
|
||||||
|
channelMixer!: (sourceData: Float32Array, sourceFrameIndex: number, targetChannelIndex: number) => number;
|
||||||
|
tempSourceBuffer: Float32Array;
|
||||||
|
|
||||||
|
constructor(options: {
|
||||||
|
sourceSampleRate: number;
|
||||||
|
targetSampleRate: number;
|
||||||
|
sourceNumberOfChannels: number;
|
||||||
|
targetNumberOfChannels: number;
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
onSample: (sample: AudioSample) => Promise<void>;
|
||||||
|
}) {
|
||||||
|
this.sourceSampleRate = options.sourceSampleRate;
|
||||||
|
this.targetSampleRate = options.targetSampleRate;
|
||||||
|
this.sourceNumberOfChannels = options.sourceNumberOfChannels;
|
||||||
|
this.targetNumberOfChannels = options.targetNumberOfChannels;
|
||||||
|
this.startTime = options.startTime;
|
||||||
|
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 = 0;
|
||||||
|
this.maxWrittenFrame = -1;
|
||||||
|
|
||||||
|
this.setupChannelMixer();
|
||||||
|
|
||||||
|
// Pre-allocate temporary buffer for source data
|
||||||
|
this.tempSourceBuffer = new Float32Array(this.sourceSampleRate * this.sourceNumberOfChannels);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets up the channel mixer to handle up/downmixing in the case where input and output channel counts don't match.
|
||||||
|
*/
|
||||||
|
setupChannelMixer(): void {
|
||||||
|
const sourceNum = this.sourceNumberOfChannels;
|
||||||
|
const targetNum = this.targetNumberOfChannels;
|
||||||
|
|
||||||
|
// Logic taken from
|
||||||
|
// https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API/Basic_concepts_behind_Web_Audio_API
|
||||||
|
// Most of the mapping functions are branchless.
|
||||||
|
|
||||||
|
if (sourceNum === 1 && targetNum === 2) {
|
||||||
|
// Mono to Stereo: M -> L, M -> R
|
||||||
|
this.channelMixer = (sourceData: Float32Array, sourceFrameIndex: number) => {
|
||||||
|
return sourceData[sourceFrameIndex * sourceNum]!;
|
||||||
|
};
|
||||||
|
} else if (sourceNum === 1 && targetNum === 4) {
|
||||||
|
// Mono to Quad: M -> L, M -> R, 0 -> SL, 0 -> SR
|
||||||
|
this.channelMixer = (sourceData: Float32Array, sourceFrameIndex: number, targetChannelIndex: number) => {
|
||||||
|
return sourceData[sourceFrameIndex * sourceNum]! * +(targetChannelIndex < 2);
|
||||||
|
};
|
||||||
|
} else if (sourceNum === 1 && targetNum === 6) {
|
||||||
|
// Mono to 5.1: 0 -> L, 0 -> R, M -> C, 0 -> LFE, 0 -> SL, 0 -> SR
|
||||||
|
this.channelMixer = (sourceData: Float32Array, sourceFrameIndex: number, targetChannelIndex: number) => {
|
||||||
|
return sourceData[sourceFrameIndex * sourceNum]! * +(targetChannelIndex === 2);
|
||||||
|
};
|
||||||
|
} else if (sourceNum === 2 && targetNum === 1) {
|
||||||
|
// Stereo to Mono: 0.5 * (L + R)
|
||||||
|
this.channelMixer = (sourceData: Float32Array, sourceFrameIndex: number) => {
|
||||||
|
const baseIdx = sourceFrameIndex * sourceNum;
|
||||||
|
return 0.5 * (sourceData[baseIdx]! + sourceData[baseIdx + 1]!);
|
||||||
|
};
|
||||||
|
} else if (sourceNum === 2 && targetNum === 4) {
|
||||||
|
// Stereo to Quad: L -> L, R -> R, 0 -> SL, 0 -> SR
|
||||||
|
this.channelMixer = (sourceData: Float32Array, sourceFrameIndex: number, targetChannelIndex: number) => {
|
||||||
|
return sourceData[sourceFrameIndex * sourceNum + targetChannelIndex]! * +(targetChannelIndex < 2);
|
||||||
|
};
|
||||||
|
} else if (sourceNum === 2 && targetNum === 6) {
|
||||||
|
// Stereo to 5.1: L -> L, R -> R, 0 -> C, 0 -> LFE, 0 -> SL, 0 -> SR
|
||||||
|
this.channelMixer = (sourceData: Float32Array, sourceFrameIndex: number, targetChannelIndex: number) => {
|
||||||
|
return sourceData[sourceFrameIndex * sourceNum + targetChannelIndex]! * +(targetChannelIndex < 2);
|
||||||
|
};
|
||||||
|
} else if (sourceNum === 4 && targetNum === 1) {
|
||||||
|
// Quad to Mono: 0.25 * (L + R + SL + SR)
|
||||||
|
this.channelMixer = (sourceData: Float32Array, sourceFrameIndex: number) => {
|
||||||
|
const baseIdx = sourceFrameIndex * sourceNum;
|
||||||
|
return 0.25 * (
|
||||||
|
sourceData[baseIdx]! + sourceData[baseIdx + 1]!
|
||||||
|
+ sourceData[baseIdx + 2]! + sourceData[baseIdx + 3]!
|
||||||
|
);
|
||||||
|
};
|
||||||
|
} else if (sourceNum === 4 && targetNum === 2) {
|
||||||
|
// Quad to Stereo: 0.5 * (L + SL), 0.5 * (R + SR)
|
||||||
|
this.channelMixer = (sourceData: Float32Array, sourceFrameIndex: number, targetChannelIndex: number) => {
|
||||||
|
const baseIdx = sourceFrameIndex * sourceNum;
|
||||||
|
return 0.5 * (
|
||||||
|
sourceData[baseIdx + targetChannelIndex]!
|
||||||
|
+ sourceData[baseIdx + targetChannelIndex + 2]!
|
||||||
|
);
|
||||||
|
};
|
||||||
|
} else if (sourceNum === 4 && targetNum === 6) {
|
||||||
|
// Quad to 5.1: L -> L, R -> R, 0 -> C, 0 -> LFE, SL -> SL, SR -> SR
|
||||||
|
this.channelMixer = (sourceData: Float32Array, sourceFrameIndex: number, targetChannelIndex: number) => {
|
||||||
|
const baseIdx = sourceFrameIndex * sourceNum;
|
||||||
|
|
||||||
|
// It's a bit harder to do this one branchlessly
|
||||||
|
if (targetChannelIndex < 2) return sourceData[baseIdx + targetChannelIndex]!; // L, R
|
||||||
|
if (targetChannelIndex === 2 || targetChannelIndex === 3) return 0; // C, LFE
|
||||||
|
return sourceData[baseIdx + targetChannelIndex - 2]!; // SL, SR
|
||||||
|
};
|
||||||
|
} else if (sourceNum === 6 && targetNum === 1) {
|
||||||
|
// 5.1 to Mono: sqrt(1/2) * (L + R) + C + 0.5 * (SL + SR)
|
||||||
|
this.channelMixer = (sourceData: Float32Array, sourceFrameIndex: number) => {
|
||||||
|
const baseIdx = sourceFrameIndex * sourceNum;
|
||||||
|
return Math.SQRT1_2 * (sourceData[baseIdx]! + sourceData[baseIdx + 1]!)
|
||||||
|
+ sourceData[baseIdx + 2]!
|
||||||
|
+ 0.5 * (sourceData[baseIdx + 4]! + sourceData[baseIdx + 5]!);
|
||||||
|
};
|
||||||
|
} else if (sourceNum === 6 && targetNum === 2) {
|
||||||
|
// 5.1 to Stereo: L + sqrt(1/2) * (C + SL), R + sqrt(1/2) * (C + SR)
|
||||||
|
this.channelMixer = (sourceData: Float32Array, sourceFrameIndex: number, targetChannelIndex: number) => {
|
||||||
|
const baseIdx = sourceFrameIndex * sourceNum;
|
||||||
|
return sourceData[baseIdx + targetChannelIndex]!
|
||||||
|
+ Math.SQRT1_2 * (sourceData[baseIdx + 2]! + sourceData[baseIdx + targetChannelIndex + 4]!);
|
||||||
|
};
|
||||||
|
} else if (sourceNum === 6 && targetNum === 4) {
|
||||||
|
// 5.1 to Quad: L + sqrt(1/2) * C, R + sqrt(1/2) * C, SL, SR
|
||||||
|
this.channelMixer = (sourceData: Float32Array, sourceFrameIndex: number, targetChannelIndex: number) => {
|
||||||
|
const baseIdx = sourceFrameIndex * sourceNum;
|
||||||
|
|
||||||
|
// It's a bit harder to do this one branchlessly
|
||||||
|
if (targetChannelIndex < 2) {
|
||||||
|
return sourceData[baseIdx + targetChannelIndex]! + Math.SQRT1_2 * sourceData[baseIdx + 2]!;
|
||||||
|
}
|
||||||
|
return sourceData[baseIdx + targetChannelIndex + 2]!; // SL, SR
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Discrete fallback: direct mapping with zero-fill or drop
|
||||||
|
this.channelMixer = (sourceData: Float32Array, sourceFrameIndex: number, targetChannelIndex: number) => {
|
||||||
|
return targetChannelIndex < sourceNum
|
||||||
|
? sourceData[sourceFrameIndex * sourceNum + targetChannelIndex]!
|
||||||
|
: 0;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureTempBufferSize(requiredSamples: number): void {
|
||||||
|
let length = this.tempSourceBuffer.length;
|
||||||
|
|
||||||
|
while (length < requiredSamples) {
|
||||||
|
length *= 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (length !== this.tempSourceBuffer.length) {
|
||||||
|
const newBuffer = new Float32Array(length);
|
||||||
|
newBuffer.set(this.tempSourceBuffer);
|
||||||
|
this.tempSourceBuffer = newBuffer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async add(audioSample: AudioSample) {
|
||||||
|
if (!audioSample || audioSample._closed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requiredSamples = audioSample.numberOfFrames * audioSample.numberOfChannels;
|
||||||
|
this.ensureTempBufferSize(requiredSamples);
|
||||||
|
|
||||||
|
// Copy the audio data to the temp buffer
|
||||||
|
const sourceDataSize = audioSample.allocationSize({ planeIndex: 0, format: 'f32' });
|
||||||
|
const sourceView = new Float32Array(this.tempSourceBuffer.buffer, 0, sourceDataSize / 4);
|
||||||
|
audioSample.copyTo(sourceView, { planeIndex: 0, format: 'f32' });
|
||||||
|
|
||||||
|
const inputStartTime = audioSample.timestamp - this.startTime;
|
||||||
|
const inputDuration = audioSample.numberOfFrames / this.sourceSampleRate;
|
||||||
|
const inputEndTime = Math.min(inputStartTime + inputDuration, this.endTime - this.startTime);
|
||||||
|
|
||||||
|
// Compute which output frames are affected by this sample
|
||||||
|
const outputStartFrame = Math.floor(inputStartTime * this.targetSampleRate);
|
||||||
|
const outputEndFrame = Math.ceil(inputEndTime * this.targetSampleRate);
|
||||||
|
|
||||||
|
for (let outputFrame = outputStartFrame; outputFrame < outputEndFrame; outputFrame++) {
|
||||||
|
if (outputFrame < this.bufferStartFrame) {
|
||||||
|
continue; // Skip writes to the past
|
||||||
|
}
|
||||||
|
|
||||||
|
while (outputFrame >= this.bufferStartFrame + this.bufferSizeInFrames) {
|
||||||
|
// The write is after the current buffer, so finalize it
|
||||||
|
await this.finalizeCurrentBuffer();
|
||||||
|
this.bufferStartFrame += this.bufferSizeInFrames;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bufferFrameIndex = outputFrame - this.bufferStartFrame;
|
||||||
|
assert(bufferFrameIndex < this.bufferSizeInFrames);
|
||||||
|
|
||||||
|
const outputTime = outputFrame / this.targetSampleRate;
|
||||||
|
const inputTime = outputTime - inputStartTime;
|
||||||
|
const sourcePosition = inputTime * this.sourceSampleRate;
|
||||||
|
|
||||||
|
const sourceLowerFrame = Math.floor(sourcePosition);
|
||||||
|
const sourceUpperFrame = Math.ceil(sourcePosition);
|
||||||
|
const fraction = sourcePosition - sourceLowerFrame;
|
||||||
|
|
||||||
|
// Process each output channel
|
||||||
|
for (let targetChannel = 0; targetChannel < this.targetNumberOfChannels; targetChannel++) {
|
||||||
|
let lowerSample = 0;
|
||||||
|
let upperSample = 0;
|
||||||
|
|
||||||
|
if (sourceLowerFrame >= 0 && sourceLowerFrame < audioSample.numberOfFrames) {
|
||||||
|
lowerSample = this.channelMixer(sourceView, sourceLowerFrame, targetChannel);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourceUpperFrame >= 0 && sourceUpperFrame < audioSample.numberOfFrames) {
|
||||||
|
upperSample = this.channelMixer(sourceView, sourceUpperFrame, targetChannel);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For resampling, we do naive linear interpolation to find the in-between sample. This produces
|
||||||
|
// suboptimal results especially for downsampling (for which a low-pass filter would first need to be
|
||||||
|
// applied), but AudioContext doesn't do this either so, whatever.
|
||||||
|
const outputSample = lowerSample + fraction * (upperSample - lowerSample);
|
||||||
|
|
||||||
|
// Write to output buffer (interleaved)
|
||||||
|
const outputIndex = bufferFrameIndex * this.targetNumberOfChannels + targetChannel;
|
||||||
|
this.outputBuffer[outputIndex]! += outputSample; // Add in case of overlapping samples
|
||||||
|
}
|
||||||
|
|
||||||
|
this.maxWrittenFrame = Math.max(this.maxWrittenFrame, bufferFrameIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async finalizeCurrentBuffer() {
|
||||||
|
if (this.maxWrittenFrame < 0) {
|
||||||
|
return; // Nothing to finalize
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
data: outputData,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.onSample(audioSample);
|
||||||
|
|
||||||
|
this.outputBuffer.fill(0);
|
||||||
|
this.maxWrittenFrame = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
finalize() {
|
||||||
|
return this.finalizeCurrentBuffer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user