@forgeax/engine-audio-webaudio 0.0.0-dev.8d955ade1c79
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/LICENSE +202 -0
- package/README.md +135 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/__tests__/audio-bus-name-owner.test-d.d.ts +2 -0
- package/dist/__tests__/audio-bus-name-owner.test-d.d.ts.map +1 -0
- package/dist/__tests__/audio-loader-contract.test.d.ts +2 -0
- package/dist/__tests__/audio-loader-contract.test.d.ts.map +1 -0
- package/dist/__tests__/audio-local-artifacts.test.d.ts +2 -0
- package/dist/__tests__/audio-local-artifacts.test.d.ts.map +1 -0
- package/dist/__tests__/audio-webaudio.browser.test.d.ts +2 -0
- package/dist/__tests__/audio-webaudio.browser.test.d.ts.map +1 -0
- package/dist/__tests__/audio-webaudio.unit.test.d.ts +2 -0
- package/dist/__tests__/audio-webaudio.unit.test.d.ts.map +1 -0
- package/dist/__tests__/gain-automation.unit.test.d.ts +2 -0
- package/dist/__tests__/gain-automation.unit.test.d.ts.map +1 -0
- package/dist/__tests__/host-audio-consumer.unit.test.d.ts +2 -0
- package/dist/__tests__/host-audio-consumer.unit.test.d.ts.map +1 -0
- package/dist/__tests__/root-surface.unit.test.d.ts +2 -0
- package/dist/__tests__/root-surface.unit.test.d.ts.map +1 -0
- package/dist/__tests__/spatial-cleanup.test.d.ts +2 -0
- package/dist/__tests__/spatial-cleanup.test.d.ts.map +1 -0
- package/dist/__tests__/web-audio-engine-decode-recovery.browser.test.d.ts +2 -0
- package/dist/__tests__/web-audio-engine-decode-recovery.browser.test.d.ts.map +1 -0
- package/dist/__tests__/web-audio-engine-decode-recovery.unit.test.d.ts +2 -0
- package/dist/__tests__/web-audio-engine-decode-recovery.unit.test.d.ts.map +1 -0
- package/dist/audio-importer.d.ts +17 -0
- package/dist/audio-importer.d.ts.map +1 -0
- package/dist/audio-importer.mjs +90 -0
- package/dist/audio-importer.mjs.map +1 -0
- package/dist/audio-listener-sync-system.d.ts +42 -0
- package/dist/audio-listener-sync-system.d.ts.map +1 -0
- package/dist/audio-loader.d.ts +4 -0
- package/dist/audio-loader.d.ts.map +1 -0
- package/dist/clip-loader.d.ts +4 -0
- package/dist/clip-loader.d.ts.map +1 -0
- package/dist/host-audio-consumer.d.ts +11 -0
- package/dist/host-audio-consumer.d.ts.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +544 -0
- package/dist/index.mjs.map +1 -0
- package/dist/plugin.d.ts +3 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/web-audio-engine.d.ts +43 -0
- package/dist/web-audio-engine.d.ts.map +1 -0
- package/package.json +67 -0
- package/src/__tests__/audio-bus-name-owner.test-d.ts +21 -0
- package/src/__tests__/audio-loader-contract.test.ts +103 -0
- package/src/__tests__/audio-local-artifacts.test.ts +34 -0
- package/src/__tests__/audio-webaudio.browser.test.ts +311 -0
- package/src/__tests__/audio-webaudio.unit.test.ts +3031 -0
- package/src/__tests__/gain-automation.unit.test.ts +173 -0
- package/src/__tests__/host-audio-consumer.unit.test.ts +195 -0
- package/src/__tests__/root-surface.unit.test.ts +8 -0
- package/src/__tests__/spatial-cleanup.test.ts +85 -0
- package/src/__tests__/web-audio-engine-decode-recovery.browser.test.ts +125 -0
- package/src/__tests__/web-audio-engine-decode-recovery.unit.test.ts +93 -0
- package/src/audio-importer.ts +149 -0
- package/src/audio-listener-sync-system.ts +98 -0
- package/src/audio-loader.ts +55 -0
- package/src/clip-loader.ts +53 -0
- package/src/host-audio-consumer.ts +168 -0
- package/src/index.ts +31 -0
- package/src/plugin.ts +15 -0
- 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,53 @@
|
|
|
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 asset system loadByGuid)
|
|
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 AudioClipAsset, err, ok, type Result } from '@forgeax/engine-types';
|
|
19
|
+
|
|
20
|
+
export async function decodeAudioClipBytes(
|
|
21
|
+
guid: string,
|
|
22
|
+
bytes: Uint8Array,
|
|
23
|
+
mediaType: `audio/${string}`,
|
|
24
|
+
): Promise<Result<AudioClipAsset, AudioError>> {
|
|
25
|
+
if (mediaType.length <= 'audio/'.length || bytes.byteLength === 0) {
|
|
26
|
+
return err(
|
|
27
|
+
new AudioError({
|
|
28
|
+
code: 'decode-failed',
|
|
29
|
+
expected: `non-empty audio mediaType and source bytes for GUID ${guid}`,
|
|
30
|
+
hint: 'verify the audio artifact mediaType and recook the source bytes',
|
|
31
|
+
detail: {
|
|
32
|
+
code: 'decode-failed' as const,
|
|
33
|
+
reason: 'audio mediaType or source bytes are empty',
|
|
34
|
+
},
|
|
35
|
+
}),
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
return ok({ kind: 'audio', sourceKey: guid, mediaType, bytes: bytes.slice() });
|
|
40
|
+
} catch (e) {
|
|
41
|
+
return err(
|
|
42
|
+
new AudioError({
|
|
43
|
+
code: 'decode-failed',
|
|
44
|
+
expected: `decodable audio artifact bytes for GUID ${guid}`,
|
|
45
|
+
hint: 'verify the audio artifact mediaType and browser-supported codec',
|
|
46
|
+
detail: {
|
|
47
|
+
code: 'decode-failed' as const,
|
|
48
|
+
reason: e instanceof Error ? e.message : 'audio artifact decode failed',
|
|
49
|
+
},
|
|
50
|
+
}),
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -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,31 @@
|
|
|
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 {
|
|
26
|
+
createHostAudioConsumer,
|
|
27
|
+
createWebAudioBackend,
|
|
28
|
+
type HostAudioConsumer,
|
|
29
|
+
} from './host-audio-consumer';
|
|
30
|
+
export { webAudioPlugin } from './plugin';
|
|
31
|
+
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
|
+
}
|