// Iterative radix-2 Cooley-Tukey FFT with precomputed twiddles and bit-reversal. // // Allocated once per size and reused across all ~21k frames of a track — the // analysis pass is the one place in this project where JS throughput actually // matters, and per-frame allocation is what would sink it. export class FFT { constructor(size) { if ((size & (size - 1)) !== 0) throw new Error(`FFT size must be a power of two, got ${size}`); this.size = size; this.half = size >> 1; this.re = new Float32Array(size); this.im = new Float32Array(size); this.magnitude = new Float32Array(this.half); // Bit-reversal permutation table. this.rev = new Uint32Array(size); const bits = Math.log2(size); for (let i = 0; i < size; i++) { let r = 0; for (let b = 0; b < bits; b++) if (i & (1 << b)) r |= 1 << (bits - 1 - b); this.rev[i] = r; } // Twiddle factors, flattened per stage. this.cos = new Float32Array(this.half); this.sin = new Float32Array(this.half); for (let i = 0; i < this.half; i++) { this.cos[i] = Math.cos((-2 * Math.PI * i) / size); this.sin[i] = Math.sin((-2 * Math.PI * i) / size); } } /** * Forward transform of a real windowed signal. Writes into `this.magnitude` * (first size/2 bins) and returns it. The buffer is reused: copy it if you * need to keep it. */ forward(input) { const { size, re, im, rev, cos, sin } = this; for (let i = 0; i < size; i++) { re[i] = input[rev[i]]; im[i] = 0; } for (let len = 2; len <= size; len <<= 1) { const halfLen = len >> 1; const step = size / len; for (let i = 0; i < size; i += len) { for (let j = 0, k = 0; j < halfLen; j++, k += step) { const c = cos[k], s = sin[k]; const a = i + j, b = a + halfLen; const tre = re[b] * c - im[b] * s; const tim = re[b] * s + im[b] * c; re[b] = re[a] - tre; im[b] = im[a] - tim; re[a] += tre; im[a] += tim; } } } const mag = this.magnitude; const scale = 2 / size; for (let i = 0; i < this.half; i++) { mag[i] = Math.sqrt(re[i] * re[i] + im[i] * im[i]) * scale; } return mag; } } /** Periodic Hann window. Periodic (not symmetric) is correct for STFT analysis. */ export function hannWindow(size) { const w = new Float32Array(size); for (let i = 0; i < size; i++) w[i] = 0.5 * (1 - Math.cos((2 * Math.PI * i) / size)); return w; }