From 71ca86456cd7a6a3d6de7c0bf4f0bd8811f42507 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sat, 8 Aug 2026 12:01:42 -0700 Subject: [PATCH] perf(core): reuse the FFT scratch arrays across carve windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both analysis loops allocated a fresh `Float64Array(4096)` pair inside the per-window loop. A 5-minute 48 kHz voiceover is ~7030 windows, so one carve churned ~460 MB of transient Float64Array through the main thread — the panel's thread — for a measurement that needs 64 KiB of scratch. Now one pair is reused; `re` is fully overwritten each window and only `im` has to be cleared. Wall-clock is unchanged (591 ms vs 596 ms on that file, within noise): the FFT dominates and a bump allocation is nearly free. This is a GC-pressure fix, not a latency one, and the band values it produces are bit-identical. The review's third loop is `analyseCarveDuck`, which runs `windowDb` rather than an FFT and allocates nothing per window — nothing to fix there. Striding the Welch hops was tried and REJECTED. It is 27x faster on a 5-minute voiceover (427 ms -> 15 ms), but it moves the result: at strength 0.9 the chosen band set changed (630 Hz became 160 Hz), and it did not converge back to the full read even at 2048 windows. That is the author's carve being silently redrawn to save time on a measurement that already locks the rack while it runs. The reason is recorded in the code so it is not re-attempted. Falsified by deleting `im.fill(0)` from both loops: five tests fail, including the two added here — a steady tone must measure the same bands whatever its length, and its dynamics envelope must sit still rather than sliding as the contamination accumulates. Co-Authored-By: Claude Opus 5 (1M context) --- packages/core/src/audioCarve.test.ts | 42 ++++++++++++++++++++++++++++ packages/core/src/audioCarve.ts | 23 ++++++++++++--- 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/packages/core/src/audioCarve.test.ts b/packages/core/src/audioCarve.test.ts index 519afc875e..468ee27b4a 100644 --- a/packages/core/src/audioCarve.test.ts +++ b/packages/core/src/audioCarve.test.ts @@ -249,6 +249,48 @@ describe("analyseCarveBands", () => { }); }); +/** + * Both analysis loops reuse one pair of FFT scratch arrays across every window + * rather than allocating a pair per hop — a 5-minute 48 kHz voiceover is ~7000 + * hops, so ~460 MB of transient Float64Array used to churn through the main + * thread for one carve. `re` is fully overwritten each window, but `im` is only + * ever added to, so it has to be cleared; missing that, the imaginary part + * accumulates across windows and every spectrum after the first is wrong by a + * growing amount. + */ +describe("reused FFT scratch across windows", () => { + /** A steady tone on an exact bin centre (48000/4096 x 128), so every window is identical. */ + const steady = (seconds: number): Float32Array => { + const n = Math.floor(SR * seconds); + const out = new Float32Array(n); + for (let i = 0; i < n; i++) out[i] = 0.5 * Math.sin((2 * Math.PI * 1500 * i) / SR); + return out; + }; + + it("measures the same bands however many windows the clip has", () => { + // Every window carries the same spectrum, so the Welch average cannot + // depend on how many were averaged — unless one window is contaminating + // the next. + const short = analyseCarveBands(steady(0.5), SR, PROFILE); + const long = analyseCarveBands(steady(12), SR, PROFILE); + expect(short.length).toBeGreaterThan(0); + expect(long).toEqual(short); + }); + + it("keeps a steady tone's dynamics envelope flat instead of drifting", () => { + const [lane] = analyseCarveDynamics(steady(12), SR, [{ freq: 1600, gainDb: -8, q: 1.4 }]); + const value = (t: number): number => + sampleAutomationLane({ target: "fx.n1.gain", points: lane!.points }, t); + // Past the attack the cut has to sit still, because the signal does. A + // window contaminated by the one before it grows the measured power over + // the clip, and the envelope — which is relative to the band's own peak — + // slides with it. + expect(value(4)).toBeLessThan(-1); + expect(value(8)).toBeCloseTo(value(4), 0); + expect(value(11)).toBeCloseTo(value(4), 0); + }); +}); + describe("carveBandsToChain", () => { it("turns bands into peaking nodes carrying the analysed values", () => { const chain = carveBandsToChain([{ freq: 1000, gainDb: -6, q: 1.4 }]); diff --git a/packages/core/src/audioCarve.ts b/packages/core/src/audioCarve.ts index 1a0fac18a0..4aff6ee6dd 100644 --- a/packages/core/src/audioCarve.ts +++ b/packages/core/src/audioCarve.ts @@ -313,12 +313,23 @@ function powerSpectrum( const bins = FRAME / 2 + 1; const acc = new Float64Array(bins); + // Reused across hops. These used to be allocated inside the loop: a 5-minute + // 48 kHz voiceover is ~7000 hops, so ~460 MB of transient Float64Array + // churned through the main thread for a single carve. `re` is fully + // overwritten below; only `im` has to be cleared. + const re = new Float64Array(FRAME); + const im = new Float64Array(FRAME); + // + // Every hop is still read. Striding them — Welch's average is supposed to + // settle long before 7000 windows — was measured on a 5-minute voiceover and + // moves the result: at strength 0.9 the chosen band set changed (630 Hz for + // 160 Hz), and it did not converge back to the full read even at 2048 + // windows. 27x faster is not worth silently redrawing the author's carve. let frames = 0; for (let start = 0; start + FRAME <= n; start += HOP) { // Goertzel-free naive DFT would be O(n^2); use a real FFT via recursion on // a copied frame. FRAME is a power of two so the radix-2 split is exact. - const re = new Float64Array(FRAME); - const im = new Float64Array(FRAME); + im.fill(0); for (let i = 0; i < FRAME; i++) re[i] = (padded[start + i] ?? 0) * window[i]!; fft(re, im); for (let k = 0; k < bins; k++) acc[k]! += re[k]! * re[k]! + im[k]! * im[k]!; @@ -554,9 +565,13 @@ export function analyseCarveDynamics( const times: number[] = []; const perBand = bands.map(() => [] as number[]); + // Reused across windows, as in powerSpectrum. `re` is fully overwritten + // below; only `im` has to be cleared. The hop here is already bounded by + // POINT_BUDGET, so there is nothing to stride. + const re = new Float64Array(FRAME); + const im = new Float64Array(FRAME); for (let start = 0; start < voice.length; start += hop) { - const re = new Float64Array(FRAME); - const im = new Float64Array(FRAME); + im.fill(0); for (let i = 0; i < FRAME; i++) re[i] = (voice[start + i] ?? 0) * window[i]!; fft(re, im); const power: number[] = [];