What's changed
Server performance
- Float32Array-native FFT — replaced fft.js (which required plain
number[]arrays V8 cannot optimise) with a self-contained radix-2 Cooley-Tukey FFT operating entirely on Float32Array. Pre-computed twiddle factors and bit-reversal table allocated once per instance. - Async deflate —
deflateRawSyncreplaced withpromisify(deflateRaw). Compression now runs on libuv's thread pool; was blocking the Node.js event loop for up to 150 ms/sec at N=65536 bins. - NCO lookup tables in FmStereoDemod —
Math.sin(pllPhase)andMath.cos(2×pllPhase)replaced with 4096-entry Float32Array table + bitwise-AND index wrap. Eliminates ~480,000 trig calls/sec per WFM client.cos(2θ)computed via double-angle identity2cos²θ−1from the same table entry. - NCO lookup table in RdsDecoder —
Math.cos(ncoPhase)at 240 kHz replaced with same table pattern. Eliminates ~240,000 calls/sec. - fastAtan2 for FM discriminator — 3rd-order minimax polynomial approximation replaces
Math.atan2inFmStereoDemodandFmMonoDemod. Max error ~0.005 rad; ~4–6× faster than V8's libm call. - SimpleFir Float64→Float32 — inner 51-tap MAC loop (24.5M iter/sec for WFM) now uses Float32Array; halves memory bandwidth, enables V8 SIMD paths.
- pendingFrame pre-allocated —
FftProcessorno longer allocatesnew Float32Array(fftSize)30×/sec; eliminates 7.5 MB/sec of GC pressure at N=65536. - CQuamStereoDemod output buffers pre-allocated — was allocating two Float32Arrays per
process()call (750 KB/sec GC pressure removed). - ImaAdpcmEncoder zero-alloc path —
encode()now accepts an optional pre-allocated output buffer; IQ hot path passes a per-client scratch buffer. - encodeFftAdpcm singleton encoder — module-level
ImaAdpcmEncoderinstance replacesnew ImaAdpcmEncoder()per FFT frame. - generateSignalSample inlined — demo mode no longer allocates ~20,000
{i,q}objects per 33 ms interval.
Audio pop / break fixes
- Buffer corruption after async deflate — IQ extraction loop moved before the first
awaitin_handleIqDataAsyncwith a defensiveBuffer.from(rawData)copy. Node.js stream internals reuse the read buffer before anawaitresumes; this was silently corrupting IQ data and producing audio pops at ~2–3/sec. - Async deflate outside client iterator — deflate payloads are now computed before entering the client loop (safe Map iteration, no
awaitinside the iterator). - Stale IQ on tune —
iqAccumBuffer.fill(0)added alongsideiqAccumPos = 0on tune command. Old frequency data was bleeding into the first chunk after a retune. - Jitter buffer single-underrun pop — AudioWorklet now requires 3 consecutive underruns before resetting
playingto false. A single late IQ chunk previously caused 150 ms of hard silence (perceived as a loud pop/click). - Opus channel snapshot —
OpusAudioPipeline.process()snapshotsthis.channelsinto a local constant before the PCM accumulation loop. A mid-call stereo↔mono switch could previously corruptpcmBufferPosand produce malformed Opus frames. - Dead decimCounterR removed —
FmStereoDemodhad an unused counter that was incremented but never read.