feat(streamer): fft + log-bin spectrum analyzer

This commit is contained in:
devilreef 2026-04-30 15:19:41 +06:00
parent 48955634af
commit 8bca1767bd
Signed by: devilreef
SSH key fingerprint: SHA256:UZisRr4iuXx+IhkbZnR655L2RWAT6o2rgbGv5F/6m3Y
2 changed files with 158 additions and 0 deletions

117
streamer/src/fft.ts Normal file
View file

@ -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<number>(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;
}
}
}
}

View file

@ -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));
});
});