Initial Commit
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
# Design: enterprise-audio-analysis-beat
|
||||
|
||||
**Change ID**: enterprise-audio-analysis-beat
|
||||
**Package**: `@suaveplan/audio-analysis-beat`
|
||||
|
||||
`@suaveplan/audio-analysis-beat` is a universal-tier Analysis Op plugin for the `@suaveplan/audio-*`
|
||||
family. It performs tempo (BPM) detection, beat-grid construction, and onset detection over
|
||||
`AudioDocument` streams. This document carries the **package-specific design** — algorithm
|
||||
rationale, code sketches, file layout, integration notes, and trade-offs. It deliberately does not
|
||||
restate the cross-cutting ecosystem model.
|
||||
|
||||
---
|
||||
|
||||
## 1. Relationship to the ecosystem architecture
|
||||
|
||||
The cross-cutting `@suaveplan/audio-*` architecture — the three-layer separation, the six plugin
|
||||
slots, the zero-duplication policy over genesis primitives, and the universal-purity rule — is
|
||||
defined once in `enterprise-audio-core/design.md` §1–§4 and is **referenced, not duplicated** here.
|
||||
|
||||
Two facts from that document anchor this package:
|
||||
|
||||
- **Analysis Op is plugin slot 3.** Per `enterprise-audio-core/design.md` §1.3, an Analysis Op
|
||||
"reads audio and emits features (BPM, key, spectrum); runs as a sidecar scan, not part of the
|
||||
render pipeline." `BeatDetector` is exactly such a sidecar: it never registers in an `OpRegistry`
|
||||
plan or process slot, never mutates the `AudioDocument`, and runs concurrently with playback or
|
||||
as a standalone batch scan.
|
||||
- **The `AnalysisOp` interface and `AnalysisResult` shape** are defined by the audio ecosystem and
|
||||
consumed here unchanged. `BeatDetector implements AnalysisOp`; `BeatResult extends
|
||||
AnalysisResult`.
|
||||
|
||||
Everything below is specific to beat/tempo/onset detection.
|
||||
|
||||
---
|
||||
|
||||
## 2. Detection pipeline
|
||||
|
||||
Beat detection is a three-stage pipeline regardless of which algorithm is selected:
|
||||
|
||||
```text
|
||||
AudioDocument ──► [1] Onset Detection Function ──► [2] Tempo Estimation ──► [3] Beat Tracking
|
||||
(per-block ODF curve) (dominant period) (phase + grid)
|
||||
```
|
||||
|
||||
### 2.1. Stage 1 — Onset Detection Function (ODF)
|
||||
|
||||
The ODF is a 1-D curve, one value per analysis block, that peaks where the signal's energy or
|
||||
spectral content rises sharply — i.e. at note onsets. Two ODF algorithms ship; a third algorithm
|
||||
(`comb`) reuses the energy ODF and differs only in stage 2.
|
||||
|
||||
**`energy` (default).** The cheapest ODF. It consumes the per-block energy statistics already
|
||||
produced by `@suaveplan/audio-core` and takes the half-wave-rectified first difference:
|
||||
|
||||
```ts
|
||||
// energy-odf.ts — sketch
|
||||
export function energyOdf(blockEnergies: Float64Array): Float64Array {
|
||||
const odf = new Float64Array(blockEnergies.length)
|
||||
for (let i = 1; i < blockEnergies.length; i++) {
|
||||
const d = blockEnergies[i] - blockEnergies[i - 1]
|
||||
odf[i] = d > 0 ? d : 0 // half-wave rectify: onsets are energy increases
|
||||
}
|
||||
return odf
|
||||
}
|
||||
```
|
||||
|
||||
It is fast and allocation-light but blurs onsets that are loud in timbre yet quiet in total energy
|
||||
(e.g. a hi-hat over a sustained pad).
|
||||
|
||||
**`spectral-flux`.** Higher precision and the right default for streaming. A hand-rolled real FFT
|
||||
(no `AnalyserNode`, no Node bindings — universal purity, see the spec delta) produces a magnitude
|
||||
spectrum per frame; the ODF is the sum of positive bin-to-bin differences:
|
||||
|
||||
```ts
|
||||
// spectral-flux.ts — sketch
|
||||
export function spectralFlux(frame: Float32Array, prevMag: Float32Array, fft: RealFft): number {
|
||||
const mag = fft.magnitude(frame) // |X[k]|
|
||||
let flux = 0
|
||||
for (let k = 0; k < mag.length; k++) {
|
||||
const d = mag[k] - prevMag[k]
|
||||
if (d > 0) flux += d // only rising bins count toward an onset
|
||||
prevMag[k] = mag[k]
|
||||
}
|
||||
return flux
|
||||
}
|
||||
```
|
||||
|
||||
Spectral flux catches timbral onsets the energy ODF misses, at the cost of an FFT per frame.
|
||||
|
||||
In both cases, peaks in the ODF above an adaptive threshold (a moving median plus a fixed margin)
|
||||
are reported as `onsets` — timestamps in seconds, strictly increasing.
|
||||
|
||||
### 2.2. Stage 2 — Tempo estimation
|
||||
|
||||
Given the ODF, the dominant periodicity is the tempo. Two strategies:
|
||||
|
||||
- **Autocorrelation** (used by `energy` and `spectral-flux`). Autocorrelate the ODF; the lag of the
|
||||
strongest peak whose implied BPM falls inside `[minBpm, maxBpm]` is the period. Octave errors
|
||||
(detecting half- or double-tempo) are resolved by preferring the candidate closest to the
|
||||
perceptual centre (~120 BPM) when two peaks are within a small ratio.
|
||||
- **Comb filter** (the `comb` algorithm). A bank of comb filters, one per candidate BPM in the
|
||||
range, is convolved with the ODF; the filter with the highest resonance output wins. This is more
|
||||
robust on syncopated material than raw autocorrelation but costs `O(range × odfLength)`.
|
||||
|
||||
```ts
|
||||
// comb-tempo.ts — sketch
|
||||
export function combTempo(odf: Float64Array, sr: number, hop: number, lo: number, hi: number) {
|
||||
let bestBpm = lo, bestScore = -Infinity
|
||||
for (let bpm = lo; bpm <= hi; bpm++) {
|
||||
const period = Math.round((60 / bpm) * (sr / hop)) // ODF samples per beat
|
||||
let score = 0
|
||||
for (let i = period; i < odf.length; i++) score += odf[i] * odf[i - period]
|
||||
if (score > bestScore) { bestScore = score; bestBpm = bpm }
|
||||
}
|
||||
return { bpm: bestBpm, score: bestScore }
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3. Stage 3 — Beat tracking and grid construction
|
||||
|
||||
With a period known, `beat-grid.ts` finds the phase: the offset that, when a `60 / bpm`-spaced grid
|
||||
is laid down, minimises total onset-to-nearest-gridline distance. The output `beats` array is that
|
||||
phase-aligned grid clipped to the document duration. `confidence` is derived from how tightly the
|
||||
detected onsets cluster around the grid (a sharp cluster → high confidence; scattered onsets → low).
|
||||
|
||||
---
|
||||
|
||||
## 3. The three entry points
|
||||
|
||||
`BeatDetector` exposes the full `AnalysisOp` surface; each entry point reuses the same three-stage
|
||||
pipeline at a different granularity.
|
||||
|
||||
- **`analyze(doc, opts?)`** — batch scan. Walks the whole document through stages 1–3 and returns a
|
||||
single settled `BeatResult`. Wrapped in the `audio.analysis.beat` OTel span.
|
||||
- **`analyzeStream(doc, opts?)`** — incremental scan. Runs stage 1 block-by-block and re-runs
|
||||
stages 2–3 on the ODF accumulated so far, yielding a refining `BeatResult` after each window. For
|
||||
a stable-tempo signal, `confidence` is non-decreasing across yields and the final yield's `bpm`
|
||||
equals `analyze`'s.
|
||||
- **`analyzeBlock(block, ctx)`** — real-time. Computes the ODF value for a single block, updates a
|
||||
running tempo estimate, and returns synchronously. Allocation-light enough to call once per
|
||||
audio block from a visualiser's render loop.
|
||||
|
||||
```ts
|
||||
// beat-detector.ts — analyze sketch
|
||||
async analyze(doc: AudioDocument, opts?: BeatDetectorOptions): Promise<BeatResult> {
|
||||
const o = BeatDetectorOptionsSchema.parse({ ...this.opts, ...opts })
|
||||
if (o.signal?.aborted) throw new AudioError('AUDIO_ANALYSIS_BEAT.ABORTED')
|
||||
if (doc.length === 0) throw new AudioError('AUDIO_ANALYSIS_BEAT.EMPTY_DOCUMENT')
|
||||
return tracer.startActiveSpan('audio.analysis.beat', async (span) => {
|
||||
const odf = await this.buildOdf(doc, o) // stage 1
|
||||
const onsets = pickPeaks(odf, doc.sampleRate)
|
||||
if (onsets.length === 0) throw new AudioError('AUDIO_ANALYSIS_BEAT.NO_BEATS_DETECTED')
|
||||
const { bpm } = this.estimateTempo(odf, o) // stage 2
|
||||
const { beats, confidence } = buildBeatGrid(onsets, bpm, doc.duration) // stage 3
|
||||
runsCounter.add(1); durationHist.record(span.duration)
|
||||
return { op: 'beat-detector', timestamp: 0, duration: doc.duration, bpm, beats, onsets, confidence }
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. File layout
|
||||
|
||||
```text
|
||||
packages/media/audio/audio-analysis-beat/
|
||||
package.json, tsconfig.json, vitest.config.ts
|
||||
src/
|
||||
index.ts
|
||||
error-codes.ts error-codes.test.ts
|
||||
detector/
|
||||
beat-detector.ts (+ .md .types.ts .test.ts) · beat-detector.schemas.ts · index.ts
|
||||
algorithms/
|
||||
energy-odf.ts (+ .md .test.ts)
|
||||
spectral-flux.ts (+ .md .test.ts)
|
||||
comb-tempo.ts (+ .md .test.ts)
|
||||
beat-grid.ts (+ .md .test.ts)
|
||||
index.ts
|
||||
__benchmarks__/ beat.bench.ts
|
||||
tests/
|
||||
integration.test.ts property.test.ts purity.test.ts exports.test.ts
|
||||
```
|
||||
|
||||
`detector/` owns the `BeatDetector` class, its `BeatDetectorOptions` / `BeatResult` types, and the
|
||||
Zod schemas. `algorithms/` holds the four pure numeric modules — each independently importable via a
|
||||
subpath export so a consumer that only wants the comb-filter estimator does not pull the whole
|
||||
package.
|
||||
|
||||
---
|
||||
|
||||
## 5. Integration notes
|
||||
|
||||
- **`@suaveplan/audio-core`** — `BeatDetector` consumes `AudioDocument` (page reads via the core's
|
||||
streaming API, never direct page poking), `BlockContext` for `analyzeBlock`, and the `AnalysisOp`
|
||||
interface. The `energy` ODF reuses the core's per-block energy statistics rather than recomputing
|
||||
them. The package never registers an op in an `OpRegistry`.
|
||||
- **`@suaveplan/error`** — `AudioError` is the base for every throw; the `AUDIO_ANALYSIS_*` codes
|
||||
are registered through `@suaveplan/error`'s `defineCodes` registry.
|
||||
- **`@suaveplan/opentelemetry`** — supplies the tracer, counter, and histogram. Instrumentation is
|
||||
inert without an exporter and never runs at import time.
|
||||
- **`@suaveplan/types`** — `LoggerLike` for the optional injected logger.
|
||||
- **Downstream consumers** — application/timeline code registers `BeatDetector` as a sidecar
|
||||
analysis op; it is not depended on by any other package in this 13-change batch.
|
||||
|
||||
---
|
||||
|
||||
## 6. Benchmarks
|
||||
|
||||
- `beat.bench.ts` — `analyze` over a synthetic 1-minute 48 kHz stereo track, once per algorithm.
|
||||
- `beat.bench.ts` — `analyze` over a synthetic 10-minute track to confirm linear scaling and bound
|
||||
per-minute scan cost.
|
||||
|
||||
---
|
||||
|
||||
## 7. Decisions and trade-offs
|
||||
|
||||
- **`energy` is the default, not `spectral-flux`.** The energy ODF reuses statistics `audio-core`
|
||||
already computes, so the default path adds almost no cost over a plain document scan. Callers who
|
||||
need timbral-onset precision opt into `spectral-flux` explicitly. This keeps the common case
|
||||
cheap and the precise case available.
|
||||
- **Hand-rolled FFT, no `AnalyserNode`.** The raw source material suggested a browser
|
||||
`AnalyserNode` for the FFT. That would break universal-tier purity (the package must run
|
||||
identically under Bun, Node, Deno, and browsers). The `spectral-flux` algorithm therefore ships a
|
||||
hand-rolled real FFT over `Float32Array` — slightly slower in a browser than the native node, but
|
||||
one code path everywhere and zero platform branching.
|
||||
- **Comb filter is a selectable algorithm, not the default.** Comb-filter tempo estimation is more
|
||||
robust on syncopated material but costs `O(bpmRange × odfLength)`. It is offered as
|
||||
`algorithm: 'comb'` rather than forced on every caller.
|
||||
- **No-beats is an error, not a zero-confidence result.** A scan that finds no onsets above
|
||||
threshold throws `AUDIO_ANALYSIS_BEAT.NO_BEATS_DETECTED` rather than returning `{ bpm: 0, confidence:
|
||||
0 }`. A silent or non-rhythmic input is a caller-visible condition, and a thrown code is
|
||||
unmissable where a zero-confidence struct is easy to ignore.
|
||||
- **Octave-error resolution favours the perceptual centre.** Autocorrelation routinely detects
|
||||
half- or double-tempo. When two candidate periods are within a small ratio, the one whose BPM is
|
||||
closest to ~120 wins — a cheap heuristic that matches human tapping far more often than picking
|
||||
the raw strongest peak.
|
||||
@@ -0,0 +1,152 @@
|
||||
# OpenSpec Change: enterprise-audio-analysis-beat
|
||||
|
||||
- **Change ID**: enterprise-audio-analysis-beat
|
||||
- **Package**: `@suaveplan/audio-analysis-beat`
|
||||
- **Category**: universal
|
||||
- **Target path**: `packages/media/audio/audio-analysis-beat`
|
||||
- **Status**: PROPOSED
|
||||
- **Date**: 2026-05-14
|
||||
- **Design**: `./design.md`
|
||||
- **Spec delta**: `./specs/audio-analysis-beat/spec.md`
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Deliver `@suaveplan/audio-analysis-beat` on the genesis branch from scratch at enterprise quality. Beat and tempo detection plugin for the universal audio analysis framework. 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 alignment.
|
||||
|
||||
Runs identically in browser and server via `BlockProcessor` or direct stream iteration.
|
||||
|
||||
---
|
||||
|
||||
## Current-state audit (genesis)
|
||||
|
||||
The package does not exist on genesis yet — this is a fresh build. No prior source, no test results, no coverage data. The old `enterprise-browser-audio-engine` openspec was browser-only and did not include beat detection.
|
||||
|
||||
---
|
||||
|
||||
## Available workspace deps on genesis
|
||||
|
||||
| Package | Role |
|
||||
|---|---|
|
||||
| `@suaveplan/audio-core` (`workspace:^`) | `AudioDocument`, `BlockProcessor`, `BlockContext` |
|
||||
| `@suaveplan/error` (`workspace:^`) | `AudioError` base class; `AUDIO_ANALYSIS.*` codes |
|
||||
| `@suaveplan/types` (`workspace:^`) | `LoggerLike` |
|
||||
| `@suaveplan/opentelemetry` (`workspace:^`) | OTel spans, counters, histograms |
|
||||
| `zod` (`catalog:forms`) | Zod schemas for option types |
|
||||
|
||||
Dev deps: `vitest`, `@vitest/coverage-istanbul`, `fast-check`, `mitata` (all `catalog:testing`).
|
||||
|
||||
---
|
||||
|
||||
## Public API
|
||||
|
||||
### Core types
|
||||
|
||||
```ts
|
||||
interface BeatDetectorOptions {
|
||||
minBpm?: number // default 60
|
||||
maxBpm?: number // default 200
|
||||
algorithm?: 'energy' | 'spectral-flux' | 'comb' // default 'energy'
|
||||
}
|
||||
|
||||
interface BeatResult extends AnalysisResult {
|
||||
bpm: number
|
||||
beats: Float64Array // beat timestamps (seconds)
|
||||
onsets: Float64Array // onset timestamps (seconds)
|
||||
confidence: number // 0..1
|
||||
}
|
||||
|
||||
class BeatDetector implements AnalysisOp {
|
||||
readonly name = 'beat-detector'
|
||||
readonly description = 'Detect tempo and beat locations'
|
||||
|
||||
constructor(options?: BeatDetectorOptions)
|
||||
|
||||
analyze(doc: AudioDocument, opts?: BeatDetectorOptions): Promise<BeatResult>
|
||||
analyzeStream(doc: AudioDocument, opts?: BeatDetectorOptions): AsyncGenerator<BeatResult>
|
||||
analyzeBlock(block: Float32Array[], ctx: BlockContext): BeatResult
|
||||
}
|
||||
```
|
||||
|
||||
### Error codes
|
||||
|
||||
```ts
|
||||
type BEATErrorCode =
|
||||
| 'AUDIO_ANALYSIS_BEAT.INVALID_OPTIONS'
|
||||
| 'AUDIO_ANALYSIS_BEAT.EMPTY_DOCUMENT'
|
||||
| 'AUDIO_ANALYSIS_BEAT.NO_BEATS_DETECTED'
|
||||
|
||||
const AUDIO_ANALYSIS_BEAT_ERROR_METADATA: Readonly<Record<BEATErrorCode, CodeMetadata>>
|
||||
function registerAudioAnalysisErrorCodes(): void
|
||||
```
|
||||
|
||||
### Zod schemas
|
||||
|
||||
```ts
|
||||
BeatDetectorOptionsSchema
|
||||
BeatResultSchema
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## New additions for genesis
|
||||
|
||||
1. **Three algorithms** — energy ODF (fast, from existing block stats), spectral flux (FFT-based, higher precision), comb-filter tempo estimation.
|
||||
2. **Beat grid alignment** — phase-align detected onsets to a regular grid after tempo is known.
|
||||
3. **Streaming refinement** — `analyzeStream()` yields partial results that refine as more audio is processed.
|
||||
4. **Block-level real-time** — `analyzeBlock()` returns instantaneous onset detection for visualization.
|
||||
5. **OTel instrumentation** — span `audio.analysis.beat`, counter `audio.analysis.beat.runs_total`, histogram `audio.analysis.beat.duration_ms`.
|
||||
6. **Property-based tests** — synthetic metronome input yields exact BPM, silence yields no beats with graceful fallback.
|
||||
7. **Benchmark suite** — analyze 1-minute track, analyze 10-minute track.
|
||||
|
||||
---
|
||||
|
||||
## File layout
|
||||
|
||||
```text
|
||||
packages/media/audio/audio-analysis-beat/
|
||||
package.json
|
||||
tsconfig.json
|
||||
vitest.config.ts
|
||||
src/
|
||||
index.ts
|
||||
error-codes.ts
|
||||
detector/
|
||||
beat-detector.ts
|
||||
beat-detector.md
|
||||
beat-detector.types.ts
|
||||
beat-detector.test.ts
|
||||
index.ts
|
||||
algorithms/
|
||||
energy-odf.ts
|
||||
energy-odf.md
|
||||
energy-odf.test.ts
|
||||
spectral-flux.ts
|
||||
spectral-flux.md
|
||||
spectral-flux.test.ts
|
||||
comb-tempo.ts
|
||||
comb-tempo.md
|
||||
comb-tempo.test.ts
|
||||
beat-grid.ts
|
||||
beat-grid.md
|
||||
beat-grid.test.ts
|
||||
index.ts
|
||||
__benchmarks__/
|
||||
beat.bench.ts
|
||||
tests/
|
||||
integration.test.ts
|
||||
property.test.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Impact
|
||||
|
||||
**Breaking changes**: none — fresh build, package does not yet exist on genesis.
|
||||
|
||||
**Prerequisites**: `@suaveplan/audio-core`, `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/opentelemetry` published to Verdaccio. All four already exist on genesis.
|
||||
|
||||
### Affected dependents
|
||||
|
||||
No dependents in this batch (verified by `bunx turbo ls --filter=...@suaveplan/audio-analysis-beat`). `@suaveplan/audio-analysis-beat` is a leaf Analysis Op plugin — no other package in this 13-change audio batch depends on it. It is consumed at runtime by application/timeline code that registers it as a sidecar analysis op (e.g. auto-mixing in `@suaveplan/audio-plugins-fx`, visualisation in a future `react-audio-player`, batch analysis in `@suaveplan/audio-cli`) — those consumers depend on `@suaveplan/audio-core`'s `AnalysisOp` interface, not on this package directly, and none are created or modified by this change.
|
||||
@@ -0,0 +1,196 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: audio-analysis-beat (audio-analysis-beat)
|
||||
|
||||
**Package**: `@suaveplan/audio-analysis-beat`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Universal-tier purity
|
||||
|
||||
The `@suaveplan/audio-analysis-beat` package SHALL import zero browser-only APIs (`window`, `document`, `navigator`, `AudioContext`, `AnalyserNode`, `AudioBuffer`) and zero Node-only APIs (`node:fs`, `node:path`, `process`, `Buffer`). All FFT, onset-detection-function, and tempo-estimation work SHALL be performed with hand-rolled numeric code over `Float32Array` / `Float64Array` so the package runs identically in Bun, Node.js, Deno, and browsers. The package's `dependencies` SHALL be limited to `@suaveplan/audio-core`, `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/opentelemetry`, `tslib`, and `zod`.
|
||||
|
||||
#### Scenario: Built dist references no platform globals
|
||||
|
||||
- **WHEN** the built `dist/` is grep'd
|
||||
- **THEN** it MUST NOT reference `window`, `document`, `navigator`, `AudioContext`, `AnalyserNode`, `node:fs`, `node:path`, `process`, or `Buffer`
|
||||
|
||||
#### Scenario: Tests pass under both runtimes
|
||||
|
||||
- **WHEN** the suite runs under `bun test` (server) and under vitest with a `happy-dom` shim (browser)
|
||||
- **THEN** both runs pass with identical results
|
||||
|
||||
**Verified by:** `tests/purity.test.ts::"dist references no browser or Node globals"` AND `tests/purity.test.ts::"dependency set is limited to the declared workspace deps and zod"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: BeatDetector implements the AnalysisOp contract
|
||||
|
||||
The package SHALL export a `BeatDetector` class implementing the `AnalysisOp` interface defined by the `@suaveplan/audio-core` ecosystem (plugin slot 3 — Analysis Op). It SHALL expose a readonly `name` of `"beat-detector"` and a readonly `description` of `"Detect tempo and beat locations"`, and SHALL be constructible with an optional `BeatDetectorOptions` argument. As an Analysis Op it SHALL run as a sidecar scan — it SHALL NOT register itself in any `OpRegistry` plan or process slot and SHALL NOT mutate the `AudioDocument` it analyses.
|
||||
|
||||
#### Scenario: Detector exposes the AnalysisOp surface
|
||||
|
||||
- **WHEN** a caller constructs `new BeatDetector()`
|
||||
- **THEN** `detector.name === "beat-detector"`, `detector.description` is the documented string, and `analyze`, `analyzeStream`, and `analyzeBlock` are all callable
|
||||
|
||||
#### Scenario: Analysis does not mutate the source document
|
||||
|
||||
- **WHEN** a caller runs `analyze(doc)` on a document
|
||||
- **THEN** `doc.edits`, `doc.version`, and `doc.pages` are unchanged after the call returns
|
||||
|
||||
**Verified by:** `src/detector/beat-detector.test.ts::"exposes the AnalysisOp name, description, and methods"` AND `src/detector/beat-detector.test.ts::"analyze leaves the source document unmutated"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Full-document tempo and beat analysis
|
||||
|
||||
`BeatDetector.analyze(doc, opts?)` SHALL return a `Promise<BeatResult>` where `BeatResult` extends `AnalysisResult` and carries `bpm` (a positive number within the configured `minBpm`/`maxBpm` range), `beats` (a `Float64Array` of beat timestamps in seconds, strictly monotonically increasing), `onsets` (a `Float64Array` of onset timestamps in seconds, strictly monotonically increasing), and `confidence` (a number in the closed interval `[0, 1]`). The carried `op` field SHALL equal `"beat-detector"`, `timestamp` SHALL be `0`, and `duration` SHALL equal the analysed document duration in seconds. A synthetic constant-tempo signal SHALL yield a `bpm` equal to its true tempo within ±1 BPM.
|
||||
|
||||
#### Scenario: Constant-tempo metronome yields the exact BPM
|
||||
|
||||
- **WHEN** `analyze` runs over a synthetic 120 BPM metronome click track
|
||||
- **THEN** `result.bpm` is within ±1 of `120`, `result.beats` is strictly increasing, and `result.confidence` is in `[0, 1]`
|
||||
|
||||
#### Scenario: Result fields are well-formed
|
||||
|
||||
- **WHEN** `analyze` returns a `BeatResult`
|
||||
- **THEN** `result.op === "beat-detector"`, `result.beats` and `result.onsets` are each strictly monotonically increasing, and `result.duration` equals the document duration in seconds
|
||||
|
||||
**Verified by:** `src/detector/beat-detector.test.ts::"analyze reports the true BPM for a synthetic metronome"` AND `tests/property.test.ts::"beats and onsets are strictly increasing and confidence is in [0,1]"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Three selectable detection algorithms
|
||||
|
||||
The package SHALL ship three onset/tempo algorithms selectable via `BeatDetectorOptions.algorithm`: `"energy"` (default) — positive first-differences of per-block energy stats sourced from `@suaveplan/audio-core` block statistics; `"spectral-flux"` — onset detection from frame-to-frame magnitude-spectrum increases via a hand-rolled real FFT; and `"comb"` — comb-filter / autocorrelation tempo estimation over an onset-detection function. Each algorithm SHALL be exported as an independently importable module under `./algorithms/*` and SHALL produce a `bpm` within the configured range. A `beat-grid` helper SHALL phase-align detected onsets to a regular grid once the tempo is known.
|
||||
|
||||
#### Scenario: Each algorithm produces an in-range BPM
|
||||
|
||||
- **WHEN** `analyze` runs over the same metronome track once per `algorithm` value (`"energy"`, `"spectral-flux"`, `"comb"`)
|
||||
- **THEN** every run returns a `bpm` within `[minBpm, maxBpm]` and within ±2 of the true tempo
|
||||
|
||||
#### Scenario: Beat grid phase-aligns onsets to a regular grid
|
||||
|
||||
- **WHEN** the `beat-grid` helper is given an onset list and a known BPM
|
||||
- **THEN** it returns a beat grid whose inter-beat interval equals `60 / bpm` seconds and whose phase minimises total onset-to-grid distance
|
||||
|
||||
**Verified by:** `src/algorithms/spectral-flux.test.ts::"spectral-flux ODF detects onsets on a metronome track"` AND `src/algorithms/comb-tempo.test.ts::"comb-filter estimation returns the true tempo"` AND `src/algorithms/beat-grid.test.ts::"beat grid phase-aligns onsets to a 60/bpm interval"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Streaming refinement analysis
|
||||
|
||||
`BeatDetector.analyzeStream(doc, opts?)` SHALL be an async generator yielding `BeatResult` values that refine as more of the document is processed. Each yielded result SHALL be well-formed (BPM in range, monotonic `beats`/`onsets`, `confidence` in `[0, 1]`), and the `confidence` of later yields SHALL be greater than or equal to that of earlier yields for a stable-tempo signal. The final yielded result SHALL be deep-equal in `bpm` to what `analyze` returns for the same document and options.
|
||||
|
||||
#### Scenario: Streaming yields refining results
|
||||
|
||||
- **WHEN** a caller fully drains `analyzeStream` over a stable-tempo document
|
||||
- **THEN** every yielded `BeatResult` is well-formed and the final yield's `bpm` matches `analyze`'s `bpm` for the same input
|
||||
|
||||
**Verified by:** `src/detector/beat-detector.test.ts::"analyzeStream yields refining results converging on analyze"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Real-time block onset detection
|
||||
|
||||
`BeatDetector.analyzeBlock(block, ctx)` SHALL accept a single `Float32Array[]` block plus a `BlockContext` and return a `BeatResult` synchronously, carrying instantaneous onset state for visualisation. It SHALL be allocation-light enough for per-block real-time use, SHALL NOT block on document-wide work, and SHALL carry a `confidence` in `[0, 1]` reflecting the current running tempo estimate. Calling `analyzeBlock` SHALL NOT require a prior `analyze` call.
|
||||
|
||||
#### Scenario: Block analysis returns instantaneous onset state
|
||||
|
||||
- **WHEN** `analyzeBlock` is called on a block containing a sharp transient
|
||||
- **THEN** it returns a `BeatResult` synchronously whose `onsets` includes a timestamp within the block window and whose `confidence` is in `[0, 1]`
|
||||
|
||||
**Verified by:** `src/detector/beat-detector.test.ts::"analyzeBlock returns instantaneous onset state synchronously"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: AbortSignal propagation
|
||||
|
||||
`BeatDetector.analyze()` and `BeatDetector.analyzeStream()` SHALL accept an `AbortSignal` via their options object. An already-aborted signal SHALL cause the method to throw `AudioAnalysisBeatError` with code `AUDIO_ANALYSIS_BEAT.ABORTED` before any page or FFT work begins; a signal aborted mid-scan SHALL terminate the generator (or reject the promise) at the next block boundary with the same error.
|
||||
|
||||
#### Scenario: Pre-aborted signal short-circuits analysis
|
||||
|
||||
- **WHEN** `analyze` is called with an already-aborted signal
|
||||
- **THEN** it throws `AudioAnalysisBeatError` whose `code === "AUDIO_ANALYSIS_BEAT.ABORTED"` before reading any page
|
||||
|
||||
#### Scenario: Mid-scan abort terminates at the next block boundary
|
||||
|
||||
- **WHEN** a signal passed to `analyzeStream` is aborted after the first yield
|
||||
- **THEN** the generator throws `AUDIO_ANALYSIS_BEAT.ABORTED` at the next block boundary and yields nothing further
|
||||
|
||||
**Verified by:** `src/detector/beat-detector.test.ts::"analyze throws AUDIO_ANALYSIS_BEAT.ABORTED for a pre-aborted signal"` AND `src/detector/beat-detector.test.ts::"analyzeStream honours mid-scan abort"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Zod boundary validation
|
||||
|
||||
The public options and result objects — `BeatDetectorOptions` and `BeatResult` — SHALL each have a Zod schema exported from a sibling `*.schemas.ts` (`BeatDetectorOptionsSchema`, `BeatResultSchema`). Every public entry point (`constructor`, `analyze`, `analyzeStream`, `analyzeBlock`) SHALL validate its options argument against `BeatDetectorOptionsSchema` before use. A validation failure — including `minBpm` greater than `maxBpm`, a non-positive BPM bound, or an unknown `algorithm` — SHALL throw `AudioAnalysisBeatError` with code `AUDIO_ANALYSIS_BEAT.INVALID_OPTIONS` carrying the Zod issue list in `context`.
|
||||
|
||||
#### Scenario: Invalid options are rejected at the boundary
|
||||
|
||||
- **WHEN** `analyze` is called with `{ minBpm: 200, maxBpm: 60 }`
|
||||
- **THEN** it throws `AudioAnalysisBeatError` whose `code === "AUDIO_ANALYSIS_BEAT.INVALID_OPTIONS"` with the Zod issues in `context`
|
||||
|
||||
#### Scenario: Unknown algorithm is rejected
|
||||
|
||||
- **WHEN** `BeatDetector` is constructed with `{ algorithm: "fourier-magic" }`
|
||||
- **THEN** it throws `AudioAnalysisBeatError` whose `code === "AUDIO_ANALYSIS_BEAT.INVALID_OPTIONS"`
|
||||
|
||||
**Verified by:** `src/detector/beat-detector.test.ts::"rejects minBpm above maxBpm with AUDIO_ANALYSIS_BEAT.INVALID_OPTIONS"` AND `src/detector/beat-detector.test.ts::"constructor rejects an unknown algorithm"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Structured error codes
|
||||
|
||||
The package SHALL export `AudioAnalysisBeatErrorCode`, `AUDIO_ANALYSIS_BEAT_ERROR_METADATA`, and `registerAudioAnalysisBeatErrorCodes()`, registering the codes `AUDIO_ANALYSIS_BEAT.INVALID_OPTIONS`, `AUDIO_ANALYSIS_BEAT.EMPTY_DOCUMENT`, `AUDIO_ANALYSIS_BEAT.NO_BEATS_DETECTED`, and `AUDIO_ANALYSIS_BEAT.ABORTED` with `@suaveplan/error`. Every error thrown by the package SHALL be an `AudioAnalysisBeatError` extending `SuaveplanError` carrying one of these codes plus `cause` and `context`. An `analyze` call on a zero-length document SHALL throw `AUDIO_ANALYSIS_BEAT.EMPTY_DOCUMENT`; a scan that finds no onsets above the detection threshold SHALL throw `AUDIO_ANALYSIS_BEAT.NO_BEATS_DETECTED` rather than returning a zero-confidence result. Every registered code SHALL be reachable from a real call site exercised by tests.
|
||||
|
||||
#### Scenario: Empty document throws AUDIO_ANALYSIS_BEAT.EMPTY_DOCUMENT
|
||||
|
||||
- **WHEN** `analyze` is called on a document whose `length` is `0`
|
||||
- **THEN** it throws `AudioAnalysisBeatError` whose `code === "AUDIO_ANALYSIS_BEAT.EMPTY_DOCUMENT"`
|
||||
|
||||
#### Scenario: A document with no detectable onsets throws NO_BEATS_DETECTED
|
||||
|
||||
- **WHEN** `analyze` runs over a pure-silence document
|
||||
- **THEN** it throws `AudioAnalysisBeatError` whose `code === "AUDIO_ANALYSIS_BEAT.NO_BEATS_DETECTED"`
|
||||
|
||||
#### Scenario: Every registered code is thrown by a call site
|
||||
|
||||
- **WHEN** the test suite runs
|
||||
- **THEN** each code in `AUDIO_ANALYSIS_BEAT_ERROR_METADATA` is asserted thrown by at least one test
|
||||
|
||||
**Verified by:** `src/detector/beat-detector.test.ts::"analyze throws AUDIO_ANALYSIS_BEAT.EMPTY_DOCUMENT for a zero-length document"` AND `src/detector/beat-detector.test.ts::"silence throws AUDIO_ANALYSIS_BEAT.NO_BEATS_DETECTED"` AND `src/error-codes.test.ts::"every AUDIO_ANALYSIS code is reachable from a call site"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: OpenTelemetry instrumentation
|
||||
|
||||
The package SHALL instrument its public entry points via `@suaveplan/opentelemetry`: a span `audio.analysis.beat` around `analyze` / `analyzeStream`, a counter `audio.analysis.beat.runs_total` incremented per completed scan, and a histogram `audio.analysis.beat.duration_ms` recording scan wall-time. Instrumentation SHALL be a no-op when no exporter is registered and SHALL NOT run as an import-time side effect.
|
||||
|
||||
#### Scenario: Instrumentation is inert without an exporter
|
||||
|
||||
- **WHEN** the package is imported and used with no OTel exporter registered
|
||||
- **THEN** no error is thrown and no global state is mutated at import time
|
||||
|
||||
#### Scenario: A scan records a span and histogram when an exporter is registered
|
||||
|
||||
- **WHEN** `analyze` runs with an exporter registered
|
||||
- **THEN** an `audio.analysis.beat` span is recorded, `audio.analysis.beat.runs_total` increments by one, and `audio.analysis.beat.duration_ms` records a sample
|
||||
|
||||
**Verified by:** `tests/purity.test.ts::"importing the package mutates no global state"` AND `src/detector/beat-detector.test.ts::"analyze records a span, counter, and histogram when an exporter is registered"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Subpath exports per feature module
|
||||
|
||||
`package.json` SHALL declare one `exports` entry per feature module — `./detector/beat-detector`, `./algorithms/energy-odf`, `./algorithms/spectral-flux`, `./algorithms/comb-tempo`, `./algorithms/beat-grid`, and `./error-codes` — in addition to the root `.` barrel, each resolving to a typed `import` condition. `sideEffects` SHALL be `false`.
|
||||
|
||||
#### Scenario: Each feature module is independently importable
|
||||
|
||||
- **WHEN** a consumer imports `@suaveplan/audio-analysis-beat/algorithms/comb-tempo`
|
||||
- **THEN** the import resolves to the comb-tempo module without pulling the whole package barrel
|
||||
|
||||
**Verified by:** `tests/exports.test.ts::"every feature module has a subpath export entry"`
|
||||
@@ -0,0 +1,79 @@
|
||||
# Tasks: enterprise-audio-analysis-beat
|
||||
|
||||
**Change ID**: enterprise-audio-analysis-beat
|
||||
**Package**: `@suaveplan/audio-analysis-beat`
|
||||
**Target path**: `packages/media/audio/audio-analysis-beat`
|
||||
**Prerequisite packages on Verdaccio**: `@suaveplan/audio-core`, `@suaveplan/error`, `@suaveplan/types`
|
||||
|
||||
---
|
||||
|
||||
## 0. Pre-flight (non-negotiable)
|
||||
|
||||
- [ ] 0.1 Confirm clean working tree (`git status` reports nothing) and no in-flight worktrees with uncommitted work (`git worktree list` then `git status` in each)
|
||||
- [ ] 0.2 If any worktrees exist under `.claude/worktrees/`, account for each one: still-active agent, work-pending-merge, or stale-and-prune. Stale worktrees MUST be removed (`git worktree remove`) before opening a new one — code that has been lost twice on main is the failure mode this guards against.
|
||||
- [ ] 0.3 Confirm the package set in `MANIFEST.md` matches `find packages -maxdepth 3 -name package.json` count. Drift here means a prior change archived without regenerating the manifest.
|
||||
|
||||
## 1. Scaffold
|
||||
|
||||
- [ ] Create directory `packages/media/audio/audio-analysis-beat/` with `src/`, `tests/`, `src/__benchmarks__/`, and all feature subdirectories per the layout in `proposal.md`.
|
||||
- [ ] Write `package.json` at version `0.1.0` with `"type": "module"`, `"sideEffects": false`, full `exports` map, deps: `@suaveplan/audio-core workspace:^`, `@suaveplan/error workspace:^`, `@suaveplan/types workspace:^`, `@suaveplan/opentelemetry workspace:^`, `zod catalog:forms`.
|
||||
- [ ] Write `tsconfig.json` extending `@suaveplan/typescript-config/library.json`.
|
||||
- [ ] Write `vitest.config.ts` using `createTestConfig()` from `@suaveplan/testing/config`.
|
||||
- [ ] Add `lint`, `typecheck`, `test`, `test:coverage`, `build`, `docs:check` scripts.
|
||||
|
||||
---
|
||||
|
||||
## 2. Types + errors
|
||||
|
||||
- [ ] Write `src/error-codes.ts` — `BEATErrorCode`, `AUDIO_ANALYSIS_BEAT_ERROR_METADATA`, `registerAudioAnalysisErrorCodes()`.
|
||||
- [ ] Write Zod schemas: `BeatDetectorOptionsSchema`, `BeatResultSchema`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Algorithms
|
||||
|
||||
- [ ] Implement `energy-odf.ts` — positive first-differences of energy block stats; `processBlock()` returns ODF value.
|
||||
- [ ] Write `energy-odf.test.ts` and `energy-odf.md` (≥200 words).
|
||||
- [ ] Implement `spectral-flux.ts` — FFT-based onset detection with magnitude spectrum differences.
|
||||
- [ ] Write `spectral-flux.test.ts` and `spectral-flux.md` (≥200 words).
|
||||
- [ ] Implement `comb-tempo.ts` — autocorrelation / comb-filter tempo estimation.
|
||||
- [ ] Write `comb-tempo.test.ts` and `comb-tempo.md` (≥200 words).
|
||||
- [ ] Implement `beat-grid.ts` — phase-align onsets to regular grid given BPM.
|
||||
- [ ] Write `beat-grid.test.ts` and `beat-grid.md` (≥200 words).
|
||||
|
||||
---
|
||||
|
||||
## 4. BeatDetector
|
||||
|
||||
- [ ] Implement `BeatDetector` class:
|
||||
- `analyze()` — full-document scan, returns `BeatResult`
|
||||
- `analyzeStream()` — `AsyncGenerator<BeatResult>` that refines as more audio arrives
|
||||
- `analyzeBlock()` — real-time per-block onset detection
|
||||
- [ ] Wire OTel: span `audio.analysis.beat`, counter, histogram.
|
||||
- [ ] Write `beat-detector.test.ts` (≤500 lines): synthetic metronome → exact BPM, silence → no beats, confidence bounds.
|
||||
- [ ] Write `beat-detector.md` (≥200 words).
|
||||
|
||||
---
|
||||
|
||||
## 5. Benchmarks
|
||||
|
||||
- [ ] Write `src/__benchmarks__/beat.bench.ts` — analyze 1-minute track, analyze 10-minute track.
|
||||
|
||||
---
|
||||
|
||||
## 6. Tests
|
||||
|
||||
- [ ] Write `tests/integration.test.ts` — full analyze round-trip on real audio fixture.
|
||||
- [ ] Write `tests/property.test.ts` (fast-check) — BPM in range, beats monotonically increasing, confidence in [0,1].
|
||||
|
||||
---
|
||||
|
||||
## 7. Docs + verify + publish
|
||||
|
||||
- [ ] All co-located `.md` files (≥200 words each).
|
||||
- [ ] Write `README.md` at package root.
|
||||
- [ ] `bunx turbo lint typecheck test --filter=@suaveplan/audio-analysis-beat` — green.
|
||||
- [ ] `bunx turbo test:coverage --filter=@suaveplan/audio-analysis-beat` — 100%.
|
||||
- [ ] `bun scripts/publish.ts --package @suaveplan/audio-analysis-beat`.
|
||||
- [ ] Fold `specs/audio-analysis-beat/spec.md` into `openspec/specs/audio-analysis-beat/spec.md`.
|
||||
- [ ] Archive to `openspec/archive/2026-05-14-enterprise-audio-analysis-beat/`.
|
||||
Reference in New Issue
Block a user