Implement custom audio resampler for conversion

This commit is contained in:
Vanilagy
2025-05-29 19:41:26 +02:00
parent 685b5087f5
commit 71a8f65578
2 changed files with 565 additions and 83 deletions
+271 -2
View File
@@ -11,9 +11,275 @@
progress.max = 1;
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 () => {
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 target = new Metamuxer.BufferTarget() ?? new Metamuxer.StreamTarget(new WritableStream({
write: console.log
@@ -21,7 +287,7 @@
chunked: true,
chunkSize: 2**20
});
const outputFormat = new Metamuxer.Mp4OutputFormat();
const outputFormat = new Metamuxer.WavOutputFormat();
const button = document.createElement('button');
button.textContent = 'Cancel';
@@ -38,6 +304,8 @@
target
}),
audio: {
numberOfChannels: 1,
sampleRate: 16000
//discard: true
//forceReencode: true,
},
@@ -66,7 +334,8 @@
},
*/
video: {
width: 640
discard: true,
//width: 640
//forceReencode: true,
//rotate: 90
//width: 720 ?? 2160,