use std::f32::consts::TAU;
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum FmWave {
Sine,
Triangle,
}
impl FmWave {
#[inline]
fn shape(self, phase: f32) -> f32 {
match self {
Self::Sine => phase.sin(),
Self::Triangle => {
let t = phase / TAU;
4.0 * (t - (t + 0.5).floor()).abs() - 1.0
}
}
}
}
#[derive(Clone)]
pub(crate) struct FmPair {
mod_ratio: f32,
carrier_ratio: f32,
carrier_wave: FmWave,
index: f32,
index_decay: f32,
detune: f32,
level: f32,
mod_phase: f32,
carrier_phase: f32,
}
impl FmPair {
pub(crate) fn new(mod_ratio: f32, carrier_ratio: f32, index: f32) -> Self {
Self {
mod_ratio,
carrier_ratio,
carrier_wave: FmWave::Sine,
index,
index_decay: 1.0,
detune: 1.0,
level: 1.0,
mod_phase: 0.0,
carrier_phase: 0.0,
}
}
pub(crate) fn with_wave(mut self, wave: FmWave) -> Self {
self.carrier_wave = wave;
self
}
pub(crate) fn with_index_decay(mut self, tau_samples: f32) -> Self {
self.index_decay = (-1.0 / tau_samples.max(1.0)).exp();
self
}
#[allow(dead_code)]
pub(crate) fn with_detune(mut self, detune: f32) -> Self {
self.detune = detune;
self
}
#[allow(dead_code)]
pub(crate) fn with_level(mut self, level: f32) -> Self {
self.level = level;
self
}
#[inline]
fn next_carrier_phase(&mut self, base_hz: f32, sample_rate: f32) -> f32 {
let carrier_hz = base_hz * self.carrier_ratio;
self.mod_phase += TAU * (base_hz * self.mod_ratio) / sample_rate;
if self.mod_phase >= TAU {
self.mod_phase -= TAU;
}
let deviation = self.mod_phase.sin() * self.index * carrier_hz;
self.index *= self.index_decay;
self.carrier_phase += TAU * (carrier_hz + deviation) / sample_rate;
if self.carrier_phase >= TAU {
self.carrier_phase -= TAU;
}
self.carrier_phase
}
#[inline]
fn next(&mut self, base_hz: f32, sample_rate: f32) -> f32 {
let phase = self.next_carrier_phase(base_hz * self.detune, sample_rate);
self.carrier_wave.shape(phase) * self.level
}
}
pub(crate) const MAX_FM_PAIRS: usize = 4;
#[derive(Clone)]
pub(crate) struct FmStack {
pairs: [Option<FmPair>; MAX_FM_PAIRS],
sample_rate: f32,
}
impl FmStack {
pub(crate) fn new(sample_rate: f32) -> Self {
Self {
pairs: [const { None }; MAX_FM_PAIRS],
sample_rate,
}
}
pub(crate) fn with_pair(mut self, pair: FmPair) -> Self {
if let Some(slot) = self.pairs.iter_mut().find(|slot| slot.is_none()) {
*slot = Some(pair);
}
self
}
#[inline]
pub(crate) fn next(&mut self, base_hz: f32) -> f32 {
let sample_rate = self.sample_rate;
let mut sum = 0.0;
for pair in self.pairs.iter_mut().flatten() {
sum += pair.next(base_hz, sample_rate);
}
sum
}
}
#[cfg(test)]
pub(crate) fn rms(samples: &[f32]) -> f32 {
if samples.is_empty() {
return 0.0;
}
(samples.iter().map(|s| s * s).sum::<f32>() / samples.len() as f32).sqrt()
}
#[cfg(test)]
mod tests {
use super::*;
fn render(stack: &mut FmStack, base_hz: f32, samples: usize) -> Vec<f32> {
(0..samples).map(|_| stack.next(base_hz)).collect()
}
#[test]
fn a_zero_index_pair_is_a_plain_carrier_oscillator() {
let mut stack = FmStack::new(48_000.0).with_pair(FmPair::new(1.0, 1.0, 0.0));
let mut reference_phase = 0.0f32;
for sample in render(&mut stack, 440.0, 512) {
reference_phase += TAU * 440.0 / 48_000.0;
if reference_phase >= TAU {
reference_phase -= TAU;
}
assert!((sample - reference_phase.sin()).abs() < 1e-6);
}
}
#[test]
fn index_decay_narrows_the_spectrum_over_time() {
let mut stack =
FmStack::new(48_000.0).with_pair(FmPair::new(2.0, 1.0, 4.0).with_index_decay(2_000.0));
let rendered = render(&mut stack, 110.0, 48_000);
let opening = rms(&rendered[..2_000]);
let tail = rms(&rendered[40_000..]);
assert!(
opening > tail,
"expected the decaying index to shed energy: {opening} vs {tail}"
);
}
#[test]
fn parallel_pairs_sum_into_one_output() {
let recipe = || FmPair::new(1.0, 1.0, 0.0).with_level(0.5);
let mut single = FmStack::new(48_000.0).with_pair(recipe());
let mut doubled = FmStack::new(48_000.0)
.with_pair(recipe())
.with_pair(recipe());
for (one, two) in
render(&mut single, 220.0, 256)
.into_iter()
.zip(render(&mut doubled, 220.0, 256))
{
assert!((two - one * 2.0).abs() < 1e-6);
}
}
#[test]
fn detune_shifts_a_pairs_base_frequency() {
let mut tuned = FmStack::new(48_000.0).with_pair(FmPair::new(1.0, 1.0, 0.0));
let mut detuned =
FmStack::new(48_000.0).with_pair(FmPair::new(1.0, 1.0, 0.0).with_detune(1.01));
let a = render(&mut tuned, 220.0, 48_000);
let b = render(&mut detuned, 220.0, 48_000);
let difference = rms(&a.iter().zip(&b).map(|(x, y)| x - y).collect::<Vec<_>>());
assert!(difference > 0.1, "detuned pair tracked the tuned one");
}
#[test]
fn pairs_past_the_cap_are_dropped_without_disturbing_the_rest() {
let recipe = || FmPair::new(1.0, 1.0, 0.0).with_level(1.0);
let mut capped = FmStack::new(48_000.0);
for _ in 0..MAX_FM_PAIRS + 3 {
capped = capped.with_pair(recipe());
}
let peak = render(&mut capped, 220.0, 2_000)
.into_iter()
.fold(0.0f32, |acc, s| acc.max(s.abs()));
assert!(peak <= MAX_FM_PAIRS as f32 + 1e-3);
}
}