From 67e93856241e7309fa114c50436cfb79fa826284 Mon Sep 17 00:00:00 2001 From: Mal Detair Date: Fri, 20 Feb 2026 16:43:26 +0100 Subject: [PATCH] refactor: code quality improvements - Replace assert_eq! in audio callback with graceful error handling to prevent DAW crashes on channel mismatch (outputs silence instead) - Add 21 unit tests to voidmic_core (previously 0): NoiseFloorTracker (5), ThreeBandEq (3), LookaheadLimiter (4), VoidProcessor (6), FrameAdapter (3) - Extract FrameAdapter to voidmic_core to deduplicate ring buffer interleave/process/deinterleave pattern shared by plugin and LV2 - Split gui.rs (1424 lines) into 8 focused modules: app.rs, engine.rs, tray.rs, devices.rs, controls.rs, advanced.rs, wizard.rs, mod.rs - Fix pre-existing clippy warnings (too_many_arguments, collapsible_else_if) --- Cargo.lock | 3 +- crates/app/src/audio.rs | 1 + crates/app/src/gui.rs | 1423 ------------------------------ crates/app/src/gui/advanced.rs | 262 ++++++ crates/app/src/gui/app.rs | 575 ++++++++++++ crates/app/src/gui/controls.rs | 153 ++++ crates/app/src/gui/devices.rs | 168 ++++ crates/app/src/gui/engine.rs | 119 +++ crates/app/src/gui/mod.rs | 11 + crates/app/src/gui/tray.rs | 16 + crates/app/src/gui/wizard.rs | 128 +++ crates/core/Cargo.toml | 1 + crates/core/src/frame_adapter.rs | 173 ++++ crates/core/src/lib.rs | 2 + crates/core/src/processor.rs | 316 ++++++- crates/lv2/Cargo.toml | 1 - crates/lv2/src/lib.rs | 81 +- crates/plugin/Cargo.toml | 1 - crates/plugin/src/lib.rs | 103 +-- 19 files changed, 1978 insertions(+), 1559 deletions(-) delete mode 100644 crates/app/src/gui.rs create mode 100644 crates/app/src/gui/advanced.rs create mode 100644 crates/app/src/gui/app.rs create mode 100644 crates/app/src/gui/controls.rs create mode 100644 crates/app/src/gui/devices.rs create mode 100644 crates/app/src/gui/engine.rs create mode 100644 crates/app/src/gui/mod.rs create mode 100644 crates/app/src/gui/tray.rs create mode 100644 crates/app/src/gui/wizard.rs create mode 100644 crates/core/src/frame_adapter.rs diff --git a/Cargo.lock b/Cargo.lock index 5e37cc1..e7117e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5823,6 +5823,7 @@ dependencies = [ "crossbeam-channel", "log", "nnnoiseless", + "ringbuf", "spectrum-analyzer", "webrtc-vad", ] @@ -5833,7 +5834,6 @@ version = "0.9.0" dependencies = [ "log", "lv2", - "ringbuf", "voidmic_core", ] @@ -5846,7 +5846,6 @@ dependencies = [ "egui", "nih_plug", "nih_plug_egui", - "ringbuf", "voidmic_core", "voidmic_ui", ] diff --git a/crates/app/src/audio.rs b/crates/app/src/audio.rs index 3a6df6a..b27dd2e 100644 --- a/crates/app/src/audio.rs +++ b/crates/app/src/audio.rs @@ -47,6 +47,7 @@ pub struct AudioEngine { impl AudioEngine { /// Starts the audio engine. + #[allow(clippy::too_many_arguments)] pub fn start( input_device_name: &str, output_device_name: &str, diff --git a/crates/app/src/gui.rs b/crates/app/src/gui.rs deleted file mode 100644 index 4bebcd6..0000000 --- a/crates/app/src/gui.rs +++ /dev/null @@ -1,1423 +0,0 @@ -use crate::audio::{AudioEngine, OutputFilterEngine}; -use crate::autostart; -use crate::config::AppConfig; -use crate::pulse_info; -use crate::updater::{self, UpdateInfo}; -use crate::virtual_device; -use cpal::traits::{DeviceTrait, HostTrait}; -use crossbeam_channel::Receiver; -use eframe::egui; -use global_hotkey::hotkey::HotKey; -use global_hotkey::{GlobalHotKeyEvent, GlobalHotKeyManager}; -use std::path::PathBuf; -use std::process::Command; -use std::sync::atomic::Ordering; -use tray_icon::Icon; -use tray_icon::{ - menu::{Menu, MenuEvent, MenuItem}, - TrayIcon, TrayIconBuilder, -}; -use voidmic_ui::{theme, visualizer, widgets}; - -/// Runs the VoidMic GUI application. -/// -/// # Arguments -/// * `model_path` - Path to the model directory (currently unused as RNNoise weights are embedded) -/// -/// # Returns -/// Result indicating success or failure of the GUI application -pub fn run_gui(model_path: PathBuf) -> eframe::Result<()> { - // Load config early to determine if we should start minimized - let config = AppConfig::load(); - let start_minimized = config.start_minimized; - let dark_mode = config.dark_mode; - - // Build viewport with saved position if available - let mut viewport = egui::ViewportBuilder::default() - .with_inner_size([450.0, 450.0]) - .with_resizable(false) - .with_visible(!start_minimized); - - if let (Some(x), Some(y)) = (config.window_x, config.window_y) { - viewport = viewport.with_position([x, y]); - } - - let options = eframe::NativeOptions { - viewport, - ..Default::default() - }; - eframe::run_native( - "VoidMic", - options, - Box::new(move |cc| { - theme::setup_custom_style(&cc.egui_ctx, dark_mode); - Ok(Box::new(VoidMicApp::new_with_config(model_path, config))) - }), - ) -} - -struct Preset { - name: &'static str, - gate_threshold: f32, - suppression_strength: f32, - dynamic_threshold_enabled: bool, -} - -const PRESETS: &[Preset] = &[ - Preset { - name: "Standard", - gate_threshold: 0.015, - suppression_strength: 1.0, - dynamic_threshold_enabled: true, - }, - Preset { - name: "Gaming", - gate_threshold: 0.030, - suppression_strength: 1.0, - dynamic_threshold_enabled: true, - }, - Preset { - name: "Podcast", - gate_threshold: 0.008, - suppression_strength: 0.6, - dynamic_threshold_enabled: true, - }, - Preset { - name: "Noisy Office", - gate_threshold: 0.020, - suppression_strength: 1.0, - dynamic_threshold_enabled: true, - }, - Preset { - name: "Music", - gate_threshold: 0.002, - suppression_strength: 0.3, - dynamic_threshold_enabled: false, - }, -]; - -#[derive(PartialEq)] -enum WizardStep { - Welcome, - SelectMic, - SelectOutput, - Calibration, - Finish, -} - -struct VoidMicApp { - input_devices: Vec, - output_devices: Vec, - selected_input: String, - selected_output: String, - engine: Option, - status_msg: String, - model_path: PathBuf, - config: AppConfig, - config_dirty: bool, - #[allow(dead_code)] // Kept alive for tray icon - tray_icon: Option, - is_quitting: bool, - is_calibrating: bool, - update_receiver: Option>>, - update_info: Option, - 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 - selected_reference: String, - // Global Hotkeys - #[allow(dead_code)] // Manager must be kept alive - hotkey_manager: Option, - hotkey_id: Option, - // Wizard State - show_wizard: bool, - wizard_step: WizardStep, - // 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"; -const SHOW_ID: &str = "show"; -const TOGGLE_ID: &str = "toggle"; - -impl VoidMicApp { - fn new_with_config(model_path: PathBuf, config: AppConfig) -> Self { - // Tray Setup - let tray_menu = Menu::new(); - let toggle_item = MenuItem::with_id(TOGGLE_ID, "Enable", true, None); - let show_item = MenuItem::with_id(SHOW_ID, "Show/Hide", true, None); - let quit_item = MenuItem::with_id(QUIT_ID, "Quit", true, None); - let _ = tray_menu.append_items(&[&toggle_item, &show_item, &quit_item]); - - let icon = load_icon(); - let tray_icon = TrayIconBuilder::new() - .with_menu(Box::new(tray_menu)) - .with_tooltip("VoidMic") - .with_icon(icon) - .build() - .ok(); - - // Start async update check - let update_receiver = Some(updater::check_for_updates_async()); - - let (inputs, outputs) = get_devices(); - - let default_in = if inputs.contains(&config.last_input) { - config.last_input.clone() - } else { - inputs - .first() - .cloned() - .unwrap_or_else(|| "default".to_string()) - }; - - let default_out = if outputs.contains(&config.last_output) { - config.last_output.clone() - } else { - outputs - .first() - .cloned() - .unwrap_or_else(|| "default".to_string()) - }; - - let default_ref = if !config.last_reference.is_empty() && inputs.contains(&config.last_reference) { - config.last_reference.clone() - } else { - inputs - .first() - .cloned() - .unwrap_or_else(|| "default".to_string()) - }; - - let auto_start = config.auto_start_processing; - let show_wizard = config.first_run; - - let mut app = Self { - input_devices: inputs, - output_devices: outputs, - selected_input: default_in, - selected_output: default_out, - engine: None, - status_msg: "Ready".to_string(), - model_path, - config, - config_dirty: false, - tray_icon, - is_quitting: false, - is_calibrating: false, - update_receiver, - update_info: None, - virtual_sink_module_id: None, - 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: match GlobalHotKeyManager::new() { - Ok(m) => Some(m), - Err(e) => { - log::warn!("Failed to initialize global hotkey manager: {:?}", e); - None - } - }, - hotkey_id: None, - show_wizard, - 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 - if let Some(ref manager) = app.hotkey_manager { - if let Ok(hotkey) = app.config.toggle_hotkey.parse::() { - if manager.register(hotkey).is_ok() { - app.hotkey_id = Some(hotkey.id()); - } else { - log::warn!("Failed to register hotkey: {}", app.config.toggle_hotkey); - } - } - } - - // Auto-start processing if enabled - if auto_start { - app.start_engine(); - } - - app - } - - fn start_engine(&mut self) { - if self.engine.is_some() { - return; // Already running - } - - self.status_msg = "Initializing Hybrid Engine...".to_string(); - - // Auto-create virtual sink on Linux - #[cfg(target_os = "linux")] - { - if self.virtual_sink_module_id.is_none() { - match virtual_device::create_virtual_sink() { - Ok(device) => { - self.virtual_sink_module_id = Some(device.module_id); - let (inputs, outputs) = get_devices(); - self.input_devices = inputs; - self.output_devices = outputs.clone(); - if let Some(sink) = outputs.iter().find(|d| d.contains("VoidMic_Clean")) { - self.selected_output = sink.clone(); - } - } - Err(e) => { - self.status_msg = format!("Virtual sink warning: {}", e); - } - } - } - } - - let (tx, rx) = crossbeam_channel::bounded(2); - - match AudioEngine::start( - &self.selected_input, - &self.selected_output, - &self.model_path, - self.config.gate_threshold, - self.config.suppression_strength, - self.config.echo_cancel_enabled, - if self.config.echo_cancel_enabled { Some(self.selected_reference.as_str()) } else { None }, - self.config.dynamic_threshold_enabled, - self.config.vad_sensitivity, - self.config.eq_enabled, - ( - self.config.eq_low_gain, - self.config.eq_mid_gain, - self.config.eq_high_gain, - ), - self.config.agc_enabled, - self.config.agc_target_level, - false, - Some(tx), - ) { - Ok(engine) => { - self.engine = Some(engine); - self.spectrum_receiver = Some(rx); - self.status_msg = "Active (RNNoise + Gate)".to_string(); - self.save_config(); - - // Start output filter AFTER main engine succeeds - if self.config.output_filter_enabled { - 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!("Active (output filter error: {})", e); - self.config.output_filter_enabled = false; - } - } - } - } - Err(e) => { - let error_str = e.to_string(); - self.status_msg = if error_str.contains("No default") { - "Error: No audio device found. Check your system settings.".to_string() - } else if error_str.contains("not found") { - "Error: Selected device not found. Try refreshing or selecting another device.".to_string() - } else if error_str.contains("permission") || error_str.contains("access") { - "Error: Permission denied. Check audio device permissions.".to_string() - } else if error_str.contains("in use") || error_str.contains("busy") { - "Error: Device is busy. Close other audio applications.".to_string() - } else { - format!("Error: {}", e) - }; - log::error!("Failed to start engine: {}", e); - } - } - } - - fn stop_engine(&mut self) { - self.engine = None; - self.output_filter_engine = None; - self.status_msg = "Stopped".to_string(); - } - - fn toggle_engine(&mut self) { - if self.engine.is_some() { - self.stop_engine(); - if let Some(ref tray) = self.tray_icon { - let _ = tray.set_tooltip(Some("VoidMic - Disabled")); - } - } else { - self.start_engine(); - if let Some(ref tray) = self.tray_icon { - let _ = tray.set_tooltip(Some("VoidMic - Active")); - } - } - } - - fn mark_config_dirty(&mut self) { - self.config_dirty = true; - } - - fn save_config(&mut self) { - if self.config_dirty { - self.config.last_input = self.selected_input.clone(); - self.config.last_output = self.selected_output.clone(); - self.config.last_reference = self.selected_reference.clone(); - // gate_threshold is already in config from slider updates - self.config.save(); - self.config_dirty = false; - } - } - - fn save_config_now(&mut self) { - self.config.last_input = self.selected_input.clone(); - self.config.last_output = self.selected_output.clone(); - self.config.last_reference = self.selected_reference.clone(); - self.config.save(); - } - - fn apply_preset(&mut self, preset_name: &str) { - if let Some(preset) = PRESETS.iter().find(|p| p.name == preset_name) { - self.config.gate_threshold = preset.gate_threshold; - self.config.suppression_strength = preset.suppression_strength; - self.config.dynamic_threshold_enabled = preset.dynamic_threshold_enabled; - // Echo cancel is user preference, not preset - // 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); - } - } - } - - /// Renders the update banner at the top of the UI. - /// Returns true if the update was dismissed. - fn render_update_banner(&mut self, ui: &mut egui::Ui) -> bool { - let mut dismiss = false; - if let Some(ref update) = self.update_info { - let version = update.version.clone(); - let url = update.download_url.clone(); - ui.horizontal(|ui| { - ui.colored_label( - egui::Color32::GOLD, - format!("๐ŸŽ‰ Update available: {}", version), - ); - if ui.small_button("Download").clicked() { - let _ = open::that(&url); - } - if ui.small_button("โœ•").clicked() { - dismiss = true; - } - }); - ui.separator(); - } - dismiss - } - - /// 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 { - f32::from_bits(engine.volume_level.load(Ordering::Relaxed)) - } else { - 0.0 - }; - - widgets::render_volume_meter(ui, volume, self.config.gate_threshold); - } - - /// Renders the device selection dropdowns. - fn render_device_selectors(&mut self, ui: &mut egui::Ui) { - egui::Grid::new("device_grid").striped(true).show(ui, |ui| { - ui.label("Microphone:"); - egui::ComboBox::from_id_salt("input_combo") - .selected_text(&self.selected_input) - .width(250.0) - .show_ui(ui, |ui| { - let mut changed = false; - for dev in &self.input_devices { - if ui - .selectable_value(&mut self.selected_input, dev.clone(), dev) - .changed() - { - changed = true; - } - } - if changed { - self.mark_config_dirty(); - } - }); - ui.end_row(); - - ui.label("Output Sink:"); - egui::ComboBox::from_id_salt("output_combo") - .selected_text(&self.selected_output) - .width(250.0) - .show_ui(ui, |ui| { - let mut changed = false; - for dev in &self.output_devices { - if ui - .selectable_value(&mut self.selected_output, dev.clone(), dev) - .changed() - { - changed = true; - } - } - if changed { - self.mark_config_dirty(); - } - }); - ui.end_row(); - }); - - ui.add_space(10.0); - - // 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(); - } - ui.horizontal(|ui| { - let sink_exists = self.virtual_sink_cached; - - if sink_exists { - ui.colored_label(egui::Color32::GREEN, "โœ” Virtual Mic Active"); - if ui.button("Destroy").clicked() { - // Best effort cleanup - if let Some(id) = self.virtual_sink_module_id { - let _ = virtual_device::destroy_virtual_sink(id); - } else { - let _ = virtual_device::destroy_virtual_sink(0); - } - self.virtual_sink_module_id = None; - // Refresh device list to remove it - let (inputs, outputs) = get_devices(); - self.input_devices = inputs; - self.output_devices = outputs; - } - - // Hint for usage - ui.label(egui::RichText::new("โ„น๏ธ Select 'VoidMic_Clean' in Discord").size(10.0)); - } else { - if ui - .button("โœจ Create Virtual Mic") - .on_hover_text("Creates a virtual device for Discord/Zoom") - .clicked() - { - match virtual_device::create_virtual_sink() { - Ok(device) => { - self.virtual_sink_module_id = Some(device.module_id); - - // Refresh devices - let (inputs, outputs) = get_devices(); - self.input_devices = inputs; - self.output_devices = outputs; - - // Auto-select the new sink - if self.output_devices.contains(&device.sink_name) { - self.selected_output = device.sink_name; - self.mark_config_dirty(); - } - - self.status_msg = "Virtual Mic Created!".to_string(); - } - Err(e) => { - self.status_msg = format!("Failed to create sink: {}", e); - } - } - } - } - }); - } - - /// Renders the threshold and suppression controls. - fn render_threshold_controls(&mut self, ui: &mut egui::Ui) { - // Presets Dropdown - ui.horizontal(|ui| { - ui.label("Preset:"); - egui::ComboBox::from_id_salt("preset_combo") - .selected_text(&self.config.preset) - .show_ui(ui, |ui| { - if ui - .selectable_label(self.config.preset == "Custom", "Custom") - .clicked() - { - self.config.preset = "Custom".to_string(); - self.save_config_now(); - } - ui.separator(); - for preset in PRESETS { - if ui - .selectable_label(self.config.preset == preset.name, preset.name) - .clicked() - { - self.apply_preset(preset.name); - } - } - }); - }); - - ui.add_space(5.0); - - ui.horizontal(|ui| { - if ui - .checkbox(&mut self.config.dynamic_threshold_enabled, "Auto-Gate") - .on_hover_text("Automatically adjusts gate based on ambient noise floor") - .changed() - { - 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| { - ui.label("Gate Threshold:"); - let slider = egui::Slider::new(&mut self.config.gate_threshold, 0.005..=0.05) - .text("") - .fixed_decimals(3); - 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); - } - } - }); - - let calibrate_enabled = self.engine.is_some() - && !self.is_calibrating - && !self.config.dynamic_threshold_enabled; - if ui - .add_enabled(calibrate_enabled, egui::Button::new("๐ŸŽฏ Calibrate")) - .clicked() - { - if let Some(engine) = &self.engine { - engine.calibration_mode.store(true, Ordering::Relaxed); - self.is_calibrating = true; - self.status_msg = "Calibrating... stay quiet for 3 seconds".to_string(); - } - } - }); - - ui.horizontal(|ui| { - ui.label("Suppression:"); - let pct = (self.config.suppression_strength * 100.0) as i32; - let slider = egui::Slider::new(&mut self.config.suppression_strength, 0.0..=1.0) - .text(format!("{}%", pct)) - .fixed_decimals(0); - 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); - } - if let Some(filter) = &self.output_filter_engine { - filter.suppression_strength.store(self.config.suppression_strength.to_bits(), Ordering::Relaxed); - } - } - }); - } - - /// Checks and handles calibration results. - fn check_calibration_result(&mut self) { - if self.is_calibrating { - if let Some(engine) = &self.engine { - if !engine.calibration_mode.load(Ordering::Relaxed) { - 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); - } - self.is_calibrating = false; - } - } - } - } - - /// Renders advanced features (output filter, echo cancellation). - fn render_advanced_features(&mut self, ui: &mut egui::Ui) { - ui.heading("Advanced Features"); - - ui.horizontal(|ui| { - if ui - .checkbox( - &mut self.config.output_filter_enabled, - "Filter Output (Speaker Denoising)", - ) - .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") - .size(10.0) - .color(egui::Color32::YELLOW), - ); - }); - - ui.horizontal(|ui| { - if ui - .checkbox(&mut self.config.echo_cancel_enabled, "Echo Cancellation") - .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; - } - } - } - }); - - if self.config.echo_cancel_enabled || self.config.output_filter_enabled { - ui.horizontal(|ui| { - ui.label("Reference Input (Monitor):"); - let prev_ref = self.selected_reference.clone(); - egui::ComboBox::from_id_salt("ref_combo") - .selected_text(&self.selected_reference) - .width(200.0) - .show_ui(ui, |ui| { - for dev in &self.input_devices { - let _ = - ui.selectable_value(&mut self.selected_reference, dev.clone(), dev); - } - }); - if self.selected_reference != prev_ref { - self.mark_config_dirty(); - } - ui.label(egui::RichText::new("โ„น๏ธ Select speaker monitor").size(10.0)); - }); - } - - ui.separator(); - - // VAD Controls - const VAD_MODES: &[(i32, &str, &str)] = &[ - (0, "Quality", "Quality (Likely Speech)"), - (1, "Low Bitrate", "Low Bitrate"), - (2, "Aggressive", "Aggressive"), - (3, "Very Aggressive", "Very Aggressive"), - ]; - ui.horizontal(|ui| { - ui.label("VAD Sensitivity:"); - let current_label = VAD_MODES - .iter() - .find(|(v, _, _)| *v == self.config.vad_sensitivity) - .map(|(_, _, full)| *full) - .unwrap_or("Unknown"); - egui::ComboBox::from_id_salt("vad_combo") - .selected_text(current_label) - .show_ui(ui, |ui| { - for (value, label, _) in VAD_MODES { - if ui - .selectable_value(&mut self.config.vad_sensitivity, *value, *label) - .clicked() - { - self.mark_config_dirty(); - if let Some(engine) = &self.engine { - engine - .vad_sensitivity - .store(self.config.vad_sensitivity as u32, Ordering::Relaxed); - } - } - } - }); - ui.label(egui::RichText::new("โ„น๏ธ WebRTC VAD").size(10.0)) - .on_hover_text("Voice Activity Detection - filters non-speech sounds"); - }); - - ui.separator(); - - // Equalizer Controls - ui.horizontal(|ui| { - if ui - .checkbox(&mut self.config.eq_enabled, "Equalizer (3-Band)") - .changed() - { - self.mark_config_dirty(); - if let Some(engine) = &self.engine { - engine.eq_enabled.store(self.config.eq_enabled, Ordering::Relaxed); - } - } - }); - - if self.config.eq_enabled { - egui::Grid::new("eq_grid").num_columns(2).show(ui, |ui| { - ui.label("Low (Bass):"); - if ui - .add(egui::Slider::new(&mut self.config.eq_low_gain, -10.0..=10.0).text("dB")) - .changed() - { - self.mark_config_dirty(); - if let Some(engine) = &self.engine { - engine - .eq_low_gain - .store(self.config.eq_low_gain.to_bits(), Ordering::Relaxed); - } - } - ui.end_row(); - - ui.label("Mid (Voice):"); - if ui - .add(egui::Slider::new(&mut self.config.eq_mid_gain, -10.0..=10.0).text("dB")) - .changed() - { - self.mark_config_dirty(); - if let Some(engine) = &self.engine { - engine - .eq_mid_gain - .store(self.config.eq_mid_gain.to_bits(), Ordering::Relaxed); - } - } - ui.end_row(); - - ui.label("High (Treble):"); - if ui - .add(egui::Slider::new(&mut self.config.eq_high_gain, -10.0..=10.0).text("dB")) - .changed() - { - self.mark_config_dirty(); - if let Some(engine) = &self.engine { - engine - .eq_high_gain - .store(self.config.eq_high_gain.to_bits(), Ordering::Relaxed); - } - } - ui.end_row(); - }); - } - - // Phase 4: Pro Audio (AGC + Bypass) - ui.separator(); - - ui.horizontal(|ui| { - if ui - .checkbox(&mut self.config.agc_enabled, "Automatic Gain Control (AGC)") - .on_hover_text("Normalizes volume to prevent clipping and boost quiet speech") - .changed() - { - self.mark_config_dirty(); - if let Some(engine) = &self.engine { - engine - .agc_enabled - .store(self.config.agc_enabled, Ordering::Relaxed); - } - } - }); - - ui.add_space(5.0); - - // BIG BYPASS BUTTON - let bypass_enabled = if let Some(engine) = &self.engine { - engine.bypass_enabled.load(Ordering::Relaxed) - } else { - false - }; - let bypass_text = if bypass_enabled { - "๐Ÿ”ด BYPASSED (Raw Audio)" - } else { - "๐ŸŸข Processing Active" - }; - if ui - .add_sized( - [ui.available_width(), 30.0], - egui::Button::new(egui::RichText::new(bypass_text).strong().size(14.0)).fill( - if bypass_enabled { - egui::Color32::DARK_RED - } else { - egui::Color32::DARK_GREEN - }, - ), - ) - .clicked() - { - if let Some(engine) = &self.engine { - let current = engine.bypass_enabled.load(Ordering::Relaxed); - engine.bypass_enabled.store(!current, Ordering::Relaxed); - } - } - // Spectrum Visualizer (Phase 6) - if self.engine.is_some() { - ui.add_space(10.0); - ui.label("๐Ÿ“Š Spectrum Analysis"); - self.render_spectrum(ui); - - // Jitter Monitor (Phase 6) - const JITTER_GOOD_US: u32 = 1000; - const JITTER_WARN_US: u32 = 5000; - let jitter = self - .engine - .as_ref() - .unwrap() - .jitter_ewma_us - .load(Ordering::Relaxed); - ui.add_space(5.0); - ui.horizontal(|ui| { - ui.label("Latency Health:"); - let color = if jitter < JITTER_GOOD_US { - egui::Color32::GREEN - } else if jitter < JITTER_WARN_US { - egui::Color32::YELLOW - } else { - egui::Color32::RED - }; - ui.colored_label(color, format!("{} ยตs jitter", jitter)) - .on_hover_text("< 1ms = excellent | 1-5ms = acceptable | > 5ms = may cause audio glitches"); - }); - } - } - - fn render_spectrum(&mut self, ui: &mut egui::Ui) { - // Receive new data - if let Some(rx) = &self.spectrum_receiver { - // Drain channel to get latest - while let Ok(data) = rx.try_recv() { - self.last_spectrum_data = data; - } - } - - let (in_data, out_data) = &self.last_spectrum_data; - visualizer::render_spectrum(ui, in_data, out_data); - } - - fn render_mini(&mut self, ctx: &egui::Context) -> bool { - let mut expanded = false; - egui::CentralPanel::default().show(ctx, |ui| { - ui.vertical_centered(|ui| { - ui.add_space(5.0); - ui.horizontal(|ui| { - ui.label("๐ŸŒŒ VoidMic"); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.button("โ›ถ").on_hover_text("Expand").clicked() { - self.config.mini_mode = false; - self.mark_config_dirty(); - expanded = true; - ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize( - [450.0, 450.0].into(), - )); - } - }); - }); - - ui.separator(); - - // Status - let active = self.engine.is_some(); - ui.colored_label( - if active { - egui::Color32::GREEN - } else { - egui::Color32::RED - }, - if active { "Active" } else { "Inactive" }, - ); - - ui.add_space(5.0); - - // Bypass Button (Big) - let bypass_enabled = if let Some(engine) = &self.engine { - engine.bypass_enabled.load(Ordering::Relaxed) - } else { - false - }; - - let btn_color = if bypass_enabled { - egui::Color32::DARK_RED - } else { - egui::Color32::DARK_GREEN - }; - let btn_text = if bypass_enabled { - "Stopped" - } else { - "Processing" - }; - - if ui - .add_sized([80.0, 30.0], egui::Button::new(btn_text).fill(btn_color)) - .clicked() - { - if let Some(engine) = &self.engine { - let current = engine.bypass_enabled.load(Ordering::Relaxed); - engine.bypass_enabled.store(!current, Ordering::Relaxed); - } - } - - ui.add_space(5.0); - self.render_volume_meter(ui); - }); - }); - expanded - } - - fn render_wizard(&mut self, ctx: &egui::Context) { - egui::CentralPanel::default().show(ctx, |ui| { - ui.vertical_centered(|ui| { - ui.add_space(20.0); - ui.heading("โœจ Welcome to VoidMic โœจ"); - ui.add_space(10.0); - ui.label("Let's get your audio set up for crystal clear communication."); - ui.add_space(20.0); - ui.separator(); - ui.add_space(20.0); - - match self.wizard_step { - WizardStep::Welcome => { - ui.label("VoidMic uses AI to remove background noise from your microphone."); - ui.label("This short wizard will help you select your devices and calibrate the noise gate."); - ui.add_space(40.0); - if ui.button("Get Started โžก").clicked() { - self.wizard_step = WizardStep::SelectMic; - } - } - WizardStep::SelectMic => { - ui.heading("๐ŸŽค Select Microphone"); - ui.add_space(10.0); - ui.label("Choose the microphone you want to clean up:"); - 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 &self.input_devices { - if ui.selectable_value(&mut self.selected_input, dev.clone(), dev).changed() { - changed = true; - } - } - }); - if changed { self.mark_config_dirty(); } - - ui.add_space(40.0); - if ui.button("Next โžก").clicked() { - self.wizard_step = WizardStep::SelectOutput; - } - } - WizardStep::SelectOutput => { - ui.heading("๐Ÿ”Š Select Output"); - ui.add_space(10.0); - ui.label("Choose where you want to hear the processed audio (or your speakers):"); - 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 &self.output_devices { - if ui.selectable_value(&mut self.selected_output, dev.clone(), dev).changed() { - changed = true; - } - } - }); - if changed { self.mark_config_dirty(); } - - ui.add_space(40.0); - ui.horizontal(|ui| { - if ui.button("โฌ… Back").clicked() { self.wizard_step = WizardStep::SelectMic; } - if ui.button("Next โžก").clicked() { self.wizard_step = WizardStep::Calibration; } - }); - } - WizardStep::Calibration => { - ui.heading("๐ŸŽ›๏ธ Calibration"); - ui.add_space(10.0); - ui.label("Stay quiet for 3 seconds to measure background noise."); - - self.render_volume_meter(ui); - - ui.add_space(20.0); - - let calibrate_enabled = self.engine.is_some() && !self.is_calibrating; - - if self.engine.is_none() { - if ui.button("โ–ถ Start Audio Engine").clicked() { - self.start_engine(); - } - } else if ui.add_enabled(calibrate_enabled, egui::Button::new("๐ŸŽฏ Start Calibration")).clicked() { - if let Some(engine) = &self.engine { - engine.calibration_mode.store(true, Ordering::Relaxed); - self.is_calibrating = true; - self.status_msg = "Calibrating... stay quiet".to_string(); - } - } - - ui.label(format!("Status: {}", self.status_msg)); - self.check_calibration_result(); - - ui.add_space(40.0); - ui.horizontal(|ui| { - if ui.button("โฌ… Back").clicked() { self.wizard_step = WizardStep::SelectOutput; } - if ui.button("Finish โœ…").clicked() { self.wizard_step = WizardStep::Finish; } - }); - } - WizardStep::Finish => { - ui.heading("๐ŸŽ‰ All Set!"); - ui.label("VoidMic is ready to use."); - ui.add_space(20.0); - if ui.button("Open Main Interface").clicked() { - self.config.first_run = false; - self.show_wizard = false; - self.save_config_now(); - } - } - } - }); - }); - } -} - -impl eframe::App for VoidMicApp { - fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - // Handle Tray Events - if let Ok(event) = MenuEvent::receiver().try_recv() { - if event.id.0 == QUIT_ID { - self.is_quitting = true; - ctx.send_viewport_cmd(egui::ViewportCommand::Close); - } else if event.id.0 == SHOW_ID { - // Toggle visibility or just show - // We can't easily check current visibility state from here without tracking it, - // but we can just force visible for now or toggle logic if we track it. - // For simplicity, let's just ensure it is visible and focused. - ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true)); - ctx.send_viewport_cmd(egui::ViewportCommand::Focus); - } else if event.id.0 == TOGGLE_ID { - self.toggle_engine(); - } - } - - // Handle Global Hotkeys - if let Ok(event) = GlobalHotKeyEvent::receiver().try_recv() { - if let Some(id) = self.hotkey_id { - if event.id == id && event.state == global_hotkey::HotKeyState::Released { - self.toggle_engine(); - } - } - } - - // Handle Close Request (Minimize to Tray) - if ctx.input(|i| i.viewport().close_requested()) && !self.is_quitting { - // Save window position before hiding - if let Some(pos) = ctx.input(|i| i.viewport().outer_rect).map(|r| r.min) { - self.config.window_x = Some(pos.x); - self.config.window_y = Some(pos.y); - self.save_config_now(); - } - - ctx.send_viewport_cmd(egui::ViewportCommand::Visible(false)); - ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); - } - - // 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 { - if let Ok(update) = rx.try_recv() { - self.update_info = update; - self.update_receiver = None; // Consumed - } - } - - if self.show_wizard { - self.render_wizard(ctx); - return; - } - - if self.config.mini_mode { - 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; - } - - egui::CentralPanel::default().show(ctx, |ui| { - // Update banner at top - if self.render_update_banner(ui) { - 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(); - } - }); - }); - ui.separator(); - ui.add_space(10.0); - - // Volume meter - self.render_volume_meter(ui); - - ui.add_space(20.0); - - // Device selectors - self.render_device_selectors(ui); - - ui.add_space(20.0); - - // Threshold and suppression controls - self.render_threshold_controls(ui); - - // Check calibration result - self.check_calibration_result(); - - // Advanced Features - ui.add_space(10.0); - self.render_advanced_features(ui); - - ui.add_space(10.0); - - // Connected Apps display (refresh every 2 seconds) - #[cfg(target_os = "linux")] - { - if self.engine.is_some() && self.last_app_refresh.elapsed().as_secs() >= 2 { - self.connected_apps = pulse_info::get_connected_apps() - .into_iter() - .map(|a| a.name) - .collect(); - self.last_app_refresh = std::time::Instant::now(); - } - - if !self.connected_apps.is_empty() { - ui.add_space(10.0); - egui::CollapsingHeader::new(format!("๐Ÿ“ฑ Connected Apps ({})", self.connected_apps.len())) - .default_open(true) - .show(ui, |ui| { - for app in &self.connected_apps { - ui.label(format!(" โ€ข {}", app)); - } - }); - } - } - - let is_running = self.engine.is_some(); - let btn_text = if is_running { "STOP ENGINE" } else { "ACTIVATE VOIDMIC" }; - - let btn = ui.add_sized([ui.available_width(), 50.0], egui::Button::new( - egui::RichText::new(btn_text).size(18.0).strong() - )); - - if btn.clicked() { - self.toggle_engine(); - } - - ui.add_space(10.0); - ui.label(format!("Status: {}", self.status_msg)); - - ui.with_layout(egui::Layout::bottom_up(egui::Align::Min), |ui| { - ui.horizontal(|ui| { - if ui.button("๐Ÿ› ๏ธ Install Virtual Cable").clicked() { - match install_virtual_cable() { - Ok(msg) => { - self.status_msg = msg; - // Refresh device list - let (inputs, outputs) = get_devices(); - self.input_devices = inputs; - self.output_devices = outputs; - } - Err(e) => { - self.status_msg = format!("Virtual Cable Error: {}", e); - } - } - } - }); - ui.separator(); - - // Start on Boot checkbox - let mut start_on_boot = self.config.start_on_boot; - if ui.checkbox(&mut start_on_boot, "Start on Boot").changed() { - self.config.start_on_boot = start_on_boot; - if start_on_boot { - if let Err(e) = autostart::enable_autostart() { - self.status_msg = format!("Autostart error: {}", e); - self.config.start_on_boot = false; - } else { - self.status_msg = "Autostart enabled".to_string(); - } - } else if let Err(e) = autostart::disable_autostart() { - self.status_msg = format!("Autostart error: {}", e); - } else { - self.status_msg = "Autostart disabled".to_string(); - } - self.save_config_now(); - } - - // Start Minimized checkbox - let mut start_minimized = self.config.start_minimized; - if ui.checkbox(&mut start_minimized, "Start Minimized to Tray").changed() { - self.config.start_minimized = start_minimized; - self.save_config_now(); - } - - // Auto-Start Processing checkbox - let mut auto_start = self.config.auto_start_processing; - if ui.checkbox(&mut auto_start, "Auto-Start Processing").changed() { - self.config.auto_start_processing = auto_start; - self.save_config_now(); - } - - // Dark Mode checkbox - let mut dark_mode = self.config.dark_mode; - if ui.checkbox(&mut dark_mode, "Dark Mode").changed() { - self.config.dark_mode = dark_mode; - self.save_config_now(); - theme::setup_custom_style(ui.ctx(), dark_mode); - } - - ui.add_space(5.0); - ui.horizontal(|ui| { - ui.label("Global Hotkey:"); - ui.code(self.config.toggle_hotkey.as_str()); - ui.label(egui::RichText::new("โ„น๏ธ Edit in config.json").size(10.0)); - }); - }); - }); // ScrollArea - }); - } -} - -fn get_devices() -> (Vec, Vec) { - let host = cpal::default_host(); - let inputs = host - .input_devices() - .map(|devs| { - devs.map(|d| d.name().unwrap_or("Unknown".to_string())) - .collect() - }) - .unwrap_or_default(); - - let outputs = host - .output_devices() - .map(|devs| { - devs.map(|d| d.name().unwrap_or("Unknown".to_string())) - .collect() - }) - .unwrap_or_default(); - - (inputs, outputs) -} - -fn install_virtual_cable() -> Result { - if cfg!(target_os = "linux") { - // Check if module is already loaded - let check = Command::new("pactl") - .args(["list", "short", "sinks"]) - .output() - .map_err(|e| { - format!( - "Failed to check sinks: {}. Is PulseAudio/PipeWire installed?", - e - ) - })?; - - let output_str = String::from_utf8_lossy(&check.stdout); - if output_str.contains("VoidMic_Clean") { - return Ok("Virtual sink 'VoidMic_Clean' already exists.".to_string()); - } - - // Load the module - let result = Command::new("pactl") - .args([ - "load-module", - "module-null-sink", - "sink_name=VoidMic_Clean", - "sink_properties=device.description=VoidMic_Clean", - ]) - .output() - .map_err(|e| format!("Failed to create sink: {}", e))?; - - if result.status.success() { - Ok("Virtual sink 'VoidMic_Clean' created! Select 'Monitor of VoidMic_Clean' in your apps.".to_string()) - } else { - let stderr = String::from_utf8_lossy(&result.stderr); - Err(format!("pactl failed: {}", stderr)) - } - } else if cfg!(target_os = "windows") { - open::that("https://vb-audio.com/Cable/") - .map_err(|e| format!("Failed to open browser: {}", e))?; - Ok("Opening VB-Cable download page...".to_string()) - } else if cfg!(target_os = "macos") { - open::that("https://github.com/ExistentialAudio/BlackHole") - .map_err(|e| format!("Failed to open browser: {}", e))?; - Ok("Opening BlackHole download page...".to_string()) - } else { - Err("Unsupported platform".to_string()) - } -} -fn load_icon() -> Icon { - let icon_bytes = include_bytes!("../assets/icon_32.png"); - let image = image::load_from_memory(icon_bytes) - .expect("Failed to load icon asset") - .into_rgba8(); - let (width, height) = image.dimensions(); - let rgba = image.into_raw(); - Icon::from_rgba(rgba, width, height) - .unwrap_or_else(|_| Icon::from_rgba(vec![0; 32 * 32 * 4], 32, 32).unwrap()) -} diff --git a/crates/app/src/gui/advanced.rs b/crates/app/src/gui/advanced.rs new file mode 100644 index 0000000..09552ec --- /dev/null +++ b/crates/app/src/gui/advanced.rs @@ -0,0 +1,262 @@ +use crate::audio::OutputFilterEngine; +use eframe::egui; +use std::sync::atomic::Ordering; + +use super::app::VoidMicApp; + +impl VoidMicApp { + /// Renders advanced features (output filter, echo cancellation, VAD, EQ, AGC, bypass, spectrum). + pub(super) fn render_advanced_features(&mut self, ui: &mut egui::Ui) { + ui.heading("Advanced Features"); + + ui.horizontal(|ui| { + if ui + .checkbox( + &mut self.config.output_filter_enabled, + "Filter Output (Speaker Denoising)", + ) + .changed() + { + self.mark_config_dirty(); + 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") + .size(10.0) + .color(egui::Color32::YELLOW), + ); + }); + + ui.horizontal(|ui| { + if ui + .checkbox(&mut self.config.echo_cancel_enabled, "Echo Cancellation") + .changed() + { + self.mark_config_dirty(); + if self.engine.is_some() { + let prev_echo = !self.config.echo_cancel_enabled; + self.stop_engine(); + self.start_engine(); + if self.engine.is_none() { + self.config.echo_cancel_enabled = prev_echo; + } + } + } + }); + + if self.config.echo_cancel_enabled || self.config.output_filter_enabled { + ui.horizontal(|ui| { + ui.label("Reference Input (Monitor):"); + let prev_ref = self.selected_reference.clone(); + egui::ComboBox::from_id_salt("ref_combo") + .selected_text(&self.selected_reference) + .width(200.0) + .show_ui(ui, |ui| { + for dev in &self.input_devices { + let _ = + ui.selectable_value(&mut self.selected_reference, dev.clone(), dev); + } + }); + if self.selected_reference != prev_ref { + self.mark_config_dirty(); + } + ui.label(egui::RichText::new("โ„น๏ธ Select speaker monitor").size(10.0)); + }); + } + + ui.separator(); + + // VAD Controls + const VAD_MODES: &[(i32, &str, &str)] = &[ + (0, "Quality", "Quality (Likely Speech)"), + (1, "Low Bitrate", "Low Bitrate"), + (2, "Aggressive", "Aggressive"), + (3, "Very Aggressive", "Very Aggressive"), + ]; + ui.horizontal(|ui| { + ui.label("VAD Sensitivity:"); + let current_label = VAD_MODES + .iter() + .find(|(v, _, _)| *v == self.config.vad_sensitivity) + .map(|(_, _, full)| *full) + .unwrap_or("Unknown"); + egui::ComboBox::from_id_salt("vad_combo") + .selected_text(current_label) + .show_ui(ui, |ui| { + for (value, label, _) in VAD_MODES { + if ui + .selectable_value(&mut self.config.vad_sensitivity, *value, *label) + .clicked() + { + self.mark_config_dirty(); + if let Some(engine) = &self.engine { + engine + .vad_sensitivity + .store(self.config.vad_sensitivity as u32, Ordering::Relaxed); + } + } + } + }); + ui.label(egui::RichText::new("โ„น๏ธ WebRTC VAD").size(10.0)) + .on_hover_text("Voice Activity Detection - filters non-speech sounds"); + }); + + ui.separator(); + + // Equalizer Controls + ui.horizontal(|ui| { + if ui + .checkbox(&mut self.config.eq_enabled, "Equalizer (3-Band)") + .changed() + { + self.mark_config_dirty(); + if let Some(engine) = &self.engine { + engine.eq_enabled.store(self.config.eq_enabled, Ordering::Relaxed); + } + } + }); + + if self.config.eq_enabled { + egui::Grid::new("eq_grid").num_columns(2).show(ui, |ui| { + ui.label("Low (Bass):"); + if ui + .add(egui::Slider::new(&mut self.config.eq_low_gain, -10.0..=10.0).text("dB")) + .changed() + { + self.mark_config_dirty(); + if let Some(engine) = &self.engine { + engine + .eq_low_gain + .store(self.config.eq_low_gain.to_bits(), Ordering::Relaxed); + } + } + ui.end_row(); + + ui.label("Mid (Voice):"); + if ui + .add(egui::Slider::new(&mut self.config.eq_mid_gain, -10.0..=10.0).text("dB")) + .changed() + { + self.mark_config_dirty(); + if let Some(engine) = &self.engine { + engine + .eq_mid_gain + .store(self.config.eq_mid_gain.to_bits(), Ordering::Relaxed); + } + } + ui.end_row(); + + ui.label("High (Treble):"); + if ui + .add(egui::Slider::new(&mut self.config.eq_high_gain, -10.0..=10.0).text("dB")) + .changed() + { + self.mark_config_dirty(); + if let Some(engine) = &self.engine { + engine + .eq_high_gain + .store(self.config.eq_high_gain.to_bits(), Ordering::Relaxed); + } + } + ui.end_row(); + }); + } + + // AGC + Bypass + ui.separator(); + + ui.horizontal(|ui| { + if ui + .checkbox(&mut self.config.agc_enabled, "Automatic Gain Control (AGC)") + .on_hover_text("Normalizes volume to prevent clipping and boost quiet speech") + .changed() + { + self.mark_config_dirty(); + if let Some(engine) = &self.engine { + engine + .agc_enabled + .store(self.config.agc_enabled, Ordering::Relaxed); + } + } + }); + + ui.add_space(5.0); + + // BIG BYPASS BUTTON + let bypass_enabled = if let Some(engine) = &self.engine { + engine.bypass_enabled.load(Ordering::Relaxed) + } else { + false + }; + let bypass_text = if bypass_enabled { + "๐Ÿ”ด BYPASSED (Raw Audio)" + } else { + "๐ŸŸข Processing Active" + }; + if ui + .add_sized( + [ui.available_width(), 30.0], + egui::Button::new(egui::RichText::new(bypass_text).strong().size(14.0)).fill( + if bypass_enabled { + egui::Color32::DARK_RED + } else { + egui::Color32::DARK_GREEN + }, + ), + ) + .clicked() + { + if let Some(engine) = &self.engine { + let current = engine.bypass_enabled.load(Ordering::Relaxed); + engine.bypass_enabled.store(!current, Ordering::Relaxed); + } + } + + // Spectrum Visualizer + if self.engine.is_some() { + ui.add_space(10.0); + ui.label("๐Ÿ“Š Spectrum Analysis"); + self.render_spectrum(ui); + + // Jitter Monitor + const JITTER_GOOD_US: u32 = 1000; + const JITTER_WARN_US: u32 = 5000; + let jitter = self + .engine + .as_ref() + .unwrap() + .jitter_ewma_us + .load(Ordering::Relaxed); + ui.add_space(5.0); + ui.horizontal(|ui| { + ui.label("Latency Health:"); + let color = if jitter < JITTER_GOOD_US { + egui::Color32::GREEN + } else if jitter < JITTER_WARN_US { + egui::Color32::YELLOW + } else { + egui::Color32::RED + }; + ui.colored_label(color, format!("{} ยตs jitter", jitter)) + .on_hover_text("< 1ms = excellent | 1-5ms = acceptable | > 5ms = may cause audio glitches"); + }); + } + } +} diff --git a/crates/app/src/gui/app.rs b/crates/app/src/gui/app.rs new file mode 100644 index 0000000..8aefc9e --- /dev/null +++ b/crates/app/src/gui/app.rs @@ -0,0 +1,575 @@ +use crate::audio::{AudioEngine, OutputFilterEngine}; +use crate::config::AppConfig; +use crate::updater::{self, UpdateInfo}; +use crossbeam_channel::Receiver; +use eframe::egui; +use global_hotkey::hotkey::HotKey; +use global_hotkey::{GlobalHotKeyEvent, GlobalHotKeyManager}; +use std::path::PathBuf; +use std::sync::atomic::Ordering; +use tray_icon::TrayIcon; +use voidmic_ui::{theme, visualizer, widgets}; + + +use super::devices::get_devices; +use super::tray::{load_icon, QUIT_ID, SHOW_ID, TOGGLE_ID}; +use super::wizard::WizardStep; + +/// Runs the VoidMic GUI application. +/// +/// # Arguments +/// * `model_path` - Path to the model directory (currently unused as RNNoise weights are embedded) +/// +/// # Returns +/// Result indicating success or failure of the GUI application +pub fn run_gui(model_path: PathBuf) -> eframe::Result<()> { + // Load config early to determine if we should start minimized + let config = AppConfig::load(); + let start_minimized = config.start_minimized; + let dark_mode = config.dark_mode; + + // Build viewport with saved position if available + let mut viewport = egui::ViewportBuilder::default() + .with_inner_size([450.0, 450.0]) + .with_resizable(false) + .with_visible(!start_minimized); + + if let (Some(x), Some(y)) = (config.window_x, config.window_y) { + viewport = viewport.with_position([x, y]); + } + + let options = eframe::NativeOptions { + viewport, + ..Default::default() + }; + eframe::run_native( + "VoidMic", + options, + Box::new(move |cc| { + theme::setup_custom_style(&cc.egui_ctx, dark_mode); + Ok(Box::new(VoidMicApp::new_with_config(model_path, config))) + }), + ) +} + +pub(super) struct VoidMicApp { + pub(super) input_devices: Vec, + pub(super) output_devices: Vec, + pub(super) selected_input: String, + pub(super) selected_output: String, + pub(super) engine: Option, + pub(super) status_msg: String, + pub(super) model_path: PathBuf, + pub(super) config: AppConfig, + pub(super) config_dirty: bool, + #[allow(dead_code)] // Kept alive for tray icon + pub(super) tray_icon: Option, + pub(super) is_quitting: bool, + pub(super) is_calibrating: bool, + pub(super) update_receiver: Option>>, + pub(super) update_info: Option, + pub(super) virtual_sink_module_id: Option, + pub(super) connected_apps: Vec, + pub(super) last_app_refresh: std::time::Instant, + pub(super) virtual_sink_cached: bool, + pub(super) last_sink_check: std::time::Instant, + // Output Filter (Speaker Denoising) + pub(super) output_filter_engine: Option, + // Echo Cancellation + pub(super) selected_reference: String, + // Global Hotkeys + #[allow(dead_code)] // Manager must be kept alive + pub(super) hotkey_manager: Option, + pub(super) hotkey_id: Option, + // Wizard State + pub(super) show_wizard: bool, + pub(super) wizard_step: WizardStep, + // Phase 6 + pub(super) spectrum_receiver: Option, Vec)>>, + pub(super) last_spectrum_data: (Vec, Vec), + // Track mini mode resize so we only send the command once + pub(super) mini_mode_resized: bool, + // Periodic auto-save for dirty config + pub(super) last_config_save: std::time::Instant, +} + +impl VoidMicApp { + pub(super) fn new_with_config(model_path: PathBuf, config: AppConfig) -> Self { + // Tray Setup + let tray_menu = tray_icon::menu::Menu::new(); + let toggle_item = + tray_icon::menu::MenuItem::with_id(TOGGLE_ID, "Enable", true, None); + let show_item = + tray_icon::menu::MenuItem::with_id(SHOW_ID, "Show/Hide", true, None); + let quit_item = + tray_icon::menu::MenuItem::with_id(QUIT_ID, "Quit", true, None); + let _ = tray_menu.append_items(&[&toggle_item, &show_item, &quit_item]); + + let icon = load_icon(); + let tray_icon = tray_icon::TrayIconBuilder::new() + .with_menu(Box::new(tray_menu)) + .with_tooltip("VoidMic") + .with_icon(icon) + .build() + .ok(); + + // Start async update check + let update_receiver = Some(updater::check_for_updates_async()); + + let (inputs, outputs) = get_devices(); + + let default_in = if inputs.contains(&config.last_input) { + config.last_input.clone() + } else { + inputs + .first() + .cloned() + .unwrap_or_else(|| "default".to_string()) + }; + + let default_out = if outputs.contains(&config.last_output) { + config.last_output.clone() + } else { + outputs + .first() + .cloned() + .unwrap_or_else(|| "default".to_string()) + }; + + let default_ref = if !config.last_reference.is_empty() && inputs.contains(&config.last_reference) { + config.last_reference.clone() + } else { + inputs + .first() + .cloned() + .unwrap_or_else(|| "default".to_string()) + }; + + let auto_start = config.auto_start_processing; + let show_wizard = config.first_run; + + let mut app = Self { + input_devices: inputs, + output_devices: outputs, + selected_input: default_in, + selected_output: default_out, + engine: None, + status_msg: "Ready".to_string(), + model_path, + config, + config_dirty: false, + tray_icon, + is_quitting: false, + is_calibrating: false, + update_receiver, + update_info: None, + virtual_sink_module_id: None, + 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: match GlobalHotKeyManager::new() { + Ok(m) => Some(m), + Err(e) => { + log::warn!("Failed to initialize global hotkey manager: {:?}", e); + None + } + }, + hotkey_id: None, + show_wizard, + 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 + if let Some(ref manager) = app.hotkey_manager { + if let Ok(hotkey) = app.config.toggle_hotkey.parse::() { + if manager.register(hotkey).is_ok() { + app.hotkey_id = Some(hotkey.id()); + } else { + log::warn!("Failed to register hotkey: {}", app.config.toggle_hotkey); + } + } + } + + // Auto-start processing if enabled + if auto_start { + app.start_engine(); + } + + app + } + + pub(super) fn mark_config_dirty(&mut self) { + self.config_dirty = true; + } + + pub(super) fn save_config(&mut self) { + if self.config_dirty { + self.config.last_input = self.selected_input.clone(); + self.config.last_output = self.selected_output.clone(); + self.config.last_reference = self.selected_reference.clone(); + self.config.save(); + self.config_dirty = false; + } + } + + pub(super) fn save_config_now(&mut self) { + self.config.last_input = self.selected_input.clone(); + self.config.last_output = self.selected_output.clone(); + self.config.last_reference = self.selected_reference.clone(); + self.config.save(); + } + + /// Renders the update banner at the top of the UI. + /// Returns true if the update was dismissed. + pub(super) fn render_update_banner(&mut self, ui: &mut egui::Ui) -> bool { + let mut dismiss = false; + if let Some(ref update) = self.update_info { + let version = update.version.clone(); + let url = update.download_url.clone(); + ui.horizontal(|ui| { + ui.colored_label( + egui::Color32::GOLD, + format!("๐ŸŽ‰ Update available: {}", version), + ); + if ui.small_button("Download").clicked() { + let _ = open::that(&url); + } + if ui.small_button("โœ•").clicked() { + dismiss = true; + } + }); + ui.separator(); + } + dismiss + } + + /// Renders the volume meter with dB scaling and threshold marker. + pub(super) fn render_volume_meter(&self, ui: &mut egui::Ui) { + let volume = if let Some(engine) = &self.engine { + f32::from_bits(engine.volume_level.load(Ordering::Relaxed)) + } else { + 0.0 + }; + widgets::render_volume_meter(ui, volume, self.config.gate_threshold); + } + + pub(super) fn render_spectrum(&mut self, ui: &mut egui::Ui) { + // Receive new data + if let Some(rx) = &self.spectrum_receiver { + while let Ok(data) = rx.try_recv() { + self.last_spectrum_data = data; + } + } + let (in_data, out_data) = &self.last_spectrum_data; + visualizer::render_spectrum(ui, in_data, out_data); + } + + /// Checks and handles calibration results. + pub(super) fn check_calibration_result(&mut self) { + if self.is_calibrating { + if let Some(engine) = &self.engine { + if !engine.calibration_mode.load(Ordering::Relaxed) { + 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); + } + self.is_calibrating = false; + } + } + } + } + + fn render_mini(&mut self, ctx: &egui::Context) -> bool { + let mut expanded = false; + egui::CentralPanel::default().show(ctx, |ui| { + ui.vertical_centered(|ui| { + ui.add_space(5.0); + ui.horizontal(|ui| { + ui.label("๐ŸŒŒ VoidMic"); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("โ›ถ").on_hover_text("Expand").clicked() { + self.config.mini_mode = false; + self.mark_config_dirty(); + expanded = true; + ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize( + [450.0, 450.0].into(), + )); + } + }); + }); + + ui.separator(); + + // Status + let active = self.engine.is_some(); + ui.colored_label( + if active { + egui::Color32::GREEN + } else { + egui::Color32::RED + }, + if active { "Active" } else { "Inactive" }, + ); + + ui.add_space(5.0); + + // Bypass Button + let bypass_enabled = if let Some(engine) = &self.engine { + engine.bypass_enabled.load(Ordering::Relaxed) + } else { + false + }; + + let btn_color = if bypass_enabled { + egui::Color32::DARK_RED + } else { + egui::Color32::DARK_GREEN + }; + let btn_text = if bypass_enabled { + "Stopped" + } else { + "Processing" + }; + + if ui + .add_sized([80.0, 30.0], egui::Button::new(btn_text).fill(btn_color)) + .clicked() + { + if let Some(engine) = &self.engine { + let current = engine.bypass_enabled.load(Ordering::Relaxed); + engine.bypass_enabled.store(!current, Ordering::Relaxed); + } + } + + ui.add_space(5.0); + self.render_volume_meter(ui); + }); + }); + expanded + } +} + +impl eframe::App for VoidMicApp { + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + // Handle Tray Events + if let Ok(event) = tray_icon::menu::MenuEvent::receiver().try_recv() { + if event.id.0 == QUIT_ID { + self.is_quitting = true; + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } else if event.id.0 == SHOW_ID { + ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true)); + ctx.send_viewport_cmd(egui::ViewportCommand::Focus); + } else if event.id.0 == TOGGLE_ID { + self.toggle_engine(); + } + } + + // Handle Global Hotkeys + if let Ok(event) = GlobalHotKeyEvent::receiver().try_recv() { + if let Some(id) = self.hotkey_id { + if event.id == id && event.state == global_hotkey::HotKeyState::Released { + self.toggle_engine(); + } + } + } + + // Handle Close Request (Minimize to Tray) + if ctx.input(|i| i.viewport().close_requested()) && !self.is_quitting { + if let Some(pos) = ctx.input(|i| i.viewport().outer_rect).map(|r| r.min) { + self.config.window_x = Some(pos.x); + self.config.window_y = Some(pos.y); + self.save_config_now(); + } + ctx.send_viewport_cmd(egui::ViewportCommand::Visible(false)); + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + } + + // Repaint rate + 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 + 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 + if let Some(ref rx) = self.update_receiver { + if let Ok(update) = rx.try_recv() { + self.update_info = update; + self.update_receiver = None; + } + } + + if self.show_wizard { + self.render_wizard(ctx); + return; + } + + if self.config.mini_mode { + if self.render_mini(ctx) { + self.mini_mode_resized = false; + } else if !self.mini_mode_resized { + ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize([150.0, 150.0].into())); + self.mini_mode_resized = true; + } + return; + } + + egui::CentralPanel::default().show(ctx, |ui| { + if self.render_update_banner(ui) { + 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(); + } + }); + }); + ui.separator(); + ui.add_space(10.0); + + // Volume meter + self.render_volume_meter(ui); + ui.add_space(20.0); + + // Device selectors + self.render_device_selectors(ui); + ui.add_space(20.0); + + // Threshold and suppression controls + self.render_threshold_controls(ui); + self.check_calibration_result(); + + // Advanced Features + ui.add_space(10.0); + self.render_advanced_features(ui); + ui.add_space(10.0); + + // Connected Apps display + #[cfg(target_os = "linux")] + { + if self.engine.is_some() && self.last_app_refresh.elapsed().as_secs() >= 2 { + self.connected_apps = crate::pulse_info::get_connected_apps() + .into_iter() + .map(|a| a.name) + .collect(); + self.last_app_refresh = std::time::Instant::now(); + } + + if !self.connected_apps.is_empty() { + ui.add_space(10.0); + egui::CollapsingHeader::new(format!("๐Ÿ“ฑ Connected Apps ({})", self.connected_apps.len())) + .default_open(true) + .show(ui, |ui| { + for app in &self.connected_apps { + ui.label(format!(" โ€ข {}", app)); + } + }); + } + } + + let is_running = self.engine.is_some(); + let btn_text = if is_running { "STOP ENGINE" } else { "ACTIVATE VOIDMIC" }; + + let btn = ui.add_sized([ui.available_width(), 50.0], egui::Button::new( + egui::RichText::new(btn_text).size(18.0).strong() + )); + if btn.clicked() { + self.toggle_engine(); + } + + ui.add_space(10.0); + ui.label(format!("Status: {}", self.status_msg)); + + ui.with_layout(egui::Layout::bottom_up(egui::Align::Min), |ui| { + ui.horizontal(|ui| { + if ui.button("๐Ÿ› ๏ธ Install Virtual Cable").clicked() { + match super::devices::install_virtual_cable() { + Ok(msg) => { + self.status_msg = msg; + let (inputs, outputs) = get_devices(); + self.input_devices = inputs; + self.output_devices = outputs; + } + Err(e) => { + self.status_msg = format!("Virtual Cable Error: {}", e); + } + } + } + }); + ui.separator(); + + // Start on Boot + let mut start_on_boot = self.config.start_on_boot; + if ui.checkbox(&mut start_on_boot, "Start on Boot").changed() { + self.config.start_on_boot = start_on_boot; + if start_on_boot { + if let Err(e) = crate::autostart::enable_autostart() { + self.status_msg = format!("Autostart error: {}", e); + self.config.start_on_boot = false; + } else { + self.status_msg = "Autostart enabled".to_string(); + } + } else if let Err(e) = crate::autostart::disable_autostart() { + self.status_msg = format!("Autostart error: {}", e); + } else { + self.status_msg = "Autostart disabled".to_string(); + } + self.save_config_now(); + } + + // Start Minimized + let mut start_minimized = self.config.start_minimized; + if ui.checkbox(&mut start_minimized, "Start Minimized to Tray").changed() { + self.config.start_minimized = start_minimized; + self.save_config_now(); + } + + // Auto-Start Processing + let mut auto_start = self.config.auto_start_processing; + if ui.checkbox(&mut auto_start, "Auto-Start Processing").changed() { + self.config.auto_start_processing = auto_start; + self.save_config_now(); + } + + // Dark Mode + let mut dark_mode = self.config.dark_mode; + if ui.checkbox(&mut dark_mode, "Dark Mode").changed() { + self.config.dark_mode = dark_mode; + self.save_config_now(); + theme::setup_custom_style(ui.ctx(), dark_mode); + } + + ui.add_space(5.0); + ui.horizontal(|ui| { + ui.label("Global Hotkey:"); + ui.code(self.config.toggle_hotkey.as_str()); + ui.label(egui::RichText::new("โ„น๏ธ Edit in config.json").size(10.0)); + }); + }); + }); // ScrollArea + }); + } +} diff --git a/crates/app/src/gui/controls.rs b/crates/app/src/gui/controls.rs new file mode 100644 index 0000000..48b3081 --- /dev/null +++ b/crates/app/src/gui/controls.rs @@ -0,0 +1,153 @@ +use eframe::egui; +use std::sync::atomic::Ordering; + +use super::app::VoidMicApp; + +pub(super) struct Preset { + pub name: &'static str, + gate_threshold: f32, + suppression_strength: f32, + dynamic_threshold_enabled: bool, +} + +pub(super) const PRESETS: &[Preset] = &[ + Preset { + name: "Standard", + gate_threshold: 0.015, + suppression_strength: 1.0, + dynamic_threshold_enabled: true, + }, + Preset { + name: "Gaming", + gate_threshold: 0.030, + suppression_strength: 1.0, + dynamic_threshold_enabled: true, + }, + Preset { + name: "Podcast", + gate_threshold: 0.008, + suppression_strength: 0.6, + dynamic_threshold_enabled: true, + }, + Preset { + name: "Noisy Office", + gate_threshold: 0.020, + suppression_strength: 1.0, + dynamic_threshold_enabled: true, + }, + Preset { + name: "Music", + gate_threshold: 0.002, + suppression_strength: 0.3, + dynamic_threshold_enabled: false, + }, +]; + +impl VoidMicApp { + pub(super) fn apply_preset(&mut self, preset_name: &str) { + if let Some(preset) = PRESETS.iter().find(|p| p.name == preset_name) { + self.config.gate_threshold = preset.gate_threshold; + self.config.suppression_strength = preset.suppression_strength; + self.config.dynamic_threshold_enabled = preset.dynamic_threshold_enabled; + 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); + } + } + } + + /// Renders the threshold and suppression controls. + pub(super) fn render_threshold_controls(&mut self, ui: &mut egui::Ui) { + // Presets Dropdown + ui.horizontal(|ui| { + ui.label("Preset:"); + egui::ComboBox::from_id_salt("preset_combo") + .selected_text(&self.config.preset) + .show_ui(ui, |ui| { + if ui + .selectable_label(self.config.preset == "Custom", "Custom") + .clicked() + { + self.config.preset = "Custom".to_string(); + self.save_config_now(); + } + ui.separator(); + for preset in PRESETS { + if ui + .selectable_label(self.config.preset == preset.name, preset.name) + .clicked() + { + self.apply_preset(preset.name); + } + } + }); + }); + + ui.add_space(5.0); + + ui.horizontal(|ui| { + if ui + .checkbox(&mut self.config.dynamic_threshold_enabled, "Auto-Gate") + .on_hover_text("Automatically adjusts gate based on ambient noise floor") + .changed() + { + 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| { + ui.label("Gate Threshold:"); + let slider = egui::Slider::new(&mut self.config.gate_threshold, 0.005..=0.05) + .text("") + .fixed_decimals(3); + 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); + } + } + }); + + let calibrate_enabled = self.engine.is_some() + && !self.is_calibrating + && !self.config.dynamic_threshold_enabled; + if ui + .add_enabled(calibrate_enabled, egui::Button::new("๐ŸŽฏ Calibrate")) + .clicked() + { + if let Some(engine) = &self.engine { + engine.calibration_mode.store(true, Ordering::Relaxed); + self.is_calibrating = true; + self.status_msg = "Calibrating... stay quiet for 3 seconds".to_string(); + } + } + }); + + ui.horizontal(|ui| { + ui.label("Suppression:"); + let pct = (self.config.suppression_strength * 100.0) as i32; + let slider = egui::Slider::new(&mut self.config.suppression_strength, 0.0..=1.0) + .text(format!("{}%", pct)) + .fixed_decimals(0); + 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); + } + if let Some(filter) = &self.output_filter_engine { + filter.suppression_strength.store(self.config.suppression_strength.to_bits(), Ordering::Relaxed); + } + } + }); + } +} diff --git a/crates/app/src/gui/devices.rs b/crates/app/src/gui/devices.rs new file mode 100644 index 0000000..28a0dc6 --- /dev/null +++ b/crates/app/src/gui/devices.rs @@ -0,0 +1,168 @@ +use crate::virtual_device; +use cpal::traits::{DeviceTrait, HostTrait}; +use eframe::egui; +use std::process::Command; + +use super::app::VoidMicApp; + +impl VoidMicApp { + /// Renders the device selection dropdowns. + pub(super) fn render_device_selectors(&mut self, ui: &mut egui::Ui) { + egui::Grid::new("device_grid").striped(true).show(ui, |ui| { + ui.label("Microphone:"); + egui::ComboBox::from_id_salt("input_combo") + .selected_text(&self.selected_input) + .width(250.0) + .show_ui(ui, |ui| { + let mut changed = false; + for dev in &self.input_devices { + if ui + .selectable_value(&mut self.selected_input, dev.clone(), dev) + .changed() + { + changed = true; + } + } + if changed { + self.mark_config_dirty(); + } + }); + ui.end_row(); + + ui.label("Output Sink:"); + egui::ComboBox::from_id_salt("output_combo") + .selected_text(&self.selected_output) + .width(250.0) + .show_ui(ui, |ui| { + let mut changed = false; + for dev in &self.output_devices { + if ui + .selectable_value(&mut self.selected_output, dev.clone(), dev) + .changed() + { + changed = true; + } + } + if changed { + self.mark_config_dirty(); + } + }); + ui.end_row(); + }); + + ui.add_space(10.0); + + // One-Click Setup Section + 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(); + } + ui.horizontal(|ui| { + let sink_exists = self.virtual_sink_cached; + + if sink_exists { + ui.colored_label(egui::Color32::GREEN, "โœ” Virtual Mic Active"); + if ui.button("Destroy").clicked() { + if let Some(id) = self.virtual_sink_module_id { + let _ = virtual_device::destroy_virtual_sink(id); + } else { + let _ = virtual_device::destroy_virtual_sink(0); + } + self.virtual_sink_module_id = None; + let (inputs, outputs) = get_devices(); + self.input_devices = inputs; + self.output_devices = outputs; + } + ui.label(egui::RichText::new("โ„น๏ธ Select 'VoidMic_Clean' in Discord").size(10.0)); + } else if ui + .button("โœจ Create Virtual Mic") + .on_hover_text("Creates a virtual device for Discord/Zoom") + .clicked() + { + match virtual_device::create_virtual_sink() { + Ok(device) => { + self.virtual_sink_module_id = Some(device.module_id); + let (inputs, outputs) = get_devices(); + self.input_devices = inputs; + self.output_devices = outputs; + if self.output_devices.contains(&device.sink_name) { + self.selected_output = device.sink_name; + self.mark_config_dirty(); + } + self.status_msg = "Virtual Mic Created!".to_string(); + } + Err(e) => { + self.status_msg = format!("Failed to create sink: {}", e); + } + } + } + }); + } +} + +pub(super) fn get_devices() -> (Vec, Vec) { + let host = cpal::default_host(); + let inputs = host + .input_devices() + .map(|devs| { + devs.map(|d| d.name().unwrap_or("Unknown".to_string())) + .collect() + }) + .unwrap_or_default(); + + let outputs = host + .output_devices() + .map(|devs| { + devs.map(|d| d.name().unwrap_or("Unknown".to_string())) + .collect() + }) + .unwrap_or_default(); + + (inputs, outputs) +} + +pub(super) fn install_virtual_cable() -> Result { + if cfg!(target_os = "linux") { + let check = Command::new("pactl") + .args(["list", "short", "sinks"]) + .output() + .map_err(|e| { + format!( + "Failed to check sinks: {}. Is PulseAudio/PipeWire installed?", + e + ) + })?; + + let output_str = String::from_utf8_lossy(&check.stdout); + if output_str.contains("VoidMic_Clean") { + return Ok("Virtual sink 'VoidMic_Clean' already exists.".to_string()); + } + + let result = Command::new("pactl") + .args([ + "load-module", + "module-null-sink", + "sink_name=VoidMic_Clean", + "sink_properties=device.description=VoidMic_Clean", + ]) + .output() + .map_err(|e| format!("Failed to create sink: {}", e))?; + + if result.status.success() { + Ok("Virtual sink 'VoidMic_Clean' created! Select 'Monitor of VoidMic_Clean' in your apps.".to_string()) + } else { + let stderr = String::from_utf8_lossy(&result.stderr); + Err(format!("pactl failed: {}", stderr)) + } + } else if cfg!(target_os = "windows") { + open::that("https://vb-audio.com/Cable/") + .map_err(|e| format!("Failed to open browser: {}", e))?; + Ok("Opening VB-Cable download page...".to_string()) + } else if cfg!(target_os = "macos") { + open::that("https://github.com/ExistentialAudio/BlackHole") + .map_err(|e| format!("Failed to open browser: {}", e))?; + Ok("Opening BlackHole download page...".to_string()) + } else { + Err("Unsupported platform".to_string()) + } +} diff --git a/crates/app/src/gui/engine.rs b/crates/app/src/gui/engine.rs new file mode 100644 index 0000000..dee8810 --- /dev/null +++ b/crates/app/src/gui/engine.rs @@ -0,0 +1,119 @@ +use crate::audio::{AudioEngine, OutputFilterEngine}; +use crate::virtual_device; + + +use super::app::VoidMicApp; +use super::devices::get_devices; + +impl VoidMicApp { + pub(super) fn start_engine(&mut self) { + if self.engine.is_some() { + return; + } + + self.status_msg = "Initializing Hybrid Engine...".to_string(); + + // Auto-create virtual sink on Linux + #[cfg(target_os = "linux")] + { + if self.virtual_sink_module_id.is_none() { + match virtual_device::create_virtual_sink() { + Ok(device) => { + self.virtual_sink_module_id = Some(device.module_id); + let (inputs, outputs) = get_devices(); + self.input_devices = inputs; + self.output_devices = outputs.clone(); + if let Some(sink) = outputs.iter().find(|d| d.contains("VoidMic_Clean")) { + self.selected_output = sink.clone(); + } + } + Err(e) => { + self.status_msg = format!("Virtual sink warning: {}", e); + } + } + } + } + + let (tx, rx) = crossbeam_channel::bounded(2); + + match AudioEngine::start( + &self.selected_input, + &self.selected_output, + &self.model_path, + self.config.gate_threshold, + self.config.suppression_strength, + self.config.echo_cancel_enabled, + if self.config.echo_cancel_enabled { Some(self.selected_reference.as_str()) } else { None }, + self.config.dynamic_threshold_enabled, + self.config.vad_sensitivity, + self.config.eq_enabled, + ( + self.config.eq_low_gain, + self.config.eq_mid_gain, + self.config.eq_high_gain, + ), + self.config.agc_enabled, + self.config.agc_target_level, + false, + Some(tx), + ) { + Ok(engine) => { + self.engine = Some(engine); + self.spectrum_receiver = Some(rx); + self.status_msg = "Active (RNNoise + Gate)".to_string(); + self.save_config(); + + // Start output filter AFTER main engine succeeds + if self.config.output_filter_enabled { + 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!("Active (output filter error: {})", e); + self.config.output_filter_enabled = false; + } + } + } + } + Err(e) => { + let error_str = e.to_string(); + self.status_msg = if error_str.contains("No default") { + "Error: No audio device found. Check your system settings.".to_string() + } else if error_str.contains("not found") { + "Error: Selected device not found. Try refreshing or selecting another device.".to_string() + } else if error_str.contains("permission") || error_str.contains("access") { + "Error: Permission denied. Check audio device permissions.".to_string() + } else if error_str.contains("in use") || error_str.contains("busy") { + "Error: Device is busy. Close other audio applications.".to_string() + } else { + format!("Error: {}", e) + }; + log::error!("Failed to start engine: {}", e); + } + } + } + + pub(super) fn stop_engine(&mut self) { + self.engine = None; + self.output_filter_engine = None; + self.status_msg = "Stopped".to_string(); + } + + pub(super) fn toggle_engine(&mut self) { + if self.engine.is_some() { + self.stop_engine(); + if let Some(ref tray) = self.tray_icon { + let _ = tray.set_tooltip(Some("VoidMic - Disabled")); + } + } else { + self.start_engine(); + if let Some(ref tray) = self.tray_icon { + let _ = tray.set_tooltip(Some("VoidMic - Active")); + } + } + } +} diff --git a/crates/app/src/gui/mod.rs b/crates/app/src/gui/mod.rs new file mode 100644 index 0000000..40bcedb --- /dev/null +++ b/crates/app/src/gui/mod.rs @@ -0,0 +1,11 @@ +//! VoidMic GUI โ€” modular implementation. + +mod advanced; +mod app; +mod controls; +mod devices; +mod engine; +mod tray; +mod wizard; + +pub use app::run_gui; diff --git a/crates/app/src/gui/tray.rs b/crates/app/src/gui/tray.rs new file mode 100644 index 0000000..84ff030 --- /dev/null +++ b/crates/app/src/gui/tray.rs @@ -0,0 +1,16 @@ +use tray_icon::Icon; + +pub(super) const QUIT_ID: &str = "quit"; +pub(super) const SHOW_ID: &str = "show"; +pub(super) const TOGGLE_ID: &str = "toggle"; + +pub(super) fn load_icon() -> Icon { + let icon_bytes = include_bytes!("../../assets/icon_32.png"); + let image = image::load_from_memory(icon_bytes) + .expect("Failed to load icon asset") + .into_rgba8(); + let (width, height) = image.dimensions(); + let rgba = image.into_raw(); + Icon::from_rgba(rgba, width, height) + .unwrap_or_else(|_| Icon::from_rgba(vec![0; 32 * 32 * 4], 32, 32).unwrap()) +} diff --git a/crates/app/src/gui/wizard.rs b/crates/app/src/gui/wizard.rs new file mode 100644 index 0000000..6f5c54f --- /dev/null +++ b/crates/app/src/gui/wizard.rs @@ -0,0 +1,128 @@ +use eframe::egui; +use std::sync::atomic::Ordering; + +use super::app::VoidMicApp; + + +#[derive(PartialEq)] +pub(super) enum WizardStep { + Welcome, + SelectMic, + SelectOutput, + Calibration, + Finish, +} + +impl VoidMicApp { + pub(super) fn render_wizard(&mut self, ctx: &egui::Context) { + egui::CentralPanel::default().show(ctx, |ui| { + ui.vertical_centered(|ui| { + ui.add_space(20.0); + ui.heading("โœจ Welcome to VoidMic โœจ"); + ui.add_space(10.0); + ui.label("Let's get your audio set up for crystal clear communication."); + ui.add_space(20.0); + ui.separator(); + ui.add_space(20.0); + + match self.wizard_step { + WizardStep::Welcome => { + ui.label("VoidMic uses AI to remove background noise from your microphone."); + ui.label("This short wizard will help you select your devices and calibrate the noise gate."); + ui.add_space(40.0); + if ui.button("Get Started โžก").clicked() { + self.wizard_step = WizardStep::SelectMic; + } + } + WizardStep::SelectMic => { + ui.heading("๐ŸŽค Select Microphone"); + ui.add_space(10.0); + ui.label("Choose the microphone you want to clean up:"); + 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 &self.input_devices { + if ui.selectable_value(&mut self.selected_input, dev.clone(), dev).changed() { + changed = true; + } + } + }); + if changed { self.mark_config_dirty(); } + + ui.add_space(40.0); + if ui.button("Next โžก").clicked() { + self.wizard_step = WizardStep::SelectOutput; + } + } + WizardStep::SelectOutput => { + ui.heading("๐Ÿ”Š Select Output"); + ui.add_space(10.0); + ui.label("Choose where you want to hear the processed audio (or your speakers):"); + 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 &self.output_devices { + if ui.selectable_value(&mut self.selected_output, dev.clone(), dev).changed() { + changed = true; + } + } + }); + if changed { self.mark_config_dirty(); } + + ui.add_space(40.0); + ui.horizontal(|ui| { + if ui.button("โฌ… Back").clicked() { self.wizard_step = WizardStep::SelectMic; } + if ui.button("Next โžก").clicked() { self.wizard_step = WizardStep::Calibration; } + }); + } + WizardStep::Calibration => { + ui.heading("๐ŸŽ›๏ธ Calibration"); + ui.add_space(10.0); + ui.label("Stay quiet for 3 seconds to measure background noise."); + + self.render_volume_meter(ui); + + ui.add_space(20.0); + + let calibrate_enabled = self.engine.is_some() && !self.is_calibrating; + + if self.engine.is_none() { + if ui.button("โ–ถ Start Audio Engine").clicked() { + self.start_engine(); + } + } else if ui.add_enabled(calibrate_enabled, egui::Button::new("๐ŸŽฏ Start Calibration")).clicked() { + if let Some(engine) = &self.engine { + engine.calibration_mode.store(true, Ordering::Relaxed); + self.is_calibrating = true; + self.status_msg = "Calibrating... stay quiet".to_string(); + } + } + + ui.label(format!("Status: {}", self.status_msg)); + self.check_calibration_result(); + + ui.add_space(40.0); + ui.horizontal(|ui| { + if ui.button("โฌ… Back").clicked() { self.wizard_step = WizardStep::SelectOutput; } + if ui.button("Finish โœ…").clicked() { self.wizard_step = WizardStep::Finish; } + }); + } + WizardStep::Finish => { + ui.heading("๐ŸŽ‰ All Set!"); + ui.label("VoidMic is ready to use."); + ui.add_space(20.0); + if ui.button("Open Main Interface").clicked() { + self.config.first_run = false; + self.show_wizard = false; + self.save_config_now(); + } + } + } + }); + }); + } +} diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 724c7b2..ee37a1f 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -12,3 +12,4 @@ aec3 = "0.1.3" anyhow = "1.0" log = "0.4" crossbeam-channel = "0.5.15" +ringbuf = "0.4.7" diff --git a/crates/core/src/frame_adapter.rs b/crates/core/src/frame_adapter.rs new file mode 100644 index 0000000..f6c447c --- /dev/null +++ b/crates/core/src/frame_adapter.rs @@ -0,0 +1,173 @@ +//! Frame adapter for bridging variable-size host buffers to fixed-size processor frames. +//! +//! Encapsulates the ring buffer plumbing shared between plugin frontends (VST3, CLAP, LV2). + +use crate::constants::FRAME_SIZE; +use crate::processor::VoidProcessor; +use ringbuf::traits::{Consumer, Observer, Producer}; +use ringbuf::HeapRb; + +/// Bridges variable-size audio buffers from plugin hosts to fixed-size +/// `FRAME_SIZE` stereo frames expected by `VoidProcessor`. +/// +/// Internally uses two ring buffers (input and output) to accumulate/drain +/// samples without blocking. +pub struct FrameAdapter { + rb_in: HeapRb, + rb_out: HeapRb, + left_in: [f32; FRAME_SIZE], + right_in: [f32; FRAME_SIZE], + left_out: [f32; FRAME_SIZE], + right_out: [f32; FRAME_SIZE], +} + +impl Default for FrameAdapter { + fn default() -> Self { + Self::new() + } +} + +impl FrameAdapter { + /// Creates a new adapter with ring buffers sized for the given channel count. + pub fn new() -> Self { + let buffer_size = FRAME_SIZE * 4 * 2; // Always stereo + Self { + rb_in: HeapRb::::new(buffer_size), + rb_out: HeapRb::::new(buffer_size), + left_in: [0.0; FRAME_SIZE], + right_in: [0.0; FRAME_SIZE], + left_out: [0.0; FRAME_SIZE], + right_out: [0.0; FRAME_SIZE], + } + } + + /// Pushes interleaved stereo sample pairs into the input ring buffer. + pub fn push_stereo_interleaved(&mut self, left: &[f32], right: &[f32]) { + let len = left.len().min(right.len()); + for i in 0..len { + let _ = self.rb_in.try_push(left[i]); + let _ = self.rb_in.try_push(right[i]); + } + } + + /// Pushes mono samples, duplicating each to both stereo channels. + pub fn push_mono(&mut self, mono: &[f32]) { + for &sample in mono { + let _ = self.rb_in.try_push(sample); + let _ = self.rb_in.try_push(sample); + } + } + + /// Processes all complete stereo frames available in the input buffer + /// through the given `VoidProcessor`, pushing results to the output buffer. + pub fn process_available( + &mut self, + processor: &mut VoidProcessor, + suppression: f32, + threshold: f32, + dynamic_threshold: bool, + ) { + // Need 2 * FRAME_SIZE samples for a full stereo frame + while self.rb_in.occupied_len() >= FRAME_SIZE * 2 { + for j in 0..FRAME_SIZE { + self.left_in[j] = self.rb_in.try_pop().unwrap_or(0.0); + self.right_in[j] = self.rb_in.try_pop().unwrap_or(0.0); + } + + processor.process_frame( + &[&self.left_in, &self.right_in], + &mut [&mut self.left_out, &mut self.right_out], + None, + suppression, + threshold, + dynamic_threshold, + ); + + for j in 0..FRAME_SIZE { + let _ = self.rb_out.try_push(self.left_out[j]); + let _ = self.rb_out.try_push(self.right_out[j]); + } + } + } + + /// Pops processed stereo output samples. Returns the number of sample pairs written. + pub fn pop_stereo(&mut self, left: &mut [f32], right: &mut [f32]) -> usize { + let len = left.len().min(right.len()); + let mut count = 0; + for i in 0..len { + if self.rb_out.occupied_len() >= 2 { + left[i] = self.rb_out.try_pop().unwrap_or(0.0); + right[i] = self.rb_out.try_pop().unwrap_or(0.0); + count += 1; + } else { + left[i] = 0.0; + right[i] = 0.0; + } + } + count + } + + /// Pops processed output as mono (averages L+R). Returns number of samples written. + pub fn pop_mono(&mut self, out: &mut [f32]) -> usize { + let mut count = 0; + for sample in out.iter_mut() { + if self.rb_out.occupied_len() >= 2 { + let l = self.rb_out.try_pop().unwrap_or(0.0); + let r = self.rb_out.try_pop().unwrap_or(0.0); + *sample = (l + r) * 0.5; + count += 1; + } else { + *sample = 0.0; + } + } + count + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_push_pop_roundtrip() { + let mut adapter = FrameAdapter::new(); + let mut processor = VoidProcessor::new(2, 2, (0.0, 0.0, 0.0), 0.7, false); + + // Push a full stereo frame + let left = [0.0f32; FRAME_SIZE]; + let right = [0.0f32; FRAME_SIZE]; + adapter.push_stereo_interleaved(&left, &right); + + // Process it + adapter.process_available(&mut processor, 1.0, 0.015, false); + + // Pop it back + let mut out_l = [0.0f32; FRAME_SIZE]; + let mut out_r = [0.0f32; FRAME_SIZE]; + let count = adapter.pop_stereo(&mut out_l, &mut out_r); + assert_eq!(count, FRAME_SIZE); + } + + #[test] + fn test_mono_duplication() { + let mut adapter = FrameAdapter::new(); + let mono = [0.5f32; 4]; + adapter.push_mono(&mono); + // Should have 8 samples in rb_in (4 pairs) + assert_eq!(adapter.rb_in.occupied_len(), 8); + } + + #[test] + fn test_partial_frame_does_not_process() { + let mut adapter = FrameAdapter::new(); + let mut processor = VoidProcessor::new(2, 2, (0.0, 0.0, 0.0), 0.7, false); + + // Push less than a full frame + let partial = [0.1f32; FRAME_SIZE / 2]; + adapter.push_stereo_interleaved(&partial, &partial); + + // Process โ€” should not produce output since not enough for a frame + adapter.process_available(&mut processor, 1.0, 0.015, false); + assert_eq!(adapter.rb_out.occupied_len(), 0); + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index d342fc9..9cd0a5b 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -1,6 +1,8 @@ pub mod constants; pub mod echo_cancel; +pub mod frame_adapter; pub mod processor; +pub use frame_adapter::FrameAdapter; pub use nnnoiseless::DenoiseState; pub use processor::VoidProcessor; diff --git a/crates/core/src/processor.rs b/crates/core/src/processor.rs index 4a3ab68..96a7450 100644 --- a/crates/core/src/processor.rs +++ b/crates/core/src/processor.rs @@ -419,8 +419,19 @@ impl VoidProcessor { dynamic_threshold_enabled: bool, ) { let channels = self.channels; - assert_eq!(input_frames.len(), channels); - assert_eq!(output_frames.len(), channels); + if input_frames.len() != channels || output_frames.len() != channels { + // Mismatch: output silence rather than crashing the host + log::error!( + "Channel count mismatch: expected {}, got input={} output={}", + channels, + input_frames.len(), + output_frames.len() + ); + for out_ch in output_frames.iter_mut() { + out_ch.fill(0.0); + } + return; + } let mut mono_mix = [0.0f32; FRAME_SIZE]; @@ -694,3 +705,304 @@ impl VoidProcessor { } // spectrum throttle } } + +#[cfg(test)] +mod tests { + use super::*; + + // โ”€โ”€ NoiseFloorTracker โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + #[test] + fn test_initial_floor() { + let tracker = NoiseFloorTracker::new(); + assert!((tracker.floor() - 0.01).abs() < 0.001); + } + + #[test] + fn test_floor_converges_to_minimum() { + let mut tracker = NoiseFloorTracker::new(); + // Feed a constant RMS for many frames + for _ in 0..500 { + tracker.update(0.05); + } + // Floor should converge toward 0.05 + assert!( + tracker.floor() > 0.02, + "Floor should converge upward: got {}", + tracker.floor() + ); + } + + #[test] + fn test_floor_ignores_near_zero() { + let mut tracker = NoiseFloorTracker::new(); + // Pre-fill with a known value + for _ in 0..100 { + tracker.update(0.03); + } + let floor_before = tracker.floor(); + // Feed near-zero values (below 0.0001 threshold) + for _ in 0..50 { + tracker.update(0.00001); + } + // Floor should not have dropped significantly from the near-zero values + assert!( + tracker.floor() > floor_before * 0.5, + "Floor should not be dragged down by near-zero: got {}", + tracker.floor() + ); + } + + #[test] + fn test_floor_updates_with_new_minimum() { + let mut tracker = NoiseFloorTracker::new(); + for _ in 0..100 { + tracker.update(0.1); + } + let high_floor = tracker.floor(); + // Now feed lower values + for _ in 0..200 { + tracker.update(0.005); + } + assert!( + tracker.floor() < high_floor, + "Floor should track downward: {} should be < {}", + tracker.floor(), + high_floor + ); + } + + #[test] + fn test_ring_buffer_wraps() { + let mut tracker = NoiseFloorTracker::new(); + // Feed more than 300 samples (the ring buffer size) + for i in 0..600 { + tracker.update(0.01 + (i as f32) * 0.0001); + } + // Should not panic, and floor should be reasonable + assert!(tracker.floor() > 0.0); + } + + // โ”€โ”€ ThreeBandEq โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + #[test] + fn test_flat_eq_is_near_identity() { + let mut eq = ThreeBandEq::new(0.0, 0.0, 0.0).unwrap(); + // Process a DC-ish pulse and verify output is close to input + // (filters have transient response, so check after warmup) + for _ in 0..100 { + eq.process(0.5); + } + let out = eq.process(0.5); + assert!( + (out - 0.5).abs() < 0.05, + "Flat EQ should be near-identity after warmup: got {}", + out + ); + } + + #[test] + fn test_eq_construction_with_valid_gains() { + assert!(ThreeBandEq::new(-6.0, 3.0, 6.0).is_ok()); + assert!(ThreeBandEq::new(0.0, 0.0, 0.0).is_ok()); + assert!(ThreeBandEq::new(10.0, -10.0, 10.0).is_ok()); + } + + #[test] + fn test_eq_update_gains() { + let mut eq = ThreeBandEq::new(0.0, 0.0, 0.0).unwrap(); + assert!(eq.update_gains(3.0, -3.0, 6.0).is_ok()); + assert!(eq.update_gains(-10.0, 0.0, 10.0).is_ok()); + } + + // โ”€โ”€ LookaheadLimiter โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + #[test] + fn test_quiet_signal_gains_up() { + let mut limiter = LookaheadLimiter::new(0.7); + let mut data = vec![0.1f32; FRAME_SIZE]; + let mut frames: Vec<&mut [f32]> = vec![data.as_mut_slice()]; + // Process several frames to let gain ramp up + for _ in 0..50 { + limiter.process_frame(&mut frames); + } + // After many frames, samples should be boosted above 0.1 + assert!( + frames[0][0].abs() > 0.1, + "Quiet signal should be boosted: got {}", + frames[0][0] + ); + } + + #[test] + fn test_loud_signal_gains_down() { + let mut limiter = LookaheadLimiter::new(0.3); + let mut data = vec![0.9f32; FRAME_SIZE]; + let mut frames: Vec<&mut [f32]> = vec![data.as_mut_slice()]; + for _ in 0..50 { + limiter.process_frame(&mut frames); + } + // After many frames, samples should be reduced below 0.9 + assert!( + frames[0][0].abs() < 0.9, + "Loud signal should be attenuated: got {}", + frames[0][0] + ); + } + + #[test] + fn test_output_never_clips() { + let mut limiter = LookaheadLimiter::new(0.7); + let mut data = vec![0.98f32; FRAME_SIZE]; + let mut frames: Vec<&mut [f32]> = vec![data.as_mut_slice()]; + for _ in 0..100 { + limiter.process_frame(&mut frames); + } + for sample in frames[0].iter() { + assert!( + sample.abs() <= 0.99, + "Output must not exceed ยฑ0.99: got {}", + sample + ); + } + } + + #[test] + fn test_empty_frames_no_panic() { + let mut limiter = LookaheadLimiter::new(0.7); + let mut frames: Vec<&mut [f32]> = vec![]; + limiter.process_frame(&mut frames); // Should not panic + } + + // โ”€โ”€ VoidProcessor โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + #[test] + fn test_processor_creation() { + let _p1 = VoidProcessor::new(1, 2, (0.0, 0.0, 0.0), 0.7, false); + let _p2 = VoidProcessor::new(2, 0, (-3.0, 0.0, 3.0), 0.5, false); + } + + #[test] + fn test_silence_produces_silence() { + let mut processor = VoidProcessor::new(1, 2, (0.0, 0.0, 0.0), 0.7, false); + let input = [0.0f32; FRAME_SIZE]; + let mut output = [0.0f32; FRAME_SIZE]; + + // Process enough frames for the gate to fully close + for _ in 0..100 { + processor.process_frame( + &[&input], + &mut [&mut output], + None, + 1.0, + 0.015, + false, + ); + } + + // After many silent frames, output should be all zeros + let max = output.iter().map(|s| s.abs()).fold(0.0f32, f32::max); + assert!(max < 0.001, "Silent input should produce silent output: max={}", max); + } + + #[test] + fn test_bypass_passes_through() { + let mut processor = VoidProcessor::new(1, 2, (0.0, 0.0, 0.0), 0.7, false); + processor.bypass_enabled.store(true, Ordering::Relaxed); + processor.process_updates(); + + // Generate a non-zero signal + let mut input = [0.0f32; FRAME_SIZE]; + for (i, s) in input.iter_mut().enumerate() { + *s = (i as f32 / FRAME_SIZE as f32) * 0.5; + } + let mut output = [0.0f32; FRAME_SIZE]; + + // Process enough frames for bypass crossfade to complete + for _ in 0..20 { + processor.process_frame( + &[&input], + &mut [&mut output], + None, + 1.0, + 0.015, + false, + ); + } + + // After crossfade settles, output should match input + for i in 0..FRAME_SIZE { + assert!( + (output[i] - input[i]).abs() < 0.01, + "Bypass should pass through: sample {} expected {} got {}", + i, + input[i], + output[i] + ); + } + } + + #[test] + fn test_gate_closes_on_silence() { + let mut processor = VoidProcessor::new(1, 2, (0.0, 0.0, 0.0), 0.7, false); + + // First, feed loud audio to open the gate + let loud = [0.3f32; FRAME_SIZE]; + let mut output = [0.0f32; FRAME_SIZE]; + for _ in 0..10 { + processor.process_frame( + &[&loud], + &mut [&mut output], + None, + 1.0, + 0.015, + false, + ); + } + + // Now feed silence - gate should close after release period + let silence = [0.0f32; FRAME_SIZE]; + for _ in 0..200 { + processor.process_frame( + &[&silence], + &mut [&mut output], + None, + 1.0, + 0.015, + false, + ); + } + + let max = output.iter().map(|s| s.abs()).fold(0.0f32, f32::max); + assert!(max < 0.001, "Gate should close after silence: max={}", max); + } + + #[test] + fn test_channel_mismatch_does_not_panic() { + let mut processor = VoidProcessor::new(2, 2, (0.0, 0.0, 0.0), 0.7, false); + let input = [0.5f32; FRAME_SIZE]; + let mut output = [0.5f32; FRAME_SIZE]; + + // Pass 1 channel to a 2-channel processor โ€” should not panic + processor.process_frame( + &[&input], // 1 channel, expected 2 + &mut [&mut output], + None, + 1.0, + 0.015, + false, + ); + + // Output should be zeroed (silence fallback) + assert_eq!(output[0], 0.0, "Mismatch should produce silence"); + } + + #[test] + fn test_process_updates_does_not_panic() { + let mut processor = VoidProcessor::new(1, 2, (0.0, 0.0, 0.0), 0.7, false); + // Call process_updates multiple times with no changes โ€” should be safe + for _ in 0..10 { + processor.process_updates(); + } + } +} diff --git a/crates/lv2/Cargo.toml b/crates/lv2/Cargo.toml index 2dc0bc5..6db4cea 100644 --- a/crates/lv2/Cargo.toml +++ b/crates/lv2/Cargo.toml @@ -9,6 +9,5 @@ crate-type = ["cdylib"] [dependencies] lv2 = "0.6" voidmic_core = { path = "../core" } -ringbuf = "0.4.7" log = "0.4" diff --git a/crates/lv2/src/lib.rs b/crates/lv2/src/lib.rs index fdfb380..111be41 100644 --- a/crates/lv2/src/lib.rs +++ b/crates/lv2/src/lib.rs @@ -1,9 +1,7 @@ use lv2::prelude::*; -use ringbuf::traits::{Consumer, Observer, Producer}; -use ringbuf::HeapRb; use std::sync::atomic::Ordering; -use voidmic_core::constants::{FRAME_SIZE, SAMPLE_RATE}; -use voidmic_core::VoidProcessor; +use voidmic_core::constants::SAMPLE_RATE; +use voidmic_core::{FrameAdapter, VoidProcessor}; #[derive(PortCollection)] struct VoidMicPorts { @@ -19,8 +17,7 @@ struct VoidMicPorts { #[uri("https://github.com/Detair/voidvoice/lv2/voidmic")] struct VoidMic { processor: VoidProcessor, - rb_in: HeapRb, - rb_out: HeapRb, + adapter: FrameAdapter, } // Safety: LV2 hosts guarantee that Plugin::run() is called from a single audio thread. @@ -52,14 +49,9 @@ impl Plugin for VoidMic { false, // Echo Cancel disabled ); - let buffer_size = FRAME_SIZE * 4 * 2; - let rb_in = HeapRb::::new(buffer_size); - let rb_out = HeapRb::::new(buffer_size); - Some(Self { processor, - rb_in, - rb_out, + adapter: FrameAdapter::new(), }) } @@ -75,56 +67,31 @@ impl Plugin for VoidMic { self.processor.process_updates(); // 2. Push Input - let input_l = ports.input_l.iter(); - let input_r = ports.input_r.iter(); - - for (l, r) in input_l.zip(input_r) { - let _ = self.rb_in.try_push(*l); - let _ = self.rb_in.try_push(*r); - } - - // 3. Process Blocks - let mut left_in = [0.0f32; FRAME_SIZE]; - let mut right_in = [0.0f32; FRAME_SIZE]; - let mut left_out = [0.0f32; FRAME_SIZE]; - let mut right_out = [0.0f32; FRAME_SIZE]; - - while self.rb_in.occupied_len() >= FRAME_SIZE * 2 { - for j in 0..FRAME_SIZE { - left_in[j] = self.rb_in.try_pop().unwrap_or(0.0); - right_in[j] = self.rb_in.try_pop().unwrap_or(0.0); - } - - self.processor.process_frame( - &[&left_in, &right_in], - &mut [&mut left_out, &mut right_out], - None, - suppression, - threshold, - false, // Use explicit threshold from control port, not dynamic - ); - - for j in 0..FRAME_SIZE { - let _ = self.rb_out.try_push(left_out[j]); - let _ = self.rb_out.try_push(right_out[j]); - } - } + let input_l: Vec = ports.input_l.iter().copied().collect(); + let input_r: Vec = ports.input_r.iter().copied().collect(); + self.adapter.push_stereo_interleaved(&input_l, &input_r); + + // 3. Process available frames + self.adapter.process_available( + &mut self.processor, + suppression, + threshold, + false, // Use explicit threshold from control port, not dynamic + ); // 4. Fill Output - let output_l = ports.output_l.iter_mut(); - let output_r = ports.output_r.iter_mut(); + let num_samples = ports.output_l.len(); + let mut out_l = vec![0.0f32; num_samples]; + let mut out_r = vec![0.0f32; num_samples]; + self.adapter.pop_stereo(&mut out_l, &mut out_r); - for (l, r) in output_l.zip(output_r) { - if self.rb_out.occupied_len() >= 2 { - *l = self.rb_out.try_pop().unwrap_or(0.0); - *r = self.rb_out.try_pop().unwrap_or(0.0); - } else { - *l = 0.0; - *r = 0.0; - } + for (dst, src) in ports.output_l.iter_mut().zip(out_l.iter()) { + *dst = *src; + } + for (dst, src) in ports.output_r.iter_mut().zip(out_r.iter()) { + *dst = *src; } } } lv2_descriptors!(VoidMic); - diff --git a/crates/plugin/Cargo.toml b/crates/plugin/Cargo.toml index dadbbb9..0a83afd 100644 --- a/crates/plugin/Cargo.toml +++ b/crates/plugin/Cargo.toml @@ -9,7 +9,6 @@ crate-type = ["cdylib"] [dependencies] voidmic_core = { path = "../core" } nih_plug = { git = "https://github.com/robbert-vdh/nih-plug.git" } -ringbuf = "0.4.7" anyhow = "1.0" nih_plug_egui = { git = "https://github.com/robbert-vdh/nih-plug.git" } egui = "0.31" diff --git a/crates/plugin/src/lib.rs b/crates/plugin/src/lib.rs index f47c166..0e7884b 100644 --- a/crates/plugin/src/lib.rs +++ b/crates/plugin/src/lib.rs @@ -1,25 +1,19 @@ use crossbeam_channel::Receiver; use nih_plug::prelude::*; use nih_plug_egui::{create_egui_editor, widgets, EguiState}; -use ringbuf::traits::{Consumer, Observer, Producer}; -use ringbuf::HeapRb; use std::num::NonZeroU32; use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::Arc; -use voidmic_core::constants::{FRAME_SIZE, SAMPLE_RATE}; -use voidmic_core::VoidProcessor; +use voidmic_core::constants::SAMPLE_RATE; +use voidmic_core::{FrameAdapter, VoidProcessor}; use voidmic_ui::{theme, visualizer, widgets as ui_widgets}; struct VoidMicPlugin { params: Arc, - // editor_state removed as it is in params // Audio Processing State processor: Option, - - // Ring Buffers (Audio I/O) - rb_in: Option>, - rb_out: Option>, + adapter: Option, // GUI Data Bridging volume_level: Arc, @@ -56,8 +50,7 @@ impl Default for VoidMicPlugin { Self { params: Arc::new(VoidMicParams::default()), processor: None, - rb_in: None, - rb_out: None, + adapter: None, volume_level: Arc::new(AtomicU32::new(0)), spectrum_receiver: None, } @@ -218,12 +211,7 @@ impl Plugin for VoidMicPlugin { self.volume_level = processor.volume_level.clone(); self.processor = Some(processor); - - let buffer_size = FRAME_SIZE * 4 * 2; // Always stereo - - // Ringbuf 0.4 - self.rb_in = Some(HeapRb::::new(buffer_size)); - self.rb_out = Some(HeapRb::::new(buffer_size)); + self.adapter = Some(FrameAdapter::new()); true } @@ -238,6 +226,10 @@ impl Plugin for VoidMicPlugin { Some(p) => p, None => return ProcessStatus::Normal, }; + let adapter = match self.adapter.as_mut() { + Some(a) => a, + None => return ProcessStatus::Normal, + }; processor .bypass_enabled @@ -255,66 +247,31 @@ impl Plugin for VoidMicPlugin { } let num_samples = channel_data[0].len(); - // 1. Push Input (Interleaved) - if let Some(rb_in) = &mut self.rb_in { - for i in 0..num_samples { - if num_channels == 2 { - let _ = rb_in.try_push(channel_data[0][i]); - let _ = rb_in.try_push(channel_data[1][i]); - } else if num_channels == 1 { - // Duplicate Mono to Stereo - let val = channel_data[0][i]; - let _ = rb_in.try_push(val); - let _ = rb_in.try_push(val); - } - } + // 1. Push Input + if num_channels == 2 { + adapter.push_stereo_interleaved(&channel_data[0][..num_samples], &channel_data[1][..num_samples]); + } else if num_channels == 1 { + adapter.push_mono(&channel_data[0][..num_samples]); } - // 2. Process chunks - if let (Some(rb_in), Some(rb_out)) = (&mut self.rb_in, &mut self.rb_out) { - let mut left_in = [0.0f32; FRAME_SIZE]; - let mut right_in = [0.0f32; FRAME_SIZE]; - let mut left_out = [0.0f32; FRAME_SIZE]; - let mut right_out = [0.0f32; FRAME_SIZE]; - - // Need 2 * FRAME_SIZE samples for a full stereo frame - while rb_in.occupied_len() >= FRAME_SIZE * 2 { - for j in 0..FRAME_SIZE { - left_in[j] = rb_in.try_pop().unwrap_or(0.0); - right_in[j] = rb_in.try_pop().unwrap_or(0.0); - } - - processor.process_frame( - &[&left_in, &right_in], - &mut [&mut left_out, &mut right_out], - None, - self.params.suppression.value(), - self.params.gate_threshold.value(), - true, - ); - - for j in 0..FRAME_SIZE { - let _ = rb_out.try_push(left_out[j]); - let _ = rb_out.try_push(right_out[j]); - } - } - } + // 2. Process available frames + adapter.process_available( + processor, + self.params.suppression.value(), + self.params.gate_threshold.value(), + true, + ); // 3. Output - if let Some(rb_out) = &mut self.rb_out { - for i in 0..num_samples { - if rb_out.occupied_len() >= 2 { - let l = rb_out.try_pop().unwrap_or(0.0); - let r = rb_out.try_pop().unwrap_or(0.0); - - if num_channels == 1 { - channel_data[0][i] = (l + r) * 0.5; - } else { - channel_data[0][i] = l; - channel_data[1][i] = r; - } - } - } + if num_channels == 1 { + adapter.pop_mono(&mut channel_data[0][..num_samples]); + } else { + // Split borrows: we need mutable references to two different slices + let (left_slice, rest) = channel_data.split_at_mut(1); + adapter.pop_stereo( + &mut left_slice[0][..num_samples], + &mut rest[0][..num_samples], + ); } ProcessStatus::Normal