From e5773e0c33ffe7206577ed641dee9133c53bb6c7 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 14:38:36 +0100 Subject: [PATCH] perf(dsp): cache the resample kernel when outputs land on input samples 2:1 decimation (44.1k -> 22.05k, used to render the warped MIDI for the alignment page) recomputed 65 windowed-sinc taps per output sample: ~17 s for a 4.7 min stereo render. Outputs at integer input positions now reuse one tap table; arithmetic is identical, so results are bit-identical. Co-Authored-By: Claude Opus 5.5 --- src/dsp/resample.ts | 27 ++++++++++++++++++++------ tests/dsp/resample.test.ts | 39 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 6 deletions(-) create mode 100644 tests/dsp/resample.test.ts diff --git a/src/dsp/resample.ts b/src/dsp/resample.ts index a7433d9..33db07b 100644 --- a/src/dsp/resample.ts +++ b/src/dsp/resample.ts @@ -10,16 +10,31 @@ export function resample(x: Float32Array, from: number, to: number): Float32Arra const out = new Float32Array(outLen); const cutoff = Math.min(1, ratio); // in units of input Nyquist const half = 16 / cutoff; // kernel half-width in input samples (16 zero-crossings) + const tap = (d: number) => { + const w = 0.42 + 0.5 * Math.cos((Math.PI * d) / half) + 0.08 * Math.cos((2 * Math.PI * d) / half); + return cutoff * sinc(cutoff * d) * w; + }; + // When an output lands exactly on an input sample (e.g. 2:1 decimation) the kernel depends only on the integer + // offset d = i - t, so the taps are computed once; the arithmetic is identical to the direct path. + const kLo = Math.ceil(-half), kHi = Math.floor(half); + let table: Float64Array | null = null, tableSum = 0; for (let j = 0; j < outLen; j++) { const t = j / ratio; const lo = Math.max(0, Math.ceil(t - half)), hi = Math.min(x.length - 1, Math.floor(t + half)); let acc = 0, wsum = 0; - for (let i = lo; i <= hi; i++) { - const d = i - t; - const w = 0.42 + 0.5 * Math.cos((Math.PI * d) / half) + 0.08 * Math.cos((2 * Math.PI * d) / half); - const k = cutoff * sinc(cutoff * d) * w; - acc += x[i]! * k; - wsum += k; + if (Number.isInteger(t) && lo === t + kLo && hi === t + kHi) { + if (!table) { + table = new Float64Array(kHi - kLo + 1); + for (let d = kLo; d <= kHi; d++) { table[d - kLo] = tap(d); tableSum += table[d - kLo]!; } + } + for (let i = lo, k = 0; i <= hi; i++, k++) acc += x[i]! * table[k]!; + wsum = tableSum; + } else { + for (let i = lo; i <= hi; i++) { + const k = tap(i - t); + acc += x[i]! * k; + wsum += k; + } } // Normalise to unity DC gain so edges and non-integer ratios stay calibrated. out[j] = wsum !== 0 ? acc / wsum : 0; diff --git a/tests/dsp/resample.test.ts b/tests/dsp/resample.test.ts new file mode 100644 index 0000000..ef15d48 --- /dev/null +++ b/tests/dsp/resample.test.ts @@ -0,0 +1,39 @@ +import { test, expect } from "bun:test"; +import { resample } from "../../src/dsp/resample.ts"; + +// Reference: the direct windowed-sinc evaluation (the original implementation), used to check the cached path is exact. +function reference(x: Float32Array, from: number, to: number): Float32Array { + const sinc = (v: number) => (v === 0 ? 1 : Math.sin(Math.PI * v) / (Math.PI * v)); + const ratio = to / from, outLen = Math.round(x.length * ratio), out = new Float32Array(outLen); + const cutoff = Math.min(1, ratio), half = 16 / cutoff; + for (let j = 0; j < outLen; j++) { + const t = j / ratio; + const lo = Math.max(0, Math.ceil(t - half)), hi = Math.min(x.length - 1, Math.floor(t + half)); + let acc = 0, wsum = 0; + for (let i = lo; i <= hi; i++) { + const d = i - t; + const w = 0.42 + 0.5 * Math.cos((Math.PI * d) / half) + 0.08 * Math.cos((2 * Math.PI * d) / half); + const k = cutoff * sinc(cutoff * d) * w; + acc += x[i]! * k; wsum += k; + } + out[j] = wsum !== 0 ? acc / wsum : 0; + } + return out; +} + +const noise = (n: number) => { let s = 1; return Float32Array.from({ length: n }, () => ((s = (s * 16807) % 2147483647) / 2147483647) * 2 - 1); }; + +test("2:1 decimation is bit-identical to the direct evaluation and fast", () => { + const x = noise(44100); + expect(Array.from(resample(x, 44100, 22050))).toEqual(Array.from(reference(x, 44100, 22050))); + const big = noise(44100 * 60); + const t0 = performance.now(); + resample(big, 44100, 22050); + expect(performance.now() - t0).toBeLessThan(500); // one minute of audio: ~50 ms cached vs ~1.7 s direct +}); + +test("non-integer ratios are unchanged", () => { + const x = noise(4800); + expect(Array.from(resample(x, 48000, 44100))).toEqual(Array.from(reference(x, 48000, 44100))); + expect(Array.from(resample(x, 22050, 44100))).toEqual(Array.from(reference(x, 22050, 44100))); +});