21 KiB
Capability Spec Delta: audio-engine-server (audio-engine-server)
Package: @suaveplan/audio-engine-server
Status: PROPOSED
Every requirement below carries a
**Verified by:**line at authoring time. No exceptions.
ADDED Requirements
Requirement: Server-tier purity
The @suaveplan/audio-engine-server package MAY import Node/Bun-only APIs (node:fs, node:path, node:stream, Buffer, process) because it is the server I/O layer of the audio family. It SHALL NOT import any browser-only API (window, document, navigator, AudioContext, AudioBuffer, AudioWorklet). The package SHALL NOT re-implement DSP, planning, streaming, or timeline logic — all of that SHALL be reached through the universal @suaveplan/audio-core, @suaveplan/audio-timeline, and @suaveplan/audio-transport packages. There is no separate server render pipeline. The package's dependencies SHALL be limited to @suaveplan/audio-core, @suaveplan/audio-timeline, @suaveplan/audio-transport, @suaveplan/audio-codecs, @suaveplan/streams, @suaveplan/error, @suaveplan/types, @suaveplan/opentelemetry, tslib, and zod.
Scenario: Built dist references no browser globals
- WHEN the built
dist/is grep'd - THEN it MUST NOT reference
window,document,navigator,AudioContext,AudioBuffer, orAudioWorklet
Scenario: No vendored DSP or planning logic
- WHEN the source tree is inspected for plan compilation, segment maths, or block-DSP loops
- THEN none is present — rendering is delegated to
@suaveplan/audio-corestreamPlan/PlanEngineexclusively
Verified by: tests/purity.test.ts::"dist references no browser globals" AND tests/purity.test.ts::"dependency set is limited to the declared workspace deps and zod"
Requirement: FileSourceAdapter loads files into AudioDocument
The package SHALL export a FileSourceAdapter class constructed from optional FileSourceOptions ({ fs?: FsLike; fetch?: typeof fetch }). load(path, opts?) SHALL read the file bytes through the injected FsLike (or node:fs when none is injected), detect the container format, decode it via @suaveplan/audio-codecs decodeSource, and resolve an AudioDocument. When path is an http/https URL the adapter SHALL fetch it through the injected fetch. loadMany(paths, opts?) SHALL load every path in parallel via Promise.all and resolve an AudioDocument[] preserving input order. A path that does not exist SHALL throw AudioEngineServerError with code AUDIO_ENGINE_SERVER.FILE_NOT_FOUND; a file whose format cannot be decoded SHALL throw AudioEngineServerError with code AUDIO_ENGINE_SERVER.DECODE_FAILED.
Scenario: load decodes a real file into an AudioDocument
- WHEN a caller writes a WAV file into a
node:fs.mkdtemptmpdir and callssource.load(path) - THEN the resolved value is an
AudioDocumentwhosesampleRate,channels, andlengthmatch the encoded file
Scenario: missing path throws AUDIO_ENGINE_SERVER.FILE_NOT_FOUND
- WHEN
loadis called with a path that does not exist under the tmpdir - THEN it throws
AudioEngineServerErrorwhosecode === "AUDIO_ENGINE_SERVER.FILE_NOT_FOUND"with the offending path incontext
Scenario: loadMany preserves input order
- WHEN a caller calls
loadMany([a, b, c])against three real tmpdir files - THEN the resolved array has the documents in
[a, b, c]order regardless of decode completion order
Verified by: src/source/file-source.test.ts::"load decodes a real tmpdir file into an AudioDocument" AND src/source/file-source.test.ts::"load throws AUDIO_ENGINE_SERVER.FILE_NOT_FOUND for a missing path" AND src/source/file-source.test.ts::"loadMany preserves input order"
Requirement: FileSourceAdapter exposes a streaming read
FileSourceAdapter.createReadStream(path) SHALL return a WHATWG ReadableStream<Uint8Array> backed by the injected FsLike (or node:fs.createReadStream), composable with @suaveplan/streams. The stream SHALL surface backpressure from the underlying file handle and SHALL NOT buffer the whole file in memory. A read error on the underlying handle SHALL error the stream with AudioEngineServerError carrying code AUDIO_ENGINE_SERVER.IO_FAILED.
Scenario: createReadStream yields the file bytes without full buffering
- WHEN a caller writes a 4 MiB file into a tmpdir and fully drains
createReadStream(path) - THEN the concatenated chunks are byte-equal to the file and no chunk exceeds the underlying handle's high-water mark
Verified by: src/source/file-source.test.ts::"createReadStream streams a real tmpdir file without full buffering"
Requirement: FileSinkAdapter saves AudioDocument to disk
The package SHALL export a FileSinkAdapter class constructed from optional FileSinkOptions ({ fs?: FsLike }). save(doc, path, format, opts?) SHALL encode the document via @suaveplan/audio-codecs for the requested format and write the bytes through the injected FsLike (or node:fs), creating parent directories as needed. saveMany(docs, pathFormat) SHALL save every document in parallel, expanding pathFormat per document index. A format with no registered codec SHALL throw AudioEngineServerError with code AUDIO_ENGINE_SERVER.ENCODE_FAILED; a write failure on the underlying handle SHALL throw AudioEngineServerError with code AUDIO_ENGINE_SERVER.IO_FAILED.
Scenario: save round-trips through load
- WHEN a caller
saves a document as FLAC into a tmpdir thenFileSourceAdapter.loads it back - THEN the reloaded document's sample count and channel count equal the original's
Scenario: unknown format throws AUDIO_ENGINE_SERVER.ENCODE_FAILED
- WHEN
saveis called with aformatstring that has no registered codec - THEN it throws
AudioEngineServerErrorwhosecode === "AUDIO_ENGINE_SERVER.ENCODE_FAILED"with the format incontext
Verified by: src/sink/file-sink.test.ts::"save then load round-trips sample and channel counts" AND src/sink/file-sink.test.ts::"save throws AUDIO_ENGINE_SERVER.ENCODE_FAILED for an unregistered format"
Requirement: FileSinkAdapter exposes a streaming write
FileSinkAdapter.createWriteStream(path) SHALL return a WHATWG WritableStream<Uint8Array> backed by the injected FsLike (or node:fs.createWriteStream), composable with @suaveplan/streams. The stream SHALL apply backpressure from the underlying file handle and SHALL create parent directories before the first write. A write error SHALL abort the stream with AudioEngineServerError carrying code AUDIO_ENGINE_SERVER.IO_FAILED.
Scenario: createWriteStream persists a piped stream to disk
- WHEN a caller pipes a
ReadableStreamof 4 MiB intocreateWriteStream(path)under a tmpdir - THEN the file on disk is byte-equal to the piped bytes after the stream closes
Verified by: src/sink/file-sink.test.ts::"createWriteStream persists a piped stream to a real tmpdir file"
Requirement: ServerTimelineAdapter implements the TimelineAdapter interface
The package SHALL export a ServerTimelineAdapter class implementing the TimelineAdapter interface (plugin slot 5) from @suaveplan/audio-timeline, constructed from optional ServerAdapterOptions ({ outputPath?: string; format?: string; sampleRate?: number; ppqn?: number }). It SHALL implement every TimelineAdapter member: init, setTracks, addTrack, removeTrack, updateTrack, play, pause, stop, seek, getCurrentTime, setLoop, setMasterVolume, setTrackVolume, setTrackMute, setTrackSolo, setTrackPan, setTempo, dispose, plus readonly lookAhead (always 0) and ppqn. The adapter renders timeline state to a file instead of speakers — lookAhead is 0 because rendering is not real-time.
Scenario: instance is structurally a TimelineAdapter
- WHEN a caller constructs
new ServerTimelineAdapter({ outputPath }) - THEN every
TimelineAdaptermember is callable andlookAhead === 0
Scenario: track-state mutations are reflected in the next render
- WHEN a caller
setTracks, thenaddTrack, thensetTrackMute(id, true)beforeplay - THEN the rendered output file omits the muted track's contribution
Verified by: src/adapter/server-adapter.test.ts::"implements every TimelineAdapter member with lookAhead 0" AND src/adapter/server-adapter.test.ts::"track-state mutations are reflected in the rendered output"
Requirement: ServerTimelineAdapter renders to file by reusing audio-core streams
ServerTimelineAdapter.play(time?) SHALL render the timeline as fast as possible — not in real time — starting from time ?? 0, by reusing the universal @suaveplan/audio-core streamPlan stream logic: it pipes the compiled document stream through the @suaveplan/audio-codecs encoder for the configured format into a FileSinkAdapter write stream targeting outputPath. The adapter SHALL additionally expose a non-interface render({ startTime?, endTime?, signal? }) method for finite-range exports that need an explicit end. getCurrentTime() SHALL return render progress in seconds. pause/stop SHALL halt the render at the next block boundary. The adapter SHALL NOT contain its own block-DSP or plan-compilation code — it is a thin orchestrator over audio-core.
Scenario: play renders the timeline to the configured output file
- WHEN a caller
setTracksthenplay(0)on an adapter whoseoutputPathis under a tmpdir - THEN after the render settles the output file exists, is non-empty, and decodes back to a document whose duration matches the timeline length
Scenario: getCurrentTime tracks render progress
- WHEN a render is in progress
- THEN
getCurrentTime()returns a monotonically non-decreasing value bounded by the timeline length
Verified by: src/adapter/server-adapter.test.ts::"play renders the timeline to the configured output file" AND src/adapter/server-adapter.test.ts::"getCurrentTime tracks render progress monotonically"
Requirement: Each track hosts one PluginChain from ./fx/plugin-chain
For each Track in the timeline, ServerTimelineAdapter SHALL construct exactly one PluginChain instance imported from ./fx/plugin-chain and use it as the track's FX slot list. The chain SHALL own all plugin instances, parameter automation, snapshot/restore, plugin delay compensation, and chain-level bypass for the track. The adapter SHALL push ambient state into the chain via chain.updateAmbient({ track, transport }) whenever track gain/pan/mute/solo or transport tempo/time/state changes. The adapter SHALL run every plugin via its BlockProcessor process() path; createAudioNode / worklet paths are not used on the server. The chain's effective per-track latency (chain.getEffectiveLatencySamples()) SHALL be used to apply per-track delay compensation across the project so tracks of different effective latencies stay phase-aligned in the rendered output.
Scenario: each track has exactly one PluginChain bound to the project BusRegistry
- WHEN
ServerTimelineAdapteris initialised andsetTracks([trackA, trackB])is called - THEN two
PluginChaininstances exist (one per track) and both received the sameBusRegistryinstance via theirChainContext.buses
Scenario: ambient changes propagate to the chain on the next block
- WHEN the adapter receives
setTrackPan(trackA.id, -0.5)mid-render - THEN the chain's
ChainContext.ambient.track.pan === -0.5is observed by every plugin in track A's chain on the next processed block
Scenario: dynamic chain latency triggers per-track PDC recompute
- WHEN any chain emits
latency-changedmid-render - THEN the adapter recomputes per-track compensation so the maximum effective latency across tracks equals the longest chain's effective latency, and the rendered output remains phase-aligned
Verified by: src/adapter/server-adapter.test.ts::"each track hosts one PluginChain bound to a shared BusRegistry" AND src/adapter/server-adapter.test.ts::"ambient changes propagate into ChainContext on the next block" AND src/adapter/server-adapter.test.ts::"chain latency-changed triggers per-track PDC recompute"
Requirement: Project-level BusRegistry owned by the adapter
The adapter SHALL construct and own one BusRegistry instance (the DefaultBusRegistry from ./fx/plugin-chain or an engine-specific implementation) for the entire TimelineAdapter lifetime. It SHALL detect bus cycles (chain A reads a bus written by chain B which reads a bus written by chain A) and reject the offending publisher with AudioEngineServerError code AUDIO_ENGINE_SERVER.BUS_CYCLE_DETECTED. The adapter SHALL order per-block chain processing so publishers write before subscribers read in the same block. dispose() SHALL close every active SidechainPublisher and release the registry.
Scenario: bus cycle is rejected
- WHEN track A's chain publishes to
bus-xand reads frombus-y, and track B's chain publishes tobus-yand reads frombus-x - THEN the second declared edge is rejected with
AudioEngineServerErrorwhosecode === "AUDIO_ENGINE_SERVER.BUS_CYCLE_DETECTED"
Scenario: publishers process before subscribers in the same block
- WHEN track A publishes to
kickand track B's compressor readskick - THEN for any block, A's
chain.process()completes before B'schain.process()begins
Verified by: src/adapter/server-adapter.test.ts::"bus cycle is rejected with AUDIO_ENGINE_SERVER.BUS_CYCLE_DETECTED" AND src/adapter/server-adapter.test.ts::"publishers process before subscribers in the same block"
Requirement: Path safety and sanitisation at the I/O boundary
Every method that accepts a filesystem path (load, loadMany, createReadStream, save, saveMany, createWriteStream, and ServerTimelineAdapter outputPath) SHALL pass the path through a sanitizePath step before issuing any filesystem call. The sanitiser SHALL reject empty strings, paths containing null bytes, and — when a rootDir boundary is configured — paths that resolve outside that boundary via .. traversal. Rejections SHALL throw AudioEngineServerError with code AUDIO_ENGINE_SERVER.INVALID_PATH. URL inputs to load SHALL be exempt from filesystem sanitisation but validated as http/https URLs.
Scenario: path-traversal attempt is rejected before any fs call
- WHEN a caller calls
source.load("../../etc/passwd")against an adapter with a configuredrootDir - THEN the call throws
AudioEngineServerErrorwhosecode === "AUDIO_ENGINE_SERVER.INVALID_PATH"before anyFsLikemethod is invoked
Scenario: null-byte path is rejected
- WHEN a caller passes a path containing a null byte to
save - THEN it throws
AudioEngineServerErrorwhosecode === "AUDIO_ENGINE_SERVER.INVALID_PATH"
Verified by: src/source/file-source.test.ts::"rejects path-traversal before any fs call" AND src/sink/file-sink.test.ts::"rejects null-byte paths with AUDIO_ENGINE_SERVER.INVALID_PATH"
Requirement: AbortSignal propagation
FileSourceAdapter.load, loadMany, FileSinkAdapter.save, saveMany, and ServerTimelineAdapter.play SHALL accept an AbortSignal via their options object. An already-aborted signal SHALL cause the method to throw AudioEngineServerError with code AUDIO_ENGINE_SERVER.ABORTED before any filesystem call is issued. A signal aborted mid-operation SHALL terminate the read, write, or render at the next chunk or block boundary with the same error and SHALL clean up any partial output file.
Scenario: pre-aborted signal short-circuits load
- WHEN
loadis called with an already-aborted signal - THEN it throws
AudioEngineServerErrorwhosecode === "AUDIO_ENGINE_SERVER.ABORTED"before anyFsLikemethod is invoked
Scenario: mid-render abort removes the partial output file
- WHEN a signal is aborted while
ServerTimelineAdapter.playis rendering - THEN the render terminates at the next block boundary with
AUDIO_ENGINE_SERVER.ABORTEDand no partial output file remains under the tmpdir
Verified by: src/source/file-source.test.ts::"load throws AUDIO_ENGINE_SERVER.ABORTED for a pre-aborted signal" AND src/adapter/server-adapter.test.ts::"mid-render abort removes the partial output file"
Requirement: Zod boundary validation
Every public options object — FileSourceOptions, FileSinkOptions, ServerAdapterOptions, LoadOptions, SaveOptions — SHALL have a Zod schema exported from a sibling *.schemas.ts, and every public constructor and entry point SHALL validate its options argument against the schema before use. A validation failure SHALL throw AudioEngineServerError with code AUDIO_ENGINE_SERVER.INVALID_OPTIONS carrying the Zod issue list in context.
Scenario: invalid options are rejected at the boundary
- WHEN
ServerTimelineAdapteris constructed with a negativesampleRate - THEN it throws
AudioEngineServerErrorwhosecode === "AUDIO_ENGINE_SERVER.INVALID_OPTIONS"with the Zod issues incontext
Verified by: src/adapter/server-adapter.test.ts::"constructor rejects invalid options with AUDIO_ENGINE_SERVER.INVALID_OPTIONS"
Requirement: Structured error codes
The package SHALL declare its codes via defineCodes("AUDIO_ENGINE_SERVER", [...]) producing the dotted codes AUDIO_ENGINE_SERVER.FILE_NOT_FOUND, AUDIO_ENGINE_SERVER.DECODE_FAILED, AUDIO_ENGINE_SERVER.ENCODE_FAILED, AUDIO_ENGINE_SERVER.IO_FAILED, AUDIO_ENGINE_SERVER.INVALID_PATH, AUDIO_ENGINE_SERVER.INVALID_OPTIONS, AUDIO_ENGINE_SERVER.ABORTED, and AUDIO_ENGINE_SERVER.BUS_CYCLE_DETECTED. The package SHALL export AudioEngineServerErrorCode, AUDIO_ENGINE_SERVER_ERROR_METADATA, registerAudioEngineServerErrorCodes(), and an AudioEngineServerError class extending AudioError from @suaveplan/audio-core. Every error thrown by the package SHALL be an AudioEngineServerError 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_ENGINE_SERVER_ERROR_METADATAis asserted thrown by at least one test against a realnode:fs.mkdtemptmpdir
Verified by: src/error-codes.test.ts::"every AUDIO_ENGINE code is reachable from a call site"
Requirement: OpenTelemetry instrumentation
The package SHALL instrument its public entry points via @suaveplan/opentelemetry: spans file.load, file.save, and server.render; counters audio_engine_bytes_read_total and audio_engine_bytes_written_total; histograms audio_engine_load_duration_ms and audio_engine_save_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
Scenario: a successful load records bytes and duration
- WHEN a caller
loads a real tmpdir file against an in-memoryMeterProvider - THEN
audio_engine_bytes_read_totalincrements by the file size ANDaudio_engine_load_duration_msrecords a single observation
Verified by: src/source/file-source.test.ts::"load records a span, byte counter, and histogram when an exporter is registered" AND tests/purity.test.ts::"importing the package mutates no global state"
Requirement: Filesystem tests use real tmpdirs, not mocks
Per the repo's "no mocks — real services" rule, every filesystem-touching test SHALL exercise a real node:fs.mkdtemp temporary directory rather than a mocked fs. The injected FsLike seam exists for dependency inversion and fault injection (e.g. forcing an EBUSY-style error from a real wrapper), not for replacing the filesystem with an in-memory fake. Each test SHALL create its tmpdir in setup and remove it in teardown.
Scenario: source and sink tests run against a real temporary directory
- WHEN the
file-sourceandfile-sinksuites run - THEN each creates a real tmpdir via
node:fs.mkdtemp, performs real reads and writes, and cleans up the tmpdir afterwards
Verified by: tests/integration.test.ts::"load then save round-trips through a real node:fs.mkdtemp tmpdir" AND tests/property.test.ts::"load then save preserves sample count across a real tmpdir round-trip"
Requirement: Subpath exports per feature module
package.json SHALL declare one exports entry per feature module (./source/file-source, ./sink/file-sink, ./adapter/server-adapter) 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-engine-server/source/file-source - THEN the import resolves to the file-source module without pulling the whole package barrel
Verified by: tests/exports.test.ts::"every feature module has a subpath export entry"