# 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` aggregated over the whole document; `analyzeStream` SHALL be an `AsyncGenerator` 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` and `analyzeStream` SHALL be an `AsyncGenerator` 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` 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"`