@woosh/meep-engine 3.46.0 → 3.47.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 (230) hide show
  1. package/editor/actions/concrete/TransformModifyAction.d.ts.map +1 -1
  2. package/editor/actions/concrete/TransformModifyAction.js +10 -2
  3. package/editor/ecs/component/FieldDescriptor.js +15 -0
  4. package/editor/ecs/component/createObjectEditor.js +41 -12
  5. package/editor/ecs/component/editors/ecs/Transform64Editor.d.ts.map +1 -1
  6. package/editor/ecs/component/editors/ecs/Transform64Editor.js +77 -70
  7. package/editor/tools/TransformToolV2.d.ts.map +1 -1
  8. package/editor/tools/TransformToolV2.js +4 -0
  9. package/editor/tools/v2/TransformControls.d.ts.map +1 -1
  10. package/editor/tools/v2/TransformControls.js +7 -0
  11. package/editor/view/ecs/EntityEditor.d.ts.map +1 -1
  12. package/editor/view/ecs/EntityEditor.js +4 -2
  13. package/package.json +1 -1
  14. package/src/core/collection/array/array_insert_at.d.ts +16 -0
  15. package/src/core/collection/array/array_insert_at.d.ts.map +1 -0
  16. package/src/core/collection/array/array_insert_at.js +32 -0
  17. package/src/core/collection/array/array_remove_at.d.ts +15 -0
  18. package/src/core/collection/array/array_remove_at.d.ts.map +1 -0
  19. package/src/core/collection/array/array_remove_at.js +30 -0
  20. package/src/core/geom/3d/quaternion/quat3_look_rotation.d.ts +37 -0
  21. package/src/core/geom/3d/quaternion/quat3_look_rotation.d.ts.map +1 -0
  22. package/src/core/geom/3d/quaternion/quat3_look_rotation.js +172 -0
  23. package/src/core/geom/Quaternion.d.ts +1 -0
  24. package/src/core/geom/Quaternion.d.ts.map +1 -1
  25. package/src/core/geom/Quaternion.js +12 -48
  26. package/src/engine/Engine.d.ts.map +1 -1
  27. package/src/engine/Engine.js +9 -4
  28. package/src/engine/ecs/storage/populateEngineSerializationRegistry.d.ts.map +1 -1
  29. package/src/engine/ecs/storage/populateEngineSerializationRegistry.js +10 -0
  30. package/src/engine/ecs/transform/t64_look_rotation.d.ts +4 -4
  31. package/src/engine/ecs/transform/t64_look_rotation.d.ts.map +1 -1
  32. package/src/engine/ecs/transform/t64_look_rotation.js +7 -18
  33. package/src/engine/graphics/ecs/camera/topdown/TopDownCameraControllerSystem.d.ts.map +1 -1
  34. package/src/engine/graphics/ecs/camera/topdown/TopDownCameraControllerSystem.js +30 -1
  35. package/src/engine/graphics3/GPUParticleEmitterSystem.d.ts +6 -0
  36. package/src/engine/graphics3/GPUParticleEmitterSystem.d.ts.map +1 -1
  37. package/src/engine/graphics3/GPUParticleEmitterSystem.js +72 -29
  38. package/src/engine/input/devices/KeyboardDevice.d.ts +1 -0
  39. package/src/engine/input/devices/KeyboardDevice.d.ts.map +1 -1
  40. package/src/engine/input/devices/KeyboardDevice.js +7 -6
  41. package/src/engine/navigation/ecs/path_following/PathFollowingSystem.d.ts.map +1 -1
  42. package/src/engine/navigation/ecs/path_following/PathFollowingSystem.js +15 -2
  43. package/src/engine/network/CONGESTION_CONTROL.md +9 -9
  44. package/src/engine/network/MESSAGE_TRANSPORT.md +364 -0
  45. package/src/engine/network/NetworkSession.d.ts +31 -11
  46. package/src/engine/network/NetworkSession.d.ts.map +1 -1
  47. package/src/engine/network/NetworkSession.js +453 -286
  48. package/src/engine/network/README.md +77 -27
  49. package/src/engine/network/SMOOTHNESS_DESIGN.md +13 -5
  50. package/src/engine/network/diagnostics/BandwidthMeter.d.ts +1 -1
  51. package/src/engine/network/diagnostics/BandwidthMeter.js +1 -1
  52. package/src/engine/network/orchestrator/HostJoin.d.ts +111 -0
  53. package/src/engine/network/orchestrator/HostJoin.d.ts.map +1 -0
  54. package/src/engine/network/orchestrator/HostJoin.js +271 -0
  55. package/src/engine/network/orchestrator/NetworkPeer.d.ts +151 -112
  56. package/src/engine/network/orchestrator/NetworkPeer.d.ts.map +1 -1
  57. package/src/engine/network/orchestrator/NetworkPeer.js +450 -499
  58. package/src/engine/network/orchestrator/PendingSlices.d.ts +30 -0
  59. package/src/engine/network/orchestrator/PendingSlices.d.ts.map +1 -0
  60. package/src/engine/network/orchestrator/PendingSlices.js +83 -0
  61. package/src/engine/network/orchestrator/ReliableFrames.d.ts +34 -0
  62. package/src/engine/network/orchestrator/ReliableFrames.d.ts.map +1 -0
  63. package/src/engine/network/orchestrator/ReliableFrames.js +96 -0
  64. package/src/engine/network/orchestrator/ServerAuthoritativeClient.d.ts +77 -10
  65. package/src/engine/network/orchestrator/ServerAuthoritativeClient.d.ts.map +1 -1
  66. package/src/engine/network/orchestrator/ServerAuthoritativeClient.js +236 -61
  67. package/src/engine/network/orchestrator/ServerAuthoritativeServer.d.ts +20 -6
  68. package/src/engine/network/orchestrator/ServerAuthoritativeServer.d.ts.map +1 -1
  69. package/src/engine/network/orchestrator/ServerAuthoritativeServer.js +33 -8
  70. package/src/engine/network/replication/Replicator.d.ts.map +1 -1
  71. package/src/engine/network/replication/Replicator.js +7 -1
  72. package/src/engine/network/sim/ActionLog.d.ts +32 -5
  73. package/src/engine/network/sim/ActionLog.d.ts.map +1 -1
  74. package/src/engine/network/sim/ActionLog.js +47 -5
  75. package/src/engine/network/sim/SimActionExecutor.d.ts +42 -2
  76. package/src/engine/network/sim/SimActionExecutor.d.ts.map +1 -1
  77. package/src/engine/network/sim/SimActionExecutor.js +100 -4
  78. package/src/engine/network/sim/Snapshotter.d.ts +12 -0
  79. package/src/engine/network/sim/Snapshotter.d.ts.map +1 -1
  80. package/src/engine/network/sim/Snapshotter.js +43 -0
  81. package/src/engine/network/state/Baseline.d.ts +1 -1
  82. package/src/engine/network/state/Baseline.js +1 -1
  83. package/src/engine/network/state/FrameBufferRing.d.ts +25 -6
  84. package/src/engine/network/state/FrameBufferRing.d.ts.map +1 -1
  85. package/src/engine/network/state/FrameBufferRing.js +51 -6
  86. package/src/engine/network/state/InputRing.d.ts +4 -0
  87. package/src/engine/network/state/InputRing.d.ts.map +1 -1
  88. package/src/engine/network/state/InputRing.js +7 -0
  89. package/src/engine/network/transport/DeferredTransport.d.ts.map +1 -1
  90. package/src/engine/network/transport/DeferredTransport.js +1 -0
  91. package/src/engine/network/transport/LoopbackTransport.d.ts.map +1 -1
  92. package/src/engine/network/transport/LoopbackTransport.js +4 -2
  93. package/src/engine/network/transport/Transport.d.ts +7 -0
  94. package/src/engine/network/transport/Transport.d.ts.map +1 -1
  95. package/src/engine/network/transport/Transport.js +8 -0
  96. package/src/engine/network/transport/adapters/NodeUDPTransport.d.ts.map +1 -1
  97. package/src/engine/network/transport/adapters/NodeUDPTransport.js +20 -5
  98. package/src/engine/network/transport/adapters/WebRTCDataChannelTransport.d.ts.map +1 -1
  99. package/src/engine/network/transport/adapters/WebRTCDataChannelTransport.js +8 -0
  100. package/src/engine/network/transport/adapters/WebTransportTransport.d.ts.map +1 -1
  101. package/src/engine/network/transport/adapters/WebTransportTransport.js +6 -0
  102. package/src/engine/network/transport/connection/Connection.d.ts +191 -0
  103. package/src/engine/network/transport/connection/Connection.d.ts.map +1 -0
  104. package/src/engine/network/transport/connection/Connection.js +1262 -0
  105. package/src/engine/network/transport/connection/RangeSet.d.ts +94 -0
  106. package/src/engine/network/transport/connection/RangeSet.d.ts.map +1 -0
  107. package/src/engine/network/transport/connection/RangeSet.js +260 -0
  108. package/src/engine/network/transport/connection/RxStream.d.ts +48 -0
  109. package/src/engine/network/transport/connection/RxStream.d.ts.map +1 -0
  110. package/src/engine/network/transport/connection/RxStream.js +211 -0
  111. package/src/engine/network/transport/connection/TxStream.d.ts +90 -0
  112. package/src/engine/network/transport/connection/TxStream.d.ts.map +1 -0
  113. package/src/engine/network/transport/connection/TxStream.js +243 -0
  114. package/src/engine/network/transport/connection/bytes_copy.d.ts +17 -0
  115. package/src/engine/network/transport/connection/bytes_copy.d.ts.map +1 -0
  116. package/src/engine/network/transport/connection/bytes_copy.js +34 -0
  117. package/src/engine/network/transport/connection/varint.d.ts +46 -0
  118. package/src/engine/network/transport/connection/varint.d.ts.map +1 -0
  119. package/src/engine/network/transport/connection/varint.js +107 -0
  120. package/src/engine/physics/fluid/ecs/FluidObstacleSystem.d.ts +4 -4
  121. package/src/engine/sound/ecs/audio/AudioEmitter.d.ts +38 -7
  122. package/src/engine/sound/ecs/audio/AudioEmitter.d.ts.map +1 -1
  123. package/src/engine/sound/ecs/audio/AudioEmitter.js +35 -7
  124. package/src/engine/sound/ecs/audio/AudioEmitterSerializationAdapter.d.ts.map +1 -1
  125. package/src/engine/sound/ecs/audio/AudioEmitterSerializationAdapter.js +5 -1
  126. package/src/engine/sound/ecs/audio/AudioEmitterSerializationUpgrader_0_1.d.ts +18 -0
  127. package/src/engine/sound/ecs/audio/AudioEmitterSerializationUpgrader_0_1.d.ts.map +1 -0
  128. package/src/engine/sound/ecs/audio/AudioEmitterSerializationUpgrader_0_1.js +48 -0
  129. package/src/engine/sound/ecs/audio/AudioEmitterSystem.d.ts +12 -2
  130. package/src/engine/sound/ecs/audio/AudioEmitterSystem.d.ts.map +1 -1
  131. package/src/engine/sound/ecs/audio/AudioEmitterSystem.js +173 -9
  132. package/src/engine/sound/ecs/audio/LiveEmitterSet.d.ts +9 -3
  133. package/src/engine/sound/ecs/audio/LiveEmitterSet.d.ts.map +1 -1
  134. package/src/engine/sound/ecs/audio/LiveEmitterSet.js +28 -8
  135. package/src/engine/sound/ecs/trigger/AudioEventTrigger.d.ts +8 -0
  136. package/src/engine/sound/ecs/trigger/AudioEventTrigger.d.ts.map +1 -1
  137. package/src/engine/sound/ecs/trigger/AudioEventTriggerSerializationAdapter.d.ts.map +1 -1
  138. package/src/engine/sound/ecs/trigger/AudioEventTriggerSerializationAdapter.js +5 -1
  139. package/src/engine/sound/ecs/trigger/AudioEventTriggerSerializationUpgrader_0_1.d.ts +18 -0
  140. package/src/engine/sound/ecs/trigger/AudioEventTriggerSerializationUpgrader_0_1.d.ts.map +1 -0
  141. package/src/engine/sound/ecs/trigger/AudioEventTriggerSerializationUpgrader_0_1.js +51 -0
  142. package/src/engine/sound/ecs/trigger/AudioEventTriggerSystem.d.ts.map +1 -1
  143. package/src/engine/sound/ecs/trigger/AudioEventTriggerSystem.js +15 -1
  144. package/src/engine/sound/sopra/README.md +768 -644
  145. package/src/engine/sound/sopra/SopraEngine.d.ts +104 -8
  146. package/src/engine/sound/sopra/SopraEngine.d.ts.map +1 -1
  147. package/src/engine/sound/sopra/SopraEngine.js +327 -12
  148. package/src/engine/sound/sopra/definition/EventDescription.d.ts +28 -2
  149. package/src/engine/sound/sopra/definition/EventDescription.d.ts.map +1 -1
  150. package/src/engine/sound/sopra/definition/EventDescription.js +55 -5
  151. package/src/engine/sound/sopra/definition/EventDescriptionSerializationAdapter.d.ts +10 -0
  152. package/src/engine/sound/sopra/definition/EventDescriptionSerializationAdapter.d.ts.map +1 -1
  153. package/src/engine/sound/sopra/definition/EventDescriptionSerializationAdapter.js +77 -55
  154. package/src/engine/sound/sopra/definition/EventDescriptionSerializationUpgrader_0_1.d.ts +31 -0
  155. package/src/engine/sound/sopra/definition/EventDescriptionSerializationUpgrader_0_1.d.ts.map +1 -0
  156. package/src/engine/sound/sopra/definition/EventDescriptionSerializationUpgrader_0_1.js +75 -0
  157. package/src/engine/sound/sopra/definition/effect/AbstractAudioEffect.d.ts +38 -9
  158. package/src/engine/sound/sopra/definition/effect/AbstractAudioEffect.d.ts.map +1 -1
  159. package/src/engine/sound/sopra/definition/effect/AbstractAudioEffect.js +34 -5
  160. package/src/engine/sound/sopra/definition/effect/CompressorEffect.d.ts +7 -2
  161. package/src/engine/sound/sopra/definition/effect/CompressorEffect.d.ts.map +1 -1
  162. package/src/engine/sound/sopra/definition/effect/CompressorEffect.js +137 -122
  163. package/src/engine/sound/sopra/definition/effect/EqEffect.d.ts +3 -1
  164. package/src/engine/sound/sopra/definition/effect/EqEffect.d.ts.map +1 -1
  165. package/src/engine/sound/sopra/definition/effect/EqEffect.js +146 -131
  166. package/src/engine/sound/sopra/definition/effect/ReverbEffect.d.ts +1 -0
  167. package/src/engine/sound/sopra/definition/effect/ReverbEffect.d.ts.map +1 -1
  168. package/src/engine/sound/sopra/definition/effect/ReverbEffect.js +6 -1
  169. package/src/engine/sound/sopra/runtime/BusGraph.d.ts +44 -2
  170. package/src/engine/sound/sopra/runtime/BusGraph.d.ts.map +1 -1
  171. package/src/engine/sound/sopra/runtime/BusGraph.js +83 -43
  172. package/src/engine/sound/sopra/runtime/EffectChain.d.ts +96 -0
  173. package/src/engine/sound/sopra/runtime/EffectChain.d.ts.map +1 -0
  174. package/src/engine/sound/sopra/runtime/EffectChain.js +286 -0
  175. package/src/engine/sound/sopra/runtime/EffectParamOverrides.d.ts +123 -0
  176. package/src/engine/sound/sopra/runtime/EffectParamOverrides.d.ts.map +1 -0
  177. package/src/engine/sound/sopra/runtime/EffectParamOverrides.js +320 -0
  178. package/src/engine/sound/sopra/runtime/EventInstance.d.ts +55 -5
  179. package/src/engine/sound/sopra/runtime/EventInstance.d.ts.map +1 -1
  180. package/src/engine/sound/sopra/runtime/EventInstance.js +201 -26
  181. package/src/engine/sound/sopra/serialization/populateSopraSerializationRegistry.d.ts +2 -1
  182. package/src/engine/sound/sopra/serialization/populateSopraSerializationRegistry.d.ts.map +1 -1
  183. package/src/engine/sound/sopra/serialization/populateSopraSerializationRegistry.js +6 -1
  184. package/src/engine/sound/sopra/serialization/sopraSerializationHarness.d.ts +15 -0
  185. package/src/engine/sound/sopra/serialization/sopraSerializationHarness.d.ts.map +1 -1
  186. package/src/engine/sound/sopra/serialization/sopraSerializationHarness.js +32 -0
  187. package/src/engine/sound/sopra/util/MockAudioContext.d.ts.map +1 -1
  188. package/src/engine/sound/sopra/util/MockAudioContext.js +3 -0
  189. package/src/engine/sound/sopra/util/fadeOutAndStop.d.ts +13 -3
  190. package/src/engine/sound/sopra/util/fadeOutAndStop.d.ts.map +1 -1
  191. package/src/engine/sound/sopra/util/fadeOutAndStop.js +25 -13
  192. package/src/shade/RENDERER_CONTRACT.md +6 -3
  193. package/src/shade/device/SHADE_DEVICE_FLOOR.d.ts +24 -0
  194. package/src/shade/device/SHADE_DEVICE_FLOOR.d.ts.map +1 -0
  195. package/src/shade/device/SHADE_DEVICE_FLOOR.js +34 -0
  196. package/src/shade/device/ShadeDeviceFailure.d.ts +12 -5
  197. package/src/shade/device/ShadeDeviceFailure.d.ts.map +1 -1
  198. package/src/shade/device/ShadeDeviceFailure.js +17 -8
  199. package/src/shade/device/ShadeDeviceFailureReason.d.ts +1 -1
  200. package/src/shade/device/ShadeDeviceFailureReason.js +2 -2
  201. package/src/shade/device/shade_device_floor_unmet.d.ts +13 -0
  202. package/src/shade/device/shade_device_floor_unmet.d.ts.map +1 -0
  203. package/src/shade/device/shade_device_floor_unmet.js +32 -0
  204. package/src/shade/renderer/Renderer.d.ts +2 -0
  205. package/src/shade/renderer/Renderer.d.ts.map +1 -1
  206. package/src/shade/renderer/Renderer.js +29 -55
  207. package/src/shade/renderer/buffer/GPUBufferAllocator.d.ts.map +1 -1
  208. package/src/shade/renderer/buffer/GPUBufferAllocator.js +9 -0
  209. package/src/shade/renderer/buffer/buffer_pool_ops.d.ts +21 -12
  210. package/src/shade/renderer/buffer/buffer_pool_ops.d.ts.map +1 -1
  211. package/src/shade/renderer/buffer/buffer_pool_ops.js +18 -19
  212. package/src/shade/renderer/gpu_primitive/test/gpu_test.d.ts.map +1 -1
  213. package/src/shade/renderer/gpu_primitive/test/gpu_test.js +8 -11
  214. package/src/shade/renderer/loader/gltf/tiny-gltf.d.ts +1 -1
  215. package/src/shade/renderer/pool/TransientResourcePool.d.ts +24 -0
  216. package/src/shade/renderer/pool/TransientResourcePool.d.ts.map +1 -1
  217. package/src/shade/renderer/pool/TransientResourcePool.js +41 -10
  218. package/src/shade/renderer/shader/ComputeShader.d.ts.map +1 -1
  219. package/src/shade/renderer/shader/ComputeShader.js +18 -2
  220. package/src/shade/renderer/shader/ImageShader.d.ts.map +1 -1
  221. package/src/shade/renderer/shader/ImageShader.js +230 -218
  222. package/src/view/graphics/GraphicsContextFailureView.d.ts +1 -2
  223. package/src/view/graphics/GraphicsContextFailureView.d.ts.map +1 -1
  224. package/src/view/graphics/GraphicsContextFailureView.js +1 -2
  225. package/src/engine/network/transport/Channel.js +0 -336
  226. package/src/engine/network/transport/ReliableCommandPipeline.js +0 -335
  227. package/src/engine/network/transport/fragments/FragmentAssembler.js +0 -350
  228. package/src/engine/network/transport/fragments/FragmentRetention.js +0 -191
  229. package/src/engine/network/transport/fragments/fragment_send.js +0 -157
  230. package/src/engine/network/transport/fragments/packet_size.js +0 -107
@@ -1,644 +1,768 @@
1
- # Sopra
2
-
3
- An object-oriented, data-driven sound engine for the meep ECS, loosely modelled on FMOD / Wwise
4
- concepts (events, a mixer bus tree, RTPC parameters, voice stealing, snapshots, ducking) but built
5
- around meep's own data model instead of an opaque middleware bank.
6
-
7
- Sopra is **ECS-agnostic at its core**. The engine (`SopraEngine` + its services) knows nothing about
8
- entities; it is a self-contained audio renderer driven by a single clock. A thin ECS layer
9
- (`AudioEmitter` + `AudioEmitterSystem`, outside this folder under `sound/ecs/audio/`) binds it to the
10
- game. You can use the core standalone (tools, previews, tests) with nothing but a `BaseAudioContext`
11
- and a `BufferProvider`.
12
-
13
- > The name "sopra" is internal. The player-facing / integration layer uses familiar terms
14
- > (`AudioEmitter`, `AudioEventTrigger`); "sopra" never appears in user-facing API.
15
-
16
- ---
17
-
18
- ## The one idea you must understand: definition vs. runtime
19
-
20
- Everything in Sopra is split into two halves. Get this and the rest follows.
21
-
22
- | | **Definition** (authored data) | **Runtime** (transient playback) |
23
- |---|---|---|
24
- | What | `EventDescription`, the `*AudioClip` graph, `BusDefinition`, `*AudioEffect`, `ParameterDefinition`, `MixerSnapshot`, `DuckingRule` | `EventInstance`, `Voice`, the live WebAudio node graph |
25
- | Lifetime | Immutable, shared, **serializable** (save/load, network) | Created on trigger, pooled, **never serialized** |
26
- | Holds | Spec only — urls, gains in dB, curves, child clips, routing | WebAudio nodes, the resolved timeline, the playhead, RNG state |
27
- | Lives in | `definition/` | `runtime/` |
28
-
29
- A definition is a recipe. Playing it spawns a transient `EventInstance` that reads the recipe once,
30
- resolves a concrete timeline of leaf plays, and drives WebAudio nodes. The same definition can back
31
- many simultaneous instances. **Definitions never hold playback state** — that is the rule the whole
32
- architecture defends.
33
-
34
- ```
35
- EventDescription (definition)
36
- │ engine.playEvent(...)
37
- ▼
38
- EventInstance (runtime) ──spawns──► Voice ──► bus chain ──► destination
39
- ```
40
-
41
- ---
42
-
43
- ## Directory map
44
-
45
- ```
46
- sopra/
47
- SopraEngine.js the facade: owns the bus graph, parameters, active instances, the clock
48
- definition/ immutable, serializable authored data (+ co-located *SerializationAdapter.js)
49
- EventDescription.js the triggerable unit (root clip + routing + 3D + voice config)
50
- BusDefinition.js one node in the mixer tree (gain, effect chain, aux sends)
51
- ParameterDefinition.js a declared RTPC parameter
52
- MixerSnapshot.js a named set of per-bus target gains
53
- DuckingRule.js an emulated sidechain duck (trigger bus → target bus)
54
- SopraPanningModel.js HRTF | EqualPower
55
- VoiceStealMode.js None | Oldest | Quietest
56
- clip/ the clip graph — what actually makes sound
57
- AbstractAudioClip.js base; planTimeline() + loops() + collectSampleClips()
58
- SampleAudioClip.js a single audio asset (the only buffer-referencing leaf)
59
- SilenceAudioClip.js dead air (a gap in a sequence)
60
- SequenceContainerAudioClip.js plays children in order
61
- RandomContainerAudioClip.js plays one child at weighted random (avoid-repeat)
62
- SwitchContainerAudioClip.js plays one child chosen by a parameter (discrete)
63
- BlendContainerAudioClip.js plays several children, gains driven by a parameter (continuous)
64
- effect/ bus insert effects
65
- AbstractAudioEffect.js base; build(ctx) → { input, output }
66
- EqEffect.js BiquadFilter
67
- CompressorEffect.js DynamicsCompressor
68
- ReverbEffect.js Convolver (procedural impulse response)
69
- runtime/ transient playback state
70
- EventInstance.js one triggered playing of an event (cursor-based scheduler)
71
- Voice.js / VoicePool.js one pooled AudioBufferSourceNode + its gain/detune
72
- BusGraph.js instantiates BusDefinitions into chained WebAudio nodes
73
- VoiceManager.js per-event instance limits + voice stealing
74
- ParameterStore.js live RTPC values + change bindings
75
- SopraPlaybackContext.js per-source seeded RNG + random-container history (determinism)
76
- asset/ how the engine gets decoded buffers
77
- BufferProvider.js the interface (tryGet sync / get async)
78
- AssetManagerBufferProvider.js production: pulls from the meep AssetManager
79
- StubBufferProvider.js in-memory, for tests / offline
80
- serialization/ sopraJSON (type-tag JSON dispatch) + the binary registry + test harness
81
- util/ buildAttenuationCurve, fadeOutAndStop, MockAudioContext
82
- legacy/ frozen SoundEmitter/SoundController readers + the converter that
83
- turns saved ones into AudioEmitter / AudioEventTrigger
84
- ```
85
-
86
- The ECS binding lives **outside** this folder, under `sound/ecs/audio/`: `AudioEmitter.js` (component),
87
- `AudioEmitterSystem.js` (system), and the spatial-scaling layer `SpatialAudioIndex.js` (BVH cull) +
88
- `LiveEmitterSet.js` (live/dormant budget) that lets it carry ~100k 3D emitters (see ECS integration).
89
-
90
- ---
91
-
92
- ## Quick start (standalone)
93
-
94
- The core needs three things: an audio context, a destination node, and a `BufferProvider`.
95
-
96
- ```js
97
- import { SopraEngine } from "./SopraEngine.js";
98
- import { BufferProvider } from "./asset/BufferProvider.js";
99
- import { EventDescription } from "./definition/EventDescription.js";
100
- import { SampleAudioClip } from "./definition/clip/SampleAudioClip.js";
101
-
102
- // 1. Tell Sopra how to obtain decoded AudioBuffers. Here, a trivial in-memory provider.
103
- class MapBufferProvider extends BufferProvider {
104
- constructor(map) { super(); this.map = map; } // Map<url, AudioBuffer>
105
- tryGet(url, usingAlias) { return this.map.get(url) ?? null; } // sync fast path (already decoded)
106
- get(url, usingAlias) { // async path (rejects if unavailable)
107
- const b = this.map.get(url);
108
- return b ? Promise.resolve(b) : Promise.reject(new Error(`missing ${url}`));
109
- }
110
- }
111
-
112
- const ctx = new AudioContext();
113
- const buffers = new Map([["click.ogg", await ctx.decodeAudioData(bytes)]]);
114
- const engine = new SopraEngine(ctx, ctx.destination, new MapBufferProvider(buffers));
115
-
116
- // 2. Describe an event (a definition). Reuse it as many times as you like.
117
- const click = EventDescription.from("ui.click", SampleAudioClip.from("click.ogg"), { busId: "effects" });
118
-
119
- // 3. Play it. playOneShot auto-releases when the sound finishes.
120
- engine.playOneShot(click);
121
-
122
- // 4. Drive the engine once per frame — this is mandatory (see "The update loop").
123
- function frame() { engine.update(); requestAnimationFrame(frame); }
124
- frame();
125
- ```
126
-
127
- `SopraEngine` ships with a default bus tree — `master` → (`effects`, `music`, `ambient`) — at the
128
- legacy mix, so `busId: "effects"` works out of the box.
129
-
130
- ---
131
-
132
- ## The clip graph
133
-
134
- `EventDescription.rootClip` is a tree of `AbstractAudioClip` nodes. Each node implements
135
- `planTimeline()`, which the instance calls **once** at trigger time to flatten the tree into a list of
136
- timed leaf plays. Gain (dB) and pitch (cents) accumulate additively down the tree and are resolved
137
- there — the runtime never sees an "inherit" sentinel.
138
-
139
- Only `SampleAudioClip` references a buffer; everything else arranges, selects, or layers.
140
-
141
- ### SampleAudioClip — a single asset (leaf)
142
-
143
- ```js
144
- SampleAudioClip.from("explosion.ogg", {
145
- gain: -3, // dB, added to the event/parent gain
146
- pitch: 0, // cents
147
- loop: false,
148
- loopStart: 0, loopEnd: 0,
149
- pitchRandom: 50, // ± cents, randomised per trigger (deterministic, see below)
150
- gainRandom: 2, // ± dB, randomised per trigger
151
- usingAlias: false // if true, `url` is an AssetManager alias, not a path
152
- });
153
- ```
154
-
155
- A `loop: true` sample plays forever — it makes its event a **persistent source** (see one-shot
156
- vs. persistent below).
157
-
158
- ### SilenceAudioClip — a gap
159
-
160
- ```js
161
- SilenceAudioClip.from(0.5); // 0.5s of dead air, e.g. between sequence steps
162
- ```
163
-
164
- ### SequenceContainerAudioClip — children in order
165
-
166
- ```js
167
- SequenceContainerAudioClip.from([
168
- SampleAudioClip.from("intro.ogg"),
169
- SilenceAudioClip.from(0.25),
170
- SampleAudioClip.from("loop.ogg", { loop: true }) // a looping child is terminal: the sequence
171
- ]); // cannot advance past it
172
- ```
173
-
174
- ### RandomContainerAudioClip — one child at weighted random
175
-
176
- Picks one child per trigger, avoiding the last N picks. Weights are **per-child, one entry per child**
177
- (default `1` = uniform); a weight of `0` means "never picked".
178
-
179
- ```js
180
- RandomContainerAudioClip.from(
181
- [SampleAudioClip.from("step1.ogg"), SampleAudioClip.from("step2.ogg"), SampleAudioClip.from("step3.ogg")],
182
- { avoidRepeatingLast: 1, weights: [3, 1, 1] } // step1 ~3× as likely; never the same twice in a row
183
- );
184
- ```
185
-
186
- Selection is **deterministic** given a seed (see Determinism) — critical for networked play.
187
-
188
- ### SwitchContainerAudioClip — one child by a parameter (discrete)
189
-
190
- Reads a parameter once at trigger, rounds + clamps it to a child index. Classic surface-switch:
191
-
192
- ```js
193
- SwitchContainerAudioClip.from(
194
- [grassClip, stoneClip, woodClip],
195
- { parameter: "surface", defaultValue: 0 } // surface 0→grass, 1→stone, 2→wood
196
- );
197
- ```
198
-
199
- ### BlendContainerAudioClip — several children, gains by a parameter (continuous)
200
-
201
- Reads a parameter once at trigger and plays every child whose blend gain is > 0, each scaled by its
202
- per-child `AnimationCurve` (parameter value → linear gain). A child with no curve is always full.
203
-
204
- ```js
205
- import { AnimationCurve } from "../../animation/curve/AnimationCurve.js";
206
-
207
- BlendContainerAudioClip.from(
208
- [calmLayer, stormLayer],
209
- {
210
- parameter: "weather",
211
- blends: [
212
- AnimationCurve.linear(0, 1, 1, 0), // calm: full at 0, silent at 1
213
- AnimationCurve.linear(0, 0, 1, 1) // storm: silent at 0, full at 1
214
- ]
215
- }
216
- );
217
- ```
218
-
219
- > v1 Blend is a **trigger-time snapshot**: the mix is fixed when the event starts; it does not
220
- > re-blend live as the parameter sweeps afterwards. Live per-voice re-blend is a planned follow-up.
221
-
222
- ---
223
-
224
- ## Events
225
-
226
- `EventDescription` is the triggerable unit. It wraps a root clip with routing, 3D, and voice config.
227
-
228
- ```js
229
- import { SopraPanningModel } from "./definition/SopraPanningModel.js";
230
- import { VoiceStealMode } from "./definition/VoiceStealMode.js";
231
-
232
- EventDescription.from("monster.roar", rootClip, {
233
- busId: "effects", // which mixer bus this routes to
234
- gainDb: 0, // event master gain
235
- is3D: true, // enable spatialization
236
- panningModel: SopraPanningModel.HRTF,
237
- distanceMin: 2, // panner reference distance
238
- distanceMax: 60, // beyond this the source is culled (virtualized)
239
- attenuation: AnimationCurve.linear(0, 1, 60, 0), // distance → linear gain multiplier
240
- maxInstances: 8, // concurrency cap for this event
241
- priority: 0,
242
- stealMode: VoiceStealMode.Oldest,
243
- virtualThresholdDb: -60 // below this post-attenuation gain a voice goes virtual
244
- });
245
- ```
246
-
247
- Non-3D events ignore the 3D fields; `attenuation` defaults to a flat-1 curve (no attenuation).
248
-
249
- An `EventDescription` is a plain object — hold it wherever you like (a module constant, a component
250
- field, a map of your own) and pass it straight to the playback methods:
251
-
252
- ```js
253
- engine.playOneShot(roar, { position: monsterPos });
254
- ```
255
-
256
- ---
257
-
258
- ## Playback and instance control
259
-
260
- `playEvent` / `playOneShot` / `createInstance` / `crossfade` all take an `EventDescription` directly —
261
- there is no id registry. Keep a reference to the description and pass it in.
262
-
263
- ```js
264
- // fire-and-forget; auto-releases when the content finishes
265
- engine.playOneShot(click);
266
-
267
- // keep the handle to control it; persistent until you stop it
268
- const inst = engine.playEvent(music, { busId: "music" });
269
-
270
- // build without starting (e.g. to configure first), then start manually
271
- const i = engine.createInstance(music);
272
- i.start(engine.currentTime);
273
- ```
274
-
275
- `playEvent` returns the `EventInstance`, or **`null`** if the play was denied by the voice limit
276
- (steal mode `None`). Always handle the null.
277
-
278
- ### One-shot vs. persistent
279
-
280
- - `oneShot: true` (what `playOneShot` sets) — the instance **self-releases** when its timeline
281
- finishes, and is killed by a `maxLifetime` backstop (default 60s) as a safety net.
282
- - `oneShot: false` (default for `playEvent`) — the instance **persists** until you stop it. Use this
283
- for looping music/ambience (whose root clip loops). A *non-looping*, non-one-shot instance plays
284
- once then sits idle (silent) until stopped — usually not what you want, so loop the clip or use
285
- `playOneShot`.
286
-
287
- The ECS `AudioEmitterSystem` derives `oneShot` automatically from `event.rootClip.loops()`.
288
-
289
- ### EventInstance API
290
-
291
- ```js
292
- inst.setGainDb(-6); // immediate volume (dB), composes with 3D attenuation
293
- inst.fadeToGainDb(0, 2); // click-safe ramp to 0 dB over 2s (does not stop)
294
- inst.fadeOutAndStop(1.5); // fade to silence over 1.5s, then stop (audio-clock deadline)
295
- inst.seek(10); // jump the playhead to 10s (keeps the resolved timeline + RNG)
296
- inst.restart(); // seek(0)
297
- inst.stop(); // stop now, tear down nodes, fire onEnded
298
-
299
- inst.playhead; // current position in seconds
300
- inst.gainDb; // current instance gain (dB)
301
- inst.state; // EventInstanceState: Initial | Playing | Stopped
302
- inst.currentGain; // current post-attenuation linear gain
303
- inst.onEnded.add(i => { ... }); // fires once when the instance fully stops
304
- ```
305
-
306
- ### Crossfade
307
-
308
- ```js
309
- // fade `current` out and a fresh play of `calmTrack` in, over 3s
310
- const next = engine.crossfade(current, calmTrack, { busId: "music", duration: 3 });
311
- ```
312
-
313
- ---
314
-
315
- ## Buses, effects, and sends
316
-
317
- The mixer is a tree of `BusDefinition`s. Each bus is `input → effect[0] → … → effect[n] → gain`, and
318
- its output routes into its parent's input (or the engine destination for a root bus, `parentId: ""`).
319
-
320
- `setBuses` **replaces** the whole tree (and rebuilds the WebAudio graph). Effects and sends are wired
321
- at build time; there is no live effect insertion.
322
-
323
- ```js
324
- import { BusDefinition } from "./definition/BusDefinition.js";
325
- import { EqEffect, EqFilterType } from "./definition/effect/EqEffect.js";
326
- import { CompressorEffect } from "./definition/effect/CompressorEffect.js";
327
- import { ReverbEffect } from "./definition/effect/ReverbEffect.js";
328
-
329
- engine.setBuses([
330
- BusDefinition.from("master", { gainDb: 0 }), // root (parentId "")
331
- BusDefinition.from("music", { parentId: "master", gainDb: -6,
332
- effects: [CompressorEffect.from({ threshold: -18, ratio: 4 })] }),
333
- BusDefinition.from("sfx", { parentId: "master",
334
- effects: [EqEffect.from({ filterType: EqFilterType.Highshelf, frequency: 6000, gainDb: -3 })],
335
- sends: [{ targetBusId: "reverb", levelDb: -9 }] }), // post-fader aux send
336
- BusDefinition.from("reverb", { parentId: "master", // a "reverb bus": others send to it
337
- effects: [ReverbEffect.from({ decaySeconds: 2.0, decayPower: 2 })] })
338
- ]);
339
- ```
340
-
341
- A **send** routes a post-fader copy of a bus into another bus at a given level — the standard way to
342
- share one reverb across many sources. `ReverbEffect` generates its impulse response procedurally
343
- (decaying noise), so there is no IR asset to load and `build` stays synchronous.
344
-
345
- Live bus volume (linear gain):
346
-
347
- ```js
348
- engine.getBusVolume("music"); // → linear gain
349
- engine.setBusVolume("music", 0.5);
350
- ```
351
-
352
- ---
353
-
354
- ## Parameters (RTPC)
355
-
356
- Named float values that drive selection (`Switch`/`Blend`) and automation. They live in the
357
- `ParameterStore`; `Switch`/`Blend` read them **once at trigger time**.
358
-
359
- ```js
360
- engine.defineParameter("surface", 0); // declare with a default (no-op if already defined)
361
- engine.setParameter("surface", 1); // 1 → the next footstep Switch picks child 1 (stone)
362
- engine.getParameter("surface"); // → 1
363
-
364
- // Drive a bus volume from a parameter through a curve — updates live as the parameter changes.
365
- engine.bindParameterToBusVolume("tension", "music", AnimationCurve.linear(0, 0.2, 1, 1));
366
- engine.setParameter("tension", 0.8); // music bus volume follows the curve immediately
367
-
368
- // Or bind an arbitrary callback (runs on change, and once now if already set).
369
- engine.bindParameter("tension", value => console.log("tension is", value));
370
- ```
371
-
372
- Note the timing difference: bus-volume bindings update **live**; clip selection (`Switch`/`Blend`) is
373
- sampled **when the event is triggered**, so changing a parameter affects the *next* play, not voices
374
- already sounding.
375
-
376
- ---
377
-
378
- ## 3D / spatialization
379
-
380
- Set the listener position, give 3D events a position, and call `update()` each frame to re-attenuate.
381
-
382
- ```js
383
- engine.setListener(camera.position); // a shared Vector3
384
-
385
- const inst = engine.playOneShot(roar, { position: monster.position });
386
- // `position` is held by reference — moving the Vector3 moves the source; update() re-evaluates.
387
- ```
388
-
389
- Per instance, distance attenuation is a custom gain node driven by `attenuation.evaluate(distance)`;
390
- the `PannerNode` does direction only (rolloff disabled). Beyond `distanceMax`, or below
391
- `virtualThresholdDb`, the instance goes **virtual**: its voices stop while the playhead keeps
392
- advancing, and revive at the correct child + buffer offset when it becomes audible again.
393
-
394
- Virtualization bounds the *voice* (source-node) count among the **live** instances. The bigger lever
395
- for huge worlds is one tier up, in the ECS layer: the `LiveEmitterSet` keeps all-but-a-budget of the
396
- registered 3D emitters **dormant** (not even an `EventInstance`), so a scene can register ~100k
397
- emitters and still tick only ~budget of them. See **ECS integration**.
398
-
399
- ---
400
-
401
- ## Voice limits and stealing
402
-
403
- `VoiceManager` caps concurrent instances **per event** at `EventDescription.maxInstances` and steals
404
- when full, per the event's `stealMode`:
405
-
406
- - `None` — over the limit, the new play is dropped (`playEvent` returns `null`).
407
- - `Oldest` — stop the earliest-started instance.
408
- - `Quietest` — stop the instance with the lowest post-attenuation gain.
409
-
410
- Defaults (`maxInstances: 32`, `stealMode: Oldest`) effectively never engage until content opts into a
411
- low limit — e.g. a machine-gun: `maxInstances: 6`.
412
-
413
- ---
414
-
415
- ## Mixer snapshots and ducking
416
-
417
- ### Snapshots — shift the whole mix between game states
418
-
419
- A `MixerSnapshot` is a named set of per-bus target gains (dB). Apply it instantly or as a click-safe
420
- ramp.
421
-
422
- ```js
423
- import { MixerSnapshot } from "./definition/MixerSnapshot.js";
424
-
425
- const combat = MixerSnapshot.from("combat", [
426
- { busId: "music", gainDb: 0 },
427
- { busId: "ambient", gainDb: -12 }
428
- ]);
429
-
430
- engine.applySnapshot(combat, { duration: 1.5 }); // ramp into the combat mix over 1.5s
431
- const saved = engine.captureSnapshot("pre-combat", ["music", "ambient"]); // grab the current mix to restore later
432
- ```
433
-
434
- ### Ducking — emulated sidechain
435
-
436
- WebAudio has no native sidechain, so this is **play-state** ducking: while any instance is live on the
437
- trigger bus, the target bus is attenuated by `duckDb` (smooth `setTargetAtTime` attack/release),
438
- restored to its captured nominal when the trigger goes quiet.
439
-
440
- ```js
441
- import { DuckingRule } from "./definition/DuckingRule.js";
442
-
443
- // duck music by 8 dB whenever anything plays on the effects bus
444
- engine.addDucker(DuckingRule.from({
445
- triggerBusId: "effects", targetBusId: "music",
446
- duckDb: -8, attack: 0.1, release: 0.4
447
- }));
448
-
449
- engine.clearDuckers(); // remove all rules (restoring any currently-ducked target)
450
- ```
451
-
452
- The trigger matches by `instance.busId`. Duckers are evaluated every `update()`.
453
-
454
- ---
455
-
456
- ## Determinism
457
-
458
- Random selection (`RandomContainer`, per-trigger pitch/gain randomisation) runs on a **seeded** RNG
459
- held in a `SopraPlaybackContext`. Same seed → identical pick stream → networked clients stay in sync.
460
-
461
- ```js
462
- // seed from a replicated source, e.g. entity id + fixed-step tick
463
- engine.playEvent(footsteps, { seed: entityId * 31 + tick });
464
-
465
- // or supply your own context (its history persists across re-triggers — reuse one per emitter)
466
- import { SopraPlaybackContext } from "./runtime/SopraPlaybackContext.js";
467
- const ctx = new SopraPlaybackContext(seed);
468
- engine.playEvent(footsteps, { playbackContext: ctx });
469
- ```
470
-
471
- If you supply neither, the engine's shared `defaultPlaybackContext` is used.
472
-
473
- ---
474
-
475
- ## The update loop
476
-
477
- `SopraEngine` is driven by a single clock — the injected `AudioContext.currentTime`. **You must call
478
- `engine.update()` once per frame.** It advances every instance (scheduling/virtualization, 3D
479
- attenuation, fade-out deadlines, one-shot release) and evaluates ducking rules.
480
-
481
- ```js
482
- engine.update(); // uses the live clock
483
- engine.update(someTime); // or pass an explicit context time (tests do this)
484
- ```
485
-
486
- Nothing self-schedules with `setTimeout`; fades and stops are resolved against the audio clock inside
487
- `update`, so a late frame never desynchronises audio.
488
-
489
- ---
490
-
491
- ## Serialization
492
-
493
- Definitions are fully serializable, two ways:
494
-
495
- **JSON** — every definition has `toJSON()` / `fromJSON()`. Polymorphic clip/effect trees go through
496
- type-tagged dispatch in `serialization/sopraJSON.js`:
497
-
498
- ```js
499
- const json = description.toJSON();
500
- const restored = new EventDescription();
501
- restored.fromJSON(json);
502
- ```
503
-
504
- **Binary** — each class has a co-located `<Class>SerializationAdapter.js`. Register them all on a
505
- `BinarySerializationRegistry` with `populateSopraSerializationRegistry`:
506
-
507
- ```js
508
- import { BinarySerializationRegistry } from "../../ecs/storage/binary/BinarySerializationRegistry.js";
509
- import { populateSopraSerializationRegistry } from "./serialization/populateSopraSerializationRegistry.js";
510
-
511
- const registry = new BinarySerializationRegistry();
512
- populateSopraSerializationRegistry(registry);
513
- ```
514
-
515
- Numeric fields are stored as float32. `serialization/sopraSerializationHarness.js` provides
516
- `makeSopraObjectAdapter()` + `binaryRoundTrip()` for round-trip tests.
517
-
518
- ---
519
-
520
- ## ECS integration
521
-
522
- In-game you almost never touch `SopraEngine` directly — you add an `AudioEmitter` component.
523
-
524
- ```js
525
- import { AudioEmitter } from "../ecs/audio/AudioEmitter.js";
526
-
527
- const emitter = new AudioEmitter();
528
- emitter.event = EventDescription.from(
529
- "torch.loop", SampleAudioClip.from("torch.ogg", { loop: true }),
530
- { busId: "ambient", is3D: true, distanceMax: 20 }
531
- );
532
- emitter.autoplay = true; // this is a looping 3D event -> spatially managed (sounds when the
533
- // listener is in range + within budget); see the two play paths below
534
- emitter.volume.set(0.8); // live multiplier on top of the event gain (Vector1)
535
-
536
- entity.add(emitter).add(new Transform(...));
537
- ```
538
-
539
- `AudioEmitter` **owns a full `EventDescription`** — the whole clip graph, routing, 3D, voice config —
540
- as first-class ECS data. There is no global "bank"; the `EntityComponentDataset` *is* the data store.
541
-
542
- `AudioEmitterSystem(assetManager, soundEngine, liveEmitterSetOptions?)` drives all emitters. It shares
543
- the one `SopraEngine` (via `SoundEngine.createSopra(bufferProvider)`, idempotent), feeds the
544
- `SoundListener` pose, and ticks the engine each frame. *How* an emitter plays is decided **once at
545
- link**, from `autoplay` + the event:
546
-
547
- - **Spatially managed** — `autoplay && is3D && looping`. Registered with a `LiveEmitterSet` (a BVH
548
- broadphase, `SpatialAudioIndex`, + a live/dormant lifecycle) and left **dormant**: no instance, no
549
- nodes, no per-frame work — just one BVH leaf. Each frame the system culls by the listener position and
550
- promotes the nearest in-range emitters up to a global voice **budget** (default 64), demoting the rest
551
- (a hard cut when culled out of range, a click-safe fade on contention). This is what lets the engine
552
- carry **~100,000 registered emitters** while only the audible ~budget ever hold WebAudio nodes — the
553
- per-frame cost tracks the budget, not the registered count.
554
- - **Direct** — any other `autoplay` event (2D sounds like music / ambience beds, or finite 3D
555
- one-shots): played immediately on link, stopped on unlink. Nothing to cull by distance, and these are
556
- few.
557
- - **Inert** — `autoplay === false`: neither played nor registered.
558
-
559
- The entity `Transform.position` is held by the instance (a 3D source tracks the moving entity);
560
- `AudioEmitter.volume` is a live multiplier carried **through** promote/demote, so a managed emitter
561
- keeps its volume across a cull. `system.instanceFor(entity)` returns the active instance (or `null`
562
- while dormant).
563
-
564
- > **Content rule for managed crowds:** the live `budget` and an event's `maxInstances` are independent
565
- > caps. If many emitters share one *content-equal* `EventDescription` (e.g. 100k identical birds), give
566
- > that event `maxInstances ≥ budget` — content-equal events share one polyphony bucket, so a lower cap
567
- > would gate the budget and fewer than `budget` would actually sound.
568
-
569
- In production the provider is an `AssetManagerBufferProvider` over the meep `AssetManager` (shared
570
- decode cache + the alias system).
571
-
572
- ---
573
-
574
- ## Testing
575
-
576
- The entire core is unit-testable without real audio: a `MockAudioContext` records the constructed
577
- graph and scheduled events, and a `StubBufferProvider` hands over fake buffers. Tests assert the graph
578
- shape, timeline math, voice lifecycle, deterministic selection, and serialization — never sound.
579
-
580
- ```js
581
- import { SopraEngine } from "./SopraEngine.js";
582
- import { MockAudioContext } from "./util/MockAudioContext.js";
583
- import { StubBufferProvider } from "./asset/StubBufferProvider.js";
584
-
585
- const ctx = new MockAudioContext();
586
- const provider = new StubBufferProvider();
587
- provider.set("x.ogg", { duration: 2 }); // a fake buffer is anything with a `duration`
588
- // provider.failOn("y.ogg"); // simulate a failed/missing asset
589
-
590
- const engine = new SopraEngine(ctx, ctx.destination, provider);
591
- const inst = engine.playOneShot(EventDescription.from("e", SampleAudioClip.from("x.ogg")));
592
-
593
- ctx.currentTime = 2;
594
- engine.update(2);
595
- // inst.state === EventInstanceState.Stopped (the one-shot self-released at the end)
596
- ```
597
-
598
- Run the suite from the repo root:
599
-
600
- ```
601
- npx jest --config jest.conf.json engine/sound
602
- ```
603
-
604
- ---
605
-
606
- ## Design rules (don't break these)
607
-
608
- - **Definitions are immutable, shared, and serializable.** No WebAudio nodes, no playback state, no
609
- per-instance mutation on a definition. Runtime state lives on `EventInstance` / the
610
- `SopraPlaybackContext`.
611
- - **Gain is composed exactly once.** Authored gains are dB and accumulate down the clip tree at plan
612
- time; bus/instance gains are linear on their own nodes. Don't double-apply.
613
- - **One clock.** Everything reads `AudioContext.currentTime`; no `setTimeout`-driven audio.
614
- - **The core never creates an `AudioContext`** and never imports ECS. It takes a context, a
615
- destination, and a `BufferProvider`.
616
- - **Errors surface.** Expected failures (missing asset, unresolved alias, limit reached) return a
617
- sentinel (`null`) or reject; unexpected errors throw. Nothing is silently swallowed.
618
- - **Determinism is sacred.** Random selection is seeded from a replicated source; never reach for
619
- `Math.random()` in the playback path.
620
-
621
- ---
622
-
623
- ## Glossary
624
-
625
- Acronyms and shorthand used in this document (and the code).
626
-
627
- | Term | Expansion | Meaning in Sopra |
628
- |---|---|---|
629
- | **2D / 3D** | two- / three-dimensional | A 2D event is non-positional (plays straight into its bus); a 3D event (`is3D: true`) is spatialised — panned and distance-attenuated relative to the listener. |
630
- | **API** | Application Programming Interface | The public methods/classes you call. |
631
- | **aux send** | auxiliary send | A post-fader copy of a bus routed into another bus at a level (`BusDefinition.sends`), e.g. feeding a shared reverb bus. |
632
- | **cents** | — | Pitch unit; 100 cents = 1 semitone, 1200 = 1 octave. All pitch offsets/randomisation are in cents. |
633
- | **dB** | decibel | Logarithmic gain unit; 0 dB = unity, −6 dB ≈ half power. **All authored gains are in dB**; node gains on the WebAudio graph are linear (the two are converted by `dB2Volume` / `volume2dB`). |
634
- | **ECS** | Entity Component System | meep's architecture: entities are ids, data lives in components, behaviour in systems. Sopra's *core* is ECS-agnostic; the ECS binding is `AudioEmitter` + `AudioEmitterSystem`. |
635
- | **EQ** | equaliser | A filter insert (`EqEffect`), backed by a WebAudio `BiquadFilterNode`. |
636
- | **FMOD / Wwise** | (product names) | Industry audio middleware. Sopra borrows their concepts — events, a bus tree, RTPC, voice stealing, snapshots, ducking — but is built on meep's data model, not a bank file. |
637
- | **HRTF** | Head-Related Transfer Function | The high-quality (per-source convolution) 3D panning model; one of the `SopraPanningModel` options (the other, `EqualPower`, is cheaper). |
638
- | **Hz** | hertz | Frequency unit (cycles/second), e.g. an EQ cutoff/centre frequency. |
639
- | **IR** | Impulse Response | The convolution kernel that defines a reverb's space. `ReverbEffect` **generates one procedurally** (decaying noise), so there is no IR asset to load. |
640
- | **JSON** | JavaScript Object Notation | One of the two serialization formats (human-readable; via `toJSON`/`fromJSON` + the type-tag dispatch in `sopraJSON.js`). The other is binary. |
641
- | **Q** | quality factor | A filter's bandwidth/resonance control (`EqEffect.Q`). |
642
- | **RNG** | Random Number Generator | Sopra's RNG is **seeded** (per `SopraPlaybackContext`) so random selection is reproducible across networked clients. |
643
- | **RTPC** | Real-Time Parameter Control | A named float "parameter" (FMOD's term). Drives `Switch`/`Blend` selection and bus-volume automation. Stored in the `ParameterStore`; see `defineParameter` / `setParameter`. |
644
- | **WebAudio** | Web Audio API | The browser audio API Sopra renders through (`AudioContext`, `GainNode`, `PannerNode`, `BiquadFilterNode`, `ConvolverNode`, …). Sopra never creates the `AudioContext` — meep's `SoundEngine` does. |
1
+ # Sopra
2
+
3
+ An object-oriented, data-driven sound engine for the meep ECS, loosely modelled on FMOD / Wwise
4
+ concepts (events, a mixer bus tree, RTPC parameters, voice stealing, snapshots, ducking) but built
5
+ around meep's own data model instead of an opaque middleware bank.
6
+
7
+ Sopra is **ECS-agnostic at its core**. The engine (`SopraEngine` + its services) knows nothing about
8
+ entities; it is a self-contained audio renderer driven by a single clock. A thin ECS layer
9
+ (`AudioEmitter` + `AudioEmitterSystem`, outside this folder under `sound/ecs/audio/`) binds it to the
10
+ game. You can use the core standalone (tools, previews, tests) with nothing but a `BaseAudioContext`
11
+ and a `BufferProvider`.
12
+
13
+ > The name "sopra" is internal. The player-facing / integration layer uses familiar terms
14
+ > (`AudioEmitter`, `AudioEventTrigger`); "sopra" never appears in user-facing API.
15
+
16
+ ---
17
+
18
+ ## The one idea you must understand: definition vs. runtime
19
+
20
+ Everything in Sopra is split into two halves. Get this and the rest follows.
21
+
22
+ | | **Definition** (authored data) | **Runtime** (transient playback) |
23
+ |---|---|---|
24
+ | What | `EventDescription`, the `*AudioClip` graph, `BusDefinition`, `*AudioEffect`, `ParameterDefinition`, `MixerSnapshot`, `DuckingRule` | `EventInstance`, `Voice`, the live WebAudio node graph |
25
+ | Lifetime | Immutable, shared, **serializable** (save/load, network) | Created on trigger, pooled, **never serialized** |
26
+ | Holds | Spec only — urls, gains in dB, curves, child clips, routing | WebAudio nodes, the resolved timeline, the playhead, RNG state |
27
+ | Lives in | `definition/` | `runtime/` |
28
+
29
+ A definition is a recipe. Playing it spawns a transient `EventInstance` that reads the recipe once,
30
+ resolves a concrete timeline of leaf plays, and drives WebAudio nodes. The same definition can back
31
+ many simultaneous instances. **Definitions never hold playback state** — that is the rule the whole
32
+ architecture defends.
33
+
34
+ ```
35
+ EventDescription (definition)
36
+ │ engine.playEvent(...)
37
+ ▼
38
+ EventInstance (runtime) ──spawns──► Voice ──► bus chain ──► destination
39
+ ```
40
+
41
+ ---
42
+
43
+ ## Directory map
44
+
45
+ ```
46
+ sopra/
47
+ SopraEngine.js the facade: owns the bus graph, parameters, active instances, the clock
48
+ definition/ immutable, serializable authored data (+ co-located *SerializationAdapter.js)
49
+ EventDescription.js the triggerable unit (root clip + routing + 3D + voice config)
50
+ BusDefinition.js one node in the mixer tree (gain, effect chain, aux sends)
51
+ ParameterDefinition.js a declared RTPC parameter
52
+ MixerSnapshot.js a named set of per-bus target gains
53
+ DuckingRule.js an emulated sidechain duck (trigger bus → target bus)
54
+ SopraPanningModel.js HRTF | EqualPower
55
+ VoiceStealMode.js None | Oldest | Quietest
56
+ clip/ the clip graph — what actually makes sound
57
+ AbstractAudioClip.js base; planTimeline() + loops() + collectSampleClips()
58
+ SampleAudioClip.js a single audio asset (the only buffer-referencing leaf)
59
+ SilenceAudioClip.js dead air (a gap in a sequence)
60
+ SequenceContainerAudioClip.js plays children in order
61
+ RandomContainerAudioClip.js plays one child at weighted random (avoid-repeat)
62
+ SwitchContainerAudioClip.js plays one child chosen by a parameter (discrete)
63
+ BlendContainerAudioClip.js plays several children, gains driven by a parameter (continuous)
64
+ effect/ insert effects, on a bus or on an event
65
+ AbstractAudioEffect.js base; static parameters; build(ctx) → { input, output, params? }
66
+ EqEffect.js BiquadFilter
67
+ CompressorEffect.js DynamicsCompressor
68
+ ReverbEffect.js Convolver (procedural impulse response)
69
+ runtime/ transient playback state
70
+ EventInstance.js one triggered playing of an event (cursor-based scheduler)
71
+ Voice.js / VoicePool.js one pooled AudioBufferSourceNode + its gain/detune
72
+ BusGraph.js instantiates BusDefinitions into chained WebAudio nodes
73
+ VoiceManager.js per-event instance limits + voice stealing
74
+ ParameterStore.js live RTPC values + change bindings
75
+ EffectParamOverrides.js an owner's effect parameters, put onto each instance it gets
76
+ SopraPlaybackContext.js per-source seeded RNG + random-container history (determinism)
77
+ asset/ how the engine gets decoded buffers
78
+ BufferProvider.js the interface (tryGet sync / get async)
79
+ AssetManagerBufferProvider.js production: pulls from the meep AssetManager
80
+ StubBufferProvider.js in-memory, for tests / offline
81
+ serialization/ sopraJSON (type-tag JSON dispatch) + the binary registry + test harness
82
+ util/ buildAttenuationCurve, fadeOutAndStop, MockAudioContext
83
+ legacy/ frozen SoundEmitter/SoundController readers + the converter that
84
+ turns saved ones into AudioEmitter / AudioEventTrigger
85
+ ```
86
+
87
+ The ECS binding lives **outside** this folder, under `sound/ecs/audio/`: `AudioEmitter.js` (component),
88
+ `AudioEmitterSystem.js` (system), and the spatial-scaling layer `SpatialAudioIndex.js` (BVH cull) +
89
+ `LiveEmitterSet.js` (live/dormant budget) that lets it carry ~100k 3D emitters (see ECS integration).
90
+
91
+ ---
92
+
93
+ ## Quick start (standalone)
94
+
95
+ The core needs three things: an audio context, a destination node, and a `BufferProvider`.
96
+
97
+ ```js
98
+ import { SopraEngine } from "./SopraEngine.js";
99
+ import { BufferProvider } from "./asset/BufferProvider.js";
100
+ import { EventDescription } from "./definition/EventDescription.js";
101
+ import { SampleAudioClip } from "./definition/clip/SampleAudioClip.js";
102
+
103
+ // 1. Tell Sopra how to obtain decoded AudioBuffers. Here, a trivial in-memory provider.
104
+ class MapBufferProvider extends BufferProvider {
105
+ constructor(map) { super(); this.map = map; } // Map<url, AudioBuffer>
106
+ tryGet(url, usingAlias) { return this.map.get(url) ?? null; } // sync fast path (already decoded)
107
+ get(url, usingAlias) { // async path (rejects if unavailable)
108
+ const b = this.map.get(url);
109
+ return b ? Promise.resolve(b) : Promise.reject(new Error(`missing ${url}`));
110
+ }
111
+ }
112
+
113
+ const ctx = new AudioContext();
114
+ const buffers = new Map([["click.ogg", await ctx.decodeAudioData(bytes)]]);
115
+ const engine = new SopraEngine(ctx, ctx.destination, new MapBufferProvider(buffers));
116
+
117
+ // 2. Describe an event (a definition). Reuse it as many times as you like.
118
+ const click = EventDescription.from("ui.click", SampleAudioClip.from("click.ogg"), { busId: "effects" });
119
+
120
+ // 3. Play it. playOneShot auto-releases when the sound finishes.
121
+ engine.playOneShot(click);
122
+
123
+ // 4. Drive the engine once per frame — this is mandatory (see "The update loop").
124
+ function frame() { engine.update(); requestAnimationFrame(frame); }
125
+ frame();
126
+ ```
127
+
128
+ `SopraEngine` ships with a default bus tree — `master` → (`effects`, `music`, `ambient`) — at the
129
+ legacy mix, so `busId: "effects"` works out of the box.
130
+
131
+ ---
132
+
133
+ ## The clip graph
134
+
135
+ `EventDescription.rootClip` is a tree of `AbstractAudioClip` nodes. Each node implements
136
+ `planTimeline()`, which the instance calls **once** at trigger time to flatten the tree into a list of
137
+ timed leaf plays. Gain (dB) and pitch (cents) accumulate additively down the tree and are resolved
138
+ there — the runtime never sees an "inherit" sentinel.
139
+
140
+ Only `SampleAudioClip` references a buffer; everything else arranges, selects, or layers.
141
+
142
+ ### SampleAudioClip — a single asset (leaf)
143
+
144
+ ```js
145
+ SampleAudioClip.from("explosion.ogg", {
146
+ gain: -3, // dB, added to the event/parent gain
147
+ pitch: 0, // cents
148
+ loop: false,
149
+ loopStart: 0, loopEnd: 0,
150
+ pitchRandom: 50, // ± cents, randomised per trigger (deterministic, see below)
151
+ gainRandom: 2, // ± dB, randomised per trigger
152
+ usingAlias: false // if true, `url` is an AssetManager alias, not a path
153
+ });
154
+ ```
155
+
156
+ A `loop: true` sample plays forever — it makes its event a **persistent source** (see one-shot
157
+ vs. persistent below).
158
+
159
+ ### SilenceAudioClip — a gap
160
+
161
+ ```js
162
+ SilenceAudioClip.from(0.5); // 0.5s of dead air, e.g. between sequence steps
163
+ ```
164
+
165
+ ### SequenceContainerAudioClip — children in order
166
+
167
+ ```js
168
+ SequenceContainerAudioClip.from([
169
+ SampleAudioClip.from("intro.ogg"),
170
+ SilenceAudioClip.from(0.25),
171
+ SampleAudioClip.from("loop.ogg", { loop: true }) // a looping child is terminal: the sequence
172
+ ]); // cannot advance past it
173
+ ```
174
+
175
+ ### RandomContainerAudioClip — one child at weighted random
176
+
177
+ Picks one child per trigger, avoiding the last N picks. Weights are **per-child, one entry per child**
178
+ (default `1` = uniform); a weight of `0` means "never picked".
179
+
180
+ ```js
181
+ RandomContainerAudioClip.from(
182
+ [SampleAudioClip.from("step1.ogg"), SampleAudioClip.from("step2.ogg"), SampleAudioClip.from("step3.ogg")],
183
+ { avoidRepeatingLast: 1, weights: [3, 1, 1] } // step1 ~3× as likely; never the same twice in a row
184
+ );
185
+ ```
186
+
187
+ Selection is **deterministic** given a seed (see Determinism) — critical for networked play.
188
+
189
+ ### SwitchContainerAudioClip — one child by a parameter (discrete)
190
+
191
+ Reads a parameter once at trigger, rounds + clamps it to a child index. Classic surface-switch:
192
+
193
+ ```js
194
+ SwitchContainerAudioClip.from(
195
+ [grassClip, stoneClip, woodClip],
196
+ { parameter: "surface", defaultValue: 0 } // surface 0→grass, 1→stone, 2→wood
197
+ );
198
+ ```
199
+
200
+ ### BlendContainerAudioClip — several children, gains by a parameter (continuous)
201
+
202
+ Reads a parameter once at trigger and plays every child whose blend gain is > 0, each scaled by its
203
+ per-child `AnimationCurve` (parameter value → linear gain). A child with no curve is always full.
204
+
205
+ ```js
206
+ import { AnimationCurve } from "../../animation/curve/AnimationCurve.js";
207
+
208
+ BlendContainerAudioClip.from(
209
+ [calmLayer, stormLayer],
210
+ {
211
+ parameter: "weather",
212
+ blends: [
213
+ AnimationCurve.linear(0, 1, 1, 0), // calm: full at 0, silent at 1
214
+ AnimationCurve.linear(0, 0, 1, 1) // storm: silent at 0, full at 1
215
+ ]
216
+ }
217
+ );
218
+ ```
219
+
220
+ > v1 Blend is a **trigger-time snapshot**: the mix is fixed when the event starts; it does not
221
+ > re-blend live as the parameter sweeps afterwards. Live per-voice re-blend is a planned follow-up.
222
+
223
+ ---
224
+
225
+ ## Events
226
+
227
+ `EventDescription` is the triggerable unit. It wraps a root clip with routing, 3D, and voice config.
228
+
229
+ ```js
230
+ import { SopraPanningModel } from "./definition/SopraPanningModel.js";
231
+ import { VoiceStealMode } from "./definition/VoiceStealMode.js";
232
+
233
+ EventDescription.from("monster.roar", rootClip, {
234
+ busId: "effects", // which mixer bus this routes to
235
+ gainDb: 0, // event master gain
236
+ is3D: true, // enable spatialization
237
+ panningModel: SopraPanningModel.HRTF,
238
+ distanceMin: 2, // panner reference distance
239
+ distanceMax: 60, // beyond this the source is culled (virtualized)
240
+ attenuation: AnimationCurve.linear(0, 1, 60, 0), // distance → linear gain multiplier
241
+ maxInstances: 8, // concurrency cap for this event
242
+ priority: 0,
243
+ stealMode: VoiceStealMode.Oldest,
244
+ virtualThresholdDb: -60, // below this post-attenuation gain a voice goes virtual
245
+ effects: [] // the event's own insert effects — see "Event effects"
246
+ });
247
+ ```
248
+
249
+ Non-3D events ignore the 3D fields; `attenuation` defaults to a flat-1 curve (no attenuation).
250
+
251
+ An `EventDescription` is a plain object — hold it wherever you like (a module constant, a component
252
+ field, a map of your own) and pass it straight to the playback methods:
253
+
254
+ ```js
255
+ engine.playOneShot(roar, { position: monsterPos });
256
+ ```
257
+
258
+ ---
259
+
260
+ ## Playback and instance control
261
+
262
+ `playEvent` / `playOneShot` / `createInstance` / `crossfade` all take an `EventDescription` directly —
263
+ there is no id registry. Keep a reference to the description and pass it in.
264
+
265
+ ```js
266
+ // fire-and-forget; auto-releases when the content finishes
267
+ engine.playOneShot(click);
268
+
269
+ // keep the handle to control it; persistent until you stop it
270
+ const inst = engine.playEvent(music, { busId: "music" });
271
+
272
+ // build without starting (e.g. to configure first), then start manually
273
+ const i = engine.createInstance(music);
274
+ i.start(engine.currentTime);
275
+ ```
276
+
277
+ `playEvent` returns the `EventInstance`, or **`null`** if the play was denied by the voice limit
278
+ (steal mode `None`). Always handle the null.
279
+
280
+ ### One-shot vs. persistent
281
+
282
+ - `oneShot: true` (what `playOneShot` sets) — the instance **self-releases** when its timeline
283
+ finishes, and is killed by a `maxLifetime` backstop (default 60s) as a safety net.
284
+ - `oneShot: false` (default for `playEvent`) — the instance **persists** until you stop it. Use this
285
+ for looping music/ambience (whose root clip loops). A *non-looping*, non-one-shot instance plays
286
+ once then sits idle (silent) until stopped — usually not what you want, so loop the clip or use
287
+ `playOneShot`.
288
+
289
+ The ECS `AudioEmitterSystem` derives `oneShot` automatically from `event.rootClip.loops()`.
290
+
291
+ ### EventInstance API
292
+
293
+ ```js
294
+ inst.setGainDb(-6); // immediate volume (dB), composes with 3D attenuation
295
+ inst.fadeToGainDb(0, 2); // click-safe ramp to 0 dB over 2s (does not stop)
296
+ inst.fadeOutAndStop(1.5); // fade to silence over 1.5s, then stop (audio-clock deadline)
297
+ inst.seek(10); // jump the playhead to 10s (keeps the resolved timeline + RNG)
298
+ inst.restart(); // seek(0)
299
+ inst.stop(); // stop now, tear down nodes, fire onEnded
300
+
301
+ inst.playhead; // current position in seconds
302
+ inst.gainDb; // current instance gain (dB)
303
+ inst.state; // EventInstanceState: Initial | Playing | Stopped
304
+ inst.currentGain; // current post-attenuation linear gain
305
+ inst.onEnded.add(i => { ... }); // fires once when the instance fully stops
306
+ ```
307
+
308
+ ### Crossfade
309
+
310
+ ```js
311
+ // fade `current` out and a fresh play of `calmTrack` in, over 3s
312
+ const next = engine.crossfade(current, calmTrack, { busId: "music", duration: 3 });
313
+ ```
314
+
315
+ ---
316
+
317
+ ## Buses, effects, and sends
318
+
319
+ The mixer is a tree of `BusDefinition`s. Each bus is `input → effect[0] → … → effect[n] → gain`, and
320
+ its output routes into its parent's input (or the engine destination for a root bus, `parentId: ""`).
321
+
322
+ `setBuses` **replaces** the whole tree (and rebuilds the WebAudio graph). Effects and sends are wired
323
+ at build time; there is no live effect insertion — but an effect's *parameters* move live (below).
324
+
325
+ Every bus of the new tree starts at its **defined** gain, which resets any fader moved since (a player's
326
+ volume setting, a bound parameter). That is right when the new tree *is* the new mix. To change the
327
+ tree without touching the mix — adding a bus to the one the player has been adjusting — pass
328
+ `keepVolumes`: buses in both trees keep their faders (and any engaged duck), new ones start at their
329
+ definition.
330
+
331
+ ```js
332
+ engine.setBuses([...SopraEngine.defaultBuses(), worldBus], { keepVolumes: true });
333
+ ```
334
+
335
+ ```js
336
+ import { BusDefinition } from "./definition/BusDefinition.js";
337
+ import { EqEffect, EqFilterType } from "./definition/effect/EqEffect.js";
338
+ import { CompressorEffect } from "./definition/effect/CompressorEffect.js";
339
+ import { ReverbEffect } from "./definition/effect/ReverbEffect.js";
340
+
341
+ engine.setBuses([
342
+ BusDefinition.from("master", { gainDb: 0 }), // root (parentId "")
343
+ BusDefinition.from("music", { parentId: "master", gainDb: -6,
344
+ effects: [CompressorEffect.from({ threshold: -18, ratio: 4 })] }),
345
+ BusDefinition.from("sfx", { parentId: "master",
346
+ effects: [EqEffect.from({ filterType: EqFilterType.Highshelf, frequency: 6000, gainDb: -3 })],
347
+ sends: [{ targetBusId: "reverb", levelDb: -9 }] }), // post-fader aux send
348
+ BusDefinition.from("reverb", { parentId: "master", // a "reverb bus": others send to it
349
+ effects: [ReverbEffect.from({ decaySeconds: 2.0, decayPower: 2 })] })
350
+ ]);
351
+ ```
352
+
353
+ A **send** routes a post-fader copy of a bus into another bus at a given level — the standard way to
354
+ share one reverb across many sources. `ReverbEffect` generates its impulse response procedurally
355
+ (decaying noise), so there is no IR asset to load and `build` stays synchronous.
356
+
357
+ Live bus volume (linear gain):
358
+
359
+ ```js
360
+ engine.getBusVolume("music"); // → linear gain
361
+ engine.setBusVolume("music", 0.5);
362
+ ```
363
+
364
+ Live effect parameters. An effect is addressed by its index in the bus's `effects`, a parameter by one
365
+ of its class's `parameters` — the definition field it starts from (`EqEffect.parameters`: `frequency`,
366
+ `Q`, `gainDb`; `CompressorEffect`: `threshold`, `knee`, `ratio`, `attack`, `release`; `ReverbEffect` has
367
+ none). The move lands on the bus's built copy; the definition is never written. The verbs are the
368
+ fader's: `set` moves it now, `approach` glides there exponentially (a time constant, like ducking), and
369
+ whatever was in flight is cancelled and held first, so a value sent every frame retargets smoothly.
370
+ ("Effect param" = a live `AudioParam` of an insert; a sopra *parameter* is a named game value,
371
+ `setParameter`.)
372
+
373
+ ```js
374
+ engine.setBusEffectParam("sfx", 0, "gainDb", -9);
375
+ engine.approachBusEffectParam("sfx", 0, "gainDb", -3, 0.05);
376
+ engine.getBusEffectParam("sfx", 0, "gainDb"); // → -3 (where it was sent, not where the glide has got to)
377
+ ```
378
+
379
+ ### Event effects
380
+
381
+ An `EventDescription` carries insert effects of its own — the same effect types, in `effects`. Every
382
+ instance builds its own copy, **pre-fader**: `voices → effects → instance gain → (3D chain) → bus`. So
383
+ one play of a cue can sound different from the next without touching the bus everything else shares:
384
+
385
+ ```js
386
+ const hurt = EventDescription.from("player.hurt", SampleAudioClip.from("hurt.ogg"), {
387
+ effects: [EqEffect.from({ filterType: EqFilterType.Lowpass, frequency: 20000 })]
388
+ });
389
+
390
+ // a light blow sounds dull, a heavy one bright — this play only
391
+ const instance = engine.playOneShot(hurt, { position });
392
+ instance?.setEffectParam(0, "frequency", lightBlow ? 1200 : 20000);
393
+ ```
394
+
395
+ `setEffectParam` / `approachEffectParam` / `getEffectParam` work from the moment the instance exists, while its buffers are
396
+ still loading included. (`playOneShot` returns `null` when the event's instance limit denies the play.)
397
+
398
+ ---
399
+
400
+ ## Parameters (RTPC)
401
+
402
+ Named float values that drive selection (`Switch`/`Blend`) and automation. They live in the
403
+ `ParameterStore`; `Switch`/`Blend` read them **once at trigger time**.
404
+
405
+ ```js
406
+ engine.defineParameter("surface", 0); // declare with a default (no-op if already defined)
407
+ engine.setParameter("surface", 1); // 1 → the next footstep Switch picks child 1 (stone)
408
+ engine.getParameter("surface"); // → 1
409
+
410
+ // Drive a bus volume from a parameter through a curve — updates live as the parameter changes.
411
+ engine.bindParameterToBusVolume("tension", "music", AnimationCurve.linear(0, 0.2, 1, 1));
412
+ engine.setParameter("tension", 0.8); // music bus volume follows the curve immediately
413
+
414
+ // Or bind an arbitrary callback (runs on change, and once now if already set).
415
+ engine.bindParameter("tension", value => console.log("tension is", value));
416
+ ```
417
+
418
+ Note the timing difference: bus-volume and bus-effect bindings update **live**; clip selection
419
+ (`Switch`/`Blend`) is sampled **when the event is triggered**, so changing a parameter affects the *next*
420
+ play, not voices already sounding.
421
+
422
+ ### Binding a bus effect to a parameter
423
+
424
+ A bus effect parameter binds the way a bus volume does: the game sets one sopra parameter, and how far
425
+ the effect moves for it is authored in an `AnimationCurve` (parameter value → effect value, in the effect
426
+ parameter's own units), not worked out in game code. The address is the one `setBusEffectParam` takes.
427
+ An optional time constant makes each change a glide (`approachBusEffectParam`), so a value set every
428
+ frame is tracked smoothly.
429
+
430
+ ```js
431
+ // the world muffles as health runs out: open at full health, 500 Hz at none
432
+ engine.defineParameter("health", 1);
433
+ engine.bindParameterToBusEffectParam("health", "world", 0, "frequency",
434
+ AnimationCurve.linear(0, 500, 1, 20000), 0.05);
435
+
436
+ engine.setParameter("health", 0.25); // the world's lowpass glides toward 5375 Hz
437
+ ```
438
+
439
+ A binding is a standing rule, not one move:
440
+
441
+ - The address is checked when you bind: a bus, effect or parameter the current tree lacks throws there.
442
+ The parameter's value takes hold at once, without a glide — now if it has one, else when it is first
443
+ defined or set; only later changes glide.
444
+ - Every tree `setBuses` builds that has the target starts **at the bound value**, with `keepVolumes` or
445
+ without — a rebuilt effect does not sit at its definition until the parameter next changes.
446
+ - Over a tree without the target the binding does nothing, and holds again over the next tree that has
447
+ it. Bindings cannot be removed, so they outlive a tree swap; a mistyped address is caught at the bind.
448
+ - One binding per parameter and target: binding the pair again replaces its curve and time constant and
449
+ moves the effect to the new curve as a change would — it adds nothing, so binding again after a
450
+ `setBuses` that keeps the target is harmless (the address is still checked: one the tree lacks throws).
451
+ - The address is positional: reorder a bus's `effects` and the binding follows whatever sits at its index.
452
+ - A direct `setBusEffectParam` on a bound effect holds until the parameter next changes.
453
+
454
+ ---
455
+
456
+ ## 3D / spatialization
457
+
458
+ Set the listener position, give 3D events a position, call `update()` each frame, and say when either
459
+ moves. An instance is placed and attenuated when it starts, and again only after a reported move: a
460
+ crowd standing still costs no Web Audio writes.
461
+
462
+ ```js
463
+ engine.setListener(camera.position); // held by reference, read by index
464
+
465
+ const inst = engine.playOneShot(roar, { position: monster.position });
466
+ // `position` is held by reference too, and read only when told it moved:
467
+ monster.position.set(4, 0, 9);
468
+ inst.moved(); // the next update() re-places and re-attenuates it
469
+
470
+ camera.position.set(0, 2, 5);
471
+ engine.setListener(camera.position); // a listener move: every 3D instance re-attenuates
472
+ ```
473
+
474
+ A write nobody reports is not heard. Give each instance a position of its own: instances sharing one
475
+ array are all wherever it was last written.
476
+
477
+ Per instance, distance attenuation is a custom gain node driven by `attenuation.evaluate(distance)`;
478
+ the `PannerNode` does direction only (rolloff disabled). Beyond `distanceMax`, or below
479
+ `virtualThresholdDb`, the instance goes **virtual**: its voices stop while the playhead keeps
480
+ advancing, and revive at the correct child + buffer offset when it becomes audible again.
481
+
482
+ Virtualization bounds the *voice* (source-node) count among the **live** instances. The bigger lever
483
+ for huge worlds is one tier up, in the ECS layer: the `LiveEmitterSet` keeps all-but-a-budget of the
484
+ registered 3D emitters **dormant** (not even an `EventInstance`), so a scene can register ~100k
485
+ emitters and still tick only ~budget of them. See **ECS integration**.
486
+
487
+ ---
488
+
489
+ ## Voice limits and stealing
490
+
491
+ `VoiceManager` caps concurrent instances **per event** at `EventDescription.maxInstances` and steals
492
+ when full, per the event's `stealMode`:
493
+
494
+ - `None` — over the limit, the new play is dropped (`playEvent` returns `null`).
495
+ - `Oldest` — stop the earliest-started instance.
496
+ - `Quietest` — stop the instance with the lowest post-attenuation gain.
497
+
498
+ Defaults (`maxInstances: 32`, `stealMode: Oldest`) effectively never engage until content opts into a
499
+ low limit — e.g. a machine-gun: `maxInstances: 6`.
500
+
501
+ ---
502
+
503
+ ## Mixer snapshots and ducking
504
+
505
+ ### Snapshots — shift the whole mix between game states
506
+
507
+ A `MixerSnapshot` is a named set of per-bus target gains (dB). Apply it instantly or as a click-safe
508
+ ramp.
509
+
510
+ ```js
511
+ import { MixerSnapshot } from "./definition/MixerSnapshot.js";
512
+
513
+ const combat = MixerSnapshot.from("combat", [
514
+ { busId: "music", gainDb: 0 },
515
+ { busId: "ambient", gainDb: -12 }
516
+ ]);
517
+
518
+ engine.applySnapshot(combat, { duration: 1.5 }); // ramp into the combat mix over 1.5s
519
+ const saved = engine.captureSnapshot("pre-combat", ["music", "ambient"]); // grab the current mix to restore later
520
+ ```
521
+
522
+ ### Ducking — emulated sidechain
523
+
524
+ WebAudio has no native sidechain, so this is **play-state** ducking: while any instance is live on the
525
+ trigger bus, the target bus is attenuated by `duckDb` (smooth `setTargetAtTime` attack/release),
526
+ restored to its captured nominal when the trigger goes quiet.
527
+
528
+ ```js
529
+ import { DuckingRule } from "./definition/DuckingRule.js";
530
+
531
+ // duck music by 8 dB whenever anything plays on the effects bus
532
+ engine.addDucker(DuckingRule.from({
533
+ triggerBusId: "effects", targetBusId: "music",
534
+ duckDb: -8, attack: 0.1, release: 0.4
535
+ }));
536
+
537
+ engine.clearDuckers(); // remove all rules (restoring any currently-ducked target)
538
+ ```
539
+
540
+ The trigger matches by `instance.busId`. Duckers are evaluated every `update()`.
541
+
542
+ ---
543
+
544
+ ## Determinism
545
+
546
+ Random selection (`RandomContainer`, per-trigger pitch/gain randomisation) runs on a **seeded** RNG
547
+ held in a `SopraPlaybackContext`. Same seed → identical pick stream → networked clients stay in sync.
548
+
549
+ ```js
550
+ // seed from a replicated source, e.g. entity id + fixed-step tick
551
+ engine.playEvent(footsteps, { seed: entityId * 31 + tick });
552
+
553
+ // or supply your own context (its history persists across re-triggers — reuse one per emitter)
554
+ import { SopraPlaybackContext } from "./runtime/SopraPlaybackContext.js";
555
+ const ctx = new SopraPlaybackContext(seed);
556
+ engine.playEvent(footsteps, { playbackContext: ctx });
557
+ ```
558
+
559
+ If you supply neither, the engine's shared `defaultPlaybackContext` is used.
560
+
561
+ ---
562
+
563
+ ## The update loop
564
+
565
+ `SopraEngine` is driven by a single clock — the injected `AudioContext.currentTime`. **You must call
566
+ `engine.update()` once per frame.** It advances every instance (scheduling/virtualization, 3D
567
+ attenuation, fade-out deadlines, one-shot release) and evaluates ducking rules.
568
+
569
+ ```js
570
+ engine.update(); // uses the live clock
571
+ engine.update(someTime); // or pass an explicit context time (tests do this)
572
+ ```
573
+
574
+ Nothing self-schedules with `setTimeout`; fades and stops are resolved against the audio clock inside
575
+ `update`, so a late frame never desynchronises audio.
576
+
577
+ ---
578
+
579
+ ## Serialization
580
+
581
+ Definitions are fully serializable, two ways:
582
+
583
+ **JSON** — every definition has `toJSON()` / `fromJSON()`. Polymorphic clip/effect trees go through
584
+ type-tagged dispatch in `serialization/sopraJSON.js`:
585
+
586
+ ```js
587
+ const json = description.toJSON();
588
+ const restored = new EventDescription();
589
+ restored.fromJSON(json);
590
+ ```
591
+
592
+ **Binary** — each class has a co-located `<Class>SerializationAdapter.js`. Register them all — with
593
+ the upgraders that read their older formats (e.g. an `EventDescription` saved before events had
594
+ `effects`) — on a `BinarySerializationRegistry` with `populateSopraSerializationRegistry`:
595
+
596
+ ```js
597
+ import { BinarySerializationRegistry } from "../../ecs/storage/binary/BinarySerializationRegistry.js";
598
+ import { populateSopraSerializationRegistry } from "./serialization/populateSopraSerializationRegistry.js";
599
+
600
+ const registry = new BinarySerializationRegistry();
601
+ populateSopraSerializationRegistry(registry);
602
+ ```
603
+
604
+ Numeric fields are stored as float32. `serialization/sopraSerializationHarness.js` provides
605
+ `makeSopraObjectAdapter()` + `binaryRoundTrip()` for round-trip tests.
606
+
607
+ ---
608
+
609
+ ## ECS integration
610
+
611
+ In-game you almost never touch `SopraEngine` directly — you add an `AudioEmitter` component.
612
+
613
+ ```js
614
+ import { AudioEmitter } from "../ecs/audio/AudioEmitter.js";
615
+
616
+ const emitter = new AudioEmitter();
617
+ emitter.event = EventDescription.from(
618
+ "torch.loop", SampleAudioClip.from("torch.ogg", { loop: true }),
619
+ { busId: "ambient", is3D: true, distanceMax: 20 }
620
+ );
621
+ emitter.autoplay = true; // this is a looping 3D event -> spatially managed (sounds when the
622
+ // listener is in range + within budget); see the two play paths below
623
+ emitter.volume.set(0.8); // live multiplier on top of the event gain (Vector1)
624
+
625
+ entity.add(emitter).add(new Transform(...));
626
+ ```
627
+
628
+ `AudioEmitter` **owns a full `EventDescription`** — the whole clip graph, routing, 3D, voice config —
629
+ as first-class ECS data. There is no global "bank"; the `EntityComponentDataset` *is* the data store.
630
+
631
+ `AudioEmitterSystem(assetManager, soundEngine, liveEmitterSetOptions?)` drives all emitters. It shares
632
+ the one `SopraEngine` (via `SoundEngine.createSopra(bufferProvider)`, idempotent), feeds the
633
+ `SoundListener` pose, and ticks the engine each frame. *How* an emitter plays is decided **once at
634
+ link**, from `autoplay` + the event:
635
+
636
+ - **Spatially managed** — `autoplay && is3D && looping`. Registered with a `LiveEmitterSet` (a BVH
637
+ broadphase, `SpatialAudioIndex`, + a live/dormant lifecycle) and left **dormant**: no instance, no
638
+ nodes, no per-frame work — just one BVH leaf. Each frame the system culls by the listener position and
639
+ promotes the nearest in-range emitters up to a global voice **budget** (default 64), demoting the rest
640
+ (a hard cut when culled out of range, a click-safe fade on contention). This is what lets the engine
641
+ carry **~100,000 registered emitters** while only the audible ~budget ever hold WebAudio nodes — the
642
+ per-frame cost tracks the budget, not the registered count.
643
+ - **Direct** — any other `autoplay` event (2D sounds like music / ambience beds, or finite 3D
644
+ one-shots): played immediately on link, stopped on unlink. Nothing to cull by distance, and these are
645
+ few.
646
+ - **Inert** — `autoplay === false`: neither played nor registered.
647
+
648
+ The entity's `Transform64` translation is held by the instance, which follows the entity when its
649
+ transform announces a move (`t64_announce_change`), as the rendered row does — and so does the
650
+ `SoundListener` entity's;
651
+ `AudioEmitter.volume` is a live multiplier carried **through** promote/demote, so a managed emitter
652
+ keeps its volume across a cull. `system.instanceFor(entity)` returns the active instance (or `null`
653
+ while dormant).
654
+
655
+ ### Per-emitter effect parameters
656
+
657
+ An instance of a managed emitter lives only as long as its promotion: demoted, it is torn down; promoted
658
+ again, a new one is built from the event. A parameter moved on `instanceFor(entity)` goes with the
659
+ instance it was moved on. To move an effect *for the emitter*, use `AudioEmitter.effectOverrides`: the
660
+ system moves whichever instance is current, and builds every new one at the overrides, not at the
661
+ event's definition.
662
+
663
+ ```js
664
+ // one torch muffled as a door shuts on it; every other torch playing the same event stays bright
665
+ emitter.effectOverrides.approach(0, "frequency", 900, 0.1); // glide the live instance, if any
666
+ emitter.effectOverrides.set(0, "Q", 2); // move it now
667
+ emitter.effectOverrides.remove(0, "frequency"); // back to the event's own value
668
+ emitter.effectOverrides.clear(); // drop them all
669
+ ```
670
+
671
+ Addressed as `EventInstance.setEffectParam` addresses the instance's own copy (effect index in the
672
+ event's `effects`, one of the class's `parameters`), and checked against the event where it is written:
673
+ on a linked emitter a parameter the event does not have throws at the `set`, and is not kept; overrides
674
+ written before the link are checked by the link, which throws instead of playing. Values are checked too,
675
+ in every build (not by `assert`). So nothing that is held can trip a later promotion, and a dormant
676
+ emitter's override simply waits for it. Clear the overrides before re-adding the component with another
677
+ event, and keep the event as it is while the emitter is linked, dormant included. One entity per
678
+ `AudioEmitter`: the overrides follow one playing instance, and a second link of the same component throws.
679
+
680
+ Overrides are **live state**, like `volume`: never saved, and outside `equals`/`hash`, so two emitters
681
+ differing only in a turned filter are still one polyphony bucket. `EffectParamOverrides` itself is
682
+ ECS-agnostic (`sopra/runtime`): anything that rebuilds instances of one event can hold one —
683
+ `attach(event, onChange)` to have writes checked against the event and to be told of each (move the
684
+ current instance now, or glide it when the time constant is above 0; a removal sends the event's own
685
+ value), `checkAttach(event)` to ask first, `applyTo(instance)` each fresh instance, and `detach(onChange)`
686
+ when done. One owner at a time.
687
+
688
+ > **Content rule for managed crowds:** the live `budget` and an event's `maxInstances` are independent
689
+ > caps. If many emitters share one *content-equal* `EventDescription` (e.g. 100k identical birds), give
690
+ > that event `maxInstances ≥ budget` — content-equal events share one polyphony bucket, so a lower cap
691
+ > would gate the budget and fewer than `budget` would actually sound.
692
+
693
+ In production the provider is an `AssetManagerBufferProvider` over the meep `AssetManager` (shared
694
+ decode cache + the alias system).
695
+
696
+ ---
697
+
698
+ ## Testing
699
+
700
+ The entire core is unit-testable without real audio: a `MockAudioContext` records the constructed
701
+ graph and scheduled events, and a `StubBufferProvider` hands over fake buffers. Tests assert the graph
702
+ shape, timeline math, voice lifecycle, deterministic selection, and serialization — never sound.
703
+
704
+ ```js
705
+ import { SopraEngine } from "./SopraEngine.js";
706
+ import { MockAudioContext } from "./util/MockAudioContext.js";
707
+ import { StubBufferProvider } from "./asset/StubBufferProvider.js";
708
+
709
+ const ctx = new MockAudioContext();
710
+ const provider = new StubBufferProvider();
711
+ provider.set("x.ogg", { duration: 2 }); // a fake buffer is anything with a `duration`
712
+ // provider.failOn("y.ogg"); // simulate a failed/missing asset
713
+
714
+ const engine = new SopraEngine(ctx, ctx.destination, provider);
715
+ const inst = engine.playOneShot(EventDescription.from("e", SampleAudioClip.from("x.ogg")));
716
+
717
+ ctx.currentTime = 2;
718
+ engine.update(2);
719
+ // inst.state === EventInstanceState.Stopped (the one-shot self-released at the end)
720
+ ```
721
+
722
+ Run the suite from the repo root:
723
+
724
+ ```
725
+ npx jest --config jest.conf.json engine/sound
726
+ ```
727
+
728
+ ---
729
+
730
+ ## Design rules (don't break these)
731
+
732
+ - **Definitions are immutable, shared, and serializable.** No WebAudio nodes, no playback state, no
733
+ per-instance mutation on a definition. Runtime state lives on `EventInstance` / the
734
+ `SopraPlaybackContext`.
735
+ - **Gain is composed exactly once.** Authored gains are dB and accumulate down the clip tree at plan
736
+ time; bus/instance gains are linear on their own nodes. Don't double-apply.
737
+ - **One clock.** Everything reads `AudioContext.currentTime`; no `setTimeout`-driven audio.
738
+ - **The core never creates an `AudioContext`** and never imports ECS. It takes a context, a
739
+ destination, and a `BufferProvider`.
740
+ - **Errors surface.** Expected failures (missing asset, unresolved alias, limit reached) return a
741
+ sentinel (`null`) or reject; unexpected errors throw. Nothing is silently swallowed.
742
+ - **Determinism is sacred.** Random selection is seeded from a replicated source; never reach for
743
+ `Math.random()` in the playback path.
744
+
745
+ ---
746
+
747
+ ## Glossary
748
+
749
+ Acronyms and shorthand used in this document (and the code).
750
+
751
+ | Term | Expansion | Meaning in Sopra |
752
+ |---|---|---|
753
+ | **2D / 3D** | two- / three-dimensional | A 2D event is non-positional (plays straight into its bus); a 3D event (`is3D: true`) is spatialised — panned and distance-attenuated relative to the listener. |
754
+ | **API** | Application Programming Interface | The public methods/classes you call. |
755
+ | **aux send** | auxiliary send | A post-fader copy of a bus routed into another bus at a level (`BusDefinition.sends`), e.g. feeding a shared reverb bus. |
756
+ | **cents** | — | Pitch unit; 100 cents = 1 semitone, 1200 = 1 octave. All pitch offsets/randomisation are in cents. |
757
+ | **dB** | decibel | Logarithmic gain unit; 0 dB = unity, −6 dB ≈ half power. **All authored gains are in dB**; node gains on the WebAudio graph are linear (the two are converted by `dB2Volume` / `volume2dB`). |
758
+ | **ECS** | Entity Component System | meep's architecture: entities are ids, data lives in components, behaviour in systems. Sopra's *core* is ECS-agnostic; the ECS binding is `AudioEmitter` + `AudioEmitterSystem`. |
759
+ | **EQ** | equaliser | A filter insert (`EqEffect`), backed by a WebAudio `BiquadFilterNode`. |
760
+ | **FMOD / Wwise** | (product names) | Industry audio middleware. Sopra borrows their concepts — events, a bus tree, RTPC, voice stealing, snapshots, ducking — but is built on meep's data model, not a bank file. |
761
+ | **HRTF** | Head-Related Transfer Function | The high-quality (per-source convolution) 3D panning model; one of the `SopraPanningModel` options (the other, `EqualPower`, is cheaper). |
762
+ | **Hz** | hertz | Frequency unit (cycles/second), e.g. an EQ cutoff/centre frequency. |
763
+ | **IR** | Impulse Response | The convolution kernel that defines a reverb's space. `ReverbEffect` **generates one procedurally** (decaying noise), so there is no IR asset to load. |
764
+ | **JSON** | JavaScript Object Notation | One of the two serialization formats (human-readable; via `toJSON`/`fromJSON` + the type-tag dispatch in `sopraJSON.js`). The other is binary. |
765
+ | **Q** | quality factor | A filter's bandwidth/resonance control (`EqEffect.Q`). |
766
+ | **RNG** | Random Number Generator | Sopra's RNG is **seeded** (per `SopraPlaybackContext`) so random selection is reproducible across networked clients. |
767
+ | **RTPC** | Real-Time Parameter Control | A named float "parameter" (FMOD's term). Drives `Switch`/`Blend` selection and bus-volume / bus-effect automation. Stored in the `ParameterStore`; see `defineParameter` / `setParameter`. |
768
+ | **WebAudio** | Web Audio API | The browser audio API Sopra renders through (`AudioContext`, `GainNode`, `PannerNode`, `BiquadFilterNode`, `ConvolverNode`, …). Sopra never creates the `AudioContext` — meep's `SoundEngine` does. |