Files
suaveplan/openspec/specs/audio-analysis-spectrum/spec.md
2026-05-27 23:29:53 +01:00

17 KiB

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"