197 lines
14 KiB
Markdown
197 lines
14 KiB
Markdown
<!-- 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"`
|