@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,863 @@
1
+ //! Audio system: a control half (this type, called from the FFI/main
2
+ //! thread) and a render half ([`AudioRenderer`], owned by the platform's
3
+ //! audio thread), connected by a lock-free SPSC command ring.
4
+ //!
5
+ //! # Why the split
6
+ //!
7
+ //! The old `AudioMixer` was one struct mutated from two threads: the FFI
8
+ //! surface pushed/removed `PlayingSound`s on the main thread while the
9
+ //! platform audio callback iterated and mutated the same Vec — a data
10
+ //! race that corrupted voice state under load, and a use-after-free
11
+ //! whenever `load_sound` reallocated the registry mid-mix. Audio threads
12
+ //! also must never block: a mutex held across a frame hitch is an audible
13
+ //! glitch.
14
+ //!
15
+ //! # Threading contract
16
+ //!
17
+ //! - `AudioMixer` (this type) lives in `EngineState` — main thread only.
18
+ //! - The platform's audio-init code calls [`AudioMixer::take_renderer`]
19
+ //! once and moves the renderer into its audio thread/callback, which
20
+ //! then calls [`AudioRenderer::mix`] exclusively.
21
+ //! - Web (single-threaded: ScriptProcessorNode fires on the JS main
22
+ //! thread) never takes the renderer; [`AudioMixer::mix_output`] mixes
23
+ //! inline through the same command path.
24
+ //! - Sample data crosses as `Arc<SoundData>`; unloading is graceful —
25
+ //! live voices finish on the old data.
26
+ //! - Render → control feedback (is-music-playing, position) flows through
27
+ //! per-music atomics ([`MusicShared`]), never shared mutable state.
28
+
29
+ mod decode;
30
+ mod render;
31
+ mod spsc;
32
+ pub mod stream;
33
+
34
+ pub use decode::{decode_audio, parse_ogg, parse_wav};
35
+ #[cfg(feature = "mp3")]
36
+ pub use decode::parse_mp3;
37
+ pub use render::AudioRenderer;
38
+
39
+ use crate::handles::HandleRegistry;
40
+ use render::Cmd;
41
+ use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
42
+ use std::sync::Arc;
43
+
44
+ /// A loaded sound effect or music track (PCM samples).
45
+ pub struct SoundData {
46
+ pub samples: Vec<f32>,
47
+ pub sample_rate: u32,
48
+ pub channels: u16,
49
+ }
50
+
51
+ /// Render-side playback state readable from the control side.
52
+ pub struct MusicShared {
53
+ pub playing: AtomicBool,
54
+ pub position: AtomicUsize,
55
+ }
56
+
57
+ enum MusicSource {
58
+ /// Fully decoded PCM (WAV always; everything on wasm32).
59
+ Full(Arc<SoundData>),
60
+ /// Compressed bytes decoded by a background worker at play time.
61
+ #[cfg(not(target_arch = "wasm32"))]
62
+ Streamed {
63
+ kind: stream::StreamKind,
64
+ bytes: Arc<Vec<u8>>,
65
+ channels: u16,
66
+ },
67
+ }
68
+
69
+ struct MusicEntry {
70
+ source: MusicSource,
71
+ shared: Arc<MusicShared>,
72
+ volume: f32,
73
+ looping: bool,
74
+ }
75
+
76
+ /// Control half of the audio system. All methods are main-thread.
77
+ pub struct AudioMixer {
78
+ pub sounds: HandleRegistry<Arc<SoundData>>,
79
+ music: HandleRegistry<MusicEntry>,
80
+ /// Default volume per sound handle, applied to future plays.
81
+ sound_volumes: Vec<(f64, f32)>,
82
+ master_volume: f32,
83
+ /// EN-029 — per-sound routing: (bus, reverb send, low-pass cutoff Hz).
84
+ /// A property of the sound, not of each play call.
85
+ routes: std::collections::HashMap<u64, (u8, f32, f32)>,
86
+ /// EN-062 — monotonic voice-id allocator. Every play gets one; the id is
87
+ /// the handle for moving/stopping/re-pitching that one voice later.
88
+ next_voice: u64,
89
+ tx: spsc::Producer<Cmd>,
90
+ /// Present until the platform takes it for its audio thread; used for
91
+ /// inline mixing on single-threaded targets (web).
92
+ renderer: Option<AudioRenderer>,
93
+ }
94
+
95
+ /// Command-ring capacity. Live emitters (EN-062) stream per-frame position
96
+ /// and volume updates — a dozen tracked voices at 60 fps is ~25 commands per
97
+ /// frame, and boot routes sounds in bursts of hundreds. 1024 gives a hitch
98
+ /// two full callback intervals of headroom before anything drops.
99
+ const CMD_CAPACITY: usize = 1024;
100
+
101
+ impl Default for AudioMixer {
102
+ fn default() -> Self {
103
+ Self::new()
104
+ }
105
+ }
106
+
107
+ impl AudioMixer {
108
+ pub fn new() -> Self {
109
+ let (tx, rx) = spsc::channel(CMD_CAPACITY);
110
+ Self {
111
+ sounds: HandleRegistry::new(),
112
+ music: HandleRegistry::new(),
113
+ sound_volumes: Vec::new(),
114
+ master_volume: 1.0,
115
+ routes: std::collections::HashMap::new(),
116
+ next_voice: 0,
117
+ tx,
118
+ renderer: Some(AudioRenderer::new(rx)),
119
+ }
120
+ }
121
+
122
+ /// Hand the render half to the platform's audio thread. Call once
123
+ /// from audio init, before the callback starts firing; the returned
124
+ /// renderer must only be used from that one thread.
125
+ pub fn take_renderer(&mut self) -> Option<AudioRenderer> {
126
+ self.renderer.take()
127
+ }
128
+
129
+ fn send(&mut self, cmd: Cmd) {
130
+ // Whether or not the renderer has been handed off, commands go
131
+ // through the ring — the inline path (web) drains it on the next
132
+ // mix_output, the threaded path on the next audio callback.
133
+ if self.tx.push(cmd).is_err() {
134
+ // Ring full: drop the command rather than block. 256 pending
135
+ // commands between two audio callbacks means something is
136
+ // very wrong upstream — say so once.
137
+ crate::ffi::log_error(
138
+ "bloom: audio command ring full — command dropped (is the audio callback running?)",
139
+ );
140
+ }
141
+ }
142
+
143
+ // ----------------------------------------------------------- sounds
144
+
145
+ pub fn load_sound(&mut self, data: SoundData) -> f64 {
146
+ self.sounds.alloc(Arc::new(data))
147
+ }
148
+
149
+ /// Shared play path. Returns the new voice's id (0.0 = unknown sound).
150
+ /// `ref_dist`/`rolloff` of 1 with a huge `max_dist` is exactly the
151
+ /// pre-EN-062 1/d behaviour, which is what the plain play calls use.
152
+ fn send_play(
153
+ &mut self, handle: f64, spatial: Option<[f32; 3]>, looping: bool,
154
+ ref_dist: f32, max_dist: f32, rolloff: f32,
155
+ ) -> f64 {
156
+ let Some(data) = self.sounds.get(handle).cloned() else { return 0.0 };
157
+ let volume = self.get_sound_volume(handle);
158
+ let (bus, send, lowpass) = self.routing(handle);
159
+ self.next_voice += 1;
160
+ let voice_id = self.next_voice;
161
+ self.send(Cmd::PlaySound {
162
+ sound_id: handle.to_bits(),
163
+ voice_id,
164
+ data,
165
+ volume,
166
+ spatial,
167
+ looping,
168
+ ref_dist,
169
+ max_dist,
170
+ rolloff,
171
+ pitch: 1.0,
172
+ bus, send, lowpass,
173
+ });
174
+ voice_id as f64
175
+ }
176
+
177
+ pub fn play_sound(&mut self, handle: f64) {
178
+ self.send_play(handle, None, false, 1.0, 1.0e9, 1.0);
179
+ }
180
+
181
+ pub fn play_sound_3d(&mut self, handle: f64, x: f32, y: f32, z: f32) {
182
+ self.send_play(handle, Some([x, y, z]), false, 1.0, 1.0e9, 1.0);
183
+ }
184
+
185
+ // ---- EN-062: live emitters ------------------------------------------
186
+ //
187
+ // A voice you can hold onto. `play_sound_3d_ex` returns a voice id; the
188
+ // id drives position/volume/pitch/low-pass updates and a click-free stop.
189
+ // This is what looping ambient emitters (river, wind, a creature's crawl)
190
+ // are made of — fire-and-forget can't move and can't loop.
191
+
192
+ /// Play with full spatial control. `looping` voices persist until
193
+ /// [`Self::stop_voice`]. `ref_dist` is the range that plays at full
194
+ /// volume, `rolloff` how hard the level falls past it, `max_dist` where
195
+ /// the mixer culls entirely. Returns the voice id (0.0 = unknown sound).
196
+ pub fn play_sound_3d_ex(
197
+ &mut self, handle: f64, x: f32, y: f32, z: f32,
198
+ looping: bool, ref_dist: f32, max_dist: f32, rolloff: f32,
199
+ ) -> f64 {
200
+ self.send_play(
201
+ handle, Some([x, y, z]), looping,
202
+ ref_dist.max(1e-3),
203
+ if max_dist > 0.0 { max_dist } else { 1.0e9 },
204
+ rolloff.max(0.0),
205
+ )
206
+ }
207
+
208
+ pub fn set_voice_position(&mut self, voice: f64, x: f32, y: f32, z: f32) {
209
+ self.send(Cmd::SetVoicePosition { voice_id: voice as u64, pos: [x, y, z] });
210
+ }
211
+
212
+ /// Fades the voice out over one mix block (~10 ms) and removes it — a
213
+ /// hard cut mid-waveform on a looping bed is an audible click.
214
+ pub fn stop_voice(&mut self, voice: f64) {
215
+ self.send(Cmd::StopVoice { voice_id: voice as u64 });
216
+ }
217
+
218
+ pub fn set_voice_volume(&mut self, voice: f64, volume: f32) {
219
+ self.send(Cmd::SetVoiceVolume { voice_id: voice as u64, volume });
220
+ }
221
+
222
+ /// Playback-rate multiplier, clamped 0.25..4. Doppler multiplies on top.
223
+ pub fn set_voice_pitch(&mut self, voice: f64, pitch: f32) {
224
+ self.send(Cmd::SetVoicePitch { voice_id: voice as u64, pitch });
225
+ }
226
+
227
+ /// Per-voice occlusion low-pass (Hz; 0 = bypass). Unlike
228
+ /// [`Self::set_sound_lowpass`] this muffles ONE emitter, not every voice
229
+ /// sharing the asset.
230
+ pub fn set_voice_lowpass(&mut self, voice: f64, cutoff: f32) {
231
+ self.send(Cmd::SetVoiceLowpass { voice_id: voice as u64, cutoff });
232
+ }
233
+
234
+ // ---- EN-029 routing ------------------------------------------------
235
+ //
236
+ // Routing is a property of the *sound*, not of the individual play call:
237
+ // a footstep is always on the SFX bus, a menu blip is always UI. Setting
238
+ // it once at load keeps the per-shot call sites unchanged.
239
+
240
+ fn routing(&self, handle: f64) -> (u8, f32, f32) {
241
+ match self.routes.get(&handle.to_bits()) {
242
+ Some(r) => *r,
243
+ None => (render::bus::SFX, 0.0, 0.0),
244
+ }
245
+ }
246
+
247
+ /// Assign a sound to a mix bus (see `render::bus`).
248
+ pub fn set_sound_bus(&mut self, handle: f64, bus: u8) {
249
+ let e = self.routes.entry(handle.to_bits()).or_insert((render::bus::SFX, 0.0, 0.0));
250
+ e.0 = bus;
251
+ }
252
+
253
+ /// Reverb send for this sound, 0..1. This is what gives a gunshot its tail.
254
+ pub fn set_sound_reverb_send(&mut self, handle: f64, send: f32) {
255
+ let send = send.clamp(0.0, 1.0);
256
+ let e = self.routes.entry(handle.to_bits()).or_insert((render::bus::SFX, 0.0, 0.0));
257
+ e.1 = send;
258
+ // Also steer voices already in flight, so a zone change is audible on
259
+ // the tail that is sounding right now rather than only the next one.
260
+ self.send(Cmd::SetSoundSend { sound_id: handle.to_bits(), send });
261
+ }
262
+
263
+ /// Low-pass cutoff in Hz for this sound; 0 = bypass. The occlusion knob.
264
+ pub fn set_sound_lowpass(&mut self, handle: f64, cutoff: f32) {
265
+ let cutoff = cutoff.max(0.0);
266
+ let e = self.routes.entry(handle.to_bits()).or_insert((render::bus::SFX, 0.0, 0.0));
267
+ e.2 = cutoff;
268
+ self.send(Cmd::SetSoundLowpass { sound_id: handle.to_bits(), cutoff });
269
+ }
270
+
271
+ pub fn set_bus_gain(&mut self, bus: u8, gain: f32) {
272
+ self.send(Cmd::SetBusGain { bus, gain });
273
+ }
274
+
275
+ pub fn duck_bus(&mut self, bus: u8, amount: f32, attack: f32, release: f32, hold: f32) {
276
+ self.send(Cmd::DuckBus { bus, amount, attack, release, hold });
277
+ }
278
+
279
+ pub fn set_reverb(&mut self, size: f32, damp: f32, wet: f32) {
280
+ self.send(Cmd::SetReverbParams { size, damp, wet });
281
+ }
282
+
283
+ pub fn stop_sound(&mut self, handle: f64) {
284
+ self.send(Cmd::StopSound { sound_id: handle.to_bits() });
285
+ }
286
+
287
+ pub fn set_sound_volume(&mut self, handle: f64, volume: f32) {
288
+ for entry in &mut self.sound_volumes {
289
+ if entry.0 == handle {
290
+ entry.1 = volume;
291
+ self.send(Cmd::SetSoundVolume { sound_id: handle.to_bits(), volume });
292
+ return;
293
+ }
294
+ }
295
+ self.sound_volumes.push((handle, volume));
296
+ self.send(Cmd::SetSoundVolume { sound_id: handle.to_bits(), volume });
297
+ }
298
+
299
+ fn get_sound_volume(&self, handle: f64) -> f32 {
300
+ self.sound_volumes
301
+ .iter()
302
+ .find(|e| e.0 == handle)
303
+ .map(|e| e.1)
304
+ .unwrap_or(1.0)
305
+ }
306
+
307
+ pub fn unload_sound(&mut self, handle: f64) {
308
+ // Voices already playing hold their own Arc and finish gracefully.
309
+ self.sounds.free(handle);
310
+ self.sound_volumes.retain(|e| e.0 != handle);
311
+ }
312
+
313
+ // ------------------------------------------------------------ music
314
+
315
+ pub fn load_music(&mut self, data: SoundData) -> f64 {
316
+ self.alloc_music(MusicSource::Full(Arc::new(data)))
317
+ }
318
+
319
+ /// Load music from raw file bytes, streaming the decode when the
320
+ /// format supports it (OGG/MP3 on native — keeps ~5 MB of compressed
321
+ /// bytes resident instead of ~57 MB of PCM for a 5-minute track).
322
+ /// WAV — and everything on wasm32, which has no threads — falls back
323
+ /// to full decode. Returns 0 on undecodable data.
324
+ pub fn load_music_bytes(&mut self, path: &str, data: Vec<u8>) -> f64 {
325
+ #[cfg(not(target_arch = "wasm32"))]
326
+ {
327
+ let lower = path.to_ascii_lowercase();
328
+ let kind = if lower.ends_with(".ogg") {
329
+ Some(stream::StreamKind::Ogg)
330
+ } else {
331
+ #[cfg(feature = "mp3")]
332
+ if lower.ends_with(".mp3") {
333
+ Some(stream::StreamKind::Mp3)
334
+ } else {
335
+ None
336
+ }
337
+ #[cfg(not(feature = "mp3"))]
338
+ None
339
+ };
340
+ if let Some(kind) = kind {
341
+ if let Some((_rate, channels)) = stream::probe(kind, &data) {
342
+ return self.alloc_music(MusicSource::Streamed {
343
+ kind,
344
+ bytes: Arc::new(data),
345
+ channels,
346
+ });
347
+ }
348
+ // Mislabelled file — fall through to sniffing decode.
349
+ }
350
+ }
351
+ match decode_audio(path, &data) {
352
+ Some(s) => self.load_music(s),
353
+ None => 0.0,
354
+ }
355
+ }
356
+
357
+ fn alloc_music(&mut self, source: MusicSource) -> f64 {
358
+ self.music.alloc(MusicEntry {
359
+ source,
360
+ shared: Arc::new(MusicShared {
361
+ playing: AtomicBool::new(false),
362
+ position: AtomicUsize::new(0),
363
+ }),
364
+ volume: 1.0,
365
+ looping: true,
366
+ })
367
+ }
368
+
369
+ pub fn play_music(&mut self, handle: f64) {
370
+ let Some(m) = self.music.get(handle) else { return };
371
+ // Optimistically flip the flag so is_music_playing is true the
372
+ // moment play_music returns (the render thread confirms on its
373
+ // next callback).
374
+ m.shared.playing.store(true, Ordering::Relaxed);
375
+ let payload = match &m.source {
376
+ MusicSource::Full(data) => render::MusicPayload::Full(data.clone()),
377
+ #[cfg(not(target_arch = "wasm32"))]
378
+ MusicSource::Streamed { kind, bytes, channels } => render::MusicPayload::Stream {
379
+ consumer: stream::start(*kind, bytes.clone(), m.looping),
380
+ channels: *channels,
381
+ },
382
+ };
383
+ let cmd = Cmd::PlayMusic {
384
+ music_id: handle.to_bits(),
385
+ payload,
386
+ shared: m.shared.clone(),
387
+ volume: m.volume,
388
+ looping: m.looping,
389
+ };
390
+ self.send(cmd);
391
+ }
392
+
393
+ pub fn stop_music(&mut self, handle: f64) {
394
+ if let Some(m) = self.music.get(handle) {
395
+ m.shared.playing.store(false, Ordering::Relaxed);
396
+ }
397
+ self.send(Cmd::StopMusic { music_id: handle.to_bits() });
398
+ }
399
+
400
+ pub fn set_music_volume(&mut self, handle: f64, volume: f32) {
401
+ if let Some(m) = self.music.get_mut(handle) {
402
+ m.volume = volume;
403
+ }
404
+ self.send(Cmd::SetMusicVolume { music_id: handle.to_bits(), volume });
405
+ }
406
+
407
+ pub fn is_music_playing(&self, handle: f64) -> bool {
408
+ self.music
409
+ .get(handle)
410
+ .map(|m| m.shared.playing.load(Ordering::Relaxed))
411
+ .unwrap_or(false)
412
+ }
413
+
414
+ pub fn update_music_stream(&mut self, _handle: f64) {
415
+ // No-op: tracks are fully decoded today. Exists for raylib API
416
+ // compatibility; becomes meaningful with streamed decode.
417
+ }
418
+
419
+ // ----------------------------------------------------------- global
420
+
421
+ pub fn set_master_volume(&mut self, volume: f32) {
422
+ self.master_volume = volume;
423
+ self.send(Cmd::SetMaster(volume));
424
+ }
425
+
426
+ pub fn master_volume(&self) -> f32 {
427
+ self.master_volume
428
+ }
429
+
430
+ pub fn set_listener_position(&mut self, x: f32, y: f32, z: f32, fx: f32, fy: f32, fz: f32) {
431
+ let len = (fx * fx + fy * fy + fz * fz).sqrt();
432
+ let forward = if len > 0.0 {
433
+ [fx / len, fy / len, fz / len]
434
+ } else {
435
+ [0.0, 0.0, -1.0]
436
+ };
437
+ self.send(Cmd::SetListener { pos: [x, y, z], forward });
438
+ }
439
+
440
+ /// Inline mixing for single-threaded targets (web) and tests: mixes
441
+ /// through the internal renderer if it has not been taken by a
442
+ /// platform audio thread.
443
+ pub fn mix_output(&mut self, output: &mut [f32]) {
444
+ match self.renderer.as_mut() {
445
+ Some(r) => r.mix(output),
446
+ None => {
447
+ // A platform audio thread owns the renderer; mixing here
448
+ // would be the exact data race this design removes.
449
+ output.iter_mut().for_each(|s| *s = 0.0);
450
+ }
451
+ }
452
+ }
453
+ }
454
+
455
+ #[cfg(test)]
456
+ mod tests {
457
+ use super::*;
458
+
459
+ fn tone(len: usize) -> SoundData {
460
+ SoundData {
461
+ samples: (0..len).map(|i| if i % 2 == 0 { 0.5 } else { -0.5 }).collect(),
462
+ sample_rate: 44_100,
463
+ channels: 1,
464
+ }
465
+ }
466
+
467
+ #[test]
468
+ fn inline_play_and_mix() {
469
+ let mut a = AudioMixer::new();
470
+ let h = a.load_sound(tone(64));
471
+ a.play_sound(h);
472
+ let mut out = [0.0f32; 32];
473
+ a.mix_output(&mut out);
474
+ assert!(out.iter().any(|&s| s != 0.0), "voice produced no output");
475
+ }
476
+
477
+ // ---- EN-029 -------------------------------------------------------
478
+
479
+ fn peak(buf: &[f32]) -> f32 {
480
+ buf.iter().fold(0.0f32, |m, s| m.max(s.abs()))
481
+ }
482
+
483
+ #[test]
484
+ fn bus_gain_scales_only_its_own_bus() {
485
+ let mut a = AudioMixer::new();
486
+ let h = a.load_sound(tone(4096));
487
+ a.play_sound(h);
488
+ let mut loud = [0.0f32; 256];
489
+ a.mix_output(&mut loud);
490
+
491
+ let mut b = AudioMixer::new();
492
+ let h2 = b.load_sound(tone(4096));
493
+ b.set_bus_gain(render::bus::SFX, 0.25);
494
+ b.play_sound(h2);
495
+ let mut quiet = [0.0f32; 256];
496
+ b.mix_output(&mut quiet);
497
+
498
+ assert!(peak(&quiet) < peak(&loud) * 0.5,
499
+ "SFX bus gain did not attenuate: {} vs {}", peak(&quiet), peak(&loud));
500
+
501
+ // A sound on a *different* bus must be untouched by that gain.
502
+ let mut c = AudioMixer::new();
503
+ let h3 = c.load_sound(tone(4096));
504
+ c.set_sound_bus(h3, render::bus::UI);
505
+ c.set_bus_gain(render::bus::SFX, 0.0);
506
+ c.play_sound(h3);
507
+ let mut ui = [0.0f32; 256];
508
+ c.mix_output(&mut ui);
509
+ assert!(peak(&ui) > 0.1, "muting SFX also muted the UI bus");
510
+ }
511
+
512
+ #[test]
513
+ fn duck_pulls_the_bus_down_then_recovers() {
514
+ let mut a = AudioMixer::new();
515
+ let h = a.load_sound(tone(1 << 16));
516
+ a.play_sound(h);
517
+ let mut out = [0.0f32; 512];
518
+ a.mix_output(&mut out);
519
+ let dry = peak(&out);
520
+
521
+ // Duck hard, effectively instantly, and hold well past the block.
522
+ // EN-062 — per-voice gains ramp linearly across one mix block (the
523
+ // anti-zipper contract), so a gain change fully lands on the block
524
+ // AFTER the one it arrives in. Measure the settled block.
525
+ a.duck_bus(render::bus::SFX, 0.9, 0.0001, 0.5, 1.0);
526
+ let mut ducked = [0.0f32; 512];
527
+ a.mix_output(&mut ducked);
528
+ ducked = [0.0f32; 512];
529
+ a.mix_output(&mut ducked);
530
+ assert!(peak(&ducked) < dry * 0.5,
531
+ "duck had no effect: {} vs {}", peak(&ducked), dry);
532
+ }
533
+
534
+ #[test]
535
+ fn lowpass_attenuates_a_nyquist_tone() {
536
+ // tone() alternates +0.5/-0.5 every sample: that is exactly Nyquist,
537
+ // the highest frequency representable. A low cutoff must crush it.
538
+ let mut a = AudioMixer::new();
539
+ let h = a.load_sound(tone(4096));
540
+ a.set_sound_lowpass(h, 200.0);
541
+ a.play_sound(h);
542
+ let mut out = [0.0f32; 512];
543
+ a.mix_output(&mut out);
544
+ assert!(peak(&out) < 0.1,
545
+ "low-pass did not attenuate a Nyquist tone: peak {}", peak(&out));
546
+ }
547
+
548
+ /// Mix `blocks` × 256-sample blocks, returning the peak seen *after* the
549
+ /// first block. The shortest comb delay is 1116 samples (~25 ms), so a
550
+ /// reverb tail cannot appear inside one short block — you have to run the
551
+ /// mixer past the delay length before asking whether it rang.
552
+ fn peak_after_first_block(a: &mut AudioMixer, blocks: usize) -> f32 {
553
+ let mut first = [0.0f32; 256];
554
+ a.mix_output(&mut first);
555
+ let mut p = 0.0f32;
556
+ for _ in 1..blocks {
557
+ let mut out = [0.0f32; 256];
558
+ a.mix_output(&mut out);
559
+ p = p.max(peak(&out));
560
+ }
561
+ p
562
+ }
563
+
564
+ #[test]
565
+ fn reverb_rings_after_the_source_stops() {
566
+ let mut a = AudioMixer::new();
567
+ // A short click, fully sent to a long, bright reverb.
568
+ let h = a.load_sound(tone(8));
569
+ a.set_reverb(0.9, 0.1, 1.0);
570
+ a.set_sound_reverb_send(h, 1.0);
571
+ a.play_sound(h);
572
+ // 40 blocks × 128 frames = 5120 frames, comfortably past the 1356-sample
573
+ // longest comb.
574
+ let tail = peak_after_first_block(&mut a, 40);
575
+ assert!(tail > 0.0, "reverb produced no tail after the source ended");
576
+ }
577
+
578
+ #[test]
579
+ fn reverb_is_bypassed_when_wet_is_zero() {
580
+ let mut a = AudioMixer::new();
581
+ let h = a.load_sound(tone(8));
582
+ a.set_sound_reverb_send(h, 1.0); // sending, but nothing returns
583
+ a.play_sound(h);
584
+ let tail = peak_after_first_block(&mut a, 40);
585
+ assert_eq!(tail, 0.0, "wet=0 must cost nothing and return nothing");
586
+ }
587
+
588
+ #[test]
589
+ fn music_playing_flag_round_trip() {
590
+ let mut a = AudioMixer::new();
591
+ let h = a.load_music(tone(16)); // tiny, non-looping after we set it
592
+ // default looping=true → flip via the public surface used by FFI
593
+ a.music.get_mut(h).unwrap().looping = false;
594
+ assert!(!a.is_music_playing(h));
595
+ a.play_music(h);
596
+ assert!(a.is_music_playing(h), "flag not set synchronously");
597
+ // Drain the whole track: 16 mono samples → 32 stereo out slots
598
+ let mut out = [0.0f32; 64];
599
+ a.mix_output(&mut out);
600
+ assert!(!a.is_music_playing(h), "non-looping track did not end");
601
+ }
602
+
603
+ #[test]
604
+ fn renderer_handoff_mixes_on_other_thread() {
605
+ let mut a = AudioMixer::new();
606
+ let h = a.load_sound(tone(1024));
607
+ let mut r = a.take_renderer().expect("renderer already taken");
608
+ a.play_sound(h);
609
+ a.set_master_volume(0.5);
610
+ let worker = std::thread::spawn(move || {
611
+ let mut out = vec![0.0f32; 256];
612
+ r.mix(&mut out);
613
+ out
614
+ });
615
+ let out = worker.join().unwrap();
616
+ assert!(out.iter().any(|&s| s != 0.0), "handed-off renderer produced no output");
617
+ // Control side mixing is now inert (no double-mixing race).
618
+ let mut silent = [1.0f32; 8];
619
+ a.mix_output(&mut silent);
620
+ assert!(silent.iter().all(|&s| s == 0.0));
621
+ }
622
+
623
+ #[test]
624
+ fn unload_mid_playback_is_graceful() {
625
+ let mut a = AudioMixer::new();
626
+ let h = a.load_sound(tone(64));
627
+ a.play_sound(h);
628
+ a.unload_sound(h);
629
+ let mut out = [0.0f32; 32];
630
+ a.mix_output(&mut out); // voice still holds its Arc
631
+ assert!(out.iter().any(|&s| s != 0.0));
632
+ }
633
+
634
+ // ---- EN-062: spatial voices ----------------------------------------
635
+
636
+ /// All-0.5 mono signal: peaks read gains directly.
637
+ fn flat(len: usize) -> SoundData {
638
+ SoundData { samples: vec![0.5; len], sample_rate: 44_100, channels: 1 }
639
+ }
640
+
641
+ fn sine(freq: f32, len: usize) -> SoundData {
642
+ SoundData {
643
+ samples: (0..len)
644
+ .map(|i| (2.0 * std::f32::consts::PI * freq * i as f32 / 44_100.0).sin())
645
+ .collect(),
646
+ sample_rate: 44_100,
647
+ channels: 1,
648
+ }
649
+ }
650
+
651
+ fn peak_lr(buf: &[f32]) -> (f32, f32) {
652
+ let mut l = 0.0f32;
653
+ let mut r = 0.0f32;
654
+ let mut i = 0;
655
+ while i + 1 < buf.len() {
656
+ l = l.max(buf[i].abs());
657
+ r = r.max(buf[i + 1].abs());
658
+ i += 2;
659
+ }
660
+ (l, r)
661
+ }
662
+
663
+ #[test]
664
+ fn looping_voice_persists_until_stop_voice() {
665
+ let mut a = AudioMixer::new();
666
+ let h = a.load_sound(flat(64)); // 64 frames — far shorter than a block
667
+ // Listener at origin looking down -Z; source dead ahead at 1 m.
668
+ let v = a.play_sound_3d_ex(h, 0.0, 0.0, -1.0, true, 1.0, 0.0, 1.0);
669
+ assert!(v > 0.0, "no voice id returned");
670
+ let mut out = [0.0f32; 512];
671
+ for _ in 0..10 {
672
+ out = [0.0f32; 512];
673
+ a.mix_output(&mut out);
674
+ }
675
+ assert!(peak(&out) > 0.1, "looping voice died before StopVoice");
676
+ a.stop_voice(v);
677
+ // Fade block, then confirmed-silent blocks.
678
+ for _ in 0..3 {
679
+ out = [0.0f32; 512];
680
+ a.mix_output(&mut out);
681
+ }
682
+ assert_eq!(peak(&out), 0.0, "voice still audible after StopVoice");
683
+ }
684
+
685
+ #[test]
686
+ fn moving_a_voice_crosses_the_stereo_field() {
687
+ let mut a = AudioMixer::new();
688
+ let h = a.load_sound(flat(1 << 16));
689
+ // Screen-left for a -Z-forward listener is -X (right = cross(f, up) = +X).
690
+ let v = a.play_sound_3d_ex(h, -10.0, 0.0, 0.0, true, 1.0, 0.0, 1.0);
691
+ let mut out = [0.0f32; 512];
692
+ a.mix_output(&mut out);
693
+ let (l1, r1) = peak_lr(&out);
694
+ assert!(l1 > r1 * 2.0, "left source not left-dominant: L={} R={}", l1, r1);
695
+
696
+ a.set_voice_position(v, 10.0, 0.0, 0.0);
697
+ // One block to ramp, one to settle.
698
+ for _ in 0..2 {
699
+ out = [0.0f32; 512];
700
+ a.mix_output(&mut out);
701
+ }
702
+ let (l2, r2) = peak_lr(&out);
703
+ assert!(r2 > l2 * 2.0, "moved source not right-dominant: L={} R={}", l2, r2);
704
+ }
705
+
706
+ #[test]
707
+ fn default_distance_model_is_still_one_over_d() {
708
+ // play_sound_3d (the pre-EN-062 API) must keep its loudness curve.
709
+ let run = |dist: f32| -> f32 {
710
+ let mut a = AudioMixer::new();
711
+ let h = a.load_sound(flat(1 << 16));
712
+ a.play_sound_3d(h, 0.0, 0.0, -dist);
713
+ let mut out = [0.0f32; 512];
714
+ a.mix_output(&mut out);
715
+ let (l, r) = peak_lr(&out);
716
+ (l * l + r * r).sqrt() // combined energy — pan-independent
717
+ };
718
+ let near = run(1.0);
719
+ let far = run(10.0);
720
+ let ratio = far / near;
721
+ assert!((ratio - 0.1).abs() < 0.02,
722
+ "distance curve changed: 10m/1m = {} (want ~0.1)", ratio);
723
+ }
724
+
725
+ #[test]
726
+ fn centered_pan_is_equal_power() {
727
+ let mut a = AudioMixer::new();
728
+ let h = a.load_sound(flat(1 << 16));
729
+ a.play_sound_3d(h, 0.0, 0.0, -1.0); // dead ahead, 1 m → att 1
730
+ let mut out = [0.0f32; 512];
731
+ a.mix_output(&mut out);
732
+ let (l, r) = peak_lr(&out);
733
+ // 0.5 sample × cos(π/4) ≈ 0.3536 per channel (linear pan gave 0.25).
734
+ assert!((l - r).abs() < 0.01, "center source unbalanced: L={} R={}", l, r);
735
+ assert!(l > 0.32 && l < 0.39, "not equal-power: L={} (want ~0.354)", l);
736
+ }
737
+
738
+ #[test]
739
+ fn air_absorption_dulls_with_distance() {
740
+ // Nyquist tone: any low-pass crushes it. Normalise by the distance
741
+ // gain so only the FILTER is compared.
742
+ let run = |dist: f32| -> f32 {
743
+ let mut a = AudioMixer::new();
744
+ let h = a.load_sound(tone(1 << 16));
745
+ a.play_sound_3d(h, 0.0, 0.0, -dist);
746
+ let mut out = [0.0f32; 2048];
747
+ a.mix_output(&mut out);
748
+ let att = 1.0 / dist;
749
+ peak(&out) / att
750
+ };
751
+ let near = run(2.0);
752
+ let far = run(150.0);
753
+ assert!(far < near * 0.7,
754
+ "no air absorption: near(norm)={} far(norm)={}", near, far);
755
+ }
756
+
757
+ #[test]
758
+ fn rear_sources_are_darker_and_dipped() {
759
+ let run = |z: f32| -> f32 {
760
+ let mut a = AudioMixer::new();
761
+ let h = a.load_sound(tone(1 << 16));
762
+ a.play_sound_3d(h, 0.0, 0.0, z); // forward is -Z: -5 ahead, +5 behind
763
+ let mut out = [0.0f32; 2048];
764
+ a.mix_output(&mut out);
765
+ peak(&out)
766
+ };
767
+ let front = run(-5.0);
768
+ let behind = run(5.0);
769
+ assert!(behind < front * 0.6,
770
+ "rear cue missing: front={} behind={}", front, behind);
771
+ }
772
+
773
+ #[test]
774
+ fn voice_pitch_scales_playback_rate() {
775
+ // A 1000-frame one-shot at pitch 2 must be finished by ~500 output
776
+ // frames; at pitch 1 it must still be sounding there.
777
+ let run = |pitch: f32| -> f32 {
778
+ let mut a = AudioMixer::new();
779
+ let h = a.load_sound(flat(1000));
780
+ let v = a.play_sound_3d_ex(h, 0.0, 0.0, -1.0, false, 1.0, 0.0, 1.0);
781
+ a.set_voice_pitch(v, pitch);
782
+ let mut first = [0.0f32; 1024]; // frames 0..512
783
+ a.mix_output(&mut first);
784
+ let mut second = [0.0f32; 1024]; // frames 512..1024
785
+ a.mix_output(&mut second);
786
+ peak(&second)
787
+ };
788
+ assert!(run(1.0) > 0.1, "pitch-1 voice ended early");
789
+ assert_eq!(run(2.0), 0.0, "pitch-2 voice still sounding past its data");
790
+ }
791
+
792
+ #[test]
793
+ fn doppler_raises_the_pitch_of_an_approaching_source() {
794
+ let crossings = |approach: bool| -> usize {
795
+ let mut a = AudioMixer::new();
796
+ let h = a.load_sound(sine(500.0, 1 << 17));
797
+ let mut z = -60.0f32;
798
+ let v = a.play_sound_3d_ex(h, 0.0, 0.0, z, true, 1.0, 0.0, 1.0);
799
+ let mut n = 0usize;
800
+ let mut last = 0.0f32;
801
+ for block in 0..40 {
802
+ if approach {
803
+ // 0.25 m per 256-frame block ≈ 43 m/s toward the listener.
804
+ z += 0.25;
805
+ a.set_voice_position(v, 0.0, 0.0, z);
806
+ }
807
+ let mut out = [0.0f32; 512];
808
+ a.mix_output(&mut out);
809
+ if block < 10 { continue; } // let the smoothed rate settle
810
+ let mut i = 0;
811
+ while i < out.len() {
812
+ let s = out[i]; // left channel
813
+ if s != 0.0 {
814
+ if last != 0.0 && (s > 0.0) != (last > 0.0) { n += 1; }
815
+ last = s;
816
+ }
817
+ i += 2;
818
+ }
819
+ }
820
+ n
821
+ };
822
+ let moving = crossings(true);
823
+ let still = crossings(false);
824
+ assert!(moving as f32 > still as f32 * 1.05,
825
+ "no doppler: approaching={} static={}", moving, still);
826
+ }
827
+
828
+ #[test]
829
+ fn max_dist_culls_but_the_loop_survives() {
830
+ let mut a = AudioMixer::new();
831
+ let h = a.load_sound(flat(1 << 12));
832
+ let v = a.play_sound_3d_ex(h, 0.0, 0.0, -500.0, true, 1.0, 100.0, 1.0);
833
+ let mut out = [0.0f32; 512];
834
+ a.mix_output(&mut out);
835
+ assert_eq!(peak(&out), 0.0, "voice audible past max_dist");
836
+ // Walk into range: the same voice comes back.
837
+ a.set_voice_position(v, 0.0, 0.0, -5.0);
838
+ for _ in 0..2 {
839
+ out = [0.0f32; 512];
840
+ a.mix_output(&mut out);
841
+ }
842
+ assert!(peak(&out) > 0.01, "culled loop never came back in range");
843
+ }
844
+
845
+ #[test]
846
+ fn per_voice_lowpass_muffles_one_emitter_only() {
847
+ let mut a = AudioMixer::new();
848
+ let h = a.load_sound(tone(1 << 16));
849
+ let muffled = a.play_sound_3d_ex(h, -2.0, 0.0, -2.0, true, 1.0, 0.0, 1.0);
850
+ a.set_voice_lowpass(muffled, 300.0);
851
+ let mut solo = [0.0f32; 2048];
852
+ a.mix_output(&mut solo);
853
+ let muffled_peak = peak(&solo);
854
+
855
+ let mut b = AudioMixer::new();
856
+ let h2 = b.load_sound(tone(1 << 16));
857
+ b.play_sound_3d_ex(h2, -2.0, 0.0, -2.0, true, 1.0, 0.0, 1.0);
858
+ let mut open = [0.0f32; 2048];
859
+ b.mix_output(&mut open);
860
+ assert!(muffled_peak < peak(&open) * 0.3,
861
+ "voice lowpass had no effect: {} vs {}", muffled_peak, peak(&open));
862
+ }
863
+ }