@waica/engine 0.11.0 → 0.13.0

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.
@@ -16,12 +16,21 @@ export interface ArchetypeArt {
16
16
  file: string;
17
17
  /** Registry URI resolved by the archetype at runtime. */
18
18
  uri: string;
19
+ /** What kind of asset this is — a sprite sheet or texture, or a sound file. */
20
+ kind: 'image' | 'sound';
19
21
  }
20
22
  /** The conventional contract exported by every archetype package. */
21
23
  export interface ArchetypeManifest {
22
24
  id: string;
23
25
  label: string;
24
26
  scene: SceneJson;
27
+ /**
28
+ * Additional demo scenes beyond `scene` (its name is always "main"),
29
+ * keyed by name — the isometric archetype's second demo scene (CA-13).
30
+ * `start:blank` never emits these; `start:demo` emits one file per entry
31
+ * alongside `scene`.
32
+ */
33
+ extraScenes?: Readonly<Record<string, SceneJson>>;
25
34
  blankScene: SceneJson;
26
35
  registry: SceneRegistry;
27
36
  palette: EntityTemplate[];
@@ -33,6 +42,15 @@ export interface ArchetypeManifest {
33
42
  bundle: ArchetypeBundle;
34
43
  /** Directional animation contract, for genres where characters face around. */
35
44
  animation?: DirectionalAnimation;
45
+ /**
46
+ * The archetype's own looping music bed, as a "waica:" registry uri —
47
+ * absent for archetypes that ship no music (G8). A host starts it itself
48
+ * (`game.audio.play(manifest.music, { channel: 'music', loop: true, scope:
49
+ * 'session' })`) when present; `installArchetype(bundle)` runs before
50
+ * `new Game(...)` exists, so the bundle has no `game` to call, and this
51
+ * field is what tells the host what to ask for instead.
52
+ */
53
+ music?: string;
36
54
  }
37
55
  /** Browser manifest enriched with URLs produced by an asset-aware bundler. */
38
56
  export interface BrowserArchetypeManifest extends ArchetypeManifest {
@@ -0,0 +1,141 @@
1
+ import type { AudioBackend } from './backend.js';
2
+ import type { AudioChannelState, AudioPlayOptions, LiveSoundInfo, SoundHandle } from './types.js';
3
+ export interface AudioSubsystemOptions {
4
+ /** The game's canvas — the audio unlock listens for a pointerdown on it (CA-6). */
5
+ canvas: HTMLCanvasElement;
6
+ /** Replaces the real WebAudio implementation (ADR 0013); defaults to it. */
7
+ backend?: AudioBackend;
8
+ /**
9
+ * Resolves a uri the same way the scene loader resolves every prefab's
10
+ * string prop (`resolveProps` in scene.ts), but for direct `play()`/
11
+ * `preload()` calls — a project role or the host, not a spawned prefab.
12
+ * Looked up on every call rather than captured once, since `Game` wires
13
+ * this to its registered scene catalog, which can be (re)registered at
14
+ * any time and — unlike `Game.registry` — survives `unloadScene()`.
15
+ * Defaults to identity, so an unresolvable or already-resolved uri (a
16
+ * pre-resolving caller) passes through unchanged either way.
17
+ */
18
+ resolveAsset?: (uri: string) => string;
19
+ }
20
+ /**
21
+ * The engine's audio mixer (`game.audio`). Talks to WebAudio only through
22
+ * the AudioBackend seam (ADR 0013), so the whole contract is assertable in
23
+ * `happy-dom` against an injected fake. A sound dies with its scene unless
24
+ * it says `{ scope: 'session' }` — the opposite default from GameUi, on
25
+ * purpose (ADR 0012); `unloadScene()` is Game's hook for that (CA-7).
26
+ * `updatePlacements()` is Game's per-frame hook for positional audio (CA-8).
27
+ */
28
+ export declare class AudioSubsystem {
29
+ private readonly backend;
30
+ private readonly canvas;
31
+ private readonly resolveAsset;
32
+ private readonly channelsMap;
33
+ private readonly live;
34
+ /**
35
+ * Loops requested before unlock (defect: a music bed started at boot,
36
+ * before any gesture, used to be discarded forever). Each is already in
37
+ * `live` and already has a real handle; only its attach() to the backend
38
+ * is deferred until the unlock latch flips. A non-looping call before
39
+ * unlock is still discarded exactly as before — see `play()`.
40
+ */
41
+ private readonly pendingUnlock;
42
+ private readonly resourceStates;
43
+ private readonly resourcePromises;
44
+ private masterVolume;
45
+ private active;
46
+ private silenced;
47
+ private unlocked;
48
+ /** Whether the backend was last told to be resumed (true) or suspended (false). */
49
+ private outputLive;
50
+ constructor(options: AudioSubsystemOptions);
51
+ /**
52
+ * Starts a sound. Before the first unlock (CA-6), a one-shot registers
53
+ * nothing and touches no backend at all — the returned handle just
54
+ * reports `playing: false` forever. A *looping* call is different: it is
55
+ * remembered (a deliberate, singular bed, unlike a burst of one-shots
56
+ * that would all fire at once and sound broken) and started once the
57
+ * unlock happens, on this same handle — nothing reaches the backend
58
+ * until then either way. `opts.at` (CA-8, positional audio) sets up
59
+ * placement tracking; Game calls updatePlacements() once per frame to
60
+ * actually compute pan/attenuation and forward them to the backend.
61
+ */
62
+ play(uri: string, opts?: AudioPlayOptions): SoundHandle;
63
+ /** Every channel name, factory and runtime-created, in creation order. */
64
+ channels(): string[];
65
+ /** A channel's current volume/mute. Reading an unnamed channel reports defaults without creating it. */
66
+ channelState(name: string): AudioChannelState;
67
+ setChannelVolume(name: string, volume: number): void;
68
+ /** Silences (or restores) every sound on the channel without stopping them. */
69
+ setChannelMuted(name: string, muted: boolean): void;
70
+ get master(): number;
71
+ /** Scales every channel's effective output. */
72
+ set master(value: number);
73
+ /**
74
+ * Fetches and decodes every uri ahead of time. Resolves even if one (or
75
+ * all) of them fail to load — each failure still only warns once, the
76
+ * same as a failing play() (CA-9).
77
+ */
78
+ preload(uris: string[]): Promise<void>;
79
+ /**
80
+ * Every sound currently registered with the subsystem — not "every sound
81
+ * currently audible". A sound enters this set in play() and leaves only
82
+ * when the backend reports onEnded or drop() removes it, so it also
83
+ * reports: a loop retained before the autoplay unlock (no backend touched
84
+ * yet, CA-6); a sound whose load() has not resolved yet; and even one
85
+ * whose fetch/decode is about to fail, which drops out a tick later once
86
+ * attach() catches up and calls drop(). Sorted by uri then channel.
87
+ */
88
+ liveSounds(): LiveSoundInfo[];
89
+ /** Called by Game from runFrame: the editor's pause suspends output without touching sounds in flight (CA-4). */
90
+ setActive(active: boolean): void;
91
+ /**
92
+ * Called by Game when a Runtime Bridge registers/unregisters (CA-5).
93
+ * Registering also counts as an unlock: the Runtime Bridge drives the
94
+ * game programmatically (injectAction/injectClick), never through a real
95
+ * trusted keydown/pointerdown, so CA-6's gesture-gate would otherwise
96
+ * discard every sound for the whole life of an automated run. Silencing
97
+ * keeps the same run producing no audio output regardless.
98
+ */
99
+ setSilenced(silenced: boolean): void;
100
+ /**
101
+ * Stops every scene-scoped sound; a sound started with `{ scope: 'session'
102
+ * }` keeps playing, untouched (CA-7, ADR 0012). Called by Game.unloadScene().
103
+ */
104
+ unloadScene(): void;
105
+ /**
106
+ * Recomputes pan and distance attenuation for every live sound started
107
+ * with `at` (CA-8) — called by Game once per frame. `listener` and every
108
+ * placement's source position are logical coordinates, so attenuation
109
+ * reflects real game distance; `toRenderSpace` (`game.renderPoint`)
110
+ * converts both to render space for panning, so an isometric source that
111
+ * reads to the right on screen pans right regardless of its logical
112
+ * distance. A sound with no placement (a flat sound) is never touched.
113
+ */
114
+ updatePlacements(listener: {
115
+ x: number;
116
+ y: number;
117
+ }, toRenderSpace: (x: number, y: number) => {
118
+ x: number;
119
+ y: number;
120
+ }): void;
121
+ /** Stops every live sound — including session-scoped ones — and closes the backend (CA-10). */
122
+ dispose(): void;
123
+ private ensureChannel;
124
+ private attach;
125
+ private startPlayback;
126
+ private drop;
127
+ private ensureLoading;
128
+ private stopSound;
129
+ private handleFor;
130
+ private handleUnlockEvent;
131
+ /**
132
+ * Flips the one-way unlock latch, detaches the DOM listeners, and releases
133
+ * every loop that was retained while locked (the boot-music-bed fix) —
134
+ * without syncing output. Callers decide when to sync so a silencing
135
+ * change arriving in the same call (setSilenced) composes into a single,
136
+ * correct resume/suspend decision instead of a spurious resume-then-
137
+ * suspend pair.
138
+ */
139
+ private markUnlocked;
140
+ private syncOutput;
141
+ }
@@ -0,0 +1,398 @@
1
+ import { Entity } from '../entity.js';
2
+ import { attenuationForDistance, panForOffset } from './spatial.js';
3
+ import { WebAudioBackend } from './web-audio-backend.js';
4
+ function resolvePlacement(at) {
5
+ if (!at)
6
+ return null;
7
+ if (at instanceof Entity)
8
+ return { kind: 'entity', entity: at };
9
+ return { kind: 'point', x: at.x, y: at.y };
10
+ }
11
+ const FACTORY_CHANNELS = ['music', 'sfx'];
12
+ /**
13
+ * The engine's audio mixer (`game.audio`). Talks to WebAudio only through
14
+ * the AudioBackend seam (ADR 0013), so the whole contract is assertable in
15
+ * `happy-dom` against an injected fake. A sound dies with its scene unless
16
+ * it says `{ scope: 'session' }` — the opposite default from GameUi, on
17
+ * purpose (ADR 0012); `unloadScene()` is Game's hook for that (CA-7).
18
+ * `updatePlacements()` is Game's per-frame hook for positional audio (CA-8).
19
+ */
20
+ export class AudioSubsystem {
21
+ backend;
22
+ canvas;
23
+ resolveAsset;
24
+ channelsMap = new Map();
25
+ live = new Set();
26
+ /**
27
+ * Loops requested before unlock (defect: a music bed started at boot,
28
+ * before any gesture, used to be discarded forever). Each is already in
29
+ * `live` and already has a real handle; only its attach() to the backend
30
+ * is deferred until the unlock latch flips. A non-looping call before
31
+ * unlock is still discarded exactly as before — see `play()`.
32
+ */
33
+ pendingUnlock = new Set();
34
+ resourceStates = new Map();
35
+ resourcePromises = new Map();
36
+ masterVolume = 1;
37
+ active = true;
38
+ silenced = false;
39
+ unlocked = false;
40
+ /** Whether the backend was last told to be resumed (true) or suspended (false). */
41
+ outputLive = false;
42
+ constructor(options) {
43
+ this.backend = options.backend ?? new WebAudioBackend();
44
+ this.canvas = options.canvas;
45
+ this.resolveAsset = options.resolveAsset ?? ((uri) => uri);
46
+ for (const name of FACTORY_CHANNELS)
47
+ this.channelsMap.set(name, { volume: 1, muted: false });
48
+ window.addEventListener('keydown', this.handleUnlockEvent);
49
+ this.canvas.addEventListener('pointerdown', this.handleUnlockEvent);
50
+ }
51
+ /**
52
+ * Starts a sound. Before the first unlock (CA-6), a one-shot registers
53
+ * nothing and touches no backend at all — the returned handle just
54
+ * reports `playing: false` forever. A *looping* call is different: it is
55
+ * remembered (a deliberate, singular bed, unlike a burst of one-shots
56
+ * that would all fire at once and sound broken) and started once the
57
+ * unlock happens, on this same handle — nothing reaches the backend
58
+ * until then either way. `opts.at` (CA-8, positional audio) sets up
59
+ * placement tracking; Game calls updatePlacements() once per frame to
60
+ * actually compute pan/attenuation and forward them to the backend.
61
+ */
62
+ play(uri, opts = {}) {
63
+ const resolvedUri = this.resolveAsset(uri);
64
+ const channel = opts.channel ?? 'sfx';
65
+ const volume = opts.volume ?? 1;
66
+ const loop = opts.loop ?? false;
67
+ const scope = opts.scope === 'session' ? 'session' : 'scene';
68
+ const placement = resolvePlacement(opts.at);
69
+ this.ensureChannel(channel);
70
+ if (!this.unlocked && !loop)
71
+ return inertHandle(volume);
72
+ const sound = {
73
+ uri: resolvedUri,
74
+ channel,
75
+ scope,
76
+ loop,
77
+ volume,
78
+ attenuation: 1,
79
+ pan: 0,
80
+ placement,
81
+ ended: false,
82
+ backendHandle: null,
83
+ fading: false,
84
+ };
85
+ this.live.add(sound);
86
+ if (this.unlocked)
87
+ this.attach(resolvedUri, sound);
88
+ else
89
+ this.pendingUnlock.add(sound);
90
+ return this.handleFor(sound);
91
+ }
92
+ /** Every channel name, factory and runtime-created, in creation order. */
93
+ channels() {
94
+ return [...this.channelsMap.keys()];
95
+ }
96
+ /** A channel's current volume/mute. Reading an unnamed channel reports defaults without creating it. */
97
+ channelState(name) {
98
+ const state = this.channelsMap.get(name);
99
+ return state ? { ...state } : { volume: 1, muted: false };
100
+ }
101
+ setChannelVolume(name, volume) {
102
+ this.ensureChannel(name).volume = volume;
103
+ this.backend.setChannelVolume(name, volume);
104
+ }
105
+ /** Silences (or restores) every sound on the channel without stopping them. */
106
+ setChannelMuted(name, muted) {
107
+ this.ensureChannel(name).muted = muted;
108
+ this.backend.setChannelMuted(name, muted);
109
+ }
110
+ get master() {
111
+ return this.masterVolume;
112
+ }
113
+ /** Scales every channel's effective output. */
114
+ set master(value) {
115
+ this.masterVolume = value;
116
+ this.backend.setMasterVolume(value);
117
+ }
118
+ /**
119
+ * Fetches and decodes every uri ahead of time. Resolves even if one (or
120
+ * all) of them fail to load — each failure still only warns once, the
121
+ * same as a failing play() (CA-9).
122
+ */
123
+ async preload(uris) {
124
+ await Promise.all(uris.map((uri) => this.ensureLoading(this.resolveAsset(uri))));
125
+ }
126
+ /**
127
+ * Every sound currently registered with the subsystem — not "every sound
128
+ * currently audible". A sound enters this set in play() and leaves only
129
+ * when the backend reports onEnded or drop() removes it, so it also
130
+ * reports: a loop retained before the autoplay unlock (no backend touched
131
+ * yet, CA-6); a sound whose load() has not resolved yet; and even one
132
+ * whose fetch/decode is about to fail, which drops out a tick later once
133
+ * attach() catches up and calls drop(). Sorted by uri then channel.
134
+ */
135
+ liveSounds() {
136
+ return [...this.live]
137
+ .map(({ uri, channel, scope }) => ({ uri, channel, scope }))
138
+ .sort((a, b) => (a.uri === b.uri ? a.channel.localeCompare(b.channel) : a.uri.localeCompare(b.uri)));
139
+ }
140
+ /** Called by Game from runFrame: the editor's pause suspends output without touching sounds in flight (CA-4). */
141
+ setActive(active) {
142
+ if (this.active === active)
143
+ return;
144
+ this.active = active;
145
+ this.syncOutput();
146
+ }
147
+ /**
148
+ * Called by Game when a Runtime Bridge registers/unregisters (CA-5).
149
+ * Registering also counts as an unlock: the Runtime Bridge drives the
150
+ * game programmatically (injectAction/injectClick), never through a real
151
+ * trusted keydown/pointerdown, so CA-6's gesture-gate would otherwise
152
+ * discard every sound for the whole life of an automated run. Silencing
153
+ * keeps the same run producing no audio output regardless.
154
+ */
155
+ setSilenced(silenced) {
156
+ if (silenced)
157
+ this.markUnlocked();
158
+ if (this.silenced === silenced)
159
+ return;
160
+ this.silenced = silenced;
161
+ this.syncOutput();
162
+ }
163
+ /**
164
+ * Stops every scene-scoped sound; a sound started with `{ scope: 'session'
165
+ * }` keeps playing, untouched (CA-7, ADR 0012). Called by Game.unloadScene().
166
+ */
167
+ unloadScene() {
168
+ for (const sound of [...this.live]) {
169
+ if (sound.scope === 'session')
170
+ continue;
171
+ this.stopSound(sound, undefined);
172
+ }
173
+ }
174
+ /**
175
+ * Recomputes pan and distance attenuation for every live sound started
176
+ * with `at` (CA-8) — called by Game once per frame. `listener` and every
177
+ * placement's source position are logical coordinates, so attenuation
178
+ * reflects real game distance; `toRenderSpace` (`game.renderPoint`)
179
+ * converts both to render space for panning, so an isometric source that
180
+ * reads to the right on screen pans right regardless of its logical
181
+ * distance. A sound with no placement (a flat sound) is never touched.
182
+ */
183
+ updatePlacements(listener, toRenderSpace) {
184
+ if (this.live.size === 0)
185
+ return;
186
+ let listenerRender = null;
187
+ for (const sound of this.live) {
188
+ if (sound.fading)
189
+ continue;
190
+ const placement = sound.placement;
191
+ if (!placement)
192
+ continue;
193
+ const source = placement.kind === 'entity'
194
+ ? { x: placement.entity.position.x, y: placement.entity.position.y }
195
+ : placement;
196
+ sound.attenuation = attenuationForDistance(Math.hypot(source.x - listener.x, source.y - listener.y));
197
+ listenerRender ??= toRenderSpace(listener.x, listener.y);
198
+ const sourceRender = toRenderSpace(source.x, source.y);
199
+ sound.pan = panForOffset(sourceRender.x - listenerRender.x);
200
+ sound.backendHandle?.setVolume(sound.volume * sound.attenuation);
201
+ sound.backendHandle?.setPan(sound.pan);
202
+ }
203
+ }
204
+ /** Stops every live sound — including session-scoped ones — and closes the backend (CA-10). */
205
+ dispose() {
206
+ window.removeEventListener('keydown', this.handleUnlockEvent);
207
+ this.canvas.removeEventListener('pointerdown', this.handleUnlockEvent);
208
+ for (const sound of [...this.live]) {
209
+ sound.ended = true;
210
+ sound.backendHandle?.stop();
211
+ }
212
+ this.live.clear();
213
+ this.pendingUnlock.clear();
214
+ this.backend.close();
215
+ }
216
+ ensureChannel(name) {
217
+ let state = this.channelsMap.get(name);
218
+ if (!state) {
219
+ state = { volume: 1, muted: false };
220
+ this.channelsMap.set(name, state);
221
+ }
222
+ return state;
223
+ }
224
+ attach(uri, sound) {
225
+ const state = this.resourceStates.get(uri);
226
+ if (state?.status === 'ready') {
227
+ this.startPlayback(sound, state.resource);
228
+ return;
229
+ }
230
+ if (state?.status === 'failed') {
231
+ this.drop(sound);
232
+ return;
233
+ }
234
+ this.ensureLoading(uri)
235
+ .then(() => {
236
+ if (sound.ended)
237
+ return;
238
+ const resolved = this.resourceStates.get(uri);
239
+ if (resolved?.status === 'ready')
240
+ this.startPlayback(sound, resolved.resource);
241
+ else
242
+ this.drop(sound);
243
+ })
244
+ .catch(() => {
245
+ // ensureLoading never rejects; this is defensive, never expected to run.
246
+ this.drop(sound);
247
+ });
248
+ }
249
+ startPlayback(sound, resource) {
250
+ sound.backendHandle = this.backend.play(resource, {
251
+ channel: sound.channel,
252
+ volume: sound.volume * sound.attenuation,
253
+ loop: sound.loop,
254
+ onEnded: () => {
255
+ if (sound.ended)
256
+ return;
257
+ sound.ended = true;
258
+ this.live.delete(sound);
259
+ },
260
+ });
261
+ // A placement update may have already run while this sound was still
262
+ // loading (CA-8) — carry its pan over now that a backend handle exists.
263
+ if (sound.placement)
264
+ sound.backendHandle.setPan(sound.pan);
265
+ }
266
+ drop(sound) {
267
+ sound.ended = true;
268
+ this.live.delete(sound);
269
+ }
270
+ ensureLoading(uri) {
271
+ const existing = this.resourcePromises.get(uri);
272
+ if (existing)
273
+ return existing;
274
+ this.resourceStates.set(uri, { status: 'pending' });
275
+ const promise = this.backend.load(uri).then((resource) => {
276
+ this.resourceStates.set(uri, { status: 'ready', resource });
277
+ }, (error) => {
278
+ console.warn(`[waica] audio: failed to load "${uri}"`, error);
279
+ this.resourceStates.set(uri, { status: 'failed' });
280
+ });
281
+ this.resourcePromises.set(uri, promise);
282
+ return promise;
283
+ }
284
+ stopSound(sound, fadeMs) {
285
+ if (sound.ended)
286
+ return;
287
+ if (!sound.backendHandle) {
288
+ // Still loading: nothing audible exists yet to fade, so cancel
289
+ // outright — it must never start once the load resolves.
290
+ this.drop(sound);
291
+ return;
292
+ }
293
+ if (fadeMs && this.outputLive) {
294
+ sound.fading = true;
295
+ sound.backendHandle.stop(fadeMs);
296
+ // playing stays true until the backend's onEnded fires, once the ramp completes.
297
+ }
298
+ else {
299
+ // Either no fade was requested, or output is currently suspended
300
+ // (setActive(false) / setSilenced(true)): the real backend schedules
301
+ // the ramp and the source's stop() against context.currentTime, which
302
+ // does not advance while suspended, so neither would ever come due —
303
+ // onEnded would never fire and the sound would stay `playing: true`
304
+ // forever. Falling back to an immediate stop sidesteps that: this
305
+ // branch never waits for the backend's onEnded anyway, it finishes
306
+ // the sound in the model synchronously right here.
307
+ sound.ended = true;
308
+ this.live.delete(sound);
309
+ sound.backendHandle.stop();
310
+ }
311
+ }
312
+ handleFor(sound) {
313
+ const subsystem = this;
314
+ return {
315
+ get playing() {
316
+ return !sound.ended;
317
+ },
318
+ get volume() {
319
+ return sound.volume;
320
+ },
321
+ set volume(value) {
322
+ sound.volume = value;
323
+ // While a fade-out ramp is running (fading), skip the backend write:
324
+ // a plain gain assignment is equivalent to a setValueAtTime inserted
325
+ // before the ramp's end (Web Audio spec), which jumps the gain back
326
+ // up and only then resumes descending — cutting the fade short,
327
+ // same mechanism updatePlacements() already guards against. The
328
+ // stored value above is updated regardless, so the sound reads back
329
+ // correctly however long it stays `fading` before it truly ends.
330
+ if (!sound.fading)
331
+ sound.backendHandle?.setVolume(value * sound.attenuation);
332
+ },
333
+ stop(opts = {}) {
334
+ subsystem.stopSound(sound, opts.fadeMs);
335
+ },
336
+ };
337
+ }
338
+ handleUnlockEvent = () => {
339
+ this.markUnlocked();
340
+ this.syncOutput();
341
+ };
342
+ /**
343
+ * Flips the one-way unlock latch, detaches the DOM listeners, and releases
344
+ * every loop that was retained while locked (the boot-music-bed fix) —
345
+ * without syncing output. Callers decide when to sync so a silencing
346
+ * change arriving in the same call (setSilenced) composes into a single,
347
+ * correct resume/suspend decision instead of a spurious resume-then-
348
+ * suspend pair.
349
+ */
350
+ markUnlocked() {
351
+ if (this.unlocked)
352
+ return;
353
+ this.unlocked = true;
354
+ window.removeEventListener('keydown', this.handleUnlockEvent);
355
+ this.canvas.removeEventListener('pointerdown', this.handleUnlockEvent);
356
+ const pending = [...this.pendingUnlock];
357
+ this.pendingUnlock.clear();
358
+ for (const sound of pending) {
359
+ // stop() while still pending drops the sound outright (no backend
360
+ // handle to fade) and marks it ended — it must never start.
361
+ if (sound.ended)
362
+ continue;
363
+ this.attach(sound.uri, sound);
364
+ }
365
+ }
366
+ syncOutput() {
367
+ const desired = this.unlocked && this.active && !this.silenced;
368
+ if (desired === this.outputLive)
369
+ return;
370
+ this.outputLive = desired;
371
+ if (desired)
372
+ this.backend.resume();
373
+ else
374
+ this.backend.suspend();
375
+ }
376
+ }
377
+ /**
378
+ * Returned by play() for a non-looping call made before the first unlock:
379
+ * registers nothing in `live`, touches no backend, and reports `playing`
380
+ * false forever. A uri already known to have failed takes a different path
381
+ * — it still registers a real sound (attach() -> drop()), so play() returns
382
+ * a handleFor() handle whose `sound.ended` is already true instead of this one.
383
+ */
384
+ function inertHandle(initialVolume) {
385
+ let volume = initialVolume;
386
+ return {
387
+ get playing() {
388
+ return false;
389
+ },
390
+ get volume() {
391
+ return volume;
392
+ },
393
+ set volume(value) {
394
+ volume = value;
395
+ },
396
+ stop() { },
397
+ };
398
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * A decoded, backend-specific playback resource (e.g. a real AudioBuffer).
3
+ * Opaque to the audio subsystem: it caches these by uri and hands them back
4
+ * to `play()` unchanged.
5
+ */
6
+ export type AudioResource = unknown;
7
+ export interface BackendPlayOptions {
8
+ /** Mixer channel this sound plays through (e.g. 'sfx', 'music'). */
9
+ channel: string;
10
+ /** The sound's own gain, independent of the channel/master mix. */
11
+ volume: number;
12
+ loop: boolean;
13
+ /**
14
+ * Called exactly once, when the sound truly stops producing audio:
15
+ * reaching its natural end, or after stop()/a fade-out ramp completes.
16
+ * Never called for a sound still looping.
17
+ */
18
+ onEnded: () => void;
19
+ }
20
+ export interface BackendPlayHandle {
21
+ /** Changes the sound's own gain while it plays. */
22
+ setVolume(volume: number): void;
23
+ /**
24
+ * Sets the stereo pan in [-1, 1] (-1 fully left, 0 centered, 1 fully
25
+ * right). Only ever called for a sound started with `at` (CA-8); a flat
26
+ * sound never receives a call.
27
+ */
28
+ setPan(pan: number): void;
29
+ /**
30
+ * Stops the sound. With no fadeMs, releases immediately (onEnded still
31
+ * fires, but the caller doesn't wait for it). With fadeMs, ramps gain to
32
+ * zero over that many milliseconds before releasing — onEnded fires once
33
+ * the ramp completes, not before. Idempotent.
34
+ */
35
+ stop(fadeMs?: number): void;
36
+ }
37
+ /**
38
+ * The seam ADR 0013 exists for: everything Game needs from WebAudio,
39
+ * abstracted so `happy-dom` — which has no AudioContext, AudioBuffer or
40
+ * GainNode at all — can exercise the whole audio contract against an
41
+ * injected fake. The real implementation (see web-audio-backend.ts) is the
42
+ * default; a host replaces it via `GameOptions.audio`, e.g. to test its own
43
+ * project's audio without a browser.
44
+ */
45
+ export interface AudioBackend {
46
+ /**
47
+ * Fetches and decodes a uri into an opaque resource. The subsystem calls
48
+ * this at most once per uri — the result is cached and reused.
49
+ */
50
+ load(uri: string): Promise<AudioResource>;
51
+ /** Starts playing a decoded resource. */
52
+ play(resource: AudioResource, options: BackendPlayOptions): BackendPlayHandle;
53
+ /** Sets a channel's own gain (independent of mute). */
54
+ setChannelVolume(channel: string, volume: number): void;
55
+ /** Silences (true) or restores (false) every sound on a channel without stopping them. */
56
+ setChannelMuted(channel: string, muted: boolean): void;
57
+ /** Scales every channel's effective output. */
58
+ setMasterVolume(volume: number): void;
59
+ /** Freezes all output; sounds in flight are neither stopped nor restarted. */
60
+ suspend(): void;
61
+ /**
62
+ * Resumes output. The real implementation lazily creates its underlying
63
+ * AudioContext here (or at load()) — never eagerly, and never in a
64
+ * constructor — so nothing touches the device before an unlock (CA-6).
65
+ */
66
+ resume(): void;
67
+ /** Stops everything permanently and releases the underlying context. */
68
+ close(): void;
69
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Engine constants for CA-8 positional audio. There are no public tuning
3
+ * knobs (spec decision 20): the curve is picked once, in the spirit of
4
+ * `CAMERA_DEFAULTS` (`camera.ts`), and frozen — CA-21 is the human-only
5
+ * game-feel pass that may revisit these numbers by ear, never a per-call or
6
+ * per-channel option.
7
+ *
8
+ * The attenuation curve mirrors WebAudio's own `PannerNode` "linear"
9
+ * distance model: full volume at or inside `referenceDistance`, a straight
10
+ * ramp down to silence at `maxDistance`, silent beyond it. `panDistance` is
11
+ * the render-space horizontal offset (world units) that reaches full
12
+ * left/right pan.
13
+ */
14
+ export declare const AUDIO_SPATIAL_DEFAULTS: {
15
+ readonly referenceDistance: 3;
16
+ readonly maxDistance: 16;
17
+ readonly panDistance: 6;
18
+ };
19
+ /** 1 at/inside referenceDistance, 0 at/beyond maxDistance, linear in between. */
20
+ export declare function attenuationForDistance(distance: number): number;
21
+ /** Maps a render-space horizontal offset (source minus listener) to a [-1, 1] stereo pan. */
22
+ export declare function panForOffset(dx: number): number;