perf(core): reuse the FFT scratch arrays across carve windows

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) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-08-08 12:01:42 -07:00
co-authored by Claude Opus 5
parent 5e166ad47c
commit 71ca86456c
2 changed files with 61 additions and 4 deletions
+42
View File
@@ -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 }]);
+19 -4
View File
@@ -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[] = [];