Skip to main content

sound_to_patch/preset/vital/
laws.rs

1//! Vital laws that span several controls: unison spread, LFO tempo rates,
2//! pan gains, modulation depth and the amplitude path.
3//!
4//! Each law is read from the Vital 1.0.6 open source and restated here.
5//! Where the reading joins several facts, the function says so; a probe or
6//! a render confirms it before a mapper relies on it.
7
8use crate::cast::usize_to_f64;
9use crate::preset::registry::Provenance;
10use crate::units::{Cents, Hz, MidiPitch, RangeError, Semitones};
11
12/// The provenance of each law in this module, by function name.
13///
14/// A law that joins several facts by a reading stays interim until a probe
15/// or a render confirms it. `Measured` rows were confirmed by renders on
16/// the [`OPEN_ENGINE`](crate::preset::vital::OPEN_ENGINE), not on retail.
17pub const PROVENANCE: [(&str, Provenance); 9] = [
18    ("power_scale", Provenance::FromFacts),
19    ("unison_outer_offset", Provenance::Measured),
20    ("unison_offsets", Provenance::Measured),
21    ("unison_gains", Provenance::Measured),
22    ("lfo_tempo_rate", Provenance::FromFacts),
23    ("lfo_keytrack_rate", Provenance::FromFacts),
24    ("pan_gains", Provenance::FromFacts),
25    ("modulation_delta", Provenance::FromFacts),
26    ("amp_gain", Provenance::Measured),
27];
28
29/// The note-number frequency law: `8.1757989156 * 2^(note / 12)` Hz.
30pub const MIDI_0_HZ: f64 = 8.175_798_915_6;
31
32/// Vital's power curve: `(e^(p*t) - 1) / (e^p - 1)`, or `t` when
33/// `|p| < 0.01`. It maps 0 to 0 and 1 to 1 and bends in between.
34#[must_use]
35pub fn power_scale(t: f64, power: f64) -> f64 {
36    if power.abs() < 0.01 {
37        t
38    } else {
39        (power * t).exp_m1() / power.exp_m1()
40    }
41}
42
43/// The offset of the outermost unison voice: `detune_range * detune^2`
44/// cents, with `detune` the stored `osc_N_unison_detune` and
45/// `detune_range` the stored `osc_N_detune_range`.
46///
47/// # Errors
48///
49/// A [`RangeError`] when the result is not finite.
50pub fn unison_outer_offset(detune_range: f64, detune: f64) -> Result<Cents, RangeError> {
51    Cents::new(detune_range * detune * detune)
52}
53
54/// The offsets of `voices` unison voices as fractions of the outer offset,
55/// from lowest to highest.
56///
57/// Position `i` of `ceil(voices / 2)` sits at
58/// `t = (2i + bump) / max(1, voices - 1)`, with `bump` 1 for an even count.
59/// Its offset is `power_scale(t, power)`, one voice up and one down; an odd
60/// count puts one voice at the centre. Renders on the open engine confirm
61/// the offsets for 4 and 8 voices at powers 0 and 1.5 within 0.03 cents.
62/// Odd counts are not rendered.
63#[must_use]
64pub fn unison_offsets(voices: u8, power: f64) -> Vec<f64> {
65    let n = usize::from(voices.max(1));
66    let bump = usize::from(n % 2 == 0);
67    let divisor = usize_to_f64(n.saturating_sub(1).max(1));
68    let mut offsets = Vec::with_capacity(n);
69    for i in 0..n.div_ceil(2) {
70        let fraction = power_scale(usize_to_f64(2 * i + bump) / divisor, power);
71        if fraction == 0.0 {
72            offsets.push(0.0);
73        } else {
74            offsets.extend([-fraction, fraction]);
75        }
76    }
77    offsets.sort_by(f64::total_cmp);
78    offsets
79}
80
81/// The gains of the centre and the detuned unison voices.
82#[derive(Clone, Copy, Debug, PartialEq)]
83pub struct UnisonGains {
84    /// Gain of the centre voice, or of the innermost pair, whose two voices
85    /// share it equally.
86    pub centre: f64,
87    /// Gain of each other pair, shared equally by its two voices.
88    pub detuned: f64,
89    /// The pair count the normalisation uses.
90    pub pairs: usize,
91}
92
93/// The level compensation of unison voices at `blend` (stored 0 to 1).
94///
95/// Up to 2 voices the centre has gain 1 and the rest 0. Above that the
96/// centre is `lerp(1, 0.4, blend)` and each detuned voice
97/// `lerp(0.6, 0, (1 - blend)^2)`, both scaled so that
98/// `centre^2 + detuned^2 * (pairs - 1) = 1`, with `pairs` the count
99/// `ceil(voices / 2)`. A gain belongs to a pair of voices, which share it
100/// equally; the centre gain goes to the centre voice of an odd count, or to
101/// the innermost pair of an even one. Renders on the open engine confirm
102/// this for 3, 4, 5 and 8 voices within 0.001 of the single-voice
103/// amplitude.
104#[must_use]
105pub fn unison_gains(voices: u8, blend: f64) -> UnisonGains {
106    let pairs = usize::from(voices).div_ceil(2);
107    if voices <= 2 {
108        return UnisonGains {
109            centre: 1.0,
110            detuned: 0.0,
111            pairs,
112        };
113    }
114    let centre = 1.0 + (0.4 - 1.0) * blend;
115    let detuned = 0.6 * (1.0 - (1.0 - blend).powi(2));
116    let norm = (centre * centre + detuned * detuned * usize_to_f64(pairs - 1))
117        .sqrt()
118        .recip();
119    UnisonGains {
120        centre: centre * norm,
121        detuned: detuned * norm,
122        pairs,
123    }
124}
125
126/// How a tempo-synced LFO scales its note division.
127#[derive(Clone, Copy, Debug, PartialEq, Eq)]
128pub enum TempoMode {
129    /// The plain division.
130    Tempo,
131    /// A dotted division, 2/3 of the rate.
132    Dotted,
133    /// A triplet division, 3/2 of the rate.
134    Triplets,
135}
136
137/// The rate of a tempo-synced LFO.
138///
139/// Index `i` of `lfo_N_tempo` (0 to 12) gives `2^(i - 8)` cycles per beat,
140/// times the mode factor, times the beat rate. Index 0 freezes the LFO at
141/// 0 Hz. At 120 BPM the beat rate is 2 Hz, so index 8 (`1/4`) runs at 2 Hz.
142///
143/// # Errors
144///
145/// A [`RangeError`] for an index above 12.
146pub fn lfo_tempo_rate(index: u8, mode: TempoMode, beat_rate: Hz) -> Result<Hz, RangeError> {
147    if index == 0 {
148        return Ok(Hz::from_const(0.0));
149    }
150    let index =
151        crate::units::Range::Closed(0.0, 12.0).check("LFO tempo index", f64::from(index))?;
152    let factor = match mode {
153        TempoMode::Tempo => 1.0,
154        TempoMode::Dotted => 2.0 / 3.0,
155        TempoMode::Triplets => 1.5,
156    };
157    Hz::new((index - 8.0).exp2() * factor * beat_rate.get())
158}
159
160/// The rate of a key-tracked LFO: the note-number law applied to
161/// `transpose + tune + note`.
162///
163/// # Errors
164///
165/// A [`RangeError`] when the result is not finite.
166pub fn lfo_keytrack_rate(
167    note: MidiPitch,
168    transpose: Semitones,
169    tune: Cents,
170) -> Result<Hz, RangeError> {
171    let semitones = note.get() + transpose.get() + tune.get() / 100.0;
172    Hz::new(MIDI_0_HZ * (semitones / 12.0).exp2())
173}
174
175/// The left and right gains of a pan position `pan` (stored -1 to 1).
176///
177/// The law is `sqrt(2) * sin(2 pi (1/8 -+ pan/8))`, so the centre gives 1
178/// on both channels. The engine uses a polynomial sine, so its gains differ
179/// from these by up to about 5e-3.
180#[must_use]
181pub fn pan_gains(pan: f64) -> (f64, f64) {
182    let angle = |x: f64| std::f64::consts::SQRT_2 * (std::f64::consts::TAU * x).sin();
183    (angle(0.125 - pan / 8.0), angle(0.125 + pan / 8.0))
184}
185
186/// The change a modulation route adds to the stored value of its
187/// destination: `(source - 0.5 * bipolar) * amount * (max - min)`. It adds
188/// before the engine scale of the destination is applied.
189#[must_use]
190pub fn modulation_delta(source: f64, amount: f64, bipolar: bool, stored_span: f64) -> f64 {
191    let centre = if bipolar { 0.5 } else { 0.0 };
192    (source - centre) * amount * stored_span
193}
194
195/// The voice gain after the amplitude envelope:
196/// `(envelope * lerp(1, velocity, velocity_track) * voice_amplitude)^2`.
197///
198/// The square means that a measured RMS contour maps to the square root of
199/// the envelope level. Renders on the open engine confirm the square for
200/// the sustain level, the velocity and `voice_amplitude`.
201#[must_use]
202pub fn amp_gain(envelope: f64, velocity: f64, velocity_track: f64, voice_amplitude: f64) -> f64 {
203    let velocity_mix = 1.0 + (velocity - 1.0) * velocity_track;
204    (envelope * velocity_mix * voice_amplitude).powi(2)
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210    use approx::assert_relative_eq;
211
212    #[test]
213    fn every_law_has_a_provenance() {
214        let measured: Vec<&str> = PROVENANCE
215            .iter()
216            .filter(|(_, provenance)| *provenance == Provenance::Measured)
217            .map(|(name, _)| *name)
218            .collect();
219        assert_eq!(
220            measured,
221            [
222                "unison_outer_offset",
223                "unison_offsets",
224                "unison_gains",
225                "amp_gain"
226            ]
227        );
228        let source = include_str!("laws.rs");
229        let public = source.matches("\npub fn ").count();
230        assert_eq!(public, PROVENANCE.len(), "every public law has a row");
231        for (name, _) in PROVENANCE {
232            assert!(source.contains(&format!("pub fn {name}(")), "{name}");
233        }
234    }
235
236    // Voice offsets in cents, measured by rendering note 72 with
237    // `osc_1_detune_range` 2 and `osc_1_unison_detune` 10 on the open
238    // engine (vita 0.1.0), peaks found with parabolic interpolation.
239    const RENDERED_OFFSETS: [(u8, f64, &[f64]); 4] = [
240        (4, 0.0, &[-200.01, -66.67, 66.68, 200.01]),
241        (
242            8,
243            0.0,
244            &[
245                -200.01, -142.84, -85.72, -28.58, 28.56, 85.73, 142.86, 200.01,
246            ],
247        ),
248        (4, 1.5, &[-200.01, -37.26, 37.26, 200.01]),
249        (
250            8,
251            1.5,
252            &[-200.01, -110.27, -51.8, -13.72, 13.71, 51.8, 110.25, 200.01],
253        ),
254    ];
255
256    #[test]
257    fn offsets_match_the_render() {
258        let outer = unison_outer_offset(2.0, 10.0).unwrap().get();
259        assert_relative_eq!(outer, 200.0);
260        for (voices, power, rendered) in RENDERED_OFFSETS {
261            let computed = unison_offsets(voices, power);
262            assert_eq!(computed.len(), rendered.len(), "{voices} voices");
263            for (fraction, cents) in computed.iter().zip(rendered) {
264                assert!(
265                    (fraction * outer - cents).abs() < 0.03,
266                    "{voices} voices, power {power}: {computed:?}"
267                );
268            }
269        }
270    }
271
272    // Per-voice amplitudes over the single-voice amplitude, measured with a
273    // flat-top window on the same renders: (voices, blend, centre voice or
274    // inner-pair voice, outer voice).
275    const RENDERED_GAINS: [(u8, f64, f64, f64); 4] = [
276        (3, 0.5, 0.8412, 0.2704),
277        (4, 0.8, 0.3351, 0.3712),
278        (5, 1.0, 0.4264, 0.3199),
279        (8, 0.5, 0.3342, 0.2148),
280    ];
281
282    #[test]
283    fn gains_match_the_render() {
284        for (voices, blend, centre, outer) in RENDERED_GAINS {
285            let gains = unison_gains(voices, blend);
286            let centre_voice = if voices % 2 == 1 {
287                gains.centre
288            } else {
289                gains.centre / 2.0
290            };
291            assert!((centre_voice - centre).abs() < 1e-3, "{voices} at {blend}");
292            assert!(
293                (gains.detuned / 2.0 - outer).abs() < 1e-3,
294                "{voices} at {blend}"
295            );
296        }
297    }
298
299    #[test]
300    fn the_amp_gain_squares_like_the_render() {
301        // Renders: sustain 0.5 gives 0.25 of the amplitude at sustain 1, and
302        // velocity 0.5 at track 0.5 gives 0.5625 of velocity 1.
303        assert_relative_eq!(
304            amp_gain(0.5, 1.0, 0.0, 1.0) / amp_gain(1.0, 1.0, 0.0, 1.0),
305            0.25
306        );
307        assert_relative_eq!(
308            amp_gain(1.0, 0.5, 0.5, 1.0) / amp_gain(1.0, 1.0, 0.5, 1.0),
309            0.5625
310        );
311    }
312
313    /// `(e^(1.5 t) - 1) / (e^1.5 - 1)`, the default power curve.
314    fn curve(t: f64) -> f64 {
315        ((1.5 * t).exp() - 1.0) / (1.5f64.exp() - 1.0)
316    }
317
318    fn assert_offsets(voices: u8, expected: &[f64]) {
319        let got = unison_offsets(voices, 1.5);
320        assert_eq!(got.len(), expected.len(), "{voices} voices: {got:?}");
321        for (got, want) in got.iter().zip(expected) {
322            assert_relative_eq!(*got, *want, epsilon = 1e-12, max_relative = 1e-12);
323        }
324    }
325
326    #[test]
327    fn unison_offsets_match_the_formula() {
328        assert_offsets(1, &[0.0]);
329        assert_offsets(2, &[-1.0, 1.0]);
330        assert_offsets(3, &[-1.0, 0.0, 1.0]);
331        let third = curve(1.0 / 3.0);
332        assert_offsets(4, &[-1.0, -third, third, 1.0]);
333        let (inner, outer) = (curve(2.0 / 6.0), curve(4.0 / 6.0));
334        assert_offsets(7, &[-1.0, -outer, -inner, 0.0, inner, outer, 1.0]);
335        let (p1, p3, p5) = (curve(1.0 / 7.0), curve(3.0 / 7.0), curve(5.0 / 7.0));
336        assert_offsets(8, &[-1.0, -p5, -p3, -p1, p1, p3, p5, 1.0]);
337    }
338
339    #[test]
340    fn unison_offsets_round_to_the_fact_table() {
341        let round = |x: f64| (x * 1000.0).round() / 1000.0;
342        let positive = |voices| -> Vec<f64> {
343            unison_offsets(voices, 1.5)
344                .into_iter()
345                .filter(|x| *x >= 0.0)
346                .map(round)
347                .collect()
348        };
349        assert_eq!(positive(4), [0.186, 1.0]);
350        assert_eq!(positive(7), [0.0, 0.186, 0.494, 1.0]);
351        assert_eq!(positive(8), [0.069, 0.259, 0.551, 1.0]);
352    }
353
354    #[test]
355    fn outer_offset_is_range_times_detune_squared() {
356        // 2 * 4.472135955^2 = 40 cents; 48 * 10^2 = 4800 cents.
357        assert_relative_eq!(
358            unison_outer_offset(2.0, 4.472_135_955).unwrap().get(),
359            40.0,
360            max_relative = 1e-9
361        );
362        assert_relative_eq!(
363            unison_outer_offset(48.0, 10.0).unwrap().get(),
364            4800.0,
365            max_relative = 1e-12
366        );
367    }
368
369    #[test]
370    fn unison_gains_are_normalised() {
371        for voices in 3..=16 {
372            for blend in [0.0, 0.3, 0.8, 1.0] {
373                let gains = unison_gains(voices, blend);
374                let sum =
375                    gains.centre.powi(2) + gains.detuned.powi(2) * usize_to_f64(gains.pairs - 1);
376                assert_relative_eq!(sum, 1.0, epsilon = 1e-12);
377            }
378        }
379        assert_eq!(unison_gains(2, 0.8).centre, 1.0);
380        assert_eq!(unison_gains(2, 0.8).detuned, 0.0);
381    }
382
383    #[test]
384    fn tempo_rates_follow_the_division() {
385        let beat = Hz::new(2.0).unwrap();
386        assert_relative_eq!(
387            lfo_tempo_rate(8, TempoMode::Tempo, beat).unwrap().get(),
388            2.0,
389            max_relative = 1e-12
390        );
391        assert_relative_eq!(
392            lfo_tempo_rate(6, TempoMode::Tempo, beat).unwrap().get(),
393            0.5,
394            max_relative = 1e-12
395        );
396        assert_relative_eq!(
397            lfo_tempo_rate(8, TempoMode::Dotted, beat).unwrap().get(),
398            4.0 / 3.0,
399            max_relative = 1e-12
400        );
401        assert_relative_eq!(
402            lfo_tempo_rate(8, TempoMode::Triplets, beat).unwrap().get(),
403            3.0,
404            max_relative = 1e-12
405        );
406        assert_eq!(
407            lfo_tempo_rate(0, TempoMode::Tempo, beat).unwrap().get(),
408            0.0
409        );
410        assert!(lfo_tempo_rate(13, TempoMode::Tempo, beat).is_err());
411    }
412
413    #[test]
414    fn keytrack_rate_follows_the_note_law() {
415        // 8.1757989156 * 2^((69 - 12 + 0) / 12) = 220 Hz
416        let rate = lfo_keytrack_rate(
417            MidiPitch::new(69.0).unwrap(),
418            Semitones::new(-12.0).unwrap(),
419            Cents::new(0.0).unwrap(),
420        )
421        .unwrap();
422        assert_relative_eq!(rate.get(), 220.0, max_relative = 1e-6);
423    }
424
425    #[test]
426    fn pan_gains_follow_the_sine_law() {
427        let (l, r) = pan_gains(0.0);
428        assert_relative_eq!(l, 1.0, epsilon = 5e-3);
429        assert_relative_eq!(r, 1.0, epsilon = 5e-3);
430        let (l, r) = pan_gains(-1.0);
431        assert_relative_eq!(l, 1.414, epsilon = 5e-3);
432        assert_relative_eq!(r, 0.0, epsilon = 5e-3);
433        let (l, r) = pan_gains(0.5);
434        assert_relative_eq!(l, 0.541, epsilon = 5e-3);
435        assert_relative_eq!(r, 1.307, epsilon = 5e-3);
436    }
437
438    #[test]
439    fn modulation_depth_scales_by_the_stored_span() {
440        // (1 - 0) * 0.25 * (136 - 8) = 32 semitones
441        assert_relative_eq!(
442            modulation_delta(1.0, 0.25, false, 128.0),
443            32.0,
444            max_relative = 1e-12
445        );
446        assert_relative_eq!(
447            modulation_delta(1.0, 0.25, true, 128.0),
448            16.0,
449            max_relative = 1e-12
450        );
451        assert_relative_eq!(
452            modulation_delta(0.0, 0.25, true, 128.0),
453            -16.0,
454            max_relative = 1e-12
455        );
456    }
457
458    #[test]
459    fn power_scale_is_linear_near_zero_power() {
460        assert_eq!(power_scale(0.3, 0.005), 0.3);
461        assert_relative_eq!(power_scale(1.0, -2.0), 1.0, max_relative = 1e-12);
462        assert_relative_eq!(power_scale(0.5, 1.5), curve(0.5), max_relative = 1e-12);
463    }
464
465    #[test]
466    fn amp_gain_squares_the_envelope() {
467        // (0.5 * lerp(1, 0.7, 1) * 1)^2 = 0.1225
468        assert_relative_eq!(amp_gain(0.5, 0.7, 1.0, 1.0), 0.1225, max_relative = 1e-12);
469        assert_relative_eq!(amp_gain(0.5, 0.7, 0.0, 1.0), 0.25, max_relative = 1e-12);
470    }
471}