feat(streamer): fft + log-bin spectrum analyzer
This commit is contained in:
parent
48955634af
commit
8bca1767bd
2 changed files with 158 additions and 0 deletions
117
streamer/src/fft.ts
Normal file
117
streamer/src/fft.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
41
streamer/tests/fft.test.ts
Normal file
41
streamer/tests/fft.test.ts
Normal 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));
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue