Skip to content

Commit 57faf4a

Browse files
committed
Vendor soundtouch as local audio module
1 parent 041e24e commit 57faf4a

8 files changed

Lines changed: 1195 additions & 102 deletions

File tree

package-lock.json

Lines changed: 0 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,6 @@
7676
"react-hotkeys-hook": "^5.2.1",
7777
"react-resizable-panels": "^3.0.6",
7878
"sonner": "^2.0.7",
79-
"soundtouchjs": "^0.2.1",
8079
"tailwind-merge": "^2.6.0",
8180
"tailwindcss-animate": "^1.0.7",
8281
"zod": "^4.1.12",

src/features/composition-runtime/worklets/soundtouch-preview-processor.worklet.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { SimpleFilter, SoundTouch } from 'soundtouchjs'
1+
import { TimeStretchFilter, TimeStretchProcessor } from '@/lib/audio/time-stretch'
22
import {
33
SOUND_TOUCH_PREVIEW_PROCESSOR_NAME,
44
type SoundTouchPreviewProcessorMessage,
@@ -22,12 +22,12 @@ declare function registerProcessor(
2222

2323
class SoundTouchPreviewProcessor extends AudioWorkletProcessor {
2424
private readonly source = new QueuedStereoBufferSource()
25-
private readonly soundTouch = new SoundTouch()
26-
private readonly filter = new SimpleFilter(
25+
private readonly processor = new TimeStretchProcessor()
26+
private readonly filter = new TimeStretchFilter(
2727
this.source as {
2828
extract: (target: Float32Array, numFrames: number, sourcePosition?: number) => number
2929
},
30-
this.soundTouch,
30+
this.processor,
3131
)
3232
private scratch = new Float32Array(256)
3333
private playing = false
@@ -43,9 +43,9 @@ class SoundTouchPreviewProcessor extends AudioWorkletProcessor {
4343
}
4444

4545
private applySettings(): void {
46-
this.soundTouch.tempo = Math.max(0.01, this.tempo)
47-
this.soundTouch.pitch = Math.max(0.01, this.pitch)
48-
this.soundTouch.rate = 1
46+
this.processor.tempo = Math.max(0.01, this.tempo)
47+
this.processor.pitch = Math.max(0.01, this.pitch)
48+
this.processor.rate = 1
4949
}
5050

5151
private handleMessage(message: SoundTouchPreviewProcessorMessage): void {

src/features/export/utils/canvas-audio.ts

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1416,8 +1416,8 @@ function applyClipFadeSpans(
14161416
}
14171417

14181418
/**
1419-
* Apply speed change to audio with pitch preservation using SoundTouch algorithm.
1420-
* Processes all channels together through a single SoundTouch instance so that
1419+
* Apply speed change to audio with pitch preservation using the local time-stretch processor.
1420+
* Processes all channels together through a single processor instance so that
14211421
* WSOLA overlap windows are consistent across channels (prevents phase drift
14221422
* between L/R that causes a hollow sound).
14231423
*
@@ -1432,31 +1432,31 @@ async function applySpeedAndPitch(
14321432
pitchShiftSemitones: number,
14331433
sampleRate: number,
14341434
): Promise<Float32Array[]> {
1435-
const requiresSoundTouch =
1435+
const requiresTimeStretch =
14361436
Math.abs(speed - 1) > 0.0001 || isAudioPitchShiftActive(pitchShiftSemitones)
1437-
if (!requiresSoundTouch) return channels
1437+
if (!requiresTimeStretch) return channels
14381438
if (channels.length === 0 || channels[0]!.length === 0) return channels
14391439

14401440
const numChannels = channels.length
14411441
const samplesPerChannel = channels[0]!.length
14421442

1443-
log.debug('Applying speed/pitch change (SoundTouch)', {
1443+
log.debug('Applying speed/pitch change with time-stretch processor', {
14441444
speed,
14451445
pitchShiftSemitones,
14461446
sampleRate,
14471447
numChannels,
14481448
})
14491449

14501450
try {
1451-
const soundtouch = await import('soundtouchjs')
1452-
const st = new soundtouch.SoundTouch()
1451+
const timeStretch = await import('@/lib/audio/time-stretch')
1452+
const st = new timeStretch.TimeStretchProcessor()
14531453

14541454
st.tempo = speed
14551455
st.pitch = getAudioPitchRatioFromSemitones(pitchShiftSemitones)
14561456
st.rate = 1.0
14571457

1458-
// SoundTouch processes interleaved stereo. Interleave all channels
1459-
// (for mono, duplicate to stereo so SoundTouch gets valid input).
1458+
// The processor consumes interleaved stereo. Interleave all channels
1459+
// (for mono, duplicate to stereo so it gets valid input).
14601460
const stereoInput = new Float32Array(samplesPerChannel * 2)
14611461
const left = channels[0]!
14621462
const right = numChannels >= 2 ? channels[1]! : left
@@ -1479,7 +1479,7 @@ async function applySpeedAndPitch(
14791479
},
14801480
}
14811481

1482-
const filter = new soundtouch.SimpleFilter(source, st)
1482+
const filter = new timeStretch.TimeStretchFilter(source, st)
14831483

14841484
const expectedOutputLength = Math.floor(samplesPerChannel / speed)
14851485
const stereoOutput = new Float32Array(expectedOutputLength * 2)
@@ -1522,7 +1522,7 @@ async function applySpeedAndPitch(
15221522
outputChannels.push(outLeft)
15231523
}
15241524

1525-
log.debug('SoundTouch time stretch complete', {
1525+
log.debug('Time-stretch processing complete', {
15261526
inputLength: samplesPerChannel,
15271527
outputLength: actualOutputLength,
15281528
expectedLength: expectedOutputLength,
@@ -1532,14 +1532,14 @@ async function applySpeedAndPitch(
15321532

15331533
return outputChannels
15341534
} catch (error) {
1535-
log.warn('SoundTouch failed, falling back to simple resampling', {
1535+
log.warn('Time-stretch processing failed, falling back to simple resampling', {
15361536
error,
15371537
speed,
15381538
pitchShiftSemitones,
15391539
})
15401540

15411541
if (isAudioPitchShiftActive(pitchShiftSemitones)) {
1542-
log.warn('Independent export pitch shift was skipped because SoundTouch failed to load')
1542+
log.warn('Independent export pitch shift was skipped because time-stretch processing failed')
15431543
}
15441544

15451545
// Fallback: simple resampling per-channel (speed only, pitch shift omitted)
@@ -1767,7 +1767,7 @@ export async function processAudio(
17671767
// Note: decoded audio is already trimmed to the range we requested.
17681768

17691769
// Apply speed across ALL channels at once to maintain phase coherence
1770-
// between L/R (SoundTouch WSOLA finds shared overlap windows).
1770+
// between L/R (the WSOLA pipeline finds shared overlap windows).
17711771
let processedChannels = decoded.samples
17721772
if (
17731773
Math.abs(segment.speed - 1) > 0.0001 ||

0 commit comments

Comments
 (0)