@forgeax/engine-audio 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.
- package/LICENSE +202 -0
- package/README.md +120 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/__tests__/asset-decoder.unit.test.d.ts +2 -0
- package/dist/__tests__/asset-decoder.unit.test.d.ts.map +1 -0
- package/dist/__tests__/audio-edge-action-owner.test-d.d.ts +2 -0
- package/dist/__tests__/audio-edge-action-owner.test-d.d.ts.map +1 -0
- package/dist/__tests__/audio-intent.unit.test.d.ts +2 -0
- package/dist/__tests__/audio-intent.unit.test.d.ts.map +1 -0
- package/dist/__tests__/scriptable-pack-consumer.test-d.d.ts +2 -0
- package/dist/__tests__/scriptable-pack-consumer.test-d.d.ts.map +1 -0
- package/dist/__tests__/simulation-intent-order.test.d.ts +2 -0
- package/dist/__tests__/simulation-intent-order.test.d.ts.map +1 -0
- package/dist/assets/audio-decoder.d.ts +3 -0
- package/dist/assets/audio-decoder.d.ts.map +1 -0
- package/dist/audio-backend.d.ts +37 -0
- package/dist/audio-backend.d.ts.map +1 -0
- package/dist/audio-intent.d.ts +36 -0
- package/dist/audio-intent.d.ts.map +1 -0
- package/dist/audio-tick-system.d.ts +20 -0
- package/dist/audio-tick-system.d.ts.map +1 -0
- package/dist/components.d.ts +10 -0
- package/dist/components.d.ts.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +266 -0
- package/dist/index.mjs.map +1 -0
- package/dist/plugin-factory.d.ts +4 -0
- package/dist/plugin-factory.d.ts.map +1 -0
- package/dist/plugin-service.d.ts +9 -0
- package/dist/plugin-service.d.ts.map +1 -0
- package/package.json +60 -0
- package/src/__tests__/asset-decoder.unit.test.ts +26 -0
- package/src/__tests__/audio-edge-action-owner.test-d.ts +25 -0
- package/src/__tests__/audio-intent.unit.test.ts +78 -0
- package/src/__tests__/scriptable-pack-consumer.test-d.ts +6 -0
- package/src/__tests__/simulation-intent-order.test.ts +41 -0
- package/src/assets/audio-decoder.ts +55 -0
- package/src/audio-backend.ts +51 -0
- package/src/audio-intent.ts +92 -0
- package/src/audio-tick-system.ts +120 -0
- package/src/components.ts +23 -0
- package/src/index.ts +44 -0
- package/src/plugin-factory.ts +56 -0
- package/src/plugin-service.ts +19 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"components.d.ts","sourceRoot":"","sources":["../src/components.ts"],"names":[],"mappings":"AAaA,eAAO,MAAM,WAAW;;;;;;;EAOtB,CAAC;AAEH,eAAO,MAAM,aAAa,8DAAuC,CAAC"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { AUDIO_ERROR_HINTS, type AudioClipAsset, AudioError, type AudioErrorCode, type AudioErrorDetail, } from '@forgeax/engine-types';
|
|
2
|
+
export { audioContribution } from './assets/audio-decoder';
|
|
3
|
+
export type { AudioListenerPose, AudioPlayOptions, AudioState } from './audio-backend';
|
|
4
|
+
export { AUDIO_ENGINE_RESOURCE_KEY, type AudioBackend, type BusName, } from './audio-backend';
|
|
5
|
+
export { type AudioIntent, type AudioIntentBackendOptions, audioIntentErrorState, createAudioIntentBackend, } from './audio-intent';
|
|
6
|
+
export { audioTickSystem, createClipResolver, detectEdge, detectRemovedEntities, type EdgeAction, listenerPoseFromWorldMatrix, } from './audio-tick-system';
|
|
7
|
+
export { AudioListener, AudioSource } from './components';
|
|
8
|
+
export { AUDIO_TICK_SYSTEM_NAME, audioPlugin } from './plugin-factory';
|
|
9
|
+
export { audioBackendPlugin } from './plugin-service';
|
|
10
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAaA,OAAO,EACL,iBAAiB,EACjB,KAAK,cAAc,EACnB,UAAU,EACV,KAAK,cAAc,EACnB,KAAK,gBAAgB,GACtB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EAAE,iBAAiB,EAAE,MAAM,wBAAwB,CAAC;AAC3D,YAAY,EAAE,iBAAiB,EAAE,gBAAgB,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AACvF,OAAO,EACL,yBAAyB,EACzB,KAAK,YAAY,EACjB,KAAK,OAAO,GACb,MAAM,iBAAiB,CAAC;AACzB,OAAO,EACL,KAAK,WAAW,EAChB,KAAK,yBAAyB,EAC9B,qBAAqB,EACrB,wBAAwB,GACzB,MAAM,gBAAgB,CAAC;AACxB,OAAO,EACL,eAAe,EACf,kBAAkB,EAClB,UAAU,EACV,qBAAqB,EACrB,KAAK,UAAU,EACf,2BAA2B,GAC5B,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EAAE,aAAa,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAC1D,OAAO,EAAE,sBAAsB,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AACvE,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC"}
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { ok, err } from '@forgeax/engine-types';
|
|
2
|
+
export { AUDIO_ERROR_HINTS, AudioError } from '@forgeax/engine-types';
|
|
3
|
+
import { defineComponent, Update } from '@forgeax/engine-ecs';
|
|
4
|
+
import { PROPAGATE_TRANSFORMS_SYSTEM, Transform } from '@forgeax/engine-scene';
|
|
5
|
+
|
|
6
|
+
// src/index.ts
|
|
7
|
+
var audioContribution = {
|
|
8
|
+
kind: { kind: "audio" },
|
|
9
|
+
consumer: "AudioBackend",
|
|
10
|
+
decoder: {
|
|
11
|
+
async decode({ envelope, artifacts }) {
|
|
12
|
+
const payload = envelope.payload;
|
|
13
|
+
if (payload !== null && typeof payload === "object") {
|
|
14
|
+
const source = payload;
|
|
15
|
+
let bytes = source.bytes instanceof Uint8Array ? source.bytes : Array.isArray(source.bytes) ? Uint8Array.from(source.bytes) : void 0;
|
|
16
|
+
const body = envelope.artifacts.body ?? envelope.artifacts.source;
|
|
17
|
+
if (body !== void 0) {
|
|
18
|
+
const bodyBytes = await artifacts.read(body);
|
|
19
|
+
if (!bodyBytes.ok) return bodyBytes;
|
|
20
|
+
bytes = bodyBytes.value;
|
|
21
|
+
}
|
|
22
|
+
if (source.kind === "audio" && typeof source.mediaType === "string" && source.mediaType.startsWith("audio/") && bytes !== void 0 && bytes.byteLength > 0) {
|
|
23
|
+
return ok({
|
|
24
|
+
kind: "audio",
|
|
25
|
+
sourceKey: typeof source.sourceKey === "string" && source.sourceKey.length > 0 ? source.sourceKey : envelope.guid,
|
|
26
|
+
mediaType: source.mediaType,
|
|
27
|
+
bytes
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return err({
|
|
32
|
+
code: "asset-package-invalid",
|
|
33
|
+
expected: "an audio payload with non-empty source bytes and audio mediaType",
|
|
34
|
+
hint: "recook the audio source with a browser-supported media type",
|
|
35
|
+
detail: { guid: envelope.guid, reason: "audio owner validation failed" }
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// src/audio-backend.ts
|
|
42
|
+
var AUDIO_ENGINE_RESOURCE_KEY = "AudioEngine";
|
|
43
|
+
|
|
44
|
+
// src/audio-intent.ts
|
|
45
|
+
function sameBytes(left, right) {
|
|
46
|
+
if (left.byteLength !== right.byteLength) return false;
|
|
47
|
+
for (let index = 0; index < left.byteLength; index += 1) {
|
|
48
|
+
if (left[index] !== right[index]) return false;
|
|
49
|
+
}
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
var DISCONNECTED_AUDIO_STATE = {
|
|
53
|
+
contextState: "suspended",
|
|
54
|
+
activeSourceCount: 0,
|
|
55
|
+
lastError: null
|
|
56
|
+
};
|
|
57
|
+
function createAudioIntentBackend(options) {
|
|
58
|
+
const publishedSources = /* @__PURE__ */ new Map();
|
|
59
|
+
let destroyed = false;
|
|
60
|
+
const emit = (intent) => {
|
|
61
|
+
if (!destroyed || intent.kind === "destroy") options.emit(intent);
|
|
62
|
+
};
|
|
63
|
+
const backend = {
|
|
64
|
+
play(entityId, clip, playOptions) {
|
|
65
|
+
const publishedBytes = publishedSources.get(clip.sourceKey);
|
|
66
|
+
const publishBytes = publishedBytes === void 0 || !sameBytes(publishedBytes, clip.bytes);
|
|
67
|
+
if (publishBytes) publishedSources.set(clip.sourceKey, clip.bytes.slice());
|
|
68
|
+
emit({
|
|
69
|
+
kind: "play",
|
|
70
|
+
entityId,
|
|
71
|
+
sourceKey: clip.sourceKey,
|
|
72
|
+
...publishBytes ? { bytes: clip.bytes } : {},
|
|
73
|
+
options: playOptions
|
|
74
|
+
});
|
|
75
|
+
},
|
|
76
|
+
stop: (entityId) => emit({ kind: "stop", entityId }),
|
|
77
|
+
setVolume: (entityId, volume) => emit({ kind: "set-volume", entityId, volume }),
|
|
78
|
+
setBusVolume: (bus, volume) => {
|
|
79
|
+
const intent = { kind: "set-bus-volume", bus, volume };
|
|
80
|
+
emit(intent);
|
|
81
|
+
},
|
|
82
|
+
setBusMute: (bus, muted) => {
|
|
83
|
+
const intent = { kind: "set-bus-mute", bus, muted };
|
|
84
|
+
emit(intent);
|
|
85
|
+
},
|
|
86
|
+
setListenerPose: (pose) => {
|
|
87
|
+
const intent = { kind: "set-listener-pose", pose };
|
|
88
|
+
emit(intent);
|
|
89
|
+
},
|
|
90
|
+
getState: () => options.state?.() ?? DISCONNECTED_AUDIO_STATE,
|
|
91
|
+
getActiveSourceCount: () => (options.state?.() ?? DISCONNECTED_AUDIO_STATE).activeSourceCount,
|
|
92
|
+
destroy() {
|
|
93
|
+
if (destroyed) return;
|
|
94
|
+
const intent = { kind: "destroy" };
|
|
95
|
+
emit(intent);
|
|
96
|
+
destroyed = true;
|
|
97
|
+
publishedSources.clear();
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
return backend;
|
|
101
|
+
}
|
|
102
|
+
function audioIntentErrorState(error) {
|
|
103
|
+
return { contextState: "suspended", activeSourceCount: 0, lastError: error };
|
|
104
|
+
}
|
|
105
|
+
var AudioSource = defineComponent("AudioSource", {
|
|
106
|
+
clip: { type: "shared<AudioClipAsset>" },
|
|
107
|
+
playing: { type: "bool", default: false },
|
|
108
|
+
loop: { type: "bool", default: false },
|
|
109
|
+
volume: { type: "f32", default: 1 },
|
|
110
|
+
spatialBlend: { type: "f32", default: 0 },
|
|
111
|
+
bus: { type: "string", default: "sfx" }
|
|
112
|
+
});
|
|
113
|
+
var AudioListener = defineComponent("AudioListener", {});
|
|
114
|
+
|
|
115
|
+
// src/audio-tick-system.ts
|
|
116
|
+
function listenerPoseFromWorldMatrix(world) {
|
|
117
|
+
const forwardLength = Math.hypot(world[8] ?? 0, world[9] ?? 0, world[10] ?? 0) || 1;
|
|
118
|
+
const upLength = Math.hypot(world[4] ?? 0, world[5] ?? 0, world[6] ?? 0) || 1;
|
|
119
|
+
return {
|
|
120
|
+
positionX: world[12] ?? 0,
|
|
121
|
+
positionY: world[13] ?? 0,
|
|
122
|
+
positionZ: world[14] ?? 0,
|
|
123
|
+
forwardX: -(world[8] ?? 0) / forwardLength,
|
|
124
|
+
forwardY: -(world[9] ?? 0) / forwardLength,
|
|
125
|
+
forwardZ: -(world[10] ?? 0) / forwardLength,
|
|
126
|
+
upX: (world[4] ?? 0) / upLength,
|
|
127
|
+
upY: (world[5] ?? 0) / upLength,
|
|
128
|
+
upZ: (world[6] ?? 0) / upLength
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
function detectEdge(previous, current) {
|
|
132
|
+
if (!previous && current) return "play-start";
|
|
133
|
+
if (previous && !current) return "play-stop";
|
|
134
|
+
return "none";
|
|
135
|
+
}
|
|
136
|
+
function detectRemovedEntities(previous, current) {
|
|
137
|
+
const currentSet = new Set(current);
|
|
138
|
+
return previous.filter((entity) => !currentSet.has(entity));
|
|
139
|
+
}
|
|
140
|
+
var states = /* @__PURE__ */ new WeakMap();
|
|
141
|
+
function stateFor(backend) {
|
|
142
|
+
const existing = states.get(backend);
|
|
143
|
+
if (existing !== void 0) return existing;
|
|
144
|
+
const created = {
|
|
145
|
+
playing: /* @__PURE__ */ new Map(),
|
|
146
|
+
previousEntities: /* @__PURE__ */ new Set(),
|
|
147
|
+
volumes: /* @__PURE__ */ new Map()
|
|
148
|
+
};
|
|
149
|
+
states.set(backend, created);
|
|
150
|
+
return created;
|
|
151
|
+
}
|
|
152
|
+
function createClipResolver(world) {
|
|
153
|
+
return (clipHandle) => {
|
|
154
|
+
const resolved = world.sharedRefs.resolve(
|
|
155
|
+
clipHandle
|
|
156
|
+
);
|
|
157
|
+
return resolved.ok && resolved.value.kind === "audio" ? resolved.value : void 0;
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
function audioTickSystem(world, backend) {
|
|
161
|
+
const state = stateFor(backend);
|
|
162
|
+
const resolveClip = createClipResolver(world);
|
|
163
|
+
const currentEntities = [];
|
|
164
|
+
const query = world.query({ read: [AudioSource] });
|
|
165
|
+
if (!query.ok) return;
|
|
166
|
+
for (const queryRow of query.value) {
|
|
167
|
+
const entity = queryRow.entity;
|
|
168
|
+
const source = queryRow.get(AudioSource);
|
|
169
|
+
const playing = source.playing === true;
|
|
170
|
+
const previous = state.playing.get(entity) ?? false;
|
|
171
|
+
const edge = detectEdge(previous, playing);
|
|
172
|
+
if (edge === "play-start") {
|
|
173
|
+
const clip = resolveClip(source.clip);
|
|
174
|
+
if (clip === void 0) {
|
|
175
|
+
state.playing.set(entity, false);
|
|
176
|
+
} else {
|
|
177
|
+
const options = {
|
|
178
|
+
loop: source.loop === true,
|
|
179
|
+
volume: typeof source.volume === "number" ? source.volume : 1,
|
|
180
|
+
spatialBlend: typeof source.spatialBlend === "number" ? source.spatialBlend : 0,
|
|
181
|
+
bus: typeof source.bus === "string" ? source.bus : "sfx"
|
|
182
|
+
};
|
|
183
|
+
backend.play(entity, clip, options);
|
|
184
|
+
state.playing.set(entity, true);
|
|
185
|
+
state.volumes.set(entity, options.volume);
|
|
186
|
+
}
|
|
187
|
+
} else {
|
|
188
|
+
state.playing.set(entity, playing);
|
|
189
|
+
if (edge === "play-stop") {
|
|
190
|
+
backend.stop(entity);
|
|
191
|
+
} else if (playing && typeof source.volume === "number" && state.volumes.get(entity) !== source.volume) {
|
|
192
|
+
backend.setVolume(entity, source.volume);
|
|
193
|
+
state.volumes.set(entity, source.volume);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
currentEntities.push(entity);
|
|
197
|
+
}
|
|
198
|
+
for (const entity of detectRemovedEntities([...state.previousEntities], currentEntities)) {
|
|
199
|
+
if (state.playing.get(entity) === true) {
|
|
200
|
+
backend.stop(entity);
|
|
201
|
+
}
|
|
202
|
+
state.playing.delete(entity);
|
|
203
|
+
state.volumes.delete(entity);
|
|
204
|
+
}
|
|
205
|
+
state.previousEntities.clear();
|
|
206
|
+
for (const entity of currentEntities) state.previousEntities.add(entity);
|
|
207
|
+
}
|
|
208
|
+
var AUDIO_TICK_SYSTEM_NAME = "audio-tick";
|
|
209
|
+
function audioPlugin() {
|
|
210
|
+
return {
|
|
211
|
+
name: "audio",
|
|
212
|
+
inject: ["world", "audio"],
|
|
213
|
+
apply(ctx) {
|
|
214
|
+
const world = ctx.world;
|
|
215
|
+
const backend = ctx.audio;
|
|
216
|
+
if (backend === void 0) throw new Error("Cordis activated audio without its provider");
|
|
217
|
+
ctx.effect(() => {
|
|
218
|
+
world.insertResource(AUDIO_ENGINE_RESOURCE_KEY, backend);
|
|
219
|
+
return () => {
|
|
220
|
+
world.removeResource(AUDIO_ENGINE_RESOURCE_KEY);
|
|
221
|
+
};
|
|
222
|
+
}, "audio/resource");
|
|
223
|
+
ctx.effect(() => {
|
|
224
|
+
world.addSystem(Update, {
|
|
225
|
+
name: AUDIO_TICK_SYSTEM_NAME,
|
|
226
|
+
queries: [],
|
|
227
|
+
fn: () => audioTickSystem(world, backend)
|
|
228
|
+
}).unwrap();
|
|
229
|
+
return () => world.removeSystem(Update, AUDIO_TICK_SYSTEM_NAME);
|
|
230
|
+
}, "audio/tick");
|
|
231
|
+
ctx.effect(() => {
|
|
232
|
+
world.addSystem(Update, {
|
|
233
|
+
name: "audio-listener-sync",
|
|
234
|
+
after: [PROPAGATE_TRANSFORMS_SYSTEM],
|
|
235
|
+
queries: [],
|
|
236
|
+
fn: () => {
|
|
237
|
+
const listeners = world.query({ read: [Transform], with: [AudioListener] });
|
|
238
|
+
if (!listeners.ok) return;
|
|
239
|
+
for (const row of listeners.value) {
|
|
240
|
+
const transform = row.get(Transform);
|
|
241
|
+
const pose = listenerPoseFromWorldMatrix(transform.world);
|
|
242
|
+
backend.setListenerPose(pose);
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}).unwrap();
|
|
247
|
+
return () => world.removeSystem(Update, "audio-listener-sync");
|
|
248
|
+
}, "audio/listener-sync");
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// src/plugin-service.ts
|
|
254
|
+
function audioBackendPlugin(backend) {
|
|
255
|
+
return {
|
|
256
|
+
name: "audio-backend",
|
|
257
|
+
provide: "audio",
|
|
258
|
+
apply(ctx) {
|
|
259
|
+
ctx.provide("audio", backend);
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export { AUDIO_ENGINE_RESOURCE_KEY, AUDIO_TICK_SYSTEM_NAME, AudioListener, AudioSource, audioBackendPlugin, audioContribution, audioIntentErrorState, audioPlugin, audioTickSystem, createAudioIntentBackend, createClipResolver, detectEdge, detectRemovedEntities, listenerPoseFromWorldMatrix };
|
|
265
|
+
//# sourceMappingURL=index.mjs.map
|
|
266
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/assets/audio-decoder.ts","../src/audio-backend.ts","../src/audio-intent.ts","../src/components.ts","../src/audio-tick-system.ts","../src/plugin-factory.ts","../src/plugin-service.ts"],"names":[],"mappings":"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","file":"index.mjs","sourcesContent":["import {\n type AssetDecoderContribution,\n type AssetKind,\n type AudioClipAsset,\n err,\n ok,\n} from '@forgeax/engine-types';\n\nexport const audioContribution: AssetDecoderContribution<AudioClipAsset, 'audio'> = {\n kind: { kind: 'audio' } as AssetKind<AudioClipAsset, 'audio'>,\n consumer: 'AudioBackend',\n decoder: {\n async decode({ envelope, artifacts }) {\n const payload = envelope.payload as unknown;\n if (payload !== null && typeof payload === 'object') {\n const source = payload as Record<string, unknown>;\n let bytes =\n source.bytes instanceof Uint8Array\n ? source.bytes\n : Array.isArray(source.bytes)\n ? Uint8Array.from(source.bytes as number[])\n : undefined;\n const body = envelope.artifacts.body ?? envelope.artifacts.source;\n if (body !== undefined) {\n const bodyBytes = await artifacts.read(body);\n if (!bodyBytes.ok) return bodyBytes;\n bytes = bodyBytes.value;\n }\n if (\n source.kind === 'audio' &&\n typeof source.mediaType === 'string' &&\n source.mediaType.startsWith('audio/') &&\n bytes !== undefined &&\n bytes.byteLength > 0\n ) {\n return ok({\n kind: 'audio',\n sourceKey:\n typeof source.sourceKey === 'string' && source.sourceKey.length > 0\n ? source.sourceKey\n : envelope.guid,\n mediaType: source.mediaType as `audio/${string}`,\n bytes,\n });\n }\n }\n return err({\n code: 'asset-package-invalid',\n expected: 'an audio payload with non-empty source bytes and audio mediaType',\n hint: 'recook the audio source with a browser-supported media type',\n detail: { guid: envelope.guid, reason: 'audio owner validation failed' },\n });\n },\n },\n};\n","// @forgeax/engine-audio -- AudioBackend protocol interface (feat-20260527-audio-system M1 / w8)\n//\n// Decision anchors:\n// - plan-strategy D-1 (dual-package split: interface in engine-audio, impl in engine-audio-webaudio)\n// - plan-strategy D-5 (fixed two-bus topology: SFX + Music -> Master; bus name literal union)\n// - requirements S-5 (dual bus mixing hierarchy) + S-9 (AudioEngine Resource)\n// - requirements S-7 (entity despawn cleanup: stop + disconnect)\n// - charter P4 (consistent abstraction: structurally parallel to InputBackend)\n\nimport type { AudioClipAsset, AudioError } from '@forgeax/engine-types';\n\nexport type BusName = 'sfx' | 'music';\n\nexport const AUDIO_ENGINE_RESOURCE_KEY = 'AudioEngine' as const;\n\nexport interface AudioPlayOptions {\n readonly loop: boolean;\n readonly volume: number;\n readonly spatialBlend: number;\n readonly bus: BusName;\n}\n\nexport interface AudioListenerPose {\n readonly positionX: number;\n readonly positionY: number;\n readonly positionZ: number;\n readonly forwardX: number;\n readonly forwardY: number;\n readonly forwardZ: number;\n readonly upX: number;\n readonly upY: number;\n readonly upZ: number;\n}\n\nexport interface AudioState {\n readonly contextState: 'running' | 'suspended' | 'closed';\n readonly activeSourceCount: number;\n readonly lastError: AudioError | null;\n}\n\nexport interface AudioBackend {\n play(entityId: number, clip: AudioClipAsset, opts: AudioPlayOptions): void;\n stop(entityId: number): void;\n setVolume(entityId: number, volume: number): void;\n setBusVolume(busName: BusName, volume: number): void;\n setBusMute(busName: BusName, muted: boolean): void;\n setListenerPose(pose: AudioListenerPose): void;\n getState(): AudioState;\n getActiveSourceCount(): number;\n destroy(): void;\n}\n","import type { AudioClipAsset, AudioError } from '@forgeax/engine-types';\nimport type {\n AudioBackend,\n AudioListenerPose,\n AudioPlayOptions,\n AudioState,\n BusName,\n} from './audio-backend';\n\nexport type AudioIntent =\n | {\n readonly kind: 'play';\n readonly entityId: number;\n readonly sourceKey: string;\n readonly bytes?: Uint8Array;\n readonly options: AudioPlayOptions;\n }\n | { readonly kind: 'stop'; readonly entityId: number }\n | { readonly kind: 'set-volume'; readonly entityId: number; readonly volume: number }\n | { readonly kind: 'set-bus-volume'; readonly bus: BusName; readonly volume: number }\n | { readonly kind: 'set-bus-mute'; readonly bus: BusName; readonly muted: boolean }\n | { readonly kind: 'set-listener-pose'; readonly pose: AudioListenerPose }\n | { readonly kind: 'destroy' };\n\nfunction sameBytes(left: Uint8Array, right: Uint8Array): boolean {\n if (left.byteLength !== right.byteLength) return false;\n for (let index = 0; index < left.byteLength; index += 1) {\n if (left[index] !== right[index]) return false;\n }\n return true;\n}\n\nexport interface AudioIntentBackendOptions {\n readonly emit: (intent: AudioIntent) => void;\n readonly state?: () => AudioState;\n}\n\nconst DISCONNECTED_AUDIO_STATE: AudioState = {\n contextState: 'suspended',\n activeSourceCount: 0,\n lastError: null,\n};\n\nexport function createAudioIntentBackend(options: AudioIntentBackendOptions): AudioBackend {\n const publishedSources = new Map<string, Uint8Array>();\n let destroyed = false;\n const emit = (intent: AudioIntent): void => {\n if (!destroyed || intent.kind === 'destroy') options.emit(intent);\n };\n const backend: AudioBackend = {\n play(entityId: number, clip: AudioClipAsset, playOptions: AudioPlayOptions): void {\n const publishedBytes = publishedSources.get(clip.sourceKey);\n const publishBytes = publishedBytes === undefined || !sameBytes(publishedBytes, clip.bytes);\n if (publishBytes) publishedSources.set(clip.sourceKey, clip.bytes.slice());\n emit({\n kind: 'play',\n entityId,\n sourceKey: clip.sourceKey,\n ...(publishBytes ? { bytes: clip.bytes } : {}),\n options: playOptions,\n });\n },\n stop: (entityId) => emit({ kind: 'stop', entityId }),\n setVolume: (entityId, volume) => emit({ kind: 'set-volume', entityId, volume }),\n setBusVolume: (bus, volume) => {\n const intent = { kind: 'set-bus-volume', bus, volume } as const;\n emit(intent);\n },\n setBusMute: (bus, muted) => {\n const intent = { kind: 'set-bus-mute', bus, muted } as const;\n emit(intent);\n },\n setListenerPose: (pose) => {\n const intent = { kind: 'set-listener-pose', pose } as const;\n emit(intent);\n },\n getState: () => options.state?.() ?? DISCONNECTED_AUDIO_STATE,\n getActiveSourceCount: () => (options.state?.() ?? DISCONNECTED_AUDIO_STATE).activeSourceCount,\n destroy(): void {\n if (destroyed) return;\n const intent = { kind: 'destroy' } as const;\n emit(intent);\n destroyed = true;\n publishedSources.clear();\n },\n };\n return backend;\n}\n\nexport function audioIntentErrorState(error: AudioError): AudioState {\n return { contextState: 'suspended', activeSourceCount: 0, lastError: error };\n}\n","// @forgeax/engine-audio -- AudioSource + AudioListener ECS components (feat-20260527-audio-system M1 / w9)\n//\n// Decision anchors:\n// - requirements S-3 (AudioSource single-component surface, 6 fields)\n// - requirements S-4 (AudioListener independent marker component)\n// - plan-strategy D-4 (edge-detection tick system; AudioSource.playing drives play/stop edges)\n// - plan-strategy D-5 (AudioSource.bus defaults to 'sfx'; BusName is 'sfx' | 'music')\n// - plan-strategy section 3.1 (6-field AudioSource + marker AudioListener)\n// - charter P1 (progressive disclosure: 3-symbol core surface)\n// - charter P4 (consistent abstraction: same defineComponent pattern as Transform/Camera)\n\nimport { defineComponent } from '@forgeax/engine-ecs';\n\nexport const AudioSource = defineComponent('AudioSource', {\n clip: { type: 'shared<AudioClipAsset>' },\n playing: { type: 'bool', default: false },\n loop: { type: 'bool', default: false },\n volume: { type: 'f32', default: 1.0 },\n spatialBlend: { type: 'f32', default: 0 },\n bus: { type: 'string', default: 'sfx' },\n});\n\nexport const AudioListener = defineComponent('AudioListener', {});\n","import type { World } from '@forgeax/engine-ecs';\nimport type { AudioClipAsset } from '@forgeax/engine-types';\nimport type { AudioBackend, AudioPlayOptions, BusName } from './audio-backend';\nimport { AudioSource } from './components';\n\nexport function listenerPoseFromWorldMatrix(world: Float32Array) {\n const forwardLength = Math.hypot(world[8] ?? 0, world[9] ?? 0, world[10] ?? 0) || 1;\n const upLength = Math.hypot(world[4] ?? 0, world[5] ?? 0, world[6] ?? 0) || 1;\n return {\n positionX: world[12] ?? 0,\n positionY: world[13] ?? 0,\n positionZ: world[14] ?? 0,\n forwardX: -(world[8] ?? 0) / forwardLength,\n forwardY: -(world[9] ?? 0) / forwardLength,\n forwardZ: -(world[10] ?? 0) / forwardLength,\n upX: (world[4] ?? 0) / upLength,\n upY: (world[5] ?? 0) / upLength,\n upZ: (world[6] ?? 0) / upLength,\n };\n}\n\nexport type EdgeAction = ReturnType<typeof detectEdge>;\n\nexport function detectEdge(previous: boolean, current: boolean) {\n if (!previous && current) return 'play-start';\n if (previous && !current) return 'play-stop';\n return 'none';\n}\n\nexport function detectRemovedEntities(\n previous: readonly number[],\n current: readonly number[],\n): number[] {\n const currentSet = new Set(current);\n return previous.filter((entity) => !currentSet.has(entity));\n}\n\ninterface TickState {\n playing: Map<number, boolean>;\n previousEntities: Set<number>;\n volumes: Map<number, number>;\n}\n\nconst states = new WeakMap<AudioBackend, TickState>();\n\nfunction stateFor(backend: AudioBackend): TickState {\n const existing = states.get(backend);\n if (existing !== undefined) return existing;\n const created: TickState = {\n playing: new Map(),\n previousEntities: new Set(),\n volumes: new Map(),\n };\n states.set(backend, created);\n return created;\n}\n\nexport function createClipResolver(\n world: World,\n): (clipHandle: number) => AudioClipAsset | undefined {\n return (clipHandle) => {\n const resolved = world.sharedRefs.resolve<string, AudioClipAsset>(\n clipHandle as unknown as Parameters<typeof world.sharedRefs.resolve>[0],\n );\n return resolved.ok && resolved.value.kind === 'audio' ? resolved.value : undefined;\n };\n}\n\nexport function audioTickSystem(world: World, backend: AudioBackend): void {\n const state = stateFor(backend);\n const resolveClip = createClipResolver(world);\n const currentEntities: number[] = [];\n const query = world.query({ read: [AudioSource] });\n if (!query.ok) return;\n for (const queryRow of query.value) {\n const entity = queryRow.entity as number;\n const source = queryRow.get(AudioSource);\n const playing = source.playing === true;\n const previous = state.playing.get(entity) ?? false;\n const edge = detectEdge(previous, playing);\n if (edge === 'play-start') {\n const clip = resolveClip(source.clip as number);\n if (clip === undefined) {\n state.playing.set(entity, false);\n } else {\n const options: AudioPlayOptions = {\n loop: source.loop === true,\n volume: typeof source.volume === 'number' ? source.volume : 1,\n spatialBlend: typeof source.spatialBlend === 'number' ? source.spatialBlend : 0,\n bus: (typeof source.bus === 'string' ? source.bus : 'sfx') as BusName,\n };\n backend.play(entity, clip, options);\n state.playing.set(entity, true);\n state.volumes.set(entity, options.volume);\n }\n } else {\n state.playing.set(entity, playing);\n if (edge === 'play-stop') {\n backend.stop(entity);\n } else if (\n playing &&\n typeof source.volume === 'number' &&\n state.volumes.get(entity) !== source.volume\n ) {\n backend.setVolume(entity, source.volume);\n state.volumes.set(entity, source.volume);\n }\n }\n currentEntities.push(entity);\n }\n for (const entity of detectRemovedEntities([...state.previousEntities], currentEntities)) {\n if (state.playing.get(entity) === true) {\n backend.stop(entity);\n }\n state.playing.delete(entity);\n state.volumes.delete(entity);\n }\n state.previousEntities.clear();\n for (const entity of currentEntities) state.previousEntities.add(entity);\n}\n","import { Update } from '@forgeax/engine-ecs';\nimport type { Plugin } from '@forgeax/engine-plugin';\nimport { PROPAGATE_TRANSFORMS_SYSTEM, Transform } from '@forgeax/engine-scene';\nimport { AUDIO_ENGINE_RESOURCE_KEY } from './audio-backend';\nimport { audioTickSystem, listenerPoseFromWorldMatrix } from './audio-tick-system';\nimport { AudioListener } from './components';\n\nexport const AUDIO_TICK_SYSTEM_NAME = 'audio-tick' as const;\n\nexport function audioPlugin(): Plugin {\n return {\n name: 'audio',\n inject: ['world', 'audio'],\n apply(ctx) {\n const world = ctx.world;\n const backend = ctx.audio;\n if (backend === undefined) throw new Error('Cordis activated audio without its provider');\n ctx.effect(() => {\n world.insertResource(AUDIO_ENGINE_RESOURCE_KEY, backend);\n return () => {\n world.removeResource(AUDIO_ENGINE_RESOURCE_KEY);\n };\n }, 'audio/resource');\n ctx.effect(() => {\n world\n .addSystem(Update, {\n name: AUDIO_TICK_SYSTEM_NAME,\n queries: [],\n fn: () => audioTickSystem(world, backend),\n })\n .unwrap();\n return () => world.removeSystem(Update, AUDIO_TICK_SYSTEM_NAME);\n }, 'audio/tick');\n ctx.effect(() => {\n world\n .addSystem(Update, {\n name: 'audio-listener-sync',\n after: [PROPAGATE_TRANSFORMS_SYSTEM],\n queries: [],\n fn: () => {\n const listeners = world.query({ read: [Transform], with: [AudioListener] });\n if (!listeners.ok) return;\n for (const row of listeners.value) {\n const transform = row.get(Transform);\n const pose = listenerPoseFromWorldMatrix(transform.world);\n backend.setListenerPose(pose);\n break;\n }\n },\n })\n .unwrap();\n return () => world.removeSystem(Update, 'audio-listener-sync');\n }, 'audio/listener-sync');\n },\n };\n}\n","import type { Plugin } from '@forgeax/engine-plugin';\n\nimport type { AudioBackend } from './audio-backend';\n\ndeclare module '@forgeax/engine-plugin' {\n interface EngineContextServices {\n audio?: AudioBackend;\n }\n}\n\nexport function audioBackendPlugin(backend: AudioBackend): Plugin {\n return {\n name: 'audio-backend',\n provide: 'audio',\n apply(ctx) {\n ctx.provide('audio', backend);\n },\n };\n}\n"]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"plugin-factory.d.ts","sourceRoot":"","sources":["../src/plugin-factory.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,wBAAwB,CAAC;AAMrD,eAAO,MAAM,sBAAsB,EAAG,YAAqB,CAAC;AAE5D,wBAAgB,WAAW,IAAI,MAAM,CA8CpC"}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Plugin } from '@forgeax/engine-plugin';
|
|
2
|
+
import type { AudioBackend } from './audio-backend';
|
|
3
|
+
declare module '@forgeax/engine-plugin' {
|
|
4
|
+
interface EngineContextServices {
|
|
5
|
+
audio?: AudioBackend;
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
export declare function audioBackendPlugin(backend: AudioBackend): Plugin;
|
|
9
|
+
//# sourceMappingURL=plugin-service.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"plugin-service.d.ts","sourceRoot":"","sources":["../src/plugin-service.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,wBAAwB,CAAC;AAErD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAEpD,OAAO,QAAQ,wBAAwB,CAAC;IACtC,UAAU,qBAAqB;QAC7B,KAAK,CAAC,EAAE,YAAY,CAAC;KACtB;CACF;AAED,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,YAAY,GAAG,MAAM,CAQhE"}
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@forgeax/engine-audio",
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"description": "Realm-neutral ForgeaX audio: ECS components, tick/plugin, backend protocol, POD clips, and closed Host intent transport.",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.mjs"
|
|
13
|
+
},
|
|
14
|
+
"./package.json": "./package.json"
|
|
15
|
+
},
|
|
16
|
+
"main": "./dist/index.mjs",
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"src",
|
|
21
|
+
"README.md",
|
|
22
|
+
"LICENSE"
|
|
23
|
+
],
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@forgeax/engine-ecs": "0.1.2",
|
|
26
|
+
"@forgeax/engine-math": "0.1.2",
|
|
27
|
+
"@forgeax/engine-plugin": "0.1.2",
|
|
28
|
+
"@forgeax/engine-scene": "0.1.2",
|
|
29
|
+
"@forgeax/engine-types": "0.1.2"
|
|
30
|
+
},
|
|
31
|
+
"forgeax": {
|
|
32
|
+
"metrics": {
|
|
33
|
+
"bundle-size": {
|
|
34
|
+
"enabled": false,
|
|
35
|
+
"reason": "runtime tick and intent producer are covered by package build/tests; no standalone player bundle is emitted"
|
|
36
|
+
},
|
|
37
|
+
"fps": {
|
|
38
|
+
"enabled": false,
|
|
39
|
+
"reason": "realm-neutral package has no canvas; frame impact is measured by consuming apps"
|
|
40
|
+
},
|
|
41
|
+
"bench": {
|
|
42
|
+
"enabled": false,
|
|
43
|
+
"reason": "intent production is linear in changed sources; end-to-end cost is measured by the multithreaded execution app"
|
|
44
|
+
},
|
|
45
|
+
"gate": {
|
|
46
|
+
"enabled": false,
|
|
47
|
+
"reason": "no package-level binary gate; test surface coverage runs as part of pnpm test:unit + tsc -b"
|
|
48
|
+
},
|
|
49
|
+
"spike-report": {
|
|
50
|
+
"enabled": false,
|
|
51
|
+
"reason": "not a spike package; this is a production MVP per plan-strategy D-1 (dual-package split)"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
"scripts": {
|
|
56
|
+
"build": "tsup",
|
|
57
|
+
"test": "vitest run",
|
|
58
|
+
"typecheck": "tsc -b"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { World } from '@forgeax/engine-ecs';
|
|
2
|
+
import type { AudioClipAsset } from '@forgeax/engine-types';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { audioContribution } from '../assets/audio-decoder';
|
|
5
|
+
import { createClipResolver } from '../audio-tick-system';
|
|
6
|
+
|
|
7
|
+
describe('audio asset consumer boundary', () => {
|
|
8
|
+
it('exposes the realm-neutral audio owner contribution', () => {
|
|
9
|
+
expect(audioContribution.kind.kind).toBe('audio');
|
|
10
|
+
expect(audioContribution.consumer).toBe('AudioBackend');
|
|
11
|
+
});
|
|
12
|
+
it('resolves a durable audio payload without a registry or host object', () => {
|
|
13
|
+
const clip: AudioClipAsset = {
|
|
14
|
+
kind: 'audio',
|
|
15
|
+
sourceKey: '019e2cc6-0c86-79da-aa76-b0984c86d45c',
|
|
16
|
+
mediaType: 'audio/ogg',
|
|
17
|
+
bytes: new Uint8Array([1, 2, 3]),
|
|
18
|
+
};
|
|
19
|
+
const world = new World();
|
|
20
|
+
const handle = world.internSharedRef('AudioClipAsset', clip);
|
|
21
|
+
const resolver = createClipResolver(world);
|
|
22
|
+
|
|
23
|
+
expect(resolver(handle)).toBe(clip);
|
|
24
|
+
expect(resolver(0)).toBeUndefined();
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, expect, expectTypeOf, it } from 'vitest';
|
|
2
|
+
import { detectEdge, type EdgeAction } from '../audio-tick-system.js';
|
|
3
|
+
import type { EdgeAction as PublicEdgeAction } from '../index.js';
|
|
4
|
+
|
|
5
|
+
describe('audio edge action owner', () => {
|
|
6
|
+
it('derives the public action from the executable edge detector', () => {
|
|
7
|
+
expectTypeOf<EdgeAction>().toEqualTypeOf<ReturnType<typeof detectEdge>>();
|
|
8
|
+
expectTypeOf<ReturnType<typeof detectEdge>>().toEqualTypeOf<
|
|
9
|
+
'none' | 'play-start' | 'play-stop'
|
|
10
|
+
>();
|
|
11
|
+
expectTypeOf<PublicEdgeAction>().toEqualTypeOf<EdgeAction>();
|
|
12
|
+
|
|
13
|
+
expect(detectEdge(false, false)).toBe('none');
|
|
14
|
+
expect(detectEdge(false, true)).toBe('play-start');
|
|
15
|
+
expect(detectEdge(true, false)).toBe('play-stop');
|
|
16
|
+
expect(detectEdge(true, true)).toBe('none');
|
|
17
|
+
|
|
18
|
+
const acceptsAction = (value: EdgeAction): EdgeAction => value;
|
|
19
|
+
acceptsAction('none');
|
|
20
|
+
acceptsAction('play-start');
|
|
21
|
+
acceptsAction('play-stop');
|
|
22
|
+
// @ts-expect-error Unknown edge actions remain outside the executable vocabulary.
|
|
23
|
+
acceptsAction('unknown');
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { createAudioIntentBackend, listenerPoseFromWorldMatrix } from '../index';
|
|
3
|
+
|
|
4
|
+
const PLAY_OPTIONS = {
|
|
5
|
+
loop: false,
|
|
6
|
+
volume: 0.75,
|
|
7
|
+
spatialBlend: 0.5,
|
|
8
|
+
bus: 'sfx' as const,
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
describe('AudioIntent producer', () => {
|
|
12
|
+
it('publishes source bytes once and reuses sourceKey afterwards', () => {
|
|
13
|
+
const emit = vi.fn();
|
|
14
|
+
const backend = createAudioIntentBackend({ emit });
|
|
15
|
+
const clip = {
|
|
16
|
+
kind: 'audio' as const,
|
|
17
|
+
sourceKey: 'laser',
|
|
18
|
+
mediaType: 'audio/ogg' as const,
|
|
19
|
+
bytes: new Uint8Array([1, 2, 3]),
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
backend.play(1, clip, PLAY_OPTIONS);
|
|
23
|
+
backend.play(2, clip, PLAY_OPTIONS);
|
|
24
|
+
|
|
25
|
+
expect(emit.mock.calls[0]?.[0]).toMatchObject({
|
|
26
|
+
kind: 'play',
|
|
27
|
+
entityId: 1,
|
|
28
|
+
sourceKey: 'laser',
|
|
29
|
+
bytes: clip.bytes,
|
|
30
|
+
});
|
|
31
|
+
expect(emit.mock.calls[1]?.[0]).toEqual({
|
|
32
|
+
kind: 'play',
|
|
33
|
+
entityId: 2,
|
|
34
|
+
sourceKey: 'laser',
|
|
35
|
+
options: PLAY_OPTIONS,
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('republishes bytes when a stable sourceKey receives changed content', () => {
|
|
40
|
+
const emit = vi.fn();
|
|
41
|
+
const backend = createAudioIntentBackend({ emit });
|
|
42
|
+
const clipA = {
|
|
43
|
+
kind: 'audio' as const,
|
|
44
|
+
sourceKey: 'laser',
|
|
45
|
+
mediaType: 'audio/ogg' as const,
|
|
46
|
+
bytes: new Uint8Array([1, 2, 3]),
|
|
47
|
+
};
|
|
48
|
+
const clipB = { ...clipA, bytes: new Uint8Array([1, 2, 4]) };
|
|
49
|
+
|
|
50
|
+
backend.play(1, clipA, PLAY_OPTIONS);
|
|
51
|
+
backend.play(2, clipA, PLAY_OPTIONS);
|
|
52
|
+
backend.play(3, clipB, PLAY_OPTIONS);
|
|
53
|
+
|
|
54
|
+
expect(emit).toHaveBeenCalledTimes(3);
|
|
55
|
+
expect(emit.mock.calls[1]?.[0]).not.toHaveProperty('bytes');
|
|
56
|
+
expect(emit.mock.calls[2]?.[0]).toMatchObject({
|
|
57
|
+
kind: 'play',
|
|
58
|
+
entityId: 3,
|
|
59
|
+
sourceKey: 'laser',
|
|
60
|
+
bytes: clipB.bytes,
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('projects a scaled world matrix into normalized host listener pose', () => {
|
|
65
|
+
const world = new Float32Array([2, 0, 0, 0, 0, 3, 0, 0, 0, 0, 4, 0, 5, 6, 7, 1]);
|
|
66
|
+
expect(listenerPoseFromWorldMatrix(world)).toEqual({
|
|
67
|
+
positionX: 5,
|
|
68
|
+
positionY: 6,
|
|
69
|
+
positionZ: 7,
|
|
70
|
+
forwardX: -0,
|
|
71
|
+
forwardY: -0,
|
|
72
|
+
forwardZ: -1,
|
|
73
|
+
upX: 0,
|
|
74
|
+
upY: 1,
|
|
75
|
+
upZ: 0,
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { AudioClipAsset } from '@forgeax/engine-audio';
|
|
2
|
+
import { expectTypeOf } from 'vitest';
|
|
3
|
+
|
|
4
|
+
declare const loadedClip: AudioClipAsset;
|
|
5
|
+
expectTypeOf(loadedClip.mediaType).toMatchTypeOf<`audio/${string}`>();
|
|
6
|
+
expectTypeOf(loadedClip.bytes).toEqualTypeOf<Uint8Array>();
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { World } from '@forgeax/engine-ecs';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { type AudioIntent, AudioSource, audioTickSystem, createAudioIntentBackend } from '../index';
|
|
4
|
+
|
|
5
|
+
describe('Audio semantic intent order', () => {
|
|
6
|
+
it('emits one play, one stop, and one disappearance cleanup', () => {
|
|
7
|
+
const world = new World();
|
|
8
|
+
const handle = world.allocSharedRef('AudioClipAsset', {
|
|
9
|
+
kind: 'audio',
|
|
10
|
+
sourceKey: 'order-tone',
|
|
11
|
+
bytes: Uint8Array.of(1),
|
|
12
|
+
});
|
|
13
|
+
const entity = world
|
|
14
|
+
.spawn({
|
|
15
|
+
component: AudioSource,
|
|
16
|
+
data: {
|
|
17
|
+
clip: handle,
|
|
18
|
+
playing: false,
|
|
19
|
+
loop: true,
|
|
20
|
+
volume: 1,
|
|
21
|
+
spatialBlend: 0,
|
|
22
|
+
bus: 'sfx',
|
|
23
|
+
},
|
|
24
|
+
})
|
|
25
|
+
.unwrap();
|
|
26
|
+
const intents: AudioIntent[] = [];
|
|
27
|
+
const backend = createAudioIntentBackend({ emit: (intent) => intents.push(intent) });
|
|
28
|
+
|
|
29
|
+
audioTickSystem(world, backend);
|
|
30
|
+
world.set(entity, AudioSource, { playing: true }).unwrap();
|
|
31
|
+
audioTickSystem(world, backend);
|
|
32
|
+
audioTickSystem(world, backend);
|
|
33
|
+
world.set(entity, AudioSource, { playing: false }).unwrap();
|
|
34
|
+
audioTickSystem(world, backend);
|
|
35
|
+
world.despawn(entity).unwrap();
|
|
36
|
+
audioTickSystem(world, backend);
|
|
37
|
+
|
|
38
|
+
expect(intents.map((intent) => intent.kind)).toEqual(['play', 'stop']);
|
|
39
|
+
expect(intents.filter((intent) => intent.kind === 'stop')).toHaveLength(1);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AssetDecoderContribution,
|
|
3
|
+
type AssetKind,
|
|
4
|
+
type AudioClipAsset,
|
|
5
|
+
err,
|
|
6
|
+
ok,
|
|
7
|
+
} from '@forgeax/engine-types';
|
|
8
|
+
|
|
9
|
+
export const audioContribution: AssetDecoderContribution<AudioClipAsset, 'audio'> = {
|
|
10
|
+
kind: { kind: 'audio' } as AssetKind<AudioClipAsset, 'audio'>,
|
|
11
|
+
consumer: 'AudioBackend',
|
|
12
|
+
decoder: {
|
|
13
|
+
async decode({ envelope, artifacts }) {
|
|
14
|
+
const payload = envelope.payload as unknown;
|
|
15
|
+
if (payload !== null && typeof payload === 'object') {
|
|
16
|
+
const source = payload as Record<string, unknown>;
|
|
17
|
+
let bytes =
|
|
18
|
+
source.bytes instanceof Uint8Array
|
|
19
|
+
? source.bytes
|
|
20
|
+
: Array.isArray(source.bytes)
|
|
21
|
+
? Uint8Array.from(source.bytes as number[])
|
|
22
|
+
: undefined;
|
|
23
|
+
const body = envelope.artifacts.body ?? envelope.artifacts.source;
|
|
24
|
+
if (body !== undefined) {
|
|
25
|
+
const bodyBytes = await artifacts.read(body);
|
|
26
|
+
if (!bodyBytes.ok) return bodyBytes;
|
|
27
|
+
bytes = bodyBytes.value;
|
|
28
|
+
}
|
|
29
|
+
if (
|
|
30
|
+
source.kind === 'audio' &&
|
|
31
|
+
typeof source.mediaType === 'string' &&
|
|
32
|
+
source.mediaType.startsWith('audio/') &&
|
|
33
|
+
bytes !== undefined &&
|
|
34
|
+
bytes.byteLength > 0
|
|
35
|
+
) {
|
|
36
|
+
return ok({
|
|
37
|
+
kind: 'audio',
|
|
38
|
+
sourceKey:
|
|
39
|
+
typeof source.sourceKey === 'string' && source.sourceKey.length > 0
|
|
40
|
+
? source.sourceKey
|
|
41
|
+
: envelope.guid,
|
|
42
|
+
mediaType: source.mediaType as `audio/${string}`,
|
|
43
|
+
bytes,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return err({
|
|
48
|
+
code: 'asset-package-invalid',
|
|
49
|
+
expected: 'an audio payload with non-empty source bytes and audio mediaType',
|
|
50
|
+
hint: 'recook the audio source with a browser-supported media type',
|
|
51
|
+
detail: { guid: envelope.guid, reason: 'audio owner validation failed' },
|
|
52
|
+
});
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
};
|