first commit
This commit is contained in:
72
docs/media/audio/audio-engine-server/README.md
Normal file
72
docs/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.
|
||||
|
||||
@@ -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,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`.
|
||||
98
docs/media/audio/audio-engine-server/src/fx/plugin-chain.md
Normal file
98
docs/media/audio/audio-engine-server/src/fx/plugin-chain.md
Normal file
@@ -0,0 +1,98 @@
|
||||
# PluginChain + BusRegistry + SidechainPublisher
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`PluginChain` owns the ordered list of FX plugins attached to a single
|
||||
track, the per-chain ambient state, the chain-level bypass flag, and the
|
||||
effective plugin-delay-compensation latency for that track. `DefaultBusRegistry`
|
||||
is the project-level registry passed to every chain via
|
||||
`ChainContext.buses`; `SidechainPublisher` is the handle returned by
|
||||
`BusRegistry.publish`. Together they implement plugin slot 5 of the audio
|
||||
family's three-layer model — the "FX slot list" that the
|
||||
`ServerTimelineAdapter` hosts on each track. Every plugin runs through its
|
||||
`BlockProcessor` `process()` path; no `createAudioNode` or worklet path is
|
||||
used on the server.
|
||||
|
||||
## Features
|
||||
|
||||
- Ordered plugin list with `add` / `remove` / `setPluginBypass`.
|
||||
- Chain-level bypass that short-circuits `process` while preserving the
|
||||
hosted plugins.
|
||||
- Ambient state propagation — `updateAmbient` merges a patch into
|
||||
`ChainContext.ambient` and fires every plugin's `onAmbient` hook before
|
||||
the next block runs.
|
||||
- Plugin-delay-compensation via `getEffectiveLatencySamples()`; the chain
|
||||
emits `latency-changed` whenever the sum of un-bypassed plugin latencies
|
||||
changes so the host engine can recompute per-track PDC.
|
||||
- Typed event surface (`plugin-added`, `plugin-removed`, `bypass-changed`,
|
||||
`ambient-changed`, `latency-changed`) with `on(event, listener)`
|
||||
returning an unsubscribe closure.
|
||||
- `DefaultBusRegistry` — `publish` returns a `SidechainPublisher`,
|
||||
`subscribe` notifies listeners on registration and on every publish /
|
||||
close, and `get` / `list` expose the read surface plugins consume.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { DefaultBusRegistry, PluginChain } from "@suaveplan/audio-engine-server";
|
||||
|
||||
const buses = new DefaultBusRegistry();
|
||||
const chain = new PluginChain({
|
||||
id: "track-1",
|
||||
channels: 2,
|
||||
sampleRate: 48_000,
|
||||
blockSize: 512,
|
||||
buses,
|
||||
});
|
||||
chain.process([new Float32Array(512), new Float32Array(512)], 0);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
const pub = buses.publish("kick", 2);
|
||||
pub.write([kickLeft, kickRight]);
|
||||
|
||||
const offChain = chain.on("latency-changed", ({ effectiveLatencySamples }) => {
|
||||
recomputePerTrackPdc(effectiveLatencySamples);
|
||||
});
|
||||
|
||||
chain.updateAmbient({
|
||||
track: { pan: -0.5 },
|
||||
transport: { state: "playing", time: 1.25 },
|
||||
});
|
||||
|
||||
offChain();
|
||||
pub.close();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class PluginChain` — `add`, `remove`, `setPluginBypass`,
|
||||
`updateAmbient`, `process`, `getEffectiveLatencySamples`, `on`,
|
||||
`dispose`, plus read accessors `id`, `bypassed`, `context`, `buses`,
|
||||
`list`.
|
||||
- `class DefaultBusRegistry implements BusRegistry` — `publish`,
|
||||
`subscribe`, `get`, `list`.
|
||||
- `interface SidechainPublisher` — `bus`, `write`, `close`.
|
||||
- `interface BusRegistry`, `interface ChainContext`,
|
||||
`interface ChainPlugin`, `interface AmbientState`,
|
||||
`interface AmbientStatePatch`, plus the `ChainEvent`/`ChainEventPayload`
|
||||
pair.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The chain re-uses the same `Float32Array[]` block as both `input` and
|
||||
`output` for each plugin's `BlockProcessor.process` call, so each plugin
|
||||
writes into the channel arrays the next plugin reads from. Latency
|
||||
recomputation runs after every mutation (`add` / `remove` /
|
||||
`setPluginBypass`) and only fires `latency-changed` when the effective sum
|
||||
actually changes — repeated no-op mutations stay quiet. The default bus
|
||||
registry stores per-bus payloads as a `Float32Array[]` slot mutated by
|
||||
`SidechainPublisher.write`; `close` drops the slot and notifies every
|
||||
subscribed listener with `undefined` so plugins reading the bus on the
|
||||
next block see silence rather than stale samples.
|
||||
62
docs/media/audio/audio-engine-server/src/internal/fs-like.md
Normal file
62
docs/media/audio/audio-engine-server/src/internal/fs-like.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# `FsLike` seam
|
||||
|
||||
## Purpose
|
||||
|
||||
`FsLike` is the dependency-inversion seam between the audio engine's source
|
||||
and sink adapters and the underlying filesystem. The default implementation
|
||||
delegates straight to `node:fs` and `node:fs/promises`; alternate
|
||||
implementations let consumers wrap the real filesystem with fault injection
|
||||
(forcing an `EBUSY`-style error on the Nth call, for example) or back the
|
||||
adapters with an in-memory or object-store backend without modifying adapter
|
||||
code. Per the "no mocks — real services" rule, every filesystem
|
||||
test runs against a real `node:fs.mkdtemp` tmpdir, so this seam exists to
|
||||
make production composition possible, not to replace the filesystem in tests.
|
||||
|
||||
## Features
|
||||
|
||||
- Six-method surface covering buffered read/write, directory creation,
|
||||
stat, unlink, and the streaming read/write factories.
|
||||
- `defaultFs` exposes the canonical `node:fs` adapter — frozen, side-effect
|
||||
free, and safe to reuse across adapters.
|
||||
- `createReadStream` / `createWriteStream` go through `Readable.toWeb` /
|
||||
`Writable.toWeb` so the WHATWG primitive surface matches the
|
||||
`@suaveplan/streams` contract.
|
||||
- `writeFile` creates parent directories implicitly via `mkdir({recursive:
|
||||
true})` so callers never have to bracket every write with a `mkdir`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { defaultFs } from "@suaveplan/audio-engine-server";
|
||||
|
||||
const bytes = await defaultFs.readFile("/tmp/sample.wav");
|
||||
await defaultFs.writeFile("/tmp/out/sample.flac", bytes);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type { FsLike } from "@suaveplan/audio-engine-server";
|
||||
|
||||
class FlakyFs implements FsLike {
|
||||
constructor(private inner: FsLike, private failures: number) {}
|
||||
async readFile(p: string) {
|
||||
if (this.failures-- > 0) throw new Error("EBUSY");
|
||||
return this.inner.readFile(p);
|
||||
}
|
||||
// ...delegate the rest to `inner`
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface FsLike` — six-method seam.
|
||||
- `defaultFs: FsLike` — canonical implementation over `node:fs`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`Readable.toWeb` and `Writable.toWeb` are Node 22+ APIs; the package's
|
||||
`engines.node >= 22` field gates this. The seam never holds open file
|
||||
handles outside a single call: every `createReadStream` /
|
||||
`createWriteStream` call constructs a fresh `node:fs` handle so cancellation
|
||||
semantics propagate through the WHATWG `cancel` / `abort` paths to the OS.
|
||||
@@ -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.
|
||||
76
docs/media/audio/audio-engine-server/src/sink/file-sink.md
Normal file
76
docs/media/audio/audio-engine-server/src/sink/file-sink.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# FileSinkAdapter
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`FileSinkAdapter` is the server-tier exit point for `AudioDocument`
|
||||
instances: it encodes a document through `@suaveplan/audio-codecs` for
|
||||
the requested format and writes the resulting bytes through the injected
|
||||
`FsLike` seam. Like `FileSourceAdapter`, the sink owns the
|
||||
path-sanitisation boundary and exposes both buffered (`save`,
|
||||
`saveMany`) and streaming (`createWriteStream`) modes.
|
||||
|
||||
## Features
|
||||
|
||||
- Buffered `save(doc, path, format, opts?)` runs the codec encoder via
|
||||
`encodeTo`, concatenates the resulting chunks, and writes the file —
|
||||
creating parent directories implicitly.
|
||||
- `saveMany(docs, pathFormat, format, opts?)` parallel-encodes a list of
|
||||
documents, expanding `%d` in the path template per index.
|
||||
- `createWriteStream(path)` returns a WHATWG `WritableStream<Uint8Array>`
|
||||
backed by `node:fs.createWriteStream` (or the injected `FsLike`).
|
||||
- Zod-validated options; failures throw `AUDIO_ENGINE_SERVER.INVALID_OPTIONS`.
|
||||
- `sanitizePath` runs before any filesystem call.
|
||||
- Unknown / unregistered format → `AUDIO_ENGINE_SERVER.ENCODE_FAILED`.
|
||||
- Underlying write errors → `AUDIO_ENGINE_SERVER.IO_FAILED`.
|
||||
- OpenTelemetry instrumentation — span `file.save`, counter
|
||||
`audio_engine_bytes_written_total`, histogram
|
||||
`audio_engine_save_duration_ms`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { FileSinkAdapter } from "@suaveplan/audio-engine-server";
|
||||
|
||||
const sink = new FileSinkAdapter();
|
||||
await sink.save(doc, "/tmp/out.flac", "flac");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
const sink = new FileSinkAdapter({ rootDir: "/var/exports" });
|
||||
const writer = sink.createWriteStream("session.wav");
|
||||
await pcmStream.pipeTo(writer);
|
||||
|
||||
await sink.saveMany(
|
||||
[trackA, trackB, trackC],
|
||||
"stem-%d.wav",
|
||||
"wav",
|
||||
{ bitDepth: 24 }
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new FileSinkAdapter(options?: FileSinkOptions)` — `fs`, `registry`,
|
||||
`rootDir` optional.
|
||||
- `save(doc, path, format, opts?): Promise<void>`
|
||||
- `saveMany(docs, pathFormat, format?, opts?): Promise<void>`
|
||||
- `createWriteStream(path): WritableStream<Uint8Array>`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The buffered `save` collects encoder chunks into an array, computes the
|
||||
total length, and writes a single concatenated buffer; the codec layer
|
||||
already streams internally so this is a small one-time copy. `saveMany`
|
||||
substitutes the literal `%d` token in `pathFormat` with the document's
|
||||
index. Unknown codec formats surface from the codec layer as
|
||||
`AUDIO_CODECS.NOT_SUPPORTED`; the sink re-throws them as
|
||||
`AUDIO_ENGINE_SERVER.ENCODE_FAILED` with the offending format in
|
||||
`context`. Underlying `node:fs` write failures (`EACCES`, `ENOSPC`, etc.)
|
||||
become `AUDIO_ENGINE_SERVER.IO_FAILED` with the resolved absolute path in
|
||||
`context`.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user