@jgengine/shell 0.31.0 → 0.33.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.
- package/CHANGELOG.md +67 -0
- package/dist/GamePlayerShell.js +7 -0
- package/dist/Shell3dPresentation.js +6 -4
- package/dist/ShellChrome.d.ts +3 -1
- package/dist/ShellChrome.js +3 -2
- package/dist/ShellHudPresentation.js +1 -1
- package/dist/audio/AudioComponents.js +61 -51
- package/dist/audio/CaptionOverlay.d.ts +15 -0
- package/dist/audio/CaptionOverlay.js +50 -0
- package/dist/audio/FeedbackHost.d.ts +14 -0
- package/dist/audio/FeedbackHost.js +46 -0
- package/dist/audio/audioEngine.d.ts +25 -0
- package/dist/audio/audioEngine.js +267 -32
- package/dist/audio/audioProbe.d.ts +22 -0
- package/dist/audio/audioProbe.js +20 -0
- package/dist/audio/busMix.d.ts +4 -0
- package/dist/audio/busMix.js +7 -0
- package/dist/audio/captions.d.ts +48 -0
- package/dist/audio/captions.js +77 -0
- package/dist/audio/emitterTracking.d.ts +40 -0
- package/dist/audio/emitterTracking.js +153 -0
- package/dist/audio/eventFeedback.d.ts +137 -0
- package/dist/audio/eventFeedback.js +273 -0
- package/dist/audio/gestureResume.d.ts +10 -0
- package/dist/audio/gestureResume.js +25 -0
- package/dist/audio/hapticsRoute.d.ts +28 -0
- package/dist/audio/hapticsRoute.js +59 -0
- package/dist/audio/loopParams.js +20 -10
- package/dist/audio/playableAudio.d.ts +1 -1
- package/dist/audio/playableAudio.js +3 -1
- package/dist/devtools/FrameTelemetry.d.ts +1 -1
- package/dist/devtools/FrameTelemetry.js +4 -2
- package/dist/devtools/agentBridge.js +13 -0
- package/dist/devtools/frameSampler.js +4 -0
- package/dist/drivers/CanvasCoverFrameloop.d.ts +13 -0
- package/dist/drivers/CanvasCoverFrameloop.js +22 -0
- package/dist/drivers/FrameRateLimiter.d.ts +2 -1
- package/dist/drivers/FrameRateLimiter.js +6 -3
- package/dist/postfx/PostProcessing.d.ts +78 -2
- package/dist/postfx/PostProcessing.js +149 -76
- package/dist/postfx/postfxOverlay.d.ts +14 -4
- package/dist/postfx/postfxOverlay.js +90 -10
- package/dist/postfx/shadowPrepass.d.ts +6 -2
- package/dist/postfx/shadowPrepass.js +7 -3
- package/dist/render/characterLod.js +6 -0
- package/dist/render/useFootIk.js +54 -15
- package/dist/render/useModelAnimation.js +22 -5
- package/dist/settings/appliedSettings.js +3 -0
- package/dist/settings/settingsController.d.ts +10 -1
- package/dist/settings/settingsController.js +94 -67
- package/dist/vision/RevealVision.d.ts +3 -1
- package/dist/vision/RevealVision.js +34 -15
- package/dist/world/WorldItems.js +11 -3
- package/package.json +4 -4
- package/skills/jgengine/api.md +3 -2
- package/skills/jgengine-ui/SKILL.md +1 -0
- package/skills/jgengine-ui/api.md +94 -24
- package/skills/jgengine-ui/capabilities.md +40 -0
- package/skills/jgengine-ui/reference-primitives.md +2 -0
- package/skills/jgengine-ui/reference.md +1 -1
- package/skills/jgengine-world/api.md +18 -8
- package/skills/jgengine-world/capabilities.md +4 -0
|
@@ -9,15 +9,24 @@ import { createNoiseBuffer, realizeSynthPatch } from "./synthEngine.js";
|
|
|
9
9
|
/** setTargetAtTime time constant (~20 ms) for zipper-free live rate/gain ramps on retained loops (#1051). */
|
|
10
10
|
const LOOP_PARAM_SMOOTH_TC = 0.02;
|
|
11
11
|
const ZERO_VELOCITY = { x: 0, y: 0, z: 0 };
|
|
12
|
+
/** Cues held while the browser keeps the audio context suspended, before any user gesture. */
|
|
13
|
+
export const MAX_PENDING_CUES = 16;
|
|
12
14
|
function createNoopEngine() {
|
|
15
|
+
const level = { rms: 0, peak: 0 };
|
|
13
16
|
return {
|
|
14
17
|
setListenerPose: () => undefined,
|
|
15
18
|
setVoiceLimit: () => undefined,
|
|
19
|
+
play: () => undefined,
|
|
16
20
|
playOneShot: () => undefined,
|
|
17
21
|
playLoop: () => null,
|
|
18
22
|
playMusic: () => undefined,
|
|
19
23
|
setBusGain: () => undefined,
|
|
24
|
+
setBusMute: () => undefined,
|
|
20
25
|
setMasterGain: () => undefined,
|
|
26
|
+
setMasterMute: () => undefined,
|
|
27
|
+
masterLevel: () => 0,
|
|
28
|
+
busLevel: () => 0,
|
|
29
|
+
measure: () => level,
|
|
21
30
|
resume: () => undefined,
|
|
22
31
|
dispose: () => undefined,
|
|
23
32
|
};
|
|
@@ -93,12 +102,36 @@ export function createAudioEngine(config = {}) {
|
|
|
93
102
|
return createNoopEngine();
|
|
94
103
|
}
|
|
95
104
|
const busGains = new Map();
|
|
105
|
+
// Authored gain survives a mute, so unmuting restores the player's volume exactly.
|
|
106
|
+
const busMix = new Map();
|
|
107
|
+
let masterVolume = 1;
|
|
108
|
+
let masterMuted = false;
|
|
109
|
+
function applyMaster() {
|
|
110
|
+
masterGain.gain.value = masterLevel();
|
|
111
|
+
}
|
|
112
|
+
function masterLevel() {
|
|
113
|
+
return masterMuted ? 0 : masterVolume;
|
|
114
|
+
}
|
|
115
|
+
function busMixOf(busId) {
|
|
116
|
+
let mix = busMix.get(busId);
|
|
117
|
+
if (mix === undefined) {
|
|
118
|
+
mix = { gain: busDefs[busId]?.gain ?? 1, muted: false };
|
|
119
|
+
busMix.set(busId, mix);
|
|
120
|
+
}
|
|
121
|
+
return mix;
|
|
122
|
+
}
|
|
123
|
+
function busLevel(busId) {
|
|
124
|
+
const mix = busMix.get(busId);
|
|
125
|
+
if (mix === undefined)
|
|
126
|
+
return busDefs[busId]?.gain ?? 1;
|
|
127
|
+
return mix.muted ? 0 : mix.gain;
|
|
128
|
+
}
|
|
96
129
|
function busGainNode(busId) {
|
|
97
130
|
let node = busGains.get(busId);
|
|
98
131
|
if (node === undefined) {
|
|
99
132
|
node = context.createGain();
|
|
100
133
|
try {
|
|
101
|
-
node.gain.value =
|
|
134
|
+
node.gain.value = busLevel(busId);
|
|
102
135
|
node.connect(masterGain);
|
|
103
136
|
busGains.set(busId, node);
|
|
104
137
|
}
|
|
@@ -109,6 +142,11 @@ export function createAudioEngine(config = {}) {
|
|
|
109
142
|
}
|
|
110
143
|
return node;
|
|
111
144
|
}
|
|
145
|
+
function applyBus(busId) {
|
|
146
|
+
const node = busGains.get(busId);
|
|
147
|
+
if (node !== undefined)
|
|
148
|
+
node.gain.value = busLevel(busId);
|
|
149
|
+
}
|
|
112
150
|
let noiseBuffer = null;
|
|
113
151
|
function sharedNoiseBuffer() {
|
|
114
152
|
if (noiseBuffer === null)
|
|
@@ -166,6 +204,116 @@ export function createAudioEngine(config = {}) {
|
|
|
166
204
|
let listenerVelocity = { x: 0, y: 0, z: 0 };
|
|
167
205
|
const nyquist = context.sampleRate / 2;
|
|
168
206
|
const activeSpatialUpdaters = new Set();
|
|
207
|
+
// A Web Audio setter costs real time per call, so a listener pose that has not moved writes nothing (#2174).
|
|
208
|
+
const listenerWritten = [NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN];
|
|
209
|
+
const listenerVelocityWritten = [NaN, NaN, NaN];
|
|
210
|
+
function writeChanged(slot, index, param, value) {
|
|
211
|
+
if (slot[index] === value)
|
|
212
|
+
return false;
|
|
213
|
+
slot[index] = value;
|
|
214
|
+
setAudioParam(param, value);
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
// Browser autoplay: until the first user gesture the context stays suspended and a played cue
|
|
218
|
+
// would vanish. Hold a bounded FIFO of ids (preallocated slots) and flush it on resume.
|
|
219
|
+
const pendingCues = Array.from({ length: MAX_PENDING_CUES }, () => ({
|
|
220
|
+
soundId: null,
|
|
221
|
+
hasPosition: false,
|
|
222
|
+
x: 0,
|
|
223
|
+
y: 0,
|
|
224
|
+
z: 0,
|
|
225
|
+
}));
|
|
226
|
+
let pendingHead = 0;
|
|
227
|
+
let pendingCount = 0;
|
|
228
|
+
function contextRunning() {
|
|
229
|
+
const state = context.state;
|
|
230
|
+
return state === undefined || state === "running";
|
|
231
|
+
}
|
|
232
|
+
function queueCue(soundId, position) {
|
|
233
|
+
let slot;
|
|
234
|
+
if (pendingCount < MAX_PENDING_CUES) {
|
|
235
|
+
slot = pendingCues[(pendingHead + pendingCount) % MAX_PENDING_CUES];
|
|
236
|
+
pendingCount += 1;
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
// Full: drop the oldest cue, which the player has already stopped hearing.
|
|
240
|
+
slot = pendingCues[pendingHead];
|
|
241
|
+
pendingHead = (pendingHead + 1) % MAX_PENDING_CUES;
|
|
242
|
+
}
|
|
243
|
+
slot.soundId = soundId;
|
|
244
|
+
slot.hasPosition = position !== undefined;
|
|
245
|
+
slot.x = position?.x ?? 0;
|
|
246
|
+
slot.y = position?.y ?? 0;
|
|
247
|
+
slot.z = position?.z ?? 0;
|
|
248
|
+
}
|
|
249
|
+
/** Play what was held pre-gesture, oldest first, once the context can actually render it. */
|
|
250
|
+
function flushPending() {
|
|
251
|
+
while (pendingCount > 0) {
|
|
252
|
+
const slot = pendingCues[pendingHead];
|
|
253
|
+
pendingHead = (pendingHead + 1) % MAX_PENDING_CUES;
|
|
254
|
+
pendingCount -= 1;
|
|
255
|
+
const soundId = slot.soundId;
|
|
256
|
+
slot.soundId = null;
|
|
257
|
+
if (soundId === null)
|
|
258
|
+
continue;
|
|
259
|
+
playInternal(soundId, slot.hasPosition ? { x: slot.x, y: slot.y, z: slot.z } : undefined, false);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
function playCue(soundId, position) {
|
|
263
|
+
if (disposed)
|
|
264
|
+
return;
|
|
265
|
+
if (!contextRunning()) {
|
|
266
|
+
queueCue(soundId, position);
|
|
267
|
+
void context.resume().catch(() => undefined);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
playInternal(soundId, position, false);
|
|
271
|
+
}
|
|
272
|
+
let analyser = null;
|
|
273
|
+
let analyserSamples = null;
|
|
274
|
+
const level = { rms: 0, peak: 0 };
|
|
275
|
+
function measure() {
|
|
276
|
+
if (disposed) {
|
|
277
|
+
level.rms = 0;
|
|
278
|
+
level.peak = 0;
|
|
279
|
+
return level;
|
|
280
|
+
}
|
|
281
|
+
if (analyser === null) {
|
|
282
|
+
try {
|
|
283
|
+
analyser = context.createAnalyser();
|
|
284
|
+
analyser.fftSize = 1024;
|
|
285
|
+
analyser.smoothingTimeConstant = 0;
|
|
286
|
+
masterGain.disconnect();
|
|
287
|
+
masterGain.connect(analyser);
|
|
288
|
+
analyser.connect(context.destination);
|
|
289
|
+
analyserSamples = new Float32Array(analyser.fftSize);
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
analyser = null;
|
|
293
|
+
level.rms = 0;
|
|
294
|
+
level.peak = 0;
|
|
295
|
+
return level;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (analyserSamples === null) {
|
|
299
|
+
level.rms = 0;
|
|
300
|
+
level.peak = 0;
|
|
301
|
+
return level;
|
|
302
|
+
}
|
|
303
|
+
analyser.getFloatTimeDomainData(analyserSamples);
|
|
304
|
+
let sum = 0;
|
|
305
|
+
let peak = 0;
|
|
306
|
+
for (let index = 0; index < analyserSamples.length; index += 1) {
|
|
307
|
+
const sample = analyserSamples[index];
|
|
308
|
+
sum += sample * sample;
|
|
309
|
+
const magnitude = Math.abs(sample);
|
|
310
|
+
if (magnitude > peak)
|
|
311
|
+
peak = magnitude;
|
|
312
|
+
}
|
|
313
|
+
level.rms = Math.sqrt(sum / analyserSamples.length);
|
|
314
|
+
level.peak = Math.min(1, peak);
|
|
315
|
+
return level;
|
|
316
|
+
}
|
|
169
317
|
function playInternal(soundId, position, loop) {
|
|
170
318
|
if (disposed)
|
|
171
319
|
return null;
|
|
@@ -192,12 +340,17 @@ export function createAudioEngine(config = {}) {
|
|
|
192
340
|
let panner = null;
|
|
193
341
|
let playback = null;
|
|
194
342
|
let stopped = false;
|
|
343
|
+
let writtenCueGain = NaN;
|
|
195
344
|
function updateGain() {
|
|
196
345
|
if (cueGain === null)
|
|
197
346
|
return;
|
|
198
|
-
|
|
347
|
+
const gain = panner === null
|
|
199
348
|
? resolveEmitterGain(distance3(currentPosition, listenerPosition), sound, 1)
|
|
200
349
|
: sound.gain ?? 1;
|
|
350
|
+
if (gain === writtenCueGain)
|
|
351
|
+
return;
|
|
352
|
+
writtenCueGain = gain;
|
|
353
|
+
cueGain.gain.value = gain;
|
|
201
354
|
}
|
|
202
355
|
function stop() {
|
|
203
356
|
if (stopped)
|
|
@@ -255,6 +408,15 @@ export function createAudioEngine(config = {}) {
|
|
|
255
408
|
let userGainNode = null;
|
|
256
409
|
let falloffGain = null;
|
|
257
410
|
let pannerNode = null;
|
|
411
|
+
// Last value written or scheduled per param: an unchanged emitter writes nothing (#2174).
|
|
412
|
+
let writtenFalloff = NaN;
|
|
413
|
+
let writtenPannerX = NaN;
|
|
414
|
+
let writtenPannerY = NaN;
|
|
415
|
+
let writtenPannerZ = NaN;
|
|
416
|
+
let writtenRate = NaN;
|
|
417
|
+
let writtenGain = NaN;
|
|
418
|
+
let writtenLowpass = NaN;
|
|
419
|
+
let writtenHighpass = NaN;
|
|
258
420
|
function effectiveRate() {
|
|
259
421
|
if (dopplerFactor === 0)
|
|
260
422
|
return currentRate;
|
|
@@ -262,15 +424,36 @@ export function createAudioEngine(config = {}) {
|
|
|
262
424
|
return clampLoopRate(currentRate * shift);
|
|
263
425
|
}
|
|
264
426
|
function applyRate() {
|
|
265
|
-
if (sourceNode
|
|
266
|
-
|
|
427
|
+
if (sourceNode === null)
|
|
428
|
+
return;
|
|
429
|
+
const rate = effectiveRate();
|
|
430
|
+
if (rate === writtenRate)
|
|
431
|
+
return;
|
|
432
|
+
writtenRate = rate;
|
|
433
|
+
sourceNode.playbackRate.setTargetAtTime(rate, context.currentTime, LOOP_PARAM_SMOOTH_TC);
|
|
267
434
|
}
|
|
268
435
|
function updateFalloff() {
|
|
269
436
|
if (falloffGain !== null && spatial === undefined) {
|
|
270
|
-
|
|
437
|
+
const gain = resolveEmitterGain(distance3(currentPosition, listenerPosition), sound, 1);
|
|
438
|
+
if (gain !== writtenFalloff) {
|
|
439
|
+
writtenFalloff = gain;
|
|
440
|
+
falloffGain.gain.value = gain;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
if (pannerNode !== null) {
|
|
444
|
+
if (writtenPannerX !== currentPosition.x) {
|
|
445
|
+
writtenPannerX = currentPosition.x;
|
|
446
|
+
setAudioParam(pannerNode.positionX, currentPosition.x);
|
|
447
|
+
}
|
|
448
|
+
if (writtenPannerY !== currentPosition.y) {
|
|
449
|
+
writtenPannerY = currentPosition.y;
|
|
450
|
+
setAudioParam(pannerNode.positionY, currentPosition.y);
|
|
451
|
+
}
|
|
452
|
+
if (writtenPannerZ !== currentPosition.z) {
|
|
453
|
+
writtenPannerZ = currentPosition.z;
|
|
454
|
+
setAudioParam(pannerNode.positionZ, currentPosition.z);
|
|
455
|
+
}
|
|
271
456
|
}
|
|
272
|
-
if (pannerNode !== null)
|
|
273
|
-
setPannerPosition(pannerNode, currentPosition);
|
|
274
457
|
if (dopplerFactor !== 0)
|
|
275
458
|
applyRate();
|
|
276
459
|
}
|
|
@@ -306,23 +489,28 @@ export function createAudioEngine(config = {}) {
|
|
|
306
489
|
sourceNode = src;
|
|
307
490
|
src.buffer = buffer;
|
|
308
491
|
src.loop = loop || (sound.loop ?? false);
|
|
309
|
-
|
|
492
|
+
writtenRate = effectiveRate();
|
|
493
|
+
src.playbackRate.value = writtenRate;
|
|
310
494
|
// Spatial panners own distance attenuation; the scalar stage keeps only authored gain.
|
|
311
495
|
const uGain = context.createGain();
|
|
312
496
|
userGainNode = uGain;
|
|
313
|
-
|
|
497
|
+
writtenGain = currentUserGain;
|
|
498
|
+
uGain.gain.value = writtenGain;
|
|
314
499
|
const fGain = context.createGain();
|
|
315
500
|
falloffGain = fGain;
|
|
316
|
-
|
|
501
|
+
writtenFalloff = resolveEmitterGain(distance3(currentPosition, listenerPosition), sound, 1);
|
|
502
|
+
fGain.gain.value = writtenFalloff;
|
|
317
503
|
if (loop) {
|
|
318
504
|
const lp = context.createBiquadFilter();
|
|
319
505
|
lowpassNode = lp;
|
|
320
506
|
lp.type = "lowpass";
|
|
321
|
-
|
|
507
|
+
writtenLowpass = clampLoopCutoff(currentLowpass, MAX_LOOP_CUTOFF, nyquist);
|
|
508
|
+
lp.frequency.value = writtenLowpass;
|
|
322
509
|
const hp = context.createBiquadFilter();
|
|
323
510
|
highpassNode = hp;
|
|
324
511
|
hp.type = "highpass";
|
|
325
|
-
|
|
512
|
+
writtenHighpass = clampLoopCutoff(currentHighpass, MIN_LOOP_CUTOFF, nyquist);
|
|
513
|
+
hp.frequency.value = writtenHighpass;
|
|
326
514
|
src.connect(lp);
|
|
327
515
|
lp.connect(hp);
|
|
328
516
|
hp.connect(uGain);
|
|
@@ -334,6 +522,9 @@ export function createAudioEngine(config = {}) {
|
|
|
334
522
|
if (spatial !== undefined) {
|
|
335
523
|
const panner = createPanner(context, spatial, currentPosition);
|
|
336
524
|
pannerNode = panner;
|
|
525
|
+
writtenPannerX = currentPosition.x;
|
|
526
|
+
writtenPannerY = currentPosition.y;
|
|
527
|
+
writtenPannerZ = currentPosition.z;
|
|
337
528
|
fGain.gain.value = sound.gain ?? 1;
|
|
338
529
|
fGain.connect(panner);
|
|
339
530
|
panner.connect(bus);
|
|
@@ -350,7 +541,9 @@ export function createAudioEngine(config = {}) {
|
|
|
350
541
|
setPosition(next) {
|
|
351
542
|
if (stopped)
|
|
352
543
|
return;
|
|
353
|
-
currentPosition =
|
|
544
|
+
currentPosition.x = next.x;
|
|
545
|
+
currentPosition.y = next.y;
|
|
546
|
+
currentPosition.z = next.z;
|
|
354
547
|
updateFalloff();
|
|
355
548
|
},
|
|
356
549
|
setRate(rate) {
|
|
@@ -359,21 +552,32 @@ export function createAudioEngine(config = {}) {
|
|
|
359
552
|
},
|
|
360
553
|
setLowpass(hz) {
|
|
361
554
|
currentLowpass = clampLoopCutoff(hz, MAX_LOOP_CUTOFF, nyquist);
|
|
555
|
+
if (currentLowpass === writtenLowpass)
|
|
556
|
+
return;
|
|
557
|
+
writtenLowpass = currentLowpass;
|
|
362
558
|
if (lowpassNode !== null)
|
|
363
559
|
lowpassNode.frequency.setTargetAtTime(currentLowpass, context.currentTime, LOOP_PARAM_SMOOTH_TC);
|
|
364
560
|
},
|
|
365
561
|
setHighpass(hz) {
|
|
366
562
|
currentHighpass = clampLoopCutoff(hz, MIN_LOOP_CUTOFF, nyquist);
|
|
563
|
+
if (currentHighpass === writtenHighpass)
|
|
564
|
+
return;
|
|
565
|
+
writtenHighpass = currentHighpass;
|
|
367
566
|
if (highpassNode !== null)
|
|
368
567
|
highpassNode.frequency.setTargetAtTime(currentHighpass, context.currentTime, LOOP_PARAM_SMOOTH_TC);
|
|
369
568
|
},
|
|
370
569
|
setVelocity(velocity) {
|
|
371
|
-
currentVelocity =
|
|
570
|
+
currentVelocity.x = velocity.x;
|
|
571
|
+
currentVelocity.y = velocity.y;
|
|
572
|
+
currentVelocity.z = velocity.z;
|
|
372
573
|
if (dopplerFactor !== 0)
|
|
373
574
|
applyRate();
|
|
374
575
|
},
|
|
375
576
|
setGain(gain) {
|
|
376
577
|
currentUserGain = clampLoopGain(gain);
|
|
578
|
+
if (currentUserGain === writtenGain)
|
|
579
|
+
return;
|
|
580
|
+
writtenGain = currentUserGain;
|
|
377
581
|
if (userGainNode !== null)
|
|
378
582
|
userGainNode.gain.setTargetAtTime(currentUserGain, context.currentTime, LOOP_PARAM_SMOOTH_TC);
|
|
379
583
|
},
|
|
@@ -401,26 +605,37 @@ export function createAudioEngine(config = {}) {
|
|
|
401
605
|
if (disposed)
|
|
402
606
|
return;
|
|
403
607
|
const pose = "position" in position ? position : { position, forward: { x: 0, y: 0, z: -1 }, up: { x: 0, y: 1, z: 0 } };
|
|
404
|
-
listenerPosition =
|
|
405
|
-
|
|
608
|
+
listenerPosition.x = pose.position.x;
|
|
609
|
+
listenerPosition.y = pose.position.y;
|
|
610
|
+
listenerPosition.z = pose.position.z;
|
|
611
|
+
const velocity = "velocity" in pose && pose.velocity !== undefined ? pose.velocity : ZERO_VELOCITY;
|
|
612
|
+
listenerVelocity.x = velocity.x;
|
|
613
|
+
listenerVelocity.y = velocity.y;
|
|
614
|
+
listenerVelocity.z = velocity.z;
|
|
406
615
|
const listener = context.listener;
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
616
|
+
let listenerMoved = writeChanged(listenerWritten, 0, listener.positionX, pose.position.x);
|
|
617
|
+
listenerMoved = writeChanged(listenerWritten, 1, listener.positionY, pose.position.y) || listenerMoved;
|
|
618
|
+
listenerMoved = writeChanged(listenerWritten, 2, listener.positionZ, pose.position.z) || listenerMoved;
|
|
619
|
+
listenerMoved = writeChanged(listenerWritten, 3, listener.forwardX, pose.forward.x) || listenerMoved;
|
|
620
|
+
listenerMoved = writeChanged(listenerWritten, 4, listener.forwardY, pose.forward.y) || listenerMoved;
|
|
621
|
+
listenerMoved = writeChanged(listenerWritten, 5, listener.forwardZ, pose.forward.z) || listenerMoved;
|
|
622
|
+
listenerMoved = writeChanged(listenerWritten, 6, listener.upX, pose.up.x) || listenerMoved;
|
|
623
|
+
listenerMoved = writeChanged(listenerWritten, 7, listener.upY, pose.up.y) || listenerMoved;
|
|
624
|
+
listenerMoved = writeChanged(listenerWritten, 8, listener.upZ, pose.up.z) || listenerMoved;
|
|
625
|
+
// Doppler loops alone read listener velocity, so only a changed velocity needs the per-voice pass.
|
|
626
|
+
let velocityChanged = writeChanged(listenerVelocityWritten, 0, undefined, velocity.x);
|
|
627
|
+
velocityChanged = writeChanged(listenerVelocityWritten, 1, undefined, velocity.y) || velocityChanged;
|
|
628
|
+
velocityChanged = writeChanged(listenerVelocityWritten, 2, undefined, velocity.z) || velocityChanged;
|
|
629
|
+
if (!listenerMoved && !velocityChanged)
|
|
630
|
+
return;
|
|
416
631
|
for (const updateGain of activeSpatialUpdaters)
|
|
417
632
|
updateGain();
|
|
418
633
|
},
|
|
634
|
+
play(soundId, position) {
|
|
635
|
+
playCue(soundId, position);
|
|
636
|
+
},
|
|
419
637
|
playOneShot(soundId, position) {
|
|
420
|
-
|
|
421
|
-
return;
|
|
422
|
-
void context.resume().catch(() => undefined);
|
|
423
|
-
playInternal(soundId, position, false);
|
|
638
|
+
playCue(soundId, position);
|
|
424
639
|
},
|
|
425
640
|
playLoop(soundId, position) {
|
|
426
641
|
return playInternal(soundId, position, true);
|
|
@@ -434,17 +649,37 @@ export function createAudioEngine(config = {}) {
|
|
|
434
649
|
setBusGain(busId, gain) {
|
|
435
650
|
if (disposed)
|
|
436
651
|
return;
|
|
437
|
-
|
|
652
|
+
busMixOf(busId).gain = gain;
|
|
653
|
+
applyBus(busId);
|
|
654
|
+
},
|
|
655
|
+
setBusMute(busId, muted) {
|
|
656
|
+
if (disposed)
|
|
657
|
+
return;
|
|
658
|
+
busMixOf(busId).muted = muted;
|
|
659
|
+
applyBus(busId);
|
|
438
660
|
},
|
|
439
661
|
setMasterGain(gain) {
|
|
440
662
|
if (disposed)
|
|
441
663
|
return;
|
|
442
|
-
|
|
664
|
+
masterVolume = gain;
|
|
665
|
+
applyMaster();
|
|
666
|
+
},
|
|
667
|
+
setMasterMute(muted) {
|
|
668
|
+
if (disposed)
|
|
669
|
+
return;
|
|
670
|
+
masterMuted = muted;
|
|
671
|
+
applyMaster();
|
|
443
672
|
},
|
|
673
|
+
masterLevel,
|
|
674
|
+
busLevel,
|
|
675
|
+
measure,
|
|
444
676
|
resume() {
|
|
445
677
|
if (disposed)
|
|
446
678
|
return;
|
|
447
|
-
void context.resume().
|
|
679
|
+
void context.resume().then(() => {
|
|
680
|
+
if (!disposed && contextRunning())
|
|
681
|
+
flushPending();
|
|
682
|
+
}).catch(() => undefined);
|
|
448
683
|
},
|
|
449
684
|
dispose() {
|
|
450
685
|
if (disposed)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { AudioEngine, AudioLevel } from "./audioEngine.js";
|
|
2
|
+
/** The read-only handle a capture script or devtools uses to prove the mix is producing sound. */
|
|
3
|
+
export interface AudioProbe {
|
|
4
|
+
/** Master RMS and peak since the last read, `0..1`. Non-zero RMS means audio reached the output. */
|
|
5
|
+
measure(): AudioLevel;
|
|
6
|
+
/** Master gain after volume and mute. */
|
|
7
|
+
masterLevel(): number;
|
|
8
|
+
/** One bus's effective gain after its volume and mute. */
|
|
9
|
+
busLevel(busId: string): number;
|
|
10
|
+
}
|
|
11
|
+
declare global {
|
|
12
|
+
interface Window {
|
|
13
|
+
__jgengineAudio?: AudioProbe;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Publish the audio engine's own level readout on `window.__jgengineAudio`, so a headless capture can
|
|
18
|
+
* state a measured RMS instead of guessing from a screenshot. Reads only; it never plays or changes a bus.
|
|
19
|
+
*
|
|
20
|
+
* @capability audio-probe read the live master RMS/peak and bus gains from a running page for capture and diagnostics
|
|
21
|
+
*/
|
|
22
|
+
export declare function installAudioProbe(engine: AudioEngine): () => void;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Publish the audio engine's own level readout on `window.__jgengineAudio`, so a headless capture can
|
|
3
|
+
* state a measured RMS instead of guessing from a screenshot. Reads only; it never plays or changes a bus.
|
|
4
|
+
*
|
|
5
|
+
* @capability audio-probe read the live master RMS/peak and bus gains from a running page for capture and diagnostics
|
|
6
|
+
*/
|
|
7
|
+
export function installAudioProbe(engine) {
|
|
8
|
+
if (typeof window === "undefined")
|
|
9
|
+
return () => undefined;
|
|
10
|
+
const probe = {
|
|
11
|
+
measure: () => engine.measure(),
|
|
12
|
+
masterLevel: () => engine.masterLevel(),
|
|
13
|
+
busLevel: (busId) => engine.busLevel(busId),
|
|
14
|
+
};
|
|
15
|
+
window.__jgengineAudio = probe;
|
|
16
|
+
return () => {
|
|
17
|
+
if (window.__jgengineAudio === probe)
|
|
18
|
+
delete window.__jgengineAudio;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Persisted mute for the whole mix, next to `sound.master`. */
|
|
2
|
+
export declare const MASTER_MUTE_SETTING_ID = "sound.muted";
|
|
3
|
+
/** Persisted mute for one bus, next to its `busVolumeSettingId(busId)` volume. */
|
|
4
|
+
export declare function busMuteSettingId(busId: string): string;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { busVolumeSettingId } from "@jgengine/core/settings/settingsModel";
|
|
2
|
+
/** Persisted mute for the whole mix, next to `sound.master`. */
|
|
3
|
+
export const MASTER_MUTE_SETTING_ID = "sound.muted";
|
|
4
|
+
/** Persisted mute for one bus, next to its `busVolumeSettingId(busId)` volume. */
|
|
5
|
+
export function busMuteSettingId(busId) {
|
|
6
|
+
return `${busVolumeSettingId(busId)}.muted`;
|
|
7
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** One caption line as the overlay renders it. */
|
|
2
|
+
export interface Caption {
|
|
3
|
+
id: number;
|
|
4
|
+
text: string;
|
|
5
|
+
/** Free-string kind the game styles ("hit", "pickup", "wave"). */
|
|
6
|
+
kind: string;
|
|
7
|
+
/** How many identical lines stacked into this one. */
|
|
8
|
+
count: number;
|
|
9
|
+
at: number;
|
|
10
|
+
expiresAt: number;
|
|
11
|
+
}
|
|
12
|
+
/** Tunables for {@link createCaptionFeed}; all retunable at runtime. */
|
|
13
|
+
export interface CaptionFeedConfig {
|
|
14
|
+
/** Maximum live lines. Default 3. */
|
|
15
|
+
cap?: number;
|
|
16
|
+
/** Line lifetime in ms. Default 2400. */
|
|
17
|
+
ttlMs?: number;
|
|
18
|
+
/** Injected clock; default `Date.now`. */
|
|
19
|
+
now?: () => number;
|
|
20
|
+
}
|
|
21
|
+
/** A capped, self-expiring list of caption lines that the accessibility captions setting turns on. */
|
|
22
|
+
export interface CaptionFeed {
|
|
23
|
+
/** Show `text`; an identical line still live stacks into one with a count. */
|
|
24
|
+
push(text: string, kind?: string): Caption;
|
|
25
|
+
/** Live lines, oldest first. Stable reference until the next change. */
|
|
26
|
+
list(): readonly Caption[];
|
|
27
|
+
/** Drop expired lines as of the feed clock. */
|
|
28
|
+
prune(nowMs?: number): void;
|
|
29
|
+
clear(): void;
|
|
30
|
+
subscribe(listener: () => void): () => void;
|
|
31
|
+
retune(config: Omit<CaptionFeedConfig, "now">): void;
|
|
32
|
+
snapshot(): {
|
|
33
|
+
captions: Caption[];
|
|
34
|
+
counter: number;
|
|
35
|
+
};
|
|
36
|
+
restore(state: {
|
|
37
|
+
captions: Caption[];
|
|
38
|
+
counter: number;
|
|
39
|
+
}): void;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The caption list the feedback seam writes to and the HUD overlay reads: bounded, self-expiring, and
|
|
43
|
+
* deduped so three hits read as "Hit x3" rather than three copies. Pure state with an injected clock,
|
|
44
|
+
* so it runs headless.
|
|
45
|
+
*
|
|
46
|
+
* @capability caption-feed bounded, self-expiring, deduped caption lines for the accessibility captions overlay
|
|
47
|
+
*/
|
|
48
|
+
export declare function createCaptionFeed(config?: CaptionFeedConfig): CaptionFeed;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
const DEFAULT_CAP = 3;
|
|
2
|
+
const DEFAULT_TTL_MS = 2400;
|
|
3
|
+
/**
|
|
4
|
+
* The caption list the feedback seam writes to and the HUD overlay reads: bounded, self-expiring, and
|
|
5
|
+
* deduped so three hits read as "Hit x3" rather than three copies. Pure state with an injected clock,
|
|
6
|
+
* so it runs headless.
|
|
7
|
+
*
|
|
8
|
+
* @capability caption-feed bounded, self-expiring, deduped caption lines for the accessibility captions overlay
|
|
9
|
+
*/
|
|
10
|
+
export function createCaptionFeed(config = {}) {
|
|
11
|
+
const now = config.now ?? Date.now;
|
|
12
|
+
let cap = Math.max(1, config.cap ?? DEFAULT_CAP);
|
|
13
|
+
let ttlMs = Math.max(0, config.ttlMs ?? DEFAULT_TTL_MS);
|
|
14
|
+
let captions = [];
|
|
15
|
+
let counter = 0;
|
|
16
|
+
const listeners = new Set();
|
|
17
|
+
function set(next) {
|
|
18
|
+
if (next === captions)
|
|
19
|
+
return;
|
|
20
|
+
captions = next;
|
|
21
|
+
for (const listener of [...listeners])
|
|
22
|
+
listener();
|
|
23
|
+
}
|
|
24
|
+
function live(time) {
|
|
25
|
+
return captions.every((caption) => caption.expiresAt > time)
|
|
26
|
+
? captions
|
|
27
|
+
: captions.filter((caption) => caption.expiresAt > time);
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
push(text, kind = "default") {
|
|
31
|
+
const time = now();
|
|
32
|
+
const current = live(time);
|
|
33
|
+
const index = current.findIndex((caption) => caption.text === text);
|
|
34
|
+
if (index >= 0) {
|
|
35
|
+
const previous = current[index];
|
|
36
|
+
const next = [...current];
|
|
37
|
+
next[index] = { ...previous, kind, count: previous.count + 1, at: time, expiresAt: time + ttlMs };
|
|
38
|
+
set(next);
|
|
39
|
+
return next[index];
|
|
40
|
+
}
|
|
41
|
+
const caption = { id: counter, text, kind, count: 1, at: time, expiresAt: time + ttlMs };
|
|
42
|
+
counter += 1;
|
|
43
|
+
const appended = [...current, caption];
|
|
44
|
+
set(appended.length > cap ? appended.slice(appended.length - cap) : appended);
|
|
45
|
+
return caption;
|
|
46
|
+
},
|
|
47
|
+
list() {
|
|
48
|
+
return live(now());
|
|
49
|
+
},
|
|
50
|
+
prune(nowMs) {
|
|
51
|
+
set(live(nowMs ?? now()));
|
|
52
|
+
},
|
|
53
|
+
clear() {
|
|
54
|
+
if (captions.length > 0)
|
|
55
|
+
set([]);
|
|
56
|
+
},
|
|
57
|
+
subscribe(listener) {
|
|
58
|
+
listeners.add(listener);
|
|
59
|
+
return () => listeners.delete(listener);
|
|
60
|
+
},
|
|
61
|
+
retune(next) {
|
|
62
|
+
if (next.cap !== undefined)
|
|
63
|
+
cap = Math.max(1, next.cap);
|
|
64
|
+
if (next.ttlMs !== undefined)
|
|
65
|
+
ttlMs = Math.max(0, next.ttlMs);
|
|
66
|
+
set(live(now()));
|
|
67
|
+
},
|
|
68
|
+
snapshot() {
|
|
69
|
+
return { captions: [...captions], counter };
|
|
70
|
+
},
|
|
71
|
+
restore(state) {
|
|
72
|
+
captions = [...state.captions];
|
|
73
|
+
counter = state.counter;
|
|
74
|
+
set(live(now()));
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { AudioEmitterHandle, Vec3 } from "./audioEngine.js";
|
|
2
|
+
/** One emitter's live key and position, written in place so a steady frame reads every source without allocating. @internal */
|
|
3
|
+
export interface EmitterPose {
|
|
4
|
+
key: string;
|
|
5
|
+
position: readonly [number, number, number];
|
|
6
|
+
}
|
|
7
|
+
/** Read side of the emitters a game declares sounds for. @internal */
|
|
8
|
+
export interface EmitterSources {
|
|
9
|
+
/**
|
|
10
|
+
* Every candidate id. Identity changes only on spawn/despawn (`ids()` in the entity and object
|
|
11
|
+
* stores), so the tracker reconciles membership by comparing the array it last saw.
|
|
12
|
+
*/
|
|
13
|
+
ids(): readonly string[];
|
|
14
|
+
/** Fill `pose` for `id`; `false` when the source is gone. Called once per tracked emitter per frame, never per candidate. */
|
|
15
|
+
read(id: string, pose: EmitterPose): boolean;
|
|
16
|
+
}
|
|
17
|
+
/** Retained loops for one sound map, maintained by membership and pose events instead of rebuilt per frame. @internal */
|
|
18
|
+
export interface EmitterTracker {
|
|
19
|
+
/** Advance one frame: pick up spawned/moved/despawned emitters and write nothing when nothing moved. */
|
|
20
|
+
step(): void;
|
|
21
|
+
/** Live emitters, for probes and assertions. */
|
|
22
|
+
count(): number;
|
|
23
|
+
dispose(): void;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Track one sound map's positional loops.
|
|
27
|
+
*
|
|
28
|
+
* Membership is reconciled only when `sources.ids()` returns a new array, and each frame then reads
|
|
29
|
+
* just the tracked emitters — so a game with hundreds of props and a handful of sounding ones pays
|
|
30
|
+
* for the handful, and a still frame allocates nothing. An emitter that gains a sounding key through
|
|
31
|
+
* a rename is picked up on the next membership change, not per frame; catalog ids never change, and a
|
|
32
|
+
* spawned entity already carries the name its sound map is keyed by.
|
|
33
|
+
* @internal
|
|
34
|
+
*/
|
|
35
|
+
export declare function createEmitterTracker(options: {
|
|
36
|
+
sources: EmitterSources;
|
|
37
|
+
/** Read per frame so a remount or a replaced sound map takes effect without rebuilding the tracker. */
|
|
38
|
+
sounds: () => Record<string, string> | undefined;
|
|
39
|
+
play: (soundId: string, position: Vec3) => AudioEmitterHandle | null;
|
|
40
|
+
}): EmitterTracker;
|