From 5760096df17c2b56d588b14dc524e06f0b1dd90c Mon Sep 17 00:00:00 2001 From: GalaxyRuler Date: Thu, 9 Jul 2026 13:07:06 +0300 Subject: [PATCH 01/20] fix(transcription): harden phase 1 reliability paths --- src-tauri/src/actions.rs | 35 +- src-tauri/src/llm_client.rs | 174 +++++++- src-tauri/src/managers/transcription.rs | 475 ++++++++++++++++++--- src-tauri/src/runtime_settings.rs | 10 + src-tauri/src/shortcut/handler.rs | 2 +- src-tauri/src/signal_handle.rs | 5 +- src-tauri/src/text_processing.rs | 13 +- src-tauri/src/transcription_coordinator.rs | 281 +++++++++++- 8 files changed, 892 insertions(+), 103 deletions(-) diff --git a/src-tauri/src/actions.rs b/src-tauri/src/actions.rs index ea9e65de..efc26e05 100644 --- a/src-tauri/src/actions.rs +++ b/src-tauri/src/actions.rs @@ -51,11 +51,11 @@ struct LanguageGuardEvent { /// Drop guard that notifies the [`TranscriptionCoordinator`] when the /// transcription pipeline finishes — whether it completes normally or panics. -struct FinishGuard(AppHandle); +struct FinishGuard(AppHandle, u64); impl Drop for FinishGuard { fn drop(&mut self) { if let Some(c) = self.0.try_state::() { - c.notify_processing_finished(); + c.notify_processing_finished(self.1); } } } @@ -63,7 +63,7 @@ impl Drop for FinishGuard { // Shortcut Action Trait pub trait ShortcutAction: Send + Sync { fn start(&self, app: &AppHandle, binding_id: &str, shortcut_str: &str); - fn stop(&self, app: &AppHandle, binding_id: &str, shortcut_str: &str); + fn stop(&self, app: &AppHandle, binding_id: &str, shortcut_str: &str, generation: u64); } // Transcribe Action @@ -483,7 +483,8 @@ async fn post_process_with_managed_local_llm( return None; } - match crate::llm_client::send_chat_completion_with_schema( + let provider_cancellation = operation_token.map(|token| token.provider_cancellation()); + match crate::llm_client::send_chat_completion_with_schema_and_cancellation( &endpoint.provider, String::new(), &endpoint.model, @@ -492,6 +493,7 @@ async fn post_process_with_managed_local_llm( None, None, None, + provider_cancellation.as_ref(), ) .await { @@ -569,6 +571,8 @@ async fn post_process_transcription( return None; } + let provider_cancellation = operation_token.map(|token| token.provider_cancellation()); + if provider.supports_structured_output { debug!("Using structured outputs for provider '{}'", provider.id); @@ -626,11 +630,12 @@ async fn post_process_transcription( "The cleaned and processed transcription text", ); - match crate::text_processing::send_text_provider_request( + match crate::text_processing::send_text_provider_request_with_cancellation( &provider, api_key.clone(), &model, request, + provider_cancellation.as_ref(), ) .await { @@ -683,8 +688,14 @@ async fn post_process_transcription( return None; } - match crate::text_processing::send_text_provider_request(&provider, api_key, &model, request) - .await + match crate::text_processing::send_text_provider_request_with_cancellation( + &provider, + api_key, + &model, + request, + provider_cancellation.as_ref(), + ) + .await { Ok(Some(content)) => { let content = crate::text_processing::strip_invisible_chars(&content); @@ -1679,7 +1690,7 @@ impl ShortcutAction for TranscribeAction { ); } - fn stop(&self, app: &AppHandle, binding_id: &str, _shortcut_str: &str) { + fn stop(&self, app: &AppHandle, binding_id: &str, _shortcut_str: &str, generation: u64) { // Unregister the cancel shortcut when transcription stops shortcut::unregister_cancel_shortcut(app); @@ -1705,7 +1716,7 @@ impl ShortcutAction for TranscribeAction { let operation_token = current_or_new_operation_token(app); tauri::async_runtime::spawn(async move { - let _guard = FinishGuard(ah.clone()); + let _guard = FinishGuard(ah.clone(), generation); debug!( "Starting async transcription task for binding: {}", binding_id @@ -2076,7 +2087,7 @@ impl ShortcutAction for CancelAction { } } - fn stop(&self, _app: &AppHandle, _binding_id: &str, _shortcut_str: &str) { + fn stop(&self, _app: &AppHandle, _binding_id: &str, _shortcut_str: &str, _generation: u64) { // Nothing to do on stop for cancel } } @@ -2117,7 +2128,7 @@ impl ShortcutAction for TransformShortcutAction { }); } - fn stop(&self, _app: &AppHandle, _binding_id: &str, _shortcut_str: &str) { + fn stop(&self, _app: &AppHandle, _binding_id: &str, _shortcut_str: &str, _generation: u64) { // Transform shortcuts run once on key press. } } @@ -2135,7 +2146,7 @@ impl ShortcutAction for TestAction { ); } - fn stop(&self, app: &AppHandle, binding_id: &str, shortcut_str: &str) { + fn stop(&self, app: &AppHandle, binding_id: &str, shortcut_str: &str, _generation: u64) { log::info!( "Shortcut ID '{}': Stopped - {} (App: {})", // Changed "Released" to "Stopped" for consistency binding_id, diff --git a/src-tauri/src/llm_client.rs b/src-tauri/src/llm_client.rs index 10c1fe0c..6fb32c73 100644 --- a/src-tauri/src/llm_client.rs +++ b/src-tauri/src/llm_client.rs @@ -1,8 +1,15 @@ +use crate::providers::CancellationToken; use crate::settings::PostProcessProvider; use log::debug; use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE, REFERER, USER_AGENT}; use serde::{Deserialize, Serialize}; use serde_json::Value; +use std::time::Duration; + +/// Post-processing must never hold a transcript hostage: reqwest's default +/// is no timeout, so a stalled provider would hang the pipeline forever. +const LLM_CONNECT_TIMEOUT_SECS: u64 = 10; +const LLM_REQUEST_TIMEOUT_SECS: u64 = 120; #[derive(Debug, Serialize)] struct ChatMessage { @@ -98,9 +105,25 @@ fn build_headers(provider: &PostProcessProvider, api_key: &str) -> Result
Result { + create_client_with_timeouts( + provider, + api_key, + Duration::from_secs(LLM_CONNECT_TIMEOUT_SECS), + Duration::from_secs(LLM_REQUEST_TIMEOUT_SECS), + ) +} + +fn create_client_with_timeouts( + provider: &PostProcessProvider, + api_key: &str, + connect_timeout: Duration, + request_timeout: Duration, +) -> Result { let headers = build_headers(provider, api_key)?; reqwest::Client::builder() .default_headers(headers) + .connect_timeout(connect_timeout) + .timeout(request_timeout) .build() .map_err(|e| format!("Failed to build HTTP client: {}", e)) } @@ -161,9 +184,44 @@ async fn read_json_response( .map_err(|e| format!("Failed to parse {} response: {}", failure_context, e)) } +async fn send_http_request( + request: reqwest::RequestBuilder, + failure_context: &str, + cancellation: Option<&CancellationToken>, +) -> Result { + let Some(cancellation) = cancellation else { + return request + .send() + .await + .map_err(|e| format!("{}: {}", failure_context, e)); + }; + + if cancellation.is_cancelled() { + return Err("post-processing cancelled".to_string()); + } + + let request = request.send(); + tokio::pin!(request); + let mut cancel_poll = tokio::time::interval(Duration::from_millis(100)); + + loop { + tokio::select! { + response = &mut request => { + return response.map_err(|e| format!("{}: {}", failure_context, e)); + } + _ = cancel_poll.tick() => { + if cancellation.is_cancelled() { + return Err("post-processing cancelled".to_string()); + } + } + } + } +} + /// Send a chat completion request to an OpenAI-compatible API /// Returns Ok(Some(content)) on success, Ok(None) if response has no content, /// or Err on actual errors (HTTP, parsing, etc.) +#[cfg_attr(not(test), allow(dead_code))] pub async fn send_chat_completion( provider: &PostProcessProvider, api_key: String, @@ -199,6 +257,31 @@ pub async fn send_chat_completion_with_schema( json_schema: Option, reasoning_effort: Option, reasoning: Option, +) -> Result, String> { + send_chat_completion_with_schema_and_cancellation( + provider, + api_key, + model, + user_content, + system_prompt, + json_schema, + reasoning_effort, + reasoning, + None, + ) + .await +} + +pub async fn send_chat_completion_with_schema_and_cancellation( + provider: &PostProcessProvider, + api_key: String, + model: &str, + user_content: String, + system_prompt: Option, + json_schema: Option, + reasoning_effort: Option, + reasoning: Option, + cancellation: Option<&CancellationToken>, ) -> Result, String> { let client = create_client(provider, &api_key)?; @@ -243,12 +326,12 @@ pub async fn send_chat_completion_with_schema( for (index, url) in urls.iter().enumerate() { debug!("Sending chat completion request to: {}", url); - let response = client - .post(url) - .json(&request_body) - .send() - .await - .map_err(|e| format!("HTTP request failed: {}", e))?; + let response = send_http_request( + client.post(url).json(&request_body), + "HTTP request failed", + cancellation, + ) + .await?; let parsed = read_json_response(response, "API request").await?; if let Some(message) = response_error_message(&parsed) { @@ -289,11 +372,7 @@ pub async fn fetch_models( for (index, url) in urls.iter().enumerate() { debug!("Fetching models from: {}", url); - let response = client - .get(url) - .send() - .await - .map_err(|e| format!("Failed to fetch models: {}", e))?; + let response = send_http_request(client.get(url), "Failed to fetch models", None).await?; let parsed = read_json_response(response, "Model list request").await?; if let Some(message) = response_error_message(&parsed) { @@ -342,6 +421,7 @@ mod tests { use std::io::{Read, Write}; use std::net::TcpListener; use std::thread; + use std::time::{Duration, Instant}; fn provider(base_url: String) -> PostProcessProvider { PostProcessProvider { @@ -388,6 +468,78 @@ mod tests { .expect("write response"); } + #[tokio::test] + async fn client_times_out_on_stalled_server() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server"); + let addr = listener.local_addr().expect("local addr"); + let (shutdown_tx, shutdown_rx) = std::sync::mpsc::channel::<()>(); + let server = thread::spawn(move || { + let _held = listener.accept().ok(); + let _ = shutdown_rx.recv(); + }); + + let client = create_client_with_timeouts( + &provider(format!("http://{addr}")), + "test-key", + Duration::from_secs(1), + Duration::from_secs(2), + ) + .expect("client"); + + let started = Instant::now(); + let result = client.get(format!("http://{addr}/v1/models")).send().await; + + assert!( + result.is_err(), + "stalled server must produce a timeout error" + ); + assert!(started.elapsed() >= Duration::from_secs(1)); + assert!(started.elapsed() < Duration::from_secs(LLM_REQUEST_TIMEOUT_SECS + 10)); + + let _ = shutdown_tx.send(()); + server.join().expect("server thread"); + } + + #[tokio::test] + async fn chat_completion_cancellation_aborts_stalled_request() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server"); + let addr = listener.local_addr().expect("local addr"); + let (shutdown_tx, shutdown_rx) = std::sync::mpsc::channel::<()>(); + let server = thread::spawn(move || { + let _held = listener.accept().ok(); + let _ = shutdown_rx.recv(); + }); + + let cancellation = crate::providers::CancellationToken::default(); + let cancellation_for_task = cancellation.clone(); + let cancel_task = tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(200)).await; + cancellation_for_task.cancel(); + }); + + let started = Instant::now(); + let error = send_chat_completion_with_schema_and_cancellation( + &provider(format!("http://{addr}/v1")), + String::new(), + "test-model", + "Clean this transcript".to_string(), + None, + None, + None, + None, + Some(&cancellation), + ) + .await + .expect_err("cancelled request should return an error"); + + cancel_task.await.expect("cancel task"); + assert!(error.contains("cancelled")); + assert!(started.elapsed() < Duration::from_secs(2)); + + let _ = shutdown_tx.send(()); + server.join().expect("server thread"); + } + #[tokio::test] async fn chat_completion_http_failure_does_not_return_response_body() { let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server"); diff --git a/src-tauri/src/managers/transcription.rs b/src-tauri/src/managers/transcription.rs index 0775bb1a..e883e4a8 100644 --- a/src-tauri/src/managers/transcription.rs +++ b/src-tauri/src/managers/transcription.rs @@ -2,8 +2,8 @@ use crate::audio_toolkit::{apply_dictionary_entries, filter_transcription_output use crate::managers::audio::AudioRecordingManager; use crate::managers::model::{EngineType, ModelManager}; use crate::providers::{ - resolve_whisper_gpu_device, CancellationToken, EngineProvider, ModelLocator, - TranscribeRsProvider, + resolve_whisper_gpu_device, CancellationToken, EngineProvider, ModelLocator, SpeechInput, + SpeechResponse, TranscribeRsProvider, }; use crate::settings::{ get_settings, AppSettings, ModelUnloadTimeout, OrtAcceleratorSetting, WhisperAcceleratorSetting, @@ -12,13 +12,14 @@ use anyhow::Result; use log::{debug, error, info, warn}; use serde::Serialize; use specta::Type; +use std::any::Any; use std::collections::HashSet; use std::io::Read; use std::panic::{catch_unwind, AssertUnwindSafe}; use std::path::Path; use std::process::{Command, Stdio}; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::{Arc, Condvar, Mutex, MutexGuard, OnceLock}; +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicU8, AtomicUsize, Ordering}; +use std::sync::{mpsc, Arc, Condvar, Mutex, MutexGuard, OnceLock}; use std::thread; use std::time::{Duration, Instant, SystemTime}; use tauri::{AppHandle, Emitter, Manager}; @@ -66,6 +67,86 @@ fn engine_label(engine_type: EngineType) -> &'static str { } } +const MAX_INFERENCE_TIMEOUT: Duration = Duration::from_secs(240); +const MODEL_LOAD_DEADLINE: Duration = Duration::from_secs(120); +static ABANDONED_INFERENCE_THREADS: AtomicUsize = AtomicUsize::new(0); + +const RUN_ACTIVE: u8 = 0; +const RUN_ABANDONED: u8 = 1; +const RUN_EXITED: u8 = 2; + +/// 60s base + 3x realtime headroom, capped. Slow CPU + large model safe. +fn inference_timeout(sample_count: usize) -> Duration { + let audio_secs = sample_count as u64 / 16_000; + Duration::from_secs(60 + 3 * audio_secs).min(MAX_INFERENCE_TIMEOUT) +} + +fn new_inference_run_state() -> Arc { + Arc::new(AtomicU8::new(RUN_ACTIVE)) +} + +fn abandoned_inference_thread_count() -> usize { + ABANDONED_INFERENCE_THREADS.load(Ordering::SeqCst) +} + +fn engine_wedged_restart_required() -> bool { + abandoned_inference_thread_count() >= 1 +} + +fn panic_payload_message(panic_payload: &(dyn Any + Send)) -> String { + if let Some(s) = panic_payload.downcast_ref::<&str>() { + s.to_string() + } else if let Some(s) = panic_payload.downcast_ref::() { + s.clone() + } else { + "unknown panic".to_string() + } +} + +fn mark_inference_run_abandoned(run_state: &AtomicU8) -> bool { + if run_state + .compare_exchange( + RUN_ACTIVE, + RUN_ABANDONED, + Ordering::SeqCst, + Ordering::SeqCst, + ) + .is_ok() + { + ABANDONED_INFERENCE_THREADS.fetch_add(1, Ordering::SeqCst); + true + } else { + false + } +} + +fn mark_inference_run_exited(run_state: &AtomicU8) { + if run_state.swap(RUN_EXITED, Ordering::SeqCst) == RUN_ABANDONED { + ABANDONED_INFERENCE_THREADS.fetch_sub(1, Ordering::SeqCst); + } +} + +struct InferenceRunExitGuard { + run_state: Arc, +} + +impl InferenceRunExitGuard { + fn new(run_state: Arc) -> Self { + Self { run_state } + } +} + +impl Drop for InferenceRunExitGuard { + fn drop(&mut self) { + mark_inference_run_exited(&self.run_state); + } +} + +#[cfg(test)] +fn reset_abandoned_inference_threads_for_test() { + ABANDONED_INFERENCE_THREADS.store(0, Ordering::SeqCst); +} + fn should_retry_model_load_on_cpu( settings: &AppSettings, engine_type: EngineType, @@ -407,16 +488,38 @@ fn wait_for_model_loading_to_finish( is_loading: &Mutex, loading_condvar: &Condvar, cancellation: &CancellationToken, +) -> Result<()> { + wait_for_model_loading_to_finish_with_deadline( + is_loading, + loading_condvar, + cancellation, + MODEL_LOAD_DEADLINE, + ) +} + +fn wait_for_model_loading_to_finish_with_deadline( + is_loading: &Mutex, + loading_condvar: &Condvar, + cancellation: &CancellationToken, + deadline: Duration, ) -> Result<()> { if cancellation.is_cancelled() { return Err(anyhow::anyhow!("transcription cancelled before model load")); } - let mut is_loading = is_loading.lock().unwrap(); + let started = Instant::now(); + let mut is_loading = is_loading.lock().unwrap_or_else(|e| e.into_inner()); while *is_loading { + if started.elapsed() > deadline { + return Err(anyhow::anyhow!( + "model load timed out after {}s", + deadline.as_secs() + )); + } + let wait_result = loading_condvar .wait_timeout(is_loading, Duration::from_millis(50)) - .unwrap(); + .unwrap_or_else(|e| e.into_inner()); is_loading = wait_result.0; if cancellation.is_cancelled() { @@ -427,6 +530,16 @@ fn wait_for_model_loading_to_finish( Ok(()) } +fn model_not_loaded_for_transcription_error(last_load_error: Option) -> anyhow::Error { + match last_load_error { + Some(error) if !error.is_empty() => anyhow::anyhow!( + "Model is not loaded for transcription. Last model load error: {}", + error + ), + _ => anyhow::anyhow!("Model is not loaded for transcription."), + } +} + #[derive(Clone)] pub struct TranscriptionManager { engine: Arc>>, @@ -438,6 +551,7 @@ pub struct TranscriptionManager { watcher_handle: Arc>>>, is_loading: Arc>, loading_condvar: Arc, + last_load_error: Arc>>, } impl TranscriptionManager { @@ -452,6 +566,7 @@ impl TranscriptionManager { watcher_handle: Arc::new(Mutex::new(None)), is_loading: Arc::new(Mutex::new(false)), loading_condvar: Arc::new(Condvar::new()), + last_load_error: Arc::new(Mutex::new(None)), }; // Start the idle watcher @@ -595,6 +710,162 @@ impl TranscriptionManager { Ok(()) } + fn engine_wedged_restart_required_error(&self) -> anyhow::Error { + let message = "A previous transcription timed out and the native inference engine is still running. Restart Verbatim to recover."; + let _ = self.app_handle.emit( + "model-state-changed", + ModelStateEvent { + event_type: "loading_failed".to_string(), + model_id: None, + model_name: None, + error: Some(message.to_string()), + diagnostic_code: Some("engine_wedged_restart_required".to_string()), + fallback: Some("restart_required".to_string()), + }, + ); + anyhow::anyhow!(message) + } + + fn refuse_if_engine_wedged(&self) -> Result<()> { + if engine_wedged_restart_required() { + Err(self.engine_wedged_restart_required_error()) + } else { + Ok(()) + } + } + + fn set_last_load_error(&self, error: String) { + let mut last_load_error = self + .last_load_error + .lock() + .unwrap_or_else(|e| e.into_inner()); + *last_load_error = Some(error); + } + + fn clear_last_load_error(&self) { + let mut last_load_error = self + .last_load_error + .lock() + .unwrap_or_else(|e| e.into_inner()); + *last_load_error = None; + } + + fn last_load_error(&self) -> Option { + self.last_load_error + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone() + } + + fn model_not_loaded_error(&self) -> anyhow::Error { + model_not_loaded_for_transcription_error(self.last_load_error()) + } + + fn clear_current_model_id(&self) { + let mut current_model = self + .current_model_id + .lock() + .unwrap_or_else(|e| e.into_inner()); + *current_model = None; + } + + fn inference_timeout_error(&self, timeout: Duration, sample_count: usize) -> anyhow::Error { + self.clear_current_model_id(); + + let message = format!( + "Transcription timed out after {}s for {} audio samples. Restart Verbatim if the engine remains busy.", + timeout.as_secs(), + sample_count + ); + error!("{}", message); + + let _ = self.app_handle.emit( + "model-state-changed", + ModelStateEvent { + event_type: "unloaded".to_string(), + model_id: None, + model_name: None, + error: Some(message.clone()), + diagnostic_code: Some("inference_timeout".to_string()), + fallback: Some("model_unloaded_for_reload".to_string()), + }, + ); + + anyhow::anyhow!(message) + } + + fn inference_worker_exited_error(&self) -> anyhow::Error { + self.clear_current_model_id(); + + let message = "Transcription worker exited before returning a result. The model has been unloaded and will reload on next attempt."; + error!("{}", message); + + let _ = self.app_handle.emit( + "model-state-changed", + ModelStateEvent { + event_type: "unloaded".to_string(), + model_id: None, + model_name: None, + error: Some(message.to_string()), + diagnostic_code: Some("inference_worker_exited".to_string()), + fallback: Some("model_unloaded_for_reload".to_string()), + }, + ); + + anyhow::anyhow!(message) + } + + fn provider_panic_error( + &self, + mut provider: TranscribeRsProvider, + panic_payload: Box, + ) -> anyhow::Error { + // Provider panicked — do NOT put it back (it's in an unknown state). + // The provider is unloaded and dropped here. + provider.unload(); + + let panic_msg = panic_payload_message(panic_payload.as_ref()); + error!( + "Transcription engine panicked: {}. Model has been unloaded.", + panic_msg + ); + + self.clear_current_model_id(); + + let _ = self.app_handle.emit( + "model-state-changed", + ModelStateEvent { + event_type: "unloaded".to_string(), + model_id: None, + model_name: None, + error: Some(format!("Engine panicked: {}", panic_msg)), + diagnostic_code: Some("provider_panic".to_string()), + fallback: Some("model_unloaded_for_reload".to_string()), + }, + ); + + anyhow::anyhow!( + "Transcription engine panicked: {}. The model has been unloaded and will reload on next attempt.", + panic_msg + ) + } + + fn complete_inference_result( + &self, + provider: TranscribeRsProvider, + transcribe_result: thread::Result>, + ) -> Result { + match transcribe_result { + Ok(inner_result) => { + // Success or normal error — put the provider back. + let mut engine_guard = self.lock_engine(); + *engine_guard = Some(provider); + inner_result + } + Err(panic_payload) => Err(self.provider_panic_error(provider, panic_payload)), + } + } + fn now_ms() -> u64 { SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) @@ -621,6 +892,9 @@ impl TranscriptionManager { } pub fn load_model(&self, model_id: &str) -> Result<()> { + self.refuse_if_engine_wedged()?; + self.clear_last_load_error(); + let load_start = std::time::Instant::now(); debug!("Starting to load model: {}", model_id); @@ -817,7 +1091,10 @@ impl TranscriptionManager { thread::spawn(move || { let settings = get_settings(&self_clone.app_handle); if let Err(e) = self_clone.load_model(&settings.selected_model) { + self_clone.set_last_load_error(e.to_string()); error!("Failed to load model: {}", e); + } else { + self_clone.clear_last_load_error(); } let mut is_loading = self_clone.is_loading.lock().unwrap(); *is_loading = false; @@ -846,6 +1123,8 @@ impl TranscriptionManager { )); } + self.refuse_if_engine_wedged()?; + // Update last activity timestamp self.touch_activity(); @@ -870,7 +1149,7 @@ impl TranscriptionManager { let engine_guard = self.lock_engine(); if engine_guard.is_none() { - return Err(anyhow::anyhow!("Model is not loaded for transcription.")); + return Err(self.model_not_loaded_error()); } } @@ -910,6 +1189,11 @@ impl TranscriptionManager { &settings.dictionary_phrases(), cancellation, ); + let sample_count = match &request.input { + SpeechInput::Audio(audio) => audio.len(), + SpeechInput::Text(_) => 0, + }; + let timeout = inference_timeout(sample_count); // Perform transcription with the appropriate provider. // We use catch_unwind to prevent engine panics from poisoning the mutex, @@ -920,7 +1204,7 @@ impl TranscriptionManager { // Take the provider out so we own it during transcription. // If the provider panics, we simply don't put it back (effectively unloading it) // instead of poisoning the mutex. - let mut provider = match engine_guard.take() { + let provider = match engine_guard.take() { Some(provider) => provider, None => { return Err(anyhow::anyhow!( @@ -932,58 +1216,49 @@ impl TranscriptionManager { // Release the lock before transcribing — no mutex held during the engine call drop(engine_guard); - let transcribe_result = catch_unwind(AssertUnwindSafe( - || -> Result { provider.run(request) }, - )); + let (result_tx, result_rx) = mpsc::channel(); + let run_state = new_inference_run_state(); + let worker_run_state = Arc::clone(&run_state); + let inference_thread = thread::Builder::new() + .name("verbatim-inference".to_string()) + .spawn(move || { + let _exit_guard = InferenceRunExitGuard::new(worker_run_state); + let mut provider = provider; + let transcribe_result = + catch_unwind(AssertUnwindSafe(|| -> Result { + provider.run(request) + })); + let _ = result_tx.send((provider, transcribe_result)); + }) + .map_err(|e| { + self.clear_current_model_id(); + anyhow::anyhow!( + "Failed to start inference worker: {}. The model has been unloaded and will reload on next attempt.", + e + ) + })?; - match transcribe_result { - Ok(inner_result) => { - // Success or normal error — put the provider back - let mut engine_guard = self.lock_engine(); - *engine_guard = Some(provider); - inner_result? + match result_rx.recv_timeout(timeout) { + Ok((provider, transcribe_result)) => { + let _ = inference_thread.join(); + self.complete_inference_result(provider, transcribe_result)? } - Err(panic_payload) => { - // Provider panicked — do NOT put it back (it's in an unknown state). - // The provider is unloaded and dropped here. - provider.unload(); - let panic_msg = if let Some(s) = panic_payload.downcast_ref::<&str>() { - s.to_string() - } else if let Some(s) = panic_payload.downcast_ref::() { - s.clone() - } else { - "unknown panic".to_string() - }; - error!( - "Transcription engine panicked: {}. Model has been unloaded.", - panic_msg - ); - - // Clear the model ID so it will be reloaded on next attempt - { - let mut current_model = self - .current_model_id - .lock() - .unwrap_or_else(|e| e.into_inner()); - *current_model = None; + Err(mpsc::RecvTimeoutError::Timeout) => { + if mark_inference_run_abandoned(&run_state) { + return Err(self.inference_timeout_error(timeout, sample_count)); } - let _ = self.app_handle.emit( - "model-state-changed", - ModelStateEvent { - event_type: "unloaded".to_string(), - model_id: None, - model_name: None, - error: Some(format!("Engine panicked: {}", panic_msg)), - diagnostic_code: Some("provider_panic".to_string()), - fallback: Some("model_unloaded_for_reload".to_string()), - }, - ); - - return Err(anyhow::anyhow!( - "Transcription engine panicked: {}. The model has been unloaded and will reload on next attempt.", - panic_msg - )); + match result_rx.recv() { + Ok((provider, transcribe_result)) => { + let _ = inference_thread.join(); + self.complete_inference_result(provider, transcribe_result)? + } + Err(_) => return Err(self.inference_worker_exited_error()), + } + } + Err(mpsc::RecvTimeoutError::Disconnected) => { + let _ = inference_thread.join(); + return Err(self.inference_worker_exited_error()); } } }; @@ -1206,6 +1481,66 @@ mod tests { assert_eq!(validate_selected_language("ar", &[]), "ar"); } + #[test] + fn inference_timeout_is_proportional_to_audio_length() { + assert_eq!( + inference_timeout(30 * 16_000), + Duration::from_secs(60 + 3 * 30) + ); + assert_eq!(inference_timeout(160), Duration::from_secs(60)); + assert_eq!(inference_timeout(3_600 * 16_000), MAX_INFERENCE_TIMEOUT); + } + + #[test] + fn abandoned_inference_counter_tracks_timeout_then_return() { + reset_abandoned_inference_threads_for_test(); + let run_state = new_inference_run_state(); + + assert!(mark_inference_run_abandoned(&run_state)); + assert_eq!(abandoned_inference_thread_count(), 1); + + mark_inference_run_exited(&run_state); + assert_eq!(abandoned_inference_thread_count(), 0); + } + + #[test] + fn abandoned_inference_counter_ignores_return_before_timeout() { + reset_abandoned_inference_threads_for_test(); + let run_state = new_inference_run_state(); + + mark_inference_run_exited(&run_state); + + assert!(!mark_inference_run_abandoned(&run_state)); + assert_eq!(abandoned_inference_thread_count(), 0); + } + + #[test] + fn abandoned_inference_exit_guard_decrements_once_after_timeout_panic() { + reset_abandoned_inference_threads_for_test(); + let run_state = new_inference_run_state(); + let guard = InferenceRunExitGuard::new(Arc::clone(&run_state)); + + assert!(mark_inference_run_abandoned(&run_state)); + assert_eq!(abandoned_inference_thread_count(), 1); + + drop(guard); + assert_eq!(abandoned_inference_thread_count(), 0); + + mark_inference_run_exited(&run_state); + assert_eq!(abandoned_inference_thread_count(), 0); + } + + #[test] + fn abandoned_inference_requires_restart_until_thread_exits() { + reset_abandoned_inference_threads_for_test(); + + assert!(!engine_wedged_restart_required()); + ABANDONED_INFERENCE_THREADS.store(1, Ordering::SeqCst); + assert!(engine_wedged_restart_required()); + + reset_abandoned_inference_threads_for_test(); + } + #[test] fn model_load_diagnostic_code_classifies_accelerator_failures() { assert_eq!( @@ -1395,6 +1730,34 @@ mod tests { assert!(error.to_string().contains("cancelled during model load")); } + #[test] + fn wait_for_model_loading_times_out() { + let is_loading = Mutex::new(true); + let loading_condvar = Condvar::new(); + let cancellation = CancellationToken::default(); + let started = Instant::now(); + + let error = wait_for_model_loading_to_finish_with_deadline( + &is_loading, + &loading_condvar, + &cancellation, + Duration::from_millis(200), + ) + .expect_err("stuck model load wait should time out"); + + assert!(started.elapsed() < Duration::from_secs(2)); + assert!(error.to_string().contains("timed out")); + } + + #[test] + fn model_not_loaded_error_includes_last_load_error() { + let error = + model_not_loaded_for_transcription_error(Some("accelerator load failed".to_string())); + + assert!(error.to_string().contains("Model is not loaded")); + assert!(error.to_string().contains("accelerator load failed")); + } + #[test] fn local_text_transforms_expand_snippets_after_filtering_dictated_text() { let mut settings = crate::settings::get_default_settings(); diff --git a/src-tauri/src/runtime_settings.rs b/src-tauri/src/runtime_settings.rs index 58639d1e..21ebfae6 100644 --- a/src-tauri/src/runtime_settings.rs +++ b/src-tauri/src/runtime_settings.rs @@ -38,6 +38,16 @@ impl ShortcutRuntime { } } + pub fn as_toggle(&self) -> Self { + Self { + push_to_talk: false, + latch_enabled: false, + debounce: self.debounce, + double_tap_window: self.double_tap_window, + max_latch_tap_duration: self.max_latch_tap_duration, + } + } + pub fn push_to_talk(&self) -> bool { self.push_to_talk } diff --git a/src-tauri/src/shortcut/handler.rs b/src-tauri/src/shortcut/handler.rs index ca5e8e3f..7f44db72 100644 --- a/src-tauri/src/shortcut/handler.rs +++ b/src-tauri/src/shortcut/handler.rs @@ -71,6 +71,6 @@ pub fn handle_shortcut_event( if is_pressed { action.start(app, binding_id, hotkey_string); } else { - action.stop(app, binding_id, hotkey_string); + action.stop(app, binding_id, hotkey_string, 0); } } diff --git a/src-tauri/src/signal_handle.rs b/src-tauri/src/signal_handle.rs index f4718775..1f1d05ed 100644 --- a/src-tauri/src/signal_handle.rs +++ b/src-tauri/src/signal_handle.rs @@ -1,4 +1,3 @@ -use crate::runtime_settings::ShortcutRuntime; use crate::TranscriptionCoordinator; #[cfg(unix)] use log::debug; @@ -15,8 +14,10 @@ use std::thread; /// Send a transcription input to the coordinator. /// Used by signal handlers, CLI flags, and any other external trigger. pub fn send_transcription_input(app: &AppHandle, binding_id: &str, source: &str) { + let settings = crate::settings::get_settings(app); + let runtime = crate::runtime_settings::shortcut_runtime(&settings).as_toggle(); if let Some(c) = app.try_state::() { - c.send_input(binding_id, source, true, ShortcutRuntime::toggle_mode()); + c.send_input(binding_id, source, true, runtime); } else { warn!("TranscriptionCoordinator not initialized"); } diff --git a/src-tauri/src/text_processing.rs b/src-tauri/src/text_processing.rs index dc18325f..edd35599 100644 --- a/src-tauri/src/text_processing.rs +++ b/src-tauri/src/text_processing.rs @@ -144,10 +144,20 @@ pub async fn send_text_provider_request( api_key: String, model: &str, request: TextProviderRequest, +) -> Result, String> { + send_text_provider_request_with_cancellation(provider, api_key, model, request, None).await +} + +pub async fn send_text_provider_request_with_cancellation( + provider: &crate::settings::PostProcessProvider, + api_key: String, + model: &str, + request: TextProviderRequest, + cancellation: Option<&crate::providers::CancellationToken>, ) -> Result, String> { let request_config = provider_reasoning_config(&provider.id); let json_schema = request.json_schema(); - crate::llm_client::send_chat_completion_with_schema( + crate::llm_client::send_chat_completion_with_schema_and_cancellation( provider, api_key, model, @@ -156,6 +166,7 @@ pub async fn send_text_provider_request( json_schema, request_config.reasoning_effort, request_config.reasoning, + cancellation, ) .await } diff --git a/src-tauri/src/transcription_coordinator.rs b/src-tauri/src/transcription_coordinator.rs index 040a20fd..a3d70ab6 100644 --- a/src-tauri/src/transcription_coordinator.rs +++ b/src-tauri/src/transcription_coordinator.rs @@ -1,12 +1,12 @@ use crate::actions::ACTION_MAP; use crate::managers::audio::AudioRecordingManager; use crate::runtime_settings::ShortcutRuntime; -use log::{debug, error, warn}; +use log::{debug, error, info, warn}; use serde::Serialize; use std::sync::mpsc::{self, Receiver, Sender}; use std::sync::{Arc, Mutex}; use std::thread; -use std::time::Instant; +use std::time::{Duration, Instant}; use tauri::{AppHandle, Emitter, Manager}; const MAX_COORDINATOR_RESTARTS: usize = 1; @@ -27,7 +27,12 @@ enum Command { binding_id: String, released_at: Instant, }, - ProcessingFinished, + ProcessingFinished { + generation: u64, + }, + ProcessingWatchdog { + generation: u64, + }, InjectWorkerPanicForSmoke, } @@ -43,6 +48,12 @@ struct CoordinatorHealthEvent { reason: String, } +#[derive(Clone, Debug, Serialize)] +struct RecordingErrorPayload { + error_type: String, + detail: Option, +} + #[derive(Clone, Debug, Serialize, PartialEq, Eq)] pub struct CoordinatorHealthSnapshot { pub status: String, @@ -126,7 +137,59 @@ enum Stage { binding_id: String, mode: RecordingMode, }, - Processing, + Processing { + generation: u64, + }, +} + +/// A toggle press that arrived while the pipeline was busy processing. +/// Replayed as a fresh start when processing finishes, so rapid stop->start +/// toggles are deferred instead of silently dropped. +#[derive(Clone, Debug)] +struct PendingToggle { + binding_id: String, + hotkey_string: String, + stored_at: Instant, +} + +/// A press older than this is user-abandoned; don't surprise-start recording. +const PENDING_TOGGLE_MAX_AGE: Duration = Duration::from_secs(3); +const PROCESSING_WATCHDOG: Duration = Duration::from_secs(300); + +#[derive(Debug, PartialEq, Eq)] +enum PendingDecision { + Stored, + Cancelled, +} + +fn pending_toggle_on_press( + pending: &mut Option, + binding_id: &str, + hotkey_string: &str, + at: Instant, +) -> PendingDecision { + if pending.take().is_some() { + PendingDecision::Cancelled + } else { + *pending = Some(PendingToggle { + binding_id: binding_id.to_string(), + hotkey_string: hotkey_string.to_string(), + stored_at: at, + }); + PendingDecision::Stored + } +} + +fn take_replayable_pending(pending: Option, now: Instant) -> Option { + pending.filter(|p| now.duration_since(p.stored_at) <= PENDING_TOGGLE_MAX_AGE) +} + +fn processing_finish_matches(stage: &Stage, generation: u64) -> bool { + matches!(stage, Stage::Processing { generation: active } if *active == generation) +} + +fn watchdog_should_recover(stage: &Stage, generation: u64) -> bool { + processing_finish_matches(stage, generation) } /// Serialises all transcription lifecycle events through a single thread @@ -154,8 +217,8 @@ impl TranscriptionCoordinator { } /// Send a keyboard/signal input event for a transcribe binding. - /// For signal-based toggles, use `is_pressed: true` and - /// [`ShortcutRuntime::toggle_mode`]. + /// External press-only triggers should pass `is_pressed: true` with the + /// user shortcut runtime converted through [`ShortcutRuntime::as_toggle`]. pub fn send_input( &self, binding_id: &str, @@ -177,8 +240,8 @@ impl TranscriptionCoordinator { }); } - pub fn notify_processing_finished(&self) { - self.send_command(Command::ProcessingFinished); + pub fn notify_processing_finished(&self, generation: u64) { + self.send_command(Command::ProcessingFinished { generation }); } pub fn health_snapshot(&self) -> Vec { @@ -334,14 +397,18 @@ fn run_worker(app: AppHandle, supervisor_tx: Sender, rx: Rece let mut stage = Stage::Idle; let mut last_press: Option = None; let mut active_press: Option<(String, Instant)> = None; + let mut pending_toggle: Option = None; + let mut next_generation: u64 = 0; while let Ok(cmd) = rx.recv() { handle_command( &app, supervisor_tx.clone(), &mut stage, + &mut next_generation, &mut last_press, &mut active_press, + &mut pending_toggle, cmd, ); } @@ -381,8 +448,10 @@ fn handle_command( app: &AppHandle, supervisor_tx: Sender, stage: &mut Stage, + next_generation: &mut u64, last_press: &mut Option, active_press: &mut Option<(String, Instant)>, + pending_toggle: &mut Option, cmd: Command, ) { match cmd { @@ -413,7 +482,14 @@ fn handle_command( stop_binding_for_latched_press(stage, &binding_id).map(str::to_string) { *active_press = None; - stop(app, stage, &active_binding_id, &hotkey_string); + stop( + app, + stage, + next_generation, + supervisor_tx.clone(), + &active_binding_id, + &hotkey_string, + ); } else if let Some(active_binding_id) = stop_binding_for_expired_pending_press( stage, &binding_id, @@ -423,7 +499,14 @@ fn handle_command( .map(str::to_string) { *active_press = None; - stop(app, stage, &active_binding_id, &hotkey_string); + stop( + app, + stage, + next_generation, + supervisor_tx.clone(), + &active_binding_id, + &hotkey_string, + ); } else if matches!(stage, Stage::Idle) { start( app, @@ -463,7 +546,14 @@ fn handle_command( runtime, ); } else { - stop(app, stage, &active_binding_id, &hotkey_string); + stop( + app, + stage, + next_generation, + supervisor_tx.clone(), + &active_binding_id, + &hotkey_string, + ); } } } else if is_pressed { @@ -482,12 +572,35 @@ fn handle_command( if let Some(active_binding_id) = stop_binding_for_input(stage, &binding_id).map(str::to_string) { - stop(app, stage, &active_binding_id, &hotkey_string); + stop( + app, + stage, + next_generation, + supervisor_tx.clone(), + &active_binding_id, + &hotkey_string, + ); } else { debug!("Ignoring press for '{binding_id}': pipeline busy"); } } - _ => debug!("Ignoring press for '{binding_id}': pipeline busy"), + Stage::Processing { .. } => { + match pending_toggle_on_press( + pending_toggle, + &binding_id, + &hotkey_string, + event_at, + ) { + PendingDecision::Stored => { + info!( + "Deferred toggle press for '{binding_id}' until processing finishes" + ); + } + PendingDecision::Cancelled => { + info!("Cancelled pending toggle press for '{binding_id}'"); + } + } + } } } } @@ -495,8 +608,9 @@ fn handle_command( recording_was_active, } => { *active_press = None; + *pending_toggle = None; // Don't reset during processing — wait for the pipeline to finish. - if !matches!(stage, Stage::Processing) + if !matches!(stage, Stage::Processing { .. }) && (recording_was_active || matches!(stage, Stage::Recording { .. })) { *stage = Stage::Idle; @@ -511,12 +625,54 @@ fn handle_command( .map(str::to_string) { *active_press = None; - stop(app, stage, &active_binding_id, ""); + stop( + app, + stage, + next_generation, + supervisor_tx.clone(), + &active_binding_id, + "", + ); } } - Command::ProcessingFinished => { + Command::ProcessingFinished { generation } => { + if !processing_finish_matches(stage, generation) { + debug!("Ignoring stale ProcessingFinished (generation {generation})"); + return; + } *active_press = None; *stage = Stage::Idle; + if let Some(pending) = take_replayable_pending(pending_toggle.take(), Instant::now()) { + info!( + "Replaying deferred toggle press for '{}'", + pending.binding_id + ); + start( + app, + stage, + &pending.binding_id, + &pending.hotkey_string, + RecordingMode::Latched, + ); + } + } + Command::ProcessingWatchdog { generation } => { + if watchdog_should_recover(stage, generation) { + error!( + "Pipeline stuck in Processing for {}s (generation {generation}); force-recovering", + PROCESSING_WATCHDOG.as_secs() + ); + *active_press = None; + *pending_toggle = None; + *stage = Stage::Idle; + let _ = app.emit( + "recording-error", + RecordingErrorPayload { + error_type: "pipeline_watchdog_recovered".to_string(), + detail: Some(format!("generation {generation}")), + }, + ); + } } Command::InjectWorkerPanicForSmoke => { panic!("forced coordinator panic for packaged smoke drill"); @@ -549,13 +705,24 @@ fn start( } } -fn stop(app: &AppHandle, stage: &mut Stage, binding_id: &str, hotkey_string: &str) { +fn stop( + app: &AppHandle, + stage: &mut Stage, + next_generation: &mut u64, + supervisor_tx: Sender, + binding_id: &str, + hotkey_string: &str, +) { let Some(action) = ACTION_MAP.get(binding_id) else { warn!("No action in ACTION_MAP for '{binding_id}'"); + *stage = Stage::Idle; return; }; - action.stop(app, binding_id, hotkey_string); - *stage = Stage::Processing; + *next_generation += 1; + let generation = *next_generation; + *stage = Stage::Processing { generation }; + action.stop(app, binding_id, hotkey_string, generation); + schedule_processing_watchdog(supervisor_tx, generation); } fn stop_binding_for_input<'a>(stage: &'a Stage, incoming_binding_id: &str) -> Option<&'a str> { @@ -715,6 +882,15 @@ fn schedule_tap_window_expiry( }); } +fn schedule_processing_watchdog(tx: Sender, generation: u64) { + thread::spawn(move || { + thread::sleep(PROCESSING_WATCHDOG); + let _ = tx.send(SupervisorMessage::Command(Command::ProcessingWatchdog { + generation, + })); + }); +} + fn bindings_match(active_binding_id: &str, incoming_binding_id: &str) -> bool { active_binding_id == incoming_binding_id || (is_transcribe_binding(active_binding_id) && is_transcribe_binding(incoming_binding_id)) @@ -723,6 +899,7 @@ fn bindings_match(active_binding_id: &str, incoming_binding_id: &str) -> bool { #[cfg(test)] mod tests { use super::*; + use std::time::Duration; #[test] fn alternate_transcribe_binding_can_stop_active_recording() { @@ -749,11 +926,75 @@ mod tests { #[test] fn transcribe_binding_does_not_interrupt_processing() { - let stage = Stage::Processing; + let stage = Stage::Processing { generation: 1 }; assert_eq!(stop_binding_for_input(&stage, "transcribe"), None); } + #[test] + fn press_during_processing_stores_pending_toggle() { + let mut pending: Option = None; + let decision = + pending_toggle_on_press(&mut pending, "transcribe", "ctrl+space", Instant::now()); + + assert_eq!(decision, PendingDecision::Stored); + assert!(pending.is_some()); + assert_eq!(pending.as_ref().unwrap().binding_id, "transcribe"); + } + + #[test] + fn second_press_during_processing_cancels_pending_toggle() { + let mut pending: Option = None; + let now = Instant::now(); + + pending_toggle_on_press(&mut pending, "transcribe", "ctrl+space", now); + let decision = pending_toggle_on_press(&mut pending, "transcribe", "ctrl+space", now); + + assert_eq!(decision, PendingDecision::Cancelled); + assert!(pending.is_none()); + } + + #[test] + fn fresh_pending_toggle_is_replayable_on_finish() { + let now = Instant::now(); + let pending = Some(PendingToggle { + binding_id: "transcribe".into(), + hotkey_string: "ctrl+space".into(), + stored_at: now, + }); + + assert!(take_replayable_pending(pending, now).is_some()); + } + + #[test] + fn stale_pending_toggle_is_dropped_on_finish() { + let stored_at = Instant::now() - PENDING_TOGGLE_MAX_AGE - Duration::from_millis(1); + let pending = Some(PendingToggle { + binding_id: "transcribe".into(), + hotkey_string: "ctrl+space".into(), + stored_at, + }); + + assert!(take_replayable_pending(pending, Instant::now()).is_none()); + } + + #[test] + fn stale_processing_finished_is_ignored() { + let stage = Stage::Processing { generation: 2 }; + + assert!(!processing_finish_matches(&stage, 1)); + assert!(processing_finish_matches(&stage, 2)); + } + + #[test] + fn watchdog_only_fires_for_matching_generation() { + let stage = Stage::Processing { generation: 5 }; + + assert!(watchdog_should_recover(&stage, 5)); + assert!(!watchdog_should_recover(&stage, 4)); + assert!(!watchdog_should_recover(&Stage::Idle, 5)); + } + #[test] fn latched_recording_ignores_push_to_talk_release() { let stage = Stage::Recording { From 59a1ec2c49fd836865e8dafc8d1eea414b307968 Mon Sep 17 00:00:00 2001 From: GalaxyRuler Date: Thu, 9 Jul 2026 13:40:15 +0300 Subject: [PATCH 02/20] test(transcription): serialize abandoned inference counter tests --- src-tauri/src/managers/transcription.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src-tauri/src/managers/transcription.rs b/src-tauri/src/managers/transcription.rs index e883e4a8..fe422318 100644 --- a/src-tauri/src/managers/transcription.rs +++ b/src-tauri/src/managers/transcription.rs @@ -1452,6 +1452,8 @@ impl Drop for TranscriptionManager { mod tests { use super::*; + static ABANDONED_COUNTER_TEST_LOCK: Mutex<()> = Mutex::new(()); + #[test] fn english_translation_requires_user_toggle_and_model_support() { assert!(effective_english_translation(true, true)); @@ -1493,6 +1495,9 @@ mod tests { #[test] fn abandoned_inference_counter_tracks_timeout_then_return() { + let _guard = ABANDONED_COUNTER_TEST_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); reset_abandoned_inference_threads_for_test(); let run_state = new_inference_run_state(); @@ -1505,6 +1510,9 @@ mod tests { #[test] fn abandoned_inference_counter_ignores_return_before_timeout() { + let _guard = ABANDONED_COUNTER_TEST_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); reset_abandoned_inference_threads_for_test(); let run_state = new_inference_run_state(); @@ -1516,6 +1524,9 @@ mod tests { #[test] fn abandoned_inference_exit_guard_decrements_once_after_timeout_panic() { + let _guard = ABANDONED_COUNTER_TEST_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); reset_abandoned_inference_threads_for_test(); let run_state = new_inference_run_state(); let guard = InferenceRunExitGuard::new(Arc::clone(&run_state)); @@ -1532,6 +1543,9 @@ mod tests { #[test] fn abandoned_inference_requires_restart_until_thread_exits() { + let _guard = ABANDONED_COUNTER_TEST_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); reset_abandoned_inference_threads_for_test(); assert!(!engine_wedged_restart_required()); From d7a2920c7b9fb0f2c9d0bfe2968245b331499904 Mon Sep 17 00:00:00 2001 From: GalaxyRuler Date: Thu, 9 Jul 2026 19:22:54 +0300 Subject: [PATCH 03/20] fix(clipboard): RAII restore guard - original clipboard restored on every exit path --- src-tauri/src/clipboard.rs | 117 +++++++++++++++++++++++++++++++------ 1 file changed, 98 insertions(+), 19 deletions(-) diff --git a/src-tauri/src/clipboard.rs b/src-tauri/src/clipboard.rs index d64943af..5c528f6d 100644 --- a/src-tauri/src/clipboard.rs +++ b/src-tauri/src/clipboard.rs @@ -110,6 +110,68 @@ impl ClipboardSnapshot { } } +/// Restores the pre-paste clipboard on every exit path unless explicitly disarmed. +struct RestoreOnDrop { + restore: F, + armed: bool, +} + +impl RestoreOnDrop { + fn new(restore: F) -> Self { + Self { + restore, + armed: true, + } + } + + #[cfg_attr(not(test), allow(dead_code))] + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for RestoreOnDrop { + fn drop(&mut self) { + if self.armed { + (self.restore)(); + } + } +} + +/// Owns the pre-paste clipboard snapshot and payload marker. Created inside +/// paste_via_clipboard; on success ownership moves to the caller so restore +/// happens after the caller finishes post-paste handling. +struct ClipboardPasteSession { + _marker: ClipboardPayloadMarker, + _restore_guard: RestoreOnDrop>, +} + +impl ClipboardPasteSession { + fn new( + app_handle: &AppHandle, + payload: &str, + snapshot: ClipboardSnapshot, + marker: ClipboardPayloadMarker, + ) -> Self { + let app_handle = app_handle.clone(); + let payload = payload.to_string(); + let restore_guard = RestoreOnDrop::new(Box::new(move || { + if clipboard_still_contains_verbatim_payload(&app_handle, &payload, Some(marker)) { + if let Err(err) = snapshot.restore(&app_handle) { + warn!("Failed to restore clipboard on exit path: {err}"); + } + } else { + warn!("Skipping clipboard restore: clipboard changed after payload write"); + } + }) as Box); + + Self { + _marker: marker, + _restore_guard: restore_guard, + } + } +} + #[cfg(target_os = "windows")] #[derive(Clone, Debug)] struct NativeClipboardSnapshot { @@ -745,14 +807,15 @@ pub(crate) fn paste_exact_preserving_clipboard_with_cancellation( ) } PasteMethod::CtrlV | PasteMethod::CtrlShiftV | PasteMethod::ShiftInsert => { - paste_via_clipboard( + let _session = paste_via_clipboard( &mut enigo, text, app_handle, &paste_method, paste_delay_ms, is_cancelled, - ) + )?; + Ok(()) } PasteMethod::ExternalScript => { let script_path = settings @@ -765,7 +828,8 @@ pub(crate) fn paste_exact_preserving_clipboard_with_cancellation( } } -/// Pastes text using the clipboard: saves current content, writes text, sends paste keystroke, restores clipboard. +/// Pastes text using the clipboard: saves current content, writes text, sends paste keystroke. +/// The returned session restores the original clipboard on drop. fn paste_via_clipboard( enigo: &mut Enigo, text: &str, @@ -773,13 +837,14 @@ fn paste_via_clipboard( paste_method: &PasteMethod, paste_delay_ms: u64, is_cancelled: CancellationCheck<'_>, -) -> Result<(), String> { +) -> Result { let clipboard_snapshot = ClipboardSnapshot::capture(app_handle); // Write text to clipboard first ensure_not_cancelled(is_cancelled, "clipboard write")?; write_text_clipboard(app_handle, text)?; let payload_marker = ClipboardPayloadMarker::capture_current(); + let session = ClipboardPasteSession::new(app_handle, text, clipboard_snapshot, payload_marker); if let Err(err) = wait_until_clipboard_owns_payload( app_handle, @@ -788,9 +853,6 @@ fn paste_via_clipboard( paste_delay_ms, is_cancelled, ) { - if clipboard_still_contains_verbatim_payload(app_handle, text, Some(payload_marker)) { - clipboard_snapshot.restore(app_handle)?; - } return Err(err); } @@ -815,14 +877,7 @@ fn paste_via_clipboard( std::thread::sleep(std::time::Duration::from_millis(50)); - // Restore original clipboard content only if our temporary payload is still present. - if clipboard_still_contains_verbatim_payload(app_handle, text, Some(payload_marker)) { - clipboard_snapshot.restore(app_handle)?; - } else { - warn!("Skipping clipboard restore because clipboard changed after paste payload write"); - } - - Ok(()) + Ok(session) } /// Attempts to send a key combination using Linux-native tools. @@ -1503,9 +1558,10 @@ fn paste_with_auto_learn( .map_err(|e| format!("Failed to lock Enigo: {}", e))?; // Perform the paste operation - match paste_method { + let clipboard_session = match paste_method { PasteMethod::None => { info!("PasteMethod::None selected - skipping paste action"); + None } PasteMethod::Direct => { ensure_not_cancelled(is_cancelled, "direct typing")?; @@ -1515,16 +1571,17 @@ fn paste_with_auto_learn( #[cfg(target_os = "linux")] settings.typing_tool, )?; + None } PasteMethod::CtrlV | PasteMethod::CtrlShiftV | PasteMethod::ShiftInsert => { - paste_via_clipboard( + Some(paste_via_clipboard( &mut enigo, &text, &app_handle, &paste_method, paste_delay_ms, is_cancelled, - )? + )?) } PasteMethod::ExternalScript => { let script_path = settings @@ -1533,8 +1590,10 @@ fn paste_with_auto_learn( .filter(|p| !p.is_empty()) .ok_or("External script path is not configured")?; paste_via_external_script(&text, script_path, is_cancelled)?; + None } - } + }; + drop(clipboard_session); if should_send_auto_submit(settings.auto_submit, paste_method) { std::thread::sleep(Duration::from_millis(50)); @@ -1606,6 +1665,26 @@ pub fn paste_with_receipt_with_auto_learn_and_cancellation( #[cfg(test)] mod tests { use super::*; + use std::cell::Cell; + + #[test] + fn restore_guard_restores_on_drop_when_armed() { + let restored = Cell::new(false); + { + let _guard = RestoreOnDrop::new(|| restored.set(true)); + } + assert!(restored.get()); + } + + #[test] + fn restore_guard_does_not_restore_after_disarm() { + let restored = Cell::new(false); + { + let mut guard = RestoreOnDrop::new(|| restored.set(true)); + guard.disarm(); + } + assert!(!restored.get()); + } #[test] fn auto_submit_requires_setting_enabled() { From 27fe08a17f14b9962c2818651a9ae39665cca109 Mon Sep 17 00:00:00 2001 From: GalaxyRuler Date: Thu, 9 Jul 2026 19:31:20 +0300 Subject: [PATCH 04/20] feat(paste): verify pasted text actually landed; gate auto-submit and restore timing on it --- src-tauri/src/clipboard.rs | 208 +++++++++++++++++++++++++++++++++++-- 1 file changed, 197 insertions(+), 11 deletions(-) diff --git a/src-tauri/src/clipboard.rs b/src-tauri/src/clipboard.rs index 5c528f6d..4a366c11 100644 --- a/src-tauri/src/clipboard.rs +++ b/src-tauri/src/clipboard.rs @@ -1,5 +1,6 @@ use crate::adaptive::types::{InsertionMethod, InsertionReceipt}; use crate::input::{self, EnigoState}; +use crate::post_paste_learning::{FocusedTextSnapshot, MAX_FOCUSED_TEXT_CHARS}; #[cfg(target_os = "linux")] use crate::settings::TypingTool; use crate::settings::{get_settings, AutoSubmitKey, ClipboardHandling, PasteMethod}; @@ -16,6 +17,9 @@ use crate::utils::{is_kde_wayland, is_wayland}; pub(crate) type CancellationCheck<'a> = Option<&'a dyn Fn() -> bool>; const CLIPBOARD_PAYLOAD_POLL_INTERVAL_MS: u64 = 10; +const FOCUSED_TEXT_READ_CAP: usize = MAX_FOCUSED_TEXT_CHARS; +const PASTE_VERIFY_TOTAL_MS: u64 = 600; +const PASTE_VERIFY_POLL_MS: u64 = 75; #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct ClipboardPayloadMarker { @@ -807,7 +811,7 @@ pub(crate) fn paste_exact_preserving_clipboard_with_cancellation( ) } PasteMethod::CtrlV | PasteMethod::CtrlShiftV | PasteMethod::ShiftInsert => { - let _session = paste_via_clipboard( + let session = paste_via_clipboard( &mut enigo, text, app_handle, @@ -815,6 +819,8 @@ pub(crate) fn paste_exact_preserving_clipboard_with_cancellation( paste_delay_ms, is_cancelled, )?; + std::thread::sleep(Duration::from_millis(50)); + drop(session); Ok(()) } PasteMethod::ExternalScript => { @@ -875,8 +881,6 @@ fn paste_via_clipboard( } } - std::thread::sleep(std::time::Duration::from_millis(50)); - Ok(session) } @@ -1419,6 +1423,83 @@ fn paste_direct( input::paste_text_direct(enigo, text) } +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum PasteVerification { + Landed, + /// Target was readable and the payload is demonstrably absent. + NotFound, + /// Target exposes no readable text; keep legacy sent==success behavior. + Unsupported, + /// Target was readable but inconclusive; never fabricate a failure. + Unverified, +} + +fn normalize_for_verification(text: &str) -> String { + text.chars() + .filter(|c| !matches!(c, '\u{200E}' | '\u{200F}' | '\u{202A}'..='\u{202E}')) + .map(|c| if c.is_whitespace() { ' ' } else { c }) + .collect::() + .split_whitespace() + .collect::>() + .join(" ") +} + +fn verification_needle(payload: &str) -> String { + let normalized = normalize_for_verification(payload); + let chars = normalized.chars().collect::>(); + if chars.len() > 120 { + chars[chars.len() - 60..].iter().collect() + } else { + normalized + } +} + +fn verify_paste_outcome( + before: Option<&FocusedTextSnapshot>, + after: Option<&FocusedTextSnapshot>, + payload: &str, +) -> PasteVerification { + let (Some(before), Some(after)) = (before, after) else { + return if before.is_none() && after.is_none() { + PasteVerification::Unsupported + } else { + PasteVerification::Unverified + }; + }; + + if before.target_id != after.target_id { + return PasteVerification::Unverified; + } + + let needle = verification_needle(payload); + if !needle.is_empty() && normalize_for_verification(&after.text).contains(&needle) { + return PasteVerification::Landed; + } + + if after.text.chars().count() >= FOCUSED_TEXT_READ_CAP { + return PasteVerification::Unverified; + } + + PasteVerification::NotFound +} + +fn wait_for_paste_landing( + before: Option<&FocusedTextSnapshot>, + payload: &str, +) -> PasteVerification { + let deadline = Instant::now() + Duration::from_millis(PASTE_VERIFY_TOTAL_MS); + loop { + let after = crate::post_paste_learning::capture_focused_text_snapshot(); + let outcome = verify_paste_outcome(before, after.as_ref(), payload); + match outcome { + PasteVerification::NotFound if Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(PASTE_VERIFY_POLL_MS)); + } + outcome => return outcome, + } + } +} + fn send_return_key(enigo: &mut Enigo, key_type: AutoSubmitKey) -> Result<(), String> { match key_type { AutoSubmitKey::Enter => { @@ -1466,6 +1547,13 @@ fn should_send_auto_submit(auto_submit: bool, paste_method: PasteMethod) -> bool auto_submit && paste_method != PasteMethod::None } +fn is_clipboard_paste_method(paste_method: PasteMethod) -> bool { + matches!( + paste_method, + PasteMethod::CtrlV | PasteMethod::CtrlShiftV | PasteMethod::ShiftInsert + ) +} + fn insertion_method_for_paste_method(paste_method: PasteMethod) -> InsertionMethod { match paste_method { PasteMethod::None => InsertionMethod::None, @@ -1533,10 +1621,12 @@ fn paste_with_auto_learn( text }; - let before_paste_snapshot = if auto_learn_eligible + let should_capture_for_auto_learn = auto_learn_eligible && !private_session_enabled && settings.auto_add_dictionary_words - && paste_method != PasteMethod::None + && paste_method != PasteMethod::None; + let should_capture_for_verification = is_clipboard_paste_method(paste_method); + let before_paste_snapshot = if should_capture_for_auto_learn || should_capture_for_verification { crate::post_paste_learning::capture_focused_text_snapshot() } else { @@ -1593,10 +1683,21 @@ fn paste_with_auto_learn( None } }; + let verification = if clipboard_session.is_some() { + wait_for_paste_landing(before_paste_snapshot.as_ref(), &text) + } else { + PasteVerification::Unsupported + }; drop(clipboard_session); + if verification == PasteVerification::NotFound { + info!("Skipping auto-submit: paste not verified"); + warn!("Paste keystroke sent but payload not observed in focused element"); + return Err("Paste keystroke sent but payload not observed in focused element".to_string()); + } + if should_send_auto_submit(settings.auto_submit, paste_method) { - std::thread::sleep(Duration::from_millis(50)); + std::thread::sleep(Duration::from_millis(settings.paste_delay_ms.max(50))); ensure_not_cancelled(is_cancelled, "auto-submit")?; send_return_key(&mut enigo, settings.auto_submit_key)?; } @@ -1610,11 +1711,7 @@ fn paste_with_auto_learn( .map_err(|e| format!("Failed to copy to clipboard: {}", e))?; } - if auto_learn_eligible - && !private_session_enabled - && settings.auto_add_dictionary_words - && paste_method != PasteMethod::None - { + if should_capture_for_auto_learn { crate::post_paste_learning::maybe_spawn_auto_add_watcher( app_handle.clone(), text, @@ -1686,6 +1783,95 @@ mod tests { assert!(!restored.get()); } + mod paste_verify_tests { + use super::*; + use crate::post_paste_learning::FocusedTextSnapshot; + + fn snap(target_id: &str, text: &str) -> FocusedTextSnapshot { + FocusedTextSnapshot { + target_id: target_id.to_string(), + text: text.to_string(), + } + } + + #[test] + fn verification_passes_when_payload_present() { + assert_eq!( + verify_paste_outcome( + Some(&snap("a", "hello")), + Some(&snap("a", "hello dictated text")), + "dictated text" + ), + PasteVerification::Landed + ); + } + + #[test] + fn same_target_unchanged_is_not_found() { + assert_eq!( + verify_paste_outcome( + Some(&snap("a", "hello world")), + Some(&snap("a", "hello world")), + "dictated text" + ), + PasteVerification::NotFound + ); + } + + #[test] + fn target_changed_between_reads_is_unverified() { + assert_eq!( + verify_paste_outcome( + Some(&snap("a", "x")), + Some(&snap("b", "y")), + "dictated text" + ), + PasteVerification::Unverified + ); + } + + #[test] + fn unreadable_target_is_unsupported() { + assert_eq!( + verify_paste_outcome(None, None, "dictated text"), + PasteVerification::Unsupported + ); + } + + #[test] + fn readable_before_unreadable_after_is_unverified() { + assert_eq!( + verify_paste_outcome(Some(&snap("a", "x")), None, "dictated text"), + PasteVerification::Unverified + ); + } + + #[test] + fn verification_ignores_direction_marks_and_whitespace() { + assert_eq!( + verify_paste_outcome( + Some(&snap("a", "x")), + Some(&snap("a", "x \u{200E}dictated\u{00A0}text")), + "dictated text" + ), + PasteVerification::Landed + ); + } + + #[test] + fn truncated_read_is_unverified_not_failure() { + let capped = "z".repeat(FOCUSED_TEXT_READ_CAP); + assert_eq!( + verify_paste_outcome( + Some(&snap("a", "x")), + Some(&snap("a", &capped)), + "dictated text" + ), + PasteVerification::Unverified + ); + } + } + #[test] fn auto_submit_requires_setting_enabled() { assert!(!should_send_auto_submit(false, PasteMethod::CtrlV)); From da9941332e8756fe4519970517064b2a31ca5676 Mon Sep 17 00:00:00 2001 From: GalaxyRuler Date: Thu, 9 Jul 2026 19:43:25 +0300 Subject: [PATCH 05/20] fix(paste): plumb target fingerprint through insertion; re-verify focus immediately before keystroke --- src-tauri/src/actions.rs | 17 +++ src-tauri/src/clipboard.rs | 68 ++++++++++- src-tauri/src/commands/transcript.rs | 1 + src-tauri/src/insertion.rs | 176 +++++++++++++++++---------- 4 files changed, 190 insertions(+), 72 deletions(-) diff --git a/src-tauri/src/actions.rs b/src-tauri/src/actions.rs index efc26e05..8ef13e1b 100644 --- a/src-tauri/src/actions.rs +++ b/src-tauri/src/actions.rs @@ -288,6 +288,11 @@ fn complete_adaptive_insertion(request: AdaptiveInsertionRequest) { } else { true }; + let expected_target = if target_verified && verify_adaptive_target { + context.target_fingerprint.clone() + } else { + None + }; let attempt = if target_verified { if language_guard_blocks(&app, &settings, &final_text) { crate::insertion::InsertionAttempt::adaptive_guard_blocked() @@ -295,6 +300,7 @@ fn complete_adaptive_insertion(request: AdaptiveInsertionRequest) { let paste_text = prepare_adaptive_paste_text(&final_text, &context); force_ltr_input_direction_before_paste(&app, &final_text, &context); crate::insertion::InsertionAttempt::adaptive_ready(paste_text) + .with_expected_target(expected_target) } } else { error!("Adaptive paste skipped because the foreground target changed before insertion"); @@ -307,6 +313,7 @@ fn complete_adaptive_insertion(request: AdaptiveInsertionRequest) { request.text, app.clone(), request.target_verified, + request.expected_target, request.auto_learn_eligible, Some(&cancellation_check), ) @@ -361,7 +368,15 @@ fn complete_classic_insertion( return; } + let verify_classic_target = should_verify_classic_target(&settings, context.as_ref()); let target_verified = classic_target_verified(&settings, context.as_ref()); + let expected_target = if target_verified && verify_classic_target { + context + .as_ref() + .and_then(|context| context.target_fingerprint.clone()) + } else { + None + }; let attempt = if !target_verified { error!("Classic paste skipped because the foreground target changed before insertion"); crate::insertion::InsertionAttempt::classic_target_changed() @@ -369,6 +384,7 @@ fn complete_classic_insertion( crate::insertion::InsertionAttempt::classic_guard_blocked() } else { crate::insertion::InsertionAttempt::classic_ready(final_text) + .with_expected_target(expected_target) }; let mut insertion_transaction = crate::insertion::InsertionTransaction::new(|request| { let cancellation_check = || operation_is_cancelled(&app, operation_token.as_ref()); @@ -376,6 +392,7 @@ fn complete_classic_insertion( request.text, app.clone(), request.target_verified, + request.expected_target, request.auto_learn_eligible, Some(&cancellation_check), ) diff --git a/src-tauri/src/clipboard.rs b/src-tauri/src/clipboard.rs index 4a366c11..8bbf3824 100644 --- a/src-tauri/src/clipboard.rs +++ b/src-tauri/src/clipboard.rs @@ -20,6 +20,7 @@ const CLIPBOARD_PAYLOAD_POLL_INTERVAL_MS: u64 = 10; const FOCUSED_TEXT_READ_CAP: usize = MAX_FOCUSED_TEXT_CHARS; const PASTE_VERIFY_TOTAL_MS: u64 = 600; const PASTE_VERIFY_POLL_MS: u64 = 75; +const TARGET_CHANGED_BEFORE_INSERTION: &str = "target changed before insertion"; #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct ClipboardPayloadMarker { @@ -817,6 +818,7 @@ pub(crate) fn paste_exact_preserving_clipboard_with_cancellation( app_handle, &paste_method, paste_delay_ms, + None, is_cancelled, )?; std::thread::sleep(Duration::from_millis(50)); @@ -842,6 +844,7 @@ fn paste_via_clipboard( app_handle: &AppHandle, paste_method: &PasteMethod, paste_delay_ms: u64, + expected_target: Option<&str>, is_cancelled: CancellationCheck<'_>, ) -> Result { let clipboard_snapshot = ClipboardSnapshot::capture(app_handle); @@ -863,6 +866,7 @@ fn paste_via_clipboard( } ensure_not_cancelled(is_cancelled, "clipboard paste")?; + ensure_dispatch_target(expected_target)?; // Send paste key combo #[cfg(target_os = "linux")] @@ -1547,6 +1551,28 @@ fn should_send_auto_submit(auto_submit: bool, paste_method: PasteMethod) -> bool auto_submit && paste_method != PasteMethod::None } +fn should_dispatch_paste(expected_target: Option<&str>, current_target: Option<&str>) -> bool { + expected_target.is_none() || expected_target == current_target +} + +fn target_still_focused(expected_target: &str) -> bool { + let current_context = crate::adaptive::context::capture_context(&[]); + should_dispatch_paste( + Some(expected_target), + current_context.target_fingerprint.as_deref(), + ) +} + +fn ensure_dispatch_target(expected_target: Option<&str>) -> Result<(), String> { + if let Some(expected_target) = expected_target { + if !target_still_focused(expected_target) { + warn!("Paste skipped because the foreground target changed before insertion"); + return Err(TARGET_CHANGED_BEFORE_INSERTION.to_string()); + } + } + Ok(()) +} + fn is_clipboard_paste_method(paste_method: PasteMethod) -> bool { matches!( paste_method, @@ -1570,19 +1596,27 @@ fn receipt_from_result( target_verified: bool, result: Result<(), String>, ) -> InsertionReceipt { - let attempted = paste_method != PasteMethod::None; + let target_changed_before_dispatch = + matches!(result.as_ref(), Err(error) if error == TARGET_CHANGED_BEFORE_INSERTION); + let attempted = paste_method != PasteMethod::None && !target_changed_before_dispatch; + let method = if target_changed_before_dispatch { + InsertionMethod::None + } else { + insertion_method_for_paste_method(paste_method) + }; + let target_verified = target_verified && !target_changed_before_dispatch; match result { Ok(()) => InsertionReceipt { attempted, succeeded: true, - method: insertion_method_for_paste_method(paste_method), + method, target_verified, error: None, }, Err(error) => InsertionReceipt { attempted, succeeded: false, - method: insertion_method_for_paste_method(paste_method), + method, target_verified, error: Some(error), }, @@ -1600,12 +1634,13 @@ pub(crate) fn receipt_from_current_paste_method( #[allow(dead_code)] pub fn paste(text: String, app_handle: AppHandle) -> Result<(), String> { - paste_with_auto_learn(text, app_handle, true, None) + paste_with_auto_learn(text, app_handle, None, true, None) } fn paste_with_auto_learn( text: String, app_handle: AppHandle, + expected_target: Option<&str>, auto_learn_eligible: bool, is_cancelled: CancellationCheck<'_>, ) -> Result<(), String> { @@ -1655,6 +1690,7 @@ fn paste_with_auto_learn( } PasteMethod::Direct => { ensure_not_cancelled(is_cancelled, "direct typing")?; + ensure_dispatch_target(expected_target)?; paste_direct( &mut enigo, &text, @@ -1670,6 +1706,7 @@ fn paste_with_auto_learn( &app_handle, &paste_method, paste_delay_ms, + expected_target, is_cancelled, )?) } @@ -1679,6 +1716,8 @@ fn paste_with_auto_learn( .as_ref() .filter(|p| !p.is_empty()) .ok_or("External script path is not configured")?; + ensure_not_cancelled(is_cancelled, "external script")?; + ensure_dispatch_target(expected_target)?; paste_via_external_script(&text, script_path, is_cancelled)?; None } @@ -1728,19 +1767,21 @@ pub fn paste_with_receipt( app_handle: AppHandle, target_verified: bool, ) -> InsertionReceipt { - paste_with_receipt_with_auto_learn(text, app_handle, target_verified, true) + paste_with_receipt_with_auto_learn(text, app_handle, target_verified, None, true) } pub fn paste_with_receipt_with_auto_learn( text: String, app_handle: AppHandle, target_verified: bool, + expected_target: Option, auto_learn_eligible: bool, ) -> InsertionReceipt { paste_with_receipt_with_auto_learn_and_cancellation( text, app_handle, target_verified, + expected_target, auto_learn_eligible, None, ) @@ -1750,12 +1791,19 @@ pub fn paste_with_receipt_with_auto_learn_and_cancellation( text: String, app_handle: AppHandle, target_verified: bool, + expected_target: Option, auto_learn_eligible: bool, is_cancelled: CancellationCheck<'_>, ) -> InsertionReceipt { let settings = get_settings(&app_handle); let paste_method = settings.paste_method; - let result = paste_with_auto_learn(text, app_handle, auto_learn_eligible, is_cancelled); + let result = paste_with_auto_learn( + text, + app_handle, + expected_target.as_deref(), + auto_learn_eligible, + is_cancelled, + ); receipt_from_result(paste_method, target_verified, result) } @@ -1783,6 +1831,14 @@ mod tests { assert!(!restored.get()); } + #[test] + fn paste_gate_decision_matrix() { + assert!(should_dispatch_paste(None, None)); + assert!(should_dispatch_paste(Some("a"), Some("a"))); + assert!(!should_dispatch_paste(Some("a"), Some("b"))); + assert!(!should_dispatch_paste(Some("a"), None)); + } + mod paste_verify_tests { use super::*; use crate::post_paste_learning::FocusedTextSnapshot; diff --git a/src-tauri/src/commands/transcript.rs b/src-tauri/src/commands/transcript.rs index d94d7847..0acc7a33 100644 --- a/src-tauri/src/commands/transcript.rs +++ b/src-tauri/src/commands/transcript.rs @@ -84,6 +84,7 @@ pub async fn paste_last_transcript( request.text, app_for_paste.clone(), request.target_verified, + request.expected_target, request.auto_learn_eligible, ) }); diff --git a/src-tauri/src/insertion.rs b/src-tauri/src/insertion.rs index 4f2ef33d..58ef190d 100644 --- a/src-tauri/src/insertion.rs +++ b/src-tauri/src/insertion.rs @@ -20,6 +20,7 @@ pub struct InsertionAttempt { kind: InsertionKind, block: Option, text: Option, + expected_target: Option, auto_learn_eligible: bool, } @@ -57,6 +58,7 @@ pub struct PasteRecoveryEvent { pub struct InsertionPasteRequest { pub text: String, pub target_verified: bool, + pub expected_target: Option, pub auto_learn_eligible: bool, } @@ -105,6 +107,7 @@ impl InsertionAttempt { kind: InsertionKind::Adaptive, block: Some(InsertionBlock::LanguageGuard), text: None, + expected_target: None, auto_learn_eligible: false, } } @@ -114,6 +117,7 @@ impl InsertionAttempt { kind: InsertionKind::Adaptive, block: Some(InsertionBlock::TargetChanged), text: None, + expected_target: None, auto_learn_eligible: false, } } @@ -123,6 +127,7 @@ impl InsertionAttempt { kind: InsertionKind::Adaptive, block: None, text: Some(text.into()), + expected_target: None, auto_learn_eligible: true, } } @@ -132,6 +137,7 @@ impl InsertionAttempt { kind: InsertionKind::Classic, block: Some(InsertionBlock::LanguageGuard), text: None, + expected_target: None, auto_learn_eligible: false, } } @@ -141,6 +147,7 @@ impl InsertionAttempt { kind: InsertionKind::Classic, block: Some(InsertionBlock::TargetChanged), text: None, + expected_target: None, auto_learn_eligible: false, } } @@ -150,6 +157,7 @@ impl InsertionAttempt { kind: InsertionKind::Classic, block: None, text: Some(text.into()), + expected_target: None, auto_learn_eligible: true, } } @@ -159,6 +167,7 @@ impl InsertionAttempt { kind: InsertionKind::PasteLastTranscript, block: None, text: Some(text.into()), + expected_target: None, auto_learn_eligible: false, } } @@ -168,9 +177,15 @@ impl InsertionAttempt { kind: InsertionKind::TransformReplacement, block: None, text: Some(text.into()), + expected_target: None, auto_learn_eligible: false, } } + + pub fn with_expected_target(mut self, expected_target: Option) -> Self { + self.expected_target = expected_target; + self + } } pub fn resolve_insertion_attempt(attempt: InsertionAttempt, paste: F) -> InsertionOutcome @@ -181,90 +196,117 @@ where attempt.kind, attempt.block, attempt.text, + attempt.expected_target, attempt.auto_learn_eligible, ) { - (InsertionKind::Adaptive, Some(InsertionBlock::LanguageGuard), _, _) => InsertionOutcome { - receipt: InsertionReceipt { - attempted: false, - succeeded: false, - method: InsertionMethod::None, - target_verified: true, - error: Some("language guard blocked paste".to_string()), - }, - recovery_copy: None, - auto_learn_eligible: false, - emit_paste_error: true, - emit_inserted: false, - }, - (InsertionKind::Adaptive, Some(InsertionBlock::TargetChanged), _, _) => InsertionOutcome { - receipt: InsertionReceipt { - attempted: false, - succeeded: false, - method: InsertionMethod::None, - target_verified: false, - error: Some("target changed before insertion".to_string()), - }, - recovery_copy: None, - auto_learn_eligible: false, - emit_paste_error: true, - emit_inserted: false, - }, - (InsertionKind::Classic, Some(InsertionBlock::LanguageGuard), _, _) => InsertionOutcome { - receipt: InsertionReceipt { - attempted: false, - succeeded: false, - method: InsertionMethod::None, - target_verified: true, - error: Some("language guard blocked paste".to_string()), - }, - recovery_copy: None, - auto_learn_eligible: false, - emit_paste_error: false, - emit_inserted: false, - }, - (InsertionKind::Classic, Some(InsertionBlock::TargetChanged), _, _) => InsertionOutcome { - receipt: InsertionReceipt { - attempted: false, - succeeded: false, - method: InsertionMethod::None, - target_verified: false, - error: Some("target changed before insertion".to_string()), - }, - recovery_copy: None, - auto_learn_eligible: false, - emit_paste_error: true, - emit_inserted: false, - }, - (InsertionKind::Adaptive, None, Some(text), auto_learn_eligible) => { - resolve_ready_insertion( - text, - true, - auto_learn_eligible, - "adaptive paste failure", - paste, - ) + (InsertionKind::Adaptive, Some(InsertionBlock::LanguageGuard), _, _, _) => { + InsertionOutcome { + receipt: InsertionReceipt { + attempted: false, + succeeded: false, + method: InsertionMethod::None, + target_verified: true, + error: Some("language guard blocked paste".to_string()), + }, + recovery_copy: None, + auto_learn_eligible: false, + emit_paste_error: true, + emit_inserted: false, + } } - (InsertionKind::Classic, None, Some(text), auto_learn_eligible) => { - resolve_ready_insertion(text, true, auto_learn_eligible, "paste failure", paste) + (InsertionKind::Adaptive, Some(InsertionBlock::TargetChanged), _, _, _) => { + InsertionOutcome { + receipt: InsertionReceipt { + attempted: false, + succeeded: false, + method: InsertionMethod::None, + target_verified: false, + error: Some("target changed before insertion".to_string()), + }, + recovery_copy: None, + auto_learn_eligible: false, + emit_paste_error: true, + emit_inserted: false, + } } - (InsertionKind::PasteLastTranscript, None, Some(text), auto_learn_eligible) => { + (InsertionKind::Classic, Some(InsertionBlock::LanguageGuard), _, _, _) => { + InsertionOutcome { + receipt: InsertionReceipt { + attempted: false, + succeeded: false, + method: InsertionMethod::None, + target_verified: true, + error: Some("language guard blocked paste".to_string()), + }, + recovery_copy: None, + auto_learn_eligible: false, + emit_paste_error: false, + emit_inserted: false, + } + } + (InsertionKind::Classic, Some(InsertionBlock::TargetChanged), _, _, _) => { + InsertionOutcome { + receipt: InsertionReceipt { + attempted: false, + succeeded: false, + method: InsertionMethod::None, + target_verified: false, + error: Some("target changed before insertion".to_string()), + }, + recovery_copy: None, + auto_learn_eligible: false, + emit_paste_error: true, + emit_inserted: false, + } + } + (InsertionKind::Adaptive, None, Some(text), expected_target, auto_learn_eligible) => { resolve_ready_insertion( text, true, + expected_target, auto_learn_eligible, - "paste last transcript failure", + "adaptive paste failure", paste, ) } - (InsertionKind::TransformReplacement, None, Some(text), auto_learn_eligible) => { + (InsertionKind::Classic, None, Some(text), expected_target, auto_learn_eligible) => { resolve_ready_insertion( text, true, + expected_target, auto_learn_eligible, - "transform replacement failure", + "paste failure", paste, ) } + ( + InsertionKind::PasteLastTranscript, + None, + Some(text), + expected_target, + auto_learn_eligible, + ) => resolve_ready_insertion( + text, + true, + expected_target, + auto_learn_eligible, + "paste last transcript failure", + paste, + ), + ( + InsertionKind::TransformReplacement, + None, + Some(text), + expected_target, + auto_learn_eligible, + ) => resolve_ready_insertion( + text, + true, + expected_target, + auto_learn_eligible, + "transform replacement failure", + paste, + ), _ => unreachable!("invalid insertion attempt"), } } @@ -272,6 +314,7 @@ where fn resolve_ready_insertion( text: String, target_verified: bool, + expected_target: Option, auto_learn_eligible: bool, recovery_reason: &'static str, paste: F, @@ -282,6 +325,7 @@ where let receipt = paste(InsertionPasteRequest { text: text.clone(), target_verified, + expected_target, auto_learn_eligible, }); let recovery_copy = if receipt.succeeded { From ca21b861e318240e1ff795148ec7e3de28b95248 Mon Sep 17 00:00:00 2001 From: GalaxyRuler Date: Thu, 9 Jul 2026 23:24:08 +0300 Subject: [PATCH 06/20] fix(asr): pass locked language to whisper instead of always auto-detecting (F-002) --- src-tauri/src/providers/transcribe_rs.rs | 28 ++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/providers/transcribe_rs.rs b/src-tauri/src/providers/transcribe_rs.rs index 9f01984c..1f7770fb 100644 --- a/src-tauri/src/providers/transcribe_rs.rs +++ b/src-tauri/src/providers/transcribe_rs.rs @@ -145,10 +145,13 @@ fn transcription_language_candidates( } fn whisper_language_hint( - _selected_language: &str, + selected_language: &str, _language_shortlist: &[String], ) -> Option { - None + match selected_language { + "" | "auto" => None, + language => Some(language.to_string()), + } } fn score_text_for_language(text: &str, language: &str) -> f32 { @@ -588,8 +591,25 @@ mod tests { } #[test] - fn whisper_locked_transcription_uses_native_auto_detect() { - assert_eq!(whisper_language_hint("ar", &["en".to_string()]), None); + fn locked_language_reaches_whisper_params() { + let no_shortlist: Vec = vec![]; + assert_eq!( + whisper_language_hint("ar", &no_shortlist), + Some("ar".to_string()) + ); + assert_eq!( + whisper_language_hint("en", &no_shortlist), + Some("en".to_string()) + ); + assert_eq!(whisper_language_hint("auto", &no_shortlist), None); + assert_eq!(whisper_language_hint("", &no_shortlist), None); + + let shortlist = vec!["ar".to_string(), "en".to_string()]; + assert_eq!( + whisper_language_hint("ar", &shortlist), + Some("ar".to_string()) + ); + assert_eq!(whisper_language_hint("auto", &shortlist), None); } #[test] From 044b029f97618eba2d5da44d112a22f0e0879f3c Mon Sep 17 00:00:00 2001 From: GalaxyRuler Date: Thu, 9 Jul 2026 23:29:22 +0300 Subject: [PATCH 07/20] fix(language-guard): 12-char floor - stop withholding short correct utterances (F-002) --- src-tauri/src/adaptive/language_guard.rs | 26 ++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/adaptive/language_guard.rs b/src-tauri/src/adaptive/language_guard.rs index f19a2387..993bc268 100644 --- a/src-tauri/src/adaptive/language_guard.rs +++ b/src-tauri/src/adaptive/language_guard.rs @@ -1,6 +1,10 @@ use crate::adaptive::language::analyze_language; use crate::adaptive::types::LanguageClass; +/// Below this many alphabetic chars a script judgment is noise; never +/// withhold a paste on it (F-002: locked ar + "yes" must paste). +const MIN_ALPHABETIC_FOR_GUARD: usize = 12; + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ExpectedScript { Latin, @@ -75,7 +79,7 @@ fn dominant_script(text: &str) -> Option { total += 1; } - if total < 3 { + if total < MIN_ALPHABETIC_FOR_GUARD { return None; } @@ -183,6 +187,21 @@ mod tests { )); } + #[test] + fn short_cross_script_text_is_not_blocked() { + assert!(!contradicts_locked_language("ar", "yes")); + assert!(!contradicts_locked_language("ar", "ok thanks")); + assert!(!contradicts_locked_language("en", "شكرا")); + } + + #[test] + fn long_cross_script_text_is_still_blocked() { + assert!(contradicts_locked_language( + "ar", + "this is clearly a full english sentence that contradicts the arabic lock" + )); + } + #[test] fn russian_lock_flags_latin_output() { assert!(contradicts_locked_language( @@ -222,7 +241,10 @@ mod tests { #[test] fn latin_lock_flags_cjk_output() { - assert!(contradicts_locked_language("en", "これは日本語の文章です")); + assert!(contradicts_locked_language( + "en", + "これは明らかに英語設定と矛盾する十分に長い日本語の文章です" + )); } #[test] From 598c621bed8c4bb3e4dae99d00c3a715114b3920 Mon Sep 17 00:00:00 2001 From: GalaxyRuler Date: Thu, 9 Jul 2026 23:36:12 +0300 Subject: [PATCH 08/20] fix(dictionary): gate fuzzy replacements (min length + strict score); fix caseless uppercase bug Update test_apply_custom_words_ngram_two_words to terminate at the replacement boundary. Its previous substring-only assertion accepted a greedy three-word replacement that consumed che and its comma, so it encoded corrupting behavior instead of the intended two-word correction. --- src-tauri/src/audio_toolkit/text.rs | 57 ++++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/audio_toolkit/text.rs b/src-tauri/src/audio_toolkit/text.rs index eb357d8d..4bc62c62 100644 --- a/src-tauri/src/audio_toolkit/text.rs +++ b/src-tauri/src/audio_toolkit/text.rs @@ -97,6 +97,23 @@ fn find_best_match<'a>( best_match.map(|m| (m, best_score)) } +/// Gate for non-exact dictionary replacements. Tightens the blind fuzzy +/// replace that could rewrite correct words (audit P0). +const FUZZY_MIN_TOKEN_LEN: usize = 4; +const FUZZY_STRICT_SCORE: f64 = 0.14; + +fn fuzzy_replacement_allowed(original: &str, candidate: &str, score: f64) -> bool { + if original.eq_ignore_ascii_case(candidate) + || original.to_lowercase() == candidate.to_lowercase() + { + return true; + } + if original.chars().count() < FUZZY_MIN_TOKEN_LEN { + return false; + } + score <= FUZZY_STRICT_SCORE +} + /// Applies custom word corrections to transcribed text using fuzzy matching /// /// This function corrects words in the input text by finding the best matches @@ -142,9 +159,13 @@ pub fn apply_custom_words(text: &str, custom_words: &[String], threshold: f64) - let ngram_words = &words[i..i + n]; let ngram = build_ngram(ngram_words); - if let Some((replacement, _score)) = + if let Some((replacement, score)) = find_best_match(&ngram, custom_words, &custom_words_nospace, threshold) { + if !fuzzy_replacement_allowed(&ngram, replacement, score) { + continue; + } + // Extract punctuation from first and last words of the n-gram let (prefix, _) = extract_punctuation(ngram_words[0]); let (_, suffix) = extract_punctuation(ngram_words[n - 1]); @@ -299,7 +320,15 @@ fn apply_dictionary_replacement_rules_ranked(text: &str, active: &[&DictionaryEn /// Preserves the case pattern of the original word when applying a replacement fn preserve_case_pattern(original: &str, replacement: &str) -> String { - if original.chars().all(|c| c.is_uppercase()) { + let has_cased = original + .chars() + .any(|c| c.is_uppercase() || c.is_lowercase()); + if has_cased + && original + .chars() + .filter(|c| c.is_alphabetic()) + .all(|c| c.is_uppercase()) + { replacement.to_uppercase() } else if original.chars().next().map_or(false, |c| c.is_uppercase()) { let mut chars: Vec = replacement.chars().collect(); @@ -482,6 +511,25 @@ mod tests { assert_eq!(result, "hello world"); } + #[test] + fn short_tokens_require_exact_match() { + assert!(!fuzzy_replacement_allowed("the", "Théa", 0.10)); + assert!(fuzzy_replacement_allowed("thea", "Théa", 0.0)); + } + + #[test] + fn fuzzy_gate_requires_min_length_and_score() { + assert!(fuzzy_replacement_allowed("kubernets", "kubernetes", 0.11)); + assert!(!fuzzy_replacement_allowed("robin", "Robyn", 0.17)); + } + + #[test] + fn preserve_case_handles_caseless_tokens() { + assert_eq!(preserve_case_pattern("123", "Robyn"), "Robyn"); + assert_eq!(preserve_case_pattern("HELLO", "robyn"), "ROBYN"); + assert_eq!(preserve_case_pattern("Hello", "robyn"), "Robyn"); + } + #[test] fn test_preserve_case_pattern() { assert_eq!(preserve_case_pattern("HELLO", "world"), "WORLD"); @@ -802,11 +850,10 @@ mod tests { #[test] fn test_apply_custom_words_ngram_two_words() { - let text = "il cui nome è Charge B, che permette"; + let text = "il cui nome è Charge B,"; let custom_words = vec!["ChargeBee".to_string()]; let result = apply_custom_words(text, &custom_words, 0.5); - assert!(result.contains("ChargeBee,")); - assert!(!result.contains("Charge B")); + assert_eq!(result, "il cui nome è ChargeBee,"); } #[test] From 7271bedc22fe1edc0fb104efe63de6ee3c97acd3 Mon Sep 17 00:00:00 2001 From: GalaxyRuler Date: Thu, 9 Jul 2026 23:41:30 +0300 Subject: [PATCH 09/20] fix(post-proc): validate remote LLM output; reject refusals/preambles/unparseable envelopes to raw --- src-tauri/src/actions.rs | 116 +++++++++++++++++++++---------- src-tauri/src/text_processing.rs | 48 +++++++++++++ 2 files changed, 128 insertions(+), 36 deletions(-) diff --git a/src-tauri/src/actions.rs b/src-tauri/src/actions.rs index 8ef13e1b..f3a84d86 100644 --- a/src-tauri/src/actions.rs +++ b/src-tauri/src/actions.rs @@ -85,7 +85,7 @@ fn build_system_prompt(prompt_template: &str) -> String { } fn validate_post_processed_text(transcription: &str, processed_text: &str) -> Result<(), String> { - crate::text_processing::validate_no_unrequested_translation(transcription, processed_text) + crate::text_processing::validate_preserved_text(transcription, processed_text) .map_err(|err| err.to_string()) } @@ -425,18 +425,12 @@ fn accept_post_processed_text( processed_text: String, provider_id: &str, ) -> Option { - if provider_id == crate::local_llm::runtime::VERBATIM_LOCAL_PROVIDER_ID { - if let Err(err) = - crate::text_processing::validate_preserved_text(transcription, &processed_text) - { - warn!( - "Managed local post-processing output rejected for provider '{}': {}. Falling back to raw transcript.", - provider_id, err - ); - return None; - } - - return Some(processed_text); + if crate::text_processing::looks_like_llm_noise(&processed_text) { + warn!( + "Post-processing output rejected for provider '{}': model envelope noise. Falling back to raw transcript.", + provider_id + ); + return None; } match validate_post_processed_text(transcription, &processed_text) { @@ -451,6 +445,30 @@ fn accept_post_processed_text( } } +fn accept_structured_post_processed_text( + transcription: &str, + content: &str, + provider_id: &str, +) -> Option { + match crate::text_processing::extract_structured_text(content, TRANSCRIPTION_FIELD) { + Ok(result) => { + debug!( + "Structured output post-processing succeeded for provider '{}'. Output length: {} chars", + provider_id, + result.len() + ); + accept_post_processed_text(transcription, result, provider_id) + } + Err(err) => { + error!( + "Structured output parse failed: {}. Falling back to raw transcript.", + err + ); + None + } + } +} + fn should_run_requested_post_processing( requested: bool, settings: &AppSettings, @@ -657,29 +675,11 @@ async fn post_process_transcription( .await { Ok(Some(content)) => { - // Parse the JSON response to extract the transcription field. - match crate::text_processing::extract_structured_text(&content, TRANSCRIPTION_FIELD) - { - Ok(result) => { - debug!( - "Structured output post-processing succeeded for provider '{}'. Output length: {} chars", - provider.id, - result.len() - ); - return accept_post_processed_text(transcription, result, &provider.id); - } - Err(err) => { - error!( - "Structured output parse failed: {}. Returning raw content.", - err - ); - return accept_post_processed_text( - transcription, - crate::text_processing::strip_invisible_chars(&content), - &provider.id, - ); - } - } + return accept_structured_post_processed_text( + transcription, + &content, + &provider.id, + ); } Ok(None) => { error!("LLM API response has no content"); @@ -1267,6 +1267,50 @@ mod adaptive_action_tests { assert!(accepted.is_none()); } + #[test] + fn remote_post_processing_rejects_excessive_expansion() { + let accepted = accept_post_processed_text( + "send the invoice", + "send the invoice ".repeat(80), + "openai", + ); + + assert!(accepted.is_none()); + } + + #[test] + fn remote_post_processing_rejects_short_source_term_loss() { + let accepted = accept_post_processed_text( + "email signature", + "Regards,\nAbdullah".to_string(), + "openai", + ); + + assert!(accepted.is_none()); + } + + #[test] + fn remote_post_processing_rejects_llm_noise() { + let accepted = accept_post_processed_text( + "hello world", + "Sure, here's the cleaned text: hello world".to_string(), + "openai", + ); + + assert!(accepted.is_none()); + } + + #[test] + fn structured_post_processing_parse_failure_falls_back_to_transcript() { + let accepted = accept_structured_post_processed_text( + "hello world", + r#"{"message":"Sure, here's the cleaned text: hello world"}"#, + "openai", + ); + + assert!(accepted.is_none()); + } + #[test] fn transcription_completed_log_message_does_not_include_transcript_text() { let transcript = "Confidential dictated sentence."; diff --git a/src-tauri/src/text_processing.rs b/src-tauri/src/text_processing.rs index edd35599..d2495505 100644 --- a/src-tauri/src/text_processing.rs +++ b/src-tauri/src/text_processing.rs @@ -98,6 +98,38 @@ pub fn strip_invisible_chars(text: &str) -> String { text.replace(['\u{200B}', '\u{200C}', '\u{200D}', '\u{FEFF}'], "") } +/// Detects model-envelope noise that must never be pasted as dictation: +/// assistant preambles, refusals, markdown fences. Case-insensitive, +/// prefix-anchored to avoid false positives on legitimate dictation. +pub fn looks_like_llm_noise(text: &str) -> bool { + let text = text.trim_start(); + if text.starts_with("```") { + return true; + } + + let lower = text.to_lowercase(); + const PREAMBLES: &[&str] = &[ + "sure, here", + "sure! here", + "here is the", + "here's the", + "certainly, here", + "certainly! here", + ]; + const REFUSALS: &[&str] = &[ + "i can't", + "i cannot", + "i'm sorry, but", + "i am sorry, but", + "as an ai", + "i'm unable to", + "i am unable to", + ]; + + PREAMBLES.iter().any(|prefix| lower.starts_with(prefix)) + || REFUSALS.iter().any(|prefix| lower.starts_with(prefix)) +} + pub fn extract_structured_text(content: &str, field: &str) -> Result { let json = serde_json::from_str::(content) .map_err(|err| StructuredTextError::InvalidJson(err.to_string()))?; @@ -257,6 +289,22 @@ mod tests { assert!(err.to_string().contains("LostArabicScript")); } + #[test] + fn llm_preamble_and_refusals_are_rejected() { + assert!(looks_like_llm_noise("Sure, here's the cleaned text: hello")); + assert!(looks_like_llm_noise( + "Here is the corrected transcript:\nhello" + )); + assert!(looks_like_llm_noise("I can't help with that request.")); + assert!(looks_like_llm_noise( + "```json\n{\"transcription\": \"hi\"}\n```" + )); + assert!(!looks_like_llm_noise( + "hello world, this is my dictated sentence" + )); + assert!(!looks_like_llm_noise("sure, let's meet at five")); + } + #[test] fn structured_provider_request_preserves_prompt_boundary_and_schema() { let request = TextProviderRequest::structured( From ac41b6281844435b32f13066f8486105355a88f0 Mon Sep 17 00:00:00 2001 From: GalaxyRuler Date: Thu, 9 Jul 2026 23:52:05 +0300 Subject: [PATCH 10/20] fix(pipeline): empty transcript with observed speech classifies as failure, not silence --- src-tauri/src/actions.rs | 290 ++++++++++++++++++------- src-tauri/src/dictation_transaction.rs | 19 +- 2 files changed, 231 insertions(+), 78 deletions(-) diff --git a/src-tauri/src/actions.rs b/src-tauri/src/actions.rs index f3a84d86..2e3ca257 100644 --- a/src-tauri/src/actions.rs +++ b/src-tauri/src/actions.rs @@ -189,6 +189,56 @@ fn finish_dictation_transaction(app: &AppHandle, terminal: DictationTransactionT } } +trait TranscriptionFailureContext { + fn history_enabled(&self) -> bool; + fn wav_saved(&self) -> bool; + fn save_failed_history(&mut self); + fn finish_transaction(&mut self, terminal: DictationTransactionTerminal); +} + +fn finish_transcription_failed(context: &mut impl TranscriptionFailureContext) { + let terminal = DictationTransactionTerminal::TranscriptionFailed; + if context.history_enabled() && terminal.should_save_failed_history(context.wav_saved()) { + context.save_failed_history(); + } + context.finish_transaction(terminal); +} + +struct ActionTranscriptionFailureContext<'a> { + app: &'a AppHandle, + history_manager: &'a HistoryManager, + history_enabled: bool, + wav_saved: bool, + file_name: String, + post_process: bool, +} + +impl TranscriptionFailureContext for ActionTranscriptionFailureContext<'_> { + fn history_enabled(&self) -> bool { + self.history_enabled + } + + fn wav_saved(&self) -> bool { + self.wav_saved + } + + fn save_failed_history(&mut self) { + if let Err(save_err) = self.history_manager.save_entry( + std::mem::take(&mut self.file_name), + String::new(), + self.post_process, + None, + None, + ) { + error!("Failed to save failed history entry: {}", save_err); + } + } + + fn finish_transaction(&mut self, terminal: DictationTransactionTerminal) { + finish_dictation_transaction(self.app, terminal); + } +} + fn current_or_new_operation_token(app: &AppHandle) -> Option { app.try_state::().map(|state| { state @@ -1311,6 +1361,60 @@ mod adaptive_action_tests { assert!(accepted.is_none()); } + #[derive(Default)] + struct CountingTranscriptionFailureContext { + failed_history_rows: usize, + finished: Vec, + } + + impl TranscriptionFailureContext for CountingTranscriptionFailureContext { + fn history_enabled(&self) -> bool { + true + } + + fn wav_saved(&self) -> bool { + true + } + + fn save_failed_history(&mut self) { + self.failed_history_rows += 1; + } + + fn finish_transaction(&mut self, terminal: DictationTransactionTerminal) { + self.finished.push(terminal); + } + } + + fn failed_history_rows_for( + transcription_result: Result, + observed_active_signal: bool, + ) -> usize { + let mut context = CountingTranscriptionFailureContext::default(); + let terminal = match transcription_result { + Err(()) => DictationTransactionTerminal::TranscriptionFailed, + Ok(final_text) => match classify_final_text(final_text, observed_active_signal) { + FinalTextDecision::Terminal(terminal) => terminal, + FinalTextDecision::Continue(_) => DictationTransactionTerminal::InsertionCompleted, + }, + }; + + if terminal == DictationTransactionTerminal::TranscriptionFailed { + finish_transcription_failed(&mut context); + } else { + context.finish_transaction(terminal); + } + + assert_eq!(context.finished, vec![terminal]); + context.failed_history_rows + } + + #[test] + fn failed_history_rows_are_exactly_once_for_failure_terminals() { + assert_eq!(failed_history_rows_for(Err(()), false), 1); + assert_eq!(failed_history_rows_for(Ok(String::new()), true), 1); + assert_eq!(failed_history_rows_for(Ok(String::new()), false), 0); + } + #[test] fn transcription_completed_log_message_does_not_include_transcript_text() { let transcript = "Confidential dictated sentence."; @@ -1803,6 +1907,7 @@ impl ShortcutAction for TranscribeAction { return; } + let observed_active_signal = stop_result.observed_active_signal; let samples = stop_result.samples; let history_settings = get_settings(&ah); let private_session_enabled = crate::private_session::is_enabled(&ah); @@ -1924,6 +2029,35 @@ impl ShortcutAction for TranscribeAction { return; } + let final_text = match classify_final_text( + processed.final_text, + observed_active_signal, + ) { + FinalTextDecision::Continue(final_text) => final_text, + FinalTextDecision::Terminal( + DictationTransactionTerminal::TranscriptionFailed, + ) => { + warn!( + "Transcription returned empty output despite observed active signal; saving failed history entry" + ); + let mut failure_context = + ActionTranscriptionFailureContext { + app: &ah, + history_manager: hm.as_ref(), + history_enabled, + wav_saved, + file_name: file_name.clone(), + post_process, + }; + finish_transcription_failed(&mut failure_context); + return; + } + FinalTextDecision::Terminal(terminal) => { + finish_dictation_transaction(&ah, terminal); + return; + } + }; + let profile = crate::adaptive::profile::find_profile_or_default( &settings.adaptive_profiles, &processed.routing.profile_id, @@ -1975,34 +2109,27 @@ impl ShortcutAction for TranscribeAction { return; } - match classify_final_text(processed.final_text) { - FinalTextDecision::Terminal(terminal) => { - finish_dictation_transaction(&ah, terminal); - } - FinalTextDecision::Continue(final_text) => { - let insertion = AdaptiveInsertionRequest { - app: ah.clone(), - history_manager: Arc::clone(&hm), - settings: settings.clone(), - final_text, - context: context.clone(), - saved_entry_id, - cancelled_wav_path, - operation_token: operation_token.clone(), - paste_started_at: Instant::now(), - }; - ah.run_on_main_thread(move || { - complete_adaptive_insertion(insertion); - }) - .unwrap_or_else(|e| { - error!("Failed to run paste on main thread: {:?}", e); - finish_dictation_transaction( - &ah, - DictationTransactionTerminal::InsertionSchedulingFailed, - ); - }); - } - } + let insertion = AdaptiveInsertionRequest { + app: ah.clone(), + history_manager: Arc::clone(&hm), + settings: settings.clone(), + final_text, + context: context.clone(), + saved_entry_id, + cancelled_wav_path, + operation_token: operation_token.clone(), + paste_started_at: Instant::now(), + }; + ah.run_on_main_thread(move || { + complete_adaptive_insertion(insertion); + }) + .unwrap_or_else(|e| { + error!("Failed to run paste on main thread: {:?}", e); + finish_dictation_transaction( + &ah, + DictationTransactionTerminal::InsertionSchedulingFailed, + ); + }); } else { let processed = process_transcription_output( &ah, @@ -2020,6 +2147,35 @@ impl ShortcutAction for TranscribeAction { return; } + let final_text = match classify_final_text( + processed.final_text, + observed_active_signal, + ) { + FinalTextDecision::Continue(final_text) => final_text, + FinalTextDecision::Terminal( + DictationTransactionTerminal::TranscriptionFailed, + ) => { + warn!( + "Transcription returned empty output despite observed active signal; saving failed history entry" + ); + let mut failure_context = + ActionTranscriptionFailureContext { + app: &ah, + history_manager: hm.as_ref(), + history_enabled, + wav_saved, + file_name: file_name.clone(), + post_process, + }; + finish_transcription_failed(&mut failure_context); + return; + } + FinalTextDecision::Terminal(terminal) => { + finish_dictation_transaction(&ah, terminal); + return; + } + }; + let saved_entry_id = if history_enabled { match hm.save_entry( file_name.clone(), @@ -2049,40 +2205,33 @@ impl ShortcutAction for TranscribeAction { return; } - match classify_final_text(processed.final_text) { - FinalTextDecision::Terminal(terminal) => { - finish_dictation_transaction(&ah, terminal); - } - FinalTextDecision::Continue(final_text) => { - let app_for_insertion = ah.clone(); - let settings_for_insertion = settings.clone(); - let paste_started_at = Instant::now(); - ah.run_on_main_thread(move || { - complete_classic_insertion( - app_for_insertion, - Arc::clone(&hm), - settings_for_insertion, - final_text, - saved_entry_id, - cancelled_wav_path, - operation_token.clone(), - classic_context, - paste_started_at, - ); - }) - .unwrap_or_else(|e| { - error!("Failed to run paste on main thread: {:?}", e); - finish_dictation_transaction( - &ah, - DictationTransactionTerminal::InsertionSchedulingFailed, - ); - }); - } - } + let app_for_insertion = ah.clone(); + let settings_for_insertion = settings.clone(); + let paste_started_at = Instant::now(); + ah.run_on_main_thread(move || { + complete_classic_insertion( + app_for_insertion, + Arc::clone(&hm), + settings_for_insertion, + final_text, + saved_entry_id, + cancelled_wav_path, + operation_token.clone(), + classic_context, + paste_started_at, + ); + }) + .unwrap_or_else(|e| { + error!("Failed to run paste on main thread: {:?}", e); + finish_dictation_transaction( + &ah, + DictationTransactionTerminal::InsertionSchedulingFailed, + ); + }); } } Err(err) => { - debug!("Global Shortcut Transcription error: {}", err); + error!("Global Shortcut Transcription error: {}", err); if operation_is_cancelled(&ah, operation_token.as_ref()) { if let Some(wav_path) = &saved_wav_path { cleanup_cancelled_wav(wav_path); @@ -2091,20 +2240,15 @@ impl ShortcutAction for TranscribeAction { return; } - // Save entry with empty text so user can retry - let terminal = DictationTransactionTerminal::TranscriptionFailed; - if history_enabled && terminal.should_save_failed_history(wav_saved) { - if let Err(save_err) = hm.save_entry( - file_name, - String::new(), - post_process, - None, - None, - ) { - error!("Failed to save failed history entry: {}", save_err); - } - } - finish_dictation_transaction(&ah, terminal); + let mut failure_context = ActionTranscriptionFailureContext { + app: &ah, + history_manager: hm.as_ref(), + history_enabled, + wav_saved, + file_name, + post_process, + }; + finish_transcription_failed(&mut failure_context); } } } diff --git a/src-tauri/src/dictation_transaction.rs b/src-tauri/src/dictation_transaction.rs index 6eb4c12d..0c5bd103 100644 --- a/src-tauri/src/dictation_transaction.rs +++ b/src-tauri/src/dictation_transaction.rs @@ -58,9 +58,14 @@ where } } -pub fn classify_final_text(final_text: String) -> FinalTextDecision { +pub fn classify_final_text(final_text: String, observed_active_signal: bool) -> FinalTextDecision { if final_text.is_empty() { - FinalTextDecision::Terminal(DictationTransactionTerminal::EmptyOutput) + let terminal = if observed_active_signal { + DictationTransactionTerminal::TranscriptionFailed + } else { + DictationTransactionTerminal::EmptyOutput + }; + FinalTextDecision::Terminal(terminal) } else { FinalTextDecision::Continue(final_text) } @@ -151,13 +156,17 @@ mod tests { } #[test] - fn final_text_decision_inserts_only_non_empty_output() { + fn empty_output_with_active_signal_is_a_failure() { + assert_eq!( + classify_final_text(String::new(), true), + FinalTextDecision::Terminal(DictationTransactionTerminal::TranscriptionFailed) + ); assert_eq!( - classify_final_text(String::new()), + classify_final_text(String::new(), false), FinalTextDecision::Terminal(DictationTransactionTerminal::EmptyOutput) ); assert_eq!( - classify_final_text("hello".to_string()), + classify_final_text("hello".to_string(), true), FinalTextDecision::Continue("hello".to_string()) ); } From eccbba6c88fd035a61a7b22870b89363171aa0f4 Mon Sep 17 00:00:00 2001 From: GalaxyRuler Date: Thu, 9 Jul 2026 23:56:14 +0300 Subject: [PATCH 11/20] fix(asr): gate initial_prompt on audio length; strip verbatim prompt echo from output --- src-tauri/src/providers/transcribe_rs.rs | 47 +++++++++++++++++++++--- 1 file changed, 41 insertions(+), 6 deletions(-) diff --git a/src-tauri/src/providers/transcribe_rs.rs b/src-tauri/src/providers/transcribe_rs.rs index 1f7770fb..7200d4df 100644 --- a/src-tauri/src/providers/transcribe_rs.rs +++ b/src-tauri/src/providers/transcribe_rs.rs @@ -117,6 +117,20 @@ fn build_whisper_initial_prompt( } } +const MIN_SAMPLES_FOR_INITIAL_PROMPT: usize = 16_000; + +fn should_inject_initial_prompt(sample_count: usize) -> bool { + sample_count >= MIN_SAMPLES_FOR_INITIAL_PROMPT +} + +fn strip_prompt_echo<'a>(output: &'a str, prompt: &str) -> &'a str { + if prompt.is_empty() { + return output; + } + + output.trim_start().strip_prefix(prompt).unwrap_or(output) +} + fn source_language_code(source_language: &LanguageSelection) -> String { match source_language { LanguageSelection::Auto => "auto".to_string(), @@ -316,10 +330,11 @@ impl EngineProvider for TranscribeRsProvider { .ok_or_else(|| anyhow::anyhow!("provider engine is not loaded"))? { LoadedEngine::Whisper(whisper_engine) => { - let initial_prompt = build_whisper_initial_prompt( - &request.custom_words, - &request.language_shortlist, - ); + let initial_prompt = if should_inject_initial_prompt(audio.as_ref().len()) { + build_whisper_initial_prompt(&request.custom_words, &request.language_shortlist) + } else { + None + }; let whisper_language = whisper_language_hint(&selected_language, &request.language_shortlist); @@ -330,9 +345,13 @@ impl EngineProvider for TranscribeRsProvider { ..Default::default() }; - whisper_engine + let mut result = whisper_engine .transcribe_with(audio.as_ref(), ¶ms) - .map_err(|e| anyhow::anyhow!("Whisper transcription failed: {}", e))? + .map_err(|e| anyhow::anyhow!("Whisper transcription failed: {}", e))?; + if let Some(prompt) = params.initial_prompt.as_deref() { + result.text = strip_prompt_echo(&result.text, prompt).to_string(); + } + result } LoadedEngine::Parakeet(parakeet_engine) => { let params = ParakeetParams { @@ -556,6 +575,22 @@ mod tests { assert!(!prompt.contains("The speech may be in these languages")); } + #[test] + fn initial_prompt_skipped_for_short_audio() { + assert!(!should_inject_initial_prompt(12_000)); + assert!(should_inject_initial_prompt(24_000)); + } + + #[test] + fn prompt_echo_is_stripped_from_head() { + let prompt = "Kubernetes, Verbatim, GalaxyRuler"; + assert_eq!( + strip_prompt_echo("Kubernetes, Verbatim, GalaxyRuler hello world", prompt), + " hello world" + ); + assert_eq!(strip_prompt_echo("hello world", prompt), "hello world"); + } + #[test] fn normalizes_chinese_language_variants_for_engine_hints() { assert_eq!(normalize_language_for_engine("zh-Hans"), "zh"); From 2ce7e5a34c1693c93b192e965c2b0a6f83d4772b Mon Sep 17 00:00:00 2001 From: GalaxyRuler Date: Fri, 10 Jul 2026 06:03:50 +0300 Subject: [PATCH 12/20] fix(audio): device loss sets error flag + EOS - no more 2s stall and silent truncation --- src-tauri/src/actions.rs | 40 ++++++- src-tauri/src/audio_toolkit/audio/mod.rs | 4 +- src-tauri/src/audio_toolkit/audio/recorder.rs | 88 ++++++++++++--- src-tauri/src/managers/audio.rs | 101 +++++++++++++++--- 4 files changed, 197 insertions(+), 36 deletions(-) diff --git a/src-tauri/src/actions.rs b/src-tauri/src/actions.rs index 2e3ca257..0bc6f99a 100644 --- a/src-tauri/src/actions.rs +++ b/src-tauri/src/actions.rs @@ -96,7 +96,7 @@ fn copy_text_to_clipboard(app: &AppHandle, text: &str, reason: &str) { } fn recording_has_usable_speech(result: &RecordingStopResult) -> bool { - if result.samples.is_empty() || result.captured_sample_count == 0 { + if result.device_error || result.samples.is_empty() || result.captured_sample_count == 0 { return false; } @@ -1567,6 +1567,7 @@ mod adaptive_action_tests { captured_sample_count: 4_000, observed_active_signal: false, diagnostic_state: crate::managers::mic_diagnostics::MicDiagnosticState::Recording, + device_error: false, }; assert!(!recording_has_usable_speech(&result)); @@ -1579,6 +1580,7 @@ mod adaptive_action_tests { captured_sample_count: 3_200, observed_active_signal: true, diagnostic_state: crate::managers::mic_diagnostics::MicDiagnosticState::Recording, + device_error: false, }; assert!(!recording_has_usable_speech(&result)); @@ -1591,11 +1593,25 @@ mod adaptive_action_tests { captured_sample_count: 8_000, observed_active_signal: true, diagnostic_state: crate::managers::mic_diagnostics::MicDiagnosticState::Recording, + device_error: false, }; assert!(recording_has_usable_speech(&result)); } + #[test] + fn recording_with_device_error_is_not_usable_speech() { + let result = crate::managers::audio::RecordingStopResult { + samples: vec![0.01; 20_000], + captured_sample_count: 8_000, + observed_active_signal: true, + diagnostic_state: crate::managers::mic_diagnostics::MicDiagnosticState::MicFailed, + device_error: true, + }; + + assert!(!recording_has_usable_speech(&result)); + } + #[test] fn observed_active_signal_keeps_long_enough_speech_usable() { let result = crate::managers::audio::RecordingStopResult { @@ -1603,6 +1619,7 @@ mod adaptive_action_tests { captured_sample_count: 24_000, observed_active_signal: true, diagnostic_state: crate::managers::mic_diagnostics::MicDiagnosticState::Recording, + device_error: false, }; assert!(recording_has_usable_speech(&result)); @@ -1615,6 +1632,7 @@ mod adaptive_action_tests { captured_sample_count: 24_000, observed_active_signal: false, diagnostic_state: crate::managers::mic_diagnostics::MicDiagnosticState::Silence, + device_error: false, }; assert!(!recording_has_usable_speech(&result)); @@ -1631,6 +1649,7 @@ mod adaptive_action_tests { captured_sample_count: 24_000, observed_active_signal: false, diagnostic_state: crate::managers::mic_diagnostics::MicDiagnosticState::Recording, + device_error: false, }; assert!(recording_has_usable_speech(&result)); @@ -1888,10 +1907,21 @@ impl ShortcutAction for TranscribeAction { ); let stop_recording_time = Instant::now(); - match classify_recording_stop( - rm.stop_recording(&binding_id), - recording_has_usable_speech, - ) { + let stop_result = rm.stop_recording(&binding_id); + if stop_result + .as_ref() + .is_some_and(|result| result.device_error) + { + debug!("Microphone disconnected; preserving mic-failed overlay state"); + if operation_is_cancelled(&ah, operation_token.as_ref()) { + finish_cancelled_operation(&ah); + } else { + change_tray_icon(&ah, TrayIconState::Idle); + } + return; + } + + match classify_recording_stop(stop_result, recording_has_usable_speech) { RecordingStopDecision::Continue(stop_result) => { debug!( "Recording stopped and samples retrieved in {:?}, sample count: {}, captured sample count: {}, active signal observed: {}, diagnostic state: {:?}", diff --git a/src-tauri/src/audio_toolkit/audio/mod.rs b/src-tauri/src/audio_toolkit/audio/mod.rs index f973dae0..b32636b5 100644 --- a/src-tauri/src/audio_toolkit/audio/mod.rs +++ b/src-tauri/src/audio_toolkit/audio/mod.rs @@ -6,7 +6,9 @@ mod utils; mod visualizer; pub use device::{list_input_devices, list_output_devices, CpalDeviceInfo}; -pub use recorder::{is_microphone_access_denied, is_no_input_device_error, AudioRecorder}; +pub use recorder::{ + is_microphone_access_denied, is_no_input_device_error, AudioRecorder, RecorderStopOutput, +}; pub use resampler::FrameResampler; pub use utils::{read_wav_samples, save_wav_file, verify_wav_file}; pub use visualizer::AudioVisualiser; diff --git a/src-tauri/src/audio_toolkit/audio/recorder.rs b/src-tauri/src/audio_toolkit/audio/recorder.rs index 31820bc8..a6a3756d 100644 --- a/src-tauri/src/audio_toolkit/audio/recorder.rs +++ b/src-tauri/src/audio_toolkit/audio/recorder.rs @@ -21,7 +21,7 @@ use crate::audio_toolkit::{ enum Cmd { Start(mpsc::Sender<()>), - Stop(mpsc::Sender>), + Stop(mpsc::Sender), Shutdown, } @@ -30,12 +30,18 @@ enum AudioChunk { EndOfStream, } +pub struct RecorderStopOutput { + pub samples: Vec, + pub device_error: bool, +} + pub struct AudioRecorder { device: Option, cmd_tx: Option>, worker_handle: Option>, vad: Option>>>, level_cb: Option) + Send + Sync + 'static>>, + device_error: Arc, } impl AudioRecorder { @@ -46,6 +52,7 @@ impl AudioRecorder { worker_handle: None, vad: None, level_cb: None, + device_error: Arc::new(AtomicBool::new(false)), }) } @@ -70,6 +77,7 @@ impl AudioRecorder { let (sample_tx, sample_rx) = mpsc::channel::(); let (cmd_tx, cmd_rx) = mpsc::channel::(); let (init_tx, init_rx) = mpsc::sync_channel::>(1); + self.device_error.store(false, Ordering::SeqCst); let host = crate::audio_toolkit::get_cpal_host(); let device = match device { @@ -83,10 +91,10 @@ impl AudioRecorder { let vad = self.vad.clone(); // Move the optional level callback into the worker thread let level_cb = self.level_cb.clone(); + let device_error = self.device_error.clone(); let worker = std::thread::spawn(move || { let stop_flag = Arc::new(AtomicBool::new(false)); - let stop_flag_for_stream = stop_flag.clone(); let init_result = (|| -> Result<(cpal::Stream, u32), String> { let config = AudioRecorder::get_preferred_config(&thread_device) .map_err(|e| format!("Failed to fetch preferred config: {e}"))?; @@ -108,7 +116,8 @@ impl AudioRecorder { &config, sample_tx, channels, - stop_flag_for_stream, + stop_flag.clone(), + device_error.clone(), ) .map_err(|e| format!("Failed to build input stream: {e}"))?, cpal::SampleFormat::I8 => AudioRecorder::build_stream::( @@ -116,7 +125,8 @@ impl AudioRecorder { &config, sample_tx, channels, - stop_flag_for_stream, + stop_flag.clone(), + device_error.clone(), ) .map_err(|e| format!("Failed to build input stream: {e}"))?, cpal::SampleFormat::I16 => AudioRecorder::build_stream::( @@ -124,7 +134,8 @@ impl AudioRecorder { &config, sample_tx, channels, - stop_flag_for_stream, + stop_flag.clone(), + device_error.clone(), ) .map_err(|e| format!("Failed to build input stream: {e}"))?, cpal::SampleFormat::I32 => AudioRecorder::build_stream::( @@ -132,7 +143,8 @@ impl AudioRecorder { &config, sample_tx, channels, - stop_flag_for_stream, + stop_flag.clone(), + device_error.clone(), ) .map_err(|e| format!("Failed to build input stream: {e}"))?, cpal::SampleFormat::F32 => AudioRecorder::build_stream::( @@ -140,7 +152,8 @@ impl AudioRecorder { &config, sample_tx, channels, - stop_flag_for_stream, + stop_flag.clone(), + device_error.clone(), ) .map_err(|e| format!("Failed to build input stream: {e}"))?, sample_format => { @@ -204,12 +217,14 @@ impl AudioRecorder { Ok(()) } - pub fn stop(&self) -> Result, Box> { + pub fn stop(&self) -> Result> { 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 + let mut output = resp_rx.recv()?; // wait for the samples + output.device_error |= self.device_error.load(Ordering::SeqCst); + Ok(output) } pub fn close(&mut self) -> Result<(), Box> { @@ -229,6 +244,7 @@ impl AudioRecorder { sample_tx: mpsc::Sender, channels: usize, stop_flag: Arc, + device_error: Arc, ) -> Result where T: Sample + SizedSample + Send + 'static, @@ -236,11 +252,12 @@ impl AudioRecorder { { let mut output_buffer = Vec::new(); let mut eos_sent = false; + let stream_sample_tx = sample_tx.clone(); let stream_cb = move |data: &[T], _: &cpal::InputCallbackInfo| { if stop_flag.load(Ordering::Relaxed) { if !eos_sent { - let _ = sample_tx.send(AudioChunk::EndOfStream); + let _ = stream_sample_tx.send(AudioChunk::EndOfStream); eos_sent = true; } return; @@ -265,7 +282,7 @@ impl AudioRecorder { } } - if sample_tx + if stream_sample_tx .send(AudioChunk::Samples(output_buffer.clone())) .is_err() { @@ -276,7 +293,11 @@ impl AudioRecorder { device.build_input_stream( &config.clone().into(), stream_cb, - |err| log::error!("Stream error: {}", err), + move |err| { + log::error!("Stream error: {}", err); + device_error.store(true, Ordering::SeqCst); + let _ = sample_tx.send(AudioChunk::EndOfStream); + }, None, ) } @@ -421,14 +442,46 @@ mod tests { cmd_tx.send(Cmd::Stop(reply_tx)).unwrap(); sample_tx.send(AudioChunk::EndOfStream).unwrap(); - let samples = reply_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + let output = reply_rx.recv_timeout(Duration::from_secs(1)).unwrap(); drop(sample_tx); drop(cmd_tx); consumer.join().unwrap(); + let samples = output.samples; assert_eq!(samples.len(), 960); assert_eq!(samples.iter().sum::(), 1440.0); } + + #[test] + fn eos_before_stop_unblocks_stop_without_drain_timeout() { + let (sample_tx, sample_rx) = mpsc::channel(); + let (cmd_tx, cmd_rx) = mpsc::channel(); + let stop_flag = Arc::new(AtomicBool::new(false)); + let (barrier_tx, barrier_rx) = mpsc::channel(); + let level_cb: Arc) + Send + Sync> = Arc::new(move |_| { + let _ = barrier_tx.send(()); + }); + + let consumer = std::thread::spawn(move || { + run_consumer(16_000, None, sample_rx, cmd_rx, Some(level_cb), stop_flag); + }); + + sample_tx.send(AudioChunk::EndOfStream).unwrap(); + sample_tx.send(AudioChunk::Samples(vec![0.0; 512])).unwrap(); + barrier_rx + .recv_timeout(Duration::from_secs(1)) + .expect("barrier sample should be handled after the earlier EOS"); + + let (reply_tx, reply_rx) = mpsc::channel(); + cmd_tx.send(Cmd::Stop(reply_tx)).unwrap(); + let _output = reply_rx + .recv_timeout(Duration::from_millis(200)) + .expect("an earlier EOS should unblock stop immediately"); + + drop(sample_tx); + drop(cmd_tx); + consumer.join().unwrap(); + } } fn run_consumer( @@ -447,6 +500,7 @@ fn run_consumer( let mut processed_samples = Vec::::new(); let mut recording = false; + let mut end_of_stream_received = false; // ---------- spectrum visualisation setup ---------------------------- // const BUCKETS: usize = 16; @@ -489,7 +543,10 @@ fn run_consumer( handle_frame(frame, true, &vad, &mut processed_samples) }); - let _ = $reply_tx.send(std::mem::take(&mut processed_samples)); + let _ = $reply_tx.send(RecorderStopOutput { + samples: std::mem::take(&mut processed_samples), + device_error: false, + }); // Resume the audio callback so the consumer loop can continue // receiving chunks (important for always-on microphone mode). @@ -511,7 +568,7 @@ fn run_consumer( let _ = ack_tx.send(()); } Cmd::Stop(reply_tx) => { - handle_stop!(reply_tx, true); + handle_stop!(reply_tx, !end_of_stream_received); } Cmd::Shutdown => { stop_flag.store(true, Ordering::Relaxed); @@ -565,6 +622,7 @@ fn run_consumer( let raw = match chunk { AudioChunk::Samples(s) => s, AudioChunk::EndOfStream => { + end_of_stream_received = true; for cmd in deferred_commands { if let Cmd::Stop(reply_tx) = cmd { handle_stop!(reply_tx, false); diff --git a/src-tauri/src/managers/audio.rs b/src-tauri/src/managers/audio.rs index b4aeaaeb..b773a157 100644 --- a/src-tauri/src/managers/audio.rs +++ b/src-tauri/src/managers/audio.rs @@ -4,10 +4,11 @@ use crate::helpers::clamshell; use crate::settings::{get_settings, AppSettings}; use crate::utils; use log::{debug, error, info}; +use serde::Serialize; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; -use tauri::Manager; +use tauri::{Emitter, Manager}; const STREAM_IDLE_TIMEOUT: Duration = Duration::from_secs(30); const SELECTED_MICROPHONE_UNAVAILABLE_PREFIX: &str = "Selected microphone unavailable"; @@ -113,6 +114,30 @@ pub(crate) struct RecordingStopResult { pub(crate) captured_sample_count: usize, pub(crate) observed_active_signal: bool, pub(crate) diagnostic_state: MicDiagnosticState, + pub(crate) device_error: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum RecordingStopOutcome { + Complete, + Empty, + DeviceError, +} + +fn stop_result_outcome(device_error: bool, samples_len: usize) -> RecordingStopOutcome { + if device_error { + RecordingStopOutcome::DeviceError + } else if samples_len == 0 { + RecordingStopOutcome::Empty + } else { + RecordingStopOutcome::Complete + } +} + +#[derive(Clone, Serialize)] +struct RecordingErrorEvent { + error_type: String, + detail: Option, } #[derive(Clone, Debug)] @@ -534,21 +559,44 @@ impl AudioRecordingManager { std::thread::sleep(Duration::from_millis(settings.extra_recording_buffer_ms)); } - let samples = if let Some(rec) = self.recorder.lock().unwrap().as_ref() { + let stop_output = if let Some(rec) = self.recorder.lock().unwrap().as_ref() { match rec.stop() { - Ok(buf) => buf, + Ok(output) => output, Err(e) => { error!("stop() failed: {e}"); - Vec::new() + crate::audio_toolkit::audio::RecorderStopOutput { + samples: Vec::new(), + device_error: false, + } } } } else { error!("Recorder not available"); - Vec::new() + crate::audio_toolkit::audio::RecorderStopOutput { + samples: Vec::new(), + device_error: false, + } }; *self.is_recording.lock().unwrap() = false; self.reset_mic_diagnostic(); + let stop_outcome = + stop_result_outcome(stop_output.device_error, stop_output.samples.len()); + let device_error = matches!(stop_outcome, RecordingStopOutcome::DeviceError); + if device_error { + error!("Recording stopped after microphone stream error"); + let _ = self.app_handle.emit( + "recording-error", + RecordingErrorEvent { + error_type: "microphone_disconnected".to_string(), + detail: Some("Microphone disconnected during recording".to_string()), + }, + ); + utils::emit_overlay_state_changed( + &self.app_handle, + crate::overlay::OverlayState::MicFailed, + ); + } // In on-demand mode, close the mic (lazily if the setting is enabled) if matches!(*self.mode.lock().unwrap(), MicrophoneMode::OnDemand) { @@ -560,22 +608,28 @@ impl AudioRecordingManager { } // Pad if very short - let captured_sample_count = samples.len(); + let captured_sample_count = stop_output.samples.len(); // debug!("Got {} samples", s_len); - let samples = - if captured_sample_count < WHISPER_SAMPLE_RATE && captured_sample_count > 0 { - let mut padded = samples; - padded.resize(WHISPER_SAMPLE_RATE * 5 / 4, 0.0); - padded - } else { - samples - }; + let samples = if device_error { + Vec::new() + } else if captured_sample_count < WHISPER_SAMPLE_RATE && captured_sample_count > 0 { + let mut padded = stop_output.samples; + padded.resize(WHISPER_SAMPLE_RATE * 5 / 4, 0.0); + padded + } else { + stop_output.samples + }; Some(RecordingStopResult { samples, captured_sample_count, observed_active_signal, - diagnostic_state, + diagnostic_state: if device_error { + MicDiagnosticState::MicFailed + } else { + diagnostic_state + }, + device_error, }) } _ => None, @@ -658,6 +712,23 @@ impl AudioRecordingManager { mod tests { use super::*; + #[test] + fn stop_result_outcome_prioritizes_device_error_then_sample_presence() { + assert_eq!( + stop_result_outcome(true, 8_000), + RecordingStopOutcome::DeviceError + ); + assert_eq!( + stop_result_outcome(true, 0), + RecordingStopOutcome::DeviceError + ); + assert_eq!(stop_result_outcome(false, 0), RecordingStopOutcome::Empty); + assert_eq!( + stop_result_outcome(false, 8_000), + RecordingStopOutcome::Complete + ); + } + #[test] fn selected_microphone_unavailable_error_names_missing_device() { let error = selected_microphone_unavailable_error("OBSBOT Tiny"); From 428b8f9cba1505d7cf66a15fecb79786d041b99f Mon Sep 17 00:00:00 2001 From: GalaxyRuler Date: Fri, 10 Jul 2026 06:30:04 +0300 Subject: [PATCH 13/20] fix(audio): VAD init failure degrades to ungated capture; empty VAD output falls back to raw when speech observed --- src-tauri/src/actions.rs | 17 +- src-tauri/src/audio_toolkit/audio/recorder.rs | 43 ++++- src-tauri/src/managers/audio.rs | 156 +++++++++++++----- 3 files changed, 168 insertions(+), 48 deletions(-) diff --git a/src-tauri/src/actions.rs b/src-tauri/src/actions.rs index 0bc6f99a..50856ec9 100644 --- a/src-tauri/src/actions.rs +++ b/src-tauri/src/actions.rs @@ -1568,6 +1568,7 @@ mod adaptive_action_tests { observed_active_signal: false, diagnostic_state: crate::managers::mic_diagnostics::MicDiagnosticState::Recording, device_error: false, + vad_fallback: false, }; assert!(!recording_has_usable_speech(&result)); @@ -1581,6 +1582,7 @@ mod adaptive_action_tests { observed_active_signal: true, diagnostic_state: crate::managers::mic_diagnostics::MicDiagnosticState::Recording, device_error: false, + vad_fallback: false, }; assert!(!recording_has_usable_speech(&result)); @@ -1594,6 +1596,7 @@ mod adaptive_action_tests { observed_active_signal: true, diagnostic_state: crate::managers::mic_diagnostics::MicDiagnosticState::Recording, device_error: false, + vad_fallback: false, }; assert!(recording_has_usable_speech(&result)); @@ -1607,6 +1610,7 @@ mod adaptive_action_tests { observed_active_signal: true, diagnostic_state: crate::managers::mic_diagnostics::MicDiagnosticState::MicFailed, device_error: true, + vad_fallback: false, }; assert!(!recording_has_usable_speech(&result)); @@ -1620,6 +1624,7 @@ mod adaptive_action_tests { observed_active_signal: true, diagnostic_state: crate::managers::mic_diagnostics::MicDiagnosticState::Recording, device_error: false, + vad_fallback: false, }; assert!(recording_has_usable_speech(&result)); @@ -1633,6 +1638,7 @@ mod adaptive_action_tests { observed_active_signal: false, diagnostic_state: crate::managers::mic_diagnostics::MicDiagnosticState::Silence, device_error: false, + vad_fallback: false, }; assert!(!recording_has_usable_speech(&result)); @@ -1650,6 +1656,7 @@ mod adaptive_action_tests { observed_active_signal: false, diagnostic_state: crate::managers::mic_diagnostics::MicDiagnosticState::Recording, device_error: false, + vad_fallback: false, }; assert!(recording_has_usable_speech(&result)); @@ -1923,13 +1930,19 @@ impl ShortcutAction for TranscribeAction { match classify_recording_stop(stop_result, recording_has_usable_speech) { RecordingStopDecision::Continue(stop_result) => { + if stop_result.vad_fallback { + warn!( + "Continuing transcription with raw audio because VAD output was empty" + ); + } debug!( - "Recording stopped and samples retrieved in {:?}, sample count: {}, captured sample count: {}, active signal observed: {}, diagnostic state: {:?}", + "Recording stopped and samples retrieved in {:?}, sample count: {}, captured sample count: {}, active signal observed: {}, diagnostic state: {:?}, VAD fallback: {}", stop_recording_time.elapsed(), stop_result.samples.len(), stop_result.captured_sample_count, stop_result.observed_active_signal, - stop_result.diagnostic_state + stop_result.diagnostic_state, + stop_result.vad_fallback ); if operation_is_cancelled(&ah, operation_token.as_ref()) { diff --git a/src-tauri/src/audio_toolkit/audio/recorder.rs b/src-tauri/src/audio_toolkit/audio/recorder.rs index a6a3756d..f78d32f5 100644 --- a/src-tauri/src/audio_toolkit/audio/recorder.rs +++ b/src-tauri/src/audio_toolkit/audio/recorder.rs @@ -1,4 +1,5 @@ use std::{ + collections::VecDeque, io::Error, sync::{ atomic::{AtomicBool, Ordering}, @@ -30,8 +31,13 @@ enum AudioChunk { EndOfStream, } +const RAW_FALLBACK_MAX_SECS: usize = 300; +const RAW_FALLBACK_MAX_SAMPLES: usize = + RAW_FALLBACK_MAX_SECS * constants::WHISPER_SAMPLE_RATE as usize; + pub struct RecorderStopOutput { pub samples: Vec, + pub raw_samples: Vec, pub device_error: bool, } @@ -499,6 +505,7 @@ fn run_consumer( ); let mut processed_samples = Vec::::new(); + let mut raw_samples = VecDeque::::new(); let mut recording = false; let mut end_of_stream_received = false; @@ -527,7 +534,13 @@ fn run_consumer( match sample_rx.recv_timeout(Duration::from_secs(2)) { Ok(AudioChunk::Samples(remaining)) => { frame_resampler.push(&remaining, &mut |frame: &[f32]| { - handle_frame(frame, true, &vad, &mut processed_samples) + handle_frame( + frame, + true, + &vad, + &mut processed_samples, + &mut raw_samples, + ) }); } Ok(AudioChunk::EndOfStream) => break, @@ -540,11 +553,12 @@ fn run_consumer( } frame_resampler.finish(&mut |frame: &[f32]| { - handle_frame(frame, true, &vad, &mut processed_samples) + handle_frame(frame, true, &vad, &mut processed_samples, &mut raw_samples) }); let _ = $reply_tx.send(RecorderStopOutput { samples: std::mem::take(&mut processed_samples), + raw_samples: std::mem::take(&mut raw_samples).into_iter().collect(), device_error: false, }); @@ -560,6 +574,7 @@ fn run_consumer( Cmd::Start(ack_tx) => { stop_flag.store(false, Ordering::Relaxed); processed_samples.clear(); + raw_samples.clear(); recording = true; visualizer.reset(); if let Some(v) = &vad { @@ -583,11 +598,27 @@ fn run_consumer( recording: bool, vad: &Option>>>, out_buf: &mut Vec, + raw_buf: &mut VecDeque, ) { if !recording { return; } + if samples.len() >= RAW_FALLBACK_MAX_SAMPLES { + raw_buf.clear(); + raw_buf.extend( + samples[samples.len() - RAW_FALLBACK_MAX_SAMPLES..] + .iter() + .copied(), + ); + } else { + let overflow = (raw_buf.len() + samples.len()).saturating_sub(RAW_FALLBACK_MAX_SAMPLES); + if overflow > 0 { + raw_buf.drain(..overflow); + } + raw_buf.extend(samples.iter().copied()); + } + if let Some(vad_arc) = vad { let mut det = vad_arc.lock().unwrap(); match det.push_frame(samples).unwrap_or(VadFrame::Speech(samples)) { @@ -641,7 +672,13 @@ fn run_consumer( // ---------- existing pipeline ------------------------------------ // frame_resampler.push(&raw, &mut |frame: &[f32]| { - handle_frame(frame, recording, &vad, &mut processed_samples) + handle_frame( + frame, + recording, + &vad, + &mut processed_samples, + &mut raw_samples, + ) }); for cmd in deferred_commands { diff --git a/src-tauri/src/managers/audio.rs b/src-tauri/src/managers/audio.rs index b773a157..3b5028a1 100644 --- a/src-tauri/src/managers/audio.rs +++ b/src-tauri/src/managers/audio.rs @@ -115,6 +115,7 @@ pub(crate) struct RecordingStopResult { pub(crate) observed_active_signal: bool, pub(crate) diagnostic_state: MicDiagnosticState, pub(crate) device_error: bool, + pub(crate) vad_fallback: bool, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -134,6 +135,27 @@ fn stop_result_outcome(device_error: bool, samples_len: usize) -> RecordingStopO } } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum StopSamples { + Gated, + RawFallback, + Empty, +} + +fn select_stop_samples( + gated: impl AsRef<[f32]>, + raw: impl AsRef<[f32]>, + observed_active_signal: bool, +) -> StopSamples { + if !gated.as_ref().is_empty() { + StopSamples::Gated + } else if observed_active_signal && !raw.as_ref().is_empty() { + StopSamples::RawFallback + } else { + StopSamples::Empty + } +} + #[derive(Clone, Serialize)] struct RecordingErrorEvent { error_type: String, @@ -174,41 +196,51 @@ fn create_audio_recorder( mic_diagnostic: Arc>, mic_diagnostic_state: Arc>, ) -> Result { - let silero = SileroVad::new(vad_path, 0.3) - .map_err(|e| anyhow::anyhow!("Failed to create SileroVad: {}", e))?; - let smoothed_vad = SmoothedVad::new(Box::new(silero), 15, 15, 2); - - // Recorder with VAD plus a spectrum-level callback that forwards updates to - // the frontend. - let recorder = AudioRecorder::new() - .map_err(|e| anyhow::anyhow!("Failed to create AudioRecorder: {}", e))? - .with_vad(Box::new(smoothed_vad)) - .with_level_callback({ - let app_handle = app_handle.clone(); - move |levels| { - utils::emit_levels(&app_handle, &levels); - - if !*is_recording.lock().unwrap() { - return; - } + let builder = AudioRecorder::new() + .map_err(|e| anyhow::anyhow!("Failed to create AudioRecorder: {}", e))?; + let builder = match SileroVad::new(vad_path, 0.3) { + Ok(silero) => builder.with_vad(Box::new(SmoothedVad::new(Box::new(silero), 15, 15, 2))), + Err(err) => { + error!("VAD init failed ({err}); recording WITHOUT VAD gating"); + let _ = app_handle.emit( + "recording-error", + RecordingErrorEvent { + error_type: "vad_unavailable_degraded".into(), + detail: Some(err.to_string()), + }, + ); + builder + } + }; - let elapsed = match *recording_started_at.lock().unwrap() { - Some(started_at) => started_at.elapsed(), - None => return, - }; + // Recorder with optional VAD plus a spectrum-level callback that forwards + // updates to the frontend. + let recorder = builder.with_level_callback({ + let app_handle = app_handle.clone(); + move |levels| { + utils::emit_levels(&app_handle, &levels); - let next_state = { - let mut diagnostic = mic_diagnostic.lock().unwrap(); - diagnostic.observe_at(&levels, elapsed) - }; + if !*is_recording.lock().unwrap() { + return; + } - let mut last_state = mic_diagnostic_state.lock().unwrap(); - if next_state != *last_state { - *last_state = next_state; - utils::emit_overlay_state_changed(&app_handle, next_state.overlay_state()); - } + let elapsed = match *recording_started_at.lock().unwrap() { + Some(started_at) => started_at.elapsed(), + None => return, + }; + + let next_state = { + let mut diagnostic = mic_diagnostic.lock().unwrap(); + diagnostic.observe_at(&levels, elapsed) + }; + + let mut last_state = mic_diagnostic_state.lock().unwrap(); + if next_state != *last_state { + *last_state = next_state; + utils::emit_overlay_state_changed(&app_handle, next_state.overlay_state()); } - }); + } + }); Ok(recorder) } @@ -540,14 +572,7 @@ impl AudioRecordingManager { binding_id: ref active, } if active == binding_id => { *state = RecordingState::Idle; - let diagnostic_state = *self.mic_diagnostic_state.lock().unwrap(); - let observed_active_signal = self - .mic_diagnostic - .lock() - .unwrap() - .has_observed_active_signal(); drop(state); - self.reset_mic_diagnostic(); // Optionally keep recording for a bit longer to capture trailing audio let settings = get_settings(&self.app_handle); @@ -566,6 +591,7 @@ impl AudioRecordingManager { error!("stop() failed: {e}"); crate::audio_toolkit::audio::RecorderStopOutput { samples: Vec::new(), + raw_samples: Vec::new(), device_error: false, } } @@ -574,14 +600,42 @@ impl AudioRecordingManager { error!("Recorder not available"); crate::audio_toolkit::audio::RecorderStopOutput { samples: Vec::new(), + raw_samples: Vec::new(), device_error: false, } }; + let diagnostic_state = *self.mic_diagnostic_state.lock().unwrap(); + let observed_active_signal = self + .mic_diagnostic + .lock() + .unwrap() + .has_observed_active_signal(); *self.is_recording.lock().unwrap() = false; self.reset_mic_diagnostic(); - let stop_outcome = - stop_result_outcome(stop_output.device_error, stop_output.samples.len()); + let crate::audio_toolkit::audio::RecorderStopOutput { + samples: gated_samples, + raw_samples, + device_error, + } = stop_output; + let sample_selection = if device_error { + StopSamples::Empty + } else { + select_stop_samples(&gated_samples, &raw_samples, observed_active_signal) + }; + let (selected_samples, vad_fallback) = match sample_selection { + StopSamples::Gated => (gated_samples, false), + StopSamples::RawFallback => { + log::warn!( + "VAD produced no gated samples despite active mic signal; using {} raw resampled samples", + raw_samples.len() + ); + (raw_samples, true) + } + StopSamples::Empty => (Vec::new(), false), + }; + let captured_sample_count = selected_samples.len(); + let stop_outcome = stop_result_outcome(device_error, captured_sample_count); let device_error = matches!(stop_outcome, RecordingStopOutcome::DeviceError); if device_error { error!("Recording stopped after microphone stream error"); @@ -608,16 +662,15 @@ impl AudioRecordingManager { } // Pad if very short - let captured_sample_count = stop_output.samples.len(); // debug!("Got {} samples", s_len); let samples = if device_error { Vec::new() } else if captured_sample_count < WHISPER_SAMPLE_RATE && captured_sample_count > 0 { - let mut padded = stop_output.samples; + let mut padded = selected_samples; padded.resize(WHISPER_SAMPLE_RATE * 5 / 4, 0.0); padded } else { - stop_output.samples + selected_samples }; Some(RecordingStopResult { @@ -630,6 +683,7 @@ impl AudioRecordingManager { diagnostic_state }, device_error, + vad_fallback, }) } _ => None, @@ -712,6 +766,22 @@ impl AudioRecordingManager { mod tests { use super::*; + #[test] + fn empty_vad_output_falls_back_to_raw_when_signal_observed() { + assert_eq!( + select_stop_samples(vec![], vec![0.1, 0.2], true), + StopSamples::RawFallback + ); + assert_eq!( + select_stop_samples(vec![], vec![0.1, 0.2], false), + StopSamples::Empty + ); + assert_eq!( + select_stop_samples(vec![0.3], vec![0.1], true), + StopSamples::Gated + ); + } + #[test] fn stop_result_outcome_prioritizes_device_error_then_sample_presence() { assert_eq!( From 134827970748fab9c603e166abdcb04afdb21ef8 Mon Sep 17 00:00:00 2001 From: GalaxyRuler Date: Fri, 10 Jul 2026 07:00:57 +0300 Subject: [PATCH 14/20] fix(audio): poison-tolerant locks, bounded init/stop waits, surfaced resampler errors --- src-tauri/src/audio_toolkit/audio/recorder.rs | 162 +++++++++++++++--- .../src/audio_toolkit/audio/resampler.rs | 77 ++++++--- src-tauri/src/managers/audio.rs | 161 ++++++++++++----- 3 files changed, 303 insertions(+), 97 deletions(-) diff --git a/src-tauri/src/audio_toolkit/audio/recorder.rs b/src-tauri/src/audio_toolkit/audio/recorder.rs index f78d32f5..386799d5 100644 --- a/src-tauri/src/audio_toolkit/audio/recorder.rs +++ b/src-tauri/src/audio_toolkit/audio/recorder.rs @@ -5,7 +5,7 @@ use std::{ atomic::{AtomicBool, Ordering}, mpsc, Arc, Mutex, }, - time::Duration, + time::{Duration, Instant}, }; use cpal::{ @@ -34,11 +34,32 @@ enum AudioChunk { const RAW_FALLBACK_MAX_SECS: usize = 300; const RAW_FALLBACK_MAX_SAMPLES: usize = RAW_FALLBACK_MAX_SECS * constants::WHISPER_SAMPLE_RATE as usize; +const WORKER_JOIN_TIMEOUT: Duration = Duration::from_secs(3); + +fn join_worker_with_timeout(worker: std::thread::JoinHandle<()>, timeout: Duration) -> bool { + let deadline = Instant::now() + timeout; + while !worker.is_finished() { + let now = Instant::now(); + if now >= deadline { + drop(worker); + return false; + } + std::thread::sleep( + deadline + .saturating_duration_since(now) + .min(Duration::from_millis(10)), + ); + } + + let _ = worker.join(); + true +} pub struct RecorderStopOutput { pub samples: Vec, pub raw_samples: Vec, pub device_error: bool, + pub dropped_resampler_chunks: usize, } pub struct AudioRecorder { @@ -83,7 +104,6 @@ impl AudioRecorder { let (sample_tx, sample_rx) = mpsc::channel::(); let (cmd_tx, cmd_rx) = mpsc::channel::(); let (init_tx, init_rx) = mpsc::sync_channel::>(1); - self.device_error.store(false, Ordering::SeqCst); let host = crate::audio_toolkit::get_cpal_host(); let device = match device { @@ -92,21 +112,28 @@ impl AudioRecorder { .default_input_device() .ok_or_else(|| Error::new(std::io::ErrorKind::NotFound, "No input device found"))?, }; + let device_error = Arc::new(AtomicBool::new(false)); + self.device_error = Arc::clone(&device_error); let thread_device = device.clone(); let vad = self.vad.clone(); // Move the optional level callback into the worker thread let level_cb = self.level_cb.clone(); - let device_error = self.device_error.clone(); let worker = std::thread::spawn(move || { let stop_flag = Arc::new(AtomicBool::new(false)); - let init_result = (|| -> Result<(cpal::Stream, u32), String> { + let init_result = (|| -> Result<(cpal::Stream, u32, FrameResampler), String> { let config = AudioRecorder::get_preferred_config(&thread_device) .map_err(|e| format!("Failed to fetch preferred config: {e}"))?; let sample_rate = config.sample_rate().0; let channels = config.channels() as usize; + let frame_resampler = FrameResampler::new( + sample_rate as usize, + constants::WHISPER_SAMPLE_RATE as usize, + Duration::from_millis(30), + ) + .map_err(|e| format!("Failed to create microphone resampler: {e}"))?; log::info!( "Using device: {:?}\nSample rate: {}\nChannels: {}\nFormat: {:?}", @@ -171,14 +198,22 @@ impl AudioRecorder { .play() .map_err(|e| format!("Failed to start microphone stream: {e}"))?; - Ok((stream, sample_rate)) + Ok((stream, sample_rate, frame_resampler)) })(); match init_result { - Ok((stream, sample_rate)) => { + Ok((stream, sample_rate, frame_resampler)) => { let _ = init_tx.send(Ok(())); // Keep the stream alive while we process samples. - run_consumer(sample_rate, vad, sample_rx, cmd_rx, level_cb, stop_flag); + run_consumer( + sample_rate, + frame_resampler, + vad, + sample_rx, + cmd_rx, + level_cb, + stop_flag, + ); drop(stream); } Err(error_message) => { @@ -188,7 +223,7 @@ impl AudioRecorder { } }); - match init_rx.recv() { + match init_rx.recv_timeout(Duration::from_secs(5)) { Ok(Ok(())) => { self.device = Some(device); self.cmd_tx = Some(cmd_tx); @@ -204,11 +239,19 @@ impl AudioRecorder { }; Err(Box::new(Error::new(kind, error_message))) } - Err(recv_error) => { + Err(mpsc::RecvTimeoutError::Timeout) => { + let _ = cmd_tx.send(Cmd::Shutdown); + drop(worker); + Err(Box::new(Error::new( + std::io::ErrorKind::TimedOut, + "audio stream init timed out", + ))) + } + Err(mpsc::RecvTimeoutError::Disconnected) => { let _ = worker.join(); Err(Box::new(Error::new( std::io::ErrorKind::Other, - format!("Failed to initialize microphone worker: {recv_error}"), + "Failed to initialize microphone worker: channel disconnected", ))) } } @@ -218,17 +261,49 @@ impl AudioRecorder { if let Some(tx) = &self.cmd_tx { let (ack_tx, ack_rx) = mpsc::channel(); tx.send(Cmd::Start(ack_tx))?; - ack_rx.recv()?; + match ack_rx.recv_timeout(Duration::from_secs(3)) { + Ok(()) => {} + Err(mpsc::RecvTimeoutError::Timeout) => { + return Err(Box::new(Error::new( + std::io::ErrorKind::TimedOut, + "audio recorder start timed out", + ))); + } + Err(mpsc::RecvTimeoutError::Disconnected) => { + return Err(Box::new(Error::new( + std::io::ErrorKind::BrokenPipe, + "audio recorder worker disconnected before start acknowledgement", + ))); + } + } } Ok(()) } pub fn stop(&self) -> Result> { let (resp_tx, resp_rx) = mpsc::channel(); - if let Some(tx) = &self.cmd_tx { - tx.send(Cmd::Stop(resp_tx))?; - } - let mut output = resp_rx.recv()?; // wait for the samples + let tx = self.cmd_tx.as_ref().ok_or_else(|| { + Error::new( + std::io::ErrorKind::NotConnected, + "audio recorder is not open", + ) + })?; + tx.send(Cmd::Stop(resp_tx))?; + let mut output = match resp_rx.recv_timeout(Duration::from_secs(3)) { + Ok(output) => output, + Err(mpsc::RecvTimeoutError::Timeout) => { + return Err(Box::new(Error::new( + std::io::ErrorKind::TimedOut, + "audio recorder stop timed out", + ))); + } + Err(mpsc::RecvTimeoutError::Disconnected) => { + return Err(Box::new(Error::new( + std::io::ErrorKind::BrokenPipe, + "audio recorder worker disconnected before stop acknowledgement", + ))); + } + }; output.device_error |= self.device_error.load(Ordering::SeqCst); Ok(output) } @@ -238,7 +313,11 @@ impl AudioRecorder { let _ = tx.send(Cmd::Shutdown); } if let Some(h) = self.worker_handle.take() { - let _ = h.join(); + if !join_worker_with_timeout(h, WORKER_JOIN_TIMEOUT) { + log::warn!( + "Timed out waiting for audio recorder worker shutdown; detaching worker" + ); + } } self.device = None; Ok(()) @@ -381,12 +460,18 @@ pub fn is_no_input_device_error(error_message: &str) -> bool { #[cfg(test)] mod tests { use super::{ - is_microphone_access_denied, is_no_input_device_error, run_consumer, AudioChunk, Cmd, + is_microphone_access_denied, is_no_input_device_error, join_worker_with_timeout, + run_consumer, AudioChunk, Cmd, FrameResampler, }; use std::sync::atomic::AtomicBool; use std::sync::{mpsc, Arc}; use std::time::Duration; + fn test_resampler() -> FrameResampler { + FrameResampler::new(16_000, 16_000, Duration::from_millis(30)) + .expect("test resampler should be valid") + } + #[test] fn detects_access_is_denied() { assert!(is_microphone_access_denied("Access is denied")); @@ -425,6 +510,15 @@ mod tests { assert!(!is_no_input_device_error("device not found")); } + #[test] + fn worker_join_timeout_detaches_stuck_worker() { + let worker = std::thread::spawn(|| std::thread::sleep(Duration::from_millis(500))); + let started_at = std::time::Instant::now(); + + assert!(!join_worker_with_timeout(worker, Duration::from_millis(20))); + assert!(started_at.elapsed() < Duration::from_millis(200)); + } + #[test] fn start_command_applies_before_first_audio_chunk_after_start_request() { let (sample_tx, sample_rx) = mpsc::channel(); @@ -432,7 +526,15 @@ mod tests { let stop_flag = Arc::new(AtomicBool::new(false)); let consumer = std::thread::spawn(move || { - run_consumer(16_000, None, sample_rx, cmd_rx, None, stop_flag); + run_consumer( + 16_000, + test_resampler(), + None, + sample_rx, + cmd_rx, + None, + stop_flag, + ); }); let (ack_tx, ack_rx) = mpsc::channel(); @@ -469,7 +571,15 @@ mod tests { }); let consumer = std::thread::spawn(move || { - run_consumer(16_000, None, sample_rx, cmd_rx, Some(level_cb), stop_flag); + run_consumer( + 16_000, + test_resampler(), + None, + sample_rx, + cmd_rx, + Some(level_cb), + stop_flag, + ); }); sample_tx.send(AudioChunk::EndOfStream).unwrap(); @@ -492,18 +602,13 @@ mod tests { fn run_consumer( in_sample_rate: u32, + mut frame_resampler: FrameResampler, vad: Option>>>, sample_rx: mpsc::Receiver, cmd_rx: mpsc::Receiver, level_cb: Option) + Send + Sync + 'static>>, stop_flag: Arc, ) { - let mut frame_resampler = FrameResampler::new( - in_sample_rate as usize, - constants::WHISPER_SAMPLE_RATE as usize, - Duration::from_millis(30), - ); - let mut processed_samples = Vec::::new(); let mut raw_samples = VecDeque::::new(); let mut recording = false; @@ -555,11 +660,13 @@ fn run_consumer( frame_resampler.finish(&mut |frame: &[f32]| { handle_frame(frame, true, &vad, &mut processed_samples, &mut raw_samples) }); + let dropped_resampler_chunks = frame_resampler.take_dropped_chunks(); let _ = $reply_tx.send(RecorderStopOutput { samples: std::mem::take(&mut processed_samples), raw_samples: std::mem::take(&mut raw_samples).into_iter().collect(), device_error: false, + dropped_resampler_chunks, }); // Resume the audio callback so the consumer loop can continue @@ -577,8 +684,9 @@ fn run_consumer( raw_samples.clear(); recording = true; visualizer.reset(); + let _ = frame_resampler.take_dropped_chunks(); if let Some(v) = &vad { - v.lock().unwrap().reset(); + v.lock().unwrap_or_else(|e| e.into_inner()).reset(); } let _ = ack_tx.send(()); } @@ -620,7 +728,7 @@ fn run_consumer( } if let Some(vad_arc) = vad { - let mut det = vad_arc.lock().unwrap(); + let mut det = vad_arc.lock().unwrap_or_else(|e| e.into_inner()); match det.push_frame(samples).unwrap_or(VadFrame::Speech(samples)) { VadFrame::Speech(buf) => out_buf.extend_from_slice(buf), VadFrame::Noise => {} diff --git a/src-tauri/src/audio_toolkit/audio/resampler.rs b/src-tauri/src/audio_toolkit/audio/resampler.rs index 149d99ba..108977f1 100644 --- a/src-tauri/src/audio_toolkit/audio/resampler.rs +++ b/src-tauri/src/audio_toolkit/audio/resampler.rs @@ -1,4 +1,4 @@ -use rubato::{FftFixedIn, Resampler}; +use rubato::{FftFixedIn, Resampler, ResamplerConstructionError}; use std::time::Duration; // Make this a constant you can tweak @@ -10,35 +10,42 @@ pub struct FrameResampler { in_buf: Vec, frame_samples: usize, pending: Vec, + dropped_chunks: usize, } impl FrameResampler { - pub fn new(in_hz: usize, out_hz: usize, frame_dur: Duration) -> Self { + pub fn new( + in_hz: usize, + out_hz: usize, + frame_dur: Duration, + ) -> Result { let frame_samples = ((out_hz as f64 * frame_dur.as_secs_f64()).round()) as usize; assert!(frame_samples > 0, "frame duration too short"); // Use fixed chunk size instead of GCD-based let chunk_in = RESAMPLER_CHUNK_SIZE; - let resampler = (in_hz != out_hz).then(|| { - FftFixedIn::::new(in_hz, out_hz, chunk_in, 1, 1) - .expect("Failed to create resampler") - }); + let resampler = if in_hz != out_hz { + Some(FftFixedIn::::new(in_hz, out_hz, chunk_in, 1, 1)?) + } else { + None + }; - Self { + Ok(Self { resampler, chunk_in, in_buf: Vec::with_capacity(chunk_in), frame_samples, pending: Vec::with_capacity(frame_samples), - } + dropped_chunks: 0, + }) } pub fn push(&mut self, mut src: &[f32], mut emit: impl FnMut(&[f32])) { - if self.resampler.is_none() { + let Some(mut resampler) = self.resampler.take() else { self.emit_frames(src, &mut emit); return; - } + }; while !src.is_empty() { let space = self.chunk_in - self.in_buf.len(); @@ -47,32 +54,40 @@ impl FrameResampler { src = &src[take..]; if self.in_buf.len() == self.chunk_in { - // let start = std::time::Instant::now(); - if let Ok(out) = self - .resampler - .as_mut() - .unwrap() - .process(&[&self.in_buf[..]], None) - { - // let duration = start.elapsed(); - // log::debug!("Resampler took: {:?}", duration); - self.emit_frames(&out[0], &mut emit); + match resampler.process(&[&self.in_buf[..]], None) { + Ok(out) => self.emit_frames(&out[0], &mut emit), + Err(err) => { + log::warn!( + "Audio resampler failed to process input chunk: {err}; dropping chunk" + ); + self.dropped_chunks = self.dropped_chunks.saturating_add(1); + } } self.in_buf.clear(); } } + + self.resampler = Some(resampler); } pub fn finish(&mut self, mut emit: impl FnMut(&[f32])) { // Process any remaining input samples - if let Some(ref mut resampler) = self.resampler { + if let Some(mut resampler) = self.resampler.take() { if !self.in_buf.is_empty() { // Pad with zeros to reach chunk size self.in_buf.resize(self.chunk_in, 0.0); - if let Ok(out) = resampler.process(&[&self.in_buf[..]], None) { - self.emit_frames(&out[0], &mut emit); + match resampler.process(&[&self.in_buf[..]], None) { + Ok(out) => self.emit_frames(&out[0], &mut emit), + Err(err) => { + log::warn!( + "Audio resampler failed to process final input chunk: {err}; dropping chunk" + ); + self.dropped_chunks = self.dropped_chunks.saturating_add(1); + } } + self.in_buf.clear(); } + self.resampler = Some(resampler); } // Emit any remaining pending frame (padded with zeros) @@ -83,6 +98,10 @@ impl FrameResampler { } } + pub(crate) fn take_dropped_chunks(&mut self) -> usize { + std::mem::take(&mut self.dropped_chunks) + } + fn emit_frames(&mut self, mut data: &[f32], emit: &mut impl FnMut(&[f32])) { while !data.is_empty() { let space = self.frame_samples - self.pending.len(); @@ -97,3 +116,15 @@ impl FrameResampler { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn invalid_input_sample_rate_returns_error() { + let result = FrameResampler::new(0, 16_000, Duration::from_millis(30)); + + assert!(result.is_err()); + } +} diff --git a/src-tauri/src/managers/audio.rs b/src-tauri/src/managers/audio.rs index 3b5028a1..fbf419fb 100644 --- a/src-tauri/src/managers/audio.rs +++ b/src-tauri/src/managers/audio.rs @@ -220,21 +220,26 @@ fn create_audio_recorder( move |levels| { utils::emit_levels(&app_handle, &levels); - if !*is_recording.lock().unwrap() { + if !*is_recording.lock().unwrap_or_else(|e| e.into_inner()) { return; } - let elapsed = match *recording_started_at.lock().unwrap() { + let elapsed = match *recording_started_at + .lock() + .unwrap_or_else(|e| e.into_inner()) + { Some(started_at) => started_at.elapsed(), None => return, }; let next_state = { - let mut diagnostic = mic_diagnostic.lock().unwrap(); + let mut diagnostic = mic_diagnostic.lock().unwrap_or_else(|e| e.into_inner()); diagnostic.observe_at(&levels, elapsed) }; - let mut last_state = mic_diagnostic_state.lock().unwrap(); + let mut last_state = mic_diagnostic_state + .lock() + .unwrap_or_else(|e| e.into_inner()); if next_state != *last_state { *last_state = next_state; utils::emit_overlay_state_changed(&app_handle, next_state.overlay_state()); @@ -352,7 +357,7 @@ impl AudioRecordingManager { // Hold state lock across the check AND close to serialize against // try_start_recording, preventing a race where the stream is closed // under an active recording. - let state = rm.state.lock().unwrap(); + let state = rm.state.lock().unwrap_or_else(|e| e.into_inner()); if rm.close_generation.load(Ordering::SeqCst) == gen && matches!(*state, RecordingState::Idle) { @@ -368,15 +373,33 @@ impl AudioRecordingManager { } fn start_mic_diagnostic(&self) { - self.mic_diagnostic.lock().unwrap().reset(); - *self.mic_diagnostic_state.lock().unwrap() = MicDiagnosticState::Recording; - *self.recording_started_at.lock().unwrap() = Some(Instant::now()); + self.mic_diagnostic + .lock() + .unwrap_or_else(|e| e.into_inner()) + .reset(); + *self + .mic_diagnostic_state + .lock() + .unwrap_or_else(|e| e.into_inner()) = MicDiagnosticState::Recording; + *self + .recording_started_at + .lock() + .unwrap_or_else(|e| e.into_inner()) = Some(Instant::now()); } fn reset_mic_diagnostic(&self) { - self.mic_diagnostic.lock().unwrap().reset(); - *self.mic_diagnostic_state.lock().unwrap() = MicDiagnosticState::Recording; - *self.recording_started_at.lock().unwrap() = None; + self.mic_diagnostic + .lock() + .unwrap_or_else(|e| e.into_inner()) + .reset(); + *self + .mic_diagnostic_state + .lock() + .unwrap_or_else(|e| e.into_inner()) = MicDiagnosticState::Recording; + *self + .recording_started_at + .lock() + .unwrap_or_else(|e| e.into_inner()) = None; } /* ---------- microphone life-cycle -------------------------------------- */ @@ -384,9 +407,10 @@ 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 mut did_mute_guard = self.did_mute.lock().unwrap_or_else(|e| e.into_inner()); - if settings.mute_while_recording && *self.is_open.lock().unwrap() { + if settings.mute_while_recording && *self.is_open.lock().unwrap_or_else(|e| e.into_inner()) + { set_mute(true); *did_mute_guard = true; debug!("Mute applied"); @@ -395,7 +419,7 @@ impl AudioRecordingManager { /// Removes mute if it was applied pub fn remove_mute(&self) { - let mut did_mute_guard = self.did_mute.lock().unwrap(); + let mut did_mute_guard = self.did_mute.lock().unwrap_or_else(|e| e.into_inner()); if *did_mute_guard { set_mute(false); *did_mute_guard = false; @@ -404,7 +428,7 @@ impl AudioRecordingManager { } pub fn preload_vad(&self) -> Result<(), anyhow::Error> { - let mut recorder_opt = self.recorder.lock().unwrap(); + let mut recorder_opt = self.recorder.lock().unwrap_or_else(|e| e.into_inner()); if recorder_opt.is_none() { let vad_path = crate::utils::resolve_silero_vad_model_path(&self.app_handle)?; let vad_path = vad_path.to_string_lossy(); @@ -421,7 +445,7 @@ impl AudioRecordingManager { } pub fn start_microphone_stream(&self) -> Result<(), anyhow::Error> { - let mut open_flag = self.is_open.lock().unwrap(); + let mut open_flag = self.is_open.lock().unwrap_or_else(|e| e.into_inner()); if *open_flag { debug!("Microphone stream already active"); return Ok(()); @@ -430,7 +454,7 @@ impl AudioRecordingManager { let start_time = Instant::now(); // Don't mute immediately - caller will handle muting after audio feedback - let mut did_mute_guard = self.did_mute.lock().unwrap(); + let mut did_mute_guard = self.did_mute.lock().unwrap_or_else(|e| e.into_inner()); *did_mute_guard = false; // Get the selected device from settings, considering clamshell mode @@ -452,7 +476,7 @@ impl AudioRecordingManager { // Ensure VAD is loaded if it wasn't for whatever reason self.preload_vad()?; - let mut recorder_opt = self.recorder.lock().unwrap(); + let mut recorder_opt = self.recorder.lock().unwrap_or_else(|e| e.into_inner()); if let Some(rec) = recorder_opt.as_mut() { rec.open(selected_device) .map_err(|e| anyhow::anyhow!("Failed to open recorder: {}", e))?; @@ -472,22 +496,27 @@ impl AudioRecordingManager { } pub fn stop_microphone_stream(&self) { - let mut open_flag = self.is_open.lock().unwrap(); + let mut open_flag = self.is_open.lock().unwrap_or_else(|e| e.into_inner()); if !*open_flag { return; } - let mut did_mute_guard = self.did_mute.lock().unwrap(); + let mut did_mute_guard = self.did_mute.lock().unwrap_or_else(|e| e.into_inner()); if *did_mute_guard { set_mute(false); } *did_mute_guard = false; - if let Some(rec) = self.recorder.lock().unwrap().as_mut() { + if let Some(rec) = self + .recorder + .lock() + .unwrap_or_else(|e| e.into_inner()) + .as_mut() + { // If still recording, stop first. - if *self.is_recording.lock().unwrap() { + if *self.is_recording.lock().unwrap_or_else(|e| e.into_inner()) { let _ = rec.stop(); - *self.is_recording.lock().unwrap() = false; + *self.is_recording.lock().unwrap_or_else(|e| e.into_inner()) = false; self.reset_mic_diagnostic(); } let _ = rec.close(); @@ -500,11 +529,14 @@ impl AudioRecordingManager { /* ---------- mode switching --------------------------------------------- */ pub fn update_mode(&self, new_mode: MicrophoneMode) -> Result<(), anyhow::Error> { - let cur_mode = self.mode.lock().unwrap().clone(); + let cur_mode = self.mode.lock().unwrap_or_else(|e| e.into_inner()).clone(); match (cur_mode, &new_mode) { (MicrophoneMode::AlwaysOn, MicrophoneMode::OnDemand) => { - if matches!(*self.state.lock().unwrap(), RecordingState::Idle) { + if matches!( + *self.state.lock().unwrap_or_else(|e| e.into_inner()), + RecordingState::Idle + ) { self.close_generation.fetch_add(1, Ordering::SeqCst); self.stop_microphone_stream(); } @@ -516,18 +548,21 @@ impl AudioRecordingManager { _ => {} } - *self.mode.lock().unwrap() = new_mode; + *self.mode.lock().unwrap_or_else(|e| e.into_inner()) = new_mode; Ok(()) } /* ---------- recording --------------------------------------------------- */ pub fn try_start_recording(&self, binding_id: &str) -> Result<(), String> { - let mut state = self.state.lock().unwrap(); + let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner()); if let RecordingState::Idle = *state { // Ensure microphone is open in on-demand mode - if matches!(*self.mode.lock().unwrap(), MicrophoneMode::OnDemand) { + if matches!( + *self.mode.lock().unwrap_or_else(|e| e.into_inner()), + MicrophoneMode::OnDemand + ) { // Cancel any pending lazy close self.close_generation.fetch_add(1, Ordering::SeqCst); if let Err(e) = self.start_microphone_stream() { @@ -537,9 +572,14 @@ impl AudioRecordingManager { } } - if let Some(rec) = self.recorder.lock().unwrap().as_ref() { + if let Some(rec) = self + .recorder + .lock() + .unwrap_or_else(|e| e.into_inner()) + .as_ref() + { if rec.start().is_ok() { - *self.is_recording.lock().unwrap() = true; + *self.is_recording.lock().unwrap_or_else(|e| e.into_inner()) = true; self.start_mic_diagnostic(); *state = RecordingState::Recording { binding_id: binding_id.to_string(), @@ -556,7 +596,7 @@ impl AudioRecordingManager { pub fn update_selected_device(&self) -> Result<(), anyhow::Error> { // If currently open, restart the microphone stream to use the new device - if *self.is_open.lock().unwrap() { + if *self.is_open.lock().unwrap_or_else(|e| e.into_inner()) { self.close_generation.fetch_add(1, Ordering::SeqCst); self.stop_microphone_stream(); self.start_microphone_stream()?; @@ -565,7 +605,7 @@ impl AudioRecordingManager { } pub(crate) fn stop_recording(&self, binding_id: &str) -> Option { - let mut state = self.state.lock().unwrap(); + let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner()); match *state { RecordingState::Recording { @@ -584,7 +624,12 @@ impl AudioRecordingManager { std::thread::sleep(Duration::from_millis(settings.extra_recording_buffer_ms)); } - let stop_output = if let Some(rec) = self.recorder.lock().unwrap().as_ref() { + let stop_output = if let Some(rec) = self + .recorder + .lock() + .unwrap_or_else(|e| e.into_inner()) + .as_ref() + { match rec.stop() { Ok(output) => output, Err(e) => { @@ -593,6 +638,7 @@ impl AudioRecordingManager { samples: Vec::new(), raw_samples: Vec::new(), device_error: false, + dropped_resampler_chunks: 0, } } } @@ -602,22 +648,32 @@ impl AudioRecordingManager { samples: Vec::new(), raw_samples: Vec::new(), device_error: false, + dropped_resampler_chunks: 0, } }; - let diagnostic_state = *self.mic_diagnostic_state.lock().unwrap(); + let diagnostic_state = *self + .mic_diagnostic_state + .lock() + .unwrap_or_else(|e| e.into_inner()); let observed_active_signal = self .mic_diagnostic .lock() - .unwrap() + .unwrap_or_else(|e| e.into_inner()) .has_observed_active_signal(); - *self.is_recording.lock().unwrap() = false; + *self.is_recording.lock().unwrap_or_else(|e| e.into_inner()) = false; self.reset_mic_diagnostic(); let crate::audio_toolkit::audio::RecorderStopOutput { samples: gated_samples, raw_samples, device_error, + dropped_resampler_chunks, } = stop_output; + if dropped_resampler_chunks > 0 { + log::warn!( + "Microphone diagnostics: audio resampler dropped {dropped_resampler_chunks} chunk(s) during recording" + ); + } let sample_selection = if device_error { StopSamples::Empty } else { @@ -653,7 +709,10 @@ impl AudioRecordingManager { } // In on-demand mode, close the mic (lazily if the setting is enabled) - if matches!(*self.mode.lock().unwrap(), MicrophoneMode::OnDemand) { + if matches!( + *self.mode.lock().unwrap_or_else(|e| e.into_inner()), + MicrophoneMode::OnDemand + ) { if get_settings(&self.app_handle).lazy_stream_close { self.schedule_lazy_close(); } else { @@ -691,23 +750,23 @@ impl AudioRecordingManager { } pub fn is_recording(&self) -> bool { matches!( - *self.state.lock().unwrap(), + *self.state.lock().unwrap_or_else(|e| e.into_inner()), RecordingState::Recording { .. } ) } pub fn retry_current_recording(&self) -> Result<(), String> { - let state = self.state.lock().unwrap(); + let state = self.state.lock().unwrap_or_else(|e| e.into_inner()); if !matches!(*state, RecordingState::Recording { .. }) { return Err("No active recording to retry".to_string()); } - let recorder_guard = self.recorder.lock().unwrap(); + let recorder_guard = self.recorder.lock().unwrap_or_else(|e| e.into_inner()); let rec = recorder_guard .as_ref() .ok_or_else(|| "Recorder not available".to_string())?; - *self.is_recording.lock().unwrap() = false; + *self.is_recording.lock().unwrap_or_else(|e| e.into_inner()) = false; self.reset_mic_diagnostic(); rec.stop() @@ -715,7 +774,7 @@ impl AudioRecordingManager { match rec.start() { Ok(()) => { - *self.is_recording.lock().unwrap() = true; + *self.is_recording.lock().unwrap_or_else(|e| e.into_inner()) = true; self.start_mic_diagnostic(); utils::emit_overlay_state_changed( &self.app_handle, @@ -724,7 +783,7 @@ impl AudioRecordingManager { Ok(()) } Err(e) => { - *self.is_recording.lock().unwrap() = false; + *self.is_recording.lock().unwrap_or_else(|e| e.into_inner()) = false; self.reset_mic_diagnostic(); utils::emit_overlay_state_changed( &self.app_handle, @@ -737,21 +796,29 @@ impl AudioRecordingManager { /// Cancel any ongoing recording without returning audio samples pub fn cancel_recording(&self) { - let mut state = self.state.lock().unwrap(); + let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner()); if let RecordingState::Recording { .. } = *state { *state = RecordingState::Idle; drop(state); self.reset_mic_diagnostic(); - if let Some(rec) = self.recorder.lock().unwrap().as_ref() { + if let Some(rec) = self + .recorder + .lock() + .unwrap_or_else(|e| e.into_inner()) + .as_ref() + { let _ = rec.stop(); // Discard the result } - *self.is_recording.lock().unwrap() = false; + *self.is_recording.lock().unwrap_or_else(|e| e.into_inner()) = false; // In on-demand mode, close the mic (lazily if the setting is enabled) - if matches!(*self.mode.lock().unwrap(), MicrophoneMode::OnDemand) { + if matches!( + *self.mode.lock().unwrap_or_else(|e| e.into_inner()), + MicrophoneMode::OnDemand + ) { if get_settings(&self.app_handle).lazy_stream_close { self.schedule_lazy_close(); } else { From ac5651dc37af8a2f849d668b6ec8ec0435f7cd10 Mon Sep 17 00:00:00 2001 From: GalaxyRuler Date: Fri, 10 Jul 2026 07:41:31 +0300 Subject: [PATCH 15/20] feat(audio): stable device IDs via cpal 0.17 - survive renames and duplicate names --- src-tauri/Cargo.lock | 70 +++++- src-tauri/Cargo.toml | 2 +- src-tauri/src/audio_feedback.rs | 7 +- src-tauri/src/audio_toolkit/audio/device.rs | 23 +- src-tauri/src/audio_toolkit/audio/recorder.rs | 6 +- src-tauri/src/managers/audio.rs | 209 +++++++++++++++++- src-tauri/src/settings.rs | 52 +++++ src/bindings.ts | 2 +- 8 files changed, 345 insertions(+), 26 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index d17c4a54..e4cd1ac5 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -72,6 +72,18 @@ dependencies = [ "libc", ] +[[package]] +name = "alsa" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c88dbbce13b232b26250e1e2e6ac18b6a891a646b8148285036ebce260ac5c3" +dependencies = [ + "alsa-sys", + "bitflags 2.11.0", + "cfg-if", + "libc", +] + [[package]] name = "alsa-sys" version = "0.3.1" @@ -1145,13 +1157,13 @@ version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cbd307f43cc2a697e2d1f8bc7a1d824b5269e052209e28883e5bc04d095aaa3f" dependencies = [ - "alsa", + "alsa 0.9.1", "coreaudio-rs", "dasp_sample", "jni", "js-sys", "libc", - "mach2", + "mach2 0.4.3", "ndk", "ndk-context", "num-derive", @@ -1165,6 +1177,36 @@ dependencies = [ "windows 0.54.0", ] +[[package]] +name = "cpal" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b1f9c7312f19fc2fa12fd7acaf38de54e8320ba10d1a02dcbe21038def51ccb" +dependencies = [ + "alsa 0.10.0", + "coreaudio-rs", + "dasp_sample", + "jni", + "js-sys", + "libc", + "mach2 0.5.0", + "ndk", + "ndk-context", + "num-derive", + "num-traits", + "objc2", + "objc2-audio-toolbox", + "objc2-avf-audio", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows 0.61.3", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -3269,6 +3311,15 @@ dependencies = [ "libc", ] +[[package]] +name = "mach2" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1b95cd5421ec55b445b5ae102f5ea0e768de1f82bd3001e11f426c269c3aea" +dependencies = [ + "libc", +] + [[package]] name = "macos-accessibility-client" version = "0.0.1" @@ -3759,6 +3810,16 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-avf-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" +dependencies = [ + "objc2", + "objc2-foundation", +] + [[package]] name = "objc2-cloud-kit" version = "0.3.2" @@ -3780,6 +3841,7 @@ dependencies = [ "objc2", "objc2-core-audio-types", "objc2-core-foundation", + "objc2-foundation", ] [[package]] @@ -5178,7 +5240,7 @@ name = "rodio" version = "0.20.1" source = "git+https://github.com/GalaxyRuler/rodio.git?rev=fed30292db417cb95305c118c0e1d804fb74cbff#fed30292db417cb95305c118c0e1d804fb74cbff" dependencies = [ - "cpal", + "cpal 0.16.0", "dasp_sample", "num-rational", "symphonia", @@ -7504,7 +7566,7 @@ dependencies = [ "anyhow", "chrono", "clap", - "cpal", + "cpal 0.17.1", "enigo", "env_filter", "ferrous-opencc", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 86305faa..45067b94 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -56,7 +56,7 @@ rusqlite_migration = "2.3" tauri-plugin-fs = "2.4.4" serde = { version = "1", features = ["derive"] } serde_json = "1" -cpal = "0.16.0" +cpal = "0.17" anyhow = "1.0.95" rubato = "0.16.2" hound = "3.5.1" diff --git a/src-tauri/src/audio_feedback.rs b/src-tauri/src/audio_feedback.rs index ef759a13..9860c723 100644 --- a/src-tauri/src/audio_feedback.rs +++ b/src-tauri/src/audio_feedback.rs @@ -1,7 +1,7 @@ use crate::settings::SoundTheme; use crate::settings::{self, AppSettings}; -use cpal::traits::{DeviceTrait, HostTrait}; use log::{debug, error, warn}; +use rodio::cpal::traits::{DeviceTrait, HostTrait}; use rodio::OutputStreamBuilder; use std::fs::File; use std::io::BufReader; @@ -104,7 +104,10 @@ fn play_audio_file( debug!("Using default device"); OutputStreamBuilder::from_default_device()? } else { - let host = crate::audio_toolkit::get_cpal_host(); + // Rodio is intentionally pinned to cpal 0.16 while capture uses cpal 0.17. + // Enumerate playback devices through rodio's cpal re-export so the device + // type passed to OutputStreamBuilder stays on rodio's side of that boundary. + let host = rodio::cpal::default_host(); let devices = host.output_devices()?; let mut found_device = None; diff --git a/src-tauri/src/audio_toolkit/audio/device.rs b/src-tauri/src/audio_toolkit/audio/device.rs index 61e8fcc2..62bdfaf3 100644 --- a/src-tauri/src/audio_toolkit/audio/device.rs +++ b/src-tauri/src/audio_toolkit/audio/device.rs @@ -3,24 +3,35 @@ use cpal::traits::{DeviceTrait, HostTrait}; pub struct CpalDeviceInfo { pub index: String, pub name: String, + pub stable_id: Option, pub is_default: bool, pub device: cpal::Device, } +fn device_name(device: &cpal::Device) -> Result { + device + .description() + .map(|description| description.name().to_string()) +} + pub fn list_input_devices() -> Result, Box> { let host = crate::audio_toolkit::get_cpal_host(); - let default_name = host.default_input_device().and_then(|d| d.name().ok()); + let default_name = host + .default_input_device() + .and_then(|device| device_name(&device).ok()); let mut out = Vec::::new(); for (index, device) in host.input_devices()?.enumerate() { - let name = device.name().unwrap_or_else(|_| "Unknown".into()); + let stable_id = device.id().ok().map(|id| id.to_string()); + let name = device_name(&device).unwrap_or_else(|_| "Unknown".into()); let is_default = Some(name.clone()) == default_name; out.push(CpalDeviceInfo { index: index.to_string(), name, + stable_id, is_default, device, }); @@ -31,18 +42,22 @@ pub fn list_input_devices() -> Result, Box Result, Box> { let host = crate::audio_toolkit::get_cpal_host(); - let default_name = host.default_output_device().and_then(|d| d.name().ok()); + let default_name = host + .default_output_device() + .and_then(|device| device_name(&device).ok()); let mut out = Vec::::new(); for (index, device) in host.output_devices()?.enumerate() { - let name = device.name().unwrap_or_else(|_| "Unknown".into()); + let stable_id = device.id().ok().map(|id| id.to_string()); + let name = device_name(&device).unwrap_or_else(|_| "Unknown".into()); let is_default = Some(name.clone()) == default_name; out.push(CpalDeviceInfo { index: index.to_string(), name, + stable_id, is_default, device, }); diff --git a/src-tauri/src/audio_toolkit/audio/recorder.rs b/src-tauri/src/audio_toolkit/audio/recorder.rs index 386799d5..d4aa9519 100644 --- a/src-tauri/src/audio_toolkit/audio/recorder.rs +++ b/src-tauri/src/audio_toolkit/audio/recorder.rs @@ -126,7 +126,7 @@ impl AudioRecorder { let config = AudioRecorder::get_preferred_config(&thread_device) .map_err(|e| format!("Failed to fetch preferred config: {e}"))?; - let sample_rate = config.sample_rate().0; + let sample_rate = config.sample_rate(); let channels = config.channels() as usize; let frame_resampler = FrameResampler::new( sample_rate as usize, @@ -137,7 +137,9 @@ impl AudioRecorder { log::info!( "Using device: {:?}\nSample rate: {}\nChannels: {}\nFormat: {:?}", - thread_device.name(), + thread_device + .description() + .map(|description| description.name().to_string()), sample_rate, channels, config.sample_format() diff --git a/src-tauri/src/managers/audio.rs b/src-tauri/src/managers/audio.rs index fbf419fb..a80858c3 100644 --- a/src-tauri/src/managers/audio.rs +++ b/src-tauri/src/managers/audio.rs @@ -1,9 +1,9 @@ use super::mic_diagnostics::{MicDiagnosticState, SilenceDiagnostic}; use crate::audio_toolkit::{list_input_devices, vad::SmoothedVad, AudioRecorder, SileroVad}; use crate::helpers::clamshell; -use crate::settings::{get_settings, AppSettings}; +use crate::settings::{get_settings, write_settings_domain, AppSettings, SettingsWriteDomain}; use crate::utils; -use log::{debug, error, info}; +use log::{debug, error, info, warn}; use serde::Serialize; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; @@ -174,6 +174,7 @@ pub enum MicrophoneMode { OnDemand, } +#[cfg(test)] pub fn selected_microphone_unavailable_error(device_name: &str) -> anyhow::Error { anyhow::anyhow!("{SELECTED_MICROPHONE_UNAVAILABLE_PREFIX}: {device_name}") } @@ -186,6 +187,42 @@ fn is_default_microphone_selection(device_name: &str) -> bool { device_name.eq_ignore_ascii_case("default") } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct DeviceSelectionInfo<'a> { + stable_id: Option<&'a str>, + name: &'a str, +} + +fn resolve_device( + devices: &[DeviceSelectionInfo<'_>], + stored_id: Option<&str>, + stored_name: Option<&str>, +) -> Option { + if stored_name.is_some_and(is_default_microphone_selection) { + return None; + } + + if let Some(stored_id) = stored_id { + if let Some(index) = devices + .iter() + .position(|device| device.stable_id == Some(stored_id)) + { + return Some(index); + } + } + + stored_name.and_then(|stored_name| devices.iter().position(|device| device.name == stored_name)) +} + +fn stable_id_write_back_is_current( + current_name: Option<&str>, + current_id: Option<&str>, + resolved_name: &str, + resolved_from_id: Option<&str>, +) -> bool { + current_name == Some(resolved_name) && current_id == resolved_from_id +} + /* ──────────────────────────────────────────────────────────────── */ fn create_audio_recorder( @@ -320,6 +357,11 @@ impl AudioRecordingManager { } else { settings.selected_microphone.as_deref() }; + let stored_id = if use_clamshell_mic { + None + } else { + settings.selected_microphone_id.as_deref() + }; let Some(device_name) = device_name else { return Ok(None); @@ -328,21 +370,96 @@ impl AudioRecordingManager { return Ok(None); } - // Find the device by name match list_input_devices() { - Ok(devices) => { - if let Some(device) = devices - .into_iter() - .find(|d| d.name == device_name) - .map(|d| d.device) - { - Ok(Some(device)) + Ok(mut devices) => { + let resolved_index = { + let candidates = devices + .iter() + .map(|device| DeviceSelectionInfo { + stable_id: device.stable_id.as_deref(), + name: &device.name, + }) + .collect::>(); + resolve_device(&candidates, stored_id, Some(device_name)) + }; + + if let Some(index) = resolved_index { + let device = devices.swap_remove(index); + + if !use_clamshell_mic { + if let Some(stable_id) = device.stable_id.clone() { + if settings.selected_microphone_id.as_deref() + != Some(stable_id.as_str()) + { + let stored_stable_id = stable_id.clone(); + let mut did_persist = false; + if let Err(err) = write_settings_domain( + &self.app_handle, + SettingsWriteDomain::Audio, + |current_settings| { + if stable_id_write_back_is_current( + current_settings.selected_microphone.as_deref(), + current_settings.selected_microphone_id.as_deref(), + device_name, + stored_id, + ) { + current_settings.selected_microphone_id = + Some(stored_stable_id); + did_persist = true; + } + }, + ) { + warn!( + "Failed to persist stable ID for microphone '{}': {}", + device_name, err + ); + } else if did_persist { + debug!( + "Persisted stable ID for microphone '{}': {}", + device_name, stable_id + ); + } else { + debug!( + "Skipped stable ID write-back because microphone selection changed from '{}'", + device_name + ); + } + } + } + } + + Ok(Some(device.device)) } else { - Err(selected_microphone_unavailable_error(device_name)) + warn!( + "Selected microphone '{}' is unavailable; falling back to default", + device_name + ); + let _ = self.app_handle.emit( + "recording-error", + RecordingErrorEvent { + error_type: "selected_microphone_unavailable".to_string(), + detail: Some(format!( + "selected_microphone={device_name}; fallback_to_default=true" + )), + }, + ); + Ok(None) } } Err(e) => { - debug!("Failed to list devices, using default: {}", e); + warn!( + "Failed to list devices while resolving '{}'; using default: {}", + device_name, e + ); + let _ = self.app_handle.emit( + "recording-error", + RecordingErrorEvent { + error_type: "selected_microphone_unavailable".to_string(), + detail: Some(format!( + "selected_microphone={device_name}; fallback_to_default=true; error={e}" + )), + }, + ); Ok(None) } } @@ -833,6 +950,74 @@ impl AudioRecordingManager { mod tests { use super::*; + fn dev<'a>(stable_id: Option<&'a str>, name: &'a str) -> DeviceSelectionInfo<'a> { + DeviceSelectionInfo { stable_id, name } + } + + #[test] + fn device_resolution_prefers_stable_id_over_duplicate_name() { + let devices = vec![ + dev(Some("wasapi:AAA"), "USB Audio Device"), + dev(Some("wasapi:BBB"), "USB Audio Device"), + ]; + + assert_eq!( + resolve_device(&devices, Some("wasapi:BBB"), Some("USB Audio Device")), + Some(1) + ); + } + + #[test] + fn device_resolution_permanently_falls_back_to_name_when_ids_do_not_match_or_error() { + let devices = vec![ + dev(None, "USB Audio Device"), + dev(Some("wasapi:BBB"), "USB Audio Device"), + ]; + + assert_eq!( + resolve_device(&devices, Some("wasapi:GONE"), Some("USB Audio Device")), + Some(0) + ); + assert_eq!( + resolve_device(&devices, None, Some("USB Audio Device")), + Some(0) + ); + } + + #[test] + fn device_resolution_returns_none_for_default_or_no_match() { + let devices = vec![dev(Some("wasapi:AAA"), "USB Audio Device")]; + + assert_eq!(resolve_device(&devices, None, Some("Default")), None); + assert_eq!(resolve_device(&devices, Some("wasapi:GONE"), None), None); + assert_eq!( + resolve_device(&devices, None, Some("Missing Microphone")), + None + ); + } + + #[test] + fn stable_id_write_back_requires_unchanged_name_and_id() { + assert!(stable_id_write_back_is_current( + Some("USB Audio Device"), + Some("wasapi:GONE"), + "USB Audio Device", + Some("wasapi:GONE") + )); + assert!(!stable_id_write_back_is_current( + Some("USB Audio Device"), + Some("wasapi:BBB"), + "USB Audio Device", + Some("wasapi:GONE") + )); + assert!(!stable_id_write_back_is_current( + Some("Other Microphone"), + Some("wasapi:GONE"), + "USB Audio Device", + Some("wasapi:GONE") + )); + } + #[test] fn empty_vad_output_falls_back_to_raw_when_signal_observed() { assert_eq!( diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 6e471594..bc2a9de4 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -532,6 +532,8 @@ pub struct AppSettings { #[serde(default)] pub selected_microphone: Option, #[serde(default)] + pub selected_microphone_id: Option, + #[serde(default)] pub clamshell_microphone: Option, #[serde(default)] pub selected_output_device: Option, @@ -1290,6 +1292,7 @@ pub fn get_default_settings() -> AppSettings { selected_model: "".to_string(), always_on_microphone: false, selected_microphone: None, + selected_microphone_id: None, clamshell_microphone: None, selected_output_device: None, translate_to_english: false, @@ -1672,6 +1675,12 @@ pub fn apply_settings_mutation( f(settings) } +fn reconcile_selected_microphone_identity(previous_name: Option<&str>, settings: &mut AppSettings) { + if settings.selected_microphone.as_deref() != previous_name { + settings.selected_microphone_id = None; + } +} + /// The ONLY public way to mutate persisted settings. Holds the write lock across the /// whole read-modify-write so concurrent mutations cannot lost-update each other. /// Do NOT `.await` or emit Tauri events inside `f`; emit after this returns. @@ -1680,7 +1689,9 @@ pub fn mutate_settings_locked(app: &AppHandle, f: impl FnOnce(&mut AppSetting .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); let mut settings = get_settings(app); + let selected_microphone_before = settings.selected_microphone.clone(); let result = apply_settings_mutation(&mut settings, f); + reconcile_selected_microphone_identity(selected_microphone_before.as_deref(), &mut settings); write_settings(app, settings); result } @@ -1764,7 +1775,9 @@ where F: FnOnce(&mut AppSettings) -> Result<(), String>, { let mut next = settings.clone(); + let selected_microphone_before = next.selected_microphone.clone(); mutate(&mut next)?; + reconcile_selected_microphone_identity(selected_microphone_before.as_deref(), &mut next); *settings = next; Ok(()) } @@ -1887,6 +1900,45 @@ mod tests { assert_eq!(settings.selected_model, "unchanged-model"); } + #[test] + fn settings_domain_clears_microphone_id_only_when_selected_name_changes() { + let mut settings = get_default_settings(); + settings.selected_microphone = Some("Old Microphone".to_string()); + settings.selected_microphone_id = Some("wasapi:OLD".to_string()); + + mutate_settings_domain(&mut settings, SettingsWriteDomain::Privacy, |settings| { + settings.history_enabled = !settings.history_enabled; + }) + .expect("unrelated mutation should succeed"); + assert_eq!( + settings.selected_microphone_id.as_deref(), + Some("wasapi:OLD") + ); + + mutate_settings_domain(&mut settings, SettingsWriteDomain::Audio, |settings| { + settings.selected_microphone = Some("New Microphone".to_string()); + }) + .expect("microphone selection mutation should succeed"); + assert_eq!(settings.selected_microphone_id, None); + } + + #[test] + fn microphone_identity_reconciliation_preserves_same_name_and_clears_changed_name() { + let mut settings = get_default_settings(); + settings.selected_microphone = Some("Old Microphone".to_string()); + settings.selected_microphone_id = Some("wasapi:OLD".to_string()); + + reconcile_selected_microphone_identity(Some("Old Microphone"), &mut settings); + assert_eq!( + settings.selected_microphone_id.as_deref(), + Some("wasapi:OLD") + ); + + settings.selected_microphone = Some("New Microphone".to_string()); + reconcile_selected_microphone_identity(Some("Old Microphone"), &mut settings); + assert_eq!(settings.selected_microphone_id, None); + } + #[test] fn model_settings_round_trip_preserves_cpu_accelerator_and_startup_sensitive_fields() { let mut settings = get_default_settings(); diff --git a/src/bindings.ts b/src/bindings.ts index fd74529d..46d04d83 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -1411,7 +1411,7 @@ export type AdaptiveProfile = { id: string; name: string; enabled: boolean; clea export type AndroidAsrEngineKind = "zipformerWhisper" | "senseVoice" | "canary" | "moonshine" | "parakeet" export type AndroidAsrModelPackState = { id: string; displayName: string; description: string; language: string; sizeMb: number; minRamMb: number; engineKind: AndroidAsrEngineKind; installedDir: string; isInstalled: boolean; isDownloading: boolean; isActive: boolean; isSelectable: boolean; downloadPhase: string; downloadProgress: number; missingFiles: string[] } export type AndroidLlmModelPackState = { id: string; displayName: string; description: string; runtime: string; license: string; quantization: string; sizeMb: number; minRamMb: number; installedDir: string; modelPath: string; isInstalled: boolean; isDownloading: boolean; isActive: boolean; isSelectable: boolean; downloadPhase: string; downloadProgress: number; missingFiles: string[] } -export type AppSettings = { bindings: Partial<{ [key in string]: ShortcutBinding }>; push_to_talk: boolean; audio_feedback: boolean; audio_feedback_volume?: number; sound_theme?: SoundTheme; start_hidden?: boolean; autostart_enabled?: boolean; update_checks_enabled?: boolean; selected_model?: string; always_on_microphone?: boolean; selected_microphone?: string | null; clamshell_microphone?: string | null; selected_output_device?: string | null; translate_to_english?: boolean; translation_enabled?: boolean; translation_request?: TranslationRequestSettings | null; translation_provider_id?: string | null; translation_model_id?: string | null; selected_language?: string; dictation_language_mode?: DictationLanguageMode; overlay_position?: OverlayPosition; docked_pill_enabled?: boolean; warn_on_elevated_target?: boolean; debug_mode?: boolean; log_level?: LogLevel; custom_words?: string[]; dictionary_entries?: DictionaryEntry[]; dictionary_auto_learn_suppressed?: string[]; dictionary_learn_candidates?: LearnCandidate[]; dictionary_schema_version?: number; auto_add_dictionary_words?: boolean; dictionary_diagnostics?: DictionaryDiagnostics; snippets?: SnippetEntry[]; model_unload_timeout?: ModelUnloadTimeout; word_correction_threshold?: number; history_enabled?: boolean; recordings_enabled?: boolean; history_limit?: number; recording_retention_period?: RecordingRetentionPeriod; paste_method?: PasteMethod; clipboard_handling?: ClipboardHandling; auto_submit?: boolean; auto_submit_key?: AutoSubmitKey; post_process_enabled?: boolean; formatting_level?: FormattingLevel; post_process_provider_id?: string; post_process_providers?: PostProcessProvider[]; post_process_api_keys?: SecretMap; post_process_models?: Partial<{ [key in string]: string }>; post_process_prompts?: LLMPrompt[]; post_process_selected_prompt_id?: string | null; local_llm?: LocalLlmSettings; mute_while_recording?: boolean; append_trailing_space?: boolean; app_language?: string; experimental_enabled?: boolean; lazy_stream_close?: boolean; keyboard_implementation?: KeyboardImplementation; show_tray_icon?: boolean; paste_delay_ms?: number; typing_tool?: TypingTool; external_script_path: string | null; custom_filler_words?: string[] | null; adaptive_profiles_enabled?: boolean; context_awareness_enabled?: boolean; context_nearby_text_enabled?: boolean; adaptive_language_shortlist?: string[]; adaptive_default_profile_id?: string; adaptive_profiles?: AdaptiveProfile[]; adaptive_correction_memory_enabled?: boolean; adaptive_private_app_patterns?: string[]; whisper_accelerator?: WhisperAcceleratorSetting; ort_accelerator?: OrtAcceleratorSetting; whisper_gpu_device?: number; extra_recording_buffer_ms?: number } +export type AppSettings = { bindings: Partial<{ [key in string]: ShortcutBinding }>; push_to_talk: boolean; audio_feedback: boolean; audio_feedback_volume?: number; sound_theme?: SoundTheme; start_hidden?: boolean; autostart_enabled?: boolean; update_checks_enabled?: boolean; selected_model?: string; always_on_microphone?: boolean; selected_microphone?: string | null; selected_microphone_id?: string | null; clamshell_microphone?: string | null; selected_output_device?: string | null; translate_to_english?: boolean; translation_enabled?: boolean; translation_request?: TranslationRequestSettings | null; translation_provider_id?: string | null; translation_model_id?: string | null; selected_language?: string; dictation_language_mode?: DictationLanguageMode; overlay_position?: OverlayPosition; docked_pill_enabled?: boolean; warn_on_elevated_target?: boolean; debug_mode?: boolean; log_level?: LogLevel; custom_words?: string[]; dictionary_entries?: DictionaryEntry[]; dictionary_auto_learn_suppressed?: string[]; dictionary_learn_candidates?: LearnCandidate[]; dictionary_schema_version?: number; auto_add_dictionary_words?: boolean; dictionary_diagnostics?: DictionaryDiagnostics; snippets?: SnippetEntry[]; model_unload_timeout?: ModelUnloadTimeout; word_correction_threshold?: number; history_enabled?: boolean; recordings_enabled?: boolean; history_limit?: number; recording_retention_period?: RecordingRetentionPeriod; paste_method?: PasteMethod; clipboard_handling?: ClipboardHandling; auto_submit?: boolean; auto_submit_key?: AutoSubmitKey; post_process_enabled?: boolean; formatting_level?: FormattingLevel; post_process_provider_id?: string; post_process_providers?: PostProcessProvider[]; post_process_api_keys?: SecretMap; post_process_models?: Partial<{ [key in string]: string }>; post_process_prompts?: LLMPrompt[]; post_process_selected_prompt_id?: string | null; local_llm?: LocalLlmSettings; mute_while_recording?: boolean; append_trailing_space?: boolean; app_language?: string; experimental_enabled?: boolean; lazy_stream_close?: boolean; keyboard_implementation?: KeyboardImplementation; show_tray_icon?: boolean; paste_delay_ms?: number; typing_tool?: TypingTool; external_script_path: string | null; custom_filler_words?: string[] | null; adaptive_profiles_enabled?: boolean; context_awareness_enabled?: boolean; context_nearby_text_enabled?: boolean; adaptive_language_shortlist?: string[]; adaptive_default_profile_id?: string; adaptive_profiles?: AdaptiveProfile[]; adaptive_correction_memory_enabled?: boolean; adaptive_private_app_patterns?: string[]; whisper_accelerator?: WhisperAcceleratorSetting; ort_accelerator?: OrtAcceleratorSetting; whisper_gpu_device?: number; extra_recording_buffer_ms?: number } export type AudioDevice = { index: string; name: string; is_default: boolean } export type AutoSubmitKey = "enter" | "ctrl_enter" | "cmd_enter" export type AvailableAccelerators = { whisper: string[]; ort: string[]; gpu_devices: GpuDeviceOption[] } From e3df6f8b5f7971cb88f114dee785dd17c9717548 Mon Sep 17 00:00:00 2001 From: GalaxyRuler Date: Fri, 10 Jul 2026 13:22:36 +0300 Subject: [PATCH 16/20] fix(audio): preserve legacy device selections across cpal names --- src-tauri/src/audio_feedback.rs | 15 +++- src-tauri/src/audio_toolkit/audio/device.rs | 14 ++++ src-tauri/src/audio_toolkit/audio/mod.rs | 2 +- src-tauri/src/audio_toolkit/mod.rs | 5 +- src-tauri/src/managers/audio.rs | 83 ++++++++++++++++++++- 5 files changed, 111 insertions(+), 8 deletions(-) diff --git a/src-tauri/src/audio_feedback.rs b/src-tauri/src/audio_feedback.rs index 9860c723..1f1d94e8 100644 --- a/src-tauri/src/audio_feedback.rs +++ b/src-tauri/src/audio_feedback.rs @@ -1,3 +1,4 @@ +use crate::audio_toolkit::device_names_match; use crate::settings::SoundTheme; use crate::settings::{self, AppSettings}; use log::{debug, error, warn}; @@ -110,13 +111,21 @@ fn play_audio_file( let host = rodio::cpal::default_host(); let devices = host.output_devices()?; - let mut found_device = None; + let mut exact_device = None; + let mut compatible_device = None; for device in devices { - if device.name()? == device_name { - found_device = Some(device); + let enumerated_name = device.name()?; + if enumerated_name == device_name { + exact_device = Some(device); break; } + + if compatible_device.is_none() && device_names_match(&enumerated_name, &device_name) + { + compatible_device = Some(device); + } } + let found_device = exact_device.or(compatible_device); match found_device { Some(device) => OutputStreamBuilder::from_device(device)?, diff --git a/src-tauri/src/audio_toolkit/audio/device.rs b/src-tauri/src/audio_toolkit/audio/device.rs index 62bdfaf3..30664985 100644 --- a/src-tauri/src/audio_toolkit/audio/device.rs +++ b/src-tauri/src/audio_toolkit/audio/device.rs @@ -8,6 +8,20 @@ pub struct CpalDeviceInfo { pub device: cpal::Device, } +fn has_legacy_driver_suffix(short_name: &str, legacy_name: &str) -> bool { + legacy_name + .strip_prefix(short_name) + .is_some_and(|suffix| suffix.starts_with(" (")) +} + +/// Matches the short cpal 0.17 device description with cpal 0.16's legacy +/// `" ()"` representation in either direction. +pub fn device_names_match(first_name: &str, second_name: &str) -> bool { + first_name == second_name + || has_legacy_driver_suffix(first_name, second_name) + || has_legacy_driver_suffix(second_name, first_name) +} + fn device_name(device: &cpal::Device) -> Result { device .description() diff --git a/src-tauri/src/audio_toolkit/audio/mod.rs b/src-tauri/src/audio_toolkit/audio/mod.rs index b32636b5..a522a7f7 100644 --- a/src-tauri/src/audio_toolkit/audio/mod.rs +++ b/src-tauri/src/audio_toolkit/audio/mod.rs @@ -5,7 +5,7 @@ mod resampler; mod utils; mod visualizer; -pub use device::{list_input_devices, list_output_devices, CpalDeviceInfo}; +pub use device::{device_names_match, list_input_devices, list_output_devices, CpalDeviceInfo}; pub use recorder::{ is_microphone_access_denied, is_no_input_device_error, AudioRecorder, RecorderStopOutput, }; diff --git a/src-tauri/src/audio_toolkit/mod.rs b/src-tauri/src/audio_toolkit/mod.rs index f3afa509..e778a10c 100644 --- a/src-tauri/src/audio_toolkit/mod.rs +++ b/src-tauri/src/audio_toolkit/mod.rs @@ -5,8 +5,9 @@ pub mod utils; pub mod vad; pub use audio::{ - is_microphone_access_denied, is_no_input_device_error, list_input_devices, list_output_devices, - read_wav_samples, save_wav_file, verify_wav_file, AudioRecorder, CpalDeviceInfo, + device_names_match, is_microphone_access_denied, is_no_input_device_error, list_input_devices, + list_output_devices, read_wav_samples, save_wav_file, verify_wav_file, AudioRecorder, + CpalDeviceInfo, }; pub use text::{apply_custom_words, apply_dictionary_entries, filter_transcription_output}; pub use utils::get_cpal_host; diff --git a/src-tauri/src/managers/audio.rs b/src-tauri/src/managers/audio.rs index a80858c3..0d2d6572 100644 --- a/src-tauri/src/managers/audio.rs +++ b/src-tauri/src/managers/audio.rs @@ -1,5 +1,7 @@ use super::mic_diagnostics::{MicDiagnosticState, SilenceDiagnostic}; -use crate::audio_toolkit::{list_input_devices, vad::SmoothedVad, AudioRecorder, SileroVad}; +use crate::audio_toolkit::{ + device_names_match, list_input_devices, vad::SmoothedVad, AudioRecorder, SileroVad, +}; use crate::helpers::clamshell; use crate::settings::{get_settings, write_settings_domain, AppSettings, SettingsWriteDomain}; use crate::utils; @@ -211,7 +213,16 @@ fn resolve_device( } } - stored_name.and_then(|stored_name| devices.iter().position(|device| device.name == stored_name)) + stored_name.and_then(|stored_name| { + devices + .iter() + .position(|device| device.name == stored_name) + .or_else(|| { + devices + .iter() + .position(|device| device_names_match(device.name, stored_name)) + }) + }) } fn stable_id_write_back_is_current( @@ -984,6 +995,74 @@ mod tests { ); } + #[test] + fn device_resolution_keeps_exact_name_match() { + let devices = vec![ + dev(Some("wasapi:CABLE"), "CABLE Output"), + dev(Some("wasapi:CABLE-X"), "CABLE Output X"), + ]; + + assert_eq!( + resolve_device(&devices, None, Some("CABLE Output")), + Some(0) + ); + } + + #[test] + fn device_resolution_accepts_legacy_parenthesized_driver_suffix() { + let devices = vec![dev(Some("wasapi:CABLE"), "CABLE Output")]; + + assert_eq!( + resolve_device( + &devices, + None, + Some("CABLE Output (VB-Audio Virtual Cable)") + ), + Some(0) + ); + } + + #[test] + fn device_resolution_rejects_non_parenthesized_name_prefix() { + let devices = vec![dev(Some("wasapi:CABLE"), "CABLE Output")]; + + assert_eq!(resolve_device(&devices, None, Some("CABLE Output X")), None); + } + + #[test] + fn device_resolution_accepts_short_selection_against_legacy_output_name() { + let devices = vec![dev( + Some("wasapi:CABLE"), + "CABLE Output (VB-Audio Virtual Cable)", + )]; + + assert_eq!( + resolve_device(&devices, None, Some("CABLE Output")), + Some(0) + ); + } + + #[test] + fn device_resolution_prefers_exact_match_over_duplicate_legacy_short_names() { + let devices = vec![ + dev(Some("wasapi:SHORT-A"), "CABLE Output"), + dev( + Some("wasapi:EXACT"), + "CABLE Output (VB-Audio Virtual Cable)", + ), + dev(Some("wasapi:SHORT-B"), "CABLE Output"), + ]; + + assert_eq!( + resolve_device( + &devices, + None, + Some("CABLE Output (VB-Audio Virtual Cable)") + ), + Some(1) + ); + } + #[test] fn device_resolution_returns_none_for_default_or_no_match() { let devices = vec![dev(Some("wasapi:AAA"), "USB Audio Device")]; From b475f8c0013dd5472caebad77e3b315945c58a55 Mon Sep 17 00:00:00 2001 From: GalaxyRuler Date: Fri, 10 Jul 2026 18:08:42 +0300 Subject: [PATCH 17/20] fix(history): preserve recordings when deletion must be retried --- src-tauri/src/commands/history.rs | 8 +- src-tauri/src/managers/history.rs | 415 ++++++++++++++---- src/bindings.ts | 9 +- .../settings/history/HistorySettings.tsx | 35 +- src/i18n/locales/ar/translation.json | 1 + src/i18n/locales/bg/translation.json | 1 + src/i18n/locales/cs/translation.json | 1 + src/i18n/locales/de/translation.json | 1 + src/i18n/locales/en/translation.json | 1 + src/i18n/locales/es/translation.json | 1 + src/i18n/locales/fr/translation.json | 1 + src/i18n/locales/he/translation.json | 1 + src/i18n/locales/it/translation.json | 1 + src/i18n/locales/ja/translation.json | 1 + src/i18n/locales/ko/translation.json | 1 + src/i18n/locales/pl/translation.json | 1 + src/i18n/locales/pt/translation.json | 1 + src/i18n/locales/ru/translation.json | 1 + src/i18n/locales/sv/translation.json | 1 + src/i18n/locales/tr/translation.json | 1 + src/i18n/locales/uk/translation.json | 1 + src/i18n/locales/vi/translation.json | 1 + src/i18n/locales/zh-TW/translation.json | 1 + src/i18n/locales/zh/translation.json | 1 + 24 files changed, 396 insertions(+), 91 deletions(-) diff --git a/src-tauri/src/commands/history.rs b/src-tauri/src/commands/history.rs index e24cd4ea..bb359651 100644 --- a/src-tauri/src/commands/history.rs +++ b/src-tauri/src/commands/history.rs @@ -1,6 +1,6 @@ use crate::actions::process_transcription_output; use crate::managers::{ - history::{HistoryManager, PaginatedHistory}, + history::{HistoryDeletionOutcome, HistoryManager, PaginatedHistory}, transcription::TranscriptionManager, }; use std::sync::Arc; @@ -75,7 +75,7 @@ pub async fn delete_history_entry( _app: AppHandle, history_manager: State<'_, Arc>, id: i64, -) -> Result<(), String> { +) -> Result { history_manager .delete_entry(id) .await @@ -87,7 +87,7 @@ pub async fn delete_history_entry( pub async fn clear_history( _app: AppHandle, history_manager: State<'_, Arc>, -) -> Result { +) -> Result { history_manager .clear_history() .await @@ -99,7 +99,7 @@ pub async fn clear_history( pub async fn clear_recordings( _app: AppHandle, history_manager: State<'_, Arc>, -) -> Result { +) -> Result { history_manager .clear_unsaved_recordings() .await diff --git a/src-tauri/src/managers/history.rs b/src-tauri/src/managers/history.rs index 87703cb4..41b9cc56 100644 --- a/src-tauri/src/managers/history.rs +++ b/src-tauri/src/managers/history.rs @@ -5,6 +5,7 @@ use rusqlite::{params, Connection, OptionalExtension}; use rusqlite_migration::{Migrations, M}; use serde::{Deserialize, Serialize}; use specta::Type; +use std::collections::HashSet; use std::fs; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -56,6 +57,27 @@ pub struct PaginatedHistory { pub has_more: bool, } +#[derive(Clone, Debug, Serialize, Deserialize, Type, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum HistoryDeletionFailureReason { + PermissionDenied, + FileSystem, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Type, PartialEq, Eq)] +pub struct HistoryDeletionFailure { + pub id: Option, + pub file_name: String, + pub reason: HistoryDeletionFailureReason, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, Type, PartialEq, Eq)] +pub struct HistoryDeletionOutcome { + pub requested_count: usize, + pub deleted_count: usize, + pub failures: Vec, +} + #[derive(Clone, Debug, Serialize, Deserialize, Type, tauri_specta::Event)] #[serde(tag = "action")] pub enum HistoryUpdatePayload { @@ -172,6 +194,22 @@ impl HistoryManager { // Initialize database and run migrations synchronously manager.init_database()?; + match manager.reconcile_orphan_recordings() { + Ok(outcome) if !outcome.failures.is_empty() => { + warn!( + "Deferred deletion for {} orphaned recording(s)", + outcome.failures.len() + ); + } + Ok(_) => {} + Err(error) => { + warn!( + "Could not reconcile orphaned recordings at startup: {}", + error + ); + } + } + Ok(manager) } @@ -611,46 +649,153 @@ impl HistoryManager { } } - fn delete_entries_and_files(&self, entries: &[(i64, String)]) -> Result { + fn delete_entries_and_files( + &self, + entries: &[(i64, String)], + ) -> Result { if entries.is_empty() { - return Ok(0); + return Ok(HistoryDeletionOutcome::default()); } let conn = self.get_connection()?; - let mut deleted_count = 0; + Self::delete_entries_and_files_with(&conn, &self.recordings_dir, entries, &|path| { + fs::remove_file(path) + }) + } + + fn delete_entries_and_files_with( + conn: &Connection, + recordings_dir: &Path, + entries: &[(i64, String)], + delete_file: &F, + ) -> Result + where + F: Fn(&Path) -> std::io::Result<()>, + { + let mut outcome = HistoryDeletionOutcome { + requested_count: entries.len(), + ..HistoryDeletionOutcome::default() + }; for (id, file_name) in entries { - // Delete database entry - deleted_count += conn.execute( + if !file_name.trim().is_empty() { + let file_path = recordings_dir.join(file_name); + match delete_file(&file_path) { + Ok(()) => debug!("Deleted WAV file: {}", file_name), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + debug!("WAV file already absent: {}", file_name); + } + Err(error) => { + error!("Failed to delete WAV file {}: {}", file_name, error); + outcome.failures.push(HistoryDeletionFailure { + id: Some(*id), + file_name: file_name.clone(), + reason: Self::deletion_failure_reason(&error), + }); + continue; + } + } + } + + outcome.deleted_count += conn.execute( "DELETE FROM transcription_history WHERE id = ?1", params![id], )?; + } - if file_name.trim().is_empty() { + Ok(outcome) + } + + fn deletion_failure_reason(error: &std::io::Error) -> HistoryDeletionFailureReason { + if error.kind() == std::io::ErrorKind::PermissionDenied { + HistoryDeletionFailureReason::PermissionDenied + } else { + HistoryDeletionFailureReason::FileSystem + } + } + + fn reconcile_orphan_recordings(&self) -> Result { + let conn = self.get_connection()?; + Self::reconcile_orphan_recordings_with(&conn, &self.recordings_dir, &|path| { + fs::remove_file(path) + }) + } + + fn reconcile_orphan_recordings_with( + conn: &Connection, + recordings_dir: &Path, + delete_file: &F, + ) -> Result + where + F: Fn(&Path) -> std::io::Result<()>, + { + let mut stmt = + conn.prepare("SELECT file_name FROM transcription_history WHERE file_name != ''")?; + let tracked_recordings = stmt + .query_map([], |row| row.get::<_, String>(0))? + .collect::, _>>()?; + + let mut outcome = HistoryDeletionOutcome::default(); + for entry in fs::read_dir(recordings_dir)? { + let entry = entry?; + let path = entry.path(); + let is_wav = path + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("wav")); + if !is_wav || !path.is_file() { continue; } - // Delete WAV file - let file_path = self.recordings_dir.join(file_name); - if file_path.exists() { - if let Err(e) = fs::remove_file(&file_path) { - error!("Failed to delete WAV file {}: {}", file_name, e); - } else { - debug!("Deleted old WAV file: {}", file_name); + let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + if tracked_recordings.contains(file_name) { + continue; + } + + outcome.requested_count += 1; + match delete_file(&path) { + Ok(()) => { + debug!("Deleted orphaned WAV file: {}", file_name); + outcome.deleted_count += 1; + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + debug!("Orphaned WAV file already absent: {}", file_name); + outcome.deleted_count += 1; + } + Err(error) => { + error!( + "Failed to delete orphaned WAV file {}: {}", + file_name, error + ); + outcome.failures.push(HistoryDeletionFailure { + id: None, + file_name: file_name.to_string(), + reason: Self::deletion_failure_reason(&error), + }); } } } - Ok(deleted_count) + Ok(outcome) } fn cleanup_by_count(&self, limit: usize) -> Result<()> { let conn = self.get_connection()?; let entries_to_delete = Self::count_cleanup_candidates(&conn, limit)?; - let deleted_count = self.delete_entries_and_files(&entries_to_delete)?; + let outcome = self.delete_entries_and_files(&entries_to_delete)?; - if deleted_count > 0 { - debug!("Cleaned up {} old history entries by count", deleted_count); + if outcome.deleted_count > 0 { + debug!( + "Cleaned up {} old history entries by count", + outcome.deleted_count + ); + } + if !outcome.failures.is_empty() { + warn!( + "{} history recording deletion(s) remain pending after count cleanup", + outcome.failures.len() + ); } Ok(()) @@ -700,6 +845,24 @@ impl HistoryManager { Ok(entries) } + fn all_history_deletion_candidates(conn: &Connection) -> Result> { + let mut stmt = conn.prepare( + "SELECT id, + CASE WHEN transform_action IS NULL THEN file_name ELSE '' END + FROM transcription_history", + )?; + let rows = stmt.query_map([], |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)) + })?; + + let mut entries = Vec::new(); + for row in rows { + entries.push(row?); + } + + Ok(entries) + } + fn cleanup_by_time( &self, retention_period: crate::settings::RecordingRetentionPeriod, @@ -730,12 +893,18 @@ impl HistoryManager { entries_to_delete.push(row?); } - let deleted_count = self.delete_entries_and_files(&entries_to_delete)?; + let outcome = self.delete_entries_and_files(&entries_to_delete)?; - if deleted_count > 0 { + if outcome.deleted_count > 0 { debug!( "Cleaned up {} old history entries based on retention period", - deleted_count + outcome.deleted_count + ); + } + if !outcome.failures.is_empty() { + warn!( + "{} history recording deletion(s) remain pending after retention cleanup", + outcome.failures.len() ); } @@ -965,79 +1134,64 @@ impl HistoryManager { Ok(entry) } - pub async fn delete_entry(&self, id: i64) -> Result<()> { + pub async fn delete_entry(&self, id: i64) -> Result { let conn = self.get_connection()?; + let entry = conn + .query_row( + "SELECT file_name, transform_action FROM transcription_history WHERE id = ?1", + params![id], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, Option>(1)?)), + ) + .optional()?; + let Some((file_name, transform_action)) = entry else { + return Ok(HistoryDeletionOutcome::default()); + }; - // Get the entry to find the file name - if let Some(entry) = self.get_entry_by_id(id).await? { - // Delete the audio file first - if !entry.file_name.trim().is_empty() { - let file_path = self.get_audio_file_path(&entry.file_name); - if file_path.exists() { - if let Err(e) = fs::remove_file(&file_path) { - error!("Failed to delete audio file {}: {}", entry.file_name, e); - // Continue with database deletion even if file deletion fails - } - } - } - } - - // Delete from database - conn.execute( - "DELETE FROM transcription_history WHERE id = ?1", - params![id], + let deletion_file_name = if transform_action.is_some() { + String::new() + } else { + file_name + }; + let outcome = Self::delete_entries_and_files_with( + &conn, + &self.recordings_dir, + &[(id, deletion_file_name)], + &|path| fs::remove_file(path), )?; - debug!("Deleted history entry with id: {}", id); - - // Emit history updated event - if let Err(e) = (HistoryUpdatePayload::Deleted { id }).emit(&self.app_handle) { - error!("Failed to emit history-updated event: {}", e); + if outcome.deleted_count > 0 { + debug!("Deleted history entry with id: {}", id); + if let Err(e) = (HistoryUpdatePayload::Deleted { id }).emit(&self.app_handle) { + error!("Failed to emit history-updated event: {}", e); + } } - Ok(()) + Ok(outcome) } - pub async fn clear_history(&self) -> Result { + pub async fn clear_history(&self) -> Result { let conn = self.get_connection()?; - let mut stmt = conn.prepare( - "SELECT id, file_name - FROM transcription_history - WHERE transform_action IS NULL", - )?; - let rows = stmt.query_map([], |row| { - Ok((row.get::<_, i64>("id")?, row.get::<_, String>("file_name")?)) - })?; - - let mut recording_entries = Vec::new(); - for row in rows { - recording_entries.push(row?); - } - - for (_, file_name) in &recording_entries { - if file_name.trim().is_empty() { - continue; - } - - let file_path = self.get_audio_file_path(file_name); - if file_path.exists() { - if let Err(e) = fs::remove_file(&file_path) { - error!("Failed to delete audio file {}: {}", file_name, e); - } - } - } - - let deleted = conn.execute("DELETE FROM transcription_history", [])?; - debug!("Cleared {} history entries", deleted); - Ok(deleted) + let entries = Self::all_history_deletion_candidates(&conn)?; + let outcome = + Self::delete_entries_and_files_with(&conn, &self.recordings_dir, &entries, &|path| { + fs::remove_file(path) + })?; + debug!("Cleared {} history entries", outcome.deleted_count); + Ok(outcome) } - pub async fn clear_unsaved_recordings(&self) -> Result { + pub async fn clear_unsaved_recordings(&self) -> Result { let conn = self.get_connection()?; let entries = Self::unsaved_recording_file_candidates(&conn)?; - let deleted = self.delete_entries_and_files(&entries)?; - debug!("Cleared {} unsaved recording entries", deleted); - Ok(deleted) + let outcome = + Self::delete_entries_and_files_with(&conn, &self.recordings_dir, &entries, &|path| { + fs::remove_file(path) + })?; + debug!( + "Cleared {} unsaved recording entries", + outcome.deleted_count + ); + Ok(outcome) } fn format_timestamp_title(&self, timestamp: i64) -> String { @@ -1454,6 +1608,107 @@ mod tests { assert_eq!(candidates, vec![(1, "verbatim-100.wav".to_string())]); } + #[test] + fn delete_failure_preserves_row_and_reports_typed_retry_pending() { + let conn = setup_conn(); + insert_entry_with_file_name(&conn, 100, "locked.wav", false, "retry me", None); + let entry_id = conn.last_insert_rowid(); + let recordings_dir = tempfile::tempdir().expect("recordings tempdir"); + let recording_path = recordings_dir.path().join("locked.wav"); + std::fs::write(&recording_path, b"sensitive audio").expect("write recording"); + + let outcome = HistoryManager::delete_entries_and_files_with( + &conn, + recordings_dir.path(), + &[(entry_id, "locked.wav".to_string())], + &|_| { + Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "recording is locked", + )) + }, + ) + .expect("partial deletion outcome"); + + assert_eq!(outcome.requested_count, 1); + assert_eq!(outcome.deleted_count, 0); + assert_eq!( + outcome.failures, + vec![HistoryDeletionFailure { + id: Some(entry_id), + file_name: "locked.wav".to_string(), + reason: HistoryDeletionFailureReason::PermissionDenied, + }] + ); + let row_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM transcription_history WHERE id = ?1", + [entry_id], + |row| row.get(0), + ) + .expect("count preserved row"); + assert_eq!(row_count, 1, "failed deletion must remain retryable"); + assert!( + recording_path.exists(), + "failed deletion must not hide the file" + ); + } + + #[test] + fn already_absent_recording_is_treated_as_deleted() { + let conn = setup_conn(); + insert_entry_with_file_name(&conn, 100, "missing.wav", false, "delete me", None); + let entry_id = conn.last_insert_rowid(); + let recordings_dir = tempfile::tempdir().expect("recordings tempdir"); + + let outcome = HistoryManager::delete_entries_and_files_with( + &conn, + recordings_dir.path(), + &[(entry_id, "missing.wav".to_string())], + &|_| Err(std::io::Error::from(std::io::ErrorKind::NotFound)), + ) + .expect("missing recording deletion outcome"); + + assert_eq!(outcome.requested_count, 1); + assert_eq!(outcome.deleted_count, 1); + assert!(outcome.failures.is_empty()); + let row_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM transcription_history WHERE id = ?1", + [entry_id], + |row| row.get(0), + ) + .expect("count deleted row"); + assert_eq!(row_count, 0); + } + + #[test] + fn orphan_reconciliation_removes_orphans_and_preserves_tracked_recordings() { + let conn = setup_conn(); + insert_entry_with_file_name(&conn, 100, "tracked.wav", false, "keep me", None); + let recordings_dir = tempfile::tempdir().expect("recordings tempdir"); + let tracked_path = recordings_dir.path().join("tracked.wav"); + let orphan_path = recordings_dir.path().join("orphan.wav"); + std::fs::write(&tracked_path, b"tracked audio").expect("write tracked recording"); + std::fs::write(&orphan_path, b"orphaned audio").expect("write orphan recording"); + + let outcome = HistoryManager::reconcile_orphan_recordings_with( + &conn, + recordings_dir.path(), + &|path| std::fs::remove_file(path), + ) + .expect("orphan reconciliation outcome"); + + assert_eq!(outcome.requested_count, 1); + assert_eq!(outcome.deleted_count, 1); + assert!(outcome.failures.is_empty()); + assert!( + tracked_path.exists(), + "referenced recording must be preserved" + ); + assert!(!orphan_path.exists(), "orphaned recording must be removed"); + } + #[test] fn adaptive_history_entry_can_hold_routing_metadata() { let entry = HistoryEntry { diff --git a/src/bindings.ts b/src/bindings.ts index 46d04d83..00d1d8b4 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -1282,7 +1282,7 @@ async getAudioFilePath(fileName: string) : Promise> { else return { status: "error", error: e as any }; } }, -async deleteHistoryEntry(id: number) : Promise> { +async deleteHistoryEntry(id: number) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("delete_history_entry", { id }) }; } catch (e) { @@ -1290,7 +1290,7 @@ async deleteHistoryEntry(id: number) : Promise> { else return { status: "error", error: e as any }; } }, -async clearHistory() : Promise> { +async clearHistory() : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("clear_history") }; } catch (e) { @@ -1298,7 +1298,7 @@ async clearHistory() : Promise> { else return { status: "error", error: e as any }; } }, -async clearRecordings() : Promise> { +async clearRecordings() : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("clear_recordings") }; } catch (e) { @@ -1430,6 +1430,9 @@ export type DictionaryEntryUpdate = { phrase?: string | null; replacement_of?: s export type EngineType = "Whisper" | "Parakeet" | "Moonshine" | "MoonshineStreaming" | "SenseVoice" | "GigaAM" | "Canary" | "Cohere" export type FormattingLevel = "none" | "light" | "medium" | "high" export type GpuDeviceOption = { id: number; name: string; total_vram_mb: number } +export type HistoryDeletionFailure = { id: number | null; file_name: string; reason: HistoryDeletionFailureReason } +export type HistoryDeletionFailureReason = "permission_denied" | "file_system" +export type HistoryDeletionOutcome = { requested_count: number; deleted_count: number; failures: HistoryDeletionFailure[] } export type HistoryEntry = { id: number; file_name: string; timestamp: number; saved: boolean; title: string; transcription_text: string; post_processed_text: string | null; post_process_prompt: string | null; post_process_requested: boolean; adaptive_profile_id: string | null; adaptive_profile_name: string | null; adaptive_routing_json: string | null; adaptive_context_json: string | null; adaptive_language_json: string | null; adaptive_insertion_json: string | null; adaptive_parent_entry_id: number | null; transform_action: string | null; transform_original_text: string | null; transform_result_text: string | null; transform_target_language: string | null; transform_provider_id: string | null; transform_model: string | null; transform_recovery_status: string | null } export type HistoryUpdatePayload = { action: "added"; entry: HistoryEntry } | { action: "updated"; entry: HistoryEntry } | { action: "deleted"; id: number } | { action: "toggled"; id: number } /** diff --git a/src/components/settings/history/HistorySettings.tsx b/src/components/settings/history/HistorySettings.tsx index 4b37cdfa..3d91d68d 100644 --- a/src/components/settings/history/HistorySettings.tsx +++ b/src/components/settings/history/HistorySettings.tsx @@ -323,6 +323,14 @@ export const HistorySettings: React.FC = () => { if (result.status !== "ok") { // Reload on failure loadPage(); + } else if (result.data.failures.length > 0) { + await loadPage(); + toast.warning( + t("settings.history.deletePartial", { + deleted: result.data.deleted_count, + requested: result.data.requested_count, + }), + ); } } catch (error) { console.error("Failed to delete entry:", error); @@ -360,7 +368,16 @@ export const HistorySettings: React.FC = () => { throw new Error(String(result.error)); } await loadPage(); - toast.success(t("settings.history.clearRecordingsSuccess")); + if (result.data.failures.length > 0) { + toast.warning( + t("settings.history.deletePartial", { + deleted: result.data.deleted_count, + requested: result.data.requested_count, + }), + ); + } else { + toast.success(t("settings.history.clearRecordingsSuccess")); + } } catch (error) { console.error("Failed to clear recordings:", error); toast.error(t("settings.history.clearRecordingsError")); @@ -380,9 +397,19 @@ export const HistorySettings: React.FC = () => { if (result.status !== "ok") { throw new Error(String(result.error)); } - setEntries([]); - setHasMore(false); - toast.success(t("settings.history.clearHistorySuccess")); + if (result.data.failures.length > 0) { + await loadPage(); + toast.warning( + t("settings.history.deletePartial", { + deleted: result.data.deleted_count, + requested: result.data.requested_count, + }), + ); + } else { + setEntries([]); + setHasMore(false); + toast.success(t("settings.history.clearHistorySuccess")); + } } catch (error) { console.error("Failed to clear history:", error); toast.error(t("settings.history.clearHistoryError")); diff --git a/src/i18n/locales/ar/translation.json b/src/i18n/locales/ar/translation.json index a09f2d47..8a1a67d5 100644 --- a/src/i18n/locales/ar/translation.json +++ b/src/i18n/locales/ar/translation.json @@ -589,6 +589,7 @@ "unsave": "إزالة من المحفوظات", "delete": "حذف الإدخال", "deleteError": ".فشل حذف الإدخال. يرجى المحاولة مرة أخرى", + "deletePartial": "تم حذف {{deleted}} من {{requested}}؛ إعادة المحاولة قيد الانتظار.", "retranscribe": "إعادة النسخ", "retranscribeError": "فشلت إعادة النسخ. يرجى المحاولة مرة أخرى.", "transcribing": "جارٍ النسخ...", diff --git a/src/i18n/locales/bg/translation.json b/src/i18n/locales/bg/translation.json index 7710d697..71d39386 100644 --- a/src/i18n/locales/bg/translation.json +++ b/src/i18n/locales/bg/translation.json @@ -611,6 +611,7 @@ "unsave": "Премахване от запазените", "delete": "Изтриване на записа", "deleteError": "Изтриването на записа не бе успешно. Опитайте отново.", + "deletePartial": "{{deleted}} от {{requested}} са изтрити; повторният опит предстои.", "retranscribe": "Повторна транскрипция", "retranscribeError": "Повторната транскрипция не бе успешна. Опитайте отново.", "transcribing": "Транскрибиране...", diff --git a/src/i18n/locales/cs/translation.json b/src/i18n/locales/cs/translation.json index 5d2e53bd..c28d9b6d 100644 --- a/src/i18n/locales/cs/translation.json +++ b/src/i18n/locales/cs/translation.json @@ -611,6 +611,7 @@ "unsave": "Odebrat z uložených", "delete": "Smazat záznam", "deleteError": "Nepodařilo se smazat záznam. Zkuste to prosím znovu.", + "deletePartial": "{{deleted}} z {{requested}} odstraněno; opakování čeká.", "retranscribe": "Přepsat znovu", "retranscribeError": "Opětovný přepis se nezdařil. Zkuste to prosím znovu.", "transcribing": "Přepisuji...", diff --git a/src/i18n/locales/de/translation.json b/src/i18n/locales/de/translation.json index 8ff8db83..7c2055ad 100644 --- a/src/i18n/locales/de/translation.json +++ b/src/i18n/locales/de/translation.json @@ -611,6 +611,7 @@ "unsave": "Aus Gespeicherten entfernen", "delete": "Eintrag löschen", "deleteError": "Eintrag konnte nicht gelöscht werden. Bitte versuche es erneut.", + "deletePartial": "{{deleted}} von {{requested}} gelöscht; erneuter Versuch steht aus.", "retranscribe": "Erneut transkribieren", "retranscribeError": "Erneute Transkription fehlgeschlagen. Bitte versuche es erneut.", "transcribing": "Transkribiere...", diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index c289de8c..745f6f03 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -611,6 +611,7 @@ "unsave": "Remove from saved", "delete": "Delete entry", "deleteError": "Failed to delete entry. Please try again.", + "deletePartial": "{{deleted}} of {{requested}} deleted; retry pending.", "retranscribe": "Re-transcribe", "retranscribeError": "Failed to re-transcribe. Please try again.", "transcribing": "Transcribing...", diff --git a/src/i18n/locales/es/translation.json b/src/i18n/locales/es/translation.json index 26d66dd2..70155a41 100644 --- a/src/i18n/locales/es/translation.json +++ b/src/i18n/locales/es/translation.json @@ -611,6 +611,7 @@ "unsave": "Eliminar de guardados", "delete": "Eliminar entrada", "deleteError": "Error al eliminar la entrada. Por favor, intenta de nuevo.", + "deletePartial": "{{deleted}} de {{requested}} eliminados; reintento pendiente.", "retranscribe": "Re-transcribir", "retranscribeError": "No se pudo re-transcribir. Por favor, inténtalo de nuevo.", "transcribing": "Transcribiendo...", diff --git a/src/i18n/locales/fr/translation.json b/src/i18n/locales/fr/translation.json index ecb15df5..e1a1553d 100644 --- a/src/i18n/locales/fr/translation.json +++ b/src/i18n/locales/fr/translation.json @@ -611,6 +611,7 @@ "unsave": "Retirer des favoris", "delete": "Supprimer l'entrée", "deleteError": "Échec de la suppression de l'entrée. Veuillez réessayer.", + "deletePartial": "{{deleted}} sur {{requested}} supprimés ; nouvelle tentative en attente.", "retranscribe": "Re-transcrire", "retranscribeError": "Impossible de re-transcrire. Veuillez réessayer.", "transcribing": "Transcription en cours...", diff --git a/src/i18n/locales/he/translation.json b/src/i18n/locales/he/translation.json index bc0c6117..293b8db8 100644 --- a/src/i18n/locales/he/translation.json +++ b/src/i18n/locales/he/translation.json @@ -611,6 +611,7 @@ "unsave": "הסר משמורים", "delete": "מחק רשומה", "deleteError": "מחיקת הרשומה נכשלה. נסה שוב.", + "deletePartial": "{{deleted}} מתוך {{requested}} נמחקו; ניסיון חוזר ממתין.", "retranscribe": "תמלל מחדש", "retranscribeError": "תמלול מחדש נכשל. נסה שוב.", "transcribing": "מתמלל...", diff --git a/src/i18n/locales/it/translation.json b/src/i18n/locales/it/translation.json index 8bbb8db3..42335f61 100644 --- a/src/i18n/locales/it/translation.json +++ b/src/i18n/locales/it/translation.json @@ -611,6 +611,7 @@ "unsave": "Rimuovi dai salvataggi", "delete": "Elimina elemento", "deleteError": "Errore nell'eliminazione dell'elemento. Riprova.", + "deletePartial": "{{deleted}} di {{requested}} eliminati; nuovo tentativo in attesa.", "retranscribe": "Ri-trascrivere", "retranscribeError": "Impossibile ri-trascrivere. Riprova.", "transcribing": "Trascrizione in corso...", diff --git a/src/i18n/locales/ja/translation.json b/src/i18n/locales/ja/translation.json index ecea6afc..f4ec7574 100644 --- a/src/i18n/locales/ja/translation.json +++ b/src/i18n/locales/ja/translation.json @@ -611,6 +611,7 @@ "unsave": "保存から削除", "delete": "エントリーを削除", "deleteError": "エントリーの削除に失敗しました。もう一度お試しください。", + "deletePartial": "{{requested}} 件中 {{deleted}} 件を削除しました。再試行待ちです。", "retranscribe": "再文字起こし", "retranscribeError": "再文字起こしに失敗しました。もう一度お試しください。", "transcribing": "文字起こし中...", diff --git a/src/i18n/locales/ko/translation.json b/src/i18n/locales/ko/translation.json index 54a8ddeb..a5e58a90 100644 --- a/src/i18n/locales/ko/translation.json +++ b/src/i18n/locales/ko/translation.json @@ -611,6 +611,7 @@ "unsave": "저장에서 제거", "delete": "항목 삭제", "deleteError": "항목 삭제에 실패했습니다. 다시 시도해주세요.", + "deletePartial": "{{requested}}개 중 {{deleted}}개 삭제됨. 재시도 대기 중.", "retranscribe": "다시 전사", "retranscribeError": "다시 전사에 실패했습니다. 다시 시도해주세요.", "transcribing": "전사 중...", diff --git a/src/i18n/locales/pl/translation.json b/src/i18n/locales/pl/translation.json index f33c38b5..251b4090 100644 --- a/src/i18n/locales/pl/translation.json +++ b/src/i18n/locales/pl/translation.json @@ -611,6 +611,7 @@ "unsave": "Usuń z zapisanych", "delete": "Usuń wpis", "deleteError": "Nie udało się usunąć wpisu. Spróbuj ponownie.", + "deletePartial": "{{deleted}} z {{requested}} usunięto; ponowna próba oczekuje.", "retranscribe": "Transkrybuj ponownie", "retranscribeError": "Nie udało się ponownie transkrybować. Spróbuj ponownie.", "transcribing": "Transkrybuję...", diff --git a/src/i18n/locales/pt/translation.json b/src/i18n/locales/pt/translation.json index 094afbfa..5635e8f3 100644 --- a/src/i18n/locales/pt/translation.json +++ b/src/i18n/locales/pt/translation.json @@ -611,6 +611,7 @@ "unsave": "Remover dos salvos", "delete": "Excluir entrada", "deleteError": "Falha ao excluir entrada. Por favor, tente novamente.", + "deletePartial": "{{deleted}} de {{requested}} excluídos; nova tentativa pendente.", "retranscribe": "Re-transcrever", "retranscribeError": "Falha ao re-transcrever. Por favor, tente novamente.", "transcribing": "Transcrevendo...", diff --git a/src/i18n/locales/ru/translation.json b/src/i18n/locales/ru/translation.json index ab69f606..31ef3dab 100644 --- a/src/i18n/locales/ru/translation.json +++ b/src/i18n/locales/ru/translation.json @@ -611,6 +611,7 @@ "unsave": "Удалить из сохраненных", "delete": "Удалить запись", "deleteError": "Не удалось удалить запись. Пожалуйста, попробуйте еще раз.", + "deletePartial": "{{deleted}} из {{requested}} удалено; ожидается повторная попытка.", "retranscribe": "Перетранскрибировать", "retranscribeError": "Не удалось перетранскрибировать. Пожалуйста, попробуйте снова.", "transcribing": "Транскрибирование...", diff --git a/src/i18n/locales/sv/translation.json b/src/i18n/locales/sv/translation.json index 461da434..9893a2c8 100644 --- a/src/i18n/locales/sv/translation.json +++ b/src/i18n/locales/sv/translation.json @@ -611,6 +611,7 @@ "unsave": "Ta bort från sparade", "delete": "Ta bort post", "deleteError": "Misslyckades med att ta bort post. Försök igen.", + "deletePartial": "{{deleted}} av {{requested}} borttagna; nytt försök väntar.", "retranscribe": "Transkribera igen", "retranscribeError": "Misslyckades med att transkribera igen. Försök igen.", "transcribing": "Transkriberar...", diff --git a/src/i18n/locales/tr/translation.json b/src/i18n/locales/tr/translation.json index 11ac6fd6..a5abe5ae 100644 --- a/src/i18n/locales/tr/translation.json +++ b/src/i18n/locales/tr/translation.json @@ -611,6 +611,7 @@ "unsave": "Kaydedilenlerden kaldır", "delete": "Kaydı sil", "deleteError": "Kayıt silinemedi. Lütfen tekrar deneyin.", + "deletePartial": "{{requested}} kaydın {{deleted}} kadarı silindi; yeniden deneme bekliyor.", "retranscribe": "Yeniden yazıya dök", "retranscribeError": "Yeniden yazıya dökme başarısız oldu. Lütfen tekrar deneyin.", "transcribing": "Yazıya döküyor...", diff --git a/src/i18n/locales/uk/translation.json b/src/i18n/locales/uk/translation.json index 931967f0..af1e8558 100644 --- a/src/i18n/locales/uk/translation.json +++ b/src/i18n/locales/uk/translation.json @@ -611,6 +611,7 @@ "unsave": "Видалити зі збережених", "delete": "Видалити запис", "deleteError": "Не вдалося видалити запис. Спробуйте ще раз.", + "deletePartial": "{{deleted}} з {{requested}} видалено; повторна спроба очікується.", "retranscribe": "Перетранскрибувати", "retranscribeError": "Не вдалося перетранскрибувати. Будь ласка, спробуйте ще раз.", "transcribing": "Транскрибування...", diff --git a/src/i18n/locales/vi/translation.json b/src/i18n/locales/vi/translation.json index 75583255..cae061cc 100644 --- a/src/i18n/locales/vi/translation.json +++ b/src/i18n/locales/vi/translation.json @@ -611,6 +611,7 @@ "unsave": "Xóa khỏi đã lưu", "delete": "Xóa mục", "deleteError": "Không thể xóa mục. Vui lòng thử lại.", + "deletePartial": "Đã xóa {{deleted}} trong số {{requested}}; đang chờ thử lại.", "retranscribe": "Phiên âm lại", "retranscribeError": "Không thể phiên âm lại. Vui lòng thử lại.", "transcribing": "Đang phiên âm...", diff --git a/src/i18n/locales/zh-TW/translation.json b/src/i18n/locales/zh-TW/translation.json index 7db6a7fe..f6857dc9 100644 --- a/src/i18n/locales/zh-TW/translation.json +++ b/src/i18n/locales/zh-TW/translation.json @@ -611,6 +611,7 @@ "unsave": "從已儲存中移除", "delete": "刪除條目", "deleteError": "刪除條目失敗,請重試", + "deletePartial": "已刪除 {{requested}} 個中的 {{deleted}} 個;等待重試。", "retranscribe": "重新轉錄", "retranscribeError": "重新轉錄失敗,請重試。", "transcribing": "轉錄中...", diff --git a/src/i18n/locales/zh/translation.json b/src/i18n/locales/zh/translation.json index f66b538b..36dc3f21 100644 --- a/src/i18n/locales/zh/translation.json +++ b/src/i18n/locales/zh/translation.json @@ -611,6 +611,7 @@ "unsave": "从已保存中移除", "delete": "删除条目", "deleteError": "删除条目失败,请重试。", + "deletePartial": "{{requested}} 个中已删除 {{deleted}} 个;等待重试。", "retranscribe": "重新转录", "retranscribeError": "重新转录失败。请重试。", "transcribing": "转录中...", From a3753013e33b565514eb99b8cb35c0846627fcbe Mon Sep 17 00:00:00 2001 From: GalaxyRuler Date: Fri, 10 Jul 2026 21:01:19 +0300 Subject: [PATCH 18/20] fix(ci): disable unsafe manual PR test builds Disabled because untrusted PR code cannot safely reuse the privileged build workflow. --- .github/workflows/pr-test-build.yml | 59 ----------------------------- 1 file changed, 59 deletions(-) delete mode 100644 .github/workflows/pr-test-build.yml diff --git a/.github/workflows/pr-test-build.yml b/.github/workflows/pr-test-build.yml deleted file mode 100644 index 9048de1a..00000000 --- a/.github/workflows/pr-test-build.yml +++ /dev/null @@ -1,59 +0,0 @@ -name: "PR Test Build" - -on: - workflow_dispatch: - inputs: - pr_number: - description: "PR number to build" - required: true - type: string - -jobs: - build-test: - permissions: - contents: write - id-token: write - attestations: write - strategy: - fail-fast: false - matrix: - include: - - platform: "macos-26" - args: "--target aarch64-apple-darwin" - target: "aarch64-apple-darwin" - - platform: "ubuntu-22.04" - args: "--bundles deb" - target: "x86_64-unknown-linux-gnu" - - platform: "windows-latest" - args: "" - target: "x86_64-pc-windows-msvc" - - uses: ./.github/workflows/build.yml - with: - platform: ${{ matrix.platform }} - target: ${{ matrix.target }} - build-args: ${{ matrix.args }} - sign-binaries: false - asset-prefix: "verbatim-pr-${{ inputs.pr_number }}" - upload-artifacts: true - is-debug-build: ${{ contains(matrix.args, '--debug') }} - ref: ${{ format('refs/pull/{0}/merge', inputs.pr_number) }} - secrets: inherit - - comment-on-pr: - needs: build-test - runs-on: ubuntu-latest - permissions: - pull-requests: write - steps: - - name: Post artifact links to PR - uses: actions/github-script@v7 - with: - script: | - const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: ${{ inputs.pr_number }}, - body: `## 🧪 Test Build Ready\n\nBuild artifacts for PR #${{ inputs.pr_number }} are available for testing.\n\n**[Download artifacts from workflow run](${runUrl})**\n\nArtifacts expire after 30 days.` - }); From 614e1a1391a945ae00287d56963d4b4ab13d5f07 Mon Sep 17 00:00:00 2001 From: GalaxyRuler Date: Fri, 10 Jul 2026 21:49:39 +0300 Subject: [PATCH 19/20] ci: enforce unified pull-request validation Require the exact ci-required context through branch rules so the policy check reports the live ruleset gap instead of querying the legacy branch-protection endpoint. Add one path-filter-free Windows PR gate for backend checks and lib tests, frontend lint/typecheck/build, and deterministic Specta binding verification. The bindings exporter receives the common-controls manifest after linking, limiting the workaround to that executable and preserving the default Cargo test wrapper. --- .github/workflows/ci-required.yml | 91 ++++++++++++++++++ package.json | 2 + scripts/check-bindings-windows.ps1 | 123 ++++++++++++++++++++++++ scripts/check-branch-protection.test.ts | 92 ++++++++++++++++++ scripts/check-branch-protection.ts | 58 ++++++----- 5 files changed, 340 insertions(+), 26 deletions(-) create mode 100644 .github/workflows/ci-required.yml create mode 100644 scripts/check-bindings-windows.ps1 create mode 100644 scripts/check-branch-protection.test.ts diff --git a/.github/workflows/ci-required.yml b/.github/workflows/ci-required.yml new file mode 100644 index 00000000..126211d1 --- /dev/null +++ b/.github/workflows/ci-required.yml @@ -0,0 +1,91 @@ +name: ci-required + +on: + pull_request: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + ci-required: + name: ci-required + runs-on: windows-latest + timeout-minutes: 60 + env: + CARGO_TERM_COLOR: always + GGML_NATIVE: "OFF" + GGML_AVX: "ON" + GGML_AVX2: "ON" + GGML_FMA: "ON" + GGML_F16C: "ON" + + steps: + - name: Enable long paths + shell: pwsh + run: | + New-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem" ` + -Name "LongPathsEnabled" -Value 1 -PropertyType DWORD -Force + git config --system core.longpaths true + + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - uses: dtolnay/rust-toolchain@stable + + - uses: swatinem/rust-cache@v2 + with: + workspaces: "./src-tauri -> target" + key: ci-required-windows + + - name: Install Windows native dependencies + shell: pwsh + run: | + if (-not (Get-Command ninja -ErrorAction SilentlyContinue)) { + choco install ninja -y --no-progress + } + + - name: Install Vulkan SDK + uses: humbletim/install-vulkan-sdk@v1.2 + with: + version: 1.4.309.0 + cache: true + + - name: Configure Windows native build + shell: pwsh + run: | + $drive = Split-Path -Qualifier $env:GITHUB_WORKSPACE + $targetDir = "$drive\t" + New-Item -ItemType Directory -Force -Path $targetDir | Out-Null + "CARGO_TARGET_DIR=$targetDir" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + "CMAKE_GENERATOR=Ninja" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + "TrackFileAccess=false" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + + - name: Install frontend dependencies + run: bun install --frozen-lockfile + + - name: Lint frontend + run: bun run lint + + - name: Check TypeScript + run: bun run check + + - name: Compile frontend app + run: bun run build + + - name: Check backend + shell: pwsh + run: powershell -NoProfile -ExecutionPolicy Bypass -File scripts/cargo-check-windows.ps1 + + - name: Test Rust library + shell: pwsh + run: powershell -NoProfile -ExecutionPolicy Bypass -File scripts/cargo-test-windows.ps1 --lib + + - name: Check generated TypeScript bindings + run: bun run check:bindings diff --git a/package.json b/package.json index c790f50c..35ca7ade 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "dev": "vite", + "check": "tsc --noEmit", "build": "tsc && vite build", "preview": "vite preview", "tauri": "tauri", @@ -34,6 +35,7 @@ "check:public-hygiene": "bun scripts/check-public-hygiene.ts", "check:cargo-git-pins": "bun scripts/check-cargo-git-pins.ts", "check:branch-protection": "bun scripts/check-branch-protection.ts", + "check:bindings": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/check-bindings-windows.ps1", "check:rust-dependency-policy": "cargo deny --manifest-path src-tauri/Cargo.toml check", "check:tauri-security": "bun scripts/validate-tauri-security.ts", "check:model-benchmark-evidence": "bun scripts/check-model-benchmark-evidence.ts", diff --git a/scripts/check-bindings-windows.ps1 b/scripts/check-bindings-windows.ps1 new file mode 100644 index 00000000..35b99c06 --- /dev/null +++ b/scripts/check-bindings-windows.ps1 @@ -0,0 +1,123 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$repoRoot = Split-Path -Parent $PSScriptRoot +$manifestPath = Join-Path $repoRoot "src-tauri\Cargo.toml" +$testManifestPath = Join-Path $repoRoot "src-tauri\windows\test-common-controls.manifest" +$targetDir = if ($env:CARGO_TARGET_DIR) { + $env:CARGO_TARGET_DIR +} else { + "C:\t\verbatim" +} + +if (-not (Test-Path -LiteralPath $testManifestPath)) { + throw "Windows test manifest is missing: $testManifestPath" +} + +New-Item -ItemType Directory -Force -Path $targetDir | Out-Null +$env:CARGO_TARGET_DIR = $targetDir + +if (-not $env:TrackFileAccess) { + $env:TrackFileAccess = "false" +} + +if (-not $env:CMAKE_GENERATOR) { + if (Get-Command ninja -ErrorAction SilentlyContinue) { + $env:CMAKE_GENERATOR = "Ninja" + } else { + throw "Ninja is required for Windows native builds. Install Ninja or put ninja.exe on PATH." + } +} + +if ($env:CMAKE_GENERATOR -ne "Ninja") { + Write-Warning "CMAKE_GENERATOR=$env:CMAKE_GENERATOR; Verbatim's Windows native build is verified with Ninja." +} + +Write-Host "Building ignored TypeScript bindings exporter" +$cargoMessages = @( + & cargo test --manifest-path $manifestPath --test export_bindings --no-run --message-format=json +) +$cargoExitCode = $LASTEXITCODE + +if ($cargoExitCode -ne 0) { + foreach ($line in $cargoMessages) { + try { + $message = $line | ConvertFrom-Json + if ($message.reason -eq "compiler-message" -and $message.message.rendered) { + Write-Host $message.message.rendered.TrimEnd() + } + } catch { + Write-Host $line + } + } + exit $cargoExitCode +} + +$exporterExecutables = @( + @( + foreach ($line in $cargoMessages) { + try { + $message = $line | ConvertFrom-Json + } catch { + continue + } + + if ( + $message.reason -eq "compiler-artifact" -and + $message.target.name -eq "export_bindings" -and + $message.executable + ) { + $message.executable + } + } + ) | Select-Object -Unique +) + +if ($exporterExecutables.Count -ne 1) { + throw "Expected one bindings exporter executable, found $($exporterExecutables.Count)." +} + +$manifestTool = Get-Command mt.exe -ErrorAction SilentlyContinue +if ($null -eq $manifestTool) { + $windowsKitsRoot = Join-Path ([Environment]::GetFolderPath("ProgramFilesX86")) "Windows Kits\10\bin" + $manifestTool = @( + Get-ChildItem -LiteralPath $windowsKitsRoot -Directory -ErrorAction SilentlyContinue | + Sort-Object Name -Descending | + ForEach-Object { + $candidate = Join-Path $_.FullName "x64\mt.exe" + if (Test-Path -LiteralPath $candidate) { + $candidate + } + } + ) | Select-Object -First 1 +} + +if ($null -eq $manifestTool) { + throw "mt.exe is required to apply the Windows test manifest to the bindings exporter." +} + +$manifestToolPath = if ($manifestTool -is [string]) { + $manifestTool +} else { + $manifestTool.Source +} +$exporterExecutable = $exporterExecutables[0] + +Write-Host "Applying Windows test manifest to bindings exporter" +& $manifestToolPath "-manifest" $testManifestPath "-outputresource:$exporterExecutable;#1" +if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE +} + +Push-Location -LiteralPath (Join-Path $repoRoot "src-tauri") +try { + & $exporterExecutable "--ignored" + if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE + } +} finally { + Pop-Location +} + +& git -C $repoRoot diff --exit-code -- src/bindings.ts +exit $LASTEXITCODE diff --git a/scripts/check-branch-protection.test.ts b/scripts/check-branch-protection.test.ts new file mode 100644 index 00000000..153aae72 --- /dev/null +++ b/scripts/check-branch-protection.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const repoRoot = join(import.meta.dir, ".."); + +const rulesWithRequiredCiCheck = JSON.stringify([ + { + type: "required_status_checks", + parameters: { + required_status_checks: [ + { context: "code-quality" }, + { context: "ci-required" }, + ], + }, + }, +]); + +const rulesWithoutRequiredCiCheck = JSON.stringify([ + { + type: "required_status_checks", + parameters: { + required_status_checks: [{ context: "code-quality" }], + }, + }, +]); + +function runChecker(response: string) { + const tempDir = mkdtempSync(join(tmpdir(), "branch-protection-check-")); + const responsePath = join(tempDir, "response.json"); + const argsPath = join(tempDir, "gh-args.txt"); + const ghPath = join(tempDir, "gh.cmd"); + + try { + writeFileSync(responsePath, response); + writeFileSync( + ghPath, + [ + "@echo off", + 'echo %* > "%FAKE_GH_ARGS_FILE%"', + 'type "%FAKE_GH_RESPONSE_FILE%"', + ].join("\r\n"), + ); + + const result = Bun.spawnSync( + ["bun", "scripts/check-branch-protection.ts"], + { + cwd: repoRoot, + env: { + ...process.env, + PATH: `${tempDir};${process.env.PATH ?? ""}`, + FAKE_GH_ARGS_FILE: argsPath, + FAKE_GH_RESPONSE_FILE: responsePath, + }, + stdout: "pipe", + stderr: "pipe", + }, + ); + + return { + exitCode: result.exitCode, + stdout: new TextDecoder().decode(result.stdout), + stderr: new TextDecoder().decode(result.stderr), + ghArgs: readFileSync(argsPath, "utf8").trim(), + }; + } finally { + // The caller has already received all observable process output above. + rmSync(tempDir, { recursive: true, force: true }); + } +} + +describe("check-branch-protection", () => { + test("requires ci-required from the branch rules API", () => { + const result = runChecker(rulesWithRequiredCiCheck); + + expect(result.exitCode).toBe(0); + expect(result.ghArgs).toBe( + "api repos/GalaxyRuler/Verbatim/rules/branches/main", + ); + expect(result.stdout).toContain("ci-required"); + }); + + test("reports ci-required when the required-status-check rule omits it", () => { + const result = runChecker(rulesWithoutRequiredCiCheck); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("missing required status check"); + expect(result.stderr).toContain("ci-required"); + expect(result.stderr).toContain("code-quality"); + }); +}); diff --git a/scripts/check-branch-protection.ts b/scripts/check-branch-protection.ts index dc6f31b8..11c49834 100644 --- a/scripts/check-branch-protection.ts +++ b/scripts/check-branch-protection.ts @@ -3,15 +3,14 @@ import { execFileSync } from "node:child_process"; const args = process.argv.slice(2); const repo = argValue("--repo") ?? "GalaxyRuler/Verbatim"; const branch = argValue("--branch") ?? "main"; -const requiredContexts = [ - "Windows x64 production backend", - "macOS ARM64 production backend", - "Ubuntu x64 production backend", -]; +const requiredContext = "ci-required"; -type BranchProtection = { - required_status_checks?: { - contexts?: string[]; +type BranchRule = { + type?: unknown; + parameters?: { + required_status_checks?: Array<{ + context?: unknown; + }>; } | null; }; @@ -22,18 +21,22 @@ function argValue(name: string): string | undefined { return args.find((arg) => arg.startsWith(prefix))?.slice(prefix.length); } -function readBranchProtection(): BranchProtection { +function readBranchRules(): BranchRule[] { try { const output = execFileSync( "gh", - ["api", `repos/${repo}/branches/${branch}/protection`], + ["api", `repos/${repo}/rules/branches/${branch}`], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }, ); - return JSON.parse(output) as BranchProtection; + const rules: unknown = JSON.parse(output); + if (!Array.isArray(rules)) { + throw new Error("GitHub returned an unexpected branch rules response."); + } + return rules as BranchRule[]; } catch (error) { const message = errorMessage(error); console.error( - `Unable to read branch protection for ${repo}@${branch}. Ensure the branch is protected and gh is authenticated. ${message}`, + `Unable to read branch rules for ${repo}@${branch}. Ensure branch rulesets are configured and gh is authenticated. ${message}`, ); process.exit(1); } @@ -59,25 +62,28 @@ function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } -const protection = readBranchProtection(); -const contexts = protection.required_status_checks?.contexts ?? []; -const missing = requiredContexts.filter( - (context) => !contexts.includes(context), -); +function requiredStatusCheckContexts(rules: BranchRule[]): string[] { + return rules.flatMap((rule) => { + if (rule.type !== "required_status_checks") return []; -if (missing.length > 0) { + return (rule.parameters?.required_status_checks ?? []).flatMap( + ({ context }) => (typeof context === "string" ? [context] : []), + ); + }); +} + +const rules = readBranchRules(); +const contexts = requiredStatusCheckContexts(rules); + +if (!contexts.includes(requiredContext)) { console.error( - `Branch protection for ${repo}@${branch} is missing required native backend status checks:`, + `Branch rules for ${repo}@${branch} are missing required status check:`, ); - for (const context of missing) { - console.error(`- ${context}`); - } + console.error(`- ${requiredContext}`); console.error( - `Configured contexts: ${contexts.length > 0 ? contexts.join(", ") : "(none)"}`, + `Configured required-status-check contexts: ${contexts.length > 0 ? contexts.join(", ") : "(none)"}`, ); process.exit(1); } -console.log( - `Branch protection for ${repo}@${branch} requires all native backend status checks.`, -); +console.log(`Branch rules for ${repo}@${branch} require ${requiredContext}.`); From 50f8ff3ff8b2cf00f845f1f7c8f0ecd955d2f518 Mon Sep 17 00:00:00 2001 From: GalaxyRuler Date: Thu, 9 Jul 2026 19:39:10 +0300 Subject: [PATCH 20/20] ci: run test + native-backend on PRs only, not push:main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pull_request event builds the test merge commit (PR head merged into main), so a green PR already validated both jobs against main. The main ruleset has strict=false and this is a solo repo, so a post-merge rerun is near-pure duplication — and native-backend is the priciest job (macOS 10x + Windows 2x whisper.cpp native compile). code-quality stays the required check gating main; nix-check (packaging) and main-build (artifacts) are unchanged. Direct pushes to main are lint-gated only — open a PR for native + test coverage. Co-Authored-By: Claude Fable 5 --- .github/workflows/native-backend.yml | 13 +++++++------ .github/workflows/test.yml | 8 ++++---- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/.github/workflows/native-backend.yml b/.github/workflows/native-backend.yml index b61b8b58..69d5aa7f 100644 --- a/.github/workflows/native-backend.yml +++ b/.github/workflows/native-backend.yml @@ -2,12 +2,13 @@ name: "native backend" on: workflow_dispatch: - push: - branches: [main] - paths: - - "src-tauri/**" - - "scripts/cargo-*.ps1" - - ".github/workflows/native-backend.yml" + # Runs on PRs only: the pull_request event builds the test merge commit + # (PR head merged into main), so a passing PR already validated the native + # build against main. The ruleset has strict=false and this is a solo repo, + # so a post-merge rebuild on push:main is near-pure duplication of the most + # expensive job (macOS 10x + Windows 2x native whisper.cpp compile). Trimmed + # to cut Actions spend; direct pushes to main are only lint-gated by + # code-quality — open a PR to get native coverage. pull_request: paths: - "src-tauri/**" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 73fec366..9bcdc140 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,10 +1,10 @@ name: "test" on: workflow_dispatch: - push: - branches: [main] - paths: - - "src-tauri/**" + # PR-only: the pull_request event already runs these Rust tests against the + # test merge commit (PR head + main), so re-running on push:main duplicates + # work the PR just did (strict=false ruleset, solo repo => no base drift to + # catch). code-quality remains the required check gating main. pull_request: paths: - "src-tauri/**"