diff --git a/src-tauri/src/actions.rs b/src-tauri/src/actions.rs index 6dfa52d31..631ef84e8 100644 --- a/src-tauri/src/actions.rs +++ b/src-tauri/src/actions.rs @@ -223,9 +223,6 @@ impl ShortcutAction for TranscribeAction { tm.initiate_model_load(); let binding_id = binding_id.to_string(); - change_tray_icon(app, TrayIconState::Recording); - show_recording_overlay(app); - let rm = app.state::>(); // Get the microphone mode to determine audio feedback timing @@ -237,17 +234,19 @@ impl ShortcutAction for TranscribeAction { if is_always_on { // Always-on mode: Play audio feedback immediately, then apply mute after sound finishes debug!("Always-on mode: Playing audio feedback immediately"); - let rm_clone = Arc::clone(&rm); - let app_clone = app.clone(); - // The blocking helper exits immediately if audio feedback is disabled, - // so we can always reuse this thread to ensure mute happens right after playback. - std::thread::spawn(move || { - play_feedback_sound_blocking(&app_clone, SoundType::Start); - rm_clone.apply_mute(); - }); - recording_started = rm.try_start_recording(&binding_id); debug!("Recording started: {}", recording_started); + + if recording_started { + let rm_clone = Arc::clone(&rm); + let app_clone = app.clone(); + // The blocking helper exits immediately if audio feedback is disabled, + // so we can always reuse this thread to ensure mute happens right after playback. + std::thread::spawn(move || { + play_feedback_sound_blocking(&app_clone, SoundType::Start); + rm_clone.apply_mute(); + }); + } } else { // On-demand mode: Start recording first, then play audio feedback, then apply mute // This allows the microphone to be activated before playing the sound @@ -273,8 +272,17 @@ impl ShortcutAction for TranscribeAction { } if recording_started { + change_tray_icon(app, TrayIconState::Recording); + show_recording_overlay(app); // Dynamically register the cancel shortcut in a separate task to avoid deadlock shortcut::register_cancel_shortcut(app); + } else { + utils::hide_recording_overlay(app); + change_tray_icon(app, TrayIconState::Idle); + + if let Ok(mut states) = app.state::().lock() { + states.active_toggles.insert(binding_id.clone(), false); + } } debug!( @@ -292,6 +300,18 @@ impl ShortcutAction for TranscribeAction { let ah = app.clone(); let rm = Arc::clone(&app.state::>()); + + if !rm.is_recording() { + utils::hide_recording_overlay(app); + change_tray_icon(app, TrayIconState::Idle); + + if let Ok(mut states) = app.state::().lock() { + states + .active_toggles + .insert(binding_id.to_string(), false); + } + return; + } let tm = Arc::clone(&app.state::>()); let hm = Arc::clone(&app.state::>()); diff --git a/src-tauri/src/audio_toolkit/audio/recorder.rs b/src-tauri/src/audio_toolkit/audio/recorder.rs index c3f23adbb..22ea5bbb6 100644 --- a/src-tauri/src/audio_toolkit/audio/recorder.rs +++ b/src-tauri/src/audio_toolkit/audio/recorder.rs @@ -1,5 +1,5 @@ use std::{ - io::Error, + io::{Error, ErrorKind}, sync::{mpsc, Arc, Mutex}, time::Duration, }; @@ -129,18 +129,28 @@ impl AudioRecorder { } pub fn start(&self) -> Result<(), Box> { - if let Some(tx) = &self.cmd_tx { - tx.send(Cmd::Start)?; - } + let tx = self + .cmd_tx + .as_ref() + .ok_or_else(|| Error::new(std::io::ErrorKind::NotConnected, "AudioRecorder not open"))?; + tx.send(Cmd::Start)?; Ok(()) } pub fn stop(&self) -> Result, Box> { let (resp_tx, resp_rx) = mpsc::channel(); - if let Some(tx) = &self.cmd_tx { - tx.send(Cmd::Stop(resp_tx))?; - } - Ok(resp_rx.recv()?) // wait for the samples + self.cmd_tx + .as_ref() + .ok_or_else(|| Error::new(ErrorKind::NotConnected, "AudioRecorder not open"))? + .send(Cmd::Stop(resp_tx))?; + + resp_rx.recv_timeout(Duration::from_secs(5)).map_err(|e| { + let (kind, msg) = match e { + mpsc::RecvTimeoutError::Timeout => (ErrorKind::TimedOut, "Stop timed out"), + mpsc::RecvTimeoutError::Disconnected => (ErrorKind::BrokenPipe, "Worker disconnected"), + }; + Box::new(Error::new(kind, msg)) as _ + }) } pub fn close(&mut self) -> Result<(), Box> { @@ -287,31 +297,15 @@ fn run_consumer( } } - loop { - let raw = match sample_rx.recv() { - Ok(s) => s, - Err(_) => break, // stream closed - }; + let mut samples_disconnected = false; - // ---------- spectrum processing ---------------------------------- // - if let Some(buckets) = visualizer.feed(&raw) { - if let Some(cb) = &level_cb { - cb(buckets); - } - } - - // ---------- existing pipeline ------------------------------------ // - frame_resampler.push(&raw, &mut |frame: &[f32]| { - handle_frame(frame, recording, &vad, &mut processed_samples) - }); - - // non-blocking check for a command + loop { while let Ok(cmd) = cmd_rx.try_recv() { match cmd { Cmd::Start => { processed_samples.clear(); recording = true; - visualizer.reset(); // Reset visualization buffer + visualizer.reset(); if let Some(v) = &vad { v.lock().unwrap().reset(); } @@ -320,7 +314,6 @@ fn run_consumer( recording = false; frame_resampler.finish(&mut |frame: &[f32]| { - // we still want to process the last few frames handle_frame(frame, true, &vad, &mut processed_samples) }); @@ -329,5 +322,50 @@ fn run_consumer( Cmd::Shutdown => return, } } + + if samples_disconnected { + match cmd_rx.recv() { + Ok(cmd) => { + match cmd { + Cmd::Start => { + processed_samples.clear(); + recording = true; + visualizer.reset(); + if let Some(v) = &vad { + v.lock().unwrap().reset(); + } + } + Cmd::Stop(reply_tx) => { + recording = false; + frame_resampler.finish(&mut |frame: &[f32]| { + handle_frame(frame, true, &vad, &mut processed_samples) + }); + let _ = reply_tx.send(std::mem::take(&mut processed_samples)); + } + Cmd::Shutdown => return, + } + } + Err(_) => return, + } + continue; + } + + match sample_rx.recv_timeout(Duration::from_millis(50)) { + Ok(raw) => { + if let Some(buckets) = visualizer.feed(&raw) { + if let Some(cb) = &level_cb { + cb(buckets); + } + } + + frame_resampler.push(&raw, &mut |frame: &[f32]| { + handle_frame(frame, recording, &vad, &mut processed_samples) + }); + } + Err(mpsc::RecvTimeoutError::Timeout) => {} + Err(mpsc::RecvTimeoutError::Disconnected) => { + samples_disconnected = true; + } + } } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 556dd3a12..ca97cb320 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -83,6 +83,7 @@ fn build_console_filter() -> env_filter::Filter { struct ShortcutToggleStates { // Map: shortcut_binding_id -> is_active active_toggles: HashMap, + last_trigger_ms: HashMap, } type ManagedToggleState = Mutex; diff --git a/src-tauri/src/managers/audio.rs b/src-tauri/src/managers/audio.rs index 0add01fcf..5686d1780 100644 --- a/src-tauri/src/managers/audio.rs +++ b/src-tauri/src/managers/audio.rs @@ -215,9 +215,11 @@ impl AudioRecordingManager { /// 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(); + let is_open = *self.is_open.lock().unwrap(); + let is_recording = *self.is_recording.lock().unwrap(); - if settings.mute_while_recording && *self.is_open.lock().unwrap() { + if settings.mute_while_recording && is_open && is_recording { + let mut did_mute_guard = self.did_mute.lock().unwrap(); set_mute(true); *did_mute_guard = true; debug!("Mute applied"); @@ -287,20 +289,24 @@ impl AudioRecordingManager { return; } - let mut did_mute_guard = self.did_mute.lock().unwrap(); - if *did_mute_guard { - set_mute(false); - } - *did_mute_guard = false; + let mut recorder_guard = self.recorder.lock().unwrap(); + let mut is_recording_guard = self.is_recording.lock().unwrap(); - if let Some(rec) = self.recorder.lock().unwrap().as_mut() { - // If still recording, stop first. - if *self.is_recording.lock().unwrap() { + if *is_recording_guard { + if let Some(rec) = recorder_guard.as_mut() { let _ = rec.stop(); - *self.is_recording.lock().unwrap() = false; } + *is_recording_guard = false; + } + + if let Some(rec) = recorder_guard.as_mut() { let _ = rec.close(); } + let mut did_mute_guard = self.did_mute.lock().unwrap(); + if *did_mute_guard { + set_mute(false); + } + *did_mute_guard = false; *open_flag = false; debug!("Microphone stream stopped"); @@ -344,8 +350,16 @@ impl AudioRecordingManager { } } - if let Some(rec) = self.recorder.lock().unwrap().as_ref() { - if rec.start().is_ok() { + let start_result = { + let recorder_guard = self.recorder.lock().unwrap(); + match recorder_guard.as_ref() { + Some(rec) => rec.start().map_err(|e| anyhow::anyhow!("{e}")), + None => Err(anyhow::anyhow!("Recorder not available")), + } + }; + + match start_result { + Ok(()) => { *self.is_recording.lock().unwrap() = true; *state = RecordingState::Recording { binding_id: binding_id.to_string(), @@ -353,9 +367,18 @@ impl AudioRecordingManager { debug!("Recording started for binding {binding_id}"); return true; } + Err(e) => { + error!("Failed to start recorder: {e}"); + drop(state); + self.stop_microphone_stream(); + if matches!(*self.mode.lock().unwrap(), MicrophoneMode::AlwaysOn) { + if let Err(e) = self.start_microphone_stream() { + error!("Failed to restart microphone stream after start failure: {e}"); + } + } + return false; + } } - error!("Recorder not available"); - false } else { false } @@ -380,17 +403,28 @@ impl AudioRecordingManager { *state = RecordingState::Idle; drop(state); - let samples = if let Some(rec) = self.recorder.lock().unwrap().as_ref() { - match rec.stop() { - Ok(buf) => buf, - Err(e) => { - error!("stop() failed: {e}"); - Vec::new() + let stop_result = { + let recorder_guard = self.recorder.lock().unwrap(); + match recorder_guard.as_ref() { + Some(rec) => rec.stop().map_err(|e| anyhow::anyhow!("{e}")), + None => Err(anyhow::anyhow!("Recorder not available")), + } + }; + + let samples = match stop_result { + Ok(buf) => buf, + Err(e) => { + error!("stop() failed: {e}"); + self.stop_microphone_stream(); + if matches!(*self.mode.lock().unwrap(), MicrophoneMode::AlwaysOn) { + if let Err(e) = self.start_microphone_stream() { + error!( + "Failed to restart microphone stream after stop failure: {e}" + ); + } } + Vec::new() } - } else { - error!("Recorder not available"); - Vec::new() }; *self.is_recording.lock().unwrap() = false; @@ -429,8 +463,22 @@ impl AudioRecordingManager { *state = RecordingState::Idle; drop(state); - if let Some(rec) = self.recorder.lock().unwrap().as_ref() { - let _ = rec.stop(); // Discard the result + let stop_result = { + let recorder_guard = self.recorder.lock().unwrap(); + match recorder_guard.as_ref() { + Some(rec) => rec.stop().map_err(|e| anyhow::anyhow!("{e}")), + None => Err(anyhow::anyhow!("Recorder not available")), + } + }; + + if let Err(e) = stop_result { + error!("cancel_recording stop() failed: {e}"); + self.stop_microphone_stream(); + if matches!(*self.mode.lock().unwrap(), MicrophoneMode::AlwaysOn) { + if let Err(e) = self.start_microphone_stream() { + error!("Failed to restart microphone stream after cancel failure: {e}"); + } + } } *self.is_recording.lock().unwrap() = false; diff --git a/src-tauri/src/shortcut/handler.rs b/src-tauri/src/shortcut/handler.rs index c00fb47b5..d3be8319c 100644 --- a/src-tauri/src/shortcut/handler.rs +++ b/src-tauri/src/shortcut/handler.rs @@ -5,6 +5,7 @@ use log::warn; use std::sync::Arc; +use std::time::SystemTime; use tauri::{AppHandle, Manager}; use crate::actions::ACTION_MAP; @@ -68,17 +69,39 @@ pub fn handle_shortcut_event( let should_start: bool; { let toggle_state_manager = app.state::(); - let mut states = toggle_state_manager - .lock() - .expect("Failed to lock toggle state manager"); + let mut states = match toggle_state_manager.lock() { + Ok(s) => s, + Err(e) => { + warn!("Failed to lock toggle state manager: {e}"); + return; + } + }; let is_currently_active = states .active_toggles - .entry(binding_id.to_string()) - .or_insert(false); + .get(binding_id) + .copied() + .unwrap_or(false); - should_start = !*is_currently_active; - *is_currently_active = should_start; + should_start = !is_currently_active; + + if should_start { + let now_ms = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map_or(0, |d| d.as_millis() as u64); + let last_ms = states.last_trigger_ms.get(binding_id).copied().unwrap_or(0); + const DEBOUNCE_MS: u64 = 250; + if now_ms.saturating_sub(last_ms) < DEBOUNCE_MS { + return; + } + states + .last_trigger_ms + .insert(binding_id.to_string(), now_ms); + } + + states + .active_toggles + .insert(binding_id.to_string(), should_start); } // Lock released here // Now call the action without holding the lock