@bornengine/engine 0.4.16

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 (213) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +231 -0
  3. package/native/android/Cargo.lock +1848 -0
  4. package/native/android/Cargo.toml +24 -0
  5. package/native/android/src/lib.rs +702 -0
  6. package/native/ios/Cargo.lock +1690 -0
  7. package/native/ios/Cargo.toml +32 -0
  8. package/native/ios/src/lib.rs +1267 -0
  9. package/native/linux/Cargo.lock +3279 -0
  10. package/native/linux/Cargo.toml +29 -0
  11. package/native/linux/src/lib.rs +1331 -0
  12. package/native/macos/Cargo.lock +3310 -0
  13. package/native/macos/Cargo.toml +46 -0
  14. package/native/macos/src/lib.rs +1302 -0
  15. package/native/shared/Cargo.lock +1899 -0
  16. package/native/shared/Cargo.toml +62 -0
  17. package/native/shared/assets/default_font.ttf +0 -0
  18. package/native/shared/build.rs +270 -0
  19. package/native/shared/shaders/common/clouds.wgsl +122 -0
  20. package/native/shared/shaders/common/fog.wgsl +16 -0
  21. package/native/shared/shaders/common/foliage_wind.wgsl +98 -0
  22. package/native/shared/shaders/common/imposter.wgsl +112 -0
  23. package/native/shared/shaders/common/pbr.wgsl +186 -0
  24. package/native/shared/shaders/common/shadows.wgsl +186 -0
  25. package/native/shared/shaders/common/sky.wgsl +8 -0
  26. package/native/shared/shaders/common/tonemap.wgsl +25 -0
  27. package/native/shared/shaders/impulse_field.wgsl +57 -0
  28. package/native/shared/shaders/material_abi.wgsl +383 -0
  29. package/native/shared/shaders/materials/test_minimal.wgsl +42 -0
  30. package/native/shared/src/anim_mixer.rs +61 -0
  31. package/native/shared/src/attach.rs +263 -0
  32. package/native/shared/src/audio/decode.rs +123 -0
  33. package/native/shared/src/audio/mod.rs +863 -0
  34. package/native/shared/src/audio/render.rs +892 -0
  35. package/native/shared/src/audio/spsc.rs +156 -0
  36. package/native/shared/src/audio/stream.rs +226 -0
  37. package/native/shared/src/custom_shaders.rs +104 -0
  38. package/native/shared/src/decals.rs +245 -0
  39. package/native/shared/src/drs.rs +211 -0
  40. package/native/shared/src/engine.rs +261 -0
  41. package/native/shared/src/ffi.rs +116 -0
  42. package/native/shared/src/ffi_core/assets.rs +388 -0
  43. package/native/shared/src/ffi_core/audio_ffi.rs +184 -0
  44. package/native/shared/src/ffi_core/draw.rs +334 -0
  45. package/native/shared/src/ffi_core/game_loop.rs +577 -0
  46. package/native/shared/src/ffi_core/input.rs +234 -0
  47. package/native/shared/src/ffi_core/mod.rs +127 -0
  48. package/native/shared/src/ffi_core/models.rs +1154 -0
  49. package/native/shared/src/ffi_core/ragdoll_ffi.rs +261 -0
  50. package/native/shared/src/ffi_core/scene.rs +626 -0
  51. package/native/shared/src/ffi_core/vfx.rs +212 -0
  52. package/native/shared/src/ffi_core/visual.rs +691 -0
  53. package/native/shared/src/frame_callbacks.rs +122 -0
  54. package/native/shared/src/geometry.rs +236 -0
  55. package/native/shared/src/handles.rs +182 -0
  56. package/native/shared/src/input.rs +448 -0
  57. package/native/shared/src/jolt_sys.rs +822 -0
  58. package/native/shared/src/lib.rs +55 -0
  59. package/native/shared/src/models.rs +1093 -0
  60. package/native/shared/src/models_gltf.rs +1280 -0
  61. package/native/shared/src/particles.rs +391 -0
  62. package/native/shared/src/physics_jolt.rs +1908 -0
  63. package/native/shared/src/picking.rs +298 -0
  64. package/native/shared/src/postfx.rs +345 -0
  65. package/native/shared/src/profiler.rs +492 -0
  66. package/native/shared/src/ragdoll.rs +474 -0
  67. package/native/shared/src/renderer/atmosphere_lut.rs +573 -0
  68. package/native/shared/src/renderer/brdf_lut.rs +154 -0
  69. package/native/shared/src/renderer/draw2d.rs +143 -0
  70. package/native/shared/src/renderer/formats.rs +822 -0
  71. package/native/shared/src/renderer/froxel.rs +421 -0
  72. package/native/shared/src/renderer/gi_bake.rs +653 -0
  73. package/native/shared/src/renderer/graph.rs +462 -0
  74. package/native/shared/src/renderer/hiz.rs +269 -0
  75. package/native/shared/src/renderer/hot_reload.rs +390 -0
  76. package/native/shared/src/renderer/impulse_field.rs +456 -0
  77. package/native/shared/src/renderer/lighting.rs +154 -0
  78. package/native/shared/src/renderer/material_instancing.rs +171 -0
  79. package/native/shared/src/renderer/material_pipeline.rs +700 -0
  80. package/native/shared/src/renderer/material_system.rs +1996 -0
  81. package/native/shared/src/renderer/material_system_tests.rs +601 -0
  82. package/native/shared/src/renderer/material_system_wasm.rs +41 -0
  83. package/native/shared/src/renderer/mod.rs +12556 -0
  84. package/native/shared/src/renderer/model_draw.rs +641 -0
  85. package/native/shared/src/renderer/occlusion.rs +429 -0
  86. package/native/shared/src/renderer/planar_pass.rs +593 -0
  87. package/native/shared/src/renderer/planar_reflection.rs +499 -0
  88. package/native/shared/src/renderer/post_pass.rs +249 -0
  89. package/native/shared/src/renderer/postfx_chain.rs +728 -0
  90. package/native/shared/src/renderer/pt_pass.rs +577 -0
  91. package/native/shared/src/renderer/scene_pass.rs +607 -0
  92. package/native/shared/src/renderer/shader_include.rs +205 -0
  93. package/native/shared/src/renderer/shader_library.rs +135 -0
  94. package/native/shared/src/renderer/shaders/ao.rs +570 -0
  95. package/native/shared/src/renderer/shaders/core.rs +1243 -0
  96. package/native/shared/src/renderer/shaders/env.rs +907 -0
  97. package/native/shared/src/renderer/shaders/gi.rs +810 -0
  98. package/native/shared/src/renderer/shaders/mod.rs +19 -0
  99. package/native/shared/src/renderer/shaders/post.rs +1558 -0
  100. package/native/shared/src/renderer/shaders/pt.rs +1859 -0
  101. package/native/shared/src/renderer/shaders/ssgi.rs +1586 -0
  102. package/native/shared/src/renderer/shadow_pass.rs +731 -0
  103. package/native/shared/src/renderer/ssgi_pass.rs +392 -0
  104. package/native/shared/src/renderer/ssr_pass.rs +188 -0
  105. package/native/shared/src/renderer/texture_store.rs +473 -0
  106. package/native/shared/src/renderer/transient.rs +591 -0
  107. package/native/shared/src/renderer/types.rs +941 -0
  108. package/native/shared/src/renderer/util.rs +152 -0
  109. package/native/shared/src/scene.rs +1362 -0
  110. package/native/shared/src/sdf_cache.rs +274 -0
  111. package/native/shared/src/shadows.rs +1036 -0
  112. package/native/shared/src/staging.rs +102 -0
  113. package/native/shared/src/string_header.rs +266 -0
  114. package/native/shared/src/text_renderer.rs +502 -0
  115. package/native/shared/src/textures.rs +197 -0
  116. package/native/tvos/Cargo.lock +1693 -0
  117. package/native/tvos/Cargo.toml +36 -0
  118. package/native/tvos/metal-patched/Cargo.toml +178 -0
  119. package/native/tvos/metal-patched/LICENSE-APACHE +201 -0
  120. package/native/tvos/metal-patched/LICENSE-MIT +25 -0
  121. package/native/tvos/metal-patched/src/acceleration_structure.rs +667 -0
  122. package/native/tvos/metal-patched/src/acceleration_structure_pass.rs +108 -0
  123. package/native/tvos/metal-patched/src/argument.rs +366 -0
  124. package/native/tvos/metal-patched/src/blitpass.rs +102 -0
  125. package/native/tvos/metal-patched/src/buffer.rs +71 -0
  126. package/native/tvos/metal-patched/src/capturedescriptor.rs +76 -0
  127. package/native/tvos/metal-patched/src/capturemanager.rs +113 -0
  128. package/native/tvos/metal-patched/src/commandbuffer.rs +192 -0
  129. package/native/tvos/metal-patched/src/commandqueue.rs +44 -0
  130. package/native/tvos/metal-patched/src/computepass.rs +107 -0
  131. package/native/tvos/metal-patched/src/constants.rs +152 -0
  132. package/native/tvos/metal-patched/src/counters.rs +119 -0
  133. package/native/tvos/metal-patched/src/depthstencil.rs +190 -0
  134. package/native/tvos/metal-patched/src/device.rs +2134 -0
  135. package/native/tvos/metal-patched/src/drawable.rs +39 -0
  136. package/native/tvos/metal-patched/src/encoder.rs +2041 -0
  137. package/native/tvos/metal-patched/src/heap.rs +281 -0
  138. package/native/tvos/metal-patched/src/indirect_encoder.rs +344 -0
  139. package/native/tvos/metal-patched/src/lib.rs +657 -0
  140. package/native/tvos/metal-patched/src/library.rs +902 -0
  141. package/native/tvos/metal-patched/src/mps.rs +575 -0
  142. package/native/tvos/metal-patched/src/pipeline/compute.rs +475 -0
  143. package/native/tvos/metal-patched/src/pipeline/mod.rs +71 -0
  144. package/native/tvos/metal-patched/src/pipeline/render.rs +762 -0
  145. package/native/tvos/metal-patched/src/renderpass.rs +443 -0
  146. package/native/tvos/metal-patched/src/resource.rs +182 -0
  147. package/native/tvos/metal-patched/src/sampler.rs +165 -0
  148. package/native/tvos/metal-patched/src/sync.rs +178 -0
  149. package/native/tvos/metal-patched/src/texture.rs +352 -0
  150. package/native/tvos/metal-patched/src/types.rs +90 -0
  151. package/native/tvos/metal-patched/src/vertexdescriptor.rs +250 -0
  152. package/native/tvos/src/audio_backend.rs +197 -0
  153. package/native/tvos/src/lib.rs +1891 -0
  154. package/native/visionos/Cargo.lock +1693 -0
  155. package/native/visionos/Cargo.toml +40 -0
  156. package/native/visionos/src/audio_backend.rs +197 -0
  157. package/native/visionos/src/lib.rs +1887 -0
  158. package/native/watchos/Cargo.lock +16 -0
  159. package/native/watchos/Cargo.toml +19 -0
  160. package/native/watchos/shaders/bloom_postfx.metal +99 -0
  161. package/native/watchos/src/BloomWatchApp.swift +1267 -0
  162. package/native/watchos/src/BloomWatchAudio.swift +179 -0
  163. package/native/watchos/src/audio.rs +55 -0
  164. package/native/watchos/src/draw_list.rs +229 -0
  165. package/native/watchos/src/ffi_stubs.rs +915 -0
  166. package/native/watchos/src/ffi_stubs_manual.rs +35 -0
  167. package/native/watchos/src/lib.rs +1124 -0
  168. package/native/watchos/src/models.rs +746 -0
  169. package/native/watchos/src/postfx.rs +95 -0
  170. package/native/watchos/src/scene.rs +534 -0
  171. package/native/watchos/src/textures.rs +184 -0
  172. package/native/web/Cargo.lock +1657 -0
  173. package/native/web/Cargo.toml +43 -0
  174. package/native/web/bloom_glue.js +695 -0
  175. package/native/web/build.sh +131 -0
  176. package/native/web/index.html +35 -0
  177. package/native/web/jolt_bridge.js +1519 -0
  178. package/native/web/src/input_ffi.rs +286 -0
  179. package/native/web/src/lib.rs +1796 -0
  180. package/native/web/src/material_ffi.rs +710 -0
  181. package/native/web/src/parity_ffi.rs +343 -0
  182. package/native/web/src/physics_ffi.rs +643 -0
  183. package/native/web/src/ragdoll_ffi.rs +250 -0
  184. package/native/web/src/render_settings.rs +98 -0
  185. package/native/windows/Cargo.lock +1815 -0
  186. package/native/windows/Cargo.toml +68 -0
  187. package/native/windows/src/lib.rs +1486 -0
  188. package/package.json +4279 -0
  189. package/src/audio/index.ts +315 -0
  190. package/src/core/colors.ts +63 -0
  191. package/src/core/index.ts +1206 -0
  192. package/src/core/keys.ts +63 -0
  193. package/src/core/types.ts +104 -0
  194. package/src/index.ts +171 -0
  195. package/src/math/index.ts +516 -0
  196. package/src/mobile/index.ts +294 -0
  197. package/src/models/index.ts +1258 -0
  198. package/src/physics/index.ts +1134 -0
  199. package/src/scene/index.ts +698 -0
  200. package/src/shapes/index.ts +120 -0
  201. package/src/text/index.ts +48 -0
  202. package/src/textures/index.ts +187 -0
  203. package/src/vfx/index.ts +191 -0
  204. package/src/world/index.ts +24 -0
  205. package/src/world/loader.ts +423 -0
  206. package/src/world/prefab.ts +217 -0
  207. package/src/world/render.ts +172 -0
  208. package/src/world/saver.ts +108 -0
  209. package/src/world/serialize.ts +301 -0
  210. package/src/world/terrain.ts +355 -0
  211. package/src/world/types.ts +160 -0
  212. package/src/world/validate.ts +319 -0
  213. package/src/world/version.ts +114 -0
@@ -0,0 +1,892 @@
1
+ //! The render half of the audio system: voice state + mixing.
2
+ //!
3
+ //! An [`AudioRenderer`] is owned exclusively by the platform's audio
4
+ //! thread (CoreAudio render callback on Apple platforms, the dedicated
5
+ //! ALSA/WASAPI/AAudio thread elsewhere, or inline on the main thread on
6
+ //! web). It never locks: control-side changes arrive as [`Cmd`]s over the
7
+ //! SPSC ring and are drained at the top of every [`AudioRenderer::mix`].
8
+ //!
9
+ //! Sample data is shared with the control side via `Arc<SoundData>` — a
10
+ //! sound unloaded mid-playback keeps its samples alive until the last
11
+ //! voice playing it finishes, then the Arc drops on this thread.
12
+ //!
13
+ //! # Spatialization (EN-062)
14
+ //!
15
+ //! Every voice carries a stable `voice_id`, so a playing voice can be
16
+ //! moved, re-volumed, re-pitched, filtered or stopped after the trigger —
17
+ //! that is what turns "a sound played at a point" into an *emitter* (a
18
+ //! river you walk along, a creature circling you). Spatial voices get:
19
+ //!
20
+ //! - **Inverse-clamped distance model** — `ref / (ref + rolloff·(d−ref))`,
21
+ //! which with ref=1, rolloff=1 is exactly the old 1/d curve, so the
22
+ //! pre-EN-062 API keeps its loudness. Past `max_dist` the voice is
23
+ //! culled from the mix but keeps its playback head advancing.
24
+ //! - **Equal-power panning** (was linear, which dipped centered sources
25
+ //! 6 dB and made walk-bys "swell" at the ears). Width is 0.85: a real
26
+ //! head leaks sound around itself; a 100%-one-ear source reads as a
27
+ //! headphone artifact, not a position.
28
+ //! - **Air absorption** — a distance-driven low-pass (20 kHz at the ear
29
+ //! falling exponentially with range). Distant gunfire is dull *before*
30
+ //! it is quiet; that ordering is most of what "far away" sounds like.
31
+ //! - **Rear cue** — sources behind the listener are low-passed toward
32
+ //! ~4.5 kHz and dipped ~1.5 dB. Cheap head-shadow approximation; it is
33
+ //! the difference between "somewhere" and "behind you".
34
+ //! - **Doppler** — playback rate bends with radial velocity (343 m/s
35
+ //! speed of sound, clamped, smoothed). Computed from the *distance
36
+ //! delta* per block, so listener motion contributes symmetrically. A
37
+ //! teleporting emitter (pool voice re-targeted to a new enemy) is
38
+ //! detected by an impossible radial speed and resets cleanly instead
39
+ //! of chirping.
40
+ //!
41
+ //! All per-voice gains ramp linearly across each mix block — a moving
42
+ //! emitter or a per-frame volume ride must never zipper or click.
43
+ //!
44
+ //! Voices resample with linear interpolation (`frame_pos` is fractional):
45
+ //! this is what makes doppler/pitch possible, and it also plays each
46
+ //! asset at its *authored* rate — previously a 44.1 kHz file on a 48 kHz
47
+ //! device played ~9% fast and sharp. Music streams are untouched.
48
+
49
+ use super::spsc::Consumer;
50
+ #[cfg(not(target_arch = "wasm32"))]
51
+ use super::stream::{StreamConsumer, StreamMsg};
52
+ use super::{MusicShared, SoundData};
53
+ use std::sync::atomic::Ordering;
54
+ use std::sync::Arc;
55
+
56
+ /// Control → render commands. Everything the FFI surface can do to audio
57
+ /// state is expressed here; the render thread is the single writer of all
58
+ /// playback state.
59
+ pub enum Cmd {
60
+ PlaySound {
61
+ sound_id: u64,
62
+ /// Stable per-play id — the handle for every SetVoice*/StopVoice.
63
+ voice_id: u64,
64
+ data: Arc<SoundData>,
65
+ volume: f32,
66
+ /// Some(world position) for spatial sounds.
67
+ spatial: Option<[f32; 3]>,
68
+ /// EN-062 — loop the sample seamlessly until StopVoice/StopSound.
69
+ looping: bool,
70
+ /// EN-062 — distance model. ref=1, rolloff=1 == the classic 1/d.
71
+ ref_dist: f32,
72
+ max_dist: f32,
73
+ rolloff: f32,
74
+ /// EN-062 — playback-rate multiplier (doppler multiplies on top).
75
+ pitch: f32,
76
+ /// EN-029 — mix bus (see [`bus`]), reverb send (0..1) and low-pass
77
+ /// cutoff in Hz (<= 0 or >= NYQUIST = bypass).
78
+ bus: u8,
79
+ send: f32,
80
+ lowpass: f32,
81
+ },
82
+ StopSound { sound_id: u64 },
83
+ SetSoundVolume { sound_id: u64, volume: f32 },
84
+ PlayMusic {
85
+ music_id: u64,
86
+ payload: MusicPayload,
87
+ shared: Arc<MusicShared>,
88
+ volume: f32,
89
+ looping: bool,
90
+ },
91
+ StopMusic { music_id: u64 },
92
+ SetMusicVolume { music_id: u64, volume: f32 },
93
+ SetMaster(f32),
94
+ SetListener { pos: [f32; 3], forward: [f32; 3] },
95
+
96
+ // ---- EN-029 -------------------------------------------------------
97
+ SetBusGain { bus: u8, gain: f32 },
98
+ /// Momentary sidechain-style duck: pull `bus` down by `amount` (linear,
99
+ /// 0..1) over `attack` seconds, hold, then release over `release`.
100
+ DuckBus { bus: u8, amount: f32, attack: f32, release: f32, hold: f32 },
101
+ SetReverbParams { size: f32, damp: f32, wet: f32 },
102
+ /// Per-playing-voice sends — this is the occlusion primitive. The game
103
+ /// raycasts and decides; the mixer just filters.
104
+ SetSoundLowpass { sound_id: u64, cutoff: f32 },
105
+ SetSoundSend { sound_id: u64, send: f32 },
106
+
107
+ // ---- EN-062: live-voice control ------------------------------------
108
+ SetVoicePosition { voice_id: u64, pos: [f32; 3] },
109
+ /// Fades out over ~1 block and removes — a hard cut on a looping bed
110
+ /// mid-waveform is an audible click.
111
+ StopVoice { voice_id: u64 },
112
+ SetVoiceVolume { voice_id: u64, volume: f32 },
113
+ SetVoicePitch { voice_id: u64, pitch: f32 },
114
+ /// Per-VOICE occlusion; SetSoundLowpass muffles every voice of a sound,
115
+ /// which is wrong the moment two emitters share an asset.
116
+ SetVoiceLowpass { voice_id: u64, cutoff: f32 },
117
+ }
118
+
119
+ /// Mix buses. Kept tiny and fixed: a general submix graph is a lot of
120
+ /// machinery for a game that needs exactly "duck the music when I'm hit" and
121
+ /// "don't let UI beeps ride the reverb".
122
+ pub mod bus {
123
+ pub const SFX: u8 = 0;
124
+ pub const MUSIC: u8 = 1;
125
+ pub const UI: u8 = 2;
126
+ pub const COUNT: usize = 3;
127
+ }
128
+
129
+ /// A duck envelope per bus. `gain` is the static level the game set; `duck` is
130
+ /// the momentary attenuation on top of it, and it is the one that moves.
131
+ #[derive(Clone, Copy)]
132
+ struct BusState {
133
+ gain: f32,
134
+ /// Current attenuation, 0 = untouched, 1 = fully ducked.
135
+ duck: f32,
136
+ /// Where `duck` is heading while the hold lasts.
137
+ duck_target: f32,
138
+ attack: f32,
139
+ release: f32,
140
+ hold_left: f32,
141
+ }
142
+
143
+ impl Default for BusState {
144
+ fn default() -> Self {
145
+ Self { gain: 1.0, duck: 0.0, duck_target: 0.0, attack: 0.01, release: 0.3, hold_left: 0.0 }
146
+ }
147
+ }
148
+
149
+ impl BusState {
150
+ fn set_duck(&mut self, amount: f32, attack: f32, release: f32, hold: f32) {
151
+ self.duck_target = amount.clamp(0.0, 1.0);
152
+ self.attack = attack.max(0.0);
153
+ self.release = release.max(0.0);
154
+ self.hold_left = hold.max(0.0);
155
+ }
156
+
157
+ /// Advance the duck envelope by one mix block.
158
+ fn advance(&mut self, dt: f32) {
159
+ let target = if self.hold_left > 0.0 {
160
+ self.hold_left -= dt;
161
+ self.duck_target
162
+ } else {
163
+ 0.0
164
+ };
165
+ let rate = if target > self.duck { self.attack } else { self.release };
166
+ if rate <= 0.0 {
167
+ self.duck = target;
168
+ } else {
169
+ // One-pole toward the target — exponential, so a long block can
170
+ // never overshoot the destination and ring.
171
+ let k = (dt / rate).clamp(0.0, 1.0);
172
+ self.duck += (target - self.duck) * k;
173
+ }
174
+ }
175
+
176
+ fn current(&self) -> f32 { (self.gain * (1.0 - self.duck)).clamp(0.0, 4.0) }
177
+ }
178
+
179
+ /// Speed of sound, m/s — the doppler constant.
180
+ const SOUND_SPEED: f32 = 343.0;
181
+ /// A radial speed past this is a teleport (voice re-targeted), not motion:
182
+ /// reset doppler instead of bending pitch through the jump.
183
+ const DOPPLER_TELEPORT: f32 = 150.0;
184
+ /// Cutoffs above this bypass the one-pole entirely (inaudible + not free).
185
+ const LP_ENGAGE_HZ: f32 = 16_000.0;
186
+
187
+ struct Voice {
188
+ sound_id: u64,
189
+ voice_id: u64,
190
+ data: Arc<SoundData>,
191
+ /// Playback head in FRAMES, fractional — doppler and pitch resample.
192
+ frame_pos: f64,
193
+ volume: f32,
194
+ spatial: Option<[f32; 3]>,
195
+ looping: bool,
196
+ ref_dist: f32,
197
+ max_dist: f32,
198
+ rolloff: f32,
199
+ /// Game-set playback rate. Doppler multiplies on top of it.
200
+ pitch: f32,
201
+ /// Smoothed doppler rate.
202
+ doppler: f32,
203
+ /// Listener distance last block; < 0 = not yet seeded.
204
+ prev_dist: f32,
205
+ /// Current combined per-channel gains (spatial × volume × master × bus),
206
+ /// ramped toward each block's target so moving emitters never zipper.
207
+ g_l: f32,
208
+ g_r: f32,
209
+ /// False until the first block computes real targets (skip the ramp-in;
210
+ /// the asset's own attack handles the onset).
211
+ seeded: bool,
212
+ /// StopVoice: fade to silence over a block, then drop.
213
+ stopping: bool,
214
+ bus: u8,
215
+ send: f32,
216
+ /// Low-pass cutoff, Hz. <= 0 = bypass.
217
+ lowpass: f32,
218
+ /// One-pole filter memory, per output channel.
219
+ lp_z: [f32; 2],
220
+ }
221
+
222
+ impl Voice {
223
+ /// Read frame `idx` as stereo (mono duplicates).
224
+ #[inline]
225
+ fn frame(&self, idx: usize) -> (f32, f32) {
226
+ if self.data.channels <= 1 {
227
+ let s = self.data.samples[idx];
228
+ (s, s)
229
+ } else {
230
+ (self.data.samples[idx * 2], self.data.samples[idx * 2 + 1])
231
+ }
232
+ }
233
+ }
234
+
235
+ /// How a music voice gets its samples.
236
+ pub enum MusicPayload {
237
+ Full(Arc<SoundData>),
238
+ /// Chunks arrive from a background decode worker; the worker handles
239
+ /// looping internally (End only arrives for finished non-loop tracks).
240
+ #[cfg(not(target_arch = "wasm32"))]
241
+ Stream { consumer: StreamConsumer, channels: u16 },
242
+ }
243
+
244
+ enum MusicSamples {
245
+ Full { data: Arc<SoundData>, position: usize },
246
+ #[cfg(not(target_arch = "wasm32"))]
247
+ Stream {
248
+ consumer: StreamConsumer,
249
+ channels: u16,
250
+ current: Vec<f32>,
251
+ offset: usize,
252
+ ended: bool,
253
+ },
254
+ }
255
+
256
+ struct MusicVoice {
257
+ music_id: u64,
258
+ samples: MusicSamples,
259
+ shared: Arc<MusicShared>,
260
+ volume: f32,
261
+ looping: bool,
262
+ /// Total samples consumed (drives the shared position mirror).
263
+ consumed: usize,
264
+ }
265
+
266
+ /// EN-029 — a Schroeder reverb: parallel comb filters (the density) into
267
+ /// series allpasses (the diffusion). Freeverb's topology, trimmed to 4+2 per
268
+ /// channel because this runs on the audio thread of a game, not a DAW.
269
+ ///
270
+ /// Delay lengths are the classic 44.1 kHz tunings. At 48 kHz the room comes
271
+ /// out ~9% smaller, which is inaudible for a gunshot tail and not worth
272
+ /// resampling the delay lines for.
273
+ struct Reverb {
274
+ combs: [Vec<f32>; 4],
275
+ comb_idx: [usize; 4],
276
+ comb_z: [f32; 4],
277
+ allpass: [Vec<f32>; 2],
278
+ ap_idx: [usize; 2],
279
+ /// 0..1 — feedback in the combs. Bigger = longer tail.
280
+ size: f32,
281
+ /// 0..1 — high-frequency absorption in the tail.
282
+ damp: f32,
283
+ /// 0..1 — how much of the wet signal reaches the output.
284
+ wet: f32,
285
+ }
286
+
287
+ impl Reverb {
288
+ const COMB_LEN: [usize; 4] = [1116, 1188, 1277, 1356];
289
+ const AP_LEN: [usize; 2] = [556, 441];
290
+
291
+ fn new() -> Self {
292
+ Self {
293
+ combs: [
294
+ vec![0.0; Self::COMB_LEN[0]],
295
+ vec![0.0; Self::COMB_LEN[1]],
296
+ vec![0.0; Self::COMB_LEN[2]],
297
+ vec![0.0; Self::COMB_LEN[3]],
298
+ ],
299
+ comb_idx: [0; 4],
300
+ comb_z: [0.0; 4],
301
+ allpass: [vec![0.0; Self::AP_LEN[0]], vec![0.0; Self::AP_LEN[1]]],
302
+ ap_idx: [0; 2],
303
+ size: 0.7,
304
+ damp: 0.5,
305
+ wet: 0.0,
306
+ }
307
+ }
308
+
309
+ /// One mono sample in, one wet sample out.
310
+ fn process(&mut self, input: f32) -> f32 {
311
+ let feedback = 0.7 + self.size * 0.28; // 0.70..0.98
312
+ let damp = self.damp.clamp(0.0, 1.0);
313
+
314
+ let mut out = 0.0;
315
+ for c in 0..4 {
316
+ let i = self.comb_idx[c];
317
+ let delayed = self.combs[c][i];
318
+ out += delayed;
319
+ // Lowpass in the feedback path = the damping.
320
+ self.comb_z[c] = delayed * (1.0 - damp) + self.comb_z[c] * damp;
321
+ self.combs[c][i] = input + self.comb_z[c] * feedback;
322
+ self.comb_idx[c] = (i + 1) % Self::COMB_LEN[c];
323
+ }
324
+ out *= 0.25;
325
+
326
+ for a in 0..2 {
327
+ let i = self.ap_idx[a];
328
+ let delayed = self.allpass[a][i];
329
+ let v = out - delayed * 0.5;
330
+ self.allpass[a][i] = v;
331
+ out = delayed + v * 0.5;
332
+ self.ap_idx[a] = (i + 1) % Self::AP_LEN[a];
333
+ }
334
+ out
335
+ }
336
+ }
337
+
338
+ /// Hard cap on simultaneously-playing voices. The render thread NEVER grows
339
+ /// `voices` past this — a heap reallocation on the audio thread is a glitch
340
+ /// (and a priority inversion against the malloc lock). At the cap, a new play
341
+ /// steals the quietest voice instead. `voices` is preallocated to this size.
342
+ const MAX_VOICES: usize = 64;
343
+ /// Same discipline for music tracks. Practically 1-2 ever play at once; the
344
+ /// cap only exists so the render thread can never reallocate.
345
+ const MAX_MUSIC: usize = 4;
346
+
347
+ pub struct AudioRenderer {
348
+ rx: Consumer<Cmd>,
349
+ voices: Vec<Voice>,
350
+ music: Vec<MusicVoice>,
351
+ master: f32,
352
+ listener_pos: [f32; 3],
353
+ listener_forward: [f32; 3],
354
+
355
+ // EN-029
356
+ buses: [BusState; bus::COUNT],
357
+ reverb_l: Reverb,
358
+ reverb_r: Reverb,
359
+ /// Reverb input accumulator, one slot per output sample. Preallocated so
360
+ /// the audio thread never allocates.
361
+ send_buf: Vec<f32>,
362
+ sample_rate: f32,
363
+ }
364
+
365
+ impl AudioRenderer {
366
+ pub(super) fn new(rx: Consumer<Cmd>) -> Self {
367
+ Self {
368
+ rx,
369
+ voices: Vec::with_capacity(MAX_VOICES),
370
+ music: Vec::with_capacity(MAX_MUSIC),
371
+ master: 1.0,
372
+ listener_pos: [0.0; 3],
373
+ listener_forward: [0.0, 0.0, -1.0],
374
+ buses: [BusState::default(); bus::COUNT],
375
+ reverb_l: Reverb::new(),
376
+ reverb_r: Reverb::new(),
377
+ send_buf: vec![0.0; 8192],
378
+ sample_rate: 44_100.0,
379
+ }
380
+ }
381
+
382
+ pub fn set_sample_rate(&mut self, sr: f32) {
383
+ if sr > 1000.0 { self.sample_rate = sr; }
384
+ }
385
+
386
+ fn apply(&mut self, cmd: Cmd) {
387
+ match cmd {
388
+ Cmd::PlaySound {
389
+ sound_id, voice_id, data, volume, spatial, looping,
390
+ ref_dist, max_dist, rolloff, pitch, bus, send, lowpass,
391
+ } => {
392
+ if self.voices.len() >= MAX_VOICES {
393
+ // Voice steal: drop the quietest non-stopping voice (least
394
+ // audible) so the push below can never reallocate. O(n) over
395
+ // <= MAX_VOICES, no allocation. `swap_remove` is O(1) and
396
+ // voice order does not affect the mix.
397
+ let victim = self.voices.iter().enumerate()
398
+ .filter(|(_, v)| !v.stopping)
399
+ .min_by(|(_, a), (_, b)| {
400
+ a.volume.partial_cmp(&b.volume)
401
+ .unwrap_or(std::cmp::Ordering::Equal)
402
+ })
403
+ .map(|(i, _)| i)
404
+ .unwrap_or(0);
405
+ self.voices.swap_remove(victim);
406
+ }
407
+ self.voices.push(Voice {
408
+ sound_id, voice_id, data, frame_pos: 0.0, volume, spatial,
409
+ looping,
410
+ ref_dist: ref_dist.max(1e-3),
411
+ max_dist: max_dist.max(0.0),
412
+ rolloff: rolloff.max(0.0),
413
+ pitch: pitch.clamp(0.25, 4.0),
414
+ doppler: 1.0,
415
+ prev_dist: -1.0,
416
+ g_l: 0.0, g_r: 0.0, seeded: false, stopping: false,
417
+ bus, send, lowpass, lp_z: [0.0; 2],
418
+ });
419
+ }
420
+ Cmd::StopSound { sound_id } => {
421
+ self.voices.retain(|v| v.sound_id != sound_id);
422
+ }
423
+ Cmd::SetSoundVolume { sound_id, volume } => {
424
+ for v in &mut self.voices {
425
+ if v.sound_id == sound_id {
426
+ v.volume = volume;
427
+ }
428
+ }
429
+ }
430
+ Cmd::PlayMusic { music_id, payload, shared, volume, looping } => {
431
+ // Restart-from-zero semantics (matches the old mixer).
432
+ self.music.retain(|m| m.music_id != music_id);
433
+ shared.playing.store(true, Ordering::Relaxed);
434
+ shared.position.store(0, Ordering::Relaxed);
435
+ let samples = match payload {
436
+ MusicPayload::Full(data) => MusicSamples::Full { data, position: 0 },
437
+ #[cfg(not(target_arch = "wasm32"))]
438
+ MusicPayload::Stream { consumer, channels } => MusicSamples::Stream {
439
+ consumer,
440
+ channels,
441
+ current: Vec::new(),
442
+ offset: 0,
443
+ ended: false,
444
+ },
445
+ };
446
+ if self.music.len() >= MAX_MUSIC {
447
+ // Evict the oldest track so the push can't reallocate. The
448
+ // evicted track's shared flag is cleared so its control-side
449
+ // handle reports stopped.
450
+ let old = self.music.remove(0);
451
+ old.shared.playing.store(false, Ordering::Relaxed);
452
+ }
453
+ self.music.push(MusicVoice { music_id, samples, shared, volume, looping, consumed: 0 });
454
+ }
455
+ Cmd::StopMusic { music_id } => {
456
+ if let Some(m) = self.music.iter().position(|m| m.music_id == music_id) {
457
+ let m = self.music.swap_remove(m);
458
+ m.shared.playing.store(false, Ordering::Relaxed);
459
+ m.shared.position.store(0, Ordering::Relaxed);
460
+ }
461
+ }
462
+ Cmd::SetMusicVolume { music_id, volume } => {
463
+ for m in &mut self.music {
464
+ if m.music_id == music_id {
465
+ m.volume = volume;
466
+ }
467
+ }
468
+ }
469
+ Cmd::SetMaster(v) => self.master = v,
470
+ Cmd::SetListener { pos, forward } => {
471
+ self.listener_pos = pos;
472
+ self.listener_forward = forward;
473
+ }
474
+ Cmd::SetBusGain { bus, gain } => {
475
+ if (bus as usize) < bus::COUNT {
476
+ self.buses[bus as usize].gain = gain.max(0.0);
477
+ }
478
+ }
479
+ Cmd::DuckBus { bus, amount, attack, release, hold } => {
480
+ if (bus as usize) < bus::COUNT {
481
+ self.buses[bus as usize].set_duck(amount, attack, release, hold);
482
+ }
483
+ }
484
+ Cmd::SetReverbParams { size, damp, wet } => {
485
+ for r in [&mut self.reverb_l, &mut self.reverb_r] {
486
+ r.size = size.clamp(0.0, 1.0);
487
+ r.damp = damp.clamp(0.0, 1.0);
488
+ r.wet = wet.clamp(0.0, 1.0);
489
+ }
490
+ }
491
+ Cmd::SetSoundLowpass { sound_id, cutoff } => {
492
+ for v in &mut self.voices {
493
+ if v.sound_id == sound_id { v.lowpass = cutoff; }
494
+ }
495
+ }
496
+ Cmd::SetSoundSend { sound_id, send } => {
497
+ for v in &mut self.voices {
498
+ if v.sound_id == sound_id { v.send = send.clamp(0.0, 1.0); }
499
+ }
500
+ }
501
+ Cmd::SetVoicePosition { voice_id, pos } => {
502
+ for v in &mut self.voices {
503
+ if v.voice_id == voice_id { v.spatial = Some(pos); }
504
+ }
505
+ }
506
+ Cmd::StopVoice { voice_id } => {
507
+ for v in &mut self.voices {
508
+ if v.voice_id == voice_id { v.stopping = true; }
509
+ }
510
+ }
511
+ Cmd::SetVoiceVolume { voice_id, volume } => {
512
+ for v in &mut self.voices {
513
+ if v.voice_id == voice_id { v.volume = volume.max(0.0); }
514
+ }
515
+ }
516
+ Cmd::SetVoicePitch { voice_id, pitch } => {
517
+ for v in &mut self.voices {
518
+ if v.voice_id == voice_id { v.pitch = pitch.clamp(0.25, 4.0); }
519
+ }
520
+ }
521
+ Cmd::SetVoiceLowpass { voice_id, cutoff } => {
522
+ for v in &mut self.voices {
523
+ if v.voice_id == voice_id { v.lowpass = cutoff.max(0.0); }
524
+ }
525
+ }
526
+ }
527
+ }
528
+
529
+ /// Mix all playing voices into `output` (interleaved stereo f32).
530
+ /// Wait-free: drains pending commands, then mixes. Call only from the
531
+ /// thread that owns this renderer.
532
+ pub fn mix(&mut self, output: &mut [f32]) {
533
+ // Bounded drain — the ring's capacity bounds this loop.
534
+ while let Some(cmd) = self.rx.pop() {
535
+ self.apply(cmd);
536
+ }
537
+
538
+ for sample in output.iter_mut() {
539
+ *sample = 0.0;
540
+ }
541
+
542
+ // EN-029 — advance the per-bus duck envelopes once per block. Block
543
+ // granularity is ~1-10 ms, far finer than any duck the ear resolves.
544
+ let out_frames = output.len() / 2;
545
+ let block_dt = (out_frames as f32).max(1.0) / self.sample_rate;
546
+ for b in self.buses.iter_mut() {
547
+ b.advance(block_dt);
548
+ }
549
+ let bus_gain = [
550
+ self.buses[bus::SFX as usize].current(),
551
+ self.buses[bus::MUSIC as usize].current(),
552
+ self.buses[bus::UI as usize].current(),
553
+ ];
554
+
555
+ // Reverb send accumulator. Grows once if a host ever hands us a bigger
556
+ // block than we sized for; steady-state it never allocates.
557
+ if self.send_buf.len() < output.len() {
558
+ self.send_buf.resize(output.len(), 0.0);
559
+ }
560
+ let reverb_active = self.reverb_l.wet > 0.0;
561
+ if reverb_active {
562
+ for s in self.send_buf[..output.len()].iter_mut() { *s = 0.0; }
563
+ }
564
+
565
+ // Spatial audio: listener-relative parameters, computed once.
566
+ let [lx, ly, lz] = self.listener_pos;
567
+ let [lfx, _lfy, lfz] = self.listener_forward; // "right" math projects out Y
568
+ // Listener right = cross(forward, up=[0,1,0]) = (-fz, 0, fx) — the SAME
569
+ // vector mat4_look_at calls `s` (screen-right). EN-062 flipped the sign
570
+ // here: this used to be (fz, -fx), which is screen-LEFT, so the whole
571
+ // stereo field was mirrored — a shriek on screen-left panned right.
572
+ let lrx = -lfz;
573
+ let lrz = lfx;
574
+ let lr_len = (lrx * lrx + lrz * lrz).sqrt().max(0.001);
575
+ let master = self.master;
576
+ let sample_rate = self.sample_rate;
577
+
578
+ // Split the borrow: the voice loop needs `voices` and `send_buf`
579
+ // mutably at once, and the compiler can only see they're disjoint if
580
+ // we name them separately.
581
+ let Self { voices, send_buf, reverb_l, reverb_r, music, .. } = self;
582
+
583
+ // Sound effects
584
+ voices.retain_mut(|v| {
585
+ let channels = v.data.channels.max(1) as usize;
586
+ let frames = v.data.samples.len() / channels;
587
+ if frames == 0 { return false; }
588
+
589
+ // ---- per-block spatial targets --------------------------------
590
+ // Manual (occlusion) low-pass; spatial cues can only lower it.
591
+ let mut cutoff = if v.lowpass > 0.0 { v.lowpass } else { f32::MAX };
592
+ let (sg_l, sg_r, dist) = if let Some([sx, sy, sz]) = v.spatial {
593
+ let dx = sx - lx;
594
+ let dy = sy - ly;
595
+ let dz = sz - lz;
596
+ let dist = (dx * dx + dy * dy + dz * dz).sqrt().max(1e-4);
597
+ // Inverse-clamped distance model. ref=1, rolloff=1 == the old
598
+ // 1/d exactly, so pre-EN-062 callers keep their loudness.
599
+ let refd = v.ref_dist;
600
+ let att = if dist > v.max_dist {
601
+ 0.0
602
+ } else {
603
+ (refd / (refd + v.rolloff * (dist.max(refd) - refd))).min(1.0)
604
+ };
605
+ // Equal-power pan from the horizontal azimuth, at 0.85 width
606
+ // (a head is not an infinite baffle).
607
+ let pan = ((dx * lrx + dz * lrz) / (dist * lr_len)).clamp(-1.0, 1.0) * 0.85;
608
+ let theta = (pan + 1.0) * std::f32::consts::FRAC_PI_4;
609
+ let mut gl = att * theta.cos();
610
+ let mut gr = att * theta.sin();
611
+ // Air absorption: distance dulls before it silences.
612
+ cutoff = cutoff.min(20_000.0 * (-0.006 * dist).exp());
613
+ // Rear cue: head shadow behind the listener — darker and a
614
+ // touch quieter. Horizontal only; overhead stays neutral.
615
+ let f_len2 = lfx * lfx + lfz * lfz;
616
+ if f_len2 > 1e-6 {
617
+ let back = (-(dx * lfx + dz * lfz) / (dist * f_len2.sqrt())).clamp(0.0, 1.0);
618
+ if back > 0.0 {
619
+ cutoff = cutoff.min(18_000.0 - 13_500.0 * back);
620
+ let dip = 1.0 - 0.15 * back;
621
+ gl *= dip;
622
+ gr *= dip;
623
+ }
624
+ }
625
+ (gl, gr, dist)
626
+ } else {
627
+ (1.0, 1.0, -1.0)
628
+ };
629
+
630
+ // ---- doppler ---------------------------------------------------
631
+ // From the block-to-block distance delta, so listener motion and
632
+ // source motion both count. Impossible speeds are teleports
633
+ // (re-targeted pool voices) — snap, don't chirp.
634
+ if dist >= 0.0 {
635
+ if v.prev_dist >= 0.0 {
636
+ let vr = (dist - v.prev_dist) / block_dt.max(1e-4);
637
+ if vr.abs() > DOPPLER_TELEPORT {
638
+ v.doppler = 1.0;
639
+ } else {
640
+ let target = (SOUND_SPEED / (SOUND_SPEED + vr)).clamp(0.5, 2.0);
641
+ v.doppler += (target - v.doppler) * 0.25;
642
+ }
643
+ }
644
+ v.prev_dist = dist;
645
+ }
646
+ let step = (v.pitch * v.doppler) as f64
647
+ * (v.data.sample_rate.max(8_000) as f64 / sample_rate as f64);
648
+
649
+ // ---- combined gain targets, ramped across the block ------------
650
+ let bg = bus_gain[(v.bus as usize).min(bus::COUNT - 1)];
651
+ let (t_l, t_r) = if v.stopping {
652
+ (0.0, 0.0)
653
+ } else {
654
+ (sg_l * v.volume * master * bg, sg_r * v.volume * master * bg)
655
+ };
656
+ if !v.seeded {
657
+ v.g_l = t_l;
658
+ v.g_r = t_r;
659
+ v.seeded = true;
660
+ }
661
+
662
+ // Fully silent (culled by distance, or a zero-volume ambient bed):
663
+ // advance the head arithmetically and skip the per-sample work.
664
+ if t_l < 1e-5 && t_r < 1e-5 && v.g_l < 1e-5 && v.g_r < 1e-5 {
665
+ if v.stopping { return false; }
666
+ v.g_l = t_l;
667
+ v.g_r = t_r;
668
+ v.frame_pos += step * out_frames as f64;
669
+ if v.frame_pos >= frames as f64 {
670
+ if v.looping {
671
+ v.frame_pos %= frames as f64;
672
+ } else {
673
+ return false;
674
+ }
675
+ }
676
+ return true;
677
+ }
678
+
679
+ let inv = 1.0 / (out_frames as f32).max(1.0);
680
+ let dg_l = (t_l - v.g_l) * inv;
681
+ let dg_r = (t_r - v.g_r) * inv;
682
+
683
+ // One-pole low-pass coefficient: occlusion, air and rear cues all
684
+ // fold into one cutoff. Muffling reads as geometry/distance in a
685
+ // way that turning the volume down never does.
686
+ let lp_a = if cutoff < LP_ENGAGE_HZ.min(sample_rate * 0.45) {
687
+ Some((-2.0 * std::f32::consts::PI * cutoff / sample_rate).exp())
688
+ } else {
689
+ None
690
+ };
691
+ let send = v.send;
692
+
693
+ let mut ended = false;
694
+ let mut f = 0usize;
695
+ while f < out_frames {
696
+ let idx = v.frame_pos as usize;
697
+ if idx >= frames { ended = !v.looping; break; }
698
+ let frac = (v.frame_pos - idx as f64) as f32;
699
+ let (s0l, s0r) = v.frame(idx);
700
+ let nidx = if idx + 1 < frames { idx + 1 } else if v.looping { 0 } else { idx };
701
+ let (s1l, s1r) = v.frame(nidx);
702
+ let mut sl = s0l + (s1l - s0l) * frac;
703
+ let mut sr = s0r + (s1r - s0r) * frac;
704
+
705
+ if let Some(a) = lp_a {
706
+ v.lp_z[0] = sl * (1.0 - a) + v.lp_z[0] * a;
707
+ v.lp_z[1] = sr * (1.0 - a) + v.lp_z[1] * a;
708
+ sl = v.lp_z[0];
709
+ sr = v.lp_z[1];
710
+ }
711
+
712
+ let i = f * 2;
713
+ let ol = sl * v.g_l;
714
+ let or = sr * v.g_r;
715
+ output[i] += ol;
716
+ if i + 1 < output.len() { output[i + 1] += or; }
717
+
718
+ if reverb_active && send > 0.0 {
719
+ send_buf[i] += ol * send;
720
+ if i + 1 < output.len() { send_buf[i + 1] += or * send; }
721
+ }
722
+
723
+ v.g_l += dg_l;
724
+ v.g_r += dg_r;
725
+ v.frame_pos += step;
726
+ if v.frame_pos >= frames as f64 {
727
+ if v.looping {
728
+ v.frame_pos -= frames as f64;
729
+ } else {
730
+ ended = true;
731
+ break;
732
+ }
733
+ }
734
+ f += 1;
735
+ }
736
+ if !ended {
737
+ // Land exactly on the target — no float drift across blocks.
738
+ v.g_l = t_l;
739
+ v.g_r = t_r;
740
+ }
741
+ if v.stopping && v.g_l < 1e-4 && v.g_r < 1e-4 {
742
+ return false;
743
+ }
744
+ !ended
745
+ });
746
+
747
+ // Wet return. Processed after the dry voices so every send this block
748
+ // contributed is in the tail.
749
+ if reverb_active {
750
+ let wet = reverb_l.wet;
751
+ let mut i = 0;
752
+ while i + 1 < output.len() {
753
+ output[i] += reverb_l.process(send_buf[i]) * wet;
754
+ output[i + 1] += reverb_r.process(send_buf[i + 1]) * wet;
755
+ i += 2;
756
+ }
757
+ }
758
+
759
+ // Music — on its own bus, which is the whole point: "duck the music
760
+ // when the player takes a hit" is the single most-used mix move in the
761
+ // genre and it needs music to be separable from SFX.
762
+ let music_gain = bus_gain[bus::MUSIC as usize];
763
+ music.retain_mut(|m| {
764
+ let vol = m.volume * master * music_gain;
765
+ let mut i = 0;
766
+ let mut finished = false;
767
+ match &mut m.samples {
768
+ MusicSamples::Full { data, position } => {
769
+ while i < output.len() && *position < data.samples.len() {
770
+ if data.channels == 1 {
771
+ let sample = data.samples[*position] * vol;
772
+ output[i] += sample;
773
+ if i + 1 < output.len() {
774
+ output[i + 1] += sample;
775
+ }
776
+ *position += 1;
777
+ i += 2;
778
+ } else {
779
+ output[i] += data.samples[*position] * vol;
780
+ *position += 1;
781
+ i += 1;
782
+ }
783
+ }
784
+ if *position >= data.samples.len() {
785
+ if m.looping {
786
+ *position = 0;
787
+ } else {
788
+ finished = true;
789
+ }
790
+ }
791
+ m.consumed = *position;
792
+ }
793
+ #[cfg(not(target_arch = "wasm32"))]
794
+ MusicSamples::Stream { consumer, channels, current, offset, ended } => {
795
+ let mono = *channels == 1;
796
+ while i < output.len() {
797
+ if *offset >= current.len() {
798
+ // Refill from the decode worker's ring.
799
+ match consumer.rx.pop() {
800
+ Some(StreamMsg::Chunk(c)) => {
801
+ *current = c;
802
+ *offset = 0;
803
+ }
804
+ Some(StreamMsg::End) => {
805
+ *ended = true;
806
+ break;
807
+ }
808
+ // Underrun: worker is behind (cold start
809
+ // or scheduling hiccup) — emit silence for
810
+ // the rest of this callback, resume next.
811
+ None => break,
812
+ }
813
+ }
814
+ if mono {
815
+ let sample = current[*offset] * vol;
816
+ output[i] += sample;
817
+ if i + 1 < output.len() {
818
+ output[i + 1] += sample;
819
+ }
820
+ *offset += 1;
821
+ m.consumed += 1;
822
+ i += 2;
823
+ } else {
824
+ output[i] += current[*offset] * vol;
825
+ *offset += 1;
826
+ m.consumed += 1;
827
+ i += 1;
828
+ }
829
+ }
830
+ if *ended && *offset >= current.len() {
831
+ finished = true;
832
+ }
833
+ }
834
+ }
835
+ if finished {
836
+ m.shared.playing.store(false, Ordering::Relaxed);
837
+ m.shared.position.store(0, Ordering::Relaxed);
838
+ return false;
839
+ }
840
+ m.shared.position.store(m.consumed, Ordering::Relaxed);
841
+ true
842
+ });
843
+ }
844
+ }
845
+
846
+ #[cfg(test)]
847
+ mod tests {
848
+ use super::*;
849
+ use crate::audio::spsc;
850
+
851
+ fn dummy_sound() -> Arc<SoundData> {
852
+ Arc::new(SoundData { samples: vec![0.0; 16], sample_rate: 44_100, channels: 1 })
853
+ }
854
+
855
+ fn play(id: u64, volume: f32) -> Cmd {
856
+ Cmd::PlaySound {
857
+ sound_id: id, voice_id: id, data: dummy_sound(), volume,
858
+ spatial: None, looping: false,
859
+ ref_dist: 1.0, max_dist: 0.0, rolloff: 1.0, pitch: 1.0,
860
+ bus: bus::SFX, send: 0.0, lowpass: 0.0,
861
+ }
862
+ }
863
+
864
+ // RT-safety: the render thread must never grow `voices` past its
865
+ // preallocated capacity — a malloc on the audio thread is a glitch.
866
+ #[test]
867
+ fn voices_never_exceed_cap_or_reallocate() {
868
+ let (_tx, rx) = spsc::channel::<Cmd>(8);
869
+ let mut r = AudioRenderer::new(rx);
870
+ let cap0 = r.voices.capacity();
871
+ for i in 0..(MAX_VOICES as u64 * 4) {
872
+ r.apply(play(i, 0.5 + (i % 7) as f32 * 0.01));
873
+ assert!(r.voices.len() <= MAX_VOICES);
874
+ }
875
+ assert_eq!(r.voices.len(), MAX_VOICES);
876
+ assert_eq!(r.voices.capacity(), cap0, "voices reallocated on the audio thread");
877
+ }
878
+
879
+ // At the cap, a new play steals the QUIETEST voice, not an arbitrary one.
880
+ #[test]
881
+ fn voice_steal_drops_the_quietest() {
882
+ let (_tx, rx) = spsc::channel::<Cmd>(8);
883
+ let mut r = AudioRenderer::new(rx);
884
+ for i in 0..(MAX_VOICES as u64 - 1) { r.apply(play(i, 1.0)); }
885
+ r.apply(play(9999, 0.01)); // quiet marker, brings us to the cap
886
+ assert_eq!(r.voices.len(), MAX_VOICES);
887
+ r.apply(play(10_000, 1.0)); // must evict the quiet marker
888
+ assert!(!r.voices.iter().any(|v| v.sound_id == 9999),
889
+ "the quietest voice should have been stolen");
890
+ assert_eq!(r.voices.len(), MAX_VOICES);
891
+ }
892
+ }