use std::collections::HashMap;
use candle_core::{DType, Device, Tensor};
use candle_nn::{GRU, GRUConfig, Linear, Module, RNN, VarBuilder};
use realfft::RealFftPlanner;
use crate::util::hann_window;
use crate::{AudioData, Denoiser, Error};
#[derive(Debug, Clone, Copy)]
pub struct NeuralConfig {
pub fft_size: usize,
pub hop_size: usize,
pub hidden: usize,
}
impl Default for NeuralConfig {
fn default() -> Self {
Self { fft_size: 512, hop_size: 128, hidden: 256 }
}
}
struct Model {
enc: Linear,
gru: GRU,
dec: Linear,
}
impl Model {
fn build(vb: VarBuilder, n_bins: usize, hidden: usize) -> Result<Self, Error> {
let enc = candle_nn::linear(n_bins, hidden, vb.pp("enc"))?;
let gru = candle_nn::gru(hidden, hidden, GRUConfig::default(), vb.pp("gru"))?;
let dec = candle_nn::linear(hidden, n_bins, vb.pp("dec"))?;
Ok(Self { enc, gru, dec })
}
}
const PRETRAINED_CHECKPOINT: &[u8] = include_bytes!("denoiser.safetensors");
pub struct NeuralDenoiser {
model: Model,
fft_size: usize,
hop_size: usize,
n_bins: usize,
device: Device,
}
impl NeuralDenoiser {
pub fn new() -> Result<Self, Error> {
Self::with_config(NeuralConfig::default())
}
pub fn with_config(cfg: NeuralConfig) -> Result<Self, Error> {
let device = Device::Cpu;
let n_bins = cfg.fft_size / 2 + 1;
let vb = VarBuilder::from_tensors(passthrough_tensors(&cfg, &device)?, DType::F32, &device);
let model = Model::build(vb, n_bins, cfg.hidden)?;
Ok(Self { model, fft_size: cfg.fft_size, hop_size: cfg.hop_size, n_bins, device })
}
pub fn pretrained() -> Result<Self, Error> {
Self::pretrained_with_config(NeuralConfig::default())
}
pub fn pretrained_with_config(cfg: NeuralConfig) -> Result<Self, Error> {
let device = Device::Cpu;
let n_bins = cfg.fft_size / 2 + 1;
let vb = candle_nn::VarBuilder::from_buffered_safetensors(
PRETRAINED_CHECKPOINT.to_vec(),
DType::F32,
&device,
)?;
let model = Model::build(vb, n_bins, cfg.hidden)?;
Ok(Self { model, fft_size: cfg.fft_size, hop_size: cfg.hop_size, n_bins, device })
}
pub fn from_safetensors<P: AsRef<std::path::Path>>(
path: P,
cfg: NeuralConfig,
) -> Result<Self, Error> {
let device = Device::Cpu;
let n_bins = cfg.fft_size / 2 + 1;
let vb =
unsafe { VarBuilder::from_mmaped_safetensors(&[path.as_ref()], DType::F32, &device)? };
let model = Model::build(vb, n_bins, cfg.hidden)?;
Ok(Self { model, fft_size: cfg.fft_size, hop_size: cfg.hop_size, n_bins, device })
}
fn predict_gains(&self, mags: &[Vec<f32>]) -> Result<Vec<Vec<f32>>, Error> {
let frames = mags.len();
let n_bins = self.n_bins;
let mut feat = Vec::with_capacity(frames * n_bins);
for frame in mags {
for &m in frame {
feat.push((m + 1e-6).ln());
}
}
let x = Tensor::from_vec(feat, (1, frames, n_bins), &self.device)?;
let x = self.model.enc.forward(&x)?.relu()?; let states = self.model.gru.seq(&x)?; let hs: Vec<Tensor> = states.iter().map(|s| s.h.clone()).collect();
let h = Tensor::stack(&hs, 1)?; let g = candle_nn::ops::sigmoid(&self.model.dec.forward(&h)?)?; Ok(g.squeeze(0)?.to_vec2::<f32>()?)
}
fn denoise_channel(&self, signal: &[f32]) -> Result<Vec<f32>, Error> {
let n = signal.len();
if n < self.fft_size {
return Err(Error::TooShort);
}
let mut planner = RealFftPlanner::<f32>::new();
let r2c = planner.plan_fft_forward(self.fft_size);
let c2r = planner.plan_fft_inverse(self.fft_size);
let hann = hann_window(self.fft_size);
let n_bins = self.n_bins;
let mut in_buf = r2c.make_input_vec();
let mut out_buf = r2c.make_output_vec();
let mut specs = Vec::new();
let mut mags = Vec::new();
let mut fi = 0usize;
loop {
let offset = fi * self.hop_size;
if offset + self.fft_size > n {
break;
}
for i in 0..self.fft_size {
in_buf[i] = signal[offset + i] * hann[i];
}
r2c.process(&mut in_buf, &mut out_buf)?;
mags.push(out_buf.iter().map(|c| (c.re * c.re + c.im * c.im).sqrt()).collect());
specs.push(out_buf.clone());
fi += 1;
}
let gains = self.predict_gains(&mags)?;
let scale = 1.0f32 / self.fft_size as f32;
let mut output = vec![0.0f32; n + self.fft_size];
let mut norm = vec![0.0f32; n + self.fft_size];
for (fi, spec) in specs.iter_mut().enumerate() {
let g = &gains[fi];
for k in 0..n_bins {
spec[k].re *= g[k];
spec[k].im *= g[k];
}
spec[0].im = 0.0;
spec[n_bins - 1].im = 0.0;
c2r.process(spec, &mut in_buf)?;
let offset = fi * self.hop_size;
for i in 0..self.fft_size {
output[offset + i] += in_buf[i] * hann[i] * scale;
norm[offset + i] += hann[i] * hann[i];
}
}
for (o, w) in output.iter_mut().zip(&norm) {
if *w > 1e-8 {
*o /= *w;
}
}
output.truncate(n);
Ok(output)
}
}
impl Denoiser for NeuralDenoiser {
fn denoise(&self, input: &AudioData) -> Result<AudioData, Error> {
let mut channels = Vec::with_capacity(input.channels.len());
for ch in &input.channels {
channels.push(self.denoise_channel(ch)?);
}
Ok(AudioData { sample_rate: input.sample_rate, channels })
}
}
fn passthrough_tensors(cfg: &NeuralConfig, dev: &Device) -> Result<HashMap<String, Tensor>, Error> {
let n_bins = cfg.fft_size / 2 + 1;
let h = cfg.hidden;
let mut m = HashMap::new();
m.insert("enc.weight".into(), Tensor::zeros((h, n_bins), DType::F32, dev)?);
m.insert("enc.bias".into(), Tensor::zeros(h, DType::F32, dev)?);
m.insert("gru.weight_ih_l0".into(), Tensor::zeros((3 * h, h), DType::F32, dev)?);
m.insert("gru.weight_hh_l0".into(), Tensor::zeros((3 * h, h), DType::F32, dev)?);
m.insert("gru.bias_ih_l0".into(), Tensor::zeros(3 * h, DType::F32, dev)?);
m.insert("gru.bias_hh_l0".into(), Tensor::zeros(3 * h, DType::F32, dev)?);
m.insert("dec.weight".into(), Tensor::zeros((n_bins, h), DType::F32, dev)?);
m.insert("dec.bias".into(), Tensor::full(6.0f32, n_bins, dev)?);
Ok(m)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{generate_wave, variance};
#[test]
fn neural_denoiser_preserves_shape() {
let audio = generate_wave(48_000, 440.0, 1.0, 0.1);
let nn = NeuralDenoiser::new().unwrap();
let out = nn.denoise(&audio).unwrap();
assert_eq!(out.sample_rate, audio.sample_rate);
assert_eq!(out.channels.len(), audio.channels.len());
assert_eq!(out.channels[0].len(), audio.channels[0].len());
assert!(out.channels[0].iter().all(|s| s.is_finite()));
}
#[test]
fn neural_denoiser_is_deterministic() {
let audio = generate_wave(48_000, 440.0, 1.0, 0.1);
let nn = NeuralDenoiser::new().unwrap();
let a = nn.denoise(&audio).unwrap();
let b = nn.denoise(&audio).unwrap();
assert_eq!(a.channels[0], b.channels[0]);
}
#[test]
fn neural_denoiser_passthrough_reconstructs_signal() {
let audio = generate_wave(48_000, 440.0, 1.0, 0.0); let nn = NeuralDenoiser::new().unwrap();
let out = nn.denoise(&audio).unwrap();
let (x, y) = (&audio.channels[0], &out.channels[0]);
let (lo, hi) = (512usize, x.len() - 512);
let mean = |v: &[f32]| v[lo..hi].iter().sum::<f32>() / (hi - lo) as f32;
let (mx, my) = (mean(x), mean(y));
let mut num = 0.0f64;
let mut dx = 0.0f64;
let mut dy = 0.0f64;
for i in lo..hi {
let (a, b) = ((x[i] - mx) as f64, (y[i] - my) as f64);
num += a * b;
dx += a * a;
dy += b * b;
}
let corr = num / (dx.sqrt() * dy.sqrt());
assert!(corr > 0.99, "passthrough should track the input, corr = {corr}");
let std_ratio = (variance(&y[lo..hi]) / variance(&x[lo..hi])).sqrt();
assert!((0.95..1.05).contains(&std_ratio), "passthrough not unity: {std_ratio}");
}
#[test]
fn neural_denoiser_loads_safetensors() {
let cfg = NeuralConfig::default();
let path = std::env::temp_dir().join("cathar_ml_rt.safetensors");
candle_core::safetensors::save(&passthrough_tensors(&cfg, &Device::Cpu).unwrap(), &path)
.unwrap();
let audio = generate_wave(48_000, 440.0, 1.0, 0.1);
let loaded = NeuralDenoiser::from_safetensors(&path, cfg).unwrap().denoise(&audio).unwrap();
std::fs::remove_file(&path).ok();
let in_memory = NeuralDenoiser::new().unwrap().denoise(&audio).unwrap();
assert_eq!(loaded.channels[0], in_memory.channels[0]);
}
#[test]
fn pretrained_denoiser_is_not_passthrough() {
let audio = generate_wave(48_000, 440.0, 1.0, 0.3);
let pthru = NeuralDenoiser::new().unwrap().denoise(&audio).unwrap();
let pretrained = NeuralDenoiser::pretrained().unwrap().denoise(&audio).unwrap();
let max_diff = pthru.channels[0]
.iter()
.zip(&pretrained.channels[0])
.map(|(a, b)| (a - b).abs())
.fold(0.0f32, f32::max);
assert!(max_diff > 1e-4, "pretrained should differ from passthrough, max_diff={max_diff}");
}
#[test]
fn neural_denoiser_rejects_short_signal() {
let audio = AudioData { sample_rate: 48_000, channels: vec![vec![0.1; 100]] };
assert!(matches!(NeuralDenoiser::new().unwrap().denoise(&audio), Err(Error::TooShort)));
}
}