Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 23 additions & 9 deletions crates/app/src/audio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ pub struct AudioEngine {
pub _agc_target: Arc<AtomicU32>, // Kept for potential GUI control
pub bypass_enabled: Arc<AtomicBool>,
pub jitter_max_us: Arc<AtomicU32>,
pub gate_threshold: Arc<AtomicU32>,
pub suppression_strength: Arc<AtomicU32>,
pub dynamic_threshold_enabled: Arc<AtomicBool>,
pub _spectrum_sender: Option<Sender<(Vec<f32>, Vec<f32>)>>,
}

Expand Down Expand Up @@ -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
Expand All @@ -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();
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
})
Expand Down
29 changes: 27 additions & 2 deletions crates/app/src/gui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ struct VoidMicApp {
virtual_sink_module_id: Option<u32>,
connected_apps: Vec<String>,
last_app_refresh: std::time::Instant,
virtual_sink_cached: bool,
last_sink_check: std::time::Instant,
// Output Filter (Speaker Denoising)
output_filter_engine: Option<OutputFilterEngine>,
// Echo Cancellation
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
}
}
}

Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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| {
Expand All @@ -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);
}
}
});

Expand Down Expand Up @@ -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);
}
}
});
}
Expand All @@ -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);
}
Expand Down
33 changes: 28 additions & 5 deletions crates/core/src/processor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -245,12 +244,18 @@ pub struct VoidProcessor {
pub agc_target: Arc<AtomicU32>,
pub bypass_enabled: Arc<AtomicBool>,
pub jitter_max_us: Arc<AtomicU32>,
pub gate_threshold: Arc<AtomicU32>,
pub suppression_strength: Arc<AtomicU32>,
pub dynamic_threshold_enabled: Arc<AtomicBool>,
pub spectrum_sender: Option<Sender<(Vec<f32>, Vec<f32>)>>,

// Pre-allocated spectrum buffers (avoid allocations in audio thread)
spectrum_in_buf: Vec<f32>,
spectrum_out_buf: Vec<f32>,
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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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],
}
}

Expand Down Expand Up @@ -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(&divide_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(&divide_by_N_sqrt),
Expand Down
Loading