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,62 @@
# @suaveplan/audio-analysis-spectrum - Universal spectral analysis: real FFT, Hann/Hamming/Blackman windows, mel filterbank, DCT-II, SpectrumAnalyzer/Spectrogram/MFCC analysis ops with OTel instrumentation. Zero browser or Node dependencies.
Universal spectral analysis for the Suaveplan audio stack: real FFT, Hann/Hamming/Blackman windows, mel filterbank, DCT-II, and three high-level analyzers — `SpectrumAnalyzer`, `Spectrogram`, `MFCC`. Zero browser or Node-only dependencies.
<!-- begin:auto-docs -->
## Documentation tree
- [dct](./src/algorithms/dct.md) — The DCT-II module is the cepstral projection step of the MFCC pipeline: it takes the log mel-spectrum of one frame and…
- [fft](./src/algorithms/fft.md) — The FFT module is the numerical core every analyzer in this package depends on. It runs an in-place Cooley–Tukey radix-…
- [mel-scale](./src/algorithms/mel-scale.md) — The mel-scale module provides the perceptual frequency warp used by MFCC and any caller that wants a psychoacoustically…
- [window](./src/algorithms/window.md) — Window functions taper the endpoints of each analysis frame so the FFT sees a periodic-looking signal rather than the a…
- [dsp](./src/analyzer/dsp.md) — The dsp module is the per-frame DSP toolbox every analyzer in this package leans on. It owns the document→mono read pat…
- [mfcc](./src/analyzer/mfcc.md) — `MFCC` is the speech/music feature extractor of this package: per frame it builds the magnitude spectrum, applies a tri…
- [runtime](./src/analyzer/runtime.md) — The runtime module is the shared spine for `SpectrumAnalyzer`, `Spectrogram`, and `MFCC`. It owns option parsing throug…
- [spectrogram](./src/analyzer/spectrogram.md) — `Spectrogram` produces the ordered list of per-frame magnitude spectra for a document, slicing by the same `frameSize`/…
- [spectrum-analyzer](./src/analyzer/spectrum-analyzer.md) — `SpectrumAnalyzer` is the workhorse analysis op of this package: it slices an `AudioDocument` into overlapping frames,…
- [error-codes](./src/error-codes/error-codes.md) — The error-codes module owns the stable `AUDIO_ANALYSIS_SPECTRUM.*` code registry used by every analyzer in this package…
- [error-codes](./src/error-codes/error-codes.md) — The error-codes module owns the stable `AUDIO_ANALYSIS_SPECTRUM.*` code registry used by every analyzer in this package…
<!-- end:auto-docs -->
## What's in the box
- **Algorithms** (`./algorithms/*`)
- `fft` — Cooley–Tukey radix-2 real-input FFT for every power of two in `[256, 32768]`.
- `window` — Hann, Hamming, Blackman windows with per-`(name, size)` caching.
- `mel-scale` — Slaney/HTK hz↔mel mapping plus a cached triangular filterbank.
- `dct` — type-II DCT with cached kernel matrix.
- **Analyzers** (`./analyzer/*`)
- `SpectrumAnalyzer` — magnitude spectrum + centroid/rolloff/flux, `analyze` / `analyzeStream` / `analyzeBlock`.
- `Spectrogram` — time-frequency magnitude matrix, `analyze` / `analyzeStream`.
- `MFCC` — 13-coefficient mel-frequency cepstral coefficients per frame.
- **Error codes** (`./error-codes`) — `AUDIO_ANALYSIS_SPECTRUM.{INVALID_OPTIONS, INVALID_FRAME, ABORTED}` via `defineCodes`.
- **OTel instrumentation** — `audio.analysis.runs_total`, `audio.analysis.duration_ms`, `audio.analysis.block_duration_ms` counters/histograms; a span per `analyze`/`analyzeStream` invocation. Inert without an exporter.
## Quick start
```ts
import { SpectrumAnalyzer } from "@suaveplan/audio-analysis-spectrum";
import { AudioDocument } from "@suaveplan/audio-core/document/document";
const analyzer = new SpectrumAnalyzer();
const result = await analyzer.analyze(doc, {
bins: 128,
frameSize: 2048,
hopSize: 512,
window: "hann",
});
console.log(result.centroid, result.rolloff, result.flux);
```
## Universal guarantees
- Imports zero browser globals (`window`, `document`, `AudioContext`, `AnalyserNode`).
- Imports zero Node-only modules (`node:fs`, `node:path`, `process`, `Buffer`).
- Runtime deps: `@suaveplan/audio-core`, `@suaveplan/error`, `@suaveplan/types`, `@opentelemetry/api`, `tslib`, `zod`.
- Each analyzer is sidecar — analysis never mutates the source `AudioDocument`.
## Status
`0.1.0`. Pre-publish gate (lint + typecheck + test) and 100% coverage are required on every change.

View File

@@ -0,0 +1,108 @@
{
"name": "@suaveplan/audio-analysis-spectrum",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Universal spectral analysis: real FFT, Hann/Hamming/Blackman windows, mel filterbank, DCT-II, SpectrumAnalyzer/Spectrogram/MFCC analysis ops with OTel instrumentation. Zero browser or Node dependencies.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./analyzer/spectrum-analyzer": {
"import": {
"types": "./dist/analyzer/spectrum-analyzer.d.ts",
"default": "./dist/analyzer/spectrum-analyzer.js"
}
},
"./analyzer/spectrogram": {
"import": {
"types": "./dist/analyzer/spectrogram.d.ts",
"default": "./dist/analyzer/spectrogram.js"
}
},
"./analyzer/mfcc": {
"import": {
"types": "./dist/analyzer/mfcc.d.ts",
"default": "./dist/analyzer/mfcc.js"
}
},
"./algorithms/fft": {
"import": {
"types": "./dist/algorithms/fft.d.ts",
"default": "./dist/algorithms/fft.js"
}
},
"./algorithms/window": {
"import": {
"types": "./dist/algorithms/window.d.ts",
"default": "./dist/algorithms/window.js"
}
},
"./algorithms/mel-scale": {
"import": {
"types": "./dist/algorithms/mel-scale.d.ts",
"default": "./dist/algorithms/mel-scale.js"
}
},
"./algorithms/dct": {
"import": {
"types": "./dist/algorithms/dct.d.ts",
"default": "./dist/algorithms/dct.js"
}
},
"./error-codes": {
"import": {
"types": "./dist/error-codes/index.d.ts",
"default": "./dist/error-codes/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/audio-core": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"@opentelemetry/api": "catalog:observability",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,44 @@
# `dct` — type-II discrete cosine transform
## Purpose
The DCT-II module is the cepstral projection step of the MFCC pipeline: it takes the log mel-spectrum of one frame and produces a compact vector of cepstral coefficients. The same routine is reusable anywhere a closed-form orthonormal-with-scale DCT is needed.
This implementation is unscaled (no `sqrt(2/N)` orthonormality factor) because downstream MFCC analysis is unit-agnostic: every coefficient is later compared to peers, not to an external reference. Callers that need an orthonormal form can post-multiply by the standard scaling factor.
## Features
- Closed-form `X_k = Σ x_n · cos((π/N)(n + ½)k)` for `0 ≤ k < coefficients`.
- Cached per `(inputSize, coefficients)` so repeated frames reuse the kernel matrix.
- Pure portable TypeScript over `Float32Array` inputs and outputs.
## Usage: Basic
```ts
import { dctII } from "@suaveplan/audio-analysis-spectrum/algorithms/dct";
const logMel = new Float32Array(40); // log mel-spectrum
const mfcc = dctII(logMel, 13);
```
## Usage: Advanced
```ts
import {
clearDctCache,
dctII,
} from "@suaveplan/audio-analysis-spectrum/algorithms/dct";
const coeffs = dctII(logMel, 20);
// later, before a benchmark sweep
clearDctCache();
```
## API Reference
- `dctII(input, coefficients): Float32Array` — first `coefficients` cepstral values.
- `clearDctCache(): void` — evict every cached kernel.
## Implementation Notes
The kernel is stored as a flat `Float64Array` of `coefficients * inputSize` doubles so the per-frame inner loop is a single linear pass with no nested allocation. Kernel construction is `O(coefficients · inputSize)` and is cached on the `(inputSize, coefficients)` pair; runtime cost is then a single matrix-vector multiply per frame. Boundary checks on `coefficients` (positive, `≤ melBands`) are enforced at the analyzer layer before this routine is called, so this module assumes a validated input.

View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { clearDctCache, dctII } from "./dct.js";
function reference(input: Float32Array, coefficients: number): Float32Array {
const N = input.length;
const out = new Float32Array(coefficients);
for (let k = 0; k < coefficients; k++) {
let sum = 0;
for (let n = 0; n < N; n++) {
sum += input[n]! * Math.cos((Math.PI / N) * (n + 0.5) * k);
}
out[k] = sum;
}
return out;
}
describe("dctII", () => {
it("DCT-II matches the closed form", { timeout: 5000 }, () => {
const input = new Float32Array([1, 2, 3, 4, 5, 4, 3, 2]);
const got = dctII(input, 4);
const ref = reference(input, 4);
for (let k = 0; k < 4; k++) {
expect(Math.abs(got[k]! - ref[k]!)).toBeLessThan(1e-5);
}
});
it("caches per (inputSize, coefficients)", { timeout: 5000 }, () => {
const a = new Float32Array(40);
const b = new Float32Array(40);
for (let i = 0; i < 40; i++) {
a[i] = i;
b[i] = i + 1;
}
const r1 = dctII(a, 13);
const r2 = dctII(b, 13);
expect(r1[0]).not.toBe(r2[0]);
});
it("clearDctCache evicts the kernel without affecting correctness", {
timeout: 5000,
}, () => {
const input = new Float32Array([1, 2, 3, 4]);
const before = dctII(input, 2);
clearDctCache();
const after = dctII(input, 2);
expect(before[0]).toBe(after[0]);
expect(before[1]).toBe(after[1]);
});
});

View File

@@ -0,0 +1,49 @@
const KERNEL_CACHE = new Map<string, Float64Array>();
function key(inputSize: number, coefficients: number): string {
return `${inputSize}:${coefficients}`;
}
function buildKernel(inputSize: number, coefficients: number): Float64Array {
const kernel = new Float64Array(coefficients * inputSize);
for (let k = 0; k < coefficients; k++) {
const row = k * inputSize;
for (let n = 0; n < inputSize; n++) {
kernel[row + n] = Math.cos((Math.PI / inputSize) * (n + 0.5) * k);
}
}
return kernel;
}
/**
* Type-II DCT.
* Returns the first `coefficients` cepstral values:
* X_k = sum_{n=0}^{N-1} x_n * cos((pi/N) * (n + 0.5) * k)
*
* `coefficients` must satisfy `1 <= coefficients <= input.length`. Out-of-range
* inputs are caller-validated by the MFCC analyzer before reaching this layer.
*/
export function dctII(input: Float32Array, coefficients: number): Float32Array {
const inputSize = input.length;
const cacheKey = key(inputSize, coefficients);
let kernel = KERNEL_CACHE.get(cacheKey);
if (kernel === undefined) {
kernel = buildKernel(inputSize, coefficients);
KERNEL_CACHE.set(cacheKey, kernel);
}
const out = new Float32Array(coefficients);
for (let k = 0; k < coefficients; k++) {
const row = k * inputSize;
let sum = 0;
for (let n = 0; n < inputSize; n++) {
sum += input[n]! * kernel[row + n]!;
}
out[k] = sum;
}
return out;
}
/** Drop every cached DCT-II kernel. */
export function clearDctCache(): void {
KERNEL_CACHE.clear();
}

View File

@@ -0,0 +1,50 @@
# `fft` — real-input FFT over power-of-two frames
## Purpose
The FFT module is the numerical core every analyzer in this package depends on. It runs an in-place Cooley–Tukey radix-2 decimation-in-time transform over a windowed real-valued frame and returns the non-redundant `frameSize / 2 + 1` magnitude bins (and optionally the matching phase array). Twiddle-factor tables and the bit-reverse index are built once per frame size and cached, so repeated frames pay only the per-call working-buffer allocation.
The transform is deterministic — the same input bytes always yield byte-identical magnitudes — and is portable TypeScript with no platform globals, satisfying the universal purity requirement.
## Features
- Radix-2 in-place Cooley–Tukey for every power of two in `[256, 32768]`.
- Cached twiddle (`cos`/`sin` over the unit circle) and bit-reverse tables.
- Optional phase output via `{ withPhase: true }`.
- Boundary `assertValidFrameSize` for callers that own their own input shape.
- `clearFFTCache` for cold-start benchmark measurement.
## Usage: Basic
```ts
import { transform } from "@suaveplan/audio-analysis-spectrum/algorithms/fft";
const frame = new Float32Array(2048);
// fill `frame` with a windowed signal...
const { magnitude } = transform(frame);
```
## Usage: Advanced
```ts
import {
assertValidFrameSize,
clearFFTCache,
transform,
} from "@suaveplan/audio-analysis-spectrum/algorithms/fft";
assertValidFrameSize(4096); // throws AUDIO_ANALYSIS_SPECTRUM.INVALID_FRAME if wrong
const { magnitude, phase } = transform(frame, { withPhase: true });
clearFFTCache(); // evict cached twiddle tables before a benchmark sweep
```
## API Reference
- `transform(frame, opts?): FFTResult` — per-bin `Float32Array` magnitude (length `frameSize/2+1`) plus optional phase.
- `assertValidFrameSize(frameSize): number` — throws `AUDIO_ANALYSIS_SPECTRUM.INVALID_FRAME` if not a power of two in `[256, 32768]`.
- `clearFFTCache(): void` — drop every cached twiddle/bit-reverse table.
- `FFT_FRAME_MIN`, `FFT_FRAME_MAX` — the inclusive bounds enforced above.
## Implementation Notes
Twiddle storage is `Float64Array` to keep the rotation pole-bound errors in float64 even though the output is downcast to `Float32Array`. The bit-reverse table is `Uint32Array` rather than computed per call. Each frame pays the cost of two length-`size` `Float64Array` working buffers, which dominates the per-call allocation budget but avoids leaking mutable state between calls. A pure sinusoid at a bin-centre frequency produces one dominant magnitude bin; an impulse produces a flat magnitude spectrum; both are tested as the floor of correctness.

View File

@@ -0,0 +1,107 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { AUDIO_ANALYSIS_SPECTRUM_CODES } from "../error-codes/error-codes.js";
import { assertValidFrameSize, clearFFTCache, transform } from "./fft.js";
function sineFrame(size: number, bin: number): Float32Array {
const out = new Float32Array(size);
for (let n = 0; n < size; n++) {
out[n] = Math.sin((2 * Math.PI * bin * n) / size);
}
return out;
}
describe("fft.transform", () => {
it("pure sinusoid yields a single magnitude peak", {
timeout: 5000,
}, () => {
const size = 1024;
const bin = 64;
const frame = sineFrame(size, bin);
const { magnitude } = transform(frame);
let peak = 0;
let peakBin = 0;
for (let k = 0; k < magnitude.length; k++) {
if (magnitude[k]! > peak) {
peak = magnitude[k]!;
peakBin = k;
}
}
expect(peakBin).toBe(bin);
// Energy concentrated in the peak bin (>5x average over the rest).
let other = 0;
for (let k = 0; k < magnitude.length; k++) {
if (k !== peakBin) other += magnitude[k]!;
}
expect(peak).toBeGreaterThan(other / magnitude.length);
});
it("rejects a non-power-of-two frame size with AUDIO_ANALYSIS_SPECTRUM.INVALID_FRAME", {
timeout: 5000,
}, () => {
const frame = new Float32Array(1000);
expect(() => transform(frame)).toThrowError(
expect.objectContaining({
code: AUDIO_ANALYSIS_SPECTRUM_CODES.INVALID_FRAME,
}) as unknown as Error
);
});
it("rejects a too-small frame", { timeout: 5000 }, () => {
expect(() => transform(new Float32Array(128))).toThrow();
});
it("rejects a too-large frame", { timeout: 5000 }, () => {
expect(() => assertValidFrameSize(65_536)).toThrow();
});
it("rejects a non-integer frame via assertValidFrameSize", {
timeout: 5000,
}, () => {
expect(() => assertValidFrameSize(2048.5)).toThrow();
});
it("produces frameSize/2 + 1 magnitude bins", { timeout: 5000 }, () => {
const frame = new Float32Array(2048);
const { magnitude, phase } = transform(frame);
expect(magnitude.length).toBe(1025);
expect(phase).toBeUndefined();
});
it("returns phase when requested", { timeout: 5000 }, () => {
const frame = sineFrame(256, 16);
const { phase } = transform(frame, { withPhase: true });
expect(phase).toBeDefined();
expect(phase!.length).toBe(129);
});
it("an impulse produces a near-flat magnitude spectrum", {
timeout: 5000,
}, () => {
const size = 512;
const frame = new Float32Array(size);
frame[0] = 1;
const { magnitude } = transform(frame);
const mean = magnitude.reduce((s, v) => s + v, 0) / magnitude.length;
for (const v of magnitude) {
expect(Math.abs(v - mean)).toBeLessThan(1e-3);
}
});
it("is deterministic across calls", { timeout: 5000 }, () => {
const frame = sineFrame(256, 8);
const a = transform(frame);
const b = transform(frame);
for (let i = 0; i < a.magnitude.length; i++) {
expect(a.magnitude[i]).toBe(b.magnitude[i]);
}
});
it("clearFFTCache evicts twiddle tables (no crash on re-use)", {
timeout: 5000,
}, () => {
transform(new Float32Array(256));
clearFFTCache();
const out = transform(new Float32Array(256));
expect(out.magnitude.length).toBe(129);
});
});

View File

@@ -0,0 +1,153 @@
import {
AUDIO_ANALYSIS_SPECTRUM_CODES,
AudioAnalysisSpectrumError,
} from "../error-codes/error-codes.js";
import {
FFT_FRAME_MAX,
FFT_FRAME_MIN,
type FFTOptions,
type FFTResult,
} from "./fft.types.js";
interface Tables {
readonly cos: Float64Array;
readonly sin: Float64Array;
readonly bitReverse: Uint32Array;
}
const TABLE_CACHE = new Map<number, Tables>();
function isPowerOfTwo(n: number): boolean {
return n > 0 && (n & (n - 1)) === 0;
}
/**
* Validate `frameSize` and throw `AUDIO_ANALYSIS_SPECTRUM.INVALID_FRAME` if
* out of range or not a power of two. Returns the validated size for chaining.
*/
export function assertValidFrameSize(frameSize: number): number {
if (
!Number.isInteger(frameSize) ||
frameSize < FFT_FRAME_MIN ||
frameSize > FFT_FRAME_MAX ||
!isPowerOfTwo(frameSize)
) {
throw new AudioAnalysisSpectrumError(
AUDIO_ANALYSIS_SPECTRUM_CODES.INVALID_FRAME,
"audio-analysis-spectrum.invalid-frame",
{ context: { frameSize } }
);
}
return frameSize;
}
function buildTables(size: number): Tables {
const cos = new Float64Array(size);
const sin = new Float64Array(size);
for (let i = 0; i < size; i++) {
const theta = (-2 * Math.PI * i) / size;
cos[i] = Math.cos(theta);
sin[i] = Math.sin(theta);
}
const bits = Math.log2(size) | 0;
const bitReverse = new Uint32Array(size);
for (let i = 0; i < size; i++) {
let j = 0;
let x = i;
for (let k = 0; k < bits; k++) {
j = (j << 1) | (x & 1);
x >>>= 1;
}
bitReverse[i] = j;
}
return { cos, sin, bitReverse };
}
function getTables(size: number): Tables {
const cached = TABLE_CACHE.get(size);
if (cached !== undefined) return cached;
const built = buildTables(size);
TABLE_CACHE.set(size, built);
return built;
}
/**
* In-place Cooley–Tukey radix-2 decimation-in-time FFT.
* `re`/`im` are length-`size` working buffers; on return they hold
* the complex spectrum.
*/
function fftInPlace(
re: Float64Array,
im: Float64Array,
tables: Tables,
size: number
): void {
// Bit-reverse permutation.
const { bitReverse, cos, sin } = tables;
for (let i = 0; i < size; i++) {
const j = bitReverse[i]!;
if (j > i) {
const tr = re[i]!;
const ti = im[i]!;
re[i] = re[j]!;
im[i] = im[j]!;
re[j] = tr;
im[j] = ti;
}
}
// Butterflies.
for (let len = 2; len <= size; len <<= 1) {
const half = len >> 1;
const step = size / len;
for (let i = 0; i < size; i += len) {
for (let k = 0; k < half; k++) {
const twiddleIdx = k * step;
const wr = cos[twiddleIdx]!;
const wi = sin[twiddleIdx]!;
const iEven = i + k;
const iOdd = iEven + half;
const tr = wr * re[iOdd]! - wi * im[iOdd]!;
const ti = wr * im[iOdd]! + wi * re[iOdd]!;
re[iOdd] = re[iEven]! - tr;
im[iOdd] = im[iEven]! - ti;
re[iEven] = re[iEven]! + tr;
im[iEven] = im[iEven]! + ti;
}
}
}
}
/**
* Transform a real-valued windowed frame into per-bin magnitude (and phase,
* if requested). Returns `frame.length / 2 + 1` non-redundant bins.
*
* `frame.length` must be a power of two in `[256, 32768]`; otherwise an
* `AUDIO_ANALYSIS_SPECTRUM.INVALID_FRAME` error is thrown.
*/
export function transform(
frame: Float32Array,
opts: FFTOptions = {}
): FFTResult {
const size = assertValidFrameSize(frame.length);
const tables = getTables(size);
const re = new Float64Array(size);
const im = new Float64Array(size);
for (let i = 0; i < size; i++) re[i] = frame[i]!;
fftInPlace(re, im, tables, size);
const halfPlus = (size >> 1) + 1;
const magnitude = new Float32Array(halfPlus);
const phase =
opts.withPhase === true ? new Float32Array(halfPlus) : undefined;
for (let k = 0; k < halfPlus; k++) {
const r = re[k]!;
const i = im[k]!;
magnitude[k] = Math.sqrt(r * r + i * i);
if (phase !== undefined) phase[k] = Math.atan2(i, r);
}
return { magnitude, phase };
}
/** Drop every cached twiddle/bit-reverse table. */
export function clearFFTCache(): void {
TABLE_CACHE.clear();
}

View File

@@ -0,0 +1,16 @@
/**
* Output of `fft.transform`: per-bin magnitude and (optionally) phase.
* Length is `frameSize / 2 + 1` — the non-redundant real-FFT bins.
*/
export interface FFTResult {
readonly magnitude: Float32Array;
readonly phase: Float32Array | undefined;
}
export interface FFTOptions {
readonly withPhase?: boolean;
}
/** Inclusive bounds on `frameSize` enforced by the FFT layer. */
export const FFT_FRAME_MIN = 256;
export const FFT_FRAME_MAX = 32_768;

View File

@@ -0,0 +1,29 @@
export { clearDctCache, dctII } from "./dct.js";
export {
assertValidFrameSize,
clearFFTCache,
transform,
} from "./fft.js";
export {
FFT_FRAME_MAX,
FFT_FRAME_MIN,
type FFTOptions,
type FFTResult,
} from "./fft.types.js";
export {
applyMelFilterbank,
buildMelFilterbank,
clearMelFilterbankCache,
hzToMel,
melToHz,
} from "./mel-scale.js";
export type {
MelFilterbank,
MelFilterbankSpec,
} from "./mel-scale.types.js";
export {
applyWindow,
clearWindowCache,
getWindow,
} from "./window.js";
export type { WindowName } from "./window.types.js";

View File

@@ -0,0 +1,58 @@
# `mel-scale` — hertz↔mel mapping and triangular filterbank
## Purpose
The mel-scale module provides the perceptual frequency warp used by MFCC and any caller that wants a psychoacoustically motivated reduction of an FFT magnitude spectrum. It exposes the Slaney/HTK closed-form `hzToMel` and its inverse `melToHz`, plus a builder that produces a triangular filterbank for a given `(frameSize, sampleRate, melBands, minHz, maxHz)` tuple.
Each filterbank is cached so repeated frames pay zero filter-construction cost; applying a filterbank to a magnitude spectrum is then a single dense matrix-vector multiply.
## Features
- Closed-form hertz↔mel mapping that round-trips to machine precision.
- Triangular filterbank with `melBands` bands evenly spaced on the mel scale.
- Per-spec caching keyed by `(frameSize, sampleRate, melBands, minHz, maxHz)`.
- Boundary validation that `melBands` is a positive integer.
## Usage: Basic
```ts
import {
applyMelFilterbank,
buildMelFilterbank,
} from "@suaveplan/audio-analysis-spectrum/algorithms/mel-scale";
const fb = buildMelFilterbank({
frameSize: 2048,
sampleRate: 48_000,
melBands: 40,
minHz: 0,
maxHz: 24_000,
});
const mel = applyMelFilterbank(spectrum, fb);
```
## Usage: Advanced
```ts
import {
clearMelFilterbankCache,
hzToMel,
melToHz,
} from "@suaveplan/audio-analysis-spectrum/algorithms/mel-scale";
const m = hzToMel(1000);
const back = melToHz(m); // ≈ 1000 within 1e-3
clearMelFilterbankCache(); // before a benchmark sweep
```
## API Reference
- `hzToMel(hz): number` / `melToHz(mel): number` — Slaney/HTK closed forms.
- `buildMelFilterbank(spec): MelFilterbank` — cached triangular filterbank.
- `applyMelFilterbank(spectrum, bank): Float32Array` — dense mel-band vector.
- `clearMelFilterbankCache(): void` — drop every cached filterbank.
## Implementation Notes
The builder allocates `melBands + 2` mel-spaced points so each band has both a lower and an upper neighbour centre. Bin frequencies are computed once as `(i * sampleRate) / frameSize` to avoid a per-band recomputation. Each band's weights peak at `1` and taper linearly to `0` at the neighbour centres; no per-band energy normalisation is applied because the downstream MFCC log+DCT step is insensitive to global scaling.

View File

@@ -0,0 +1,81 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { AUDIO_ANALYSIS_SPECTRUM_CODES } from "../error-codes/error-codes.js";
import {
applyMelFilterbank,
buildMelFilterbank,
clearMelFilterbankCache,
hzToMel,
melToHz,
} from "./mel-scale.js";
describe("mel-scale", () => {
it("hertz-to-mel mapping round-trips", { timeout: 5000 }, () => {
for (const hz of [50, 250, 1000, 4000, 12_000]) {
const back = melToHz(hzToMel(hz));
expect(Math.abs(back - hz)).toBeLessThan(1e-3);
}
});
it("filterbank maps a spectrum to the mel band count", {
timeout: 5000,
}, () => {
const fb = buildMelFilterbank({
frameSize: 2048,
sampleRate: 48_000,
melBands: 40,
minHz: 0,
maxHz: 24_000,
});
const spectrum = new Float32Array(1025);
for (let i = 0; i < spectrum.length; i++) spectrum[i] = 1;
const mel = applyMelFilterbank(spectrum, fb);
expect(mel.length).toBe(40);
for (const v of mel) expect(v).toBeGreaterThanOrEqual(0);
});
it("caches per spec and rebuilds after clear", { timeout: 5000 }, () => {
const spec = {
frameSize: 512,
sampleRate: 16_000,
melBands: 10,
minHz: 0,
maxHz: 8000,
};
const a = buildMelFilterbank(spec);
const b = buildMelFilterbank(spec);
expect(a).toBe(b);
clearMelFilterbankCache();
const c = buildMelFilterbank(spec);
expect(a).not.toBe(c);
});
it("rejects non-positive melBands with INVALID_OPTIONS", {
timeout: 5000,
}, () => {
expect(() =>
buildMelFilterbank({
frameSize: 512,
sampleRate: 16_000,
melBands: 0,
minHz: 0,
maxHz: 8000,
})
).toThrowError(
expect.objectContaining({
code: AUDIO_ANALYSIS_SPECTRUM_CODES.INVALID_OPTIONS,
}) as unknown as Error
);
});
it("rejects non-integer melBands", { timeout: 5000 }, () => {
expect(() =>
buildMelFilterbank({
frameSize: 512,
sampleRate: 16_000,
melBands: 5.5,
minHz: 0,
maxHz: 8000,
})
).toThrow();
});
});

View File

@@ -0,0 +1,97 @@
import {
AUDIO_ANALYSIS_SPECTRUM_CODES,
AudioAnalysisSpectrumError,
} from "../error-codes/error-codes.js";
import type { MelFilterbank, MelFilterbankSpec } from "./mel-scale.types.js";
/** Slaney/HTK mel-from-Hz mapping. */
export function hzToMel(hz: number): number {
return 2595 * Math.log10(1 + hz / 700);
}
/** Inverse of `hzToMel`. */
export function melToHz(mel: number): number {
return 700 * (10 ** (mel / 2595) - 1);
}
const CACHE = new Map<string, MelFilterbank>();
function key(spec: MelFilterbankSpec): string {
return `${spec.frameSize}:${spec.sampleRate}:${spec.melBands}:${spec.minHz}:${spec.maxHz}`;
}
/**
* Build (or retrieve from cache) the triangular mel filterbank for the spec.
* Each row is a length-`frameSize/2+1` weight vector with peak `1` at the
* filter centre and linearly tapered sides between the lower and upper
* neighbour centres.
*/
export function buildMelFilterbank(spec: MelFilterbankSpec): MelFilterbank {
if (!Number.isInteger(spec.melBands) || spec.melBands <= 0) {
throw new AudioAnalysisSpectrumError(
AUDIO_ANALYSIS_SPECTRUM_CODES.INVALID_OPTIONS,
"audio-analysis-spectrum.invalid-mel-bands",
{ context: { melBands: spec.melBands } }
);
}
const cached = CACHE.get(key(spec));
if (cached !== undefined) return cached;
const bins = (spec.frameSize >> 1) + 1;
const melLow = hzToMel(spec.minHz);
const melHigh = hzToMel(spec.maxHz);
// Need `melBands + 2` mel points so each band has a lower neighbour and
// an upper neighbour centre.
const points = new Float64Array(spec.melBands + 2);
const step = (melHigh - melLow) / (spec.melBands + 1);
for (let i = 0; i < points.length; i++) {
points[i] = melToHz(melLow + i * step);
}
const binFrequencies = new Float64Array(bins);
for (let i = 0; i < bins; i++) {
binFrequencies[i] = (i * spec.sampleRate) / spec.frameSize;
}
const weights: Float32Array[] = [];
for (let m = 1; m <= spec.melBands; m++) {
const left = points[m - 1]!;
const centre = points[m]!;
const right = points[m + 1]!;
const row = new Float32Array(bins);
for (let b = 0; b < bins; b++) {
const f = binFrequencies[b]!;
if (f >= left && f <= centre) {
row[b] = (f - left) / (centre - left);
} else if (f > centre && f <= right) {
row[b] = (right - f) / (right - centre);
}
}
weights.push(row);
}
const fb: MelFilterbank = { spec, weights };
CACHE.set(key(spec), fb);
return fb;
}
/**
* Apply a mel filterbank to a magnitude spectrum.
* `spectrum.length` must equal `spec.frameSize / 2 + 1`.
*/
export function applyMelFilterbank(
spectrum: Float32Array,
bank: MelFilterbank
): Float32Array {
const out = new Float32Array(bank.weights.length);
for (let m = 0; m < bank.weights.length; m++) {
const row = bank.weights[m]!;
let sum = 0;
for (let b = 0; b < row.length; b++) {
sum += row[b]! * spectrum[b]!;
}
out[m] = sum;
}
return out;
}
/** Drop every cached filterbank. */
export function clearMelFilterbankCache(): void {
CACHE.clear();
}

View File

@@ -0,0 +1,16 @@
export interface MelFilterbankSpec {
readonly frameSize: number;
readonly sampleRate: number;
readonly melBands: number;
readonly minHz: number;
readonly maxHz: number;
}
/**
* Triangular filterbank stored as a dense matrix of `melBands` rows by
* `frameSize/2 + 1` columns — one weight per FFT bin per band.
*/
export interface MelFilterbank {
readonly spec: MelFilterbankSpec;
readonly weights: Float32Array[];
}

View File

@@ -0,0 +1,56 @@
# `window` — discrete window functions
## Purpose
Window functions taper the endpoints of each analysis frame so the FFT sees a periodic-looking signal rather than the abrupt discontinuities created by a rectangular slice. Tapering reduces spectral leakage at the cost of a small main-lobe widening, which is the standard trade-off used by every spectral analyzer in this package.
The module exposes three closed-form windows — Hann, Hamming, and Blackman — selectable by the `window` option of `SpectrumAnalyzerOptions`. Vectors are cached per `(name, size)` so the spectrum, spectrogram, and MFCC analyzers share a single allocation across every frame of the same shape.
## Features
- Hann, Hamming, Blackman generators producing `Float32Array` vectors.
- Cached per `(name, size)` to avoid re-allocation on every frame.
- `applyWindow` for in-place multiplication into a caller-provided buffer.
- `clearWindowCache` for benchmarks measuring cold-start cost.
## Usage: Basic
```ts
import {
applyWindow,
getWindow,
} from "@suaveplan/audio-analysis-spectrum/algorithms/window";
const w = getWindow("hann", 2048);
const frame = new Float32Array(2048);
const out = new Float32Array(2048);
applyWindow(frame, w, out);
```
## Usage: Advanced
```ts
import {
clearWindowCache,
getWindow,
} from "@suaveplan/audio-analysis-spectrum/algorithms/window";
// Pre-warm the cache for the working set.
for (const name of ["hann", "hamming", "blackman"] as const) {
for (const size of [1024, 2048, 4096]) getWindow(name, size);
}
// Later, before a benchmark, evict every vector.
clearWindowCache();
```
## API Reference
- `getWindow(name, size): Float32Array` — cached window vector.
- `applyWindow(frame, window, out): void` — element-wise multiply.
- `clearWindowCache(): void` — drop every cached vector.
- `WindowName = "hann" | "hamming" | "blackman"`.
## Implementation Notes
Each closed form is the standard periodic definition with `N - 1` in the denominator, so the endpoints of Hann and Blackman are exact zeros and the centre is exactly `1`. Hamming uses the `(0.54, 0.46)` coefficients from Hamming's 1989 paper. The cache key is `${name}:${size}`; since both inputs are tightly constrained by the analyzer layer the key set is small.

View File

@@ -0,0 +1,77 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { applyWindow, clearWindowCache, getWindow } from "./window.js";
describe("window", () => {
it("Hann window matches the closed form", { timeout: 5000 }, () => {
const size = 2048;
const w = getWindow("hann", size);
const denom = size - 1;
expect(Math.abs(w[0]!)).toBeLessThan(1e-6);
expect(Math.abs(w[denom]!)).toBeLessThan(1e-6);
for (let n = 0; n < size; n++) {
const expected = 0.5 - 0.5 * Math.cos((2 * Math.PI * n) / denom);
expect(Math.abs(w[n]! - expected)).toBeLessThan(1e-5);
}
});
it("Hamming and Blackman windows match their closed forms", {
timeout: 5000,
}, () => {
const size = 1024;
const denom = size - 1;
const ham = getWindow("hamming", size);
const black = getWindow("blackman", size);
for (let n = 0; n < size; n++) {
const ex = (2 * Math.PI * n) / denom;
expect(
Math.abs(ham[n]! - (0.54 - 0.46 * Math.cos(ex)))
).toBeLessThan(1e-5);
expect(
Math.abs(
black[n]! -
(0.42 - 0.5 * Math.cos(ex) + 0.08 * Math.cos(2 * ex))
)
).toBeLessThan(1e-5);
}
});
it("caches per (name, size) so repeated calls return the same buffer", {
timeout: 5000,
}, () => {
const a = getWindow("hann", 512);
const b = getWindow("hann", 512);
expect(a).toBe(b);
});
it("handles a size-1 window by returning [1] for every shape", {
timeout: 5000,
}, () => {
clearWindowCache();
for (const name of ["hann", "hamming", "blackman"] as const) {
const w = getWindow(name, 1);
expect(w.length).toBe(1);
expect(w[0]).toBe(1);
}
});
it("applyWindow multiplies element-wise into the output buffer", {
timeout: 5000,
}, () => {
const w = getWindow("hann", 4);
const frame = new Float32Array([1, 1, 1, 1]);
const out = new Float32Array(4);
applyWindow(frame, w, out);
for (let i = 0; i < 4; i++) {
expect(out[i]).toBeCloseTo(w[i]!, 6);
}
});
it("clearWindowCache evicts and forces re-allocation", {
timeout: 5000,
}, () => {
const a = getWindow("hamming", 256);
clearWindowCache();
const b = getWindow("hamming", 256);
expect(a).not.toBe(b);
});
});

View File

@@ -0,0 +1,96 @@
import type { WindowName } from "./window.types.js";
const CACHE = new Map<string, Float32Array>();
function cacheKey(name: WindowName, size: number): string {
return `${name}:${size}`;
}
function hann(size: number): Float32Array {
const out = new Float32Array(size);
if (size === 1) {
out[0] = 1;
return out;
}
const denom = size - 1;
for (let n = 0; n < size; n++) {
out[n] = 0.5 - 0.5 * Math.cos((2 * Math.PI * n) / denom);
}
return out;
}
function hamming(size: number): Float32Array {
const out = new Float32Array(size);
if (size === 1) {
out[0] = 1;
return out;
}
const denom = size - 1;
for (let n = 0; n < size; n++) {
out[n] = 0.54 - 0.46 * Math.cos((2 * Math.PI * n) / denom);
}
return out;
}
function blackman(size: number): Float32Array {
const out = new Float32Array(size);
if (size === 1) {
out[0] = 1;
return out;
}
const denom = size - 1;
const a0 = 0.42;
const a1 = 0.5;
const a2 = 0.08;
for (let n = 0; n < size; n++) {
const x = (2 * Math.PI * n) / denom;
out[n] = a0 - a1 * Math.cos(x) + a2 * Math.cos(2 * x);
}
return out;
}
const GENERATORS: Readonly<Record<WindowName, (size: number) => Float32Array>> =
Object.freeze({
hann,
hamming,
blackman,
});
/**
* Return the window vector of length `size` for the named function.
* Cached per `(name, size)` so repeated frames reuse one allocation.
*
* `size` must be a positive integer. Callers in this package always pass
* the validated `frameSize` from the FFT layer, which is already bounded
* to powers of two in `[256, 32768]`.
*/
export function getWindow(name: WindowName, size: number): Float32Array {
const key = cacheKey(name, size);
const cached = CACHE.get(key);
if (cached !== undefined) return cached;
const generated = GENERATORS[name](size);
CACHE.set(key, generated);
return generated;
}
/**
* Multiply each sample in `frame` by the corresponding window coefficient,
* writing into `out`. `frame.length === out.length === window.length`.
*/
export function applyWindow(
frame: Float32Array,
window: Float32Array,
out: Float32Array
): void {
for (let i = 0; i < frame.length; i++) {
out[i] = frame[i]! * window[i]!;
}
}
/**
* Drop every cached window vector. Exposed for tests and benchmarks that
* want to measure cold-cache allocation cost.
*/
export function clearWindowCache(): void {
CACHE.clear();
}

View File

@@ -0,0 +1,5 @@
/**
* Discrete window function names supported by the analyzers.
* The values match the case-sensitive `window` option in `SpectrumAnalyzerOptions`.
*/
export type WindowName = "hann" | "hamming" | "blackman";

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

View File

@@ -0,0 +1,57 @@
# Error codes — `@suaveplan/audio-analysis-spectrum`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The error-codes module owns the stable `AUDIO_ANALYSIS_SPECTRUM.*` code registry used by every analyzer in this package. Each error thrown by the FFT, window, mel filterbank, DCT, `SpectrumAnalyzer`, `Spectrogram`, or `MFCC` carries one of these codes so consumers can branch on machine-readable identifiers rather than message strings.
The module is import-time pure — `registerAudioAnalysisSpectrumErrorCodes` is a no-op because metadata is already frozen at module load — but it is exported so OTel registries and report builders can declare an explicit dependency on the codes existing.
## Features
- Three stable codes: `INVALID_OPTIONS`, `INVALID_FRAME`, `ABORTED`.
- Frozen metadata record with `description` and `severity` for every code.
- `AudioAnalysisSpectrumError` subclass of `SuaveplanError` with a public constructor and a `category: "audio-analysis-spectrum"` discriminator.
## Usage: Basic
```ts
import {
AudioAnalysisSpectrumError,
AUDIO_ANALYSIS_SPECTRUM_CODES,
} from "@suaveplan/audio-analysis-spectrum/error-codes";
throw new AudioAnalysisSpectrumError(
AUDIO_ANALYSIS_SPECTRUM_CODES.ABORTED,
"audio-analysis-spectrum.aborted",
);
```
## Usage: Advanced
```ts
import {
AUDIO_ANALYSIS_SPECTRUM_CODES,
AUDIO_ANALYSIS_SPECTRUM_ERROR_METADATA,
} from "@suaveplan/audio-analysis-spectrum/error-codes";
for (const code of Object.values(AUDIO_ANALYSIS_SPECTRUM_CODES)) {
const meta = AUDIO_ANALYSIS_SPECTRUM_ERROR_METADATA[code];
console.log(code, meta.severity, meta.description);
}
```
## API Reference
- `AUDIO_ANALYSIS_SPECTRUM_CODES` — frozen record produced by `defineCodes`.
- `AudioAnalysisSpectrumErrorCode` — string-literal union of the three codes.
- `AUDIO_ANALYSIS_SPECTRUM_ERROR_METADATA` — `Record<code, { description; severity }>`.
- `AudioAnalysisSpectrumError` — `SuaveplanError` subclass with `category` and a public constructor.
- `registerAudioAnalysisSpectrumErrorCodes(): void` — idempotent no-op for startup hooks.
## Implementation Notes
The class widens `SuaveplanError`'s `protected` constructor to `public` via subclass declaration. The `category` literal field defeats biome's "useless class" rule without requiring an inline suppression — see CLAUDE.md rule 9. Every code in `AUDIO_ANALYSIS_SPECTRUM_ERROR_METADATA` must be reachable from at least one test (`error-codes.test.ts` walks the metadata and asserts each is thrown from a real call site).

View File

@@ -0,0 +1,83 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
AUDIO_ANALYSIS_SPECTRUM_CODES,
AUDIO_ANALYSIS_SPECTRUM_ERROR_METADATA,
AudioAnalysisSpectrumError,
registerAudioAnalysisSpectrumErrorCodes,
} from "./error-codes.js";
describe("error-codes", () => {
it("registers exactly the three AUDIO_ANALYSIS_SPECTRUM codes", {
timeout: 5000,
}, () => {
expect(Object.keys(AUDIO_ANALYSIS_SPECTRUM_CODES).sort()).toEqual([
"ABORTED",
"INVALID_FRAME",
"INVALID_OPTIONS",
]);
});
it("namespaces every code as AUDIO_ANALYSIS_SPECTRUM.<REASON>", {
timeout: 5000,
}, () => {
expect(AUDIO_ANALYSIS_SPECTRUM_CODES.INVALID_OPTIONS).toBe(
"AUDIO_ANALYSIS_SPECTRUM.INVALID_OPTIONS"
);
expect(AUDIO_ANALYSIS_SPECTRUM_CODES.INVALID_FRAME).toBe(
"AUDIO_ANALYSIS_SPECTRUM.INVALID_FRAME"
);
expect(AUDIO_ANALYSIS_SPECTRUM_CODES.ABORTED).toBe(
"AUDIO_ANALYSIS_SPECTRUM.ABORTED"
);
});
it("freezes the metadata record", { timeout: 5000 }, () => {
expect(Object.isFrozen(AUDIO_ANALYSIS_SPECTRUM_ERROR_METADATA)).toBe(
true
);
});
it("metadata covers every code with a real description", {
timeout: 5000,
}, () => {
for (const code of Object.values(AUDIO_ANALYSIS_SPECTRUM_CODES)) {
const meta = AUDIO_ANALYSIS_SPECTRUM_ERROR_METADATA[code];
expect(meta).toBeDefined();
expect(meta.description.length).toBeGreaterThan(10);
expect(["warning", "error"]).toContain(meta.severity);
}
});
it("AudioAnalysisSpectrumError carries code, name, cause, and context", {
timeout: 5000,
}, () => {
const cause = new Error("inner");
const err = new AudioAnalysisSpectrumError(
AUDIO_ANALYSIS_SPECTRUM_CODES.ABORTED,
"audio-analysis-spectrum.aborted",
{ cause, context: { hint: 1 } }
);
expect(err.code).toBe("AUDIO_ANALYSIS_SPECTRUM.ABORTED");
expect(err.name).toBe("AudioAnalysisSpectrumError");
expect(err.cause).toBe(cause);
expect(err.context).toEqual({ hint: 1 });
expect(err.category).toBe("audio-analysis-spectrum");
});
it("constructs without options object (default branch)", {
timeout: 5000,
}, () => {
const err = new AudioAnalysisSpectrumError(
AUDIO_ANALYSIS_SPECTRUM_CODES.INVALID_FRAME,
"audio-analysis-spectrum.invalid-frame"
);
expect(err.code).toBe("AUDIO_ANALYSIS_SPECTRUM.INVALID_FRAME");
});
it("registerAudioAnalysisSpectrumErrorCodes is idempotent", {
timeout: 5000,
}, () => {
expect(() => registerAudioAnalysisSpectrumErrorCodes()).not.toThrow();
expect(() => registerAudioAnalysisSpectrumErrorCodes()).not.toThrow();
});
});

View File

@@ -0,0 +1,73 @@
import {
defineCodes,
SuaveplanError,
type SuaveplanErrorOptions,
} from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/audio-analysis-spectrum`.
* `defineCodes` namespaces the string values as `AUDIO_ANALYSIS_SPECTRUM.<REASON>`.
*/
export const AUDIO_ANALYSIS_SPECTRUM_CODES = defineCodes(
"AUDIO_ANALYSIS_SPECTRUM",
["INVALID_OPTIONS", "INVALID_FRAME", "ABORTED"] as const
);
export type AudioAnalysisSpectrumErrorCode =
(typeof AUDIO_ANALYSIS_SPECTRUM_CODES)[keyof typeof AUDIO_ANALYSIS_SPECTRUM_CODES];
export interface AudioAnalysisSpectrumErrorMetadata {
readonly description: string;
readonly severity: "warning" | "error";
}
export const AUDIO_ANALYSIS_SPECTRUM_ERROR_METADATA: Readonly<
Record<AudioAnalysisSpectrumErrorCode, AudioAnalysisSpectrumErrorMetadata>
> = Object.freeze({
[AUDIO_ANALYSIS_SPECTRUM_CODES.INVALID_OPTIONS]: {
description:
"A public options object failed Zod boundary validation. The Zod `issues` array is attached on `error.context.issues`.",
severity: "error",
},
[AUDIO_ANALYSIS_SPECTRUM_CODES.INVALID_FRAME]: {
description:
"The FFT was asked to transform a frame whose size is not a power of two between 256 and 32768 inclusive. The offending size is on `error.context.frameSize`.",
severity: "error",
},
[AUDIO_ANALYSIS_SPECTRUM_CODES.ABORTED]: {
description:
"Analysis was cancelled by an `AbortSignal` before or during the scan. The signal reason, if any, is on `error.context.reason`.",
severity: "warning",
},
});
/**
* Concrete `SuaveplanError` subclass for every fault raised by this package.
* The base constructor is `protected`; this subclass widens it to `public`
* while remaining non-trivial via the `category` field, so it constructs
* without any static-analysis suppression.
*/
export class AudioAnalysisSpectrumError extends SuaveplanError<AudioAnalysisSpectrumErrorCode> {
public readonly category = "audio-analysis-spectrum" as const;
public constructor(
code: AudioAnalysisSpectrumErrorCode,
messageKey: string,
options: SuaveplanErrorOptions = {}
) {
super(code, messageKey, options);
this.name = "AudioAnalysisSpectrumError";
}
}
/**
* Idempotent registration hook. Metadata is already frozen and exported for
* direct lookup; consumers binding code registries to OTel attributes or
* report-builders can call this once at startup to ensure the registry
* has been touched.
*/
export function registerAudioAnalysisSpectrumErrorCodes(): void {
// No-op — registry side-effects are static at module load.
}
registerAudioAnalysisSpectrumErrorCodes();

View File

@@ -0,0 +1,10 @@
export type {
AudioAnalysisSpectrumErrorCode,
AudioAnalysisSpectrumErrorMetadata,
} from "./error-codes.js";
export {
AUDIO_ANALYSIS_SPECTRUM_CODES,
AUDIO_ANALYSIS_SPECTRUM_ERROR_METADATA,
AudioAnalysisSpectrumError,
registerAudioAnalysisSpectrumErrorCodes,
} from "./error-codes.js";

View File

@@ -0,0 +1,45 @@
export {
applyMelFilterbank,
applyWindow,
assertValidFrameSize,
buildMelFilterbank,
clearDctCache,
clearFFTCache,
clearMelFilterbankCache,
clearWindowCache,
dctII,
FFT_FRAME_MAX,
FFT_FRAME_MIN,
type FFTOptions,
type FFTResult,
getWindow,
hzToMel,
type MelFilterbank,
type MelFilterbankSpec,
melToHz,
transform,
type WindowName,
} from "./algorithms/index.js";
export {
type BlockContext,
MFCC,
type MFCCResult,
type MFCCRunOptions,
Spectrogram,
type SpectrogramResult,
type SpectrogramRunOptions,
SpectrumAnalyzer,
type SpectrumAnalyzerOptions,
type SpectrumAnalyzerOptionsInput,
SpectrumAnalyzerOptionsSchema,
type SpectrumAnalyzerRunOptions,
type SpectrumResult,
} from "./analyzer/index.js";
export {
AUDIO_ANALYSIS_SPECTRUM_CODES,
AUDIO_ANALYSIS_SPECTRUM_ERROR_METADATA,
AudioAnalysisSpectrumError,
type AudioAnalysisSpectrumErrorCode,
type AudioAnalysisSpectrumErrorMetadata,
registerAudioAnalysisSpectrumErrorCodes,
} from "./error-codes/index.js";

View File

@@ -0,0 +1,51 @@
import { AudioDocument } from "@suaveplan/audio-core/document/document";
const PAGE_SIZE = 1024 * 1024;
/** Build an `AudioDocument` from a flat mono `Float32Array`. */
export function makeDoc(
samples: Float32Array,
sampleRate = 48_000
): AudioDocument {
const pages: { channelData: Float32Array[]; startSample: number }[] = [];
let offset = 0;
while (offset < samples.length) {
const end = Math.min(offset + PAGE_SIZE, samples.length);
pages.push({
channelData: [samples.slice(offset, end)],
startSample: offset,
});
offset = end;
}
if (pages.length === 0) {
pages.push({ channelData: [new Float32Array(0)], startSample: 0 });
}
return new AudioDocument({
sampleRate,
channels: 1,
sourceLength: samples.length,
pages,
});
}
export function sineWave(
durationSec: number,
freqHz: number,
sampleRate = 48_000
): Float32Array {
const n = Math.floor(durationSec * sampleRate);
const out = new Float32Array(n);
for (let i = 0; i < n; i++)
out[i] = Math.sin((2 * Math.PI * freqHz * i) / sampleRate);
return out;
}
export function whiteNoise(length: number, seed = 12345): Float32Array {
let s = seed >>> 0;
const out = new Float32Array(length);
for (let i = 0; i < length; i++) {
s = (1_664_525 * s + 1_013_904_223) >>> 0;
out[i] = (s / 0xffff_ffff) * 2 - 1;
}
return out;
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"noEmit": true,
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["dist", "node_modules"]
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,38 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/audio-analysis-spectrum",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/audio-core",
"@suaveplan/error",
"@suaveplan/types",
"@opentelemetry/api",
"tslib",
"zod",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,5 @@
import { createTestConfig } from "@suaveplan/testing/config";
export default createTestConfig({
testTimeout: 30000,
});