@aethervtt/shard-platform-web 0.0.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,44 @@
1
+ import type { AudioClipSource, AudioPreparation } from '@aethervtt/shard-platform';
2
+ export interface PreparedAudio {
3
+ buffer: AudioBuffer | null;
4
+ peak: number;
5
+ url: string | null;
6
+ }
7
+ /** Owned decode cache. Both preparation leases and physical voices pin entries. */
8
+ export declare class AudioCache {
9
+ private readonly entries;
10
+ private readonly byteKeys;
11
+ private readonly queue;
12
+ private running;
13
+ private stamp;
14
+ private closed;
15
+ readonly context: BaseAudioContext;
16
+ readonly budgetBytes: number;
17
+ readonly maxDecodes: number;
18
+ constructor(context: BaseAudioContext, budgetBytes?: number, maxDecodes?: number);
19
+ private slot;
20
+ private done;
21
+ private load;
22
+ private key;
23
+ /** Already prepared sources can be scheduled synchronously as one reserved group. */
24
+ acquireReady(clip: AudioClipSource): (AudioPreparation & {
25
+ value: PreparedAudio;
26
+ }) | null;
27
+ acquire(clip: AudioClipSource): Promise<AudioPreparation & {
28
+ value: PreparedAudio;
29
+ }>;
30
+ private lease;
31
+ private remove;
32
+ private evict;
33
+ collect(): void;
34
+ dispose(): void;
35
+ memory(): {
36
+ decodedBytes: number;
37
+ encodedBytes: number;
38
+ pinned: number;
39
+ evictable: number;
40
+ urls: number;
41
+ budgetBytes: number;
42
+ loading: number;
43
+ };
44
+ }
@@ -0,0 +1,215 @@
1
+ import { ShardError } from "@aethervtt/shard-core";
2
+ class AudioCache {
3
+ entries = /* @__PURE__ */ new Map();
4
+ byteKeys = /* @__PURE__ */ new WeakMap();
5
+ queue = [];
6
+ running = 0;
7
+ stamp = 0;
8
+ closed = false;
9
+ context;
10
+ budgetBytes;
11
+ maxDecodes;
12
+ constructor(context, budgetBytes = 64 * 1024 * 1024, maxDecodes = 4) {
13
+ if (!Number.isInteger(maxDecodes) || maxDecodes < 1 || !Number.isFinite(budgetBytes) || budgetBytes <= 0)
14
+ throw new ShardError(
15
+ "audio/invalid-range",
16
+ "Decode concurrency and memory budget must be positive"
17
+ );
18
+ this.context = context;
19
+ this.budgetBytes = budgetBytes;
20
+ this.maxDecodes = maxDecodes;
21
+ }
22
+ async slot() {
23
+ if (this.running < this.maxDecodes) {
24
+ this.running++;
25
+ return;
26
+ }
27
+ if (this.queue.length >= 2048)
28
+ throw new ShardError("audio/capacity", "Audio decode queue is full");
29
+ await new Promise((resolve) => this.queue.push(resolve));
30
+ }
31
+ done() {
32
+ const next = this.queue.shift();
33
+ if (next) next();
34
+ else this.running--;
35
+ }
36
+ async load(entry) {
37
+ await this.slot();
38
+ try {
39
+ if (this.closed) throw new ShardError("audio/prepare-failed", "The audio host was disposed");
40
+ const clip = entry.clip;
41
+ if (clip.stream) {
42
+ if (clip.url) return { buffer: null, peak: 1, url: clip.url };
43
+ entry.url = URL.createObjectURL(new Blob([clip.bytes.slice().buffer]));
44
+ return { buffer: null, peak: 1, url: entry.url };
45
+ }
46
+ const buffer = await this.context.decodeAudioData(clip.bytes.slice().buffer);
47
+ const bytes = buffer.length * buffer.numberOfChannels * 4;
48
+ if (this.memory().decodedBytes - entry.bytes + bytes > this.budgetBytes) {
49
+ throw new ShardError(
50
+ "audio/memory-budget",
51
+ "Decoded samples exceed the audio memory budget",
52
+ {
53
+ hint: "Release unused collections, shorten clips, or increase decodedBudgetMiB."
54
+ }
55
+ );
56
+ }
57
+ entry.bytes = bytes;
58
+ let peak = 0;
59
+ for (let c = 0; c < buffer.numberOfChannels; c++) {
60
+ const samples = buffer.getChannelData(c);
61
+ for (let i = 0; i < samples.length; i++) peak = Math.max(peak, Math.abs(samples[i]));
62
+ }
63
+ return { buffer, peak, url: null };
64
+ } catch (cause) {
65
+ if (cause instanceof ShardError) throw cause;
66
+ throw new ShardError("audio/decode-failed", `Cannot decode ${entry.clip.id}`, {
67
+ cause,
68
+ hint: "Check the source file and codec support on this host. Try PCM WAV."
69
+ });
70
+ } finally {
71
+ this.done();
72
+ }
73
+ }
74
+ key(clip) {
75
+ let key;
76
+ if (clip.url) key = `${clip.stream ? "media" : "decoded"}:${clip.url}`;
77
+ else {
78
+ let keys = this.byteKeys.get(clip.bytes);
79
+ if (!keys) {
80
+ keys = { decoded: {}, media: {} };
81
+ this.byteKeys.set(clip.bytes, keys);
82
+ }
83
+ key = clip.stream ? keys.media : keys.decoded;
84
+ }
85
+ return key;
86
+ }
87
+ /** Already prepared sources can be scheduled synchronously as one reserved group. */
88
+ acquireReady(clip) {
89
+ if (this.closed) return null;
90
+ const key = this.key(clip), entry = this.entries.get(key);
91
+ if (!entry?.value) return null;
92
+ entry.refs++;
93
+ entry.touched = this.stamp++;
94
+ return this.lease(clip, key, entry, entry.value);
95
+ }
96
+ async acquire(clip) {
97
+ if (this.closed) throw new ShardError("audio/prepare-failed", "The audio host was disposed");
98
+ const key = this.key(clip);
99
+ let entry = this.entries.get(key);
100
+ if (!entry) {
101
+ const bytes = clip.stream ? 0 : Math.ceil(clip.duration * this.context.sampleRate) * (clip.channels ?? 2) * 4;
102
+ this.evict(bytes);
103
+ if (this.memory().decodedBytes + bytes > this.budgetBytes) {
104
+ throw new ShardError(
105
+ "audio/memory-budget",
106
+ `Cannot prepare ${clip.id}: pinned audio fills the budget`,
107
+ {
108
+ hint: "Release preparation leases or increase the decoded audio budget."
109
+ }
110
+ );
111
+ }
112
+ entry = {
113
+ clip,
114
+ refs: 0,
115
+ bytes,
116
+ touched: this.stamp++,
117
+ url: null,
118
+ value: null,
119
+ promise: Promise.resolve({ buffer: null, peak: 1, url: null })
120
+ };
121
+ this.entries.set(key, entry);
122
+ const pending = entry;
123
+ entry.promise = this.load(entry).then((value2) => {
124
+ pending.value = value2;
125
+ return value2;
126
+ });
127
+ }
128
+ const owned = entry;
129
+ owned.refs++;
130
+ owned.touched = this.stamp++;
131
+ let value;
132
+ try {
133
+ value = await owned.promise;
134
+ } catch (error) {
135
+ owned.refs--;
136
+ if (owned.refs === 0) this.remove(key, owned);
137
+ throw error;
138
+ }
139
+ if (this.closed) {
140
+ owned.refs--;
141
+ this.remove(key, owned);
142
+ throw new ShardError("audio/prepare-failed", "Audio preparation was cancelled by disposal");
143
+ }
144
+ return this.lease(clip, key, owned, value);
145
+ }
146
+ lease(clip, key, owned, value) {
147
+ let released = false;
148
+ return {
149
+ clip,
150
+ value,
151
+ decodedBytes: owned.bytes,
152
+ encodedBytes: clip.bytes.byteLength,
153
+ sampleRate: value.buffer?.sampleRate ?? this.context.sampleRate,
154
+ samples: value.buffer?.length ?? 0,
155
+ channels: value.buffer?.numberOfChannels ?? clip.channels ?? 0,
156
+ get released() {
157
+ return released;
158
+ },
159
+ release: () => {
160
+ if (released) return;
161
+ released = true;
162
+ owned.refs--;
163
+ owned.touched = this.stamp++;
164
+ if (owned.refs === 0 && (clip.stream || this.closed)) this.remove(key, owned);
165
+ }
166
+ };
167
+ }
168
+ remove(key, entry) {
169
+ if (entry.url) {
170
+ URL.revokeObjectURL(entry.url);
171
+ entry.url = null;
172
+ }
173
+ if (this.entries.get(key) === entry) this.entries.delete(key);
174
+ }
175
+ evict(required) {
176
+ const unused = [...this.entries].filter(([, entry]) => entry.refs === 0).sort((a, b) => a[1].touched - b[1].touched);
177
+ for (const [key, entry] of unused) {
178
+ if (this.memory().decodedBytes + required <= this.budgetBytes) break;
179
+ this.remove(key, entry);
180
+ }
181
+ }
182
+ collect() {
183
+ for (const [key, entry] of this.entries) if (!entry.refs) this.remove(key, entry);
184
+ }
185
+ dispose() {
186
+ this.closed = true;
187
+ this.collect();
188
+ }
189
+ memory() {
190
+ let decodedBytes = 0;
191
+ let encodedBytes = 0;
192
+ let pinned = 0;
193
+ let evictable = 0;
194
+ let urls = 0;
195
+ for (const entry of this.entries.values()) {
196
+ decodedBytes += entry.bytes;
197
+ encodedBytes += entry.clip.bytes.byteLength;
198
+ if (entry.refs) pinned++;
199
+ else evictable++;
200
+ if (entry.url) urls++;
201
+ }
202
+ return {
203
+ decodedBytes,
204
+ encodedBytes,
205
+ pinned,
206
+ evictable,
207
+ urls,
208
+ budgetBytes: this.budgetBytes,
209
+ loading: this.running + this.queue.length
210
+ };
211
+ }
212
+ }
213
+ export {
214
+ AudioCache
215
+ };
@@ -0,0 +1,4 @@
1
+ /** Firefox exposes legacy listener methods instead of AudioParam listener coordinates. */
2
+ export declare function setListenerPose(listener: AudioListener, matrix: ArrayLike<number>): void;
3
+ /** At current time .value is the rendered value; future automation supplies its tracked value. */
4
+ export declare function holdAudioParam(param: AudioParam, at: number, value?: number): void;
@@ -0,0 +1,34 @@
1
+ function setListenerPose(listener, matrix) {
2
+ if (listener.positionX) {
3
+ listener.positionX.value = matrix[3];
4
+ listener.positionY.value = matrix[7];
5
+ listener.positionZ.value = matrix[11];
6
+ listener.forwardX.value = -matrix[2];
7
+ listener.forwardY.value = -matrix[6];
8
+ listener.forwardZ.value = -matrix[10];
9
+ listener.upX.value = matrix[1];
10
+ listener.upY.value = matrix[5];
11
+ listener.upZ.value = matrix[9];
12
+ } else {
13
+ listener.setPosition(matrix[3], matrix[7], matrix[11]);
14
+ listener.setOrientation(
15
+ -matrix[2],
16
+ -matrix[6],
17
+ -matrix[10],
18
+ matrix[1],
19
+ matrix[5],
20
+ matrix[9]
21
+ );
22
+ }
23
+ }
24
+ function holdAudioParam(param, at, value = param.value) {
25
+ if (typeof param.cancelAndHoldAtTime === "function") param.cancelAndHoldAtTime(at);
26
+ else {
27
+ param.cancelScheduledValues(at);
28
+ param.setValueAtTime(value, at);
29
+ }
30
+ }
31
+ export {
32
+ holdAudioParam,
33
+ setListenerPose
34
+ };
@@ -0,0 +1,35 @@
1
+ import type { AudioMixerGraph, AudioPreparation } from '@aethervtt/shard-platform';
2
+ import type { AudioCache } from './audio-cache.js';
3
+ /** A compiled Web Audio graph. No DOM or nodes escape the platform boundary. */
4
+ export declare class WebAudioGraph {
5
+ readonly inputs: Map<string, GainNode>;
6
+ readonly outputs: Map<string, GainNode>;
7
+ readonly sendInputs: Map<string, Map<string, GainNode>>;
8
+ readonly nodes: AudioNode[];
9
+ readonly leases: AudioPreparation[];
10
+ private readonly values;
11
+ private readonly gates;
12
+ private readonly userGains;
13
+ private readonly ancestry;
14
+ private readonly buses;
15
+ readonly context: BaseAudioContext;
16
+ readonly output: GainNode;
17
+ readonly maxNodes: number;
18
+ tailSeconds: number;
19
+ generatedBytes: number;
20
+ private readonly delays;
21
+ hasBus(id: string): boolean;
22
+ private buffer;
23
+ private writeDelay;
24
+ /** Coalesce rapid delay edits, then crossfade finite kernels at an audio-clock boundary. */
25
+ tick(): void;
26
+ private constructor();
27
+ private own;
28
+ private bind;
29
+ private effect;
30
+ private chain;
31
+ static build(context: BaseAudioContext, graph: AudioMixerGraph, output: GainNode, cache: Pick<AudioCache, 'acquire'>): Promise<WebAudioGraph>;
32
+ private refreshGates;
33
+ set(bus: string, field: string, value: number, seconds: number): void;
34
+ dispose(): void;
35
+ }
@@ -0,0 +1,365 @@
1
+ import { ShardError } from "@aethervtt/shard-core";
2
+ import { holdAudioParam } from "./audio-compat.js";
3
+ function ramp(param, value, at, seconds) {
4
+ holdAudioParam(param, at);
5
+ if (seconds > 0) param.linearRampToValueAtTime(value, at + seconds);
6
+ else param.setValueAtTime(value, at);
7
+ }
8
+ class WebAudioGraph {
9
+ inputs = /* @__PURE__ */ new Map();
10
+ outputs = /* @__PURE__ */ new Map();
11
+ sendInputs = /* @__PURE__ */ new Map();
12
+ nodes = [];
13
+ leases = [];
14
+ values = /* @__PURE__ */ new Map();
15
+ gates = /* @__PURE__ */ new Map();
16
+ userGains = /* @__PURE__ */ new Map();
17
+ ancestry = /* @__PURE__ */ new Map();
18
+ buses = /* @__PURE__ */ new Map();
19
+ context;
20
+ output;
21
+ maxNodes;
22
+ tailSeconds = 0;
23
+ generatedBytes = 0;
24
+ delays = [];
25
+ hasBus(id) {
26
+ return this.buses.has(id);
27
+ }
28
+ buffer(channels, frames) {
29
+ const bytes = channels * frames * 4;
30
+ if (this.generatedBytes + bytes > 64 * 1024 * 1024)
31
+ throw new ShardError("audio/memory-budget", "Finite effect kernels exceed 64 MiB");
32
+ this.generatedBytes += bytes;
33
+ return this.context.createBuffer(channels, frames, this.context.sampleRate);
34
+ }
35
+ writeDelay(delay, slot) {
36
+ const buffer = delay.buffers[slot], data = buffer.getChannelData(0);
37
+ data.fill(0);
38
+ const period = delay.seconds * buffer.sampleRate;
39
+ if (period < 1) data[0] = 1 / (1 - delay.feedback);
40
+ else {
41
+ let gain = 1;
42
+ for (let position = period; position < data.length; position += period) {
43
+ const frame = Math.floor(position), fraction = position - frame;
44
+ data[frame] = data[frame] + gain * (1 - fraction);
45
+ if (frame + 1 < data.length) data[frame + 1] = data[frame + 1] + gain * fraction;
46
+ gain *= delay.feedback;
47
+ }
48
+ }
49
+ delay.nodes[slot].buffer = buffer;
50
+ }
51
+ /** Coalesce rapid delay edits, then crossfade finite kernels at an audio-clock boundary. */
52
+ tick() {
53
+ const at = this.context.currentTime;
54
+ for (let i = 0; i < this.delays.length; i++) {
55
+ const delay = this.delays[i];
56
+ if (!delay.dirty || at < delay.next) continue;
57
+ const next = 1 - delay.active;
58
+ this.writeDelay(delay, next);
59
+ ramp(delay.gains[delay.active].gain, 0, at, 0.02);
60
+ ramp(delay.gains[next].gain, 1, at, 0.02);
61
+ delay.active = next;
62
+ delay.next = at + 0.02;
63
+ delay.dirty = false;
64
+ }
65
+ }
66
+ constructor(context, graph, output) {
67
+ this.context = context;
68
+ this.output = output;
69
+ this.maxNodes = graph.maxNodes;
70
+ for (const bus of graph.buses) {
71
+ this.buses.set(bus.id, bus);
72
+ this.userGains.set(bus.id, bus.userGain);
73
+ }
74
+ }
75
+ own(node) {
76
+ this.nodes.push(node);
77
+ if (this.nodes.length > this.maxNodes)
78
+ throw new ShardError("audio/mixer-capacity", "Partitioned mixer exceeds its node budget", {
79
+ hint: "Reduce sends/effects or increase maxNodes; independent user categories require independent effect state."
80
+ });
81
+ return node;
82
+ }
83
+ bind(key, param) {
84
+ const list = this.values.get(key);
85
+ if (list) list.push(param);
86
+ else this.values.set(key, [param]);
87
+ }
88
+ async effect(bus, effect, cache) {
89
+ const ctx = this.context;
90
+ const key = `${bus}/${effect.id}/`;
91
+ switch (effect.kind) {
92
+ case "lowpass":
93
+ case "highpass":
94
+ case "peaking": {
95
+ const node = this.own(ctx.createBiquadFilter());
96
+ node.type = effect.kind;
97
+ node.frequency.value = Math.min(ctx.sampleRate / 2, effect.frequency);
98
+ node.Q.value = effect.q;
99
+ node.gain.value = effect.gainDb;
100
+ this.bind(`${key}frequency`, node.frequency);
101
+ this.bind(`${key}q`, node.Q);
102
+ this.bind(`${key}gainDb`, node.gain);
103
+ return { input: node, output: node };
104
+ }
105
+ case "compressor": {
106
+ const node = this.own(ctx.createDynamicsCompressor());
107
+ node.threshold.value = effect.threshold;
108
+ node.ratio.value = effect.ratio;
109
+ node.attack.value = effect.attack;
110
+ node.release.value = effect.release;
111
+ this.bind(`${key}threshold`, node.threshold);
112
+ this.bind(`${key}ratio`, node.ratio);
113
+ this.bind(`${key}attack`, node.attack);
114
+ this.bind(`${key}release`, node.release);
115
+ return { input: node, output: node };
116
+ }
117
+ case "delay": {
118
+ const input = this.own(ctx.createGain()), output = this.own(ctx.createGain());
119
+ const delay = {
120
+ key,
121
+ seconds: effect.seconds,
122
+ feedback: effect.feedback,
123
+ tail: effect.tailSeconds,
124
+ nodes: [],
125
+ gains: [],
126
+ buffers: [],
127
+ active: 0,
128
+ next: 0,
129
+ dirty: false
130
+ };
131
+ for (let i = 0; i < 2; i++) {
132
+ const node = this.own(ctx.createConvolver()), gain = this.own(ctx.createGain());
133
+ node.normalize = false;
134
+ gain.gain.value = i === 0 ? 1 : 0;
135
+ input.connect(node).connect(gain).connect(output);
136
+ delay.nodes.push(node);
137
+ delay.gains.push(gain);
138
+ delay.buffers.push(
139
+ this.buffer(1, Math.max(1, Math.ceil(effect.tailSeconds * ctx.sampleRate)))
140
+ );
141
+ this.writeDelay(delay, i);
142
+ }
143
+ this.delays.push(delay);
144
+ return { input, output };
145
+ }
146
+ case "reverb": {
147
+ if (!effect.ir || effect.ir.stream)
148
+ throw new ShardError("audio/invalid-effect", "Reverb requires a decoded impulse response");
149
+ const lease = await cache.acquire(effect.ir);
150
+ this.leases.push(lease);
151
+ if (!lease.value.buffer || ![1, 2, 4].includes(lease.channels)) {
152
+ throw new ShardError("audio/invalid-effect", "Impulse responses need 1, 2 or 4 channels");
153
+ }
154
+ const node = this.own(ctx.createConvolver());
155
+ node.normalize = false;
156
+ const original = lease.value.buffer;
157
+ const frames = Math.min(original.length, Math.ceil(effect.tailSeconds * ctx.sampleRate));
158
+ if (frames === original.length) node.buffer = original;
159
+ else {
160
+ const trimmed = this.buffer(original.numberOfChannels, frames);
161
+ const fade = Math.min(frames, Math.ceil(ctx.sampleRate * 5e-3));
162
+ for (let channel = 0; channel < original.numberOfChannels; channel++) {
163
+ const data = trimmed.getChannelData(channel);
164
+ data.set(original.getChannelData(channel).subarray(0, frames));
165
+ for (let i = frames - fade; i < frames; i++)
166
+ data[i] = data[i] * (frames - 1 - i) / fade;
167
+ }
168
+ node.buffer = trimmed;
169
+ }
170
+ return { input: node, output: node };
171
+ }
172
+ }
173
+ }
174
+ async chain(bus, cache) {
175
+ const input = this.own(this.context.createGain());
176
+ let tail = input;
177
+ for (const effect of bus.effects) {
178
+ const insert = await this.effect(bus.id, effect, cache);
179
+ tail.connect(insert.input);
180
+ tail = insert.output;
181
+ }
182
+ const fader = this.own(this.context.createGain());
183
+ fader.gain.value = bus.gain;
184
+ tail.connect(fader);
185
+ this.bind(`${bus.id}/gain`, fader.gain);
186
+ return { input, pre: tail, post: fader };
187
+ }
188
+ static async build(context, graph, output, cache) {
189
+ const result = new WebAudioGraph(context, graph, output);
190
+ try {
191
+ const master = result.buses.get("master");
192
+ if (!master) throw new ShardError("audio/invalid-effect", "Mixer needs a master bus");
193
+ const root = await result.chain(master, cache);
194
+ const masterUser = result.own(context.createGain());
195
+ masterUser.gain.value = master.userGain;
196
+ root.post.connect(masterUser).connect(output);
197
+ result.bind("master/userGain", masterUser.gain);
198
+ const masterInput = result.own(context.createGain());
199
+ masterInput.connect(root.input);
200
+ result.inputs.set("master", masterInput);
201
+ result.outputs.set("master", masterUser);
202
+ if (master.sends.length || master.effects.some((e) => e.kind === "delay" || e.kind === "reverb")) {
203
+ throw new ShardError(
204
+ "audio/invalid-effect",
205
+ "Master cannot own sends or stateful wet effects; put these inside a user-control domain"
206
+ );
207
+ }
208
+ const ordinary = /* @__PURE__ */ new Map();
209
+ for (const bus of graph.buses) {
210
+ if (bus.return || bus.id === "master") continue;
211
+ const chain = await result.chain(bus, cache);
212
+ ordinary.set(bus.id, chain);
213
+ const input = result.own(context.createGain());
214
+ input.connect(chain.input);
215
+ result.inputs.set(bus.id, input);
216
+ result.outputs.set(bus.id, chain.post);
217
+ }
218
+ const returns = /* @__PURE__ */ new Map();
219
+ const visiting = /* @__PURE__ */ new Set();
220
+ const routeReturn = async (id, domain) => {
221
+ if (id === "master") return result.gates.get(domain);
222
+ const key = `${domain}/${id}`;
223
+ if (visiting.has(key)) throw new ShardError("audio/mixer-cycle", `Return cycle at ${id}`);
224
+ const existing = returns.get(key);
225
+ if (existing) return existing.input;
226
+ const bus = result.buses.get(id);
227
+ if (!bus?.return)
228
+ throw new ShardError("audio/unknown-bus", `Send destination ${id} must be a return`);
229
+ visiting.add(key);
230
+ const nodes = await result.chain(bus, cache);
231
+ returns.set(key, nodes);
232
+ let meter = result.outputs.get(id);
233
+ if (!meter) {
234
+ meter = result.own(context.createGain());
235
+ result.outputs.set(id, meter);
236
+ }
237
+ const returnUser = result.own(context.createGain());
238
+ returnUser.gain.value = bus.userGain;
239
+ result.bind(`${bus.id}/userGain`, returnUser.gain);
240
+ nodes.post.connect(returnUser).connect(meter);
241
+ returnUser.connect(await routeReturn(bus.parent, domain));
242
+ for (const send of bus.sends) {
243
+ const gain = result.own(context.createGain());
244
+ gain.gain.value = send.gain;
245
+ (send.pre ? nodes.pre : nodes.post).connect(gain).connect(await routeReturn(send.target, domain));
246
+ result.bind(`${id}/send/${send.target}`, gain.gain);
247
+ }
248
+ visiting.delete(key);
249
+ return nodes.input;
250
+ };
251
+ for (const bus of graph.buses) {
252
+ if (bus.return || bus.id === "master") continue;
253
+ const path = [];
254
+ let parent = bus.id;
255
+ while (parent !== "master") {
256
+ if (path.includes(parent))
257
+ throw new ShardError("audio/mixer-cycle", `Parent cycle at ${parent}`);
258
+ path.push(parent);
259
+ const ancestor = result.buses.get(parent);
260
+ if (!ancestor || ancestor.return || !ancestor.parent)
261
+ throw new ShardError("audio/unknown-bus", `Invalid parent ${parent}`);
262
+ parent = ancestor.parent;
263
+ }
264
+ const domain = path[path.length - 1];
265
+ if (!result.gates.has(domain)) {
266
+ const gate = result.own(context.createGain());
267
+ gate.connect(root.input);
268
+ result.gates.set(domain, gate);
269
+ result.ancestry.set(domain, [domain]);
270
+ }
271
+ const nodes = ordinary.get(bus.id);
272
+ if (bus.id === domain) nodes.post.connect(result.gates.get(domain));
273
+ else {
274
+ const user = result.own(context.createGain());
275
+ user.gain.value = bus.userGain;
276
+ result.bind(`${bus.id}/userGain`, user.gain);
277
+ nodes.post.connect(user).connect(ordinary.get(bus.parent).input);
278
+ }
279
+ const voiceRoutes = /* @__PURE__ */ new Map();
280
+ result.sendInputs.set(bus.id, voiceRoutes);
281
+ for (const send of bus.sends) {
282
+ const destination = await routeReturn(send.target, domain);
283
+ const input = result.own(context.createGain());
284
+ input.connect(destination);
285
+ voiceRoutes.set(send.target, input);
286
+ const gain = result.own(context.createGain());
287
+ gain.gain.value = send.gain;
288
+ (send.pre ? nodes.pre : nodes.post).connect(gain).connect(destination);
289
+ result.bind(`${bus.id}/send/${send.target}`, gain.gain);
290
+ }
291
+ }
292
+ const tails = /* @__PURE__ */ new Map(), tailVisiting = /* @__PURE__ */ new Set();
293
+ const pathTail = (id) => {
294
+ if (tails.has(id)) return tails.get(id);
295
+ if (tailVisiting.has(id))
296
+ throw new ShardError("audio/mixer-cycle", `Tail path cycle at ${id}`);
297
+ tailVisiting.add(id);
298
+ const bus = result.buses.get(id);
299
+ let own = 0, next = bus.parent ? pathTail(bus.parent) : 0;
300
+ for (const effect of bus.effects)
301
+ if (effect.kind === "delay" || effect.kind === "reverb") own += effect.tailSeconds;
302
+ for (const send of bus.sends) next = Math.max(next, pathTail(send.target));
303
+ tailVisiting.delete(id);
304
+ tails.set(id, own + next);
305
+ return own + next;
306
+ };
307
+ for (const bus of graph.buses)
308
+ result.tailSeconds = Math.max(result.tailSeconds, pathTail(bus.id));
309
+ result.refreshGates();
310
+ return result;
311
+ } catch (error) {
312
+ result.dispose();
313
+ throw error;
314
+ }
315
+ }
316
+ refreshGates(seconds = 0) {
317
+ for (const [origin, gate] of this.gates) {
318
+ let gain = 1;
319
+ for (const bus of this.ancestry.get(origin)) gain *= this.userGains.get(bus) ?? 1;
320
+ ramp(gate.gain, gain, this.context.currentTime, seconds);
321
+ }
322
+ }
323
+ set(bus, field, value, seconds) {
324
+ if (field === "userGain" && this.gates.has(bus)) {
325
+ this.userGains.set(bus, value);
326
+ this.refreshGates(seconds);
327
+ return;
328
+ }
329
+ const key = `${bus}/${field}`;
330
+ let matched = false;
331
+ for (const delay of this.delays) {
332
+ if (key === `${delay.key}seconds`) {
333
+ if (delay.seconds !== value) {
334
+ delay.seconds = value;
335
+ delay.dirty = true;
336
+ }
337
+ matched = true;
338
+ } else if (key === `${delay.key}feedback`) {
339
+ if (delay.feedback !== value) {
340
+ delay.feedback = value;
341
+ delay.dirty = true;
342
+ }
343
+ matched = true;
344
+ }
345
+ }
346
+ if (matched) {
347
+ this.tick();
348
+ return;
349
+ }
350
+ const params = this.values.get(key);
351
+ if (!params) throw new ShardError("audio/invalid-effect", `No mixer target ${bus}/${field}`);
352
+ for (const param of params) ramp(param, value, this.context.currentTime, seconds);
353
+ }
354
+ dispose() {
355
+ for (const node of this.nodes) node.disconnect();
356
+ for (const lease of this.leases) lease.release();
357
+ this.nodes.length = 0;
358
+ this.leases.length = 0;
359
+ this.delays.length = 0;
360
+ this.generatedBytes = 0;
361
+ }
362
+ }
363
+ export {
364
+ WebAudioGraph
365
+ };