Skip to main content

audiobox/mixer/effects/
colour.rs

1//! Modulation and colour DSP shared by the prepared chain machinery.
2use super::{EffectTime, finite, within};
3use crate::Result;
4use crate::math::{cos_turns, db_to_gain, exp2, sin_turns};
5
6/// LFO speed in cycles/second or one cycle per stated number of quarter-note beats.
7/// Beat mode takes an explicit BPM. Send updated settings to follow tempo changes.
8/// Speed updates preserve phase; this is period sync, not transport phase lock.
9#[derive(Clone, Copy, Debug, PartialEq)]
10pub enum ModulationRate {
11    /// 0..=20 Hz; zero freezes the phase.
12    Hertz(f64),
13    Beats {
14        beats: f64,
15        bpm: f64,
16    },
17}
18impl ModulationRate {
19    fn hz(self) -> Result<f64> {
20        let hz = match self {
21            Self::Hertz(hz) => hz,
22            Self::Beats { beats, bpm } => 1.0 / EffectTime::Beats { beats, bpm }.seconds()?,
23        };
24        within(hz, 0.0, 20.0)?;
25        Ok(hz)
26    }
27}
28
29/// Two sinusoidally modulated delay taps per channel, with a quarter-cycle offset
30/// between taps. The wet signal is their average. Linear interpolation; no added
31/// dry-path latency. Delay +/- depth must fit 1 sample..=80 ms at preparation.
32#[derive(Clone, Copy, Debug, PartialEq)]
33pub struct Chorus {
34    pub rate: ModulationRate,
35    pub delay_ms: f32,
36    pub depth_ms: f32,
37    /// -0.95..=0.95, fed back from the averaged wet signal.
38    pub feedback: f32,
39    /// Right-channel LFO offset in turns, 0..=1; mono uses zero offset.
40    pub stereo_phase: f32,
41    /// Linear dry/wet blend, 0..=1.
42    pub mix: f32,
43}
44impl Default for Chorus {
45    fn default() -> Self {
46        Self {
47            rate: ModulationRate::Hertz(0.8),
48            delay_ms: 18.0,
49            depth_ms: 6.0,
50            feedback: 0.0,
51            stereo_phase: 0.5,
52            mix: 0.35,
53        }
54    }
55}
56impl Chorus {
57    pub(super) fn values(self, rate: u32) -> Result<[f64; 6]> {
58        modulation(
59            self.rate,
60            self.delay_ms,
61            self.depth_ms,
62            self.feedback,
63            self.stereo_phase,
64            self.mix,
65            0.08,
66            rate,
67        )
68    }
69}
70
71/// One sinusoidally modulated short delay tap per channel, creating a moving comb
72/// response when mixed with dry audio. Delay +/- depth must fit 1 sample..=20 ms.
73/// Default settings need at least 2000 Hz; reduce depth or increase delay below it.
74#[derive(Clone, Copy, Debug, PartialEq)]
75pub struct Flanger {
76    pub rate: ModulationRate,
77    pub delay_ms: f32,
78    pub depth_ms: f32,
79    /// -0.95..=0.95.
80    pub feedback: f32,
81    /// Right-channel LFO offset in turns, 0..=1.
82    pub stereo_phase: f32,
83    /// Linear dry/wet blend, 0..=1.
84    pub mix: f32,
85}
86impl Default for Flanger {
87    fn default() -> Self {
88        Self {
89            rate: ModulationRate::Hertz(0.25),
90            delay_ms: 2.0,
91            depth_ms: 1.5,
92            feedback: 0.5,
93            stereo_phase: 0.0,
94            mix: 0.5,
95        }
96    }
97}
98impl Flanger {
99    pub(super) fn values(self, rate: u32) -> Result<[f64; 6]> {
100        modulation(
101            self.rate,
102            self.delay_ms,
103            self.depth_ms,
104            self.feedback,
105            self.stereo_phase,
106            self.mix,
107            0.02,
108            rate,
109        )
110    }
111}
112#[allow(clippy::too_many_arguments)]
113fn modulation(
114    speed: ModulationRate,
115    delay: f32,
116    depth: f32,
117    feedback: f32,
118    spread: f32,
119    mix: f32,
120    maximum: f64,
121    rate: u32,
122) -> Result<[f64; 6]> {
123    let delay = delay as f64 * rate as f64 / 1000.0;
124    let depth = depth as f64 * rate as f64 / 1000.0;
125    within(depth, 0.0, maximum * rate as f64)?;
126    within(delay - depth, 1.0, maximum * rate as f64)?;
127    within(delay + depth, 1.0, maximum * rate as f64)?;
128    feedback_range(feedback)?;
129    within(spread as f64, 0.0, 1.0)?;
130    within(mix as f64, 0.0, 1.0)?;
131    Ok([
132        speed.hz()?,
133        delay,
134        depth,
135        feedback as f64,
136        spread as f64,
137        mix as f64,
138    ])
139}
140fn feedback_range(feedback: f32) -> Result<()> {
141    within(feedback as f64, -(0.95f32 as f64), 0.95f32 as f64)
142}
143
144/// Four first-order allpass stages per channel. Full wet with zero feedback has
145/// unity magnitude at a fixed frequency; blending dry/wet creates two notches.
146/// The default 800 Hz centre needs at least 1778 Hz output; lower it at lower rates.
147#[derive(Clone, Copy, Debug, PartialEq)]
148pub struct Phaser {
149    pub rate: ModulationRate,
150    /// Centre of the logarithmic sweep, 1 Hz..=0.45 * sample rate.
151    pub centre_hz: f32,
152    /// Sweep +/- this many octaves, 0..=4. Instantaneous frequency is clamped to
153    /// 1 Hz..=0.45 * sample rate, including during ramps.
154    pub depth_octaves: f32,
155    /// -0.95..=0.95, from the previous frame's allpass output.
156    pub feedback: f32,
157    /// Right-channel LFO offset in turns, 0..=1.
158    pub stereo_phase: f32,
159    /// Linear dry/wet blend, 0..=1.
160    pub mix: f32,
161}
162impl Default for Phaser {
163    fn default() -> Self {
164        Self {
165            rate: ModulationRate::Hertz(0.4),
166            centre_hz: 800.0,
167            depth_octaves: 2.0,
168            feedback: 0.2,
169            stereo_phase: 0.5,
170            mix: 0.5,
171        }
172    }
173}
174impl Phaser {
175    pub(super) fn values(self, rate: u32) -> Result<[f64; 6]> {
176        within(self.centre_hz as f64, 1.0, rate as f64 * 0.45)?;
177        within(self.depth_octaves as f64, 0.0, 4.0)?;
178        feedback_range(self.feedback)?;
179        within(self.stereo_phase as f64, 0.0, 1.0)?;
180        within(self.mix as f64, 0.0, 1.0)?;
181        Ok([
182            self.rate.hz()?,
183            self.centre_hz as f64,
184            self.depth_octaves as f64,
185            self.feedback as f64,
186            self.stereo_phase as f64,
187            self.mix as f64,
188        ])
189    }
190}
191
192/// Signed PCM quantization plus sample-and-hold rate reduction. At integer `bits`,
193/// exactly 2^bits levels span -1..=1-2^(1-bits), rounded to nearest (ties away from
194/// zero). Fractional bits blend adjacent integer quantizers for smooth automation.
195/// Captures the first frame, then advances one shared stereo phase per frame.
196/// Aliasing and quantization/hold steps are intentional; no dither or anti-aliasing.
197#[derive(Clone, Copy, Debug, PartialEq)]
198pub struct Bitcrusher {
199    /// 2..=24, including fractional values for automation.
200    pub bits: f32,
201    /// Capture rate / output rate, 0.001..=1. Default 0.25 (one in four frames).
202    pub rate_ratio: f32,
203    /// Linear dry/wet blend, 0..=1.
204    pub mix: f32,
205}
206impl Default for Bitcrusher {
207    fn default() -> Self {
208        Self {
209            bits: 8.0,
210            rate_ratio: 0.25,
211            mix: 1.0,
212        }
213    }
214}
215impl Bitcrusher {
216    pub(super) fn values(self) -> Result<[f64; 6]> {
217        within(self.bits as f64, 2.0, 24.0)?;
218        within(self.rate_ratio as f64, 0.001f32 as f64, 1.0)?;
219        within(self.mix as f64, 0.0, 1.0)?;
220        Ok([
221            self.bits as f64,
222            self.rate_ratio as f64,
223            self.mix as f64,
224            0.0,
225            0.0,
226            0.0,
227        ])
228    }
229}
230
231/// Symmetric cubic soft saturation, without oversampling. For driven input v,
232/// f(v)=v-v^3/3 when |v|<1, otherwise sign(v)*2/3. The slope joins continuously
233/// at +/-1. This is a colour effect, not a physical tape model; harmonics can alias.
234#[derive(Clone, Copy, Debug, PartialEq)]
235pub struct Saturation {
236    /// Input drive, 0..=36 dB.
237    pub drive_db: f32,
238    /// Wet output gain, -24..=24 dB.
239    pub output_db: f32,
240    /// Linear dry/wet blend, 0..=1.
241    pub mix: f32,
242}
243impl Default for Saturation {
244    fn default() -> Self {
245        Self {
246            drive_db: 6.0,
247            output_db: 0.0,
248            mix: 1.0,
249        }
250    }
251}
252impl Saturation {
253    pub(super) fn values(self) -> Result<[f64; 6]> {
254        within(self.drive_db as f64, 0.0, 36.0)?;
255        within(self.output_db as f64, -24.0, 24.0)?;
256        within(self.mix as f64, 0.0, 1.0)?;
257        Ok([
258            self.drive_db as f64,
259            self.output_db as f64,
260            self.mix as f64,
261            0.0,
262            0.0,
263            0.0,
264        ])
265    }
266}
267
268pub(super) enum Processor {
269    ModDelay {
270        ring: Vec<f64>,
271        at: usize,
272        phase: f64,
273        chorus: bool,
274    },
275    Phaser {
276        memory: Box<[[f64; 4]]>,
277        feedback: [f64; 8],
278        phase: f64,
279    },
280    Crusher {
281        held: [f64; 8],
282        phase: f64,
283    },
284    Saturation,
285}
286impl Processor {
287    pub(super) fn new(kind: u8, rate: u32, channels: usize) -> Self {
288        match kind {
289            5 | 6 => Self::ModDelay {
290                ring: vec![
291                    0.0;
292                    ((rate as f64 * if kind == 5 { 0.08 } else { 0.02 }).ceil() as usize + 2)
293                        * channels
294                ],
295                at: 0,
296                phase: 0.0,
297                chorus: kind == 5,
298            },
299            7 => Self::Phaser {
300                memory: vec![[0.0; 4]; channels].into_boxed_slice(),
301                feedback: [0.0; 8],
302                phase: 0.0,
303            },
304            8 => Self::Crusher {
305                held: [0.0; 8],
306                phase: 1.0,
307            },
308            _ => Self::Saturation,
309        }
310    }
311    pub(super) fn process(&mut self, x: &mut [f64; 8], p: [f64; 6], rate: u32, channels: usize) {
312        match self {
313            Self::ModDelay {
314                ring,
315                at,
316                phase,
317                chorus,
318            } => {
319                // Endpoint-valid settings can cross a bound during nonlinear ramps.
320                let max = rate as f64 * if *chorus { 0.08 } else { 0.02 };
321                for c in 0..channels {
322                    let phase = *phase + c as f64 * p[4];
323                    let delay = (p[1] + p[2] * sin_turns(phase)).clamp(1.0, max);
324                    let mut wet = read(ring, *at, delay, c, channels);
325                    if *chorus {
326                        let delay = (p[1] + p[2] * sin_turns(phase + 0.25)).clamp(1.0, max);
327                        wet = (wet + read(ring, *at, delay, c, channels)) * 0.5;
328                    }
329                    ring[*at * channels + c] = finite(x[c] + wet * p[3]);
330                    x[c] = x[c] * (1.0 - p[5]) + wet * p[5];
331                }
332                *at = (*at + 1) % (ring.len() / channels);
333                advance(phase, p[0] / rate as f64);
334            }
335            Self::Phaser {
336                memory,
337                feedback,
338                phase,
339            } => {
340                for c in 0..channels {
341                    let hz = (p[1] * exp2(p[2] * sin_turns(*phase + c as f64 * p[4])))
342                        .clamp(1.0, rate as f64 * 0.45);
343                    let turns = hz / (2.0 * rate as f64);
344                    let g = sin_turns(turns) / cos_turns(turns);
345                    let a = (g - 1.0) / (g + 1.0);
346                    let mut wet = finite(x[c] + feedback[c] * p[3]);
347                    for state in &mut memory[c] {
348                        let output = a * wet + *state;
349                        *state = finite(wet - a * output);
350                        wet = finite(output);
351                    }
352                    feedback[c] = wet;
353                    x[c] = x[c] * (1.0 - p[5]) + wet * p[5];
354                }
355                advance(phase, p[0] / rate as f64);
356            }
357            Self::Crusher { held, phase } => {
358                if *phase >= 1.0 {
359                    *held = *x;
360                    *phase -= phase.floor();
361                }
362                *phase += p[1];
363                let bits = p[0].floor() as u32;
364                let fraction = p[0] - bits as f64;
365                for c in 0..channels {
366                    let low = quantize(held[c], bits);
367                    let high = quantize(held[c], (bits + 1).min(24));
368                    let wet = low + (high - low) * fraction;
369                    x[c] = x[c] * (1.0 - p[2]) + wet * p[2];
370                }
371            }
372            Self::Saturation => {
373                let drive = db_to_gain(p[0]);
374                let gain = db_to_gain(p[1]);
375                for sample in &mut x[..channels] {
376                    let v = *sample * drive;
377                    let wet = if v.abs() >= 1.0 {
378                        v.signum() * (2.0 / 3.0)
379                    } else {
380                        v - v * v * v / 3.0
381                    } * gain;
382                    *sample = *sample * (1.0 - p[2]) + wet * p[2];
383                }
384            }
385        }
386    }
387}
388fn read(ring: &[f64], at: usize, delay: f64, channel: usize, channels: usize) -> f64 {
389    let frames = ring.len() / channels;
390    let mut pos = at as f64 - delay;
391    if pos < 0.0 {
392        pos += frames as f64;
393    }
394    let a = pos.floor() as usize;
395    let fraction = pos - a as f64;
396    ring[a * channels + channel] * (1.0 - fraction)
397        + ring[((a + 1) % frames) * channels + channel] * fraction
398}
399fn advance(phase: &mut f64, step: f64) {
400    *phase += step;
401    *phase -= phase.floor();
402}
403fn quantize(value: f64, bits: u32) -> f64 {
404    let scale = (1u64 << (bits - 1)) as f64;
405    (value.clamp(-1.0, 1.0) * scale)
406        .round()
407        .clamp(-scale, scale - 1.0)
408        / scale
409}