From 4c1a63178ae09fc79a4b0eccea58438ff8d6f13f Mon Sep 17 00:00:00 2001 From: Alan Trebugeais Date: Sun, 10 May 2026 20:48:13 +0200 Subject: [PATCH] fix: audio pitch --- src/components/video-editor/SettingsPanel.tsx | 2 +- .../video-editor/audio/useAudioPreviewSync.ts | 27 +--- src/lib/exporter/audioEncoder.ts | 120 +++++++++++++++++- 3 files changed, 121 insertions(+), 28 deletions(-) diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 56e74d01..28285e1e 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -3239,7 +3239,7 @@ export function SettingsPanel({ value={settings.volume} defaultValue={1} min={0} - max={2} + max={1} step={0.01} onChange={(v) => onSourceAudioTrackVolumeChange?.(track.id, v)} formatValue={(v) => `${Math.round(v * 100)}%`} diff --git a/src/components/video-editor/audio/useAudioPreviewSync.ts b/src/components/video-editor/audio/useAudioPreviewSync.ts index 78482460..3ff9d116 100644 --- a/src/components/video-editor/audio/useAudioPreviewSync.ts +++ b/src/components/video-editor/audio/useAudioPreviewSync.ts @@ -189,20 +189,9 @@ export function useAudioPreviewSync({ audio.volume = 1; audio.dataset.sourceAudioPath = audioPath; - const context = ensureSourceAudioContext(); - const masterGain = sourceAudioMasterGainRef.current; - if (context && masterGain && !sourceAudioMediaNodesRef.current.has(audioPath)) { - try { - const mediaNode = context.createMediaElementSource(audio); - const trackGainNode = context.createGain(); - mediaNode.connect(trackGainNode); - trackGainNode.connect(masterGain); - sourceAudioMediaNodesRef.current.set(audioPath, mediaNode); - sourceAudioGainNodesRef.current.set(audioPath, trackGainNode); - } catch (error) { - onSourceFallbackLoadError(error); - } - } + // Web Audio API createMediaElementSource breaks preservesPitch on Chromium. + // We route directly through the HTMLAudioElement to ensure pitch preservation works + // during speed changes. Note: this limits maximum preview volume to 1.0 (100%). if (sourceAudioElementResourcesRef.current.get(audioPath) !== audioPath) { audio.pause(); @@ -245,10 +234,7 @@ export function useAudioPreviewSync({ })(); } - const trackGainNode = sourceAudioGainNodesRef.current.get(audioPath); - if (trackGainNode) { - trackGainNode.gain.value = Math.max(0, Math.min(2, getSourceTrackPreviewGain(audioPath))); - } + audio.volume = Math.max(0, Math.min(1, getSourceTrackPreviewGain(audioPath) * (isCurrentClipMuted ? 0 : previewVolume))); } if (sourceAudioMasterGainRef.current) { @@ -378,10 +364,7 @@ export function useAudioPreviewSync({ for (const audio of sourceAudioElementsRef.current.values()) { const sourceAudioPath = audio.dataset.sourceAudioPath ?? ""; - const trackGainNode = sourceAudioGainNodesRef.current.get(sourceAudioPath); - if (trackGainNode) { - trackGainNode.gain.value = Math.max(0, Math.min(2, getSourceTrackPreviewGain(sourceAudioPath))); - } + audio.volume = Math.max(0, Math.min(1, getSourceTrackPreviewGain(sourceAudioPath) * (isCurrentClipMuted ? 0 : previewVolume))); enablePitchPreservingPlayback(audio); const audioDuration = Number.isFinite(audio.duration) ? audio.duration : null; diff --git a/src/lib/exporter/audioEncoder.ts b/src/lib/exporter/audioEncoder.ts index 8e98091e..182ed090 100644 --- a/src/lib/exporter/audioEncoder.ts +++ b/src/lib/exporter/audioEncoder.ts @@ -1488,23 +1488,133 @@ export class AudioProcessor { const source = ctx.createBufferSource(); const gainNode = ctx.createGain(); gainNode.gain.value = Math.max(0, Math.min(2, gain)); - source.buffer = buffer; - source.playbackRate.value = slice.speed; - source.connect(gainNode); - gainNode.connect(ctx.destination); const sourceOffsetSec = effectiveBufferStartSec + (audibleRange.startSec - (localOutputStartSec + chunkOutputStartSec)) * slice.speed; const localStartSec = audibleRange.startSec - chunkOutputStartSec; const sourceDurationSec = audibleDurationSec * slice.speed; - source.start(localStartSec, sourceOffsetSec, sourceDurationSec); + + const stretchedBuffer = this.stretchAudioBuffer( + buffer, + slice.speed, + sourceOffsetSec, + sourceDurationSec, + audibleDurationSec, + ctx, + ); + + source.buffer = stretchedBuffer; + source.playbackRate.value = 1; + source.connect(gainNode); + gainNode.connect(ctx.destination); + + source.start(localStartSec); } outputOffsetSec += sliceOutputDurationSec; } } + private stretchAudioBuffer( + originalBuffer: AudioBuffer, + speed: number, + sourceOffsetSec: number, + sourceDurationSec: number, + audibleDurationSec: number, + ctx: BaseAudioContext, + ): AudioBuffer { + const sampleRate = originalBuffer.sampleRate; + const channels = originalBuffer.numberOfChannels; + + const startSample = Math.max(0, Math.floor(sourceOffsetSec * sampleRate)); + const sourceSamples = Math.floor(sourceDurationSec * sampleRate); + const endSample = Math.min(originalBuffer.length, startSample + sourceSamples); + + const outSamples = Math.floor(audibleDurationSec * sampleRate); + if (outSamples <= 0 || startSample >= originalBuffer.length) { + return ctx.createBuffer(channels, 1, sampleRate); + } + + const outBuffer = ctx.createBuffer(channels, outSamples, sampleRate); + + if (Math.abs(speed - 1) < 0.001) { + const copyLength = Math.min(endSample - startSample, outSamples); + if (copyLength > 0) { + for (let c = 0; c < channels; c++) { + outBuffer.copyToChannel( + originalBuffer.getChannelData(c).subarray(startSample, startSample + copyLength), + c, + ); + } + } + return outBuffer; + } + + const windowSize = Math.floor(sampleRate * 0.04); + const hopOut = Math.floor(windowSize * 0.5); + const hopIn = Math.floor(hopOut * speed); + const searchRange = Math.floor(sampleRate * 0.015); + + const window = new Float32Array(windowSize); + for (let i = 0; i < windowSize; i++) { + window[i] = 0.5 * (1 - Math.cos((2 * Math.PI * i) / (windowSize - 1))); + } + + for (let c = 0; c < channels; c++) { + const inData = originalBuffer.getChannelData(c); + const outData = outBuffer.getChannelData(c); + + let inOffset = startSample; + let outOffset = 0; + + for (let i = 0; i < windowSize; i++) { + if (inOffset + i < endSample && outOffset + i < outSamples) { + outData[outOffset + i] += inData[inOffset + i] * window[i]; + } + } + + outOffset += hopOut; + inOffset += hopIn; + + while (outOffset + windowSize < outSamples && inOffset < endSample) { + let bestOffset = inOffset; + const minSearch = Math.max(startSample, inOffset - searchRange); + const maxSearch = Math.min(endSample - windowSize, inOffset + searchRange); + + if (maxSearch > minSearch) { + let maxCorr = -Infinity; + let bestDelta = 0; + + for (let testOffset = minSearch; testOffset <= maxSearch; testOffset += 4) { + let corr = 0; + for (let i = 0; i < hopOut; i += 4) { + if (outOffset + i < outSamples && testOffset + i < endSample) { + corr += outData[outOffset + i] * inData[testOffset + i]; + } + } + if (corr > maxCorr) { + maxCorr = corr; + bestDelta = testOffset - inOffset; + } + } + bestOffset = inOffset + bestDelta; + } + + for (let i = 0; i < windowSize; i++) { + if (bestOffset + i < endSample && outOffset + i < outSamples) { + outData[outOffset + i] += inData[bestOffset + i] * window[i]; + } + } + + outOffset += hopOut; + inOffset += hopIn; + } + } + + return outBuffer; + } + // Create a WAV file header for the given audio parameters. private createWavHeader( sampleRate: number,