From 3e89d2998588f8e35c524e523ba2ee853f0f6475 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 20 Apr 2026 11:31:17 +0200 Subject: refactor: restructure files to their domains, new preview overlay system, and dep graph --- apps/web/src/retime/audio-stretch.ts | 182 +++++++++++++++++++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 apps/web/src/retime/audio-stretch.ts (limited to 'apps/web/src/retime/audio-stretch.ts') diff --git a/apps/web/src/retime/audio-stretch.ts b/apps/web/src/retime/audio-stretch.ts new file mode 100644 index 00000000..7df67f36 --- /dev/null +++ b/apps/web/src/retime/audio-stretch.ts @@ -0,0 +1,182 @@ +import { PitchShifter } from "soundtouchjs"; +import { clampRetimeRate, shouldMaintainPitch } from "@/retime/rate"; +import type { RetimeConfig } from "@/timeline"; +import { getSourceTimeAtClipTime } from "./resolve"; + +const RATE_EPSILON = 1e-6; + +function sampleLinear({ + channelData, + position, +}: { + channelData: Float32Array; + position: number; +}): number { + if (position <= 0) { + return channelData[0] ?? 0; + } + const lower = Math.floor(position); + const upper = Math.min(channelData.length - 1, lower + 1); + if (lower >= channelData.length) { + return 0; + } + const fraction = position - lower; + return channelData[lower] * (1 - fraction) + channelData[upper] * fraction; +} + +function buildResampledBuffer({ + audioContext, + sourceBuffer, + trimStart, + clipDuration, + targetSampleRate, + retime, +}: { + audioContext: BaseAudioContext; + sourceBuffer: AudioBuffer; + trimStart: number; + clipDuration: number; + targetSampleRate: number; + retime?: RetimeConfig; +}): AudioBuffer { + const outputLength = Math.max(1, Math.ceil(clipDuration * targetSampleRate)); + const numChannels = Math.max(1, Math.min(2, sourceBuffer.numberOfChannels)); + const outputBuffer = audioContext.createBuffer( + numChannels, + outputLength, + targetSampleRate, + ); + + for (let channel = 0; channel < numChannels; channel++) { + const sourceData = sourceBuffer.getChannelData( + Math.min(channel, sourceBuffer.numberOfChannels - 1), + ); + const outputData = outputBuffer.getChannelData(channel); + + for (let i = 0; i < outputLength; i++) { + const clipTime = i / targetSampleRate; + const sourceTime = + trimStart + getSourceTimeAtClipTime({ clipTime, retime }); + outputData[i] = sampleLinear({ + channelData: sourceData, + position: sourceTime * sourceBuffer.sampleRate, + }); + } + } + + return outputBuffer; +} + +async function buildPitchPreservedBuffer({ + sourceBuffer, + trimStart, + clipDuration, + rate, + targetSampleRate, +}: { + sourceBuffer: AudioBuffer; + trimStart: number; + clipDuration: number; + rate: number; + targetSampleRate: number; +}): Promise { + const nativeSampleRate = sourceBuffer.sampleRate; + const sourceDuration = clipDuration * rate; + const startSample = Math.max(0, Math.floor(trimStart * nativeSampleRate)); + const numSourceSamples = Math.max( + 1, + Math.ceil(sourceDuration * nativeSampleRate), + ); + const available = Math.max(0, sourceBuffer.length - startSample); + const actualSamples = Math.max(1, Math.min(numSourceSamples, available)); + const numChannels = Math.max(1, Math.min(2, sourceBuffer.numberOfChannels)); + + // Resample to targetSampleRate first — soundtouchjs reads raw channel data + // and does not respect the source buffer's native sample rate. + const resampledLength = Math.max( + 1, + Math.ceil(sourceDuration * targetSampleRate), + ); + const resampleCtx = new OfflineAudioContext( + numChannels, + resampledLength, + targetSampleRate, + ); + const nativeBuffer = resampleCtx.createBuffer( + numChannels, + actualSamples, + nativeSampleRate, + ); + + for (let ch = 0; ch < numChannels; ch++) { + const src = sourceBuffer.getChannelData( + Math.min(ch, sourceBuffer.numberOfChannels - 1), + ); + nativeBuffer.copyToChannel( + src.subarray(startSample, startSample + actualSamples), + ch, + ); + } + + const resampleSourceNode = resampleCtx.createBufferSource(); + resampleSourceNode.buffer = nativeBuffer; + resampleSourceNode.connect(resampleCtx.destination); + resampleSourceNode.start(0); + const resampledBuffer = await resampleCtx.startRendering(); + + const outputSamples = Math.max( + 1, + Math.ceil(clipDuration * targetSampleRate), + ); + const stretchCtx = new OfflineAudioContext( + numChannels, + outputSamples, + targetSampleRate, + ); + const shifter = new PitchShifter(stretchCtx, resampledBuffer, 4096); + shifter.tempo = rate; + shifter.pitch = 1; + shifter.connect(stretchCtx.destination); + return stretchCtx.startRendering(); +} + +export async function renderRetimedBuffer({ + audioContext, + sourceBuffer, + trimStart, + clipDuration, + retime, + maintainPitch = false, +}: { + audioContext: BaseAudioContext; + sourceBuffer: AudioBuffer; + trimStart: number; + clipDuration: number; + retime?: RetimeConfig; + maintainPitch?: boolean; +}): Promise { + const targetSampleRate = audioContext.sampleRate; + const rate = clampRetimeRate({ rate: retime?.rate ?? 1 }); + const usePitchPreservation = + shouldMaintainPitch({ rate, maintainPitch }) && + Math.abs(rate - 1) > RATE_EPSILON; + + if (usePitchPreservation) { + return buildPitchPreservedBuffer({ + sourceBuffer, + trimStart, + clipDuration, + rate, + targetSampleRate, + }); + } + + return buildResampledBuffer({ + audioContext, + sourceBuffer, + trimStart, + clipDuration, + targetSampleRate, + retime, + }); +} -- cgit v1.3.1