Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,200 @@
# Design: enterprise-audio-analysis-spectrum
**Change ID**: enterprise-audio-analysis-spectrum
**Package**: `@suaveplan/audio-analysis-spectrum`
`@suaveplan/audio-analysis-spectrum` is a universal-tier analysis plugin in the `@suaveplan/audio-*`
family. It computes magnitude spectra, spectrograms, MFCCs, and the scalar spectral features
(centroid, rolloff, flux) that downstream packages — beat, pitch, and visualisation layers — build
on. This document carries the **package-specific design**: the algorithm rationale, code sketches,
file layout, integration notes, benchmarks, and the decisions behind them.
---
## 1. Relationship to the ecosystem architecture
The cross-cutting `@suaveplan/audio-*` architecture — the three-layer separation, the six plugin
slots, the zero-duplication rule, and the universal-tier purity constraint — lives in
`enterprise-audio-core/design.md` §1–§4 and is **referenced, not duplicated** here.
The only slot this package occupies is **Analysis Op (plugin slot 3)**: a sidecar processor that
reads an `AudioDocument` and emits features. It is *not* part of the render pipeline — it never
appears in a `CompiledPlan` or a `ProcessPipeline`. `SpectrumAnalyzer`, `Spectrogram`, and `MFCC`
each implement the `AnalysisOp` contract defined by `@suaveplan/audio-core` and consume the
`AudioDocument`, `BlockProcessor`, and `BlockContext` types from there.
---
## 2. Algorithm rationale
### 2.1. Real FFT (radix-2)
The FFT is the hot path for every analyzer, so it is hand-rolled rather than pulled from a
dependency: a portable radix-2 Cooley–Tukey decimation-in-time transform restricted to power-of-two
frame sizes (`256`–`32768`). Restricting to power-of-two keeps the implementation small and
branch-free and matches the natural `frameSize` choices for audio (`2048` default). Twiddle-factor
tables (`cos`/`sin` lookup) are precomputed once per `frameSize` and cached, so a multi-frame scan
allocates them exactly once. Because the input is real, only `frameSize / 2 + 1` bins are unique;
the analyzer keeps just that half-spectrum. Determinism is a hard requirement — the same input frame
always yields byte-identical magnitudes — which is what makes the property tests (sine → single
peak) stable.
The earlier draft mentioned delegating to a browser `AnalyserNode`. That path is **dropped from the
universal core**: `AnalyserNode` is a browser global and would break universal-tier purity. If a
browser engine wants hardware-accelerated FFT it injects an adapter; the package's own code stays
portable.
### 2.2. Windowing
Each frame is multiplied by a window before the FFT to suppress spectral leakage. Three windows
ship — Hann (default), Hamming, Blackman — selectable per call. Hann is the default because it is
the standard general-purpose choice for music analysis: a good main-lobe/side-lobe trade-off.
Window arrays are pure functions of `(type, size)` and are cached, so repeated same-shape frames
reuse one `Float32Array`.
```ts
function hann(size: number): Float32Array {
const w = new Float32Array(size)
for (let n = 0; n < size; n++) w[n] = 0.5 - 0.5 * Math.cos((2 * Math.PI * n) / (size - 1))
return w
}
```
### 2.3. Frame slicing
`SpectrumAnalyzer`, `Spectrogram`, and `MFCC` share one framing model: slide a `frameSize` window
over the document advancing by `hopSize`. The frame count for a document of length `L` is
`floor((L - frameSize) / hopSize) + 1`, and a document shorter than one frame yields an empty
result rather than throwing — callers analysing very short clips should not have to guard.
### 2.4. Spectral features
- **Centroid** — magnitude-weighted mean frequency; the "brightness" of the frame.
- **Rolloff** — the frequency below which 85% of the spectral energy lies.
- **Flux** — summed positive frame-to-frame magnitude difference; the raw onset signal that
`@suaveplan/audio-analysis-beat` consumes.
All three are cheap reductions over the magnitude spectrum already computed for the frame, so they
are produced as a by-product rather than a second pass.
### 2.5. Mel scale and the triangular filterbank
The mel scale is a perceptual frequency warping — roughly linear below 1 kHz, logarithmic above.
The filterbank is a set of `melBands` triangular filters evenly spaced on the mel scale across
`[minHz, maxHz]`; applying it to a magnitude spectrum collapses `frameSize / 2 + 1` FFT bins into
`melBands` perceptually-spaced energies. The filter matrix is a pure function of
`(frameSize, sampleRate, melBands, minHz, maxHz)` and is cached.
```ts
const hzToMel = (hz: number) => 2595 * Math.log10(1 + hz / 700)
const melToHz = (mel: number) => 700 * (10 ** (mel / 2595) - 1)
```
### 2.6. MFCC — DCT-II on the log mel-spectrum
MFCCs are the standard compact timbre descriptor. The pipeline per frame is: magnitude spectrum →
mel filterbank → natural log of mel energies (floored to avoid `log(0)`) → type-II DCT → keep the
first `coefficients` values (default `13`). The DCT decorrelates the log-mel vector so the low-order
coefficients capture the spectral envelope. `coefficients` must not exceed `melBands` — asking for
more cepstral coefficients than mel bands is meaningless and is rejected at the boundary.
---
## 3. Public surface and code sketch
```ts
class SpectrumAnalyzer implements AnalysisOp {
readonly name = 'spectrum-analyzer'
readonly description = 'Magnitude spectrum + spectral centroid/rolloff/flux'
async analyze(doc: AudioDocument, opts?: SpectrumAnalyzerOptions): Promise<SpectrumResult> {
const o = SpectrumAnalyzerOptionsSchema.parse(opts ?? {})
const win = window(o.window, o.frameSize)
let prevMag: Float32Array | undefined
// slide frames, fft(win · frame), reduce to bins + centroid/rolloff/flux
}
analyzeStream(doc: AudioDocument, opts?: SpectrumAnalyzerOptions): AsyncGenerator<SpectrumResult>
analyzeBlock(block: Float32Array[], ctx: BlockContext): SpectrumResult
}
```
`Spectrogram` and `MFCC` follow the same shape; `Spectrogram` keeps every per-frame magnitude
vector, `MFCC` runs the mel + log + DCT chain per frame. All option validation goes through the
Zod schemas in `*.schemas.ts`; all errors are `AudioError` instances carrying an
`AUDIO_ANALYSIS_*` code.
---
## 4. File layout
```text
packages/media/audio/audio-analysis-spectrum/
package.json, tsconfig.json, vitest.config.ts
src/
index.ts
error-codes.ts
analyzer/
spectrum-analyzer.ts (+ .md .types.ts .schemas.ts .test.ts)
spectrogram.ts (+ .md .types.ts .schemas.ts .test.ts)
mfcc.ts (+ .md .types.ts .schemas.ts .test.ts)
index.ts
algorithms/
fft.ts (+ .md .test.ts)
window.ts (+ .md .test.ts)
mel-scale.ts (+ .md .test.ts)
dct.ts (+ .md .test.ts)
index.ts
__benchmarks__/
spectrum.bench.ts
tests/
integration.test.ts property.test.ts purity.test.ts exports.test.ts
```
---
## 5. Integration with genesis packages
- **`@suaveplan/audio-core`** — supplies `AudioDocument` (the scan input), the `AnalysisOp`
contract, and `BlockProcessor` / `BlockContext` for `analyzeBlock`. The analyzers iterate the
document with `doc.stream()` / `doc.read()` rather than touching pages directly.
- **`@suaveplan/error`** — `AudioError` base class; this package registers the `AUDIO_ANALYSIS`
code namespace (`AUDIO_ANALYSIS_SPECTRUM.INVALID_OPTIONS`, `AUDIO_ANALYSIS_SPECTRUM.INVALID_FRAME`,
`AUDIO_ANALYSIS_SPECTRUM.ABORTED`).
- **`@suaveplan/opentelemetry`** — spans, counters, and histograms (`audio.analysis.runs_total`,
`audio.analysis.duration_ms`, `audio.analysis.block_duration_ms`); inert with no exporter.
- **`@suaveplan/types`** — `LoggerLike` for optional injected logging.
Downstream, `@suaveplan/audio-analysis-beat` consumes the spectral-flux signal, and
`@suaveplan/audio-analysis-pitch` builds its chromagram from the magnitude spectrum — but those are
separate changes; this package exposes the surface, it does not import them.
---
## 6. Benchmarks
- `spectrum.bench.ts` — spectrogram of a 1-minute 48 kHz track at `frameSize` `2048` / `hopSize`
`512`, and MFCC of the same track at `13` coefficients / `40` mel bands. Establishes the
per-frame FFT + reduction cost and the mel + DCT overhead.
---
## 7. Decisions and trade-offs
- **No `AnalyserNode` in the universal core.** The draft suggested delegating FFT to the browser's
`AnalyserNode`. That is a browser global and breaks universal-tier purity, so the package ships
one portable radix-2 FFT used everywhere. A browser engine wanting hardware acceleration injects
an adapter; the universal path never references a platform global.
- **Power-of-two frame sizes only.** Restricting `frameSize` to `256`–`32768` powers of two keeps
the FFT small, branch-free, and deterministic, and matches the natural audio frame sizes. A
non-power-of-two request is a boundary error (`AUDIO_ANALYSIS_SPECTRUM.INVALID_FRAME`), not a slow
Bluestein fallback.
- **Three analyzers, one framing model.** `SpectrumAnalyzer`, `Spectrogram`, and `MFCC` share frame
slicing, windowing, and the FFT; they differ only in the reduction. Splitting them into three
`AnalysisOp` classes (rather than one mega-analyzer with flags) keeps each registerable
independently and keeps each result type tight.
- **Cached pure tables.** Window arrays, FFT twiddle factors, and mel filterbank matrices are all
pure functions of their shape parameters and are cached. A long scan allocates each exactly once;
this is the single biggest constant-factor win and is why the benchmark targets a full minute.
- **Features as a by-product.** Centroid, rolloff, and flux are computed from the magnitude
spectrum already in hand, not in a second pass — keeping `SpectrumAnalyzer` a single-pass scan.

View File

@@ -0,0 +1,164 @@
# OpenSpec Change: enterprise-audio-analysis-spectrum
- **Change ID**: enterprise-audio-analysis-spectrum
- **Package**: `@suaveplan/audio-analysis-spectrum`
- **Category**: universal
- **Target path**: `packages/media/audio/audio-analysis-spectrum`
- **Status**: PROPOSED
- **Date**: 2026-05-14
- **Design**: `./design.md`
- **Spec delta**: `./specs/audio-analysis-spectrum/spec.md`
---
## Summary
Deliver `@suaveplan/audio-analysis-spectrum` on the genesis branch from scratch at enterprise quality. Spectrum analysis plugin for the universal audio analysis framework. Computes magnitude spectra, spectrograms, MFCCs, spectral centroid, rolloff, and flux. Powers visualizations, feature extraction, and downstream analysis (beat, pitch, loudness).
Runs identically in browser and server via `BlockProcessor` or direct stream iteration.
---
## Current-state audit (genesis)
The package does not exist on genesis yet — this is a fresh build. No prior source, no test results, no coverage data.
---
## Available workspace deps on genesis
| Package | Role |
|---|---|
| `@suaveplan/audio-core` (`workspace:^`) | `AudioDocument`, `BlockProcessor`, `BlockContext` |
| `@suaveplan/error` (`workspace:^`) | `AudioError` base class; `AUDIO_ANALYSIS.*` codes |
| `@suaveplan/types` (`workspace:^`) | `LoggerLike` |
| `@suaveplan/opentelemetry` (`workspace:^`) | OTel spans, counters, histograms |
| `zod` (`catalog:forms`) | Zod schemas for option types |
Dev deps: `vitest`, `@vitest/coverage-istanbul`, `fast-check`, `mitata` (all `catalog:testing`).
---
## Public API
### Core types
```ts
interface SpectrumAnalyzerOptions {
bins?: number // default 128
frameSize?: number // default 2048
hopSize?: number // default 512
window?: 'hann' | 'hamming' | 'blackman' // default 'hann'
}
interface SpectrumResult extends AnalysisResult {
spectrum: Float32Array // magnitude per bin
centroid: number
rolloff: number
flux: number
}
interface SpectrogramResult extends AnalysisResult {
spectrogram: Float32Array[] // time-frequency matrix
}
interface MFCCResult extends AnalysisResult {
mfcc: Float32Array[] // MFCC coefficients per frame
}
class SpectrumAnalyzer implements AnalysisOp {
readonly name = 'spectrum-analyzer'
analyze(doc: AudioDocument, opts?: SpectrumAnalyzerOptions): Promise<SpectrumResult>
analyzeStream(doc: AudioDocument, opts?: SpectrumAnalyzerOptions): AsyncGenerator<SpectrumResult>
analyzeBlock(block: Float32Array[], ctx: BlockContext): SpectrumResult
}
class Spectrogram implements AnalysisOp {
readonly name = 'spectrogram'
analyze(doc: AudioDocument, opts?: SpectrumAnalyzerOptions): Promise<SpectrogramResult>
analyzeStream(doc: AudioDocument, opts?: SpectrumAnalyzerOptions): AsyncGenerator<SpectrogramResult>
}
class MFCC implements AnalysisOp {
readonly name = 'mfcc'
analyze(doc: AudioDocument, opts?: SpectrumAnalyzerOptions): Promise<MFCCResult>
analyzeStream(doc: AudioDocument, opts?: SpectrumAnalyzerOptions): AsyncGenerator<MFCCResult>
}
```
---
## New additions for genesis
1. **Real FFT** — hand-rolled real FFT for universal use; on browser can delegate to `AnalyserNode` if available.
2. **Mel-spectrum** — triangular mel-scale filterbank with configurable bin count.
3. **MFCC** — DCT-II on log mel-spectrum, configurable coefficient count.
4. **Spectral features** — centroid, rolloff, flux computed per frame.
5. **Streaming analysis** — `analyzeStream()` yields per-frame results for real-time visualization.
6. **OTel instrumentation** — spans, counters, histograms per analyzer.
7. **Property-based tests** — sine wave → single peak, white noise → flat spectrum.
8. **Benchmark suite** — spectrogram 1-minute track at 2048/512.
---
## File layout
```text
packages/media/audio/audio-analysis-spectrum/
package.json
tsconfig.json
vitest.config.ts
src/
index.ts
error-codes.ts
analyzer/
spectrum-analyzer.ts
spectrum-analyzer.md
spectrum-analyzer.types.ts
spectrum-analyzer.test.ts
spectrogram.ts
spectrogram.md
spectrogram.types.ts
spectrogram.test.ts
mfcc.ts
mfcc.md
mfcc.types.ts
mfcc.test.ts
index.ts
algorithms/
fft.ts
fft.md
fft.test.ts
mel-scale.ts
mel-scale.md
mel-scale.test.ts
window.ts
window.md
window.test.ts
dct.ts
dct.md
dct.test.ts
index.ts
__benchmarks__/
spectrum.bench.ts
tests/
integration.test.ts
property.test.ts
```
---
## Impact
**Breaking changes**: none — fresh build, package does not yet exist on genesis.
**Prerequisites**: `@suaveplan/audio-core`, `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/opentelemetry` published to Verdaccio.
### Affected dependents
`@suaveplan/audio-analysis-spectrum` is a leaf analysis plugin: No dependents in this batch
(verified by `bunx turbo ls --filter=...@suaveplan/audio-analysis-spectrum`). Consumed at runtime
by application/visualization code registering it as an analysis op. Sibling analysis packages that
will read its surface in later waves (`@suaveplan/audio-analysis-beat` for the spectral-flux signal,
`@suaveplan/audio-analysis-pitch` for the chromagram) do not exist on genesis yet and are each
created against this package's spec delta, not retrofitted.

View 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 -->

View File

@@ -0,0 +1,73 @@
# Tasks: enterprise-audio-analysis-spectrum
**Change ID**: enterprise-audio-analysis-spectrum
**Package**: `@suaveplan/audio-analysis-spectrum`
**Target path**: `packages/media/audio/audio-analysis-spectrum`
**Prerequisite packages on Verdaccio**: `@suaveplan/audio-core`, `@suaveplan/error`, `@suaveplan/types`
---
## 1. Scaffold
- [ ] Create directory `packages/media/audio/audio-analysis-spectrum/` with `src/`, `tests/`, `src/__benchmarks__/`, and all feature subdirectories per the layout in `proposal.md`.
- [ ] Write `package.json` at version `0.1.0` with `"type": "module"`, `"sideEffects": false`, full `exports` map, deps: `@suaveplan/audio-core workspace:^`, `@suaveplan/error workspace:^`, `@suaveplan/types workspace:^`, `@suaveplan/opentelemetry workspace:^`, `zod catalog:forms`.
- [ ] Write `tsconfig.json` extending `@suaveplan/typescript-config/library.json`.
- [ ] Write `vitest.config.ts` using `createTestConfig()` from `@suaveplan/testing/config`.
- [ ] Add `lint`, `typecheck`, `test`, `test:coverage`, `build`, `docs:check` scripts.
---
## 2. Types + errors
- [ ] Write `src/error-codes.ts` — `SPECTRUMErrorCode`, `AUDIO_ANALYSIS_SPECTRUM_ERROR_METADATA`, `registerAudioAnalysisErrorCodes()`.
- [ ] Write Zod schemas: `SpectrumAnalyzerOptionsSchema`, `SpectrumResultSchema`, `SpectrogramResultSchema`, `MFCCResultSchema`.
---
## 3. Algorithms
- [ ] Implement `fft.ts` — real FFT with configurable frame size.
- [ ] Write `fft.test.ts` and `fft.md` (≥200 words).
- [ ] Implement `window.ts` — Hann, Hamming, Blackman window functions.
- [ ] Write `window.test.ts` and `window.md` (≥200 words).
- [ ] Implement `mel-scale.ts` — mel-frequency mapping and triangular filterbank.
- [ ] Write `mel-scale.test.ts` and `mel-scale.md` (≥200 words).
- [ ] Implement `dct.ts` — DCT-II for MFCC computation.
- [ ] Write `dct.test.ts` and `dct.md` (≥200 words).
---
## 4. Analyzers
- [ ] Implement `SpectrumAnalyzer` class with `analyze()`, `analyzeStream()`, `analyzeBlock()`.
- [ ] Write `spectrum-analyzer.test.ts` and `spectrum-analyzer.md` (≥200 words).
- [ ] Implement `Spectrogram` class with `analyze()`, `analyzeStream()`.
- [ ] Write `spectrogram.test.ts` and `spectrogram.md` (≥200 words).
- [ ] Implement `MFCC` class with `analyze()`, `analyzeStream()`.
- [ ] Write `mfcc.test.ts` and `mfcc.md` (≥200 words).
- [ ] Wire OTel: spans, counters, histograms per analyzer.
---
## 5. Benchmarks
- [ ] Write `src/__benchmarks__/spectrum.bench.ts` — spectrogram 1-minute track, MFCC 1-minute track.
---
## 6. Tests
- [ ] Write `tests/integration.test.ts` — full spectrum/spectrogram/MFCC round-trip on real audio fixture.
- [ ] Write `tests/property.test.ts` (fast-check) — sine wave → single peak, white noise → flat spectrum.
---
## 7. Docs + verify + publish
- [ ] All co-located `.md` files (≥200 words each).
- [ ] Write `README.md` at package root.
- [ ] `bunx turbo lint typecheck test --filter=@suaveplan/audio-analysis-spectrum` — green.
- [ ] `bunx turbo test:coverage --filter=@suaveplan/audio-analysis-spectrum` — 100%.
- [ ] `bun scripts/publish.ts --package @suaveplan/audio-analysis-spectrum`.
- [ ] Fold `specs/audio-analysis-spectrum/spec.md` into `openspec/specs/audio-analysis-spectrum/spec.md`.
- [ ] Archive to `openspec/archive/2026-05-14-enterprise-audio-analysis-spectrum/`.