@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.
- package/LICENSE +21 -0
- package/README.md +231 -0
- package/native/android/Cargo.lock +1848 -0
- package/native/android/Cargo.toml +24 -0
- package/native/android/src/lib.rs +702 -0
- package/native/ios/Cargo.lock +1690 -0
- package/native/ios/Cargo.toml +32 -0
- package/native/ios/src/lib.rs +1267 -0
- package/native/linux/Cargo.lock +3279 -0
- package/native/linux/Cargo.toml +29 -0
- package/native/linux/src/lib.rs +1331 -0
- package/native/macos/Cargo.lock +3310 -0
- package/native/macos/Cargo.toml +46 -0
- package/native/macos/src/lib.rs +1302 -0
- package/native/shared/Cargo.lock +1899 -0
- package/native/shared/Cargo.toml +62 -0
- package/native/shared/assets/default_font.ttf +0 -0
- package/native/shared/build.rs +270 -0
- package/native/shared/shaders/common/clouds.wgsl +122 -0
- package/native/shared/shaders/common/fog.wgsl +16 -0
- package/native/shared/shaders/common/foliage_wind.wgsl +98 -0
- package/native/shared/shaders/common/imposter.wgsl +112 -0
- package/native/shared/shaders/common/pbr.wgsl +186 -0
- package/native/shared/shaders/common/shadows.wgsl +186 -0
- package/native/shared/shaders/common/sky.wgsl +8 -0
- package/native/shared/shaders/common/tonemap.wgsl +25 -0
- package/native/shared/shaders/impulse_field.wgsl +57 -0
- package/native/shared/shaders/material_abi.wgsl +383 -0
- package/native/shared/shaders/materials/test_minimal.wgsl +42 -0
- package/native/shared/src/anim_mixer.rs +61 -0
- package/native/shared/src/attach.rs +263 -0
- package/native/shared/src/audio/decode.rs +123 -0
- package/native/shared/src/audio/mod.rs +863 -0
- package/native/shared/src/audio/render.rs +892 -0
- package/native/shared/src/audio/spsc.rs +156 -0
- package/native/shared/src/audio/stream.rs +226 -0
- package/native/shared/src/custom_shaders.rs +104 -0
- package/native/shared/src/decals.rs +245 -0
- package/native/shared/src/drs.rs +211 -0
- package/native/shared/src/engine.rs +261 -0
- package/native/shared/src/ffi.rs +116 -0
- package/native/shared/src/ffi_core/assets.rs +388 -0
- package/native/shared/src/ffi_core/audio_ffi.rs +184 -0
- package/native/shared/src/ffi_core/draw.rs +334 -0
- package/native/shared/src/ffi_core/game_loop.rs +577 -0
- package/native/shared/src/ffi_core/input.rs +234 -0
- package/native/shared/src/ffi_core/mod.rs +127 -0
- package/native/shared/src/ffi_core/models.rs +1154 -0
- package/native/shared/src/ffi_core/ragdoll_ffi.rs +261 -0
- package/native/shared/src/ffi_core/scene.rs +626 -0
- package/native/shared/src/ffi_core/vfx.rs +212 -0
- package/native/shared/src/ffi_core/visual.rs +691 -0
- package/native/shared/src/frame_callbacks.rs +122 -0
- package/native/shared/src/geometry.rs +236 -0
- package/native/shared/src/handles.rs +182 -0
- package/native/shared/src/input.rs +448 -0
- package/native/shared/src/jolt_sys.rs +822 -0
- package/native/shared/src/lib.rs +55 -0
- package/native/shared/src/models.rs +1093 -0
- package/native/shared/src/models_gltf.rs +1280 -0
- package/native/shared/src/particles.rs +391 -0
- package/native/shared/src/physics_jolt.rs +1908 -0
- package/native/shared/src/picking.rs +298 -0
- package/native/shared/src/postfx.rs +345 -0
- package/native/shared/src/profiler.rs +492 -0
- package/native/shared/src/ragdoll.rs +474 -0
- package/native/shared/src/renderer/atmosphere_lut.rs +573 -0
- package/native/shared/src/renderer/brdf_lut.rs +154 -0
- package/native/shared/src/renderer/draw2d.rs +143 -0
- package/native/shared/src/renderer/formats.rs +822 -0
- package/native/shared/src/renderer/froxel.rs +421 -0
- package/native/shared/src/renderer/gi_bake.rs +653 -0
- package/native/shared/src/renderer/graph.rs +462 -0
- package/native/shared/src/renderer/hiz.rs +269 -0
- package/native/shared/src/renderer/hot_reload.rs +390 -0
- package/native/shared/src/renderer/impulse_field.rs +456 -0
- package/native/shared/src/renderer/lighting.rs +154 -0
- package/native/shared/src/renderer/material_instancing.rs +171 -0
- package/native/shared/src/renderer/material_pipeline.rs +700 -0
- package/native/shared/src/renderer/material_system.rs +1996 -0
- package/native/shared/src/renderer/material_system_tests.rs +601 -0
- package/native/shared/src/renderer/material_system_wasm.rs +41 -0
- package/native/shared/src/renderer/mod.rs +12556 -0
- package/native/shared/src/renderer/model_draw.rs +641 -0
- package/native/shared/src/renderer/occlusion.rs +429 -0
- package/native/shared/src/renderer/planar_pass.rs +593 -0
- package/native/shared/src/renderer/planar_reflection.rs +499 -0
- package/native/shared/src/renderer/post_pass.rs +249 -0
- package/native/shared/src/renderer/postfx_chain.rs +728 -0
- package/native/shared/src/renderer/pt_pass.rs +577 -0
- package/native/shared/src/renderer/scene_pass.rs +607 -0
- package/native/shared/src/renderer/shader_include.rs +205 -0
- package/native/shared/src/renderer/shader_library.rs +135 -0
- package/native/shared/src/renderer/shaders/ao.rs +570 -0
- package/native/shared/src/renderer/shaders/core.rs +1243 -0
- package/native/shared/src/renderer/shaders/env.rs +907 -0
- package/native/shared/src/renderer/shaders/gi.rs +810 -0
- package/native/shared/src/renderer/shaders/mod.rs +19 -0
- package/native/shared/src/renderer/shaders/post.rs +1558 -0
- package/native/shared/src/renderer/shaders/pt.rs +1859 -0
- package/native/shared/src/renderer/shaders/ssgi.rs +1586 -0
- package/native/shared/src/renderer/shadow_pass.rs +731 -0
- package/native/shared/src/renderer/ssgi_pass.rs +392 -0
- package/native/shared/src/renderer/ssr_pass.rs +188 -0
- package/native/shared/src/renderer/texture_store.rs +473 -0
- package/native/shared/src/renderer/transient.rs +591 -0
- package/native/shared/src/renderer/types.rs +941 -0
- package/native/shared/src/renderer/util.rs +152 -0
- package/native/shared/src/scene.rs +1362 -0
- package/native/shared/src/sdf_cache.rs +274 -0
- package/native/shared/src/shadows.rs +1036 -0
- package/native/shared/src/staging.rs +102 -0
- package/native/shared/src/string_header.rs +266 -0
- package/native/shared/src/text_renderer.rs +502 -0
- package/native/shared/src/textures.rs +197 -0
- package/native/tvos/Cargo.lock +1693 -0
- package/native/tvos/Cargo.toml +36 -0
- package/native/tvos/metal-patched/Cargo.toml +178 -0
- package/native/tvos/metal-patched/LICENSE-APACHE +201 -0
- package/native/tvos/metal-patched/LICENSE-MIT +25 -0
- package/native/tvos/metal-patched/src/acceleration_structure.rs +667 -0
- package/native/tvos/metal-patched/src/acceleration_structure_pass.rs +108 -0
- package/native/tvos/metal-patched/src/argument.rs +366 -0
- package/native/tvos/metal-patched/src/blitpass.rs +102 -0
- package/native/tvos/metal-patched/src/buffer.rs +71 -0
- package/native/tvos/metal-patched/src/capturedescriptor.rs +76 -0
- package/native/tvos/metal-patched/src/capturemanager.rs +113 -0
- package/native/tvos/metal-patched/src/commandbuffer.rs +192 -0
- package/native/tvos/metal-patched/src/commandqueue.rs +44 -0
- package/native/tvos/metal-patched/src/computepass.rs +107 -0
- package/native/tvos/metal-patched/src/constants.rs +152 -0
- package/native/tvos/metal-patched/src/counters.rs +119 -0
- package/native/tvos/metal-patched/src/depthstencil.rs +190 -0
- package/native/tvos/metal-patched/src/device.rs +2134 -0
- package/native/tvos/metal-patched/src/drawable.rs +39 -0
- package/native/tvos/metal-patched/src/encoder.rs +2041 -0
- package/native/tvos/metal-patched/src/heap.rs +281 -0
- package/native/tvos/metal-patched/src/indirect_encoder.rs +344 -0
- package/native/tvos/metal-patched/src/lib.rs +657 -0
- package/native/tvos/metal-patched/src/library.rs +902 -0
- package/native/tvos/metal-patched/src/mps.rs +575 -0
- package/native/tvos/metal-patched/src/pipeline/compute.rs +475 -0
- package/native/tvos/metal-patched/src/pipeline/mod.rs +71 -0
- package/native/tvos/metal-patched/src/pipeline/render.rs +762 -0
- package/native/tvos/metal-patched/src/renderpass.rs +443 -0
- package/native/tvos/metal-patched/src/resource.rs +182 -0
- package/native/tvos/metal-patched/src/sampler.rs +165 -0
- package/native/tvos/metal-patched/src/sync.rs +178 -0
- package/native/tvos/metal-patched/src/texture.rs +352 -0
- package/native/tvos/metal-patched/src/types.rs +90 -0
- package/native/tvos/metal-patched/src/vertexdescriptor.rs +250 -0
- package/native/tvos/src/audio_backend.rs +197 -0
- package/native/tvos/src/lib.rs +1891 -0
- package/native/visionos/Cargo.lock +1693 -0
- package/native/visionos/Cargo.toml +40 -0
- package/native/visionos/src/audio_backend.rs +197 -0
- package/native/visionos/src/lib.rs +1887 -0
- package/native/watchos/Cargo.lock +16 -0
- package/native/watchos/Cargo.toml +19 -0
- package/native/watchos/shaders/bloom_postfx.metal +99 -0
- package/native/watchos/src/BloomWatchApp.swift +1267 -0
- package/native/watchos/src/BloomWatchAudio.swift +179 -0
- package/native/watchos/src/audio.rs +55 -0
- package/native/watchos/src/draw_list.rs +229 -0
- package/native/watchos/src/ffi_stubs.rs +915 -0
- package/native/watchos/src/ffi_stubs_manual.rs +35 -0
- package/native/watchos/src/lib.rs +1124 -0
- package/native/watchos/src/models.rs +746 -0
- package/native/watchos/src/postfx.rs +95 -0
- package/native/watchos/src/scene.rs +534 -0
- package/native/watchos/src/textures.rs +184 -0
- package/native/web/Cargo.lock +1657 -0
- package/native/web/Cargo.toml +43 -0
- package/native/web/bloom_glue.js +695 -0
- package/native/web/build.sh +131 -0
- package/native/web/index.html +35 -0
- package/native/web/jolt_bridge.js +1519 -0
- package/native/web/src/input_ffi.rs +286 -0
- package/native/web/src/lib.rs +1796 -0
- package/native/web/src/material_ffi.rs +710 -0
- package/native/web/src/parity_ffi.rs +343 -0
- package/native/web/src/physics_ffi.rs +643 -0
- package/native/web/src/ragdoll_ffi.rs +250 -0
- package/native/web/src/render_settings.rs +98 -0
- package/native/windows/Cargo.lock +1815 -0
- package/native/windows/Cargo.toml +68 -0
- package/native/windows/src/lib.rs +1486 -0
- package/package.json +4279 -0
- package/src/audio/index.ts +315 -0
- package/src/core/colors.ts +63 -0
- package/src/core/index.ts +1206 -0
- package/src/core/keys.ts +63 -0
- package/src/core/types.ts +104 -0
- package/src/index.ts +171 -0
- package/src/math/index.ts +516 -0
- package/src/mobile/index.ts +294 -0
- package/src/models/index.ts +1258 -0
- package/src/physics/index.ts +1134 -0
- package/src/scene/index.ts +698 -0
- package/src/shapes/index.ts +120 -0
- package/src/text/index.ts +48 -0
- package/src/textures/index.ts +187 -0
- package/src/vfx/index.ts +191 -0
- package/src/world/index.ts +24 -0
- package/src/world/loader.ts +423 -0
- package/src/world/prefab.ts +217 -0
- package/src/world/render.ts +172 -0
- package/src/world/saver.ts +108 -0
- package/src/world/serialize.ts +301 -0
- package/src/world/terrain.ts +355 -0
- package/src/world/types.ts +160 -0
- package/src/world/validate.ts +319 -0
- 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
|
+
}
|