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:
parent
f3f362198e
commit
25f4a564fd
5 changed files with 147 additions and 64 deletions
1
bun.lock
1
bun.lock
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "handy-app",
|
||||
|
|
|
|||
47
src-tauri/Cargo.lock
generated
47
src-tauri/Cargo.lock
generated
|
|
@ -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]]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue