@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
@@ -0,0 +1,149 @@
1
+ // audio-importer.ts - the build-time audioImporter (feat-20260603-asset-import-loader-injection M3 / w25).
2
+ //
3
+ // The `{ key: 'audio', import }` Importer the @forgeax/engine-import runner
4
+ // dispatches a `*.meta.json` with `importer: 'audio'` to.
5
+ //
6
+ // SEMANTIC HETEROGENEITY (plan-strategy D-3 / requirements AC-18 callout):
7
+ // audio is NOT like image / gltf. There is no JS decoder to strip out of the
8
+ // runtime bundle -- the runtime decodes audio with the native Web Audio
9
+ // `AudioContext.decodeAudioData` (clip-loader.ts), and the browser owns codec
10
+ // selection (wav / mp3 / ogg / flac). So this importer is NOT a bundle
11
+ // optimization: AC-16's bundle-delta evidence is image-only and does NOT
12
+ // apply to audio. The audioImporter's value is the UNIFIED IMPORT ENTRY -- it
13
+ // lets an audio source flow through the same declare -> import -> load
14
+ // pipeline (meta.importer='audio') as every other asset family, so an AI user
15
+ // reads one consistent import surface instead of an audio special case.
16
+ //
17
+ // The importer body MUST NOT call `AudioContext` / `decodeAudioData`: decode is
18
+ // the runtime loader's job (clip-loader.ts, which fetches the source URL and
19
+ // decodes in the browser). A decoded `AudioClipAsset` carries an `AudioBuffer`,
20
+ // a runtime-only Web Audio object that cannot be produced at build time. The
21
+ // importer therefore emits a thin pass-through descriptor (`kind: 'audio'` +
22
+ // the source path) under the meta-declared GUID; the runtime resolves it to a
23
+ // decoded clip at load time.
24
+ //
25
+ // GUID import-stable iron law: every produced GUID comes from `ctx.subAssets[]`.
26
+
27
+ import {
28
+ IMPORT_ERROR_HINTS,
29
+ type ImportContext,
30
+ ImportError,
31
+ type ImportedAsset,
32
+ type Importer,
33
+ type ImportResult,
34
+ } from '@forgeax/engine-types';
35
+
36
+ /** Audio output identity is semantic and independent of source path/index. */
37
+ export function sourceKeyForAudioOutput(kind = 'audio'): string | undefined {
38
+ const normalizedKind = kind.trim();
39
+ return normalizedKind.length === 0 ? undefined : `audio:${normalizedKind}`;
40
+ }
41
+
42
+ function audioMediaType(source: string): string {
43
+ const lower = source.toLowerCase();
44
+ if (lower.endsWith('.wav')) return 'audio/wav';
45
+ if (lower.endsWith('.mp3')) return 'audio/mpeg';
46
+ if (lower.endsWith('.ogg')) return 'audio/ogg';
47
+ if (lower.endsWith('.flac')) return 'audio/flac';
48
+ return 'application/octet-stream';
49
+ }
50
+
51
+ function validateAudioOutputTopology(ctx: ImportContext): ImportError | undefined {
52
+ if (ctx.subAssets.length === 1 && ctx.subAssets[0]?.kind === 'audio') return undefined;
53
+
54
+ const actual =
55
+ ctx.subAssets.length === 0
56
+ ? 'subAssets[] is empty'
57
+ : ctx.subAssets.map((sub, index) => `subAssets[${index}]=${sub.kind}:${sub.guid}`).join(', ');
58
+
59
+ return new ImportError({
60
+ code: 'source-validation-failed',
61
+ expected: 'exactly one subAssets[] entry with kind "audio"',
62
+ actual,
63
+ hint: IMPORT_ERROR_HINTS['source-validation-failed'],
64
+ detail: {
65
+ diagnostics: [
66
+ {
67
+ code: 'audio-subasset-topology',
68
+ severity: 'error',
69
+ sourcePath: `${ctx.source}#subAssets`,
70
+ sourceRange: { start: 0, end: 0, line: 1, column: 1 },
71
+ rule: 'audio-required-single-output',
72
+ expected: 'exactly one subAssets[] entry with kind "audio"',
73
+ actual,
74
+ hint: 'declare exactly one audio sub-asset and remove foreign or duplicate entries',
75
+ },
76
+ ],
77
+ },
78
+ });
79
+ }
80
+
81
+ async function importAudio(ctx: ImportContext): Promise<ImportResult> {
82
+ const topologyError = validateAudioOutputTopology(ctx);
83
+ if (topologyError !== undefined) return { ok: false, error: topologyError };
84
+
85
+ // Probe the source is readable so a missing file fails the build (the runner
86
+ // already probes, but this keeps the importer self-validating, P3). No decode
87
+ // happens here -- decodeAudioData is the runtime loader's job.
88
+ const read = await ctx.readSource();
89
+ if (!read.ok) {
90
+ return {
91
+ ok: false,
92
+ error: new ImportError({
93
+ code: 'source-read-failed',
94
+ expected: `readable source file at meta.source "${ctx.source}"`,
95
+ hint: IMPORT_ERROR_HINTS['source-read-failed'],
96
+ detail: {
97
+ source: ctx.source,
98
+ reason: read.error instanceof Error ? read.error.message : String(read.error),
99
+ },
100
+ }),
101
+ };
102
+ }
103
+
104
+ const out: ImportedAsset[] = [];
105
+ for (const sub of ctx.subAssets) {
106
+ if (sub.kind !== 'audio') continue;
107
+ // Thin pass-through descriptor: the runtime audio loader fetches the source
108
+ // URL and decodes via the browser, so the build-time payload carries only
109
+ // the source reference (no AudioBuffer; cast through the Asset slot like the
110
+ // other importers' build-time POD-vs-runtime-handle bridges).
111
+ const payload = {
112
+ kind: 'audio',
113
+ mediaType: audioMediaType(ctx.source),
114
+ source: ctx.source,
115
+ bytes: read.value,
116
+ } as unknown as ImportedAsset['payload'];
117
+ out.push({
118
+ guid: sub.guid,
119
+ kind: 'audio',
120
+ payload,
121
+ refs: [],
122
+ artifacts: {
123
+ source: {
124
+ mediaType: audioMediaType(ctx.source),
125
+ assetCodec: { name: 'browser-audio' },
126
+ bytes: read.value,
127
+ },
128
+ },
129
+ });
130
+ }
131
+ return { ok: true, value: { assets: out, sourceDependencies: [ctx.source] } };
132
+ }
133
+
134
+ /**
135
+ * The audio {@link Importer}. Register it into an `ImporterRegistry` so the
136
+ * import runner dispatches `meta.importer === 'audio'` sidecars here.
137
+ *
138
+ * @example
139
+ * ```ts
140
+ * import { ImporterRegistry } from '@forgeax/engine-import';
141
+ * import { audioImporter } from '@forgeax/engine-audio-webaudio/audio-importer';
142
+ * const importers = new ImporterRegistry();
143
+ * importers.register(audioImporter);
144
+ * ```
145
+ */
146
+ export const audioImporter: Importer = {
147
+ key: 'audio',
148
+ import: importAudio,
149
+ };
@@ -0,0 +1,98 @@
1
+ // audio-listener-sync-system.ts -- AudioListener world-matrix sync.
2
+ //
3
+ // Syncs the first AudioListener entity's resolved world transform to the Web
4
+ // Audio listener (position + forward/up orientation).
5
+ //
6
+ // feat-20260601 D-6: the sync consumes the single `Transform.world` mat4 (16
7
+ // column-major floats, written by propagateTransforms) instead of a decomposed
8
+ // GlobalTransform TRS. forward / up / position are extracted via the
9
+ // `@forgeax/engine-math` mat4 helpers (getForward = -col2 normalized; getUp =
10
+ // col1 normalized; getTranslation = col3, NOT normalized) -- direction
11
+ // normalization eliminates non-uniform-scale pollution that a bare-column read
12
+ // would leak. The old quaternion-rotate-vector formula is gone.
13
+ //
14
+ // Decision anchors:
15
+ // - plan-strategy D-6 (rename + world-mat4 reshape + mat4 helper reuse)
16
+ // - requirements AC-08 (AudioListener world-matrix sync; quadrant 3 falsify)
17
+ // - requirements E-3 (multiple AudioListeners -> first only)
18
+ //
19
+ // Architecture note: the audio-webaudio package has no dependency on
20
+ // engine-runtime (where Transform lives). The sync function is a pure helper
21
+ // exported for host assembly. The host (engine-runtime or app layer) queries
22
+ // the first AudioListener entity, reads its `Transform.world` mat4 (a 16-float
23
+ // Float32Array), obtains the AudioContext listener from the AudioBackend, and
24
+ // calls `syncListenerFromWorldMatrix(listener, worldMatrix)`.
25
+ //
26
+ // charter awareness:
27
+ // - P3 explicit failure: no-op contracts, no throw for missing listener
28
+ // - P4 consistent abstraction: reuse the math mat4 extract helpers (no
29
+ // hand-rolled quaternion rotation); pure function exports for unit testing
30
+ // - P5 producer/consumer: sync function is the producer; host is the consumer
31
+
32
+ import { mat4, vec3 } from '@forgeax/engine-math';
33
+
34
+ /**
35
+ * Resolved world transform shape (feat-20260601 D-6): a single column-major
36
+ * mat4 carried as 16 contiguous floats -- the `Transform.world` column array
37
+ * view written by propagateTransforms.
38
+ */
39
+ export interface WorldMatrixData {
40
+ readonly worldMatrix: Float32Array;
41
+ }
42
+
43
+ /**
44
+ * Pure function: write a world mat4's position/orientation to the Web Audio
45
+ * listener's AudioParams.
46
+ *
47
+ * Exported for unit testing (listener-sync.test.ts) and for host assembly (the
48
+ * host reads `Transform.world` + AudioListener from the World and calls this
49
+ * each frame).
50
+ *
51
+ * - position = `mat4.getTranslation(world)` (col3, copied directly, not normalized)
52
+ * - forward = `mat4.getForward(world)` (-col2, normalized -- removes scale)
53
+ * - up = `mat4.getUp(world)` (col1, normalized -- removes scale)
54
+ *
55
+ * @param listener The Web Audio API AudioListener (from AudioContext.listener)
56
+ * @param worldMatrix The resolved world mat4 (16 column-major floats) of the
57
+ * entity carrying AudioListener.
58
+ */
59
+ export function syncListenerFromWorldMatrix(
60
+ listener: AudioListener,
61
+ worldMatrix: Float32Array,
62
+ ): void {
63
+ const m = worldMatrix as unknown as mat4.Mat4Like;
64
+
65
+ const position = mat4.getTranslation(vec3.create(), m);
66
+ listener.positionX.value = position[0] as number;
67
+ listener.positionY.value = position[1] as number;
68
+ listener.positionZ.value = position[2] as number;
69
+
70
+ const forward = mat4.getForward(vec3.create(), m);
71
+ listener.forwardX.value = forward[0] as number;
72
+ listener.forwardY.value = forward[1] as number;
73
+ listener.forwardZ.value = forward[2] as number;
74
+
75
+ const up = mat4.getUp(vec3.create(), m);
76
+ listener.upX.value = up[0] as number;
77
+ listener.upY.value = up[1] as number;
78
+ listener.upZ.value = up[2] as number;
79
+ }
80
+
81
+ /**
82
+ * Run the audio listener sync system for a host-owned audio context.
83
+ *
84
+ * The host is responsible for:
85
+ * 1. Querying the World for entities with AudioListener
86
+ * 2. Taking the first AudioListener entity (E-3)
87
+ * 3. Reading its `Transform.world` mat4 (16-float Float32Array)
88
+ * 4. Calling this function with the AudioContext's listener
89
+ *
90
+ * This function is a convenience wrapper that calls
91
+ * `syncListenerFromWorldMatrix(ctx.listener, worldMatrix)`.
92
+ *
93
+ * @param ctx The Web Audio AudioContext (whose .listener receives position/orientation)
94
+ * @param worldMatrix The `Transform.world` mat4 from the AudioListener entity
95
+ */
96
+ export function audioListenerSyncSystem(ctx: AudioContext, worldMatrix: Float32Array): void {
97
+ syncListenerFromWorldMatrix(ctx.listener, worldMatrix);
98
+ }
@@ -0,0 +1,55 @@
1
+ import { AssetError, type Loader, type LoaderAsyncResult } from '@forgeax/engine-types';
2
+ import { decodeAudioClipBytes } from './clip-loader';
3
+
4
+ /** Loads an audio catalog row through the browser's native decoder. */
5
+ export const audioLoader: Loader = {
6
+ kind: 'audio',
7
+ loadPack(input): Promise<LoaderAsyncResult> {
8
+ if (input.kind !== 'audio') {
9
+ return Promise.resolve({
10
+ ok: false,
11
+ error: new AssetError({
12
+ code: 'asset-parse-failed',
13
+ expected: "Pack v2 loader input with kind 'audio'",
14
+ hint: 'pass the asset-local audio envelope to the audio loader',
15
+ detail: { sourcePath: input.guid },
16
+ }),
17
+ });
18
+ }
19
+ const source = input.artifacts.source;
20
+ const payloadMediaType =
21
+ typeof input.payload.mediaType === 'string' ? input.payload.mediaType : undefined;
22
+ if (
23
+ source === undefined ||
24
+ !source.descriptor.mediaType.startsWith('audio/') ||
25
+ payloadMediaType !== source.descriptor.mediaType
26
+ ) {
27
+ return Promise.resolve({
28
+ ok: false,
29
+ error: {
30
+ code: 'asset-artifact-media-unsupported',
31
+ expected: 'an asset-local source artifact with an audio/* mediaType',
32
+ hint: 'declare the audio source artifact with a supported mediaType and re-cook',
33
+ detail: {
34
+ guid: input.guid,
35
+ artifactKey: 'source',
36
+ observed: payloadMediaType ?? source?.descriptor.mediaType ?? 'missing',
37
+ expected: 'payload.mediaType matching the audio/* source artifact',
38
+ },
39
+ },
40
+ });
41
+ }
42
+ return decodeAudioClipBytes(input.guid, source.bytes, payloadMediaType as `audio/${string}`);
43
+ },
44
+ async load(): Promise<LoaderAsyncResult> {
45
+ return {
46
+ ok: false,
47
+ error: new AssetError({
48
+ code: 'asset-parse-failed',
49
+ expected: 'Pack v2 audio input with an asset-local source artifact',
50
+ hint: 're-cook the audio source into the Pack v2 asset envelope',
51
+ detail: { sourcePath: 'audio-loader-input' },
52
+ }),
53
+ };
54
+ },
55
+ };
@@ -0,0 +1,111 @@
1
+ // clip-loader.ts -- M3 (w27) AudioClipAsset register/load path
2
+ //
3
+ // Load AudioClipAsset from pack-index catalog via fetch + decodeAudioData.
4
+ //
5
+ // Decision anchors:
6
+ // - plan-strategy D-6 (load path: fetch ArrayBuffer -> decodeAudioData -> register)
7
+ // - requirements AC-03 (AudioClipAsset via typed AssetRegistry.load)
8
+ // - requirements constraint 5 (format decided by browser decodeAudioData)
9
+ // - requirements E-2 (decodeAudioData failure returns Err with code: 'decode-failed')
10
+ // - requirements E-9 (nonexistent GUID returns Err with code 'asset-not-found')
11
+ // - research Finding 'decodeAudioData error semantics'
12
+ //
13
+ // charter awareness:
14
+ // - P3 explicit failure: returns Result<AudioClipAsset, AudioError> with structured errors
15
+ // - P4 consistent abstraction: parallel to image/gltf loader patterns
16
+
17
+ import { AudioError } from '@forgeax/engine-audio';
18
+ import type {
19
+ AssetDecoderContribution,
20
+ AssetKind,
21
+ AudioClipAsset,
22
+ Result,
23
+ } from '@forgeax/engine-types';
24
+ import { err, ok } from '@forgeax/engine-types';
25
+
26
+ export async function decodeAudioClipBytes(
27
+ guid: string,
28
+ bytes: Uint8Array,
29
+ mediaType: `audio/${string}`,
30
+ ): Promise<Result<AudioClipAsset, AudioError>> {
31
+ if (mediaType.length <= 'audio/'.length || bytes.byteLength === 0) {
32
+ return err(
33
+ new AudioError({
34
+ code: 'decode-failed',
35
+ expected: `non-empty audio mediaType and source bytes for GUID ${guid}`,
36
+ hint: 'verify the audio artifact mediaType and recook the source bytes',
37
+ detail: {
38
+ code: 'decode-failed' as const,
39
+ reason: 'audio mediaType or source bytes are empty',
40
+ },
41
+ }),
42
+ );
43
+ }
44
+ try {
45
+ return ok({ kind: 'audio', sourceKey: guid, mediaType, bytes: bytes.slice() });
46
+ } catch (e) {
47
+ return err(
48
+ new AudioError({
49
+ code: 'decode-failed',
50
+ expected: `decodable audio artifact bytes for GUID ${guid}`,
51
+ hint: 'verify the audio artifact mediaType and browser-supported codec',
52
+ detail: {
53
+ code: 'decode-failed' as const,
54
+ reason: e instanceof Error ? e.message : 'audio artifact decode failed',
55
+ },
56
+ }),
57
+ );
58
+ }
59
+ }
60
+
61
+ export const audioClipContribution: AssetDecoderContribution<AudioClipAsset, 'audio'> = {
62
+ kind: { kind: 'audio' } as AssetKind<AudioClipAsset, 'audio'>,
63
+ consumer: 'WebAudioEngine',
64
+ decoder: {
65
+ async decode({ envelope, artifacts }) {
66
+ const source = Object.values(envelope.artifacts).find((descriptor) =>
67
+ descriptor.path.endsWith('.audio'),
68
+ );
69
+ if (source === undefined) {
70
+ return err({
71
+ code: 'asset-package-invalid',
72
+ expected: 'an asset-local audio source artifact',
73
+ hint: 'recook the audio asset with its source artifact',
74
+ detail: { guid: envelope.guid, reason: 'source artifact is missing' },
75
+ });
76
+ }
77
+ const bytes = await artifacts.read(source);
78
+ if (!bytes.ok) {
79
+ return err({
80
+ code: 'asset-package-invalid',
81
+ expected: 'a readable audio source artifact',
82
+ hint: 'repair the Pack artifact and retry the audio decoder',
83
+ detail: { guid: envelope.guid, reason: bytes.error.code },
84
+ });
85
+ }
86
+ const mediaType = source.mediaType;
87
+ if (!mediaType.startsWith('audio/')) {
88
+ return err({
89
+ code: 'asset-package-invalid',
90
+ expected: 'an audio/* source artifact',
91
+ hint: 'publish the audio artifact with its actual media type',
92
+ detail: { guid: envelope.guid, reason: `unsupported media type ${mediaType}` },
93
+ });
94
+ }
95
+ if (bytes.value.byteLength === 0) {
96
+ return err({
97
+ code: 'asset-package-invalid',
98
+ expected: 'non-empty audio source bytes',
99
+ hint: 'recook the audio source and retry the decoder',
100
+ detail: { guid: envelope.guid, reason: 'empty audio artifact' },
101
+ });
102
+ }
103
+ return ok({
104
+ kind: 'audio',
105
+ sourceKey: envelope.guid,
106
+ mediaType: mediaType as `audio/${string}`,
107
+ bytes: bytes.value.slice(),
108
+ });
109
+ },
110
+ },
111
+ };
@@ -0,0 +1,168 @@
1
+ import {
2
+ type AudioBackend,
3
+ type AudioIntent,
4
+ type AudioPlayOptions,
5
+ type AudioState,
6
+ createAudioIntentBackend,
7
+ } from '@forgeax/engine-audio';
8
+ import { AudioError } from '@forgeax/engine-types';
9
+ import { WebAudioEngine } from './web-audio-engine';
10
+
11
+ interface ActiveSource {
12
+ readonly entityId: number;
13
+ readonly sourceKey: string;
14
+ readonly bytes?: Uint8Array;
15
+ readonly options: AudioPlayOptions;
16
+ }
17
+
18
+ export interface HostAudioConsumer {
19
+ consume(intent: AudioIntent): void;
20
+ state(): AudioState;
21
+ dispose(): void;
22
+ readonly engine: WebAudioEngine;
23
+ }
24
+
25
+ function decodeError(sourceKey: string, cause: unknown): AudioError {
26
+ return new AudioError({
27
+ code: 'decode-failed',
28
+ expected: `browser-decodable audio bytes for sourceKey ${sourceKey}`,
29
+ hint: 'verify the audio media type and source bytes',
30
+ detail: {
31
+ code: 'decode-failed',
32
+ reason: cause instanceof Error ? cause.message : String(cause),
33
+ },
34
+ });
35
+ }
36
+
37
+ function sameBytes(left: Uint8Array, right: Uint8Array): boolean {
38
+ if (left.byteLength !== right.byteLength) return false;
39
+ for (let index = 0; index < left.byteLength; index += 1) {
40
+ if (left[index] !== right[index]) return false;
41
+ }
42
+ return true;
43
+ }
44
+
45
+ interface DecodeEntry {
46
+ readonly promise: Promise<AudioBuffer>;
47
+ }
48
+
49
+ export function createHostAudioConsumer(engine = new WebAudioEngine()): HostAudioConsumer {
50
+ const sources = new Map<string, DecodeEntry>();
51
+ const sourceBytes = new Map<string, Uint8Array>();
52
+ const activeSources = new Map<number, ActiveSource>();
53
+ const entityEpoch = new Map<number, number>();
54
+ const bus = {
55
+ sfx: { volume: 1, muted: false },
56
+ music: { volume: 1, muted: false },
57
+ };
58
+ const cleanup: number[] = [];
59
+ let lastError: AudioError | null = null;
60
+ let disposed = false;
61
+ const nextEpoch = (entityId: number): number => {
62
+ const epoch = (entityEpoch.get(entityId) ?? 0) + 1;
63
+ entityEpoch.set(entityId, epoch);
64
+ return epoch;
65
+ };
66
+ const consumer: HostAudioConsumer = {
67
+ engine,
68
+ consume(intent): void {
69
+ if (disposed && intent.kind !== 'destroy') return;
70
+ if (intent.kind === 'play') {
71
+ const epoch = nextEpoch(intent.entityId);
72
+ if (intent.bytes !== undefined) {
73
+ const incomingBytes = intent.bytes.slice();
74
+ const publishedBytes = sourceBytes.get(intent.sourceKey);
75
+ if (publishedBytes === undefined || !sameBytes(publishedBytes, incomingBytes)) {
76
+ sourceBytes.set(intent.sourceKey, incomingBytes);
77
+ sources.delete(intent.sourceKey);
78
+ }
79
+ }
80
+ const bytes = sourceBytes.get(intent.sourceKey);
81
+ activeSources.set(intent.entityId, {
82
+ entityId: intent.entityId,
83
+ sourceKey: intent.sourceKey,
84
+ ...(bytes === undefined ? {} : { bytes: bytes.slice() }),
85
+ options: intent.options,
86
+ });
87
+ let decoded = sources.get(intent.sourceKey);
88
+ if (decoded === undefined && bytes !== undefined) {
89
+ const entry: DecodeEntry = {
90
+ promise: engine.decode(bytes),
91
+ };
92
+ decoded = entry;
93
+ sources.set(intent.sourceKey, entry);
94
+ void entry.promise.then(
95
+ () => {
96
+ if (sources.get(intent.sourceKey) === entry && lastError?.code === 'decode-failed') {
97
+ lastError = null;
98
+ }
99
+ },
100
+ (cause) => {
101
+ if (sources.get(intent.sourceKey) !== entry) return;
102
+ sources.delete(intent.sourceKey);
103
+ lastError = decodeError(intent.sourceKey, cause);
104
+ },
105
+ );
106
+ }
107
+ if (decoded === undefined) {
108
+ lastError = decodeError(intent.sourceKey, new Error('sourceKey was not published'));
109
+ return;
110
+ }
111
+ const currentDecode = decoded;
112
+ void currentDecode.promise
113
+ .then((buffer) => {
114
+ if (
115
+ !disposed &&
116
+ sources.get(intent.sourceKey) === currentDecode &&
117
+ entityEpoch.get(intent.entityId) === epoch
118
+ ) {
119
+ engine.play(intent.entityId, buffer, intent.options);
120
+ }
121
+ })
122
+ .catch(() => {});
123
+ } else if (intent.kind === 'stop') {
124
+ nextEpoch(intent.entityId);
125
+ if (activeSources.delete(intent.entityId)) {
126
+ cleanup.push(intent.entityId);
127
+ engine.stop(intent.entityId);
128
+ }
129
+ } else if (intent.kind === 'set-volume') {
130
+ engine.setVolume(intent.entityId, intent.volume);
131
+ } else if (intent.kind === 'set-bus-volume') {
132
+ bus[intent.bus].volume = intent.volume;
133
+ bus[intent.bus].muted = false;
134
+ engine.setBusVolume(intent.bus, intent.volume);
135
+ } else if (intent.kind === 'set-bus-mute') {
136
+ bus[intent.bus].muted = intent.muted;
137
+ engine.setBusMute(intent.bus, intent.muted);
138
+ } else if (intent.kind === 'set-listener-pose') {
139
+ engine.setListenerPose(intent.pose);
140
+ } else {
141
+ consumer.dispose();
142
+ }
143
+ },
144
+ state(): AudioState {
145
+ return { ...engine.getState(), lastError };
146
+ },
147
+ dispose(): void {
148
+ if (disposed) return;
149
+ disposed = true;
150
+ activeSources.clear();
151
+ entityEpoch.clear();
152
+ sources.clear();
153
+ sourceBytes.clear();
154
+ cleanup.length = 0;
155
+ engine.destroy();
156
+ },
157
+ };
158
+ return consumer;
159
+ }
160
+
161
+ export function createWebAudioBackend(): AudioBackend {
162
+ const consumer = createHostAudioConsumer();
163
+ const backend = createAudioIntentBackend({
164
+ emit: (intent) => consumer.consume(intent),
165
+ state: () => consumer.state(),
166
+ });
167
+ return backend;
168
+ }
package/src/index.ts ADDED
@@ -0,0 +1,32 @@
1
+ // @forgeax/engine-audio-webaudio -- public barrel (feat-20260527-audio-system M2 / w17+w19)
2
+ //
3
+ // Single-entry surface: AI users import `@forgeax/engine-audio-webaudio` for
4
+ // the Web Audio API backend (charter P1 progressive disclosure).
5
+ //
6
+ // Exports:
7
+ // - createWebAudioBackend() factory (w17)
8
+ // - WebAudioEngine class (w16)
9
+ //
10
+ // Placeholder exports (M3 impl):
11
+ // - audioTickSystem (M3)
12
+ // - audioListenerSyncSystem (M3)
13
+ //
14
+ // Re-exports from @forgeax/engine-audio:
15
+ // - AudioBackend, AUDIO_ENGINE_RESOURCE_KEY
16
+
17
+ // Re-exports from engine-audio for convenience
18
+ export { AUDIO_ENGINE_RESOURCE_KEY, type AudioBackend } from '@forgeax/engine-audio';
19
+ // audio listener sync system (Transform.world mat4 -> Web Audio listener)
20
+ export {
21
+ audioListenerSyncSystem,
22
+ syncListenerFromWorldMatrix,
23
+ } from './audio-listener-sync-system';
24
+ export { audioLoader } from './audio-loader';
25
+ export { audioClipContribution } from './clip-loader';
26
+ export {
27
+ createHostAudioConsumer,
28
+ createWebAudioBackend,
29
+ type HostAudioConsumer,
30
+ } from './host-audio-consumer';
31
+ export { webAudioPlugin } from './plugin';
32
+ export { WebAudioEngine } from './web-audio-engine';
package/src/plugin.ts ADDED
@@ -0,0 +1,15 @@
1
+ import type { Plugin } from '@forgeax/engine-plugin';
2
+
3
+ import { createWebAudioBackend } from './host-audio-consumer';
4
+
5
+ export function webAudioPlugin(): Plugin {
6
+ return {
7
+ name: 'web-audio',
8
+ provide: 'audio',
9
+ apply(ctx) {
10
+ const backend = createWebAudioBackend();
11
+ ctx.effect(() => () => backend.destroy(), 'audio/destroy-webaudio');
12
+ ctx.provide('audio', backend);
13
+ },
14
+ };
15
+ }