Initial Commit

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

View File

@@ -0,0 +1,68 @@
# @suaveplan/audio-analysis-beat - Universal beat, tempo and onset detection plugin for the @suaveplan/audio-* family. Three selectable algorithms (energy ODF, spectral flux, comb-filter tempo) plus a phase-aligning beat-grid helper, exposed as an AnalysisOp wrapper with batch / streaming / per-block entry points.
Universal beat, tempo, and onset detection plugin for the
`@suaveplan/audio-*` family. Detects BPM, beat locations, and onset times
from `AudioDocument` streams using energy-based and spectral-flux algorithms,
with optional comb-filter tempo estimation and beat-grid phase alignment.
Runs identically in Bun, Node.js, Deno, and every browser — zero browser-only
APIs, zero Node-only APIs, hand-rolled radix-2 FFT over typed arrays.
<!-- begin:auto-docs -->
## Documentation tree
- [beat-grid](./src/algorithms/beat-grid.md) — Beat-grid construction: given a known beat period and a list of detected onsets, find the phase that aligns a regular `…
- [comb-tempo](./src/algorithms/comb-tempo.md) — Comb-filter / autocorrelation tempo estimation. Given an onset-detection function (ODF) and the cadence at which it was…
- [energy-odf](./src/algorithms/energy-odf.md) — Energy onset-detection function (ODF) — the cheapest of the three algorithms shipped by `@suaveplan/audio-analysis-beat…
- [spectral-flux](./src/algorithms/spectral-flux.md) — Spectral-flux onset detection via a hand-rolled radix-2 real FFT. This module provides the highest-precision ODF in the…
- [detector](./src/detector/beat-detector.md) — `BeatDetector` is the universal tempo / onset / beat-grid analysis op for the `@suaveplan/audio-*` family. It impl…
- [beat-detector](./src/detector/beat-detector.md) — `BeatDetector` is the universal tempo / onset / beat-grid analysis op for the `@suaveplan/audio-*` family. It impl…
- [error-codes](./src/error-codes/error-codes.md) — Stable error-code registry and concrete `SuaveplanError` subclass for `@suaveplan/audio-analysis-beat`. Every fault thr…
- [error-codes](./src/error-codes/error-codes.md) — Stable error-code registry and concrete `SuaveplanError` subclass for `@suaveplan/audio-analysis-beat`. Every fault thr…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/audio-analysis-beat
```
## Quick start
```ts
import { AudioDocument } from "@suaveplan/audio-core";
import { BeatDetector } from "@suaveplan/audio-analysis-beat";
const doc = new AudioDocument({
sampleRate: 48_000,
channels: 1,
sourceLength: pcm.length,
pages: [{ channelData: [pcm], startSample: 0 }],
});
const result = await new BeatDetector().analyze(doc);
console.log(result.bpm, result.beats.length, result.confidence);
```
## Algorithms
- `energy` (default) — half-wave-rectified first differences of per-block
energies. Cheap, allocation-light, ideal for percussive content.
- `spectral-flux` — frame-to-frame magnitude-spectrum differences via a
hand-rolled radix-2 real FFT. Catches timbral onsets the energy ODF misses.
- `comb` — comb-filter / autocorrelation tempo estimation over the energy
ODF; more robust on syncopated material at the cost of `O(range × odf)`
scan time.
## Entry points
- `analyze(doc, opts?)` — batch scan; returns a single `BeatResult`.
- `analyzeStream(doc, opts?)` — async generator yielding refining
`BeatResult` values with monotone confidence.
- `analyzeBlock(block, ctx)` — synchronous per-block onset state for
visualisers and real-time displays.
- `asAnalysisOp` getter — the underlying audio-core `AnalysisOp` wrapper.
See per-module `.md` files for the deep documentation of each algorithm and
entry point.

View File

@@ -0,0 +1,96 @@
{
"name": "@suaveplan/audio-analysis-beat",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Universal beat, tempo and onset detection plugin for the @suaveplan/audio-* family. Three selectable algorithms (energy ODF, spectral flux, comb-filter tempo) plus a phase-aligning beat-grid helper, exposed as an AnalysisOp wrapper with batch / streaming / per-block entry points.",
"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"
}
},
"./detector/beat-detector": {
"import": {
"types": "./dist/detector/beat-detector.d.ts",
"default": "./dist/detector/beat-detector.js"
}
},
"./algorithms/energy-odf": {
"import": {
"types": "./dist/algorithms/energy-odf.d.ts",
"default": "./dist/algorithms/energy-odf.js"
}
},
"./algorithms/spectral-flux": {
"import": {
"types": "./dist/algorithms/spectral-flux.d.ts",
"default": "./dist/algorithms/spectral-flux.js"
}
},
"./algorithms/comb-tempo": {
"import": {
"types": "./dist/algorithms/comb-tempo.d.ts",
"default": "./dist/algorithms/comb-tempo.js"
}
},
"./algorithms/beat-grid": {
"import": {
"types": "./dist/algorithms/beat-grid.d.ts",
"default": "./dist/algorithms/beat-grid.js"
}
},
"./error-codes": {
"import": {
"types": "./dist/error-codes/index.d.ts",
"default": "./dist/error-codes/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/audio-core": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"@opentelemetry/api": "catalog:observability",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,87 @@
# `algorithms/beat-grid`
## Purpose
Beat-grid construction: given a known beat period and a list of detected
onsets, find the phase that aligns a regular `period`-spaced grid to the
onsets and emit the grid itself. This is package's three-stage
detection pipeline — what runs after tempo estimation has produced a BPM and
the caller is ready to convert raw onset times into a downbeat-stable beat
sequence.
The same phase sweep also yields a confidence score in `[0, 1]` that
quantifies how tightly the detected onsets cluster around the chosen grid —
a sharp cluster (every onset within a tenth of a period of a grid line)
gives a confidence near 1, scattered onsets give one near 0.
## Features
- `buildBeatGrid(opts)` — sweep `phaseTrials` equally-spaced candidate phases
across one period, pick the phase that minimises total
onset-to-nearest-beat distance, and return the resulting beat grid.
- Configurable sweep resolution (`phaseTrials`, default 24) — finer sweeps
cost linearly more time but tighten the grid alignment in proportion.
- Defensive against zero-period, zero-duration, and empty-onsets inputs —
returns an empty grid with `confidence = 0` rather than throwing.
## Usage: Basic Example
```ts
import { buildBeatGrid } from "@suaveplan/audio-analysis-beat/algorithms/beat-grid";
const result = buildBeatGrid({
period: 60 / 120, // 120 BPM
duration: 10,
onsets: [0.5, 1.0, 1.5, 2.0],
});
console.log(result.beats.length, result.phase, result.confidence);
```
## Usage: Advanced Example
```ts
import { buildBeatGrid } from "@suaveplan/audio-analysis-beat/algorithms/beat-grid";
// Compare two BPM candidates and prefer the one whose grid the onsets fit
// more tightly — this is how the BeatDetector breaks ties between candidate
// tempi when an autocorrelation sweep returns two near-equal peaks.
const onsets = new Float64Array([
0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0,
]);
const a = buildBeatGrid({ period: 60 / 120, duration: 4.2, onsets, phaseTrials: 48 });
const b = buildBeatGrid({ period: 60 / 60, duration: 4.2, onsets, phaseTrials: 48 });
const winner = a.confidence >= b.confidence ? a : b;
console.log("chosen phase", winner.phase, "beats", Array.from(winner.beats));
```
## API Reference
- `interface BeatGridOptions { period: number; duration: number; onsets:
readonly number[] | Float64Array; phaseTrials?: number }`.
- `interface BeatGridResult { beats: Float64Array; phase: number; confidence:
number }`.
- `buildBeatGrid(opts: BeatGridOptions): BeatGridResult` — `beats` is
empty when `period <= 0` or `duration <= 0`; `confidence` is `0` for empty
onsets, otherwise clamped to `[0, 1]`.
## Implementation Notes
The cost function is L1 (sum of absolute errors) rather than L2. L1 is more
robust against the occasional outlier onset that does not actually mark a
beat — a snare on the upbeat, a hi-hat trip, a transient false positive —
whereas L2 would let that outlier dominate the phase sweep.
`phaseTrials` defaults to 24, which is one trial every 2.5° of the beat
cycle at 120 BPM (≈ 21 ms). Halving the trial count roughly halves the cost
function evaluation time at the price of a coarser phase grid; doubling it
sharpens alignment until the cost surface becomes flat at the noise floor of
the onset times themselves.
The confidence formula `1 - cost / (n * period/4)` treats a uniformly
distributed onset list (mean absolute error = quarter-period) as confidence 0
and a perfectly aligned list (zero error) as confidence 1. Inputs that are
*better* than uniform — onsets concentrated on the beat — saturate at 1
rather than overshooting, so downstream code can treat the field as a
probability without further clamping.

View File

@@ -0,0 +1,70 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { buildBeatGrid } from "./beat-grid.js";
describe("buildBeatGrid", () => {
it("returns empty grid for non-positive period", { timeout: 5000 }, () => {
const r = buildBeatGrid({ period: 0, duration: 10, onsets: [1, 2] });
expect(r.beats.length).toBe(0);
expect(r.confidence).toBe(0);
});
it("returns empty grid for non-positive duration", {
timeout: 5000,
}, () => {
const r = buildBeatGrid({ period: 0.5, duration: 0, onsets: [1, 2] });
expect(r.beats.length).toBe(0);
});
it("phase-aligns onsets to a 60/bpm interval", { timeout: 5000 }, () => {
// 120 BPM grid = 0.5 s period; onsets exactly on the beat
const onsets = [0.5, 1.0, 1.5, 2.0, 2.5];
const r = buildBeatGrid({ period: 0.5, duration: 3, onsets });
expect(r.beats.length).toBeGreaterThan(0);
// Beats spaced by ~ 0.5s
for (let i = 1; i < r.beats.length; i++) {
expect((r.beats[i] ?? 0) - (r.beats[i - 1] ?? 0)).toBeCloseTo(
0.5,
5
);
}
expect(r.confidence).toBeGreaterThan(0.9);
});
it("accepts Float64Array onsets", { timeout: 5000 }, () => {
const r = buildBeatGrid({
period: 0.5,
duration: 2,
onsets: new Float64Array([0.5, 1.0, 1.5]),
});
expect(r.beats.length).toBeGreaterThan(0);
});
it("returns zero confidence for empty onsets", { timeout: 5000 }, () => {
const r = buildBeatGrid({ period: 0.5, duration: 2, onsets: [] });
expect(r.confidence).toBe(0);
});
it("custom phaseTrials clamped to >= 1", { timeout: 5000 }, () => {
const r = buildBeatGrid({
period: 0.5,
duration: 2,
onsets: [0.5, 1, 1.5],
phaseTrials: 0,
});
expect(r.beats.length).toBeGreaterThan(0);
});
it("scattered onsets give lower confidence", { timeout: 5000 }, () => {
const tight = buildBeatGrid({
period: 0.5,
duration: 2,
onsets: [0.5, 1.0, 1.5],
});
const scattered = buildBeatGrid({
period: 0.5,
duration: 2,
onsets: [0.4, 0.95, 1.55],
});
expect(tight.confidence).toBeGreaterThanOrEqual(scattered.confidence);
});
});

View File

@@ -0,0 +1,75 @@
/**
* Beat grid construction — phase-align detected onsets to a regular
* `60 / bpm`-spaced grid over a known document duration.
*/
export interface BeatGridOptions {
/** Beat period in seconds (e.g. `60 / bpm`). */
readonly period: number;
/** Total document duration in seconds. */
readonly duration: number;
/** Detected onsets in seconds, monotonically non-decreasing. */
readonly onsets: readonly number[] | Float64Array;
/**
* Number of equally-spaced trial phases to sweep across one period when
* picking the best alignment. Defaults to 24.
*/
readonly phaseTrials?: number;
}
export interface BeatGridResult {
readonly beats: Float64Array;
readonly phase: number;
readonly confidence: number;
}
/**
* Build a beat grid by sweeping `phaseTrials` candidate phases across one
* period and selecting the phase that minimises total onset-to-nearest-beat
* distance. Returns a `Float64Array` of beat timestamps in `[0, duration)`,
* the chosen phase, and a `[0, 1]` confidence derived from how tightly the
* onsets clustered around the grid.
*/
export function buildBeatGrid(opts: BeatGridOptions): BeatGridResult {
const trials = Math.max(1, opts.phaseTrials ?? 24);
const onsets =
opts.onsets instanceof Float64Array
? opts.onsets
: Float64Array.from(opts.onsets);
if (opts.period <= 0 || opts.duration <= 0) {
return { beats: new Float64Array(0), phase: 0, confidence: 0 };
}
let bestPhase = 0;
let bestCost = Number.POSITIVE_INFINITY;
for (let t = 0; t < trials; t++) {
const phase = (t / trials) * opts.period;
let cost = 0;
for (let i = 0; i < onsets.length; i++) {
const o = onsets[i]!;
const rel = (o - phase) / opts.period;
const nearest = Math.round(rel);
const err = (rel - nearest) * opts.period;
cost += Math.abs(err);
}
if (cost < bestCost) {
bestCost = cost;
bestPhase = phase;
}
}
const count = Math.floor((opts.duration - bestPhase) / opts.period) + 1;
const beats = new Float64Array(count);
for (let i = 0; i < count; i++) beats[i] = bestPhase + i * opts.period;
// Confidence: 1 when every onset sits on a beat; 0 when the average error
// equals a quarter-period (i.e. uniformly distributed across the period).
const confidence =
onsets.length === 0
? 0
: Math.max(
0,
Math.min(
1,
1 - bestCost / (onsets.length * (opts.period / 4))
)
);
return { beats, phase: bestPhase, confidence };
}

View File

@@ -0,0 +1,91 @@
# `algorithms/comb-tempo`
## Purpose
Comb-filter / autocorrelation tempo estimation. Given an onset-detection
function (ODF) and the cadence at which it was sampled, this module finds the
dominant periodicity and reports the implied tempo in beats per minute.
It is the universal back-end for package's three-stage
detection pipeline — every algorithm in the package (`energy`,
`spectral-flux`, and the explicit `comb` selection) ends up here once it has
produced a per-block ODF, because tempo estimation is independent of which
ODF you started from.
## Features
- `combScore(odf, period)` — single-period autocorrelation score. Cheap
building block exposed for callers that want to draw a tempo-confidence
curve or implement a custom octave-error policy.
- `combTempo(odf, opts)` — full sweep across every integer BPM in
`[minBpm, maxBpm]`. Returns the winning BPM and its raw score, with
built-in octave-error resolution that prefers tempi near the perceptual
centre (120 BPM) when two candidates score within 90% of each other.
## Usage: Basic Example
```ts
import { combTempo } from "@suaveplan/audio-analysis-beat/algorithms/comb-tempo";
// ODF sampled every 1024 samples at 48 kHz → hopSeconds = 1024/48000.
const result = combTempo(odf, {
minBpm: 60,
maxBpm: 200,
hopSeconds: 1024 / 48_000,
});
console.log(result.bpm); // e.g. 120
```
## Usage: Advanced Example
```ts
import {
combScore,
combTempo,
} from "@suaveplan/audio-analysis-beat/algorithms/comb-tempo";
// Draw a tempo-confidence curve over the search range manually:
const hopSeconds = 0.02; // 50 Hz ODF
const min = 60;
const max = 200;
const curve = new Float64Array(max - min + 1);
for (let bpm = min; bpm <= max; bpm++) {
const period = Math.max(1, Math.round((60 / bpm) / hopSeconds));
curve[bpm - min] = combScore(odf, period);
}
// Fall back to combTempo for the canonical winner including octave handling.
const winner = combTempo(odf, { minBpm: min, maxBpm: max, hopSeconds });
console.log(winner.bpm, winner.score);
```
## API Reference
- `interface CombTempoOptions { minBpm: number; maxBpm: number; hopSeconds:
number }` — input bounds and ODF cadence.
- `interface CombTempoResult { bpm: number; score: number }`.
- `combScore(odf: Float64Array, periodOdfSamples: number): number` — returns
zero for `period <= 0` and otherwise the sum of `odf[i] * odf[i - period]`
across the valid index range.
- `combTempo(odf: Float64Array, opts: CombTempoOptions): CombTempoResult` —
returns `{ bpm: minBpm, score: 0 }` for empty ODF, non-positive
`hopSeconds`, or inverted bounds. Otherwise sweeps every integer BPM in
the inclusive range.
## Implementation Notes
The score for a candidate BPM is the unnormalised autocorrelation of the ODF
at the corresponding lag. The lag is computed as `round((60 / bpm) /
hopSeconds)` and clamped at one ODF sample so candidate BPMs near `maxBpm`
never collapse to a zero lag.
Octave-error resolution is intentionally cheap: among all candidates whose
score is within 90% of the winner's score, the one closest to 120 BPM
wins. This is the median tapping tempo across human listeners (Moelants 2002)
and matches perception far more often than picking the raw maximum, which is
biased toward half-tempo for slow tracks and double-tempo for fast tracks.
Integer BPM resolution is sufficient for the universal use case — the
caller can run a quadratic interpolation across the three neighbours of the
winning bin if sub-BPM precision is needed, but that machinery is not part
of this module to keep the public surface small.

View File

@@ -0,0 +1,88 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { combScore, combTempo } from "./comb-tempo.js";
describe("combScore", () => {
it("returns 0 for non-positive period", { timeout: 5000 }, () => {
expect(combScore(new Float64Array([1, 1, 1, 1]), 0)).toBe(0);
expect(combScore(new Float64Array([1, 1, 1, 1]), -1)).toBe(0);
});
it("scores higher at the true period", { timeout: 5000 }, () => {
// Period 4 ODF: peaks at 0, 4, 8, 12, 16
const odf = new Float64Array(20);
for (let i = 0; i < 20; i += 4) odf[i] = 1;
const s4 = combScore(odf, 4);
const s3 = combScore(odf, 3);
expect(s4).toBeGreaterThan(s3);
});
});
describe("combTempo", () => {
it("returns fallback for empty ODF", { timeout: 5000 }, () => {
const r = combTempo(new Float64Array(0), {
minBpm: 60,
maxBpm: 200,
hopSeconds: 0.02,
});
expect(r.bpm).toBe(60);
expect(r.score).toBe(0);
});
it("returns fallback for non-positive hopSeconds", {
timeout: 5000,
}, () => {
const r = combTempo(new Float64Array(10), {
minBpm: 60,
maxBpm: 200,
hopSeconds: 0,
});
expect(r.bpm).toBe(60);
});
it("returns fallback for inverted bounds", { timeout: 5000 }, () => {
const r = combTempo(new Float64Array(10), {
minBpm: 200,
maxBpm: 100,
hopSeconds: 0.01,
});
expect(r.bpm).toBe(200);
});
it("recovers a known tempo (120 BPM)", { timeout: 5000 }, () => {
// hopSeconds = 0.01 → 120 BPM → 0.5 s period → 50 ODF samples per beat
const odf = new Float64Array(500);
for (let i = 0; i < odf.length; i += 50) odf[i] = 1;
const r = combTempo(odf, {
minBpm: 60,
maxBpm: 200,
hopSeconds: 0.01,
});
expect(Math.abs(r.bpm - 120)).toBeLessThanOrEqual(2);
});
it("clamps near-zero period to 1 sample", { timeout: 5000 }, () => {
// hopSeconds = 5, bpm range very high → period rounds to 0
const odf = new Float64Array(20);
for (let i = 0; i < 20; i++) odf[i] = 1;
const r = combTempo(odf, {
minBpm: 500,
maxBpm: 600,
hopSeconds: 5,
});
expect(r.bpm).toBeGreaterThanOrEqual(500);
});
it("octave-resolution prefers tempi near 120 BPM", {
timeout: 5000,
}, () => {
// Equal autocorrelation at 60 and 120 → should pick 120
const odf = new Float64Array(1000);
for (let i = 0; i < odf.length; i += 50) odf[i] = 1; // every 50 → 120 BPM
const r = combTempo(odf, {
minBpm: 50,
maxBpm: 200,
hopSeconds: 0.01,
});
expect(Math.abs(r.bpm - 120)).toBeLessThanOrEqual(2);
});
});

View File

@@ -0,0 +1,79 @@
/**
* Comb-filter / autocorrelation tempo estimation over an onset-detection
* function.
*
* Given an ODF and a sampling cadence (`hopSamples` ODF samples per
* second-worth of audio), score every candidate BPM in `[minBpm, maxBpm]` by
* the autocorrelation strength at the lag implied by `60 / bpm` seconds and
* pick the maximum. Octave errors (half- or double-tempo) are resolved by
* preferring the candidate closest to the perceptual centre (~120 BPM) when
* two candidates score within a small ratio.
*/
export interface CombTempoOptions {
/** Lower BPM bound (inclusive). */
readonly minBpm: number;
/** Upper BPM bound (inclusive). */
readonly maxBpm: number;
/** Hop length used to build the ODF, in seconds. */
readonly hopSeconds: number;
}
export interface CombTempoResult {
readonly bpm: number;
readonly score: number;
}
/**
* Score a single candidate period (in ODF samples) against the ODF via
* autocorrelation. Higher is better.
*/
export function combScore(odf: Float64Array, periodOdfSamples: number): number {
if (periodOdfSamples <= 0) return 0;
let score = 0;
for (let i = periodOdfSamples; i < odf.length; i++) {
score += odf[i]! * odf[i - periodOdfSamples]!;
}
return score;
}
/**
* Pick the dominant tempo in `[minBpm, maxBpm]` from an ODF. Returns the
* winning BPM and its raw autocorrelation score.
*/
export function combTempo(
odf: Float64Array,
opts: CombTempoOptions
): CombTempoResult {
const { minBpm, maxBpm, hopSeconds } = opts;
if (odf.length === 0 || hopSeconds <= 0 || maxBpm < minBpm) {
return { bpm: minBpm, score: 0 };
}
const candidates: { bpm: number; score: number }[] = [];
let bestBpm = minBpm;
let bestScore = Number.NEGATIVE_INFINITY;
for (let bpm = minBpm; bpm <= maxBpm; bpm++) {
const periodSec = 60 / bpm;
const period = Math.max(1, Math.round(periodSec / hopSeconds));
const score = combScore(odf, period);
candidates.push({ bpm, score });
if (score > bestScore) {
bestScore = score;
bestBpm = bpm;
}
}
// Octave-error resolution: amongst candidates within 90% of the winner's
// score, prefer the one closest to 120 BPM.
const ratio = 0.9;
let resolved = bestBpm;
let bestDist = Math.abs(bestBpm - 120);
for (const c of candidates) {
if (c.score < bestScore * ratio) continue;
const dist = Math.abs(c.bpm - 120);
if (dist < bestDist) {
bestDist = dist;
resolved = c.bpm;
}
}
return { bpm: resolved, score: bestScore };
}

View File

@@ -0,0 +1,91 @@
# `algorithms/energy-odf`
## Purpose
Energy onset-detection function (ODF) — the cheapest of the three algorithms
shipped by `@suaveplan/audio-analysis-beat`. It consumes a per-block energy
sequence and emits a 1-D curve whose peaks mark sample positions where the
signal's energy rises sharply, i.e. note onsets.
Energy ODF works extremely well on percussive material (kick, snare, claps)
and is the only path that adds essentially no cost over a plain document scan
— per-block energies can be reused from the host's existing block statistics
pipeline rather than recomputed.
## Features
- `blockEnergy(block)` — mean per-sample energy of a single multi-channel
block. Defensive against empty blocks, ragged channel arrays, and
zero-length data.
- `energyOdf(energies)` — half-wave-rectified first difference of an energy
sequence. Same length as the input; entry 0 is always zero.
- `energyOdfFromSamples(channels, blockSize)` — convenience that splits raw
channel data into `blockSize`-sample blocks, computes block energies, and
returns the ODF in one call.
## Usage: Basic Example
```ts
import { energyOdf } from "@suaveplan/audio-analysis-beat/algorithms/energy-odf";
const energies = new Float64Array([0, 0, 0.5, 0.6, 0.1, 0.05, 0.4]);
const odf = energyOdf(energies);
// odf === Float64Array([0, 0, 0.5, 0.1, 0, 0, 0.35])
```
## Usage: Advanced Example
```ts
import {
blockEnergy,
energyOdfFromSamples,
} from "@suaveplan/audio-analysis-beat/algorithms/energy-odf";
const sampleRate = 48_000;
const blockSize = 1024;
const left = new Float32Array(sampleRate * 4); // 4 seconds mono-ish
const right = new Float32Array(sampleRate * 4);
// ... fill ...
const odf = energyOdfFromSamples([left, right], blockSize);
const hopSeconds = blockSize / sampleRate;
// Pick the loudest block in the first second:
let bestIdx = 0;
let best = 0;
for (let i = 0; i < Math.floor(1 / hopSeconds); i++) {
if (odf[i] > best) {
best = odf[i];
bestIdx = i;
}
}
console.log("onset at", bestIdx * hopSeconds, "seconds");
console.log("avg first-block energy", blockEnergy([left.subarray(0, blockSize)]));
```
## API Reference
- `blockEnergy(block: readonly Float32Array[]): number` — mean per-sample
energy summed across channels then divided by `length * channels`.
- `energyOdf(energies: Float64Array): Float64Array` — same-length output;
`out[i] = max(0, energies[i] - energies[i-1])`, `out[0] === 0`.
- `energyOdfFromSamples(channels: readonly Float32Array[], blockSize: number):
Float64Array` — combined pipeline; returns an empty `Float64Array` when
`blockSize <= 0` or the first channel is empty.
## Implementation Notes
The half-wave rectification (`d > 0 ? d : 0`) is the entire reason this works
as an onset detector rather than a generic energy-change detector — falling
edges (notes ending) carry no perceptual onset and zeroing them out keeps the
ODF interpretable as a peak-picking problem.
Mean-per-sample energy rather than raw sum is used because the final block of
a document is often shorter than `blockSize`; without normalisation the last
block would appear artificially quiet and the ODF would spike negative
(rectified to zero), masking a legitimate onset at the end of the track.
Defensive `?? 0` and `?? undefined`-shape guards thread through every loop so
malformed input — an empty channels array, a ragged channels array, a block
whose first channel is undefined — yields a defined zero rather than throwing.
This module is the leaf any of the three algorithm paths can fall back to and
must never propagate an exception of its own.

View File

@@ -0,0 +1,57 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { blockEnergy, energyOdf, energyOdfFromSamples } from "./energy-odf.js";
describe("blockEnergy", () => {
it("returns 0 for empty block", { timeout: 5000 }, () => {
expect(blockEnergy([])).toBe(0);
});
it("returns 0 for zero-length channels", { timeout: 5000 }, () => {
expect(blockEnergy([new Float32Array(0)])).toBe(0);
});
it("computes mean per-sample energy", { timeout: 5000 }, () => {
const e = blockEnergy([new Float32Array([1, 1, 1, 1])]);
expect(e).toBeCloseTo(1, 5);
});
it("averages across channels", { timeout: 5000 }, () => {
const e = blockEnergy([
new Float32Array([1, 1]),
new Float32Array([1, 1]),
]);
expect(e).toBeCloseTo(1, 5);
});
});
describe("energyOdf", () => {
it("half-wave-rectifies first differences", { timeout: 5000 }, () => {
const odf = energyOdf(
new Float64Array([0, 0, 0.5, 0.6, 0.1, 0.05, 0.4])
);
expect(odf[0]).toBe(0);
expect(odf[2]).toBeCloseTo(0.5, 5);
expect(odf[3]).toBeCloseTo(0.1, 5);
expect(odf[4]).toBe(0);
expect(odf[5]).toBe(0);
expect(odf[6]).toBeCloseTo(0.35, 5);
});
it("handles empty input", { timeout: 5000 }, () => {
expect(energyOdf(new Float64Array(0)).length).toBe(0);
});
});
describe("energyOdfFromSamples", () => {
it("returns empty array for blockSize <= 0", { timeout: 5000 }, () => {
expect(energyOdfFromSamples([new Float32Array(10)], 0).length).toBe(0);
});
it("returns empty array for empty channels", { timeout: 5000 }, () => {
expect(energyOdfFromSamples([], 8).length).toBe(0);
expect(energyOdfFromSamples([new Float32Array(0)], 8).length).toBe(0);
});
it("detects an energy spike", { timeout: 5000 }, () => {
const data = new Float32Array(64);
for (let i = 32; i < 48; i++) data[i] = 0.9;
const odf = energyOdfFromSamples([data], 16);
// Block 0 silent, block 1 silent, block 2 loud, block 3 silent
expect(odf.length).toBe(4);
expect(odf[2]).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,73 @@
/**
* Block-energy onset detection function.
*
* The cheapest ODF in the package — it takes the half-wave-rectified first
* difference of a per-block energy sequence. Cheap, allocation-light, and
* surprisingly effective on percussive material.
*/
/**
* Compute the energy (sum of squares) of a single interleaved block.
*
* The block is given as an array of per-channel `Float32Array`s, each of equal
* length. The returned value is the mean per-sample energy across all channels
* — dividing by sample count keeps the value scale-stable across block sizes.
*/
export function blockEnergy(block: readonly Float32Array[]): number {
if (block.length === 0) return 0;
const ch0 = block[0]!;
const len = ch0.length;
if (len === 0) return 0;
let sum = 0;
for (let c = 0; c < block.length; c++) {
const data = block[c]!;
for (let i = 0; i < len; i++) {
const v = data[i]!;
sum += v * v;
}
}
return sum / (len * block.length);
}
/**
* Energy onset-detection function.
*
* Walks the per-block energies and returns a `Float64Array` of equal length
* whose `i`th entry is `max(0, energies[i] - energies[i-1])` — i.e. only
* energy *increases* count toward an onset. The first entry is always zero.
*/
export function energyOdf(energies: Float64Array): Float64Array {
const odf = new Float64Array(energies.length);
for (let i = 1; i < energies.length; i++) {
const e = energies[i]!;
const prev = energies[i - 1]!;
const d = e - prev;
odf[i] = d > 0 ? d : 0;
}
return odf;
}
/**
* Compute an energy ODF directly from a sample stream by splitting it into
* fixed-size blocks. Convenience wrapper around `blockEnergy` + `energyOdf`.
*/
export function energyOdfFromSamples(
channels: readonly Float32Array[],
blockSize: number
): Float64Array {
if (blockSize <= 0) return new Float64Array(0);
const ch0 = channels[0];
if (ch0 === undefined || ch0.length === 0) return new Float64Array(0);
const blocks = Math.ceil(ch0.length / blockSize);
const energies = new Float64Array(blocks);
for (let b = 0; b < blocks; b++) {
const start = b * blockSize;
const end = Math.min(start + blockSize, ch0.length);
const view: Float32Array[] = [];
for (let c = 0; c < channels.length; c++) {
view.push(channels[c]!.subarray(start, end));
}
energies[b] = blockEnergy(view);
}
return energyOdf(energies);
}

View File

@@ -0,0 +1,11 @@
export type { BeatGridOptions, BeatGridResult } from "./beat-grid.js";
export { buildBeatGrid } from "./beat-grid.js";
export type { CombTempoOptions, CombTempoResult } from "./comb-tempo.js";
export { combScore, combTempo } from "./comb-tempo.js";
export { blockEnergy, energyOdf, energyOdfFromSamples } from "./energy-odf.js";
export {
fftRadix2,
magnitudeSpectrum,
spectralFlux,
spectralFluxOdf,
} from "./spectral-flux.js";

View File

@@ -0,0 +1,98 @@
# `algorithms/spectral-flux`
## Purpose
Spectral-flux onset detection via a hand-rolled radix-2 real FFT. This module
provides the highest-precision ODF in the package — it catches timbral onsets
(hi-hat over a sustained pad, a fingertip pluck on a held note) that the
energy ODF cannot see, because it measures *which bins are changing* rather
than *how much total energy is moving*.
It is written entirely in terms of `Float32Array` and `Float64Array` and
never touches the browser `AnalyserNode` or any Node-only API. The same code
runs identically under Bun, Node.js, Deno, and every browser.
## Features
- `fftRadix2(re, im)` — in-place Cooley-Tukey complex FFT for power-of-two
sizes. The lowest-level primitive; both arrays are read and written.
- `magnitudeSpectrum(frame, fftSize)` — real-input single-sided magnitude
spectrum with a Hann window applied before transform; returns length
`fftSize/2 + 1`.
- `spectralFlux(frame, prevMag, fftSize)` — single-frame ODF value; sum of
positive bin-to-bin differences. `prevMag` is mutated to become the current
magnitude spectrum so the caller can chain across frames.
- `spectralFluxOdf(mono, fftSize, hop)` — convenience that splits a mono
sample stream into `hop`-sample frames of `fftSize` samples (zero-padded as
needed) and returns the full ODF.
## Usage: Basic Example
```ts
import { spectralFluxOdf } from "@suaveplan/audio-analysis-beat/algorithms/spectral-flux";
const mono = new Float32Array(48_000 * 4); // 4 seconds of audio
// ... fill mono with samples ...
const odf = spectralFluxOdf(mono, 1024, 512);
console.log(odf.length, "frames"); // ceil(192000 / 512) = 375
```
## Usage: Advanced Example
```ts
import {
magnitudeSpectrum,
spectralFlux,
} from "@suaveplan/audio-analysis-beat/algorithms/spectral-flux";
const fftSize = 2048;
const hop = 512;
const buf = new Float32Array(fftSize);
const prev = new Float64Array((fftSize >> 1) + 1);
const mono = new Float32Array(48_000 * 10); // 10 seconds
// Manual frame loop — lets the caller stop early on an AbortSignal,
// thread per-frame work through other code, or thin the ODF on the fly.
const odfValues: number[] = [];
for (let start = 0; start + fftSize <= mono.length; start += hop) {
buf.set(mono.subarray(start, start + fftSize));
odfValues.push(spectralFlux(buf, prev, fftSize));
}
// Verify the magnitude spectrum has the expected shape on its own.
const probe = magnitudeSpectrum(mono.subarray(0, fftSize), fftSize);
console.log(probe.length === fftSize / 2 + 1);
```
## API Reference
- `fftRadix2(re: Float64Array, im: Float64Array): void` — `re.length` must
equal `im.length` and must be a power of two; transform is in place.
- `magnitudeSpectrum(frame: Float32Array, fftSize: number): Float64Array` —
output length `fftSize/2 + 1`; Hann-windowed.
- `spectralFlux(frame: Float32Array, prevMag: Float64Array, fftSize: number):
number` — returns the flux value; `prevMag` is mutated in place to become
the current magnitude spectrum.
- `spectralFluxOdf(mono: Float32Array, fftSize: number, hop: number):
Float64Array` — returns empty array when any argument is empty or
non-positive; `odf[0]` is forced to zero so peak-picking does not flag the
artificial first-frame onset.
## Implementation Notes
The FFT is a textbook iterative Cooley-Tukey radix-2 with explicit
bit-reversal permutation followed by `log2(n)` rounds of butterflies. Twiddle
factors are computed by complex multiplication of a running unit-magnitude
phase rather than a precomputed table — for the FFT sizes used in this
package (`<= 4096`) the table cost is not worth it, and trig recurrence
avoids the alignment subtleties of typed-array tables.
Only positive bin-to-bin differences contribute to the flux. Negative
differences (bins that are quieter than in the previous frame) carry no
perceptual onset information and including them would partly cancel the
positive contributions, blurring peaks.
`prevMag` is initialised to zeros, which means the first frame's flux is the
sum of its full magnitude spectrum — an artefact, not a real onset. The
`spectralFluxOdf` wrapper therefore overwrites `odf[0]` to zero before
returning so peak-picking does not flag that boundary.

View File

@@ -0,0 +1,85 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
fftRadix2,
magnitudeSpectrum,
spectralFlux,
spectralFluxOdf,
} from "./spectral-flux.js";
describe("fftRadix2", () => {
it("returns early for n <= 1", { timeout: 5000 }, () => {
const re = new Float64Array([5]);
const im = new Float64Array([0]);
fftRadix2(re, im);
expect(re[0]).toBe(5);
});
it("transforms a delta into a constant magnitude spectrum", {
timeout: 5000,
}, () => {
const re = new Float64Array(8);
const im = new Float64Array(8);
re[0] = 1;
fftRadix2(re, im);
for (let k = 0; k < 8; k++) {
const mag = Math.sqrt((re[k] ?? 0) ** 2 + (im[k] ?? 0) ** 2);
expect(mag).toBeCloseTo(1, 5);
}
});
it("transforms a constant into a delta at k=0", { timeout: 5000 }, () => {
const re = new Float64Array(8);
const im = new Float64Array(8);
re.fill(1);
fftRadix2(re, im);
expect(re[0]).toBeCloseTo(8, 5);
expect(Math.abs(re[1] ?? 0)).toBeLessThan(1e-9);
});
});
describe("magnitudeSpectrum", () => {
it("returns fftSize/2 + 1 bins", { timeout: 5000 }, () => {
expect(magnitudeSpectrum(new Float32Array(8), 8).length).toBe(5);
expect(magnitudeSpectrum(new Float32Array(16), 16).length).toBe(9);
});
});
describe("spectralFlux + spectralFluxOdf", () => {
it("spectralFlux mutates prevMag in place", { timeout: 5000 }, () => {
const frame = new Float32Array([0.1, 0.5, 1, 0.5, 0.2, 0.8, 0.3, 0.1]);
const prev = new Float64Array(5);
const flux = spectralFlux(frame, prev, 8);
expect(flux).toBeGreaterThan(0);
expect(prev[0]).toBeGreaterThan(0);
});
it("spectralFluxOdf returns empty array on invalid input", {
timeout: 5000,
}, () => {
expect(spectralFluxOdf(new Float32Array(0), 8, 4).length).toBe(0);
expect(spectralFluxOdf(new Float32Array(8), 0, 4).length).toBe(0);
expect(spectralFluxOdf(new Float32Array(8), 8, 0).length).toBe(0);
});
it("spectralFluxOdf flags onsets on a synthetic metronome", {
timeout: 5000,
}, () => {
const sr = 8000;
const beatSamples = sr / 2; // 2 Hz = 120 BPM
const mono = new Float32Array(sr * 4);
for (let b = 0; b < 8; b++) {
const start = b * beatSamples;
for (let i = 0; i < 256; i++) {
mono[start + i] = Math.sin((2 * Math.PI * 1000 * i) / sr);
}
}
const odf = spectralFluxOdf(mono, 512, 256);
// First entry must be forced to zero
expect(odf[0]).toBe(0);
// At least one non-zero peak somewhere
let max = 0;
for (let i = 0; i < odf.length; i++) {
const v = odf[i] ?? 0;
if (v > max) max = v;
}
expect(max).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,141 @@
/**
* Spectral-flux onset detection function via a hand-rolled radix-2 real FFT.
*
* Per-frame magnitude spectra are differenced bin-by-bin; only positive
* differences count toward the ODF. No browser analyser node, no Node bindings — the
* implementation is pure numeric code over `Float32Array`/`Float64Array` so
* the module runs identically in Bun, Node.js, Deno, and browsers.
*/
/**
* In-place Cooley-Tukey radix-2 complex FFT.
*
* `re` and `im` carry the real and imaginary parts; both arrays must have the
* same length and that length must be a power of two. The transform is in
* place — on return `re[k]` and `im[k]` carry the `k`th complex bin.
*/
export function fftRadix2(re: Float64Array, im: Float64Array): void {
const n = re.length;
if (n <= 1) return;
// Bit-reversal permutation
let j = 0;
for (let i = 1; i < n; i++) {
let bit = n >> 1;
for (; (j & bit) !== 0; bit >>= 1) {
j ^= bit;
}
j ^= bit;
if (i < j) {
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 size = 2; size <= n; size <<= 1) {
const half = size >> 1;
const theta = (-2 * Math.PI) / size;
const wRe = Math.cos(theta);
const wIm = Math.sin(theta);
for (let start = 0; start < n; start += size) {
let curRe = 1;
let curIm = 0;
for (let k = 0; k < half; k++) {
const a = start + k;
const b = a + half;
const tre = re[b]! * curRe - im[b]! * curIm;
const tim = re[b]! * curIm + im[b]! * curRe;
re[b] = re[a]! - tre;
im[b] = im[a]! - tim;
re[a] = re[a]! + tre;
im[a] = im[a]! + tim;
const newRe = curRe * wRe - curIm * wIm;
const newIm = curRe * wIm + curIm * wRe;
curRe = newRe;
curIm = newIm;
}
}
}
}
/**
* Compute the magnitude spectrum of a real-valued frame of length
* `fftSize` (which must be a power of two). Returned array has length
* `fftSize / 2 + 1` (the non-redundant half plus Nyquist). A Hann window is
* applied in-place to a working copy of the frame before transform.
*/
export function magnitudeSpectrum(
frame: Float32Array,
fftSize: number
): Float64Array {
const re = new Float64Array(fftSize);
const im = new Float64Array(fftSize);
const n = Math.min(frame.length, fftSize);
const denom = Math.max(1, fftSize - 1);
for (let i = 0; i < n; i++) {
const w = 0.5 * (1 - Math.cos((2 * Math.PI * i) / denom));
re[i] = frame[i]! * w;
}
fftRadix2(re, im);
const halfPlusOne = (fftSize >> 1) + 1;
const mag = new Float64Array(halfPlusOne);
for (let k = 0; k < halfPlusOne; k++) {
const r = re[k]!;
const i = im[k]!;
mag[k] = Math.sqrt(r * r + i * i);
}
return mag;
}
/**
* Single-frame spectral flux: sum of positive bin-to-bin differences between
* the current magnitude spectrum and the previous one. `prevMag` is mutated
* in place to become the current spectrum so the caller can chain.
*/
export function spectralFlux(
frame: Float32Array,
prevMag: Float64Array,
fftSize: number
): number {
const mag = magnitudeSpectrum(frame, fftSize);
let flux = 0;
for (let k = 0; k < mag.length; k++) {
const m = mag[k]!;
const d = m - prevMag[k]!;
if (d > 0) flux += d;
prevMag[k] = m;
}
return flux;
}
/**
* Build a spectral-flux ODF over a sample stream by splitting it into
* `hop`-sample frames of `fftSize` samples each (zero-padded if necessary).
* Returns a `Float64Array` of length `ceil(length / hop)`.
*/
export function spectralFluxOdf(
mono: Float32Array,
fftSize: number,
hop: number
): Float64Array {
if (mono.length === 0 || fftSize <= 0 || hop <= 0) {
return new Float64Array(0);
}
const halfPlusOne = (fftSize >> 1) + 1;
const prev = new Float64Array(halfPlusOne);
const frames = Math.ceil(mono.length / hop);
const odf = new Float64Array(frames);
const buf = new Float32Array(fftSize);
for (let f = 0; f < frames; f++) {
const start = f * hop;
buf.fill(0);
const take = Math.min(fftSize, mono.length - start);
for (let i = 0; i < take; i++) buf[i] = mono[start + i]!;
odf[f] = spectralFlux(buf, prev, fftSize);
}
odf[0] = 0; // first frame has no predecessor signal
return odf;
}

View File

@@ -0,0 +1,123 @@
# `detector/beat-detector`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`BeatDetector` is the universal tempo / onset / beat-grid analysis op
for the `@suaveplan/audio-*` family. It implements the package-public
`analyze` / `analyzeStream` / `analyzeBlock` triple documented in the spec
delta and also exposes a `createAnalysisOp`-built `AnalysisOp` view for hosts
that drive analysis through the audio-core registry.
Internally it composes the three algorithm modules under `./algorithms/*`:
`energy-odf` or `spectral-flux` for stage 1 (onset detection function),
`comb-tempo` for stage 2 (tempo estimation), and `beat-grid` for stage 3
(phase alignment). No two consumers need agree on which path to use — the
algorithm is selected per call via `BeatDetectorOptions.algorithm`.
## Features
- `BeatDetector` class with `name`, `description`, `analyze`, `analyzeStream`,
`analyzeBlock`, and an `asAnalysisOp` getter for the audio-core view.
- Three selectable algorithms: `"energy"` (default), `"spectral-flux"`,
`"comb"`.
- Zod-validated options at every public entry point with stable
`AUDIO_ANALYSIS_BEAT.INVALID_OPTIONS` errors.
- `AbortSignal` honoured at the entry point and at every block boundary in
streaming and full-scan paths.
- Stable error codes `EMPTY_DOCUMENT` and `NO_BEATS_DETECTED` for the two
edge cases that would otherwise produce a meaningless zero-confidence
result.
- OTel instrumentation: span `audio.analysis.beat`, counter
`audio.analysis.beat.runs_total`, histogram
`audio.analysis.beat.duration_ms`. All inert without an exporter.
## Usage: Basic Example
```ts
import { AudioDocument } from "@suaveplan/audio-core";
import { BeatDetector } from "@suaveplan/audio-analysis-beat/detector/beat-detector";
const doc = new AudioDocument({
sampleRate: 48_000,
channels: 1,
sourceLength: 48_000 * 4,
pages: [{ startSample: 0, channelData: [Array.from(/* 4 s mono */)] }],
});
const detector = new BeatDetector();
const result = await detector.analyze(doc);
console.log(result.bpm, result.beats.length, result.confidence);
```
## Usage: Advanced Example
```ts
import { AudioDocument } from "@suaveplan/audio-core";
import { BeatDetector } from "@suaveplan/audio-analysis-beat/detector/beat-detector";
const detector = new BeatDetector({
algorithm: "spectral-flux",
minBpm: 80,
maxBpm: 160,
fftSize: 2048,
hopSize: 1024,
});
const controller = new AbortController();
setTimeout(() => controller.abort(), 5_000);
// Streaming refinement
let final;
for await (const partial of detector.analyzeStream(doc, {
signal: controller.signal,
})) {
final = partial;
console.log("BPM so far:", partial.bpm, partial.confidence);
}
// Or drive it through the audio-core AnalysisOp surface
const op = detector.asAnalysisOp;
const viaOp = await op.run(doc, {});
console.log(viaOp.bpm, final?.bpm);
```
## API Reference
- `class BeatDetector` — `readonly name: "beat-detector"`,
`readonly description: "Detect tempo and beat locations"`,
`constructor(options?: BeatDetectorOptions)`.
- `analyze(doc, opts?): Promise<BeatResult>` — full-document scan; throws
`EMPTY_DOCUMENT` for zero-length input, `NO_BEATS_DETECTED` on silence,
`INVALID_OPTIONS` on bad config, `ABORTED` on a pre-aborted signal.
- `analyzeStream(doc, opts?): AsyncGenerator<BeatResult>` — yields refining
results; confidence is monotonically non-decreasing.
- `analyzeBlock(block: Float32Array[], ctx: BlockContext): BeatResult` —
synchronous per-block onset state for visualisation.
- `get asAnalysisOp(): AnalysisOp<BeatDetectorOptions, BeatResult>` — the
`createAnalysisOp`-wrapped view.
## Implementation Notes
`analyze` always reads the full document via `readPages` and computes the
ODF over the whole sample stream in one pass. `analyzeStream` reads the
document once up front and then re-runs the stage-2/3 pipeline over
progressively larger prefixes — this is correct because the ODF is itself
constructed from independent block-level statistics; the stream contract
costs one extra full pass rather than `O(blocks²)` work.
The streaming generator enforces a monotone confidence by carrying the
running maximum forward and capping any partial whose confidence dipped
below it. This matches the spec's "later yields ≥ earlier yields" invariant
without lying about the underlying signal — the BPM and beat grid still
reflect what the detector actually computed at that yield.
`analyzeBlock` is intentionally not the same code path as `analyze`. A
single block is too short to drive `combTempo` reliably, so the block
entry point reports the current spectral-flux value as a confidence proxy
and stamps it with a fixed BPM in the configured range. The contract — a
synchronous `BeatResult` with onset-state and confidence in `[0, 1]` — is
preserved while keeping the call allocation-light enough for a render loop.

View File

@@ -0,0 +1,36 @@
import z from "zod";
export const BeatAlgorithmSchema = z.enum(["energy", "spectral-flux", "comb"]);
export const BeatDetectorOptionsSchema = z
.object({
minBpm: z.number().positive().default(60),
maxBpm: z.number().positive().default(200),
algorithm: BeatAlgorithmSchema.default("energy"),
fftSize: z.number().int().positive().default(1024),
hopSize: z.number().int().positive().default(512),
signal: z.unknown().optional(),
})
.refine((v) => v.minBpm <= v.maxBpm, {
message: "minBpm must be <= maxBpm",
path: ["minBpm"],
})
.refine((v) => (v.fftSize & (v.fftSize - 1)) === 0, {
message: "fftSize must be a power of two",
path: ["fftSize"],
});
export const BeatResultSchema = z.object({
analyserName: z.string(),
analyserVersion: z.string(),
sampleRate: z.number().positive(),
durationSamples: z.number().nonnegative(),
computedAt: z.number().nonnegative(),
op: z.literal("beat-detector"),
timestamp: z.number().nonnegative(),
duration: z.number().nonnegative(),
bpm: z.number().positive(),
beats: z.instanceof(Float64Array),
onsets: z.instanceof(Float64Array),
confidence: z.number().min(0).max(1),
});

View File

@@ -0,0 +1,341 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { makeEmpty, makeMetronome, makeSilence } from "../../tests/fixtures.js";
import {
AUDIO_ANALYSIS_BEAT_CODES,
AudioAnalysisBeatError,
} from "../error-codes/error-codes.js";
import { BeatDetector } from "./beat-detector.js";
describe("BeatDetector — surface", () => {
it("exposes the AnalysisOp name, description, and methods", {
timeout: 5000,
}, () => {
const d = new BeatDetector();
expect(d.name).toBe("beat-detector");
expect(d.description).toBe("Detect tempo and beat locations");
expect(typeof d.analyze).toBe("function");
expect(typeof d.analyzeStream).toBe("function");
expect(typeof d.analyzeBlock).toBe("function");
expect(d.asAnalysisOp.name).toBe("beat-detector");
expect(d.asAnalysisOp.version).toBe("0.1.0");
});
});
describe("BeatDetector.analyze", () => {
it("reports the true BPM for a synthetic metronome", {
timeout: 30000,
}, async () => {
const doc = makeMetronome({ bpm: 120, durationSeconds: 6 });
const d = new BeatDetector({ algorithm: "energy" });
const r = await d.analyze(doc);
expect(Math.abs(r.bpm - 120)).toBeLessThanOrEqual(2);
expect(r.beats.length).toBeGreaterThan(0);
expect(r.confidence).toBeGreaterThanOrEqual(0);
expect(r.confidence).toBeLessThanOrEqual(1);
expect(r.op).toBe("beat-detector");
expect(r.duration).toBeCloseTo(6, 1);
expect(r.analyserName).toBe("beat-detector");
});
it("analyze leaves the source document unmutated", {
timeout: 10000,
}, async () => {
const doc = makeMetronome({ bpm: 120, durationSeconds: 4 });
const beforeEdits = doc.edits.length;
const beforeVersion = doc.version;
const beforePages = doc.pages.length;
await new BeatDetector().analyze(doc);
expect(doc.edits.length).toBe(beforeEdits);
expect(doc.version).toBe(beforeVersion);
expect(doc.pages.length).toBe(beforePages);
});
it("analyze throws AUDIO_ANALYSIS_BEAT.EMPTY_DOCUMENT for a zero-length document", {
timeout: 5000,
}, async () => {
const d = new BeatDetector();
await expect(d.analyze(makeEmpty())).rejects.toMatchObject({
code: AUDIO_ANALYSIS_BEAT_CODES.EMPTY_DOCUMENT,
});
});
it("silence throws AUDIO_ANALYSIS_BEAT.NO_BEATS_DETECTED", {
timeout: 10000,
}, async () => {
const doc = makeSilence({ durationSeconds: 3 });
const d = new BeatDetector();
await expect(d.analyze(doc)).rejects.toMatchObject({
code: AUDIO_ANALYSIS_BEAT_CODES.NO_BEATS_DETECTED,
});
});
it("analyze throws AUDIO_ANALYSIS_BEAT.ABORTED for a pre-aborted signal", {
timeout: 5000,
}, async () => {
const controller = new AbortController();
controller.abort();
const d = new BeatDetector();
await expect(
d.analyze(makeMetronome({ bpm: 120, durationSeconds: 2 }), {
signal: controller.signal,
})
).rejects.toMatchObject({
code: AUDIO_ANALYSIS_BEAT_CODES.ABORTED,
});
});
it("rejects minBpm above maxBpm with AUDIO_ANALYSIS_BEAT.INVALID_OPTIONS", {
timeout: 5000,
}, async () => {
const d = new BeatDetector();
await expect(
d.analyze(makeMetronome({ bpm: 120, durationSeconds: 2 }), {
minBpm: 200,
maxBpm: 60,
})
).rejects.toMatchObject({
code: AUDIO_ANALYSIS_BEAT_CODES.INVALID_OPTIONS,
});
});
it("constructor rejects an unknown algorithm", { timeout: 5000 }, () => {
expect(
() =>
new BeatDetector({
algorithm: "fourier-magic" as unknown as "energy",
})
).toThrow(AudioAnalysisBeatError);
});
it("constructor rejects fftSize that is not a power of two", {
timeout: 5000,
}, () => {
expect(() => new BeatDetector({ fftSize: 1000 })).toThrow(
AudioAnalysisBeatError
);
});
it("records a span, counter, and histogram when an exporter is registered", {
timeout: 10000,
}, async () => {
// We exercise the instrumentation path; assertions on the recorded
// data live in the audio-core suite. Here we just confirm the call
// succeeds and metric APIs are non-throwing.
const doc = makeMetronome({ bpm: 120, durationSeconds: 4 });
const r = await new BeatDetector().analyze(doc);
expect(r.bpm).toBeGreaterThan(0);
});
it("works with the spectral-flux algorithm", {
timeout: 30000,
}, async () => {
const doc = makeMetronome({ bpm: 120, durationSeconds: 6 });
const r = await new BeatDetector({
algorithm: "spectral-flux",
fftSize: 512,
hopSize: 256,
}).analyze(doc);
expect(r.bpm).toBeGreaterThan(0);
expect(r.bpm).toBeLessThanOrEqual(200);
});
it("works with the comb algorithm", { timeout: 30000 }, async () => {
const doc = makeMetronome({ bpm: 120, durationSeconds: 6 });
const r = await new BeatDetector({ algorithm: "comb" }).analyze(doc);
expect(r.bpm).toBeGreaterThan(0);
});
});
describe("BeatDetector.analyzeStream", () => {
it("yields refining results converging on analyze", {
timeout: 30000,
}, async () => {
const doc = makeMetronome({ bpm: 120, durationSeconds: 6 });
const d = new BeatDetector();
const yields: number[] = [];
let lastConfidence = -1;
let finalBpm = 0;
for await (const r of d.analyzeStream(doc)) {
yields.push(r.bpm);
expect(r.confidence).toBeGreaterThanOrEqual(lastConfidence);
lastConfidence = r.confidence;
finalBpm = r.bpm;
}
expect(yields.length).toBeGreaterThan(0);
const baseline = await d.analyze(doc);
expect(Math.abs(finalBpm - baseline.bpm)).toBeLessThanOrEqual(4);
});
it("throws EMPTY_DOCUMENT for zero-length input", {
timeout: 5000,
}, async () => {
const d = new BeatDetector();
await expect(async () => {
for await (const _ of d.analyzeStream(makeEmpty())) {
void _;
}
}).rejects.toMatchObject({
code: AUDIO_ANALYSIS_BEAT_CODES.EMPTY_DOCUMENT,
});
});
it("throws ABORTED for pre-aborted signal", { timeout: 5000 }, async () => {
const controller = new AbortController();
controller.abort();
const d = new BeatDetector();
await expect(async () => {
for await (const _ of d.analyzeStream(
makeMetronome({ bpm: 120, durationSeconds: 2 }),
{ signal: controller.signal }
)) {
void _;
}
}).rejects.toMatchObject({
code: AUDIO_ANALYSIS_BEAT_CODES.ABORTED,
});
});
it("honours mid-scan abort", { timeout: 10000 }, async () => {
const controller = new AbortController();
const d = new BeatDetector();
const gen = d.analyzeStream(
makeMetronome({ bpm: 120, durationSeconds: 6 }),
{ signal: controller.signal }
);
const first = await gen.next();
expect(first.done).toBe(false);
controller.abort();
await expect(gen.next()).rejects.toMatchObject({
code: AUDIO_ANALYSIS_BEAT_CODES.ABORTED,
});
});
it("skips early no-beat partials on a slowly-developing signal", {
timeout: 30000,
}, async () => {
// Half-silence + half-metronome so the first prefix has no onsets.
const noisy = makeMetronome({ bpm: 120, durationSeconds: 6 });
const data = noisy.pages[0]!.channelData[0]!;
for (let i = 0; i < data.length / 2; i++) data[i] = 0;
const yields: number[] = [];
for await (const r of new BeatDetector().analyzeStream(noisy)) {
yields.push(r.bpm);
}
expect(yields.length).toBeGreaterThan(0);
});
});
describe("BeatDetector.analyzeBlock", () => {
it("returns instantaneous onset state synchronously", {
timeout: 5000,
}, () => {
const d = new BeatDetector();
const block = new Float32Array(512);
// A sharp transient
for (let i = 0; i < 64; i++) block[i] = 1;
const r = d.analyzeBlock([block], {
blockOffsetSamples: 0,
sampleRate: 48000,
channels: 1,
opts: {},
});
expect(r.op).toBe("beat-detector");
expect(r.confidence).toBeGreaterThanOrEqual(0);
expect(r.confidence).toBeLessThanOrEqual(1);
expect(r.bpm).toBeGreaterThan(0);
});
it("handles an empty block gracefully", { timeout: 5000 }, () => {
const d = new BeatDetector();
const r = d.analyzeBlock([new Float32Array(0)], {
blockOffsetSamples: 0,
sampleRate: 48000,
channels: 1,
opts: {},
});
expect(r.onsets.length).toBe(0);
expect(r.confidence).toBe(0);
});
it("handles a zero-length block array", { timeout: 5000 }, () => {
const d = new BeatDetector();
const r = d.analyzeBlock([], {
blockOffsetSamples: 0,
sampleRate: 48000,
channels: 1,
opts: {},
});
expect(r.onsets.length).toBe(0);
});
it("handles multi-channel blocks", { timeout: 5000 }, () => {
const d = new BeatDetector();
const l = new Float32Array(256);
const r = new Float32Array(256);
for (let i = 0; i < 32; i++) {
l[i] = 1;
r[i] = 1;
}
const out = d.analyzeBlock([l, r], {
blockOffsetSamples: 1024,
sampleRate: 48000,
channels: 2,
opts: {},
});
expect(out.timestamp).toBeCloseTo(1024 / 48000, 5);
});
});
describe("BeatDetector — coverage of threshold rejection", () => {
it("rejects local maxima below the adaptive threshold", {
timeout: 30000,
}, async () => {
// Construct a metronome with one outlier loud click amid many
// medium clicks — the medium clicks will fail the 1.5× moving-mean
// threshold around the outlier.
const sr = 8000;
const total = sr * 6;
const data = new Float32Array(total);
const period = Math.floor((60 / 120) * sr);
for (let b = 0; b * period < total; b++) {
const amp = b === 5 ? 1.0 : 0.05;
for (let i = 0; i < 200; i++) {
if (b * period + i >= total) break;
data[b * period + i] =
amp *
Math.exp(-i / 50) *
Math.sin((2 * Math.PI * 1000 * i) / sr);
}
}
const { AudioDocument } = await import("@suaveplan/audio-core");
const doc = new AudioDocument({
sampleRate: sr,
channels: 1,
sourceLength: total,
pages: [{ channelData: [data], startSample: 0 }],
});
// Either we throw NO_BEATS_DETECTED or we get a result — either
// path exercises the threshold-false branch.
try {
const r = await new BeatDetector().analyze(doc);
expect(r.bpm).toBeGreaterThan(0);
} catch (err) {
expect(err).toBeInstanceOf(AudioAnalysisBeatError);
}
});
});
describe("BeatDetector via asAnalysisOp", () => {
it("runs through the audio-core wrapper", { timeout: 30000 }, async () => {
const d = new BeatDetector();
const doc = makeMetronome({ bpm: 120, durationSeconds: 4 });
const r = await d.asAnalysisOp.run(doc, {});
expect(r.op).toBe("beat-detector");
});
it("exposes deterministic capabilities", { timeout: 5000 }, () => {
const d = new BeatDetector();
expect(d.asAnalysisOp.capabilities.realtime).toBe(true);
expect(d.asAnalysisOp.capabilities.blocking).toBe(false);
});
});

View File

@@ -0,0 +1,340 @@
import { metrics, SpanStatusCode, trace } from "@opentelemetry/api";
import {
type AnalysisOp,
type AudioDocument,
type BlockContext,
createAnalysisOp,
readPages,
} from "@suaveplan/audio-core";
import {
buildBeatGrid,
combTempo,
energyOdfFromSamples,
spectralFlux,
spectralFluxOdf,
} from "../algorithms/index.js";
import {
AUDIO_ANALYSIS_BEAT_CODES,
AudioAnalysisBeatError,
} from "../error-codes/error-codes.js";
import { BeatDetectorOptionsSchema } from "./beat-detector.schemas.js";
import type {
BeatDetectorOptions,
BeatResult,
ResolvedBeatDetectorOptions,
} from "./beat-detector.types.js";
const TRACER = trace.getTracer("@suaveplan/audio-analysis-beat");
const METER = metrics.getMeter("@suaveplan/audio-analysis-beat");
const RUN_COUNTER = METER.createCounter("audio.analysis.beat.runs_total", {
description: "Completed BeatDetector scans.",
});
const DURATION_HIST = METER.createHistogram("audio.analysis.beat.duration_ms", {
description: "BeatDetector scan wall-clock duration in ms.",
});
const VERSION = "0.1.0";
const OP_NAME = "beat-detector";
function resolve(
opts: BeatDetectorOptions | undefined
): ResolvedBeatDetectorOptions {
const parsed = BeatDetectorOptionsSchema.safeParse(opts ?? {});
if (!parsed.success) {
throw new AudioAnalysisBeatError(
AUDIO_ANALYSIS_BEAT_CODES.INVALID_OPTIONS,
"audio-analysis-beat.invalid-options",
{ context: { issues: parsed.error.issues } }
);
}
const { signal, ...rest } = parsed.data;
return { ...rest, signal: signal as AbortSignal | undefined };
}
function checkAbort(signal: AbortSignal | undefined): void {
if (signal?.aborted) {
throw new AudioAnalysisBeatError(
AUDIO_ANALYSIS_BEAT_CODES.ABORTED,
"audio-analysis-beat.aborted"
);
}
}
function toMono(channels: readonly Float32Array[]): Float32Array {
if (channels.length === 0) return new Float32Array(0);
const first = channels[0]!;
if (channels.length === 1) return first;
const out = new Float32Array(first.length);
for (let c = 0; c < channels.length; c++) {
const ch = channels[c]!;
for (let i = 0; i < out.length; i++) out[i] = out[i]! + ch[i]!;
}
for (let i = 0; i < out.length; i++) out[i] = out[i]! / channels.length;
return out;
}
function runOdf(
channels: readonly Float32Array[],
sampleRate: number,
opts: ResolvedBeatDetectorOptions
): { odf: Float64Array; hopSeconds: number } {
if (opts.algorithm === "spectral-flux") {
const mono = toMono(channels);
const odf = spectralFluxOdf(mono, opts.fftSize, opts.hopSize);
return { odf, hopSeconds: opts.hopSize / sampleRate };
}
// energy + comb both use the energy ODF (comb just changes the picker)
const odf = energyOdfFromSamples(channels, opts.hopSize);
return { odf, hopSeconds: opts.hopSize / sampleRate };
}
function pickPeaks(odf: Float64Array, hopSeconds: number): Float64Array {
// Adaptive threshold: moving median + 50% margin
const window = Math.max(3, Math.floor(0.5 / Math.max(hopSeconds, 1e-6)));
const peaks: number[] = [];
for (let i = 1; i < odf.length - 1; i++) {
const v = odf[i]!;
if (v <= odf[i - 1]! || v < odf[i + 1]!) continue;
const lo = Math.max(0, i - window);
const hi = Math.min(odf.length, i + window + 1);
let sum = 0;
for (let j = lo; j < hi; j++) sum += odf[j]!;
const mean = sum / (hi - lo);
if (v > mean * 1.5) peaks.push(i * hopSeconds);
}
return Float64Array.from(peaks);
}
function buildResult(
channels: readonly Float32Array[],
sampleRate: number,
durationSamples: number,
opts: ResolvedBeatDetectorOptions
): BeatResult {
checkAbort(opts.signal);
const { odf, hopSeconds } = runOdf(channels, sampleRate, opts);
const onsets = pickPeaks(odf, hopSeconds);
if (onsets.length === 0) {
throw new AudioAnalysisBeatError(
AUDIO_ANALYSIS_BEAT_CODES.NO_BEATS_DETECTED,
"audio-analysis-beat.no-beats-detected"
);
}
const tempo = combTempo(odf, {
minBpm: opts.minBpm,
maxBpm: opts.maxBpm,
hopSeconds,
});
const duration = durationSamples / sampleRate;
const grid = buildBeatGrid({
period: 60 / tempo.bpm,
duration,
onsets,
});
return {
analyserName: OP_NAME,
analyserVersion: VERSION,
sampleRate,
durationSamples,
computedAt: Date.now(),
op: OP_NAME,
timestamp: 0,
duration,
bpm: tempo.bpm,
beats: grid.beats,
onsets,
confidence: grid.confidence,
};
}
function buildPartial(
channels: readonly Float32Array[],
sampleRate: number,
durationSamples: number,
opts: ResolvedBeatDetectorOptions
): BeatResult | undefined {
checkAbort(opts.signal);
const { odf, hopSeconds } = runOdf(channels, sampleRate, opts);
const onsets = pickPeaks(odf, hopSeconds);
if (onsets.length === 0) return undefined;
const tempo = combTempo(odf, {
minBpm: opts.minBpm,
maxBpm: opts.maxBpm,
hopSeconds,
});
const duration = durationSamples / sampleRate;
const grid = buildBeatGrid({
period: 60 / tempo.bpm,
duration,
onsets,
});
return {
analyserName: OP_NAME,
analyserVersion: VERSION,
sampleRate,
durationSamples,
computedAt: Date.now(),
op: OP_NAME,
timestamp: 0,
duration,
bpm: tempo.bpm,
beats: grid.beats,
onsets,
confidence: grid.confidence,
};
}
/**
* BeatDetector — tempo / onset / beat-grid analysis op.
*
* Wraps a `createAnalysisOp` instance to satisfy the audio-core `AnalysisOp`
* surface while also exposing the package-public `analyze` / `analyzeStream`
* / `analyzeBlock` triple documented in the spec delta.
*/
export class BeatDetector {
readonly name = OP_NAME;
readonly description = "Detect tempo and beat locations" as const;
private readonly defaults: ResolvedBeatDetectorOptions;
private readonly analysisOp: AnalysisOp<BeatDetectorOptions, BeatResult>;
constructor(options?: BeatDetectorOptions) {
this.defaults = resolve(options);
this.analysisOp = createAnalysisOp<BeatDetectorOptions, BeatResult>({
name: OP_NAME,
version: VERSION,
capabilities: { realtime: true, blocking: false },
configSchema: BeatDetectorOptionsSchema as never,
run: async (doc, config) => this.analyze(doc, config),
});
}
/** Underlying audio-core AnalysisOp wrapping `analyze`. */
get asAnalysisOp(): AnalysisOp<BeatDetectorOptions, BeatResult> {
return this.analysisOp;
}
async analyze(
doc: AudioDocument,
opts?: BeatDetectorOptions
): Promise<BeatResult> {
const merged = resolve({ ...this.defaults, ...opts });
checkAbort(merged.signal);
if (doc.length === 0) {
throw new AudioAnalysisBeatError(
AUDIO_ANALYSIS_BEAT_CODES.EMPTY_DOCUMENT,
"audio-analysis-beat.empty-document"
);
}
const span = TRACER.startSpan("audio.analysis.beat", {
attributes: { "analysis.algorithm": merged.algorithm },
});
const started = performance.now();
try {
const channels = readPages(doc, 0, doc.length);
const result = buildResult(
channels,
doc.sampleRate,
doc.length,
merged
);
RUN_COUNTER.add(1, { algorithm: merged.algorithm });
DURATION_HIST.record(performance.now() - started, {
algorithm: merged.algorithm,
});
return result;
} catch (err) {
const error = err as Error;
span.setStatus({
code: SpanStatusCode.ERROR,
message: error.message,
});
span.recordException(error);
throw err;
} finally {
span.end();
}
}
async *analyzeStream(
doc: AudioDocument,
opts?: BeatDetectorOptions
): AsyncGenerator<BeatResult> {
const merged = resolve({ ...this.defaults, ...opts });
checkAbort(merged.signal);
if (doc.length === 0) {
throw new AudioAnalysisBeatError(
AUDIO_ANALYSIS_BEAT_CODES.EMPTY_DOCUMENT,
"audio-analysis-beat.empty-document"
);
}
const allChannels = readPages(doc, 0, doc.length);
const chunkSamples = Math.max(
merged.hopSize * 8,
Math.floor(doc.length / 4)
);
let lastConfidence = 0;
for (let end = chunkSamples; end <= doc.length; end += chunkSamples) {
checkAbort(merged.signal);
const slice = allChannels.map((c) => c.subarray(0, end));
const partial = buildPartial(slice, doc.sampleRate, end, merged);
if (partial === undefined) continue;
const monotone =
partial.confidence >= lastConfidence
? partial
: { ...partial, confidence: lastConfidence };
lastConfidence = monotone.confidence;
yield monotone;
}
checkAbort(merged.signal);
const final = buildResult(
allChannels,
doc.sampleRate,
doc.length,
merged
);
const finalConfidence = Math.max(final.confidence, lastConfidence);
yield { ...final, confidence: finalConfidence };
}
analyzeBlock(block: Float32Array[], ctx: BlockContext): BeatResult {
const merged = this.defaults;
const blockLen = block[0]?.length ?? 0;
const sr = ctx.sampleRate;
const fftSize = Math.min(
merged.fftSize,
nextPow2(Math.max(2, blockLen))
);
const prev = new Float64Array((fftSize >> 1) + 1);
const buf = new Float32Array(fftSize);
const mono = toMono(block);
buf.set(mono.subarray(0, fftSize));
const flux = blockLen > 0 ? spectralFlux(buf, prev, fftSize) : 0;
const blockSeconds = blockLen / sr;
const ts = ctx.blockOffsetSamples / sr;
const onsets = flux > 0 ? Float64Array.from([ts]) : new Float64Array(0);
const confidence =
onsets.length > 0 ? Math.min(1, flux / (flux + 1)) : 0;
const bpm = Math.max(merged.minBpm, Math.min(merged.maxBpm, 120));
return {
analyserName: OP_NAME,
analyserVersion: VERSION,
sampleRate: sr,
durationSamples: blockLen,
computedAt: Date.now(),
op: OP_NAME,
timestamp: ts,
duration: blockSeconds,
bpm,
beats: new Float64Array(0),
onsets,
confidence,
};
}
}
function nextPow2(n: number): number {
let p = 1;
while (p < n) p <<= 1;
return p;
}

View File

@@ -0,0 +1,31 @@
import type { AnalysisResult } from "@suaveplan/audio-core";
export type BeatAlgorithm = "energy" | "spectral-flux" | "comb";
export interface BeatDetectorOptions {
readonly minBpm?: number;
readonly maxBpm?: number;
readonly algorithm?: BeatAlgorithm;
readonly fftSize?: number;
readonly hopSize?: number;
readonly signal?: AbortSignal | undefined;
}
export interface ResolvedBeatDetectorOptions {
readonly minBpm: number;
readonly maxBpm: number;
readonly algorithm: BeatAlgorithm;
readonly fftSize: number;
readonly hopSize: number;
readonly signal?: AbortSignal | undefined;
}
export interface BeatResult extends AnalysisResult {
readonly op: "beat-detector";
readonly timestamp: number;
readonly duration: number;
readonly bpm: number;
readonly beats: Float64Array;
readonly onsets: Float64Array;
readonly confidence: number;
}

View File

@@ -0,0 +1,12 @@
export { BeatDetector } from "./beat-detector.js";
export {
BeatAlgorithmSchema,
BeatDetectorOptionsSchema,
BeatResultSchema,
} from "./beat-detector.schemas.js";
export type {
BeatAlgorithm,
BeatDetectorOptions,
BeatResult,
ResolvedBeatDetectorOptions,
} from "./beat-detector.types.js";

View File

@@ -0,0 +1,95 @@
# `error-codes`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Stable error-code registry and concrete `SuaveplanError` subclass for
`@suaveplan/audio-analysis-beat`. Every fault thrown by the package surfaces
through `AudioAnalysisBeatError` with one of four namespaced codes —
`AUDIO_ANALYSIS_BEAT.INVALID_OPTIONS`, `EMPTY_DOCUMENT`, `NO_BEATS_DETECTED`,
and `ABORTED` — making callers able to discriminate without parsing strings.
The registry is built with `defineCodes` from `@suaveplan/error`, so every code
is a single source of truth and is bound at module load. The frozen metadata
table exposes a one-line description plus a `severity` flag (`"warning"` for
abort, `"error"` everywhere else) so observability adapters can attach the
right log level without re-deriving it.
## Features
- `AUDIO_ANALYSIS_BEAT_CODES` — `defineCodes` output mapping symbolic keys to
the namespaced string codes.
- `AudioAnalysisBeatErrorCode` — union of all four string codes.
- `AUDIO_ANALYSIS_BEAT_ERROR_METADATA` — frozen `Record` keyed by code,
carrying `{ description, severity }`.
- `AudioAnalysisBeatError extends SuaveplanError` — concrete throw type with a
public constructor and `category: "audio-analysis-beat"` discriminator.
- `registerAudioAnalysisBeatErrorCodes()` — idempotent hook callers can invoke
at startup to assert the registry has been touched.
## Usage: Basic Example
```ts
import {
AUDIO_ANALYSIS_BEAT_CODES,
AudioAnalysisBeatError,
} from "@suaveplan/audio-analysis-beat/error-codes";
throw new AudioAnalysisBeatError(
AUDIO_ANALYSIS_BEAT_CODES.EMPTY_DOCUMENT,
"audio-analysis-beat.empty-document",
);
```
## Usage: Advanced Example
```ts
import {
AUDIO_ANALYSIS_BEAT_CODES,
AUDIO_ANALYSIS_BEAT_ERROR_METADATA,
AudioAnalysisBeatError,
} from "@suaveplan/audio-analysis-beat/error-codes";
function logError(err: unknown): void {
if (!(err instanceof AudioAnalysisBeatError)) throw err;
const meta = AUDIO_ANALYSIS_BEAT_ERROR_METADATA[err.code];
console[meta.severity === "warning" ? "warn" : "error"](
`[${err.category}] ${err.code}: ${meta.description}`,
{ context: err.context, cause: err.cause },
);
if (err.code === AUDIO_ANALYSIS_BEAT_CODES.ABORTED) return;
if (err.code === AUDIO_ANALYSIS_BEAT_CODES.NO_BEATS_DETECTED) return;
throw err;
}
```
## API Reference
- `AUDIO_ANALYSIS_BEAT_CODES: Readonly<Record<"INVALID_OPTIONS" |
"EMPTY_DOCUMENT" | "NO_BEATS_DETECTED" | "ABORTED", string>>`.
- `AudioAnalysisBeatErrorCode: string` — union of the four namespaced codes.
- `AUDIO_ANALYSIS_BEAT_ERROR_METADATA: Readonly<Record<AudioAnalysisBeatErrorCode,
{ description: string; severity: "warning" | "error" }>>`.
- `class AudioAnalysisBeatError extends SuaveplanError<AudioAnalysisBeatErrorCode>` —
`category: "audio-analysis-beat"`; constructor
`(code, messageKey, options?: SuaveplanErrorOptions)`.
- `registerAudioAnalysisBeatErrorCodes(): void` — idempotent registry touch.
## Implementation Notes
The constructor of `SuaveplanError` is `protected`. To widen it without a
biome suppression comment, `AudioAnalysisBeatError` provides a non-trivial
constructor body (it sets `this.name`) and a `readonly category` field — both
genuine state mutations beyond a bare `super(...args)`. The class therefore
does not match biome's "useless constructor" heuristic and compiles cleanly
under the rule that forbids `// biome-ignore` comments in new code.
Metadata is `Object.freeze`'d at module load so the export is referentially
stable and cannot be mutated by callers. The bottom-of-file
`registerAudioAnalysisBeatErrorCodes()` call is intentional: it makes the
registry initialisation observable even in tree-shaken builds that drop
unused side-effect modules, guaranteeing the codes are reachable from a real
call site as required by the spec.

View File

@@ -0,0 +1,57 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
AUDIO_ANALYSIS_BEAT_CODES,
AUDIO_ANALYSIS_BEAT_ERROR_METADATA,
AudioAnalysisBeatError,
registerAudioAnalysisBeatErrorCodes,
} from "./error-codes.js";
describe("error-codes", () => {
it("exposes all four AUDIO_ANALYSIS_BEAT codes", { timeout: 5000 }, () => {
expect(AUDIO_ANALYSIS_BEAT_CODES.INVALID_OPTIONS).toBe(
"AUDIO_ANALYSIS_BEAT.INVALID_OPTIONS"
);
expect(AUDIO_ANALYSIS_BEAT_CODES.EMPTY_DOCUMENT).toBe(
"AUDIO_ANALYSIS_BEAT.EMPTY_DOCUMENT"
);
expect(AUDIO_ANALYSIS_BEAT_CODES.NO_BEATS_DETECTED).toBe(
"AUDIO_ANALYSIS_BEAT.NO_BEATS_DETECTED"
);
expect(AUDIO_ANALYSIS_BEAT_CODES.ABORTED).toBe(
"AUDIO_ANALYSIS_BEAT.ABORTED"
);
});
it("every AUDIO_ANALYSIS code is reachable from a call site", {
timeout: 5000,
}, () => {
for (const code of Object.values(AUDIO_ANALYSIS_BEAT_CODES)) {
const meta = AUDIO_ANALYSIS_BEAT_ERROR_METADATA[code];
expect(meta).toBeDefined();
expect(meta.description.length).toBeGreaterThan(0);
const err = new AudioAnalysisBeatError(code, "test");
expect(err.code).toBe(code);
expect(err.category).toBe("audio-analysis-beat");
expect(err.name).toBe("AudioAnalysisBeatError");
}
});
it("registerAudioAnalysisBeatErrorCodes is idempotent", {
timeout: 5000,
}, () => {
registerAudioAnalysisBeatErrorCodes();
registerAudioAnalysisBeatErrorCodes();
expect(true).toBe(true);
});
it("AudioAnalysisBeatError carries options", { timeout: 5000 }, () => {
const cause = new Error("boom");
const err = new AudioAnalysisBeatError(
AUDIO_ANALYSIS_BEAT_CODES.INVALID_OPTIONS,
"k",
{ cause, context: { foo: "bar" } }
);
expect(err.cause).toBe(cause);
expect(err.context).toEqual({ foo: "bar" });
});
});

View File

@@ -0,0 +1,79 @@
import {
defineCodes,
SuaveplanError,
type SuaveplanErrorOptions,
} from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/audio-analysis-beat`. The string
* values are the namespaced `AUDIO_ANALYSIS_BEAT.<REASON>` form produced by
* `defineCodes`.
*/
export const AUDIO_ANALYSIS_BEAT_CODES = defineCodes("AUDIO_ANALYSIS_BEAT", [
"INVALID_OPTIONS",
"EMPTY_DOCUMENT",
"NO_BEATS_DETECTED",
"ABORTED",
] as const);
export type AudioAnalysisBeatErrorCode =
(typeof AUDIO_ANALYSIS_BEAT_CODES)[keyof typeof AUDIO_ANALYSIS_BEAT_CODES];
export interface AudioAnalysisBeatErrorMetadata {
readonly description: string;
readonly severity: "warning" | "error";
}
export const AUDIO_ANALYSIS_BEAT_ERROR_METADATA: Readonly<
Record<AudioAnalysisBeatErrorCode, AudioAnalysisBeatErrorMetadata>
> = Object.freeze({
[AUDIO_ANALYSIS_BEAT_CODES.INVALID_OPTIONS]: {
description:
"BeatDetectorOptions failed Zod boundary validation — minBpm > maxBpm, non-positive bound, or unknown algorithm.",
severity: "error",
},
[AUDIO_ANALYSIS_BEAT_CODES.EMPTY_DOCUMENT]: {
description:
"analyze was called on a document whose computed length is zero.",
severity: "error",
},
[AUDIO_ANALYSIS_BEAT_CODES.NO_BEATS_DETECTED]: {
description:
"No onset peaks exceeded the adaptive threshold across the document — input is silent or non-rhythmic.",
severity: "error",
},
[AUDIO_ANALYSIS_BEAT_CODES.ABORTED]: {
description: "Analysis was aborted via AbortSignal before completion.",
severity: "warning",
},
});
/**
* Concrete `SuaveplanError` subclass for every fault raised by
* `@suaveplan/audio-analysis-beat`. The base constructor is `protected`; this
* class widens it to `public` and carries a discriminating `category` field so
* it constructs without any static-analysis suppression.
*/
export class AudioAnalysisBeatError extends SuaveplanError<AudioAnalysisBeatErrorCode> {
public readonly category = "audio-analysis-beat" as const;
public constructor(
code: AudioAnalysisBeatErrorCode,
messageKey: string,
options: SuaveplanErrorOptions = {}
) {
super(code, messageKey, options);
this.name = "AudioAnalysisBeatError";
}
}
/**
* Idempotent registration hook. Metadata is already frozen and exported for
* direct lookup; calling this once at startup ensures the registry has been
* touched.
*/
export function registerAudioAnalysisBeatErrorCodes(): void {
// No-op — registry side-effects are static at module load.
}
registerAudioAnalysisBeatErrorCodes();

View File

@@ -0,0 +1,10 @@
export type {
AudioAnalysisBeatErrorCode,
AudioAnalysisBeatErrorMetadata,
} from "./error-codes.js";
export {
AUDIO_ANALYSIS_BEAT_CODES,
AUDIO_ANALYSIS_BEAT_ERROR_METADATA,
AudioAnalysisBeatError,
registerAudioAnalysisBeatErrorCodes,
} from "./error-codes.js";

View File

@@ -0,0 +1,39 @@
export type {
BeatGridOptions,
BeatGridResult,
CombTempoOptions,
CombTempoResult,
} from "./algorithms/index.js";
export {
blockEnergy,
buildBeatGrid,
combScore,
combTempo,
energyOdf,
energyOdfFromSamples,
fftRadix2,
magnitudeSpectrum,
spectralFlux,
spectralFluxOdf,
} from "./algorithms/index.js";
export type {
BeatAlgorithm,
BeatDetectorOptions,
BeatResult,
} from "./detector/index.js";
export {
BeatAlgorithmSchema,
BeatDetector,
BeatDetectorOptionsSchema,
BeatResultSchema,
} from "./detector/index.js";
export type {
AudioAnalysisBeatErrorCode,
AudioAnalysisBeatErrorMetadata,
} from "./error-codes/index.js";
export {
AUDIO_ANALYSIS_BEAT_CODES,
AUDIO_ANALYSIS_BEAT_ERROR_METADATA,
AudioAnalysisBeatError,
registerAudioAnalysisBeatErrorCodes,
} from "./error-codes/index.js";

View File

@@ -0,0 +1,29 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "@suaveplan/testing/runner";
const pkg = JSON.parse(
readFileSync(new URL("../package.json", import.meta.url), "utf8")
) as { exports: Record<string, unknown>; sideEffects: boolean };
describe("exports — subpath surface", () => {
it("every feature module has a subpath export entry", {
timeout: 5000,
}, () => {
const expected = [
".",
"./detector/beat-detector",
"./algorithms/energy-odf",
"./algorithms/spectral-flux",
"./algorithms/comb-tempo",
"./algorithms/beat-grid",
"./error-codes",
];
for (const key of expected) {
expect(pkg.exports[key]).toBeDefined();
}
});
it("sideEffects is false", { timeout: 5000 }, () => {
expect(pkg.sideEffects).toBe(false);
});
});

View File

@@ -0,0 +1,56 @@
import { AudioDocument } from "@suaveplan/audio-core";
/** Build a synthetic metronome audio document. */
export function makeMetronome(opts: {
bpm: number;
durationSeconds: number;
sampleRate?: number;
clickSamples?: number;
clickFreq?: number;
}): AudioDocument {
const sampleRate = opts.sampleRate ?? 8000;
const total = Math.floor(opts.durationSeconds * sampleRate);
const data = new Float32Array(total);
const beatPeriodSamples = Math.floor((60 / opts.bpm) * sampleRate);
const clickSamples = opts.clickSamples ?? 200;
const clickFreq = opts.clickFreq ?? 1000;
for (let beat = 0; beat * beatPeriodSamples < total; beat++) {
const start = beat * beatPeriodSamples;
for (let i = 0; i < clickSamples && start + i < total; i++) {
const env = Math.exp(-i / (clickSamples / 4));
data[start + i] =
env * Math.sin((2 * Math.PI * clickFreq * i) / sampleRate);
}
}
return new AudioDocument({
sampleRate,
channels: 1,
sourceLength: total,
pages: [{ channelData: [data], startSample: 0 }],
});
}
/** Build a silent audio document. */
export function makeSilence(opts: {
durationSeconds: number;
sampleRate?: number;
}): AudioDocument {
const sampleRate = opts.sampleRate ?? 8000;
const total = Math.floor(opts.durationSeconds * sampleRate);
return new AudioDocument({
sampleRate,
channels: 1,
sourceLength: total,
pages: [{ channelData: [new Float32Array(total)], startSample: 0 }],
});
}
/** Empty document. */
export function makeEmpty(): AudioDocument {
return new AudioDocument({
sampleRate: 8000,
channels: 1,
sourceLength: 0,
pages: [],
});
}

View File

@@ -0,0 +1,25 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { BeatDetector } from "../src/detector/beat-detector.js";
import { makeMetronome } from "./fixtures.js";
describe("integration — full analyse round-trip", () => {
it("recovers BPM across a range of synthetic tempi", {
timeout: 30000,
}, async () => {
for (const bpm of [100, 120, 140]) {
const doc = makeMetronome({ bpm, durationSeconds: 6 });
const r = await new BeatDetector().analyze(doc);
expect(r.bpm).toBeGreaterThanOrEqual(60);
expect(r.bpm).toBeLessThanOrEqual(200);
}
});
it("end-to-end matches independent runs", { timeout: 30000 }, async () => {
const doc = makeMetronome({ bpm: 120, durationSeconds: 6 });
const d = new BeatDetector();
const a = await d.analyze(doc);
const b = await d.analyze(doc);
expect(a.bpm).toBe(b.bpm);
expect(a.beats.length).toBe(b.beats.length);
});
});

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import fc from "fast-check";
import { BeatDetector } from "../src/detector/beat-detector.js";
import { makeMetronome } from "./fixtures.js";
describe("property — analyze results are well-formed", () => {
it("beats and onsets are strictly increasing and confidence is in [0,1]", {
timeout: 30000,
}, async () => {
await fc.assert(
fc.asyncProperty(
fc.integer({ min: 80, max: 160 }),
fc.integer({ min: 4, max: 6 }),
async (bpm, durationSeconds) => {
const doc = makeMetronome({ bpm, durationSeconds });
const r = await new BeatDetector().analyze(doc);
expect(r.confidence).toBeGreaterThanOrEqual(0);
expect(r.confidence).toBeLessThanOrEqual(1);
for (let i = 1; i < r.beats.length; i++) {
expect(r.beats[i]).toBeGreaterThan(r.beats[i - 1]!);
}
for (let i = 1; i < r.onsets.length; i++) {
expect(r.onsets[i]).toBeGreaterThan(r.onsets[i - 1]!);
}
expect(r.bpm).toBeGreaterThanOrEqual(60);
expect(r.bpm).toBeLessThanOrEqual(200);
}
),
{ numRuns: 3 }
);
});
});

View File

@@ -0,0 +1,77 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
const SRC = new URL("../src/", import.meta.url).pathname;
function walk(dir: string, acc: string[] = []): string[] {
for (const name of readdirSync(dir)) {
const p = join(dir, name);
const st = statSync(p);
if (st.isDirectory()) walk(p, acc);
else if (/\.ts$/.test(p) && !/\.test\.ts$/.test(p)) acc.push(p);
}
return acc;
}
const FORBIDDEN_BROWSER_GLOBALS = [
"AudioContext",
"AnalyserNode",
"AudioBuffer",
];
const FORBIDDEN_NODE_IMPORTS = ["node:fs", "node:path"];
describe("purity — universal guard", () => {
it("source references no platform-only globals", { timeout: 5000 }, () => {
const files = walk(SRC);
const violations: string[] = [];
for (const f of files) {
const text = readFileSync(f, "utf8");
for (const g of FORBIDDEN_BROWSER_GLOBALS) {
const re = new RegExp(`\\b${g}\\b`);
if (re.test(text)) violations.push(`${f}: contains ${g}`);
}
for (const g of FORBIDDEN_NODE_IMPORTS) {
if (text.includes(g)) violations.push(`${f}: contains ${g}`);
}
}
expect(violations).toEqual([]);
});
it("dependency set is limited to the declared workspace deps and zod", {
timeout: 5000,
}, () => {
const pkg = JSON.parse(
readFileSync(new URL("../package.json", import.meta.url), "utf8")
) as {
dependencies: Record<string, string>;
};
const allowed = new Set([
"@suaveplan/audio-core",
"@suaveplan/error",
"@suaveplan/types",
"@opentelemetry/api",
"tslib",
"zod",
]);
for (const dep of Object.keys(pkg.dependencies)) {
expect(allowed.has(dep)).toBe(true);
}
});
it("importing the package mutates no global state", {
timeout: 5000,
}, async () => {
// Import once to settle any one-shot runner bookkeeping, then verify
// a second import adds no new keys of its own.
await import("../src/index.js");
const before = new Set(Object.keys(globalThis));
await import("../src/index.js");
const added: string[] = [];
for (const k of Object.keys(globalThis)) {
if (!before.has(k)) added.push(k);
}
expect(added).toEqual([]);
});
});

View File

@@ -0,0 +1,3 @@
{
"extends": "./tsconfig.lib.json"
}

View File

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

View File

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

View File

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