Initial Commit
This commit is contained in:
242
openspec/specs/audio-analysis-spectrum/spec.md
Normal file
242
openspec/specs/audio-analysis-spectrum/spec.md
Normal file
@@ -0,0 +1,242 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: audio-analysis-spectrum (audio-analysis-spectrum)
|
||||
|
||||
**Package**: `@suaveplan/audio-analysis-spectrum`
|
||||
**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-spectrum` package SHALL import zero browser-only APIs (`window`, `document`, `navigator`, `AudioContext`, `AnalyserNode`, `AudioBuffer`) and zero Node-only APIs (`node:fs`, `node:path`, `process`, `Buffer`). FFT, windowing, mel-filterbank, and DCT SHALL be implemented in portable TypeScript over `Float32Array`; any platform-accelerated path (e.g. a browser `AnalyserNode`) SHALL be reached only through an injected adapter, never a direct global reference. The package's runtime `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: AnalysisOp conformance
|
||||
|
||||
`SpectrumAnalyzer`, `Spectrogram`, and `MFCC` SHALL each implement the `AnalysisOp` contract from `@suaveplan/audio-core` (plugin slot 3): a readonly `name` (`"spectrum-analyzer"`, `"spectrogram"`, `"mfcc"` respectively), a readonly `description`, an async `analyze(doc, opts?)`, an `analyzeStream(doc, opts?)` async generator, and a synchronous `analyzeBlock(block, ctx)`. Every emitted result SHALL extend `AnalysisResult` — carrying `op`, `timestamp`, `duration`, `features`, and optional `confidence` — so the three analyzers register interchangeably as analysis ops. Analysis SHALL run as a sidecar scan and SHALL NOT mutate the input `AudioDocument` or its edit list.
|
||||
|
||||
#### Scenario: Each analyzer registers as an AnalysisOp
|
||||
|
||||
- **WHEN** a caller assigns `new SpectrumAnalyzer()`, `new Spectrogram()`, and `new MFCC()` to `AnalysisOp` slots
|
||||
- **THEN** each exposes `name`, `description`, `analyze`, `analyzeStream`, and `analyzeBlock`, and `analyze` resolves to a value extending `AnalysisResult`
|
||||
|
||||
#### Scenario: Analysis leaves the document unchanged
|
||||
|
||||
- **WHEN** `SpectrumAnalyzer.analyze(doc)` completes
|
||||
- **THEN** `doc.version` and `doc.edits` are unchanged from before the call
|
||||
|
||||
**Verified by:** `src/analyzer/spectrum-analyzer.test.ts::"implements the AnalysisOp contract"` AND `tests/integration.test.ts::"analysis does not mutate the source document"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Window functions
|
||||
|
||||
The package SHALL export a `window` module providing Hann, Hamming, and Blackman window generators selectable via the `window` option (`'hann'` default, `'hamming'`, `'blackman'`). A generator SHALL return a `Float32Array` of the requested frame size whose coefficients match the closed-form definition of the window to within `1e-6`, and SHALL be applied multiplicatively to each frame before the FFT. Window arrays SHALL be cached per `(type, size)` so repeated frames of the same shape reuse one allocation.
|
||||
|
||||
#### Scenario: Hann window matches its closed form
|
||||
|
||||
- **WHEN** a caller requests a Hann window of size `2048`
|
||||
- **THEN** every coefficient equals `0.5 - 0.5 * cos(2πn / (N-1))` to within `1e-6`, the endpoints are `0`, and the centre is `1`
|
||||
|
||||
#### Scenario: Unknown window name is rejected
|
||||
|
||||
- **WHEN** `analyze` is called with `window` set to a value outside `'hann' | 'hamming' | 'blackman'`
|
||||
- **THEN** it throws `AudioAnalysisSpectrumError` whose `code === "AUDIO_ANALYSIS_SPECTRUM.INVALID_OPTIONS"` with the Zod issues in `context`
|
||||
|
||||
**Verified by:** `src/algorithms/window.test.ts::"Hann window matches the closed form"` AND `src/analyzer/spectrum-analyzer.test.ts::"rejects an unknown window name with AUDIO_ANALYSIS_SPECTRUM.INVALID_OPTIONS"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Real FFT over power-of-two frames
|
||||
|
||||
The package SHALL export an `fft` module computing the real-input FFT of a windowed frame, returning per-bin magnitude (and phase where requested) for `frameSize / 2 + 1` bins. `frameSize` SHALL be a power of two between `256` and `32768` inclusive; a non-power-of-two or out-of-range `frameSize` SHALL throw `AudioAnalysisSpectrumError` with code `AUDIO_ANALYSIS_SPECTRUM.INVALID_FRAME`. The transform SHALL be deterministic — the same input frame SHALL always yield byte-identical magnitude output — and SHALL allocate its twiddle-factor tables once per `frameSize`.
|
||||
|
||||
#### Scenario: Single-frequency input produces a single magnitude peak
|
||||
|
||||
- **WHEN** a frame containing a pure sinusoid at a bin-centre frequency is transformed
|
||||
- **THEN** the magnitude spectrum has one dominant bin at that frequency and all other bins are near zero
|
||||
|
||||
#### Scenario: Non-power-of-two frame size is rejected
|
||||
|
||||
- **WHEN** `fft` is asked to transform a frame whose size is not a power of two
|
||||
- **THEN** it throws `AudioAnalysisSpectrumError` whose `code === "AUDIO_ANALYSIS_SPECTRUM.INVALID_FRAME"` with the offending size in `context`
|
||||
|
||||
**Verified by:** `src/algorithms/fft.test.ts::"pure sinusoid yields a single magnitude peak"` AND `src/algorithms/fft.test.ts::"rejects a non-power-of-two frame size with AUDIO_ANALYSIS_SPECTRUM.INVALID_FRAME"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: SpectrumAnalyzer magnitude spectrum and spectral features
|
||||
|
||||
`SpectrumAnalyzer.analyze(doc, opts?)` SHALL slice `doc` into overlapping frames of `frameSize` samples advanced by `hopSize`, apply the configured window, run the real FFT, and reduce the per-bin magnitudes into a `SpectrumResult` carrying a `spectrum: Float32Array` of `bins` magnitude values plus the scalar spectral features `centroid`, `rolloff`, and `flux`. `bins` (default `128`), `frameSize` (default `2048`), and `hopSize` (default `512`) SHALL be caller-configurable. `centroid` SHALL be the magnitude-weighted mean frequency, `rolloff` the frequency below which 85% of spectral energy lies, and `flux` the summed positive frame-to-frame magnitude difference. `analyzeBlock(block, ctx)` SHALL compute the same `SpectrumResult` for a single real-time block using `ctx.sampleRate`.
|
||||
|
||||
#### Scenario: Sine input centroid tracks the tone frequency
|
||||
|
||||
- **WHEN** `analyze` runs on a document containing a 1 kHz sine at 48 kHz
|
||||
- **THEN** `result.centroid` is within one bin width of 1 kHz and `result.spectrum` has its peak at the corresponding bin
|
||||
|
||||
#### Scenario: analyzeBlock yields a SpectrumResult for one block
|
||||
|
||||
- **WHEN** `analyzeBlock` is called with a single `Float32Array[]` block and a `BlockContext`
|
||||
- **THEN** it returns a `SpectrumResult` whose `op === "spectrum-analyzer"` and whose `spectrum` length equals the configured `bins`
|
||||
|
||||
**Verified by:** `src/analyzer/spectrum-analyzer.test.ts::"centroid tracks a pure tone frequency"` AND `src/analyzer/spectrum-analyzer.test.ts::"analyzeBlock returns a SpectrumResult for a single block"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Spectrogram time-frequency matrix
|
||||
|
||||
`Spectrogram.analyze(doc, opts?)` SHALL produce a `SpectrogramResult` whose `spectrogram: Float32Array[]` is the ordered list of per-frame magnitude spectra computed with the same `frameSize`/`hopSize`/`window` options as `SpectrumAnalyzer`. The number of frames SHALL equal `floor((doc.length - frameSize) / hopSize) + 1` for a document at least `frameSize` samples long, and each frame vector SHALL have `frameSize / 2 + 1` magnitude values. A document shorter than `frameSize` SHALL yield an empty `spectrogram` array rather than throwing.
|
||||
|
||||
#### Scenario: Frame count matches the hop arithmetic
|
||||
|
||||
- **WHEN** `analyze` runs on a document of known length with `frameSize` `2048` and `hopSize` `512`
|
||||
- **THEN** `result.spectrogram.length === floor((doc.length - 2048) / 512) + 1`
|
||||
|
||||
#### Scenario: Document shorter than one frame yields an empty spectrogram
|
||||
|
||||
- **WHEN** `analyze` runs on a document with fewer than `frameSize` samples
|
||||
- **THEN** `result.spectrogram` is an empty array and no error is thrown
|
||||
|
||||
**Verified by:** `src/analyzer/spectrogram.test.ts::"frame count matches the hop arithmetic"` AND `src/analyzer/spectrogram.test.ts::"short document yields an empty spectrogram"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Mel filterbank
|
||||
|
||||
The package SHALL export a `mel-scale` module providing a hertz-to-mel mapping, its inverse, and a triangular mel filterbank builder. The builder SHALL produce `melBands` triangular filters evenly spaced on the mel scale across a `[minHz, maxHz]` range, each filter normalised so its triangular weights sum consistently, returned as a reusable matrix keyed by `(frameSize, sampleRate, melBands, minHz, maxHz)`. Applying the filterbank to a magnitude spectrum SHALL yield a `melBands`-length mel-spectrum vector. `melBands` SHALL be a positive integer; a non-positive value SHALL throw `AudioAnalysisSpectrumError` with code `AUDIO_ANALYSIS_SPECTRUM.INVALID_OPTIONS`.
|
||||
|
||||
#### Scenario: Filterbank maps a spectrum to the mel band count
|
||||
|
||||
- **WHEN** a 1025-bin magnitude spectrum is passed through a 40-band mel filterbank
|
||||
- **THEN** the result is a `Float32Array` of length `40` with non-negative entries
|
||||
|
||||
#### Scenario: Mel mapping round-trips
|
||||
|
||||
- **WHEN** a frequency is converted hertz-to-mel and back
|
||||
- **THEN** the recovered frequency equals the original to within `1e-3`
|
||||
|
||||
**Verified by:** `src/algorithms/mel-scale.test.ts::"filterbank maps a spectrum to the mel band count"` AND `src/algorithms/mel-scale.test.ts::"hertz-to-mel mapping round-trips"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: MFCC via DCT-II on the log mel-spectrum
|
||||
|
||||
The package SHALL export a `dct` module computing the type-II DCT, and an `MFCC` analyzer that, per frame, builds the magnitude spectrum, applies the mel filterbank, takes the natural log of the mel energies (floored to avoid `log(0)`), and runs DCT-II to produce `coefficients` cepstral values. `MFCC.analyze(doc, opts?)` SHALL return an `MFCCResult` whose `mfcc: Float32Array[]` holds one coefficient vector per frame. `coefficients` SHALL default to `13` and SHALL be a positive integer no greater than `melBands`; a value exceeding `melBands` SHALL throw `AudioAnalysisSpectrumError` with code `AUDIO_ANALYSIS_SPECTRUM.INVALID_OPTIONS`.
|
||||
|
||||
#### Scenario: DCT-II matches its closed form
|
||||
|
||||
- **WHEN** the `dct` module transforms a known input vector
|
||||
- **THEN** the output equals the closed-form type-II DCT of that vector to within `1e-6`
|
||||
|
||||
#### Scenario: MFCC yields one coefficient vector per frame
|
||||
|
||||
- **WHEN** `MFCC.analyze` runs on a multi-frame document with `coefficients` `13`
|
||||
- **THEN** `result.mfcc.length` equals the spectrogram frame count and every vector has length `13`
|
||||
|
||||
**Verified by:** `src/algorithms/dct.test.ts::"DCT-II matches the closed form"` AND `src/analyzer/mfcc.test.ts::"produces one 13-coefficient vector per frame"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Streaming per-frame analysis
|
||||
|
||||
`SpectrumAnalyzer.analyzeStream`, `Spectrogram.analyzeStream`, and `MFCC.analyzeStream` SHALL each be async generators that consume `doc` block-by-block and yield a partial `AnalysisResult` per completed frame, so a visualiser can update in real time before the full scan finishes. The concatenation of streamed frame results SHALL be equivalent to the single-shot `analyze` output for the same document and options — same frame count, same per-frame vectors.
|
||||
|
||||
#### Scenario: Streamed frames equal the single-shot result
|
||||
|
||||
- **WHEN** a caller fully drains `Spectrogram.analyzeStream(doc, opts)` and collects every yielded frame
|
||||
- **THEN** the collected frames are deep-equal to `Spectrogram.analyze(doc, opts)`'s `spectrogram`
|
||||
|
||||
**Verified by:** `src/analyzer/spectrogram.test.ts::"analyzeStream frames equal the single-shot analyze result"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: AbortSignal propagation
|
||||
|
||||
`analyze` and `analyzeStream` on all three analyzers SHALL accept an `AbortSignal` via their options object. An already-aborted signal SHALL cause the method to throw `AudioAnalysisSpectrumError` with code `AUDIO_ANALYSIS_SPECTRUM.ABORTED` before any frame work; a signal aborted mid-scan SHALL terminate the generator at the next frame boundary with the same error and yield nothing further.
|
||||
|
||||
#### Scenario: Pre-aborted signal short-circuits the scan
|
||||
|
||||
- **WHEN** `analyze` is called with an already-aborted signal
|
||||
- **THEN** it throws `AudioAnalysisSpectrumError` whose `code === "AUDIO_ANALYSIS_SPECTRUM.ABORTED"` before processing any frame
|
||||
|
||||
#### Scenario: Mid-scan abort terminates at the next frame boundary
|
||||
|
||||
- **WHEN** a signal is aborted after the first streamed frame is yielded
|
||||
- **THEN** the generator throws `AUDIO_ANALYSIS_SPECTRUM.ABORTED` at the next frame boundary and yields nothing further
|
||||
|
||||
**Verified by:** `src/analyzer/spectrum-analyzer.test.ts::"analyze throws AUDIO_ANALYSIS_SPECTRUM.ABORTED for a pre-aborted signal"` AND `src/analyzer/spectrogram.test.ts::"analyzeStream honours mid-scan abort"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Zod boundary validation
|
||||
|
||||
Every public options object — `SpectrumAnalyzerOptions` — and every emitted result type — `SpectrumResult`, `SpectrogramResult`, `MFCCResult` — SHALL have a Zod schema exported from a sibling `*.schemas.ts`, and every public entry point (`analyze`, `analyzeStream`, `analyzeBlock`) SHALL validate its options argument against the schema before use. A validation failure SHALL throw `AudioAnalysisSpectrumError` with code `AUDIO_ANALYSIS_SPECTRUM.INVALID_OPTIONS` carrying the Zod issue list in `context`.
|
||||
|
||||
#### Scenario: Invalid options are rejected at the boundary
|
||||
|
||||
- **WHEN** `SpectrumAnalyzer.analyze` is called with a negative `hopSize`
|
||||
- **THEN** it throws `AudioAnalysisSpectrumError` whose `code === "AUDIO_ANALYSIS_SPECTRUM.INVALID_OPTIONS"` with the Zod issues in `context`
|
||||
|
||||
**Verified by:** `src/analyzer/spectrum-analyzer.test.ts::"rejects invalid options with AUDIO_ANALYSIS_SPECTRUM.INVALID_OPTIONS"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Structured error codes
|
||||
|
||||
The package SHALL export `AudioAnalysisSpectrumErrorCode`, `AUDIO_ANALYSIS_SPECTRUM_ERROR_METADATA`, and `registerAudioAnalysisSpectrumErrorCodes()`, registering the codes `AUDIO_ANALYSIS_SPECTRUM.INVALID_OPTIONS`, `AUDIO_ANALYSIS_SPECTRUM.INVALID_FRAME`, and `AUDIO_ANALYSIS_SPECTRUM.ABORTED` with `@suaveplan/error`. Every error thrown by the package SHALL be an `AudioAnalysisSpectrumError` extending `SuaveplanError` and carry one of these codes, `cause`, and `context`. 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_SPECTRUM_ERROR_METADATA` is asserted thrown by at least one test
|
||||
|
||||
**Verified by:** `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.<op>` per `analyze`/`analyzeStream` invocation; a counter `audio.analysis.runs_total{op,format}`; a histogram `audio.analysis.duration_ms{op}`; and a histogram `audio.analysis.block_duration_ms{op}` for `analyzeBlock`. 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 completed analyze records a span and duration histogram
|
||||
|
||||
- **WHEN** `SpectrumAnalyzer.analyze` runs against a real in-memory `MeterProvider`
|
||||
- **THEN** `audio.analysis.runs_total{op="spectrum-analyzer"}` increments by 1 and `audio.analysis.duration_ms{op="spectrum-analyzer"}` records a single observation
|
||||
|
||||
**Verified by:** `src/analyzer/spectrum-analyzer.test.ts::"records a span and duration histogram when an exporter is registered"` AND `tests/purity.test.ts::"importing the package mutates no global state"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Subpath exports per feature module
|
||||
|
||||
`package.json` SHALL declare one `exports` entry per feature module (`./analyzer/spectrum-analyzer`, `./analyzer/spectrogram`, `./analyzer/mfcc`, `./algorithms/fft`, `./algorithms/window`, `./algorithms/mel-scale`, `./algorithms/dct`) 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-spectrum/algorithms/fft`
|
||||
- **THEN** the import resolves to the FFT module without pulling the whole package barrel
|
||||
|
||||
**Verified by:** `tests/exports.test.ts::"every feature module has a subpath export entry"`
|
||||
|
||||
<!-- markdownlint-enable MD024 -->
|
||||
Reference in New Issue
Block a user