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 <cj@cjpais.com>
This commit is contained in:
Maicon Moreira 2025-11-16 23:40:47 -03:00 committed by GitHub
parent f3f362198e
commit 25f4a564fd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 147 additions and 64 deletions

View file

@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "handy-app",

47
src-tauri/Cargo.lock generated
View file

@ -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]]

View file

@ -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

View file

@ -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);

View file

@ -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::<IAudioEndpointVolume>(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<Mutex<Option<AudioRecorder>>>,
is_open: Arc<Mutex<bool>>,
is_recording: Arc<Mutex<bool>>,
initial_volume: Arc<Mutex<Option<u8>>>,
did_mute: Arc<Mutex<bool>>,
}
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.