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, 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 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
trimedit that halves the range - THEN
doc.lengthreflects the trimmed sample count,doc.versionhas incremented, and noAudioPagehas been allocated or copied
Scenario: Evicted page is represented as null
- WHEN a page is evicted from
doc.pages - THEN the slot holds
nulland 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.editsis empty,doc.versionhas incremented twice more, andundoreturned 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
editsandversionare 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 anAudioDocument - 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
editand 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
walkPagesover 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.
gainat unity) is compiled - THEN
plan.totalLenequalsdoc.sourceLength
Scenario: Unknown op type throws AUDIO_CORE.PLUGIN_NOT_FOUND
- WHEN
compileencounters an edit whosetypeis not in the registry - THEN it throws
AudioErrorwhosecode === "AUDIO_CORE.PLUGIN_NOT_FOUND"with the offendingtypeincontext
Scenario: Compilation is deterministic
- WHEN the same document is compiled twice
- THEN the two
CompiledPlanvalues 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
streamPlanfor 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
readPlanis asked for a range beyondplan.totalLen - THEN it throws
AudioErrorwhosecode === "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
patchwith the same op set but changed parameter values - THEN the existing
ProcessState[]buffers are reused and the new parameter values take effect on the nextprocesscall
Scenario: Structural change crossfades
- WHEN an op is added to the pipeline between two
processcalls - 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:errorevent fires with the op name,outputequalsinputfor 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
getwith 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
getresolves 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
trimedit selecting samples[1000, 2000)is compiled - THEN
plan.totalLen === 1000and rendering yields the original samples from that range
Scenario: resample changes the plan sample rate
- WHEN a
resampleedit 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
gainop with multiplier0.5processes a block - THEN every output sample equals the corresponding input sample multiplied by
0.5
Scenario: pan applies a constant-power law
- WHEN a
panop 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.processruns on a block - THEN
outputis byte-equal toinput
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
TransformStreamand piped to a sink - THEN the sink receives the same blocks
streamPlanwould 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
computeStatsruns over a known signal (e.g. a unity-amplitude sine) - THEN
peakis1,rmsis approximately0.707, anddcOffsetis approximately0
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
AudioErrorwhosecode === "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_ABORTEDat 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
AudioDocumentis constructed with a negativesampleRate - THEN it throws
AudioErrorwhosecode === "AUDIO_CORE.INVALID_EDIT"with the Zod issues incontext
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_METADATAis 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 invokesop.run(doc, badConfig) - THEN the builder MUST reject with
AudioError("AUDIO_CORE.ANALYSIS_INVALID_CONFIG")carrying the Zod issues oncontext.issues, beforerunis 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 counteraudio_analysis_runs_total{analysis=${name}}, and record histogramaudio_analysis_duration_ms{analysis=${name}}
Scenario: builder forwards AbortSignal
- WHEN
op.run(doc, validConfig, { signal })is invoked andsignal.aborted === truebeforerunreturns - THEN the inner
runcallback MUST observe the signal and the builder MUST surface the abort asAudioError("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 anAsyncIterable<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"