#![allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
use std::f32::consts::TAU;
const TABLE_SIZE: usize = 2048;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WtWaveform {
Sine,
Triangle,
Square,
SawUp,
SawDown,
SoftSine,
}
fn build_table(waveform: WtWaveform) -> Box<[f32; TABLE_SIZE]> {
let mut table = Box::new([0.0_f32; TABLE_SIZE]);
for (i, entry) in table.iter_mut().enumerate() {
let phase = i as f32 / TABLE_SIZE as f32; *entry = match waveform {
WtWaveform::Sine => (phase * TAU).sin(),
WtWaveform::Triangle => {
if phase < 0.25 {
phase * 4.0
} else if phase < 0.75 {
2.0 - phase * 4.0
} else {
phase * 4.0 - 4.0
}
}
WtWaveform::Square => {
if phase < 0.5 {
1.0
} else {
-1.0
}
}
WtWaveform::SawUp => phase * 2.0 - 1.0,
WtWaveform::SawDown => 1.0 - phase * 2.0,
WtWaveform::SoftSine => {
let rc = (1.0 - (phase * TAU).cos()) / 2.0;
rc * 2.0 - 1.0
}
};
}
table
}
pub struct WavetableLfo {
table: Box<[f32; TABLE_SIZE]>,
phase: f64,
phase_inc: f64,
amplitude: f32,
offset: f32,
waveform: WtWaveform,
}
impl WavetableLfo {
#[must_use]
pub fn new(waveform: WtWaveform, frequency: f32, sample_rate: f32) -> Self {
let freq = frequency.clamp(0.001, 200.0);
let sr = sample_rate.max(1.0);
Self {
table: build_table(waveform),
phase: 0.0,
phase_inc: (freq as f64) / (sr as f64),
amplitude: 1.0,
offset: 0.0,
waveform,
}
}
#[must_use]
pub fn waveform(&self) -> WtWaveform {
self.waveform
}
#[must_use]
pub fn frequency(&self) -> f32 {
self.phase_inc as f32
}
pub fn set_frequency(&mut self, frequency: f32, sample_rate: f32) {
let freq = frequency.clamp(0.001, 200.0);
let sr = sample_rate.max(1.0);
self.phase_inc = (freq as f64) / (sr as f64);
}
pub fn set_amplitude(&mut self, amplitude: f32) {
self.amplitude = amplitude.clamp(0.0, 1.0);
}
pub fn set_offset(&mut self, offset: f32) {
self.offset = offset;
}
pub fn set_waveform(&mut self, waveform: WtWaveform) {
if waveform != self.waveform {
self.table = build_table(waveform);
self.waveform = waveform;
}
}
pub fn reset(&mut self) {
self.phase = 0.0;
}
pub fn set_phase(&mut self, phase: f64) {
self.phase = phase.rem_euclid(1.0);
}
fn table_read(&self, phase: f64) -> f32 {
let frac_pos = phase * TABLE_SIZE as f64;
let idx_lo = (frac_pos as usize) % TABLE_SIZE;
let idx_hi = (idx_lo + 1) % TABLE_SIZE;
let frac = (frac_pos - frac_pos.floor()) as f32;
self.table[idx_lo] + frac * (self.table[idx_hi] - self.table[idx_lo])
}
pub fn next_sample(&mut self) -> f32 {
let out = self.table_read(self.phase);
self.phase = (self.phase + self.phase_inc).rem_euclid(1.0);
out * self.amplitude + self.offset
}
pub fn fill(&mut self, buffer: &mut [f32]) {
for sample in buffer.iter_mut() {
*sample = self.next_sample();
}
}
#[must_use]
pub fn collect(&mut self, n: usize) -> Vec<f32> {
let mut v = vec![0.0; n];
self.fill(&mut v);
v
}
#[must_use]
pub fn phase(&self) -> f64 {
self.phase
}
}
pub struct StereoWavetableLfo {
pub left: WavetableLfo,
pub right: WavetableLfo,
}
impl StereoWavetableLfo {
#[must_use]
pub fn new(
waveform: WtWaveform,
frequency: f32,
sample_rate: f32,
phase_offset: f32,
) -> Self {
let mut left = WavetableLfo::new(waveform, frequency, sample_rate);
let mut right = WavetableLfo::new(waveform, frequency, sample_rate);
right.set_phase(phase_offset as f64);
left.set_phase(0.0);
Self { left, right }
}
pub fn set_frequency(&mut self, frequency: f32, sample_rate: f32) {
self.left.set_frequency(frequency, sample_rate);
self.right.set_frequency(frequency, sample_rate);
}
pub fn set_amplitude(&mut self, amplitude: f32) {
self.left.set_amplitude(amplitude);
self.right.set_amplitude(amplitude);
}
pub fn next_sample(&mut self) -> (f32, f32) {
(self.left.next_sample(), self.right.next_sample())
}
pub fn reset(&mut self) {
self.left.reset();
self.right.reset();
}
}
#[cfg(test)]
mod tests {
use super::*;
const SR: f32 = 48_000.0;
fn sine_peak_approx(lfo: &mut WavetableLfo, samples: usize) -> f32 {
let mut max = 0.0_f32;
for _ in 0..samples {
max = max.max(lfo.next_sample().abs());
}
max
}
fn lfo_mean(lfo: &mut WavetableLfo, n: usize) -> f32 {
let sum: f32 = (0..n).map(|_| lfo.next_sample()).sum();
sum / n as f32
}
fn full_cycle_rms(waveform: WtWaveform, freq: f32) -> f32 {
let mut lfo = WavetableLfo::new(waveform, freq, SR);
let n = (SR / freq) as usize * 4; let samples: Vec<f32> = lfo.collect(n);
let mean_sq: f32 = samples.iter().map(|&s| s * s).sum::<f32>() / n as f32;
mean_sq.sqrt()
}
#[test]
fn test_sine_range() {
let mut lfo = WavetableLfo::new(WtWaveform::Sine, 1.0, SR);
let peak = sine_peak_approx(&mut lfo, SR as usize);
assert!(
peak > 0.99 && peak <= 1.0,
"sine peak should be ~1.0, got {peak}"
);
}
#[test]
fn test_all_waveforms_finite() {
for wf in [
WtWaveform::Sine,
WtWaveform::Triangle,
WtWaveform::Square,
WtWaveform::SawUp,
WtWaveform::SawDown,
WtWaveform::SoftSine,
] {
let mut lfo = WavetableLfo::new(wf, 10.0, SR);
for _ in 0..4800 {
let s = lfo.next_sample();
assert!(s.is_finite(), "waveform {wf:?} produced non-finite output {s}");
}
}
}
#[test]
fn test_amplitude_scaling() {
let mut lfo = WavetableLfo::new(WtWaveform::Sine, 5.0, SR);
lfo.set_amplitude(0.5);
let peak = sine_peak_approx(&mut lfo, SR as usize);
assert!(
peak > 0.49 && peak <= 0.5 + 1e-3,
"amplitude=0.5 peak should be ~0.5, got {peak}"
);
}
#[test]
fn test_reset_restores_phase() {
let mut lfo = WavetableLfo::new(WtWaveform::Sine, 1.0, SR);
let first = lfo.next_sample();
for _ in 0..1000 {
let _ = lfo.next_sample();
}
lfo.reset();
let after_reset = lfo.next_sample();
assert!(
(first - after_reset).abs() < 1e-4,
"reset should reproduce first sample; first={first}, after_reset={after_reset}"
);
}
#[test]
fn test_frequency_change() {
let mut lfo = WavetableLfo::new(WtWaveform::Sine, 1.0, SR);
let old_inc = lfo.phase_inc;
lfo.set_frequency(10.0, SR);
assert!(
lfo.phase_inc > old_inc,
"higher frequency must give larger phase increment"
);
}
#[test]
fn test_square_wave_values() {
let mut lfo = WavetableLfo::new(WtWaveform::Square, 440.0, SR);
let samples = lfo.collect((SR / 440.0) as usize);
let all_bipolar = samples.iter().all(|&s| (s - 1.0).abs() < 1e-3 || (s + 1.0).abs() < 1e-3);
assert!(all_bipolar, "square wave should produce values near ±1");
}
#[test]
fn test_sine_near_zero_mean() {
let mut lfo = WavetableLfo::new(WtWaveform::Sine, 1.0, SR);
let mean = lfo_mean(&mut lfo, SR as usize);
assert!(
mean.abs() < 1e-2,
"sine LFO mean should be ~0, got {mean}"
);
}
#[test]
fn test_saw_up_monotone() {
let mut lfo = WavetableLfo::new(WtWaveform::SawUp, 10.0, SR);
let n = (SR / 10.0) as usize - 1; let samples = lfo.collect(n);
let increasing = samples.windows(2).filter(|w| w[0] < w[1]).count();
assert!(
increasing > (n * 9 / 10),
"saw-up should mostly increase: {increasing}/{n} pairs"
);
}
#[test]
fn test_stereo_lfo_phase_offset() {
let mut stereo = StereoWavetableLfo::new(WtWaveform::Sine, 1.0, SR, 0.25);
let (l, r) = stereo.next_sample();
assert!(l.abs() < 0.1, "left initial phase ≈ 0 (sin=0), got {l}");
assert!((r - 1.0).abs() < 0.1, "right 90° ahead ≈ 1 (sin=π/2), got {r}");
}
#[test]
fn test_fill_buffer() {
let mut lfo = WavetableLfo::new(WtWaveform::Sine, 100.0, SR);
let mut buf = vec![0.0_f32; 256];
lfo.fill(&mut buf);
assert!(
buf.iter().all(|&s| s.is_finite() && s.abs() <= 1.001),
"fill buffer should produce finite samples in [-1,1]"
);
}
#[test]
fn test_triangle_rms_approx_half() {
let rms = full_cycle_rms(WtWaveform::Triangle, 1.0);
assert!(
(rms - (1.0_f32 / 3.0_f32.sqrt())).abs() < 0.02,
"triangle RMS should be ~{:.3}, got {rms}",
1.0_f32 / 3.0_f32.sqrt()
);
}
#[test]
fn test_set_phase_manual() {
let mut lfo = WavetableLfo::new(WtWaveform::Sine, 1.0, SR);
lfo.set_phase(0.25);
let s = lfo.next_sample();
assert!(
(s - 1.0).abs() < 0.01,
"phase=0.25 on sine should ≈ 1.0, got {s}"
);
}
#[test]
fn test_waveform_switch() {
let mut lfo = WavetableLfo::new(WtWaveform::Sine, 1.0, SR);
assert_eq!(lfo.waveform(), WtWaveform::Sine);
lfo.set_waveform(WtWaveform::Triangle);
assert_eq!(lfo.waveform(), WtWaveform::Triangle);
let s = lfo.next_sample();
assert!(s.is_finite());
}
#[test]
fn test_soft_sine_bounded() {
let mut lfo = WavetableLfo::new(WtWaveform::SoftSine, 5.0, SR);
for _ in 0..9600 {
let s = lfo.next_sample();
assert!(
s >= -1.001 && s <= 1.001,
"soft-sine must stay in [-1, 1], got {s}"
);
}
}
}