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, 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: 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
PitchResultcarriesop === "pitch-detector", apitchin Hz, amidiNote, aconfidencein[0, 1], andtimestamp/durationcovering the analysed range
Scenario: analyzeBlock is synchronous and non-mutating
- WHEN a caller calls
analyzeBlock(block, ctx)on a single block - THEN a
PitchResultis returned synchronously, the inputblockarrays are unmodified, and noAudioDocumentstate 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
analyzeruns on a synthesised 440 Hz sine at 48 kHz with the default"yin"algorithm - THEN
pitchis within ±1 Hz of440,midiNoteis69, andconfidenceis greater than0.9
Scenario: Silence yields no pitch
- WHEN
analyzeruns on a fully silent document - THEN
pitchis0,midiNoteis-1, andconfidenceis0
Scenario: Algorithm selection is honoured
- WHEN
analyzeis called with{ algorithm: "mcleod" }and again with{ algorithm: "cepstrum" }on the same 440 Hz tone - THEN each run estimates
pitchwithin ±2 Hz of440using 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
KeyResultcarriesop === "key-detector", akeystring, aconfidencein[0, 1], and achromagramFloat32Arrayof length12
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.analyzeruns on a document built from a C-major triad/scale with the default profile - THEN
keyequals"C major"andconfidenceis greater than0.6
Scenario: Key profile selection is honoured
- WHEN
analyzeis called with{ profile: "temperley" } - THEN the Temperley weighting is used to score the chromagram and the returned
keyreflects that profile
Scenario: Chord progression is time-ordered and smoothed
- WHEN
ChordDetector.analyzeruns on a document with a C-major then G-major passage - THEN
chordsis ordered by ascendingtimestamp, eachChord.root/qualitynames the matched chord, noChordis 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
analyzeStreamis called with an already-aborted signal - THEN it throws
AudioAnalysisErrorwhosecode === "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.ABORTEDat 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.analyzeis called with{ minFreq: -1 }or an unknownalgorithm - THEN it throws
AudioAnalysisErrorwhosecode === "AUDIO_ANALYSIS_PITCH.INVALID_OPTIONS"with the Zod issues incontext
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_METADATAis asserted thrown by at least one test
Scenario: Too-short input throws AUDIO_ANALYSIS_PITCH.INSUFFICIENT_DATA
- WHEN
PitchDetector.analyzeruns on a document shorter than one analysis window - THEN it throws
AudioAnalysisErrorwhosecode === "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.analyzeruns with an exporter registered - THEN an
audio.analysis.pitchspan is recorded,audio_analysis_runs_total{op="pitch-detector"}is incremented, andaudio_analysis_duration_msobserves 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"