@forgeax/engine-audio 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 +120 -0
- package/dist/.tsbuildinfo +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 +274 -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__/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 +66 -0
- package/src/plugin-service.ts +19 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { AudioClipAsset, AudioError } from '@forgeax/engine-types';
|
|
2
|
+
import type {
|
|
3
|
+
AudioBackend,
|
|
4
|
+
AudioListenerPose,
|
|
5
|
+
AudioPlayOptions,
|
|
6
|
+
AudioState,
|
|
7
|
+
BusName,
|
|
8
|
+
} from './audio-backend';
|
|
9
|
+
|
|
10
|
+
export type AudioIntent =
|
|
11
|
+
| {
|
|
12
|
+
readonly kind: 'play';
|
|
13
|
+
readonly entityId: number;
|
|
14
|
+
readonly sourceKey: string;
|
|
15
|
+
readonly bytes?: Uint8Array;
|
|
16
|
+
readonly options: AudioPlayOptions;
|
|
17
|
+
}
|
|
18
|
+
| { readonly kind: 'stop'; readonly entityId: number }
|
|
19
|
+
| { readonly kind: 'set-volume'; readonly entityId: number; readonly volume: number }
|
|
20
|
+
| { readonly kind: 'set-bus-volume'; readonly bus: BusName; readonly volume: number }
|
|
21
|
+
| { readonly kind: 'set-bus-mute'; readonly bus: BusName; readonly muted: boolean }
|
|
22
|
+
| { readonly kind: 'set-listener-pose'; readonly pose: AudioListenerPose }
|
|
23
|
+
| { readonly kind: 'destroy' };
|
|
24
|
+
|
|
25
|
+
function sameBytes(left: Uint8Array, right: Uint8Array): boolean {
|
|
26
|
+
if (left.byteLength !== right.byteLength) return false;
|
|
27
|
+
for (let index = 0; index < left.byteLength; index += 1) {
|
|
28
|
+
if (left[index] !== right[index]) return false;
|
|
29
|
+
}
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface AudioIntentBackendOptions {
|
|
34
|
+
readonly emit: (intent: AudioIntent) => void;
|
|
35
|
+
readonly state?: () => AudioState;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const DISCONNECTED_AUDIO_STATE: AudioState = {
|
|
39
|
+
contextState: 'suspended',
|
|
40
|
+
activeSourceCount: 0,
|
|
41
|
+
lastError: null,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export function createAudioIntentBackend(options: AudioIntentBackendOptions): AudioBackend {
|
|
45
|
+
const publishedSources = new Map<string, Uint8Array>();
|
|
46
|
+
let destroyed = false;
|
|
47
|
+
const emit = (intent: AudioIntent): void => {
|
|
48
|
+
if (!destroyed || intent.kind === 'destroy') options.emit(intent);
|
|
49
|
+
};
|
|
50
|
+
const backend: AudioBackend = {
|
|
51
|
+
play(entityId: number, clip: AudioClipAsset, playOptions: AudioPlayOptions): void {
|
|
52
|
+
const publishedBytes = publishedSources.get(clip.sourceKey);
|
|
53
|
+
const publishBytes = publishedBytes === undefined || !sameBytes(publishedBytes, clip.bytes);
|
|
54
|
+
if (publishBytes) publishedSources.set(clip.sourceKey, clip.bytes.slice());
|
|
55
|
+
emit({
|
|
56
|
+
kind: 'play',
|
|
57
|
+
entityId,
|
|
58
|
+
sourceKey: clip.sourceKey,
|
|
59
|
+
...(publishBytes ? { bytes: clip.bytes } : {}),
|
|
60
|
+
options: playOptions,
|
|
61
|
+
});
|
|
62
|
+
},
|
|
63
|
+
stop: (entityId) => emit({ kind: 'stop', entityId }),
|
|
64
|
+
setVolume: (entityId, volume) => emit({ kind: 'set-volume', entityId, volume }),
|
|
65
|
+
setBusVolume: (bus, volume) => {
|
|
66
|
+
const intent = { kind: 'set-bus-volume', bus, volume } as const;
|
|
67
|
+
emit(intent);
|
|
68
|
+
},
|
|
69
|
+
setBusMute: (bus, muted) => {
|
|
70
|
+
const intent = { kind: 'set-bus-mute', bus, muted } as const;
|
|
71
|
+
emit(intent);
|
|
72
|
+
},
|
|
73
|
+
setListenerPose: (pose) => {
|
|
74
|
+
const intent = { kind: 'set-listener-pose', pose } as const;
|
|
75
|
+
emit(intent);
|
|
76
|
+
},
|
|
77
|
+
getState: () => options.state?.() ?? DISCONNECTED_AUDIO_STATE,
|
|
78
|
+
getActiveSourceCount: () => (options.state?.() ?? DISCONNECTED_AUDIO_STATE).activeSourceCount,
|
|
79
|
+
destroy(): void {
|
|
80
|
+
if (destroyed) return;
|
|
81
|
+
const intent = { kind: 'destroy' } as const;
|
|
82
|
+
emit(intent);
|
|
83
|
+
destroyed = true;
|
|
84
|
+
publishedSources.clear();
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
return backend;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function audioIntentErrorState(error: AudioError): AudioState {
|
|
91
|
+
return { contextState: 'suspended', activeSourceCount: 0, lastError: error };
|
|
92
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { World } from '@forgeax/engine-ecs';
|
|
2
|
+
import type { AudioClipAsset } from '@forgeax/engine-types';
|
|
3
|
+
import type { AudioBackend, AudioPlayOptions, BusName } from './audio-backend';
|
|
4
|
+
import { AudioSource } from './components';
|
|
5
|
+
|
|
6
|
+
export function listenerPoseFromWorldMatrix(world: Float32Array) {
|
|
7
|
+
const forwardLength = Math.hypot(world[8] ?? 0, world[9] ?? 0, world[10] ?? 0) || 1;
|
|
8
|
+
const upLength = Math.hypot(world[4] ?? 0, world[5] ?? 0, world[6] ?? 0) || 1;
|
|
9
|
+
return {
|
|
10
|
+
positionX: world[12] ?? 0,
|
|
11
|
+
positionY: world[13] ?? 0,
|
|
12
|
+
positionZ: world[14] ?? 0,
|
|
13
|
+
forwardX: -(world[8] ?? 0) / forwardLength,
|
|
14
|
+
forwardY: -(world[9] ?? 0) / forwardLength,
|
|
15
|
+
forwardZ: -(world[10] ?? 0) / forwardLength,
|
|
16
|
+
upX: (world[4] ?? 0) / upLength,
|
|
17
|
+
upY: (world[5] ?? 0) / upLength,
|
|
18
|
+
upZ: (world[6] ?? 0) / upLength,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type EdgeAction = 'none' | 'play-start' | 'play-stop';
|
|
23
|
+
|
|
24
|
+
export function detectEdge(previous: boolean, current: boolean): EdgeAction {
|
|
25
|
+
if (!previous && current) return 'play-start';
|
|
26
|
+
if (previous && !current) return 'play-stop';
|
|
27
|
+
return 'none';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function detectRemovedEntities(
|
|
31
|
+
previous: readonly number[],
|
|
32
|
+
current: readonly number[],
|
|
33
|
+
): number[] {
|
|
34
|
+
const currentSet = new Set(current);
|
|
35
|
+
return previous.filter((entity) => !currentSet.has(entity));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface TickState {
|
|
39
|
+
playing: Map<number, boolean>;
|
|
40
|
+
previousEntities: Set<number>;
|
|
41
|
+
volumes: Map<number, number>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const states = new WeakMap<AudioBackend, TickState>();
|
|
45
|
+
|
|
46
|
+
function stateFor(backend: AudioBackend): TickState {
|
|
47
|
+
const existing = states.get(backend);
|
|
48
|
+
if (existing !== undefined) return existing;
|
|
49
|
+
const created: TickState = {
|
|
50
|
+
playing: new Map(),
|
|
51
|
+
previousEntities: new Set(),
|
|
52
|
+
volumes: new Map(),
|
|
53
|
+
};
|
|
54
|
+
states.set(backend, created);
|
|
55
|
+
return created;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function createClipResolver(
|
|
59
|
+
world: World,
|
|
60
|
+
): (clipHandle: number) => AudioClipAsset | undefined {
|
|
61
|
+
return (clipHandle) => {
|
|
62
|
+
const resolved = world.sharedRefs.resolve<string, AudioClipAsset>(
|
|
63
|
+
clipHandle as unknown as Parameters<typeof world.sharedRefs.resolve>[0],
|
|
64
|
+
);
|
|
65
|
+
return resolved.ok && resolved.value.kind === 'audio' ? resolved.value : undefined;
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function audioTickSystem(world: World, backend: AudioBackend): void {
|
|
70
|
+
const state = stateFor(backend);
|
|
71
|
+
const resolveClip = createClipResolver(world);
|
|
72
|
+
const currentEntities: number[] = [];
|
|
73
|
+
const query = world.query({ read: [AudioSource] });
|
|
74
|
+
if (!query.ok) return;
|
|
75
|
+
for (const queryRow of query.value) {
|
|
76
|
+
const entity = queryRow.entity as number;
|
|
77
|
+
const source = queryRow.get(AudioSource);
|
|
78
|
+
const playing = source.playing === true;
|
|
79
|
+
const previous = state.playing.get(entity) ?? false;
|
|
80
|
+
const edge = detectEdge(previous, playing);
|
|
81
|
+
if (edge === 'play-start') {
|
|
82
|
+
const clip = resolveClip(source.clip as number);
|
|
83
|
+
if (clip === undefined) {
|
|
84
|
+
state.playing.set(entity, false);
|
|
85
|
+
} else {
|
|
86
|
+
const options: AudioPlayOptions = {
|
|
87
|
+
loop: source.loop === true,
|
|
88
|
+
volume: typeof source.volume === 'number' ? source.volume : 1,
|
|
89
|
+
spatialBlend: typeof source.spatialBlend === 'number' ? source.spatialBlend : 0,
|
|
90
|
+
bus: (typeof source.bus === 'string' ? source.bus : 'sfx') as BusName,
|
|
91
|
+
};
|
|
92
|
+
backend.play(entity, clip, options);
|
|
93
|
+
state.playing.set(entity, true);
|
|
94
|
+
state.volumes.set(entity, options.volume);
|
|
95
|
+
}
|
|
96
|
+
} else {
|
|
97
|
+
state.playing.set(entity, playing);
|
|
98
|
+
if (edge === 'play-stop') {
|
|
99
|
+
backend.stop(entity);
|
|
100
|
+
} else if (
|
|
101
|
+
playing &&
|
|
102
|
+
typeof source.volume === 'number' &&
|
|
103
|
+
state.volumes.get(entity) !== source.volume
|
|
104
|
+
) {
|
|
105
|
+
backend.setVolume(entity, source.volume);
|
|
106
|
+
state.volumes.set(entity, source.volume);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
currentEntities.push(entity);
|
|
110
|
+
}
|
|
111
|
+
for (const entity of detectRemovedEntities([...state.previousEntities], currentEntities)) {
|
|
112
|
+
if (state.playing.get(entity) === true) {
|
|
113
|
+
backend.stop(entity);
|
|
114
|
+
}
|
|
115
|
+
state.playing.delete(entity);
|
|
116
|
+
state.volumes.delete(entity);
|
|
117
|
+
}
|
|
118
|
+
state.previousEntities.clear();
|
|
119
|
+
for (const entity of currentEntities) state.previousEntities.add(entity);
|
|
120
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// @forgeax/engine-audio -- AudioSource + AudioListener ECS components (feat-20260527-audio-system M1 / w9)
|
|
2
|
+
//
|
|
3
|
+
// Decision anchors:
|
|
4
|
+
// - requirements S-3 (AudioSource single-component surface, 6 fields)
|
|
5
|
+
// - requirements S-4 (AudioListener independent marker component)
|
|
6
|
+
// - plan-strategy D-4 (edge-detection tick system; AudioSource.playing drives play/stop edges)
|
|
7
|
+
// - plan-strategy D-5 (AudioSource.bus defaults to 'sfx'; BusName is 'sfx' | 'music')
|
|
8
|
+
// - plan-strategy section 3.1 (6-field AudioSource + marker AudioListener)
|
|
9
|
+
// - charter P1 (progressive disclosure: 3-symbol core surface)
|
|
10
|
+
// - charter P4 (consistent abstraction: same defineComponent pattern as Transform/Camera)
|
|
11
|
+
|
|
12
|
+
import { defineComponent } from '@forgeax/engine-ecs';
|
|
13
|
+
|
|
14
|
+
export const AudioSource = defineComponent('AudioSource', {
|
|
15
|
+
clip: { type: 'shared<AudioClipAsset>' },
|
|
16
|
+
playing: { type: 'bool', default: false },
|
|
17
|
+
loop: { type: 'bool', default: false },
|
|
18
|
+
volume: { type: 'f32', default: 1.0 },
|
|
19
|
+
spatialBlend: { type: 'f32', default: 0 },
|
|
20
|
+
bus: { type: 'string', default: 'sfx' },
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export const AudioListener = defineComponent('AudioListener', {});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// @forgeax/engine-audio -- public barrel (feat-20260527-audio-system M1 / w10)
|
|
2
|
+
//
|
|
3
|
+
// Single-entry surface: AI users import `@forgeax/engine-audio` and discover
|
|
4
|
+
// the full audio subsystem surface in one go (charter P1 progressive disclosure).
|
|
5
|
+
//
|
|
6
|
+
// Re-exports from @forgeax/engine-types (error model SSOT):
|
|
7
|
+
// AudioError, AudioErrorCode, AudioErrorDetail, AUDIO_ERROR_HINTS
|
|
8
|
+
//
|
|
9
|
+
// Package-internal exports:
|
|
10
|
+
// AudioBackend interface, BusName, AudioPlayOptions, AudioState,
|
|
11
|
+
// AUDIO_ENGINE_RESOURCE_KEY
|
|
12
|
+
// AudioSource component, AudioListener component
|
|
13
|
+
|
|
14
|
+
export {
|
|
15
|
+
AUDIO_ERROR_HINTS,
|
|
16
|
+
type AudioClipAsset,
|
|
17
|
+
AudioError,
|
|
18
|
+
type AudioErrorCode,
|
|
19
|
+
type AudioErrorDetail,
|
|
20
|
+
} from '@forgeax/engine-types';
|
|
21
|
+
export { audioContribution } from './assets/audio-decoder';
|
|
22
|
+
export type { AudioListenerPose, AudioPlayOptions, AudioState } from './audio-backend';
|
|
23
|
+
export {
|
|
24
|
+
AUDIO_ENGINE_RESOURCE_KEY,
|
|
25
|
+
type AudioBackend,
|
|
26
|
+
type BusName,
|
|
27
|
+
} from './audio-backend';
|
|
28
|
+
export {
|
|
29
|
+
type AudioIntent,
|
|
30
|
+
type AudioIntentBackendOptions,
|
|
31
|
+
audioIntentErrorState,
|
|
32
|
+
createAudioIntentBackend,
|
|
33
|
+
} from './audio-intent';
|
|
34
|
+
export {
|
|
35
|
+
audioTickSystem,
|
|
36
|
+
createClipResolver,
|
|
37
|
+
detectEdge,
|
|
38
|
+
detectRemovedEntities,
|
|
39
|
+
type EdgeAction,
|
|
40
|
+
listenerPoseFromWorldMatrix,
|
|
41
|
+
} from './audio-tick-system';
|
|
42
|
+
export { AudioListener, AudioSource } from './components';
|
|
43
|
+
export { AUDIO_TICK_SYSTEM_NAME, audioPlugin } from './plugin-factory';
|
|
44
|
+
export { audioBackendPlugin } from './plugin-service';
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { type Component, Update, type World } from '@forgeax/engine-ecs';
|
|
2
|
+
import type { Plugin } from '@forgeax/engine-plugin';
|
|
3
|
+
import { PROPAGATE_TRANSFORMS_SYSTEM, Transform } from '@forgeax/engine-scene';
|
|
4
|
+
import { AUDIO_ENGINE_RESOURCE_KEY } from './audio-backend';
|
|
5
|
+
import { audioTickSystem, listenerPoseFromWorldMatrix } from './audio-tick-system';
|
|
6
|
+
import { AudioListener, AudioSource } from './components';
|
|
7
|
+
|
|
8
|
+
export const AUDIO_TICK_SYSTEM_NAME = 'audio-tick' as const;
|
|
9
|
+
|
|
10
|
+
const AUDIO_COMPONENTS: readonly Component[] = [AudioSource, AudioListener];
|
|
11
|
+
|
|
12
|
+
function registerAudioComponents(world: World): () => void {
|
|
13
|
+
const leases = AUDIO_COMPONENTS.map((component) => world.components.register(component).unwrap());
|
|
14
|
+
return () => {
|
|
15
|
+
for (let index = leases.length - 1; index >= 0; index -= 1) leases[index]?.dispose();
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function audioPlugin(): Plugin {
|
|
20
|
+
return {
|
|
21
|
+
name: 'audio',
|
|
22
|
+
inject: ['world', 'audio'],
|
|
23
|
+
apply(ctx) {
|
|
24
|
+
const world = ctx.world;
|
|
25
|
+
const backend = ctx.audio;
|
|
26
|
+
if (backend === undefined) throw new Error('Cordis activated audio without its provider');
|
|
27
|
+
ctx.effect(() => registerAudioComponents(world), 'audio/components');
|
|
28
|
+
ctx.effect(() => {
|
|
29
|
+
world.insertResource(AUDIO_ENGINE_RESOURCE_KEY, backend);
|
|
30
|
+
return () => {
|
|
31
|
+
world.removeResource(AUDIO_ENGINE_RESOURCE_KEY);
|
|
32
|
+
};
|
|
33
|
+
}, 'audio/resource');
|
|
34
|
+
ctx.effect(() => {
|
|
35
|
+
world
|
|
36
|
+
.addSystem(Update, {
|
|
37
|
+
name: AUDIO_TICK_SYSTEM_NAME,
|
|
38
|
+
queries: [],
|
|
39
|
+
fn: () => audioTickSystem(world, backend),
|
|
40
|
+
})
|
|
41
|
+
.unwrap();
|
|
42
|
+
return () => world.removeSystem(Update, AUDIO_TICK_SYSTEM_NAME);
|
|
43
|
+
}, 'audio/tick');
|
|
44
|
+
ctx.effect(() => {
|
|
45
|
+
world
|
|
46
|
+
.addSystem(Update, {
|
|
47
|
+
name: 'audio-listener-sync',
|
|
48
|
+
after: [PROPAGATE_TRANSFORMS_SYSTEM],
|
|
49
|
+
queries: [],
|
|
50
|
+
fn: () => {
|
|
51
|
+
const listeners = world.query({ read: [Transform], with: [AudioListener] });
|
|
52
|
+
if (!listeners.ok) return;
|
|
53
|
+
for (const row of listeners.value) {
|
|
54
|
+
const transform = row.get(Transform);
|
|
55
|
+
const pose = listenerPoseFromWorldMatrix(transform.world);
|
|
56
|
+
backend.setListenerPose(pose);
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
})
|
|
61
|
+
.unwrap();
|
|
62
|
+
return () => world.removeSystem(Update, 'audio-listener-sync');
|
|
63
|
+
}, 'audio/listener-sync');
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Plugin } from '@forgeax/engine-plugin';
|
|
2
|
+
|
|
3
|
+
import type { AudioBackend } from './audio-backend';
|
|
4
|
+
|
|
5
|
+
declare module '@forgeax/engine-plugin' {
|
|
6
|
+
interface EngineContextServices {
|
|
7
|
+
audio?: AudioBackend;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function audioBackendPlugin(backend: AudioBackend): Plugin {
|
|
12
|
+
return {
|
|
13
|
+
name: 'audio-backend',
|
|
14
|
+
provide: 'audio',
|
|
15
|
+
apply(ctx) {
|
|
16
|
+
ctx.provide('audio', backend);
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
}
|