Initial Commit

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

View File

@@ -0,0 +1,7 @@
export { classifyNode, WebAudioGraph } from "./web-audio-graph.js";
export { AudioGraphSnapshotSchema } from "./web-audio-graph.schemas.js";
export type {
AudioGraphConnectionSnapshot,
AudioGraphSnapshot,
AudioNodeType,
} from "./web-audio-graph.types.js";

View File

@@ -0,0 +1,131 @@
# `WebAudioGraph`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`WebAudioGraph` is the thin id-keyed registry that sits between
`BrowserTimelineAdapter` and a `BaseAudioContext`. It exists so the adapter
can address `AudioNode`s by stable string id (e.g. `track:42:gain`) rather
than juggling raw node references, and so a populated graph can be
snapshotted to a JSON-safe object and restored into a fresh context.
The registry is intentionally narrow: it owns the node table and the edge
list, validates ids on every operation, and produces a snapshot whose
shape (node-types + edges) is independent of any live `AudioNode`
instance. Snapshots round-trip through `JSON.stringify` and survive
context replacement — the same snapshot can be restored into a realtime
`AudioContext`, an `OfflineAudioContext`, or a deterministic test shim.
## Features
- `addNode` / `removeNode` keyed by string id.
- `connect` / `disconnect` wrapping `AudioNode.connect` / `disconnect`
with full optional `outputIndex`/`inputIndex` support.
- `getNode` and `listIds` for read-side inspection.
- `snapshot()` returning a `Readonly<AudioGraphSnapshot>` containing
node-type tags and the edge list.
- `restore(snapshot)` Zod-validates the snapshot and rebuilds the graph
against the bound context, raising
`AUDIO_ENGINE_BROWSER.NODE_NOT_FOUND` for orphan edges.
- `classifyNode(node)` exported helper that maps a live `AudioNode` to
its `AudioNodeType` tag using its `constructor.name`.
- Defensive: every id mismatch raises a typed
`BrowserAudioError(NODE_NOT_FOUND)` carrying the offending id.
## Usage: Basic Example
```ts
import { WebAudioGraph } from "@suaveplan/audio-engine-browser/graph/web-audio-graph";
const graph = new WebAudioGraph(audioContext);
graph.addNode("master", audioContext.createGain());
graph.addNode("dest", audioContext.destination);
graph.connect("master", "dest");
```
## Usage: Advanced Example
```ts
import {
WebAudioGraph,
} from "@suaveplan/audio-engine-browser/graph/web-audio-graph";
const graph = new WebAudioGraph(audioContext);
graph.addNode("src", audioContext.createBufferSource());
graph.addNode("gain", audioContext.createGain());
graph.addNode("dest", audioContext.destination);
graph.connect("src", "gain");
graph.connect("gain", "dest");
// Persist for an undo step
const snap = graph.snapshot();
localStorage.setItem("graph", JSON.stringify(snap));
// Later — possibly in a new context — restore
const restored = new WebAudioGraph(newContext);
restored.restore(JSON.parse(localStorage.getItem("graph")!));
```
## API Reference
### `new WebAudioGraph(context)`
Constructs a graph bound to a `BaseAudioContext`-shaped value. The
context is the factory used by `restore()`; calls to `addNode` accept
externally-constructed nodes.
### `addNode(id, node) → void`
Registers a node under `id`. Existing entries are overwritten so the
adapter can swap nodes during reconfiguration; the edge list is left
untouched.
### `removeNode(id) → void`
Disconnects the node, removes it, and prunes every edge that referenced
it. Throws `NODE_NOT_FOUND` for an unknown id.
### `connect(sourceId, destinationId, outputIndex?, inputIndex?) → void`
Wraps `AudioNode.connect`. Throws `NODE_NOT_FOUND` if either id is
absent. Records the edge so it appears in `snapshot()`.
### `disconnect(sourceId, destinationId?) → void`
When `destinationId` is omitted, disconnects every outgoing edge from
`source`. Otherwise disconnects just that edge. Throws `NODE_NOT_FOUND`
for missing source.
### `snapshot() → AudioGraphSnapshot`
Returns `{ nodes: Record<id, AudioNodeType>, connections: Edge[] }`.
### `restore(snapshot) → void`
Zod-validates the snapshot, clears the current registry, rebuilds nodes
via the bound context's factories, and replays every edge. Throws
`NODE_NOT_FOUND` for invalid or orphan-edge snapshots.
### `classifyNode(node) → AudioNodeType`
Static helper inferring the type tag from `constructor.name`.
## Implementation Notes
- `snapshot()` records **node types**, not node instances; this is what
makes it JSON-safe.
- `restore()` calls into the context's `create*` factories
(`createGain`, `createStereoPanner`, …) so the restored graph uses
nodes native to the new context. Worklet and `ScriptProcessor` nodes
cannot be reconstructed by tag alone and are rejected at restore.
- Per-disconnect graph mutations are best-effort: the underlying
`AudioNode.disconnect` may throw when the destination is not actually
connected, which is treated as a no-op so a careless caller cannot
desynchronise the edge list from the live graph.
- The Zod validator rejects unknown node types and any edge referencing
an absent node before any factory call is issued — the graph reaches
an inconsistent state only when a downstream context factory fails,
in which case the partial state is left for the caller to observe.

View File

@@ -0,0 +1,36 @@
import z from "zod";
const AudioNodeTypeSchema = z.union([
z.literal("gain"),
z.literal("panner"),
z.literal("stereo-panner"),
z.literal("biquad-filter"),
z.literal("delay"),
z.literal("dynamics-compressor"),
z.literal("convolver"),
z.literal("analyser"),
z.literal("oscillator"),
z.literal("audio-buffer-source"),
z.literal("script-processor"),
z.literal("worklet"),
z.literal("destination"),
z.literal("channel-splitter"),
z.literal("channel-merger"),
z.literal("unknown"),
]);
const ConnectionSchema = z
.object({
source: z.string().min(1),
destination: z.string().min(1),
outputIndex: z.number().int().nonnegative().optional(),
inputIndex: z.number().int().nonnegative().optional(),
})
.strict();
export const AudioGraphSnapshotSchema = z
.object({
nodes: z.record(z.string(), AudioNodeTypeSchema),
connections: z.array(ConnectionSchema),
})
.strict();

View File

@@ -0,0 +1,268 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { FakeAudioContext } from "../../tests/fake-context.js";
import {
AUDIO_ENGINE_BROWSER_CODES,
BrowserAudioError,
} from "../error-codes/error-codes.js";
import { classifyNode, WebAudioGraph } from "./web-audio-graph.js";
describe("WebAudioGraph", () => {
it("connect wires two registered nodes", { timeout: 5000 }, () => {
const ctx = new FakeAudioContext();
const graph = new WebAudioGraph(ctx as never);
const a = ctx.createGain();
const b = ctx.createGain();
graph.addNode("a", a as never);
graph.addNode("b", b as never);
graph.connect("a", "b");
expect(a.connections).toHaveLength(1);
expect(a.connections[0]?.target).toBe(b);
const snap = graph.snapshot();
expect(snap.connections).toEqual([{ source: "a", destination: "b" }]);
});
it("connect throws AUDIO_ENGINE_BROWSER.NODE_NOT_FOUND for an unknown id", {
timeout: 5000,
}, () => {
const ctx = new FakeAudioContext();
const graph = new WebAudioGraph(ctx as never);
graph.addNode("dst", ctx.createGain() as never);
try {
graph.connect("ghost", "dst");
throw new Error("expected throw");
} catch (e) {
expect(e).toBeInstanceOf(BrowserAudioError);
expect((e as BrowserAudioError).code).toBe(
AUDIO_ENGINE_BROWSER_CODES.NODE_NOT_FOUND
);
}
});
it("snapshot and restore round-trip the graph shape", {
timeout: 5000,
}, () => {
const ctx = new FakeAudioContext();
const graph = new WebAudioGraph(ctx as never);
graph.addNode("g1", ctx.createGain() as never);
graph.addNode("g2", ctx.createGain() as never);
graph.addNode("p", ctx.createStereoPanner() as never);
graph.connect("g1", "p");
graph.connect("p", "g2", 0, 0);
const snap = graph.snapshot();
const ctx2 = new FakeAudioContext();
const restored = new WebAudioGraph(ctx2 as never);
restored.restore(snap);
const out = restored.snapshot();
expect(out).toEqual(snap);
});
it("restore rejects an invalid snapshot", { timeout: 5000 }, () => {
const ctx = new FakeAudioContext();
const graph = new WebAudioGraph(ctx as never);
const bad = {
nodes: { a: "gain" },
connections: [{ source: "a", destination: "missing" }],
};
try {
graph.restore(bad as never);
throw new Error("expected throw");
} catch (e) {
expect(e).toBeInstanceOf(BrowserAudioError);
expect((e as BrowserAudioError).code).toBe(
AUDIO_ENGINE_BROWSER_CODES.NODE_NOT_FOUND
);
}
});
it("removeNode prunes edges and unknown id throws", {
timeout: 5000,
}, () => {
const ctx = new FakeAudioContext();
const graph = new WebAudioGraph(ctx as never);
graph.addNode("a", ctx.createGain() as never);
graph.addNode("b", ctx.createGain() as never);
graph.connect("a", "b");
graph.removeNode("a");
expect(graph.getNode("a")).toBeUndefined();
expect(graph.snapshot().connections).toEqual([]);
expect(() => graph.removeNode("nope")).toThrow(BrowserAudioError);
});
it("disconnect preserves unrelated edges", { timeout: 5000 }, () => {
const ctx = new FakeAudioContext();
const graph = new WebAudioGraph(ctx as never);
graph.addNode("a", ctx.createGain() as never);
graph.addNode("b", ctx.createGain() as never);
graph.addNode("c", ctx.createGain() as never);
graph.addNode("d", ctx.createGain() as never);
graph.connect("a", "b");
graph.connect("c", "d");
graph.disconnect("a");
expect(
graph
.snapshot()
.connections.map((e) => `${e.source}=>${e.destination}`)
).toEqual(["c=>d"]);
});
it("disconnect with and without destination", { timeout: 5000 }, () => {
const ctx = new FakeAudioContext();
const graph = new WebAudioGraph(ctx as never);
graph.addNode("a", ctx.createGain() as never);
graph.addNode("b", ctx.createGain() as never);
graph.addNode("c", ctx.createGain() as never);
graph.connect("a", "b");
graph.connect("a", "c");
graph.disconnect("a", "b");
expect(graph.snapshot().connections.map((e) => e.destination)).toEqual([
"c",
]);
graph.disconnect("a");
expect(graph.snapshot().connections).toEqual([]);
});
it("classifyNode tags every documented node", { timeout: 5000 }, () => {
const ctx = new FakeAudioContext();
expect(classifyNode(ctx.createGain())).toBe("gain");
expect(classifyNode(ctx.createStereoPanner())).toBe("stereo-panner");
expect(classifyNode(ctx.createBiquadFilter())).toBe("biquad-filter");
expect(classifyNode(ctx.createDelay())).toBe("delay");
expect(classifyNode(ctx.createDynamicsCompressor())).toBe(
"dynamics-compressor"
);
expect(classifyNode(ctx.createConvolver())).toBe("convolver");
expect(classifyNode(ctx.createAnalyser())).toBe("analyser");
expect(classifyNode(ctx.createOscillator())).toBe("oscillator");
expect(classifyNode(ctx.createBufferSource())).toBe(
"audio-buffer-source"
);
expect(classifyNode(ctx.createScriptProcessor())).toBe(
"script-processor"
);
expect(classifyNode(ctx.createChannelSplitter())).toBe(
"channel-splitter"
);
expect(classifyNode(ctx.createChannelMerger())).toBe("channel-merger");
expect(classifyNode(ctx.destination)).toBe("destination");
expect(classifyNode(null)).toBe("unknown");
expect(classifyNode({})).toBe("unknown");
expect(classifyNode({ constructor: { name: "FooBarNode" } })).toBe(
"unknown"
);
expect(classifyNode({ constructor: {} })).toBe("unknown");
expect(classifyNode(Object.create(null))).toBe("unknown");
});
it("restore rebuilds node types via context factories", {
timeout: 5000,
}, () => {
const ctx = new FakeAudioContext();
const graph = new WebAudioGraph(ctx as never);
graph.addNode("a", ctx.createGain() as never);
graph.addNode("b", ctx.createBiquadFilter() as never);
graph.connect("a", "b");
const snap = graph.snapshot();
const ctx2 = new FakeAudioContext();
const g2 = new WebAudioGraph(ctx2 as never);
g2.restore(snap);
expect(g2.snapshot()).toEqual(snap);
expect(g2.listIds().sort()).toEqual(["a", "b"]);
});
it("restore rejects an unsupported node type", { timeout: 5000 }, () => {
const ctx = new FakeAudioContext();
const graph = new WebAudioGraph(ctx as never);
try {
graph.restore({
nodes: { p: "panner" },
connections: [],
} as never);
throw new Error("expected");
} catch (e) {
expect(e).toBeInstanceOf(BrowserAudioError);
expect((e as BrowserAudioError).code).toBe(
AUDIO_ENGINE_BROWSER_CODES.NODE_NOT_FOUND
);
}
});
it("connect with explicit io indices records them in snapshot", {
timeout: 5000,
}, () => {
const ctx = new FakeAudioContext();
const graph = new WebAudioGraph(ctx as never);
graph.addNode("a", ctx.createGain() as never);
graph.addNode("b", ctx.createGain() as never);
graph.connect("a", "b", 1, 0);
expect(graph.snapshot().connections[0]).toMatchObject({
outputIndex: 1,
inputIndex: 0,
});
});
it("removeNode tolerates disconnect throws", { timeout: 5000 }, () => {
const ctx = new FakeAudioContext();
const graph = new WebAudioGraph(ctx as never);
const node = ctx.createGain();
node.disconnect = () => {
throw new Error("can't disconnect");
};
graph.addNode("a", node as never);
expect(() => graph.removeNode("a")).not.toThrow();
});
it("disconnect tolerates underlying throws", { timeout: 5000 }, () => {
const ctx = new FakeAudioContext();
const graph = new WebAudioGraph(ctx as never);
const a = ctx.createGain();
const b = ctx.createGain();
a.disconnect = () => {
throw new Error("nope");
};
graph.addNode("a", a as never);
graph.addNode("b", b as never);
graph.connect("a", "b");
expect(() => graph.disconnect("a", "b")).not.toThrow();
});
it("restore with destination node only is permitted", {
timeout: 5000,
}, () => {
const ctx = new FakeAudioContext();
const graph = new WebAudioGraph(ctx as never);
graph.restore({ nodes: { d: "destination" }, connections: [] });
expect(graph.getNode("d")).toBe(ctx.destination);
});
it("restore rejects a Zod-invalid snapshot shape", {
timeout: 5000,
}, () => {
const ctx = new FakeAudioContext();
const graph = new WebAudioGraph(ctx as never);
try {
graph.restore({ nodes: "not-an-object" } as never);
throw new Error("expected");
} catch (e) {
expect((e as BrowserAudioError).code).toBe(
AUDIO_ENGINE_BROWSER_CODES.NODE_NOT_FOUND
);
}
});
it("restore throws when destination factory cannot find a destination", {
timeout: 5000,
}, () => {
const ctx = {} as never;
const graph = new WebAudioGraph(ctx);
try {
graph.restore({ nodes: { d: "destination" }, connections: [] });
throw new Error("expected");
} catch (e) {
expect((e as BrowserAudioError).code).toBe(
AUDIO_ENGINE_BROWSER_CODES.NODE_NOT_FOUND
);
}
});
});

View File

@@ -0,0 +1,248 @@
import {
AUDIO_ENGINE_BROWSER_CODES,
BrowserAudioError,
} from "../error-codes/error-codes.js";
import { AudioGraphSnapshotSchema } from "./web-audio-graph.schemas.js";
import type {
AudioGraphConnectionSnapshot,
AudioGraphSnapshot,
AudioNodeType,
} from "./web-audio-graph.types.js";
interface AudioNodeLike {
connect(
destination: AudioNodeLike,
outputIndex?: number,
inputIndex?: number
): unknown;
disconnect(...args: unknown[]): void;
}
interface AudioContextLike {
readonly destination?: AudioNodeLike;
createGain?(): AudioNodeLike;
createStereoPanner?(): AudioNodeLike;
createBiquadFilter?(): AudioNodeLike;
createDelay?(): AudioNodeLike;
createDynamicsCompressor?(): AudioNodeLike;
createConvolver?(): AudioNodeLike;
createAnalyser?(): AudioNodeLike;
createOscillator?(): AudioNodeLike;
createBufferSource?(): AudioNodeLike;
createChannelSplitter?(): AudioNodeLike;
createChannelMerger?(): AudioNodeLike;
}
/** Identify the class of a live `AudioNode`. */
export function classifyNode(node: unknown): AudioNodeType {
if (node === null || typeof node !== "object") return "unknown";
const ctor = (node as { constructor?: { name?: string } }).constructor
?.name;
if (!ctor) return "unknown";
const mapping: Record<string, AudioNodeType> = {
GainNode: "gain",
PannerNode: "panner",
StereoPannerNode: "stereo-panner",
BiquadFilterNode: "biquad-filter",
DelayNode: "delay",
DynamicsCompressorNode: "dynamics-compressor",
ConvolverNode: "convolver",
AnalyserNode: "analyser",
OscillatorNode: "oscillator",
AudioBufferSourceNode: "audio-buffer-source",
ScriptProcessorNode: "script-processor",
AudioWorkletNode: "worklet",
AudioDestinationNode: "destination",
ChannelSplitterNode: "channel-splitter",
ChannelMergerNode: "channel-merger",
};
return mapping[ctor] ?? "unknown";
}
function createNodeOfType(
ctx: AudioContextLike,
type: AudioNodeType
): AudioNodeLike {
const factories: Record<AudioNodeType, (() => AudioNodeLike) | undefined> =
{
gain: ctx.createGain?.bind(ctx),
panner: undefined,
"stereo-panner": ctx.createStereoPanner?.bind(ctx),
"biquad-filter": ctx.createBiquadFilter?.bind(ctx),
delay: ctx.createDelay?.bind(ctx),
"dynamics-compressor": ctx.createDynamicsCompressor?.bind(ctx),
convolver: ctx.createConvolver?.bind(ctx),
analyser: ctx.createAnalyser?.bind(ctx),
oscillator: ctx.createOscillator?.bind(ctx),
"audio-buffer-source": ctx.createBufferSource?.bind(ctx),
"script-processor": undefined,
worklet: undefined,
destination: () => {
const dest = ctx.destination;
if (!dest)
throw new BrowserAudioError(
AUDIO_ENGINE_BROWSER_CODES.NODE_NOT_FOUND,
"audio-engine-browser.destination-missing"
);
return dest;
},
"channel-splitter": ctx.createChannelSplitter?.bind(ctx),
"channel-merger": ctx.createChannelMerger?.bind(ctx),
unknown: undefined,
};
const factory = factories[type];
if (!factory) {
throw new BrowserAudioError(
AUDIO_ENGINE_BROWSER_CODES.NODE_NOT_FOUND,
"audio-engine-browser.node-type-unsupported",
{ context: { type } }
);
}
return factory();
}
/**
* Id-keyed registry over a `BaseAudioContext`. Nodes are addressed by
* stable string id (e.g. `track:42:gain`); the registry records every
* edge so the graph can be snapshotted to a JSON-safe shape and restored
* into a fresh context.
*/
export class WebAudioGraph {
private readonly nodes = new Map<string, AudioNodeLike>();
private readonly edges: AudioGraphConnectionSnapshot[] = [];
public constructor(private readonly context: AudioContextLike) {}
public addNode(id: string, node: AudioNodeLike): void {
this.nodes.set(id, node);
}
public removeNode(id: string): void {
const node = this.nodes.get(id);
if (!node) {
throw new BrowserAudioError(
AUDIO_ENGINE_BROWSER_CODES.NODE_NOT_FOUND,
"audio-engine-browser.node-not-found",
{ context: { id } }
);
}
try {
node.disconnect();
} catch {
/* live disconnect is best-effort */
}
this.nodes.delete(id);
for (let i = this.edges.length - 1; i >= 0; i -= 1) {
const edge = this.edges[i]!;
if (edge.source === id || edge.destination === id) {
this.edges.splice(i, 1);
}
}
}
public getNode(id: string): AudioNodeLike | undefined {
return this.nodes.get(id);
}
public connect(
sourceId: string,
destinationId: string,
outputIndex?: number,
inputIndex?: number
): void {
const source = this.requireNode(sourceId);
const destination = this.requireNode(destinationId);
source.connect(destination, outputIndex, inputIndex);
this.edges.push({
source: sourceId,
destination: destinationId,
...(outputIndex !== undefined ? { outputIndex } : {}),
...(inputIndex !== undefined ? { inputIndex } : {}),
});
}
public disconnect(sourceId: string, destinationId?: string): void {
const source = this.requireNode(sourceId);
if (destinationId === undefined) {
source.disconnect();
for (let i = this.edges.length - 1; i >= 0; i -= 1) {
if (this.edges[i]!.source === sourceId) this.edges.splice(i, 1);
}
return;
}
const destination = this.requireNode(destinationId);
try {
source.disconnect(destination);
} catch {
/* downstream may not be connected */
}
for (let i = this.edges.length - 1; i >= 0; i -= 1) {
const e = this.edges[i]!;
if (e.source === sourceId && e.destination === destinationId) {
this.edges.splice(i, 1);
}
}
}
public snapshot(): AudioGraphSnapshot {
const nodes: Record<string, AudioNodeType> = {};
for (const [id, node] of this.nodes) nodes[id] = classifyNode(node);
return {
nodes,
connections: this.edges.map((e) => ({ ...e })),
};
}
public restore(snapshot: AudioGraphSnapshot): void {
const parsed = AudioGraphSnapshotSchema.safeParse(snapshot);
if (!parsed.success) {
throw new BrowserAudioError(
AUDIO_ENGINE_BROWSER_CODES.NODE_NOT_FOUND,
"audio-engine-browser.invalid-snapshot",
{ context: { issues: parsed.error.issues } }
);
}
const data = parsed.data;
for (const conn of data.connections) {
if (
!Object.hasOwn(data.nodes, conn.source) ||
!Object.hasOwn(data.nodes, conn.destination)
) {
throw new BrowserAudioError(
AUDIO_ENGINE_BROWSER_CODES.NODE_NOT_FOUND,
"audio-engine-browser.snapshot-edge-orphan",
{ context: { connection: conn } }
);
}
}
this.nodes.clear();
this.edges.length = 0;
for (const [id, type] of Object.entries(data.nodes)) {
this.nodes.set(id, createNodeOfType(this.context, type));
}
for (const conn of data.connections) {
this.connect(
conn.source,
conn.destination,
conn.outputIndex,
conn.inputIndex
);
}
}
public listIds(): ReadonlyArray<string> {
return Array.from(this.nodes.keys());
}
private requireNode(id: string): AudioNodeLike {
const node = this.nodes.get(id);
if (!node) {
throw new BrowserAudioError(
AUDIO_ENGINE_BROWSER_CODES.NODE_NOT_FOUND,
"audio-engine-browser.node-not-found",
{ context: { id } }
);
}
return node;
}
}

View File

@@ -0,0 +1,34 @@
/**
* Type tag describing the class of `AudioNode` represented by a snapshot
* entry. `WebAudioGraph.snapshot()` records these tags rather than live
* `AudioNode` references so a snapshot is JSON-safe.
*/
export type AudioNodeType =
| "gain"
| "panner"
| "stereo-panner"
| "biquad-filter"
| "delay"
| "dynamics-compressor"
| "convolver"
| "analyser"
| "oscillator"
| "audio-buffer-source"
| "script-processor"
| "worklet"
| "destination"
| "channel-splitter"
| "channel-merger"
| "unknown";
export interface AudioGraphConnectionSnapshot {
readonly source: string;
readonly destination: string;
readonly outputIndex?: number;
readonly inputIndex?: number;
}
export interface AudioGraphSnapshot {
readonly nodes: Readonly<Record<string, AudioNodeType>>;
readonly connections: ReadonlyArray<AudioGraphConnectionSnapshot>;
}