use super::support::{lerp, smooth_towards};
use super::{analyze_frame, band_weight_for_voice, classify_frame, FrameClass};
use crate::contracts::AudioProcessor;
use crate::core::{AudioError, AudioFrame, AudioProfile, AudioResult, AudioSpec};
use crate::profiles::profile_tuning;
use biquad::{Biquad, Coefficients, DirectForm1, Hertz, ToHertz, Type, Q_BUTTERWORTH_F32};
use realfft::{num_complex::Complex32, ComplexToReal, RealFftPlanner, RealToComplex};
use std::sync::Arc;
struct SpectralPlan {
len: usize,
hop_len: usize,
forward: Arc<dyn RealToComplex<f32>>,
inverse: Arc<dyn ComplexToReal<f32>>,
analysis_window: Vec<f32>,
synthesis_window: Vec<f32>,
input: Vec<f32>,
spectrum: Vec<Complex32>,
output: Vec<f32>,
noise_profile: Vec<f32>,
prev_input_tail: Vec<f32>,
overlap_output: Vec<f32>,
}
impl SpectralPlan {
fn new(hop_len: usize) -> Self {
let len = (hop_len.max(64) * 2).next_power_of_two();
let hop_len = len / 2;
let mut planner = RealFftPlanner::<f32>::new();
let forward = planner.plan_fft_forward(len);
let inverse = planner.plan_fft_inverse(len);
let analysis_window = (0..len)
.map(|i| {
let position = i as f32 / (len.saturating_sub(1).max(1)) as f32;
(0.5 - 0.5 * (std::f32::consts::TAU * position).cos()).sqrt()
})
.collect::<Vec<_>>();
let synthesis_window = analysis_window.clone();
let input = forward.make_input_vec();
let spectrum = forward.make_output_vec();
let output = inverse.make_output_vec();
let noise_profile = vec![0.0; spectrum.len()];
let prev_input_tail = vec![0.0; hop_len];
let overlap_output = vec![0.0; hop_len];
Self {
len,
hop_len,
forward,
inverse,
analysis_window,
synthesis_window,
input,
spectrum,
output,
noise_profile,
prev_input_tail,
overlap_output,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum StartupPhase {
Warmup,
NoiseProfiling,
FadeIn,
Active,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum FadeInReason {
ProfiledNoise,
Timeout,
}
pub struct SpectralDenoiseProcessor {
profile: AudioProfile,
amount: f32,
noise_calibration_ms: u32,
subtraction_mix: f32,
speech_hold_frames: u8,
sample_rate: u32,
startup_phase: StartupPhase,
fade_in_reason: Option<FadeInReason>,
startup_frames_seen: u64,
warmup_frames: u64,
startup_timeout_frames: u64,
fade_in_frames: u64,
fade_in_progress: u64,
required_noise_samples: usize,
profiled_noise_samples: usize,
profiled_noise_frames: u64,
timeout_reached: bool,
startup_mix_cap: f32,
plan: Option<SpectralPlan>,
}
impl SpectralDenoiseProcessor {
pub fn new(amount: f32, profile: AudioProfile, noise_calibration_ms: u32) -> Self {
Self {
profile,
amount: amount.clamp(0.0, 0.9),
noise_calibration_ms,
subtraction_mix: 0.0,
speech_hold_frames: 0,
sample_rate: 44_100,
startup_phase: StartupPhase::Warmup,
fade_in_reason: None,
startup_frames_seen: 0,
warmup_frames: 0,
startup_timeout_frames: 0,
fade_in_frames: 0,
fade_in_progress: 0,
required_noise_samples: 0,
profiled_noise_samples: 0,
profiled_noise_frames: 0,
timeout_reached: false,
startup_mix_cap: 1.0,
plan: None,
}
}
fn ensure_plan(&mut self, hop_len: usize) {
let needs_rebuild = self
.plan
.as_ref()
.map(|plan| plan.hop_len != hop_len)
.unwrap_or(true);
if needs_rebuild {
self.plan = Some(SpectralPlan::new(hop_len));
}
}
fn confidence(&self) -> f32 {
if self.required_noise_samples == 0 {
0.0
} else {
(self.profiled_noise_samples as f32 / self.required_noise_samples as f32)
.clamp(0.0, 1.0)
}
}
fn startup_mix_target(&self) -> f32 {
match self.startup_phase {
StartupPhase::Warmup | StartupPhase::NoiseProfiling => 0.0,
StartupPhase::FadeIn => self.startup_mix_cap,
StartupPhase::Active => {
if self.timeout_reached {
(0.25 + 0.75 * self.confidence()).clamp(0.25, 1.0)
} else {
1.0
}
}
}
}
}
impl AudioProcessor for SpectralDenoiseProcessor {
fn name(&self) -> &'static str {
"spectral_denoise"
}
fn prepare(&mut self, spec: AudioSpec) -> AudioResult<()> {
self.sample_rate = spec.sample_rate;
self.required_noise_samples =
((self.sample_rate as u64 * self.noise_calibration_ms as u64) / 1000) as usize;
self.subtraction_mix = 0.0;
self.startup_phase = StartupPhase::Warmup;
self.fade_in_reason = None;
self.startup_frames_seen = 0;
self.profiled_noise_samples = 0;
self.profiled_noise_frames = 0;
self.timeout_reached = false;
self.startup_mix_cap = 1.0;
self.fade_in_progress = 0;
let frame_len = (spec.sample_rate as usize / 100)
.max(160)
.next_power_of_two() as u64;
let warmup_samples = ((self.sample_rate as u64 * 120) / 1000).max(frame_len);
self.warmup_frames = warmup_samples.div_ceil(frame_len).max(1);
let timeout_samples = ((self.sample_rate as u64 * self.noise_calibration_ms as u64 * 2)
/ 1000)
.max((self.sample_rate as u64 * 700) / 1000);
self.startup_timeout_frames = timeout_samples
.div_ceil(frame_len)
.max(self.warmup_frames + 1);
let fade_in_samples = ((self.sample_rate as u64 * 260) / 1000).max(frame_len * 2);
self.fade_in_frames = fade_in_samples.div_ceil(frame_len).max(4);
if let Some(plan) = self.plan.as_mut() {
plan.noise_profile.fill(0.0);
plan.prev_input_tail.fill(0.0);
plan.overlap_output.fill(0.0);
}
Ok(())
}
fn process(&mut self, frame: &mut AudioFrame) -> AudioResult<()> {
if frame.samples.len() < 32 {
return Ok(());
}
let speechiness = frame
.samples
.iter()
.map(|sample| sample.abs())
.fold(0.0f32, f32::max);
if speechiness > 0.08 {
self.speech_hold_frames = 10;
} else if self.speech_hold_frames > 0 {
self.speech_hold_frames -= 1;
}
self.startup_frames_seen += 1;
self.ensure_plan(frame.samples.len());
let hop_len = self
.plan
.as_ref()
.ok_or_else(|| AudioError::new("Spectral denoise plan missing"))?
.hop_len;
if frame.samples.len() != hop_len {
return Ok(());
}
let current_input = frame.samples.clone();
let analysis_noise_floor = 0.01;
let features = analyze_frame(¤t_input, analysis_noise_floor);
let frame_class = classify_frame(self.profile, features, analysis_noise_floor);
let noise_only_frame = frame_class == FrameClass::NoiseOnly;
let in_startup = self.startup_phase != StartupPhase::Active;
if self.startup_phase == StartupPhase::Warmup
&& self.startup_frames_seen >= self.warmup_frames
{
self.startup_phase = StartupPhase::NoiseProfiling;
}
if self.startup_phase == StartupPhase::NoiseProfiling
&& self.profiled_noise_samples >= self.required_noise_samples.max(frame.samples.len())
{
self.startup_phase = StartupPhase::FadeIn;
self.fade_in_reason = Some(FadeInReason::ProfiledNoise);
self.startup_mix_cap = 1.0;
self.fade_in_progress = 0;
} else if self.startup_phase == StartupPhase::NoiseProfiling
&& self.startup_frames_seen >= self.startup_timeout_frames
{
self.startup_phase = StartupPhase::FadeIn;
self.fade_in_reason = Some(FadeInReason::Timeout);
self.timeout_reached = true;
self.startup_mix_cap = (0.20 + self.confidence() * 0.45).clamp(0.20, 0.65);
self.fade_in_progress = 0;
}
let target_mix = self.startup_mix_target();
let plan = self
.plan
.as_mut()
.ok_or_else(|| AudioError::new("Spectral denoise plan missing"))?;
for (index, value) in plan.input.iter_mut().enumerate() {
let sample = if index < plan.hop_len {
plan.prev_input_tail[index]
} else {
current_input[index - plan.hop_len]
};
*value = sample * plan.analysis_window[index];
}
plan.forward
.process(&mut plan.input, &mut plan.spectrum)
.map_err(|err| AudioError::new(format!("Spectral forward FFT failed: {}", err)))?;
let noise_tracking = match (in_startup, frame_class, self.speech_hold_frames) {
(true, FrameClass::NoiseOnly, _) => 0.35,
(true, _, _) => 0.0,
(false, FrameClass::NoiseOnly, 0) => 0.10,
(false, FrameClass::Transitional, 0) => 0.03,
_ => 0.01,
};
let tuning = profile_tuning(self.profile);
let mix_smoothing = if target_mix > self.subtraction_mix {
if self.startup_phase == StartupPhase::FadeIn {
0.10
} else {
0.18
}
} else {
0.08
};
self.subtraction_mix = smooth_towards(self.subtraction_mix, target_mix, mix_smoothing);
let subtraction_strength =
(self.amount * tuning.spectral_subtraction_scale * self.subtraction_mix)
.clamp(0.0, 0.95);
let floor_ratio = tuning.spectral_floor_ratio;
let bin_hz = self.sample_rate as f32 / plan.len as f32;
for (index, (bin, noise_mag)) in plan
.spectrum
.iter_mut()
.zip(plan.noise_profile.iter_mut())
.enumerate()
{
let magnitude = bin.norm();
*noise_mag = *noise_mag * (1.0 - noise_tracking) + magnitude * noise_tracking;
let frequency_hz = index as f32 * bin_hz;
let band_weight = band_weight_for_voice(self.profile, frequency_hz);
let reduced = (magnitude - *noise_mag * subtraction_strength * band_weight)
.max(*noise_mag * floor_ratio);
let scale = if magnitude > 1e-6 {
reduced / magnitude
} else {
1.0
};
*bin *= scale;
}
if self.startup_phase == StartupPhase::NoiseProfiling && noise_only_frame {
self.profiled_noise_samples += frame.samples.len();
self.profiled_noise_frames += 1;
}
if self.startup_phase == StartupPhase::FadeIn {
self.fade_in_progress += 1;
if self.fade_in_progress >= self.fade_in_frames {
self.startup_phase = StartupPhase::Active;
}
}
plan.inverse
.process(&mut plan.spectrum, &mut plan.output)
.map_err(|err| AudioError::new(format!("Spectral inverse FFT failed: {}", err)))?;
let normalize = 1.0 / plan.len as f32;
for index in 0..plan.hop_len {
let current = plan.output[index] * normalize * plan.synthesis_window[index];
frame.samples[index] = current + plan.overlap_output[index];
}
for index in 0..plan.hop_len {
plan.overlap_output[index] = plan.output[index + plan.hop_len]
* normalize
* plan.synthesis_window[index + plan.hop_len];
plan.prev_input_tail[index] = current_input[index];
}
Ok(())
}
fn diagnostics_notes(&self) -> Vec<String> {
let reason = match self.fade_in_reason {
Some(FadeInReason::ProfiledNoise) => "profiled_noise",
Some(FadeInReason::Timeout) => "timeout",
None => "not_started",
};
vec![
format!("spectral_startup_phase={:?}", self.startup_phase),
format!("spectral_warmup_frames={}", self.warmup_frames),
format!(
"spectral_noise_profiled_frames={}",
self.profiled_noise_frames
),
format!("spectral_fade_in_reason={}", reason),
format!("spectral_timeout_reached={}", self.timeout_reached),
]
}
}
pub struct DehissProcessor {
profile: AudioProfile,
filter: Option<DirectForm1<f32>>,
noise_floor: f32,
speech_envelope: f32,
current_strength: f32,
}
pub struct AirBandNoiseReducerProcessor {
profile: AudioProfile,
filter: Option<DirectForm1<f32>>,
noise_floor: f32,
speech_envelope: f32,
current_strength: f32,
}
impl DehissProcessor {
pub fn new(profile: AudioProfile) -> Self {
Self {
profile,
filter: None,
noise_floor: 0.01,
speech_envelope: 0.0,
current_strength: 0.0,
}
}
}
impl AirBandNoiseReducerProcessor {
pub fn new(profile: AudioProfile) -> Self {
Self {
profile,
filter: None,
noise_floor: 0.01,
speech_envelope: 0.0,
current_strength: 0.0,
}
}
}
impl AudioProcessor for DehissProcessor {
fn name(&self) -> &'static str {
"dehiss"
}
fn prepare(&mut self, spec: AudioSpec) -> AudioResult<()> {
let cutoff_hz = match self.profile {
AudioProfile::Raw => spec.sample_rate as f32 / 2.0 - 100.0,
_ => profile_tuning(self.profile).post_low_pass_hz,
};
let coeffs = Coefficients::<f32>::from_params(
Type::LowPass,
Hertz::<f32>::from_hz(spec.sample_rate as f32)
.map_err(|_| AudioError::new("Invalid sample rate for dehiss"))?,
cutoff_hz.hz(),
Q_BUTTERWORTH_F32,
)
.map_err(|_| AudioError::new("Invalid dehiss configuration"))?;
self.filter = Some(DirectForm1::<f32>::new(coeffs));
Ok(())
}
fn process(&mut self, frame: &mut AudioFrame) -> AudioResult<()> {
if frame.samples.is_empty() {
return Ok(());
}
let features = analyze_frame(&frame.samples, self.noise_floor);
let rms = features.rms;
let peak = features.peak;
if peak < self.noise_floor * 3.2 && rms < self.noise_floor * 2.2 {
self.noise_floor = self.noise_floor * 0.93 + rms.max(0.0005) * 0.07;
} else {
self.noise_floor = self.noise_floor * 0.997 + rms.max(0.0005) * 0.003;
}
self.speech_envelope = self.speech_envelope * 0.84 + features.speechiness * 0.16;
let frame_class = classify_frame(self.profile, features, self.noise_floor);
let tuning = profile_tuning(self.profile);
let base_strength = match frame_class {
FrameClass::NoiseOnly => tuning.dehiss_strength * 1.30,
FrameClass::Transitional => tuning.dehiss_strength,
FrameClass::SpeechLike => tuning.dehiss_strength * 0.55,
}
.clamp(0.0, 0.95);
let speech_relief = ((self.speech_envelope - tuning.adaptive_min_speechiness)
/ tuning.adaptive_min_speechiness.max(1.0))
.clamp(0.0, 1.0);
let target_strength = (base_strength * (1.0 - 0.45 * speech_relief)).clamp(0.0, 0.95);
let start_strength = self.current_strength;
let smoothing = if target_strength > self.current_strength {
0.20
} else {
0.12
};
self.current_strength = smooth_towards(self.current_strength, target_strength, smoothing);
let filter = self
.filter
.as_mut()
.ok_or_else(|| AudioError::new("Dehiss filter not prepared"))?;
let len = frame.samples.len().max(1) as f32;
for (index, sample) in frame.samples.iter_mut().enumerate() {
let t = index as f32 / len;
let strength = lerp(start_strength, self.current_strength, t);
let low = filter.run(*sample);
let high = *sample - low;
*sample = low + high * (1.0 - strength);
}
Ok(())
}
}
impl AudioProcessor for AirBandNoiseReducerProcessor {
fn name(&self) -> &'static str {
"air_band_reducer"
}
fn prepare(&mut self, spec: AudioSpec) -> AudioResult<()> {
let cutoff_hz = profile_tuning(self.profile).air_band_split_hz;
let coeffs = Coefficients::<f32>::from_params(
Type::LowPass,
Hertz::<f32>::from_hz(spec.sample_rate as f32)
.map_err(|_| AudioError::new("Invalid sample rate for air-band reducer"))?,
cutoff_hz.hz(),
Q_BUTTERWORTH_F32,
)
.map_err(|_| AudioError::new("Invalid air-band reducer configuration"))?;
self.filter = Some(DirectForm1::<f32>::new(coeffs));
Ok(())
}
fn process(&mut self, frame: &mut AudioFrame) -> AudioResult<()> {
if frame.samples.is_empty() {
return Ok(());
}
let features = analyze_frame(&frame.samples, self.noise_floor);
let rms = features.rms;
let peak = features.peak;
if peak < self.noise_floor * 3.2 && rms < self.noise_floor * 2.2 {
self.noise_floor = self.noise_floor * 0.93 + rms.max(0.0005) * 0.07;
} else {
self.noise_floor = self.noise_floor * 0.997 + rms.max(0.0005) * 0.003;
}
self.speech_envelope = self.speech_envelope * 0.84 + features.speechiness * 0.16;
let frame_class = classify_frame(self.profile, features, self.noise_floor);
let tuning = profile_tuning(self.profile);
let base_strength = match frame_class {
FrameClass::NoiseOnly => tuning.air_band_reduction * 1.2,
FrameClass::Transitional => tuning.air_band_reduction,
FrameClass::SpeechLike => tuning.air_band_reduction * 0.4,
}
.clamp(0.0, 0.9);
let speech_relief = ((self.speech_envelope - tuning.adaptive_min_speechiness)
/ tuning.adaptive_min_speechiness.max(1.0))
.clamp(0.0, 1.0);
let target_strength = (base_strength * (1.0 - 0.55 * speech_relief)).clamp(0.0, 0.9);
let start_strength = self.current_strength;
let smoothing = if target_strength > self.current_strength {
0.18
} else {
0.10
};
self.current_strength = smooth_towards(self.current_strength, target_strength, smoothing);
let filter = self
.filter
.as_mut()
.ok_or_else(|| AudioError::new("Air-band reducer not prepared"))?;
let len = frame.samples.len().max(1) as f32;
for (index, sample) in frame.samples.iter_mut().enumerate() {
let t = index as f32 / len;
let high_band_strength = lerp(start_strength, self.current_strength, t);
let low = filter.run(*sample);
let high = *sample - low;
*sample = low + high * (1.0 - high_band_strength);
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::contracts::AudioProcessor;
fn test_spec() -> AudioSpec {
AudioSpec {
sample_rate: 44_100,
channels: 1,
}
}
fn noise_frame(spec: AudioSpec, amplitude: f32) -> AudioFrame {
AudioFrame {
samples: (0..512)
.map(|i| if i % 2 == 0 { amplitude } else { -amplitude })
.collect(),
spec,
}
}
fn speech_frame(spec: AudioSpec) -> AudioFrame {
AudioFrame {
samples: (0..512)
.map(|i| {
let t = i as f32 / spec.sample_rate as f32;
let body = (std::f32::consts::TAU * 220.0 * t).sin() * 0.16;
let brightness = (std::f32::consts::TAU * 2_800.0 * t).sin() * 0.04;
body + brightness
})
.collect(),
spec,
}
}
#[test]
fn spectral_denoise_protects_immediate_speech_during_startup() {
let spec = test_spec();
let mut processor = SpectralDenoiseProcessor::new(0.6, AudioProfile::VoiceHvac, 350);
processor.prepare(spec).unwrap();
for _ in 0..6 {
let mut frame = speech_frame(spec);
processor.process(&mut frame).unwrap();
}
assert_eq!(processor.profiled_noise_frames, 0);
assert!(processor.subtraction_mix < 0.05);
assert_ne!(processor.startup_phase, StartupPhase::Active);
}
#[test]
fn spectral_denoise_profiles_only_noise_before_fade_in() {
let spec = test_spec();
let mut processor = SpectralDenoiseProcessor::new(0.6, AudioProfile::VoiceHvac, 180);
processor.prepare(spec).unwrap();
while processor.startup_phase != StartupPhase::Active {
let mut frame = noise_frame(spec, 0.01);
processor.process(&mut frame).unwrap();
if processor.startup_frames_seen > 64 {
break;
}
}
assert!(processor.profiled_noise_frames > 0);
assert_eq!(processor.fade_in_reason, Some(FadeInReason::ProfiledNoise));
assert_eq!(processor.startup_phase, StartupPhase::Active);
assert!(processor.subtraction_mix > 0.20);
}
#[test]
fn spectral_denoise_times_out_conservatively_without_noise_frames() {
let spec = test_spec();
let mut processor = SpectralDenoiseProcessor::new(0.6, AudioProfile::VoiceHvac, 120);
processor.prepare(spec).unwrap();
while processor.startup_phase != StartupPhase::Active {
let mut frame = speech_frame(spec);
processor.process(&mut frame).unwrap();
if processor.startup_frames_seen > 96 {
break;
}
}
assert!(processor.timeout_reached);
assert_eq!(processor.fade_in_reason, Some(FadeInReason::Timeout));
assert_eq!(processor.profiled_noise_frames, 0);
assert!(processor.subtraction_mix < 0.45);
}
#[test]
fn spectral_denoise_ignores_transitional_frames_during_noise_profiling() {
let spec = test_spec();
let mut processor = SpectralDenoiseProcessor::new(0.6, AudioProfile::VoiceHvac, 350);
processor.prepare(spec).unwrap();
while processor.startup_phase == StartupPhase::Warmup {
let mut frame = speech_frame(spec);
processor.process(&mut frame).unwrap();
}
let before = processor.profiled_noise_frames;
let mut transitional = AudioFrame {
samples: (0..512)
.map(|i| {
let t = i as f32 / spec.sample_rate as f32;
(std::f32::consts::TAU * 320.0 * t).sin() * 0.03
})
.collect(),
spec,
};
processor.process(&mut transitional).unwrap();
assert_eq!(processor.startup_phase, StartupPhase::NoiseProfiling);
assert_eq!(processor.profiled_noise_frames, before);
}
#[test]
fn dehiss_reduces_high_frequency_residue() {
let spec = test_spec();
let mut processor = DehissProcessor::new(AudioProfile::VoiceHvac);
processor.prepare(spec).unwrap();
let mut frame = AudioFrame {
samples: (0..512)
.map(|i| {
let voice = ((i as f32 / 512.0) * std::f32::consts::TAU * 6.0).sin() * 0.15;
let hiss = if i % 2 == 0 { 0.06 } else { -0.06 };
voice + hiss
})
.collect(),
spec,
};
let before =
frame.samples.iter().map(|s| s.abs()).sum::<f32>() / frame.samples.len() as f32;
processor.process(&mut frame).unwrap();
let after = frame.samples.iter().map(|s| s.abs()).sum::<f32>() / frame.samples.len() as f32;
assert!(after < before);
assert!(after > before * 0.55);
}
#[test]
fn air_band_reducer_softens_high_band_without_collapsing_signal() {
let spec = test_spec();
let mut processor = AirBandNoiseReducerProcessor::new(AudioProfile::VoiceHvac);
processor.prepare(spec).unwrap();
let mut frame = AudioFrame {
samples: (0..512)
.map(|i| {
let voice = ((i as f32 / 512.0) * std::f32::consts::TAU * 7.0).sin() * 0.14;
let high_band = if i % 2 == 0 { 0.05 } else { -0.05 };
voice + high_band
})
.collect(),
spec,
};
let before =
frame.samples.iter().map(|s| s.abs()).sum::<f32>() / frame.samples.len() as f32;
processor.process(&mut frame).unwrap();
let after = frame.samples.iter().map(|s| s.abs()).sum::<f32>() / frame.samples.len() as f32;
assert!(after < before);
assert!(after > before * 0.60);
}
}