fix: audio pitch

This commit is contained in:
Alan Trebugeais
2026-05-10 20:48:13 +02:00
parent 55a81a0b6a
commit 4c1a63178a
3 changed files with 121 additions and 28 deletions
@@ -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)}%`}
@@ -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;
+115 -5
View File
@@ -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,