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, 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: 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(), andnew MFCC()toAnalysisOpslots - THEN each exposes
name,description,analyze,analyzeStream, andanalyzeBlock, andanalyzeresolves to a value extendingAnalysisResult
Scenario: Analysis leaves the document unchanged
- WHEN
SpectrumAnalyzer.analyze(doc)completes - THEN
doc.versionanddoc.editsare 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 within1e-6, the endpoints are0, and the centre is1
Scenario: Unknown window name is rejected
- WHEN
analyzeis called withwindowset to a value outside'hann' | 'hamming' | 'blackman' - THEN it throws
AudioAnalysisSpectrumErrorwhosecode === "AUDIO_ANALYSIS_SPECTRUM.INVALID_OPTIONS"with the Zod issues incontext
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
fftis asked to transform a frame whose size is not a power of two - THEN it throws
AudioAnalysisSpectrumErrorwhosecode === "AUDIO_ANALYSIS_SPECTRUM.INVALID_FRAME"with the offending size incontext
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
analyzeruns on a document containing a 1 kHz sine at 48 kHz - THEN
result.centroidis within one bin width of 1 kHz andresult.spectrumhas its peak at the corresponding bin
Scenario: analyzeBlock yields a SpectrumResult for one block
- WHEN
analyzeBlockis called with a singleFloat32Array[]block and aBlockContext - THEN it returns a
SpectrumResultwhoseop === "spectrum-analyzer"and whosespectrumlength equals the configuredbins
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
analyzeruns on a document of known length withframeSize2048andhopSize512 - THEN
result.spectrogram.length === floor((doc.length - 2048) / 512) + 1
Scenario: Document shorter than one frame yields an empty spectrogram
- WHEN
analyzeruns on a document with fewer thanframeSizesamples - THEN
result.spectrogramis 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
Float32Arrayof length40with 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
dctmodule 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.analyzeruns on a multi-frame document withcoefficients13 - THEN
result.mfcc.lengthequals the spectrogram frame count and every vector has length13
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)'sspectrogram
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
analyzeis called with an already-aborted signal - THEN it throws
AudioAnalysisSpectrumErrorwhosecode === "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.ABORTEDat 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.analyzeis called with a negativehopSize - THEN it throws
AudioAnalysisSpectrumErrorwhosecode === "AUDIO_ANALYSIS_SPECTRUM.INVALID_OPTIONS"with the Zod issues incontext
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_METADATAis 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.analyzeruns against a real in-memoryMeterProvider - THEN
audio.analysis.runs_total{op="spectrum-analyzer"}increments by 1 andaudio.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"