Initial Commit
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user