diff --git a/crates/app/src/audio.rs b/crates/app/src/audio.rs index 3e83dbb..12e0bb4 100644 --- a/crates/app/src/audio.rs +++ b/crates/app/src/audio.rs @@ -34,10 +34,11 @@ pub struct AudioEngine { pub eq_mid_gain: Arc, pub eq_high_gain: Arc, + pub eq_enabled: Arc, pub agc_enabled: Arc, pub _agc_target: Arc, // Kept for potential GUI control pub bypass_enabled: Arc, - pub jitter_max_us: Arc, + pub jitter_ewma_us: Arc, pub gate_threshold: Arc, pub suppression_strength: Arc, pub dynamic_threshold_enabled: Arc, @@ -135,18 +136,20 @@ impl AudioEngine { // Build reference capture stream if echo cancellation is enabled let reference_stream: Option = if let Some(ref_dev) = &reference_device { - let stream = ref_dev.build_input_stream( + match ref_dev.build_input_stream( &config, move |data: &[f32], _| { let _ = prod_ref.push_slice(data); }, |err| warn!("Reference input error: {}", err), None, - ).ok(); - if stream.is_none() { - warn!("Failed to open reference device for echo cancellation"); + ) { + Ok(stream) => Some(stream), + Err(e) => { + warn!("Failed to open reference device for echo cancellation: {}", e); + None + } } - stream } else { None }; @@ -175,14 +178,11 @@ impl AudioEngine { )?; // Initialize Processor + // Always pass real EQ params; eq_enabled atomic controls whether EQ runs let mut processor = VoidProcessor::new( 1, // Mono for App vad_sensitivity, - if eq_enabled { - eq_params - } else { - (0.0, 0.0, 0.0) - }, + eq_params, agc_target_level, echo_cancel_enabled, ); @@ -197,6 +197,7 @@ impl AudioEngine { processor .dynamic_threshold_enabled .store(dynamic_threshold_enabled, Ordering::Relaxed); + processor.eq_enabled.store(eq_enabled, Ordering::Relaxed); processor.agc_enabled.store(agc_enabled, Ordering::Relaxed); processor .bypass_enabled @@ -213,10 +214,11 @@ impl AudioEngine { let eq_low_atomic = processor.eq_low_gain.clone(); let eq_mid_atomic = processor.eq_mid_gain.clone(); let eq_high_atomic = processor.eq_high_gain.clone(); + let eq_enabled_atomic = processor.eq_enabled.clone(); let agc_enabled_atomic = processor.agc_enabled.clone(); 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 jitter_atomic = processor.jitter_ewma_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(); @@ -231,10 +233,10 @@ impl AudioEngine { let mut output_frame = [0.0f32; FRAME_SIZE]; let mut ref_frame = [0.0f32; FRAME_SIZE]; - // Jitter State + // Jitter State - EWMA for smoother, more responsive display let mut last_loop_time = std::time::Instant::now(); - let mut jitter_accum = 0; - let mut frames_since_jitter_reset = 0; + let mut jitter_ewma: f32 = 0.0; + let mut frames_since_jitter_report = 0u32; loop { if !run_flag.load(Ordering::Relaxed) { @@ -245,29 +247,26 @@ impl AudioEngine { processor.process_updates(); if cons_in.occupied_len() >= FRAME_SIZE { - // Jitter Calculation + // Jitter Calculation - skip obviously invalid deltas (e.g. system suspend) let now = std::time::Instant::now(); let loop_delta = now.duration_since(last_loop_time).as_micros() as u32; last_loop_time = now; - let expected = 10_000; - let jitter = if loop_delta > expected { - loop_delta - expected - } else { - expected - loop_delta - }; + if loop_delta < 100_000 { + let expected = 10_000u32; + let jitter = loop_delta.abs_diff(expected) as f32; - if jitter > jitter_accum { - jitter_accum = jitter; + // EWMA: alpha=0.1 gives ~10-frame smoothing + jitter_ewma = jitter_ewma * 0.9 + jitter * 0.1; } - frames_since_jitter_reset += 1; - if frames_since_jitter_reset >= 100 { + // Report to GUI every 50 frames (~500ms) + frames_since_jitter_report += 1; + if frames_since_jitter_report >= 50 { processor - .jitter_max_us - .store(jitter_accum, Ordering::Relaxed); - jitter_accum = 0; - frames_since_jitter_reset = 0; + .jitter_ewma_us + .store(jitter_ewma as u32, Ordering::Relaxed); + frames_since_jitter_report = 0; } // Read Audio @@ -291,11 +290,18 @@ impl AudioEngine { processor.dynamic_threshold_enabled.load(Ordering::Relaxed), ); - // Write Audio + // Write Audio - retry briefly if output buffer is full + let mut retries = 0; while prod_out.vacant_len() < FRAME_SIZE { - thread::sleep(Duration::from_micros(500)); + thread::yield_now(); + retries += 1; + if retries > 100 { + break; + } + } + if prod_out.vacant_len() >= FRAME_SIZE { + prod_out.push_slice(&output_frame); } - prod_out.push_slice(&output_frame); } else { thread::sleep(Duration::from_micros(200)); } @@ -320,6 +326,7 @@ impl AudioEngine { eq_low_gain: eq_low_atomic, eq_mid_gain: eq_mid_atomic, eq_high_gain: eq_high_atomic, + eq_enabled: eq_enabled_atomic, agc_enabled: agc_enabled_atomic, _agc_target: agc_target_atomic, bypass_enabled: bypass_enabled_atomic, @@ -327,7 +334,7 @@ impl AudioEngine { suppression_strength: suppression_atomic, dynamic_threshold_enabled: dynamic_threshold_atomic, _spectrum_sender: spectrum_sender, - jitter_max_us: jitter_atomic, + jitter_ewma_us: jitter_atomic, }) } } @@ -346,6 +353,7 @@ pub struct OutputFilterEngine { _input_stream: cpal::Stream, _output_stream: cpal::Stream, is_running: Arc, + pub suppression_strength: Arc, } impl OutputFilterEngine { @@ -417,6 +425,8 @@ impl OutputFilterEngine { let is_running = Arc::new(AtomicBool::new(true)); let run_flag = is_running.clone(); + let suppression_atomic = Arc::new(AtomicU32::new(suppression_strength.to_bits())); + let suppression_for_thread = suppression_atomic.clone(); thread::spawn(move || { let mut denoise = DenoiseState::new(); @@ -430,16 +440,24 @@ impl OutputFilterEngine { // Denoise with RNNoise denoise.process_frame(&mut output_frame, &input_frame); - // Blend based on suppression strength + // Blend based on suppression strength (live-updated from GUI) + let strength = f32::from_bits(suppression_for_thread.load(Ordering::Relaxed)); for i in 0..FRAME_SIZE { - output_frame[i] = input_frame[i] * (1.0 - suppression_strength) - + output_frame[i] * suppression_strength; + output_frame[i] = input_frame[i] * (1.0 - strength) + + output_frame[i] * strength; } + let mut retries = 0; while prod_out.vacant_len() < FRAME_SIZE { - thread::sleep(Duration::from_micros(500)); + thread::yield_now(); + retries += 1; + if retries > 100 { + break; + } + } + if prod_out.vacant_len() >= FRAME_SIZE { + prod_out.push_slice(&output_frame); } - prod_out.push_slice(&output_frame); } else { thread::sleep(Duration::from_micros(500)); } @@ -453,6 +471,7 @@ impl OutputFilterEngine { _input_stream: input_stream, _output_stream: output_stream, is_running, + suppression_strength: suppression_atomic, }) } } diff --git a/crates/app/src/gui.rs b/crates/app/src/gui.rs index af20924..80cd4fa 100644 --- a/crates/app/src/gui.rs +++ b/crates/app/src/gui.rs @@ -140,6 +140,10 @@ struct VoidMicApp { // Phase 6 spectrum_receiver: Option, Vec)>>, last_spectrum_data: (Vec, Vec), // Cache for rendering + // Track mini mode resize so we only send the command once + mini_mode_resized: bool, + // Periodic auto-save for dirty config + last_config_save: std::time::Instant, } const QUIT_ID: &str = "quit"; @@ -222,6 +226,8 @@ impl VoidMicApp { wizard_step: WizardStep::Welcome, spectrum_receiver: None, last_spectrum_data: (Vec::new(), Vec::new()), + mini_mode_resized: false, + last_config_save: std::time::Instant::now(), }; // Register Hotkey @@ -307,7 +313,9 @@ impl VoidMicApp { ) { Ok(filter) => self.output_filter_engine = Some(filter), Err(e) => { - log::warn!("Output filter failed to start: {}", e); + log::error!("Output filter failed to start: {}", e); + self.status_msg = format!("Active (output filter error: {})", e); + self.config.output_filter_enabled = false; } } } @@ -413,7 +421,6 @@ impl VoidMicApp { dismiss } - /// Renders the volume meter with dB scaling. /// Renders the volume meter with dB scaling and threshold marker. fn render_volume_meter(&self, ui: &mut egui::Ui) { let volume = if let Some(engine) = &self.engine { @@ -471,8 +478,8 @@ impl VoidMicApp { ui.add_space(10.0); - // One-Click Setup Section (cache pactl check, refresh every 2 seconds) - if self.last_sink_check.elapsed().as_secs() >= 2 { + // One-Click Setup Section (cache pactl check, refresh every 5 seconds) + if self.last_sink_check.elapsed().as_secs() >= 5 { self.virtual_sink_cached = virtual_device::virtual_sink_exists(); self.last_sink_check = std::time::Instant::now(); } @@ -612,6 +619,9 @@ impl VoidMicApp { if let Some(engine) = &self.engine { engine.suppression_strength.store(self.config.suppression_strength.to_bits(), Ordering::Relaxed); } + if let Some(filter) = &self.output_filter_engine { + filter.suppression_strength.store(self.config.suppression_strength.to_bits(), Ordering::Relaxed); + } } }); } @@ -647,6 +657,25 @@ impl VoidMicApp { .changed() { self.mark_config_dirty(); + // Start/stop output filter engine on toggle + if self.config.output_filter_enabled { + if self.engine.is_some() && self.output_filter_engine.is_none() { + match OutputFilterEngine::start( + &self.selected_reference, + &self.selected_output, + self.config.suppression_strength, + ) { + Ok(filter) => self.output_filter_engine = Some(filter), + Err(e) => { + log::error!("Output filter failed to start: {}", e); + self.status_msg = format!("Output filter error: {}", e); + self.config.output_filter_enabled = false; + } + } + } + } else { + self.output_filter_engine = None; + } } ui.label( egui::RichText::new("âš ī¸ ~100ms latency") @@ -661,6 +690,16 @@ impl VoidMicApp { .changed() { self.mark_config_dirty(); + // Echo cancel requires engine restart to reconfigure streams + if self.engine.is_some() { + let prev_echo = !self.config.echo_cancel_enabled; + self.stop_engine(); + self.start_engine(); + if self.engine.is_none() { + // Restart failed - revert toggle so UI matches reality + self.config.echo_cancel_enabled = prev_echo; + } + } } }); @@ -726,6 +765,9 @@ impl VoidMicApp { .changed() { self.mark_config_dirty(); + if let Some(engine) = &self.engine { + engine.eq_enabled.store(self.config.eq_enabled, Ordering::Relaxed); + } } }); @@ -837,7 +879,7 @@ impl VoidMicApp { .engine .as_ref() .unwrap() - .jitter_max_us + .jitter_ewma_us .load(Ordering::Relaxed); ui.add_space(5.0); ui.horizontal(|ui| { @@ -961,17 +1003,18 @@ impl VoidMicApp { ui.heading("🎤 Select Microphone"); ui.add_space(10.0); ui.label("Choose the microphone you want to clean up:"); - let input_devices = self.input_devices.clone(); + let mut changed = false; egui::ComboBox::from_id_salt("wizard_mic") .selected_text(&self.selected_input) .width(250.0) .show_ui(ui, |ui| { - for dev in &input_devices { + for dev in &self.input_devices { if ui.selectable_value(&mut self.selected_input, dev.clone(), dev).changed() { - self.mark_config_dirty(); + changed = true; } } }); + if changed { self.mark_config_dirty(); } ui.add_space(40.0); if ui.button("Next ➡").clicked() { @@ -982,17 +1025,18 @@ impl VoidMicApp { ui.heading("🔊 Select Output"); ui.add_space(10.0); ui.label("Choose where you want to hear the processed audio (or your speakers):"); - let output_devices = self.output_devices.clone(); + let mut changed = false; egui::ComboBox::from_id_salt("wizard_out") .selected_text(&self.selected_output) .width(250.0) .show_ui(ui, |ui| { - for dev in &output_devices { + for dev in &self.output_devices { if ui.selectable_value(&mut self.selected_output, dev.clone(), dev).changed() { - self.mark_config_dirty(); + changed = true; } } }); + if changed { self.mark_config_dirty(); } ui.add_space(40.0); ui.horizontal(|ui| { @@ -1089,7 +1133,19 @@ impl eframe::App for VoidMicApp { ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); } - ctx.request_repaint_after(std::time::Duration::from_millis(33)); + // Only repaint at 30fps when engine is running (for meters/visualizer) + // Otherwise use a slow poll rate for tray events and hotkeys + if self.engine.is_some() { + ctx.request_repaint_after(std::time::Duration::from_millis(33)); + } else { + ctx.request_repaint_after(std::time::Duration::from_millis(500)); + } + + // Auto-save dirty config every 5 seconds to prevent data loss on crash + if self.config_dirty && self.last_config_save.elapsed().as_secs() >= 5 { + self.save_config(); + self.last_config_save = std::time::Instant::now(); + } // Check for update result from async receiver if let Some(ref rx) = self.update_receiver { @@ -1105,9 +1161,13 @@ impl eframe::App for VoidMicApp { } if self.config.mini_mode { - if !self.render_mini(ctx) { - // Shrink window if not expanding + if self.render_mini(ctx) { + // Expanding back to full size + self.mini_mode_resized = false; + } else if !self.mini_mode_resized { + // Shrink window once on transition to mini mode ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize([150.0, 150.0].into())); + self.mini_mode_resized = true; } return; } @@ -1118,12 +1178,15 @@ impl eframe::App for VoidMicApp { self.update_info = None; } + egui::ScrollArea::vertical().auto_shrink(false).show(ui, |ui| { + ui.heading("VoidMic 🌌"); ui.horizontal(|ui| { ui.label(egui::RichText::new("Hybrid Noise Reduction").size(10.0).weak()); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui.button("➖").on_hover_text("Compact Mode").clicked() { self.config.mini_mode = true; + self.mini_mode_resized = false; self.mark_config_dirty(); } }); @@ -1247,15 +1310,7 @@ impl eframe::App for VoidMicApp { if ui.checkbox(&mut dark_mode, "Dark Mode").changed() { self.config.dark_mode = dark_mode; self.save_config_now(); - // Apply theme change immediately - if dark_mode { - let mut visuals = egui::Visuals::dark(); - visuals.window_fill = egui::Color32::from_rgb(20, 20, 25); - visuals.panel_fill = egui::Color32::from_rgb(20, 20, 25); - ui.ctx().set_visuals(visuals); - } else { - ui.ctx().set_visuals(egui::Visuals::light()); - } + theme::setup_custom_style(ui.ctx(), dark_mode); } ui.add_space(5.0); @@ -1265,6 +1320,7 @@ impl eframe::App for VoidMicApp { ui.label(egui::RichText::new("â„šī¸ Edit in config.json").size(10.0)); }); }); + }); // ScrollArea }); } } diff --git a/crates/core/src/processor.rs b/crates/core/src/processor.rs index f94083b..8fedf59 100644 --- a/crates/core/src/processor.rs +++ b/crates/core/src/processor.rs @@ -240,10 +240,11 @@ pub struct VoidProcessor { pub eq_low_gain: Arc, pub eq_mid_gain: Arc, pub eq_high_gain: Arc, + pub eq_enabled: Arc, pub agc_enabled: Arc, pub agc_target: Arc, pub bypass_enabled: Arc, - pub jitter_max_us: Arc, + pub jitter_ewma_us: Arc, pub gate_threshold: Arc, pub suppression_strength: Arc, pub dynamic_threshold_enabled: Arc, @@ -258,10 +259,12 @@ pub struct VoidProcessor { windowed_out: [f32; FRAME_SIZE], } -// Safety: VoidProcessor owns all its mutable state (Vad, EchoCanceller, DenoiseState) and is moved -// to a single audio thread. The only cross-thread access is through the Arc fields, -// which are inherently thread-safe. VoidProcessor must NOT be shared via &reference across threads -// (no Sync), only moved (Send). +// SAFETY: VoidProcessor owns all its mutable state (Vad, EchoCanceller, DenoiseState) +// and is moved to a single audio processing thread. These types use raw pointers internally +// (preventing auto-Send), but are safe to move across threads since they are exclusively +// owned and never aliased. The only cross-thread access is through Arc fields, +// which are inherently thread-safe. VoidProcessor must NOT be shared via &reference +// across threads (it does not implement Sync), only moved (Send). unsafe impl Send for VoidProcessor {} impl VoidProcessor { @@ -335,10 +338,11 @@ impl VoidProcessor { eq_low_gain: Arc::new(AtomicU32::new(eq_params.0.to_bits())), eq_mid_gain: Arc::new(AtomicU32::new(eq_params.1.to_bits())), eq_high_gain: Arc::new(AtomicU32::new(eq_params.2.to_bits())), + eq_enabled: Arc::new(AtomicBool::new(true)), agc_enabled: Arc::new(AtomicBool::new(false)), 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)), + jitter_ewma_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)), @@ -556,9 +560,11 @@ impl VoidProcessor { } // Equalizer - if let Some(eq) = self.eq.get_mut(i) { - for sample in output_ch.iter_mut() { - *sample = eq.process(*sample); + if self.eq_enabled.load(Ordering::Relaxed) { + if let Some(eq) = self.eq.get_mut(i) { + for sample in output_ch.iter_mut() { + *sample = eq.process(*sample); + } } } } @@ -672,9 +678,18 @@ impl VoidProcessor { self.spectrum_out_buf.push(val.val()); } - // Clone to send (channel requires owned data) - let _ = - sender.try_send((self.spectrum_in_buf.clone(), self.spectrum_out_buf.clone())); + // Only clone when channel has room to avoid wasted Vec allocations + if !sender.is_full() { + if let Err(crossbeam_channel::TrySendError::Disconnected(_)) = + sender.try_send(( + self.spectrum_in_buf.clone(), + self.spectrum_out_buf.clone(), + )) + { + log::warn!("Spectrum receiver disconnected, disabling sender"); + self.spectrum_sender = None; + } + } } } } // spectrum throttle diff --git a/crates/lv2/src/lib.rs b/crates/lv2/src/lib.rs index c3ebe17..1246e5c 100644 --- a/crates/lv2/src/lib.rs +++ b/crates/lv2/src/lib.rs @@ -2,7 +2,7 @@ use lv2::prelude::*; use ringbuf::traits::{Consumer, Observer, Producer}; use ringbuf::HeapRb; use std::sync::atomic::Ordering; -use voidmic_core::constants::FRAME_SIZE; +use voidmic_core::constants::{FRAME_SIZE, SAMPLE_RATE}; use voidmic_core::VoidProcessor; #[derive(PortCollection)] @@ -34,6 +34,16 @@ impl Plugin for VoidMic { type AudioFeatures = (); fn new(_info: &PluginInfo, _features: &mut ()) -> Option { + // Validate sample rate - VoidMic requires 48kHz + if _info.sample_rate() as u32 != SAMPLE_RATE { + eprintln!( + "VoidMic LV2: requires {}Hz sample rate, host is using {}Hz", + SAMPLE_RATE, + _info.sample_rate() + ); + return None; + } + let processor = VoidProcessor::new( 2, // Channels: Stereo 2, // VAD sensitivity: Aggressive