# 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.` 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"`