From 89a6fd61f883787e6c244db0032b10237e743bf1 Mon Sep 17 00:00:00 2001 From: Mal Detair Date: Sat, 7 Feb 2026 19:59:06 +0100 Subject: [PATCH 1/3] fix: resolve 15 frontend and backend review issues Frontend (high priority): - Dark mode toggle now uses custom theme instead of generic visuals - Mini mode only sends resize command once, not every frame - Repaint rate reduced to 0.5fps when engine is idle (saves CPU/battery) - ScrollArea wraps main panel to prevent content overflow - EQ/output filter/echo cancel toggles now update running engine Frontend (medium priority): - Spectrum send skips clone when channel is full - pactl poll interval increased from 2s to 5s - Wizard device lists no longer cloned every frame - Auto-save dirty config every 5s to prevent crash data loss - Removed duplicate docstring on render_volume_meter Backend: - OutputFilterEngine suppression_strength uses atomic for live updates - LV2 plugin validates 48kHz sample rate on init - Audio thread busy-wait replaced with yield_now + continue - Improved unsafe Send safety comment to follow Rust conventions - Jitter measurement uses EWMA instead of max-over-window Co-Authored-By: Claude Opus 4.6 --- crates/app/src/audio.rs | 63 +++++++++++++------------ crates/app/src/gui.rs | 90 +++++++++++++++++++++++++++--------- crates/core/src/processor.rs | 30 ++++++++---- crates/lv2/src/lib.rs | 12 ++++- 4 files changed, 133 insertions(+), 62 deletions(-) diff --git a/crates/app/src/audio.rs b/crates/app/src/audio.rs index 3e83dbb..1668185 100644 --- a/crates/app/src/audio.rs +++ b/crates/app/src/audio.rs @@ -34,6 +34,7 @@ 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, @@ -175,14 +176,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 +195,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,6 +212,7 @@ 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(); @@ -231,10 +231,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) { @@ -250,24 +250,19 @@ impl AudioEngine { 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 - }; + 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; + .store(jitter_ewma as u32, Ordering::Relaxed); + frames_since_jitter_report = 0; } // Read Audio @@ -291,9 +286,10 @@ impl AudioEngine { processor.dynamic_threshold_enabled.load(Ordering::Relaxed), ); - // Write Audio - while prod_out.vacant_len() < FRAME_SIZE { - thread::sleep(Duration::from_micros(500)); + // Write Audio - yield briefly if output buffer is full + if prod_out.vacant_len() < FRAME_SIZE { + thread::yield_now(); + continue; // Re-check in next loop iteration } prod_out.push_slice(&output_frame); } else { @@ -320,6 +316,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, @@ -346,6 +343,7 @@ pub struct OutputFilterEngine { _input_stream: cpal::Stream, _output_stream: cpal::Stream, is_running: Arc, + pub suppression_strength: Arc, } impl OutputFilterEngine { @@ -417,6 +415,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,14 +430,16 @@ 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; } - while prod_out.vacant_len() < FRAME_SIZE { - thread::sleep(Duration::from_micros(500)); + if prod_out.vacant_len() < FRAME_SIZE { + thread::yield_now(); + continue; } prod_out.push_slice(&output_frame); } else { @@ -453,6 +455,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..ad08cb1 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 @@ -413,7 +419,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 +476,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 +617,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 +655,24 @@ 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::warn!("Output filter failed to start: {}", e); + self.status_msg = format!("Output filter error: {}", e); + } + } + } + } else { + self.output_filter_engine = None; + } } ui.label( egui::RichText::new("âš ī¸ ~100ms latency") @@ -661,6 +687,11 @@ impl VoidMicApp { .changed() { self.mark_config_dirty(); + // Echo cancel requires engine restart to reconfigure streams + if self.engine.is_some() { + self.stop_engine(); + self.start_engine(); + } } }); @@ -726,6 +757,9 @@ impl VoidMicApp { .changed() { self.mark_config_dirty(); + if let Some(engine) = &self.engine { + engine.eq_enabled.store(self.config.eq_enabled, Ordering::Relaxed); + } } }); @@ -961,17 +995,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 +1017,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 +1125,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 +1153,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 +1170,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 +1302,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 +1312,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..2343719 100644 --- a/crates/core/src/processor.rs +++ b/crates/core/src/processor.rs @@ -240,6 +240,7 @@ 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, @@ -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,6 +338,7 @@ 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)), @@ -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,13 @@ 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 allocation + if !sender.is_full() { + let _ = sender.try_send(( + self.spectrum_in_buf.clone(), + self.spectrum_out_buf.clone(), + )); + } } } } // 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 From 0c143cead021346706604d811de8cc33503f435b Mon Sep 17 00:00:00 2001 From: Mal Detair Date: Sat, 7 Feb 2026 20:50:08 +0100 Subject: [PATCH 2/3] fix: resolve 4 critical/important issues from PR review - Fix frame drop: replace yield+continue with retry loop (up to 100 yields) in both AudioEngine and OutputFilterEngine to prevent silent audio frame loss under buffer-full conditions - Fix echo cancel toggle: revert checkbox on engine restart failure so UI matches actual state - Fix output filter toggle: revert checkbox and escalate to log::error when OutputFilterEngine::start fails - Fix jitter measurement: clamp loop_delta to <100ms to ignore system suspend/resume spikes that skew the EWMA Co-Authored-By: Claude Opus 4.6 --- crates/app/src/audio.rs | 38 ++++++++++++++++++++++++++------------ crates/app/src/gui.rs | 8 +++++++- 2 files changed, 33 insertions(+), 13 deletions(-) diff --git a/crates/app/src/audio.rs b/crates/app/src/audio.rs index 1668185..3d94278 100644 --- a/crates/app/src/audio.rs +++ b/crates/app/src/audio.rs @@ -245,16 +245,18 @@ 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_000u32; - let jitter = loop_delta.abs_diff(expected) as f32; + if loop_delta < 100_000 { + let expected = 10_000u32; + let jitter = loop_delta.abs_diff(expected) as f32; - // EWMA: alpha=0.1 gives ~10-frame smoothing - jitter_ewma = jitter_ewma * 0.9 + jitter * 0.1; + // EWMA: alpha=0.1 gives ~10-frame smoothing + jitter_ewma = jitter_ewma * 0.9 + jitter * 0.1; + } // Report to GUI every 50 frames (~500ms) frames_since_jitter_report += 1; @@ -286,12 +288,18 @@ impl AudioEngine { processor.dynamic_threshold_enabled.load(Ordering::Relaxed), ); - // Write Audio - yield briefly if output buffer is full - if prod_out.vacant_len() < FRAME_SIZE { + // Write Audio - retry briefly if output buffer is full + let mut retries = 0; + while prod_out.vacant_len() < FRAME_SIZE { thread::yield_now(); - continue; // Re-check in next loop iteration + 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)); } @@ -437,11 +445,17 @@ impl OutputFilterEngine { + output_frame[i] * strength; } - if prod_out.vacant_len() < FRAME_SIZE { + let mut retries = 0; + while prod_out.vacant_len() < FRAME_SIZE { thread::yield_now(); - continue; + 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)); } diff --git a/crates/app/src/gui.rs b/crates/app/src/gui.rs index ad08cb1..ef2620d 100644 --- a/crates/app/src/gui.rs +++ b/crates/app/src/gui.rs @@ -665,8 +665,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!("Output filter error: {}", e); + self.config.output_filter_enabled = false; } } } @@ -689,8 +690,13 @@ impl VoidMicApp { 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; + } } } }); From 74312811569da82735837b2635fbfb21ea7e1017 Mon Sep 17 00:00:00 2001 From: Mal Detair Date: Sat, 7 Feb 2026 20:58:14 +0100 Subject: [PATCH 3/3] fix: resolve 3 medium-severity review issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace reference stream .ok() with match to log actual cpal errors - Handle spectrum sender Disconnected by clearing sender to stop wasted clones - Add status message and config revert for output filter failure in start_engine() - Rename jitter_max_us → jitter_ewma_us to match EWMA semantics Co-Authored-By: Claude Opus 4.6 --- crates/app/src/audio.rs | 20 +++++++++++--------- crates/app/src/gui.rs | 6 ++++-- crates/core/src/processor.rs | 19 ++++++++++++------- 3 files changed, 27 insertions(+), 18 deletions(-) diff --git a/crates/app/src/audio.rs b/crates/app/src/audio.rs index 3d94278..12e0bb4 100644 --- a/crates/app/src/audio.rs +++ b/crates/app/src/audio.rs @@ -38,7 +38,7 @@ pub struct AudioEngine { 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, @@ -136,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 }; @@ -216,7 +218,7 @@ impl AudioEngine { 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(); @@ -262,7 +264,7 @@ impl AudioEngine { frames_since_jitter_report += 1; if frames_since_jitter_report >= 50 { processor - .jitter_max_us + .jitter_ewma_us .store(jitter_ewma as u32, Ordering::Relaxed); frames_since_jitter_report = 0; } @@ -332,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, }) } } diff --git a/crates/app/src/gui.rs b/crates/app/src/gui.rs index ef2620d..80cd4fa 100644 --- a/crates/app/src/gui.rs +++ b/crates/app/src/gui.rs @@ -313,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; } } } @@ -877,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| { diff --git a/crates/core/src/processor.rs b/crates/core/src/processor.rs index 2343719..8fedf59 100644 --- a/crates/core/src/processor.rs +++ b/crates/core/src/processor.rs @@ -244,7 +244,7 @@ pub struct VoidProcessor { 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, @@ -342,7 +342,7 @@ impl VoidProcessor { 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)), @@ -678,12 +678,17 @@ impl VoidProcessor { self.spectrum_out_buf.push(val.val()); } - // Only clone when channel has room to avoid wasted allocation + // Only clone when channel has room to avoid wasted Vec allocations if !sender.is_full() { - let _ = sender.try_send(( - self.spectrum_in_buf.clone(), - self.spectrum_out_buf.clone(), - )); + 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; + } } } }