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