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.
|
||||
@@ -0,0 +1,178 @@
|
||||
# OpenSpec Change: enterprise-audio-engine-server
|
||||
|
||||
- **Change ID**: enterprise-audio-engine-server
|
||||
- **Package**: `@suaveplan/audio-engine-server`
|
||||
- **Category**: server
|
||||
- **Target path**: `packages/media/audio/audio-engine-server`
|
||||
- **Status**: PROPOSED
|
||||
- **Date**: 2026-05-14
|
||||
- **Design**: `./design.md`
|
||||
- **Spec delta**: `./specs/audio-engine-server/spec.md`
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Deliver `@suaveplan/audio-engine-server` on the genesis branch from scratch at enterprise quality. Server-side file I/O adapters for the genesis audio ecosystem. This package is **intentionally thin** — all DSP, planning, streaming, and timeline logic lives in universal packages (`audio-core`, `audio-timeline`, `audio-transport`). This package only provides file source adapters, file sink adapters, and a server timeline adapter that renders to file output instead of speakers.
|
||||
|
||||
Batch processing is universal (uses `@suaveplan/queue` + `@suaveplan/streams`) and is documented in the master spec as a cross-cutting pattern, not a server-specific feature.
|
||||
|
||||
---
|
||||
|
||||
## Current-state audit (genesis)
|
||||
|
||||
The package does not exist on genesis yet — this is a fresh build. No prior source, no test results, no coverage data. The old `enterprise-browser-audio-engine` was browser-only and had no server equivalent.
|
||||
|
||||
---
|
||||
|
||||
## Available workspace deps on genesis
|
||||
|
||||
| Package | Role |
|
||||
|---|---|
|
||||
| `@suaveplan/audio-core` (`workspace:^`) | `AudioDocument`, `PlanEngine`, `ProcessPipeline` |
|
||||
| `@suaveplan/audio-timeline` (`workspace:^`) | `TimelineEngine`, `Track`, `Clip` |
|
||||
| `@suaveplan/audio-transport` (`workspace:^`) | `Transport` |
|
||||
| `@suaveplan/audio-codecs` (`workspace:^`) | `CodecRegistry`, `encodeTo`, `decodeSource` |
|
||||
| `@suaveplan/streams` (`workspace:^`) | `TransformStream`, `ReadableStream`, `WritableStream` primitives |
|
||||
| `@suaveplan/error` (`workspace:^`) | `SuaveplanError` + `defineCodes`; the package exports `AudioEngineServerError` (extending `AudioError` from `@suaveplan/audio-core`) carrying `AUDIO_ENGINE_SERVER.*` codes. |
|
||||
| `@suaveplan/types` (`workspace:^`) | `LoggerLike` |
|
||||
| `@suaveplan/opentelemetry` (`workspace:^`) | OTel spans, counters, histograms |
|
||||
| `zod` (`catalog:forms`) | Zod schemas for option types |
|
||||
|
||||
Dev deps: `vitest`, `@vitest/coverage-istanbul`, `fast-check`, `mitata` (all `catalog:testing`).
|
||||
|
||||
---
|
||||
|
||||
## Public API
|
||||
|
||||
### Core types
|
||||
|
||||
```ts
|
||||
interface FileSourceOptions {
|
||||
fs?: FsLike // injectable fs adapter for testing
|
||||
fetch?: typeof fetch // injectable fetch for URLs
|
||||
}
|
||||
|
||||
class FileSourceAdapter {
|
||||
constructor(options?: FileSourceOptions)
|
||||
async load(path: string, opts?: LoadOptions): Promise<AudioDocument>
|
||||
createReadStream(path: string): ReadableStream<Uint8Array>
|
||||
async loadMany(paths: string[], opts?: LoadOptions): Promise<AudioDocument[]>
|
||||
}
|
||||
|
||||
interface FileSinkOptions {
|
||||
fs?: FsLike
|
||||
}
|
||||
|
||||
class FileSinkAdapter {
|
||||
constructor(options?: FileSinkOptions)
|
||||
async save(doc: AudioDocument, path: string, format: string, opts?: SaveOptions): Promise<void>
|
||||
createWriteStream(path: string): WritableStream<Uint8Array>
|
||||
async saveMany(docs: AudioDocument[], pathFormat: string): Promise<void>
|
||||
}
|
||||
|
||||
interface ServerAdapterOptions {
|
||||
outputPath?: string
|
||||
format?: string
|
||||
sampleRate?: number
|
||||
ppqn?: number
|
||||
}
|
||||
|
||||
class ServerTimelineAdapter implements TimelineAdapter {
|
||||
constructor(options?: ServerAdapterOptions)
|
||||
init(options?: { signal?: AbortSignal }): Promise<void>
|
||||
setTracks(tracks: Track[]): void
|
||||
addTrack(track: Track): void
|
||||
removeTrack(trackId: string): void
|
||||
updateTrack(trackId: string, track: Track): void
|
||||
play(time?: number): void
|
||||
pause(time?: number): void
|
||||
stop(time?: number): void
|
||||
seek(time: number): void
|
||||
getCurrentTime(): number
|
||||
setLoop(start: number, end: number, enabled: boolean): void
|
||||
setMasterVolume(volume: number): void
|
||||
setTrackVolume(trackId: string, volume: number): void
|
||||
setTrackMute(trackId: string, muted: boolean): void
|
||||
setTrackSolo(trackId: string, soloed: boolean): void
|
||||
setTrackPan(trackId: string, pan: number): void
|
||||
setTempo(bpm: number, atTick?: number): void
|
||||
ticksToSeconds(ticks: number): number
|
||||
secondsToTicks(seconds: number): number
|
||||
readonly lookAhead: 0
|
||||
readonly ppqn: number
|
||||
dispose(): void
|
||||
/** Extra non-interface method: render the timeline to a finite output, useful for non-realtime export. */
|
||||
render(options?: { startTime?: number; endTime?: number; signal?: AbortSignal }): Promise<void>
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## New additions for genesis
|
||||
|
||||
1. **Injectable fs adapter** — `FileSourceAdapter` and `FileSinkAdapter` accept an `FsLike` interface for testing without touching the real filesystem.
|
||||
2. **Streaming load/save** — `createReadStream` / `createWriteStream` return WHATWG streams compatible with `@suaveplan/streams`.
|
||||
3. **Batch helpers** — `loadMany` / `saveMany` for bulk operations.
|
||||
4. **Server playback = render** — `ServerTimelineAdapter.play()` renders as fast as possible via `audio-core.streamPlan()` piped to codec encoder and file sink.
|
||||
5. **OTel instrumentation** — spans for `file.load`, `file.save`, `server.render`, counters for bytes read/written.
|
||||
6. **Property-based tests** — load then save → file exists, round-trip preserves sample count.
|
||||
7. **Benchmark suite** — load 100MB WAV, save 100MB FLAC.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
file-source.md
|
||||
file-source.types.ts
|
||||
file-source.test.ts
|
||||
index.ts
|
||||
sink/
|
||||
file-sink.ts
|
||||
file-sink.md
|
||||
file-sink.types.ts
|
||||
file-sink.test.ts
|
||||
index.ts
|
||||
adapter/
|
||||
server-adapter.ts
|
||||
server-adapter.md
|
||||
server-adapter.types.ts
|
||||
server-adapter.test.ts
|
||||
index.ts
|
||||
__benchmarks__/
|
||||
io.bench.ts
|
||||
tests/
|
||||
integration.test.ts
|
||||
property.test.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Impact
|
||||
|
||||
**Breaking changes**: none — fresh build, package does not yet exist on genesis.
|
||||
|
||||
**Prerequisites**: `@suaveplan/audio-core`, `@suaveplan/audio-timeline`, `@suaveplan/audio-transport`, `@suaveplan/audio-codecs`, `@suaveplan/streams`, `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/opentelemetry` published to Verdaccio.
|
||||
|
||||
**No universal package may depend on this.** It is a leaf node in the dependency graph.
|
||||
|
||||
### Affected dependents
|
||||
|
||||
`@suaveplan/audio-engine-server` is a leaf node — its only downstream consumer is the audio CLI,
|
||||
which does not exist on genesis yet (it lands in a later change in this same batch). It is created
|
||||
against this package's spec delta, not retrofitted.
|
||||
|
||||
- `@suaveplan/audio-cli` — [ ] tests run / [ ] docs reviewed / [x] no action needed (reason: does not exist yet; created in `enterprise-audio-cli`)
|
||||
|
||||
Once landed, any subsequent change to this package's public surface MUST run
|
||||
`bunx turbo test --filter=...@suaveplan/audio-engine-server` per `openspec/AGENTS.md` §13 step 10.
|
||||
@@ -0,0 +1,278 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# 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 `save`s a document as FLAC into a tmpdir then `FileSourceAdapter.load`s 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 `load`s 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"`
|
||||
|
||||
<!-- markdownlint-enable MD024 -->
|
||||
@@ -0,0 +1,80 @@
|
||||
# Tasks: enterprise-audio-engine-server
|
||||
|
||||
**Change ID**: enterprise-audio-engine-server
|
||||
**Package**: `@suaveplan/audio-engine-server`
|
||||
**Target path**: `packages/media/audio/audio-engine-server`
|
||||
**Prerequisite packages on Verdaccio**: `@suaveplan/audio-core`, `@suaveplan/audio-timeline`, `@suaveplan/audio-transport`, `@suaveplan/audio-codecs`, `@suaveplan/streams`, `@suaveplan/error`, `@suaveplan/types`
|
||||
|
||||
---
|
||||
|
||||
## 1. Scaffold
|
||||
|
||||
- [ ] Create directory `packages/media/audio/audio-engine-server/` with `src/`, `tests/`, `src/__benchmarks__/`, and all feature subdirectories per the layout in `proposal.md`.
|
||||
- [ ] Write `package.json` at version `0.1.0` with `"type": "module"`, `"sideEffects": false`, full `exports` map, deps: `@suaveplan/audio-core workspace:^`, `@suaveplan/audio-timeline workspace:^`, `@suaveplan/audio-transport workspace:^`, `@suaveplan/audio-codecs workspace:^`, `@suaveplan/streams workspace:^`, `@suaveplan/error workspace:^`, `@suaveplan/types workspace:^`, `@suaveplan/opentelemetry workspace:^`, `zod catalog:forms`.
|
||||
- [ ] Write `tsconfig.json` extending `@suaveplan/typescript-config/library.json`.
|
||||
- [ ] Write `vitest.config.ts` using `createTestConfig()` from `@suaveplan/testing/config`.
|
||||
- [ ] Add `lint`, `typecheck`, `test`, `test:coverage`, `build`, `docs:check` scripts.
|
||||
|
||||
---
|
||||
|
||||
## 2. Types + errors
|
||||
|
||||
- [ ] Write `src/error-codes.ts` — `defineCodes("AUDIO_ENGINE_SERVER", [...])` producing `AUDIO_ENGINE_SERVER.*` code strings; export `AudioEngineServerErrorCode`, `AUDIO_ENGINE_SERVER_ERROR_METADATA`, `registerAudioEngineServerErrorCodes()`, and an `AudioEngineServerError` class extending `AudioError` from `@suaveplan/audio-core`.
|
||||
- [ ] Write Zod schemas: `FileSourceOptionsSchema`, `FileSinkOptionsSchema`, `ServerAdapterOptionsSchema`.
|
||||
|
||||
---
|
||||
|
||||
## 3. FileSourceAdapter
|
||||
|
||||
- [ ] Implement `FileSourceAdapter` with injectable `FsLike`:
|
||||
- `load(path)` — read file, detect format, decode to `AudioDocument`
|
||||
- `createReadStream(path)` — return `ReadableStream<Uint8Array>`
|
||||
- `loadMany(paths)` — parallel load with `Promise.all`
|
||||
- [ ] Write `file-source.test.ts` and `file-source.md` (≥200 words).
|
||||
|
||||
---
|
||||
|
||||
## 4. FileSinkAdapter
|
||||
|
||||
- [ ] Implement `FileSinkAdapter` with injectable `FsLike`:
|
||||
- `save(doc, path, format)` — encode and write file
|
||||
- `createWriteStream(path)` — return `WritableStream<Uint8Array>`
|
||||
- `saveMany(docs, pathFormat)` — parallel save
|
||||
- [ ] Write `file-sink.test.ts` and `file-sink.md` (≥200 words).
|
||||
|
||||
---
|
||||
|
||||
## 5. ServerTimelineAdapter
|
||||
|
||||
- [ ] Implement `ServerTimelineAdapter` implementing `TimelineAdapter`:
|
||||
- All timeline state methods (setTracks, addTrack, removeTrack, updateTrack)
|
||||
- Transport controls (play, pause, stop, seek)
|
||||
- `play()` renders via `audio-core.streamPlan()` → codec encoder → file sink
|
||||
- `getCurrentTime()` returns render progress
|
||||
- [ ] Write `server-adapter.test.ts` and `server-adapter.md` (≥200 words).
|
||||
- [ ] Wire OTel: spans, counters, histograms.
|
||||
|
||||
---
|
||||
|
||||
## 6. Benchmarks
|
||||
|
||||
- [ ] Write `src/__benchmarks__/io.bench.ts` — load 100MB WAV, save 100MB FLAC.
|
||||
|
||||
---
|
||||
|
||||
## 7. Tests
|
||||
|
||||
- [ ] Write `tests/integration.test.ts` — full load → edit → save round-trip.
|
||||
- [ ] Write `tests/property.test.ts` (fast-check) — load then save → file exists, round-trip preserves sample count.
|
||||
|
||||
---
|
||||
|
||||
## 8. Docs + verify + publish
|
||||
|
||||
- [ ] All co-located `.md` files (≥200 words each).
|
||||
- [ ] Write `README.md` at package root.
|
||||
- [ ] `bunx turbo lint typecheck test --filter=@suaveplan/audio-engine-server` — green.
|
||||
- [ ] `bunx turbo test:coverage --filter=@suaveplan/audio-engine-server` — 100%.
|
||||
- [ ] `bun scripts/publish.ts --package @suaveplan/audio-engine-server`.
|
||||
- [ ] Fold `specs/audio-engine-server/spec.md` into `openspec/specs/audio-engine-server/spec.md`.
|
||||
- [ ] Archive to `openspec/archive/2026-05-14-enterprise-audio-engine-server/`.
|
||||
Reference in New Issue
Block a user