@aethervtt/shard-platform-web 0.1.0 → 0.2.1
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.
- package/dist/audio-cache.d.ts +44 -0
- package/dist/audio-cache.js +215 -0
- package/dist/audio-compat.d.ts +4 -0
- package/dist/audio-compat.js +34 -0
- package/dist/audio-graph.d.ts +35 -0
- package/dist/audio-graph.js +365 -0
- package/dist/audio-preview.d.ts +59 -0
- package/dist/audio-preview.js +142 -0
- package/dist/audio.d.ts +8 -12
- package/dist/audio.js +835 -248
- package/dist/index.d.ts +1 -0
- package/dist/index.js +22 -1
- package/dist/performance.js +66 -1
- package/package.json +4 -4
package/dist/audio.js
CHANGED
|
@@ -1,275 +1,862 @@
|
|
|
1
1
|
import { ShardError } from "@aethervtt/shard-core";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
2
|
+
import { validateAudioAutomation } from "@aethervtt/shard-platform";
|
|
3
|
+
import { AudioCache } from "./audio-cache.js";
|
|
4
|
+
import { holdAudioParam, setListenerPose } from "./audio-compat.js";
|
|
5
|
+
import { WebAudioGraph } from "./audio-graph.js";
|
|
6
|
+
function automatedValue(track, at) {
|
|
7
|
+
if (at < track.command.at)
|
|
8
|
+
return track.previous ? automatedValue(track.previous, at) : track.start;
|
|
9
|
+
const command = track.command, start = track.start;
|
|
10
|
+
const t = command.duration > 0 ? Math.max(0, Math.min(1, (at - command.at) / command.duration)) : 1;
|
|
11
|
+
if (command.curve === "equal-power-in")
|
|
12
|
+
return start + (command.value - start) * Math.sin(t * Math.PI / 2);
|
|
13
|
+
if (command.curve === "equal-power-out")
|
|
14
|
+
return command.value + (start - command.value) * Math.cos(t * Math.PI / 2);
|
|
15
|
+
if (command.curve === "exponential" && start > 0 && command.value > 0)
|
|
16
|
+
return start * (command.value / start) ** t;
|
|
17
|
+
return start + (command.value - start) * t;
|
|
18
|
+
}
|
|
19
|
+
const gestures = ["pointerdown", "keydown", "touchend"];
|
|
20
|
+
const identity = new Float64Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0]);
|
|
21
|
+
class WebSession {
|
|
22
|
+
kind = "web";
|
|
23
|
+
runtime = this;
|
|
24
|
+
context;
|
|
25
|
+
output;
|
|
26
|
+
clock;
|
|
27
|
+
capabilities;
|
|
28
|
+
host;
|
|
29
|
+
root;
|
|
30
|
+
onError;
|
|
31
|
+
disposed = false;
|
|
32
|
+
nextId = 1;
|
|
33
|
+
voices = /* @__PURE__ */ new Map();
|
|
34
|
+
statuses = /* @__PURE__ */ new Map();
|
|
35
|
+
buses = /* @__PURE__ */ new Map();
|
|
36
|
+
busGains = /* @__PURE__ */ new Map();
|
|
37
|
+
leases = /* @__PURE__ */ new Set();
|
|
38
|
+
wakes = /* @__PURE__ */ new Set();
|
|
39
|
+
retired = /* @__PURE__ */ new Map();
|
|
40
|
+
timers = /* @__PURE__ */ new Set();
|
|
41
|
+
listener = identity.slice();
|
|
42
|
+
meters = /* @__PURE__ */ new Map();
|
|
43
|
+
graph = null;
|
|
44
|
+
graphRevision = 0;
|
|
45
|
+
reserved = 0;
|
|
46
|
+
group = 0;
|
|
47
|
+
ordinal;
|
|
48
|
+
priority;
|
|
49
|
+
maxVoices;
|
|
50
|
+
budgetBytes;
|
|
51
|
+
pendingStarts = /* @__PURE__ */ new Set();
|
|
52
|
+
ownPreparedBytes = 0;
|
|
53
|
+
preparationPins = /* @__PURE__ */ new Map();
|
|
54
|
+
constructor(host, root, options = {}) {
|
|
55
|
+
this.host = host;
|
|
56
|
+
this.ordinal = host.nextSession++;
|
|
57
|
+
this.priority = options.priority ?? 0;
|
|
58
|
+
if (!Number.isFinite(this.priority))
|
|
59
|
+
throw new ShardError("audio/invalid-range", "Session priority must be finite");
|
|
60
|
+
this.root = root;
|
|
61
|
+
this.context = host.context;
|
|
62
|
+
this.output = host.context.createGain();
|
|
63
|
+
this.output.connect(host.output);
|
|
64
|
+
this.maxVoices = options.maxVoices ?? 64;
|
|
65
|
+
this.budgetBytes = options.decodedBudgetBytes ?? host.cache.budgetBytes;
|
|
66
|
+
this.capabilities = {
|
|
67
|
+
scheduled: true,
|
|
68
|
+
automation: true,
|
|
69
|
+
mixer: true,
|
|
70
|
+
media: !host.offline,
|
|
71
|
+
meters: !host.offline,
|
|
72
|
+
offline: host.offline,
|
|
73
|
+
sessions: root && host.fixedListener
|
|
74
|
+
};
|
|
75
|
+
this.clock = {
|
|
76
|
+
now: () => this.context.currentTime,
|
|
77
|
+
wallNow: () => performance.now() / 1e3,
|
|
78
|
+
wake: (callback, seconds) => {
|
|
79
|
+
if (host.offline || this.disposed) return () => {
|
|
80
|
+
};
|
|
81
|
+
const handle = setInterval(callback, Math.max(1, seconds * 1e3));
|
|
82
|
+
this.wakes.add(handle);
|
|
83
|
+
return () => {
|
|
84
|
+
clearInterval(handle);
|
|
85
|
+
this.wakes.delete(handle);
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
if (!host.offline) this.wakes.add(setInterval(this.maintainGraphs, 20));
|
|
90
|
+
host.sessions.add(this);
|
|
91
|
+
this.context.addEventListener("statechange", this.stateChanged);
|
|
92
|
+
if (!host.offline)
|
|
93
|
+
for (const gesture of gestures)
|
|
94
|
+
window.addEventListener(gesture, this.unlock, { capture: true });
|
|
95
|
+
}
|
|
96
|
+
get activeVoices() {
|
|
97
|
+
return this.voices.size;
|
|
98
|
+
}
|
|
99
|
+
get state() {
|
|
100
|
+
if (this.disposed || this.context.state === "closed") return "closed";
|
|
101
|
+
return this.host.offline || this.context.state === "running" ? "running" : "suspended";
|
|
102
|
+
}
|
|
103
|
+
createSession(options) {
|
|
104
|
+
if (!this.root || !this.host.fixedListener || this.disposed)
|
|
105
|
+
throw new ShardError(
|
|
106
|
+
"audio/unsupported-capability",
|
|
107
|
+
"Independent sessions require a managed fixed listener",
|
|
108
|
+
{ hint: "Use a Shard-owned context or grant fixedListener ownership." }
|
|
109
|
+
);
|
|
110
|
+
return new WebSession(this.host, false, options);
|
|
111
|
+
}
|
|
112
|
+
unlock = () => {
|
|
113
|
+
void this.resume().catch(this.report);
|
|
25
114
|
};
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
115
|
+
report = (error) => {
|
|
116
|
+
this.onError?.(
|
|
117
|
+
error instanceof ShardError ? error : new ShardError("audio/prepare-failed", "Audio operation failed", { cause: error })
|
|
118
|
+
);
|
|
29
119
|
};
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
120
|
+
stateChanged = () => {
|
|
121
|
+
this.maintainGraphs();
|
|
122
|
+
if (this.state === "running")
|
|
123
|
+
for (const voice of this.voices.values()) {
|
|
124
|
+
if (!voice.started && !voice.loading) this.start(voice);
|
|
125
|
+
else if (voice.started && voice.presentation.state === "blocked")
|
|
126
|
+
voice.presentation.state = voice.presentation.scheduledAt > this.context.currentTime ? "scheduled" : "rendering";
|
|
127
|
+
}
|
|
128
|
+
else
|
|
129
|
+
for (const voice of this.voices.values())
|
|
130
|
+
if (voice.presentation.state === "rendering" || voice.presentation.state === "scheduled")
|
|
131
|
+
voice.presentation.state = "blocked";
|
|
132
|
+
};
|
|
133
|
+
async resume() {
|
|
134
|
+
if (this.host.offline || this.state === "running") return;
|
|
135
|
+
if (this.state === "closed") throw new ShardError("audio/prepare-failed", "Audio is closed");
|
|
136
|
+
await this.context.resume();
|
|
137
|
+
}
|
|
138
|
+
busNode(name) {
|
|
139
|
+
const output = this.graph?.outputs.get(name);
|
|
140
|
+
if (output) return output;
|
|
141
|
+
let node = this.buses.get(name);
|
|
40
142
|
if (!node) {
|
|
41
|
-
node =
|
|
42
|
-
node.gain.value = busGains.get(name) ?? 1;
|
|
43
|
-
node.connect(output);
|
|
44
|
-
buses.set(name, node);
|
|
143
|
+
node = this.context.createGain();
|
|
144
|
+
node.gain.value = this.busGains.get(name) ?? 1;
|
|
145
|
+
node.connect(this.output);
|
|
146
|
+
this.buses.set(name, node);
|
|
45
147
|
}
|
|
46
148
|
return node;
|
|
47
149
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
150
|
+
input(name) {
|
|
151
|
+
if (!this.graph) return this.busNode(name);
|
|
152
|
+
const input = this.graph.inputs.get(name);
|
|
153
|
+
if (!input) throw new ShardError("audio/unknown-bus", `No ordinary mixer input ${name}`);
|
|
154
|
+
return input;
|
|
155
|
+
}
|
|
156
|
+
place(voice) {
|
|
157
|
+
const panner = voice.panner;
|
|
158
|
+
if (!panner) return;
|
|
159
|
+
let x = voice.params.x, y = voice.params.y, z = voice.params.z;
|
|
160
|
+
if (this.host.fixedListener) {
|
|
161
|
+
const m = this.listener;
|
|
162
|
+
const dx = x - m[3], dy = y - m[7], dz = z - m[11];
|
|
163
|
+
const sx = Math.sqrt(m[0] ** 2 + m[4] ** 2 + m[8] ** 2) || 1;
|
|
164
|
+
const sy = Math.sqrt(m[1] ** 2 + m[5] ** 2 + m[9] ** 2) || 1;
|
|
165
|
+
const sz = Math.sqrt(m[2] ** 2 + m[6] ** 2 + m[10] ** 2) || 1;
|
|
166
|
+
x = (dx * m[0] + dy * m[4] + dz * m[8]) / sx;
|
|
167
|
+
y = (dx * m[1] + dy * m[5] + dz * m[9]) / sy;
|
|
168
|
+
z = (dx * m[2] + dy * m[6] + dz * m[10]) / sz;
|
|
169
|
+
}
|
|
170
|
+
const at = this.context.currentTime;
|
|
171
|
+
if (panner.positionX) {
|
|
172
|
+
panner.positionX.setValueAtTime(x, at);
|
|
173
|
+
panner.positionY.setValueAtTime(y, at);
|
|
174
|
+
panner.positionZ.setValueAtTime(z, at);
|
|
175
|
+
} else panner.setPosition(x, y, z);
|
|
176
|
+
}
|
|
177
|
+
validateRoutes(desc) {
|
|
178
|
+
if ((desc.sends?.length ?? 0) > 8 || new Set(desc.sends?.map((send) => send.target)).size !== (desc.sends?.length ?? 0))
|
|
179
|
+
throw new ShardError("audio/capacity", "At most eight voice sends");
|
|
180
|
+
for (const send of desc.sends ?? []) {
|
|
181
|
+
if (!Number.isFinite(send.gain) || send.gain < 0 || send.gain > 16 || !this.graph?.sendInputs.get(desc.bus)?.has(send.target))
|
|
182
|
+
throw new ShardError("audio/unknown-bus", `Invalid voice send ${desc.bus}/${send.target}`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
route(voice) {
|
|
186
|
+
voice.highpass.disconnect();
|
|
187
|
+
voice.panner?.disconnect();
|
|
188
|
+
for (const [target, node] of voice.sends)
|
|
189
|
+
if (!voice.desc.sends?.some((send) => send.target === target)) {
|
|
190
|
+
node.disconnect();
|
|
191
|
+
voice.sends.delete(target);
|
|
192
|
+
}
|
|
193
|
+
const spatial = voice.desc.spatial;
|
|
194
|
+
if (spatial) {
|
|
195
|
+
const node = voice.panner ?? this.context.createPanner();
|
|
196
|
+
voice.panner = node;
|
|
197
|
+
node.panningModel = spatial.panning === "hrtf" ? "HRTF" : "equalpower";
|
|
198
|
+
node.distanceModel = spatial.distanceModel;
|
|
199
|
+
node.refDistance = Math.max(1e-5, spatial.refDistance);
|
|
200
|
+
node.maxDistance = Math.max(spatial.maxDistance, node.refDistance + 1e-5);
|
|
201
|
+
node.rolloffFactor = spatial.rolloffFactor;
|
|
202
|
+
this.place(voice);
|
|
203
|
+
voice.highpass.connect(node).connect(this.input(voice.desc.bus));
|
|
204
|
+
} else {
|
|
205
|
+
voice.panner = null;
|
|
206
|
+
voice.highpass.connect(this.input(voice.desc.bus));
|
|
207
|
+
}
|
|
208
|
+
for (const send of voice.desc.sends ?? []) {
|
|
209
|
+
let node = voice.sends.get(send.target);
|
|
210
|
+
if (!node) {
|
|
211
|
+
node = this.context.createGain();
|
|
212
|
+
node.gain.value = send.gain;
|
|
213
|
+
voice.sends.set(send.target, node);
|
|
214
|
+
} else {
|
|
215
|
+
node.disconnect();
|
|
216
|
+
holdAudioParam(node.gain, this.context.currentTime);
|
|
217
|
+
node.gain.setValueAtTime(send.gain, this.context.currentTime);
|
|
218
|
+
}
|
|
219
|
+
const input = this.graph?.sendInputs.get(voice.desc.bus)?.get(send.target);
|
|
220
|
+
if (!input) throw new ShardError("audio/unknown-bus", `Missing voice send ${send.target}`);
|
|
221
|
+
(voice.panner ?? voice.highpass).connect(node).connect(input);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
setVoiceSend(id, target, gain, seconds) {
|
|
225
|
+
if (!Number.isFinite(gain) || gain < 0 || gain > 16 || !Number.isFinite(seconds) || seconds < 0)
|
|
226
|
+
throw new ShardError("audio/invalid-range", "Invalid voice send automation");
|
|
227
|
+
const node = this.voices.get(id)?.sends.get(target);
|
|
228
|
+
if (!node) throw new ShardError("audio/unknown-bus", `Missing voice send ${target}`);
|
|
229
|
+
const at = this.context.currentTime;
|
|
230
|
+
holdAudioParam(node.gain, at);
|
|
231
|
+
if (seconds) node.gain.linearRampToValueAtTime(gain, at + seconds);
|
|
232
|
+
else node.gain.setValueAtTime(gain, at);
|
|
233
|
+
}
|
|
234
|
+
setDecodedBudget(bytes) {
|
|
235
|
+
if (!Number.isFinite(bytes) || bytes < this.ownPreparedBytes)
|
|
236
|
+
throw new ShardError("audio/memory-budget", "Budget is invalid or below pinned PCM");
|
|
237
|
+
this.budgetBytes = bytes;
|
|
238
|
+
}
|
|
239
|
+
reserveVoices(count) {
|
|
240
|
+
if (!Number.isInteger(count) || count < 1 || this.voices.size + this.reserved + count > this.maxVoices)
|
|
241
|
+
throw new ShardError("audio/capacity", "Physical voice reservation cannot fit");
|
|
242
|
+
const needed = Math.max(0, this.host.voices + this.host.reserved + count - this.host.maxVoices);
|
|
243
|
+
if (needed) {
|
|
244
|
+
const candidates = [];
|
|
245
|
+
for (const session of this.host.sessions) {
|
|
246
|
+
if (session === this || session.priority > this.priority || session.priority === this.priority && session.ordinal < this.ordinal)
|
|
247
|
+
continue;
|
|
248
|
+
for (const voice of session.voices.values()) candidates.push({ session, voice });
|
|
249
|
+
}
|
|
250
|
+
candidates.sort(
|
|
251
|
+
(a, b) => a.session.priority - b.session.priority || b.session.ordinal - a.session.ordinal || b.voice.id - a.voice.id
|
|
60
252
|
);
|
|
61
|
-
|
|
253
|
+
if (candidates.length < needed)
|
|
254
|
+
throw new ShardError("audio/capacity", "Higher-ranked sessions own the host voice budget");
|
|
255
|
+
let freed = 0;
|
|
256
|
+
for (let i = 0; i < candidates.length && freed < needed; i++) {
|
|
257
|
+
const victim = candidates[i];
|
|
258
|
+
if (victim.voice.finished) continue;
|
|
259
|
+
for (const voice of victim.session.voices.values())
|
|
260
|
+
if (voice.group === victim.voice.group) {
|
|
261
|
+
voice.presentation.error = "audio/host-stolen";
|
|
262
|
+
victim.session.finish(voice);
|
|
263
|
+
freed++;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
const previous = this.reserved;
|
|
268
|
+
this.group++;
|
|
269
|
+
this.reserved += count;
|
|
270
|
+
this.host.reserved += count;
|
|
271
|
+
let released = false;
|
|
272
|
+
return () => {
|
|
273
|
+
if (released) return;
|
|
274
|
+
released = true;
|
|
275
|
+
const unused = Math.max(0, this.reserved - previous);
|
|
276
|
+
this.reserved -= unused;
|
|
277
|
+
this.host.reserved -= unused;
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
play(desc) {
|
|
281
|
+
if (this.disposed) throw new ShardError("audio/prepare-failed", "Audio session is disposed");
|
|
282
|
+
if (this.voices.size >= this.maxVoices)
|
|
283
|
+
throw new ShardError("audio/capacity", "Physical audio voice budget is exhausted", {
|
|
284
|
+
hint: "Release a voice or increase the session/host voice budget."
|
|
285
|
+
});
|
|
286
|
+
if (this.host.offline && desc.clip.stream)
|
|
287
|
+
throw new ShardError("audio/unsupported-capability", "Offline rendering needs decoded clips");
|
|
288
|
+
this.input(desc.bus);
|
|
289
|
+
this.validateRoutes(desc);
|
|
290
|
+
if (!this.reserved) this.reserveVoices(1);
|
|
291
|
+
this.reserved--;
|
|
292
|
+
this.host.reserved--;
|
|
293
|
+
const id = this.nextId++;
|
|
294
|
+
const gain = this.context.createGain();
|
|
295
|
+
gain.gain.value = desc.gain;
|
|
296
|
+
const lowpass = this.context.createBiquadFilter();
|
|
297
|
+
lowpass.type = "lowpass";
|
|
298
|
+
lowpass.frequency.value = desc.lowpass ?? this.context.sampleRate / 2;
|
|
299
|
+
const highpass = this.context.createBiquadFilter();
|
|
300
|
+
highpass.type = "highpass";
|
|
301
|
+
highpass.frequency.value = desc.highpass ?? 0;
|
|
302
|
+
const envelope = this.context.createGain();
|
|
303
|
+
gain.connect(envelope).connect(lowpass).connect(highpass);
|
|
304
|
+
const presentation = {
|
|
305
|
+
state: "preparing",
|
|
306
|
+
scheduledAt: desc.when ?? this.context.currentTime,
|
|
307
|
+
endedAt: -1,
|
|
308
|
+
error: null
|
|
309
|
+
};
|
|
310
|
+
const voice = {
|
|
311
|
+
group: this.group,
|
|
312
|
+
id,
|
|
313
|
+
desc: { ...desc },
|
|
314
|
+
params: { gain: desc.gain, pitch: desc.pitch, x: desc.x, y: desc.y, z: desc.z },
|
|
315
|
+
gain,
|
|
316
|
+
envelope,
|
|
317
|
+
lowpass,
|
|
318
|
+
highpass,
|
|
319
|
+
sends: /* @__PURE__ */ new Map(),
|
|
320
|
+
panner: null,
|
|
321
|
+
source: null,
|
|
322
|
+
media: null,
|
|
323
|
+
mediaNode: null,
|
|
324
|
+
lease: null,
|
|
325
|
+
presentation,
|
|
326
|
+
loading: false,
|
|
327
|
+
started: false,
|
|
328
|
+
finished: false,
|
|
329
|
+
norm: 1,
|
|
330
|
+
requestedAt: performance.now(),
|
|
331
|
+
timer: null,
|
|
332
|
+
automation: [],
|
|
333
|
+
tracks: /* @__PURE__ */ new Map()
|
|
334
|
+
};
|
|
335
|
+
this.voices.set(id, voice);
|
|
336
|
+
this.statuses.set(id, presentation);
|
|
337
|
+
this.host.voices++;
|
|
338
|
+
try {
|
|
339
|
+
this.route(voice);
|
|
340
|
+
this.start(voice);
|
|
341
|
+
} catch (error) {
|
|
342
|
+
this.finish(voice);
|
|
343
|
+
throw error;
|
|
344
|
+
}
|
|
345
|
+
return id;
|
|
346
|
+
}
|
|
347
|
+
start(voice) {
|
|
348
|
+
if (voice.finished || voice.started || voice.loading) return;
|
|
349
|
+
if (this.state !== "running") {
|
|
350
|
+
voice.presentation.state = "blocked";
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
const ready = this.host.cache.acquireReady(voice.desc.clip);
|
|
354
|
+
if (ready) {
|
|
355
|
+
this.startPrepared(voice, this.ownLease(ready));
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
voice.loading = true;
|
|
359
|
+
const pending = this.acquireOwned(voice.desc.clip).then((lease) => this.startPrepared(voice, lease)).catch((error) => this.fail(voice, error)).finally(() => this.pendingStarts.delete(pending));
|
|
360
|
+
this.pendingStarts.add(pending);
|
|
361
|
+
}
|
|
362
|
+
startPrepared(voice, lease) {
|
|
363
|
+
voice.loading = false;
|
|
364
|
+
if (voice.finished || this.disposed) {
|
|
365
|
+
lease.release();
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
voice.lease = lease;
|
|
369
|
+
const desc = voice.desc, now = this.context.currentTime;
|
|
370
|
+
let offset = desc.offset;
|
|
371
|
+
if (desc.when === void 0 && !this.host.offline)
|
|
372
|
+
offset += (performance.now() - voice.requestedAt) / 1e3 * voice.params.pitch;
|
|
373
|
+
const end = desc.clip.loopEnd || desc.clip.duration, start = desc.clip.loopStart;
|
|
374
|
+
if (desc.loop && offset >= end) offset = start + (offset - start) % (end - start);
|
|
375
|
+
if (!desc.loop && offset >= desc.clip.duration) {
|
|
376
|
+
this.finish(voice);
|
|
377
|
+
return;
|
|
62
378
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const blob = new Blob([clip.bytes.slice().buffer], { type: MIME[clip.codec] ?? "audio/*" });
|
|
69
|
-
url = URL.createObjectURL(blob);
|
|
70
|
-
urls.set(clip.bytes, url);
|
|
379
|
+
if (this.state !== "running") {
|
|
380
|
+
voice.lease.release();
|
|
381
|
+
voice.lease = null;
|
|
382
|
+
voice.presentation.state = "blocked";
|
|
383
|
+
return;
|
|
71
384
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
function start(voice) {
|
|
84
|
-
if (voice.stopped || voice.started || ctx.state !== "running") return;
|
|
85
|
-
const d = voice.desc;
|
|
86
|
-
if (d.clip.stream) {
|
|
385
|
+
if (desc.clip.stream) {
|
|
386
|
+
const media = new Audio(lease.value.url);
|
|
387
|
+
voice.media = media;
|
|
388
|
+
media.loop = desc.loop;
|
|
389
|
+
media.preservesPitch = false;
|
|
390
|
+
media.playbackRate = voice.params.pitch;
|
|
391
|
+
media.currentTime = offset;
|
|
392
|
+
voice.mediaNode = this.context.createMediaElementSource(media);
|
|
393
|
+
voice.mediaNode.connect(voice.gain);
|
|
394
|
+
media.onended = () => this.finish(voice);
|
|
395
|
+
media.onerror = () => this.fail(voice, new ShardError("audio/decode-failed", `Cannot play ${desc.clip.id}`));
|
|
87
396
|
voice.started = true;
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
ctx.createMediaElementSource(el).connect(voice.gain);
|
|
94
|
-
voice.element = el;
|
|
95
|
-
el.play().catch(
|
|
96
|
-
(cause) => report(
|
|
97
|
-
new ShardError("audio/decode-failed", `Can't stream ${d.clip.id} (${d.clip.codec})`, {
|
|
98
|
-
cause
|
|
99
|
-
})
|
|
100
|
-
)
|
|
397
|
+
void media.play().then(
|
|
398
|
+
() => {
|
|
399
|
+
if (!voice.finished) voice.presentation.state = "rendering";
|
|
400
|
+
},
|
|
401
|
+
(error) => this.fail(voice, error)
|
|
101
402
|
);
|
|
403
|
+
} else {
|
|
404
|
+
const source = this.context.createBufferSource();
|
|
405
|
+
voice.source = source;
|
|
406
|
+
source.buffer = lease.value.buffer;
|
|
407
|
+
source.loop = desc.loop;
|
|
408
|
+
source.loopStart = start;
|
|
409
|
+
source.loopEnd = end;
|
|
410
|
+
source.playbackRate.value = voice.params.pitch;
|
|
411
|
+
voice.norm = desc.clip.normalize && lease.value.peak > 0 ? 1 / lease.value.peak : 1;
|
|
412
|
+
voice.gain.gain.value = voice.params.gain * voice.norm;
|
|
413
|
+
source.connect(voice.gain);
|
|
414
|
+
source.onended = () => this.finish(voice);
|
|
415
|
+
if (desc.when !== void 0 && desc.when < now)
|
|
416
|
+
throw new ShardError("audio/music-late", "Prepared source missed its scheduled start");
|
|
417
|
+
const when = desc.when ?? now;
|
|
418
|
+
voice.presentation.scheduledAt = when;
|
|
419
|
+
voice.presentation.state = when > now ? "scheduled" : "rendering";
|
|
420
|
+
voice.started = true;
|
|
421
|
+
for (const command of voice.automation) this.automate(voice.id, command);
|
|
422
|
+
voice.automation.length = 0;
|
|
423
|
+
source.start(when, offset);
|
|
424
|
+
if (desc.duration !== void 0) source.stop(when + desc.duration);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
fail(voice, error) {
|
|
428
|
+
if (voice.finished) return;
|
|
429
|
+
voice.presentation.error = error instanceof ShardError ? error.code : "audio/decode-failed";
|
|
430
|
+
this.finish(voice, true);
|
|
431
|
+
this.report(error);
|
|
432
|
+
}
|
|
433
|
+
finish(voice, failed = false) {
|
|
434
|
+
if (voice.finished) return;
|
|
435
|
+
voice.finished = true;
|
|
436
|
+
if (voice.timer !== null) clearTimeout(voice.timer);
|
|
437
|
+
if (voice.source) {
|
|
438
|
+
voice.source.onended = null;
|
|
439
|
+
try {
|
|
440
|
+
voice.source.stop();
|
|
441
|
+
} catch {
|
|
442
|
+
}
|
|
443
|
+
voice.source.disconnect();
|
|
444
|
+
}
|
|
445
|
+
if (voice.media) {
|
|
446
|
+
voice.media.onended = null;
|
|
447
|
+
voice.media.onerror = null;
|
|
448
|
+
voice.media.pause();
|
|
449
|
+
voice.media.removeAttribute("src");
|
|
450
|
+
voice.media.load();
|
|
451
|
+
}
|
|
452
|
+
voice.mediaNode?.disconnect();
|
|
453
|
+
voice.gain.disconnect();
|
|
454
|
+
voice.envelope.disconnect();
|
|
455
|
+
voice.lowpass.disconnect();
|
|
456
|
+
voice.highpass.disconnect();
|
|
457
|
+
voice.panner?.disconnect();
|
|
458
|
+
for (const node of voice.sends.values()) node.disconnect();
|
|
459
|
+
voice.sends.clear();
|
|
460
|
+
voice.lease?.release();
|
|
461
|
+
voice.lease = null;
|
|
462
|
+
this.voices.delete(voice.id);
|
|
463
|
+
this.host.voices--;
|
|
464
|
+
voice.presentation.state = failed ? "failed" : "ended";
|
|
465
|
+
voice.presentation.endedAt = this.context.currentTime;
|
|
466
|
+
if (this.statuses.size > 4096)
|
|
467
|
+
for (const [id, status] of this.statuses) {
|
|
468
|
+
if (status.state === "ended" || status.state === "failed") this.statuses.delete(id);
|
|
469
|
+
if (this.statuses.size <= 4096) break;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
stop(id, fade = 0) {
|
|
473
|
+
const voice = this.voices.get(id);
|
|
474
|
+
if (!voice || voice.finished) return;
|
|
475
|
+
if (fade <= 0 || !voice.started || this.state !== "running") {
|
|
476
|
+
this.finish(voice);
|
|
102
477
|
return;
|
|
103
478
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
479
|
+
voice.presentation.state = "stopping";
|
|
480
|
+
const at = this.context.currentTime;
|
|
481
|
+
holdAudioParam(voice.gain.gain, at);
|
|
482
|
+
voice.gain.gain.linearRampToValueAtTime(0, at + fade);
|
|
483
|
+
if (voice.source) voice.source.stop(at + fade);
|
|
484
|
+
if (voice.media && !this.host.offline) {
|
|
485
|
+
if (voice.timer !== null) clearTimeout(voice.timer);
|
|
486
|
+
const finishAt = at + fade;
|
|
487
|
+
const poll = () => {
|
|
488
|
+
if (this.context.currentTime >= finishAt) this.finish(voice);
|
|
489
|
+
else voice.timer = setTimeout(poll, 25);
|
|
490
|
+
};
|
|
491
|
+
voice.timer = setTimeout(poll, 25);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
cancel(id) {
|
|
495
|
+
const voice = this.voices.get(id);
|
|
496
|
+
if (!voice) return "ended";
|
|
497
|
+
const started = voice.started && this.context.currentTime >= voice.presentation.scheduledAt;
|
|
498
|
+
this.stop(id, started ? 0.01 : 0);
|
|
499
|
+
return started ? "already-started" : "cancelled";
|
|
500
|
+
}
|
|
501
|
+
async flush() {
|
|
502
|
+
await Promise.all(this.pendingStarts);
|
|
503
|
+
for (const status of this.statuses.values())
|
|
504
|
+
if (status.state === "failed")
|
|
505
|
+
throw new ShardError(
|
|
506
|
+
status.error ?? "audio/prepare-failed",
|
|
507
|
+
"Audio source preparation failed"
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
presentation(id) {
|
|
511
|
+
const status = this.statuses.get(id);
|
|
512
|
+
if (status && this.state !== "running" && this.voices.has(id)) {
|
|
513
|
+
if (status.state === "rendering" || status.state === "scheduled") status.state = "blocked";
|
|
514
|
+
} else if (status?.state === "scheduled" && this.context.currentTime >= status.scheduledAt)
|
|
515
|
+
status.state = "rendering";
|
|
516
|
+
return status;
|
|
517
|
+
}
|
|
518
|
+
update(id, params) {
|
|
519
|
+
const voice = this.voices.get(id);
|
|
520
|
+
if (!voice) return;
|
|
521
|
+
const at = this.context.currentTime;
|
|
522
|
+
if (voice.params.gain !== params.gain)
|
|
523
|
+
voice.gain.gain.setTargetAtTime(params.gain * voice.norm, at, 0.015);
|
|
524
|
+
if (voice.params.pitch !== params.pitch) {
|
|
525
|
+
voice.source?.playbackRate.setValueAtTime(params.pitch, at);
|
|
526
|
+
if (voice.media) voice.media.playbackRate = params.pitch;
|
|
527
|
+
}
|
|
528
|
+
voice.params.gain = params.gain;
|
|
529
|
+
voice.params.pitch = params.pitch;
|
|
530
|
+
voice.params.x = params.x;
|
|
531
|
+
voice.params.y = params.y;
|
|
532
|
+
voice.params.z = params.z;
|
|
533
|
+
this.place(voice);
|
|
534
|
+
}
|
|
535
|
+
reconfigure(id, desc) {
|
|
536
|
+
const voice = this.voices.get(id);
|
|
537
|
+
if (!voice || voice.desc.clip.bytes !== desc.clip.bytes) return false;
|
|
538
|
+
this.input(desc.bus);
|
|
539
|
+
this.validateRoutes(desc);
|
|
540
|
+
voice.desc = { ...voice.desc, ...desc };
|
|
541
|
+
if (voice.source) {
|
|
542
|
+
voice.source.loop = desc.loop;
|
|
543
|
+
voice.source.loopStart = desc.clip.loopStart;
|
|
544
|
+
voice.source.loopEnd = desc.clip.loopEnd || desc.clip.duration;
|
|
545
|
+
}
|
|
546
|
+
if (voice.media) voice.media.loop = desc.loop;
|
|
547
|
+
this.route(voice);
|
|
548
|
+
this.update(id, desc);
|
|
549
|
+
return true;
|
|
550
|
+
}
|
|
551
|
+
automate(id, command) {
|
|
552
|
+
validateAudioAutomation(command);
|
|
553
|
+
const voice = this.voices.get(id);
|
|
554
|
+
if (!voice) return;
|
|
555
|
+
if (!voice.source && !voice.media) {
|
|
556
|
+
if (voice.automation.length >= 128)
|
|
557
|
+
throw new ShardError("audio/capacity", "Voice automation queue is full");
|
|
558
|
+
voice.automation.push({ ...command });
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
const param = command.property === "envelope" ? voice.envelope.gain : command.property === "gain" ? voice.gain.gain : command.property === "pitch" ? voice.source?.playbackRate : command.property === "lowpass" ? voice.lowpass.frequency : voice.highpass.frequency;
|
|
562
|
+
if (!param)
|
|
563
|
+
throw new ShardError(
|
|
564
|
+
"audio/unsupported-capability",
|
|
565
|
+
"Pitch automation needs a prepared decoded source"
|
|
566
|
+
);
|
|
567
|
+
const value = command.value * (command.property === "gain" ? voice.norm : 1);
|
|
568
|
+
const previous = voice.tracks.get(command.property);
|
|
569
|
+
if (previous && this.context.currentTime >= previous.command.at) {
|
|
570
|
+
previous.previous = null;
|
|
571
|
+
previous.depth = 1;
|
|
572
|
+
}
|
|
573
|
+
const depth = command.at > this.context.currentTime ? (previous?.depth ?? 0) + 1 : 1;
|
|
574
|
+
if (depth > 128)
|
|
575
|
+
throw new ShardError("audio/capacity", "Voice future automation capacity reached");
|
|
576
|
+
const initial = command.property === "envelope" ? 1 : command.property === "gain" ? voice.params.gain * voice.norm : command.property === "pitch" ? voice.params.pitch : command.property === "lowpass" ? voice.desc.lowpass ?? this.context.sampleRate / 2 : voice.desc.highpass ?? 0;
|
|
577
|
+
const start = previous ? automatedValue(previous, command.at) : initial;
|
|
578
|
+
voice.tracks.set(command.property, {
|
|
579
|
+
depth,
|
|
580
|
+
command: { ...command, value },
|
|
581
|
+
start,
|
|
582
|
+
previous: command.at > this.context.currentTime ? previous ?? null : null
|
|
583
|
+
});
|
|
584
|
+
holdAudioParam(param, command.at, start);
|
|
585
|
+
param.setValueAtTime(start, command.at);
|
|
586
|
+
if (command.duration <= 0) param.setValueAtTime(value, command.at);
|
|
587
|
+
else if (command.curve === "exponential")
|
|
588
|
+
param.exponentialRampToValueAtTime(Math.max(value, 1e-6), command.at + command.duration);
|
|
589
|
+
else if (command.curve === "equal-power-in" || command.curve === "equal-power-out") {
|
|
590
|
+
const samples = new Float32Array(129);
|
|
591
|
+
for (let i = 0; i < samples.length; i++) {
|
|
592
|
+
const phase = i / (samples.length - 1) * Math.PI / 2;
|
|
593
|
+
samples[i] = command.curve === "equal-power-in" ? start + (value - start) * Math.sin(phase) : value + (start - value) * Math.cos(phase);
|
|
594
|
+
}
|
|
595
|
+
param.setValueCurveAtTime(samples, command.at, command.duration);
|
|
596
|
+
} else param.linearRampToValueAtTime(value, command.at + command.duration);
|
|
597
|
+
}
|
|
598
|
+
setListener(matrix) {
|
|
599
|
+
for (let i = 0; i < 12; i++) this.listener[i] = matrix[i];
|
|
600
|
+
if (this.host.fixedListener) for (const voice of this.voices.values()) this.place(voice);
|
|
601
|
+
else setListenerPose(this.context.listener, matrix);
|
|
602
|
+
}
|
|
603
|
+
setBus(name, gain) {
|
|
604
|
+
this.busGains.set(name, gain);
|
|
605
|
+
if (!this.graph) this.busNode(name).gain.setTargetAtTime(gain, this.context.currentTime, 0.015);
|
|
606
|
+
}
|
|
607
|
+
preload(clip) {
|
|
608
|
+
void this.prepare(clip).then((lease) => lease.release()).catch(this.report);
|
|
609
|
+
}
|
|
610
|
+
async prepare(clip) {
|
|
611
|
+
return this.acquireOwned(clip);
|
|
612
|
+
}
|
|
613
|
+
async acquireOwned(clip) {
|
|
614
|
+
if (this.disposed) throw new ShardError("audio/prepare-failed", "Session is disposed");
|
|
615
|
+
return this.ownLease(await this.host.cache.acquire(clip));
|
|
616
|
+
}
|
|
617
|
+
ownLease(lease) {
|
|
618
|
+
const clip = lease.clip;
|
|
619
|
+
const pinned = this.preparationPins.get(lease.value);
|
|
620
|
+
if (this.disposed || this.ownPreparedBytes + (pinned ? 0 : lease.decodedBytes) > this.budgetBytes) {
|
|
621
|
+
lease.release();
|
|
622
|
+
throw new ShardError(
|
|
623
|
+
this.disposed ? "audio/prepare-failed" : "audio/memory-budget",
|
|
624
|
+
"Session cannot retain preparation"
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
const pin = pinned ?? { count: 0, bytes: lease.decodedBytes };
|
|
628
|
+
if (!pinned) {
|
|
629
|
+
this.preparationPins.set(lease.value, pin);
|
|
630
|
+
this.ownPreparedBytes += pin.bytes;
|
|
631
|
+
}
|
|
632
|
+
pin.count++;
|
|
633
|
+
let released = false;
|
|
634
|
+
const owned = {
|
|
635
|
+
clip,
|
|
636
|
+
value: lease.value,
|
|
637
|
+
decodedBytes: lease.decodedBytes,
|
|
638
|
+
encodedBytes: lease.encodedBytes,
|
|
639
|
+
sampleRate: lease.sampleRate,
|
|
640
|
+
samples: lease.samples,
|
|
641
|
+
channels: lease.channels,
|
|
642
|
+
get released() {
|
|
643
|
+
return released;
|
|
123
644
|
},
|
|
124
|
-
(
|
|
125
|
-
|
|
645
|
+
release: () => {
|
|
646
|
+
if (released) return;
|
|
647
|
+
released = true;
|
|
648
|
+
if (--pin.count === 0) {
|
|
649
|
+
this.preparationPins.delete(lease.value);
|
|
650
|
+
this.ownPreparedBytes -= pin.bytes;
|
|
651
|
+
}
|
|
652
|
+
this.leases.delete(owned);
|
|
653
|
+
lease.release();
|
|
654
|
+
}
|
|
655
|
+
};
|
|
656
|
+
this.leases.add(owned);
|
|
657
|
+
return owned;
|
|
126
658
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
panner.positionY.setTargetAtTime(y, at, SMOOTH);
|
|
131
|
-
panner.positionZ.setTargetAtTime(z, at, SMOOTH);
|
|
132
|
-
} else panner.setPosition(x, y, z);
|
|
659
|
+
collect() {
|
|
660
|
+
this.maintainGraphs();
|
|
661
|
+
this.host.cache.collect();
|
|
133
662
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
element: null,
|
|
171
|
-
norm: 1,
|
|
172
|
-
requestedAt: performance.now(),
|
|
173
|
-
started: false,
|
|
174
|
-
stopped: false
|
|
175
|
-
};
|
|
176
|
-
voices.set(id, voice);
|
|
177
|
-
start(voice);
|
|
178
|
-
return id;
|
|
179
|
-
},
|
|
180
|
-
preload(clip) {
|
|
181
|
-
if (!clip.stream) decode(clip).catch((err) => backend.onError?.(err));
|
|
182
|
-
},
|
|
183
|
-
update(id, params) {
|
|
184
|
-
const v = voices.get(id);
|
|
185
|
-
if (!v) return;
|
|
186
|
-
const now = ctx.currentTime;
|
|
187
|
-
const p = v.params;
|
|
188
|
-
if (p.gain !== params.gain) v.gain.gain.setTargetAtTime(params.gain * v.norm, now, SMOOTH);
|
|
189
|
-
if (p.pitch !== params.pitch) {
|
|
190
|
-
if (v.source) v.source.playbackRate.setTargetAtTime(params.pitch, now, SMOOTH);
|
|
191
|
-
if (v.element) v.element.playbackRate = params.pitch;
|
|
663
|
+
memory() {
|
|
664
|
+
let nodes = 1 + this.buses.size + this.meters.size;
|
|
665
|
+
for (const voice of this.voices.values())
|
|
666
|
+
nodes += 4 + voice.sends.size + Number(!!voice.panner) + Number(!!voice.source || !!voice.mediaNode);
|
|
667
|
+
nodes += this.graph ? this.graph.nodes.length + 1 : 0;
|
|
668
|
+
for (const graph of this.retired.keys()) nodes += graph.nodes.length + 1;
|
|
669
|
+
let effectBytes = this.graph?.generatedBytes ?? 0;
|
|
670
|
+
for (const graph of this.retired.keys()) effectBytes += graph.generatedBytes;
|
|
671
|
+
return { ...this.host.cache.memory(), effectBytes, voices: this.voices.size, nodes };
|
|
672
|
+
}
|
|
673
|
+
async configureMixer(graph) {
|
|
674
|
+
const revision = ++this.graphRevision, output = this.context.createGain();
|
|
675
|
+
output.gain.value = 0;
|
|
676
|
+
output.connect(this.output);
|
|
677
|
+
let next;
|
|
678
|
+
try {
|
|
679
|
+
next = await WebAudioGraph.build(this.context, graph, output, {
|
|
680
|
+
acquire: (clip) => this.acquireOwned(clip)
|
|
681
|
+
});
|
|
682
|
+
} catch (error) {
|
|
683
|
+
output.disconnect();
|
|
684
|
+
throw error;
|
|
685
|
+
}
|
|
686
|
+
if (this.disposed || revision !== this.graphRevision || next.nodes.length + (this.graph?.nodes.length ?? 0) + [...this.retired.keys()].reduce((sum, graph2) => sum + graph2.nodes.length, 0) > graph.maxNodes || next.generatedBytes + (this.graph?.generatedBytes ?? 0) + [...this.retired.keys()].reduce((sum, old2) => sum + old2.generatedBytes, 0) > 64 * 1024 * 1024) {
|
|
687
|
+
next.dispose();
|
|
688
|
+
output.disconnect();
|
|
689
|
+
throw new ShardError(
|
|
690
|
+
"audio/mixer-capacity",
|
|
691
|
+
"Graph replacement cancelled or exceeds overlap budget"
|
|
692
|
+
);
|
|
693
|
+
}
|
|
694
|
+
for (const voice of this.voices.values())
|
|
695
|
+
if (!next.inputs.has(voice.desc.bus) || [...voice.sends.keys()].some((target) => !next.sendInputs.get(voice.desc.bus)?.has(target))) {
|
|
696
|
+
next.dispose();
|
|
697
|
+
output.disconnect();
|
|
698
|
+
throw new ShardError("audio/unknown-bus", `Graph removes active route ${voice.desc.bus}`);
|
|
192
699
|
}
|
|
193
|
-
|
|
194
|
-
|
|
700
|
+
const old = this.graph;
|
|
701
|
+
this.graph = next;
|
|
702
|
+
for (const voice of this.voices.values()) {
|
|
703
|
+
const input = next.inputs.get(voice.desc.bus);
|
|
704
|
+
if (input) (voice.panner ?? voice.highpass).connect(input);
|
|
705
|
+
for (const [target, node] of voice.sends)
|
|
706
|
+
node.connect(next.sendInputs.get(voice.desc.bus).get(target));
|
|
707
|
+
}
|
|
708
|
+
const at = this.context.currentTime, fade = old ? 0.02 : 0;
|
|
709
|
+
output.gain.setValueAtTime(fade ? 0 : 1, at);
|
|
710
|
+
if (fade) output.gain.linearRampToValueAtTime(1, at + fade);
|
|
711
|
+
for (const node of this.buses.values()) node.disconnect();
|
|
712
|
+
this.buses.clear();
|
|
713
|
+
for (const meter of this.meters.values()) meter.node.disconnect();
|
|
714
|
+
this.meters.clear();
|
|
715
|
+
if (old) {
|
|
716
|
+
const tailEnd = at + fade + old.tailSeconds;
|
|
717
|
+
this.retired.set(old, tailEnd + 0.02);
|
|
718
|
+
for (const input of old.inputs.values()) {
|
|
719
|
+
holdAudioParam(input.gain, at);
|
|
720
|
+
input.gain.linearRampToValueAtTime(0, at + fade);
|
|
195
721
|
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
p.z = params.z;
|
|
201
|
-
},
|
|
202
|
-
stop(id, fade = 0) {
|
|
203
|
-
const v = voices.get(id);
|
|
204
|
-
if (!v) return;
|
|
205
|
-
voices.delete(id);
|
|
206
|
-
v.stopped = true;
|
|
207
|
-
const now = ctx.currentTime;
|
|
208
|
-
const f = Math.max(fade, MIN_FADE);
|
|
209
|
-
v.gain.gain.cancelScheduledValues(now);
|
|
210
|
-
v.gain.gain.setValueAtTime(v.gain.gain.value, now);
|
|
211
|
-
v.gain.gain.linearRampToValueAtTime(0, now + f);
|
|
212
|
-
const release = () => {
|
|
213
|
-
v.gain.disconnect();
|
|
214
|
-
v.panner?.disconnect();
|
|
215
|
-
if (v.element) {
|
|
216
|
-
v.element.pause();
|
|
217
|
-
v.element.removeAttribute("src");
|
|
722
|
+
for (const routes of old.sendInputs.values())
|
|
723
|
+
for (const input of routes.values()) {
|
|
724
|
+
holdAudioParam(input.gain, at);
|
|
725
|
+
input.gain.linearRampToValueAtTime(0, at + fade);
|
|
218
726
|
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
v.source.onended = release;
|
|
222
|
-
v.source.stop(now + f);
|
|
223
|
-
} else setTimeout(release, f * 1e3 + 50);
|
|
224
|
-
},
|
|
225
|
-
setListener(m) {
|
|
226
|
-
const l = ctx.listener;
|
|
227
|
-
const fl = Math.sqrt(m[2] * m[2] + m[6] * m[6] + m[10] * m[10]) || 1;
|
|
228
|
-
const ul = Math.sqrt(m[1] * m[1] + m[5] * m[5] + m[9] * m[9]) || 1;
|
|
229
|
-
const fx = -m[2] / fl;
|
|
230
|
-
const fy = -m[6] / fl;
|
|
231
|
-
const fz = -m[10] / fl;
|
|
232
|
-
const ux = m[1] / ul;
|
|
233
|
-
const uy = m[5] / ul;
|
|
234
|
-
const uz = m[9] / ul;
|
|
235
|
-
if (l.positionX) {
|
|
236
|
-
const now = ctx.currentTime;
|
|
237
|
-
l.positionX.setTargetAtTime(m[3], now, SMOOTH);
|
|
238
|
-
l.positionY.setTargetAtTime(m[7], now, SMOOTH);
|
|
239
|
-
l.positionZ.setTargetAtTime(m[11], now, SMOOTH);
|
|
240
|
-
l.forwardX.setTargetAtTime(fx, now, SMOOTH);
|
|
241
|
-
l.forwardY.setTargetAtTime(fy, now, SMOOTH);
|
|
242
|
-
l.forwardZ.setTargetAtTime(fz, now, SMOOTH);
|
|
243
|
-
l.upX.setTargetAtTime(ux, now, SMOOTH);
|
|
244
|
-
l.upY.setTargetAtTime(uy, now, SMOOTH);
|
|
245
|
-
l.upZ.setTargetAtTime(uz, now, SMOOTH);
|
|
246
|
-
} else {
|
|
247
|
-
l.setPosition(m[3], m[7], m[11]);
|
|
248
|
-
l.setOrientation(fx, fy, fz, ux, uy, uz);
|
|
249
|
-
}
|
|
250
|
-
},
|
|
251
|
-
setBus(name, gain) {
|
|
252
|
-
busGains.set(name, gain);
|
|
253
|
-
bus(name).gain.setTargetAtTime(gain, ctx.currentTime, SMOOTH);
|
|
254
|
-
},
|
|
255
|
-
dispose() {
|
|
256
|
-
for (const g of GESTURES) window.removeEventListener(g, onGesture, { capture: true });
|
|
257
|
-
for (const id of [...voices.keys()]) backend.stop(id);
|
|
258
|
-
void ctx.close();
|
|
727
|
+
old.output.gain.setValueAtTime(1, tailEnd);
|
|
728
|
+
old.output.gain.linearRampToValueAtTime(0, tailEnd + 0.02);
|
|
259
729
|
}
|
|
730
|
+
}
|
|
731
|
+
maintainGraphs = () => {
|
|
732
|
+
this.graph?.tick();
|
|
733
|
+
this.retired.forEach(this.maintainRetired);
|
|
260
734
|
};
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
735
|
+
maintainRetired = (deadline, old) => {
|
|
736
|
+
if (this.context.currentTime < deadline) return;
|
|
737
|
+
for (const voice of this.voices.values())
|
|
738
|
+
for (const input of old.inputs.values()) {
|
|
739
|
+
try {
|
|
740
|
+
;
|
|
741
|
+
(voice.panner ?? voice.highpass).disconnect(input);
|
|
742
|
+
} catch {
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
for (const voice of this.voices.values())
|
|
746
|
+
for (const [target, node] of voice.sends) {
|
|
747
|
+
const input = old.sendInputs.get(voice.desc.bus)?.get(target);
|
|
748
|
+
if (input)
|
|
749
|
+
try {
|
|
750
|
+
node.disconnect(input);
|
|
751
|
+
} catch {
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
old.dispose();
|
|
755
|
+
old.output.disconnect();
|
|
756
|
+
this.retired.delete(old);
|
|
757
|
+
};
|
|
758
|
+
setMixerValue(bus, field, value, rampSeconds = 0.01) {
|
|
759
|
+
if (!this.graph) throw new ShardError("audio/unsupported-capability", "No mixer is installed");
|
|
760
|
+
this.graph.set(bus, field, value, rampSeconds);
|
|
761
|
+
if (field === "userGain") {
|
|
762
|
+
for (const old of this.retired.keys())
|
|
763
|
+
if (old.hasBus(bus)) old.set(bus, field, value, rampSeconds);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
emptyMeter() {
|
|
767
|
+
return {
|
|
768
|
+
available: !this.host.offline,
|
|
769
|
+
peak: 0,
|
|
770
|
+
rms: 0,
|
|
771
|
+
samples: 0,
|
|
772
|
+
sampleRate: this.context.sampleRate,
|
|
773
|
+
at: -1
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
meter(bus) {
|
|
777
|
+
if (this.host.offline) return this.emptyMeter();
|
|
778
|
+
let meter = this.meters.get(bus);
|
|
779
|
+
if (!meter) {
|
|
780
|
+
const node = this.context.createAnalyser();
|
|
781
|
+
node.fftSize = 2048;
|
|
782
|
+
this.busNode(bus).connect(node);
|
|
783
|
+
meter = { node, data: new Float32Array(2048), value: this.emptyMeter() };
|
|
784
|
+
this.meters.set(bus, meter);
|
|
270
785
|
}
|
|
786
|
+
if (this.context.currentTime - meter.value.at < 0.05) return meter.value;
|
|
787
|
+
meter.node.getFloatTimeDomainData(meter.data);
|
|
788
|
+
let peak = 0, sum = 0;
|
|
789
|
+
for (let i = 0; i < meter.data.length; i++) {
|
|
790
|
+
const v = meter.data[i];
|
|
791
|
+
peak = Math.max(peak, Math.abs(v));
|
|
792
|
+
sum += v * v;
|
|
793
|
+
}
|
|
794
|
+
meter.value.peak = peak;
|
|
795
|
+
meter.value.rms = Math.sqrt(sum / meter.data.length);
|
|
796
|
+
meter.value.samples = meter.data.length;
|
|
797
|
+
meter.value.at = this.context.currentTime;
|
|
798
|
+
return meter.value;
|
|
799
|
+
}
|
|
800
|
+
dispose() {
|
|
801
|
+
if (this.disposed) return;
|
|
802
|
+
this.disposed = true;
|
|
803
|
+
this.graphRevision++;
|
|
804
|
+
for (const handle of this.wakes) clearInterval(handle);
|
|
805
|
+
for (const handle of this.timers) clearTimeout(handle);
|
|
806
|
+
this.wakes.clear();
|
|
807
|
+
this.timers.clear();
|
|
808
|
+
this.context.removeEventListener("statechange", this.stateChanged);
|
|
809
|
+
if (!this.host.offline)
|
|
810
|
+
for (const gesture of gestures)
|
|
811
|
+
window.removeEventListener(gesture, this.unlock, { capture: true });
|
|
812
|
+
for (const voice of this.voices.values()) this.finish(voice);
|
|
813
|
+
for (const lease of this.leases) lease.release();
|
|
814
|
+
for (const node of this.buses.values()) node.disconnect();
|
|
815
|
+
for (const meter of this.meters.values()) meter.node.disconnect();
|
|
816
|
+
this.buses.clear();
|
|
817
|
+
this.meters.clear();
|
|
818
|
+
this.statuses.clear();
|
|
819
|
+
this.graph?.dispose();
|
|
820
|
+
this.graph?.output.disconnect();
|
|
821
|
+
this.graph = null;
|
|
822
|
+
for (const graph of this.retired.keys()) {
|
|
823
|
+
graph.dispose();
|
|
824
|
+
graph.output.disconnect();
|
|
825
|
+
}
|
|
826
|
+
this.retired.clear();
|
|
827
|
+
this.output.disconnect();
|
|
828
|
+
this.host.reserved -= this.reserved;
|
|
829
|
+
this.reserved = 0;
|
|
830
|
+
this.host.sessions.delete(this);
|
|
831
|
+
if (this.root) {
|
|
832
|
+
for (const session of this.host.sessions) session.dispose();
|
|
833
|
+
this.host.cache.dispose();
|
|
834
|
+
this.host.output.disconnect();
|
|
835
|
+
if (this.host.ownsContext && !this.host.offline) void this.context.close();
|
|
836
|
+
} else this.host.cache.collect();
|
|
271
837
|
}
|
|
272
|
-
|
|
838
|
+
}
|
|
839
|
+
function createWebAudioBackend(options = {}) {
|
|
840
|
+
const context = options.context ?? new AudioContext({ latencyHint: "interactive" });
|
|
841
|
+
const offline = typeof OfflineAudioContext !== "undefined" && context instanceof OfflineAudioContext;
|
|
842
|
+
const output = context.createGain();
|
|
843
|
+
output.connect(context.destination);
|
|
844
|
+
const fixedListener = options.context ? options.fixedListener === true : true;
|
|
845
|
+
if (fixedListener) setListenerPose(context.listener, identity);
|
|
846
|
+
const host = {
|
|
847
|
+
context,
|
|
848
|
+
output,
|
|
849
|
+
offline,
|
|
850
|
+
fixedListener,
|
|
851
|
+
ownsContext: !options.context,
|
|
852
|
+
cache: new AudioCache(context, options.decodedBudgetBytes, options.maxDecodes),
|
|
853
|
+
sessions: /* @__PURE__ */ new Set(),
|
|
854
|
+
maxVoices: options.maxHostVoices ?? 256,
|
|
855
|
+
voices: 0,
|
|
856
|
+
reserved: 0,
|
|
857
|
+
nextSession: 0
|
|
858
|
+
};
|
|
859
|
+
return new WebSession(host, true, options);
|
|
273
860
|
}
|
|
274
861
|
export {
|
|
275
862
|
createWebAudioBackend
|