feat(audio): add Reverb (Schroeder) and Echo effects
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@ pub mod spatial;
|
|||||||
pub mod hrtf;
|
pub mod hrtf;
|
||||||
pub mod mix_group;
|
pub mod mix_group;
|
||||||
pub mod audio_source;
|
pub mod audio_source;
|
||||||
|
pub mod reverb;
|
||||||
|
|
||||||
pub use audio_clip::AudioClip;
|
pub use audio_clip::AudioClip;
|
||||||
pub use audio_source::AudioSource;
|
pub use audio_source::AudioSource;
|
||||||
@@ -16,3 +17,4 @@ pub use mixing::{PlayingSound, mix_sounds};
|
|||||||
pub use audio_system::AudioSystem;
|
pub use audio_system::AudioSystem;
|
||||||
pub use spatial::{Listener, SpatialParams, distance_attenuation, stereo_pan, compute_spatial_gains};
|
pub use spatial::{Listener, SpatialParams, distance_attenuation, stereo_pan, compute_spatial_gains};
|
||||||
pub use mix_group::{MixGroup, MixerState};
|
pub use mix_group::{MixGroup, MixerState};
|
||||||
|
pub use reverb::{Reverb, Echo, DelayLine};
|
||||||
|
|||||||
189
crates/voltex_audio/src/reverb.rs
Normal file
189
crates/voltex_audio/src/reverb.rs
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
/// Simple delay line for echo effect.
|
||||||
|
pub struct DelayLine {
|
||||||
|
pub(crate) buffer: Vec<f32>,
|
||||||
|
pub(crate) write_pos: usize,
|
||||||
|
delay_samples: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DelayLine {
|
||||||
|
pub fn new(delay_samples: usize) -> Self {
|
||||||
|
DelayLine { buffer: vec![0.0; delay_samples.max(1)], write_pos: 0, delay_samples }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn process(&mut self, input: f32, feedback: f32) -> f32 {
|
||||||
|
let read_pos = (self.write_pos + self.buffer.len() - self.delay_samples) % self.buffer.len();
|
||||||
|
let delayed = self.buffer[read_pos];
|
||||||
|
self.buffer[self.write_pos] = input + delayed * feedback;
|
||||||
|
self.write_pos = (self.write_pos + 1) % self.buffer.len();
|
||||||
|
delayed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simple Schroeder reverb using 4 parallel comb filters + 2 allpass filters.
|
||||||
|
pub struct Reverb {
|
||||||
|
comb_filters: Vec<CombFilter>,
|
||||||
|
allpass_filters: Vec<AllpassFilter>,
|
||||||
|
pub wet: f32, // 0.0-1.0
|
||||||
|
pub dry: f32, // 0.0-1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CombFilter {
|
||||||
|
delay: DelayLine,
|
||||||
|
feedback: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CombFilter {
|
||||||
|
fn new(delay_samples: usize, feedback: f32) -> Self {
|
||||||
|
CombFilter { delay: DelayLine::new(delay_samples), feedback }
|
||||||
|
}
|
||||||
|
fn process(&mut self, input: f32) -> f32 {
|
||||||
|
self.delay.process(input, self.feedback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AllpassFilter {
|
||||||
|
delay: DelayLine,
|
||||||
|
gain: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AllpassFilter {
|
||||||
|
fn new(delay_samples: usize, gain: f32) -> Self {
|
||||||
|
AllpassFilter { delay: DelayLine::new(delay_samples), gain }
|
||||||
|
}
|
||||||
|
fn process(&mut self, input: f32) -> f32 {
|
||||||
|
let delayed = self.delay.process(input, 0.0);
|
||||||
|
let _output = -self.gain * input + delayed + self.gain * delayed;
|
||||||
|
// Actually: allpass: output = -g*input + delayed, buffer = input + g*delayed
|
||||||
|
// Simplified:
|
||||||
|
let buf_val = self.delay.buffer[(self.delay.write_pos + self.delay.buffer.len() - 1) % self.delay.buffer.len()];
|
||||||
|
-self.gain * input + buf_val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Reverb {
|
||||||
|
pub fn new(sample_rate: u32) -> Self {
|
||||||
|
// Schroeder reverb with prime-number delay lengths
|
||||||
|
let sr = sample_rate as f32;
|
||||||
|
Reverb {
|
||||||
|
comb_filters: vec![
|
||||||
|
CombFilter::new((0.0297 * sr) as usize, 0.805),
|
||||||
|
CombFilter::new((0.0371 * sr) as usize, 0.827),
|
||||||
|
CombFilter::new((0.0411 * sr) as usize, 0.783),
|
||||||
|
CombFilter::new((0.0437 * sr) as usize, 0.764),
|
||||||
|
],
|
||||||
|
allpass_filters: vec![
|
||||||
|
AllpassFilter::new((0.005 * sr) as usize, 0.7),
|
||||||
|
AllpassFilter::new((0.0017 * sr) as usize, 0.7),
|
||||||
|
],
|
||||||
|
wet: 0.3,
|
||||||
|
dry: 0.7,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn process(&mut self, input: f32) -> f32 {
|
||||||
|
// Sum comb filter outputs
|
||||||
|
let mut comb_sum = 0.0;
|
||||||
|
for comb in &mut self.comb_filters {
|
||||||
|
comb_sum += comb.process(input);
|
||||||
|
}
|
||||||
|
comb_sum /= self.comb_filters.len() as f32;
|
||||||
|
|
||||||
|
// Chain through allpass filters
|
||||||
|
let mut output = comb_sum;
|
||||||
|
for ap in &mut self.allpass_filters {
|
||||||
|
output = ap.process(output);
|
||||||
|
}
|
||||||
|
|
||||||
|
input * self.dry + output * self.wet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simple echo (single delay + feedback).
|
||||||
|
pub struct Echo {
|
||||||
|
delay: DelayLine,
|
||||||
|
pub feedback: f32,
|
||||||
|
pub wet: f32,
|
||||||
|
pub dry: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Echo {
|
||||||
|
pub fn new(delay_ms: f32, sample_rate: u32) -> Self {
|
||||||
|
let samples = (delay_ms * sample_rate as f32 / 1000.0) as usize;
|
||||||
|
Echo { delay: DelayLine::new(samples), feedback: 0.5, wet: 0.3, dry: 0.7 }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn process(&mut self, input: f32) -> f32 {
|
||||||
|
let delayed = self.delay.process(input, self.feedback);
|
||||||
|
input * self.dry + delayed * self.wet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_delay_line_basic() {
|
||||||
|
let mut dl = DelayLine::new(2);
|
||||||
|
assert!((dl.process(1.0, 0.0) - 0.0).abs() < 1e-6); // no output yet
|
||||||
|
assert!((dl.process(0.0, 0.0) - 0.0).abs() < 1e-6);
|
||||||
|
assert!((dl.process(0.0, 0.0) - 1.0).abs() < 1e-6); // input appears after 2-sample delay
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_delay_line_feedback() {
|
||||||
|
let mut dl = DelayLine::new(2);
|
||||||
|
dl.process(1.0, 0.5);
|
||||||
|
dl.process(0.0, 0.5);
|
||||||
|
let out = dl.process(0.0, 0.5); // delayed 1.0
|
||||||
|
assert!((out - 1.0).abs() < 1e-6);
|
||||||
|
dl.process(0.0, 0.5);
|
||||||
|
let out2 = dl.process(0.0, 0.5); // feedback: 0.5
|
||||||
|
assert!((out2 - 0.5).abs() < 1e-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_reverb_preserves_signal() {
|
||||||
|
let mut rev = Reverb::new(44100);
|
||||||
|
// Process silence -> should be silence
|
||||||
|
let out = rev.process(0.0);
|
||||||
|
assert!((out - 0.0).abs() < 1e-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_reverb_produces_output() {
|
||||||
|
let mut rev = Reverb::new(44100);
|
||||||
|
// Send an impulse, collect some output
|
||||||
|
let mut energy = 0.0;
|
||||||
|
rev.process(1.0);
|
||||||
|
for _ in 0..4410 {
|
||||||
|
let s = rev.process(0.0);
|
||||||
|
energy += s.abs();
|
||||||
|
}
|
||||||
|
assert!(energy > 0.01, "reverb should produce decaying output after impulse");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_echo_basic() {
|
||||||
|
let mut echo = Echo::new(100.0, 44100); // 100ms delay
|
||||||
|
let delay_samples = (100.0 * 44100.0 / 1000.0) as usize;
|
||||||
|
// Send impulse
|
||||||
|
echo.process(1.0);
|
||||||
|
// Process until delay
|
||||||
|
for _ in 1..delay_samples {
|
||||||
|
echo.process(0.0);
|
||||||
|
}
|
||||||
|
let out = echo.process(0.0);
|
||||||
|
// Should have echo of input (wet * delayed)
|
||||||
|
assert!(out.abs() > 0.01, "echo should produce delayed output");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_echo_wet_dry() {
|
||||||
|
let mut echo = Echo::new(10.0, 44100);
|
||||||
|
echo.wet = 0.0;
|
||||||
|
echo.dry = 1.0;
|
||||||
|
let out = echo.process(0.5);
|
||||||
|
assert!((out - 0.5).abs() < 1e-6); // dry only
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user