diff --git a/crates/firewheel-core/src/dsp/delay_line.rs b/crates/firewheel-core/src/dsp/delay_line.rs new file mode 100644 index 00000000..d3b1c94d --- /dev/null +++ b/crates/firewheel-core/src/dsp/delay_line.rs @@ -0,0 +1,191 @@ +use core::num::NonZeroU32; + +use bevy_platform::prelude::Vec; +#[cfg(not(feature = "std"))] +use num_traits::Float; + +#[derive(Debug)] +pub struct DelayLine { + buffer: Vec, + index: usize, +} + +impl DelayLine { + pub fn new(length: usize) -> Self { + // No need to carry extra capacity around. + let mut buffer = Vec::new(); + buffer.reserve_exact(length); + buffer.extend(core::iter::repeat_n(0.0, length)); + + Self { buffer, index: 0 } + } + + /// Read the least recent sample pushed to this delay line (the sample that + /// will be replaced with the next [`Self::write_and_advance`]). + pub fn read_last(&self) -> f64 { + self.buffer[self.index] + } + + pub fn read(&self, num_samples_delay: usize) -> Option { + let buffer_len = self.buffer.len(); + + // Ensure that requested samples of delay are not greater than our capacity and that the number of samples of delay is not zero. + if buffer_len < num_samples_delay || num_samples_delay == 0 { + return None; + } + + // Wrap the requested delay if necessary + let index = match num_samples_delay > self.index { + // Wrapping is needed - wrap from the end of the vec. + true => buffer_len - (num_samples_delay - self.index), + // No wrapping required - just subtract from the index. + false => self.index - num_samples_delay, + }; + + // Our index must be in range + Some(self.buffer[index]) + } + + /// Read a sample at some delay of samples. Fractional delays will linearly + /// interpolate between the two nearest samples. + /// + /// # Returns + /// + /// Returns the value of the delayed sample, if the delay samples is not + /// greater than the delay line capacity, in which case `None` is returned. + pub fn read_seconds(&self, seconds_delay: f32, sample_rate: NonZeroU32) -> Option { + // Get the number of samples to delay. This number may be fractional and + // will be interpolated. Add 1.0, as a delay of 0.0 is invalid and fractional delays + // will always start at at least 1.0. + let num_samples_delay_f = (seconds_delay * sample_rate.get() as f32) + 1f32; + + let buffer_len = self.buffer.len(); + + // Ensure the requested delay is within bounds + if buffer_len < num_samples_delay_f.ceil() as usize { + return None; + } + + // Get the actual index of the delay, as a fraction + let mut index_f = self.index as f32 - num_samples_delay_f; + + // If negative, wrap to the end of the buffer + if index_f.is_sign_negative() { + index_f = buffer_len as f32 - index_f.abs(); + } + + // Find the two indices to interpolate between + let mut index_a = index_f.floor() as usize; + // Account for rounding errors + if index_a == buffer_len { + index_a -= 1; + } + let index_b = (index_a + 1) % buffer_len; + + let sample_a = self.buffer[index_a]; + let sample_b = self.buffer[index_b]; + + // Amount to interpolate + let fract = index_f.fract() as f64; + + let mix_a = sample_a * (1.0 - fract); + let mix_b = sample_b * fract; + + Some(mix_a + mix_b) + } + + /// Overwrite the least recent sample. + pub fn write_and_advance(&mut self, value: f64) { + self.buffer[self.index] = value; + + if self.index == self.buffer.len() - 1 { + self.index = 0; + } else { + self.index += 1; + } + } + + pub fn reset(&mut self) { + self.buffer.fill(0.0); + } + + pub fn resize(&mut self, size: usize) { + // little point in messing around with the exact + // capacity here + self.buffer.resize(size, 0.0); + self.index %= self.buffer.len(); + } +} + +#[cfg(test)] +mod tests { + macro_rules! delay_line_test { + ($name:ident, $length:expr) => { + #[test] + fn $name() { + let mut line = super::DelayLine::new($length); + for i in 0..$length { + assert_eq!(line.read_last(), 0.0); + line.write_and_advance(i as f64); + } + for i in 0..$length { + assert_eq!(line.read_last(), i as f64); + line.write_and_advance(0.0); + } + } + }; + } + + delay_line_test!(length_1, 1); + delay_line_test!(length_3, 3); + delay_line_test!(length_10, 10); + + #[test] + fn read_delay_line() { + let mut line = super::DelayLine::new(10); + + // Write enough times to overwrite some old values + for i in 0..14 { + line.write_and_advance(i as f64); + } + + // 10, 11, 12, 13, 4, 5, 6, 7, 8, 9 + // └ Index + + assert_eq!(line.read_last(), 4.0); + // Read without wrapping + assert_eq!(line.read(1), Some(13.0)); + // Read with wrapping + assert_eq!(line.read(8), Some(6.0)); + // The index should be equal to the maximum delay + assert_eq!(line.read_last(), line.read(line.buffer.len()).unwrap()); + // Obtain nothing with invalid ranges + assert_eq!(line.read(0), None); + assert_eq!(line.read(11), None); + } + + #[test] + fn read_delay_line_fractional() { + let mut line = super::DelayLine::new(10); + + // Write enough times to overwrite some old values + for i in 0..14 { + line.write_and_advance(i as f64); + } + + let sample_rate = core::num::NonZeroU32::new(1u32).unwrap(); + + // 10, 11, 12, 13, 4, 5, 6, 7, 8, 9 + // │ └ Index + // └─── 0s + + // Read without interpolation + assert_eq!(line.read_seconds(0.0, sample_rate), Some(13.0)); + // Read with interpolation, without wrapping + assert_eq!(line.read_seconds(1.5, sample_rate), Some(11.5)); + // Read with interpolation, with wrapping + assert_eq!(line.read_seconds(5.5, sample_rate), Some(7.5)); + // Obtain nothing with invalid ranges + assert_eq!(line.read_seconds(9.5, sample_rate), None); + } +} diff --git a/crates/firewheel-core/src/dsp/mod.rs b/crates/firewheel-core/src/dsp/mod.rs index 86d8b9d0..6a75df95 100644 --- a/crates/firewheel-core/src/dsp/mod.rs +++ b/crates/firewheel-core/src/dsp/mod.rs @@ -2,6 +2,7 @@ pub mod algo; pub mod buffer; pub mod coeff_update; pub mod declick; +pub mod delay_line; pub mod distance_attenuation; pub mod fade; pub mod filter; diff --git a/crates/firewheel-nodes/Cargo.toml b/crates/firewheel-nodes/Cargo.toml index 4f571291..82359eac 100644 --- a/crates/firewheel-nodes/Cargo.toml +++ b/crates/firewheel-nodes/Cargo.toml @@ -40,6 +40,7 @@ all_nodes = [ "freeverb", "convolution", "fast_rms", + "echo", "triple_buffer", ] # All nodes which are no_std compatible @@ -55,6 +56,7 @@ all_nodes_no_std = [ "mix", "freeverb", "fast_rms", + "echo", "triple_buffer" ] # Enables event scheduling support in some nodes. @@ -90,6 +92,8 @@ freeverb = [] convolution = ["dep:fft-convolver"] # Enables the FastRmsNode for measuring loudness fast_rms = [] +# Enables the echo node +echo = [] # Enables `Component` derive macros bevy = ["dep:bevy_ecs", "firewheel-core/bevy"] # Enables `Reflect` derive macros diff --git a/crates/firewheel-nodes/src/echo.rs b/crates/firewheel-nodes/src/echo.rs new file mode 100644 index 00000000..f588b8b2 --- /dev/null +++ b/crates/firewheel-nodes/src/echo.rs @@ -0,0 +1,537 @@ +use core::{array::from_fn, num::NonZeroU32}; + +use firewheel_core::{ + channel_config::{ChannelConfig, NonZeroChannelCount}, + diff::{Diff, Notify, Patch}, + dsp::{ + buffer::ChannelBuffer, + declick::{DeclickFadeCurve, Declicker}, + delay_line::DelayLine, + fade::FadeCurve, + filter::{ + single_pole_iir::{ + OnePoleIirHPF, OnePoleIirHPFCoeff, OnePoleIirLPF, OnePoleIirLPFCoeff, + }, + smoothing_filter::DEFAULT_SMOOTH_SECONDS, + }, + mix::{Mix, MixDSP}, + volume::Volume, + }, + event::ProcEvents, + node::{ + AudioNode, AudioNodeInfo, AudioNodeProcessor, ConstructProcessorContext, ProcBuffers, + ProcExtra, ProcInfo, ProcessStatus, + }, + param::smoother::{SmoothedParam, SmootherConfig}, +}; +#[cfg(not(feature = "std"))] +use num_traits::Float; + +const DEFAULT_DELAY_SMOOTH_SECONDS: f32 = 0.25; + +/// The configuration for an [`EchoNode`] +#[derive(Debug, Clone, Copy, PartialEq)] +#[cfg_attr(feature = "bevy", derive(bevy_ecs::prelude::Component))] +#[cfg_attr(feature = "bevy_reflect", derive(bevy_reflect::Reflect))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct EchoNodeConfig { + /// The maximum amount of samples available per channel + pub buffer_capacity: usize, + /// The number of supported channels + pub channels: NonZeroChannelCount, +} + +impl EchoNodeConfig { + /// Create a configuration that can hold up to a specified number of seconds + /// of audio + pub fn new( + max_duration_seconds: f32, + sample_rate: impl Into, + channels: impl Into, + ) -> Self { + Self { + buffer_capacity: (max_duration_seconds * sample_rate.into().get() as f32).ceil() + as usize, + channels: channels.into(), + } + } +} + +impl Default for EchoNodeConfig { + fn default() -> Self { + // Assume a common rate, as it cannot be known at compile time + Self::new( + 5.0, + NonZeroU32::new(44_100).unwrap(), + NonZeroChannelCount::STEREO, + ) + } +} + +#[derive(Diff, Patch, Debug, Clone, Copy, PartialEq)] +#[cfg_attr(feature = "bevy", derive(bevy_ecs::prelude::Component))] +#[cfg_attr(feature = "bevy_reflect", derive(bevy_reflect::Reflect))] +pub struct EchoNode { + /// The lowpass frequency in hertz in the range + /// `[20.0, 20480.0]`. + pub feedback_lpf: f32, + /// The highpass frequency in hertz in the range `[20.0, 20480.0]`. + pub feedback_hpf: f32, + /// The value representing the mix between the dry and wet audio signals + /// + /// This is a normalized value in the range `[0.0, 1.0]`, where `0.0` is + /// fully the dry signal, `1.0` is fully the wet signal, and `0.5` is an + /// equal mix of both. + /// + /// By default this is set to [`Mix::CENTER`]. + pub mix: Mix, + + /// The delay time, in seconds. + pub delay_seconds: [f32; CHANNELS], + + /// Feedback amplitude + pub feedback: [Volume; CHANNELS], + + /// Crossfeed to the other channel. Unused in mono. + /// + /// Warning: crossfeed may lead to runaway feedback + pub crossfeed: [Volume; CHANNELS], + + /// The algorithm used to map the normalized mix value in the range `[0.0, + /// 1.0]` to the corresponding gain values for the two signals. + /// + /// By default this is set to [`FadeCurve::EqualPower3dB`]. + pub fade_curve: FadeCurve, + + /// Adjusts the time in seconds over which parameters are smoothed. + /// + /// Defaults to `0.015` (15ms). + pub smooth_seconds: f32, + + /// Adjusts the time in seconds over which the delay time is smoothed. + /// This value is set separately from `smooth_seconds` to prevent clicking + /// when quickly changing delay times. + /// + /// Defaults to `0.25` (250ms). + pub delay_smooth_seconds: f32, + + pub stop: Notify<()>, + pub paused: bool, +} + +impl Default for EchoNode { + fn default() -> Self { + Self { + feedback_lpf: 6_000.0, + feedback_hpf: 70.0, + mix: Mix::CENTER, + fade_curve: FadeCurve::EqualPower3dB, + stop: Notify::default(), + paused: false, + delay_seconds: [0.5; CHANNELS], + feedback: [Volume::from_percent(30.0); CHANNELS], + crossfeed: [Volume::from_percent(0.0); CHANNELS], + smooth_seconds: DEFAULT_SMOOTH_SECONDS, + delay_smooth_seconds: 0.25, + } + } +} + +impl EchoNode { + fn smoother_config(&self) -> SmootherConfig { + SmootherConfig { + smooth_seconds: self.smooth_seconds, + ..Default::default() + } + } +} + +impl AudioNode for EchoNode { + type Configuration = EchoNodeConfig; + + fn info(&self, _config: &Self::Configuration) -> AudioNodeInfo { + AudioNodeInfo::new() + .debug_name("echo") + .channel_config(ChannelConfig::new(CHANNELS, CHANNELS)) + } + + fn construct_processor( + &self, + config: &Self::Configuration, + cx: ConstructProcessorContext, + ) -> impl AudioNodeProcessor { + let max_frames = cx.stream_info.max_block_frames.get() as usize; + let sample_rate = cx.stream_info.sample_rate; + let smoother_config = self.smoother_config(); + Processor:: { + params: *self, + declicker: Declicker::default(), + delay_seconds_smoothed: self.delay_seconds.map(|channel| { + SmoothedParam::new( + channel, + SmootherConfig { + smooth_seconds: DEFAULT_DELAY_SMOOTH_SECONDS, + ..Default::default() + }, + sample_rate, + ) + }), + feedback_smoothed: self + .feedback + .map(|channel| SmoothedParam::new(channel.linear(), smoother_config, sample_rate)), + crossfeed_smoothed: self + .crossfeed + .map(|channel| SmoothedParam::new(channel.linear(), smoother_config, sample_rate)), + delay_seconds_smoothed_buffer: ChannelBuffer::::new(max_frames), + feedback_smoothed_buffer: ChannelBuffer::::new(max_frames), + crossfeed_smoothed_buffer: ChannelBuffer::::new(max_frames), + delay_buffers: from_fn(|_| DelayLine::new(config.buffer_capacity)), + mix_dsp: MixDSP::new( + self.mix, + self.fade_curve, + smoother_config, + cx.stream_info.sample_rate, + ), + feedback_lpf: [OnePoleIirLPF::default(); CHANNELS], + feedback_hpf: [OnePoleIirHPF::default(); CHANNELS], + prev_delay_seconds: [None; CHANNELS], + next_delay_seconds: [None; CHANNELS], + feedback_lpf_smoothed: SmoothedParam::new( + self.feedback_lpf, + smoother_config, + sample_rate, + ), + feedback_hpf_smoothed: SmoothedParam::new( + self.feedback_hpf, + smoother_config, + sample_rate, + ), + } + } +} + +struct Processor { + params: EchoNode, + feedback_lpf_smoothed: SmoothedParam, + feedback_hpf_smoothed: SmoothedParam, + feedback_lpf: [OnePoleIirLPF; CHANNELS], + feedback_hpf: [OnePoleIirHPF; CHANNELS], + mix_dsp: MixDSP, + declicker: Declicker, + // Set when transitioning delay seconds. When settled on the new value, + // it is unset. We need this value to get the amount to mix the two + // echos of different delay times. + prev_delay_seconds: [Option; CHANNELS], + // Represents the current amount of delay. + delay_seconds_smoothed: [SmoothedParam; CHANNELS], + // In order to smoothly mix without phase discontinuity, we must finish + // mixing completely before moving on to another interpolation. For example, + // imagine an interpolation is 50% complete before a new delay time is + // requested. We would need to jump to 0% completion and move to the new + // position, resulting in a click. + // + // To resolve this, the current interpolation will always run to completion + // before the next requested target is considered, so only two delay lines + // are mixed at any given time. This parameter is like a queue of a length + // of 1. The latest request will replace any currently set value. + // + // This will be popped into `delay_seconds_smoothed` as the next target value + // when `delay_seconds_smoothed` has settled. + next_delay_seconds: [Option; CHANNELS], + feedback_smoothed: [SmoothedParam; CHANNELS], + crossfeed_smoothed: [SmoothedParam; CHANNELS], + // Should always be the same count as `CHANNELS` + delay_buffers: [DelayLine; CHANNELS], + // We need to calculate all of these buffers at once, so scratch buffers may + // not be enough depending on channels + delay_seconds_smoothed_buffer: ChannelBuffer, + feedback_smoothed_buffer: ChannelBuffer, + crossfeed_smoothed_buffer: ChannelBuffer, +} + +impl AudioNodeProcessor for Processor { + fn process( + &mut self, + info: &ProcInfo, + buffers: ProcBuffers, + events: &mut ProcEvents, + extra: &mut ProcExtra, + ) -> ProcessStatus { + const SCRATCH_CHANNELS: usize = 2; + const LPF_SCRATCH_INDEX: usize = 0; + const HPF_SCRATCH_INDEX: usize = 1; + + let mut clear_buffers = false; + for mut patch in events.drain_patches::>() { + match &mut patch { + EchoNodePatch::SmoothSeconds(seconds) => { + // Change all smoothed parameters to new smoothing, except for delay + let update_smoothing = |param: &mut SmoothedParam| { + param.set_smooth_seconds(*seconds, info.sample_rate); + }; + self.crossfeed_smoothed + .iter_mut() + .chain(self.feedback_smoothed.iter_mut()) + .chain([self.feedback_hpf_smoothed, self.feedback_lpf_smoothed].iter_mut()) + .for_each(update_smoothing); + } + EchoNodePatch::DelaySmoothSeconds(seconds) => { + // Change delay smoothed parameters to new smoothing + let update_smoothing = |param: &mut SmoothedParam| { + param.set_smooth_seconds(*seconds, info.sample_rate); + }; + self.delay_seconds_smoothed + .iter_mut() + .for_each(update_smoothing); + } + EchoNodePatch::FeedbackLpf(cutoff_hz) => { + self.feedback_lpf_smoothed.set_value(*cutoff_hz); + } + EchoNodePatch::FeedbackHpf(cutoff_hz) => { + self.feedback_hpf_smoothed.set_value(*cutoff_hz); + } + EchoNodePatch::Mix(mix) => { + self.mix_dsp.set_mix(*mix, self.params.fade_curve); + } + EchoNodePatch::DelaySeconds((index, delay_seconds)) => { + // Check to see if settled. + if self.prev_delay_seconds[*index].is_none() { + self.prev_delay_seconds[*index] = + Some(self.delay_seconds_smoothed[*index].target_value()); + self.delay_seconds_smoothed[*index].set_value(*delay_seconds) + } else { + // If we're still transitioning, queue up the desired change. + self.next_delay_seconds[*index] = Some(*delay_seconds); + } + } + EchoNodePatch::Feedback((index, feedback)) => { + self.feedback_smoothed[*index].set_value(feedback.linear()) + } + EchoNodePatch::Crossfeed((index, crossfeed)) => { + self.crossfeed_smoothed[*index].set_value(crossfeed.linear()); + } + EchoNodePatch::Stop(_) => { + clear_buffers = true; + self.params.paused = true; + self.declicker.fade_to_enabled(false, &extra.declick_values); + } + EchoNodePatch::Paused(is_paused) => { + self.declicker + .fade_to_enabled(!*is_paused, &extra.declick_values); + } + EchoNodePatch::FadeCurve(fade_curve) => { + self.mix_dsp.set_mix(self.params.mix, *fade_curve); + } + } + self.params.apply(patch); + } + + if self.params.paused && self.declicker.has_settled() { + return ProcessStatus::ClearAllOutputs; + } + + // Zero outputs so that crossfeeds can be added to the output TODO: Is + // there a more efficient way to do this that avoids clearing the + // buffer? + for output in buffers.outputs.iter_mut() { + output.fill(0.0); + } + + // Process smoothed values all at the same time + + // Smoothed cutoff values do not have to be calculated per channel. + // Calculate smoothed filter values + let mut scratch = extra.scratch_buffers.channels_mut::(); + let scratch: [&mut [&mut [f32]]; 2] = scratch.split_at_mut(1).into(); + let lpf_smoothed = &mut scratch[LPF_SCRATCH_INDEX][0]; + self.feedback_lpf_smoothed.process_into_buffer(lpf_smoothed); + + let hpf_smoothed = &mut scratch[HPF_SCRATCH_INDEX][0]; + self.feedback_hpf_smoothed.process_into_buffer(hpf_smoothed); + + for channel_index in (0..CHANNELS).into_iter() { + // Queue up delays if applicable + if self.next_delay_seconds[channel_index].is_some() { + // If there are no delays in progress... + if self.prev_delay_seconds[channel_index] == None { + // Queue next value + self.delay_seconds_smoothed[channel_index] + .set_value(self.next_delay_seconds[channel_index].take().unwrap()); + } + } + self.delay_seconds_smoothed[channel_index].process_into_buffer( + self.delay_seconds_smoothed_buffer + .channels_mut::()[channel_index], + ); + self.feedback_smoothed[channel_index].process_into_buffer( + self.feedback_smoothed_buffer.channels_mut::()[channel_index], + ); + self.crossfeed_smoothed[channel_index].process_into_buffer( + self.crossfeed_smoothed_buffer.channels_mut::()[channel_index], + ); + } + + // The block diagram for this echo effect looks like this. (Declicking + // has been omitted) + /* + XFeed In + ▼ + │ + │ ┌─► XFeed Out + ┌──────┐ │ ┌─────┐ │ + ┌─►│Filter├────►────┴──►│Delay├───┤ + │ └──────┘ Feedback └─────┘ │ + │ ┌─▼─┐ + In ●─┴──────────────────────────────►│Mix├───► Out + └───┘ + */ + // Because we have crossfeed, everything must happen in lockstep. We'll + // do each step of processing all channels at a time. + for sample_index in 0..info.frames { + // First, read delayed samples for all channels + let delayed_samples: [f32; CHANNELS] = from_fn(|channel_index| { + // The value of seconds delay that we wish to move towards (or have settled at). + let next_secs_delay = self.delay_seconds_smoothed[channel_index].target_value(); + + // Target delay, in fractional samples. This will act as the final state of our lerp (1.0). + let mut next_delay_sample = self.delay_buffers[channel_index] + .read_seconds(next_secs_delay, info.sample_rate) + .unwrap() as f32; + + // If we aren't transitioning time, we're done at this point! + // However, if the delay_seconds_smoothed is still settling, + // that means we are still transitioning from a previous time + // selection. We'll need the initial 0.0 state, with the completion of + // the delay seconds from previous to next position (0.0 to 1.0) as the interpolator. + + if let Some(prev_secs_delay) = self.prev_delay_seconds[channel_index] { + // Get the sample that will act as position 0.0 of the interpolation + let prev_delay_sample = self.delay_buffers[channel_index] + .read_seconds(prev_secs_delay, info.sample_rate) + .unwrap() as f32; + + // We can now calculate how much to interpolate, based on the completion of the delay smoother buffer + let current_secs_delay = + self.delay_seconds_smoothed_buffer.all()[channel_index][sample_index]; + + let denom = next_secs_delay - prev_secs_delay; + let interpolation_factor = { + match denom <= f32::EPSILON { + true => 1.0, + false => { + // assert!(current_secs_delay <= next_secs_delay); + // dbg!(current_secs_delay, prev_secs_delay); + // assert!(current_secs_delay >= prev_secs_delay); // this is failing and causing issues + ((current_secs_delay - prev_secs_delay) / denom).clamp(0.0, 1.0) + } + } + }; + + next_delay_sample *= interpolation_factor; + next_delay_sample += (1.0 - interpolation_factor) * prev_delay_sample; + } + + if self.delay_seconds_smoothed[channel_index].has_settled() + && self.prev_delay_seconds[channel_index].is_some() + { + // A `prev_delay_seconds` existing for this channel signals a delay change. If settled, remove it. + self.prev_delay_seconds[channel_index] = None; + } + + next_delay_sample + }); + + // Process signal to find next samples to feed into the buffer (wet + // signal) + let next_buffer_samples: [f32; CHANNELS] = from_fn(|channel_index| { + let lpf = &mut self.feedback_lpf[channel_index]; + let hpf = &mut self.feedback_hpf[channel_index]; + + let input_sample = buffers.inputs[channel_index][sample_index]; + let feedback_sample = delayed_samples[channel_index] + * self.feedback_smoothed_buffer.all()[channel_index][sample_index]; + let crossfed_sample = (0..CHANNELS) + .filter(|i| i != &channel_index) + .map(|i| { + delayed_samples[i] * self.crossfeed_smoothed_buffer.all()[i][sample_index] + }) + .sum::(); + + let mut next = input_sample + feedback_sample + crossfed_sample; + + // Change filter coeffs based on smoothed values + let scratch = extra.scratch_buffers.channels::(); + + // Filter samples through high and lowpass filter + next = lpf.process( + next, + OnePoleIirLPFCoeff::new( + scratch[LPF_SCRATCH_INDEX][sample_index], + info.sample_rate_recip as f32, + ), + ); + next = hpf.process( + next, + OnePoleIirHPFCoeff::new( + scratch[HPF_SCRATCH_INDEX][sample_index], + info.sample_rate_recip as f32, + ), + ); + next + }); + + for channel_index in 0..CHANNELS { + self.delay_buffers[channel_index] + .write_and_advance(next_buffer_samples[channel_index] as f64); + buffers.outputs[channel_index][sample_index] = delayed_samples[channel_index]; + } + } + + // Mix the resultant signal + match CHANNELS { + 1 => { + self.mix_dsp.mix_dry_into_wet_mono( + buffers.inputs[0], + buffers.outputs[0], + info.frames, + ); + } + 2 => { + let (dry_l, dry_r) = (buffers.inputs[0], buffers.inputs[1]); + let (wet_l, wet_r) = buffers.outputs.split_at_mut(1); + self.mix_dsp + .mix_dry_into_wet_stereo(dry_l, dry_r, wet_l[0], wet_r[0], info.frames); + } + _ => { + let mut scratch_buffers = extra.scratch_buffers.channels_mut::<2>(); + let (split_a, split_b) = scratch_buffers.split_at_mut(1); + self.mix_dsp.mix_dry_into_wet( + info.frames, + buffers.inputs, + buffers.outputs, + split_a[0], + split_b[0], + ); + } + } + + // Declick when pausing or stopping + + self.declicker.process( + buffers.outputs, + 0..info.frames, + &extra.declick_values, + 1.0, + DeclickFadeCurve::EqualPower3dB, + ); + + // Clear internal buffers if signaled, such as when stopping + if clear_buffers && self.declicker.has_settled() { + for buffer in self.delay_buffers.iter_mut() { + buffer.reset(); + } + } + + buffers.check_for_silence_on_outputs(f32::EPSILON) + } +} diff --git a/crates/firewheel-nodes/src/freeverb/all_pass.rs b/crates/firewheel-nodes/src/freeverb/all_pass.rs index 5ce2073d..315dac7c 100644 --- a/crates/firewheel-nodes/src/freeverb/all_pass.rs +++ b/crates/firewheel-nodes/src/freeverb/all_pass.rs @@ -1,4 +1,4 @@ -use super::delay_line::DelayLine; +use firewheel_core::dsp::delay_line::DelayLine; #[derive(Debug)] pub struct AllPass { @@ -13,7 +13,7 @@ impl AllPass { } pub fn tick(&mut self, input: f64) -> f64 { - let delayed = self.delay_line.read(); + let delayed = self.delay_line.read_last(); let output = -input + delayed; // in the original version of freeverb this is a member which is never modified diff --git a/crates/firewheel-nodes/src/freeverb/comb.rs b/crates/firewheel-nodes/src/freeverb/comb.rs index 077fbb88..2f6313b8 100644 --- a/crates/firewheel-nodes/src/freeverb/comb.rs +++ b/crates/firewheel-nodes/src/freeverb/comb.rs @@ -1,4 +1,4 @@ -use super::delay_line::DelayLine; +use firewheel_core::dsp::delay_line::DelayLine; #[derive(Debug)] pub struct Comb { @@ -30,7 +30,7 @@ impl Comb { } pub fn tick(&mut self, input: f64) -> f64 { - let output = self.delay_line.read(); + let output = self.delay_line.read_last(); self.filter_state = output * self.dampening_inverse + self.filter_state * self.dampening; diff --git a/crates/firewheel-nodes/src/freeverb/delay_line.rs b/crates/firewheel-nodes/src/freeverb/delay_line.rs deleted file mode 100644 index 06eaf8df..00000000 --- a/crates/firewheel-nodes/src/freeverb/delay_line.rs +++ /dev/null @@ -1,67 +0,0 @@ -use bevy_platform::prelude::Vec; - -#[derive(Debug)] -pub struct DelayLine { - buffer: Vec, - index: usize, -} - -impl DelayLine { - pub fn new(length: usize) -> Self { - // No need to carry extra capacity around. - let mut buffer = Vec::new(); - buffer.reserve_exact(length); - buffer.extend(core::iter::repeat_n(0.0, length)); - - Self { buffer, index: 0 } - } - - pub fn read(&self) -> f64 { - self.buffer[self.index] - } - - pub fn write_and_advance(&mut self, value: f64) { - self.buffer[self.index] = value; - - if self.index == self.buffer.len() - 1 { - self.index = 0; - } else { - self.index += 1; - } - } - - pub fn reset(&mut self) { - self.buffer.fill(0.0); - } - - pub fn resize(&mut self, size: usize) { - // little point in messing around with the exact - // capacity here - self.buffer.resize(size, 0.0); - self.index %= self.buffer.len(); - } -} - -#[cfg(test)] -mod tests { - macro_rules! delay_line_test { - ($name:ident, $length:expr) => { - #[test] - fn $name() { - let mut line = super::DelayLine::new($length); - for i in 0..$length { - assert_eq!(line.read(), 0.0); - line.write_and_advance(i as f64); - } - for i in 0..$length { - assert_eq!(line.read(), i as f64); - line.write_and_advance(0.0); - } - } - }; - } - - delay_line_test!(length_1, 1); - delay_line_test!(length_3, 3); - delay_line_test!(length_10, 10); -} diff --git a/crates/firewheel-nodes/src/freeverb/mod.rs b/crates/firewheel-nodes/src/freeverb/mod.rs index 4d9d4d75..6d654a2c 100644 --- a/crates/firewheel-nodes/src/freeverb/mod.rs +++ b/crates/firewheel-nodes/src/freeverb/mod.rs @@ -21,7 +21,6 @@ use firewheel_core::{ mod all_pass; mod comb; -mod delay_line; mod freeverb; /// A simple, relatively cheap stereo reverb. diff --git a/crates/firewheel-nodes/src/lib.rs b/crates/firewheel-nodes/src/lib.rs index 1b295c57..f1f1ae2d 100644 --- a/crates/firewheel-nodes/src/lib.rs +++ b/crates/firewheel-nodes/src/lib.rs @@ -42,6 +42,9 @@ pub mod fast_rms; #[cfg(feature = "triple_buffer")] pub mod triple_buffer; +#[cfg(feature = "echo")] +pub mod echo; + mod stereo_to_mono; pub use stereo_to_mono::StereoToMonoNode; diff --git a/examples/visual_node_graph/src/system.rs b/examples/visual_node_graph/src/system.rs index 2b5141a0..861efbf8 100644 --- a/examples/visual_node_graph/src/system.rs +++ b/examples/visual_node_graph/src/system.rs @@ -8,6 +8,7 @@ use firewheel::{ nodes::{ beep_test::BeepTestNode, convolution::{ConvolutionNode, ConvolutionNodeConfig}, + echo::EchoNode, fast_filters::{ bandpass::FastBandpassNode, highpass::FastHighpassNode, lowpass::FastLowpassNode, }, @@ -53,6 +54,8 @@ pub enum NodeType { Freeverb, ConvolutionMono, ConvolutionStereo, + EchoMono, + EchoStereo, } pub struct AudioSystem { @@ -185,6 +188,8 @@ impl AudioSystem { }), ), NodeType::ConvolutionStereo => self.cx.add_node(ConvolutionNode::<2>::default(), None), + NodeType::EchoMono => self.cx.add_node(EchoNode::<1>::default(), None), + NodeType::EchoStereo => self.cx.add_node(EchoNode::<2>::default(), None), }; match node_type { @@ -253,6 +258,14 @@ impl AudioSystem { id, params: Default::default(), }, + NodeType::EchoMono => GuiAudioNode::EchoMono { + id, + params: Default::default(), + }, + NodeType::EchoStereo => GuiAudioNode::EchoStereo { + id, + params: Default::default(), + }, } } diff --git a/examples/visual_node_graph/src/ui.rs b/examples/visual_node_graph/src/ui.rs index 45acc598..c708c7d6 100644 --- a/examples/visual_node_graph/src/ui.rs +++ b/examples/visual_node_graph/src/ui.rs @@ -12,6 +12,7 @@ use firewheel::{ nodes::{ beep_test::BeepTestNode, convolution::{ConvolutionNode, ImpulseResponse}, + echo::EchoNode, fast_filters::{ bandpass::FastBandpassNode, highpass::FastHighpassNode, lowpass::FastLowpassNode, MAX_HZ, MIN_HZ, @@ -102,6 +103,14 @@ pub enum GuiAudioNode { id: firewheel::node::NodeID, params: Memo>, }, + EchoMono { + id: firewheel::node::NodeID, + params: Memo>, + }, + EchoStereo { + id: firewheel::node::NodeID, + params: Memo>, + }, } impl GuiAudioNode { @@ -126,6 +135,8 @@ impl GuiAudioNode { &Self::Freeverb { id, .. } => id, &Self::ConvolutionMono { id, .. } => id, &Self::ConvolutionStereo { id, .. } => id, + &Self::EchoMono { id, .. } => id, + &Self::EchoStereo { id, .. } => id, } } @@ -150,6 +161,8 @@ impl GuiAudioNode { &Self::Freeverb { .. } => "Freeverb", &Self::ConvolutionMono { .. } => "Convolution (Mono)", &Self::ConvolutionStereo { .. } => "Convolution (Stereo)", + &Self::EchoMono { .. } => "Echo (Mono)", + &Self::EchoStereo { .. } => "Echo (Stereo)", } .into() } @@ -175,6 +188,8 @@ impl GuiAudioNode { &Self::Freeverb { .. } => 2, &Self::ConvolutionMono { .. } => 1, &Self::ConvolutionStereo { .. } => 2, + &Self::EchoMono { .. } => 1, + &Self::EchoStereo { .. } => 2, } } @@ -199,6 +214,8 @@ impl GuiAudioNode { &Self::Freeverb { .. } => 2, &Self::ConvolutionMono { .. } => 1, &Self::ConvolutionStereo { .. } => 2, + &Self::EchoMono { .. } => 1, + &Self::EchoStereo { .. } => 2, } } } @@ -316,107 +333,83 @@ impl<'a> SnarlViewer for DemoViewer<'a> { } fn show_graph_menu(&mut self, pos: egui::Pos2, ui: &mut Ui, snarl: &mut Snarl) { - ui.label("Add node"); - if ui.button("Beep Test").clicked() { - let node = self.audio_system.add_node(NodeType::BeepTest); + let mut add_node = |ui: &mut Ui, node_type: NodeType| { + let node = self.audio_system.add_node(node_type); snarl.insert_node(pos, node); ui.close_kind(UiKind::Menu); + }; + + ui.label("Add node"); + if ui.button("Beep Test").clicked() { + add_node(ui, NodeType::BeepTest); } if ui.button("White Noise Generator").clicked() { - let node = self.audio_system.add_node(NodeType::WhiteNoiseGen); - snarl.insert_node(pos, node); - ui.close_kind(UiKind::Menu); + add_node(ui, NodeType::WhiteNoiseGen); } if ui.button("Pink Noise Generator").clicked() { - let node = self.audio_system.add_node(NodeType::PinkNoiseGen); - snarl.insert_node(pos, node); - ui.close_kind(UiKind::Menu); + add_node(ui, NodeType::PinkNoiseGen); } if ui.button("Stereo To Mono").clicked() { - let node = self.audio_system.add_node(NodeType::StereoToMono); - snarl.insert_node(pos, node); - ui.close_kind(UiKind::Menu); + add_node(ui, NodeType::StereoToMono); } ui.menu_button("Volume", |ui| { if ui.button("Volume (mono)").clicked() { - let node = self.audio_system.add_node(NodeType::VolumeMono); - snarl.insert_node(pos, node); - ui.close_kind(UiKind::Menu); + add_node(ui, NodeType::VolumeMono); } if ui.button("Volume (stereo)").clicked() { - let node = self.audio_system.add_node(NodeType::VolumeStereo); - snarl.insert_node(pos, node); - ui.close_kind(UiKind::Menu); + add_node(ui, NodeType::VolumeStereo); } }); if ui.button("Volume & Pan").clicked() { - let node = self.audio_system.add_node(NodeType::VolumePan); - snarl.insert_node(pos, node); - ui.close_kind(UiKind::Menu); + add_node(ui, NodeType::VolumePan); } if ui.button("Fast Lowpass").clicked() { - let node = self.audio_system.add_node(NodeType::FastLowpass); - snarl.insert_node(pos, node); - ui.close_kind(UiKind::Menu); + add_node(ui, NodeType::FastLowpass); } if ui.button("Fast Highpass").clicked() { - let node = self.audio_system.add_node(NodeType::FastHighpass); - snarl.insert_node(pos, node); - ui.close_kind(UiKind::Menu); + add_node(ui, NodeType::FastHighpass); } if ui.button("Fast Bandpass").clicked() { - let node = self.audio_system.add_node(NodeType::FastBandpass); - snarl.insert_node(pos, node); - ui.close_kind(UiKind::Menu); + add_node(ui, NodeType::FastBandpass); } if ui.button("SVF").clicked() { - let node = self.audio_system.add_node(NodeType::SVF); - snarl.insert_node(pos, node); - ui.close_kind(UiKind::Menu); + add_node(ui, NodeType::SVF); } if ui.button("Mix (Mono)").clicked() { - let node = self.audio_system.add_node(NodeType::MixMono); - snarl.insert_node(pos, node); - ui.close_kind(UiKind::Menu); + add_node(ui, NodeType::MixMono); } if ui.button("Mix (Stereo)").clicked() { - let node = self.audio_system.add_node(NodeType::MixStereo); - snarl.insert_node(pos, node); - ui.close_kind(UiKind::Menu); + add_node(ui, NodeType::MixStereo); } if ui.button("Sampler").clicked() { - let node = self.audio_system.add_node(NodeType::Sampler); - snarl.insert_node(pos, node); - ui.close_kind(UiKind::Menu); + add_node(ui, NodeType::Sampler); } if ui.button("Freeverb").clicked() { - let node = self.audio_system.add_node(NodeType::Freeverb); - snarl.insert_node(pos, node); - ui.close_kind(UiKind::Menu); + add_node(ui, NodeType::Freeverb); } // Mono section ui.menu_button("Mix", |ui| { if ui.button("Mix (Mono)").clicked() { - let node = self.audio_system.add_node(NodeType::MixMono); - snarl.insert_node(pos, node); - ui.close_kind(UiKind::Menu); + add_node(ui, NodeType::MixMono); } if ui.button("Mix (Stereo)").clicked() { - let node = self.audio_system.add_node(NodeType::MixStereo); - snarl.insert_node(pos, node); - ui.close_kind(UiKind::Menu); + add_node(ui, NodeType::MixStereo); } }); ui.menu_button("Convolution", |ui| { if ui.button("Convolution (Mono)").clicked() { - let node = self.audio_system.add_node(NodeType::ConvolutionMono); - snarl.insert_node(pos, node); - ui.close_kind(UiKind::Menu); + add_node(ui, NodeType::ConvolutionMono); } if ui.button("Convolution (Stereo)").clicked() { - let node = self.audio_system.add_node(NodeType::ConvolutionStereo); - snarl.insert_node(pos, node); - ui.close_kind(UiKind::Menu); + add_node(ui, NodeType::ConvolutionStereo); + } + }); + ui.menu_button("Echo", |ui| { + if ui.button("Echo (Mono)").clicked() { + add_node(ui, NodeType::EchoMono); + } + if ui.button("Echo (Stereo)").clicked() { + add_node(ui, NodeType::EchoStereo); } }); } @@ -796,6 +789,14 @@ impl<'a> SnarlViewer for DemoViewer<'a> { convolution_ui(ui, params, self.audio_system, *id); params.update_memo(&mut self.audio_system.event_queue(*id)); } + GuiAudioNode::EchoMono { id, params } => { + echo_ui(ui, params); + params.update_memo(&mut self.audio_system.event_queue(*id)); + } + GuiAudioNode::EchoStereo { id, params } => { + echo_ui(ui, params); + params.update_memo(&mut self.audio_system.event_queue(*id)); + } _ => {} } } @@ -897,6 +898,85 @@ fn convolution_ui( }); } +// Reusable echo UI for any amount of channels +fn echo_ui(ui: &mut Ui, params: &mut Memo>) { + // The padding of the boxes used to contain each channel's controls + const PADDING: f32 = 4.0; + ui.vertical(|ui| { + for channel in (0..CHANNELS).into_iter() { + let mut controls = |ui: &mut Ui| { + let delay = &mut params.delay_seconds[channel]; + ui.add(egui::Slider::new(delay, 0.0..=3.0).text("delay")) + .changed(); + + let feedback = &mut params.feedback[channel]; + let mut feedback_volume = feedback.linear(); + if ui + .add(egui::Slider::new(&mut feedback_volume, 0.0..=1.0).text("feedback")) + .changed() + { + *feedback = Volume::Linear(feedback_volume); + } + + if CHANNELS > 1 { + let crossfeed = &mut params.crossfeed[channel]; + let mut crossfeed_volume = crossfeed.linear(); + if ui + .add(egui::Slider::new(&mut crossfeed_volume, 0.0..=1.0).text("crossfeed")) + .changed() + { + *crossfeed = Volume::Linear(crossfeed_volume); + } + } + }; + + // Group each channel visually if multichannel + if CHANNELS > 1 { + egui::Frame::default() + .stroke(ui.visuals().widgets.noninteractive.bg_stroke) + .corner_radius(ui.visuals().widgets.noninteractive.corner_radius) + .inner_margin(PADDING) + .show(ui, |ui| { + ui.label(if channel == 0 { "Left" } else { "Right" }); + controls(ui); + }); + } else { + controls(ui); + } + } + + ui.add( + egui::Slider::new(&mut params.feedback_lpf, MIN_HZ..=MAX_HZ) + .logarithmic(true) + .text("feedback lpf"), + ); + ui.add( + egui::Slider::new(&mut params.feedback_hpf, MIN_HZ..=MAX_HZ) + .logarithmic(true) + .text("feedback hpf"), + ); + + let mut mix = params.mix.get(); + ui.add(egui::Slider::new(&mut mix, 0.0..=1.0).text("mix")); + params.mix = Mix::new(mix); + + ui.horizontal(|ui| { + if ui.button("Stop").clicked() { + params.stop.notify(); + } + if !params.paused { + if ui.button("Pause").clicked() { + params.paused = true; + } + } else { + if ui.button("Play").clicked() { + params.paused = false; + } + } + }); + }); +} + pub struct DemoApp { snarl: Snarl, style: SnarlStyle,