Skip to main content

embedded_audio/
fx.rs

1//! Lightweight per-sample effects ported from DaisySP's `Effects` module: waveshaping
2//! distortion, wavefolding, and tremolo. All three run on `i8` PCM with plain arithmetic
3//! (no `libm`/`std`), so they're part of the always-on core rather than the `dsp` feature.
4
5use crate::fixed::{Phase, apply_gain_q8, hz_to_phase_inc, phase_index, sin_table};
6
7fn soft_limit(x: f32) -> f32 {
8    x * (27.0 + x * x) / (27.0 + 9.0 * x * x)
9}
10
11fn soft_clip(x: f32) -> f32 {
12    if x < -3.0 {
13        -1.0
14    } else if x > 3.0 {
15        1.0
16    } else {
17        soft_limit(x)
18    }
19}
20
21fn i8_to_f32(sample: i8) -> f32 {
22    sample as f32 / 127.0
23}
24
25fn f32_to_i8(sample: f32) -> i8 {
26    (sample * 127.0).clamp(-128.0, 127.0) as i8
27}
28
29/// Waveshaping distortion/overdrive, ported from `daisysp::Overdrive`.
30#[derive(Debug, Clone, Copy)]
31pub struct Overdrive {
32    pre_gain: f32,
33    post_gain: f32,
34}
35
36impl Overdrive {
37    /// Creates an overdrive with `drive` in `0.0..=1.0` (`1.0` = max fuzz). Note this mirrors
38    /// DaisySP exactly: `drive = 0.0` drives the pre-gain to zero, muting the signal rather than
39    /// passing it through clean — pick a small nonzero drive (e.g. `0.1`) for a mild effect.
40    pub fn new(drive: f32) -> Self {
41        let mut od = Self {
42            pre_gain: 0.0,
43            post_gain: 1.0,
44        };
45        od.set_drive(drive);
46        od
47    }
48
49    /// Sets the drive amount, clamped to `0.0..=1.0`.
50    pub fn set_drive(&mut self, drive: f32) {
51        let drive = 2.0 * drive.clamp(0.0, 1.0);
52        let drive_2 = drive * drive;
53        let pre_gain_a = drive * 0.5;
54        let pre_gain_b = drive_2 * drive_2 * drive * 24.0;
55        self.pre_gain = pre_gain_a + (pre_gain_b - pre_gain_a) * drive_2;
56
57        let drive_squashed = drive * (2.0 - drive);
58        self.post_gain = 1.0 / soft_clip(0.33 + drive_squashed * (self.pre_gain - 0.33));
59    }
60
61    /// Processes one PCM sample.
62    pub fn process(&self, input: i8) -> i8 {
63        let pre = self.pre_gain * i8_to_f32(input);
64        f32_to_i8(soft_clip(pre) * self.post_gain)
65    }
66}
67
68fn floor_f32(x: f32) -> f32 {
69    let truncated = x as i32 as f32;
70    if truncated > x {
71        truncated - 1.0
72    } else {
73        truncated
74    }
75}
76
77/// Wavefolder, ported from `daisysp::Wavefolder`. Input magnitude beyond `1.0` (post-gain)
78/// folds back on itself instead of clipping.
79#[derive(Debug, Clone, Copy)]
80pub struct Wavefolder {
81    gain: f32,
82    offset: f32,
83}
84
85impl Wavefolder {
86    /// Creates a wavefolder at unity gain with no DC offset.
87    pub const fn new() -> Self {
88        Self {
89            gain: 1.0,
90            offset: 0.0,
91        }
92    }
93
94    /// Sets the input gain. Negative values fold through zero.
95    pub fn set_gain(&mut self, gain: f32) {
96        self.gain = gain;
97    }
98
99    /// Sets a pre-gain DC offset for asymmetrical folding.
100    pub fn set_offset(&mut self, offset: f32) {
101        self.offset = offset;
102    }
103
104    /// Processes one PCM sample.
105    pub fn process(&self, input: i8) -> i8 {
106        let x = (i8_to_f32(input) + self.offset) * self.gain;
107        let fold_count = floor_f32((x + 1.0) * 0.5);
108        let sign = if (fold_count as i64) % 2 == 0 {
109            1.0
110        } else {
111            -1.0
112        };
113        f32_to_i8(sign * (x - 2.0 * fold_count))
114    }
115}
116
117impl Default for Wavefolder {
118    fn default() -> Self {
119        Self::new()
120    }
121}
122
123/// Amplitude tremolo driven by the crate's built-in sine wavetable, ported from
124/// `daisysp::Tremolo`.
125#[derive(Debug, Clone, Copy)]
126pub struct Tremolo {
127    phase: Phase,
128    phase_inc: u32,
129    half_depth_q8: u8,
130}
131
132impl Tremolo {
133    /// Creates a tremolo at 1 Hz, full depth (call [`Self::set_freq`]/[`Self::set_depth_q8`] to taste).
134    pub const fn new() -> Self {
135        Self {
136            phase: 0,
137            phase_inc: 0,
138            half_depth_q8: 127,
139        }
140    }
141
142    /// Sets the LFO rate in Hz.
143    pub fn set_freq(&mut self, freq_hz: u32, sample_rate_hz: u32) {
144        self.phase_inc = hz_to_phase_inc(freq_hz, sample_rate_hz);
145    }
146
147    /// Sets how much to modulate volume, `0` (no effect) to `255` (full tremolo, silent at trough).
148    pub fn set_depth_q8(&mut self, depth_q8: u8) {
149        self.half_depth_q8 = depth_q8 / 2;
150    }
151
152    /// Processes one PCM sample.
153    pub fn process(&mut self, input: i8) -> i8 {
154        let lfo = sin_table(phase_index(self.phase)) as i32;
155        self.phase = self.phase.wrapping_add(self.phase_inc);
156
157        let half_depth = self.half_depth_q8 as i32;
158        let dc = 255 - half_depth;
159        let gain_q8 = (dc + (lfo * half_depth) / 127).clamp(0, 255) as u8;
160        apply_gain_q8(input, gain_q8)
161    }
162}
163
164impl Default for Tremolo {
165    fn default() -> Self {
166        Self::new()
167    }
168}