first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

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