@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.
Files changed (62) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/dist/GamePlayerShell.js +7 -0
  3. package/dist/Shell3dPresentation.js +6 -4
  4. package/dist/ShellChrome.d.ts +3 -1
  5. package/dist/ShellChrome.js +3 -2
  6. package/dist/ShellHudPresentation.js +1 -1
  7. package/dist/audio/AudioComponents.js +61 -51
  8. package/dist/audio/CaptionOverlay.d.ts +15 -0
  9. package/dist/audio/CaptionOverlay.js +50 -0
  10. package/dist/audio/FeedbackHost.d.ts +14 -0
  11. package/dist/audio/FeedbackHost.js +46 -0
  12. package/dist/audio/audioEngine.d.ts +25 -0
  13. package/dist/audio/audioEngine.js +267 -32
  14. package/dist/audio/audioProbe.d.ts +22 -0
  15. package/dist/audio/audioProbe.js +20 -0
  16. package/dist/audio/busMix.d.ts +4 -0
  17. package/dist/audio/busMix.js +7 -0
  18. package/dist/audio/captions.d.ts +48 -0
  19. package/dist/audio/captions.js +77 -0
  20. package/dist/audio/emitterTracking.d.ts +40 -0
  21. package/dist/audio/emitterTracking.js +153 -0
  22. package/dist/audio/eventFeedback.d.ts +137 -0
  23. package/dist/audio/eventFeedback.js +273 -0
  24. package/dist/audio/gestureResume.d.ts +10 -0
  25. package/dist/audio/gestureResume.js +25 -0
  26. package/dist/audio/hapticsRoute.d.ts +28 -0
  27. package/dist/audio/hapticsRoute.js +59 -0
  28. package/dist/audio/loopParams.js +20 -10
  29. package/dist/audio/playableAudio.d.ts +1 -1
  30. package/dist/audio/playableAudio.js +3 -1
  31. package/dist/devtools/FrameTelemetry.d.ts +1 -1
  32. package/dist/devtools/FrameTelemetry.js +4 -2
  33. package/dist/devtools/agentBridge.js +13 -0
  34. package/dist/devtools/frameSampler.js +4 -0
  35. package/dist/drivers/CanvasCoverFrameloop.d.ts +13 -0
  36. package/dist/drivers/CanvasCoverFrameloop.js +22 -0
  37. package/dist/drivers/FrameRateLimiter.d.ts +2 -1
  38. package/dist/drivers/FrameRateLimiter.js +6 -3
  39. package/dist/postfx/PostProcessing.d.ts +78 -2
  40. package/dist/postfx/PostProcessing.js +149 -76
  41. package/dist/postfx/postfxOverlay.d.ts +14 -4
  42. package/dist/postfx/postfxOverlay.js +90 -10
  43. package/dist/postfx/shadowPrepass.d.ts +6 -2
  44. package/dist/postfx/shadowPrepass.js +7 -3
  45. package/dist/render/characterLod.js +6 -0
  46. package/dist/render/useFootIk.js +54 -15
  47. package/dist/render/useModelAnimation.js +22 -5
  48. package/dist/settings/appliedSettings.js +3 -0
  49. package/dist/settings/settingsController.d.ts +10 -1
  50. package/dist/settings/settingsController.js +94 -67
  51. package/dist/vision/RevealVision.d.ts +3 -1
  52. package/dist/vision/RevealVision.js +34 -15
  53. package/dist/world/WorldItems.js +11 -3
  54. package/package.json +4 -4
  55. package/skills/jgengine/api.md +3 -2
  56. package/skills/jgengine-ui/SKILL.md +1 -0
  57. package/skills/jgengine-ui/api.md +94 -24
  58. package/skills/jgengine-ui/capabilities.md +40 -0
  59. package/skills/jgengine-ui/reference-primitives.md +2 -0
  60. package/skills/jgengine-ui/reference.md +1 -1
  61. package/skills/jgengine-world/api.md +18 -8
  62. 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 = busDefs[busId]?.gain ?? 1;
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
- cueGain.gain.value = panner === null
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 !== null)
266
- sourceNode.playbackRate.setTargetAtTime(effectiveRate(), context.currentTime, LOOP_PARAM_SMOOTH_TC);
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
- falloffGain.gain.value = resolveEmitterGain(distance3(currentPosition, listenerPosition), sound, 1);
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
- src.playbackRate.value = effectiveRate();
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
- uGain.gain.value = currentUserGain;
497
+ writtenGain = currentUserGain;
498
+ uGain.gain.value = writtenGain;
314
499
  const fGain = context.createGain();
315
500
  falloffGain = fGain;
316
- fGain.gain.value = resolveEmitterGain(distance3(currentPosition, listenerPosition), sound, 1);
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
- lp.frequency.value = clampLoopCutoff(currentLowpass, MAX_LOOP_CUTOFF, nyquist);
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
- hp.frequency.value = clampLoopCutoff(currentHighpass, MIN_LOOP_CUTOFF, nyquist);
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 = { ...next };
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 = { ...velocity };
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 = { ...pose.position };
405
- listenerVelocity = "velocity" in pose && pose.velocity !== undefined ? { ...pose.velocity } : ZERO_VELOCITY;
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
- setAudioParam(listener.positionX, pose.position.x);
408
- setAudioParam(listener.positionY, pose.position.y);
409
- setAudioParam(listener.positionZ, pose.position.z);
410
- setAudioParam(listener.forwardX, pose.forward.x);
411
- setAudioParam(listener.forwardY, pose.forward.y);
412
- setAudioParam(listener.forwardZ, pose.forward.z);
413
- setAudioParam(listener.upX, pose.up.x);
414
- setAudioParam(listener.upY, pose.up.y);
415
- setAudioParam(listener.upZ, pose.up.z);
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
- if (disposed)
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
- busGainNode(busId).gain.value = gain;
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
- masterGain.gain.value = gain;
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().catch(() => undefined);
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;