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

213 lines
10 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`](../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:
```ts
// 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:
```ts
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
```text
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.