14 KiB
Capability Spec: audio-analysis-beat
Package: @suaveplan/audio-analysis-beat
Status: SHIPPED (2026-05-23)
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, orBuffer
Scenario: Tests pass under both runtimes
- WHEN the suite runs under
bun test(server) and under vitest with ahappy-domshim (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.descriptionis the documented string, andanalyze,analyzeStream, andanalyzeBlockare all callable
Scenario: Analysis does not mutate the source document
- WHEN a caller runs
analyze(doc)on a document - THEN
doc.edits,doc.version, anddoc.pagesare 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
analyzeruns over a synthetic 120 BPM metronome click track - THEN
result.bpmis within ±1 of120,result.beatsis strictly increasing, andresult.confidenceis in[0, 1]
Scenario: Result fields are well-formed
- WHEN
analyzereturns aBeatResult - THEN
result.op === "beat-detector",result.beatsandresult.onsetsare each strictly monotonically increasing, andresult.durationequals 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
analyzeruns over the same metronome track once peralgorithmvalue ("energy","spectral-flux","comb") - THEN every run returns a
bpmwithin[minBpm, maxBpm]and within ±2 of the true tempo
Scenario: Beat grid phase-aligns onsets to a regular grid
- WHEN the
beat-gridhelper is given an onset list and a known BPM - THEN it returns a beat grid whose inter-beat interval equals
60 / bpmseconds 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
analyzeStreamover a stable-tempo document - THEN every yielded
BeatResultis well-formed and the final yield'sbpmmatchesanalyze'sbpmfor 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
analyzeBlockis called on a block containing a sharp transient - THEN it returns a
BeatResultsynchronously whoseonsetsincludes a timestamp within the block window and whoseconfidenceis 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
analyzeis called with an already-aborted signal - THEN it throws
AudioAnalysisBeatErrorwhosecode === "AUDIO_ANALYSIS_BEAT.ABORTED"before reading any page
Scenario: Mid-scan abort terminates at the next block boundary
- WHEN a signal passed to
analyzeStreamis aborted after the first yield - THEN the generator throws
AUDIO_ANALYSIS_BEAT.ABORTEDat 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
analyzeis called with{ minBpm: 200, maxBpm: 60 } - THEN it throws
AudioAnalysisBeatErrorwhosecode === "AUDIO_ANALYSIS_BEAT.INVALID_OPTIONS"with the Zod issues incontext
Scenario: Unknown algorithm is rejected
- WHEN
BeatDetectoris constructed with{ algorithm: "fourier-magic" } - THEN it throws
AudioAnalysisBeatErrorwhosecode === "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
analyzeis called on a document whoselengthis0 - THEN it throws
AudioAnalysisBeatErrorwhosecode === "AUDIO_ANALYSIS_BEAT.EMPTY_DOCUMENT"
Scenario: A document with no detectable onsets throws NO_BEATS_DETECTED
- WHEN
analyzeruns over a pure-silence document - THEN it throws
AudioAnalysisBeatErrorwhosecode === "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_METADATAis 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
analyzeruns with an exporter registered - THEN an
audio.analysis.beatspan is recorded,audio.analysis.beat.runs_totalincrements by one, andaudio.analysis.beat.duration_msrecords 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"