From 2c3001d32c37232df603b1243df3eda823e491ce Mon Sep 17 00:00:00 2001 From: Mal Detair Date: Sat, 7 Feb 2026 19:28:19 +0100 Subject: [PATCH] fix: resolve 3 medium-severity review issues 1. Live-updatable gate threshold, suppression & dynamic threshold: Added Arc fields to VoidProcessor for gate_threshold, suppression_strength, and dynamic_threshold_enabled. The audio thread now reads these atomics each frame instead of using values captured at engine start. GUI slider/preset/calibration changes take effect immediately without restarting the engine. 2. Cache pactl virtual sink check: virtual_sink_exists() was spawning pactl on every GUI frame (~30fps). Now cached with a 2-second refresh timer, matching the connected apps refresh pattern. 3. Pre-allocate spectrum Hann window buffers: Pre-compute Hann window coefficients once at processor creation and reuse fixed-size buffers for windowed samples, eliminating two Vec allocations per spectrum analysis call on the audio thread. Co-Authored-By: Claude Opus 4.6 --- crates/app/src/audio.rs | 32 +++++++++++++++++++++++--------- crates/app/src/gui.rs | 29 +++++++++++++++++++++++++++-- crates/core/src/processor.rs | 33 ++++++++++++++++++++++++++++----- 3 files changed, 78 insertions(+), 16 deletions(-) diff --git a/crates/app/src/audio.rs b/crates/app/src/audio.rs index 8a14eb1..3e83dbb 100644 --- a/crates/app/src/audio.rs +++ b/crates/app/src/audio.rs @@ -38,6 +38,9 @@ pub struct AudioEngine { pub _agc_target: Arc, // Kept for potential GUI control pub bypass_enabled: Arc, pub jitter_max_us: Arc, + pub gate_threshold: Arc, + pub suppression_strength: Arc, + pub dynamic_threshold_enabled: Arc, pub _spectrum_sender: Option, Vec)>>, } @@ -184,11 +187,16 @@ impl AudioEngine { echo_cancel_enabled, ); - // Set initial state - // processor.gate_threshold is passed in process_frame, we don't set it here explicitly? - // Wait, processor doesn't store threshold. It's passed in process_frame. - // But we need it for the thread loop? - // In the thread loop: start with the `gate_threshold` argument. + // Set initial state via atomics (live-updatable from GUI) + processor + .gate_threshold + .store(gate_threshold.to_bits(), Ordering::Relaxed); + processor + .suppression_strength + .store(suppression_strength.to_bits(), Ordering::Relaxed); + processor + .dynamic_threshold_enabled + .store(dynamic_threshold_enabled, Ordering::Relaxed); processor.agc_enabled.store(agc_enabled, Ordering::Relaxed); processor .bypass_enabled @@ -209,6 +217,9 @@ impl AudioEngine { let agc_target_atomic = processor.agc_target.clone(); let bypass_enabled_atomic = processor.bypass_enabled.clone(); let jitter_atomic = processor.jitter_max_us.clone(); + let gate_threshold_atomic = processor.gate_threshold.clone(); + let suppression_atomic = processor.suppression_strength.clone(); + let dynamic_threshold_atomic = processor.dynamic_threshold_enabled.clone(); let is_running = Arc::new(AtomicBool::new(true)); let run_flag = is_running.clone(); @@ -270,14 +281,14 @@ impl AudioEngine { None }; - // Process Audio + // Process Audio (read live values from atomics) processor.process_frame( &[&input_frame], &mut [&mut output_frame], ref_frames, - suppression_strength, - gate_threshold, - dynamic_threshold_enabled, + f32::from_bits(processor.suppression_strength.load(Ordering::Relaxed)), + f32::from_bits(processor.gate_threshold.load(Ordering::Relaxed)), + processor.dynamic_threshold_enabled.load(Ordering::Relaxed), ); // Write Audio @@ -312,6 +323,9 @@ impl AudioEngine { agc_enabled: agc_enabled_atomic, _agc_target: agc_target_atomic, bypass_enabled: bypass_enabled_atomic, + gate_threshold: gate_threshold_atomic, + suppression_strength: suppression_atomic, + dynamic_threshold_enabled: dynamic_threshold_atomic, _spectrum_sender: spectrum_sender, jitter_max_us: jitter_atomic, }) diff --git a/crates/app/src/gui.rs b/crates/app/src/gui.rs index cf6345a..af20924 100644 --- a/crates/app/src/gui.rs +++ b/crates/app/src/gui.rs @@ -124,6 +124,8 @@ struct VoidMicApp { virtual_sink_module_id: Option, connected_apps: Vec, last_app_refresh: std::time::Instant, + virtual_sink_cached: bool, + last_sink_check: std::time::Instant, // Output Filter (Speaker Denoising) output_filter_engine: Option, // Echo Cancellation @@ -211,6 +213,8 @@ impl VoidMicApp { connected_apps: Vec::new(), output_filter_engine: None, last_app_refresh: std::time::Instant::now(), + virtual_sink_cached: false, + last_sink_check: std::time::Instant::now() - std::time::Duration::from_secs(5), selected_reference: default_ref, hotkey_manager: GlobalHotKeyManager::new().unwrap(), hotkey_id: None, @@ -375,6 +379,13 @@ impl VoidMicApp { // Output filter is user preference self.config.preset = preset_name.to_string(); self.save_config_now(); + + // Update running engine immediately + if let Some(engine) = &self.engine { + engine.gate_threshold.store(self.config.gate_threshold.to_bits(), Ordering::Relaxed); + engine.suppression_strength.store(self.config.suppression_strength.to_bits(), Ordering::Relaxed); + engine.dynamic_threshold_enabled.store(self.config.dynamic_threshold_enabled, Ordering::Relaxed); + } } } @@ -460,9 +471,13 @@ impl VoidMicApp { ui.add_space(10.0); - // One-Click Setup Section + // One-Click Setup Section (cache pactl check, refresh every 2 seconds) + if self.last_sink_check.elapsed().as_secs() >= 2 { + self.virtual_sink_cached = virtual_device::virtual_sink_exists(); + self.last_sink_check = std::time::Instant::now(); + } ui.horizontal(|ui| { - let sink_exists = virtual_device::virtual_sink_exists(); + let sink_exists = self.virtual_sink_cached; if sink_exists { ui.colored_label(egui::Color32::GREEN, "✔ Virtual Mic Active"); @@ -551,6 +566,9 @@ impl VoidMicApp { { self.config.preset = "Custom".to_string(); self.mark_config_dirty(); + if let Some(engine) = &self.engine { + engine.dynamic_threshold_enabled.store(self.config.dynamic_threshold_enabled, Ordering::Relaxed); + } } ui.add_enabled_ui(!self.config.dynamic_threshold_enabled, |ui| { @@ -561,6 +579,9 @@ impl VoidMicApp { if ui.add(slider).changed() { self.config.preset = "Custom".to_string(); self.mark_config_dirty(); + if let Some(engine) = &self.engine { + engine.gate_threshold.store(self.config.gate_threshold.to_bits(), Ordering::Relaxed); + } } }); @@ -588,6 +609,9 @@ impl VoidMicApp { if ui.add(slider).changed() { self.config.preset = "Custom".to_string(); self.mark_config_dirty(); + if let Some(engine) = &self.engine { + engine.suppression_strength.store(self.config.suppression_strength.to_bits(), Ordering::Relaxed); + } } }); } @@ -600,6 +624,7 @@ impl VoidMicApp { let result = f32::from_bits(engine.calibration_result.load(Ordering::Relaxed)); if result > 0.0 { self.config.gate_threshold = result; + engine.gate_threshold.store(result.to_bits(), Ordering::Relaxed); self.save_config_now(); self.status_msg = format!("Calibrated! Threshold set to {:.3}", result); } diff --git a/crates/core/src/processor.rs b/crates/core/src/processor.rs index 7f6d2c9..f94083b 100644 --- a/crates/core/src/processor.rs +++ b/crates/core/src/processor.rs @@ -5,7 +5,6 @@ use biquad::{Biquad, Coefficients, DirectForm2Transposed, ToHertz, Type}; use crossbeam_channel::Sender; use nnnoiseless::DenoiseState; use spectrum_analyzer::scaling::divide_by_N_sqrt; -use spectrum_analyzer::windows::hann_window; use spectrum_analyzer::{samples_fft_to_spectrum, FrequencyLimit}; use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::sync::Arc; @@ -245,12 +244,18 @@ pub struct VoidProcessor { pub agc_target: Arc, pub bypass_enabled: Arc, pub jitter_max_us: Arc, + pub gate_threshold: Arc, + pub suppression_strength: Arc, + pub dynamic_threshold_enabled: Arc, pub spectrum_sender: Option, Vec)>>, // Pre-allocated spectrum buffers (avoid allocations in audio thread) spectrum_in_buf: Vec, spectrum_out_buf: Vec, spectrum_frame_counter: u32, + hann_coefficients: [f32; FRAME_SIZE], + windowed_in: [f32; FRAME_SIZE], + windowed_out: [f32; FRAME_SIZE], } // Safety: VoidProcessor owns all its mutable state (Vad, EchoCanceller, DenoiseState) and is moved @@ -281,6 +286,14 @@ impl VoidProcessor { let mut echo_canceller = Vec::with_capacity(channels); let mut eq = Vec::with_capacity(channels); + // Pre-compute Hann window coefficients (periodic form matching spectrum-analyzer crate) + let mut hann_coefficients = [0.0f32; FRAME_SIZE]; + for (i, coeff) in hann_coefficients.iter_mut().enumerate() { + *coeff = 0.5 + * (1.0 + - (2.0 * std::f32::consts::PI * i as f32 / FRAME_SIZE as f32).cos()); + } + for _ in 0..channels { denoise.push(DenoiseState::new()); if echo_cancel_enabled { @@ -326,11 +339,17 @@ impl VoidProcessor { agc_target: Arc::new(AtomicU32::new(agc_target_level.to_bits())), bypass_enabled: Arc::new(AtomicBool::new(false)), jitter_max_us: Arc::new(AtomicU32::new(0)), + gate_threshold: Arc::new(AtomicU32::new(0.015f32.to_bits())), + suppression_strength: Arc::new(AtomicU32::new(1.0f32.to_bits())), + dynamic_threshold_enabled: Arc::new(AtomicBool::new(false)), spectrum_sender: None, // Pre-allocate spectrum buffers (FRAME_SIZE/2 bins typical for FFT) spectrum_in_buf: Vec::with_capacity(FRAME_SIZE / 2), spectrum_out_buf: Vec::with_capacity(FRAME_SIZE / 2), spectrum_frame_counter: 0, + hann_coefficients, + windowed_in: [0.0; FRAME_SIZE], + windowed_out: [0.0; FRAME_SIZE], } } @@ -619,18 +638,22 @@ impl VoidProcessor { input_mono[j] *= norm_factor; } - let window_in = hann_window(&input_mono); + // Apply Hann window using pre-computed coefficients (avoids Vec allocation) + for j in 0..FRAME_SIZE { + self.windowed_in[j] = input_mono[j] * self.hann_coefficients[j]; + self.windowed_out[j] = mono_mix[j] * self.hann_coefficients[j]; + } + let input_spectrum = samples_fft_to_spectrum( - &window_in, + &self.windowed_in, SAMPLE_RATE, FrequencyLimit::Range(20.0, 20_000.0), Some(÷_by_N_sqrt), ) .ok(); - let window_out = hann_window(&mono_mix); let output_spectrum = samples_fft_to_spectrum( - &window_out, + &self.windowed_out, SAMPLE_RATE, FrequencyLimit::Range(20.0, 20_000.0), Some(÷_by_N_sqrt),