Skip to main content

audiobox/synth/
synthwave.rs

1//! Opt-in polyphonic, stereo instruments. Legacy instruments keep their sample bits.
2use super::controls::{ControlParameter, Values};
3use super::prepared::BeatClock;
4use super::{Envelope, Oscillator, Waveform};
5use crate::math::{cos_turns, exp2, sin_turns};
6use crate::{Error, Result};
7
8/// Low/high-pass have a 12 dB/octave slope; band-pass has a normalized peak.
9#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
10pub enum FilterMode {
11    LowPass,
12    HighPass,
13    BandPass,
14}
15/// A per-note trapezoidal state-variable filter. Output is not limited: resonance
16/// can exceed full scale. Applied before the amplitude envelope, so tails are bounded.
17#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
18pub struct VoiceFilter {
19    pub mode: FilterMode,
20    /// Base cutoff, 1..=96000 Hz. Modulated cutoff clamps to 1..=0.45*sample_rate Hz.
21    pub cutoff_hz: u32,
22    /// Q multiplied by 100, 50..=1000 (Q 0.5..=10).
23    pub resonance: u16,
24    /// Cutoff tracking relative to C4: 0..=200 percent; 100 follows note pitch.
25    pub keytrack: u16,
26    /// Independent cutoff ADSR (same units/bounds as the amplitude envelope).
27    pub envelope: Envelope,
28    /// Peak envelope offset, -9600..=9600 cents.
29    pub envelope_cents: i16,
30}
31impl Default for VoiceFilter {
32    fn default() -> Self {
33        Self {
34            mode: FilterMode::LowPass,
35            cutoff_hz: 2000,
36            resonance: 71,
37            keytrack: 0,
38            envelope: Envelope::default(),
39            envelope_cents: 0,
40        }
41    }
42}
43/// A free-running frequency or a rational period in quarter-note beats.
44#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
45pub enum LfoRate {
46    /// 1..=20000 millihertz (0.001..=20 Hz).
47    MilliHertz(u32),
48    /// `numerator / denominator` quarter-note beats per cycle, both 1..=64.
49    Beats { numerator: u16, denominator: u16 },
50}
51/// Sinusoidal modulation. All LFOs follow total rendered frames, including loops;
52/// note triggers/legato do not reset their phase. Synth reset starts at phase zero.
53#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
54pub struct Lfo {
55    pub rate: LfoRate,
56    /// Pitch/cutoff cents (0..=2400 / 0..=9600); amplitude/pan percent (0..=100).
57    pub depth: u16,
58}
59impl Lfo {
60    fn validate(self, max: u16) -> Result<()> {
61        let rate = match self.rate {
62            LfoRate::MilliHertz(n) => (1..=20000).contains(&n),
63            LfoRate::Beats {
64                numerator,
65                denominator,
66            } => (1..=64).contains(&numerator) && (1..=64).contains(&denominator),
67        };
68        if !rate || self.depth > max {
69            return Err(Error::invalid("Invalid LFO rate or depth"));
70        }
71        Ok(())
72    }
73    fn value(self, clock: u64, rate: u32, tempo: u16) -> f64 {
74        let (num, den) = match self.rate {
75            LfoRate::MilliHertz(n) => (n as u128, rate as u128 * 1000),
76            LfoRate::Beats {
77                numerator,
78                denominator,
79            } => (
80                tempo as u128 * denominator as u128,
81                rate as u128 * 60 * numerator as u128,
82            ),
83        };
84        // The phase's integer numerator, in 64 bits while the product fits: the same
85        // value as the 128-bit form, without a 128-bit division per sample.
86        let phase = match ((clock as u128).checked_mul(num), u64::try_from(den)) {
87            (Some(product), Ok(den64)) if product <= u64::MAX as u128 => {
88                (product as u64 % den64) as u128
89            }
90            _ => (clock as u128 * num) % den,
91        };
92        sin_turns(phase as f64 / den as f64) * self.depth as f64
93    }
94    fn value_clock(self, clock: u64, rate: u32, tempo: u16, musical: Option<BeatClock>) -> f64 {
95        if let (
96            Some(c),
97            LfoRate::Beats {
98                numerator,
99                denominator,
100            },
101        ) = (musical, self.rate)
102        {
103            let den = c.denominator * numerator as u128;
104            let phase = c.numerator * denominator as u128 % den;
105            sin_turns(phase as f64 / den as f64) * self.depth as f64
106        } else {
107            self.value(clock, rate, tempo)
108        }
109    }
110}
111/// Opt-in synthwave controls, combined with an instrument's waveform and amplitude
112/// ADSR. Supports pulse/square, saw and triangle. Mutually exclusive with chip/drum,
113/// except that a wavetable chip source takes `voices` alone, with every other field at
114/// its default, for per-note voices.
115#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
116pub struct Synthwave {
117    /// Oscillators per note, 1..=8, normalized by their count.
118    pub unison: u8,
119    /// Symmetric outer detune, 0..=100 cents; one oscillator has zero detune.
120    pub detune: u16,
121    /// Symmetric linear stereo spread, 0..=100 percent.
122    pub spread: u8,
123    /// Whole-note stereo pan, -100..=100 percent.
124    pub pan: i8,
125    /// Voice slots per channel, 1..=16. Idle, then oldest releasing, then oldest
126    /// held voice is reused, ties by slot index. Stealing is immediate.
127    pub voices: u8,
128    /// Portamento duration in milliseconds, 0..=60000. Requires voices=1.
129    /// Active-note retriggers interpolate in cents from the previous current pitch.
130    pub glide_ms: u32,
131    /// With voices=1, adjacent full-gate notes of the same instrument preserve
132    /// amplitude/filter envelopes, phase and filter state. Gaps retrigger.
133    pub legato: bool,
134    pub filter: Option<VoiceFilter>,
135    pub pitch_lfo: Option<Lfo>,
136    pub cutoff_lfo: Option<Lfo>,
137    pub amplitude_lfo: Option<Lfo>,
138    pub pan_lfo: Option<Lfo>,
139}
140impl Default for Synthwave {
141    fn default() -> Self {
142        Self {
143            unison: 1,
144            detune: 0,
145            spread: 0,
146            pan: 0,
147            voices: 8,
148            glide_ms: 0,
149            legato: false,
150            filter: None,
151            pitch_lfo: None,
152            cutoff_lfo: None,
153            amplitude_lfo: None,
154            pan_lfo: None,
155        }
156    }
157}
158impl Synthwave {
159    pub fn validate(&self) -> Result<()> {
160        if !(1..=8).contains(&self.unison)
161            || self.detune > 100
162            || self.spread > 100
163            || !(-100..=100).contains(&self.pan)
164            || !(1..=16).contains(&self.voices)
165            || self.glide_ms > 60000
166            || ((self.glide_ms != 0 || self.legato) && self.voices != 1)
167        {
168            return Err(Error::invalid(
169                "Invalid unison, spread, pan, voice limit or mono glide/legato settings",
170            ));
171        }
172        if let Some(f) = self.filter {
173            let e = f.envelope;
174            if !(1..=96000).contains(&f.cutoff_hz)
175                || !(50..=1000).contains(&f.resonance)
176                || f.keytrack > 200
177                || !(-9600..=9600).contains(&f.envelope_cents)
178                || e.sustain > 100
179                || [e.attack_ms, e.decay_ms, e.release_ms]
180                    .iter()
181                    .any(|&t| t > 60000)
182            {
183                return Err(Error::invalid("Invalid voice filter or filter envelope"));
184            }
185        } else if self.cutoff_lfo.is_some() {
186            return Err(Error::invalid("Cutoff LFO requires a filter"));
187        }
188        for (lfo, max) in [
189            (self.pitch_lfo, 2400),
190            (self.cutoff_lfo, 9600),
191            (self.amplitude_lfo, 100),
192            (self.pan_lfo, 100),
193        ] {
194            if let Some(lfo) = lfo {
195                lfo.validate(max)?;
196            }
197        }
198        Ok(())
199    }
200}
201#[derive(Clone, Debug)]
202pub(super) struct WidePlan {
203    pub settings: Synthwave,
204    rate: u32,
205    tempo: u16,
206    ratios: [f64; 8],
207    pans: [f64; 8],
208    glide: u64,
209}
210impl WidePlan {
211    pub fn new(settings: Synthwave, rate: u32, tempo: u16) -> Self {
212        let mut ratios = [1.0; 8];
213        let mut pans = [0.0; 8];
214        for i in 0..settings.unison as usize {
215            let offset = if settings.unison == 1 {
216                0.0
217            } else {
218                2.0 * i as f64 / (settings.unison - 1) as f64 - 1.0
219            };
220            ratios[i] = exp2(offset * settings.detune as f64 / 1200.0);
221            pans[i] = offset * settings.spread as f64 / 100.0;
222        }
223        Self {
224            settings,
225            rate,
226            tempo,
227            ratios,
228            pans,
229            glide: (settings.glide_ms as u64 * rate as u64 + 500) / 1000,
230        }
231    }
232}
233// Trapezoidal SVF equations: Andrew Simper, Cytomic, 2013.
234// https://cytomic.com/files/dsp/SvfLinearTrapOptimised2.pdf
235#[derive(Clone, Copy, Debug, Default)]
236struct FilterState {
237    ic1: f64,
238    ic2: f64,
239}
240impl FilterState {
241    fn sample(&mut self, x: f64, mode: FilterMode, g: f64, k: f64) -> f64 {
242        let a1 = 1.0 / (1.0 + g * (g + k));
243        let a2 = g * a1;
244        let a3 = g * a2;
245        let v3 = x - self.ic2;
246        let v1 = a1 * self.ic1 + a2 * v3;
247        let v2 = self.ic2 + a2 * self.ic1 + a3 * v3;
248        self.ic1 = 2.0 * v1 - self.ic1;
249        self.ic2 = 2.0 * v2 - self.ic2;
250        match mode {
251            FilterMode::LowPass => v2,
252            FilterMode::HighPass => x - k * v1 - v2,
253            FilterMode::BandPass => k * v1,
254        }
255    }
256}
257#[derive(Clone, Debug)]
258pub(super) struct WideVoice {
259    oscillators: [Oscillator; 8],
260    filters: [FilterState; 2],
261    age: u64,
262    released: Option<(u64, f64)>,
263    filter_level: f64,
264    pub pitch: f64,
265    from: f64,
266    target: f64,
267    glide_age: u64,
268    /// The last frequency's input cents (as bits) and result, reused while unchanged.
269    hz: (u64, f64),
270    /// The last filter coefficient's inputs (cents bits, base cutoff, rate) and `g`.
271    coefficient: (u64, u64, u32, f64),
272}
273impl WideVoice {
274    pub fn new() -> Self {
275        Self {
276            oscillators: std::array::from_fn(|_| Oscillator::new(super::Aliasing::BandLimited)),
277            filters: [FilterState::default(); 2],
278            age: 0,
279            released: None,
280            filter_level: 0.0,
281            pitch: 0.0,
282            from: 0.0,
283            target: 0.0,
284            glide_age: 0,
285            hz: (f64::NAN.to_bits(), 0.0),
286            coefficient: (f64::NAN.to_bits(), 0, 0, 0.0),
287        }
288    }
289    pub fn trigger(&mut self, plan: &WidePlan, cents: f64, active: bool, legato: bool) {
290        self.from = if active && plan.settings.voices == 1 && plan.glide > 0 {
291            self.pitch
292        } else {
293            cents
294        };
295        self.target = cents;
296        self.glide_age = 0;
297        self.pitch = self.from;
298        if !legato {
299            self.age = 0;
300            self.released = None;
301            self.filter_level = 0.0;
302            self.filters = [FilterState::default(); 2];
303            for (i, osc) in self.oscillators.iter_mut().enumerate() {
304                *osc = Oscillator::new(super::Aliasing::BandLimited);
305                // A small fixed offset decorrelates voices without cancelling a
306                // zero-detune square wave into silence.
307                osc.phase_offset(i as f64 * 0.037);
308            }
309        }
310    }
311    pub fn release(&mut self) {
312        if self.released.is_none() {
313            self.released = Some((0, self.filter_level));
314        }
315    }
316    pub fn sample(
317        &mut self,
318        plan: &WidePlan,
319        wave: Waveform,
320        cents_offset: f64,
321        clock: u64,
322        musical: Option<BeatClock>,
323        controls: Option<Values>,
324    ) -> [f64; 2] {
325        let s = plan.settings;
326        self.pitch = if plan.glide == 0 {
327            self.target
328        } else {
329            self.from
330                + (self.target - self.from)
331                    * (self.glide_age.min(plan.glide) as f64 / plan.glide as f64)
332        };
333        self.glide_age = self.glide_age.saturating_add(1);
334        let get = |p| controls.and_then(|c| c.get(p));
335        let lfo = |l: Option<Lfo>, parameter: ControlParameter| {
336            l.map_or(0.0, |l| {
337                if let Some(depth) = get(parameter) {
338                    Lfo { depth: 1, ..l }.value_clock(clock, plan.rate, plan.tempo, musical) * depth
339                } else {
340                    l.value_clock(clock, plan.rate, plan.tempo, musical)
341                }
342            })
343        };
344        let mut cents =
345            self.pitch + cents_offset + lfo(s.pitch_lfo, ControlParameter::PitchLfoDepth);
346        if let Some(offset) = get(ControlParameter::PitchCents) {
347            cents += offset;
348        }
349        if self.hz.0 != cents.to_bits() {
350            self.hz = (cents.to_bits(), crate::math::cents_to_hz(cents));
351        }
352        let hz = self.hz.1;
353        let mut out = [0.0; 2];
354        for i in 0..s.unison as usize {
355            let sample =
356                self.oscillators[i].next(wave, hz * plan.ratios[i], plan.rate) / s.unison as f64;
357            let pan = plan.pans[i];
358            out[0] += sample * (1.0 - pan.max(0.0));
359            out[1] += sample * (1.0 + pan.min(0.0));
360        }
361        if let Some(f) = s.filter {
362            self.filter_level = if let Some((age, from)) = &mut self.released {
363                let release = frames(f.envelope.release_ms, plan.rate);
364                let level = if release == 0 {
365                    0.0
366                } else {
367                    *from * (1.0 - (*age as f64 / release as f64).min(1.0))
368                };
369                *age += 1;
370                level
371            } else {
372                envelope(f.envelope, self.age, plan.rate)
373            };
374            let cents = f.envelope_cents as f64 * self.filter_level
375                + (self.pitch + 900.0) * f.keytrack as f64 / 100.0
376                + lfo(s.cutoff_lfo, ControlParameter::CutoffLfoDepth);
377            let base = get(ControlParameter::CutoffHz).unwrap_or(f.cutoff_hz as f64);
378            let key = (cents.to_bits(), base.to_bits(), plan.rate);
379            if (self.coefficient.0, self.coefficient.1, self.coefficient.2) != key {
380                let cutoff = (base * exp2(cents / 1200.0)).clamp(1.0, plan.rate as f64 * 0.45);
381                let turns = cutoff / (2.0 * plan.rate as f64);
382                self.coefficient = (key.0, key.1, key.2, sin_turns(turns) / cos_turns(turns));
383            }
384            let g = self.coefficient.3;
385            let k = get(ControlParameter::ResonanceQ)
386                .map_or_else(|| 100.0 / f.resonance as f64, |q| 1.0 / q);
387            for (state, value) in self.filters.iter_mut().zip(&mut out) {
388                *value = state.sample(*value, f.mode, g, k);
389            }
390        }
391        let amp = s.amplitude_lfo.map_or(1.0, |l| {
392            1.0 - (get(ControlParameter::AmplitudeLfoDepth).unwrap_or(l.depth as f64)
393                + lfo(Some(l), ControlParameter::AmplitudeLfoDepth))
394                / 200.0
395        });
396        let pan_lfo = lfo(s.pan_lfo, ControlParameter::PanLfoDepth);
397        let pan = if let Some(p) = get(ControlParameter::Pan) {
398            (p + pan_lfo / 100.0).clamp(-1.0, 1.0)
399        } else {
400            ((s.pan as f64 + pan_lfo) / 100.0).clamp(-1.0, 1.0)
401        };
402        out[0] *= amp * (1.0 - pan.max(0.0));
403        out[1] *= amp * (1.0 + pan.min(0.0));
404        self.age += 1;
405        out
406    }
407}
408fn frames(ms: u32, rate: u32) -> u64 {
409    (ms as u64 * rate as u64 + 500) / 1000
410}
411fn envelope(e: Envelope, age: u64, rate: u32) -> f64 {
412    let attack = frames(e.attack_ms, rate);
413    let decay = frames(e.decay_ms, rate);
414    let sustain = e.sustain as f64 / 100.0;
415    if age < attack {
416        age as f64 / attack as f64
417    } else if age - attack < decay {
418        1.0 - (1.0 - sustain) * (age - attack) as f64 / decay as f64
419    } else {
420        sustain
421    }
422}
423
424#[cfg(test)]
425mod tests {
426    use super::*;
427    #[test]
428    fn filters_have_measured_pass_stop_and_resonance_responses() {
429        let rate = 48000.0;
430        let cutoff = 1000.0;
431        let g = sin_turns(cutoff / (2.0 * rate)) / cos_turns(cutoff / (2.0 * rate));
432        let response = |mode, q, freq| {
433            let mut state = FilterState::default();
434            let mut input = 0.0;
435            let mut output = 0.0;
436            for n in 0..24000 {
437                let x = sin_turns(n as f64 * freq / rate);
438                let y = state.sample(x, mode, g, 1.0 / q);
439                if n > 12000 {
440                    input += x * x;
441                    output += y * y;
442                }
443            }
444            (output / input).sqrt()
445        };
446        for mode in [
447            FilterMode::LowPass,
448            FilterMode::HighPass,
449            FilterMode::BandPass,
450        ] {
451            for q in [0.5, 1.0, 4.0, 10.0] {
452                let centre = response(mode, q, cutoff);
453                let expected = if mode == FilterMode::BandPass { 1.0 } else { q };
454                assert!(
455                    (centre / expected - 1.0).abs() < 0.003,
456                    "{mode:?} Q={q}: {centre}"
457                );
458            }
459        }
460        assert!(response(FilterMode::LowPass, 0.71, 125.0) > 0.98);
461        assert!(response(FilterMode::LowPass, 0.71, 8000.0) < 0.02);
462        assert!(response(FilterMode::HighPass, 0.71, 125.0) < 0.02);
463        assert!(response(FilterMode::HighPass, 0.71, 8000.0) > 0.98);
464        for freq in [125.0, 8000.0] {
465            assert!(response(FilterMode::BandPass, 4.0, freq) < 0.04);
466        }
467    }
468    #[test]
469    fn glide_reaches_target_at_the_exact_prepared_duration() {
470        let plan = WidePlan::new(
471            Synthwave {
472                voices: 1,
473                glide_ms: 100,
474                legato: true,
475                ..Default::default()
476            },
477            1000,
478            120,
479        );
480        let mut voice = WideVoice::new();
481        voice.trigger(&plan, 0.0, false, false);
482        voice.sample(&plan, Waveform::Triangle, 0.0, 0, None, None);
483        voice.trigger(&plan, 1200.0, true, true);
484        for frame in 0..=100 {
485            voice.sample(&plan, Waveform::Triangle, 0.0, frame, None, None);
486            assert!((voice.pitch - frame as f64 * 12.0).abs() < 1e-9);
487        }
488        voice.sample(&plan, Waveform::Triangle, 0.0, 101, None, None);
489        assert_eq!(voice.pitch, 1200.0);
490        voice.trigger(&plan, -1200.0, true, true);
491        voice.sample(&plan, Waveform::Triangle, 0.0, 102, None, None);
492        assert_eq!(voice.pitch, 1200.0);
493    }
494    #[test]
495    fn modulation_extremes_remain_finite_at_supported_rate_edges() {
496        for rate in [1000, 8000, 48000, 768000] {
497            for mode in [
498                FilterMode::LowPass,
499                FilterMode::HighPass,
500                FilterMode::BandPass,
501            ] {
502                let plan = WidePlan::new(
503                    Synthwave {
504                        unison: 8,
505                        detune: 100,
506                        spread: 100,
507                        filter: Some(VoiceFilter {
508                            mode,
509                            cutoff_hz: 96000,
510                            resonance: 1000,
511                            keytrack: 200,
512                            envelope_cents: 9600,
513                            envelope: Envelope {
514                                attack_ms: 1,
515                                decay_ms: 2,
516                                sustain: 0,
517                                release_ms: 1,
518                            },
519                        }),
520                        cutoff_lfo: Some(Lfo {
521                            rate: LfoRate::MilliHertz(20000),
522                            depth: 9600,
523                        }),
524                        pitch_lfo: Some(Lfo {
525                            rate: LfoRate::MilliHertz(20000),
526                            depth: 2400,
527                        }),
528                        ..Default::default()
529                    },
530                    rate,
531                    999,
532                );
533                let mut voice = WideVoice::new();
534                voice.trigger(&plan, -6900.0, false, false);
535                for n in 0..4000 {
536                    let frame = voice.sample(&plan, Waveform::Sawtooth, 0.0, n, None, None);
537                    assert!(
538                        frame.iter().all(|x| x.is_finite() && x.abs() < 1000.0),
539                        "rate={rate} {mode:?}: {frame:?}"
540                    );
541                }
542            }
543        }
544    }
545}