Initial Commit
This commit is contained in:
183
openspec/specs/audio-analysis-pitch/spec.md
Normal file
183
openspec/specs/audio-analysis-pitch/spec.md
Normal file
@@ -0,0 +1,183 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# 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"`
|
||||
Reference in New Issue
Block a user