diff --git a/streamer/src/fft.ts b/streamer/src/fft.ts new file mode 100644 index 0000000..57620ee --- /dev/null +++ b/streamer/src/fft.ts @@ -0,0 +1,117 @@ +export interface SpectrumOpts { + bars: number; + sampleRate: number; + fftSize?: number; + smoothing?: number; +} + +export class SpectrumAnalyzer { + private fftSize: number; + private window: Float32Array; + private buffer: Float32Array; + private bufFill = 0; + private smooth: Float32Array; + private bandStarts: number[]; + private bandEnds: number[]; + + constructor(private opts: SpectrumOpts) { + this.fftSize = opts.fftSize ?? 1024; + this.window = new Float32Array(this.fftSize); + for (let i = 0; i < this.fftSize; i++) { + this.window[i] = 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / (this.fftSize - 1)); + } + this.buffer = new Float32Array(this.fftSize); + this.smooth = new Float32Array(opts.bars); + [this.bandStarts, this.bandEnds] = this.makeBands(); + } + + private makeBands(): [number[], number[]] { + const minHz = 40; + const maxHz = Math.min(16000, this.opts.sampleRate / 2); + const fftBin = (hz: number) => Math.round((hz / this.opts.sampleRate) * this.fftSize); + const starts: number[] = []; + const ends: number[] = []; + for (let i = 0; i < this.opts.bars; i++) { + const a = minHz * Math.pow(maxHz / minHz, i / this.opts.bars); + const b = minHz * Math.pow(maxHz / minHz, (i + 1) / this.opts.bars); + starts.push(Math.max(1, fftBin(a))); + ends.push(Math.max(starts[i]! + 1, fftBin(b))); + } + return [starts, ends]; + } + + feed(pcm: Buffer): void { + const samples = pcm.length / 4; + let bi = this.bufFill; + for (let i = 0; i < samples; i++) { + const l = pcm.readInt16LE(i * 4); + const r = pcm.readInt16LE(i * 4 + 2); + this.buffer[bi] = ((l + r) / 2) / 32768; + bi++; + if (bi >= this.fftSize) { + this.computeFrame(); + bi = 0; + } + } + this.bufFill = bi; + } + + bars(): number[] { + const out = new Array(this.opts.bars); + for (let i = 0; i < this.opts.bars; i++) out[i] = this.smooth[i]!; + return out; + } + + private computeFrame(): void { + const re = new Float32Array(this.fftSize); + const im = new Float32Array(this.fftSize); + for (let i = 0; i < this.fftSize; i++) re[i] = this.buffer[i]! * this.window[i]!; + fftInPlace(re, im); + + const alpha = this.opts.smoothing ?? 0.6; + const peakRef = this.fftSize / 4; + 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); + this.smooth[i] = alpha * this.smooth[i]! + (1 - alpha) * norm; + } + } +} + +function fftInPlace(re: Float32Array, im: Float32Array): void { + const n = re.length; + for (let i = 1, j = 0; i < n; i++) { + let bit = n >> 1; + for (; j & bit; bit >>= 1) j ^= bit; + j ^= bit; + if (i < j) { + [re[i], re[j]] = [re[j]!, re[i]!]; + [im[i], im[j]] = [im[j]!, im[i]!]; + } + } + for (let len = 2; len <= n; len <<= 1) { + const halfLen = len >> 1; + const angle = (-2 * Math.PI) / len; + const wre = Math.cos(angle); + const wim = Math.sin(angle); + for (let i = 0; i < n; i += len) { + let cre = 1; + let cim = 0; + for (let k = 0; k < halfLen; k++) { + const tre = cre * re[i + k + halfLen]! - cim * im[i + k + halfLen]!; + const tim = cre * im[i + k + halfLen]! + cim * re[i + k + halfLen]!; + re[i + k + halfLen] = re[i + k]! - tre; + im[i + k + halfLen] = im[i + k]! - tim; + re[i + k] = re[i + k]! + tre; + im[i + k] = im[i + k]! + tim; + const ncre = cre * wre - cim * wim; + cim = cre * wim + cim * wre; + cre = ncre; + } + } + } +} diff --git a/streamer/tests/fft.test.ts b/streamer/tests/fft.test.ts new file mode 100644 index 0000000..1a5e392 --- /dev/null +++ b/streamer/tests/fft.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { SpectrumAnalyzer } from "../src/fft.ts"; + +function sineFrame(freqHz: number, sampleRate = 48000, samples = 1024): Buffer { + const buf = Buffer.alloc(samples * 2 * 2); // s16le stereo + for (let i = 0; i < samples; i++) { + const v = Math.round(Math.sin((2 * Math.PI * freqHz * i) / sampleRate) * 16000); + buf.writeInt16LE(v, i * 4); + buf.writeInt16LE(v, i * 4 + 2); + } + return buf; +} + +describe("SpectrumAnalyzer", () => { + it("produces N bars of normalized [0..1] floats", () => { + const a = new SpectrumAnalyzer({ bars: 48, sampleRate: 48000 }); + a.feed(sineFrame(1000)); + const bars = a.bars(); + expect(bars).toHaveLength(48); + bars.forEach((v) => { + expect(v).toBeGreaterThanOrEqual(0); + expect(v).toBeLessThanOrEqual(1); + }); + }); + + it("a 1khz sine puts most energy in low bars (logarithmic binning)", () => { + const a = new SpectrumAnalyzer({ bars: 48, sampleRate: 48000 }); + a.feed(sineFrame(1000)); + 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); + }); + + it("silence produces all-near-zero bars", () => { + const a = new SpectrumAnalyzer({ bars: 72, sampleRate: 48000 }); + a.feed(Buffer.alloc(1024 * 4)); + const bars = a.bars(); + bars.forEach((v) => expect(v).toBeLessThan(0.01)); + }); +});