Files
suaveplan/openspec/specs/audio-engine-server/spec.md
2026-05-27 23:29:53 +01:00

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, or AudioWorklet

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-core streamPlan / PlanEngine exclusively

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.mkdtemp tmpdir and calls source.load(path)
  • THEN the resolved value is an AudioDocument whose sampleRate, channels, and length match the encoded file

Scenario: missing path throws AUDIO_ENGINE_SERVER.FILE_NOT_FOUND

  • WHEN load is called with a path that does not exist under the tmpdir
  • THEN it throws AudioEngineServerError whose code === "AUDIO_ENGINE_SERVER.FILE_NOT_FOUND" with the offending path in context

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 then FileSourceAdapter.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 save is called with a format string that has no registered codec
  • THEN it throws AudioEngineServerError whose code === "AUDIO_ENGINE_SERVER.ENCODE_FAILED" with the format in context

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 ReadableStream of 4 MiB into createWriteStream(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 TimelineAdapter member is callable and lookAhead === 0

Scenario: track-state mutations are reflected in the next render

  • WHEN a caller setTracks, then addTrack, then setTrackMute(id, true) before play
  • 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 setTracks then play(0) on an adapter whose outputPath is 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 ServerTimelineAdapter is initialised and setTracks([trackA, trackB]) is called
  • THEN two PluginChain instances exist (one per track) and both received the same BusRegistry instance via their ChainContext.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.5 is 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-changed mid-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-x and reads from bus-y, and track B's chain publishes to bus-y and reads from bus-x
  • THEN the second declared edge is rejected with AudioEngineServerError whose code === "AUDIO_ENGINE_SERVER.BUS_CYCLE_DETECTED"

Scenario: publishers process before subscribers in the same block

  • WHEN track A publishes to kick and track B's compressor reads kick
  • THEN for any block, A's chain.process() completes before B's chain.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 configured rootDir
  • THEN the call throws AudioEngineServerError whose code === "AUDIO_ENGINE_SERVER.INVALID_PATH" before any FsLike method is invoked

Scenario: null-byte path is rejected

  • WHEN a caller passes a path containing a null byte to save
  • THEN it throws AudioEngineServerError whose code === "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 load is called with an already-aborted signal
  • THEN it throws AudioEngineServerError whose code === "AUDIO_ENGINE_SERVER.ABORTED" before any FsLike method is invoked

Scenario: mid-render abort removes the partial output file

  • WHEN a signal is aborted while ServerTimelineAdapter.play is rendering
  • THEN the render terminates at the next block boundary with AUDIO_ENGINE_SERVER.ABORTED and 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 ServerTimelineAdapter is constructed with a negative sampleRate
  • THEN it throws AudioEngineServerError whose code === "AUDIO_ENGINE_SERVER.INVALID_OPTIONS" with the Zod issues in context

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_METADATA is asserted thrown by at least one test against a real node:fs.mkdtemp tmpdir

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-memory MeterProvider
  • THEN audio_engine_bytes_read_total increments by the file size AND audio_engine_load_duration_ms records 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-source and file-sink suites 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"