mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-09-27 10:53:50 +02:00
Implement custom audio resampler for conversion
This commit is contained in:
+271
-2
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user