From 84dc7aeb207aabc24999785b8554308d59a006b4 Mon Sep 17 00:00:00 2001 From: tolelom <98kimsungmin@naver.com> Date: Thu, 26 Mar 2026 16:33:04 +0900 Subject: [PATCH] feat(audio): add occlusion simulation with low-pass filter Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/voltex_audio/src/lib.rs | 2 + crates/voltex_audio/src/occlusion.rs | 96 ++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 crates/voltex_audio/src/occlusion.rs diff --git a/crates/voltex_audio/src/lib.rs b/crates/voltex_audio/src/lib.rs index b6a94e1..c6122f5 100644 --- a/crates/voltex_audio/src/lib.rs +++ b/crates/voltex_audio/src/lib.rs @@ -9,6 +9,7 @@ pub mod hrtf; pub mod mix_group; pub mod audio_source; pub mod reverb; +pub mod occlusion; pub use audio_clip::AudioClip; pub use audio_source::AudioSource; @@ -18,3 +19,4 @@ pub use audio_system::AudioSystem; pub use spatial::{Listener, SpatialParams, distance_attenuation, stereo_pan, compute_spatial_gains}; pub use mix_group::{MixGroup, MixerState}; pub use reverb::{Reverb, Echo, DelayLine}; +pub use occlusion::{OcclusionResult, LowPassFilter, calculate_occlusion}; diff --git a/crates/voltex_audio/src/occlusion.rs b/crates/voltex_audio/src/occlusion.rs new file mode 100644 index 0000000..662486e --- /dev/null +++ b/crates/voltex_audio/src/occlusion.rs @@ -0,0 +1,96 @@ +/// Audio occlusion parameters. +#[derive(Debug, Clone, Copy)] +pub struct OcclusionResult { + pub volume_multiplier: f32, // 0.0-1.0, reduced by occlusion + pub lowpass_cutoff: f32, // Hz, lower = more muffled +} + +/// Simple ray-based occlusion check. +/// `occlusion_factor`: 0.0 = no occlusion (direct line of sight), 1.0 = fully occluded. +pub fn calculate_occlusion(occlusion_factor: f32) -> OcclusionResult { + let factor = occlusion_factor.clamp(0.0, 1.0); + OcclusionResult { + volume_multiplier: 1.0 - factor * 0.7, // Max 70% volume reduction + lowpass_cutoff: 20000.0 * (1.0 - factor * 0.8), // 20kHz → 4kHz when fully occluded + } +} + +/// Simple low-pass filter (one-pole IIR). +pub struct LowPassFilter { + prev_output: f32, + alpha: f32, +} + +impl LowPassFilter { + pub fn new(cutoff_hz: f32, sample_rate: u32) -> Self { + let rc = 1.0 / (2.0 * std::f32::consts::PI * cutoff_hz); + let dt = 1.0 / sample_rate as f32; + let alpha = dt / (rc + dt); + LowPassFilter { prev_output: 0.0, alpha } + } + + pub fn set_cutoff(&mut self, cutoff_hz: f32, sample_rate: u32) { + let rc = 1.0 / (2.0 * std::f32::consts::PI * cutoff_hz); + let dt = 1.0 / sample_rate as f32; + self.alpha = dt / (rc + dt); + } + + pub fn process(&mut self, input: f32) -> f32 { + self.prev_output = self.prev_output + self.alpha * (input - self.prev_output); + self.prev_output + } + + pub fn reset(&mut self) { + self.prev_output = 0.0; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_no_occlusion() { + let r = calculate_occlusion(0.0); + assert!((r.volume_multiplier - 1.0).abs() < 1e-6); + assert!((r.lowpass_cutoff - 20000.0).abs() < 1.0); + } + + #[test] + fn test_full_occlusion() { + let r = calculate_occlusion(1.0); + assert!((r.volume_multiplier - 0.3).abs() < 1e-6); + assert!((r.lowpass_cutoff - 4000.0).abs() < 1.0); + } + + #[test] + fn test_partial_occlusion() { + let r = calculate_occlusion(0.5); + assert!(r.volume_multiplier > 0.3 && r.volume_multiplier < 1.0); + assert!(r.lowpass_cutoff > 4000.0 && r.lowpass_cutoff < 20000.0); + } + + #[test] + fn test_lowpass_attenuates_high_freq() { + let mut lpf = LowPassFilter::new(100.0, 44100); // very low cutoff + // High frequency signal (alternating +1/-1) should be attenuated + let mut max_output = 0.0_f32; + for i in 0..100 { + let input = if i % 2 == 0 { 1.0 } else { -1.0 }; + let output = lpf.process(input); + max_output = max_output.max(output.abs()); + } + assert!(max_output < 0.5, "high freq should be attenuated, got {}", max_output); + } + + #[test] + fn test_lowpass_passes_dc() { + let mut lpf = LowPassFilter::new(5000.0, 44100); + // DC signal (constant 1.0) should pass through + for _ in 0..1000 { + lpf.process(1.0); + } + let output = lpf.process(1.0); + assert!((output - 1.0).abs() < 0.01, "DC should pass, got {}", output); + } +}