@jgengine/shell 0.18.1 → 0.21.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 (261) hide show
  1. package/CHANGELOG.md +504 -7
  2. package/dist/GameHost.d.ts +12 -1
  3. package/dist/GameHost.js +14 -1
  4. package/dist/GamePlayerShell.js +132 -17
  5. package/dist/Shell3dPresentation.d.ts +5 -11
  6. package/dist/Shell3dPresentation.js +73 -11
  7. package/dist/ShellChrome.d.ts +2 -1
  8. package/dist/ShellChrome.js +22 -6
  9. package/dist/ShellHudPresentation.d.ts +20 -2
  10. package/dist/ShellHudPresentation.js +41 -6
  11. package/dist/audio/AudioComponents.js +6 -2
  12. package/dist/audio/audioEngine.d.ts +24 -0
  13. package/dist/audio/audioEngine.js +231 -83
  14. package/dist/audio/audioWire.d.ts +3 -0
  15. package/dist/audio/audioWire.js +9 -3
  16. package/dist/audio/loopParams.d.ts +39 -0
  17. package/dist/audio/loopParams.js +43 -0
  18. package/dist/audio/synthEngine.d.ts +7 -1
  19. package/dist/audio/synthEngine.js +103 -38
  20. package/dist/camera/GameCameraRig.js +3 -2
  21. package/dist/camera/GameFirstPersonCamera.d.ts +6 -4
  22. package/dist/camera/GameFirstPersonCamera.js +68 -27
  23. package/dist/camera/Viewports.d.ts +34 -0
  24. package/dist/camera/Viewports.js +123 -0
  25. package/dist/camera/cameraRigs.js +76 -62
  26. package/dist/camera/chaseHeading.d.ts +9 -0
  27. package/dist/camera/chaseHeading.js +10 -0
  28. package/dist/camera/customMuzzle.d.ts +14 -0
  29. package/dist/camera/customMuzzle.js +68 -0
  30. package/dist/camera/index.d.ts +1 -0
  31. package/dist/camera/index.js +1 -0
  32. package/dist/camera/orbitCameraMath.js +2 -2
  33. package/dist/camera/rigMath.d.ts +82 -4
  34. package/dist/camera/rigMath.js +133 -15
  35. package/dist/camera/rigResolve.d.ts +3 -0
  36. package/dist/camera/rigResolve.js +11 -0
  37. package/dist/combat/ProjectileModels.d.ts +31 -0
  38. package/dist/combat/ProjectileModels.js +70 -0
  39. package/dist/commandSink.d.ts +1 -2
  40. package/dist/commandSink.js +3 -2
  41. package/dist/defineGame.d.ts +19 -3
  42. package/dist/defineGame.js +39 -7
  43. package/dist/devtools/DevtoolsOverlay.d.ts +2 -69
  44. package/dist/devtools/DevtoolsOverlay.js +15 -167
  45. package/dist/devtools/DevtoolsRuntime.d.ts +7 -0
  46. package/dist/devtools/DevtoolsRuntime.js +61 -0
  47. package/dist/devtools/agentBridge.d.ts +6 -0
  48. package/dist/devtools/agentBridge.js +16 -2
  49. package/dist/devtools/devtoolsReports.d.ts +68 -0
  50. package/dist/devtools/devtoolsReports.js +100 -0
  51. package/dist/devtools/latencyInstrumentation.d.ts +3 -0
  52. package/dist/devtools/latencyInstrumentation.js +11 -0
  53. package/dist/devtools/playtestProbe.d.ts +4 -0
  54. package/dist/devtools/playtestProbe.js +22 -0
  55. package/dist/diagnostics/FpsCounter.d.ts +17 -0
  56. package/dist/diagnostics/FpsCounter.js +32 -0
  57. package/dist/drivers/FrameDriver.js +35 -20
  58. package/dist/drivers/FrameRateLimiter.d.ts +9 -0
  59. package/dist/drivers/FrameRateLimiter.js +39 -0
  60. package/dist/drivers/HudOnlyDriver.d.ts +6 -1
  61. package/dist/drivers/HudOnlyDriver.js +32 -14
  62. package/dist/environment/DayNightSky.js +5 -5
  63. package/dist/environment/Daylight.d.ts +35 -1
  64. package/dist/environment/Daylight.js +98 -28
  65. package/dist/environment/EnvironmentScene.js +11 -10
  66. package/dist/environment/VolumetricClouds.js +20 -2
  67. package/dist/environment/daylightCycle.d.ts +20 -0
  68. package/dist/environment/daylightCycle.js +59 -7
  69. package/dist/environment/sunShadowMath.d.ts +45 -0
  70. package/dist/environment/sunShadowMath.js +50 -0
  71. package/dist/input/gamepadPoll.d.ts +47 -0
  72. package/dist/input/gamepadPoll.js +142 -0
  73. package/dist/input/gamepadSource.d.ts +11 -1
  74. package/dist/input/gamepadSource.js +117 -71
  75. package/dist/input/mouseLook.js +2 -1
  76. package/dist/input/padHaptics.d.ts +17 -0
  77. package/dist/input/padHaptics.js +32 -0
  78. package/dist/input/pointerLock.d.ts +11 -0
  79. package/dist/input/pointerLock.js +19 -0
  80. package/dist/inputSink.d.ts +6 -6
  81. package/dist/inputSink.js +60 -21
  82. package/dist/lazyShell3dPresentation.d.ts +9 -0
  83. package/dist/lazyShell3dPresentation.js +24 -0
  84. package/dist/materialOverride.d.ts +42 -0
  85. package/dist/materialOverride.js +95 -36
  86. package/dist/multiplayer.d.ts +33 -3
  87. package/dist/multiplayer.js +105 -22
  88. package/dist/pointer/pointerService.d.ts +2 -0
  89. package/dist/pointer/pointerService.js +12 -2
  90. package/dist/postfx/PostProcessing.d.ts +11 -1
  91. package/dist/postfx/PostProcessing.js +14 -42
  92. package/dist/postfx/gradeShader.d.ts +1 -1
  93. package/dist/postfx/gradeShader.js +30 -2
  94. package/dist/postfx/shadowPrepass.d.ts +3 -0
  95. package/dist/postfx/shadowPrepass.js +18 -0
  96. package/dist/postfx/stylizePass.d.ts +23 -0
  97. package/dist/postfx/stylizePass.js +216 -0
  98. package/dist/presentationRecovery.d.ts +75 -0
  99. package/dist/presentationRecovery.js +204 -0
  100. package/dist/presentationRecovery3d.d.ts +4 -0
  101. package/dist/presentationRecovery3d.js +32 -0
  102. package/dist/presentationResolve.d.ts +8 -1
  103. package/dist/presentationResolve.js +5 -2
  104. package/dist/render/CascadedShadows.d.ts +7 -2
  105. package/dist/render/CascadedShadows.js +98 -20
  106. package/dist/render/EnvironmentLighting.js +74 -35
  107. package/dist/render/HairCards.d.ts +7 -0
  108. package/dist/render/HairCards.js +23 -0
  109. package/dist/render/SceneModels.js +52 -78
  110. package/dist/render/SkinnedInstances.d.ts +91 -0
  111. package/dist/render/SkinnedInstances.js +249 -0
  112. package/dist/render/TextureFiltering.d.ts +16 -0
  113. package/dist/render/TextureFiltering.js +64 -0
  114. package/dist/render/authoredSurfaceMaterial.d.ts +43 -0
  115. package/dist/render/authoredSurfaceMaterial.js +108 -0
  116. package/dist/render/displayColor.d.ts +33 -0
  117. package/dist/render/displayColor.js +138 -0
  118. package/dist/render/materialAppearanceSignals.d.ts +23 -0
  119. package/dist/render/materialAppearanceSignals.js +58 -0
  120. package/dist/render/materialAsset.d.ts +98 -0
  121. package/dist/render/materialAsset.js +454 -0
  122. package/dist/render/measureBounds.js +16 -3
  123. package/dist/render/measureCollisionMesh.js +8 -1
  124. package/dist/render/modelAssets.d.ts +8 -0
  125. package/dist/render/modelAssets.js +43 -0
  126. package/dist/render/modelBindPose.d.ts +5 -0
  127. package/dist/render/modelBindPose.js +35 -0
  128. package/dist/render/modelLoad.d.ts +32 -0
  129. package/dist/render/modelLoad.js +70 -1
  130. package/dist/render/modelNodeVisibility.d.ts +6 -0
  131. package/dist/render/modelNodeVisibility.js +18 -0
  132. package/dist/render/modelRender.d.ts +18 -1
  133. package/dist/render/modelRender.js +81 -3
  134. package/dist/render/projectedOverdraw.d.ts +35 -0
  135. package/dist/render/projectedOverdraw.js +204 -0
  136. package/dist/render/resolveModel.d.ts +12 -2
  137. package/dist/render/resolveModel.js +25 -4
  138. package/dist/render/rigNode.d.ts +26 -0
  139. package/dist/render/rigNode.js +32 -0
  140. package/dist/render/spriteRender.d.ts +4 -0
  141. package/dist/render/spriteRender.js +5 -0
  142. package/dist/render/staticShapeInstances.d.ts +34 -0
  143. package/dist/render/staticShapeInstances.js +78 -0
  144. package/dist/render/useFootIk.d.ts +53 -4
  145. package/dist/render/useFootIk.js +262 -70
  146. package/dist/render/useModelAnimation.d.ts +50 -0
  147. package/dist/render/useModelAnimation.js +245 -99
  148. package/dist/render/useModelInstance.d.ts +25 -0
  149. package/dist/render/useModelInstance.js +43 -0
  150. package/dist/scatter/scatterModels.d.ts +3 -2
  151. package/dist/scatter/scatterModels.js +15 -7
  152. package/dist/scene/AuthoredScene.d.ts +23 -6
  153. package/dist/scene/AuthoredScene.js +46 -9
  154. package/dist/scene/CityRenderer.js +6 -6
  155. package/dist/scene/GeneratedAssetRenderer.js +11 -42
  156. package/dist/settings/QuickControls.js +1 -1
  157. package/dist/settings/SettingsMenu.js +11 -18
  158. package/dist/settings/SettingsRuntime.d.ts +5 -0
  159. package/dist/settings/SettingsRuntime.js +29 -2
  160. package/dist/settings/appliedSettings.d.ts +9 -3
  161. package/dist/settings/appliedSettings.js +6 -5
  162. package/dist/settings/settingsController.d.ts +3 -0
  163. package/dist/settings/settingsController.js +47 -3
  164. package/dist/shellControlSuspension.d.ts +18 -0
  165. package/dist/shellControlSuspension.js +28 -0
  166. package/dist/shellInputPublication.d.ts +28 -0
  167. package/dist/shellInputPublication.js +49 -0
  168. package/dist/shellPointerInput.d.ts +18 -0
  169. package/dist/shellPointerInput.js +49 -0
  170. package/dist/structures/BuildingKitBatch.d.ts +3 -1
  171. package/dist/structures/BuildingKitBatch.js +35 -56
  172. package/dist/structures/BuildingRenderBudget.d.ts +23 -0
  173. package/dist/structures/BuildingRenderBudget.js +54 -0
  174. package/dist/structures/GeneratedBuilding.d.ts +3 -15
  175. package/dist/structures/GeneratedBuilding.js +33 -22
  176. package/dist/structures/buildingKitMaterials.d.ts +20 -0
  177. package/dist/structures/buildingKitMaterials.js +70 -0
  178. package/dist/structures/buildingSpatialBatch.d.ts +11 -0
  179. package/dist/structures/buildingSpatialBatch.js +45 -0
  180. package/dist/structures/buildingSurface.d.ts +21 -0
  181. package/dist/structures/buildingSurface.js +134 -0
  182. package/dist/terrain/GrassField.d.ts +2 -1
  183. package/dist/terrain/GrassField.js +59 -5
  184. package/dist/terrain/grassGeometry.d.ts +10 -0
  185. package/dist/terrain/grassGeometry.js +55 -0
  186. package/dist/terrain/grassMaterial.js +2 -2
  187. package/dist/terrain/terrainDetailMaterial.d.ts +3 -3
  188. package/dist/terrain/terrainDetailMaterial.js +7 -7
  189. package/dist/touch/TouchControlsOverlay.js +4 -4
  190. package/dist/useShellMultiplayerSync.d.ts +1 -1
  191. package/dist/useShellMultiplayerSync.js +9 -3
  192. package/dist/vfx/ParticleField.d.ts +23 -20
  193. package/dist/vfx/ParticleField.js +168 -50
  194. package/dist/visibility/CullingProvider.js +2 -62
  195. package/dist/visibility/cullingDriver.d.ts +9 -0
  196. package/dist/visibility/cullingDriver.js +68 -0
  197. package/dist/weather/AuthoredWeatherLayer.d.ts +16 -0
  198. package/dist/weather/AuthoredWeatherLayer.js +31 -0
  199. package/dist/weather/DustField.d.ts +2 -1
  200. package/dist/weather/DustField.js +17 -3
  201. package/dist/weather/FireSpreadLayer.d.ts +4 -2
  202. package/dist/weather/FireSpreadLayer.js +5 -11
  203. package/dist/weather/LightningStrike.d.ts +4 -1
  204. package/dist/weather/LightningStrike.js +13 -17
  205. package/dist/weather/RainField.d.ts +2 -1
  206. package/dist/weather/RainField.js +17 -3
  207. package/dist/weather/RainImpactField.d.ts +16 -0
  208. package/dist/weather/RainImpactField.js +89 -0
  209. package/dist/weather/SnowField.d.ts +2 -1
  210. package/dist/weather/SnowField.js +17 -3
  211. package/dist/weather/WeatherLayer.d.ts +11 -2
  212. package/dist/weather/WeatherLayer.js +30 -6
  213. package/dist/weather/index.d.ts +2 -0
  214. package/dist/weather/index.js +2 -0
  215. package/dist/weather/weatherMath.d.ts +2 -0
  216. package/dist/weather/weatherMath.js +8 -0
  217. package/dist/weather/weatherShelter.d.ts +18 -0
  218. package/dist/weather/weatherShelter.js +75 -0
  219. package/dist/weather/weatherUniforms.d.ts +40 -1
  220. package/dist/weather/weatherUniforms.js +29 -9
  221. package/dist/world/AuthoredFlocks.d.ts +22 -0
  222. package/dist/world/AuthoredFlocks.js +87 -0
  223. package/dist/world/StaticObjectModels.d.ts +8 -0
  224. package/dist/world/StaticObjectModels.js +75 -0
  225. package/dist/world/WorldHud.d.ts +14 -6
  226. package/dist/world/WorldHud.js +25 -12
  227. package/dist/world/WorldParticles.d.ts +10 -23
  228. package/dist/world/WorldParticles.js +210 -62
  229. package/dist/world/WorldScene.d.ts +3 -1
  230. package/dist/world/WorldScene.js +41 -26
  231. package/dist/world/staticObjectBatches.d.ts +35 -0
  232. package/dist/world/staticObjectBatches.js +163 -0
  233. package/dist/world/worldBarSamples.d.ts +37 -33
  234. package/dist/world/worldBarSamples.js +117 -75
  235. package/package.json +4 -4
  236. package/skills/ce-handoff/SKILL.md +2 -2
  237. package/skills/game-design/SKILL.md +2 -2
  238. package/skills/jgengine/SKILL.md +28 -6
  239. package/skills/jgengine/api.md +139 -99
  240. package/skills/jgengine/capabilities.md +28 -0
  241. package/skills/jgengine/recipes/minimal-game.md +10 -2
  242. package/skills/jgengine-ui/SKILL.md +16 -17
  243. package/skills/jgengine-ui/api.md +603 -365
  244. package/skills/jgengine-ui/capabilities.md +131 -1
  245. package/skills/jgengine-ui/reference-primitives.md +179 -0
  246. package/skills/jgengine-ui/reference.md +4 -81
  247. package/skills/jgengine-world/SKILL.md +25 -4
  248. package/skills/jgengine-world/api.md +1045 -655
  249. package/skills/jgengine-world/capabilities.md +246 -9
  250. package/skills/jgengine-world/recipes/character-animation.md +62 -0
  251. package/skills/jgengine-world/recipes/controls.md +48 -0
  252. package/skills/jgengine-world/recipes/flight-feel.md +76 -0
  253. package/skills/jgengine-world/recipes/npc-senses.md +55 -0
  254. package/skills/jgengine-world/recipes/vehicle-feel.md +87 -0
  255. package/skills/jgengine-world/reference.md +180 -4
  256. package/skills/jgengine-world/visibility.md +139 -0
  257. package/skills/level-design/SKILL.md +1 -1
  258. package/dist/render/testFixtures/proceduralSpriteAtlas.d.ts +0 -15
  259. package/dist/render/testFixtures/proceduralSpriteAtlas.js +0 -30
  260. package/dist/render/testFixtures/proceduralTileLayer.d.ts +0 -13
  261. package/dist/render/testFixtures/proceduralTileLayer.js +0 -30
@@ -1,14 +1,18 @@
1
1
  import { distance3, resolveEmitterGain } from "@jgengine/core/audio/audioFalloff";
2
+ import { dopplerRate } from "@jgengine/core/audio/doppler";
2
3
  import { patchDuration } from "@jgengine/core/audio/synth";
4
+ import { createVoiceAllocator } from "@jgengine/core/audio/voiceAllocator";
3
5
  import { createDisposer } from "@jgengine/core/game/defineGame";
4
- import { clampLoopGain, clampLoopRate } from "./loopParams.js";
6
+ import { clampLoopCutoff, clampLoopGain, clampLoopRate, MIN_LOOP_CUTOFF, MAX_LOOP_CUTOFF } from "./loopParams.js";
5
7
  import { MusicDirector } from "./musicDirector.js";
6
8
  import { createNoiseBuffer, realizeSynthPatch } from "./synthEngine.js";
7
9
  /** setTargetAtTime time constant (~20 ms) for zipper-free live rate/gain ramps on retained loops (#1051). */
8
10
  const LOOP_PARAM_SMOOTH_TC = 0.02;
11
+ const ZERO_VELOCITY = { x: 0, y: 0, z: 0 };
9
12
  function createNoopEngine() {
10
13
  return {
11
14
  setListenerPose: () => undefined,
15
+ setVoiceLimit: () => undefined,
12
16
  playOneShot: () => undefined,
13
17
  playLoop: () => null,
14
18
  playMusic: () => undefined,
@@ -42,25 +46,38 @@ function setPannerPosition(panner, position) {
42
46
  }
43
47
  function createPanner(context, spatial, position) {
44
48
  const panner = context.createPanner();
45
- panner.panningModel = spatial.panning === "hrtf" ? "HRTF" : "equalpower";
46
- panner.distanceModel = "inverse";
47
- if (spatial.refDistance !== undefined)
48
- panner.refDistance = spatial.refDistance;
49
- if (spatial.maxDistance !== undefined)
50
- panner.maxDistance = spatial.maxDistance;
51
- if (spatial.rolloff !== undefined)
52
- panner.rolloffFactor = spatial.rolloff;
53
- if (spatial.coneInner !== undefined)
54
- panner.coneInnerAngle = spatial.coneInner;
55
- if (spatial.coneOuter !== undefined)
56
- panner.coneOuterAngle = spatial.coneOuter;
57
- if (spatial.coneOuterGain !== undefined)
58
- panner.coneOuterGain = spatial.coneOuterGain;
59
- setPannerPosition(panner, position);
49
+ try {
50
+ panner.panningModel = spatial.panning === "hrtf" ? "HRTF" : "equalpower";
51
+ panner.distanceModel = "inverse";
52
+ if (spatial.refDistance !== undefined)
53
+ panner.refDistance = spatial.refDistance;
54
+ if (spatial.maxDistance !== undefined)
55
+ panner.maxDistance = spatial.maxDistance;
56
+ if (spatial.rolloff !== undefined)
57
+ panner.rolloffFactor = spatial.rolloff;
58
+ if (spatial.coneInner !== undefined)
59
+ panner.coneInnerAngle = spatial.coneInner;
60
+ if (spatial.coneOuter !== undefined)
61
+ panner.coneOuterAngle = spatial.coneOuter;
62
+ if (spatial.coneOuterGain !== undefined)
63
+ panner.coneOuterGain = spatial.coneOuterGain;
64
+ setPannerPosition(panner, position);
65
+ }
66
+ catch (error) {
67
+ panner.disconnect();
68
+ throw error;
69
+ }
60
70
  return panner;
61
71
  }
72
+ /**
73
+ * Game-owned sound catalog playback with bounded SFX reservations, spatial panning, and shared buses.
74
+ * @capability bounded-spatial-audio play positional or flat cues with listener orientation, voice budgets, and per-source cleanup
75
+ */
62
76
  export function createAudioEngine(config = {}) {
63
77
  const sounds = config.sounds ?? {};
78
+ const allocator = config.voiceAllocator ?? createVoiceAllocator({ maxTotal: config.maxVoices, overflow: config.voiceOverflow });
79
+ let disposed = false;
80
+ const activeVoices = new Map();
64
81
  const busDefs = config.buses ?? {};
65
82
  const AudioContextCtor = resolveAudioContextCtor();
66
83
  if (AudioContextCtor === undefined)
@@ -80,9 +97,15 @@ export function createAudioEngine(config = {}) {
80
97
  let node = busGains.get(busId);
81
98
  if (node === undefined) {
82
99
  node = context.createGain();
83
- node.gain.value = busDefs[busId]?.gain ?? 1;
84
- node.connect(masterGain);
85
- busGains.set(busId, node);
100
+ try {
101
+ node.gain.value = busDefs[busId]?.gain ?? 1;
102
+ node.connect(masterGain);
103
+ busGains.set(busId, node);
104
+ }
105
+ catch (error) {
106
+ node.disconnect();
107
+ throw error;
108
+ }
86
109
  }
87
110
  return node;
88
111
  }
@@ -140,36 +163,69 @@ export function createAudioEngine(config = {}) {
140
163
  return pending;
141
164
  }
142
165
  let listenerPosition = { x: 0, y: 0, z: 0 };
166
+ let listenerVelocity = { x: 0, y: 0, z: 0 };
167
+ const nyquist = context.sampleRate / 2;
143
168
  const activeSpatialUpdaters = new Set();
144
- const activeLoops = new Set();
145
- const NOOP_HANDLE = {
146
- setPosition: () => undefined,
147
- setRate: () => undefined,
148
- setGain: () => undefined,
149
- stop: () => undefined,
150
- };
151
169
  function playInternal(soundId, position, loop) {
170
+ if (disposed)
171
+ return null;
152
172
  const sound = sounds[soundId];
153
- if (sound === undefined)
173
+ if (sound === undefined || (sound.synth === undefined && sound.url === undefined))
154
174
  return null;
155
- const bus = busGainNode(sound.bus);
156
- const currentPosition = position ?? listenerPosition;
157
- // A one-shot synth cue realises its decaying voices live and is fire-and-forget.
175
+ const allocation = allocator.request(soundId, sound.priority, sound.maxVoices);
176
+ if (!allocation.ok)
177
+ return null;
178
+ for (const id of allocation.stolen)
179
+ activeVoices.get(id)?.();
180
+ const voiceId = allocation.voiceId;
181
+ let bus;
182
+ try {
183
+ bus = busGainNode(sound.bus);
184
+ }
185
+ catch {
186
+ allocator.release(voiceId);
187
+ return null;
188
+ }
189
+ const currentPosition = { ...(position ?? listenerPosition) };
158
190
  if (sound.synth !== undefined && !loop) {
159
- const cueGain = context.createGain();
160
- cueGain.gain.value = sound.spatial === undefined
161
- ? resolveEmitterGain(distance3(currentPosition, listenerPosition), sound, 1)
162
- : sound.gain ?? 1;
163
- if (sound.spatial !== undefined) {
164
- const panner = createPanner(context, sound.spatial, currentPosition);
165
- cueGain.connect(panner);
166
- panner.connect(bus);
191
+ let cueGain = null;
192
+ let panner = null;
193
+ let playback = null;
194
+ let stopped = false;
195
+ function updateGain() {
196
+ if (cueGain === null)
197
+ return;
198
+ cueGain.gain.value = panner === null
199
+ ? resolveEmitterGain(distance3(currentPosition, listenerPosition), sound, 1)
200
+ : sound.gain ?? 1;
167
201
  }
168
- else {
169
- cueGain.connect(bus);
202
+ function stop() {
203
+ if (stopped)
204
+ return;
205
+ stopped = true;
206
+ activeVoices.delete(voiceId);
207
+ activeSpatialUpdaters.delete(updateGain);
208
+ allocator.release(voiceId);
209
+ playback?.stop();
210
+ cueGain?.disconnect();
211
+ panner?.disconnect();
170
212
  }
171
- realizeSynthPatch(context, cueGain, sharedNoiseBuffer(), sound.synth);
172
- return NOOP_HANDLE;
213
+ activeVoices.set(voiceId, stop);
214
+ try {
215
+ cueGain = context.createGain();
216
+ const spatial = sound.positional === false ? undefined : sound.spatial;
217
+ panner = spatial === undefined ? null : createPanner(context, spatial, currentPosition);
218
+ updateGain();
219
+ cueGain.connect(panner ?? bus);
220
+ panner?.connect(bus);
221
+ playback = realizeSynthPatch(context, cueGain, sharedNoiseBuffer(), sound.synth);
222
+ activeSpatialUpdaters.add(updateGain);
223
+ playback.onEnded(stop);
224
+ }
225
+ catch {
226
+ stop();
227
+ }
228
+ return null;
173
229
  }
174
230
  // Everything else resolves to one AudioBuffer we loop/play: sample URL, or a synth patch rendered once.
175
231
  let bufferPromise;
@@ -179,79 +235,142 @@ export function createAudioEngine(config = {}) {
179
235
  bufferPromise = loadBuffer(sound.url);
180
236
  else
181
237
  return null;
182
- return playBufferSource(sound, bus, bufferPromise, currentPosition, loop);
238
+ return playBufferSource(sound, bus, bufferPromise, currentPosition, loop, voiceId);
183
239
  }
184
- function playBufferSource(sound, bus, bufferPromise, initialPosition, loop) {
240
+ function playBufferSource(sound, bus, bufferPromise, initialPosition, loop, voiceId) {
185
241
  const disposer = createDisposer();
186
- let currentPosition = initialPosition;
242
+ let currentPosition = { ...initialPosition };
243
+ let stopped = false;
244
+ const spatial = sound.positional === false ? undefined : sound.spatial;
187
245
  // Live control state, applied on source creation so updates that race the async buffer load stick.
188
246
  let currentRate = 1;
189
247
  let currentUserGain = 1;
248
+ let currentLowpass = MAX_LOOP_CUTOFF;
249
+ let currentHighpass = MIN_LOOP_CUTOFF;
250
+ let currentVelocity = { x: 0, y: 0, z: 0 };
251
+ const dopplerFactor = loop ? sound.doppler ?? 0 : 0;
190
252
  let sourceNode = null;
253
+ let lowpassNode = null;
254
+ let highpassNode = null;
191
255
  let userGainNode = null;
192
256
  let falloffGain = null;
193
257
  let pannerNode = null;
258
+ function effectiveRate() {
259
+ if (dopplerFactor === 0)
260
+ return currentRate;
261
+ const shift = dopplerRate([listenerPosition.x, listenerPosition.y, listenerPosition.z], [listenerVelocity.x, listenerVelocity.y, listenerVelocity.z], [currentPosition.x, currentPosition.y, currentPosition.z], [currentVelocity.x, currentVelocity.y, currentVelocity.z], { factor: dopplerFactor });
262
+ return clampLoopRate(currentRate * shift);
263
+ }
264
+ function applyRate() {
265
+ if (sourceNode !== null)
266
+ sourceNode.playbackRate.setTargetAtTime(effectiveRate(), context.currentTime, LOOP_PARAM_SMOOTH_TC);
267
+ }
194
268
  function updateFalloff() {
195
- if (falloffGain !== null && sound.spatial === undefined) {
269
+ if (falloffGain !== null && spatial === undefined) {
196
270
  falloffGain.gain.value = resolveEmitterGain(distance3(currentPosition, listenerPosition), sound, 1);
197
271
  }
198
272
  if (pannerNode !== null)
199
273
  setPannerPosition(pannerNode, currentPosition);
274
+ if (dopplerFactor !== 0)
275
+ applyRate();
200
276
  }
277
+ disposer.onDispose(() => {
278
+ if (sourceNode !== null)
279
+ sourceNode.onended = null;
280
+ try {
281
+ sourceNode?.stop();
282
+ }
283
+ catch {
284
+ }
285
+ sourceNode?.disconnect();
286
+ lowpassNode?.disconnect();
287
+ highpassNode?.disconnect();
288
+ userGainNode?.disconnect();
289
+ falloffGain?.disconnect();
290
+ pannerNode?.disconnect();
291
+ sourceNode = null;
292
+ lowpassNode = null;
293
+ highpassNode = null;
294
+ userGainNode = null;
295
+ falloffGain = null;
296
+ pannerNode = null;
297
+ });
201
298
  void bufferPromise.then((buffer) => {
202
- if (buffer === null)
299
+ if (stopped || disposed)
300
+ return;
301
+ if (buffer === null) {
302
+ handle.stop();
203
303
  return;
304
+ }
204
305
  const src = context.createBufferSource();
306
+ sourceNode = src;
205
307
  src.buffer = buffer;
206
308
  src.loop = loop || (sound.loop ?? false);
207
- src.playbackRate.value = currentRate;
208
- // source → per-loop user gain (setGain) → distance falloff → bus
309
+ src.playbackRate.value = effectiveRate();
310
+ // Spatial panners own distance attenuation; the scalar stage keeps only authored gain.
209
311
  const uGain = context.createGain();
312
+ userGainNode = uGain;
210
313
  uGain.gain.value = currentUserGain;
211
314
  const fGain = context.createGain();
315
+ falloffGain = fGain;
212
316
  fGain.gain.value = resolveEmitterGain(distance3(currentPosition, listenerPosition), sound, 1);
213
- src.connect(uGain);
317
+ if (loop) {
318
+ const lp = context.createBiquadFilter();
319
+ lowpassNode = lp;
320
+ lp.type = "lowpass";
321
+ lp.frequency.value = clampLoopCutoff(currentLowpass, MAX_LOOP_CUTOFF, nyquist);
322
+ const hp = context.createBiquadFilter();
323
+ highpassNode = hp;
324
+ hp.type = "highpass";
325
+ hp.frequency.value = clampLoopCutoff(currentHighpass, MIN_LOOP_CUTOFF, nyquist);
326
+ src.connect(lp);
327
+ lp.connect(hp);
328
+ hp.connect(uGain);
329
+ }
330
+ else {
331
+ src.connect(uGain);
332
+ }
214
333
  uGain.connect(fGain);
215
- if (sound.spatial !== undefined) {
216
- const panner = createPanner(context, sound.spatial, currentPosition);
217
- fGain.gain.value = 1;
334
+ if (spatial !== undefined) {
335
+ const panner = createPanner(context, spatial, currentPosition);
336
+ pannerNode = panner;
337
+ fGain.gain.value = sound.gain ?? 1;
218
338
  fGain.connect(panner);
219
339
  panner.connect(bus);
220
- pannerNode = panner;
221
340
  }
222
341
  else {
223
342
  fGain.connect(bus);
224
343
  }
344
+ src.onended = () => handle.stop();
225
345
  src.start();
226
- sourceNode = src;
227
- userGainNode = uGain;
228
- falloffGain = fGain;
229
- disposer.onDispose(() => {
230
- try {
231
- src.stop();
232
- }
233
- catch {
234
- }
235
- src.disconnect();
236
- uGain.disconnect();
237
- fGain.disconnect();
238
- pannerNode?.disconnect();
239
- sourceNode = null;
240
- userGainNode = null;
241
- falloffGain = null;
242
- });
243
- });
244
- if (loop)
245
- activeSpatialUpdaters.add(updateFalloff);
346
+ }).catch(() => handle.stop());
347
+ activeSpatialUpdaters.add(updateFalloff);
246
348
  const handle = {
349
+ isPlaying: () => !stopped,
247
350
  setPosition(next) {
248
- currentPosition = next;
351
+ if (stopped)
352
+ return;
353
+ currentPosition = { ...next };
249
354
  updateFalloff();
250
355
  },
251
356
  setRate(rate) {
252
357
  currentRate = clampLoopRate(rate);
253
- if (sourceNode !== null)
254
- sourceNode.playbackRate.setTargetAtTime(currentRate, context.currentTime, LOOP_PARAM_SMOOTH_TC);
358
+ applyRate();
359
+ },
360
+ setLowpass(hz) {
361
+ currentLowpass = clampLoopCutoff(hz, MAX_LOOP_CUTOFF, nyquist);
362
+ if (lowpassNode !== null)
363
+ lowpassNode.frequency.setTargetAtTime(currentLowpass, context.currentTime, LOOP_PARAM_SMOOTH_TC);
364
+ },
365
+ setHighpass(hz) {
366
+ currentHighpass = clampLoopCutoff(hz, MIN_LOOP_CUTOFF, nyquist);
367
+ if (highpassNode !== null)
368
+ highpassNode.frequency.setTargetAtTime(currentHighpass, context.currentTime, LOOP_PARAM_SMOOTH_TC);
369
+ },
370
+ setVelocity(velocity) {
371
+ currentVelocity = { ...velocity };
372
+ if (dopplerFactor !== 0)
373
+ applyRate();
255
374
  },
256
375
  setGain(gain) {
257
376
  currentUserGain = clampLoopGain(gain);
@@ -259,19 +378,31 @@ export function createAudioEngine(config = {}) {
259
378
  userGainNode.gain.setTargetAtTime(currentUserGain, context.currentTime, LOOP_PARAM_SMOOTH_TC);
260
379
  },
261
380
  stop() {
381
+ if (stopped)
382
+ return;
383
+ stopped = true;
262
384
  activeSpatialUpdaters.delete(updateFalloff);
263
- activeLoops.delete(handle);
385
+ activeVoices.delete(voiceId);
386
+ allocator.release(voiceId);
264
387
  disposer.dispose();
265
388
  },
266
389
  };
267
- if (loop)
268
- activeLoops.add(handle);
390
+ activeVoices.set(voiceId, () => handle.stop());
269
391
  return handle;
270
392
  }
271
393
  return {
394
+ setVoiceLimit(maxTotal, overflow) {
395
+ if (disposed)
396
+ return;
397
+ for (const id of allocator.retune({ maxTotal, overflow }))
398
+ activeVoices.get(id)?.();
399
+ },
272
400
  setListenerPose(position) {
401
+ if (disposed)
402
+ return;
273
403
  const pose = "position" in position ? position : { position, forward: { x: 0, y: 0, z: -1 }, up: { x: 0, y: 1, z: 0 } };
274
- listenerPosition = pose.position;
404
+ listenerPosition = { ...pose.position };
405
+ listenerVelocity = "velocity" in pose && pose.velocity !== undefined ? { ...pose.velocity } : ZERO_VELOCITY;
275
406
  const listener = context.listener;
276
407
  setAudioParam(listener.positionX, pose.position.x);
277
408
  setAudioParam(listener.positionY, pose.position.y);
@@ -286,6 +417,8 @@ export function createAudioEngine(config = {}) {
286
417
  updateGain();
287
418
  },
288
419
  playOneShot(soundId, position) {
420
+ if (disposed)
421
+ return;
289
422
  void context.resume().catch(() => undefined);
290
423
  playInternal(soundId, position, false);
291
424
  },
@@ -293,23 +426,38 @@ export function createAudioEngine(config = {}) {
293
426
  return playInternal(soundId, position, true);
294
427
  },
295
428
  playMusic(themeId, options) {
429
+ if (disposed)
430
+ return;
296
431
  void context.resume().catch(() => undefined);
297
432
  musicDirector()?.crossfadeTo(themeId, options);
298
433
  },
299
434
  setBusGain(busId, gain) {
435
+ if (disposed)
436
+ return;
300
437
  busGainNode(busId).gain.value = gain;
301
438
  },
302
439
  setMasterGain(gain) {
440
+ if (disposed)
441
+ return;
303
442
  masterGain.gain.value = gain;
304
443
  },
305
444
  resume() {
445
+ if (disposed)
446
+ return;
306
447
  void context.resume().catch(() => undefined);
307
448
  },
308
449
  dispose() {
309
- for (const handle of [...activeLoops])
310
- handle.stop();
450
+ if (disposed)
451
+ return;
452
+ disposed = true;
453
+ for (const stop of [...activeVoices.values()])
454
+ stop();
311
455
  activeSpatialUpdaters.clear();
312
456
  director?.dispose();
457
+ for (const node of busGains.values())
458
+ node.disconnect();
459
+ busGains.clear();
460
+ masterGain.disconnect();
313
461
  void context.close().catch(() => undefined);
314
462
  },
315
463
  };
@@ -17,6 +17,9 @@ type AudioLoopSetPayload = {
17
17
  rate?: number;
18
18
  gain?: number;
19
19
  at?: readonly [number, number, number];
20
+ velocity?: readonly [number, number, number];
21
+ lowpass?: number;
22
+ highpass?: number;
20
23
  };
21
24
  type AudioLoopStopPayload = {
22
25
  id: string;
@@ -19,8 +19,8 @@ export function attachAudioEventWire(events, audioEngine) {
19
19
  const offLoopStart = events.on("audio.loopStart", ({ id, sound, at }) => {
20
20
  const existing = loops.get(id);
21
21
  if (existing !== undefined) {
22
- // Same sound: idempotent — keep the running source so a per-tick re-issue never clicks.
23
- if (existing.sound === sound)
22
+ // Keep active/pending loops; a stolen voice can retry on the next explicit start.
23
+ if (existing.sound === sound && existing.handle.isPlaying?.() !== false)
24
24
  return;
25
25
  existing.handle.stop();
26
26
  loops.delete(id);
@@ -29,7 +29,7 @@ export function attachAudioEventWire(events, audioEngine) {
29
29
  if (handle !== null)
30
30
  loops.set(id, { sound, handle });
31
31
  });
32
- const offLoopSet = events.on("audio.loopSet", ({ id, rate, gain, at }) => {
32
+ const offLoopSet = events.on("audio.loopSet", ({ id, rate, gain, at, velocity, lowpass, highpass }) => {
33
33
  const entry = loops.get(id);
34
34
  // Unknown id: a live update may race a stop — silently drop it.
35
35
  if (entry === undefined)
@@ -40,6 +40,12 @@ export function attachAudioEventWire(events, audioEngine) {
40
40
  entry.handle.setGain(gain);
41
41
  if (at !== undefined)
42
42
  entry.handle.setPosition({ x: at[0], y: at[1], z: at[2] });
43
+ if (velocity !== undefined)
44
+ entry.handle.setVelocity({ x: velocity[0], y: velocity[1], z: velocity[2] });
45
+ if (lowpass !== undefined)
46
+ entry.handle.setLowpass(lowpass);
47
+ if (highpass !== undefined)
48
+ entry.handle.setHighpass(highpass);
43
49
  });
44
50
  const offLoopStop = events.on("audio.loopStop", ({ id }) => {
45
51
  const entry = loops.get(id);
@@ -18,3 +18,42 @@ export declare function clampLoopRate(rate: number): number;
18
18
  * @internal — the audio engine applies this; game code sets gain through `ctx.game.audio.setLoop`.
19
19
  */
20
20
  export declare function clampLoopGain(gain: number): number;
21
+ /** Lowest live filter cutoff, Hz. */
22
+ export declare const MIN_LOOP_CUTOFF = 10;
23
+ /** Highest live filter cutoff, Hz; the engine also caps it at the context's Nyquist frequency. */
24
+ export declare const MAX_LOOP_CUTOFF = 22050;
25
+ /**
26
+ * Clamp a loop filter cutoff into `MIN_LOOP_CUTOFF`–`min(MAX_LOOP_CUTOFF, nyquist)` Hz. Non-finite input
27
+ * falls back to `fallback` (the open, transparent end of that filter).
28
+ * @internal — the audio engine applies this; game code sets cutoffs through `ctx.game.audio.setLoop`.
29
+ */
30
+ export declare function clampLoopCutoff(hz: number, fallback: number, nyquist?: number): number;
31
+ /** Smoothed listener velocity estimate from per-frame camera positions. @internal */
32
+ export interface ListenerVelocityTrack {
33
+ last: {
34
+ x: number;
35
+ y: number;
36
+ z: number;
37
+ } | null;
38
+ velocity: {
39
+ x: number;
40
+ y: number;
41
+ z: number;
42
+ };
43
+ }
44
+ /** @internal */
45
+ export declare function createListenerVelocityTrack(): ListenerVelocityTrack;
46
+ /**
47
+ * Advance the listener velocity estimate by one frame: the position delta over `dt`, eased with a
48
+ * ~50 ms time constant so frame-time jitter does not warble doppler. A cut resets it to zero.
49
+ * @internal
50
+ */
51
+ export declare function stepListenerVelocity(track: ListenerVelocityTrack, position: {
52
+ x: number;
53
+ y: number;
54
+ z: number;
55
+ }, dt: number): {
56
+ x: number;
57
+ y: number;
58
+ z: number;
59
+ };
@@ -26,3 +26,46 @@ export function clampLoopGain(gain) {
26
26
  return 0;
27
27
  return Math.min(1, Math.max(0, gain));
28
28
  }
29
+ /** Lowest live filter cutoff, Hz. */
30
+ export const MIN_LOOP_CUTOFF = 10;
31
+ /** Highest live filter cutoff, Hz; the engine also caps it at the context's Nyquist frequency. */
32
+ export const MAX_LOOP_CUTOFF = 22050;
33
+ /**
34
+ * Clamp a loop filter cutoff into `MIN_LOOP_CUTOFF`–`min(MAX_LOOP_CUTOFF, nyquist)` Hz. Non-finite input
35
+ * falls back to `fallback` (the open, transparent end of that filter).
36
+ * @internal — the audio engine applies this; game code sets cutoffs through `ctx.game.audio.setLoop`.
37
+ */
38
+ export function clampLoopCutoff(hz, fallback, nyquist = MAX_LOOP_CUTOFF) {
39
+ const max = Math.min(MAX_LOOP_CUTOFF, nyquist);
40
+ if (!Number.isFinite(hz))
41
+ return Math.min(max, Math.max(MIN_LOOP_CUTOFF, fallback));
42
+ return Math.min(max, Math.max(MIN_LOOP_CUTOFF, hz));
43
+ }
44
+ /** @internal */
45
+ export function createListenerVelocityTrack() {
46
+ return { last: null, velocity: { x: 0, y: 0, z: 0 } };
47
+ }
48
+ /** Camera jumps faster than this (world units/s) are cuts or teleports, not motion, and reset the estimate. */
49
+ const MAX_LISTENER_SPEED = 400;
50
+ /**
51
+ * Advance the listener velocity estimate by one frame: the position delta over `dt`, eased with a
52
+ * ~50 ms time constant so frame-time jitter does not warble doppler. A cut resets it to zero.
53
+ * @internal
54
+ */
55
+ export function stepListenerVelocity(track, position, dt) {
56
+ const last = track.last;
57
+ track.last = { x: position.x, y: position.y, z: position.z };
58
+ if (last === null || !(dt > 0))
59
+ return track.velocity;
60
+ const vx = (position.x - last.x) / dt;
61
+ const vy = (position.y - last.y) / dt;
62
+ const vz = (position.z - last.z) / dt;
63
+ if (Math.hypot(vx, vy, vz) > MAX_LISTENER_SPEED) {
64
+ track.velocity = { x: 0, y: 0, z: 0 };
65
+ return track.velocity;
66
+ }
67
+ const blend = 1 - Math.exp(-dt / 0.05);
68
+ const v = track.velocity;
69
+ track.velocity = { x: v.x + (vx - v.x) * blend, y: v.y + (vy - v.y) * blend, z: v.z + (vz - v.z) * blend };
70
+ return track.velocity;
71
+ }
@@ -1,9 +1,15 @@
1
1
  import type { SynthPatch } from "@jgengine/core/audio/synth";
2
2
  /** Build the shared 1-second mono white-noise buffer every noise voice samples from. */
3
3
  export declare function createNoiseBuffer(ctx: BaseAudioContext): AudioBuffer;
4
+ /** Scheduled procedural sources; stopping releases their source/filter/envelope graph. */
5
+ export interface SynthPlayback {
6
+ stop(): void;
7
+ /** Called once after every source ends; late subscribers are notified immediately. */
8
+ onEnded(callback: () => void): void;
9
+ }
4
10
  /**
5
11
  * Realise a procedural cue on Web Audio: every voice is scheduled at
6
12
  * `ctx.currentTime + delay` into `out`, summed into one one-shot. `noiseBuf` is
7
13
  * the shared buffer from {@link createNoiseBuffer}.
8
14
  */
9
- export declare function realizeSynthPatch(ctx: BaseAudioContext, out: AudioNode, noiseBuf: AudioBuffer, patch: SynthPatch): void;
15
+ export declare function realizeSynthPatch(ctx: BaseAudioContext, out: AudioNode, noiseBuf: AudioBuffer, patch: SynthPatch): SynthPlayback;