Initial Commit
This commit is contained in:
@@ -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";
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
Reference in New Issue
Block a user