Files
2026-05-27 23:29:53 +01:00

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