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