From 25f4a564fd9a192ee49f64f1fd35781581e36aeb Mon Sep 17 00:00:00 2001 From: Maicon Moreira <50086618+Maicon-Moreira@users.noreply.github.com> Date: Sun, 16 Nov 2025 23:40:47 -0300 Subject: [PATCH] Fix unstable mute implementation on Windows/Linux (#341) * Fix unstable mute implementation on Windows/Linux * add some delay before the mute so the audio feedback plays --------- Co-authored-by: CJ Pais --- bun.lock | 1 + src-tauri/Cargo.lock | 47 +---------- src-tauri/Cargo.toml | 8 +- src-tauri/src/actions.rs | 20 ++++- src-tauri/src/managers/audio.rs | 135 ++++++++++++++++++++++++++++---- 5 files changed, 147 insertions(+), 64 deletions(-) diff --git a/bun.lock b/bun.lock index 8beb3b9..5b5b03b 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "handy-app", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 2fa0b4a..ffc514a 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -973,21 +973,6 @@ dependencies = [ "libc", ] -[[package]] -name = "cpvc" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30cefc7f947361fa9b9b2709391ac9ab8970f8150bcdbc7dab6287399f5fda34" -dependencies = [ - "alsa", - "core-foundation 0.10.1", - "libpulse-binding", - "libpulse-sys", - "objc2-core-audio", - "objc2-core-audio-types", - "windows 0.61.3", -] - [[package]] name = "crc" version = "3.3.0" @@ -2306,7 +2291,6 @@ dependencies = [ "async-openai 0.30.1", "chrono", "cpal", - "cpvc", "enigo", "env_logger 0.11.8", "flate2", @@ -2342,6 +2326,7 @@ dependencies = [ "tokio", "transcribe-rs", "vad-rs", + "windows 0.61.3", ] [[package]] @@ -3087,33 +3072,6 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" -[[package]] -name = "libpulse-binding" -version = "2.30.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "909eb3049e16e373680fe65afe6e2a722ace06b671250cc4849557bc57d6a397" -dependencies = [ - "bitflags 2.10.0", - "libc", - "libpulse-sys", - "num-derive", - "num-traits", - "winapi", -] - -[[package]] -name = "libpulse-sys" -version = "1.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d74371848b22e989f829cc1621d2ebd74960711557d8b45cfe740f60d0a05e61" -dependencies = [ - "libc", - "num-derive", - "num-traits", - "pkg-config", - "winapi", -] - [[package]] name = "libredox" version = "0.1.10" @@ -3695,13 +3653,10 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" dependencies = [ - "block2 0.6.2", "dispatch2", - "libc", "objc2 0.6.3", "objc2-core-audio-types", "objc2-core-foundation", - "objc2-foundation 0.3.2", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 5117a9f..3df8f10 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -66,7 +66,6 @@ rusqlite = { version = "0.32.1", features = ["bundled"] } tar = "0.4.44" flate2 = "1.0" transcribe-rs = "0.1.4" -cpvc = "0.4.1" [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-autostart = "2.5.1" @@ -74,6 +73,13 @@ tauri-plugin-global-shortcut = "2.3.1" tauri-plugin-single-instance = "2.3.2" tauri-plugin-updater = "2.9.0" +[target.'cfg(windows)'.dependencies] +windows = { version = "0.61.3", features = [ + "Win32_Media_Audio_Endpoints", + "Win32_System_Com_StructuredStorage", + "Win32_System_Variant", +] } + [profile.release] lto = true codegen-units = 1 diff --git a/src-tauri/src/actions.rs b/src-tauri/src/actions.rs index c2aff8a..ff5556d 100644 --- a/src-tauri/src/actions.rs +++ b/src-tauri/src/actions.rs @@ -181,13 +181,21 @@ impl ShortcutAction for TranscribeAction { debug!("Microphone mode - always_on: {}", is_always_on); if is_always_on { - // Always-on mode: Play audio feedback immediately + // Always-on mode: Play audio feedback immediately, then apply mute after sound finishes debug!("Always-on mode: Playing audio feedback immediately"); play_feedback_sound(app, SoundType::Start); + + // Apply mute after audio feedback has time to play (500ms should be enough for most sounds) + let rm_clone = Arc::clone(&rm); + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(500)); + rm_clone.apply_mute(); + }); + let recording_started = rm.try_start_recording(&binding_id); debug!("Recording started: {}", recording_started); } else { - // On-demand mode: Start recording first, then play audio feedback + // On-demand mode: Start recording first, then play audio feedback, then apply mute // This allows the microphone to be activated before playing the sound debug!("On-demand mode: Starting recording first, then audio feedback"); let recording_start_time = Instant::now(); @@ -195,10 +203,15 @@ impl ShortcutAction for TranscribeAction { debug!("Recording started in {:?}", recording_start_time.elapsed()); // Small delay to ensure microphone stream is active let app_clone = app.clone(); + let rm_clone = Arc::clone(&rm); std::thread::spawn(move || { std::thread::sleep(std::time::Duration::from_millis(100)); debug!("Playing delayed audio feedback"); play_feedback_sound(&app_clone, SoundType::Start); + + // Apply mute after audio feedback has time to play + std::thread::sleep(std::time::Duration::from_millis(500)); + rm_clone.apply_mute(); }); } else { debug!("Failed to start recording"); @@ -223,6 +236,9 @@ impl ShortcutAction for TranscribeAction { change_tray_icon(app, TrayIconState::Transcribing); show_transcribing_overlay(app); + // Unmute before playing audio feedback so the stop sound is audible + rm.remove_mute(); + // Play audio feedback for recording stop play_feedback_sound(app, SoundType::Stop); diff --git a/src-tauri/src/managers/audio.rs b/src-tauri/src/managers/audio.rs index fe7810e..c99c389 100644 --- a/src-tauri/src/managers/audio.rs +++ b/src-tauri/src/managers/audio.rs @@ -6,6 +6,95 @@ use std::sync::{Arc, Mutex}; use std::time::Instant; use tauri::Manager; +fn set_mute(mute: bool) { + // Expected behavior: + // - Windows: works on most systems using standard audio drivers. + // - Linux: works on many systems (PipeWire, PulseAudio, ALSA), + // but some distros may lack the tools used. + // - macOS: works on most standard setups via AppleScript. + // If unsupported, fails silently. + + #[cfg(target_os = "windows")] + { + unsafe { + use windows::Win32::{ + Media::Audio::{ + eMultimedia, eRender, Endpoints::IAudioEndpointVolume, IMMDeviceEnumerator, + MMDeviceEnumerator, + }, + System::Com::{CoCreateInstance, CoInitializeEx, CLSCTX_ALL, COINIT_MULTITHREADED}, + }; + + macro_rules! unwrap_or_return { + ($expr:expr) => { + match $expr { + Ok(val) => val, + Err(_) => return, + } + }; + } + + // Initialize the COM library for this thread. + // If already initialized (e.g., by another library like Tauri), this does nothing. + let _ = CoInitializeEx(None, COINIT_MULTITHREADED); + + let all_devices: IMMDeviceEnumerator = + unwrap_or_return!(CoCreateInstance(&MMDeviceEnumerator, None, CLSCTX_ALL)); + let default_device = + unwrap_or_return!(all_devices.GetDefaultAudioEndpoint(eRender, eMultimedia)); + let volume_interface = unwrap_or_return!( + default_device.Activate::(CLSCTX_ALL, None) + ); + + let _ = volume_interface.SetMute(mute, std::ptr::null()); + } + } + + #[cfg(target_os = "linux")] + { + use std::process::Command; + + let mute_val = if mute { "1" } else { "0" }; + let amixer_state = if mute { "mute" } else { "unmute" }; + + // Try multiple backends to increase compatibility + // 1. PipeWire (wpctl) + if Command::new("wpctl") + .args(["set-mute", "@DEFAULT_AUDIO_SINK@", mute_val]) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + { + return; + } + + // 2. PulseAudio (pactl) + if Command::new("pactl") + .args(["set-sink-mute", "@DEFAULT_SINK@", mute_val]) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + { + return; + } + + // 3. ALSA (amixer) + let _ = Command::new("amixer") + .args(["set", "Master", amixer_state]) + .output(); + } + + #[cfg(target_os = "macos")] + { + use std::process::Command; + let script = format!( + "set volume output muted {}", + if mute { "true" } else { "false" } + ); + let _ = Command::new("osascript").args(["-e", &script]).output(); + } +} + const WHISPER_SAMPLE_RATE: usize = 16000; /* ──────────────────────────────────────────────────────────────── */ @@ -58,7 +147,7 @@ pub struct AudioRecordingManager { recorder: Arc>>, is_open: Arc>, is_recording: Arc>, - initial_volume: Arc>>, + did_mute: Arc>, } impl AudioRecordingManager { @@ -80,7 +169,7 @@ impl AudioRecordingManager { recorder: Arc::new(Mutex::new(None)), is_open: Arc::new(Mutex::new(false)), is_recording: Arc::new(Mutex::new(false)), - initial_volume: Arc::new(Mutex::new(None)), + did_mute: Arc::new(Mutex::new(false)), }; // Always-on? Open immediately. @@ -93,6 +182,28 @@ impl AudioRecordingManager { /* ---------- microphone life-cycle -------------------------------------- */ + /// Applies mute if mute_while_recording is enabled and stream is open + pub fn apply_mute(&self) { + let settings = get_settings(&self.app_handle); + let mut did_mute_guard = self.did_mute.lock().unwrap(); + + if settings.mute_while_recording && *self.is_open.lock().unwrap() { + set_mute(true); + *did_mute_guard = true; + debug!("Mute applied"); + } + } + + /// Removes mute if it was applied + pub fn remove_mute(&self) { + let mut did_mute_guard = self.did_mute.lock().unwrap(); + if *did_mute_guard { + set_mute(false); + *did_mute_guard = false; + debug!("Mute removed"); + } + } + pub fn start_microphone_stream(&self) -> Result<(), anyhow::Error> { let mut open_flag = self.is_open.lock().unwrap(); if *open_flag { @@ -102,15 +213,9 @@ impl AudioRecordingManager { let start_time = Instant::now(); - let settings = get_settings(&self.app_handle); - let mut initial_volume_guard = self.initial_volume.lock().unwrap(); - - if settings.mute_while_recording { - *initial_volume_guard = Some(cpvc::get_system_volume()); - cpvc::set_system_volume(0); - } else { - *initial_volume_guard = None; - } + // Don't mute immediately - caller will handle muting after audio feedback + let mut did_mute_guard = self.did_mute.lock().unwrap(); + *did_mute_guard = false; let vad_path = self .app_handle @@ -166,11 +271,11 @@ impl AudioRecordingManager { return; } - let mut initial_volume_guard = self.initial_volume.lock().unwrap(); - if let Some(vol) = *initial_volume_guard { - cpvc::set_system_volume(vol); + let mut did_mute_guard = self.did_mute.lock().unwrap(); + if *did_mute_guard { + set_mute(false); } - *initial_volume_guard = None; + *did_mute_guard = false; if let Some(rec) = self.recorder.lock().unwrap().as_mut() { // If still recording, stop first.