diff --git a/dev/convert.html b/dev/convert.html index e58be51..febc93b 100644 --- a/dev/convert.html +++ b/dev/convert.html @@ -11,275 +11,9 @@ 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 @@ -305,7 +39,7 @@ function createTestBuffer(sampleRate = 48000, duration = 1, frequency = 440) { }), audio: { numberOfChannels: 1, - sampleRate: 16000 + sampleRate: 8000 //discard: true //forceReencode: true, }, diff --git a/src/conversion.ts b/src/conversion.ts index 940f8f7..3b0cb68 100644 --- a/src/conversion.ts +++ b/src/conversion.ts @@ -112,18 +112,8 @@ const FALLBACK_NUMBER_OF_CHANNELS = 2; const FALLBACK_SAMPLE_RATE = 48000; /** - * Utility function to convert one media file into another. In addition to conversion, this function 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. + * Represents a media file conversion process, used to convert one media file into another. In addition to conversion, + * this class can be used to resize and rotate video, resample audio, drop tracks, or trim to a specific time range. * @public */ 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 - * 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; /** * A callback that is fired whenever the conversion progresses. Only called if the `computeProgress` option * is enabled. */ - onProgress?: () => unknown = undefined; + onProgress?: (progress: number) => unknown = undefined; /** The list of tracks that are included in the output file. */ utilizedTracks: InputTrack[] = []; @@ -333,6 +324,22 @@ export class Conversion { const outputTrackCounts = this._output.format.getSupportedTrackCounts(); 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) { this.discardedTracks.push({ track, @@ -350,24 +357,8 @@ export class Conversion { } if (track.isVideoTrack()) { - if (this._options.video?.discard) { - this.discardedTracks.push({ - track, - reason: 'discardedByUser', - }); - continue; - } - await this._processVideoTrack(track); } else if (track.isAudioTrack()) { - if (this._options.audio?.discard) { - this.discardedTracks.push({ - track, - reason: 'discardedByUser', - }); - continue; - } - await this._processAudioTrack(track); } } @@ -383,7 +374,7 @@ export class Conversion { await this._input.computeDuration() - this._startTimestamp, this._endTimestamp - this._startTimestamp, ); - this.onProgress?.(); + this.onProgress?.(this.progress); } } @@ -406,9 +397,9 @@ export class Conversion { await this._output.finalize(); - if (this._options.computeProgress) { + if (this._options.computeProgress && 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 newProgress = clamp(averageTimestamp / this._totalDuration, 0, 1); - this.progress = 0.99 * clamp(averageTimestamp / this._totalDuration, 0, 1); - this.onProgress?.(); + if (newProgress !== this.progress) { + this.progress = newProgress; + this.onProgress?.(this.progress); + } } } diff --git a/src/index.ts b/src/index.ts index bce8770..1022169 100644 --- a/src/index.ts +++ b/src/index.ts @@ -121,7 +121,7 @@ export { AudioBufferSink, WrappedAudioBuffer, } from './media-sink'; -export { convert, ConversionOptions, Conversion } from './conversion'; +export { ConversionOptions, Conversion } from './conversion'; export { CustomVideoDecoder, CustomAudioDecoder,