Initial Commit
This commit is contained in:
382
openspec/specs/audio-core/spec.md
Normal file
382
openspec/specs/audio-core/spec.md
Normal file
@@ -0,0 +1,382 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: audio-core (audio-core)
|
||||
|
||||
**Package**: `@suaveplan/audio-core`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Universal-tier purity
|
||||
|
||||
The `@suaveplan/audio-core` package SHALL import zero browser-only APIs (`window`, `document`, `navigator`, `AudioContext`, `AudioBuffer`) and zero Node-only APIs (`node:fs`, `node:path`, `process`, `Buffer`). All environment-specific behaviour SHALL be reached only through injected adapters. The package's `dependencies` SHALL be limited to `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/events`, `@suaveplan/streams`, `@suaveplan/serialize`, `tslib`, and `zod`; `@opentelemetry/api` is the only permitted non-workspace runtime dependency.
|
||||
|
||||
#### Scenario: Built dist references no platform globals
|
||||
|
||||
- **WHEN** the built `dist/` is grep'd
|
||||
- **THEN** it MUST NOT reference `window`, `document`, `navigator`, `AudioContext`, `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 and zod"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: AudioDocument paged container
|
||||
|
||||
The package SHALL export an `AudioDocument` class constructed from `AudioDocumentOptions`. It SHALL expose readonly `id`, `pages`, `sampleRate`, `channels`, and `sourceLength`, plus virtual `length` and `duration` getters whose cached values are invalidated by `version` bumps. PCM SHALL be stored as paged `AudioPage` records (`{ channelData: Float32Array[]; startSample: number }`); a `null` slot in the page array indicates an evicted page that can be reloaded. Construction with no `id` SHALL derive a stable id via the injected `IdGenerator`.
|
||||
|
||||
#### Scenario: Virtual length tracks edits without materialisation
|
||||
|
||||
- **WHEN** a caller appends a `trim` edit that halves the range
|
||||
- **THEN** `doc.length` reflects the trimmed sample count, `doc.version` has incremented, and no `AudioPage` has been allocated or copied
|
||||
|
||||
#### Scenario: Evicted page is represented as null
|
||||
|
||||
- **WHEN** a page is evicted from `doc.pages`
|
||||
- **THEN** the slot holds `null` and a subsequent read for that range triggers a reload rather than throwing
|
||||
|
||||
**Verified by:** `src/document/document.test.ts::"virtual length reflects edits with O(1) cost"` AND `src/page/page.test.ts::"evicted page slot is null and reloadable"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Non-destructive edit history
|
||||
|
||||
`AudioDocument.run(...edits)` SHALL append `AudioEdit` records, increment `version`, and emit an `edit` event. `undo(n?)` SHALL pop the last `n` edits (default 1), emit an `undo` event, and return the removed edit(s) or `null` when the history is empty. `clone()` SHALL produce a document with an independent `pages` array and `edits` array such that mutating one does not affect the other. Editing SHALL be O(1) in the number of samples — materialisation is deferred to `read()` / `stream()`.
|
||||
|
||||
#### Scenario: Run then undo restores edit state
|
||||
|
||||
- **WHEN** a caller runs two edits then calls `undo(2)`
|
||||
- **THEN** `doc.edits` is empty, `doc.version` has incremented twice more, and `undo` returned both removed edits
|
||||
|
||||
#### Scenario: Clone is structurally independent
|
||||
|
||||
- **WHEN** a caller clones a document and runs an edit on the clone
|
||||
- **THEN** the original's `edits` and `version` are unchanged
|
||||
|
||||
**Verified by:** `src/document/document.test.ts::"run appends and undo removes edits with events"` AND `src/document/document.test.ts::"clone produces an independent document"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Document serialization round-trip
|
||||
|
||||
`AudioDocument.toJSON()` SHALL return an `AudioDocumentJSON` value that, when passed through `@suaveplan/serialize`, round-trips exactly — including `Float32Array` page data, the `edits` array, `version`, `source`, and `metadata`. A document reconstructed from the deserialized JSON SHALL produce a `toJSON()` value deep-equal to the original's.
|
||||
|
||||
#### Scenario: toJSON survives serialize/deserialize
|
||||
|
||||
- **WHEN** a caller serializes `doc.toJSON()` and deserializes it back into an `AudioDocument`
|
||||
- **THEN** the reconstructed document's `toJSON()` is deep-equal to the original's
|
||||
|
||||
**Verified by:** `src/document/document.test.ts::"toJSON round-trips through @suaveplan/serialize"` AND `tests/property.test.ts::"clone toJSON equals original toJSON"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Typed document event surface
|
||||
|
||||
`AudioDocument` SHALL expose `on` / `off` methods typed against `AudioDocumentEventMap` with the topics `edit`, `undo`, `plugin:error`, and `page:loaded`, backed by `@suaveplan/events`. Listener registration and removal SHALL be idempotent — registering the same listener twice SHALL fire it once, and `off` for an unregistered listener SHALL be a no-op.
|
||||
|
||||
#### Scenario: Edit event carries the edit and version
|
||||
|
||||
- **WHEN** a listener is registered for `edit` and a caller runs an edit
|
||||
- **THEN** the listener is invoked once with `{ edit, version }` matching the appended edit and the new version
|
||||
|
||||
**Verified by:** `src/document/document.test.ts::"on/off manage typed listeners idempotently"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Paged memory primitives
|
||||
|
||||
The package SHALL export `PAGE_SIZE` (`1024 * 1024` samples) and `BLOCK_SIZE` (`1024` samples) constants, and the primitives `walkPages(doc, channel, startSample, count, visitor)`, `copyPages(doc, channel, startSample, count, target, targetOffset)`, and `readPages(doc, offset?, duration?)`. Pages SHALL be allocated from a reusable pool (`allocatePage` / `releasePage`) to reduce GC pressure during long streams. `walkPages` SHALL invoke the visitor once per overlapping page with the correct intra-page offset and length; `copyPages` SHALL write into a caller-supplied pre-allocated buffer without allocating.
|
||||
|
||||
#### Scenario: walkPages visits every overlapping page
|
||||
|
||||
- **WHEN** a caller calls `walkPages` over a range spanning three pages
|
||||
- **THEN** the visitor is invoked exactly three times with offsets and lengths that sum to the requested count
|
||||
|
||||
#### Scenario: Page pool reuses released buffers
|
||||
|
||||
- **WHEN** a page is released and a new page of the same shape is allocated
|
||||
- **THEN** the allocator returns the previously released `Float32Array[]` rather than allocating fresh memory
|
||||
|
||||
**Verified by:** `src/page/page.test.ts::"walkPages visits each overlapping page with correct offsets"` AND `src/page/page.test.ts::"page pool reuses released buffers"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Plan compilation
|
||||
|
||||
The package SHALL export a `PlanEngine` class constructed from an `OpRegistry`. `compile(doc, final?)` SHALL convert `doc.edits` into a `CompiledPlan` (`{ segs, pipeline, totalLen, sampleRate, limit }`) in two passes: a resolve pass that expands high-level edits into primitive edits via each op's `resolve` function, and a plan pass that transforms the `Segment[]` via each op's `plan` function. Ops without a `plan` function SHALL be appended to `pipeline` as process-stage ops. An edit whose `type` is not registered SHALL throw `AudioError` with code `AUDIO_CORE.PLUGIN_NOT_FOUND`. When `final` is false the engine SHALL track a `limit` marking the safe source boundary for incremental decode.
|
||||
|
||||
#### Scenario: Identity edit preserves total length
|
||||
|
||||
- **WHEN** a document with an identity edit (e.g. `gain` at unity) is compiled
|
||||
- **THEN** `plan.totalLen` equals `doc.sourceLength`
|
||||
|
||||
#### Scenario: Unknown op type throws AUDIO_CORE.PLUGIN_NOT_FOUND
|
||||
|
||||
- **WHEN** `compile` encounters an edit whose `type` is not in the registry
|
||||
- **THEN** it throws `AudioError` whose `code === "AUDIO_CORE.PLUGIN_NOT_FOUND"` with the offending `type` in `context`
|
||||
|
||||
#### Scenario: Compilation is deterministic
|
||||
|
||||
- **WHEN** the same document is compiled twice
|
||||
- **THEN** the two `CompiledPlan` values are deep-equal
|
||||
|
||||
**Verified by:** `src/plan/plan-engine.test.ts::"compile resolves and plans edits into a CompiledPlan"` AND `src/plan/plan-engine.test.ts::"compile throws AUDIO_PLUGIN_NOT_FOUND for unknown op"` AND `tests/property.test.ts::"plan compilation is deterministic"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Plan-driven read and stream
|
||||
|
||||
`PlanEngine.readPlan(doc, plan, offset?, duration?)` SHALL render a static range into `Float32Array[]` using the compiled segments. `streamPlan(doc, plan, opts?)` SHALL be an async generator yielding `Float32Array[]` blocks of at most `BLOCK_SIZE` samples that, summed, equal the requested range. `ensurePages(doc, plan, offset?, duration?)` SHALL preload any evicted pages a range requires before rendering. `AudioDocument.read()` and `AudioDocument.stream()` SHALL delegate to these methods. An out-of-range page request SHALL throw `AudioError` with code `AUDIO_CORE.PAGE_OUT_OF_RANGE`.
|
||||
|
||||
#### Scenario: Stream yields the exact total sample count
|
||||
|
||||
- **WHEN** a caller fully drains `streamPlan` for a known range
|
||||
- **THEN** the concatenated block lengths equal the requested sample count
|
||||
|
||||
#### Scenario: Out-of-range read throws AUDIO_CORE.PAGE_OUT_OF_RANGE
|
||||
|
||||
- **WHEN** `readPlan` is asked for a range beyond `plan.totalLen`
|
||||
- **THEN** it throws `AudioError` whose `code === "AUDIO_CORE.PAGE_OUT_OF_RANGE"`
|
||||
|
||||
**Verified by:** `src/plan/plan-engine.test.ts::"streamPlan yields the exact total sample count"` AND `src/plan/plan-engine.test.ts::"readPlan throws AUDIO_PAGE_OUT_OF_RANGE beyond plan length"` AND `tests/property.test.ts::"stream yields exact total sample count"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Block-level process pipeline
|
||||
|
||||
The package SHALL export a `ProcessPipeline` class constructed from an `OpRegistry`. `init(pipeline, totalDuration, sampleRate, channels)` SHALL allocate `ProcessState[]`. `patch(state, pipeline)` SHALL hot-swap edit parameter values without re-allocating buffers. `process(input, output, state, blockOffsetSamples, sampleRate)` SHALL run each process op in order over a single block. When the pipeline structure changes between blocks (ops added or removed, detected via a pipeline signature), the pipeline SHALL reinitialise affected processors and crossfade over approximately 20 ms to avoid clicks.
|
||||
|
||||
#### Scenario: patch updates parameters without re-allocation
|
||||
|
||||
- **WHEN** a caller calls `patch` with the same op set but changed parameter values
|
||||
- **THEN** the existing `ProcessState[]` buffers are reused and the new parameter values take effect on the next `process` call
|
||||
|
||||
#### Scenario: Structural change crossfades
|
||||
|
||||
- **WHEN** an op is added to the pipeline between two `process` calls
|
||||
- **THEN** the pipeline reinitialises and the output transitions over a crossfade window rather than discontinuously
|
||||
|
||||
**Verified by:** `src/process/process-pipeline.test.ts::"patch swaps parameters without re-allocating buffers"` AND `src/process/process-pipeline.test.ts::"structural pipeline change triggers a crossfade"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Process-op error isolation
|
||||
|
||||
When a process op throws inside `ProcessPipeline.process`, the pipeline SHALL NOT propagate the throw. It SHALL emit a `plugin:error` event carrying `{ plugin, error }` and copy the block's `input` channels into `output` unmodified, so a crashing op degrades to pass-through without corrupting the stream. Subsequent ops in the pipeline SHALL still run on the passed-through block.
|
||||
|
||||
#### Scenario: Crashing op degrades to pass-through
|
||||
|
||||
- **WHEN** a registered process op throws on a block
|
||||
- **THEN** a `plugin:error` event fires with the op name, `output` equals `input` for that op's stage, and the stream continues
|
||||
|
||||
**Verified by:** `src/process/process-pipeline.test.ts::"crashing process op emits plugin:error and passes input through"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Operation registry and the six plugin slots
|
||||
|
||||
The package SHALL export an `OpRegistry` class with `register(descriptor)`, `unregister(name)`, `get(name)`, `list()`, and a static `createWithDefaults()`. An `AudioOpDescriptor` SHALL carry a readonly `name` and optional `params`, `resolve`, `plan`, `process`, `sr`, and `ch` members. Registering a descriptor whose `name` is already registered SHALL replace the prior descriptor. `createWithDefaults()` SHALL return a registry pre-populated with every built-in op.
|
||||
|
||||
#### Scenario: register then get round-trips a descriptor
|
||||
|
||||
- **WHEN** a caller registers a descriptor and calls `get` with its name
|
||||
- **THEN** the same descriptor is returned, and it appears in `list()`
|
||||
|
||||
#### Scenario: createWithDefaults pre-registers every built-in op
|
||||
|
||||
- **WHEN** a caller calls `OpRegistry.createWithDefaults()`
|
||||
- **THEN** `get` resolves every built-in plan op and process op named in this spec
|
||||
|
||||
**Verified by:** `src/registry/op-registry.test.ts::"register/get/unregister/list manage descriptors"` AND `src/registry/op-registry.test.ts::"createWithDefaults pre-registers all built-in ops"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Built-in plan operations
|
||||
|
||||
The default registry SHALL ship these plan ops: `trim`, `crop`, `speed`, `stretch`, `reverse`, `insert`, `remove`, `repeat`, `pad`, and `resample`. Each SHALL transform the `Segment[]` so the compiled `totalLen`, `sampleRate`, or `channels` reflect the operation: `trim`/`crop`/`remove` shorten output, `repeat`/`pad`/`insert` lengthen it, `speed`/`stretch` change duration, `reverse` inverts segment order, and `resample` changes `sampleRate` via the op's `sr` transform.
|
||||
|
||||
#### Scenario: trim restricts the output range
|
||||
|
||||
- **WHEN** a `trim` edit selecting samples `[1000, 2000)` is compiled
|
||||
- **THEN** `plan.totalLen === 1000` and rendering yields the original samples from that range
|
||||
|
||||
#### Scenario: resample changes the plan sample rate
|
||||
|
||||
- **WHEN** a `resample` edit to 48000 Hz is compiled on a 44100 Hz document
|
||||
- **THEN** `plan.sampleRate === 48000`
|
||||
|
||||
**Verified by:** `src/registry/built-in/trim.test.ts::"trim restricts output to the selected range"` AND `src/registry/built-in/resample.test.ts::"resample updates the plan sample rate"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Built-in process operations
|
||||
|
||||
The default registry SHALL ship these process ops: `gain`, `normalize`, `fade`, `pan`, `mix`, `dither`, and `transform`. Each SHALL implement a `process` function operating block-by-block on `Float32Array[]`: `gain` applies a linear multiplier, `normalize` applies peak or LUFS normalisation, `fade` applies a linear or exponential ramp, `pan` applies a constant-power stereo law, `mix` sums another document's blocks, `dither` applies TPDF dither, and `transform` applies a caller-supplied per-sample function.
|
||||
|
||||
#### Scenario: gain scales every sample
|
||||
|
||||
- **WHEN** a `gain` op with multiplier `0.5` processes a block
|
||||
- **THEN** every output sample equals the corresponding input sample multiplied by `0.5`
|
||||
|
||||
#### Scenario: pan applies a constant-power law
|
||||
|
||||
- **WHEN** a `pan` op at hard-left processes a stereo block
|
||||
- **THEN** the left channel carries the constant-power-scaled signal and the right channel is silent
|
||||
|
||||
**Verified by:** `src/registry/built-in/gain.test.ts::"gain scales every sample by the multiplier"` AND `src/registry/built-in/pan.test.ts::"pan applies a constant-power stereo law"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: BlockProcessor interface
|
||||
|
||||
The package SHALL export a `BlockProcessor` interface (`name`, `setup(config)`, `process(input, output, ctx)`, `reset()`, `dispose()`) and the supporting `ProcessorConfig` and `BlockContext` types. This is the universal DSP contract implemented by every FX, analysis, and codec-post-processing plugin across the audio family. A reference `NoopBlockProcessor` SHALL ship for tests, copying `input` to `output` unchanged.
|
||||
|
||||
#### Scenario: NoopBlockProcessor passes audio through unchanged
|
||||
|
||||
- **WHEN** `NoopBlockProcessor.process` runs on a block
|
||||
- **THEN** `output` is byte-equal to `input`
|
||||
|
||||
**Verified by:** `src/process/block-processor.test.ts::"NoopBlockProcessor copies input to output unchanged"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Streaming integration with @suaveplan/streams
|
||||
|
||||
`AudioDocument.stream()` and `PlanEngine.streamPlan()` SHALL produce values composable with `@suaveplan/streams` `TransformStream`. The package SHALL expose a helper that wraps a compiled plan plus process pipeline as a `TransformStream<Float32Array[], Float32Array[]>`, so downstream FX can be appended with native stream backpressure.
|
||||
|
||||
#### Scenario: Plan renders through a TransformStream
|
||||
|
||||
- **WHEN** a compiled plan is wrapped as a `TransformStream` and piped to a sink
|
||||
- **THEN** the sink receives the same blocks `streamPlan` would yield directly
|
||||
|
||||
**Verified by:** `src/stream/stream.test.ts::"compiled plan renders through a @suaveplan/streams TransformStream"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Audio statistics
|
||||
|
||||
The package SHALL export `computeStats(pages)` returning `AudioStats` (`{ peak, rms, dcOffset, lufs? }`) over a page set. `peak` SHALL be the maximum absolute sample, `rms` the root-mean-square across all channels, and `dcOffset` the mean sample value. Stats SHALL be attachable to `AudioDocument.stats`.
|
||||
|
||||
#### Scenario: computeStats reports peak, rms, and dcOffset
|
||||
|
||||
- **WHEN** `computeStats` runs over a known signal (e.g. a unity-amplitude sine)
|
||||
- **THEN** `peak` is `1`, `rms` is approximately `0.707`, and `dcOffset` is approximately `0`
|
||||
|
||||
**Verified by:** `src/stats/stats.test.ts::"computeStats reports peak, rms, and dcOffset for a known signal"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: AbortSignal propagation
|
||||
|
||||
`AudioDocument.read()`, `AudioDocument.stream()`, `PlanEngine.streamPlan()`, and `PlanEngine.ensurePages()` SHALL accept an `AbortSignal` via their options object. An already-aborted signal SHALL cause the method to throw `AudioError` with code `AUDIO_CORE.STREAM_ABORTED` before any page work; a signal aborted mid-stream SHALL terminate the generator at the next block boundary with the same error.
|
||||
|
||||
#### Scenario: Pre-aborted signal short-circuits stream
|
||||
|
||||
- **WHEN** `stream()` is called with an already-aborted signal
|
||||
- **THEN** it throws `AudioError` whose `code === "AUDIO_CORE.STREAM_ABORTED"` before yielding any block
|
||||
|
||||
#### Scenario: Mid-stream abort terminates at the next block boundary
|
||||
|
||||
- **WHEN** a signal is aborted after the first block is yielded
|
||||
- **THEN** the generator throws `AUDIO_CORE.STREAM_ABORTED` at the next block boundary and yields nothing further
|
||||
|
||||
**Verified by:** `src/document/document.test.ts::"stream throws AUDIO_STREAM_ABORTED for a pre-aborted signal"` AND `src/plan/plan-engine.test.ts::"streamPlan honours mid-stream abort"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Zod boundary validation
|
||||
|
||||
Every public options object — `AudioDocumentOptions`, `AudioPage`, `AudioEdit`, `AudioStats`, `StreamOptions`, `CompiledPlan`, `AudioOpDescriptor`, `ProcessorConfig` — SHALL have a Zod schema exported from a sibling `*.schemas.ts`, and every public entry point SHALL validate its options argument against the schema before use. A validation failure SHALL throw `AudioError` with code `AUDIO_CORE.INVALID_EDIT` carrying the Zod issue list in `context`.
|
||||
|
||||
#### Scenario: Invalid options are rejected at the boundary
|
||||
|
||||
- **WHEN** `AudioDocument` is constructed with a negative `sampleRate`
|
||||
- **THEN** it throws `AudioError` whose `code === "AUDIO_CORE.INVALID_EDIT"` with the Zod issues in `context`
|
||||
|
||||
**Verified by:** `src/document/document.test.ts::"constructor rejects invalid options with AUDIO_INVALID_EDIT"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Structured error codes
|
||||
|
||||
The package SHALL export `AudioCoreErrorCode`, `AUDIO_CORE_ERROR_METADATA`, and `registerAudioCoreErrorCodes()`, registering — via `@suaveplan/error`'s `defineCodes("AUDIO_CORE", …)` — the codes `AUDIO_CORE.PLUGIN_NOT_FOUND`, `AUDIO_CORE.INVALID_EDIT`, `AUDIO_CORE.STREAM_ABORTED`, `AUDIO_CORE.PAGE_OUT_OF_RANGE`, and `AUDIO_CORE.PROCESS_ERROR`. Code string values are the namespaced `NAMESPACE.REASON` form produced by `defineCodes` — never a hand-written literal. Every error thrown by the package SHALL be an `AudioError` 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_CORE_ERROR_METADATA` is asserted thrown (or carried on a report) by at least one test
|
||||
|
||||
**Verified by:** `src/error-codes.test.ts::"every AUDIO_CORE code is reachable from a call site"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: OpenTelemetry instrumentation
|
||||
|
||||
The package SHALL instrument its public entry points via `@opentelemetry/api`: spans `plan.compile`, `process.block`, and `stream.read`; counters `audio_core_edits_total` and `audio_core_stream_blocks_total`; histograms `audio_core_compile_duration_ms` and `audio_core_process_duration_ms`. 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
|
||||
|
||||
**Verified by:** `src/plan/plan-engine.test.ts::"compile records a span and 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 (`./document/document`, `./page/page`, `./plan/plan-engine`, `./plan/segment`, `./process/process-pipeline`, `./process/block-processor`, `./registry/op-registry`, `./stream/stream`, `./render/render`, `./stats/stats`) 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-core/plan/plan-engine`
|
||||
- **THEN** the import resolves to the plan-engine module without pulling the whole package barrel
|
||||
|
||||
**Verified by:** `tests/exports.test.ts::"every feature module has a subpath export entry"`
|
||||
|
||||
### Requirement: AnalysisOp operator contract
|
||||
|
||||
The audio-core package SHALL expose an `AnalysisOp<TConfig, TResult>` operator contract that every `@suaveplan/audio-analysis-*` package implements, and a `createAnalysisOp` builder that constructs conforming operators with Zod-validated config, OTel instrumentation, and AbortSignal-aware cancellation.
|
||||
|
||||
`AnalysisOp` is a discriminated operator with: `name: string` (unique within an analysis pipeline), `version: string` (semver), `capabilities: AnalysisCapabilities` (streaming/realtime/blocking flags), `configSchema: z.ZodTypeAny`, `run(doc: AudioDocument, config: TConfig, opts?: { signal?: AbortSignal }): Promise<TResult>`, and `runStream?(source: AsyncIterable<AnalysisFrame>, config: TConfig, opts?: { signal?: AbortSignal }): AsyncIterable<TResult>`.
|
||||
|
||||
`AnalysisResult` is the base shape every analyser extends — `{ analyserName: string; analyserVersion: string; sampleRate: number; durationSamples: number; computedAt: number }`. Concrete analyser results add their own fields on top.
|
||||
|
||||
#### Scenario: builder validates config via Zod
|
||||
|
||||
- **WHEN** `createAnalysisOp({ name, version, capabilities, configSchema, run })` is called and a downstream caller invokes `op.run(doc, badConfig)`
|
||||
- **THEN** the builder MUST reject with `AudioError("AUDIO_CORE.ANALYSIS_INVALID_CONFIG")` carrying the Zod issues on `context.issues`, before `run` is invoked
|
||||
|
||||
#### Scenario: builder instruments runs via OpenTelemetry
|
||||
|
||||
- **WHEN** `op.run(doc, validConfig)` is called and succeeds
|
||||
- **THEN** the builder MUST emit one OTel span named `analysis.${name}` with attributes `{ "analysis.name": name, "analysis.version": version, "audio.sample_rate": doc.sampleRate, "audio.duration_samples": doc.sourceLength }`, increment counter `audio_analysis_runs_total{analysis=${name}}`, and record histogram `audio_analysis_duration_ms{analysis=${name}}`
|
||||
|
||||
#### Scenario: builder forwards AbortSignal
|
||||
|
||||
- **WHEN** `op.run(doc, validConfig, { signal })` is invoked and `signal.aborted === true` before `run` returns
|
||||
- **THEN** the inner `run` callback MUST observe the signal and the builder MUST surface the abort as `AudioError("AUDIO_CORE.ANALYSIS_ABORTED")`
|
||||
|
||||
#### Scenario: capability flags advertise streaming support
|
||||
|
||||
- **WHEN** a caller inspects `op.capabilities.supportsStream`
|
||||
- **THEN** the value MUST equal `typeof op.runStream === "function"`
|
||||
|
||||
**Verified by:** `src/analysis/analysis-op.test.ts`
|
||||
|
||||
### Requirement: AnalysisFrame streaming window descriptor
|
||||
|
||||
The audio-core package SHALL export an `AnalysisFrame` shape — `{ samples: Float32Array[]; sampleRate: number; channelCount: number; startSample: number; sampleCount: number }` — usable as the streaming-input element type for any `AnalysisOp.runStream` implementation. The shape is types-only; no runtime construction helper is mandated.
|
||||
|
||||
#### Scenario: streaming analyser consumes AnalysisFrame iterable
|
||||
|
||||
- **WHEN** a spectrum analyser's `runStream(source, config)` is invoked with an `AsyncIterable<AnalysisFrame>`
|
||||
- **THEN** the analyser MUST process each frame independently and MAY yield zero, one, or many `AnalysisResult`-extending values per input frame
|
||||
|
||||
**Verified by:** `src/analysis/analysis-op.test.ts::"runStream yields wrapped results and observes abort between yields"`
|
||||
Reference in New Issue
Block a user