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

10 KiB
Raw Permalink Blame History

Design: enterprise-audio-engine-server

Change ID: enterprise-audio-engine-server Package: @suaveplan/audio-engine-server

@suaveplan/audio-engine-server is the server I/O layer of the @suaveplan/audio-* family — the third row of the three-layer model (Data Model / Timeline-Transport / Rendering Engine). It is deliberately thin: it adds Node/Bun filesystem I/O and a file-output TimelineAdapter, and nothing else. Every piece of DSP, plan compilation, segment maths, streaming, and timeline logic is reused from the universal packages — this package never re-implements any of it.


1. Relationship to the ecosystem architecture

The cross-cutting architecture for the whole audio family — the three-layer separation, the six plugin slots, the zero-duplication rule, the universal Segment shape, and the AudioError base in @suaveplan/audio-core — lives in enterprise-audio-core/design.md §1–§4. That document is the single source of truth for the shared model; it is referenced here, not duplicated.

The two facts from that document most relevant to this package:

  • Three-layer separation (§1.2). The Rendering Engine row (audio-engine-*) is responsible for "node graphs, speakers, file I/O, MIDI". The server engine's share of that responsibility is file I/O only — there are no speakers and no node graph on the server, so the server engine is the thinnest of the three engine packages.
  • Plugin slot 5 — Timeline Adapter (§1.3). "Renders timeline state to a platform backend." ServerTimelineAdapter is the server implementation of that slot: the backend is a file on disk instead of an audio device.

2. Why this package is thin

The earlier ecosystem had a browser-only enterprise-browser-audio-engine and no server equivalent. The temptation when adding a server engine is to mirror the browser engine's structure — a node graph, a scheduler, a render loop. That would be duplication: the server has no real-time constraint and no AudioContext, so a "server render pipeline" would just be a worse copy of audio-core's streamPlan.

Instead, the server engine is defined by what the server uniquely has that the browser does not: a filesystem. So the package is exactly three things:

  1. FileSourceAdapter — disk/URL bytes → AudioDocument (via audio-codecs decode).
  2. FileSinkAdapter — AudioDocument → disk bytes (via audio-codecs encode).
  3. ServerTimelineAdapter — the slot-5 TimelineAdapter whose backend is a file.

Everything else — compiling edits, walking pages, running process ops, scheduling clips, resolving musical time — is imported. If a feature can run unchanged in a browser worker or an edge function, it does not belong here.


3. Source / sink adapter strategy

3.1. The FsLike seam

FileSourceAdapter and FileSinkAdapter accept an optional injectable FsLike adapter. This is a dependency-inversion seam, not a mocking hook. Its two real uses:

  • Fault injection — wrap a real node:fs with a thin shim that throws a specific error (e.g. an EBUSY-style failure) on the Nth call, so error paths can be exercised deterministically.
  • Alternate backends — a future in-memory or object-store backend can satisfy the same interface without changing adapter code.

Per the repo's "no mocks — real services" rule, the default path uses node:fs directly, and every filesystem test runs against a real node:fs.mkdtemp temporary directory. The FsLike seam is never used to replace the filesystem with an in-memory fake in tests.

3.2. Format detection and codec delegation

FileSourceAdapter.load reads the leading bytes, sniffs the container, and hands off to @suaveplan/audio-codecs decodeSource. FileSinkAdapter.save looks up the requested format in the CodecRegistry and pipes through its encoder. The engine package owns zero codec logic — it only owns the byte transport between disk and the codec layer.

3.3. Streaming vs. buffered

Both adapters expose two modes:

  • Buffered — load / save for the common "decode this whole file" / "encode this whole document" case.
  • Streaming — createReadStream / createWriteStream return WHATWG ReadableStream / WritableStream, composable with @suaveplan/streams, for large files that should not be fully buffered. These are backed by node:fs.createReadStream / createWriteStream.

3.4. Code sketch — server render is a pipe

ServerTimelineAdapter.play is not a render loop; it is a single pipe expression that reuses audio-core's stream:

// TimelineAdapter contract: play(time?: number)
play(time?: number) {
  const stream = audioCore.streamPlan(this.compiledDoc, { at: time ?? 0 })
  const encoder = codecRegistry.encodeStream(this.compiledDoc, this.format)
  stream
    .pipeThrough(encoder)
    .pipeTo(this.sink.createWriteStream(this.outputPath))
}

lookAhead is 0 because there is no real-time scheduler — the render runs as fast as the disk and CPU allow. getCurrentTime() reports render progress, not wall-clock playback position.


4. Path safety

Server file I/O is an attack surface. Every path argument — to load, loadMany, createReadStream, save, saveMany, createWriteStream, and the ServerTimelineAdapter outputPath — passes through a sanitizePath step before any FsLike call:

  • empty strings and null-byte paths are rejected;
  • when a rootDir boundary is configured, paths that resolve outside it via .. traversal are rejected;
  • rejections throw AudioEngineServerError with code AUDIO_ENGINE_SERVER.INVALID_PATH before any filesystem call.

URL inputs to load skip filesystem sanitisation but are validated as http/https URLs and fetched through the injected fetch.


5. Transcode use-cases

The canonical server use-case is transcoding — load one format, optionally apply edits, save another format. The full transcode workflow (queueing, parallelism) is not a server-specific feature: it uses @suaveplan/queue + @suaveplan/streams and runs identically in a browser worker, a server process, or an edge function. The master spec documents that as a cross-cutting pattern.

The only environment-specific code in a transcode job is the source/sink adapter — which is exactly what this package provides. A CLI transcode is:

import { FileSourceAdapter, FileSinkAdapter } from '@suaveplan/audio-engine-server'

const source = new FileSourceAdapter()
const sink = new FileSinkAdapter()

const doc = await source.load(inputPath)
// ...apply universal edits via doc.run(...)...
await sink.save(doc, outputPath, 'flac')

loadMany / saveMany provide bulk helpers for the simple parallel case without pulling in the full @suaveplan/queue machinery.


6. Integration with genesis packages

  • @suaveplan/audio-core — AudioDocument is the in-memory currency of both adapters; streamPlan is the render engine ServerTimelineAdapter pipes from. No DSP is re-implemented.
  • @suaveplan/audio-codecs — decodeSource / encodeStream / CodecRegistry do all format work; the engine package only moves bytes between disk and the codec layer.
  • @suaveplan/audio-timeline — supplies the TimelineAdapter interface (plugin slot 5) and the Track / Clip types ServerTimelineAdapter consumes.
  • @suaveplan/audio-transport — supplies the Transport contract the adapter's playback controls (play/pause/stop/seek) align to, even though server playback is non-real-time.
  • @suaveplan/streams — the ReadableStream / WritableStream / TransformStream primitives the streaming read/write paths and the render pipe are built from.
  • @suaveplan/opentelemetry — spans (file.load, file.save, server.render), byte counters, and duration histograms; inert when no exporter is registered.
  • @suaveplan/error — supplies SuaveplanError and defineCodes. The package exports AudioEngineServerError (extending AudioError from @suaveplan/audio-core) carrying AUDIO_ENGINE_SERVER.* codes registered via defineCodes("AUDIO_ENGINE_SERVER", [...]).

7. File layout

packages/media/audio/audio-engine-server/
  package.json, tsconfig.json, vitest.config.ts
  src/
    index.ts
    error-codes.ts
    source/   file-source.ts .md .types.ts .test.ts index.ts
    sink/     file-sink.ts .md .types.ts .test.ts index.ts
    adapter/  server-adapter.ts .md .types.ts .test.ts index.ts
    __benchmarks__/  io.bench.ts
  tests/
    integration.test.ts  property.test.ts  purity.test.ts  exports.test.ts

8. Benchmarks

  • io.bench.ts — load a 100 MB WAV through FileSourceAdapter.load, then save a 100 MB FLAC through FileSinkAdapter.save, measuring throughput in MB/s for both the buffered and the streaming path.

9. Decisions and trade-offs

  • No separate server render pipeline. The server engine reuses audio-core's streamPlan rather than owning a render loop. A server-specific pipeline would duplicate logic that already runs correctly and is already tested in audio-core; the only thing the server adds is the filesystem sink at the end of the pipe.
  • FsLike is a seam, not a mock. Tests run against real node:fs.mkdtemp tmpdirs per the repo's no-mocks rule. The injectable interface exists for fault injection and alternate backends, and the default path is plain node:fs.
  • lookAhead is 0. The TimelineAdapter contract carries a lookAhead for real-time scheduling headroom. Server rendering is not real-time — it runs as fast as possible — so the value is fixed at 0 rather than configurable.
  • Batch processing is documented, not implemented. Bulk transcoding via @suaveplan/queue is a universal pattern; only the source/sink adapter is environment-specific. Adding a queue here would pull a universal concern into a leaf package. loadMany / saveMany cover the simple parallel case; anything richer composes @suaveplan/queue at the application layer.
  • Leaf node. No universal package may depend on audio-engine-server; its sole downstream consumer is @suaveplan/audio-cli. This keeps the Node-only surface confined to the bottom of the dependency graph.