diff --git a/streamer/src/fft.ts b/streamer/src/fft.ts index 57620ee..de193ed 100644 --- a/streamer/src/fft.ts +++ b/streamer/src/fft.ts @@ -69,14 +69,19 @@ export class SpectrumAnalyzer { fftInPlace(re, im); const alpha = this.opts.smoothing ?? 0.6; - const peakRef = this.fftSize / 4; + // db scaling — same idea as web audio's getByteFrequencyData. + // hann-windowed full-scale sine has peak mag ~ fftSize/2; reference to that. + const ref = this.fftSize / 2; + const dbMin = -80; + const dbMax = -20; for (let i = 0; i < this.opts.bars; i++) { let mag = 0; const s = this.bandStarts[i]!; const e = this.bandEnds[i]!; for (let k = s; k < e; k++) mag += Math.sqrt(re[k]! * re[k]! + im[k]! * im[k]!); mag /= e - s; - const norm = Math.min(1, mag / peakRef); + const db = 20 * Math.log10(Math.max(mag, 1e-9) / ref); + const norm = Math.max(0, Math.min(1, (db - dbMin) / (dbMax - dbMin))); this.smooth[i] = alpha * this.smooth[i]! + (1 - alpha) * norm; } } diff --git a/streamer/tests/fft.test.ts b/streamer/tests/fft.test.ts index 1a5e392..60f27d4 100644 --- a/streamer/tests/fft.test.ts +++ b/streamer/tests/fft.test.ts @@ -23,13 +23,14 @@ describe("SpectrumAnalyzer", () => { }); }); - it("a 1khz sine puts most energy in low bars (logarithmic binning)", () => { + it("a 200hz sine puts most energy in low bars (logarithmic binning)", () => { const a = new SpectrumAnalyzer({ bars: 48, sampleRate: 48000 }); - a.feed(sineFrame(1000)); + for (let n = 0; n < 4; n++) a.feed(sineFrame(200)); const bars = a.bars(); const lowSum = bars.slice(0, 16).reduce((s, v) => s + v, 0); const highSum = bars.slice(32).reduce((s, v) => s + v, 0); - expect(lowSum).toBeGreaterThan(highSum * 5); + expect(lowSum).toBeGreaterThan(0.1); + expect(lowSum).toBeGreaterThan(highSum + 0.1); }); it("silence produces all-near-zero bars", () => {