Files
suaveplan/openspec/specs/audio-analysis-pitch/spec.md
2026-05-27 23:29:53 +01:00

14 KiB

Capability Spec Delta: audio-analysis-pitch (audio-analysis-pitch)

Package: @suaveplan/audio-analysis-pitch 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-pitch package SHALL import zero browser-only APIs (window, document, navigator, AudioContext, AudioBuffer, AnalyserNode) and zero Node-only APIs (node:fs, node:path, process, Buffer). All environment-specific behaviour SHALL be reached only through AudioDocument and injected adapters. The package's dependencies SHALL be limited to @suaveplan/audio-core, @suaveplan/error, @suaveplan/types, @suaveplan/opentelemetry, tslib, and zod. The package SHALL ship its own pitch-domain FFT/autocorrelation so it has no hard dependency on @suaveplan/audio-analysis-spectrum.

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: PitchDetector implements the AnalysisOp contract

The package SHALL export a PitchDetector class implementing the AnalysisOp interface from @suaveplan/audio-core (plugin slot 3, Analysis Op). It SHALL expose a readonly name of "pitch-detector", a readonly description, and the three methods analyze(doc, opts?), analyzeStream(doc, opts?), and analyzeBlock(block, ctx). analyze SHALL return a Promise<PitchResult> aggregated over the whole document; analyzeStream SHALL be an AsyncGenerator<PitchResult> whose confidence field is non-decreasing in expectation as more audio is consumed; analyzeBlock SHALL return a synchronous PitchResult for a single Float32Array[] block suitable for real-time visualisation. As an Analysis Op the detector SHALL run as a sidecar scan and SHALL NOT mutate the AudioDocument or participate in its render pipeline.

Scenario: analyze returns an aggregate PitchResult

  • WHEN a caller await new PitchDetector().analyze(doc) on a document containing a steady tone
  • THEN the resolved PitchResult carries op === "pitch-detector", a pitch in Hz, a midiNote, a confidence in [0, 1], and timestamp/duration covering the analysed range

Scenario: analyzeBlock is synchronous and non-mutating

  • WHEN a caller calls analyzeBlock(block, ctx) on a single block
  • THEN a PitchResult is returned synchronously, the input block arrays are unmodified, and no AudioDocument state is touched

Verified by: src/detector/pitch-detector.test.ts::"analyze returns an aggregate PitchResult for a steady tone" AND src/detector/pitch-detector.test.ts::"analyzeBlock returns a synchronous result without mutating input"


Requirement: Fundamental-frequency estimation algorithms

PitchDetector SHALL accept PitchDetectorOptions with algorithm ("yin" | "mcleod" | "cepstrum", default "yin"), minFreq (default 20), and maxFreq (default 20000). It SHALL estimate the fundamental frequency of the dominant periodic component within [minFreq, maxFreq] and report it on PitchResult.pitch, with the corresponding midiNote derived as 69 + 12 * log2(pitch / 440) rounded to the nearest integer. When the input has no detectable periodicity above the algorithm's confidence floor (e.g. silence or broadband noise), pitch SHALL be 0, midiNote SHALL be -1, and confidence SHALL be 0.

Scenario: Known sine frequency is estimated within tolerance

  • WHEN analyze runs on a synthesised 440 Hz sine at 48 kHz with the default "yin" algorithm
  • THEN pitch is within ±1 Hz of 440, midiNote is 69, and confidence is greater than 0.9

Scenario: Silence yields no pitch

  • WHEN analyze runs on a fully silent document
  • THEN pitch is 0, midiNote is -1, and confidence is 0

Scenario: Algorithm selection is honoured

  • WHEN analyze is called with { algorithm: "mcleod" } and again with { algorithm: "cepstrum" } on the same 440 Hz tone
  • THEN each run estimates pitch within ±2 Hz of 440 using the selected estimator

Verified by: src/algorithms/yin.test.ts::"estimates a 440 Hz sine within one hertz" AND src/detector/pitch-detector.test.ts::"silence yields zero pitch and zero confidence" AND src/detector/pitch-detector.test.ts::"honours the algorithm option for mcleod and cepstrum"


Requirement: KeyDetector implements the AnalysisOp contract

The package SHALL export a KeyDetector class implementing the AnalysisOp interface (plugin slot 3, Analysis Op). It SHALL expose a readonly name of "key-detector", a readonly description, and the methods analyze(doc, opts?) and analyzeStream(doc, opts?). analyze SHALL return a Promise<KeyResult> and analyzeStream SHALL be an AsyncGenerator<KeyResult> whose estimate refines as more audio is consumed. The detector SHALL accumulate a 12-bin chromagram over the analysed range and SHALL NOT mutate the AudioDocument.

Scenario: analyze returns a KeyResult with a 12-bin chromagram

  • WHEN a caller await new KeyDetector().analyze(doc) on a tonal document
  • THEN the resolved KeyResult carries op === "key-detector", a key string, a confidence in [0, 1], and a chromagram Float32Array of length 12

Verified by: src/detector/key-detector.test.ts::"analyze returns a KeyResult with a length-12 chromagram"


Requirement: Key and chord estimation outputs

KeyDetector SHALL accept KeyDetectorOptions with profile ("krumhansl-schmuckler" | "temperley", default "krumhansl-schmuckler"). It SHALL correlate the aggregated chromagram against all 24 major/minor key profiles for the selected weighting and report the best-scoring key on KeyResult.key as a pitch-class name plus mode (e.g. "C major", "F# minor"), with the normalised correlation margin on KeyResult.confidence. The package SHALL additionally export a ChordDetector AnalysisOp (name === "chord-detector") whose analyze returns a Promise<ChordResult> carrying a time-ordered chords array of Chord records ({ timestamp, duration, root, quality, confidence }) produced by template-matching the per-frame chromagram against major/minor/diminished/augmented templates with temporal smoothing so transient frames do not produce spurious chords.

Scenario: A C-major signal is identified as C major

  • WHEN KeyDetector.analyze runs on a document built from a C-major triad/scale with the default profile
  • THEN key equals "C major" and confidence is greater than 0.6

Scenario: Key profile selection is honoured

  • WHEN analyze is called with { profile: "temperley" }
  • THEN the Temperley weighting is used to score the chromagram and the returned key reflects that profile

Scenario: Chord progression is time-ordered and smoothed

  • WHEN ChordDetector.analyze runs on a document with a C-major then G-major passage
  • THEN chords is ordered by ascending timestamp, each Chord.root/quality names the matched chord, no Chord is shorter than the smoothing window, and adjacent identical chords are merged

Verified by: src/algorithms/key-finding.test.ts::"identifies a C-major signal as C major" AND src/detector/key-detector.test.ts::"honours the temperley profile option" AND src/algorithms/chord-matching.test.ts::"emits a time-ordered smoothed chord progression"


Requirement: Streaming refinement and AbortSignal propagation

PitchDetector.analyzeStream, KeyDetector.analyzeStream, and ChordDetector.analyzeStream SHALL accept an AbortSignal via their options object and yield progressively refined AnalysisResult values as document blocks are consumed. An already-aborted signal SHALL cause the generator to throw AudioAnalysisError with code AUDIO_ANALYSIS_PITCH.ABORTED before any block work; a signal aborted mid-stream SHALL terminate the generator at the next block boundary with the same error. analyze SHALL likewise honour an AbortSignal and reject with AUDIO_ANALYSIS_PITCH.ABORTED.

Scenario: Pre-aborted signal short-circuits the stream

  • WHEN analyzeStream is called with an already-aborted signal
  • THEN it throws AudioAnalysisError whose code === "AUDIO_ANALYSIS_PITCH.ABORTED" before yielding any result

Scenario: Mid-stream abort terminates at the next block boundary

  • WHEN a signal is aborted after the first partial result is yielded
  • THEN the generator throws AUDIO_ANALYSIS_PITCH.ABORTED at the next block boundary and yields nothing further

Verified by: src/detector/pitch-detector.test.ts::"analyzeStream throws AUDIO_ANALYSIS_PITCH.ABORTED for a pre-aborted signal" AND src/detector/key-detector.test.ts::"analyzeStream honours a mid-stream abort"


Requirement: Zod boundary validation

Every public options object — PitchDetectorOptions and KeyDetectorOptions — SHALL have a Zod schema exported from a sibling *.schemas.ts, and PitchResult, KeyResult, and ChordResult SHALL each have a Zod schema for output validation. Every public entry point (analyze, analyzeStream, analyzeBlock) SHALL validate its options argument against the schema before use. A validation failure SHALL throw AudioAnalysisError with code AUDIO_ANALYSIS_PITCH.INVALID_OPTIONS carrying the Zod issue list in context.

Scenario: Invalid options are rejected at the boundary

  • WHEN PitchDetector.analyze is called with { minFreq: -1 } or an unknown algorithm
  • THEN it throws AudioAnalysisError whose code === "AUDIO_ANALYSIS_PITCH.INVALID_OPTIONS" with the Zod issues in context

Verified by: src/detector/pitch-detector.test.ts::"rejects invalid options with AUDIO_ANALYSIS_PITCH.INVALID_OPTIONS"


Requirement: Structured error codes

The package SHALL export AudioAnalysisPitchErrorCode, AUDIO_ANALYSIS_PITCH_ERROR_METADATA, and registerAudioAnalysisPitchErrorCodes(), registering the codes AUDIO_ANALYSIS_PITCH.INVALID_OPTIONS, AUDIO_ANALYSIS_PITCH.ABORTED, AUDIO_ANALYSIS_PITCH.UNSUPPORTED_FORMAT, and AUDIO_ANALYSIS_PITCH.INSUFFICIENT_DATA with @suaveplan/error. Every error thrown by the package SHALL be an AudioAnalysisError extending SuaveplanError and carry one of these codes, cause, and context. AUDIO_ANALYSIS_PITCH.UNSUPPORTED_FORMAT SHALL be thrown when a document's channel layout or sample rate cannot be analysed; AUDIO_ANALYSIS_PITCH.INSUFFICIENT_DATA SHALL be thrown when the analysed range is shorter than the minimum window an algorithm requires. Every registered code SHALL be reachable from a real call site exercised by tests.

Scenario: Every registered code is thrown by a call site

  • WHEN the test suite runs
  • THEN each code in AUDIO_ANALYSIS_PITCH_ERROR_METADATA is asserted thrown by at least one test

Scenario: Too-short input throws AUDIO_ANALYSIS_PITCH.INSUFFICIENT_DATA

  • WHEN PitchDetector.analyze runs on a document shorter than one analysis window
  • THEN it throws AudioAnalysisError whose code === "AUDIO_ANALYSIS_PITCH.INSUFFICIENT_DATA"

Verified by: src/error-codes.test.ts::"every AUDIO_ANALYSIS code is reachable from a call site" AND src/detector/pitch-detector.test.ts::"throws AUDIO_ANALYSIS_PITCH.INSUFFICIENT_DATA for a too-short document"


Requirement: OpenTelemetry instrumentation

The package SHALL instrument its public entry points via @suaveplan/opentelemetry: a span per detector run (audio.analysis.pitch, audio.analysis.key, audio.analysis.chord), a counter audio_analysis_runs_total tagged with op, and histograms audio_analysis_duration_ms and audio_analysis_block_duration_ms tagged with op. 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 detector run records a span and increments the counter

  • WHEN PitchDetector.analyze runs with an exporter registered
  • THEN an audio.analysis.pitch span is recorded, audio_analysis_runs_total{op="pitch-detector"} is incremented, and audio_analysis_duration_ms observes the run

Verified by: tests/purity.test.ts::"importing the package mutates no global state" AND src/detector/pitch-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/pitch-detector, ./detector/key-detector, ./detector/chord-detector, ./algorithms/yin, ./algorithms/chromagram, ./algorithms/key-finding, ./algorithms/chord-matching) 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-pitch/algorithms/yin
  • THEN the import resolves to the YIN module without pulling the whole package barrel

Verified by: tests/exports.test.ts::"every feature module has a subpath export entry"