use crate::audio::{
crusher::Bitcrusher,
env::{EnvStage, Envelope},
filter::SvFilter,
osc::{Lfo, Oscillator, detune_hz, midi_to_hz},
};
use crate::params::{MidiNote, RingModMode, SynthParams};
#[derive(Default)]
struct ModBus {
pitch: f32,
pw: f32,
cutoff: f32,
volume: f32,
}
impl ModBus {
fn add_lfo(&mut self, lfo_val: f32, depth: f32, target: crate::params::LfoTarget) {
use crate::params::LfoTarget;
let c = lfo_val * depth;
match target {
LfoTarget::Pitch => self.pitch += c,
LfoTarget::PulseWidth => self.pw += c,
LfoTarget::Cutoff => self.cutoff += c,
LfoTarget::Volume => self.volume += c,
}
}
}
pub struct Voice {
pub active: bool,
pub target_note: MidiNote,
pub target_freq: f32,
pub current_freq: f32,
pub osc: Oscillator,
pub osc2: Oscillator,
pub crusher: Bitcrusher,
pub env: Envelope,
pub filter: SvFilter,
pub lfo: Lfo,
pub lfo2: Lfo,
pub filter_env: Envelope,
pub pitch_env: Envelope,
pub glide_coeff: f32,
}
impl Default for Voice {
fn default() -> Self {
Self::new()
}
}
impl Voice {
#[must_use]
pub fn new() -> Self {
Self {
active: false,
target_note: MidiNote::A4,
target_freq: 440.0,
current_freq: 440.0,
osc: Oscillator::default(),
osc2: Oscillator::default(),
crusher: Bitcrusher::default(),
env: Envelope::default(),
filter: SvFilter::default(),
lfo: Lfo::default(),
lfo2: Lfo::seeded(0xDEAD_BEEF),
filter_env: Envelope::default(),
pitch_env: Envelope::default(),
glide_coeff: 0.0,
}
}
pub fn update_glide(&mut self, glide_time: f32, sample_rate: f32) {
self.glide_coeff = if glide_time < 1e-4 {
0.0
} else {
crate::math::expf(-1.0_f32 / (glide_time * sample_rate))
};
}
pub fn note_on(&mut self, note: impl Into<MidiNote>, params: &SynthParams, sample_rate: f32) {
let note = note.into();
let legato = self.active;
self.target_note = note;
let base = midi_to_hz(note);
self.target_freq = detune_hz(base, params.osc.detune);
if !legato {
self.current_freq = self.target_freq;
self.crusher.reset();
}
self.active = true;
self.update_glide(params.global.glide_time, sample_rate);
self.env.note_on(legato);
self.filter_env.note_on(legato);
self.pitch_env.note_on(legato);
}
pub fn note_off(&mut self) {
self.env.note_off();
self.filter_env.note_off();
self.pitch_env.note_off();
}
pub fn panic(&mut self) {
self.active = false;
self.env.reset();
self.filter.reset();
self.filter_env.reset();
self.pitch_env.reset();
self.crusher.reset();
}
pub fn process(&mut self, params: &SynthParams, sample_rate: f32) -> f32 {
if !self.active && !self.env.is_active() {
return 0.0;
}
if self.env.stage == EnvStage::Idle && !self.env.is_active() {
self.active = false;
}
let mut bus = ModBus::default();
let lfo1 = self
.lfo
.next(params.lfo.lfo_rate, params.lfo.lfo_shape, sample_rate);
bus.add_lfo(lfo1, params.lfo.lfo_depth, params.lfo.lfo_target);
let lfo2 = self
.lfo2
.next(params.lfo2.lfo_rate, params.lfo2.lfo_shape, sample_rate);
bus.add_lfo(lfo2, params.lfo2.lfo_depth, params.lfo2.lfo_target);
let fenv = self.filter_env.process(
params.filter_env.attack,
params.filter_env.decay,
params.filter_env.sustain,
params.filter_env.release,
false,
sample_rate,
);
bus.cutoff += fenv * params.filter_env.depth;
let penv = self.pitch_env.process(
params.pitch_env.attack,
params.pitch_env.decay,
params.pitch_env.sustain,
params.pitch_env.release,
false,
sample_rate,
);
bus.pitch += penv * params.pitch_env.depth * 10.0;
let gc = self.glide_coeff;
self.current_freq = self.target_freq + (self.current_freq - self.target_freq) * gc;
let freq = self.current_freq;
let modded_freq = freq * crate::math::powf(2.0_f32, bus.pitch * 0.1);
let final_freq = modded_freq;
let pw = (params.osc.pulse_width + bus.pw * 0.4).clamp(0.05, 0.95);
let osc_out = self.osc.next_sample(
final_freq,
sample_rate,
params.osc.waveform,
pw,
params.osc.noise_mix,
);
let osc_out = if params.osc2.osc2_mix > 0.001 {
if params.osc2.hard_sync && self.osc.just_wrapped() {
self.osc2.reset();
}
let osc2_freq = detune_hz(final_freq, params.osc2.detune);
let secondary =
self.osc2
.next_sample(osc2_freq, sample_rate, params.osc2.waveform, pw, 0.0);
let modulated = match params.osc2.ring_mod {
RingModMode::Off => secondary,
RingModMode::Osc2ByOsc1Sign => secondary * self.osc.phase_sign(),
RingModMode::Osc1ByOsc2Sign => osc_out * self.osc2.phase_sign(),
RingModMode::Analog => osc_out * secondary,
};
osc_out * (1.0 - params.osc2.osc2_mix) + modulated * params.osc2.osc2_mix
} else {
osc_out
};
let osc_out = self
.crusher
.process(osc_out, params.crusher.bits, params.crusher.rate);
let env_val = self.env.process(
params.env.attack,
params.env.decay,
params.env.sustain,
params.env.release,
params.env.env_reverse,
sample_rate,
);
let vol_mod = (1.0 - bus.volume * 0.5).max(0.0);
let cutoff_mod = (params.filter.cutoff * crate::math::powf(2.0_f32, bus.cutoff * 2.0))
.clamp(20.0, 18000.0);
let filtered = self.filter.process(
osc_out * env_val,
params.filter.filter_mode,
cutoff_mod,
params.filter.resonance,
params.filter.drive,
sample_rate,
);
filtered * env_val * vol_mod * params.global.volume
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::params::{MidiNote, RingModMode, SynthParams, Waveform};
#[test]
fn voice_osc2_mix_zero_is_finite() {
let mut voice = Voice::new();
let params = SynthParams::default(); voice.note_on(MidiNote::A4, ¶ms, 44100.0);
for _ in 0..1000 {
let s = voice.process(¶ms, 44100.0);
assert!(s.is_finite(), "non-finite sample with osc2 off: {s}");
}
}
#[test]
fn voice_osc2_hard_sync_is_finite() {
let mut voice = Voice::new();
let mut params = SynthParams::default();
params.osc2.osc2_mix = 0.5;
params.osc2.hard_sync = true;
params.osc2.waveform = Waveform::Sawtooth;
params.osc2.detune = 7.0;
voice.note_on(MidiNote::A4, ¶ms, 44100.0);
for _ in 0..1000 {
let s = voice.process(¶ms, 44100.0);
assert!(s.is_finite(), "non-finite sample with hard sync on: {s}");
}
}
#[test]
fn voice_hard_sync_resets_osc2_phase() {
use crate::params::{EnvParams, FilterMode, FilterParams, GlobalParams};
let mut params = SynthParams::default();
params.osc.waveform = Waveform::Sawtooth;
params.osc2.waveform = Waveform::Sawtooth;
params.osc2.detune = 700.0; params.osc2.osc2_mix = 1.0; params.osc2.hard_sync = true;
params.env = EnvParams {
attack: 0.0,
decay: 0.0,
sustain: 1.0,
release: 0.0,
env_reverse: false,
};
params.filter = FilterParams {
filter_mode: FilterMode::LowPass,
cutoff: 20000.0,
resonance: 0.0,
drive: 0.0,
};
params.global = GlobalParams {
volume: 1.0,
glide_time: 0.0,
};
let mut voice = Voice::new();
voice.note_on(MidiNote::A4, ¶ms, 44100.0);
let mut wrap_samples: Vec<f32> = Vec::new();
for _ in 0..600 {
let s = voice.process(¶ms, 44100.0);
if voice.osc.just_wrapped() {
wrap_samples.push(s);
}
}
assert!(
wrap_samples.len() >= 4,
"expected ≥4 OSC1 wraps in 600 samples, got {}",
wrap_samples.len()
);
for (i, &s) in wrap_samples[1..].iter().enumerate() {
assert!(
s < -0.5,
"wrap sample {}: expected output near -1 (OSC2 reset to phase 0), got {s}",
i + 1
);
}
let mut params_free = params.clone();
params_free.osc2.hard_sync = false;
let mut voice_free = Voice::new();
voice_free.note_on(MidiNote::A4, ¶ms_free, 44100.0);
let mut any_positive = false;
for _ in 0..600 {
let s = voice_free.process(¶ms_free, 44100.0);
if voice_free.osc.just_wrapped() && s > 0.0 {
any_positive = true;
break;
}
}
assert!(
any_positive,
"unsynced OSC2 should produce positive values at some OSC1 wrap-points"
);
}
#[test]
fn filter_env_opens_cutoff() {
use crate::params::{
EnvParams, FilterMode, FilterParams, GlobalParams, ModEnvParams, Waveform,
};
let mut params = SynthParams {
filter: FilterParams {
filter_mode: FilterMode::LowPass,
cutoff: 200.0,
resonance: 0.0,
drive: 0.0,
},
filter_env: ModEnvParams {
attack: 0.001,
decay: 4.0,
sustain: 1.0,
release: 4.0,
depth: 1.0,
},
env: EnvParams {
attack: 0.001,
decay: 0.0,
sustain: 1.0,
release: 4.0,
env_reverse: false,
},
global: GlobalParams {
volume: 1.0,
glide_time: 0.0,
},
..SynthParams::default()
};
params.osc.waveform = Waveform::Sawtooth;
params.lfo.lfo_depth = 0.0;
params.lfo2.lfo_depth = 0.0;
params.pitch_env.depth = 0.0;
let mut v_open = Voice::new();
v_open.note_on(MidiNote::A4, ¶ms, 44100.0);
let rms_open: f32 = (0..4410)
.map(|_| v_open.process(¶ms, 44100.0).powi(2))
.sum::<f32>()
.sqrt();
let mut params_closed = params.clone();
params_closed.filter_env.depth = 0.0;
let mut v_closed = Voice::new();
v_closed.note_on(MidiNote::A4, ¶ms_closed, 44100.0);
let rms_closed: f32 = (0..4410)
.map(|_| v_closed.process(¶ms_closed, 44100.0).powi(2))
.sum::<f32>()
.sqrt();
assert!(
rms_open > rms_closed * 1.5,
"filter env depth=1 should pass more signal than depth=0: open={rms_open:.4}, closed={rms_closed:.4}"
);
}
#[test]
fn pitch_env_is_finite() {
use crate::params::ModEnvParams;
let params = SynthParams {
pitch_env: ModEnvParams {
attack: 0.001,
decay: 0.5,
sustain: 0.0,
release: 0.1,
depth: 1.0,
},
..SynthParams::default()
};
let mut voice = Voice::new();
voice.note_on(MidiNote::A4, ¶ms, 44100.0);
for i in 0..4410 {
let s = voice.process(¶ms, 44100.0);
assert!(
s.is_finite(),
"non-finite sample at {i} with pitch env: {s}"
);
}
}
#[test]
fn lfo2_and_lfo1_both_active_is_finite() {
use crate::params::LfoShape;
let mut params = SynthParams::default();
params.lfo.lfo_depth = 0.5;
params.lfo.lfo_shape = LfoShape::Square;
params.lfo.lfo_target = crate::params::LfoTarget::Pitch;
params.lfo2.lfo_depth = 0.5;
params.lfo2.lfo_shape = LfoShape::Sawtooth;
params.lfo2.lfo_target = crate::params::LfoTarget::Pitch;
let mut voice = Voice::new();
voice.note_on(MidiNote::A4, ¶ms, 44100.0);
for i in 0..4410 {
let s = voice.process(¶ms, 44100.0);
assert!(s.is_finite(), "non-finite sample at {i} with two LFOs: {s}");
}
}
#[test]
fn ring_mod_modes_alter_output() {
use crate::params::EnvParams;
let base_params = {
let mut p = SynthParams::default();
p.osc.waveform = Waveform::Sawtooth;
p.osc2.osc2_mix = 1.0;
p.osc2.waveform = Waveform::Sawtooth;
p.osc2.detune = 700.0; p.env = EnvParams {
attack: 0.0,
decay: 0.0,
sustain: 1.0,
release: 0.0,
env_reverse: false,
};
p
};
let mut params_off = base_params.clone();
params_off.osc2.ring_mod = RingModMode::Off;
let mut voice_off = Voice::new();
voice_off.note_on(MidiNote::A4, ¶ms_off, 44100.0);
let samples_off: Vec<f32> = (0..500)
.map(|_| voice_off.process(¶ms_off, 44100.0))
.collect();
for mode in [
RingModMode::Osc2ByOsc1Sign,
RingModMode::Osc1ByOsc2Sign,
RingModMode::Analog,
] {
let mut params_rm = base_params.clone();
params_rm.osc2.ring_mod = mode;
let mut voice_rm = Voice::new();
voice_rm.note_on(MidiNote::A4, ¶ms_rm, 44100.0);
let abs_diff: f32 = samples_off
.iter()
.map(|&off| {
let rm = voice_rm.process(¶ms_rm, 44100.0);
(rm - off).abs()
})
.sum();
assert!(
abs_diff > 0.1,
"RingModMode::{mode:?}: cumulative |diff| vs Off = {abs_diff:.6} (expected > 0.1)"
);
}
}
#[test]
fn ring_mod_analog_output_is_finite() {
use crate::params::EnvParams;
let mut params = SynthParams::default();
params.osc2.osc2_mix = 1.0;
params.osc2.waveform = Waveform::Sawtooth;
params.osc2.detune = 700.0;
params.osc2.ring_mod = RingModMode::Analog;
params.env = EnvParams {
attack: 0.0,
decay: 0.0,
sustain: 1.0,
release: 0.0,
env_reverse: false,
};
let mut voice = Voice::new();
voice.note_on(MidiNote::A4, ¶ms, 44100.0);
for i in 0..1000 {
let s = voice.process(¶ms, 44100.0);
assert!(s.is_finite(), "sample {i}: non-finite output: {s}");
}
}
#[test]
fn ring_mod_off_is_deterministic() {
let mut params_a = SynthParams::default();
params_a.osc2.osc2_mix = 0.5;
params_a.osc2.waveform = Waveform::Sawtooth;
params_a.osc2.detune = 7.0;
params_a.osc2.ring_mod = RingModMode::Off;
let params_b = params_a.clone();
let mut voice_a = Voice::new();
voice_a.note_on(MidiNote::A4, ¶ms_a, 44100.0);
let mut voice_b = Voice::new();
voice_b.note_on(MidiNote::A4, ¶ms_b, 44100.0);
for i in 0..200 {
let a = voice_a.process(¶ms_a, 44100.0);
let b = voice_b.process(¶ms_b, 44100.0);
assert!(
a.to_bits() == b.to_bits(),
"sample {i}: Off mode is non-deterministic: {a} != {b}"
);
}
}
}