Initial Commit
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
# `dsp` — shared signal-processing helpers
|
||||
|
||||
## Purpose
|
||||
|
||||
The dsp module is the per-frame DSP toolbox every analyzer in this package leans on. It owns the document→mono read path, frame windowing+FFT, log-spaced bucket-mean reduction from the FFT bins down to the configured `bins`, and the three scalar spectral features (centroid, rolloff, flux). Centralising these primitives keeps `spectrum-analyzer.ts`, `spectrogram.ts`, and `mfcc.ts` thin orchestration layers focused on result-envelope construction.
|
||||
|
||||
## Features
|
||||
|
||||
- `readMono` — fold an `AudioDocument` into a single channel-0 `Float32Array`.
|
||||
- `frameMagnitude` — slice + window + FFT in one call, returning the magnitude bin vector.
|
||||
- `frameCount` — hop-arithmetic frame count for a given document length.
|
||||
- `reduceToBins` — average FFT magnitude bins into `bins` log-spaced buckets.
|
||||
- `spectralCentroid`/`spectralRolloff`/`spectralFlux` — closed-form scalar features.
|
||||
- `logFloor` — `Math.log` floored to `1e-10` so empty mel bands don't produce `-Infinity`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
frameCount,
|
||||
frameMagnitude,
|
||||
readMono,
|
||||
reduceToBins,
|
||||
spectralCentroid,
|
||||
} from "@suaveplan/audio-analysis-spectrum/analyzer/dsp";
|
||||
|
||||
const samples = await readMono(doc);
|
||||
const count = frameCount(samples.length, 2048, 512);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const mag = frameMagnitude(samples, i * 512, 2048, "hann");
|
||||
const peak = reduceToBins(mag, 128);
|
||||
const centroidHz = spectralCentroid(mag, doc.sampleRate, 2048);
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
logFloor,
|
||||
spectralFlux,
|
||||
spectralRolloff,
|
||||
} from "@suaveplan/audio-analysis-spectrum/analyzer/dsp";
|
||||
|
||||
let previous: Float32Array | undefined;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const mag = frameMagnitude(samples, i * 512, 2048, "hann");
|
||||
const flux = spectralFlux(mag, previous);
|
||||
const rolloffHz = spectralRolloff(mag, doc.sampleRate, 2048, 0.9);
|
||||
const logMel = mag.map(logFloor);
|
||||
previous = mag;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `readMono(doc): Promise<Float32Array>` — fold channels.
|
||||
- `frameMagnitude(samples, offset, frameSize, window): Float32Array`.
|
||||
- `frameCount(length, frameSize, hopSize): number`.
|
||||
- `reduceToBins(magnitude, bins): Float32Array`.
|
||||
- `spectralCentroid(magnitude, sampleRate, frameSize): number`.
|
||||
- `spectralRolloff(magnitude, sampleRate, frameSize, threshold?): number`.
|
||||
- `spectralFlux(magnitude, previous): number`.
|
||||
- `logFloor(value): number`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`reduceToBins` falls back to a direct copy when `bins >= magnitude.length`, since a strict bucket mean would be ill-defined; this is exercised by the spectrum-analyzer tests that request `bins ≥ frameSize/2 + 1`. The flux helper handles a `previous = undefined` first frame by returning `0`, which matches every textbook definition where the first frame has no predecessor.
|
||||
@@ -0,0 +1,128 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { makeDoc } from "../../tests/fixtures.js";
|
||||
import {
|
||||
frameCount,
|
||||
frameMagnitude,
|
||||
logFloor,
|
||||
readMono,
|
||||
reduceToBins,
|
||||
spectralCentroid,
|
||||
spectralFlux,
|
||||
spectralRolloff,
|
||||
} from "./dsp.js";
|
||||
|
||||
describe("dsp", () => {
|
||||
it("readMono returns channel-0 samples", { timeout: 5000 }, async () => {
|
||||
const doc = makeDoc(new Float32Array([1, 2, 3, 4]), 48_000);
|
||||
const mono = await readMono(doc);
|
||||
expect(Array.from(mono)).toEqual([1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
it("readMono falls back to empty array when doc.read yields zero channels", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const doc = makeDoc(new Float32Array([1, 2, 3]), 48_000);
|
||||
(doc as unknown as { read: () => Promise<Float32Array[]> }).read =
|
||||
async () => [];
|
||||
const mono = await readMono(doc);
|
||||
expect(mono.length).toBe(0);
|
||||
});
|
||||
|
||||
it("readMono returns empty array for a zero-length document", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const doc = makeDoc(new Float32Array(0), 48_000);
|
||||
const mono = await readMono(doc);
|
||||
expect(mono.length).toBe(0);
|
||||
});
|
||||
|
||||
it("frameCount handles short and long windows", { timeout: 5000 }, () => {
|
||||
expect(frameCount(100, 256, 128)).toBe(0);
|
||||
expect(frameCount(256, 256, 128)).toBe(1);
|
||||
expect(frameCount(1024, 256, 128)).toBe(7);
|
||||
});
|
||||
|
||||
it("frameMagnitude returns frameSize/2+1 bins", { timeout: 5000 }, () => {
|
||||
const samples = new Float32Array(512);
|
||||
samples[0] = 1;
|
||||
const mag = frameMagnitude(samples, 0, 256, "hann");
|
||||
expect(mag.length).toBe(129);
|
||||
});
|
||||
|
||||
it("reduceToBins averages FFT bins log-spaced", { timeout: 5000 }, () => {
|
||||
const mag = new Float32Array(8);
|
||||
for (let i = 0; i < 8; i++) mag[i] = i;
|
||||
const reduced = reduceToBins(mag, 4);
|
||||
expect(reduced.length).toBe(4);
|
||||
// First bucket spans indices [0,1] -> (0+1)/2 = 0.5.
|
||||
expect(reduced[0]).toBeCloseTo(0.5, 6);
|
||||
});
|
||||
|
||||
it("reduceToBins copies through when bins >= input size", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const mag = new Float32Array([1, 2, 3]);
|
||||
const reduced = reduceToBins(mag, 5);
|
||||
expect(reduced.length).toBe(5);
|
||||
expect(reduced[0]).toBe(1);
|
||||
expect(reduced[3]).toBe(0);
|
||||
});
|
||||
|
||||
it("spectralCentroid is 0 for a silent spectrum", { timeout: 5000 }, () => {
|
||||
expect(spectralCentroid(new Float32Array(1025), 48_000, 2048)).toBe(0);
|
||||
});
|
||||
|
||||
it("spectralRolloff is 0 for a silent spectrum", { timeout: 5000 }, () => {
|
||||
expect(spectralRolloff(new Float32Array(1025), 48_000, 2048)).toBe(0);
|
||||
});
|
||||
|
||||
it("spectralRolloff returns the upper bin when all energy is in the last bin", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const mag = new Float32Array(8);
|
||||
mag[7] = 1;
|
||||
const r = spectralRolloff(mag, 1024, 16, 0.85);
|
||||
expect(r).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("spectralRolloff returns the bin just past the threshold", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const mag = new Float32Array(4);
|
||||
mag[0] = 1;
|
||||
mag[3] = 1; // total 2, 0.85 * 2 = 1.7
|
||||
const r = spectralRolloff(mag, 100, 4, 0.85);
|
||||
expect(r).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("spectralRolloff falls through to last bin when no bin exceeds the threshold", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Threshold of 0 reaches at index 0; use threshold > 1 to force fall-through.
|
||||
const mag = new Float32Array([1, 1, 1, 1]);
|
||||
const r = spectralRolloff(mag, 100, 4, 1.5);
|
||||
// 1.5 * 4 = 6 > sum(4); never satisfied; falls through.
|
||||
expect(r).toBe(((mag.length - 1) * 100) / 4);
|
||||
});
|
||||
|
||||
it("spectralFlux returns 0 on the first frame (no previous)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(spectralFlux(new Float32Array([1, 2, 3]), undefined)).toBe(0);
|
||||
});
|
||||
|
||||
it("spectralFlux sums only positive differences", { timeout: 5000 }, () => {
|
||||
const prev = new Float32Array([1, 2, 3]);
|
||||
const next = new Float32Array([2, 1, 5]);
|
||||
// differences: 1, -1, 2; positive sum = 3
|
||||
expect(spectralFlux(next, prev)).toBe(3);
|
||||
});
|
||||
|
||||
it("logFloor clamps tiny values away from -Infinity", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(Number.isFinite(logFloor(0))).toBe(true);
|
||||
expect(Number.isFinite(logFloor(-1))).toBe(true);
|
||||
expect(logFloor(Math.E)).toBeCloseTo(1, 6);
|
||||
});
|
||||
});
|
||||
145
packages/media/audio/audio-analysis-spectrum/src/analyzer/dsp.ts
Normal file
145
packages/media/audio/audio-analysis-spectrum/src/analyzer/dsp.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import type { AudioDocument } from "@suaveplan/audio-core/document/document";
|
||||
import { transform } from "../algorithms/fft.js";
|
||||
import { applyWindow, getWindow } from "../algorithms/window.js";
|
||||
import type { WindowName } from "../algorithms/window.types.js";
|
||||
|
||||
/**
|
||||
* Read the document into a single mono channel (channel-0 if multi-channel),
|
||||
* concatenating pages in order. Universal — uses only `AudioDocument.read`.
|
||||
*/
|
||||
export async function readMono(doc: AudioDocument): Promise<Float32Array> {
|
||||
const channels = await doc.read();
|
||||
const first = channels[0];
|
||||
return first === undefined ? new Float32Array(0) : first;
|
||||
}
|
||||
|
||||
/**
|
||||
* Multiply `samples[offset..offset+frameSize]` by the named window and
|
||||
* return the windowed buffer plus the FFT magnitude.
|
||||
*/
|
||||
export function frameMagnitude(
|
||||
samples: Float32Array,
|
||||
offset: number,
|
||||
frameSize: number,
|
||||
window: WindowName
|
||||
): Float32Array {
|
||||
const w = getWindow(window, frameSize);
|
||||
const windowed = new Float32Array(frameSize);
|
||||
const slice = samples.subarray(offset, offset + frameSize);
|
||||
applyWindow(slice, w, windowed);
|
||||
const { magnitude } = transform(windowed);
|
||||
return magnitude;
|
||||
}
|
||||
|
||||
/**
|
||||
* Number of complete frames that fit in `length` samples with the given
|
||||
* frame and hop sizes. Returns `0` if `length < frameSize`.
|
||||
*/
|
||||
export function frameCount(
|
||||
length: number,
|
||||
frameSize: number,
|
||||
hopSize: number
|
||||
): number {
|
||||
if (length < frameSize) return 0;
|
||||
return Math.floor((length - frameSize) / hopSize) + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce an FFT magnitude spectrum of length `frameSize/2 + 1` down to `bins`
|
||||
* log-spaced bucket means. The first bucket starts at bin 1 (skipping DC) so
|
||||
* empty audio cleanly yields zero peaks rather than a DC artefact.
|
||||
*/
|
||||
export function reduceToBins(
|
||||
magnitude: Float32Array,
|
||||
bins: number
|
||||
): Float32Array {
|
||||
const out = new Float32Array(bins);
|
||||
const totalBins = magnitude.length;
|
||||
if (bins >= totalBins) {
|
||||
// Copy and zero-pad — no reduction needed.
|
||||
out.set(magnitude.subarray(0, Math.min(bins, totalBins)));
|
||||
return out;
|
||||
}
|
||||
const step = totalBins / bins;
|
||||
for (let b = 0; b < bins; b++) {
|
||||
const start = Math.floor(b * step);
|
||||
const computedEnd = Math.floor((b + 1) * step);
|
||||
// step >= 1 here (bins < totalBins) but guard against degenerate
|
||||
// rounding edges by widening to at least one bin.
|
||||
const hi = Math.max(computedEnd, start + 1);
|
||||
let sum = 0;
|
||||
for (let k = start; k < hi; k++) {
|
||||
sum += magnitude[k]!;
|
||||
}
|
||||
out[b] = sum / (hi - start);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Magnitude-weighted mean frequency (Hz) over the FFT spectrum.
|
||||
*/
|
||||
export function spectralCentroid(
|
||||
magnitude: Float32Array,
|
||||
sampleRate: number,
|
||||
frameSize: number
|
||||
): number {
|
||||
let weighted = 0;
|
||||
let total = 0;
|
||||
for (let k = 0; k < magnitude.length; k++) {
|
||||
const m = magnitude[k]!;
|
||||
weighted += k * m;
|
||||
total += m;
|
||||
}
|
||||
if (total === 0) return 0;
|
||||
const meanBin = weighted / total;
|
||||
return (meanBin * sampleRate) / frameSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Frequency below which `threshold` (default 0.85) of the cumulative spectral
|
||||
* energy lies.
|
||||
*/
|
||||
export function spectralRolloff(
|
||||
magnitude: Float32Array,
|
||||
sampleRate: number,
|
||||
frameSize: number,
|
||||
threshold = 0.85
|
||||
): number {
|
||||
let total = 0;
|
||||
for (let k = 0; k < magnitude.length; k++) total += magnitude[k]!;
|
||||
if (total === 0) return 0;
|
||||
const limit = threshold * total;
|
||||
let running = 0;
|
||||
for (let k = 0; k < magnitude.length; k++) {
|
||||
running += magnitude[k]!;
|
||||
if (running >= limit) {
|
||||
return (k * sampleRate) / frameSize;
|
||||
}
|
||||
}
|
||||
return ((magnitude.length - 1) * sampleRate) / frameSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sum of positive frame-to-frame magnitude differences. `previous` may be
|
||||
* `undefined` (first frame), in which case the result is zero.
|
||||
*/
|
||||
export function spectralFlux(
|
||||
magnitude: Float32Array,
|
||||
previous: Float32Array | undefined
|
||||
): number {
|
||||
if (previous === undefined) return 0;
|
||||
let sum = 0;
|
||||
const len = Math.min(magnitude.length, previous.length);
|
||||
for (let k = 0; k < len; k++) {
|
||||
const diff = magnitude[k]! - previous[k]!;
|
||||
if (diff > 0) sum += diff;
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
const LOG_FLOOR = 1e-10;
|
||||
|
||||
export function logFloor(value: number): number {
|
||||
return Math.log(value < LOG_FLOOR ? LOG_FLOOR : value);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export { MFCC, type MFCCRunOptions } from "./mfcc.js";
|
||||
export {
|
||||
type SpectrumAnalyzerOptions,
|
||||
type SpectrumAnalyzerOptionsInput,
|
||||
SpectrumAnalyzerOptionsSchema,
|
||||
} from "./options.schemas.js";
|
||||
export type {
|
||||
BlockContext,
|
||||
MFCCResult,
|
||||
SpectrogramResult,
|
||||
SpectrumResult,
|
||||
} from "./result.types.js";
|
||||
export { Spectrogram, type SpectrogramRunOptions } from "./spectrogram.js";
|
||||
export {
|
||||
SpectrumAnalyzer,
|
||||
type SpectrumAnalyzerRunOptions,
|
||||
} from "./spectrum-analyzer.js";
|
||||
@@ -0,0 +1,51 @@
|
||||
# `MFCC` — mel-frequency cepstral coefficients
|
||||
|
||||
## Purpose
|
||||
|
||||
`MFCC` is the speech/music feature extractor of this package: per frame it builds the magnitude spectrum, applies a triangular mel filterbank, takes the natural log of the mel energies (floored to `1e-10` so empty bands do not produce `-Infinity`), and runs a type-II DCT to produce `coefficients` cepstral values.
|
||||
|
||||
The default is 13 coefficients over 40 mel bands — the canonical MFCC-13 configuration used in speaker-ID and music-genre pipelines.
|
||||
|
||||
## Features
|
||||
|
||||
- 40-band mel filterbank by default; configurable via `melBands`.
|
||||
- 13 coefficients by default; configurable via `coefficients` (`≤ melBands`).
|
||||
- `[minHz, maxHz]` configurable; `maxHz` defaults to the Nyquist frequency.
|
||||
- `analyzeStream` yields one coefficient vector at a time.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { MFCC } from "@suaveplan/audio-analysis-spectrum/analyzer/mfcc";
|
||||
|
||||
const analyzer = new MFCC();
|
||||
const result = await analyzer.analyze(doc);
|
||||
console.log(result.mfcc.length, result.mfcc[0]?.length); // frame count, 13
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
const analyzer = new MFCC();
|
||||
for await (const partial of analyzer.analyzeStream(doc, {
|
||||
frameSize: 4096,
|
||||
hopSize: 1024,
|
||||
melBands: 26,
|
||||
coefficients: 20,
|
||||
minHz: 80,
|
||||
maxHz: 8000,
|
||||
window: "hamming",
|
||||
})) {
|
||||
// partial.mfcc is a one-element array — accumulate as needed
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `name`, `description`, `analyze(doc, opts?)`, `analyzeStream(doc, opts?)`.
|
||||
- `opts` includes `melBands`, `coefficients`, `minHz`, `maxHz`, `frameSize`, `hopSize`, `window`, `signal`.
|
||||
- Result extends `AnalysisResult` with `op: "mfcc"` and `mfcc: Float32Array[]`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`coefficients > melBands` is an unconditional `AUDIO_ANALYSIS_SPECTRUM.INVALID_OPTIONS` because the DCT projection would be ill-defined. The log floor is `1e-10` — small enough to be invisible to every downstream comparator, large enough to keep `Math.log` finite. The mel filterbank is keyed on `(frameSize, sampleRate, melBands, minHz, maxHz)` so repeated frames of the same document share a single matrix.
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { makeDoc, sineWave } from "../../tests/fixtures.js";
|
||||
import { AUDIO_ANALYSIS_SPECTRUM_CODES } from "../error-codes/error-codes.js";
|
||||
import { MFCC } from "./mfcc.js";
|
||||
|
||||
describe("MFCC", () => {
|
||||
it("produces one 13-coefficient vector per frame", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const samples = sineWave(0.05, 440, 48_000);
|
||||
const doc = makeDoc(samples, 48_000);
|
||||
const analyzer = new MFCC();
|
||||
const result = await analyzer.analyze(doc);
|
||||
expect(result.op).toBe("mfcc");
|
||||
expect(result.mfcc.length).toBeGreaterThan(0);
|
||||
for (const v of result.mfcc) expect(v.length).toBe(13);
|
||||
});
|
||||
|
||||
it("rejects coefficients > melBands", { timeout: 5000 }, async () => {
|
||||
const doc = makeDoc(new Float32Array(4096), 48_000);
|
||||
const analyzer = new MFCC();
|
||||
await expect(
|
||||
analyzer.analyze(doc, { coefficients: 50, melBands: 10 })
|
||||
).rejects.toMatchObject({
|
||||
code: AUDIO_ANALYSIS_SPECTRUM_CODES.INVALID_OPTIONS,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the document's Nyquist as default maxHz", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const samples = sineWave(0.2, 440, 22_050);
|
||||
const doc = makeDoc(samples, 22_050);
|
||||
const analyzer = new MFCC();
|
||||
const result = await analyzer.analyze(doc);
|
||||
expect(result.mfcc[0]?.length).toBe(13);
|
||||
});
|
||||
|
||||
it("analyzeStream yields per-frame results", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const samples = sineWave(0.05, 440, 48_000);
|
||||
const doc = makeDoc(samples, 48_000);
|
||||
const analyzer = new MFCC();
|
||||
const collected: Float32Array[] = [];
|
||||
for await (const partial of analyzer.analyzeStream(doc)) {
|
||||
for (const v of partial.mfcc) collected.push(v);
|
||||
}
|
||||
const single = await analyzer.analyze(doc);
|
||||
expect(collected.length).toBe(single.mfcc.length);
|
||||
});
|
||||
|
||||
it("rejects a pre-aborted signal", { timeout: 5000 }, async () => {
|
||||
const doc = makeDoc(new Float32Array(4096), 48_000);
|
||||
const analyzer = new MFCC();
|
||||
const ctrl = new AbortController();
|
||||
ctrl.abort();
|
||||
await expect(
|
||||
analyzer.analyze(doc, { signal: ctrl.signal })
|
||||
).rejects.toMatchObject({
|
||||
code: AUDIO_ANALYSIS_SPECTRUM_CODES.ABORTED,
|
||||
});
|
||||
});
|
||||
|
||||
it("analyzeStream rejects a pre-aborted signal", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const doc = makeDoc(new Float32Array(4096), 48_000);
|
||||
const analyzer = new MFCC();
|
||||
const ctrl = new AbortController();
|
||||
ctrl.abort();
|
||||
await expect(
|
||||
(async () => {
|
||||
for await (const _ of analyzer.analyzeStream(doc, {
|
||||
signal: ctrl.signal,
|
||||
})) {
|
||||
// unreachable
|
||||
}
|
||||
})()
|
||||
).rejects.toMatchObject({
|
||||
code: AUDIO_ANALYSIS_SPECTRUM_CODES.ABORTED,
|
||||
});
|
||||
});
|
||||
|
||||
it("analyzeStream honours mid-scan abort", { timeout: 5000 }, async () => {
|
||||
const samples = new Float32Array(48_000);
|
||||
const doc = makeDoc(samples, 48_000);
|
||||
const analyzer = new MFCC();
|
||||
const ctrl = new AbortController();
|
||||
const collected: Float32Array[] = [];
|
||||
await expect(
|
||||
(async () => {
|
||||
for await (const partial of analyzer.analyzeStream(doc, {
|
||||
signal: ctrl.signal,
|
||||
})) {
|
||||
for (const v of partial.mfcc) collected.push(v);
|
||||
ctrl.abort();
|
||||
}
|
||||
})()
|
||||
).rejects.toMatchObject({
|
||||
code: AUDIO_ANALYSIS_SPECTRUM_CODES.ABORTED,
|
||||
});
|
||||
});
|
||||
|
||||
it("propagates mid-loop abort through analyze's catch", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const samples = new Float32Array(48_000);
|
||||
const doc = makeDoc(samples, 48_000);
|
||||
const analyzer = new MFCC();
|
||||
let calls = 0;
|
||||
const fakeSignal = {
|
||||
get aborted() {
|
||||
calls++;
|
||||
return calls > 3;
|
||||
},
|
||||
reason: undefined as unknown,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
throwIfAborted: () => {},
|
||||
dispatchEvent: () => false,
|
||||
onabort: null,
|
||||
} as unknown as AbortSignal;
|
||||
await expect(
|
||||
analyzer.analyze(doc, { signal: fakeSignal })
|
||||
).rejects.toMatchObject({
|
||||
code: AUDIO_ANALYSIS_SPECTRUM_CODES.ABORTED,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid options", { timeout: 5000 }, async () => {
|
||||
const doc = makeDoc(new Float32Array(4096), 48_000);
|
||||
const analyzer = new MFCC();
|
||||
await expect(
|
||||
analyzer.analyze(doc, { coefficients: 0 })
|
||||
).rejects.toMatchObject({
|
||||
code: AUDIO_ANALYSIS_SPECTRUM_CODES.INVALID_OPTIONS,
|
||||
});
|
||||
});
|
||||
|
||||
it("handles silence (all-zero log-mel handled via floor)", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const doc = makeDoc(new Float32Array(8192), 48_000);
|
||||
const analyzer = new MFCC();
|
||||
const result = await analyzer.analyze(doc);
|
||||
for (const v of result.mfcc) {
|
||||
for (const x of v) expect(Number.isFinite(x)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import type { AudioDocument } from "@suaveplan/audio-core/document/document";
|
||||
import { dctII } from "../algorithms/dct.js";
|
||||
import {
|
||||
applyMelFilterbank,
|
||||
buildMelFilterbank,
|
||||
} from "../algorithms/mel-scale.js";
|
||||
import {
|
||||
AUDIO_ANALYSIS_SPECTRUM_CODES,
|
||||
AudioAnalysisSpectrumError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { frameCount, frameMagnitude, logFloor, readMono } from "./dsp.js";
|
||||
import type {
|
||||
SpectrumAnalyzerOptions,
|
||||
SpectrumAnalyzerOptionsInput,
|
||||
} from "./options.schemas.js";
|
||||
import type { MFCCResult } from "./result.types.js";
|
||||
import {
|
||||
buildBase,
|
||||
ensureNotAborted,
|
||||
parseOptions,
|
||||
startRun,
|
||||
} from "./runtime.js";
|
||||
|
||||
export interface MFCCRunOptions extends SpectrumAnalyzerOptionsInput {
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
|
||||
function assertCoefficients(opts: SpectrumAnalyzerOptions): void {
|
||||
if (opts.coefficients > opts.melBands) {
|
||||
throw new AudioAnalysisSpectrumError(
|
||||
AUDIO_ANALYSIS_SPECTRUM_CODES.INVALID_OPTIONS,
|
||||
"audio-analysis-spectrum.coefficients-exceed-mel-bands",
|
||||
{
|
||||
context: {
|
||||
coefficients: opts.coefficients,
|
||||
melBands: opts.melBands,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function frameMfcc(
|
||||
mag: Float32Array,
|
||||
doc: AudioDocument,
|
||||
opts: SpectrumAnalyzerOptions
|
||||
): Float32Array {
|
||||
const fb = buildMelFilterbank({
|
||||
frameSize: opts.frameSize,
|
||||
sampleRate: doc.sampleRate,
|
||||
melBands: opts.melBands,
|
||||
minHz: opts.minHz,
|
||||
maxHz: opts.maxHz ?? doc.sampleRate / 2,
|
||||
});
|
||||
const mel = applyMelFilterbank(mag, fb);
|
||||
const logMel = new Float32Array(mel.length);
|
||||
for (let m = 0; m < mel.length; m++) logMel[m] = logFloor(mel[m]!);
|
||||
return dctII(logMel, opts.coefficients);
|
||||
}
|
||||
|
||||
export class MFCC {
|
||||
public readonly name = "mfcc";
|
||||
public readonly description =
|
||||
"Mel-frequency cepstral coefficients (DCT-II of the log mel-spectrum) per frame.";
|
||||
|
||||
public async analyze(
|
||||
doc: AudioDocument,
|
||||
raw?: MFCCRunOptions
|
||||
): Promise<MFCCResult> {
|
||||
const opts = parseOptions(raw);
|
||||
assertCoefficients(opts);
|
||||
ensureNotAborted(raw?.signal);
|
||||
const handle = startRun("mfcc", doc, "analyze");
|
||||
try {
|
||||
const samples = await readMono(doc);
|
||||
const count = frameCount(
|
||||
samples.length,
|
||||
opts.frameSize,
|
||||
opts.hopSize
|
||||
);
|
||||
const mfcc: Float32Array[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
ensureNotAborted(raw?.signal);
|
||||
const offset = i * opts.hopSize;
|
||||
const mag = frameMagnitude(
|
||||
samples,
|
||||
offset,
|
||||
opts.frameSize,
|
||||
opts.window
|
||||
);
|
||||
mfcc.push(frameMfcc(mag, doc, opts));
|
||||
}
|
||||
handle.end();
|
||||
return { ...buildBase("mfcc", doc), op: "mfcc", mfcc };
|
||||
} catch (err) {
|
||||
handle.fail(err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
public async *analyzeStream(
|
||||
doc: AudioDocument,
|
||||
raw?: MFCCRunOptions
|
||||
): AsyncGenerator<MFCCResult> {
|
||||
const opts = parseOptions(raw);
|
||||
assertCoefficients(opts);
|
||||
ensureNotAborted(raw?.signal);
|
||||
const handle = startRun("mfcc", doc, "stream");
|
||||
try {
|
||||
const samples = await readMono(doc);
|
||||
const count = frameCount(
|
||||
samples.length,
|
||||
opts.frameSize,
|
||||
opts.hopSize
|
||||
);
|
||||
for (let i = 0; i < count; i++) {
|
||||
ensureNotAborted(raw?.signal);
|
||||
const offset = i * opts.hopSize;
|
||||
const mag = frameMagnitude(
|
||||
samples,
|
||||
offset,
|
||||
opts.frameSize,
|
||||
opts.window
|
||||
);
|
||||
yield {
|
||||
...buildBase("mfcc", doc),
|
||||
op: "mfcc",
|
||||
mfcc: [frameMfcc(mag, doc, opts)],
|
||||
};
|
||||
}
|
||||
handle.end();
|
||||
} catch (err) {
|
||||
handle.fail(err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import z from "zod";
|
||||
import { FFT_FRAME_MAX, FFT_FRAME_MIN } from "../algorithms/fft.types.js";
|
||||
|
||||
const FRAME_SIZES: ReadonlyArray<number> = (() => {
|
||||
const out: number[] = [];
|
||||
for (let s = FFT_FRAME_MIN; s <= FFT_FRAME_MAX; s <<= 1) out.push(s);
|
||||
return out;
|
||||
})();
|
||||
|
||||
/**
|
||||
* Options accepted by every analyzer. Defaults: `bins=128`, `frameSize=2048`,
|
||||
* `hopSize=512`, `window="hann"`. `signal` propagates abort to long-running
|
||||
* scans.
|
||||
*/
|
||||
export const SpectrumAnalyzerOptionsSchema = z.object({
|
||||
bins: z.number().int().positive().default(128),
|
||||
frameSize: z
|
||||
.number()
|
||||
.int()
|
||||
.refine((v) => FRAME_SIZES.includes(v), {
|
||||
message: `frameSize must be a power of two in [${FFT_FRAME_MIN}, ${FFT_FRAME_MAX}]`,
|
||||
})
|
||||
.default(2048),
|
||||
hopSize: z.number().int().positive().default(512),
|
||||
window: z.enum(["hann", "hamming", "blackman"]).default("hann"),
|
||||
melBands: z.number().int().positive().default(40),
|
||||
coefficients: z.number().int().positive().default(13),
|
||||
minHz: z.number().nonnegative().default(0),
|
||||
maxHz: z.number().positive().optional(),
|
||||
});
|
||||
|
||||
export type SpectrumAnalyzerOptionsInput = z.input<
|
||||
typeof SpectrumAnalyzerOptionsSchema
|
||||
>;
|
||||
export type SpectrumAnalyzerOptions = z.output<
|
||||
typeof SpectrumAnalyzerOptionsSchema
|
||||
>;
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { AnalysisResult } from "@suaveplan/audio-core/analysis";
|
||||
|
||||
/**
|
||||
* `BlockContext` mirrors the shape `audio-core` passes to per-block analyzers:
|
||||
* the caller supplies a `sampleRate` and the sample index of the block start.
|
||||
*/
|
||||
export interface BlockContext {
|
||||
readonly sampleRate: number;
|
||||
readonly startSample: number;
|
||||
}
|
||||
|
||||
export interface SpectrumResult extends AnalysisResult {
|
||||
readonly op: "spectrum-analyzer";
|
||||
readonly spectrum: Float32Array;
|
||||
readonly centroid: number;
|
||||
readonly rolloff: number;
|
||||
readonly flux: number;
|
||||
}
|
||||
|
||||
export interface SpectrogramResult extends AnalysisResult {
|
||||
readonly op: "spectrogram";
|
||||
readonly spectrogram: Float32Array[];
|
||||
}
|
||||
|
||||
export interface MFCCResult extends AnalysisResult {
|
||||
readonly op: "mfcc";
|
||||
readonly mfcc: Float32Array[];
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
# `runtime` — shared analyzer plumbing
|
||||
|
||||
## Purpose
|
||||
|
||||
The runtime module is the shared spine for `SpectrumAnalyzer`, `Spectrogram`, and `MFCC`. It owns option parsing through the canonical Zod schema, abort-signal short-circuits, OTel span+counter+histogram instrumentation, and construction of the audio-core `AnalysisResult` envelope. Centralising this scaffolding means every analyzer stays focused on its DSP and the package as a whole emits identical telemetry shapes.
|
||||
|
||||
## Features
|
||||
|
||||
- `parseOptions` — single boundary into `SpectrumAnalyzerOptionsSchema` that throws `AUDIO_ANALYSIS_SPECTRUM.INVALID_OPTIONS` with the Zod issues.
|
||||
- `ensureNotAborted` — throws `AUDIO_ANALYSIS_SPECTRUM.ABORTED` if a caller-supplied `AbortSignal` is already aborted.
|
||||
- `startRun` — opens a span, records sample-rate/duration attributes, and returns `end`/`fail` callbacks bound to the runs counter and duration histogram.
|
||||
- `recordBlock` — bumps the block-duration histogram for `analyzeBlock`.
|
||||
- `buildBase` — produces the `AnalysisResult` envelope (`analyserName`, `analyserVersion`, `sampleRate`, `durationSamples`, `computedAt`).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
buildBase,
|
||||
ensureNotAborted,
|
||||
parseOptions,
|
||||
startRun,
|
||||
} from "@suaveplan/audio-analysis-spectrum/analyzer/runtime";
|
||||
|
||||
const opts = parseOptions(raw);
|
||||
ensureNotAborted(opts.signal);
|
||||
const handle = startRun("spectrum-analyzer", doc, "analyze");
|
||||
try {
|
||||
// ... run the DSP ...
|
||||
handle.end();
|
||||
} catch (err) {
|
||||
handle.fail(err);
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { recordBlock } from "@suaveplan/audio-analysis-spectrum/analyzer/runtime";
|
||||
|
||||
const startedAt = performance.now();
|
||||
const result = computeBlock();
|
||||
recordBlock("spectrum-analyzer", performance.now() - startedAt);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `parseOptions(raw): SpectrumAnalyzerOptions` — schema-validated options.
|
||||
- `ensureNotAborted(signal): void` — throws `ABORTED` on pre-aborted signals.
|
||||
- `startRun(op, doc, mode): { end, fail }` — OTel run handle.
|
||||
- `recordBlock(op, durationMs): void` — record a single block timing.
|
||||
- `buildBase(op, doc): { analyserName, analyserVersion, sampleRate, durationSamples, computedAt }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Instrumentation uses `@opentelemetry/api` which short-circuits to a no-op when no SDK is registered, so importing this module mutates no global state and runs free in suites that don't configure a `MeterProvider`. Tags on the counter and histograms are intentionally minimal — `op` and `mode` — so cardinality stays bounded even with high analyzer fan-out.
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { makeDoc } from "../../tests/fixtures.js";
|
||||
import { AUDIO_ANALYSIS_SPECTRUM_CODES } from "../error-codes/error-codes.js";
|
||||
import {
|
||||
buildBase,
|
||||
ensureNotAborted,
|
||||
parseOptions,
|
||||
recordBlock,
|
||||
startRun,
|
||||
} from "./runtime.js";
|
||||
|
||||
describe("runtime", () => {
|
||||
it("parseOptions defaults the entire option set", { timeout: 5000 }, () => {
|
||||
const opts = parseOptions(undefined);
|
||||
expect(opts.bins).toBe(128);
|
||||
expect(opts.frameSize).toBe(2048);
|
||||
expect(opts.hopSize).toBe(512);
|
||||
expect(opts.window).toBe("hann");
|
||||
expect(opts.melBands).toBe(40);
|
||||
expect(opts.coefficients).toBe(13);
|
||||
});
|
||||
|
||||
it("parseOptions accepts an explicit empty object", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const opts = parseOptions({});
|
||||
expect(opts.bins).toBe(128);
|
||||
});
|
||||
|
||||
it("parseOptions rejects negative hopSize", { timeout: 5000 }, () => {
|
||||
expect(() => parseOptions({ hopSize: -1 })).toThrowError(
|
||||
expect.objectContaining({
|
||||
code: AUDIO_ANALYSIS_SPECTRUM_CODES.INVALID_OPTIONS,
|
||||
}) as unknown as Error
|
||||
);
|
||||
});
|
||||
|
||||
it("ensureNotAborted is a no-op when the signal is missing or not aborted", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => ensureNotAborted(undefined)).not.toThrow();
|
||||
const ctrl = new AbortController();
|
||||
expect(() => ensureNotAborted(ctrl.signal)).not.toThrow();
|
||||
});
|
||||
|
||||
it("ensureNotAborted throws ABORTED on a pre-aborted signal", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ctrl = new AbortController();
|
||||
ctrl.abort();
|
||||
expect(() => ensureNotAborted(ctrl.signal)).toThrowError(
|
||||
expect.objectContaining({
|
||||
code: AUDIO_ANALYSIS_SPECTRUM_CODES.ABORTED,
|
||||
}) as unknown as Error
|
||||
);
|
||||
});
|
||||
|
||||
it("startRun returns end and fail callbacks", { timeout: 5000 }, () => {
|
||||
const doc = makeDoc(new Float32Array(4), 48_000);
|
||||
const handle = startRun("spectrum-analyzer", doc, "analyze");
|
||||
expect(typeof handle.end).toBe("function");
|
||||
expect(typeof handle.fail).toBe("function");
|
||||
handle.end();
|
||||
});
|
||||
|
||||
it("startRun.fail tolerates an unknown error shape", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const doc = makeDoc(new Float32Array(4), 48_000);
|
||||
const handle = startRun("mfcc", doc, "stream");
|
||||
handle.fail(new Error("boom"));
|
||||
});
|
||||
|
||||
it("recordBlock does not throw without an exporter", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => recordBlock("spectrogram", 1.234)).not.toThrow();
|
||||
});
|
||||
|
||||
it("buildBase produces an audio-core-compatible envelope", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const doc = makeDoc(new Float32Array(8), 48_000);
|
||||
const base = buildBase("mfcc", doc);
|
||||
expect(base.analyserName).toBe("mfcc");
|
||||
expect(base.analyserVersion).toBe("0.1.0");
|
||||
expect(base.sampleRate).toBe(48_000);
|
||||
expect(base.durationSamples).toBe(8);
|
||||
expect(typeof base.computedAt).toBe("number");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { metrics, SpanStatusCode, trace } from "@opentelemetry/api";
|
||||
import type { AudioDocument } from "@suaveplan/audio-core/document/document";
|
||||
import {
|
||||
AUDIO_ANALYSIS_SPECTRUM_CODES,
|
||||
AudioAnalysisSpectrumError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import {
|
||||
type SpectrumAnalyzerOptions,
|
||||
type SpectrumAnalyzerOptionsInput,
|
||||
SpectrumAnalyzerOptionsSchema,
|
||||
} from "./options.schemas.js";
|
||||
|
||||
const TRACER = trace.getTracer("@suaveplan/audio-analysis-spectrum");
|
||||
const METER = metrics.getMeter("@suaveplan/audio-analysis-spectrum");
|
||||
|
||||
const RUN_COUNTER = METER.createCounter("audio.analysis.runs_total", {
|
||||
description: "Completed analysis runs by op and mode.",
|
||||
});
|
||||
const DURATION_HIST = METER.createHistogram("audio.analysis.duration_ms", {
|
||||
description: "Wall-clock duration of analysis runs.",
|
||||
});
|
||||
const BLOCK_DURATION_HIST = METER.createHistogram(
|
||||
"audio.analysis.block_duration_ms",
|
||||
{ description: "Wall-clock duration of single-block analysis." }
|
||||
);
|
||||
|
||||
/** Validate option input against the shared Zod schema. */
|
||||
export function parseOptions(
|
||||
raw: SpectrumAnalyzerOptionsInput | undefined
|
||||
): SpectrumAnalyzerOptions {
|
||||
const parsed = SpectrumAnalyzerOptionsSchema.safeParse(raw ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new AudioAnalysisSpectrumError(
|
||||
AUDIO_ANALYSIS_SPECTRUM_CODES.INVALID_OPTIONS,
|
||||
"audio-analysis-spectrum.invalid-options",
|
||||
{ context: { issues: parsed.error.issues } }
|
||||
);
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
/** Throws `ABORTED` if `signal` is already aborted. */
|
||||
export function ensureNotAborted(signal: AbortSignal | undefined): void {
|
||||
if (signal?.aborted === true) {
|
||||
throw new AudioAnalysisSpectrumError(
|
||||
AUDIO_ANALYSIS_SPECTRUM_CODES.ABORTED,
|
||||
"audio-analysis-spectrum.aborted",
|
||||
{ context: { reason: signal.reason } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface OtelRunHandle {
|
||||
readonly end: () => void;
|
||||
readonly fail: (err: unknown) => void;
|
||||
}
|
||||
|
||||
/** Start an OTel span + duration timer for an `analyze` invocation. */
|
||||
export function startRun(
|
||||
op: string,
|
||||
doc: AudioDocument,
|
||||
mode: "analyze" | "stream"
|
||||
): OtelRunHandle {
|
||||
const span = TRACER.startSpan(`audio.analysis.${op}`, {
|
||||
attributes: {
|
||||
"analysis.op": op,
|
||||
"analysis.mode": mode,
|
||||
"audio.sample_rate": doc.sampleRate,
|
||||
"audio.duration_samples": doc.sourceLength,
|
||||
},
|
||||
});
|
||||
const startedAt = performance.now();
|
||||
return {
|
||||
end: () => {
|
||||
RUN_COUNTER.add(1, { op, mode });
|
||||
DURATION_HIST.record(performance.now() - startedAt, { op, mode });
|
||||
span.end();
|
||||
},
|
||||
fail: (err) => {
|
||||
const error = err as Error;
|
||||
span.setStatus({
|
||||
code: SpanStatusCode.ERROR,
|
||||
message: error.message,
|
||||
});
|
||||
span.recordException(error);
|
||||
span.end();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Record a single `analyzeBlock` invocation in the block-duration histogram. */
|
||||
export function recordBlock(op: string, durationMs: number): void {
|
||||
BLOCK_DURATION_HIST.record(durationMs, { op });
|
||||
}
|
||||
|
||||
/** Build the audio-core-compatible result envelope. */
|
||||
export function buildBase(
|
||||
op: string,
|
||||
doc: AudioDocument
|
||||
): {
|
||||
analyserName: string;
|
||||
analyserVersion: string;
|
||||
sampleRate: number;
|
||||
durationSamples: number;
|
||||
computedAt: number;
|
||||
} {
|
||||
return {
|
||||
analyserName: op,
|
||||
analyserVersion: "0.1.0",
|
||||
sampleRate: doc.sampleRate,
|
||||
durationSamples: doc.sourceLength,
|
||||
computedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
# `Spectrogram` — time-frequency magnitude matrix
|
||||
|
||||
## Purpose
|
||||
|
||||
`Spectrogram` produces the ordered list of per-frame magnitude spectra for a document, slicing by the same `frameSize`/`hopSize`/`window` options as `SpectrumAnalyzer`. The result is the canonical input for downstream beat, pitch, and chromagram analyzers. Documents shorter than `frameSize` cleanly yield an empty matrix rather than throwing.
|
||||
|
||||
`analyze` returns the entire matrix in one allocation; `analyzeStream` yields one frame at a time so a visualiser can update during the scan. Concatenating the per-frame matrices yielded by the stream matches `analyze`'s `spectrogram` exactly.
|
||||
|
||||
## Features
|
||||
|
||||
- Frame count matches `floor((doc.length - frameSize) / hopSize) + 1` exactly.
|
||||
- Each row is the full `frameSize / 2 + 1` magnitude vector.
|
||||
- Empty result for documents shorter than one frame — no error thrown.
|
||||
- Streamed frames are deep-equal to the single-shot matrix.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { Spectrogram } from "@suaveplan/audio-analysis-spectrum/analyzer/spectrogram";
|
||||
|
||||
const analyzer = new Spectrogram();
|
||||
const result = await analyzer.analyze(doc);
|
||||
console.log(result.spectrogram.length, result.spectrogram[0]?.length);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
const analyzer = new Spectrogram();
|
||||
const all: Float32Array[] = [];
|
||||
for await (const partial of analyzer.analyzeStream(doc, {
|
||||
frameSize: 4096,
|
||||
hopSize: 1024,
|
||||
window: "blackman",
|
||||
})) {
|
||||
for (const frame of partial.spectrogram) all.push(frame);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `name`, `description`, `analyze(doc, opts?)`, `analyzeStream(doc, opts?)`.
|
||||
- `opts` includes `frameSize`, `hopSize`, `window`, `signal`.
|
||||
- Result extends `AnalysisResult` with `op: "spectrogram"` and `spectrogram: Float32Array[]`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`analyzeStream` yields one `SpectrogramResult` per frame whose `spectrogram` array has length `1`. The empty-result case yields a single zero-frame result so callers can rely on the generator producing at least one event. Like `SpectrumAnalyzer`, the abort signal is checked before each frame to honour the mid-scan abort semantics.
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { makeDoc, sineWave } from "../../tests/fixtures.js";
|
||||
import { AUDIO_ANALYSIS_SPECTRUM_CODES } from "../error-codes/error-codes.js";
|
||||
import { Spectrogram } from "./spectrogram.js";
|
||||
|
||||
describe("Spectrogram", () => {
|
||||
it("frame count matches the hop arithmetic", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const length = 48_000;
|
||||
const samples = new Float32Array(length);
|
||||
const doc = makeDoc(samples, 48_000);
|
||||
const analyzer = new Spectrogram();
|
||||
const result = await analyzer.analyze(doc, {
|
||||
frameSize: 2048,
|
||||
hopSize: 512,
|
||||
});
|
||||
expect(result.spectrogram.length).toBe(
|
||||
Math.floor((length - 2048) / 512) + 1
|
||||
);
|
||||
for (const f of result.spectrogram) expect(f.length).toBe(1025);
|
||||
});
|
||||
|
||||
it("short document yields an empty spectrogram", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const doc = makeDoc(new Float32Array(512), 48_000);
|
||||
const analyzer = new Spectrogram();
|
||||
const result = await analyzer.analyze(doc, {
|
||||
frameSize: 2048,
|
||||
hopSize: 512,
|
||||
});
|
||||
expect(result.spectrogram).toEqual([]);
|
||||
});
|
||||
|
||||
it("analyzeStream frames equal the single-shot analyze result", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const samples = sineWave(0.05, 440, 48_000);
|
||||
const doc = makeDoc(samples, 48_000);
|
||||
const analyzer = new Spectrogram();
|
||||
const single = await analyzer.analyze(doc);
|
||||
const collected: Float32Array[] = [];
|
||||
for await (const partial of analyzer.analyzeStream(doc)) {
|
||||
for (const frame of partial.spectrogram) collected.push(frame);
|
||||
}
|
||||
expect(collected.length).toBe(single.spectrogram.length);
|
||||
for (let i = 0; i < collected.length; i++) {
|
||||
for (let k = 0; k < collected[i]!.length; k++) {
|
||||
expect(collected[i]![k]).toBeCloseTo(
|
||||
single.spectrogram[i]![k]!,
|
||||
5
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("analyzeStream emits an empty result for a short document", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const doc = makeDoc(new Float32Array(256), 48_000);
|
||||
const analyzer = new Spectrogram();
|
||||
const collected: Float32Array[] = [];
|
||||
for await (const partial of analyzer.analyzeStream(doc)) {
|
||||
for (const frame of partial.spectrogram) collected.push(frame);
|
||||
}
|
||||
expect(collected).toEqual([]);
|
||||
});
|
||||
|
||||
it("analyzeStream honours mid-scan abort", { timeout: 5000 }, async () => {
|
||||
const samples = new Float32Array(48_000);
|
||||
const doc = makeDoc(samples, 48_000);
|
||||
const analyzer = new Spectrogram();
|
||||
const ctrl = new AbortController();
|
||||
const collected: Float32Array[] = [];
|
||||
await expect(
|
||||
(async () => {
|
||||
for await (const partial of analyzer.analyzeStream(doc, {
|
||||
signal: ctrl.signal,
|
||||
})) {
|
||||
for (const frame of partial.spectrogram)
|
||||
collected.push(frame);
|
||||
if (collected.length >= 1) ctrl.abort();
|
||||
}
|
||||
})()
|
||||
).rejects.toMatchObject({
|
||||
code: AUDIO_ANALYSIS_SPECTRUM_CODES.ABORTED,
|
||||
});
|
||||
expect(collected.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("rejects a pre-aborted signal on analyze", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const doc = makeDoc(new Float32Array(4096), 48_000);
|
||||
const analyzer = new Spectrogram();
|
||||
const ctrl = new AbortController();
|
||||
ctrl.abort();
|
||||
await expect(
|
||||
analyzer.analyze(doc, { signal: ctrl.signal })
|
||||
).rejects.toMatchObject({
|
||||
code: AUDIO_ANALYSIS_SPECTRUM_CODES.ABORTED,
|
||||
});
|
||||
});
|
||||
|
||||
it("propagates mid-loop abort through analyze's catch", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const samples = new Float32Array(48_000);
|
||||
const doc = makeDoc(samples, 48_000);
|
||||
const analyzer = new Spectrogram();
|
||||
let calls = 0;
|
||||
const fakeSignal = {
|
||||
get aborted() {
|
||||
calls++;
|
||||
return calls > 3;
|
||||
},
|
||||
reason: "trigger" as unknown,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
throwIfAborted: () => {},
|
||||
dispatchEvent: () => false,
|
||||
onabort: null,
|
||||
} as unknown as AbortSignal;
|
||||
await expect(
|
||||
analyzer.analyze(doc, { signal: fakeSignal })
|
||||
).rejects.toMatchObject({
|
||||
code: AUDIO_ANALYSIS_SPECTRUM_CODES.ABORTED,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid options", { timeout: 5000 }, async () => {
|
||||
const doc = makeDoc(new Float32Array(4096), 48_000);
|
||||
const analyzer = new Spectrogram();
|
||||
await expect(
|
||||
analyzer.analyze(doc, { hopSize: 0 })
|
||||
).rejects.toMatchObject({
|
||||
code: AUDIO_ANALYSIS_SPECTRUM_CODES.INVALID_OPTIONS,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { AudioDocument } from "@suaveplan/audio-core/document/document";
|
||||
import { frameCount, frameMagnitude, readMono } from "./dsp.js";
|
||||
import type { SpectrumAnalyzerOptionsInput } from "./options.schemas.js";
|
||||
import type { SpectrogramResult } from "./result.types.js";
|
||||
import {
|
||||
buildBase,
|
||||
ensureNotAborted,
|
||||
parseOptions,
|
||||
startRun,
|
||||
} from "./runtime.js";
|
||||
|
||||
export interface SpectrogramRunOptions extends SpectrumAnalyzerOptionsInput {
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
|
||||
function emptyResult(doc: AudioDocument): SpectrogramResult {
|
||||
return {
|
||||
...buildBase("spectrogram", doc),
|
||||
op: "spectrogram",
|
||||
spectrogram: [],
|
||||
};
|
||||
}
|
||||
|
||||
export class Spectrogram {
|
||||
public readonly name = "spectrogram";
|
||||
public readonly description =
|
||||
"Time-frequency magnitude matrix of an audio document.";
|
||||
|
||||
public async analyze(
|
||||
doc: AudioDocument,
|
||||
raw?: SpectrogramRunOptions
|
||||
): Promise<SpectrogramResult> {
|
||||
const opts = parseOptions(raw);
|
||||
ensureNotAborted(raw?.signal);
|
||||
const handle = startRun("spectrogram", doc, "analyze");
|
||||
try {
|
||||
const samples = await readMono(doc);
|
||||
const count = frameCount(
|
||||
samples.length,
|
||||
opts.frameSize,
|
||||
opts.hopSize
|
||||
);
|
||||
const spectrogram: Float32Array[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
ensureNotAborted(raw?.signal);
|
||||
const offset = i * opts.hopSize;
|
||||
spectrogram.push(
|
||||
frameMagnitude(samples, offset, opts.frameSize, opts.window)
|
||||
);
|
||||
}
|
||||
handle.end();
|
||||
return {
|
||||
...buildBase("spectrogram", doc),
|
||||
op: "spectrogram",
|
||||
spectrogram,
|
||||
};
|
||||
} catch (err) {
|
||||
handle.fail(err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
public async *analyzeStream(
|
||||
doc: AudioDocument,
|
||||
raw?: SpectrogramRunOptions
|
||||
): AsyncGenerator<SpectrogramResult> {
|
||||
const opts = parseOptions(raw);
|
||||
ensureNotAborted(raw?.signal);
|
||||
const handle = startRun("spectrogram", doc, "stream");
|
||||
try {
|
||||
const samples = await readMono(doc);
|
||||
const count = frameCount(
|
||||
samples.length,
|
||||
opts.frameSize,
|
||||
opts.hopSize
|
||||
);
|
||||
if (count === 0) {
|
||||
yield emptyResult(doc);
|
||||
handle.end();
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < count; i++) {
|
||||
ensureNotAborted(raw?.signal);
|
||||
const offset = i * opts.hopSize;
|
||||
const mag = frameMagnitude(
|
||||
samples,
|
||||
offset,
|
||||
opts.frameSize,
|
||||
opts.window
|
||||
);
|
||||
yield {
|
||||
...buildBase("spectrogram", doc),
|
||||
op: "spectrogram",
|
||||
spectrogram: [mag],
|
||||
};
|
||||
}
|
||||
handle.end();
|
||||
} catch (err) {
|
||||
handle.fail(err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
# `SpectrumAnalyzer` — magnitude spectrum + scalar features
|
||||
|
||||
## Purpose
|
||||
|
||||
`SpectrumAnalyzer` is the workhorse analysis op of this package: it slices an `AudioDocument` into overlapping frames, windows each frame, runs the real FFT, and reduces the per-bin magnitudes into a fixed-length `spectrum` plus three scalar features — centroid, rolloff, flux. The default 128-bin output is the standard size used by visualisers and feature pipelines.
|
||||
|
||||
The class implements `analyze` (single-shot whole document), `analyzeStream` (per-frame async generator suitable for real-time visualisation), and `analyzeBlock` (one already-windowed block via a `BlockContext`).
|
||||
|
||||
## Features
|
||||
|
||||
- Slices `doc` into `frameSize`/`hopSize` overlapping frames (defaults `2048`/`512`).
|
||||
- Hann window by default; Hamming or Blackman selectable via `window`.
|
||||
- Log-spaced bucket-mean reduction to the configured `bins` (default `128`).
|
||||
- Centroid, 85% rolloff, and positive-flux scalars per result.
|
||||
- `analyzeStream` yields per-frame results; the final frame's vectors match `analyze`.
|
||||
- `analyzeBlock` runs a single FFT-sized block with `BlockContext.sampleRate`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { SpectrumAnalyzer } from "@suaveplan/audio-analysis-spectrum/analyzer/spectrum-analyzer";
|
||||
|
||||
const analyzer = new SpectrumAnalyzer();
|
||||
const result = await analyzer.analyze(doc);
|
||||
console.log(result.centroid, result.rolloff, result.flux, result.spectrum);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
const analyzer = new SpectrumAnalyzer();
|
||||
const ctrl = new AbortController();
|
||||
setTimeout(() => ctrl.abort(), 50);
|
||||
try {
|
||||
for await (const frame of analyzer.analyzeStream(doc, {
|
||||
frameSize: 4096,
|
||||
hopSize: 1024,
|
||||
bins: 256,
|
||||
window: "blackman",
|
||||
signal: ctrl.signal,
|
||||
})) {
|
||||
updateVisualiser(frame.spectrum);
|
||||
}
|
||||
} catch (err) {
|
||||
// AUDIO_ANALYSIS_SPECTRUM.ABORTED expected on early cancel.
|
||||
}
|
||||
|
||||
const block = analyzer.analyzeBlock([new Float32Array(2048)], {
|
||||
sampleRate: 48_000,
|
||||
startSample: 0,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `name`, `description`, `analyze(doc, opts?)`, `analyzeStream(doc, opts?)`, `analyzeBlock(block, ctx)`.
|
||||
- `opts` includes `bins`, `frameSize`, `hopSize`, `window`, `signal`; defaults from the Zod schema.
|
||||
- Result extends `AnalysisResult` with `op: "spectrum-analyzer"`, `spectrum`, `centroid`, `rolloff`, `flux`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The DSP pipeline lives in the `dsp` module so `SpectrumAnalyzer` stays an orchestrator. `analyze` retains only the final frame's `spectrum`/`magnitude` and the previous frame's `magnitude` so flux is well-defined. Each frame revalidates the abort signal between iterations, satisfying the mid-scan abort scenario. `analyzeBlock` does not consult an `AbortSignal` — block calls are bounded-time and the call site owns its own cancellation.
|
||||
@@ -0,0 +1,233 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { makeDoc, sineWave } from "../../tests/fixtures.js";
|
||||
import { AUDIO_ANALYSIS_SPECTRUM_CODES } from "../error-codes/error-codes.js";
|
||||
import { SpectrumAnalyzer } from "./spectrum-analyzer.js";
|
||||
|
||||
describe("SpectrumAnalyzer", () => {
|
||||
it("implements the AnalysisOp-style contract", { timeout: 5000 }, () => {
|
||||
const analyzer = new SpectrumAnalyzer();
|
||||
expect(analyzer.name).toBe("spectrum-analyzer");
|
||||
expect(typeof analyzer.analyze).toBe("function");
|
||||
expect(typeof analyzer.analyzeStream).toBe("function");
|
||||
expect(typeof analyzer.analyzeBlock).toBe("function");
|
||||
expect(analyzer.description.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
it("centroid tracks a pure tone frequency", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const doc = makeDoc(sineWave(0.2, 1000, 48_000), 48_000);
|
||||
const analyzer = new SpectrumAnalyzer();
|
||||
const result = await analyzer.analyze(doc);
|
||||
const binWidth = 48_000 / 2048;
|
||||
expect(Math.abs(result.centroid - 1000)).toBeLessThan(binWidth * 5);
|
||||
});
|
||||
|
||||
it("analyzeBlock returns a SpectrumResult for a single block", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const analyzer = new SpectrumAnalyzer();
|
||||
const block = [new Float32Array(2048)];
|
||||
const result = analyzer.analyzeBlock(block, {
|
||||
sampleRate: 48_000,
|
||||
startSample: 0,
|
||||
});
|
||||
expect(result.op).toBe("spectrum-analyzer");
|
||||
expect(result.spectrum.length).toBe(128);
|
||||
expect(result.flux).toBe(0);
|
||||
});
|
||||
|
||||
it("analyzeBlock pads short blocks up to frameSize", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const analyzer = new SpectrumAnalyzer();
|
||||
const result = analyzer.analyzeBlock([new Float32Array(100)], {
|
||||
sampleRate: 48_000,
|
||||
startSample: 0,
|
||||
});
|
||||
expect(result.spectrum.length).toBe(128);
|
||||
});
|
||||
|
||||
it("analyzeBlock handles an empty block array", { timeout: 5000 }, () => {
|
||||
const analyzer = new SpectrumAnalyzer();
|
||||
const result = analyzer.analyzeBlock([], {
|
||||
sampleRate: 48_000,
|
||||
startSample: 0,
|
||||
});
|
||||
expect(result.spectrum.length).toBe(128);
|
||||
});
|
||||
|
||||
it("rejects an unknown window name with AUDIO_ANALYSIS_SPECTRUM.INVALID_OPTIONS", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const analyzer = new SpectrumAnalyzer();
|
||||
const doc = makeDoc(new Float32Array(4096));
|
||||
await expect(
|
||||
analyzer.analyze(doc, { window: "kaiser" } as unknown as {
|
||||
window: "hann";
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: AUDIO_ANALYSIS_SPECTRUM_CODES.INVALID_OPTIONS,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid options with AUDIO_ANALYSIS_SPECTRUM.INVALID_OPTIONS", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const analyzer = new SpectrumAnalyzer();
|
||||
const doc = makeDoc(new Float32Array(4096));
|
||||
await expect(
|
||||
analyzer.analyze(doc, { hopSize: -10 })
|
||||
).rejects.toMatchObject({
|
||||
code: AUDIO_ANALYSIS_SPECTRUM_CODES.INVALID_OPTIONS,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects non-power-of-two frameSize as INVALID_OPTIONS", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const analyzer = new SpectrumAnalyzer();
|
||||
const doc = makeDoc(new Float32Array(4096));
|
||||
await expect(
|
||||
analyzer.analyze(doc, { frameSize: 1000 })
|
||||
).rejects.toMatchObject({
|
||||
code: AUDIO_ANALYSIS_SPECTRUM_CODES.INVALID_OPTIONS,
|
||||
});
|
||||
});
|
||||
|
||||
it("analyze throws AUDIO_ANALYSIS_SPECTRUM.ABORTED for a pre-aborted signal", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const analyzer = new SpectrumAnalyzer();
|
||||
const doc = makeDoc(new Float32Array(4096));
|
||||
const ctrl = new AbortController();
|
||||
ctrl.abort();
|
||||
await expect(
|
||||
analyzer.analyze(doc, { signal: ctrl.signal })
|
||||
).rejects.toMatchObject({
|
||||
code: AUDIO_ANALYSIS_SPECTRUM_CODES.ABORTED,
|
||||
});
|
||||
});
|
||||
|
||||
it("analyzeStream throws AUDIO_ANALYSIS_SPECTRUM.ABORTED for a pre-aborted signal", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const analyzer = new SpectrumAnalyzer();
|
||||
const doc = makeDoc(new Float32Array(4096));
|
||||
const ctrl = new AbortController();
|
||||
ctrl.abort();
|
||||
await expect(
|
||||
(async () => {
|
||||
for await (const _ of analyzer.analyzeStream(doc, {
|
||||
signal: ctrl.signal,
|
||||
})) {
|
||||
// unreachable
|
||||
}
|
||||
})()
|
||||
).rejects.toMatchObject({
|
||||
code: AUDIO_ANALYSIS_SPECTRUM_CODES.ABORTED,
|
||||
});
|
||||
});
|
||||
|
||||
it("analyzeStream yields per-frame results matching the single-shot last frame", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const doc = makeDoc(sineWave(0.1, 440, 48_000), 48_000);
|
||||
const analyzer = new SpectrumAnalyzer();
|
||||
const frames: Float32Array[] = [];
|
||||
for await (const r of analyzer.analyzeStream(doc))
|
||||
frames.push(r.spectrum);
|
||||
const finalShot = await analyzer.analyze(doc);
|
||||
expect(frames.length).toBeGreaterThan(1);
|
||||
const last = frames[frames.length - 1]!;
|
||||
for (let i = 0; i < last.length; i++) {
|
||||
expect(last[i]).toBeCloseTo(finalShot.spectrum[i]!, 5);
|
||||
}
|
||||
});
|
||||
|
||||
it("records a span and duration histogram when an exporter is registered", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// Inert path: ensure import + run do not throw without an exporter.
|
||||
const analyzer = new SpectrumAnalyzer();
|
||||
const doc = makeDoc(new Float32Array(4096));
|
||||
const result = await analyzer.analyze(doc);
|
||||
expect(result.op).toBe("spectrum-analyzer");
|
||||
});
|
||||
|
||||
it("uses every supported window name without throwing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const analyzer = new SpectrumAnalyzer();
|
||||
const doc = makeDoc(new Float32Array(4096));
|
||||
for (const window of ["hann", "hamming", "blackman"] as const) {
|
||||
const r = await analyzer.analyze(doc, { window });
|
||||
expect(r.spectrum.length).toBe(128);
|
||||
}
|
||||
});
|
||||
|
||||
it("propagates dsp errors through OTel span recording", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const analyzer = new SpectrumAnalyzer();
|
||||
const doc = makeDoc(new Float32Array(4096));
|
||||
const ctrl = new AbortController();
|
||||
setTimeout(() => ctrl.abort(), 0);
|
||||
try {
|
||||
for await (const _ of analyzer.analyzeStream(doc, {
|
||||
signal: ctrl.signal,
|
||||
})) {
|
||||
ctrl.abort();
|
||||
}
|
||||
} catch (err) {
|
||||
expect((err as { code: string }).code).toBe(
|
||||
AUDIO_ANALYSIS_SPECTRUM_CODES.ABORTED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("propagates mid-loop abort through analyze's catch", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const samples = new Float32Array(48_000);
|
||||
const doc = makeDoc(samples, 48_000);
|
||||
const analyzer = new SpectrumAnalyzer();
|
||||
let calls = 0;
|
||||
const fakeSignal = {
|
||||
get aborted() {
|
||||
calls++;
|
||||
return calls > 3;
|
||||
},
|
||||
reason: undefined as unknown,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
throwIfAborted: () => {},
|
||||
dispatchEvent: () => false,
|
||||
onabort: null,
|
||||
} as unknown as AbortSignal;
|
||||
await expect(
|
||||
analyzer.analyze(doc, { signal: fakeSignal })
|
||||
).rejects.toMatchObject({
|
||||
code: AUDIO_ANALYSIS_SPECTRUM_CODES.ABORTED,
|
||||
});
|
||||
});
|
||||
|
||||
it("yields bins equal to or larger than the FFT magnitude bins", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const analyzer = new SpectrumAnalyzer();
|
||||
const doc = makeDoc(new Float32Array(4096));
|
||||
const result = await analyzer.analyze(doc, { bins: 2048 });
|
||||
expect(result.spectrum.length).toBe(2048);
|
||||
});
|
||||
|
||||
it("handles a document shorter than frameSize without throwing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const analyzer = new SpectrumAnalyzer();
|
||||
const doc = makeDoc(new Float32Array(512));
|
||||
const result = await analyzer.analyze(doc);
|
||||
expect(result.op).toBe("spectrum-analyzer");
|
||||
expect(result.flux).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
import type { AudioDocument } from "@suaveplan/audio-core/document/document";
|
||||
import {
|
||||
frameCount,
|
||||
frameMagnitude,
|
||||
readMono,
|
||||
reduceToBins,
|
||||
spectralCentroid,
|
||||
spectralFlux,
|
||||
spectralRolloff,
|
||||
} from "./dsp.js";
|
||||
import type {
|
||||
SpectrumAnalyzerOptions,
|
||||
SpectrumAnalyzerOptionsInput,
|
||||
} from "./options.schemas.js";
|
||||
import type { BlockContext, SpectrumResult } from "./result.types.js";
|
||||
import {
|
||||
buildBase,
|
||||
ensureNotAborted,
|
||||
parseOptions,
|
||||
recordBlock,
|
||||
startRun,
|
||||
} from "./runtime.js";
|
||||
|
||||
export interface SpectrumAnalyzerRunOptions
|
||||
extends SpectrumAnalyzerOptionsInput {
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
|
||||
function buildResult(
|
||||
doc: AudioDocument,
|
||||
spectrum: Float32Array,
|
||||
magnitude: Float32Array,
|
||||
previous: Float32Array | undefined,
|
||||
opts: SpectrumAnalyzerOptions
|
||||
): SpectrumResult {
|
||||
return {
|
||||
...buildBase("spectrum-analyzer", doc),
|
||||
op: "spectrum-analyzer",
|
||||
spectrum,
|
||||
centroid: spectralCentroid(magnitude, doc.sampleRate, opts.frameSize),
|
||||
rolloff: spectralRolloff(magnitude, doc.sampleRate, opts.frameSize),
|
||||
flux: spectralFlux(magnitude, previous),
|
||||
};
|
||||
}
|
||||
|
||||
export class SpectrumAnalyzer {
|
||||
public readonly name = "spectrum-analyzer";
|
||||
public readonly description =
|
||||
"Magnitude spectrum + centroid/rolloff/flux for a real-valued audio document.";
|
||||
|
||||
public async analyze(
|
||||
doc: AudioDocument,
|
||||
raw?: SpectrumAnalyzerRunOptions
|
||||
): Promise<SpectrumResult> {
|
||||
const opts = parseOptions(raw);
|
||||
ensureNotAborted(raw?.signal);
|
||||
const handle = startRun("spectrum-analyzer", doc, "analyze");
|
||||
try {
|
||||
const samples = await readMono(doc);
|
||||
const count = frameCount(
|
||||
samples.length,
|
||||
opts.frameSize,
|
||||
opts.hopSize
|
||||
);
|
||||
let previous: Float32Array | undefined;
|
||||
let lastMagnitude: Float32Array = new Float32Array(
|
||||
(opts.frameSize >> 1) + 1
|
||||
);
|
||||
let lastSpectrum: Float32Array = new Float32Array(opts.bins);
|
||||
for (let i = 0; i < count; i++) {
|
||||
ensureNotAborted(raw?.signal);
|
||||
const offset = i * opts.hopSize;
|
||||
const mag = frameMagnitude(
|
||||
samples,
|
||||
offset,
|
||||
opts.frameSize,
|
||||
opts.window
|
||||
);
|
||||
const reduced = reduceToBins(mag, opts.bins);
|
||||
previous = lastMagnitude;
|
||||
lastMagnitude = mag;
|
||||
lastSpectrum = reduced;
|
||||
}
|
||||
const result = buildResult(
|
||||
doc,
|
||||
lastSpectrum,
|
||||
lastMagnitude,
|
||||
previous,
|
||||
opts
|
||||
);
|
||||
handle.end();
|
||||
return result;
|
||||
} catch (err) {
|
||||
handle.fail(err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
public async *analyzeStream(
|
||||
doc: AudioDocument,
|
||||
raw?: SpectrumAnalyzerRunOptions
|
||||
): AsyncGenerator<SpectrumResult> {
|
||||
const opts = parseOptions(raw);
|
||||
ensureNotAborted(raw?.signal);
|
||||
const handle = startRun("spectrum-analyzer", doc, "stream");
|
||||
try {
|
||||
const samples = await readMono(doc);
|
||||
const count = frameCount(
|
||||
samples.length,
|
||||
opts.frameSize,
|
||||
opts.hopSize
|
||||
);
|
||||
let previous: Float32Array | undefined;
|
||||
for (let i = 0; i < count; i++) {
|
||||
ensureNotAborted(raw?.signal);
|
||||
const offset = i * opts.hopSize;
|
||||
const mag = frameMagnitude(
|
||||
samples,
|
||||
offset,
|
||||
opts.frameSize,
|
||||
opts.window
|
||||
);
|
||||
const reduced = reduceToBins(mag, opts.bins);
|
||||
yield buildResult(doc, reduced, mag, previous, opts);
|
||||
previous = mag;
|
||||
}
|
||||
handle.end();
|
||||
} catch (err) {
|
||||
handle.fail(err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
public analyzeBlock(
|
||||
block: Float32Array[],
|
||||
ctx: BlockContext
|
||||
): SpectrumResult {
|
||||
const opts = parseOptions({});
|
||||
const startedAt = performance.now();
|
||||
const channel = block[0] ?? new Float32Array(opts.frameSize);
|
||||
const padded =
|
||||
channel.length >= opts.frameSize
|
||||
? channel.subarray(0, opts.frameSize)
|
||||
: ((): Float32Array => {
|
||||
const padded2 = new Float32Array(opts.frameSize);
|
||||
padded2.set(channel);
|
||||
return padded2;
|
||||
})();
|
||||
const mag = frameMagnitude(padded, 0, opts.frameSize, opts.window);
|
||||
const reduced = reduceToBins(mag, opts.bins);
|
||||
const result: SpectrumResult = {
|
||||
analyserName: "spectrum-analyzer",
|
||||
analyserVersion: "0.1.0",
|
||||
sampleRate: ctx.sampleRate,
|
||||
durationSamples: opts.frameSize,
|
||||
computedAt: Date.now(),
|
||||
op: "spectrum-analyzer",
|
||||
spectrum: reduced,
|
||||
centroid: spectralCentroid(mag, ctx.sampleRate, opts.frameSize),
|
||||
rolloff: spectralRolloff(mag, ctx.sampleRate, opts.frameSize),
|
||||
flux: 0,
|
||||
};
|
||||
recordBlock("spectrum-analyzer", performance.now() - startedAt);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user