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[] = [];