@forgeax/engine-audio-webaudio 0.1.2

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.
Files changed (65) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +134 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/__tests__/audio-bus-name-owner.test-d.d.ts +2 -0
  5. package/dist/__tests__/audio-bus-name-owner.test-d.d.ts.map +1 -0
  6. package/dist/__tests__/audio-loader-contract.test.d.ts +2 -0
  7. package/dist/__tests__/audio-loader-contract.test.d.ts.map +1 -0
  8. package/dist/__tests__/audio-local-artifacts.test.d.ts +2 -0
  9. package/dist/__tests__/audio-local-artifacts.test.d.ts.map +1 -0
  10. package/dist/__tests__/audio-webaudio.browser.test.d.ts +2 -0
  11. package/dist/__tests__/audio-webaudio.browser.test.d.ts.map +1 -0
  12. package/dist/__tests__/audio-webaudio.unit.test.d.ts +2 -0
  13. package/dist/__tests__/audio-webaudio.unit.test.d.ts.map +1 -0
  14. package/dist/__tests__/gain-automation.unit.test.d.ts +2 -0
  15. package/dist/__tests__/gain-automation.unit.test.d.ts.map +1 -0
  16. package/dist/__tests__/host-audio-consumer.unit.test.d.ts +2 -0
  17. package/dist/__tests__/host-audio-consumer.unit.test.d.ts.map +1 -0
  18. package/dist/__tests__/root-surface.unit.test.d.ts +2 -0
  19. package/dist/__tests__/root-surface.unit.test.d.ts.map +1 -0
  20. package/dist/__tests__/spatial-cleanup.test.d.ts +2 -0
  21. package/dist/__tests__/spatial-cleanup.test.d.ts.map +1 -0
  22. package/dist/__tests__/web-audio-engine-decode-recovery.browser.test.d.ts +2 -0
  23. package/dist/__tests__/web-audio-engine-decode-recovery.browser.test.d.ts.map +1 -0
  24. package/dist/__tests__/web-audio-engine-decode-recovery.unit.test.d.ts +2 -0
  25. package/dist/__tests__/web-audio-engine-decode-recovery.unit.test.d.ts.map +1 -0
  26. package/dist/audio-importer.d.ts +17 -0
  27. package/dist/audio-importer.d.ts.map +1 -0
  28. package/dist/audio-importer.mjs +90 -0
  29. package/dist/audio-importer.mjs.map +1 -0
  30. package/dist/audio-listener-sync-system.d.ts +42 -0
  31. package/dist/audio-listener-sync-system.d.ts.map +1 -0
  32. package/dist/audio-loader.d.ts +4 -0
  33. package/dist/audio-loader.d.ts.map +1 -0
  34. package/dist/clip-loader.d.ts +5 -0
  35. package/dist/clip-loader.d.ts.map +1 -0
  36. package/dist/host-audio-consumer.d.ts +11 -0
  37. package/dist/host-audio-consumer.d.ts.map +1 -0
  38. package/dist/index.d.ts +8 -0
  39. package/dist/index.d.ts.map +1 -0
  40. package/dist/index.mjs +595 -0
  41. package/dist/index.mjs.map +1 -0
  42. package/dist/plugin.d.ts +3 -0
  43. package/dist/plugin.d.ts.map +1 -0
  44. package/dist/web-audio-engine.d.ts +43 -0
  45. package/dist/web-audio-engine.d.ts.map +1 -0
  46. package/package.json +67 -0
  47. package/src/__tests__/audio-bus-name-owner.test-d.ts +21 -0
  48. package/src/__tests__/audio-loader-contract.test.ts +103 -0
  49. package/src/__tests__/audio-local-artifacts.test.ts +34 -0
  50. package/src/__tests__/audio-webaudio.browser.test.ts +311 -0
  51. package/src/__tests__/audio-webaudio.unit.test.ts +3031 -0
  52. package/src/__tests__/gain-automation.unit.test.ts +173 -0
  53. package/src/__tests__/host-audio-consumer.unit.test.ts +195 -0
  54. package/src/__tests__/root-surface.unit.test.ts +8 -0
  55. package/src/__tests__/spatial-cleanup.test.ts +85 -0
  56. package/src/__tests__/web-audio-engine-decode-recovery.browser.test.ts +125 -0
  57. package/src/__tests__/web-audio-engine-decode-recovery.unit.test.ts +93 -0
  58. package/src/audio-importer.ts +149 -0
  59. package/src/audio-listener-sync-system.ts +98 -0
  60. package/src/audio-loader.ts +55 -0
  61. package/src/clip-loader.ts +111 -0
  62. package/src/host-audio-consumer.ts +168 -0
  63. package/src/index.ts +32 -0
  64. package/src/plugin.ts +15 -0
  65. package/src/web-audio-engine.ts +411 -0
package/dist/index.mjs ADDED
@@ -0,0 +1,595 @@
1
+ import { AudioError, AUDIO_ERROR_HINTS, createAudioIntentBackend } from '@forgeax/engine-audio';
2
+ export { AUDIO_ENGINE_RESOURCE_KEY } from '@forgeax/engine-audio';
3
+ import { mat4, vec3 } from '@forgeax/engine-math';
4
+ import { err, ok, AssetError, AudioError as AudioError$1 } from '@forgeax/engine-types';
5
+
6
+ // src/index.ts
7
+ function syncListenerFromWorldMatrix(listener, worldMatrix) {
8
+ const m = worldMatrix;
9
+ const position = mat4.getTranslation(vec3.create(), m);
10
+ listener.positionX.value = position[0];
11
+ listener.positionY.value = position[1];
12
+ listener.positionZ.value = position[2];
13
+ const forward = mat4.getForward(vec3.create(), m);
14
+ listener.forwardX.value = forward[0];
15
+ listener.forwardY.value = forward[1];
16
+ listener.forwardZ.value = forward[2];
17
+ const up = mat4.getUp(vec3.create(), m);
18
+ listener.upX.value = up[0];
19
+ listener.upY.value = up[1];
20
+ listener.upZ.value = up[2];
21
+ }
22
+ function audioListenerSyncSystem(ctx, worldMatrix) {
23
+ syncListenerFromWorldMatrix(ctx.listener, worldMatrix);
24
+ }
25
+ async function decodeAudioClipBytes(guid, bytes, mediaType) {
26
+ if (mediaType.length <= "audio/".length || bytes.byteLength === 0) {
27
+ return err(
28
+ new AudioError({
29
+ code: "decode-failed",
30
+ expected: `non-empty audio mediaType and source bytes for GUID ${guid}`,
31
+ hint: "verify the audio artifact mediaType and recook the source bytes",
32
+ detail: {
33
+ code: "decode-failed",
34
+ reason: "audio mediaType or source bytes are empty"
35
+ }
36
+ })
37
+ );
38
+ }
39
+ try {
40
+ return ok({ kind: "audio", sourceKey: guid, mediaType, bytes: bytes.slice() });
41
+ } catch (e) {
42
+ return err(
43
+ new AudioError({
44
+ code: "decode-failed",
45
+ expected: `decodable audio artifact bytes for GUID ${guid}`,
46
+ hint: "verify the audio artifact mediaType and browser-supported codec",
47
+ detail: {
48
+ code: "decode-failed",
49
+ reason: e instanceof Error ? e.message : "audio artifact decode failed"
50
+ }
51
+ })
52
+ );
53
+ }
54
+ }
55
+ var audioClipContribution = {
56
+ kind: { kind: "audio" },
57
+ consumer: "WebAudioEngine",
58
+ decoder: {
59
+ async decode({ envelope, artifacts }) {
60
+ const source = Object.values(envelope.artifacts).find(
61
+ (descriptor) => descriptor.path.endsWith(".audio")
62
+ );
63
+ if (source === void 0) {
64
+ return err({
65
+ code: "asset-package-invalid",
66
+ expected: "an asset-local audio source artifact",
67
+ hint: "recook the audio asset with its source artifact",
68
+ detail: { guid: envelope.guid, reason: "source artifact is missing" }
69
+ });
70
+ }
71
+ const bytes = await artifacts.read(source);
72
+ if (!bytes.ok) {
73
+ return err({
74
+ code: "asset-package-invalid",
75
+ expected: "a readable audio source artifact",
76
+ hint: "repair the Pack artifact and retry the audio decoder",
77
+ detail: { guid: envelope.guid, reason: bytes.error.code }
78
+ });
79
+ }
80
+ const mediaType = source.mediaType;
81
+ if (!mediaType.startsWith("audio/")) {
82
+ return err({
83
+ code: "asset-package-invalid",
84
+ expected: "an audio/* source artifact",
85
+ hint: "publish the audio artifact with its actual media type",
86
+ detail: { guid: envelope.guid, reason: `unsupported media type ${mediaType}` }
87
+ });
88
+ }
89
+ if (bytes.value.byteLength === 0) {
90
+ return err({
91
+ code: "asset-package-invalid",
92
+ expected: "non-empty audio source bytes",
93
+ hint: "recook the audio source and retry the decoder",
94
+ detail: { guid: envelope.guid, reason: "empty audio artifact" }
95
+ });
96
+ }
97
+ return ok({
98
+ kind: "audio",
99
+ sourceKey: envelope.guid,
100
+ mediaType,
101
+ bytes: bytes.value.slice()
102
+ });
103
+ }
104
+ }
105
+ };
106
+
107
+ // src/audio-loader.ts
108
+ var audioLoader = {
109
+ kind: "audio",
110
+ loadPack(input) {
111
+ if (input.kind !== "audio") {
112
+ return Promise.resolve({
113
+ ok: false,
114
+ error: new AssetError({
115
+ code: "asset-parse-failed",
116
+ expected: "Pack v2 loader input with kind 'audio'",
117
+ hint: "pass the asset-local audio envelope to the audio loader",
118
+ detail: { sourcePath: input.guid }
119
+ })
120
+ });
121
+ }
122
+ const source = input.artifacts.source;
123
+ const payloadMediaType = typeof input.payload.mediaType === "string" ? input.payload.mediaType : void 0;
124
+ if (source === void 0 || !source.descriptor.mediaType.startsWith("audio/") || payloadMediaType !== source.descriptor.mediaType) {
125
+ return Promise.resolve({
126
+ ok: false,
127
+ error: {
128
+ code: "asset-artifact-media-unsupported",
129
+ expected: "an asset-local source artifact with an audio/* mediaType",
130
+ hint: "declare the audio source artifact with a supported mediaType and re-cook",
131
+ detail: {
132
+ guid: input.guid,
133
+ artifactKey: "source",
134
+ observed: payloadMediaType ?? source?.descriptor.mediaType ?? "missing",
135
+ expected: "payload.mediaType matching the audio/* source artifact"
136
+ }
137
+ }
138
+ });
139
+ }
140
+ return decodeAudioClipBytes(input.guid, source.bytes, payloadMediaType);
141
+ },
142
+ async load() {
143
+ return {
144
+ ok: false,
145
+ error: new AssetError({
146
+ code: "asset-parse-failed",
147
+ expected: "Pack v2 audio input with an asset-local source artifact",
148
+ hint: "re-cook the audio source into the Pack v2 asset envelope",
149
+ detail: { sourcePath: "audio-loader-input" }
150
+ })
151
+ };
152
+ }
153
+ };
154
+ var GESTURE_EVENTS = ["click", "keydown", "touchstart"];
155
+ var GAIN_TRANSITION_SECONDS = 0.01;
156
+ var WebAudioEngine = class {
157
+ ctx;
158
+ closed = false;
159
+ masterGain;
160
+ sfxGain;
161
+ musicGain;
162
+ sources = /* @__PURE__ */ new Map();
163
+ gestureListening = false;
164
+ resumeInFlight;
165
+ gestureResumeHandler;
166
+ lastError = null;
167
+ // Per-bus previous-volume cache for mute/unmute restore (D-5).
168
+ busVolumes = /* @__PURE__ */ new Map([
169
+ ["sfx", 1],
170
+ ["music", 1]
171
+ ]);
172
+ busMuted = /* @__PURE__ */ new Map([
173
+ ["sfx", false],
174
+ ["music", false]
175
+ ]);
176
+ constructor() {
177
+ this.gestureResumeHandler = () => {
178
+ void this.tryResume();
179
+ };
180
+ }
181
+ /**
182
+ * Returns the Web Audio AudioListener for spatialization (D-2).
183
+ * Triggers lazy ensureContext() on first access.
184
+ * Returns undefined if the context could not be created or is closed.
185
+ */
186
+ get listener() {
187
+ return this.ensureContext().listener;
188
+ }
189
+ setListenerPose(pose) {
190
+ const listener = this.ensureContext().listener;
191
+ listener.positionX.value = pose.positionX;
192
+ listener.positionY.value = pose.positionY;
193
+ listener.positionZ.value = pose.positionZ;
194
+ listener.forwardX.value = pose.forwardX;
195
+ listener.forwardY.value = pose.forwardY;
196
+ listener.forwardZ.value = pose.forwardZ;
197
+ listener.upX.value = pose.upX;
198
+ listener.upY.value = pose.upY;
199
+ listener.upZ.value = pose.upZ;
200
+ }
201
+ // -----------------------------------------------------------------------
202
+ // ensureContext -- lazy AudioContext + bus topology creation
203
+ // -----------------------------------------------------------------------
204
+ ensureContext() {
205
+ if (this.ctx) {
206
+ this.registerGestureListener(this.ctx);
207
+ return this.ctx;
208
+ }
209
+ const ctx = new AudioContext();
210
+ const master = ctx.createGain();
211
+ master.gain.value = 1;
212
+ master.connect(ctx.destination);
213
+ const sfx = ctx.createGain();
214
+ sfx.gain.value = 1;
215
+ sfx.connect(master);
216
+ const music = ctx.createGain();
217
+ music.gain.value = 1;
218
+ music.connect(master);
219
+ this.ctx = ctx;
220
+ this.masterGain = master;
221
+ this.sfxGain = sfx;
222
+ this.musicGain = music;
223
+ this.registerGestureListener(ctx);
224
+ return ctx;
225
+ }
226
+ // -----------------------------------------------------------------------
227
+ // Gesture listener -- D-3 bounded resume retry on user gesture
228
+ // -----------------------------------------------------------------------
229
+ registerGestureListener(ctx) {
230
+ if (ctx.state !== "suspended") {
231
+ return;
232
+ }
233
+ if (this.gestureListening) {
234
+ return;
235
+ }
236
+ this.gestureListening = true;
237
+ for (const event of GESTURE_EVENTS) {
238
+ document.addEventListener(event, this.gestureResumeHandler, { once: true });
239
+ }
240
+ }
241
+ removeGestureListener() {
242
+ if (!this.gestureListening) {
243
+ return;
244
+ }
245
+ this.gestureListening = false;
246
+ for (const event of GESTURE_EVENTS) {
247
+ document.removeEventListener(event, this.gestureResumeHandler);
248
+ }
249
+ }
250
+ async tryResume() {
251
+ const ctx = this.ctx;
252
+ if (!ctx || this.closed || ctx.state !== "suspended") return;
253
+ if (this.resumeInFlight !== void 0) return this.resumeInFlight;
254
+ const attempt = (async () => {
255
+ try {
256
+ await ctx.resume();
257
+ } catch {
258
+ } finally {
259
+ if (!this.closed && this.ctx === ctx) {
260
+ if (ctx.state === "running") {
261
+ this.lastError = null;
262
+ this.removeGestureListener();
263
+ } else if (ctx.state === "suspended") {
264
+ this.recordResumeFailure();
265
+ this.rearmGestureListener(ctx);
266
+ } else {
267
+ this.removeGestureListener();
268
+ }
269
+ }
270
+ this.resumeInFlight = void 0;
271
+ }
272
+ })();
273
+ this.resumeInFlight = attempt;
274
+ return attempt;
275
+ }
276
+ recordResumeFailure() {
277
+ this.lastError = new AudioError({
278
+ code: "context-suspended",
279
+ expected: "AudioContext.resume() to make the existing context running",
280
+ hint: AUDIO_ERROR_HINTS["context-suspended"],
281
+ detail: { code: "context-suspended" }
282
+ });
283
+ }
284
+ recordDecodeFailure(sourceKey, cause) {
285
+ this.lastError = new AudioError({
286
+ code: "decode-failed",
287
+ expected: `browser-decodable audio bytes for sourceKey ${sourceKey}`,
288
+ hint: AUDIO_ERROR_HINTS["decode-failed"],
289
+ detail: {
290
+ code: "decode-failed",
291
+ reason: cause instanceof Error ? cause.message : String(cause)
292
+ }
293
+ });
294
+ }
295
+ rearmGestureListener(ctx) {
296
+ this.removeGestureListener();
297
+ this.registerGestureListener(ctx);
298
+ }
299
+ // -----------------------------------------------------------------------
300
+ // AudioBackend implementation
301
+ // -----------------------------------------------------------------------
302
+ decode(bytes) {
303
+ return this.ensureContext().decodeAudioData(bytes.slice().buffer);
304
+ }
305
+ play(entityId, clip, opts) {
306
+ if ("kind" in clip) {
307
+ void this.decode(clip.bytes).then(
308
+ (buffer) => {
309
+ if (this.lastError?.code === "decode-failed") {
310
+ this.lastError = null;
311
+ }
312
+ this.play(entityId, buffer, opts);
313
+ },
314
+ (cause) => this.recordDecodeFailure(clip.sourceKey, cause)
315
+ );
316
+ return;
317
+ }
318
+ const clipBuffer = clip;
319
+ if (this.sources.has(entityId)) {
320
+ this.stop(entityId);
321
+ }
322
+ const ctx = this.ensureContext();
323
+ const sourceGain = ctx.createGain();
324
+ sourceGain.gain.value = opts.volume;
325
+ let panner;
326
+ if (opts.spatialBlend > 0) {
327
+ panner = ctx.createPanner();
328
+ panner.panningModel = "equalpower";
329
+ }
330
+ const busGain = this.busGainFor(opts.bus);
331
+ if (!busGain) return;
332
+ if (panner) {
333
+ sourceGain.connect(panner);
334
+ panner.connect(busGain);
335
+ } else {
336
+ sourceGain.connect(busGain);
337
+ }
338
+ const node = ctx.createBufferSource();
339
+ node.buffer = clipBuffer;
340
+ node.loop = opts.loop;
341
+ node.connect(sourceGain);
342
+ node.start();
343
+ this.sources.set(entityId, { node, sourceGain, panner, bus: opts.bus });
344
+ if (!opts.loop) {
345
+ node.onended = () => {
346
+ const current = this.sources.get(entityId);
347
+ if (current?.node === node) {
348
+ this.stop(entityId);
349
+ }
350
+ };
351
+ }
352
+ }
353
+ stop(entityId) {
354
+ const source = this.sources.get(entityId);
355
+ if (!source) return;
356
+ try {
357
+ source.node.stop();
358
+ } catch {
359
+ }
360
+ source.node.disconnect();
361
+ source.sourceGain.disconnect();
362
+ source.panner?.disconnect();
363
+ this.sources.delete(entityId);
364
+ }
365
+ setVolume(entityId, volume) {
366
+ const source = this.sources.get(entityId);
367
+ if (!source) return;
368
+ this.scheduleGainTransition(source.sourceGain, volume);
369
+ }
370
+ setBusVolume(busName, volume) {
371
+ const gain = this.busGainFor(busName);
372
+ if (!gain) return;
373
+ if (!this.scheduleGainTransition(gain, volume)) return;
374
+ this.busVolumes.set(busName, volume);
375
+ if (this.busMuted.get(busName)) {
376
+ this.busMuted.set(busName, false);
377
+ }
378
+ }
379
+ setBusMute(busName, muted) {
380
+ const gain = this.busGainFor(busName);
381
+ if (!gain) return;
382
+ const target = muted ? 0 : this.busVolumes.get(busName) ?? 1;
383
+ if (!this.scheduleGainTransition(gain, target)) return;
384
+ this.busMuted.set(busName, muted);
385
+ }
386
+ getState() {
387
+ if (this.closed) {
388
+ return { contextState: "closed", activeSourceCount: 0, lastError: null };
389
+ }
390
+ const contextState = this.ctx?.state === "closed" ? "closed" : this.ctx?.state === "running" ? "running" : "suspended";
391
+ return {
392
+ contextState,
393
+ activeSourceCount: this.sources.size,
394
+ lastError: this.lastError
395
+ };
396
+ }
397
+ getActiveSourceCount() {
398
+ return this.sources.size;
399
+ }
400
+ destroy() {
401
+ if (this.closed) return;
402
+ this.closed = true;
403
+ for (const entityId of this.sources.keys()) {
404
+ this.stop(entityId);
405
+ }
406
+ if (this.sfxGain) {
407
+ this.sfxGain.disconnect();
408
+ this.sfxGain = void 0;
409
+ }
410
+ if (this.musicGain) {
411
+ this.musicGain.disconnect();
412
+ this.musicGain = void 0;
413
+ }
414
+ if (this.masterGain) {
415
+ this.masterGain.disconnect();
416
+ this.masterGain = void 0;
417
+ }
418
+ this.removeGestureListener();
419
+ if (this.ctx) {
420
+ void this.ctx.close();
421
+ this.ctx = void 0;
422
+ }
423
+ }
424
+ // -----------------------------------------------------------------------
425
+ // Private helpers
426
+ // -----------------------------------------------------------------------
427
+ scheduleGainTransition(gain, target) {
428
+ if (!this.ctx || !Number.isFinite(target) || target < 0) return false;
429
+ const now = this.ctx.currentTime;
430
+ const param = gain.gain;
431
+ param.cancelScheduledValues(now);
432
+ param.setValueAtTime(param.value, now);
433
+ param.linearRampToValueAtTime(target, now + GAIN_TRANSITION_SECONDS);
434
+ return true;
435
+ }
436
+ busGainFor(busName) {
437
+ switch (busName) {
438
+ case "sfx":
439
+ return this.sfxGain;
440
+ case "music":
441
+ return this.musicGain;
442
+ }
443
+ }
444
+ };
445
+
446
+ // src/host-audio-consumer.ts
447
+ function decodeError(sourceKey, cause) {
448
+ return new AudioError$1({
449
+ code: "decode-failed",
450
+ expected: `browser-decodable audio bytes for sourceKey ${sourceKey}`,
451
+ hint: "verify the audio media type and source bytes",
452
+ detail: {
453
+ code: "decode-failed",
454
+ reason: cause instanceof Error ? cause.message : String(cause)
455
+ }
456
+ });
457
+ }
458
+ function sameBytes(left, right) {
459
+ if (left.byteLength !== right.byteLength) return false;
460
+ for (let index = 0; index < left.byteLength; index += 1) {
461
+ if (left[index] !== right[index]) return false;
462
+ }
463
+ return true;
464
+ }
465
+ function createHostAudioConsumer(engine = new WebAudioEngine()) {
466
+ const sources = /* @__PURE__ */ new Map();
467
+ const sourceBytes = /* @__PURE__ */ new Map();
468
+ const activeSources = /* @__PURE__ */ new Map();
469
+ const entityEpoch = /* @__PURE__ */ new Map();
470
+ const bus = {
471
+ sfx: { volume: 1, muted: false },
472
+ music: { volume: 1, muted: false }
473
+ };
474
+ const cleanup = [];
475
+ let lastError = null;
476
+ let disposed = false;
477
+ const nextEpoch = (entityId) => {
478
+ const epoch = (entityEpoch.get(entityId) ?? 0) + 1;
479
+ entityEpoch.set(entityId, epoch);
480
+ return epoch;
481
+ };
482
+ const consumer = {
483
+ engine,
484
+ consume(intent) {
485
+ if (disposed && intent.kind !== "destroy") return;
486
+ if (intent.kind === "play") {
487
+ const epoch = nextEpoch(intent.entityId);
488
+ if (intent.bytes !== void 0) {
489
+ const incomingBytes = intent.bytes.slice();
490
+ const publishedBytes = sourceBytes.get(intent.sourceKey);
491
+ if (publishedBytes === void 0 || !sameBytes(publishedBytes, incomingBytes)) {
492
+ sourceBytes.set(intent.sourceKey, incomingBytes);
493
+ sources.delete(intent.sourceKey);
494
+ }
495
+ }
496
+ const bytes = sourceBytes.get(intent.sourceKey);
497
+ activeSources.set(intent.entityId, {
498
+ entityId: intent.entityId,
499
+ sourceKey: intent.sourceKey,
500
+ ...bytes === void 0 ? {} : { bytes: bytes.slice() },
501
+ options: intent.options
502
+ });
503
+ let decoded = sources.get(intent.sourceKey);
504
+ if (decoded === void 0 && bytes !== void 0) {
505
+ const entry = {
506
+ promise: engine.decode(bytes)
507
+ };
508
+ decoded = entry;
509
+ sources.set(intent.sourceKey, entry);
510
+ void entry.promise.then(
511
+ () => {
512
+ if (sources.get(intent.sourceKey) === entry && lastError?.code === "decode-failed") {
513
+ lastError = null;
514
+ }
515
+ },
516
+ (cause) => {
517
+ if (sources.get(intent.sourceKey) !== entry) return;
518
+ sources.delete(intent.sourceKey);
519
+ lastError = decodeError(intent.sourceKey, cause);
520
+ }
521
+ );
522
+ }
523
+ if (decoded === void 0) {
524
+ lastError = decodeError(intent.sourceKey, new Error("sourceKey was not published"));
525
+ return;
526
+ }
527
+ const currentDecode = decoded;
528
+ void currentDecode.promise.then((buffer) => {
529
+ if (!disposed && sources.get(intent.sourceKey) === currentDecode && entityEpoch.get(intent.entityId) === epoch) {
530
+ engine.play(intent.entityId, buffer, intent.options);
531
+ }
532
+ }).catch(() => {
533
+ });
534
+ } else if (intent.kind === "stop") {
535
+ nextEpoch(intent.entityId);
536
+ if (activeSources.delete(intent.entityId)) {
537
+ cleanup.push(intent.entityId);
538
+ engine.stop(intent.entityId);
539
+ }
540
+ } else if (intent.kind === "set-volume") {
541
+ engine.setVolume(intent.entityId, intent.volume);
542
+ } else if (intent.kind === "set-bus-volume") {
543
+ bus[intent.bus].volume = intent.volume;
544
+ bus[intent.bus].muted = false;
545
+ engine.setBusVolume(intent.bus, intent.volume);
546
+ } else if (intent.kind === "set-bus-mute") {
547
+ bus[intent.bus].muted = intent.muted;
548
+ engine.setBusMute(intent.bus, intent.muted);
549
+ } else if (intent.kind === "set-listener-pose") {
550
+ engine.setListenerPose(intent.pose);
551
+ } else {
552
+ consumer.dispose();
553
+ }
554
+ },
555
+ state() {
556
+ return { ...engine.getState(), lastError };
557
+ },
558
+ dispose() {
559
+ if (disposed) return;
560
+ disposed = true;
561
+ activeSources.clear();
562
+ entityEpoch.clear();
563
+ sources.clear();
564
+ sourceBytes.clear();
565
+ cleanup.length = 0;
566
+ engine.destroy();
567
+ }
568
+ };
569
+ return consumer;
570
+ }
571
+ function createWebAudioBackend() {
572
+ const consumer = createHostAudioConsumer();
573
+ const backend = createAudioIntentBackend({
574
+ emit: (intent) => consumer.consume(intent),
575
+ state: () => consumer.state()
576
+ });
577
+ return backend;
578
+ }
579
+
580
+ // src/plugin.ts
581
+ function webAudioPlugin() {
582
+ return {
583
+ name: "web-audio",
584
+ provide: "audio",
585
+ apply(ctx) {
586
+ const backend = createWebAudioBackend();
587
+ ctx.effect(() => () => backend.destroy(), "audio/destroy-webaudio");
588
+ ctx.provide("audio", backend);
589
+ }
590
+ };
591
+ }
592
+
593
+ export { WebAudioEngine, audioClipContribution, audioListenerSyncSystem, audioLoader, createHostAudioConsumer, createWebAudioBackend, syncListenerFromWorldMatrix, webAudioPlugin };
594
+ //# sourceMappingURL=index.mjs.map
595
+ //# sourceMappingURL=index.mjs.map