#![allow(dead_code, clippy::cast_precision_loss)]
use std::f32::consts::PI;
#[derive(Debug, Clone)]
pub struct HarmonicExciterConfig {
pub drive: f32,
pub mix: f32,
pub hp_cutoff_hz: f32,
pub sample_rate: f32,
}
impl Default for HarmonicExciterConfig {
fn default() -> Self {
Self {
drive: 0.5,
mix: 0.25,
hp_cutoff_hz: 2_000.0,
sample_rate: 48_000.0,
}
}
}
#[derive(Debug, Clone, Default)]
struct OnePoleHp {
prev_in: f32,
prev_out: f32,
coeff: f32, }
impl OnePoleHp {
fn new(cutoff_hz: f32, sample_rate: f32) -> Self {
let rc = 1.0 / (2.0 * PI * cutoff_hz.max(1.0));
let dt = 1.0 / sample_rate.max(1.0);
let coeff = rc / (rc + dt);
Self {
prev_in: 0.0,
prev_out: 0.0,
coeff,
}
}
#[inline]
fn process(&mut self, x: f32) -> f32 {
let y = self.coeff * (self.prev_out + x - self.prev_in);
self.prev_in = x;
self.prev_out = y;
y
}
fn reset(&mut self) {
self.prev_in = 0.0;
self.prev_out = 0.0;
}
}
pub struct HarmonicExciter {
config: HarmonicExciterConfig,
hp: OnePoleHp,
smooth_drive: f32,
smooth_coeff: f32,
}
impl HarmonicExciter {
#[must_use]
pub fn new(drive: f32) -> Self {
let config = HarmonicExciterConfig {
drive: drive.clamp(0.0, 1.0),
..HarmonicExciterConfig::default()
};
Self::with_config(config)
}
#[must_use]
pub fn with_config(config: HarmonicExciterConfig) -> Self {
let hp = OnePoleHp::new(config.hp_cutoff_hz, config.sample_rate);
let smooth_coeff = (-1.0_f32 / (0.010 * config.sample_rate.max(1.0))).exp();
Self {
smooth_drive: config.drive,
hp,
smooth_coeff,
config,
}
}
pub fn set_drive(&mut self, drive: f32) {
self.config.drive = drive.clamp(0.0, 1.0);
}
pub fn set_mix(&mut self, mix: f32) {
self.config.mix = mix.clamp(0.0, 1.0);
}
#[must_use]
pub fn drive(&self) -> f32 {
self.config.drive
}
#[must_use]
pub fn mix(&self) -> f32 {
self.config.mix
}
pub fn process_sample(&mut self, input: f32) -> f32 {
self.smooth_drive =
self.smooth_drive * self.smooth_coeff + self.config.drive * (1.0 - self.smooth_coeff);
let hp_signal = self.hp.process(input);
let driven = hp_signal * (1.0 + self.smooth_drive * 8.0);
let harmonic = driven * driven.abs(); let harmonic_norm = harmonic.tanh();
input + self.config.mix * self.smooth_drive * harmonic_norm
}
#[must_use]
pub fn process(&mut self, samples: &[f32]) -> Vec<f32> {
samples.iter().map(|&s| self.process_sample(s)).collect()
}
pub fn reset(&mut self) {
self.hp.reset();
self.smooth_drive = self.config.drive;
}
}
impl crate::AudioEffect for HarmonicExciter {
fn process_sample(&mut self, input: f32) -> f32 {
self.process_sample(input)
}
fn reset(&mut self) {
self.reset();
}
fn set_sample_rate(&mut self, sample_rate: f32) {
self.config.sample_rate = sample_rate;
self.hp = OnePoleHp::new(self.config.hp_cutoff_hz, sample_rate);
self.smooth_coeff = (-1.0_f32 / (0.010 * sample_rate.max(1.0))).exp();
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_sine(freq_hz: f32, sr: f32, n: usize) -> Vec<f32> {
(0..n)
.map(|i| (2.0 * PI * freq_hz * i as f32 / sr).sin())
.collect()
}
fn rms(buf: &[f32]) -> f32 {
if buf.is_empty() {
return 0.0;
}
(buf.iter().map(|&s| s * s).sum::<f32>() / buf.len() as f32).sqrt()
}
#[test]
fn test_new_clamps_drive() {
let exc = HarmonicExciter::new(5.0);
assert!((exc.drive() - 1.0).abs() < 1e-6);
let exc2 = HarmonicExciter::new(-0.5);
assert!((exc2.drive() - 0.0).abs() < 1e-6);
}
#[test]
fn test_output_length_matches_input() {
let mut exc = HarmonicExciter::new(0.5);
let input: Vec<f32> = (0..512).map(|i| (i as f32 * 0.01).sin()).collect();
let output = exc.process(&input);
assert_eq!(output.len(), input.len());
}
#[test]
fn test_all_outputs_finite() {
let mut exc = HarmonicExciter::new(0.8);
let sine = make_sine(440.0, 48_000.0, 1024);
let output = exc.process(&sine);
for (i, &s) in output.iter().enumerate() {
assert!(s.is_finite(), "Sample {i} is not finite: {s}");
}
}
#[test]
fn test_drive_zero_near_identity() {
let mut exc = HarmonicExciter::new(0.0);
let input = vec![0.3_f32, -0.2, 0.5, 0.0, -0.4];
let output = exc.process(&input);
for (i, (&inp, &out)) in input.iter().zip(output.iter()).enumerate() {
assert!(
(inp - out).abs() < 1e-4,
"drive=0 should be near-identity at sample {i}: in={inp}, out={out}"
);
}
}
#[test]
fn test_drive_high_adds_energy() {
let mut exc = HarmonicExciter::new(1.0);
exc.set_mix(1.0);
let settle = make_sine(5_000.0, 48_000.0, 2048);
let _ = exc.process(&settle);
let input = make_sine(5_000.0, 48_000.0, 1024);
let output = exc.process(&input);
assert!(
rms(&output) > rms(&input) * 0.95,
"High drive should add or maintain energy: in_rms={:.4}, out_rms={:.4}",
rms(&input),
rms(&output)
);
}
#[test]
fn test_reset_clears_state() {
let mut exc = HarmonicExciter::new(0.7);
let loud = vec![0.9_f32; 256];
let _ = exc.process(&loud);
exc.reset();
let zeros = vec![0.0_f32; 64];
let out = exc.process(&zeros);
for &s in &out {
assert!(s.abs() < 1e-5, "After reset, zero in → ~zero out, got {s}");
}
}
#[test]
fn test_set_sample_rate() {
use crate::AudioEffect;
let mut exc = HarmonicExciter::new(0.5);
exc.set_sample_rate(44_100.0);
assert!((exc.config.sample_rate - 44_100.0).abs() < 1.0);
}
#[test]
fn test_audio_effect_trait_process_sample() {
let mut exc = HarmonicExciter::new(0.5);
let out = exc.process_sample(0.5);
assert!(out.is_finite());
}
}