Files
2026-05-27 23:29:53 +01:00

25 KiB

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"