Initial Commit
This commit is contained in:
72
packages/media/audio/audio-engine-server/README.md
Normal file
72
packages/media/audio/audio-engine-server/README.md
Normal 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.
|
||||
|
||||
71
packages/media/audio/audio-engine-server/package.json
Normal file
71
packages/media/audio/audio-engine-server/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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`.
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
{}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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";
|
||||
15
packages/media/audio/audio-engine-server/src/fx/index.ts
Normal file
15
packages/media/audio/audio-engine-server/src/fx/index.ts
Normal 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";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
248
packages/media/audio/audio-engine-server/src/fx/plugin-chain.ts
Normal file
248
packages/media/audio/audio-engine-server/src/fx/plugin-chain.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
60
packages/media/audio/audio-engine-server/src/index.ts
Normal file
60
packages/media/audio/audio-engine-server/src/index.ts
Normal 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";
|
||||
@@ -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.
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
},
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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") } }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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`.
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
199
packages/media/audio/audio-engine-server/src/sink/file-sink.ts
Normal file
199
packages/media/audio/audio-engine-server/src/sink/file-sink.ts
Normal 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;
|
||||
}
|
||||
@@ -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 };
|
||||
10
packages/media/audio/audio-engine-server/src/sink/index.ts
Normal file
10
packages/media/audio/audio-engine-server/src/sink/index.ts
Normal 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";
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 } }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
11
packages/media/audio/audio-engine-server/src/source/index.ts
Normal file
11
packages/media/audio/audio-engine-server/src/source/index.ts
Normal 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";
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
12
packages/media/audio/audio-engine-server/tsconfig.json
Normal file
12
packages/media/audio/audio-engine-server/tsconfig.json
Normal 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"]
|
||||
}
|
||||
18
packages/media/audio/audio-engine-server/tsconfig.lib.json
Normal file
18
packages/media/audio/audio-engine-server/tsconfig.lib.json
Normal 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"
|
||||
]
|
||||
}
|
||||
39
packages/media/audio/audio-engine-server/vite.config.ts
Normal file
39
packages/media/audio/audio-engine-server/vite.config.ts
Normal 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",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
import { createTestConfig } from "@suaveplan/testing/config";
|
||||
|
||||
export default createTestConfig({ testTimeout: 30_000 });
|
||||
Reference in New Issue
Block a user