1use crate::cast::usize_to_f64;
9use crate::preset::registry::Provenance;
10use crate::units::{Cents, Hz, MidiPitch, RangeError, Semitones};
11
12pub 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
29pub const MIDI_0_HZ: f64 = 8.175_798_915_6;
31
32#[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
43pub fn unison_outer_offset(detune_range: f64, detune: f64) -> Result<Cents, RangeError> {
51 Cents::new(detune_range * detune * detune)
52}
53
54#[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#[derive(Clone, Copy, Debug, PartialEq)]
83pub struct UnisonGains {
84 pub centre: f64,
87 pub detuned: f64,
89 pub pairs: usize,
91}
92
93#[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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
128pub enum TempoMode {
129 Tempo,
131 Dotted,
133 Triplets,
135}
136
137pub 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
160pub 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#[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#[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#[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 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 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 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 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 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 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 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 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}