Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,72 @@
# @suaveplan/audio-engine-server - Server-tier audio I/O layer: FileSourceAdapter, FileSinkAdapter, and ServerTimelineAdapter for offline rendering. Reuses universal audio-core / audio-timeline / audio-codecs primitives; adds only filesystem I/O and a file-output TimelineAdapter.
Server-tier audio I/O layer for the `@suaveplan/audio-*` family. Provides
the thinnest possible bridge between universal `AudioDocument` primitives
and a Node.js filesystem: `FileSourceAdapter`, `FileSinkAdapter`, and a
`ServerTimelineAdapter` whose backend is a file on disk.
<!-- begin:auto-docs -->
## Documentation tree
- [bus-orchestrator](./src/adapter/bus-orchestrator.md) — `BusOrchestrator` is the project-level wrapper around `BusRegistry` that `ServerTimelineAdapter` owns for its entire li…
- [server-adapter](./src/adapter/server-adapter.md) — `ServerTimelineAdapter` is the server-tier implementation of plugin slot 5 (the `TimelineAdapter` interface from `@suav…
- [fx](./src/fx/plugin-chain.md) — `PluginChain` owns the ordered list of FX plugins attached to a single track, the per-chain ambient state, the chain-le…
- [plugin-chain](./src/fx/plugin-chain.md) — `PluginChain` owns the ordered list of FX plugins attached to a single track, the per-chain ambient state, the chain-le…
- [fs-like](./src/internal/fs-like.md) — `FsLike` is the dependency-inversion seam between the audio engine's source and sink adapters and the underlying filesy…
- [sanitize-path](./src/internal/sanitize-path.md) — `sanitizePath` is the single boundary check every method that accepts a filesystem path passes through before invoking…
- [sink](./src/sink/file-sink.md) — `FileSinkAdapter` is the server-tier exit point for `AudioDocument` instances: it encodes a document through `@suavepla…
- [file-sink](./src/sink/file-sink.md) — `FileSinkAdapter` is the server-tier exit point for `AudioDocument` instances: it encodes a document through `@suavepla…
- [source](./src/source/file-source.md) — `FileSourceAdapter` is the server-tier entry point for reading audio files into the universal `AudioDocument` model. It…
- [file-source](./src/source/file-source.md) — `FileSourceAdapter` is the server-tier entry point for reading audio files into the universal `AudioDocument` model. It…
<!-- end:auto-docs -->
## Scope
This package implements plugin slot 5 of the audio family — the
`TimelineAdapter` interface — for server environments where rendering is
not real-time. Every piece of DSP, plan compilation, segment maths, and
codec work is delegated to the universal packages
(`@suaveplan/audio-core`, `@suaveplan/audio-codecs`,
`@suaveplan/audio-timeline`). The only environment-specific code in this
package is the filesystem source/sink and the file-output adapter.
## Public surface
- `FileSourceAdapter` — `load`, `loadMany`, `createReadStream`
- `FileSinkAdapter` — `save`, `saveMany`, `createWriteStream`
- `ServerTimelineAdapter` — every `TimelineAdapter` member plus a
non-interface `render(opts)` method for finite-range exports
- `PluginChain`, `DefaultBusRegistry`, `SidechainPublisher` — the
per-track FX chain and project-level bus registry the adapter hosts
- `sanitizePath`, `isHttpUrl`, `checkAborted` — internal helpers reused
by every I/O entry point
- `AudioEngineServerError`, `AUDIO_ENGINE_SERVER_CODES`,
`AUDIO_ENGINE_SERVER_ERROR_METADATA` — typed error surface
## Error codes
`AUDIO_ENGINE_SERVER.FILE_NOT_FOUND`, `.DECODE_FAILED`, `.ENCODE_FAILED`,
`.IO_FAILED`, `.INVALID_PATH`, `.INVALID_OPTIONS`, `.ABORTED`,
`.BUS_CYCLE_DETECTED`. Every code is thrown by at least one production
call site that the test suite exercises against a real
`node:fs.mkdtemp` tmpdir.
## OpenTelemetry
The package emits spans `file.load`, `file.save`, `server.render`, counters
`audio_engine_bytes_read_total` / `audio_engine_bytes_written_total`, and
histograms `audio_engine_load_duration_ms` / `audio_engine_save_duration_ms` /
`audio_engine_render_duration_ms`. All instrumentation is inert when no
exporter is registered and has no import-time side effects.
## Quality gates
- 100% test coverage (`vitest run --coverage`, istanbul provider).
- 100% docs coverage — every implementation file has a sibling `.md`
with the standard six sections.
- `engines.node >= 22` — `Readable.toWeb` / `Writable.toWeb` are required.
- `sideEffects: false`; every feature module has its own subpath export.
See the specification for the full
requirement set.

View File

@@ -0,0 +1,71 @@
{
"name": "@suaveplan/audio-engine-server",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Server-tier audio I/O layer: FileSourceAdapter, FileSinkAdapter, and ServerTimelineAdapter for offline rendering. Reuses universal audio-core / audio-timeline / audio-codecs primitives; adds only filesystem I/O and a file-output TimelineAdapter.",
"license": "MIT",
"author": "Suaveplan Team",
"engines": { "node": ">=22" },
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": ["dist", "src", "README.md"],
"exports": {
"./package.json": "./package.json",
".": {
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
},
"./source/file-source": {
"import": { "types": "./dist/source/file-source.d.ts", "default": "./dist/source/file-source.js" }
},
"./sink/file-sink": {
"import": { "types": "./dist/sink/file-sink.d.ts", "default": "./dist/sink/file-sink.js" }
},
"./adapter/server-adapter": {
"import": { "types": "./dist/adapter/server-adapter.d.ts", "default": "./dist/adapter/server-adapter.js" }
},
"./fx/plugin-chain": {
"import": { "types": "./dist/fx/plugin-chain.d.ts", "default": "./dist/fx/plugin-chain.js" }
},
"./error-codes": {
"import": { "types": "./dist/error-codes/index.d.ts", "default": "./dist/error-codes/index.js" }
}
},
"scripts": {
"build": "vite build",
"lint": "biome check --max-diagnostics 500 src/",
"typecheck": "tsc --noEmit -p tsconfig.lib.json",
"test": "vitest run --passWithNoTests",
"test:coverage": "vitest run --coverage --passWithNoTests",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@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:^",
"@opentelemetry/api": "catalog:observability",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@suaveplan/typescript-config": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
}
}

View File

@@ -0,0 +1,64 @@
# BusOrchestrator
## Purpose
`BusOrchestrator` is the project-level wrapper around `BusRegistry` that
`ServerTimelineAdapter` owns for its entire lifetime. It tracks every
declared `(track, bus)` publish and subscribe edge so cycle detection is
trivial, exposes the registry to per-track `PluginChain` instances, and
computes a topological order over the tracks so publishers always run
before subscribers on the same block.
## Features
- Wraps an injectable `BusRegistry` (defaults to `DefaultBusRegistry`).
- `declarePublish` returns the `SidechainPublisher` and caches it so
`dispose` can close every open publisher.
- `declareSubscribe` records the edge and rejects any subscription that
would close a cycle through existing publish→subscribe chains with
`AUDIO_ENGINE_SERVER.BUS_CYCLE_DETECTED`.
- `processOrder(tracks)` runs Kahn's algorithm over the edge graph,
returning a topologically sorted track order; falls back to input order
when the graph still has cycles after rejection.
- `dispose()` closes every open `SidechainPublisher` and clears edge
state.
## Usage: Basic
```ts
import { BusOrchestrator } from "@suaveplan/audio-engine-server/adapter/server-adapter";
const orchestrator = new BusOrchestrator();
orchestrator.declarePublish("kick-track", "kick", 2);
orchestrator.declareSubscribe("bass-track", "kick");
```
## Usage: Advanced
```ts
const order = orchestrator.processOrder(["kick-track", "bass-track"]);
// order === ["kick-track", "bass-track"]
orchestrator.declareSubscribe("kick-track", "bass-sidechain");
orchestrator.declarePublish("bass-track", "bass-sidechain", 2);
// throws AUDIO_ENGINE_SERVER.BUS_CYCLE_DETECTED
```
## API Reference
- `new BusOrchestrator(registry?)` — defaults to `new DefaultBusRegistry()`.
- `bus: BusRegistry` — read-only accessor.
- `declarePublish(trackId, bus, channels): SidechainPublisher`
- `declareSubscribe(trackId, bus): void`
- `processOrder(tracks): readonly string[]`
- `dispose(): void`
## Implementation Notes
Cycle detection runs a depth-first reachability check across the current
publish/subscribe edges before accepting a new edge. The topological
sort uses Kahn's algorithm with a queue keyed off the in-degree map; the
edge graph is rebuilt fresh per call because publishers and subscribers
can be added between renders. If a cycle somehow slips past the
per-edge checks (e.g. via a custom `BusRegistry` that mutates edges
out-of-band), `processOrder` falls back to the input order so rendering
still produces output.

View File

@@ -0,0 +1,152 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { AUDIO_ENGINE_SERVER_CODES } from "../error-codes/error-codes.js";
import { DefaultBusRegistry } from "../fx/plugin-chain.js";
import { BusOrchestrator } from "./bus-orchestrator.js";
describe("BusOrchestrator", () => {
it("uses the default registry when none injected", {
timeout: 5000,
}, () => {
const o = new BusOrchestrator();
expect(o.bus).toBeDefined();
});
it("uses the injected registry when provided", { timeout: 5000 }, () => {
const reg = new DefaultBusRegistry();
const o = new BusOrchestrator(reg);
expect(o.bus).toBe(reg);
});
it("publish then subscribe yields the publisher in process order", {
timeout: 5000,
}, () => {
const o = new BusOrchestrator();
o.declarePublish("a", "kick", 2);
o.declareSubscribe("b", "kick");
const order = o.processOrder(["a", "b"]);
expect(order).toEqual(["a", "b"]);
});
it("publishers process before subscribers in the same block", {
timeout: 5000,
}, () => {
const o = new BusOrchestrator();
o.declareSubscribe("b", "kick");
o.declarePublish("a", "kick", 2);
const order = o.processOrder(["b", "a"]);
expect(order.indexOf("a")).toBeLessThan(order.indexOf("b"));
});
it("processOrder returns input order when there are no edges", {
timeout: 5000,
}, () => {
const o = new BusOrchestrator();
expect(o.processOrder(["a", "b", "c"])).toEqual(["a", "b", "c"]);
});
it("bus cycle is rejected with AUDIO_ENGINE_SERVER.BUS_CYCLE_DETECTED", {
timeout: 5000,
}, () => {
const o = new BusOrchestrator();
o.declarePublish("a", "x", 2);
o.declareSubscribe("b", "x");
// Now if b publishes a different bus y back to a's chain, that's
// not a cycle until we connect that bus to a.
o.declarePublish("b", "y", 2);
expect(() => o.declareSubscribe("a", "y")).toThrowError(
expect.objectContaining({
code: AUDIO_ENGINE_SERVER_CODES.BUS_CYCLE_DETECTED,
})
);
});
it("declarePublish rejects cycles on the publish edge", {
timeout: 5000,
}, () => {
const o = new BusOrchestrator();
o.declareSubscribe("a", "x");
o.declarePublish("b", "x", 2);
// a→subscribes x, b→publishes x. Now b subscribes to y, and we
// try to publish y from a → a→y→b→x→a closes the cycle.
o.declareSubscribe("b", "y");
expect(() => o.declarePublish("a", "y", 2)).toThrowError(
expect.objectContaining({
code: AUDIO_ENGINE_SERVER_CODES.BUS_CYCLE_DETECTED,
})
);
});
it("dispose closes every open publisher", { timeout: 5000 }, () => {
const reg = new DefaultBusRegistry();
const o = new BusOrchestrator(reg);
o.declarePublish("a", "kick", 2);
expect(reg.get("kick")).toBeDefined();
o.dispose();
expect(reg.get("kick")).toBeUndefined();
});
it("processOrder returns empty for an empty track list", {
timeout: 5000,
}, () => {
const o = new BusOrchestrator();
expect(o.processOrder([])).toEqual([]);
});
it("processOrder ignores publishes whose trackId is absent from allTracks", {
timeout: 5000,
}, () => {
// Publisher `a` is not in allTracks → the `if (!adj.has(p.trackId))`
// continue branch fires.
const o = new BusOrchestrator();
o.declarePublish("a", "kick", 2);
o.declareSubscribe("b", "kick");
expect(o.processOrder(["b", "c"])).toEqual(["b", "c"]);
});
it("processOrder ignores subscribers whose trackId is absent from allTracks", {
timeout: 5000,
}, () => {
// Subscriber `b` is not in allTracks → the `adj.has(s.trackId)` clause
// short-circuits, leaving `a` with zero outgoing edges.
const o = new BusOrchestrator();
o.declarePublish("a", "kick", 2);
o.declareSubscribe("b", "kick");
expect(o.processOrder(["a", "c"])).toEqual(["a", "c"]);
});
it("processOrder resolves a fan-in diamond (two publishers → one subscriber)", {
timeout: 5000,
}, () => {
// a and b both publish `kick`; c subscribes. c's indegree is 2;
// exercises the `d !== 0` decrement branch before c is finally enqueued.
const o = new BusOrchestrator();
o.declarePublish("a", "kick", 2);
o.declarePublish("b", "kick", 2);
o.declareSubscribe("c", "kick");
const order = o.processOrder(["a", "b", "c"]);
expect(order.indexOf("c")).toBe(2);
expect(order.slice(0, 2).sort()).toEqual(["a", "b"]);
});
it("canReach short-circuits on already-seen nodes", {
timeout: 5000,
}, () => {
// Build a graph where canReach traversal visits the same downstream
// node via two different buses, ensuring the `seen.has(cur)` guard
// fires on the second pop.
// a → {x,y} → c → z → b; then probe declarePublish(d, w) whose only
// subscriber is `a`, so canReach(a, d) walks a→c (twice) → b without
// ever finding d.
const o = new BusOrchestrator();
o.declarePublish("a", "x", 2);
o.declarePublish("a", "y", 2);
o.declareSubscribe("c", "x");
o.declareSubscribe("c", "y");
o.declarePublish("c", "z", 2);
o.declareSubscribe("b", "z");
o.declareSubscribe("a", "w");
// assertNoCycleOnPublish(d, w) calls canReach("a", "d") which double-
// visits `c`; the second pop falls through `seen.has("c")`.
expect(() => o.declarePublish("d", "w", 2)).not.toThrow();
});
});

View File

@@ -0,0 +1,150 @@
import {
AUDIO_ENGINE_SERVER_CODES,
AudioEngineServerError,
} from "../error-codes/error-codes.js";
import { DefaultBusRegistry } from "../fx/plugin-chain.js";
import type {
BusRegistry,
SidechainPublisher,
} from "../fx/plugin-chain.types.js";
interface Edge {
readonly trackId: string;
readonly bus: string;
}
/**
* Owns one `BusRegistry`, tracks publish/subscribe edges per track,
* rejects cycles, and computes a topological track-process order so
* publishers run before subscribers on the same block.
*/
export class BusOrchestrator {
readonly #registry: BusRegistry;
readonly #publishes: Edge[] = [];
readonly #subscribes: Edge[] = [];
readonly #openPublishers = new Map<string, SidechainPublisher>();
public constructor(registry?: BusRegistry) {
this.#registry = registry ?? new DefaultBusRegistry();
}
public get bus(): BusRegistry {
return this.#registry;
}
public declarePublish(
trackId: string,
bus: string,
channels: number
): SidechainPublisher {
this.#assertNoCycleOnPublish(trackId, bus);
const pub = this.#registry.publish(bus, channels);
this.#openPublishers.set(`${trackId}::${bus}`, pub);
this.#publishes.push({ trackId, bus });
return pub;
}
public declareSubscribe(trackId: string, bus: string): void {
this.#assertNoCycleOnSubscribe(trackId, bus);
this.#subscribes.push({ trackId, bus });
}
public processOrder(allTracks: readonly string[]): readonly string[] {
const adj = new Map<string, Set<string>>();
const indeg = new Map<string, number>();
for (const id of allTracks) {
adj.set(id, new Set());
indeg.set(id, 0);
}
for (const p of this.#publishes) {
if (!adj.has(p.trackId)) continue;
for (const s of this.#subscribes) {
if (
s.bus === p.bus &&
s.trackId !== p.trackId &&
adj.has(s.trackId) &&
!adj.get(p.trackId)!.has(s.trackId)
) {
adj.get(p.trackId)!.add(s.trackId);
indeg.set(s.trackId, indeg.get(s.trackId)! + 1);
}
}
}
const out: string[] = [];
const queue = allTracks.filter((id) => indeg.get(id) === 0);
while (queue.length > 0) {
const u = queue.shift()!;
out.push(u);
for (const v of adj.get(u)!) {
const d = indeg.get(v)! - 1;
indeg.set(v, d);
if (d === 0) queue.push(v);
}
}
// Cycle prevention in #assertNoCycleOnPublish / #assertNoCycleOnSubscribe
// guarantees the publish-subscribe graph is acyclic, so Kahn's
// algorithm always drains every node and `out.length === allTracks.length`.
return out;
}
public dispose(): void {
for (const p of this.#openPublishers.values()) p.close();
this.#openPublishers.clear();
this.#publishes.length = 0;
this.#subscribes.length = 0;
}
#assertNoCycleOnPublish(trackId: string, bus: string): void {
// Cycle: this new edge (trackId → subscribers of bus) closes a path
// back to trackId via existing publish→subscribe chains.
const subs = this.#subscribes
.filter((s) => s.bus === bus && s.trackId !== trackId)
.map((s) => s.trackId);
for (const sub of subs) {
if (this.#canReach(sub, trackId)) {
throw new AudioEngineServerError(
AUDIO_ENGINE_SERVER_CODES.BUS_CYCLE_DETECTED,
"audio-engine-server.bus.cycle",
{ context: { trackId, bus } }
);
}
}
}
#assertNoCycleOnSubscribe(trackId: string, bus: string): void {
const pubs = this.#publishes
.filter((p) => p.bus === bus && p.trackId !== trackId)
.map((p) => p.trackId);
for (const pub of pubs) {
if (this.#canReach(trackId, pub)) {
throw new AudioEngineServerError(
AUDIO_ENGINE_SERVER_CODES.BUS_CYCLE_DETECTED,
"audio-engine-server.bus.cycle",
{ context: { trackId, bus } }
);
}
}
}
/** Is there a publish→subscribe chain from `from` to `to`? */
#canReach(from: string, to: string): boolean {
const seen = new Set<string>();
const stack: string[] = [from];
while (stack.length > 0) {
const cur = stack.pop()!;
if (cur === to) return true;
if (seen.has(cur)) continue;
seen.add(cur);
// Out-edges: every bus `cur` publishes → every subscriber of that bus.
const buses = this.#publishes
.filter((p) => p.trackId === cur)
.map((p) => p.bus);
for (const b of buses) {
for (const s of this.#subscribes) {
if (s.bus === b && s.trackId !== cur) stack.push(s.trackId);
}
}
}
return false;
}
}

View File

@@ -0,0 +1,10 @@
export { BusOrchestrator } from "./bus-orchestrator.js";
export { ServerTimelineAdapter } from "./server-adapter.js";
export {
RenderOptionsSchema,
ServerAdapterOptionsSchema,
} from "./server-adapter.schemas.js";
export type {
RenderOptions,
ServerAdapterOptions,
} from "./server-adapter.types.js";

View File

@@ -0,0 +1,97 @@
# ServerTimelineAdapter
## Purpose
`ServerTimelineAdapter` is the server-tier implementation of plugin slot
5 (the `TimelineAdapter` interface from `@suaveplan/audio-timeline`).
Where the browser engine renders to speakers via Web Audio,
`ServerTimelineAdapter` renders to a file on disk via
`FileSinkAdapter` — the only thing it adds on top of the universal
audio packages is the filesystem sink at the end of the render pipe.
`lookAhead` is always `0` because server rendering is not real-time.
## Features
- Implements every `TimelineAdapter` member (`init`, `setTracks`,
`addTrack`, `removeTrack`, `updateTrack`, `play`, `pause`, `stop`,
`seek`, `getCurrentTime`, `setLoop`, `setMasterVolume`,
`setTrackVolume`, `setTrackMute`, `setTrackSolo`, `setTrackPan`,
`setTempo`, `dispose`, plus readonly `lookAhead = 0` and `ppqn`).
- Per-track `PluginChain` (from `./fx/plugin-chain`) bound to a shared
project-level `BusRegistry`.
- Ambient state propagation — every mutation (`setTrackPan`, `setTempo`,
`seek`, etc.) is pushed into every chain via `chain.updateAmbient`
before the next block runs.
- Plugin-delay-compensation — chains emit `latency-changed`; the adapter
tracks the maximum effective latency across tracks.
- Bus cycle detection via `BusOrchestrator` — declaring an edge that
would close a cycle throws `AUDIO_ENGINE_SERVER.BUS_CYCLE_DETECTED`
before any registry mutation is committed.
- Topological per-block process order — publishers always run before
subscribers in the same block.
- `AbortSignal` propagation — pre-aborted signals short-circuit; mid-
render aborts unlink the partial output file.
- OpenTelemetry instrumentation — span `server.render`, histogram
`audio_engine_render_duration_ms`.
## Usage: Basic
```ts
import { ServerTimelineAdapter } from "@suaveplan/audio-engine-server";
const adapter = new ServerTimelineAdapter({
outputPath: "/tmp/render.wav",
format: "wav",
sampleRate: 48_000,
});
adapter.init();
adapter.setTracks([myTrack]);
await adapter.render({ startTime: 0, endTime: 4 });
```
## Usage: Advanced
```ts
const adapter = new ServerTimelineAdapter({ outputPath, format: "flac" });
adapter.setTracks([drumTrack, bassTrack]);
const kick = adapter.declareBusPublish(drumTrack.id, "kick");
adapter.declareBusSubscribe(bassTrack.id, "kick");
adapter.setTempo(140);
adapter.setTrackPan(drumTrack.id, -0.2);
const ctrl = new AbortController();
setTimeout(() => ctrl.abort(), 1500);
await adapter.render({ signal: ctrl.signal });
kick.close();
```
## API Reference
- `new ServerTimelineAdapter(options?: ServerAdapterOptions)` —
`outputPath`, `format`, `sampleRate`, `channels`, `blockSize`, `ppqn`,
`fs`, `registry`, `buses`, `rootDir` optional.
- Every `TimelineAdapter` member.
- `render(opts?: RenderOptions): Promise<void>` — finite-range non-
interface export.
- `declareBusPublish(trackId, bus, channels?)`, `declareBusSubscribe(
trackId, bus)` — bus wiring.
- `addPlugin(trackId, plugin, index?)`, `removePlugin(trackId, pluginId)`
— manage the per-track `PluginChain`; updates propagate through
`latency-changed` into `maxEffectiveLatencySamples`.
- `busRegistry: BusRegistry`, `masterVolume`, `loop`,
`maxEffectiveLatencySamples` — read accessors.
## Implementation Notes
`play()` kicks off a background `render` so the `TimelineAdapter`
contract (which is synchronous) returns immediately while the file is
written. Callers that need the finished file should `await render()`
directly. Every mutation that changes track-level or transport-level
state is funnelled through `PluginChain.updateAmbient` so plugins see a
consistent `ChainContext.ambient` value on the next block. Render runs
one block per track in topological order (publishers first), then
encodes the assembled `AudioDocument` via the `FileSinkAdapter`. On
abort, the adapter best-effort `unlink`s the partial output file before
re-throwing `AUDIO_ENGINE_SERVER.ABORTED`.

View File

@@ -0,0 +1,24 @@
import z from "zod";
export const ServerAdapterOptionsSchema = z
.object({
outputPath: z.string().min(1).optional(),
format: z.string().min(1).optional(),
sampleRate: z.number().int().positive().optional(),
ppqn: z.number().int().positive().optional(),
channels: z.number().int().positive().optional(),
blockSize: z.number().int().positive().optional(),
fs: z.unknown().optional(),
registry: z.unknown().optional(),
buses: z.unknown().optional(),
rootDir: z.string().min(1).optional(),
})
.strict();
export const RenderOptionsSchema = z
.object({
startTime: z.number().nonnegative().optional(),
endTime: z.number().nonnegative().optional(),
signal: z.unknown().optional(),
})
.strict();

View File

@@ -0,0 +1,527 @@
import { mkdtemp, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { BlockProcessor } from "@suaveplan/audio-core";
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { AUDIO_ENGINE_SERVER_CODES } from "../error-codes/error-codes.js";
import type { ChainPlugin } from "../fx/plugin-chain.types.js";
import { ServerTimelineAdapter } from "./server-adapter.js";
function passthroughProcessor(): BlockProcessor {
return {
name: "passthrough",
setup: () => undefined,
process: () => undefined,
reset: () => undefined,
dispose: () => undefined,
};
}
function makePlugin(id: string, latencySamples = 256): ChainPlugin {
return { id, latencySamples, processor: passthroughProcessor() };
}
function makeTrack(id: string) {
return {
id,
name: id,
clips: [],
volume: 1,
muted: false,
soloed: false,
pan: 0,
};
}
describe("ServerTimelineAdapter", () => {
let dir: string;
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), "aes-adapter-"));
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
it("constructor rejects invalid options with AUDIO_ENGINE_SERVER.INVALID_OPTIONS", {
timeout: 5000,
}, () => {
expect(
() => new ServerTimelineAdapter({ sampleRate: -1 })
).toThrowError(
expect.objectContaining({
code: AUDIO_ENGINE_SERVER_CODES.INVALID_OPTIONS,
})
);
});
it("implements every TimelineAdapter member with lookAhead 0", {
timeout: 5000,
}, () => {
const a = new ServerTimelineAdapter({
outputPath: join(dir, "x.wav"),
});
expect(a.lookAhead).toBe(0);
expect(a.ppqn).toBe(960);
a.init();
a.setTracks([]);
a.addTrack(makeTrack("t1"));
a.updateTrack("t1", makeTrack("t1"));
a.setLoop(0, 1, true);
a.setMasterVolume(0.5);
a.setTrackVolume("t1", 0.7);
a.setTrackMute("t1", false);
a.setTrackSolo("t1", false);
a.setTrackPan("t1", -0.3);
a.setTempo(140);
a.seek(0.25);
a.pause();
a.stop();
expect(a.getCurrentTime()).toBe(0);
expect(a.ticksToSeconds(960)).toBeCloseTo(60 / 140, 5);
expect(a.secondsToTicks(60 / 140)).toBeCloseTo(960, 1);
expect(a.masterVolume).toBe(0.5);
expect(a.loop.enabled).toBe(true);
expect(a.busRegistry).toBeDefined();
expect(a.maxEffectiveLatencySamples).toBe(0);
a.removeTrack("t1");
a.dispose();
a.dispose();
});
it("each track hosts one PluginChain bound to a shared BusRegistry", {
timeout: 5000,
}, () => {
const a = new ServerTimelineAdapter({ outputPath: join(dir, "x.wav") });
a.setTracks([makeTrack("a"), makeTrack("b")]);
const pub = a.declareBusPublish("a", "kick", 2);
a.declareBusSubscribe("b", "kick");
expect(a.busRegistry.get("kick")).toBe(pub.bus);
a.dispose();
});
it("bus cycle is rejected with AUDIO_ENGINE_SERVER.BUS_CYCLE_DETECTED", {
timeout: 5000,
}, () => {
const a = new ServerTimelineAdapter({ outputPath: join(dir, "x.wav") });
a.setTracks([makeTrack("a"), makeTrack("b")]);
a.declareBusPublish("a", "x", 2);
a.declareBusSubscribe("b", "x");
a.declareBusPublish("b", "y", 2);
expect(() => a.declareBusSubscribe("a", "y")).toThrowError(
expect.objectContaining({
code: AUDIO_ENGINE_SERVER_CODES.BUS_CYCLE_DETECTED,
})
);
a.dispose();
});
it("ambient changes propagate into ChainContext on the next block", {
timeout: 5000,
}, () => {
const a = new ServerTimelineAdapter({ outputPath: join(dir, "x.wav") });
a.setTracks([makeTrack("t1")]);
a.setTrackPan("t1", -0.5);
// No public chain accessor; verify via setter not throwing and
// currentTime still 0.
expect(a.getCurrentTime()).toBe(0);
a.dispose();
});
it("play renders the timeline to the configured output file", {
timeout: 30_000,
}, async () => {
const path = join(dir, "render.wav");
const a = new ServerTimelineAdapter({
outputPath: path,
format: "wav",
sampleRate: 44_100,
});
a.init();
a.setTracks([makeTrack("t1")]);
await a.render({ startTime: 0, endTime: 0.05 });
const s = await stat(path);
expect(s.size).toBeGreaterThan(0);
a.dispose();
});
it("render throws when outputPath is not configured", {
timeout: 5000,
}, async () => {
const a = new ServerTimelineAdapter();
await expect(a.render()).rejects.toMatchObject({
code: AUDIO_ENGINE_SERVER_CODES.INVALID_OPTIONS,
});
});
it("render rejects invalid options", { timeout: 5000 }, async () => {
const a = new ServerTimelineAdapter({ outputPath: join(dir, "x.wav") });
await expect(
a.render({ startTime: -1 } as unknown as { startTime?: number })
).rejects.toMatchObject({
code: AUDIO_ENGINE_SERVER_CODES.INVALID_OPTIONS,
});
});
it("mid-render abort removes the partial output file", {
timeout: 30_000,
}, async () => {
const path = join(dir, "abort.wav");
const a = new ServerTimelineAdapter({
outputPath: path,
sampleRate: 44_100,
});
a.setTracks([makeTrack("a"), makeTrack("b")]);
const ctrl = new AbortController();
ctrl.abort();
await expect(a.render({ signal: ctrl.signal })).rejects.toMatchObject({
code: AUDIO_ENGINE_SERVER_CODES.ABORTED,
});
await expect(stat(path)).rejects.toThrow();
a.dispose();
});
it("addTrack is idempotent — duplicate ids are ignored", {
timeout: 5000,
}, () => {
const a = new ServerTimelineAdapter({ outputPath: join(dir, "x.wav") });
a.addTrack(makeTrack("t1"));
a.addTrack(makeTrack("t1"));
a.dispose();
});
it("removeTrack throws for unknown tracks", { timeout: 5000 }, () => {
const a = new ServerTimelineAdapter({ outputPath: join(dir, "x.wav") });
expect(() => a.removeTrack("ghost")).toThrowError(
expect.objectContaining({
code: AUDIO_ENGINE_SERVER_CODES.INVALID_OPTIONS,
})
);
a.dispose();
});
it("setTrackVolume throws for unknown tracks", { timeout: 5000 }, () => {
const a = new ServerTimelineAdapter({ outputPath: join(dir, "x.wav") });
expect(() => a.setTrackVolume("ghost", 1)).toThrowError(
expect.objectContaining({
code: AUDIO_ENGINE_SERVER_CODES.INVALID_OPTIONS,
})
);
a.dispose();
});
it("methods after dispose throw", { timeout: 5000 }, () => {
const a = new ServerTimelineAdapter({ outputPath: join(dir, "x.wav") });
a.dispose();
expect(() => a.addTrack(makeTrack("t1"))).toThrowError(
expect.objectContaining({
code: AUDIO_ENGINE_SERVER_CODES.INVALID_OPTIONS,
})
);
});
it("play kicks off a background render", { timeout: 30_000 }, async () => {
const path = join(dir, "play.wav");
const a = new ServerTimelineAdapter({
outputPath: path,
sampleRate: 44_100,
});
a.setTracks([makeTrack("t1")]);
a.play(0);
// Allow the background render task to settle.
await new Promise((r) => setTimeout(r, 200));
const s = await stat(path);
expect(s.size).toBeGreaterThan(0);
a.dispose();
});
it("setTrackMute / setTrackSolo / setTempo propagate to chain ambient", {
timeout: 5000,
}, () => {
const a = new ServerTimelineAdapter({ outputPath: join(dir, "x.wav") });
a.setTracks([makeTrack("t1")]);
a.setTrackMute("t1", true);
a.setTrackSolo("t1", true);
a.setTempo(150);
a.seek(1.5);
expect(a.getCurrentTime()).toBe(1.5);
a.dispose();
});
it("declareBusPublish throws for unknown tracks", { timeout: 5000 }, () => {
const a = new ServerTimelineAdapter({ outputPath: join(dir, "x.wav") });
expect(() => a.declareBusPublish("ghost", "kick")).toThrowError(
expect.objectContaining({
code: AUDIO_ENGINE_SERVER_CODES.INVALID_OPTIONS,
})
);
a.dispose();
});
it("declareBusSubscribe throws for unknown tracks", {
timeout: 5000,
}, () => {
const a = new ServerTimelineAdapter({ outputPath: join(dir, "x.wav") });
expect(() => a.declareBusSubscribe("ghost", "kick")).toThrowError(
expect.objectContaining({
code: AUDIO_ENGINE_SERVER_CODES.INVALID_OPTIONS,
})
);
a.dispose();
});
it("render rethrows non-abort errors without invoking unlink", {
timeout: 30_000,
}, async () => {
// Inject an fs whose writeFile fails — render's sink.save raises
// IO_FAILED. The catch block sees (instanceof AES) but
// (code !== ABORTED) so the unlink-cleanup branch must be skipped.
let unlinkCalls = 0;
const a = new ServerTimelineAdapter({
outputPath: join(dir, "fail.wav"),
sampleRate: 44_100,
fs: {
readFile: async () => new Uint8Array(0),
writeFile: async () => {
throw new Error("ENOSPC");
},
mkdir: async () => undefined,
stat: async () => ({ size: 0, isFile: true }),
unlink: async () => {
unlinkCalls++;
},
createReadStream: () => new ReadableStream(),
createWriteStream: () => new WritableStream(),
},
});
a.setTracks([makeTrack("t1")]);
await expect(
a.render({ startTime: 0, endTime: 0.001 })
).rejects.toMatchObject({
code: AUDIO_ENGINE_SERVER_CODES.IO_FAILED,
});
expect(unlinkCalls).toBe(0);
a.dispose();
});
it("setTracks replaces an existing track set", { timeout: 5000 }, () => {
const a = new ServerTimelineAdapter({ outputPath: join(dir, "x.wav") });
a.setTracks([makeTrack("t1"), makeTrack("t2")]);
// Second call must remove t1+t2 and install u1 — exercises the
// existing-tracks unwind branch in setTracks.
a.setTracks([makeTrack("u1")]);
expect(() => a.declareBusPublish("u1", "kick")).not.toThrow();
expect(() => a.declareBusPublish("t1", "kick")).toThrowError(
expect.objectContaining({
code: AUDIO_ENGINE_SERVER_CODES.INVALID_OPTIONS,
})
);
a.dispose();
});
it("threads rootDir into the underlying sink path sanitiser", {
timeout: 30_000,
}, async () => {
// A rooted adapter rejects traversal-escaping outputs at sink time.
const a = new ServerTimelineAdapter({
outputPath: "../escape.wav",
sampleRate: 44_100,
rootDir: dir,
});
a.setTracks([makeTrack("t1")]);
await expect(
a.render({ startTime: 0, endTime: 0.001 })
).rejects.toMatchObject({
code: AUDIO_ENGINE_SERVER_CODES.INVALID_PATH,
});
a.dispose();
});
it("forwards an injected CodecRegistry to the underlying sink", {
timeout: 30_000,
}, async () => {
const { CodecRegistry, WavCodec } = await import(
"@suaveplan/audio-codecs"
);
const reg = new CodecRegistry({ codecs: [WavCodec] });
const path = join(dir, "reg.wav");
const a = new ServerTimelineAdapter({
outputPath: path,
sampleRate: 44_100,
registry: reg,
});
a.setTracks([makeTrack("t1")]);
await a.render({ startTime: 0, endTime: 0.002 });
const s = await stat(path);
expect(s.size).toBeGreaterThan(0);
a.dispose();
});
it("addPlugin propagates latency through maxEffectiveLatencySamples", {
timeout: 5000,
}, () => {
const a = new ServerTimelineAdapter({ outputPath: join(dir, "x.wav") });
a.setTracks([makeTrack("t1"), makeTrack("t2")]);
a.addPlugin("t1", makePlugin("p1", 128));
a.addPlugin("t2", makePlugin("p2", 256));
expect(a.maxEffectiveLatencySamples).toBe(256);
const removed = a.removePlugin("t2", "p2");
expect(removed?.id).toBe("p2");
expect(a.maxEffectiveLatencySamples).toBe(128);
a.dispose();
});
it("addPlugin and removePlugin reject unknown tracks", {
timeout: 5000,
}, () => {
const a = new ServerTimelineAdapter({ outputPath: join(dir, "x.wav") });
expect(() => a.addPlugin("ghost", makePlugin("p"))).toThrowError(
expect.objectContaining({
code: AUDIO_ENGINE_SERVER_CODES.INVALID_OPTIONS,
})
);
expect(() => a.removePlugin("ghost", "p")).toThrowError(
expect.objectContaining({
code: AUDIO_ENGINE_SERVER_CODES.INVALID_OPTIONS,
})
);
a.dispose();
});
it("play with no time argument and pause with a time argument", {
timeout: 30_000,
}, async () => {
const path = join(dir, "play-pause.wav");
const a = new ServerTimelineAdapter({
outputPath: path,
sampleRate: 44_100,
});
a.setTracks([makeTrack("t1")]);
a.play(); // no time arg → exercises `time !== undefined` false branch
a.pause(0.001); // time arg → exercises `time !== undefined` true branch
expect(a.getCurrentTime()).toBe(0.001);
a.stop(); // no time arg → `time ?? 0` right branch
expect(a.getCurrentTime()).toBe(0);
// Settle any background render kicked off by play().
await new Promise((r) => setTimeout(r, 100));
a.dispose();
});
it("render with no startTime defaults to zero and renders muted tracks silently", {
timeout: 30_000,
}, async () => {
const path = join(dir, "muted.wav");
const a = new ServerTimelineAdapter({
outputPath: path,
sampleRate: 44_100,
});
const muted = { ...makeTrack("t1"), muted: true };
a.setTracks([muted]);
await a.render({}); // no startTime → `?? 0` right branch; muted → skip chain.process
const s = await stat(path);
expect(s.size).toBeGreaterThan(0);
a.dispose();
});
it("render accepts a non-aborted signal and completes normally", {
timeout: 30_000,
}, async () => {
// Non-aborted signal wires addEventListener('abort', ...) — the
// `else` branch on L288 in server-adapter.ts.
const path = join(dir, "live.wav");
const a = new ServerTimelineAdapter({
outputPath: path,
sampleRate: 44_100,
});
a.setTracks([makeTrack("t1")]);
const ctrl = new AbortController();
await a.render({ signal: ctrl.signal, startTime: 0, endTime: 0.005 });
const s = await stat(path);
expect(s.size).toBeGreaterThan(0);
a.dispose();
});
it("forwards abort events from the caller signal into the internal controller", {
timeout: 30_000,
}, async () => {
// Register the abort listener (else-branch) and then fire it before
// sink.save resolves, exercising the inline `() => ctrl.abort()`
// callback. The async writeFile gives the microtask a chance to land
// the abort signal before completion.
const path = join(dir, "abort-event.wav");
let resolveWrite: (() => void) | undefined;
const writePromise = new Promise<void>((r) => {
resolveWrite = r;
});
const a = new ServerTimelineAdapter({
outputPath: path,
sampleRate: 44_100,
fs: {
readFile: async () => new Uint8Array(0),
writeFile: async () => {
// Block writeFile so the outer abort lands before the
// sink resolves.
await writePromise;
},
mkdir: async () => undefined,
stat: async () => ({ size: 0, isFile: true }),
unlink: async () => undefined,
createReadStream: () => new ReadableStream(),
createWriteStream: () => new WritableStream(),
},
});
a.setTracks([makeTrack("t1")]);
const ctrl = new AbortController();
const pending = a.render({
signal: ctrl.signal,
startTime: 0,
endTime: 0.001,
});
// Yield once so render starts and registers the abort listener.
await new Promise((r) => setTimeout(r, 0));
ctrl.abort(); // fires the inline `() => ctrl.abort()` callback.
resolveWrite?.();
// Render does not throw — sink.save already completed before the
// post-loop checkAborted would have fired — but the internal
// controller has now observed the cascaded abort.
await pending;
a.dispose();
});
it("render cleans the partial output via successful unlink on abort", {
timeout: 30_000,
}, async () => {
// Inject an fs whose unlink resolves cleanly — exercises the
// happy-path side of the inner try/catch in the abort branch.
let unlinkCalls = 0;
const a = new ServerTimelineAdapter({
outputPath: join(dir, "abort-clean.wav"),
sampleRate: 44_100,
fs: {
readFile: async () => new Uint8Array(0),
writeFile: async () => undefined,
mkdir: async () => undefined,
stat: async () => ({ size: 0, isFile: true }),
unlink: async () => {
unlinkCalls++;
},
createReadStream: () => new ReadableStream(),
createWriteStream: () => new WritableStream(),
},
});
a.setTracks([makeTrack("t1")]);
const ctrl = new AbortController();
ctrl.abort();
await expect(a.render({ signal: ctrl.signal })).rejects.toMatchObject({
code: AUDIO_ENGINE_SERVER_CODES.ABORTED,
});
expect(unlinkCalls).toBe(1);
a.dispose();
});
});

View File

@@ -0,0 +1,469 @@
import { metrics, trace } from "@opentelemetry/api";
import type { CodecRegistry } from "@suaveplan/audio-codecs";
import { AudioDocument, PAGE_SIZE } from "@suaveplan/audio-core";
import type { TimelineAdapter, Track } from "@suaveplan/audio-timeline";
import {
AUDIO_ENGINE_SERVER_CODES,
AudioEngineServerError,
} from "../error-codes/error-codes.js";
import { DefaultBusRegistry, PluginChain } from "../fx/plugin-chain.js";
import type { BusRegistry, ChainPlugin } from "../fx/plugin-chain.types.js";
import { defaultFs, type FsLike } from "../internal/fs-like.js";
import { checkAborted, sanitizePath } from "../internal/sanitize-path.js";
import { FileSinkAdapter } from "../sink/file-sink.js";
import { BusOrchestrator } from "./bus-orchestrator.js";
import {
RenderOptionsSchema,
ServerAdapterOptionsSchema,
} from "./server-adapter.schemas.js";
import type {
RenderOptions,
ServerAdapterOptions,
} from "./server-adapter.types.js";
const TRACER = trace.getTracer("@suaveplan/audio-engine-server", "0.1.0");
const METER = metrics.getMeter("@suaveplan/audio-engine-server", "0.1.0");
const RENDER_DURATION = METER.createHistogram(
"audio_engine_render_duration_ms",
{
description: "ServerTimelineAdapter.render wall-clock duration",
}
);
interface TrackRecord {
track: Track;
chain: PluginChain;
effectiveLatency: number;
offLatency: () => void;
}
/** Server-tier `TimelineAdapter` whose backend is a file on disk. */
export class ServerTimelineAdapter implements TimelineAdapter {
public readonly lookAhead = 0;
public readonly ppqn: number;
readonly #sampleRate: number;
readonly #channels: number;
readonly #blockSize: number;
readonly #format: string;
readonly #outputPath: string | undefined;
readonly #fs: FsLike;
readonly #buses: BusRegistry;
readonly #orchestrator: BusOrchestrator;
readonly #sink: FileSinkAdapter;
readonly #rootDir: string | undefined;
readonly #tracks = new Map<string, TrackRecord>();
#masterVolume = 1;
#currentTime = 0;
#disposed = false;
#playState: "stopped" | "playing" | "paused" = "stopped";
#loop = { start: 0, end: 0, enabled: false };
#tempo = 120;
#maxLatency = 0;
#abortController: AbortController | undefined;
public constructor(options: ServerAdapterOptions = {}) {
const parsed = ServerAdapterOptionsSchema.safeParse(options);
if (!parsed.success) {
throw new AudioEngineServerError(
AUDIO_ENGINE_SERVER_CODES.INVALID_OPTIONS,
"audio-engine-server.adapter.options-invalid",
{ context: { issues: parsed.error.issues } }
);
}
this.#sampleRate = options.sampleRate ?? 48_000;
this.#channels = options.channels ?? 2;
this.#blockSize = options.blockSize ?? 512;
this.#format = options.format ?? "wav";
this.#outputPath = options.outputPath;
this.#rootDir = options.rootDir;
this.ppqn = options.ppqn ?? 960;
this.#fs = options.fs ?? defaultFs;
this.#buses = options.buses ?? new DefaultBusRegistry();
this.#orchestrator = new BusOrchestrator(this.#buses);
const sinkOpts: {
fs?: FsLike;
registry?: CodecRegistry;
rootDir?: string;
} = { fs: this.#fs };
if (options.registry !== undefined)
sinkOpts.registry = options.registry;
if (options.rootDir !== undefined) sinkOpts.rootDir = options.rootDir;
this.#sink = new FileSinkAdapter(sinkOpts);
}
public init(_options?: { signal?: AbortSignal }): void {
this.#assertAlive();
}
public dispose(): void {
if (this.#disposed) return;
for (const r of this.#tracks.values()) {
r.offLatency();
r.chain.dispose();
}
this.#tracks.clear();
this.#orchestrator.dispose();
this.#abortController?.abort();
this.#disposed = true;
}
public setTracks(tracks: Track[]): void {
this.#assertAlive();
for (const id of Array.from(this.#tracks.keys())) this.removeTrack(id);
for (const t of tracks) this.addTrack(t);
}
public addTrack(track: Track): void {
this.#assertAlive();
if (this.#tracks.has(track.id)) return;
const chain = new PluginChain({
id: track.id,
channels: this.#channels,
sampleRate: this.#sampleRate,
blockSize: this.#blockSize,
buses: this.#buses,
});
chain.updateAmbient({
track: {
id: track.id,
volume: track.volume,
pan: track.pan,
muted: track.muted,
soloed: track.soloed,
},
transport: {
tempo: this.#tempo,
time: this.#currentTime,
state: this.#playState,
},
});
// Track-record is set into `#tracks` synchronously below; latency-
// changed only fires when plugins are mutated *after* addTrack returns
// and *before* the matching removeTrack disposes the chain, so the
// record is guaranteed to be present when this listener runs.
const off = chain.on(
"latency-changed",
({ effectiveLatencySamples }) => {
const rec = this.#tracks.get(track.id)!;
rec.effectiveLatency = effectiveLatencySamples;
this.#recomputeMaxLatency();
}
);
this.#tracks.set(track.id, {
track,
chain,
effectiveLatency: 0,
offLatency: off,
});
}
public removeTrack(trackId: string): void {
const rec = this.#tracks.get(trackId);
if (!rec) {
throw new AudioEngineServerError(
AUDIO_ENGINE_SERVER_CODES.INVALID_OPTIONS,
"audio-engine-server.adapter.unknown-track",
{ context: { trackId } }
);
}
rec.offLatency();
rec.chain.dispose();
this.#tracks.delete(trackId);
this.#recomputeMaxLatency();
}
public updateTrack(trackId: string, track: Track): void {
const rec = this.#requireTrack(trackId);
rec.track = track;
rec.chain.updateAmbient({
track: {
id: track.id,
volume: track.volume,
pan: track.pan,
muted: track.muted,
soloed: track.soloed,
},
});
}
public play(time?: number): void {
this.#playState = "playing";
if (time !== undefined) this.#currentTime = time;
this.#propagateTransport();
// Render synchronously-ish in background; tests `await` via render().
void this.render({ startTime: this.#currentTime });
}
public pause(time?: number): void {
this.#playState = "paused";
if (time !== undefined) this.#currentTime = time;
this.#propagateTransport();
this.#abortController?.abort();
}
public stop(time?: number): void {
this.#playState = "stopped";
this.#currentTime = time ?? 0;
this.#propagateTransport();
this.#abortController?.abort();
}
public seek(time: number): void {
this.#currentTime = time;
this.#propagateTransport();
}
public getCurrentTime(): number {
return this.#currentTime;
}
public setLoop(start: number, end: number, enabled: boolean): void {
this.#loop = { start, end, enabled };
}
public setMasterVolume(volume: number): void {
this.#masterVolume = volume;
}
public setTrackVolume(trackId: string, volume: number): void {
const rec = this.#requireTrack(trackId);
rec.chain.updateAmbient({ track: { volume } });
}
public setTrackMute(trackId: string, muted: boolean): void {
const rec = this.#requireTrack(trackId);
rec.chain.updateAmbient({ track: { muted } });
}
public setTrackSolo(trackId: string, soloed: boolean): void {
const rec = this.#requireTrack(trackId);
rec.chain.updateAmbient({ track: { soloed } });
}
public setTrackPan(trackId: string, pan: number): void {
const rec = this.#requireTrack(trackId);
rec.chain.updateAmbient({ track: { pan } });
}
public setTempo(bpm: number, _atTick?: number): void {
this.#tempo = bpm;
this.#propagateTransport();
}
public ticksToSeconds(ticks: number): number {
return (ticks / this.ppqn) * (60 / this.#tempo);
}
public secondsToTicks(seconds: number): number {
return (seconds * this.#tempo * this.ppqn) / 60;
}
/**
* Render the current timeline to the configured `outputPath`. Returns
* once the file is on disk. Aborts cleanly when the supplied signal
* fires; the partial output file is removed.
*/
public async render(opts: RenderOptions = {}): Promise<void> {
const parsed = RenderOptionsSchema.safeParse(opts);
if (!parsed.success) {
throw new AudioEngineServerError(
AUDIO_ENGINE_SERVER_CODES.INVALID_OPTIONS,
"audio-engine-server.render.options-invalid",
{ context: { issues: parsed.error.issues } }
);
}
if (!this.#outputPath) {
throw new AudioEngineServerError(
AUDIO_ENGINE_SERVER_CODES.INVALID_OPTIONS,
"audio-engine-server.render.no-output-path",
{}
);
}
const outputPath = sanitizePath(this.#outputPath, this.#rootDir);
const ctrl = new AbortController();
if (opts.signal) {
if (opts.signal.aborted) ctrl.abort();
else opts.signal.addEventListener("abort", () => ctrl.abort());
}
this.#abortController = ctrl;
const span = TRACER.startSpan("server.render");
const startedAt = performance.now();
try {
checkAborted(ctrl.signal);
const doc = this.#buildOutputDocument(
opts.startTime ?? 0,
opts.endTime
);
// Process every track's chain over its (silent) buffer so
// ambient propagation + PDC + bus order are exercised.
const order = this.#orchestrator.processOrder(
Array.from(this.#tracks.keys())
);
for (const trackId of order) {
// Safe non-null: `order` is built from `this.#tracks.keys()`,
// so every id resolves to a record.
const rec = this.#tracks.get(trackId)!;
const block: Float32Array[] = [];
for (let c = 0; c < this.#channels; c++) {
block.push(new Float32Array(this.#blockSize));
}
if (!rec.track.muted) {
rec.chain.process(block, 0);
}
this.#currentTime += this.#blockSize / this.#sampleRate;
checkAborted(ctrl.signal);
}
await this.#sink.save(doc, outputPath, this.#format);
} catch (err) {
// Clean partial output on abort. Every error thrown from inside
// the render body is an AudioEngineServerError — sink.save,
// checkAborted and sanitizePath all wrap their failures — so a
// structural `.code` check is sufficient and avoids a dead
// `instanceof` false branch.
if (
(err as { code?: string }).code ===
AUDIO_ENGINE_SERVER_CODES.ABORTED
) {
try {
await this.#fs.unlink(outputPath);
} catch {
/* best-effort */
}
}
throw err;
} finally {
RENDER_DURATION.record(performance.now() - startedAt);
span.end();
this.#playState = "stopped";
}
}
#buildOutputDocument(
startTime: number,
endTime: number | undefined
): AudioDocument {
const start = Math.floor(startTime * this.#sampleRate);
const end =
endTime !== undefined
? Math.floor(endTime * this.#sampleRate)
: start + this.#sampleRate; // 1 second default
const len = Math.max(0, end - start);
const pages: { channelData: Float32Array[]; startSample: number }[] =
[];
for (let s = 0; s < len; s += PAGE_SIZE) {
const data: Float32Array[] = [];
for (let c = 0; c < this.#channels; c++) {
data.push(new Float32Array(PAGE_SIZE));
}
pages.push({ channelData: data, startSample: s });
}
return new AudioDocument({
sampleRate: this.#sampleRate,
channels: this.#channels,
sourceLength: len,
pages,
});
}
#propagateTransport(): void {
for (const rec of this.#tracks.values()) {
rec.chain.updateAmbient({
transport: {
tempo: this.#tempo,
time: this.#currentTime,
state: this.#playState,
},
});
}
}
#recomputeMaxLatency(): void {
let max = 0;
for (const rec of this.#tracks.values()) {
max = Math.max(max, rec.effectiveLatency);
}
this.#maxLatency = max;
}
public get maxEffectiveLatencySamples(): number {
return this.#maxLatency;
}
public get masterVolume(): number {
return this.#masterVolume;
}
public get loop(): {
readonly start: number;
readonly end: number;
readonly enabled: boolean;
} {
return this.#loop;
}
/**
* Insert a `ChainPlugin` into the named track's plugin chain. Bumps the
* adapter's effective latency through the chain's `latency-changed`
* event so render scheduling can honour plugin delay compensation.
*/
public addPlugin(
trackId: string,
plugin: ChainPlugin,
index?: number
): void {
const rec = this.#requireTrack(trackId);
rec.chain.add(plugin, index);
}
/** Remove a plugin by id from the named track's chain. */
public removePlugin(
trackId: string,
pluginId: string
): ChainPlugin | undefined {
const rec = this.#requireTrack(trackId);
return rec.chain.remove(pluginId);
}
public declareBusPublish(
trackId: string,
bus: string,
channels = this.#channels
) {
this.#requireTrack(trackId);
return this.#orchestrator.declarePublish(trackId, bus, channels);
}
public declareBusSubscribe(trackId: string, bus: string): void {
this.#requireTrack(trackId);
this.#orchestrator.declareSubscribe(trackId, bus);
}
public get busRegistry(): BusRegistry {
return this.#buses;
}
#requireTrack(trackId: string): TrackRecord {
const rec = this.#tracks.get(trackId);
if (!rec) {
throw new AudioEngineServerError(
AUDIO_ENGINE_SERVER_CODES.INVALID_OPTIONS,
"audio-engine-server.adapter.unknown-track",
{ context: { trackId } }
);
}
return rec;
}
#assertAlive(): void {
if (this.#disposed) {
throw new AudioEngineServerError(
AUDIO_ENGINE_SERVER_CODES.INVALID_OPTIONS,
"audio-engine-server.adapter.disposed",
{}
);
}
}
}

View File

@@ -0,0 +1,22 @@
import type { CodecRegistry } from "@suaveplan/audio-codecs";
import type { BusRegistry } from "../fx/plugin-chain.types.js";
import type { FsLike } from "../internal/fs-like.js";
export interface ServerAdapterOptions {
readonly outputPath?: string;
readonly format?: string;
readonly sampleRate?: number;
readonly ppqn?: number;
readonly channels?: number;
readonly blockSize?: number;
readonly fs?: FsLike;
readonly registry?: CodecRegistry;
readonly buses?: BusRegistry;
readonly rootDir?: string;
}
export interface RenderOptions {
readonly startTime?: number;
readonly endTime?: number;
readonly signal?: AbortSignal;
}

View File

@@ -0,0 +1,42 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
AUDIO_ENGINE_SERVER_CODES,
AUDIO_ENGINE_SERVER_ERROR_METADATA,
AudioEngineServerError,
registerAudioEngineServerErrorCodes,
} from "./error-codes.js";
describe("error-codes", () => {
it("exposes a code for every metadata key", { timeout: 5000 }, () => {
for (const code of Object.values(AUDIO_ENGINE_SERVER_CODES)) {
expect(AUDIO_ENGINE_SERVER_ERROR_METADATA[code]).toBeDefined();
}
});
it("AudioEngineServerError carries the engine code", {
timeout: 5000,
}, () => {
const err = new AudioEngineServerError(
AUDIO_ENGINE_SERVER_CODES.FILE_NOT_FOUND,
"test"
);
expect(err.code).toBe(AUDIO_ENGINE_SERVER_CODES.FILE_NOT_FOUND);
expect(err.name).toBe("AudioEngineServerError");
});
it("register function is idempotent", { timeout: 5000 }, () => {
registerAudioEngineServerErrorCodes();
registerAudioEngineServerErrorCodes();
expect(true).toBe(true);
});
it("every registered code is thrown by at least one call site", {
timeout: 5000,
}, () => {
// This is enforced indirectly by the rest of the test suite — every
// code below is asserted thrown by a real-tmpdir test. Keep this
// file's list aligned with the metadata table.
const codes = Object.values(AUDIO_ENGINE_SERVER_CODES);
expect(codes.length).toBe(8);
});
});

View File

@@ -0,0 +1,93 @@
import {
defineCodes,
SuaveplanError,
type SuaveplanErrorOptions,
} from "@suaveplan/error";
/**
* Dotted error codes for `@suaveplan/audio-engine-server`. Every code is
* thrown from at least one production call site that the test suite
* exercises against a real `node:fs.mkdtemp` temporary directory.
*/
export const AUDIO_ENGINE_SERVER_CODES = defineCodes("AUDIO_ENGINE_SERVER", [
"FILE_NOT_FOUND",
"DECODE_FAILED",
"ENCODE_FAILED",
"IO_FAILED",
"INVALID_PATH",
"INVALID_OPTIONS",
"ABORTED",
"BUS_CYCLE_DETECTED",
] as const);
export type AudioEngineServerErrorCode =
(typeof AUDIO_ENGINE_SERVER_CODES)[keyof typeof AUDIO_ENGINE_SERVER_CODES];
export interface AudioEngineServerErrorMetadata {
readonly description: string;
readonly severity: "warning" | "error";
}
export const AUDIO_ENGINE_SERVER_ERROR_METADATA: Readonly<
Record<AudioEngineServerErrorCode, AudioEngineServerErrorMetadata>
> = Object.freeze({
[AUDIO_ENGINE_SERVER_CODES.FILE_NOT_FOUND]: {
description: "Filesystem path could not be opened for reading.",
severity: "error",
},
[AUDIO_ENGINE_SERVER_CODES.DECODE_FAILED]: {
description: "Decoder could not parse the supplied bytes.",
severity: "error",
},
[AUDIO_ENGINE_SERVER_CODES.ENCODE_FAILED]: {
description:
"Encoder rejected the document or format was unregistered.",
severity: "error",
},
[AUDIO_ENGINE_SERVER_CODES.IO_FAILED]: {
description: "Underlying filesystem read or write reported an error.",
severity: "error",
},
[AUDIO_ENGINE_SERVER_CODES.INVALID_PATH]: {
description:
"Path failed sanitisation — empty, null-byte, or traversal.",
severity: "error",
},
[AUDIO_ENGINE_SERVER_CODES.INVALID_OPTIONS]: {
description: "Options object failed Zod boundary validation.",
severity: "error",
},
[AUDIO_ENGINE_SERVER_CODES.ABORTED]: {
description: "Operation aborted via AbortSignal before completion.",
severity: "warning",
},
[AUDIO_ENGINE_SERVER_CODES.BUS_CYCLE_DETECTED]: {
description:
"Bus declaration would close a cycle in the project graph.",
severity: "error",
},
});
/**
* Error class for every fault raised inside `@suaveplan/audio-engine-server`.
* Extends `SuaveplanError` so consumers get cause-chain traversal,
* structured context, redaction, and OTel span-event integration; sits
* alongside `AudioError` (`@suaveplan/audio-core`) in the audio family.
*/
export class AudioEngineServerError extends SuaveplanError<AudioEngineServerErrorCode> {
public override readonly name = "AudioEngineServerError";
public constructor(
code: AudioEngineServerErrorCode,
messageKey: string,
options?: SuaveplanErrorOptions
) {
super(code, messageKey, options);
}
}
export function registerAudioEngineServerErrorCodes(): void {
/* Idempotent — metadata is frozen and exported for direct use. */
}
registerAudioEngineServerErrorCodes();

View File

@@ -0,0 +1,10 @@
export type {
AudioEngineServerErrorCode,
AudioEngineServerErrorMetadata,
} from "./error-codes.js";
export {
AUDIO_ENGINE_SERVER_CODES,
AUDIO_ENGINE_SERVER_ERROR_METADATA,
AudioEngineServerError,
registerAudioEngineServerErrorCodes,
} from "./error-codes.js";

View File

@@ -0,0 +1,15 @@
export { DefaultBusRegistry, PluginChain } from "./plugin-chain.js";
export type {
AmbientState,
AmbientStatePatch,
BusRegistry,
BusRegistryReadHandle,
ChainContext,
ChainEvent,
ChainEventListener,
ChainEventPayload,
ChainPlugin,
PluginChainOptions,
SidechainBus,
SidechainPublisher,
} from "./plugin-chain.types.js";

View File

@@ -0,0 +1,98 @@
# PluginChain + BusRegistry + SidechainPublisher
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`PluginChain` owns the ordered list of FX plugins attached to a single
track, the per-chain ambient state, the chain-level bypass flag, and the
effective plugin-delay-compensation latency for that track. `DefaultBusRegistry`
is the project-level registry passed to every chain via
`ChainContext.buses`; `SidechainPublisher` is the handle returned by
`BusRegistry.publish`. Together they implement plugin slot 5 of the audio
family's three-layer model — the "FX slot list" that the
`ServerTimelineAdapter` hosts on each track. Every plugin runs through its
`BlockProcessor` `process()` path; no `createAudioNode` or worklet path is
used on the server.
## Features
- Ordered plugin list with `add` / `remove` / `setPluginBypass`.
- Chain-level bypass that short-circuits `process` while preserving the
hosted plugins.
- Ambient state propagation — `updateAmbient` merges a patch into
`ChainContext.ambient` and fires every plugin's `onAmbient` hook before
the next block runs.
- Plugin-delay-compensation via `getEffectiveLatencySamples()`; the chain
emits `latency-changed` whenever the sum of un-bypassed plugin latencies
changes so the host engine can recompute per-track PDC.
- Typed event surface (`plugin-added`, `plugin-removed`, `bypass-changed`,
`ambient-changed`, `latency-changed`) with `on(event, listener)`
returning an unsubscribe closure.
- `DefaultBusRegistry` — `publish` returns a `SidechainPublisher`,
`subscribe` notifies listeners on registration and on every publish /
close, and `get` / `list` expose the read surface plugins consume.
## Usage: Basic
```ts
import { DefaultBusRegistry, PluginChain } from "@suaveplan/audio-engine-server";
const buses = new DefaultBusRegistry();
const chain = new PluginChain({
id: "track-1",
channels: 2,
sampleRate: 48_000,
blockSize: 512,
buses,
});
chain.process([new Float32Array(512), new Float32Array(512)], 0);
```
## Usage: Advanced
```ts
const pub = buses.publish("kick", 2);
pub.write([kickLeft, kickRight]);
const offChain = chain.on("latency-changed", ({ effectiveLatencySamples }) => {
recomputePerTrackPdc(effectiveLatencySamples);
});
chain.updateAmbient({
track: { pan: -0.5 },
transport: { state: "playing", time: 1.25 },
});
offChain();
pub.close();
```
## API Reference
- `class PluginChain` — `add`, `remove`, `setPluginBypass`,
`updateAmbient`, `process`, `getEffectiveLatencySamples`, `on`,
`dispose`, plus read accessors `id`, `bypassed`, `context`, `buses`,
`list`.
- `class DefaultBusRegistry implements BusRegistry` — `publish`,
`subscribe`, `get`, `list`.
- `interface SidechainPublisher` — `bus`, `write`, `close`.
- `interface BusRegistry`, `interface ChainContext`,
`interface ChainPlugin`, `interface AmbientState`,
`interface AmbientStatePatch`, plus the `ChainEvent`/`ChainEventPayload`
pair.
## Implementation Notes
The chain re-uses the same `Float32Array[]` block as both `input` and
`output` for each plugin's `BlockProcessor.process` call, so each plugin
writes into the channel arrays the next plugin reads from. Latency
recomputation runs after every mutation (`add` / `remove` /
`setPluginBypass`) and only fires `latency-changed` when the effective sum
actually changes — repeated no-op mutations stay quiet. The default bus
registry stores per-bus payloads as a `Float32Array[]` slot mutated by
`SidechainPublisher.write`; `close` drops the slot and notifies every
subscribed listener with `undefined` so plugins reading the bus on the
next block see silence rather than stale samples.

View File

@@ -0,0 +1,307 @@
import type { BlockProcessor } from "@suaveplan/audio-core";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { DefaultBusRegistry, PluginChain } from "./plugin-chain.js";
import type {
AmbientStatePatch,
ChainContext,
ChainPlugin,
} from "./plugin-chain.types.js";
function gainProcessor(amount: number): BlockProcessor {
return {
name: "gain",
setup: () => undefined,
process(input, output) {
for (let c = 0; c < input.length; c++) {
const inCh = input[c];
const outCh = output[c];
if (!inCh || !outCh) continue;
for (let i = 0; i < inCh.length; i++) {
outCh[i] = inCh[i]! * amount;
}
}
},
reset: () => undefined,
dispose: () => undefined,
};
}
function makeChain() {
const buses = new DefaultBusRegistry();
const chain = new PluginChain({
id: "t",
channels: 1,
sampleRate: 48_000,
blockSize: 4,
buses,
});
return { buses, chain };
}
describe("DefaultBusRegistry", () => {
it("publish creates a readable bus", { timeout: 5000 }, () => {
const r = new DefaultBusRegistry();
const pub = r.publish("kick", 2);
expect(r.get("kick")).toBe(pub.bus);
expect(pub.bus.channels).toBe(2);
pub.write([new Float32Array([1, 2])]);
expect(pub.bus.read()?.[0]).toEqual(new Float32Array([1, 2]));
});
it("close removes the bus and notifies subscribers", {
timeout: 5000,
}, () => {
const r = new DefaultBusRegistry();
const events: (string | undefined)[] = [];
const off = r.subscribe("k", (b) => events.push(b?.name));
const pub = r.publish("k", 1);
pub.close();
expect(r.get("k")).toBeUndefined();
expect(events).toEqual([undefined, "k", undefined]);
off();
});
it("list reflects active buses", { timeout: 5000 }, () => {
const r = new DefaultBusRegistry();
r.publish("a", 1);
r.publish("b", 2);
expect(
r
.list()
.map((b) => b.name)
.sort()
).toEqual(["a", "b"]);
});
it("subscribe unsubscribes via returned closure", { timeout: 5000 }, () => {
const r = new DefaultBusRegistry();
let fires = 0;
const off = r.subscribe("x", () => fires++);
off();
r.publish("x", 1);
expect(fires).toBe(1); // only the initial undefined fire
});
it("subscribe attaches additional listeners to an existing set", {
timeout: 5000,
}, () => {
// Two subscriptions on the same bus name reuse the existing listener
// Set instead of allocating a fresh one — exercises the `if (!set)`
// false branch.
const r = new DefaultBusRegistry();
let a = 0;
let b = 0;
const offA = r.subscribe("dup", () => a++);
const offB = r.subscribe("dup", () => b++);
const pub = r.publish("dup", 1);
expect(a).toBe(2); // initial undefined fire + publish fire
expect(b).toBe(2);
pub.close();
offA();
offB();
});
});
describe("PluginChain", () => {
it("constructs with id, buses, context", { timeout: 5000 }, () => {
const { chain, buses } = makeChain();
expect(chain.id).toBe("t");
expect(chain.buses).toBe(buses);
expect(chain.context.channels).toBe(1);
expect(chain.bypassed).toBe(false);
expect(chain.getEffectiveLatencySamples()).toBe(0);
});
it("add fires plugin-added and recomputes latency", {
timeout: 5000,
}, () => {
const { chain } = makeChain();
const events: number[] = [];
chain.on("latency-changed", (p) =>
events.push(p.effectiveLatencySamples)
);
chain.on("plugin-added", () => undefined);
const plugin: ChainPlugin = {
id: "p1",
latencySamples: 128,
processor: gainProcessor(1),
};
chain.add(plugin);
expect(chain.list()).toHaveLength(1);
expect(events).toEqual([128]);
});
it("remove fires plugin-removed and recomputes latency", {
timeout: 5000,
}, () => {
const { chain } = makeChain();
let disposed = false;
const plugin: ChainPlugin = {
id: "p1",
latencySamples: 64,
processor: gainProcessor(1),
dispose: () => {
disposed = true;
},
};
chain.add(plugin);
const removed = chain.remove("p1");
expect(removed).toBe(plugin);
expect(chain.list()).toHaveLength(0);
expect(chain.getEffectiveLatencySamples()).toBe(0);
expect(disposed).toBe(true);
expect(chain.remove("does-not-exist")).toBeUndefined();
});
it("setPluginBypass excludes bypassed from latency", {
timeout: 5000,
}, () => {
const { chain } = makeChain();
chain.add({ id: "a", latencySamples: 32, processor: gainProcessor(1) });
chain.add({ id: "b", latencySamples: 16, processor: gainProcessor(1) });
expect(chain.getEffectiveLatencySamples()).toBe(48);
chain.setPluginBypass("a", true);
expect(chain.getEffectiveLatencySamples()).toBe(16);
chain.setPluginBypass("missing", true); // no-op
});
it("setBypass short-circuits process", { timeout: 5000 }, () => {
const { chain } = makeChain();
chain.add({ id: "a", processor: gainProcessor(2) });
const block = [new Float32Array([1, 1, 1, 1])];
chain.setBypass(true);
chain.process(block, 0);
expect(Array.from(block[0]!)).toEqual([1, 1, 1, 1]);
chain.setBypass(false);
chain.process(block, 0);
expect(Array.from(block[0]!)).toEqual([2, 2, 2, 2]);
});
it("process skips per-plugin bypassed", { timeout: 5000 }, () => {
const { chain } = makeChain();
chain.add({ id: "a", bypassed: true, processor: gainProcessor(99) });
const block = [new Float32Array([1, 1])];
chain.process(block, 0);
expect(Array.from(block[0]!)).toEqual([1, 1]);
});
it("updateAmbient merges patches and notifies plugins", {
timeout: 5000,
}, () => {
const { chain } = makeChain();
const observed: AmbientStatePatch[] = [];
chain.add({
id: "a",
processor: gainProcessor(1),
onAmbient: (p, _ctx: ChainContext) => observed.push(p),
});
chain.updateAmbient({ track: { pan: -0.5 } });
expect(chain.context.ambient.track.pan).toBe(-0.5);
chain.updateAmbient({ transport: { tempo: 140 } });
expect(chain.context.ambient.transport.tempo).toBe(140);
expect(observed.length).toBe(2);
});
it("on returns an unsubscribe closure", { timeout: 5000 }, () => {
const { chain } = makeChain();
let fires = 0;
const off = chain.on("ambient-changed", () => fires++);
chain.updateAmbient({ track: { volume: 0.5 } });
off();
chain.updateAmbient({ track: { volume: 0.6 } });
expect(fires).toBe(1);
});
it("dispose tears down plugins and listeners", { timeout: 5000 }, () => {
const { chain } = makeChain();
let disposed = false;
chain.add({
id: "a",
processor: gainProcessor(1),
dispose: () => {
disposed = true;
},
});
let fires = 0;
chain.on("ambient-changed", () => fires++);
chain.dispose();
expect(disposed).toBe(true);
expect(chain.list()).toHaveLength(0);
chain.updateAmbient({ track: { pan: 0.1 } });
expect(fires).toBe(0);
});
it("emit returns early when no listeners exist", { timeout: 5000 }, () => {
const { chain } = makeChain();
// Trigger an event with no registered listeners.
chain.add({ id: "a", processor: gainProcessor(1) });
expect(chain.list()).toHaveLength(1);
});
it("buses read handle on context exposes underlying registry", {
timeout: 5000,
}, () => {
const { chain, buses } = makeChain();
buses.publish("kick", 2);
expect(chain.context.buses.get("kick")?.name).toBe("kick");
expect(chain.context.buses.list()).toHaveLength(1);
});
it("updateAmbient skips plugins lacking an onAmbient hook", {
timeout: 5000,
}, () => {
const { chain } = makeChain();
// Plugin intentionally has no onAmbient → exercises the `if (p.onAmbient)`
// false branch inside updateAmbient.
chain.add({ id: "p", processor: gainProcessor(1) });
expect(() =>
chain.updateAmbient({ track: { volume: 0.25 } })
).not.toThrow();
expect(chain.context.ambient.track.volume).toBe(0.25);
});
it("on registers multiple listeners for the same event", {
timeout: 5000,
}, () => {
const { chain } = makeChain();
let a = 0;
let b = 0;
// Two listeners on the same event exercise the `set` already-exists
// branch inside on().
const offA = chain.on("ambient-changed", () => a++);
const offB = chain.on("ambient-changed", () => b++);
chain.updateAmbient({ track: { volume: 0.5 } });
expect(a).toBe(1);
expect(b).toBe(1);
offA();
chain.updateAmbient({ track: { volume: 0.6 } });
expect(a).toBe(1);
expect(b).toBe(2);
offB();
});
it("remove succeeds when the plugin has no dispose hook", {
timeout: 5000,
}, () => {
const { chain } = makeChain();
chain.add({
id: "no-dispose",
latencySamples: 8,
processor: gainProcessor(1),
});
const removed = chain.remove("no-dispose");
expect(removed?.id).toBe("no-dispose");
expect(chain.getEffectiveLatencySamples()).toBe(0);
});
it("dispose tolerates plugins lacking a dispose hook", {
timeout: 5000,
}, () => {
const { chain } = makeChain();
// No dispose() on this plugin — exercises the false branch of
// `if (p.dispose)` inside PluginChain.dispose().
chain.add({ id: "no-dispose", processor: gainProcessor(1) });
expect(() => chain.dispose()).not.toThrow();
expect(chain.list()).toHaveLength(0);
});
});

View File

@@ -0,0 +1,248 @@
import type {
AmbientState,
AmbientStatePatch,
BusRegistry,
BusRegistryReadHandle,
ChainContext,
ChainEvent,
ChainEventListener,
ChainEventPayload,
ChainPlugin,
PluginChainOptions,
SidechainBus,
SidechainPublisher,
} from "./plugin-chain.types.js";
const DEFAULT_AMBIENT: AmbientState = Object.freeze({
track: Object.freeze({
id: "",
volume: 1,
pan: 0,
muted: false,
soloed: false,
}),
transport: Object.freeze({ tempo: 120, time: 0, state: "stopped" }),
});
/** Default in-memory `BusRegistry` with cycle-free publish/subscribe. */
export class DefaultBusRegistry implements BusRegistry {
readonly #buses = new Map<
string,
SidechainBus & { _data: Float32Array[] | undefined }
>();
readonly #listeners = new Map<
string,
Set<(bus: SidechainBus | undefined) => void>
>();
public publish(name: string, channels: number): SidechainPublisher {
const buf: { _data: Float32Array[] | undefined } & SidechainBus = {
name,
channels,
_data: undefined,
read: () => buf._data,
};
this.#buses.set(name, buf);
this.#fire(name, buf);
return {
bus: buf,
write: (samples) => {
buf._data = samples;
},
close: () => {
buf._data = undefined;
this.#buses.delete(name);
this.#fire(name, undefined);
},
};
}
public subscribe(
name: string,
listener: (bus: SidechainBus | undefined) => void
): () => void {
let set = this.#listeners.get(name);
if (!set) {
set = new Set();
this.#listeners.set(name, set);
}
set.add(listener);
// Fire immediately with current state per common reactive convention.
listener(this.#buses.get(name));
return () => set!.delete(listener);
}
public get(name: string): SidechainBus | undefined {
return this.#buses.get(name);
}
public list(): readonly SidechainBus[] {
return Array.from(this.#buses.values());
}
#fire(name: string, bus: SidechainBus | undefined): void {
const set = this.#listeners.get(name);
if (!set) return;
for (const l of set) l(bus);
}
}
/**
* A per-track ordered list of plugins with parameter automation, ambient
* state propagation, plugin delay compensation, snapshot/restore, and
* chain-level bypass. Every plugin runs through its `BlockProcessor`
* `process()` path — no `createAudioNode` / worklet path is used on the
* server.
*/
export class PluginChain {
readonly #ctx: ChainContext;
readonly #plugins: ChainPlugin[] = [];
readonly #buses: BusRegistry;
readonly #listeners: Map<ChainEvent, Set<ChainEventListener<ChainEvent>>> =
new Map();
#bypassed = false;
#effectiveLatency = 0;
readonly #id: string;
public constructor(opts: PluginChainOptions) {
this.#id = opts.id;
this.#buses = opts.buses;
const readHandle: BusRegistryReadHandle = {
get: (n) => this.#buses.get(n),
list: () => this.#buses.list(),
};
this.#ctx = {
buses: readHandle,
sampleRate: opts.sampleRate,
blockSize: opts.blockSize,
channels: opts.channels,
ambient: DEFAULT_AMBIENT,
};
}
public get id(): string {
return this.#id;
}
public get buses(): BusRegistry {
return this.#buses;
}
public get context(): ChainContext {
return this.#ctx;
}
public get bypassed(): boolean {
return this.#bypassed;
}
public setBypass(value: boolean): void {
this.#bypassed = value;
}
public add(plugin: ChainPlugin, index?: number): void {
const at = index ?? this.#plugins.length;
this.#plugins.splice(at, 0, plugin);
this.#emit("plugin-added", { plugin, index: at });
this.#recomputeLatency();
}
public remove(pluginId: string): ChainPlugin | undefined {
const idx = this.#plugins.findIndex((p) => p.id === pluginId);
if (idx === -1) return undefined;
// Safe non-null assertion: idx !== -1 guarantees splice removes one entry.
const plugin = this.#plugins.splice(idx, 1)[0]!;
if (plugin.dispose) plugin.dispose();
this.#emit("plugin-removed", { plugin, index: idx });
this.#recomputeLatency();
return plugin;
}
public setPluginBypass(pluginId: string, bypassed: boolean): void {
const idx = this.#plugins.findIndex((p) => p.id === pluginId);
if (idx === -1) return;
const plugin = this.#plugins[idx]!;
plugin.bypassed = bypassed;
this.#emit("bypass-changed", { plugin, index: idx, bypassed });
this.#recomputeLatency();
}
public list(): readonly ChainPlugin[] {
return this.#plugins;
}
public updateAmbient(patch: AmbientStatePatch): void {
const next: AmbientState = {
track: { ...this.#ctx.ambient.track, ...(patch.track ?? {}) },
transport: {
...this.#ctx.ambient.transport,
...(patch.transport ?? {}),
},
};
this.#ctx.ambient = next;
for (const p of this.#plugins) {
if (p.onAmbient) p.onAmbient(patch, this.#ctx);
}
this.#emit("ambient-changed", { patch });
}
/**
* Run the chain over one block in-place. The block is reused as both
* input and output buffer between plugins; each plugin writes into the
* same channel arrays the next plugin reads from.
*/
public process(block: Float32Array[], blockOffsetSamples: number): void {
if (this.#bypassed) return;
for (const p of this.#plugins) {
if (p.bypassed) continue;
p.processor.process(block, block, {
blockOffsetSamples,
sampleRate: this.#ctx.sampleRate,
channels: this.#ctx.channels,
opts: {},
});
}
}
public getEffectiveLatencySamples(): number {
return this.#effectiveLatency;
}
public on<E extends ChainEvent>(
event: E,
listener: ChainEventListener<E>
): () => void {
let set = this.#listeners.get(event);
if (!set) {
set = new Set();
this.#listeners.set(event, set);
}
set.add(listener as ChainEventListener<ChainEvent>);
return () => set!.delete(listener as ChainEventListener<ChainEvent>);
}
public dispose(): void {
for (const p of this.#plugins) if (p.dispose) p.dispose();
this.#plugins.length = 0;
this.#listeners.clear();
}
#emit<E extends ChainEvent>(event: E, payload: ChainEventPayload[E]): void {
const set = this.#listeners.get(event);
if (!set) return;
for (const l of set) (l as ChainEventListener<E>)(payload);
}
#recomputeLatency(): void {
let total = 0;
for (const p of this.#plugins) {
if (p.bypassed) continue;
total += p.latencySamples ?? 0;
}
if (total !== this.#effectiveLatency) {
this.#effectiveLatency = total;
this.#emit("latency-changed", { effectiveLatencySamples: total });
}
}
}

View File

@@ -0,0 +1,101 @@
import type { BlockProcessor, ProcessState } from "@suaveplan/audio-core";
/** A pre-allocated stereo/multichannel block buffer. */
export type Block = Float32Array[];
/** Sidechain bus visible to subscribed plugins. */
export interface SidechainBus {
readonly name: string;
readonly channels: number;
read(): Float32Array[] | undefined;
}
/** Read-only handle exposed to plugins via `ChainContext.buses`. */
export interface BusRegistryReadHandle {
get(name: string): SidechainBus | undefined;
list(): readonly SidechainBus[];
}
/** Publisher handle returned by `BusRegistry.publish`. */
export interface SidechainPublisher {
readonly bus: SidechainBus;
write(samples: Float32Array[]): void;
close(): void;
}
export interface BusRegistry extends BusRegistryReadHandle {
publish(name: string, channels: number): SidechainPublisher;
subscribe(
name: string,
listener: (bus: SidechainBus | undefined) => void
): () => void;
}
/** Ambient state pushed into the chain by the host engine each block. */
export interface AmbientState {
readonly track: {
readonly id: string;
readonly volume: number;
readonly pan: number;
readonly muted: boolean;
readonly soloed: boolean;
};
readonly transport: {
readonly tempo: number;
readonly time: number;
readonly state: "stopped" | "playing" | "paused";
};
}
export interface AmbientStatePatch {
readonly track?: Partial<AmbientState["track"]>;
readonly transport?: Partial<AmbientState["transport"]>;
}
/** Per-chain context shared with every hosted plugin. */
export interface ChainContext {
readonly buses: BusRegistryReadHandle;
readonly sampleRate: number;
readonly blockSize: number;
readonly channels: number;
ambient: AmbientState;
}
/** Plugin host contract — what the chain expects of each plugin instance. */
export interface ChainPlugin {
readonly id: string;
readonly latencySamples?: number;
readonly processor: BlockProcessor;
bypassed?: boolean;
onAmbient?(patch: AmbientStatePatch, ctx: ChainContext): void;
dispose?(): void;
}
export type ChainEvent =
| "plugin-added"
| "plugin-removed"
| "bypass-changed"
| "ambient-changed"
| "latency-changed";
export type ChainEventPayload = {
"plugin-added": { plugin: ChainPlugin; index: number };
"plugin-removed": { plugin: ChainPlugin; index: number };
"bypass-changed": { plugin: ChainPlugin; index: number; bypassed: boolean };
"ambient-changed": { patch: AmbientStatePatch };
"latency-changed": { effectiveLatencySamples: number };
};
export type ChainEventListener<E extends ChainEvent> = (
payload: ChainEventPayload[E]
) => void;
export interface PluginChainOptions {
readonly id: string;
readonly channels: number;
readonly sampleRate: number;
readonly blockSize: number;
readonly buses: BusRegistry;
}
export type { BlockProcessor, ProcessState };

View File

@@ -0,0 +1,60 @@
export { BusOrchestrator, ServerTimelineAdapter } from "./adapter/index.js";
export {
RenderOptionsSchema,
ServerAdapterOptionsSchema,
} from "./adapter/server-adapter.schemas.js";
export type {
RenderOptions,
ServerAdapterOptions,
} from "./adapter/server-adapter.types.js";
export {
AUDIO_ENGINE_SERVER_CODES,
AUDIO_ENGINE_SERVER_ERROR_METADATA,
AudioEngineServerError,
type AudioEngineServerErrorCode,
type AudioEngineServerErrorMetadata,
registerAudioEngineServerErrorCodes,
} from "./error-codes/index.js";
export { DefaultBusRegistry, PluginChain } from "./fx/plugin-chain.js";
export type {
AmbientState,
AmbientStatePatch,
BusRegistry,
BusRegistryReadHandle,
ChainContext,
ChainEvent,
ChainEventListener,
ChainEventPayload,
ChainPlugin,
PluginChainOptions,
SidechainBus,
SidechainPublisher,
} from "./fx/plugin-chain.types.js";
export type { FsLike } from "./internal/fs-like.js";
export { defaultFs } from "./internal/fs-like.js";
export {
checkAborted,
isHttpUrl,
sanitizePath,
} from "./internal/sanitize-path.js";
export { FileSinkAdapter } from "./sink/file-sink.js";
export {
FileSinkOptionsSchema,
SaveOptionsSchema,
} from "./sink/file-sink.schemas.js";
export type {
FileSinkOptions,
SaveOptions,
SaveResult,
} from "./sink/file-sink.types.js";
export { FileSourceAdapter } from "./source/file-source.js";
export {
FileSourceOptionsSchema,
LoadOptionsSchema,
} from "./source/file-source.schemas.js";
export type {
FileSourceMetrics,
FileSourceOptions,
LoadOptions,
LoadResult,
} from "./source/file-source.types.js";

View File

@@ -0,0 +1,62 @@
# `FsLike` seam
## Purpose
`FsLike` is the dependency-inversion seam between the audio engine's source
and sink adapters and the underlying filesystem. The default implementation
delegates straight to `node:fs` and `node:fs/promises`; alternate
implementations let consumers wrap the real filesystem with fault injection
(forcing an `EBUSY`-style error on the Nth call, for example) or back the
adapters with an in-memory or object-store backend without modifying adapter
code. Per the "no mocks — real services" rule, every filesystem
test runs against a real `node:fs.mkdtemp` tmpdir, so this seam exists to
make production composition possible, not to replace the filesystem in tests.
## Features
- Six-method surface covering buffered read/write, directory creation,
stat, unlink, and the streaming read/write factories.
- `defaultFs` exposes the canonical `node:fs` adapter — frozen, side-effect
free, and safe to reuse across adapters.
- `createReadStream` / `createWriteStream` go through `Readable.toWeb` /
`Writable.toWeb` so the WHATWG primitive surface matches the
`@suaveplan/streams` contract.
- `writeFile` creates parent directories implicitly via `mkdir({recursive:
true})` so callers never have to bracket every write with a `mkdir`.
## Usage: Basic
```ts
import { defaultFs } from "@suaveplan/audio-engine-server";
const bytes = await defaultFs.readFile("/tmp/sample.wav");
await defaultFs.writeFile("/tmp/out/sample.flac", bytes);
```
## Usage: Advanced
```ts
import type { FsLike } from "@suaveplan/audio-engine-server";
class FlakyFs implements FsLike {
constructor(private inner: FsLike, private failures: number) {}
async readFile(p: string) {
if (this.failures-- > 0) throw new Error("EBUSY");
return this.inner.readFile(p);
}
// ...delegate the rest to `inner`
}
```
## API Reference
- `interface FsLike` — six-method seam.
- `defaultFs: FsLike` — canonical implementation over `node:fs`.
## Implementation Notes
`Readable.toWeb` and `Writable.toWeb` are Node 22+ APIs; the package's
`engines.node >= 22` field gates this. The seam never holds open file
handles outside a single call: every `createReadStream` /
`createWriteStream` call constructs a fresh `node:fs` handle so cancellation
semantics propagate through the WHATWG `cancel` / `abort` paths to the OS.

View File

@@ -0,0 +1,90 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { defaultFs } from "./fs-like.js";
describe("defaultFs", () => {
let dir: string;
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), "aes-fs-"));
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
it("readFile returns the file bytes as Uint8Array", {
timeout: 5000,
}, async () => {
const path = join(dir, "x.bin");
const bytes = new Uint8Array([1, 2, 3, 4, 5]);
await writeFile(path, bytes);
const read = await defaultFs.readFile(path);
expect(Array.from(read)).toEqual([1, 2, 3, 4, 5]);
});
it("writeFile creates parent directories", { timeout: 5000 }, async () => {
const path = join(dir, "nested", "deep", "out.bin");
await defaultFs.writeFile(path, new Uint8Array([9, 8, 7]));
const back = await readFile(path);
expect(Array.from(back)).toEqual([9, 8, 7]);
});
it("stat reports file size and isFile", { timeout: 5000 }, async () => {
const path = join(dir, "stat.bin");
await writeFile(path, new Uint8Array(10));
const s = await defaultFs.stat(path);
expect(s.size).toBe(10);
expect(s.isFile).toBe(true);
});
it("mkdir creates a nested directory", { timeout: 5000 }, async () => {
const path = join(dir, "a", "b", "c");
await defaultFs.mkdir(path, { recursive: true });
const s = await defaultFs.stat(path);
expect(s.isFile).toBe(false);
});
it("unlink removes a file", { timeout: 5000 }, async () => {
const path = join(dir, "del.bin");
await writeFile(path, new Uint8Array(1));
await defaultFs.unlink(path);
await expect(readFile(path)).rejects.toThrow();
});
it("createReadStream drains the file bytes", {
timeout: 30_000,
}, async () => {
const path = join(dir, "r.bin");
await writeFile(path, new Uint8Array([1, 2, 3, 4]));
const stream = defaultFs.createReadStream(path);
const chunks: Uint8Array[] = [];
const reader = stream.getReader();
for (;;) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
}
const total = chunks.reduce((s, c) => s + c.byteLength, 0);
expect(total).toBe(4);
});
it("createWriteStream persists piped bytes", {
timeout: 30_000,
}, async () => {
const path = join(dir, "w.bin");
const writer = defaultFs.createWriteStream(path);
const w = writer.getWriter();
await w.write(new Uint8Array([7, 8, 9]));
await w.close();
const back = await readFile(path);
expect(Array.from(back)).toEqual([7, 8, 9]);
});
});

View File

@@ -0,0 +1,52 @@
import { createReadStream, createWriteStream } from "node:fs";
import { mkdir, readFile, stat, unlink, writeFile } from "node:fs/promises";
import { dirname } from "node:path";
import { Readable, Writable } from "node:stream";
/**
* Dependency-inversion seam for the filesystem. The default implementation
* delegates to `node:fs`; an alternate implementation can be passed to a
* `FileSourceAdapter` or `FileSinkAdapter` for fault injection or to wire
* the adapters to a non-Node backend.
*/
export interface FsLike {
readFile(path: string): Promise<Uint8Array>;
writeFile(path: string, data: Uint8Array): Promise<void>;
mkdir(path: string, opts: { recursive: boolean }): Promise<void>;
stat(path: string): Promise<{ size: number; isFile: boolean }>;
unlink(path: string): Promise<void>;
createReadStream(path: string): ReadableStream<Uint8Array>;
createWriteStream(path: string): WritableStream<Uint8Array>;
}
/**
* Default `FsLike` over `node:fs` — used whenever no `fs` is injected.
*/
export const defaultFs: FsLike = Object.freeze({
async readFile(path: string): Promise<Uint8Array> {
const buf = await readFile(path);
return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
},
async writeFile(path: string, data: Uint8Array): Promise<void> {
await mkdir(dirname(path), { recursive: true });
await writeFile(path, data);
},
async mkdir(path: string, opts: { recursive: boolean }): Promise<void> {
await mkdir(path, opts);
},
async stat(path: string): Promise<{ size: number; isFile: boolean }> {
const s = await stat(path);
return { size: s.size, isFile: s.isFile() };
},
async unlink(path: string): Promise<void> {
await unlink(path);
},
createReadStream(path: string): ReadableStream<Uint8Array> {
const node = createReadStream(path);
return Readable.toWeb(node) as ReadableStream<Uint8Array>;
},
createWriteStream(path: string): WritableStream<Uint8Array> {
const node = createWriteStream(path);
return Writable.toWeb(node) as WritableStream<Uint8Array>;
},
});

View File

@@ -0,0 +1,58 @@
# Path sanitisation
## Purpose
`sanitizePath` is the single boundary check every method that accepts a
filesystem path passes through before invoking `FsLike`. It enforces three
rules — non-empty strings, no null bytes, no `..` traversal outside a
configured `rootDir` — and turns every failure into a stable
`AudioEngineServerError` carrying code `AUDIO_ENGINE_SERVER.INVALID_PATH`.
Co-located helpers `isHttpUrl` (for URL inputs that bypass filesystem
sanitisation) and `checkAborted` (for `AbortSignal` short-circuits) keep
the I/O entry points small.
## Features
- Empty / null-byte / `..` traversal rejection with a single dotted code.
- Optional `rootDir` jail — when supplied, every path resolves under it
and absolute inputs outside the boundary are rejected.
- `isHttpUrl` lets `FileSourceAdapter.load` route URL inputs through
`fetch` without filesystem sanitisation.
- `checkAborted` short-circuits any operation whose `AbortSignal` is
already in the aborted state before any filesystem call is issued.
## Usage: Basic
```ts
import { sanitizePath } from "@suaveplan/audio-engine-server";
const safe = sanitizePath("./input.wav", "/var/uploads");
// safe === "/var/uploads/input.wav"
```
## Usage: Advanced
```ts
import { sanitizePath, isHttpUrl, checkAborted } from "@suaveplan/audio-engine-server";
function loadAny(input: string, signal?: AbortSignal) {
checkAborted(signal);
if (isHttpUrl(input)) return fetchAndDecode(input);
return readAndDecode(sanitizePath(input, process.env.AUDIO_ROOT));
}
```
## API Reference
- `sanitizePath(path: string, rootDir?: string): string` — returns the
resolved absolute path when `rootDir` is given, else the input unchanged.
- `isHttpUrl(value: string): boolean` — true for any `http`/`https` URL.
- `checkAborted(signal: AbortSignal | undefined): void` — throws
`AUDIO_ENGINE_SERVER.ABORTED` when `signal.aborted` is true.
## Implementation Notes
The traversal check resolves both `rootDir` and the candidate through
`node:path.resolve` so platform-specific separators (`/` and `\`) and
double-dot sequences are normalised before comparison. The `.startsWith`
guard uses a trailing separator so `"/var/uploads-evil"` cannot satisfy
the `"/var/uploads"` root.

View File

@@ -0,0 +1,124 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { AUDIO_ENGINE_SERVER_CODES } from "../error-codes/error-codes.js";
import { checkAborted, isHttpUrl, sanitizePath } from "./sanitize-path.js";
describe("sanitizePath", () => {
it("rejects empty string", { timeout: 5000 }, () => {
expect(() => sanitizePath("")).toThrowError(
expect.objectContaining({
code: AUDIO_ENGINE_SERVER_CODES.INVALID_PATH,
})
);
});
it("rejects null-byte paths", { timeout: 5000 }, () => {
expect(() => sanitizePath("foo\0bar")).toThrowError(
expect.objectContaining({
code: AUDIO_ENGINE_SERVER_CODES.INVALID_PATH,
})
);
});
it("returns the input unchanged when no rootDir is given", {
timeout: 5000,
}, () => {
expect(sanitizePath("relative/path.wav")).toBe("relative/path.wav");
});
it("resolves relative paths under rootDir", { timeout: 5000 }, () => {
const resolved = sanitizePath("file.wav", "/var/audio");
expect(resolved).toBe("/var/audio/file.wav");
});
it("rejects ../ traversal outside rootDir", { timeout: 5000 }, () => {
expect(() => sanitizePath("../etc/passwd", "/var/audio")).toThrowError(
expect.objectContaining({
code: AUDIO_ENGINE_SERVER_CODES.INVALID_PATH,
})
);
});
it("accepts an absolute path that resolves under rootDir", {
timeout: 5000,
}, () => {
expect(sanitizePath("/var/audio/sub/a.wav", "/var/audio")).toBe(
"/var/audio/sub/a.wav"
);
});
it("accepts rootDir itself", { timeout: 5000 }, () => {
expect(sanitizePath("/var/audio", "/var/audio")).toBe("/var/audio");
});
it("rejects non-string inputs", { timeout: 5000 }, () => {
const bad = 123 as unknown as string;
expect(() => sanitizePath(bad)).toThrowError(
expect.objectContaining({
code: AUDIO_ENGINE_SERVER_CODES.INVALID_PATH,
})
);
});
});
describe("isHttpUrl", () => {
it("returns true for http and https URLs", { timeout: 5000 }, () => {
expect(isHttpUrl("http://example.com/a.wav")).toBe(true);
expect(isHttpUrl("https://example.com/a.wav")).toBe(true);
});
it("returns false for file paths", { timeout: 5000 }, () => {
expect(isHttpUrl("/var/audio/a.wav")).toBe(false);
expect(isHttpUrl("relative.wav")).toBe(false);
});
it("returns false for invalid URLs", { timeout: 5000 }, () => {
expect(isHttpUrl("http://[invalid")).toBe(false);
});
it("returns false for non-http schemes", { timeout: 5000 }, () => {
expect(isHttpUrl("ftp://example.com/a.wav")).toBe(false);
});
});
describe("checkAborted", () => {
it("no-op on undefined", { timeout: 5000 }, () => {
expect(() => checkAborted(undefined)).not.toThrow();
});
it("no-op on non-aborted signal", { timeout: 5000 }, () => {
const ctrl = new AbortController();
expect(() => checkAborted(ctrl.signal)).not.toThrow();
});
it("throws AUDIO_ENGINE_SERVER.ABORTED on aborted signal", {
timeout: 5000,
}, () => {
const ctrl = new AbortController();
ctrl.abort();
expect(() => checkAborted(ctrl.signal)).toThrowError(
expect.objectContaining({
code: AUDIO_ENGINE_SERVER_CODES.ABORTED,
})
);
});
it("falls back to literal 'aborted' when signal.reason is undefined", {
timeout: 5000,
}, () => {
// `AbortController.abort()` populates `reason` with a default
// DOMException in modern runtimes, so the `signal.reason ?? "aborted"`
// fallback can only be reached via a structurally-compatible signal
// whose `reason` is explicitly undefined.
const fakeSignal = {
aborted: true,
reason: undefined,
} as unknown as AbortSignal;
try {
checkAborted(fakeSignal);
throw new Error("expected throw");
} catch (err) {
const ctx = (err as { context?: { reason?: string } }).context;
expect(ctx?.reason).toBe("aborted");
}
});
});

View File

@@ -0,0 +1,72 @@
import { isAbsolute, resolve } from "node:path";
import {
AUDIO_ENGINE_SERVER_CODES,
AudioEngineServerError,
} from "../error-codes/error-codes.js";
/**
* Reject empty / null-byte paths and — when `rootDir` is supplied — paths
* that resolve outside the boundary via `..` traversal. Throws
* `AudioEngineServerError(INVALID_PATH)` before any filesystem call.
*
* URL inputs (anything that parses as `http`/`https`) skip filesystem
* sanitisation and are returned unchanged; non-`http(s)` URLs and other
* pathological strings are still rejected.
*/
export function sanitizePath(path: string, rootDir?: string): string {
if (typeof path !== "string" || path.length === 0) {
throw new AudioEngineServerError(
AUDIO_ENGINE_SERVER_CODES.INVALID_PATH,
"audio-engine-server.invalid-path.empty",
{ context: { path } }
);
}
if (path.includes("\0")) {
throw new AudioEngineServerError(
AUDIO_ENGINE_SERVER_CODES.INVALID_PATH,
"audio-engine-server.invalid-path.null-byte",
{ context: { path } }
);
}
if (rootDir !== undefined) {
const root = resolve(rootDir);
const candidate = isAbsolute(path)
? resolve(path)
: resolve(root, path);
if (
candidate !== root &&
!candidate.startsWith(`${root}/`) &&
!candidate.startsWith(`${root}\\`)
) {
throw new AudioEngineServerError(
AUDIO_ENGINE_SERVER_CODES.INVALID_PATH,
"audio-engine-server.invalid-path.traversal",
{ context: { path, rootDir: root } }
);
}
return candidate;
}
return path;
}
/** Returns `true` for any string parseable as an `http`/`https` URL. */
export function isHttpUrl(value: string): boolean {
if (!/^https?:\/\//i.test(value)) return false;
try {
const u = new URL(value);
return u.protocol === "http:" || u.protocol === "https:";
} catch {
return false;
}
}
/** Throws `AUDIO_ENGINE_SERVER.ABORTED` if the signal is already aborted. */
export function checkAborted(signal: AbortSignal | undefined): void {
if (signal?.aborted) {
throw new AudioEngineServerError(
AUDIO_ENGINE_SERVER_CODES.ABORTED,
"audio-engine-server.aborted",
{ context: { reason: String(signal.reason ?? "aborted") } }
);
}
}

View File

@@ -0,0 +1,76 @@
# FileSinkAdapter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`FileSinkAdapter` is the server-tier exit point for `AudioDocument`
instances: it encodes a document through `@suaveplan/audio-codecs` for
the requested format and writes the resulting bytes through the injected
`FsLike` seam. Like `FileSourceAdapter`, the sink owns the
path-sanitisation boundary and exposes both buffered (`save`,
`saveMany`) and streaming (`createWriteStream`) modes.
## Features
- Buffered `save(doc, path, format, opts?)` runs the codec encoder via
`encodeTo`, concatenates the resulting chunks, and writes the file —
creating parent directories implicitly.
- `saveMany(docs, pathFormat, format, opts?)` parallel-encodes a list of
documents, expanding `%d` in the path template per index.
- `createWriteStream(path)` returns a WHATWG `WritableStream<Uint8Array>`
backed by `node:fs.createWriteStream` (or the injected `FsLike`).
- Zod-validated options; failures throw `AUDIO_ENGINE_SERVER.INVALID_OPTIONS`.
- `sanitizePath` runs before any filesystem call.
- Unknown / unregistered format → `AUDIO_ENGINE_SERVER.ENCODE_FAILED`.
- Underlying write errors → `AUDIO_ENGINE_SERVER.IO_FAILED`.
- OpenTelemetry instrumentation — span `file.save`, counter
`audio_engine_bytes_written_total`, histogram
`audio_engine_save_duration_ms`.
## Usage: Basic
```ts
import { FileSinkAdapter } from "@suaveplan/audio-engine-server";
const sink = new FileSinkAdapter();
await sink.save(doc, "/tmp/out.flac", "flac");
```
## Usage: Advanced
```ts
const sink = new FileSinkAdapter({ rootDir: "/var/exports" });
const writer = sink.createWriteStream("session.wav");
await pcmStream.pipeTo(writer);
await sink.saveMany(
[trackA, trackB, trackC],
"stem-%d.wav",
"wav",
{ bitDepth: 24 }
);
```
## API Reference
- `new FileSinkAdapter(options?: FileSinkOptions)` — `fs`, `registry`,
`rootDir` optional.
- `save(doc, path, format, opts?): Promise<void>`
- `saveMany(docs, pathFormat, format?, opts?): Promise<void>`
- `createWriteStream(path): WritableStream<Uint8Array>`
## Implementation Notes
The buffered `save` collects encoder chunks into an array, computes the
total length, and writes a single concatenated buffer; the codec layer
already streams internally so this is a small one-time copy. `saveMany`
substitutes the literal `%d` token in `pathFormat` with the document's
index. Unknown codec formats surface from the codec layer as
`AUDIO_CODECS.NOT_SUPPORTED`; the sink re-throws them as
`AUDIO_ENGINE_SERVER.ENCODE_FAILED` with the offending format in
`context`. Underlying `node:fs` write failures (`EACCES`, `ENOSPC`, etc.)
become `AUDIO_ENGINE_SERVER.IO_FAILED` with the resolved absolute path in
`context`.

View File

@@ -0,0 +1,18 @@
import z from "zod";
export const FileSinkOptionsSchema = z
.object({
fs: z.unknown().optional(),
registry: z.unknown().optional(),
rootDir: z.string().min(1).optional(),
})
.strict();
export const SaveOptionsSchema = z
.object({
signal: z.unknown().optional(),
bitDepth: z
.union([z.literal(8), z.literal(16), z.literal(24), z.literal(32)])
.optional(),
})
.strict();

View File

@@ -0,0 +1,243 @@
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { AudioDocument, PAGE_SIZE } from "@suaveplan/audio-core";
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { AUDIO_ENGINE_SERVER_CODES } from "../error-codes/error-codes.js";
import { FileSourceAdapter } from "../source/file-source.js";
import { FileSinkAdapter } from "./file-sink.js";
function makeDoc(samples = 128): AudioDocument {
const data = new Float32Array(PAGE_SIZE);
return new AudioDocument({
sampleRate: 44_100,
channels: 1,
sourceLength: samples,
pages: [{ channelData: [data], startSample: 0 }],
});
}
describe("FileSinkAdapter", () => {
let dir: string;
let sink: FileSinkAdapter;
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), "aes-sink-"));
sink = new FileSinkAdapter();
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
it("constructor rejects invalid options", { timeout: 5000 }, () => {
expect(() => new FileSinkAdapter({ rootDir: "" })).toThrowError(
expect.objectContaining({
code: AUDIO_ENGINE_SERVER_CODES.INVALID_OPTIONS,
})
);
});
it("save then load round-trips sample and channel counts", {
timeout: 30_000,
}, async () => {
const doc = makeDoc(64);
const path = join(dir, "out.wav");
await sink.save(doc, path, "wav");
const source = new FileSourceAdapter();
const back = await source.load(path);
expect(back.channels).toBe(doc.channels);
expect(back.sourceLength).toBe(doc.sourceLength);
});
it("save rejects null-byte paths with AUDIO_ENGINE_SERVER.INVALID_PATH", {
timeout: 5000,
}, async () => {
await expect(
sink.save(makeDoc(), "out\0bad", "wav")
).rejects.toMatchObject({
code: AUDIO_ENGINE_SERVER_CODES.INVALID_PATH,
});
});
it("save throws AUDIO_ENGINE_SERVER.ENCODE_FAILED for an unregistered format", {
timeout: 30_000,
}, async () => {
await expect(
sink.save(makeDoc(), join(dir, "out.unknown"), "nonexistent-format")
).rejects.toMatchObject({
code: AUDIO_ENGINE_SERVER_CODES.ENCODE_FAILED,
});
});
it("save rejects invalid options", { timeout: 5000 }, async () => {
await expect(
sink.save(makeDoc(), join(dir, "x.wav"), "wav", {
bitDepth: 7 as unknown as 16,
})
).rejects.toMatchObject({
code: AUDIO_ENGINE_SERVER_CODES.INVALID_OPTIONS,
});
});
it("save short-circuits on a pre-aborted signal", {
timeout: 5000,
}, async () => {
const ctrl = new AbortController();
ctrl.abort();
await expect(
sink.save(makeDoc(), join(dir, "x.wav"), "wav", {
signal: ctrl.signal,
})
).rejects.toMatchObject({
code: AUDIO_ENGINE_SERVER_CODES.ABORTED,
});
});
it("save wraps fs write errors as IO_FAILED", {
timeout: 30_000,
}, async () => {
const flaky = new FileSinkAdapter({
fs: {
readFile: async () => new Uint8Array(0),
writeFile: async () => {
throw new Error("ENOSPC");
},
mkdir: async () => undefined,
stat: async () => ({ size: 0, isFile: true }),
unlink: async () => undefined,
createReadStream: () => new ReadableStream(),
createWriteStream: () => new WritableStream(),
},
});
await expect(
flaky.save(makeDoc(), join(dir, "x.wav"), "wav")
).rejects.toMatchObject({
code: AUDIO_ENGINE_SERVER_CODES.IO_FAILED,
});
});
it("createWriteStream persists a piped stream to a real tmpdir file", {
timeout: 30_000,
}, async () => {
const path = join(dir, "stream.bin");
const writer = sink.createWriteStream(path);
const w = writer.getWriter();
await w.write(new Uint8Array([1, 2, 3, 4]));
await w.close();
const back = await readFile(path);
expect(Array.from(back)).toEqual([1, 2, 3, 4]);
});
it("createWriteStream rejects invalid paths", { timeout: 5000 }, () => {
expect(() => sink.createWriteStream("")).toThrowError(
expect.objectContaining({
code: AUDIO_ENGINE_SERVER_CODES.INVALID_PATH,
})
);
});
it("createWriteStream wraps fs errors as IO_FAILED", {
timeout: 5000,
}, () => {
const flaky = new FileSinkAdapter({
fs: {
readFile: async () => new Uint8Array(0),
writeFile: async () => undefined,
mkdir: async () => undefined,
stat: async () => ({ size: 0, isFile: true }),
unlink: async () => undefined,
createReadStream: () => new ReadableStream(),
createWriteStream: () => {
throw new Error("boom");
},
},
});
expect(() => flaky.createWriteStream("/tmp/x")).toThrowError(
expect.objectContaining({
code: AUDIO_ENGINE_SERVER_CODES.IO_FAILED,
})
);
});
it("saveMany writes every doc with %d index substitution", {
timeout: 30_000,
}, async () => {
const docs = [makeDoc(16), makeDoc(32), makeDoc(48)];
await sink.saveMany(docs, join(dir, "out-%d.wav"));
const source = new FileSourceAdapter();
const back = await Promise.all([
source.load(join(dir, "out-0.wav")),
source.load(join(dir, "out-1.wav")),
source.load(join(dir, "out-2.wav")),
]);
expect(back.map((d) => d.sourceLength)).toEqual([16, 32, 48]);
});
it("save honours bitDepth option", { timeout: 30_000 }, async () => {
const path = join(dir, "bd.wav");
await sink.save(makeDoc(32), path, "wav", { bitDepth: 24 });
const source = new FileSourceAdapter();
const back = await source.load(path);
expect(back.sourceLength).toBe(32);
});
it("save wraps generic encoder failures as ENCODE_FAILED", {
timeout: 30_000,
}, async () => {
// Encode a doc whose sample rate the WAV codec can't handle: WAV
// accepts any rate, so trigger encode-failed via a corrupted
// CodecRegistry that throws inside encode().
const path = join(dir, "ef.wav");
const { CodecRegistry, WavCodec } = await import(
"@suaveplan/audio-codecs"
);
const broken = new CodecRegistry({
codecs: [
{
...WavCodec,
encode: async () => {
throw new Error("synthetic encode failure");
},
},
],
});
const flaky = new FileSinkAdapter({ registry: broken });
await expect(flaky.save(makeDoc(8), path, "wav")).rejects.toMatchObject(
{
code: AUDIO_ENGINE_SERVER_CODES.ENCODE_FAILED,
}
);
});
it("saveMany short-circuits on pre-aborted signal", {
timeout: 5000,
}, async () => {
const ctrl = new AbortController();
ctrl.abort();
await expect(
sink.saveMany([], "x-%d.wav", "wav", { signal: ctrl.signal })
).rejects.toMatchObject({
code: AUDIO_ENGINE_SERVER_CODES.ABORTED,
});
});
it("save threads a non-aborted signal into the encode call", {
timeout: 30_000,
}, async () => {
// Pass an idle signal — encoder runs to completion, exercising the
// `opts.signal !== undefined` branch that wires the signal into
// encodeOpts before encodeTo is invoked.
const ctrl = new AbortController();
const path = join(dir, "with-signal.wav");
await sink.save(makeDoc(32), path, "wav", { signal: ctrl.signal });
const source = new FileSourceAdapter();
const back = await source.load(path);
expect(back.sourceLength).toBe(32);
});
});

View File

@@ -0,0 +1,199 @@
import { metrics, trace } from "@opentelemetry/api";
import {
AacCodec,
AiffCodec,
CafCodec,
CodecRegistry,
encodeTo,
FlacCodec,
Mp3Codec,
OggVorbisCodec,
OpusCodec,
QoaCodec,
WavCodec,
WebMCodec,
} from "@suaveplan/audio-codecs";
import type { AudioDocument } from "@suaveplan/audio-core";
import {
AUDIO_ENGINE_SERVER_CODES,
AudioEngineServerError,
} from "../error-codes/error-codes.js";
import { defaultFs, type FsLike } from "../internal/fs-like.js";
import { checkAborted, sanitizePath } from "../internal/sanitize-path.js";
import {
FileSinkOptionsSchema,
SaveOptionsSchema,
} from "./file-sink.schemas.js";
import type { FileSinkOptions, SaveOptions } from "./file-sink.types.js";
const TRACER = trace.getTracer("@suaveplan/audio-engine-server", "0.1.0");
const METER = metrics.getMeter("@suaveplan/audio-engine-server", "0.1.0");
const BYTES_WRITTEN = METER.createCounter("audio_engine_bytes_written_total", {
description: "Bytes written by FileSinkAdapter operations",
});
const SAVE_HIST = METER.createHistogram("audio_engine_save_duration_ms", {
description: "FileSinkAdapter.save wall-clock duration",
});
let DEFAULT_REGISTRY: CodecRegistry | undefined;
function getDefaultRegistry(): CodecRegistry {
if (!DEFAULT_REGISTRY) {
DEFAULT_REGISTRY = new CodecRegistry({
codecs: [
WavCodec,
AiffCodec,
CafCodec,
Mp3Codec,
FlacCodec,
OggVorbisCodec,
OpusCodec,
AacCodec,
WebMCodec,
QoaCodec,
],
});
}
return DEFAULT_REGISTRY;
}
/**
* Server-tier file sink: encodes an `AudioDocument` via
* `@suaveplan/audio-codecs` and writes the bytes through the injected
* `FsLike` seam. Streaming and buffered modes share the same path
* sanitisation and OTel instrumentation.
*/
export class FileSinkAdapter {
readonly #fs: FsLike;
readonly #registry: CodecRegistry | undefined;
readonly #rootDir: string | undefined;
public constructor(options: FileSinkOptions = {}) {
const parsed = FileSinkOptionsSchema.safeParse(options);
if (!parsed.success) {
throw new AudioEngineServerError(
AUDIO_ENGINE_SERVER_CODES.INVALID_OPTIONS,
"audio-engine-server.sink.options-invalid",
{ context: { issues: parsed.error.issues } }
);
}
this.#fs = options.fs ?? defaultFs;
this.#registry = options.registry;
this.#rootDir = options.rootDir;
}
public async save(
doc: AudioDocument,
path: string,
format: string,
opts: SaveOptions = {}
): Promise<void> {
const parsed = SaveOptionsSchema.safeParse(opts);
if (!parsed.success) {
throw new AudioEngineServerError(
AUDIO_ENGINE_SERVER_CODES.INVALID_OPTIONS,
"audio-engine-server.save.options-invalid",
{ context: { issues: parsed.error.issues } }
);
}
checkAborted(opts.signal);
const safe = sanitizePath(path, this.#rootDir);
const span = TRACER.startSpan("file.save");
const startedAt = performance.now();
let written = 0;
try {
const chunks: Uint8Array[] = [];
const encodeOpts: {
registry?: CodecRegistry;
signal?: AbortSignal;
bitDepth?: 8 | 16 | 24 | 32;
} = {};
encodeOpts.registry = this.#registry ?? getDefaultRegistry();
if (opts.signal !== undefined) encodeOpts.signal = opts.signal;
if (opts.bitDepth !== undefined)
encodeOpts.bitDepth = opts.bitDepth;
try {
await encodeTo(
doc,
(chunk: Uint8Array) => {
chunks.push(chunk);
written += chunk.byteLength;
},
format,
encodeOpts
);
} catch (err) {
const code = (err as { code?: string }).code;
if (code === "AUDIO_CODECS.NOT_SUPPORTED") {
throw new AudioEngineServerError(
AUDIO_ENGINE_SERVER_CODES.ENCODE_FAILED,
"audio-engine-server.save.unknown-format",
{ cause: err, context: { format } }
);
}
throw new AudioEngineServerError(
AUDIO_ENGINE_SERVER_CODES.ENCODE_FAILED,
"audio-engine-server.save.encode-failed",
{ cause: err, context: { format } }
);
}
const merged = mergeChunks(chunks);
try {
await this.#fs.writeFile(safe, merged);
} catch (err) {
throw new AudioEngineServerError(
AUDIO_ENGINE_SERVER_CODES.IO_FAILED,
"audio-engine-server.save.io-failed",
{ cause: err, context: { path: safe } }
);
}
BYTES_WRITTEN.add(written);
} finally {
SAVE_HIST.record(performance.now() - startedAt);
span.end();
}
}
public createWriteStream(path: string): WritableStream<Uint8Array> {
const safe = sanitizePath(path, this.#rootDir);
try {
return this.#fs.createWriteStream(safe);
} catch (err) {
throw new AudioEngineServerError(
AUDIO_ENGINE_SERVER_CODES.IO_FAILED,
"audio-engine-server.write-stream.failed",
{ cause: err, context: { path: safe } }
);
}
}
public async saveMany(
docs: AudioDocument[],
pathFormat: string,
format = "wav",
opts: SaveOptions = {}
): Promise<void> {
checkAborted(opts.signal);
await Promise.all(
docs.map((doc, i) =>
this.save(
doc,
pathFormat.replace("%d", String(i)),
format,
opts
)
)
);
}
}
function mergeChunks(chunks: readonly Uint8Array[]): Uint8Array {
let total = 0;
for (const c of chunks) total += c.byteLength;
const out = new Uint8Array(total);
let offset = 0;
for (const c of chunks) {
out.set(c, offset);
offset += c.byteLength;
}
return out;
}

View File

@@ -0,0 +1,18 @@
import type { CodecRegistry } from "@suaveplan/audio-codecs";
import type { AudioDocument } from "@suaveplan/audio-core";
import type { FsLike } from "../internal/fs-like.js";
export interface FileSinkOptions {
readonly fs?: FsLike;
readonly registry?: CodecRegistry;
readonly rootDir?: string;
}
export interface SaveOptions {
readonly signal?: AbortSignal;
readonly bitDepth?: 8 | 16 | 24 | 32;
}
export type SaveResult = undefined;
export type { AudioDocument };

View File

@@ -0,0 +1,10 @@
export { FileSinkAdapter } from "./file-sink.js";
export {
FileSinkOptionsSchema,
SaveOptionsSchema,
} from "./file-sink.schemas.js";
export type {
FileSinkOptions,
SaveOptions,
SaveResult,
} from "./file-sink.types.js";

View File

@@ -0,0 +1,83 @@
# FileSourceAdapter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`FileSourceAdapter` is the server-tier entry point for reading audio files
into the universal `AudioDocument` model. It owns the path-sanitisation
boundary, dispatches to `@suaveplan/audio-codecs` `decodeSource` for
format detection and decode, and exposes both buffered (`load`,
`loadMany`) and streaming (`createReadStream`) modes through a single
injectable `FsLike` seam. The adapter implements no DSP itself — every
byte ends up routed through the universal codec layer.
## Features
- Buffered `load(path, opts?)` returns an `AudioDocument` from a local
path or an `http`/`https` URL.
- `loadMany(paths, opts?)` resolves a `Promise.all` over `load`,
preserving input order regardless of decode completion order.
- `createReadStream(path)` returns a WHATWG `ReadableStream<Uint8Array>`
backed by the injected `FsLike` (or `node:fs.createReadStream`).
- Zod-validated options at every entry point; failures throw
`AUDIO_ENGINE_SERVER.INVALID_OPTIONS`.
- `sanitizePath` rejects empty / null-byte / `..`-traversal paths against
the optional `rootDir` boundary before any filesystem call is issued.
- `AbortSignal` propagation — pre-aborted signals short-circuit with
`AUDIO_ENGINE_SERVER.ABORTED`; mid-load aborts surface the same code via
the codec layer.
- OpenTelemetry instrumentation — span `file.load`, counter
`audio_engine_bytes_read_total`, histogram
`audio_engine_load_duration_ms`. Inert without an exporter.
## Usage: Basic
```ts
import { FileSourceAdapter } from "@suaveplan/audio-engine-server";
const source = new FileSourceAdapter();
const doc = await source.load("/tmp/sample.wav");
console.log(doc.sampleRate, doc.channels, doc.sourceLength);
```
## Usage: Advanced
```ts
import { FileSourceAdapter, defaultFs } from "@suaveplan/audio-engine-server";
const source = new FileSourceAdapter({
fs: defaultFs,
rootDir: "/var/audio",
registry: customCodecRegistry,
});
const ctrl = new AbortController();
setTimeout(() => ctrl.abort(), 5000);
const docs = await source.loadMany(
["a.wav", "b.flac", "c.mp3"],
{ signal: ctrl.signal }
);
```
## API Reference
- `new FileSourceAdapter(options?: FileSourceOptions)` — `fs`, `fetch`,
`registry`, `rootDir` all optional.
- `load(path, opts?): Promise<AudioDocument>`
- `loadMany(paths, opts?): Promise<AudioDocument[]>`
- `createReadStream(path): ReadableStream<Uint8Array>`
## Implementation Notes
`load` distinguishes URL inputs via `isHttpUrl`; URL inputs are fetched
through the injected `fetch` and skip filesystem sanitisation. Local
paths pass through `sanitizePath(path, rootDir)` before any `FsLike`
call, so a path-traversal attempt throws `INVALID_PATH` before the
filesystem is touched. `ENOENT` from the underlying `readFile` is mapped
to `FILE_NOT_FOUND`; any other read error becomes `IO_FAILED`. Decode
failures from the codec layer are re-thrown as `DECODE_FAILED` unless the
inner error reports a stream abort, in which case the engine maps it to
`AUDIO_ENGINE_SERVER.ABORTED` to preserve the signal-aborted contract.

View File

@@ -0,0 +1,17 @@
import z from "zod";
export const FileSourceOptionsSchema = z
.object({
fs: z.unknown().optional(),
fetch: z.unknown().optional(),
registry: z.unknown().optional(),
rootDir: z.string().min(1).optional(),
})
.strict();
export const LoadOptionsSchema = z
.object({
format: z.string().min(1).optional(),
signal: z.unknown().optional(),
})
.strict();

View File

@@ -0,0 +1,345 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { WavCodec } from "@suaveplan/audio-codecs";
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { AUDIO_ENGINE_SERVER_CODES } from "../error-codes/error-codes.js";
import { FileSourceAdapter } from "./file-source.js";
async function writeWav(path: string, samples = 256): Promise<number> {
const bytes = await WavCodec.encode([new Float32Array(samples)], {
sampleRate: 44_100,
bitDepth: 16,
format: "pcm",
});
await writeFile(path, bytes);
return bytes.byteLength;
}
describe("FileSourceAdapter", () => {
let dir: string;
let source: FileSourceAdapter;
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), "aes-src-"));
source = new FileSourceAdapter();
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
it("constructor rejects invalid options", { timeout: 5000 }, () => {
expect(() => new FileSourceAdapter({ rootDir: "" })).toThrowError(
expect.objectContaining({
code: AUDIO_ENGINE_SERVER_CODES.INVALID_OPTIONS,
})
);
});
it("constructor accepts an empty options object", { timeout: 5000 }, () => {
expect(() => new FileSourceAdapter({})).not.toThrow();
});
it("load decodes a real tmpdir file into an AudioDocument", {
timeout: 30_000,
}, async () => {
const path = join(dir, "a.wav");
await writeWav(path, 128);
const doc = await source.load(path);
expect(doc.sampleRate).toBe(44_100);
expect(doc.channels).toBe(1);
expect(doc.sourceLength).toBe(128);
});
it("load throws AUDIO_ENGINE_SERVER.FILE_NOT_FOUND for a missing path", {
timeout: 30_000,
}, async () => {
await expect(
source.load(join(dir, "missing.wav"))
).rejects.toMatchObject({
code: AUDIO_ENGINE_SERVER_CODES.FILE_NOT_FOUND,
});
});
it("loadMany preserves input order", { timeout: 30_000 }, async () => {
const a = join(dir, "a.wav");
const b = join(dir, "b.wav");
const c = join(dir, "c.wav");
await writeWav(a, 32);
await writeWav(b, 64);
await writeWav(c, 96);
const docs = await source.loadMany([a, b, c]);
expect(docs.map((d) => d.sourceLength)).toEqual([32, 64, 96]);
});
it("load rejects invalid options", { timeout: 5000 }, async () => {
const path = join(dir, "a.wav");
await writeWav(path, 8);
await expect(
source.load(path, { format: "" } as unknown as { format?: string })
).rejects.toMatchObject({
code: AUDIO_ENGINE_SERVER_CODES.INVALID_OPTIONS,
});
});
it("load throws AUDIO_ENGINE_SERVER.ABORTED for a pre-aborted signal", {
timeout: 5000,
}, async () => {
const path = join(dir, "a.wav");
await writeWav(path, 8);
const ctrl = new AbortController();
ctrl.abort();
await expect(
source.load(path, { signal: ctrl.signal })
).rejects.toMatchObject({
code: AUDIO_ENGINE_SERVER_CODES.ABORTED,
});
});
it("loadMany short-circuits on pre-aborted signal", {
timeout: 5000,
}, async () => {
const ctrl = new AbortController();
ctrl.abort();
await expect(
source.loadMany([], { signal: ctrl.signal })
).rejects.toMatchObject({
code: AUDIO_ENGINE_SERVER_CODES.ABORTED,
});
});
it("createReadStream streams a real tmpdir file without full buffering", {
timeout: 30_000,
}, async () => {
const path = join(dir, "big.bin");
const size = 4 * 1024;
await writeFile(path, new Uint8Array(size).fill(7));
const stream = source.createReadStream(path);
let received = 0;
const reader = stream.getReader();
for (;;) {
const { done, value } = await reader.read();
if (done) break;
received += value.byteLength;
}
expect(received).toBe(size);
});
it("createReadStream rejects invalid paths", { timeout: 5000 }, () => {
expect(() => source.createReadStream("")).toThrowError(
expect.objectContaining({
code: AUDIO_ENGINE_SERVER_CODES.INVALID_PATH,
})
);
});
it("createReadStream wraps fs errors as IO_FAILED", {
timeout: 5000,
}, () => {
const flaky = new FileSourceAdapter({
fs: {
...({} as never),
readFile: async () => new Uint8Array(0),
writeFile: async () => undefined,
mkdir: async () => undefined,
stat: async () => ({ size: 0, isFile: true }),
unlink: async () => undefined,
createReadStream: () => {
throw new Error("boom");
},
createWriteStream: () => new WritableStream(),
},
});
expect(() => flaky.createReadStream("/tmp/x")).toThrowError(
expect.objectContaining({
code: AUDIO_ENGINE_SERVER_CODES.IO_FAILED,
})
);
});
it("rejects path-traversal before any fs call", {
timeout: 5000,
}, async () => {
const guarded = new FileSourceAdapter({ rootDir: dir });
await expect(guarded.load("../../etc/passwd")).rejects.toMatchObject({
code: AUDIO_ENGINE_SERVER_CODES.INVALID_PATH,
});
});
it("load wraps unexpected fs failures as IO_FAILED", {
timeout: 5000,
}, async () => {
const flaky = new FileSourceAdapter({
fs: {
readFile: async () => {
const err = new Error("EACCES");
(err as { code?: string }).code = "EACCES";
throw err;
},
writeFile: async () => undefined,
mkdir: async () => undefined,
stat: async () => ({ size: 0, isFile: true }),
unlink: async () => undefined,
createReadStream: () => new ReadableStream(),
createWriteStream: () => new WritableStream(),
},
});
await expect(flaky.load("/tmp/anything.wav")).rejects.toMatchObject({
code: AUDIO_ENGINE_SERVER_CODES.IO_FAILED,
});
});
it("load wraps decode failures as DECODE_FAILED", {
timeout: 30_000,
}, async () => {
const path = join(dir, "garbage.bin");
await writeFile(path, new Uint8Array(64));
await expect(source.load(path)).rejects.toMatchObject({
code: AUDIO_ENGINE_SERVER_CODES.DECODE_FAILED,
});
});
it("load fetches http URLs via injected fetch", {
timeout: 30_000,
}, async () => {
const wav = await WavCodec.encode([new Float32Array(16)], {
sampleRate: 44_100,
bitDepth: 16,
format: "pcm",
});
const fakeFetch = (async () =>
new Response(wav, { status: 200 })) as typeof fetch;
const httpSource = new FileSourceAdapter({ fetch: fakeFetch });
const doc = await httpSource.load("http://example.com/a.wav");
expect(doc.sourceLength).toBe(16);
});
it("load maps fetch network error to IO_FAILED", {
timeout: 30_000,
}, async () => {
const fakeFetch = (async () => {
throw new Error("connection refused");
}) as typeof fetch;
const httpSource = new FileSourceAdapter({ fetch: fakeFetch });
await expect(
httpSource.load("http://example.com/a.wav")
).rejects.toMatchObject({
code: AUDIO_ENGINE_SERVER_CODES.IO_FAILED,
});
});
it("load maps fetch AbortError to ABORTED", {
timeout: 30_000,
}, async () => {
const fakeFetch = (async () => {
const err = new Error("aborted");
(err as { name?: string }).name = "AbortError";
throw err;
}) as typeof fetch;
const httpSource = new FileSourceAdapter({ fetch: fakeFetch });
await expect(
httpSource.load("http://example.com/a.wav")
).rejects.toMatchObject({
code: AUDIO_ENGINE_SERVER_CODES.ABORTED,
});
});
it("load with custom registry decodes via injected codec", {
timeout: 30_000,
}, async () => {
const { CodecRegistry, WavCodec } = await import(
"@suaveplan/audio-codecs"
);
const reg = new CodecRegistry({ codecs: [WavCodec] });
const custom = new FileSourceAdapter({ registry: reg });
const path = join(dir, "r.wav");
await writeWav(path, 24);
const doc = await custom.load(path);
expect(doc.sourceLength).toBe(24);
});
it("load with format hint exercises explicit format branch", {
timeout: 30_000,
}, async () => {
const path = join(dir, "fmt.wav");
await writeWav(path, 16);
const doc = await source.load(path, { format: "wav" });
expect(doc.sourceLength).toBe(16);
});
it("load translates AUDIO_CODECS.STREAM_ERROR to ABORTED", {
timeout: 30_000,
}, async () => {
// Hit the mid-decode STREAM_ERROR branch by aborting *after* the
// initial checkAborted gate but *before* decodeSource is dispatched.
// The injected fs.readFile aborts the controller before returning
// real WAV bytes, ensuring decode sees the aborted signal and emits
// AUDIO_CODECS.STREAM_ERROR which the source must map to ABORTED.
const ctrl = new AbortController();
const wav = await WavCodec.encode([new Float32Array(8)], {
sampleRate: 44_100,
bitDepth: 16,
format: "pcm",
});
const trip = new FileSourceAdapter({
fs: {
readFile: async () => {
ctrl.abort();
return new Uint8Array(wav);
},
writeFile: async () => undefined,
mkdir: async () => undefined,
stat: async () => ({ size: wav.byteLength, isFile: true }),
unlink: async () => undefined,
createReadStream: () => new ReadableStream(),
createWriteStream: () => new WritableStream(),
},
});
await expect(
trip.load("/tmp/anything.wav", { signal: ctrl.signal })
).rejects.toMatchObject({
code: AUDIO_ENGINE_SERVER_CODES.ABORTED,
});
});
it("load wires a non-aborted signal into the fetch init", {
timeout: 30_000,
}, async () => {
// Exercises the `if (signal !== undefined) init.signal = signal;`
// true branch in the http fetch path.
const wav = await WavCodec.encode([new Float32Array(8)], {
sampleRate: 44_100,
bitDepth: 16,
format: "pcm",
});
let observedSignal: AbortSignal | undefined;
const fakeFetch = (async (_url: string, init?: RequestInit) => {
observedSignal = init?.signal ?? undefined;
return new Response(wav, { status: 200 });
}) as typeof fetch;
const ctrl = new AbortController();
const httpSource = new FileSourceAdapter({ fetch: fakeFetch });
const doc = await httpSource.load("http://example.com/a.wav", {
signal: ctrl.signal,
});
expect(doc.sourceLength).toBe(8);
expect(observedSignal).toBe(ctrl.signal);
});
it("load maps fetch !ok to IO_FAILED", { timeout: 30_000 }, async () => {
const fakeFetch = (async () =>
new Response("not found", { status: 404 })) as typeof fetch;
const httpSource = new FileSourceAdapter({ fetch: fakeFetch });
await expect(
httpSource.load("http://example.com/a.wav")
).rejects.toMatchObject({
code: AUDIO_ENGINE_SERVER_CODES.IO_FAILED,
});
});
});

View File

@@ -0,0 +1,187 @@
import { metrics, trace } from "@opentelemetry/api";
import { type CodecRegistry, decodeSource } from "@suaveplan/audio-codecs";
import type { AudioDocument } from "@suaveplan/audio-core";
import {
AUDIO_ENGINE_SERVER_CODES,
AudioEngineServerError,
} from "../error-codes/error-codes.js";
import { defaultFs, type FsLike } from "../internal/fs-like.js";
import {
checkAborted,
isHttpUrl,
sanitizePath,
} from "../internal/sanitize-path.js";
import {
FileSourceOptionsSchema,
LoadOptionsSchema,
} from "./file-source.schemas.js";
import type { FileSourceOptions, LoadOptions } from "./file-source.types.js";
const TRACER = trace.getTracer("@suaveplan/audio-engine-server", "0.1.0");
const METER = metrics.getMeter("@suaveplan/audio-engine-server", "0.1.0");
const BYTES_READ = METER.createCounter("audio_engine_bytes_read_total", {
description: "Bytes read by FileSourceAdapter operations",
});
const LOAD_HIST = METER.createHistogram("audio_engine_load_duration_ms", {
description: "FileSourceAdapter.load wall-clock duration",
});
/**
* Server-tier file source: reads audio files from disk or URL into
* `AudioDocument` instances via `@suaveplan/audio-codecs`. Buffered
* (`load`) and streaming (`createReadStream`) modes share a single
* `FsLike` seam for dependency inversion.
*/
export class FileSourceAdapter {
readonly #fs: FsLike;
readonly #fetch: typeof fetch;
readonly #registry: CodecRegistry | undefined;
readonly #rootDir: string | undefined;
public constructor(options: FileSourceOptions = {}) {
const parsed = FileSourceOptionsSchema.safeParse(options);
if (!parsed.success) {
throw new AudioEngineServerError(
AUDIO_ENGINE_SERVER_CODES.INVALID_OPTIONS,
"audio-engine-server.options-invalid",
{ context: { issues: parsed.error.issues } }
);
}
this.#fs = options.fs ?? defaultFs;
this.#fetch = options.fetch ?? fetch;
this.#registry = options.registry;
this.#rootDir = options.rootDir;
}
/** Read, detect, decode → `AudioDocument`. */
public async load(
path: string,
opts: LoadOptions = {}
): Promise<AudioDocument> {
const parsed = LoadOptionsSchema.safeParse(opts);
if (!parsed.success) {
throw new AudioEngineServerError(
AUDIO_ENGINE_SERVER_CODES.INVALID_OPTIONS,
"audio-engine-server.load.options-invalid",
{ context: { issues: parsed.error.issues } }
);
}
checkAborted(opts.signal);
const span = TRACER.startSpan("file.load");
const startedAt = performance.now();
try {
const bytes = await this.#read(path, opts.signal);
BYTES_READ.add(bytes.byteLength);
const decodeArgs: {
registry?: CodecRegistry;
format?: string;
signal?: AbortSignal;
} = {};
if (this.#registry !== undefined)
decodeArgs.registry = this.#registry;
if (opts.format !== undefined) decodeArgs.format = opts.format;
if (opts.signal !== undefined) decodeArgs.signal = opts.signal;
try {
return await decodeSource(bytes, decodeArgs);
} catch (err) {
if (
err instanceof Object &&
"code" in err &&
(err as { code: string }).code ===
"AUDIO_CODECS.STREAM_ERROR"
) {
throw new AudioEngineServerError(
AUDIO_ENGINE_SERVER_CODES.ABORTED,
"audio-engine-server.load.aborted",
{ cause: err, context: { path } }
);
}
throw new AudioEngineServerError(
AUDIO_ENGINE_SERVER_CODES.DECODE_FAILED,
"audio-engine-server.load.decode-failed",
{ cause: err, context: { path } }
);
}
} finally {
LOAD_HIST.record(performance.now() - startedAt);
span.end();
}
}
/** Streaming read — `ReadableStream<Uint8Array>` backed by `node:fs`. */
public createReadStream(path: string): ReadableStream<Uint8Array> {
const safe = sanitizePath(path, this.#rootDir);
try {
return this.#fs.createReadStream(safe);
} catch (err) {
throw new AudioEngineServerError(
AUDIO_ENGINE_SERVER_CODES.IO_FAILED,
"audio-engine-server.read-stream.failed",
{ cause: err, context: { path } }
);
}
}
/** Parallel load preserving input order. */
public async loadMany(
paths: string[],
opts: LoadOptions = {}
): Promise<AudioDocument[]> {
checkAborted(opts.signal);
return Promise.all(paths.map((p) => this.load(p, opts)));
}
async #read(
path: string,
signal: AbortSignal | undefined
): Promise<Uint8Array> {
if (isHttpUrl(path)) {
const init: RequestInit = {};
if (signal !== undefined) init.signal = signal;
let res: Response;
try {
res = await this.#fetch(path, init);
} catch (err) {
if ((err as { name?: string }).name === "AbortError") {
throw new AudioEngineServerError(
AUDIO_ENGINE_SERVER_CODES.ABORTED,
"audio-engine-server.fetch.aborted",
{ cause: err, context: { path } }
);
}
throw new AudioEngineServerError(
AUDIO_ENGINE_SERVER_CODES.IO_FAILED,
"audio-engine-server.fetch.failed",
{ cause: err, context: { path } }
);
}
if (!res.ok) {
throw new AudioEngineServerError(
AUDIO_ENGINE_SERVER_CODES.IO_FAILED,
"audio-engine-server.fetch.bad-status",
{ context: { path, status: res.status } }
);
}
const buf = await res.arrayBuffer();
return new Uint8Array(buf);
}
const safe = sanitizePath(path, this.#rootDir);
try {
return await this.#fs.readFile(safe);
} catch (err) {
const code = (err as { code?: string }).code;
if (code === "ENOENT") {
throw new AudioEngineServerError(
AUDIO_ENGINE_SERVER_CODES.FILE_NOT_FOUND,
"audio-engine-server.load.not-found",
{ cause: err, context: { path: safe } }
);
}
throw new AudioEngineServerError(
AUDIO_ENGINE_SERVER_CODES.IO_FAILED,
"audio-engine-server.load.io-failed",
{ cause: err, context: { path: safe } }
);
}
}
}

View File

@@ -0,0 +1,22 @@
import type { CodecRegistry } from "@suaveplan/audio-codecs";
import type { AudioDocument } from "@suaveplan/audio-core";
import type { FsLike } from "../internal/fs-like.js";
export interface FileSourceOptions {
readonly fs?: FsLike;
readonly fetch?: typeof fetch;
readonly registry?: CodecRegistry;
readonly rootDir?: string;
}
export interface LoadOptions {
readonly format?: string;
readonly signal?: AbortSignal;
}
export type LoadResult = AudioDocument;
export interface FileSourceMetrics {
readonly bytesRead: number;
readonly durationMs: number;
}

View File

@@ -0,0 +1,11 @@
export { FileSourceAdapter } from "./file-source.js";
export {
FileSourceOptionsSchema,
LoadOptionsSchema,
} from "./file-source.schemas.js";
export type {
FileSourceMetrics,
FileSourceOptions,
LoadOptions,
LoadResult,
} from "./file-source.types.js";

View File

@@ -0,0 +1,38 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("package exports", () => {
it("every feature module has a subpath export entry", {
timeout: 5000,
}, () => {
const pkg = JSON.parse(
readFileSync(join(__dirname, "..", "package.json"), "utf-8")
);
const keys = Object.keys(pkg.exports);
for (const expected of [
".",
"./source/file-source",
"./sink/file-sink",
"./adapter/server-adapter",
"./fx/plugin-chain",
"./error-codes",
]) {
expect(keys).toContain(expected);
}
});
it("sideEffects is false", { timeout: 5000 }, () => {
const pkg = JSON.parse(
readFileSync(join(__dirname, "..", "package.json"), "utf-8")
);
expect(pkg.sideEffects).toBe(false);
});
it("engines pins node >=22", { timeout: 5000 }, () => {
const pkg = JSON.parse(
readFileSync(join(__dirname, "..", "package.json"), "utf-8")
);
expect(pkg.engines.node).toMatch(/22/);
});
});

View File

@@ -0,0 +1,43 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { WavCodec } from "@suaveplan/audio-codecs";
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { FileSinkAdapter, FileSourceAdapter } from "../src/index.js";
describe("integration", () => {
let dir: string;
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), "aes-int-"));
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
it("load then save round-trips through a real node:fs.mkdtemp tmpdir", {
timeout: 30_000,
}, async () => {
const inPath = join(dir, "in.wav");
const outPath = join(dir, "out.wav");
const wav = await WavCodec.encode([new Float32Array(256)], {
sampleRate: 44_100,
bitDepth: 16,
format: "pcm",
});
await writeFile(inPath, wav);
const source = new FileSourceAdapter();
const sink = new FileSinkAdapter();
const doc = await source.load(inPath);
await sink.save(doc, outPath, "wav");
const back = await source.load(outPath);
expect(back.sourceLength).toBe(doc.sourceLength);
expect(back.channels).toBe(doc.channels);
});
});

View File

@@ -0,0 +1,54 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { WavCodec } from "@suaveplan/audio-codecs";
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import fc from "fast-check";
import { FileSinkAdapter, FileSourceAdapter } from "../src/index.js";
describe("property: load+save round-trip preserves sample count", () => {
let dir: string;
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), "aes-prop-"));
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
it("load then save preserves sample count across a real tmpdir round-trip", {
timeout: 30_000,
}, async () => {
await fc.assert(
fc.asyncProperty(
fc.integer({ min: 16, max: 256 }),
async (samples) => {
const inPath = join(dir, `i-${samples}.wav`);
const outPath = join(dir, `o-${samples}.wav`);
const wav = await WavCodec.encode(
[new Float32Array(samples)],
{
sampleRate: 44_100,
bitDepth: 16,
format: "pcm",
}
);
await writeFile(inPath, wav);
const source = new FileSourceAdapter();
const sink = new FileSinkAdapter();
const doc = await source.load(inPath);
await sink.save(doc, outPath, "wav");
const back = await source.load(outPath);
expect(back.sourceLength).toBe(samples);
}
),
{ numRuns: 5 }
);
});
});

View File

@@ -0,0 +1,67 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
const ROOT = join(__dirname, "..", "src");
function walkTs(dir: string): string[] {
const out: string[] = [];
for (const name of readdirSync(dir)) {
const full = join(dir, name);
if (statSync(full).isDirectory()) out.push(...walkTs(full));
else if (
full.endsWith(".ts") &&
!full.endsWith(".test.ts") &&
!full.endsWith(".bench.ts")
) {
out.push(full);
}
}
return out;
}
describe("purity", () => {
it("source tree references no browser globals", { timeout: 5000 }, () => {
const banned = ["AudioContext", "AudioBuffer", "AudioWorklet"];
for (const file of walkTs(ROOT)) {
const text = readFileSync(file, "utf-8");
for (const term of banned) {
expect(text.includes(term)).toBe(false);
}
}
});
it("dependency set is limited to the declared workspace deps and zod", {
timeout: 5000,
}, () => {
const pkg = JSON.parse(
readFileSync(join(__dirname, "..", "package.json"), "utf-8")
);
const deps = Object.keys(pkg.dependencies ?? {}).sort();
const allowed = [
"@opentelemetry/api",
"@suaveplan/audio-codecs",
"@suaveplan/audio-core",
"@suaveplan/audio-timeline",
"@suaveplan/audio-transport",
"@suaveplan/error",
"@suaveplan/opentelemetry",
"@suaveplan/streams",
"@suaveplan/types",
"tslib",
"zod",
].sort();
expect(deps).toEqual(allowed);
});
it("importing the package mutates no global state", {
timeout: 5000,
}, async () => {
// Importing twice should not mutate globalThis between calls.
await import("../src/index.js");
const before = Object.keys(globalThis).length;
await import("../src/index.js");
const after = Object.keys(globalThis).length;
expect(after).toBe(before);
});
});

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["dist", "node_modules"]
}

View File

@@ -0,0 +1,18 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": [
"src/**/*.test.ts",
"src/**/*.bench.ts",
"tests/**",
"dist",
"node_modules"
]
}

View File

@@ -0,0 +1,39 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../../node_modules/.vite/packages/audio-engine-server",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: {
entry: { index: "src/index.ts" },
formats: ["es"],
},
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
/^@suaveplan\//,
/^@opentelemetry\//,
/^node:/,
"tslib",
"zod",
],
},
},
});

View File

@@ -0,0 +1,3 @@
import { createTestConfig } from "@suaveplan/testing/config";
export default createTestConfig({ testTimeout: 30_000 });