Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

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

View File

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

View File

@@ -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"`

View File

@@ -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/`.