mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-10-02 21:33:52 +02:00
Small conversion logic & API adjustments
This commit is contained in:
+1
-267
@@ -11,275 +11,9 @@
|
|||||||
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
|
||||||
@@ -305,7 +39,7 @@ function createTestBuffer(sampleRate = 48000, duration = 1, frequency = 440) {
|
|||||||
}),
|
}),
|
||||||
audio: {
|
audio: {
|
||||||
numberOfChannels: 1,
|
numberOfChannels: 1,
|
||||||
sampleRate: 16000
|
sampleRate: 8000
|
||||||
//discard: true
|
//discard: true
|
||||||
//forceReencode: true,
|
//forceReencode: true,
|
||||||
},
|
},
|
||||||
|
|||||||
+29
-35
@@ -112,18 +112,8 @@ const FALLBACK_NUMBER_OF_CHANNELS = 2;
|
|||||||
const FALLBACK_SAMPLE_RATE = 48000;
|
const FALLBACK_SAMPLE_RATE = 48000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Utility function to convert one media file into another. In addition to conversion, this function can be used to
|
* Represents a media file conversion process, used to convert one media file into another. In addition to conversion,
|
||||||
* resize and rotate video, resample audio, drop tracks, or trim to a specific time range.
|
* this class can be used to resize and rotate video, resample audio, drop tracks, or trim to a specific time range.
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
export const convert = async (options: ConversionOptions) => {
|
|
||||||
const conversion = await Conversion.init(options);
|
|
||||||
await conversion.execute();
|
|
||||||
return conversion;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents a media file conversion process.
|
|
||||||
* @public
|
* @public
|
||||||
*/
|
*/
|
||||||
export class Conversion {
|
export class Conversion {
|
||||||
@@ -171,14 +161,15 @@ export class Conversion {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* A number between 0 and 1, indicating the completion of the conversion. If the `computeProgress` option is not
|
* A number between 0 and 1, indicating the completion of the conversion. If the `computeProgress` option is not
|
||||||
* enabled, this value will be stuck at 0.
|
* enabled, this value will be stuck at 0. Note that a progress of 1 doesn't necessarily mean the conversion is
|
||||||
|
* complete; the conversion is complete once `execute` resolves.
|
||||||
*/
|
*/
|
||||||
progress = 0;
|
progress = 0;
|
||||||
/**
|
/**
|
||||||
* A callback that is fired whenever the conversion progresses. Only called if the `computeProgress` option
|
* A callback that is fired whenever the conversion progresses. Only called if the `computeProgress` option
|
||||||
* is enabled.
|
* is enabled.
|
||||||
*/
|
*/
|
||||||
onProgress?: () => unknown = undefined;
|
onProgress?: (progress: number) => unknown = undefined;
|
||||||
|
|
||||||
/** The list of tracks that are included in the output file. */
|
/** The list of tracks that are included in the output file. */
|
||||||
utilizedTracks: InputTrack[] = [];
|
utilizedTracks: InputTrack[] = [];
|
||||||
@@ -333,6 +324,22 @@ export class Conversion {
|
|||||||
const outputTrackCounts = this._output.format.getSupportedTrackCounts();
|
const outputTrackCounts = this._output.format.getSupportedTrackCounts();
|
||||||
|
|
||||||
for (const track of inputTracks) {
|
for (const track of inputTracks) {
|
||||||
|
if (track.isVideoTrack() && this._options.video?.discard) {
|
||||||
|
this.discardedTracks.push({
|
||||||
|
track,
|
||||||
|
reason: 'discardedByUser',
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (track.isAudioTrack() && this._options.audio?.discard) {
|
||||||
|
this.discardedTracks.push({
|
||||||
|
track,
|
||||||
|
reason: 'discardedByUser',
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (this._totalTrackCount === outputTrackCounts.total.max) {
|
if (this._totalTrackCount === outputTrackCounts.total.max) {
|
||||||
this.discardedTracks.push({
|
this.discardedTracks.push({
|
||||||
track,
|
track,
|
||||||
@@ -350,24 +357,8 @@ export class Conversion {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (track.isVideoTrack()) {
|
if (track.isVideoTrack()) {
|
||||||
if (this._options.video?.discard) {
|
|
||||||
this.discardedTracks.push({
|
|
||||||
track,
|
|
||||||
reason: 'discardedByUser',
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this._processVideoTrack(track);
|
await this._processVideoTrack(track);
|
||||||
} else if (track.isAudioTrack()) {
|
} else if (track.isAudioTrack()) {
|
||||||
if (this._options.audio?.discard) {
|
|
||||||
this.discardedTracks.push({
|
|
||||||
track,
|
|
||||||
reason: 'discardedByUser',
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this._processAudioTrack(track);
|
await this._processAudioTrack(track);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -383,7 +374,7 @@ export class Conversion {
|
|||||||
await this._input.computeDuration() - this._startTimestamp,
|
await this._input.computeDuration() - this._startTimestamp,
|
||||||
this._endTimestamp - this._startTimestamp,
|
this._endTimestamp - this._startTimestamp,
|
||||||
);
|
);
|
||||||
this.onProgress?.();
|
this.onProgress?.(this.progress);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -406,9 +397,9 @@ export class Conversion {
|
|||||||
|
|
||||||
await this._output.finalize();
|
await this._output.finalize();
|
||||||
|
|
||||||
if (this._options.computeProgress) {
|
if (this._options.computeProgress && this.progress !== 1) {
|
||||||
this.progress = 1;
|
this.progress = 1;
|
||||||
this.onProgress?.();
|
this.onProgress?.(this.progress);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -845,9 +836,12 @@ export class Conversion {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const averageTimestamp = totalTimestamps / this._totalTrackCount;
|
const averageTimestamp = totalTimestamps / this._totalTrackCount;
|
||||||
|
const newProgress = clamp(averageTimestamp / this._totalDuration, 0, 1);
|
||||||
|
|
||||||
this.progress = 0.99 * clamp(averageTimestamp / this._totalDuration, 0, 1);
|
if (newProgress !== this.progress) {
|
||||||
this.onProgress?.();
|
this.progress = newProgress;
|
||||||
|
this.onProgress?.(this.progress);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -121,7 +121,7 @@ export {
|
|||||||
AudioBufferSink,
|
AudioBufferSink,
|
||||||
WrappedAudioBuffer,
|
WrappedAudioBuffer,
|
||||||
} from './media-sink';
|
} from './media-sink';
|
||||||
export { convert, ConversionOptions, Conversion } from './conversion';
|
export { ConversionOptions, Conversion } from './conversion';
|
||||||
export {
|
export {
|
||||||
CustomVideoDecoder,
|
CustomVideoDecoder,
|
||||||
CustomAudioDecoder,
|
CustomAudioDecoder,
|
||||||
|
|||||||
Reference in New Issue
Block a user