use std::sync::Arc;
use super::Voice;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SampleLoopMode {
NoLoop,
OneShot,
LoopContinuous,
LoopSustain,
}
#[derive(Clone, Copy, Debug)]
pub struct EnvelopeParams {
pub delay_s: f32,
pub attack_s: f32,
pub hold_s: f32,
pub decay_s: f32,
pub sustain_level: f32,
pub release_s: f32,
}
impl Default for EnvelopeParams {
fn default() -> Self {
Self {
delay_s: 0.0,
attack_s: 0.005,
hold_s: 0.0,
decay_s: 0.100,
sustain_level: 1.0,
release_s: 0.100,
}
}
}
#[derive(Clone, Copy, Debug, Default)]
pub struct VibratoParams {
pub freq_hz: f32,
pub depth_cents: f32,
pub delay_s: f32,
}
#[derive(Clone, Copy, Debug)]
pub struct ModEnvParams {
pub delay_s: f32,
pub attack_s: f32,
pub hold_s: f32,
pub decay_s: f32,
pub sustain_level: f32,
pub release_s: f32,
pub to_filter_cents: i32,
}
impl Default for ModEnvParams {
fn default() -> Self {
Self {
delay_s: 0.0,
attack_s: 0.0,
hold_s: 0.0,
decay_s: 0.0,
sustain_level: 0.0,
release_s: 0.0,
to_filter_cents: 0,
}
}
}
impl ModEnvParams {
pub fn is_inert(&self) -> bool {
self.to_filter_cents == 0
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum FilterType {
OnePoleLowPass,
OnePoleHighPass,
#[default]
TwoPoleLowPass,
TwoPoleHighPass,
TwoPoleBandPass,
TwoPoleBandReject,
}
impl FilterType {
pub fn parse_sfz(s: &str) -> Self {
match s.trim().to_ascii_lowercase().as_str() {
"lpf_1p" => FilterType::OnePoleLowPass,
"hpf_1p" => FilterType::OnePoleHighPass,
"lpf_2p" => FilterType::TwoPoleLowPass,
"hpf_2p" => FilterType::TwoPoleHighPass,
"bpf_2p" => FilterType::TwoPoleBandPass,
"brf_2p" => FilterType::TwoPoleBandReject,
_ => FilterType::TwoPoleLowPass,
}
}
pub fn is_one_pole(self) -> bool {
matches!(
self,
FilterType::OnePoleLowPass | FilterType::OnePoleHighPass
)
}
}
#[derive(Clone, Copy, Debug)]
pub struct FilterParams {
pub cutoff_cents: i32,
pub q_centibels: i32,
pub kind: FilterType,
}
impl Default for FilterParams {
fn default() -> Self {
Self {
cutoff_cents: 13_500,
q_centibels: 0,
kind: FilterType::TwoPoleLowPass,
}
}
}
#[derive(Clone, Debug)]
pub struct SamplePlayerConfig {
pub samples: Arc<[f32]>,
pub native_rate: u32,
pub loop_start: u32,
pub loop_end: u32,
pub sample_end: u32,
pub loop_mode: SampleLoopMode,
pub pitch_ratio: f64,
pub amplitude: f32,
pub envelope: EnvelopeParams,
pub vibrato: VibratoParams,
pub mod_env: ModEnvParams,
pub filter: FilterParams,
pub exclusive_class: u16,
}
pub struct SamplePlayer {
samples: Arc<[f32]>,
end: u32,
loop_start: u32,
loop_end: u32,
loop_mode: SampleLoopMode,
phase: f64,
base_phase_inc: f64,
phase_inc: f64,
amplitude: f32,
pressure_gain: f32,
pitch_bend_cents: i32,
elapsed: u32,
release_pos: Option<u32>,
release_start_level: f32,
delay_samples: u32,
attack_samples: u32,
hold_samples: u32,
decay_samples: u32,
release_samples: u32,
sustain_level: f32,
done: bool,
lfo_freq_hz: f32,
lfo_depth_cents: f32,
lfo_delay_samples: u32,
output_rate: f32,
exclusive_class: u16,
loop_broken: bool,
mod_env_delay: u32,
mod_env_attack: u32,
mod_env_hold: u32,
mod_env_decay: u32,
mod_env_release: u32,
mod_env_sustain_level: f32,
mod_env_release_start_level: f32,
mod_env_to_filter_cents: i32,
initial_filter_fc_cents: i32,
initial_filter_q_cb: i32,
filter_kind: FilterType,
filter: Option<BiquadState>,
}
struct BiquadState {
last_cutoff_cents: i32,
a1: f32,
a2: f32,
b0: f32,
b1: f32,
b2: f32,
x1: f32,
x2: f32,
y1: f32,
y2: f32,
}
impl BiquadState {
fn new() -> Self {
Self {
last_cutoff_cents: i32::MIN,
a1: 0.0,
a2: 0.0,
b0: 1.0,
b1: 0.0,
b2: 0.0,
x1: 0.0,
x2: 0.0,
y1: 0.0,
y2: 0.0,
}
}
fn tick(&mut self, x: f32) -> f32 {
let y = self.b0 * x + self.b1 * self.x1 + self.b2 * self.x2
- self.a1 * self.y1
- self.a2 * self.y2;
self.x2 = self.x1;
self.x1 = x;
self.y2 = self.y1;
self.y1 = y;
y
}
}
impl SamplePlayer {
pub fn new(cfg: SamplePlayerConfig, output_rate: u32) -> Self {
let sr = output_rate.max(1) as f32;
let phase_inc = cfg.pitch_ratio * (cfg.native_rate as f64 / output_rate.max(1) as f64);
let buf_len = cfg.samples.len() as u32;
let end = cfg.sample_end.min(buf_len);
let loop_end = cfg.loop_end.min(end);
let loop_start = cfg.loop_start.min(loop_end);
let non_lowpass = !matches!(
cfg.filter.kind,
FilterType::TwoPoleLowPass | FilterType::OnePoleLowPass
);
let needs_filter = cfg.filter.cutoff_cents < 13_000
|| cfg.mod_env.to_filter_cents.abs() > 200
|| non_lowpass;
let filter = if needs_filter {
Some(BiquadState::new())
} else {
None
};
let mod_env_sustain_level = cfg.mod_env.sustain_level.clamp(0.0, 1.0);
Self {
samples: cfg.samples,
end,
loop_start,
loop_end,
loop_mode: cfg.loop_mode,
phase: 0.0,
base_phase_inc: phase_inc,
phase_inc,
amplitude: cfg.amplitude,
pressure_gain: 1.0,
pitch_bend_cents: 0,
elapsed: 0,
release_pos: None,
release_start_level: 1.0,
delay_samples: (sr * cfg.envelope.delay_s.max(0.0)) as u32,
attack_samples: (sr * cfg.envelope.attack_s.max(0.0)).max(1.0) as u32,
hold_samples: (sr * cfg.envelope.hold_s.max(0.0)) as u32,
decay_samples: (sr * cfg.envelope.decay_s.max(0.0)).max(1.0) as u32,
release_samples: (sr * cfg.envelope.release_s.max(0.0)).max(1.0) as u32,
sustain_level: cfg.envelope.sustain_level.clamp(0.0, 1.0),
done: false,
lfo_freq_hz: cfg.vibrato.freq_hz.max(0.0),
lfo_depth_cents: cfg.vibrato.depth_cents,
lfo_delay_samples: (sr * cfg.vibrato.delay_s.max(0.0)) as u32,
output_rate: sr,
exclusive_class: cfg.exclusive_class,
loop_broken: false,
mod_env_delay: (sr * cfg.mod_env.delay_s.max(0.0)) as u32,
mod_env_attack: (sr * cfg.mod_env.attack_s.max(0.0)).max(1.0) as u32,
mod_env_hold: (sr * cfg.mod_env.hold_s.max(0.0)) as u32,
mod_env_decay: (sr * cfg.mod_env.decay_s.max(0.0)).max(1.0) as u32,
mod_env_release: (sr * cfg.mod_env.release_s.max(0.0)).max(1.0) as u32,
mod_env_sustain_level,
mod_env_release_start_level: 1.0,
mod_env_to_filter_cents: cfg.mod_env.to_filter_cents,
initial_filter_fc_cents: cfg.filter.cutoff_cents,
initial_filter_q_cb: cfg.filter.q_centibels,
filter_kind: cfg.filter.kind,
filter,
}
}
fn envelope_at(&self, t: u32) -> f32 {
if let Some(rel_at) = self.release_pos {
let since = t.saturating_sub(rel_at);
if since >= self.release_samples {
return 0.0;
}
let x = since as f32 / self.release_samples.max(1) as f32;
let curve = (1.0 - x) * (1.0 - x);
return self.release_start_level * curve;
}
if t < self.delay_samples {
return 0.0;
}
let t = t - self.delay_samples;
if t < self.attack_samples {
return t as f32 / self.attack_samples.max(1) as f32;
}
let t = t - self.attack_samples;
if t < self.hold_samples {
return 1.0;
}
let t = t - self.hold_samples;
if t < self.decay_samples {
let x = t as f32 / self.decay_samples.max(1) as f32;
let drop = 1.0 - self.sustain_level;
let curve = 1.0 - (1.0 - x) * (1.0 - x);
return 1.0 - drop * curve;
}
self.sustain_level
}
fn lfo_cents_at(&self, t: u32) -> f32 {
if self.lfo_depth_cents == 0.0 || self.lfo_freq_hz == 0.0 {
return 0.0;
}
if t < self.lfo_delay_samples {
return 0.0;
}
let t_active = (t - self.lfo_delay_samples) as f32 / self.output_rate.max(1.0);
let phase = t_active * self.lfo_freq_hz * std::f32::consts::TAU;
phase.sin() * self.lfo_depth_cents
}
fn mod_env_at(&self, t: u32) -> f32 {
if let Some(rel_at) = self.release_pos {
let since = t.saturating_sub(rel_at);
if since >= self.mod_env_release {
return 0.0;
}
let x = since as f32 / self.mod_env_release.max(1) as f32;
let curve = (1.0 - x) * (1.0 - x);
return self.mod_env_release_start_level * curve;
}
if t < self.mod_env_delay {
return 0.0;
}
let t = t - self.mod_env_delay;
if t < self.mod_env_attack {
return t as f32 / self.mod_env_attack.max(1) as f32;
}
let t = t - self.mod_env_attack;
if t < self.mod_env_hold {
return 1.0;
}
let t = t - self.mod_env_hold;
if t < self.mod_env_decay {
let x = t as f32 / self.mod_env_decay.max(1) as f32;
let drop = 1.0 - self.mod_env_sustain_level;
let curve = 1.0 - (1.0 - x) * (1.0 - x);
return 1.0 - drop * curve;
}
self.mod_env_sustain_level
}
fn update_filter_coeffs(&mut self, cutoff_cents: i32) {
let Some(filter) = self.filter.as_mut() else {
return;
};
let cents = cutoff_cents.clamp(1_500, 13_500);
let cutoff_hz = 8.176_f32 * (2.0_f32).powf(cents as f32 / 1200.0);
let nyquist = self.output_rate * 0.5;
let cutoff_hz = cutoff_hz.min(nyquist * 0.99).max(20.0);
match self.filter_kind {
FilterType::OnePoleLowPass => {
let omega = 2.0 * std::f32::consts::PI * cutoff_hz / self.output_rate;
let k = (omega * 0.5).tan();
let norm = 1.0 / (1.0 + k);
filter.b0 = k * norm;
filter.b1 = k * norm;
filter.b2 = 0.0;
filter.a1 = (k - 1.0) * norm;
filter.a2 = 0.0;
}
FilterType::OnePoleHighPass => {
let omega = 2.0 * std::f32::consts::PI * cutoff_hz / self.output_rate;
let k = (omega * 0.5).tan();
let norm = 1.0 / (1.0 + k);
filter.b0 = norm;
filter.b1 = -norm;
filter.b2 = 0.0;
filter.a1 = (k - 1.0) * norm;
filter.a2 = 0.0;
}
FilterType::TwoPoleLowPass
| FilterType::TwoPoleHighPass
| FilterType::TwoPoleBandPass
| FilterType::TwoPoleBandReject => {
let q_lin = (10.0_f32).powf(self.initial_filter_q_cb as f32 / 200.0)
* std::f32::consts::FRAC_1_SQRT_2;
let q = q_lin.clamp(0.1, 16.0);
let omega = 2.0 * std::f32::consts::PI * cutoff_hz / self.output_rate;
let sin_w = omega.sin();
let cos_w = omega.cos();
let alpha = sin_w / (2.0 * q);
let a0 = 1.0 + alpha;
let inv_a0 = 1.0 / a0;
let (b0, b1, b2) = match self.filter_kind {
FilterType::TwoPoleLowPass => {
let v = (1.0 - cos_w) * 0.5;
(v, 1.0 - cos_w, v)
}
FilterType::TwoPoleHighPass => {
let v = (1.0 + cos_w) * 0.5;
(v, -(1.0 + cos_w), v)
}
FilterType::TwoPoleBandPass => {
(sin_w * 0.5, 0.0, -sin_w * 0.5)
}
FilterType::TwoPoleBandReject => (1.0, -2.0 * cos_w, 1.0),
FilterType::OnePoleLowPass | FilterType::OnePoleHighPass => (1.0, 0.0, 0.0),
};
filter.b0 = b0 * inv_a0;
filter.b1 = b1 * inv_a0;
filter.b2 = b2 * inv_a0;
filter.a1 = (-2.0 * cos_w) * inv_a0;
filter.a2 = (1.0 - alpha) * inv_a0;
}
}
filter.last_cutoff_cents = cutoff_cents;
}
fn live_cutoff_cents(&self, t: u32) -> i32 {
if self.mod_env_to_filter_cents == 0 {
return self.initial_filter_fc_cents;
}
let lvl = self.mod_env_at(t);
self.initial_filter_fc_cents + (lvl * self.mod_env_to_filter_cents as f32) as i32
}
fn fetch(&self, phase: f64) -> f32 {
let i = phase.floor() as i64;
if i < 0 || (i as usize) + 1 >= self.samples.len() {
return 0.0;
}
let a = self.samples[i as usize];
let b = self.samples[i as usize + 1];
let frac = (phase - i as f64) as f32;
a + (b - a) * frac
}
}
impl Voice for SamplePlayer {
fn render(&mut self, out: &mut [f32]) -> usize {
if self.done {
return 0;
}
for (i, slot) in out.iter_mut().enumerate() {
let lfo_cents = self.lfo_cents_at(self.elapsed);
if self.pitch_bend_cents != 0 || lfo_cents != 0.0 {
let total_cents = self.pitch_bend_cents as f32 + lfo_cents;
let bend_ratio = (2.0f64).powf(total_cents as f64 / 1200.0);
self.phase_inc = self.base_phase_inc * bend_ratio;
}
let env = self.envelope_at(self.elapsed);
if self.release_pos.is_some() && env <= 0.0 {
self.done = true;
return i;
}
if self.filter.is_some() {
let target = self.live_cutoff_cents(self.elapsed);
let last = self
.filter
.as_ref()
.map(|f| f.last_cutoff_cents)
.unwrap_or(i32::MIN);
if target.saturating_sub(last).saturating_abs() > 50 {
self.update_filter_coeffs(target);
}
}
if self.phase >= self.end as f64 {
let should_wrap = matches!(
self.loop_mode,
SampleLoopMode::LoopContinuous | SampleLoopMode::LoopSustain
) && !self.loop_broken
&& self.loop_end > self.loop_start;
if should_wrap {
let over = self.phase - self.loop_end as f64;
let loop_len = (self.loop_end as f64 - self.loop_start as f64).max(1.0);
let wrapped = over.rem_euclid(loop_len);
self.phase = self.loop_start as f64 + wrapped;
} else {
self.done = true;
return i;
}
} else if matches!(
self.loop_mode,
SampleLoopMode::LoopContinuous | SampleLoopMode::LoopSustain
) && !self.loop_broken
&& self.loop_end > self.loop_start
&& self.phase >= self.loop_end as f64
{
let over = self.phase - self.loop_end as f64;
let loop_len = (self.loop_end as f64 - self.loop_start as f64).max(1.0);
let wrapped = over.rem_euclid(loop_len);
self.phase = self.loop_start as f64 + wrapped;
}
let mut s = self.fetch(self.phase);
if let Some(filter) = self.filter.as_mut() {
s = filter.tick(s);
}
*slot = s * env * self.amplitude * self.pressure_gain;
self.phase += self.phase_inc;
self.elapsed = self.elapsed.wrapping_add(1);
}
out.len()
}
fn release(&mut self) {
if matches!(self.loop_mode, SampleLoopMode::OneShot) {
return;
}
if self.release_pos.is_none() {
self.release_start_level = self.envelope_at(self.elapsed).max(0.0);
self.mod_env_release_start_level = self.mod_env_at(self.elapsed).max(0.0);
self.release_pos = Some(self.elapsed);
if matches!(self.loop_mode, SampleLoopMode::LoopSustain) {
self.loop_broken = true;
}
}
}
fn done(&self) -> bool {
self.done
}
fn set_pitch_bend_cents(&mut self, cents: i32) {
self.pitch_bend_cents = cents;
}
fn set_pressure(&mut self, pressure: f32) {
let p = pressure.clamp(0.0, 1.0);
self.pressure_gain = 1.0 + 0.5 * p;
}
fn exclusive_class(&self) -> u16 {
self.exclusive_class
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ramp(n: usize) -> Arc<[f32]> {
(0..n)
.map(|i| (i as f32 / n.saturating_sub(1).max(1) as f32) - 0.5)
.collect::<Vec<f32>>()
.into()
}
#[test]
fn no_loop_voice_finishes_when_sample_ends() {
let buf = ramp(64);
let len = buf.len() as u32;
let cfg = SamplePlayerConfig {
samples: buf,
native_rate: 44_100,
loop_start: 0,
loop_end: len,
sample_end: len,
loop_mode: SampleLoopMode::NoLoop,
pitch_ratio: 1.0,
amplitude: 1.0,
envelope: EnvelopeParams::default(),
vibrato: VibratoParams::default(),
mod_env: ModEnvParams::default(),
filter: FilterParams::default(),
exclusive_class: 0,
};
let mut v = SamplePlayer::new(cfg, 44_100);
let mut out = vec![0.0f32; 256];
let n = v.render(&mut out);
assert!(n < 256, "expected early stop, got {n}");
assert!(v.done());
}
#[test]
fn looping_voice_runs_indefinitely_until_release() {
let buf = ramp(32);
let len = buf.len() as u32;
let cfg = SamplePlayerConfig {
samples: buf,
native_rate: 44_100,
loop_start: 0,
loop_end: len,
sample_end: len,
loop_mode: SampleLoopMode::LoopContinuous,
pitch_ratio: 1.0,
amplitude: 1.0,
envelope: EnvelopeParams::default(),
vibrato: VibratoParams::default(),
mod_env: ModEnvParams::default(),
filter: FilterParams::default(),
exclusive_class: 0,
};
let mut v = SamplePlayer::new(cfg, 44_100);
let mut out = vec![0.0f32; 1024];
let n = v.render(&mut out);
assert_eq!(n, 1024, "looping voice should render the full buffer");
assert!(!v.done());
v.release();
let mut total = 0;
for _ in 0..16 {
let n = v.render(&mut out);
total += n;
if v.done() {
break;
}
}
assert!(v.done(), "voice should finish post-release");
assert!(total > 0);
}
#[test]
fn one_shot_ignores_release() {
let buf = ramp(2048);
let len = buf.len() as u32;
let cfg = SamplePlayerConfig {
samples: buf,
native_rate: 44_100,
loop_start: 0,
loop_end: len,
sample_end: len,
loop_mode: SampleLoopMode::OneShot,
pitch_ratio: 1.0,
amplitude: 1.0,
envelope: EnvelopeParams::default(),
vibrato: VibratoParams::default(),
mod_env: ModEnvParams::default(),
filter: FilterParams::default(),
exclusive_class: 0,
};
let mut v = SamplePlayer::new(cfg, 44_100);
let mut out = vec![0.0f32; 256];
v.render(&mut out);
v.release();
let n = v.render(&mut out);
assert_eq!(n, 256, "OneShot must ignore note-off");
assert!(!v.done());
}
#[test]
fn pitch_bend_changes_phase_inc() {
let buf = ramp(16_384);
let len = buf.len() as u32;
let cfg = SamplePlayerConfig {
samples: buf,
native_rate: 44_100,
loop_start: 0,
loop_end: len,
sample_end: len,
loop_mode: SampleLoopMode::NoLoop,
pitch_ratio: 1.0,
amplitude: 1.0,
envelope: EnvelopeParams::default(),
vibrato: VibratoParams::default(),
mod_env: ModEnvParams::default(),
filter: FilterParams::default(),
exclusive_class: 0,
};
let mut a = SamplePlayer::new(cfg.clone(), 44_100);
let mut b = SamplePlayer::new(cfg, 44_100);
b.set_pitch_bend_cents(1200); let mut buf_a = vec![0.0f32; 1024];
let mut buf_b = vec![0.0f32; 1024];
a.render(&mut buf_a);
b.render(&mut buf_b);
let last_a = buf_a[1000];
let last_b = buf_b[1000];
assert!(
last_b > last_a,
"+1 octave bend should advance further into the ramp: a={last_a} b={last_b}"
);
}
#[test]
fn vibrato_lfo_modulates_pitch() {
let buf = ramp(16_384);
let len = buf.len() as u32;
let cfg_no_lfo = SamplePlayerConfig {
samples: buf.clone(),
native_rate: 44_100,
loop_start: 0,
loop_end: len,
sample_end: len,
loop_mode: SampleLoopMode::NoLoop,
pitch_ratio: 1.0,
amplitude: 1.0,
envelope: EnvelopeParams::default(),
vibrato: VibratoParams::default(),
mod_env: ModEnvParams::default(),
filter: FilterParams::default(),
exclusive_class: 0,
};
let mut cfg_lfo = cfg_no_lfo.clone();
cfg_lfo.vibrato = VibratoParams {
freq_hz: 5.0,
depth_cents: 100.0,
delay_s: 0.0,
};
let mut a = SamplePlayer::new(cfg_no_lfo, 44_100);
let mut b = SamplePlayer::new(cfg_lfo, 44_100);
let mut out_a = vec![0.0f32; 4096];
let mut out_b = vec![0.0f32; 4096];
a.render(&mut out_a);
b.render(&mut out_b);
let diff: f32 = out_a
.iter()
.zip(out_b.iter())
.map(|(x, y)| (x - y).abs())
.sum();
assert!(diff > 0.001, "LFO should perturb output, got diff {diff}");
}
fn square(period: usize, frames: usize) -> Arc<[f32]> {
(0..frames)
.map(|i| if (i / period) & 1 == 0 { 0.5 } else { -0.5 })
.collect::<Vec<f32>>()
.into()
}
fn sine(freq_hz: f32, native_rate: u32, frames: usize) -> Arc<[f32]> {
let step = std::f32::consts::TAU * freq_hz / native_rate as f32;
(0..frames)
.map(|i| (i as f32 * step).sin() * 0.5)
.collect::<Vec<f32>>()
.into()
}
fn rms(buf: &[f32]) -> f32 {
let n = buf.len().max(1) as f32;
let sum_sq: f32 = buf.iter().map(|x| x * x).sum();
(sum_sq / n).sqrt()
}
fn cfg_with(samples: Arc<[f32]>, native_rate: u32) -> SamplePlayerConfig {
let len = samples.len() as u32;
SamplePlayerConfig {
samples,
native_rate,
loop_start: 0,
loop_end: len,
sample_end: len,
loop_mode: SampleLoopMode::NoLoop,
pitch_ratio: 1.0,
amplitude: 1.0,
envelope: EnvelopeParams {
attack_s: 0.001,
hold_s: 1.0,
decay_s: 0.001,
sustain_level: 1.0,
release_s: 0.1,
..Default::default()
},
vibrato: VibratoParams::default(),
mod_env: ModEnvParams::default(),
filter: FilterParams::default(),
exclusive_class: 0,
}
}
#[test]
fn default_filter_is_inert_no_biquad_allocated() {
let cfg = cfg_with(square(8, 4096), 44_100);
let v = SamplePlayer::new(cfg, 44_100);
assert!(
v.filter.is_none(),
"default FilterParams + inert ModEnvParams should not allocate biquad"
);
}
#[test]
fn low_cutoff_filter_attenuates_high_frequencies() {
let buf = sine(5_000.0, 44_100, 8_192);
let cfg_open = cfg_with(buf.clone(), 44_100);
let mut v_open = SamplePlayer::new(cfg_open, 44_100);
let mut out_open = vec![0.0f32; 8_192];
v_open.render(&mut out_open);
let mut cfg_lp = cfg_with(buf, 44_100);
cfg_lp.filter = FilterParams {
cutoff_cents: 7_138,
q_centibels: 0,
..Default::default()
};
let mut v_lp = SamplePlayer::new(cfg_lp, 44_100);
assert!(
v_lp.filter.is_some(),
"low-cutoff config must allocate biquad"
);
let mut out_lp = vec![0.0f32; 8_192];
v_lp.render(&mut out_lp);
let rms_open = rms(&out_open[4_096..]);
let rms_lp = rms(&out_lp[4_096..]);
let ratio = rms_lp / rms_open.max(1e-6);
assert!(
ratio < 0.1,
"low-pass at 500 Hz should attenuate 5 kHz by >20 dB; got ratio {ratio} (open={rms_open}, lp={rms_lp})"
);
}
#[test]
fn mod_env_dahdsr_shape_matches_spec() {
let buf = sine(440.0, 44_100, 44_100);
let mut cfg = cfg_with(buf, 44_100);
cfg.mod_env = ModEnvParams {
delay_s: 0.0,
attack_s: 0.100,
hold_s: 0.0,
decay_s: 0.100,
sustain_level: 0.5,
release_s: 0.100,
to_filter_cents: -3000,
};
cfg.filter = FilterParams {
cutoff_cents: 10_000,
q_centibels: 0,
..Default::default()
};
let v = SamplePlayer::new(cfg, 44_100);
assert!(
v.mod_env_at(0).abs() < 1e-3,
"mod env at t=0 should be 0, got {}",
v.mod_env_at(0)
);
let mid_attack = v.mod_env_at(44_100 / 20);
assert!(
(mid_attack - 0.5).abs() < 0.02,
"mod env mid-attack should be ~0.5, got {mid_attack}"
);
let peak = v.mod_env_at(44_100 / 10);
assert!(
(peak - 1.0).abs() < 0.02,
"mod env at attack peak should be ~1.0, got {peak}"
);
let sus = v.mod_env_at(44_100 / 2);
assert!(
(sus - 0.5).abs() < 0.02,
"mod env in sustain should be ~0.5, got {sus}"
);
}
#[test]
fn eg2_filter_sweep_changes_spectrum_over_note() {
let buf = sine(5_000.0, 44_100, 44_100);
let base_cutoff = 5_938;
let mut cfg_base = cfg_with(buf.clone(), 44_100);
cfg_base.filter = FilterParams {
cutoff_cents: base_cutoff,
q_centibels: 0,
..Default::default()
};
let mut v_base = SamplePlayer::new(cfg_base, 44_100);
let mut out_base = vec![0.0f32; 44_100];
v_base.render(&mut out_base);
let mut cfg_sweep = cfg_with(buf, 44_100);
cfg_sweep.filter = FilterParams {
cutoff_cents: base_cutoff,
q_centibels: 0,
..Default::default()
};
cfg_sweep.mod_env = ModEnvParams {
delay_s: 0.0,
attack_s: 0.500,
hold_s: 1.0,
decay_s: 0.001,
sustain_level: 1.0,
release_s: 0.001,
to_filter_cents: 7_200,
};
let mut v_sweep = SamplePlayer::new(cfg_sweep, 44_100);
let mut out_sweep = vec![0.0f32; 44_100];
v_sweep.render(&mut out_sweep);
let early_sweep = rms(&out_sweep[880..3_528]);
let late_sweep = rms(&out_sweep[37_485..41_895]);
let early_base = rms(&out_base[880..3_528]);
let late_base = rms(&out_base[37_485..41_895]);
let base_ratio = (late_base / early_base.max(1e-6) - 1.0).abs();
assert!(
base_ratio < 0.3,
"static-filter baseline should be roughly steady; got early={early_base}, late={late_base}"
);
let sweep_gain = late_sweep / early_sweep.max(1e-6);
assert!(
sweep_gain > 5.0,
"EG2 should sweep the cutoff open, raising late-window RMS over early-window RMS; got gain={sweep_gain} (early={early_sweep}, late={late_sweep})"
);
assert!(
late_sweep > late_base * 2.0,
"post-sweep filter should be substantially wider than the static 250 Hz baseline; got sweep={late_sweep}, base={late_base}"
);
}
#[test]
fn high_q_filter_resonates_at_cutoff() {
let buf = sine(1_000.0, 44_100, 16_384);
let mut cfg_q0 = cfg_with(buf.clone(), 44_100);
cfg_q0.filter = FilterParams {
cutoff_cents: 8_338,
q_centibels: 0,
..Default::default()
};
let mut v_q0 = SamplePlayer::new(cfg_q0, 44_100);
let mut out_q0 = vec![0.0f32; 16_384];
v_q0.render(&mut out_q0);
let mut cfg_q24 = cfg_with(buf, 44_100);
cfg_q24.filter = FilterParams {
cutoff_cents: 8_338,
q_centibels: 240,
..Default::default()
};
let mut v_q24 = SamplePlayer::new(cfg_q24, 44_100);
let mut out_q24 = vec![0.0f32; 16_384];
v_q24.render(&mut out_q24);
let rms_q0 = rms(&out_q0[8_192..]);
let rms_q24 = rms(&out_q24[8_192..]);
assert!(
rms_q24 > rms_q0 * 2.0,
"Q=24 dB should boost the cutoff-frequency sine over Q=0; got q0={rms_q0}, q24={rms_q24}"
);
}
#[test]
fn release_captures_mod_env_level() {
let buf = sine(440.0, 44_100, 44_100);
let mut cfg = cfg_with(buf, 44_100);
cfg.mod_env = ModEnvParams {
attack_s: 0.100,
decay_s: 0.100,
sustain_level: 0.5,
release_s: 0.100,
to_filter_cents: -3000,
..Default::default()
};
cfg.filter = FilterParams {
cutoff_cents: 10_000,
q_centibels: 0,
..Default::default()
};
let mut v = SamplePlayer::new(cfg, 44_100);
let mut out = vec![0.0f32; 44_100 / 20];
v.render(&mut out);
let pre_release_level = v.mod_env_at(v.elapsed);
v.release();
assert!(
(v.mod_env_release_start_level - pre_release_level).abs() < 1e-4,
"release should capture mid-flight EG2 level; pre={pre_release_level}, captured={}",
v.mod_env_release_start_level
);
assert!(
(pre_release_level - 0.5).abs() < 0.05,
"mid-attack EG2 should be ~0.5; got {pre_release_level}"
);
}
fn sine_buf(freq_hz: f32, sr: u32, len: usize) -> Arc<[f32]> {
let v: Vec<f32> = (0..len)
.map(|i| {
let t = i as f32 / sr as f32;
(2.0 * std::f32::consts::PI * freq_hz * t).sin()
})
.collect();
Arc::from(v.into_boxed_slice())
}
#[test]
fn high_pass_attenuates_below_cutoff() {
let sr = 44_100;
let buf = sine_buf(5_000.0, sr, 16_384);
let mut cfg = cfg_with(buf, sr);
cfg.filter = FilterParams {
cutoff_cents: 12_331,
q_centibels: 0,
kind: FilterType::TwoPoleHighPass,
};
let mut v = SamplePlayer::new(cfg, sr);
assert!(
v.filter.is_some(),
"non-low-pass must always allocate biquad"
);
let mut out = vec![0.0f32; 16_384];
v.render(&mut out);
let r = rms(&out[8_192..]);
let buf2 = sine_buf(5_000.0, sr, 16_384);
let cfg_open = cfg_with(buf2, sr);
let mut v_open = SamplePlayer::new(cfg_open, sr);
let mut out_open = vec![0.0f32; 16_384];
v_open.render(&mut out_open);
let r_open = rms(&out_open[8_192..]);
assert!(
r < r_open * 0.5,
"10 kHz hpf_2p should attenuate 5 kHz; got r={r}, open={r_open}",
);
}
#[test]
fn band_pass_peaks_at_cutoff() {
let sr = 44_100;
let cutoff_cents = 8_338; let mut cfg_on = cfg_with(sine_buf(1_000.0, sr, 16_384), sr);
cfg_on.filter = FilterParams {
cutoff_cents,
q_centibels: 60,
kind: FilterType::TwoPoleBandPass,
};
let mut v_on = SamplePlayer::new(cfg_on, sr);
let mut out_on = vec![0.0f32; 16_384];
v_on.render(&mut out_on);
let mut cfg_off = cfg_with(sine_buf(250.0, sr, 16_384), sr);
cfg_off.filter = FilterParams {
cutoff_cents,
q_centibels: 60,
kind: FilterType::TwoPoleBandPass,
};
let mut v_off = SamplePlayer::new(cfg_off, sr);
let mut out_off = vec![0.0f32; 16_384];
v_off.render(&mut out_off);
let r_on = rms(&out_on[8_192..]);
let r_off = rms(&out_off[8_192..]);
assert!(
r_on > r_off * 2.0,
"bpf_2p should peak at cutoff; got at_cutoff={r_on}, two_octaves_off={r_off}",
);
}
#[test]
fn band_reject_kills_signal_at_cutoff() {
let sr = 44_100;
let cutoff_cents = 8_338; let mut cfg_in = cfg_with(sine_buf(1_000.0, sr, 16_384), sr);
cfg_in.filter = FilterParams {
cutoff_cents,
q_centibels: 240,
kind: FilterType::TwoPoleBandReject,
};
let mut v_in = SamplePlayer::new(cfg_in, sr);
let mut out_in = vec![0.0f32; 16_384];
v_in.render(&mut out_in);
let mut cfg_lo = cfg_with(sine_buf(100.0, sr, 16_384), sr);
cfg_lo.filter = FilterParams {
cutoff_cents,
q_centibels: 240,
kind: FilterType::TwoPoleBandReject,
};
let mut v_lo = SamplePlayer::new(cfg_lo, sr);
let mut out_lo = vec![0.0f32; 16_384];
v_lo.render(&mut out_lo);
let r_in = rms(&out_in[8_192..]);
let r_lo = rms(&out_lo[8_192..]);
assert!(
r_in < r_lo * 0.3,
"brf_2p should null the cutoff sine; got at_cutoff={r_in}, off_band={r_lo}",
);
}
#[test]
fn one_pole_low_pass_attenuates_high_frequencies() {
let sr = 44_100;
let buf = sine_buf(5_000.0, sr, 16_384);
let mut cfg = cfg_with(buf, sr);
cfg.filter = FilterParams {
cutoff_cents: 7_138,
q_centibels: 0,
kind: FilterType::OnePoleLowPass,
};
let mut v = SamplePlayer::new(cfg, sr);
let mut out = vec![0.0f32; 16_384];
v.render(&mut out);
let r = rms(&out[8_192..]);
let buf2 = sine_buf(5_000.0, sr, 16_384);
let mut v_open = SamplePlayer::new(cfg_with(buf2, sr), sr);
let mut out_open = vec![0.0f32; 16_384];
v_open.render(&mut out_open);
let r_open = rms(&out_open[8_192..]);
assert!(
r < r_open * 0.3,
"lpf_1p at 500 Hz should attenuate 5 kHz; got r={r}, open={r_open}",
);
}
}