Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -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.

View File

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

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

View File

@@ -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";

View File

@@ -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.

View File

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

View File

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

View File

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

View File

@@ -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[];
}

View File

@@ -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.

View File

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

View File

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

View File

@@ -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.

View File

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

View File

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

View File

@@ -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.

View File

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

View File

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