From 8c42b65e12914ef3c0504a2b9db7dc222e87862d Mon Sep 17 00:00:00 2001 From: CJ Pais Date: Mon, 20 Jul 2026 20:07:27 +0800 Subject: [PATCH 1/4] wip filler fixes --- src-tauri/src/audio_toolkit/mod.rs | 4 +- src-tauri/src/audio_toolkit/text.rs | 153 +++++++++---- src-tauri/src/lib.rs | 1 + src-tauri/src/managers/transcription.rs | 208 ++++++++++++++++-- src-tauri/src/settings.rs | 9 + src-tauri/src/shortcut/mod.rs | 12 + src/bindings.ts | 10 +- src/components/settings/FillerWordRemoval.tsx | 31 +++ .../settings/advanced/AdvancedSettings.tsx | 2 + src/components/settings/index.ts | 1 + src/i18n/locales/en/translation.json | 4 + src/stores/settingsStore.ts | 2 + 12 files changed, 377 insertions(+), 60 deletions(-) create mode 100644 src/components/settings/FillerWordRemoval.tsx diff --git a/src-tauri/src/audio_toolkit/mod.rs b/src-tauri/src/audio_toolkit/mod.rs index 291753a22b..fed4924836 100644 --- a/src-tauri/src/audio_toolkit/mod.rs +++ b/src-tauri/src/audio_toolkit/mod.rs @@ -8,6 +8,8 @@ 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, VadPolicy, }; -pub use text::{apply_custom_words, filter_transcription_output}; +pub use text::{ + apply_custom_words, normalize_transcription_output, remove_filler_words, OutputLanguageEvidence, +}; pub use utils::get_cpal_host; pub use vad::{SileroVad, VoiceActivityDetector}; diff --git a/src-tauri/src/audio_toolkit/text.rs b/src-tauri/src/audio_toolkit/text.rs index 069cf0e582..ee2b66ffa2 100644 --- a/src-tauri/src/audio_toolkit/text.rs +++ b/src-tauri/src/audio_toolkit/text.rs @@ -265,38 +265,58 @@ fn extract_punctuation(word: &str) -> (&str, &str) { (prefix, suffix) } +/// Evidence for the language of the text being cleaned. +/// +/// This intentionally describes the transcription output, not Handy's UI +/// language. Unknown output languages fail closed: built-in filler removal is +/// skipped rather than applying a language profile speculatively. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum OutputLanguageEvidence { + UserSelected(String), + ModelConstrained(String), + TranslatedToEnglish, + Unknown, +} + +impl OutputLanguageEvidence { + fn language(&self) -> Option<&str> { + match self { + Self::UserSelected(language) | Self::ModelConstrained(language) => Some(language), + Self::TranslatedToEnglish => Some("en"), + Self::Unknown => None, + } + } +} + /// Returns filler words appropriate for the given language code. /// /// Some words like "um" and "ha" are real words in certain languages /// (e.g., Portuguese "um" = "a/an", Spanish "ha" = "has"), so we only /// include them as fillers for languages where they are truly fillers. -fn get_filler_words_for_language(lang: &str) -> &'static [&'static str] { +fn get_filler_words_for_language(lang: &str) -> Option<&'static [&'static str]> { let base_lang = lang.split(&['-', '_'][..]).next().unwrap_or(lang); match base_lang { - "en" => &[ + "en" => Some(&[ "uh", "um", "uhm", "umm", "uhh", "uhhh", "ah", "hmm", "hm", "mmm", "mm", "mh", "eh", "ehh", "ha", - ], - "es" => &["ehm", "mmm", "hmm", "hm"], - "pt" => &["ahm", "hmm", "mmm", "hm"], - "fr" => &["euh", "hmm", "hm", "mmm"], - "de" => &["äh", "ähm", "hmm", "hm", "mmm"], - "it" => &["ehm", "hmm", "mmm", "hm"], - "cs" => &["ehm", "hmm", "mmm", "hm"], - "pl" => &["hmm", "mmm", "hm"], - "tr" => &["hmm", "mmm", "hm"], - "ru" => &["хм", "ммм", "hmm", "mmm"], - "uk" => &["хм", "ммм", "hmm", "mmm"], - "ar" => &["hmm", "mmm"], - "ja" => &["hmm", "mmm"], - "ko" => &["hmm", "mmm"], - "vi" => &["hmm", "mmm", "hm"], - "zh" => &["hmm", "mmm"], - // Conservative universal fallback (no "um", "eh", "ha") - _ => &[ - "uh", "uhm", "umm", "uhh", "uhhh", "ah", "hmm", "hm", "mmm", "mm", "mh", "ehh", - ], + ]), + "es" => Some(&["ehm", "mmm", "hmm", "hm"]), + "pt" => Some(&["ahm", "hmm", "mmm", "hm"]), + "fr" => Some(&["euh", "hmm", "hm", "mmm"]), + "de" => Some(&["äh", "ähm", "hmm", "hm", "mmm"]), + "it" => Some(&["ehm", "hmm", "mmm", "hm"]), + "cs" => Some(&["ehm", "hmm", "mmm", "hm"]), + "pl" => Some(&["hmm", "mmm", "hm"]), + "tr" => Some(&["hmm", "mmm", "hm"]), + "ru" => Some(&["хм", "ммм", "hmm", "mmm"]), + "uk" => Some(&["хм", "ммм", "hmm", "mmm"]), + "ar" => Some(&["hmm", "mmm"]), + "ja" => Some(&["hmm", "mmm"]), + "ko" => Some(&["hmm", "mmm"]), + "vi" => Some(&["hmm", "mmm", "hm"]), + "zh" => Some(&["hmm", "mmm"]), + _ => None, } } @@ -341,27 +361,31 @@ fn collapse_stutters(text: &str) -> String { result.join(" ") } -/// Filters transcription output by removing filler words and stutter artifacts. +/// Removes filler words from transcription output when enabled. /// -/// This function cleans up raw transcription text by: -/// 1. Removing filler words based on the app language (or custom list) -/// 2. Collapsing repeated word stutters (e.g., "wh wh wh" -> "wh") -/// 3. Cleaning up excess whitespace +/// A custom list is an explicit user override and therefore does not require +/// language evidence. `Some(empty vec)` disables removal, preserving the +/// legacy power-user setting. The master toggle takes precedence over both +/// built-in and custom lists. /// /// # Arguments /// * `text` - The raw transcription text to filter -/// * `lang` - The app language code (e.g., "en", "pt-BR") used to select filler words +/// * `language` - Evidence for the language of the transcription output /// * `custom_filler_words` - Optional user-provided filler word list. `Some(vec)` overrides /// language defaults; `Some(empty vec)` disables filtering; `None` uses language defaults. +/// * `enabled` - Whether filler-word removal is enabled /// /// # Returns -/// The filtered text with filler words and stutters removed -pub fn filter_transcription_output( +/// The text with configured filler words removed +pub fn remove_filler_words( text: &str, - lang: &str, + language: &OutputLanguageEvidence, custom_filler_words: &Option>, + enabled: bool, ) -> String { - let mut filtered = text.to_string(); + if !enabled { + return text.to_string(); + } // Build filler patterns from custom list or language defaults let patterns: Vec = match custom_filler_words { @@ -369,31 +393,57 @@ pub fn filter_transcription_output( .iter() .filter_map(|word| Regex::new(&format!(r"(?i)\b{}\b[,.]?", regex::escape(word))).ok()) .collect(), - None => get_filler_words_for_language(lang) + None => language + .language() + .and_then(get_filler_words_for_language) + .unwrap_or_default() .iter() .map(|word| Regex::new(&format!(r"(?i)\b{}\b[,.]?", regex::escape(word))).unwrap()) .collect(), }; // Remove filler words + let mut filtered = text.to_string(); for pattern in &patterns { filtered = pattern.replace_all(&filtered, "").to_string(); } - // Collapse repeated 1-2 letter words (stutter artifacts like "wh wh wh wh") - filtered = collapse_stutters(&filtered); + filtered +} + +/// Applies non-filler transcription cleanup. +/// +/// Kept separate from [`remove_filler_words`] so disabling filler deletion +/// does not also disable the existing repeated-word and whitespace cleanup. +pub fn normalize_transcription_output(text: &str) -> String { + let mut normalized = collapse_stutters(text); // Clean up multiple spaces to single space - filtered = MULTI_SPACE_PATTERN.replace_all(&filtered, " ").to_string(); + normalized = MULTI_SPACE_PATTERN + .replace_all(&normalized, " ") + .to_string(); // Trim leading/trailing whitespace - filtered.trim().to_string() + normalized.trim().to_string() } #[cfg(test)] mod tests { use super::*; + /// Exercise the complete cleanup sequence with an explicitly selected + /// language. Individual tests below predate the split between filler + /// removal and non-filler normalization. + fn filter_transcription_output( + text: &str, + language: &str, + custom_filler_words: &Option>, + ) -> String { + let language = OutputLanguageEvidence::UserSelected(language.to_string()); + let filtered = remove_filler_words(text, &language, custom_filler_words, true); + normalize_transcription_output(&filtered) + } + #[test] fn test_apply_custom_words_exact_match() { let text = "hello world"; @@ -572,20 +622,41 @@ mod tests { } #[test] - fn test_filter_unknown_language_uses_fallback() { + fn test_filter_unknown_language_skips_builtin_removal() { let text = "uh I think uhm this works"; let result = filter_transcription_output(text, "xx", &None); - assert_eq!(result, "I think this works"); + assert_eq!(result, "uh I think uhm this works"); } #[test] - fn test_filter_fallback_does_not_remove_um() { - // Fallback (unknown language) should not remove "um" since it's a real word in some languages + fn test_filter_unknown_language_does_not_remove_um() { let text = "um I think this works"; let result = filter_transcription_output(text, "xx", &None); assert_eq!(result, "um I think this works"); } + #[test] + fn test_filter_master_toggle_disables_custom_and_builtin_removal() { + let text = "um customword I think"; + let language = OutputLanguageEvidence::UserSelected("en".to_string()); + let custom = Some(vec!["customword".to_string()]); + + let result = remove_filler_words(text, &language, &custom, false); + + assert_eq!(result, text); + } + + #[test] + fn test_filter_custom_words_apply_without_language_evidence() { + let custom = Some(vec!["customword".to_string()]); + let text = "customword should be removed but um should remain"; + + let filtered = remove_filler_words(text, &OutputLanguageEvidence::Unknown, &custom, true); + let result = normalize_transcription_output(&filtered); + + assert_eq!(result, "should be removed but um should remain"); + } + #[test] fn test_apply_custom_words_ngram_two_words() { let text = "il cui nome è Charge B, che permette"; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9d931216b9..92581eb149 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -614,6 +614,7 @@ pub fn run(cli_args: CliArgs) { shortcut::change_append_trailing_space_setting, shortcut::change_lazy_stream_close_setting, shortcut::change_vad_enabled_setting, + shortcut::change_filler_word_removal_enabled_setting, shortcut::change_app_language_setting, shortcut::change_update_checks_setting, shortcut::change_show_whats_new_on_update_setting, diff --git a/src-tauri/src/managers/transcription.rs b/src-tauri/src/managers/transcription.rs index 8bf7bd5854..ca545d5424 100644 --- a/src-tauri/src/managers/transcription.rs +++ b/src-tauri/src/managers/transcription.rs @@ -1,4 +1,6 @@ -use crate::audio_toolkit::{apply_custom_words, filter_transcription_output}; +use crate::audio_toolkit::{ + apply_custom_words, normalize_transcription_output, remove_filler_words, OutputLanguageEvidence, +}; use crate::managers::audio::AudioRecordingManager; use crate::managers::model::{EngineType, ModelManager}; use crate::settings::{ @@ -99,10 +101,15 @@ enum StreamCmd { Feed(Vec), /// Flush the stream and reply with the final text, or `None` if no stream /// was ever active (caller should fall back to batch transcription). - Finalize(mpsc::Sender>), + Finalize(mpsc::Sender>), Cancel, } +struct FinalizedStreamText { + text: String, + output_language: OutputLanguageEvidence, +} + /// Routes real-time audio frames to the active streaming worker. Shared between /// the [`TranscriptionManager`] (opens/closes the route) and the audio recorder's /// per-frame callback (feeds frames). The recorder holds an `Arc` @@ -900,6 +907,12 @@ impl TranscriptionManager { &languages, supports_translate, ); + let output_language = resolve_output_language_evidence( + &settings, + &effective_language, + &languages, + run_plan.target_language.as_deref() == Some("en"), + ); let run_options = RunOptions { task: run_plan.task, language: run_plan.language, @@ -911,8 +924,8 @@ impl TranscriptionManager { // (and thus the engine) for its lifetime, so the feed/finalize loop // lives in a labeled block — when it exits, the borrow is released and // the engine can be moved into return_engine(). - let mut finalize_reply: Option>> = None; - let mut finalize_result: Option> = None; + let mut finalize_reply: Option>> = None; + let mut finalize_result: Option> = None; let stream_started = 'stream: { let session = match &mut engine { LoadedEngine::TranscribeCpp(s) => s, @@ -983,7 +996,10 @@ impl TranscriptionManager { update.audio_committed_ms, update.buffered_ms, ); - Some(stream.text().display()) + Some(FinalizedStreamText { + text: stream.text().display(), + output_language: output_language.clone(), + }) } Err(e) => { perf.record_compute(finalize_start.elapsed()); @@ -995,7 +1011,7 @@ impl TranscriptionManager { } }; let chars = match &result { - Some(text) => text.len(), + Some(finalized) => finalized.text.len(), _ => 0, }; perf.log_finalized(chars); @@ -1062,8 +1078,8 @@ impl TranscriptionManager { if tx.send(StreamCmd::Finalize(reply_tx)).is_err() { return Ok(None); } - let raw = match reply_rx.recv_timeout(STREAM_FINALIZE_REPLY_TIMEOUT) { - Ok(Some(text)) => text, + let finalized = match reply_rx.recv_timeout(STREAM_FINALIZE_REPLY_TIMEOUT) { + Ok(Some(finalized)) => finalized, Ok(None) => return Ok(None), Err(mpsc::RecvTimeoutError::Disconnected) => return Ok(None), Err(mpsc::RecvTimeoutError::Timeout) => { @@ -1078,7 +1094,12 @@ impl TranscriptionManager { let settings = get_settings(&self.app_handle); // Streaming models do not receive a decode prompt, so custom words // always go through the shared fuzzy post-correction path. - let filtered = post_process_transcription_text(raw, &settings, false); + let filtered = post_process_transcription_text( + finalized.text, + &settings, + false, + &finalized.output_language, + ); self.maybe_unload_immediately("streaming transcription"); Ok(Some(filtered)) @@ -1186,7 +1207,7 @@ impl TranscriptionManager { // Perform transcription with the appropriate engine. // We use catch_unwind to prevent engine panics from poisoning the mutex, // which would make the app hang indefinitely on subsequent operations. - let result = { + let (result, output_language) = { let mut engine_guard = self.lock_engine(); // Take the engine out so we own it during transcription. @@ -1210,7 +1231,12 @@ impl TranscriptionManager { // non-whisper archs (parakeet, voxtral, …) reject it with // INVALID_ARG; attach it — and translate — only where supported. let mut model_supports_translate = false; - let mut model_languages: Vec = Vec::new(); + let mut model_languages = self + .model_manager + .get_model_info(&active_model) + .map(|info| info.supported_languages) + .unwrap_or_default(); + let mut output_was_translated = false; if let LoadedEngine::TranscribeCpp(session) = &engine { let model = session.model(); let caps = model.capabilities(); @@ -1251,6 +1277,7 @@ impl TranscriptionManager { &model_languages, model_supports_translate, ); + output_was_translated = run_plan.target_language.as_deref() == Some("en"); let run_options = RunOptions { task: run_plan.task, @@ -1317,6 +1344,7 @@ impl TranscriptionManager { .map(|r| r.text) .map_err(|e| anyhow::anyhow!("GigaAM transcription failed: {}", e)), LoadedEngine::Canary(canary_engine) => { + output_was_translated = settings.translate_to_english; let lang = if validated_language == "auto" { None } else { @@ -1350,7 +1378,7 @@ impl TranscriptionManager { } })); - match transcribe_result { + let text = match transcribe_result { Ok(inner_result) => { // Success or normal error: return the engine unless a model // switch/unload invalidated it while it was in use. @@ -1390,7 +1418,16 @@ impl TranscriptionManager { panic_msg )); } - } + }; + + let output_language = resolve_output_language_evidence( + &settings, + &validated_language, + &model_languages, + output_was_translated, + ); + + (text, output_language) }; // Apply fuzzy word correction if custom words are configured — UNLESS the @@ -1398,7 +1435,8 @@ impl TranscriptionManager { // family). We don't pass a prompt to non-whisper models (it requires the // whisper-kind run extension), so they still get fuzzy correction here, // same as the ONNX engines. - let filtered_result = post_process_transcription_text(result, &settings, model_is_whisper); + let filtered_result = + post_process_transcription_text(result, &settings, model_is_whisper, &output_language); let et = std::time::Instant::now(); let translation_note = if settings.translate_to_english { @@ -1552,6 +1590,10 @@ fn normalize_cjk_language(language: &str) -> &str { } } +fn base_language_code(language: &str) -> &str { + language.split(&['-', '_'][..]).next().unwrap_or(language) +} + /// Resolve the persisted language intent into the language a specific model can /// use without writing the coerced value back to settings. fn effective_language_for_model( @@ -1569,6 +1611,45 @@ fn effective_language_for_model( } } +/// Resolve how confidently Handy knows the language of the text produced by a +/// transcription run. The UI language is deliberately not part of this +/// decision. +fn resolve_output_language_evidence( + settings: &AppSettings, + effective_language: &str, + supported_languages: &[String], + translated_to_english: bool, +) -> OutputLanguageEvidence { + if translated_to_english { + return OutputLanguageEvidence::TranslatedToEnglish; + } + + // An explicit, usable user selection is the strongest source-language + // signal. If the selection was unsupported, effective_language is "auto" + // (or a model-required fallback), so it must not be treated as selected. + if settings.selected_language != "auto" + && effective_language != "auto" + && base_language_code(&settings.selected_language) == base_language_code(effective_language) + { + return OutputLanguageEvidence::UserSelected(effective_language.to_string()); + } + + // A single-language model has a known output language even if its metadata + // also advertises language detection and effective_language remains auto. + if let [language] = supported_languages { + return OutputLanguageEvidence::ModelConstrained(language.clone()); + } + + // Models that cannot auto-detect are coerced to a concrete supported + // language. That model-required fallback is still reliable evidence about + // the output, but is distinct from the user's persisted intent. + if effective_language != "auto" { + return OutputLanguageEvidence::ModelConstrained(effective_language.to_string()); + } + + OutputLanguageEvidence::Unknown +} + struct TranscribeCppRunPlan { task: Task, language: Option, @@ -1609,6 +1690,7 @@ fn post_process_transcription_text( raw: String, settings: &AppSettings, custom_words_already_prompted: bool, + output_language: &OutputLanguageEvidence, ) -> String { fail_open_text_transform(raw, |raw| { let corrected = if !settings.custom_words.is_empty() && !custom_words_already_prompted { @@ -1621,11 +1703,14 @@ fn post_process_transcription_text( raw }; - filter_transcription_output( + let without_fillers = remove_filler_words( &corrected, - &settings.app_language, + output_language, &settings.custom_filler_words, - ) + settings.filler_word_removal_enabled, + ); + + normalize_transcription_output(&without_fillers) }) } @@ -1931,6 +2016,95 @@ mod tests { assert_eq!(result, raw); } + #[test] + fn portuguese_transcription_does_not_use_english_ui_filler_words() { + let settings = AppSettings { + app_language: "en".to_string(), + selected_language: "pt-BR".to_string(), + ..Default::default() + }; + let supported = languages(&["en", "pt"]); + let evidence = resolve_output_language_evidence(&settings, "pt", &supported, false); + + let result = post_process_transcription_text( + "eu vi um carro".to_string(), + &settings, + false, + &evidence, + ); + + assert_eq!( + evidence, + OutputLanguageEvidence::UserSelected("pt".to_string()) + ); + assert_eq!(result, "eu vi um carro"); + } + + #[test] + fn auto_language_without_detection_skips_builtin_filler_removal() { + let settings = AppSettings { + selected_language: "auto".to_string(), + ..Default::default() + }; + let evidence = + resolve_output_language_evidence(&settings, "auto", &languages(&["en", "pt"]), false); + + let result = post_process_transcription_text( + "um this may be Portuguese".to_string(), + &settings, + false, + &evidence, + ); + + assert_eq!(evidence, OutputLanguageEvidence::Unknown); + assert_eq!(result, "um this may be Portuguese"); + } + + #[test] + fn auto_language_uses_single_language_model_as_evidence() { + let settings = AppSettings { + selected_language: "auto".to_string(), + ..Default::default() + }; + + let evidence = + resolve_output_language_evidence(&settings, "auto", &languages(&["en"]), false); + + assert_eq!( + evidence, + OutputLanguageEvidence::ModelConstrained("en".to_string()) + ); + } + + #[test] + fn unsupported_explicit_language_uses_model_fallback_as_evidence() { + let settings = AppSettings { + selected_language: "pt".to_string(), + ..Default::default() + }; + + let evidence = + resolve_output_language_evidence(&settings, "en", &languages(&["en", "de"]), false); + + assert_eq!( + evidence, + OutputLanguageEvidence::ModelConstrained("en".to_string()) + ); + } + + #[test] + fn translated_output_is_treated_as_english() { + let settings = AppSettings { + selected_language: "pt".to_string(), + ..Default::default() + }; + + let evidence = + resolve_output_language_evidence(&settings, "pt", &languages(&["en", "pt"]), true); + + assert_eq!(evidence, OutputLanguageEvidence::TranslatedToEnglish); + } + #[test] fn transcribe_cpp_run_plan_maps_chinese_variants() { let plan = transcribe_cpp_run_plan(false, "zh-Hant", &languages(&["zh"]), true); diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 5fa6279f0e..d03ffa5b51 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -447,6 +447,8 @@ pub struct AppSettings { pub typing_tool: TypingTool, #[serde(default)] pub external_script_path: Option, + #[serde(default = "default_filler_word_removal_enabled")] + pub filler_word_removal_enabled: bool, #[serde(default)] pub custom_filler_words: Option>, #[serde(default)] @@ -531,6 +533,10 @@ fn default_vad_enabled() -> bool { true } +fn default_filler_word_removal_enabled() -> bool { + true +} + fn default_debug_mode() -> bool { false } @@ -887,6 +893,7 @@ pub fn get_default_settings() -> AppSettings { paste_delay_after_ms: default_paste_delay_after_ms(), typing_tool: default_typing_tool(), external_script_path: None, + filler_word_removal_enabled: default_filler_word_removal_enabled(), custom_filler_words: None, transcribe_accelerator: TranscribeAcceleratorSetting::default(), ort_accelerator: OrtAcceleratorSetting::default(), @@ -1131,6 +1138,7 @@ mod tests { .expect("all AppSettings fields need serde defaults"); assert!(settings.push_to_talk); assert!(!settings.audio_feedback); + assert!(settings.filler_word_removal_enabled); // Bindings default to empty; the load path merges the real defaults in. assert!(settings.bindings.is_empty()); } @@ -1248,6 +1256,7 @@ mod tests { assert_eq!(settings.bindings["transcribe"].current_binding, "f13"); assert_eq!(settings.log_level, LogLevel::Debug); assert_eq!(settings.sound_theme, SoundTheme::Pop); + assert!(settings.filler_word_removal_enabled); // A current-format store must not be rewritten on every read. assert!(!apply_settings_migrations(&mut settings, &stored)); diff --git a/src-tauri/src/shortcut/mod.rs b/src-tauri/src/shortcut/mod.rs index e20edfc26f..291b6cac7b 100644 --- a/src-tauri/src/shortcut/mod.rs +++ b/src-tauri/src/shortcut/mod.rs @@ -1199,6 +1199,18 @@ pub fn change_vad_enabled_setting(app: AppHandle, enabled: bool) -> Result<(), S Ok(()) } +#[tauri::command] +#[specta::specta] +pub fn change_filler_word_removal_enabled_setting( + app: AppHandle, + enabled: bool, +) -> Result<(), String> { + let mut settings = settings::get_settings(&app); + settings.filler_word_removal_enabled = enabled; + settings::write_settings(&app, settings); + Ok(()) +} + #[tauri::command] #[specta::specta] pub fn change_app_language_setting(app: AppHandle, language: String) -> Result<(), String> { diff --git a/src/bindings.ts b/src/bindings.ts index f31c730a3b..51e64c97f2 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -351,6 +351,14 @@ async changeVadEnabledSetting(enabled: boolean) : Promise> else return { status: "error", error: e as any }; } }, +async changeFillerWordRemovalEnabledSetting(enabled: boolean) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("change_filler_word_removal_enabled_setting", { enabled }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, async changeAppLanguageSetting(language: string) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("change_app_language_setting", { language }) }; @@ -905,7 +913,7 @@ bindings?: Partial<{ [key in string]: ShortcutBinding }>; push_to_talk?: boolean * upgrading from before this key existed are blanked by the migration so they * see the current release's notes — see `apply_settings_migrations`. */ -whats_new_last_seen_version?: string; selected_model?: string; onboarding_completed?: boolean; always_on_microphone?: boolean; selected_microphone?: string | null; clamshell_microphone?: string | null; selected_output_device?: string | null; translate_to_english?: boolean; selected_language?: string; overlay_position?: OverlayPosition; debug_mode?: boolean; log_level?: LogLevel; custom_words?: string[]; model_unload_timeout?: ModelUnloadTimeout; word_correction_threshold?: number; history_limit?: number; recording_retention_period?: RecordingRetentionPeriod; paste_method?: PasteMethod; clipboard_handling?: ClipboardHandling; auto_submit?: boolean; auto_submit_key?: AutoSubmitKey; post_process_enabled?: boolean; 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; mute_while_recording?: boolean; append_trailing_space?: boolean; app_language?: string; theme?: Theme; experimental_enabled?: boolean; lazy_stream_close?: boolean; keyboard_implementation?: KeyboardImplementation; show_tray_icon?: boolean; paste_delay_ms?: number; paste_delay_after_ms?: number; typing_tool?: TypingTool; external_script_path?: string | null; custom_filler_words?: string[] | null; transcribe_accelerator?: TranscribeAcceleratorSetting; ort_accelerator?: OrtAcceleratorSetting; transcribe_gpu_device?: number; extra_recording_buffer_ms?: number; vad_enabled?: boolean; +whats_new_last_seen_version?: string; selected_model?: string; onboarding_completed?: boolean; always_on_microphone?: boolean; selected_microphone?: string | null; clamshell_microphone?: string | null; selected_output_device?: string | null; translate_to_english?: boolean; selected_language?: string; overlay_position?: OverlayPosition; debug_mode?: boolean; log_level?: LogLevel; custom_words?: string[]; model_unload_timeout?: ModelUnloadTimeout; word_correction_threshold?: number; history_limit?: number; recording_retention_period?: RecordingRetentionPeriod; paste_method?: PasteMethod; clipboard_handling?: ClipboardHandling; auto_submit?: boolean; auto_submit_key?: AutoSubmitKey; post_process_enabled?: boolean; 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; mute_while_recording?: boolean; append_trailing_space?: boolean; app_language?: string; theme?: Theme; experimental_enabled?: boolean; lazy_stream_close?: boolean; keyboard_implementation?: KeyboardImplementation; show_tray_icon?: boolean; paste_delay_ms?: number; paste_delay_after_ms?: number; typing_tool?: TypingTool; external_script_path?: string | null; filler_word_removal_enabled?: boolean; custom_filler_words?: string[] | null; transcribe_accelerator?: TranscribeAcceleratorSetting; ort_accelerator?: OrtAcceleratorSetting; transcribe_gpu_device?: number; extra_recording_buffer_ms?: number; vad_enabled?: boolean; /** * Which recording overlay to show: None / Minimal / Live. Streaming mode is * not gated on this — that follows model capability. Migrated from the old diff --git a/src/components/settings/FillerWordRemoval.tsx b/src/components/settings/FillerWordRemoval.tsx new file mode 100644 index 0000000000..59ab465313 --- /dev/null +++ b/src/components/settings/FillerWordRemoval.tsx @@ -0,0 +1,31 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { ToggleSwitch } from "../ui/ToggleSwitch"; +import { useSettings } from "../../hooks/useSettings"; + +interface FillerWordRemovalProps { + descriptionMode?: "inline" | "tooltip"; + grouped?: boolean; +} + +export const FillerWordRemoval: React.FC = React.memo( + ({ descriptionMode = "tooltip", grouped = false }) => { + const { t } = useTranslation(); + const { getSetting, updateSetting, isUpdating } = useSettings(); + const enabled = getSetting("filler_word_removal_enabled") ?? true; + + return ( + + updateSetting("filler_word_removal_enabled", nextEnabled) + } + isUpdating={isUpdating("filler_word_removal_enabled")} + label={t("settings.advanced.fillerWordRemoval.title")} + description={t("settings.advanced.fillerWordRemoval.description")} + descriptionMode={descriptionMode} + grouped={grouped} + /> + ); + }, +); diff --git a/src/components/settings/advanced/AdvancedSettings.tsx b/src/components/settings/advanced/AdvancedSettings.tsx index e5eb25f03a..f8749708be 100644 --- a/src/components/settings/advanced/AdvancedSettings.tsx +++ b/src/components/settings/advanced/AdvancedSettings.tsx @@ -21,6 +21,7 @@ import { KeyboardImplementationSelector } from "../debug/KeyboardImplementationS import { VoiceActivityDetection } from "../VoiceActivityDetection"; import { AccelerationSelector } from "../AccelerationSelector"; import { LazyStreamClose } from "../LazyStreamClose"; +import { FillerWordRemoval } from "../FillerWordRemoval"; export const AdvancedSettings: React.FC = () => { const { t } = useTranslation(); @@ -47,6 +48,7 @@ export const AdvancedSettings: React.FC = () => { + diff --git a/src/components/settings/index.ts b/src/components/settings/index.ts index c50132ae10..2e26a92da8 100644 --- a/src/components/settings/index.ts +++ b/src/components/settings/index.ts @@ -23,6 +23,7 @@ export { CustomWords } from "./CustomWords"; export { PostProcessingToggle } from "./PostProcessingToggle"; export { PostProcessingSettingsApi } from "./PostProcessingSettingsApi"; export { PostProcessingSettingsPrompts } from "./PostProcessingSettingsPrompts"; +export { FillerWordRemoval } from "./FillerWordRemoval"; export { AppDataDirectory } from "./AppDataDirectory"; export { ModelUnloadTimeoutSetting } from "./ModelUnloadTimeout"; export { StartHidden } from "./StartHidden"; diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index 33c7321e11..1ce07f67b2 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -367,6 +367,10 @@ "voiceActivityDetection": { "title": "Voice Activity Detection", "description": "Filter silence from recordings. Streaming-capable models use a longer VAD tail; disabling VAD records raw audio." + }, + "fillerWordRemoval": { + "title": "Remove Filler Words", + "description": "Remove common hesitation words when Handy knows the transcription language. Disable for verbatim transcription." } }, "postProcessing": { diff --git a/src/stores/settingsStore.ts b/src/stores/settingsStore.ts index b4e042829d..9f686c0585 100644 --- a/src/stores/settingsStore.ts +++ b/src/stores/settingsStore.ts @@ -152,6 +152,8 @@ const settingUpdaters: { commands.changeLazyStreamCloseSetting(value as boolean), overlay_style: (value) => commands.changeOverlayStyleSetting(value as string), vad_enabled: (value) => commands.changeVadEnabledSetting(value as boolean), + filler_word_removal_enabled: (value) => + commands.changeFillerWordRemovalEnabledSetting(value as boolean), show_tray_icon: (value) => commands.changeShowTrayIconSetting(value as boolean), transcribe_accelerator: (value) => From f310c3725215824739ff46f216825e65c8779ade Mon Sep 17 00:00:00 2001 From: CJ Pais Date: Tue, 21 Jul 2026 17:21:25 +0800 Subject: [PATCH 2/4] translations --- src/i18n/locales/ar/translation.json | 8 ++++++-- src/i18n/locales/bg/translation.json | 8 ++++++-- src/i18n/locales/cs/translation.json | 8 ++++++-- src/i18n/locales/de/translation.json | 4 ++++ src/i18n/locales/es/translation.json | 4 ++++ src/i18n/locales/fr/translation.json | 4 ++++ src/i18n/locales/he/translation.json | 8 ++++++-- src/i18n/locales/hi/translation.json | 4 ++++ src/i18n/locales/it/translation.json | 4 ++++ src/i18n/locales/ja/translation.json | 4 ++++ src/i18n/locales/ko/translation.json | 8 ++++++-- src/i18n/locales/ne/translation.json | 4 ++++ src/i18n/locales/nl/translation.json | 4 ++++ src/i18n/locales/pl/translation.json | 8 ++++++-- src/i18n/locales/pt/translation.json | 8 ++++++-- src/i18n/locales/ru/translation.json | 8 ++++++-- src/i18n/locales/sv/translation.json | 8 ++++++-- src/i18n/locales/tr/translation.json | 8 ++++++-- src/i18n/locales/uk/translation.json | 8 ++++++-- src/i18n/locales/vi/translation.json | 8 ++++++-- src/i18n/locales/zh-TW/translation.json | 8 ++++++-- src/i18n/locales/zh/translation.json | 8 ++++++-- 22 files changed, 116 insertions(+), 28 deletions(-) diff --git a/src/i18n/locales/ar/translation.json b/src/i18n/locales/ar/translation.json index 801229cfc5..35e9c89ddb 100644 --- a/src/i18n/locales/ar/translation.json +++ b/src/i18n/locales/ar/translation.json @@ -344,8 +344,12 @@ "duplicate": "\"{{word}}\" موجود بالفعل" }, "voiceActivityDetection": { - "title": "Voice Activity Detection", - "description": "Filter silence from recordings. Streaming-capable models use a longer VAD tail; disabling VAD records raw audio." + "title": "اكتشاف النشاط الصوتي (VAD)", + "description": "تصفية الصمت من التسجيلات. تستخدم النماذج التي تدعم البث ذيل VAD أطول؛ تعطيل VAD يسجّل الصوت الخام." + }, + "fillerWordRemoval": { + "title": "إزالة كلمات الحشو", + "description": "إزالة كلمات التردد الشائعة عندما يعرف Handy لغة التفريغ. عطّل هذا الخيار للحصول على تفريغ حرفي." } }, "postProcessing": { diff --git a/src/i18n/locales/bg/translation.json b/src/i18n/locales/bg/translation.json index 937680ce1a..3953545695 100644 --- a/src/i18n/locales/bg/translation.json +++ b/src/i18n/locales/bg/translation.json @@ -365,8 +365,12 @@ "duplicate": "„{{word}}“ вече съществува" }, "voiceActivityDetection": { - "title": "Voice Activity Detection", - "description": "Filter silence from recordings. Streaming-capable models use a longer VAD tail; disabling VAD records raw audio." + "title": "Разпознаване на гласова активност (VAD)", + "description": "Филтрира тишината от записите. Моделите с поддръжка на стрийминг използват по-дълга VAD опашка; изключването на VAD записва необработено аудио." + }, + "fillerWordRemoval": { + "title": "Премахване на думи паразити", + "description": "Премахва често срещаните думи за колебание, когато Handy знае езика на транскрипцията. Изключете за дословна транскрипция." } }, "postProcessing": { diff --git a/src/i18n/locales/cs/translation.json b/src/i18n/locales/cs/translation.json index ec7017c72b..c15d8e7b3b 100644 --- a/src/i18n/locales/cs/translation.json +++ b/src/i18n/locales/cs/translation.json @@ -365,8 +365,12 @@ "duplicate": "\"{{word}}\" již existuje" }, "voiceActivityDetection": { - "title": "Voice Activity Detection", - "description": "Filter silence from recordings. Streaming-capable models use a longer VAD tail; disabling VAD records raw audio." + "title": "Detekce hlasové aktivity (VAD)", + "description": "Odfiltruje ticho z nahrávek. Modely s podporou streamování používají delší doběh VAD; při vypnutém VAD se nahrává neupravený zvuk." + }, + "fillerWordRemoval": { + "title": "Odstranit výplňová slova", + "description": "Odstraní běžná zaváhací slova, pokud Handy zná jazyk přepisu. Vypněte pro doslovný přepis." } }, "postProcessing": { diff --git a/src/i18n/locales/de/translation.json b/src/i18n/locales/de/translation.json index 094737eb70..8d13faadb1 100644 --- a/src/i18n/locales/de/translation.json +++ b/src/i18n/locales/de/translation.json @@ -367,6 +367,10 @@ "voiceActivityDetection": { "title": "Sprachaktivitätserkennung (VAD)", "description": "Filtert Stille aus Aufnahmen. Streamingfähige Modelle verwenden ein längeres VAD-Ende; das Deaktivieren von VAD nimmt Rohaudio auf." + }, + "fillerWordRemoval": { + "title": "Füllwörter entfernen", + "description": "Entfernt häufige Verzögerungslaute, wenn Handy die Sprache der Transkription kennt. Deaktivieren für eine wortgetreue Transkription." } }, "postProcessing": { diff --git a/src/i18n/locales/es/translation.json b/src/i18n/locales/es/translation.json index 3efacbdd0f..b74d2f2fdd 100644 --- a/src/i18n/locales/es/translation.json +++ b/src/i18n/locales/es/translation.json @@ -367,6 +367,10 @@ "voiceActivityDetection": { "title": "Detección de actividad de voz", "description": "Filtra el silencio de las grabaciones. Los modelos compatibles con streaming usan una cola de VAD más larga; desactivar el VAD graba audio sin procesar." + }, + "fillerWordRemoval": { + "title": "Eliminar muletillas", + "description": "Elimina las muletillas más comunes cuando Handy conoce el idioma de la transcripción. Desactívalo para una transcripción literal." } }, "postProcessing": { diff --git a/src/i18n/locales/fr/translation.json b/src/i18n/locales/fr/translation.json index 9cce8c47b3..014c5075bc 100644 --- a/src/i18n/locales/fr/translation.json +++ b/src/i18n/locales/fr/translation.json @@ -367,6 +367,10 @@ "voiceActivityDetection": { "title": "Détection d'activité vocale (VAD)", "description": "Filtre le silence des enregistrements. Les modèles compatibles avec le streaming utilisent une marge de détection VAD plus longue ; désactiver le VAD enregistre l'audio brut." + }, + "fillerWordRemoval": { + "title": "Supprimer les mots de remplissage", + "description": "Supprime les hésitations courantes lorsque Handy connaît la langue de la transcription. Désactivez cette option pour une transcription mot à mot." } }, "postProcessing": { diff --git a/src/i18n/locales/he/translation.json b/src/i18n/locales/he/translation.json index 677ee35f88..cbf519b1ef 100644 --- a/src/i18n/locales/he/translation.json +++ b/src/i18n/locales/he/translation.json @@ -365,8 +365,12 @@ "duplicate": "\"{{word}}\" כבר קיימת" }, "voiceActivityDetection": { - "title": "Voice Activity Detection", - "description": "Filter silence from recordings. Streaming-capable models use a longer VAD tail; disabling VAD records raw audio." + "title": "זיהוי פעילות קולית (VAD)", + "description": "מסנן שקט מההקלטות. מודלים התומכים בהזרמה משתמשים בזנב VAD ארוך יותר; השבתת VAD מקליטה אודיו גולמי." + }, + "fillerWordRemoval": { + "title": "הסרת מילות מילוי", + "description": "מסיר מילות היסוס נפוצות כאשר Handy מזהה את שפת התמלול. השבת לתמלול מילה במילה." } }, "postProcessing": { diff --git a/src/i18n/locales/hi/translation.json b/src/i18n/locales/hi/translation.json index 775392b964..5277ff6aa9 100644 --- a/src/i18n/locales/hi/translation.json +++ b/src/i18n/locales/hi/translation.json @@ -367,6 +367,10 @@ "voiceActivityDetection": { "title": "वॉइस एक्टिविटी डिटेक्शन", "description": "रिकॉर्डिंग से बिना आवाज़ वाले हिस्से हटाएं. स्ट्रीमिंग वाले मॉडल लंबी VAD टेल इस्तेमाल करते हैं; VAD बंद करने पर कच्चा ऑडियो रिकॉर्ड होता है." + }, + "fillerWordRemoval": { + "title": "फिलर शब्द हटाएं", + "description": "जब Handy को ट्रांसक्रिप्शन की भाषा पता हो, तब आम हिचकिचाहट वाले शब्द हटाएं. शब्दशः ट्रांसक्रिप्शन के लिए इसे बंद करें." } }, "postProcessing": { diff --git a/src/i18n/locales/it/translation.json b/src/i18n/locales/it/translation.json index 5c0b9aee7b..657fe1779e 100644 --- a/src/i18n/locales/it/translation.json +++ b/src/i18n/locales/it/translation.json @@ -367,6 +367,10 @@ "voiceActivityDetection": { "title": "Rilevamento dell'attività vocale", "description": "Filtra il silenzio dalle registrazioni. I modelli che supportano lo streaming utilizzano una coda VAD più lunga; disabilitando la VAD si registra l'audio grezzo." + }, + "fillerWordRemoval": { + "title": "Rimuovi le parole riempitive", + "description": "Rimuove le parole di esitazione più comuni quando Handy conosce la lingua della trascrizione. Disattiva per una trascrizione letterale." } }, "postProcessing": { diff --git a/src/i18n/locales/ja/translation.json b/src/i18n/locales/ja/translation.json index cac4c1d3ee..7b27f8183e 100644 --- a/src/i18n/locales/ja/translation.json +++ b/src/i18n/locales/ja/translation.json @@ -367,6 +367,10 @@ "voiceActivityDetection": { "title": "音声区間検出 (VAD)", "description": "録音から無音部分を除去します。ストリーミング対応モデルはより長い音声検出区間を使用します。VAD を無効にすると音声をそのまま録音します。" + }, + "fillerWordRemoval": { + "title": "フィラー語を削除", + "description": "Handy が文字起こしの言語を認識している場合に、よくあるつなぎ言葉を削除します。そのまま文字起こしする場合は無効にしてください。" } }, "postProcessing": { diff --git a/src/i18n/locales/ko/translation.json b/src/i18n/locales/ko/translation.json index a843dc53a7..7035da15af 100644 --- a/src/i18n/locales/ko/translation.json +++ b/src/i18n/locales/ko/translation.json @@ -365,8 +365,12 @@ "duplicate": "\"{{word}}\"이(가) 이미 존재합니다" }, "voiceActivityDetection": { - "title": "Voice Activity Detection", - "description": "Filter silence from recordings. Streaming-capable models use a longer VAD tail; disabling VAD records raw audio." + "title": "음성 활동 감지 (VAD)", + "description": "녹음에서 무음 구간을 걸러냅니다. 스트리밍을 지원하는 모델은 더 긴 VAD 종료 대기를 사용하며, VAD를 끄면 원본 오디오가 그대로 녹음됩니다." + }, + "fillerWordRemoval": { + "title": "군더더기 표현 제거", + "description": "Handy가 전사 언어를 알고 있을 때 흔한 머뭇거림 표현을 제거합니다. 있는 그대로 전사하려면 비활성화하세요." } }, "postProcessing": { diff --git a/src/i18n/locales/ne/translation.json b/src/i18n/locales/ne/translation.json index 7ec013ebe4..ace30f61cb 100644 --- a/src/i18n/locales/ne/translation.json +++ b/src/i18n/locales/ne/translation.json @@ -367,6 +367,10 @@ "voiceActivityDetection": { "title": "भ्वाइस एक्टिभिटी डिटेक्सन", "description": "रेकर्डिङबाट मौनता हटाउँछ। स्ट्रिमिङ-सक्षम मोडेलहरूले लामो VAD टेल प्रयोग गर्छन्; VAD अफ गर्दा कच्चा अडियो रेकर्ड हुन्छ।" + }, + "fillerWordRemoval": { + "title": "फिलर शब्दहरू हटाउनुहोस्", + "description": "Handy लाई ट्रान्सक्रिप्सनको भाषा थाहा हुँदा सामान्य हिचकिचाहटका शब्दहरू हटाउँछ। शब्दशः ट्रान्सक्रिप्सनका लागि यसलाई निष्क्रिय पार्नुहोस्।" } }, "postProcessing": { diff --git a/src/i18n/locales/nl/translation.json b/src/i18n/locales/nl/translation.json index 9d9c4946c7..d23957afb7 100644 --- a/src/i18n/locales/nl/translation.json +++ b/src/i18n/locales/nl/translation.json @@ -367,6 +367,10 @@ "voiceActivityDetection": { "title": "Spraakactiviteitsdetectie (VAD)", "description": "Filter stilte uit opnames. Streaming-modellen gebruiken een langere VAD-uitloop; het uitschakelen van VAD neemt de ruwe audio op." + }, + "fillerWordRemoval": { + "title": "Stopwoorden verwijderen", + "description": "Verwijdert veelvoorkomende twijfelwoorden wanneer Handy de taal van de transcriptie kent. Schakel dit uit voor een woordelijke transcriptie." } }, "postProcessing": { diff --git a/src/i18n/locales/pl/translation.json b/src/i18n/locales/pl/translation.json index ff2570f9fc..e374a92a31 100644 --- a/src/i18n/locales/pl/translation.json +++ b/src/i18n/locales/pl/translation.json @@ -365,8 +365,12 @@ "duplicate": "\"{{word}}\" już istnieje" }, "voiceActivityDetection": { - "title": "Voice Activity Detection", - "description": "Filter silence from recordings. Streaming-capable models use a longer VAD tail; disabling VAD records raw audio." + "title": "Wykrywanie aktywności głosowej (VAD)", + "description": "Odfiltrowuje ciszę z nagrań. Modele obsługujące strumieniowanie używają dłuższego ogona VAD; wyłączenie VAD nagrywa surowy dźwięk." + }, + "fillerWordRemoval": { + "title": "Usuń wyrazy wypełniające", + "description": "Usuwa typowe wtrącenia i wahania, gdy Handy zna język transkrypcji. Wyłącz, aby uzyskać dosłowną transkrypcję." } }, "postProcessing": { diff --git a/src/i18n/locales/pt/translation.json b/src/i18n/locales/pt/translation.json index 1bf553adf0..4c6e814211 100644 --- a/src/i18n/locales/pt/translation.json +++ b/src/i18n/locales/pt/translation.json @@ -365,8 +365,12 @@ "duplicate": "\"{{word}}\" já existe" }, "voiceActivityDetection": { - "title": "Voice Activity Detection", - "description": "Filter silence from recordings. Streaming-capable models use a longer VAD tail; disabling VAD records raw audio." + "title": "Detecção de atividade de voz (VAD)", + "description": "Filtra o silêncio das gravações. Modelos compatíveis com streaming usam uma cauda de VAD mais longa; desativar o VAD grava o áudio bruto." + }, + "fillerWordRemoval": { + "title": "Remover palavras de preenchimento", + "description": "Remove hesitações comuns quando o Handy reconhece o idioma da transcrição. Desative para uma transcrição literal." } }, "postProcessing": { diff --git a/src/i18n/locales/ru/translation.json b/src/i18n/locales/ru/translation.json index 53db4a3d45..31b6184409 100644 --- a/src/i18n/locales/ru/translation.json +++ b/src/i18n/locales/ru/translation.json @@ -365,8 +365,12 @@ "duplicate": "\"{{word}}\" уже существует" }, "voiceActivityDetection": { - "title": "Voice Activity Detection", - "description": "Filter silence from recordings. Streaming-capable models use a longer VAD tail; disabling VAD records raw audio." + "title": "Детектор голосовой активности (VAD)", + "description": "Отфильтровывает тишину из записей. Модели с поддержкой потоковой обработки используют более длинный «хвост» VAD; при отключении VAD записывается исходный звук." + }, + "fillerWordRemoval": { + "title": "Удалять слова-паразиты", + "description": "Удаляет распространённые слова-заминки, когда Handy знает язык расшифровки. Отключите для дословной расшифровки." } }, "postProcessing": { diff --git a/src/i18n/locales/sv/translation.json b/src/i18n/locales/sv/translation.json index dc7294c8b4..b5a6204fd9 100644 --- a/src/i18n/locales/sv/translation.json +++ b/src/i18n/locales/sv/translation.json @@ -365,8 +365,12 @@ "duplicate": "\"{{word}}\" finns redan" }, "voiceActivityDetection": { - "title": "Voice Activity Detection", - "description": "Filter silence from recordings. Streaming-capable models use a longer VAD tail; disabling VAD records raw audio." + "title": "Röstaktivitetsdetektering (VAD)", + "description": "Filtrerar bort tystnad från inspelningar. Modeller med stöd för strömning använder en längre VAD-svans; om VAD inaktiveras spelas rått ljud in." + }, + "fillerWordRemoval": { + "title": "Ta bort utfyllnadsord", + "description": "Tar bort vanliga tvekljud när Handy känner till transkriberingens språk. Inaktivera för ordagrann transkribering." } }, "postProcessing": { diff --git a/src/i18n/locales/tr/translation.json b/src/i18n/locales/tr/translation.json index 7bf0526601..a86f17444a 100644 --- a/src/i18n/locales/tr/translation.json +++ b/src/i18n/locales/tr/translation.json @@ -365,8 +365,12 @@ "duplicate": "\"{{word}}\" zaten mevcut" }, "voiceActivityDetection": { - "title": "Voice Activity Detection", - "description": "Filter silence from recordings. Streaming-capable models use a longer VAD tail; disabling VAD records raw audio." + "title": "Ses Etkinliği Algılama (VAD)", + "description": "Kayıtlardaki sessizliği filtreler. Akış destekleyen modeller daha uzun bir VAD kuyruğu kullanır; VAD devre dışı bırakıldığında ses ham olarak kaydedilir." + }, + "fillerWordRemoval": { + "title": "Dolgu sözcüklerini kaldır", + "description": "Handy, transkripsiyon dilini bildiğinde yaygın duraksama sözcüklerini kaldırır. Birebir transkripsiyon için devre dışı bırakın." } }, "postProcessing": { diff --git a/src/i18n/locales/uk/translation.json b/src/i18n/locales/uk/translation.json index 90e302fa2f..8537c3e67a 100644 --- a/src/i18n/locales/uk/translation.json +++ b/src/i18n/locales/uk/translation.json @@ -365,8 +365,12 @@ "duplicate": "\"{{word}}\" вже існує" }, "voiceActivityDetection": { - "title": "Voice Activity Detection", - "description": "Filter silence from recordings. Streaming-capable models use a longer VAD tail; disabling VAD records raw audio." + "title": "Виявлення голосової активності (VAD)", + "description": "Відфільтровує тишу із записів. Моделі з підтримкою потокового режиму використовують довший «хвіст» VAD; якщо вимкнути VAD, записується необроблений звук." + }, + "fillerWordRemoval": { + "title": "Видаляти слова-паразити", + "description": "Видаляє поширені слова-вагання, коли Handy знає мову транскрипції. Вимкніть для дослівної транскрипції." } }, "postProcessing": { diff --git a/src/i18n/locales/vi/translation.json b/src/i18n/locales/vi/translation.json index 2a636c67e1..a05cff8e75 100644 --- a/src/i18n/locales/vi/translation.json +++ b/src/i18n/locales/vi/translation.json @@ -365,8 +365,12 @@ "duplicate": "\"{{word}}\" đã tồn tại" }, "voiceActivityDetection": { - "title": "Voice Activity Detection", - "description": "Filter silence from recordings. Streaming-capable models use a longer VAD tail; disabling VAD records raw audio." + "title": "Phát hiện hoạt động giọng nói (VAD)", + "description": "Lọc bỏ khoảng lặng khỏi bản ghi. Các mô hình hỗ trợ phát trực tuyến dùng đuôi VAD dài hơn; tắt VAD sẽ ghi âm thanh thô." + }, + "fillerWordRemoval": { + "title": "Loại bỏ từ đệm", + "description": "Loại bỏ các từ ngập ngừng phổ biến khi Handy biết ngôn ngữ phiên âm. Tắt để có bản ghi nguyên văn." } }, "postProcessing": { diff --git a/src/i18n/locales/zh-TW/translation.json b/src/i18n/locales/zh-TW/translation.json index 4ab651b97a..2792740b35 100644 --- a/src/i18n/locales/zh-TW/translation.json +++ b/src/i18n/locales/zh-TW/translation.json @@ -365,8 +365,12 @@ "duplicate": "「{{word}}」已存在" }, "voiceActivityDetection": { - "title": "Voice Activity Detection", - "description": "Filter silence from recordings. Streaming-capable models use a longer VAD tail; disabling VAD records raw audio." + "title": "語音活動偵測 (VAD)", + "description": "過濾錄音中的靜音片段。支援串流的模型會使用較長的 VAD 尾音時長;關閉 VAD 則錄製原始音訊。" + }, + "fillerWordRemoval": { + "title": "移除語助詞", + "description": "當 Handy 已知轉錄語言時,移除常見的語助詞。若需逐字轉錄,請關閉此選項。" } }, "postProcessing": { diff --git a/src/i18n/locales/zh/translation.json b/src/i18n/locales/zh/translation.json index eff414234d..315045301b 100644 --- a/src/i18n/locales/zh/translation.json +++ b/src/i18n/locales/zh/translation.json @@ -365,8 +365,12 @@ "duplicate": "\"{{word}}\" 已存在" }, "voiceActivityDetection": { - "title": "Voice Activity Detection", - "description": "Filter silence from recordings. Streaming-capable models use a longer VAD tail; disabling VAD records raw audio." + "title": "语音活动检测 (VAD)", + "description": "过滤录音中的静音片段。支持流式的模型会使用更长的 VAD 尾音时长;关闭 VAD 则录制原始音频。" + }, + "fillerWordRemoval": { + "title": "移除语气词", + "description": "当 Handy 已知转录语言时,移除常见的语气词。如需逐字转录,请关闭此选项。" } }, "postProcessing": { From bb05d2744c41851729b5730bdb54a7fbf1cedf48 Mon Sep 17 00:00:00 2001 From: CJ Pais Date: Sun, 9 Aug 2026 15:54:20 +0800 Subject: [PATCH 3/4] add lid + use transcribe.cpp lid --- src-tauri/Cargo.lock | 97 +++++++++----- src-tauri/Cargo.toml | 4 + src-tauri/src/audio_toolkit/lang_id.rs | 90 +++++++++++++ src-tauri/src/audio_toolkit/mod.rs | 2 + src-tauri/src/audio_toolkit/text.rs | 148 ++++++++++++++------ src-tauri/src/managers/transcription.rs | 171 +++++++++++++++++++++--- src/bindings.ts | 2 +- 7 files changed, 428 insertions(+), 86 deletions(-) create mode 100644 src-tauri/src/audio_toolkit/lang_id.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 780b2be19b..0ef6847ad8 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -25,6 +25,18 @@ dependencies = [ "version_check", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -49,6 +61,12 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "alsa" version = "0.9.1" @@ -133,7 +151,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -144,7 +162,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1369,7 +1387,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1665,7 +1683,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1791,8 +1809,8 @@ dependencies = [ "clap", "ferrous-opencc-compiler", "fst", - "phf 0.11.3", - "phf_codegen 0.11.3", + "phf 0.13.1", + "phf_codegen 0.13.1", "serde", "serde_json", "tempfile", @@ -2463,6 +2481,7 @@ dependencies = [ "handy-keys", "hf-hub", "hound", + "isolang", "log", "natural", "once_cell", @@ -2504,6 +2523,7 @@ dependencies = [ "transcribe-cpp", "transcribe-rs", "vad-rs", + "whatlang", "windows 0.61.3", "winreg 0.55.0", ] @@ -2534,7 +2554,17 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" dependencies = [ - "ahash", + "ahash 0.7.8", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash 0.8.12", + "allocator-api2", ] [[package]] @@ -2785,7 +2815,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.58.0", + "windows-core 0.61.2", ] [[package]] @@ -3039,7 +3069,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3058,6 +3088,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "isolang" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe50d48c77760c55188549098b9a7f6e37ae980c586a24693d6b01c3b2010c3c" +dependencies = [ + "phf 0.11.3", +] + [[package]] name = "itoa" version = "1.0.17" @@ -3488,7 +3527,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3613,7 +3652,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4152,7 +4191,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4293,7 +4332,6 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" dependencies = [ - "phf_macros 0.11.3", "phf_shared 0.11.3", ] @@ -4392,19 +4430,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "phf_macros" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "phf_macros" version = "0.13.1" @@ -5253,7 +5278,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5310,7 +5335,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6804,7 +6829,7 @@ dependencies = [ "getrandom 0.4.1", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -7250,7 +7275,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7947,6 +7972,16 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" +[[package]] +name = "whatlang" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471d1c1645d361eb782a1650b1786a8fb58dd625e681a04c09f5ff7c8764a7b0" +dependencies = [ + "hashbrown 0.14.5", + "once_cell", +] + [[package]] name = "widestring" version = "1.2.1" @@ -7975,7 +8010,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 41ee9608ce..fcc5c4f3ce 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -64,6 +64,10 @@ rustfft = "6.4.0" strsim = "0.11.0" natural = "0.5.0" regex = "1" +# Text-based language ID (filler-word removal fallback); isolang bridges the +# ISO 639-1 codes in model metadata to whatlang's 639-3 codes. +whatlang = "0.16" +isolang = "2" chrono = "0.4" rusqlite = { version = "0.37", features = ["bundled"] } tar = "0.4.44" diff --git a/src-tauri/src/audio_toolkit/lang_id.rs b/src-tauri/src/audio_toolkit/lang_id.rs new file mode 100644 index 0000000000..14cf5e2dec --- /dev/null +++ b/src-tauri/src/audio_toolkit/lang_id.rs @@ -0,0 +1,90 @@ +//! Confidence-gated text-based language identification. +//! +//! Last-resort evidence for filler-word removal when neither the user's +//! language selection nor the transcription model identifies the output +//! language. Detection is constrained to the languages the active model can +//! produce and fails closed: any doubt returns `None`, which callers treat as +//! an unknown output language. + +use whatlang::{Detector, Lang}; + +/// Minimum whatlang confidence (0.0–1.0) to accept a detection, on top of +/// whatlang's own `is_reliable()` heuristic. A wrong accepted language can +/// reintroduce real-word deletion (e.g. Portuguese "um"), so the gate is +/// deliberately strict: calibrated on ~8k short Tatoeba sentences across the +/// 16 filler-profile languages, `is_reliable() && confidence >= 0.9` fires on +/// ~66% of sentences with 99.9% accuracy (script-distinct languages ~100%, +/// Latin-script languages 22–64%). Missed detections merely skip gated filler +/// removal; the universal tier still applies. +const MIN_CONFIDENCE: f64 = 0.9; + +/// Whatlang's Mandarin code is `cmn`, which has no ISO 639-1 form; model +/// metadata uses `zh`. +fn whatlang_lang_for_iso639_1(code: &str) -> Option { + let three = match code { + "zh" => "cmn", + other => isolang::Language::from_639_1(other)?.to_639_3(), + }; + Lang::from_code(three) +} + +fn iso639_1_for_whatlang(lang: Lang) -> Option<&'static str> { + match lang { + Lang::Cmn => Some("zh"), + other => isolang::Language::from_639_3(other.code())?.to_639_1(), + } +} + +/// Detects the language of transcribed text, constrained to the languages the +/// model can output. Returns an ISO 639-1 code only for a reliable, +/// high-confidence detection; `None` otherwise. +pub fn detect_output_language(text: &str, supported_languages: &[String]) -> Option { + let allowlist: Vec = supported_languages + .iter() + .filter_map(|code| whatlang_lang_for_iso639_1(code)) + .collect(); + + // No usable metadata means no constraint, not no detection. + let detector = if allowlist.is_empty() { + Detector::new() + } else { + Detector::with_allowlist(allowlist) + }; + + let info = detector.detect(text)?; + if !info.is_reliable() || info.confidence() < MIN_CONFIDENCE { + return None; + } + + iso639_1_for_whatlang(info.lang()).map(str::to_string) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn langs(codes: &[&str]) -> Vec { + codes.iter().map(|c| c.to_string()).collect() + } + + #[test] + fn detects_portuguese_sentence_containing_um() { + let detected = detect_output_language( + "eu vi um carro na rua ontem de manhã quando fui ao mercado", + &langs(&["en", "pt", "es"]), + ); + assert_eq!(detected.as_deref(), Some("pt")); + } + + #[test] + fn short_ambiguous_text_returns_none() { + let detected = detect_output_language("um ok", &langs(&["en", "pt"])); + assert_eq!(detected, None); + } + + #[test] + fn chinese_maps_between_zh_and_cmn() { + assert_eq!(whatlang_lang_for_iso639_1("zh"), Some(Lang::Cmn)); + assert_eq!(iso639_1_for_whatlang(Lang::Cmn), Some("zh")); + } +} diff --git a/src-tauri/src/audio_toolkit/mod.rs b/src-tauri/src/audio_toolkit/mod.rs index fed4924836..f7f453c604 100644 --- a/src-tauri/src/audio_toolkit/mod.rs +++ b/src-tauri/src/audio_toolkit/mod.rs @@ -1,5 +1,6 @@ pub mod audio; pub mod constants; +pub mod lang_id; pub mod text; pub mod utils; pub mod vad; @@ -8,6 +9,7 @@ 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, VadPolicy, }; +pub use lang_id::detect_output_language; pub use text::{ apply_custom_words, normalize_transcription_output, remove_filler_words, OutputLanguageEvidence, }; diff --git a/src-tauri/src/audio_toolkit/text.rs b/src-tauri/src/audio_toolkit/text.rs index ee2b66ffa2..b212301ea6 100644 --- a/src-tauri/src/audio_toolkit/text.rs +++ b/src-tauri/src/audio_toolkit/text.rs @@ -274,6 +274,12 @@ fn extract_punctuation(word: &str) -> (&str, &str) { pub enum OutputLanguageEvidence { UserSelected(String), ModelConstrained(String), + /// The transcription model itself identified the language (audio-based + /// LID, e.g. Whisper in auto mode). + ModelDetected(String), + /// Detected from the transcribed text with high confidence, constrained to + /// the model's supported languages. Weakest accepted evidence. + TextDetected(String), TranslatedToEnglish, Unknown, } @@ -281,42 +287,36 @@ pub enum OutputLanguageEvidence { impl OutputLanguageEvidence { fn language(&self) -> Option<&str> { match self { - Self::UserSelected(language) | Self::ModelConstrained(language) => Some(language), + Self::UserSelected(language) + | Self::ModelConstrained(language) + | Self::ModelDetected(language) + | Self::TextDetected(language) => Some(language), Self::TranslatedToEnglish => Some("en"), Self::Unknown => None, } } } -/// Returns filler words appropriate for the given language code. -/// -/// Some words like "um" and "ha" are real words in certain languages -/// (e.g., Portuguese "um" = "a/an", Spanish "ha" = "has"), so we only -/// include them as fillers for languages where they are truly fillers. -fn get_filler_words_for_language(lang: &str) -> Option<&'static [&'static str]> { +/// Filler tokens that are not lexical words in any language Handy's models can +/// output, so removing them cannot corrupt text regardless of the (possibly +/// unknown) output language. Kept deliberately conservative: anything that is a +/// real word somewhere ("um" pt/de, "ha" es, "ah"/"eh" interjections, "mm" +/// millimetres) belongs in the language-gated lists instead. +const UNIVERSAL_FILLER_WORDS: &[&str] = &[ + "uh", "uhm", "umm", "uhh", "uhhh", "ehh", "ehm", "ahm", "hmm", "hm", "mmm", "хм", "ммм", +]; + +/// Filler words that are only safe to remove with evidence for the output +/// language, because the same token is a real word elsewhere (e.g. Portuguese +/// "um" = "a/an", German "um" = "at/around", Spanish "ha" = "has"). +fn gated_filler_words_for_language(lang: &str) -> &'static [&'static str] { let base_lang = lang.split(&['-', '_'][..]).next().unwrap_or(lang); match base_lang { - "en" => Some(&[ - "uh", "um", "uhm", "umm", "uhh", "uhhh", "ah", "hmm", "hm", "mmm", "mm", "mh", "eh", - "ehh", "ha", - ]), - "es" => Some(&["ehm", "mmm", "hmm", "hm"]), - "pt" => Some(&["ahm", "hmm", "mmm", "hm"]), - "fr" => Some(&["euh", "hmm", "hm", "mmm"]), - "de" => Some(&["äh", "ähm", "hmm", "hm", "mmm"]), - "it" => Some(&["ehm", "hmm", "mmm", "hm"]), - "cs" => Some(&["ehm", "hmm", "mmm", "hm"]), - "pl" => Some(&["hmm", "mmm", "hm"]), - "tr" => Some(&["hmm", "mmm", "hm"]), - "ru" => Some(&["хм", "ммм", "hmm", "mmm"]), - "uk" => Some(&["хм", "ммм", "hmm", "mmm"]), - "ar" => Some(&["hmm", "mmm"]), - "ja" => Some(&["hmm", "mmm"]), - "ko" => Some(&["hmm", "mmm"]), - "vi" => Some(&["hmm", "mmm", "hm"]), - "zh" => Some(&["hmm", "mmm"]), - _ => None, + "en" => &["um", "ah", "eh", "ha"], + "de" => &["äh", "ähm"], + "fr" => &["euh"], + _ => &[], } } @@ -363,10 +363,13 @@ fn collapse_stutters(text: &str) -> String { /// Removes filler words from transcription output when enabled. /// -/// A custom list is an explicit user override and therefore does not require -/// language evidence. `Some(empty vec)` disables removal, preserving the -/// legacy power-user setting. The master toggle takes precedence over both -/// built-in and custom lists. +/// Built-in removal is two-tiered: [`UNIVERSAL_FILLER_WORDS`] apply regardless +/// of language evidence, while [`gated_filler_words_for_language`] tokens are +/// only removed when the output language is known. A custom list is an +/// explicit user override and replaces both tiers without requiring language +/// evidence. `Some(empty vec)` disables removal, preserving the legacy +/// power-user setting. The master toggle takes precedence over both built-in +/// and custom lists. /// /// # Arguments /// * `text` - The raw transcription text to filter @@ -387,17 +390,20 @@ pub fn remove_filler_words( return text.to_string(); } - // Build filler patterns from custom list or language defaults + // Build filler patterns from custom list or the built-in tiers let patterns: Vec = match custom_filler_words { Some(words) => words .iter() .filter_map(|word| Regex::new(&format!(r"(?i)\b{}\b[,.]?", regex::escape(word))).ok()) .collect(), - None => language - .language() - .and_then(get_filler_words_for_language) - .unwrap_or_default() + None => UNIVERSAL_FILLER_WORDS .iter() + .chain( + language + .language() + .map(gated_filler_words_for_language) + .unwrap_or_default(), + ) .map(|word| Regex::new(&format!(r"(?i)\b{}\b[,.]?", regex::escape(word))).unwrap()) .collect(), }; @@ -622,10 +628,10 @@ mod tests { } #[test] - fn test_filter_unknown_language_skips_builtin_removal() { + fn test_filter_unknown_language_still_removes_universal_fillers() { let text = "uh I think uhm this works"; let result = filter_transcription_output(text, "xx", &None); - assert_eq!(result, "uh I think uhm this works"); + assert_eq!(result, "I think this works"); } #[test] @@ -635,6 +641,72 @@ mod tests { assert_eq!(result, "um I think this works"); } + #[test] + fn test_filter_unknown_evidence_removes_universal_keeps_gated() { + let filtered = remove_filler_words( + "uhh bueno hmm creo que um ha llegado", + &OutputLanguageEvidence::Unknown, + &None, + true, + ); + assert_eq!( + normalize_transcription_output(&filtered), + "bueno creo que um ha llegado" + ); + + let cyrillic = remove_filler_words( + "хм я думаю ммм это работает", + &OutputLanguageEvidence::Unknown, + &None, + true, + ); + assert_eq!( + normalize_transcription_output(&cyrillic), + "я думаю это работает" + ); + } + + #[test] + fn test_filter_german_gated_fillers_require_evidence() { + let text = "äh ich glaube ähm das passt"; + + let unknown = remove_filler_words(text, &OutputLanguageEvidence::Unknown, &None, true); + assert_eq!(normalize_transcription_output(&unknown), text); + + let result = filter_transcription_output(text, "de", &None); + assert_eq!(result, "ich glaube das passt"); + } + + #[test] + fn test_filter_preserves_millimetre_unit() { + // "mm" was removed from the filler lists because it eats units. + let text = "the screw is 5 mm long"; + let result = filter_transcription_output(text, "en", &None); + assert_eq!(result, "the screw is 5 mm long"); + } + + #[test] + fn test_filter_detected_evidence_unlocks_gated_fillers() { + let model = remove_filler_words( + "um I think this works", + &OutputLanguageEvidence::ModelDetected("en".to_string()), + &None, + true, + ); + assert_eq!(normalize_transcription_output(&model), "I think this works"); + + let text = remove_filler_words( + "euh je pense que ça marche", + &OutputLanguageEvidence::TextDetected("fr".to_string()), + &None, + true, + ); + assert_eq!( + normalize_transcription_output(&text), + "je pense que ça marche" + ); + } + #[test] fn test_filter_master_toggle_disables_custom_and_builtin_removal() { let text = "um customword I think"; diff --git a/src-tauri/src/managers/transcription.rs b/src-tauri/src/managers/transcription.rs index ca545d5424..4b0de59558 100644 --- a/src-tauri/src/managers/transcription.rs +++ b/src-tauri/src/managers/transcription.rs @@ -1,5 +1,6 @@ use crate::audio_toolkit::{ - apply_custom_words, normalize_transcription_output, remove_filler_words, OutputLanguageEvidence, + apply_custom_words, detect_output_language, normalize_transcription_output, + remove_filler_words, OutputLanguageEvidence, }; use crate::managers::audio::AudioRecordingManager; use crate::managers::model::{EngineType, ModelManager}; @@ -108,6 +109,8 @@ enum StreamCmd { struct FinalizedStreamText { text: String, output_language: OutputLanguageEvidence, + /// The streaming model's supported languages, for text-based detection. + supported_languages: Vec, } /// Routes real-time audio frames to the active streaming worker. Shared between @@ -996,9 +999,22 @@ impl TranscriptionManager { update.audio_committed_ms, update.buffered_ms, ); + // In auto mode the model's own LID is the best + // remaining evidence; the snapshot is only + // materialized when it can change the outcome. + let output_language = match &output_language { + OutputLanguageEvidence::Unknown => { + with_model_detected_language( + OutputLanguageEvidence::Unknown, + stream.snapshot().language, + ) + } + resolved => resolved.clone(), + }; Some(FinalizedStreamText { text: stream.text().display(), - output_language: output_language.clone(), + output_language, + supported_languages: languages.clone(), }) } Err(e) => { @@ -1099,6 +1115,7 @@ impl TranscriptionManager { &settings, false, &finalized.output_language, + &finalized.supported_languages, ); self.maybe_unload_immediately("streaming transcription"); @@ -1207,7 +1224,7 @@ impl TranscriptionManager { // Perform transcription with the appropriate engine. // We use catch_unwind to prevent engine panics from poisoning the mutex, // which would make the app hang indefinitely on subsequent operations. - let (result, output_language) = { + let (result, output_language, model_languages) = { let mut engine_guard = self.lock_engine(); // Take the engine out so we own it during transcription. @@ -1237,6 +1254,7 @@ impl TranscriptionManager { .map(|info| info.supported_languages) .unwrap_or_default(); let mut output_was_translated = false; + let mut model_detected_language: Option = None; if let LoadedEngine::TranscribeCpp(session) = &engine { let model = session.model(); let caps = model.capabilities(); @@ -1296,7 +1314,12 @@ impl TranscriptionManager { session .run(&audio, &run_options) - .map(|t| t.text) + .map(|t| { + // Whisper's audio-based LID (auto mode only; + // `None` when a language hint was passed). + model_detected_language = t.language; + t.text + }) .map_err(|e| { anyhow::anyhow!("transcribe-cpp transcription failed: {}", e) }) @@ -1420,14 +1443,18 @@ impl TranscriptionManager { } }; - let output_language = resolve_output_language_evidence( - &settings, - &validated_language, - &model_languages, - output_was_translated, + let output_language = with_model_detected_language( + resolve_output_language_evidence( + &settings, + &validated_language, + &model_languages, + output_was_translated, + ), + model_detected_language, ); + debug!("Output language evidence: {:?}", output_language); - (text, output_language) + (text, output_language, model_languages) }; // Apply fuzzy word correction if custom words are configured — UNLESS the @@ -1435,8 +1462,13 @@ impl TranscriptionManager { // family). We don't pass a prompt to non-whisper models (it requires the // whisper-kind run extension), so they still get fuzzy correction here, // same as the ONNX engines. - let filtered_result = - post_process_transcription_text(result, &settings, model_is_whisper, &output_language); + let filtered_result = post_process_transcription_text( + result, + &settings, + model_is_whisper, + &output_language, + &model_languages, + ); let et = std::time::Instant::now(); let translation_note = if settings.translate_to_english { @@ -1650,6 +1682,23 @@ fn resolve_output_language_evidence( OutputLanguageEvidence::Unknown } +/// Upgrade [`OutputLanguageEvidence::Unknown`] with the language the model +/// itself detected during the run (audio-based LID, e.g. Whisper in auto +/// mode). Stronger evidence resolved before the run is never overridden. +fn with_model_detected_language( + evidence: OutputLanguageEvidence, + detected: Option, +) -> OutputLanguageEvidence { + match (evidence, detected) { + (OutputLanguageEvidence::Unknown, Some(language)) + if !language.is_empty() && language != "auto" => + { + OutputLanguageEvidence::ModelDetected(language) + } + (evidence, _) => evidence, + } +} + struct TranscribeCppRunPlan { task: Task, language: Option, @@ -1691,6 +1740,7 @@ fn post_process_transcription_text( settings: &AppSettings, custom_words_already_prompted: bool, output_language: &OutputLanguageEvidence, + supported_languages: &[String], ) -> String { fail_open_text_transform(raw, |raw| { let corrected = if !settings.custom_words.is_empty() && !custom_words_already_prompted { @@ -1703,9 +1753,28 @@ fn post_process_transcription_text( raw }; + // Last-resort language evidence: confidence-gated detection from the + // transcribed text itself, constrained to the model's languages. Only + // consulted when it can change the outcome (built-in gated fillers). + let output_language = match output_language { + OutputLanguageEvidence::Unknown + if settings.filler_word_removal_enabled + && settings.custom_filler_words.is_none() => + { + match detect_output_language(&corrected, supported_languages) { + Some(language) => { + debug!("Text-based language detection resolved '{}'", language); + OutputLanguageEvidence::TextDetected(language) + } + None => OutputLanguageEvidence::Unknown, + } + } + other => other.clone(), + }; + let without_fillers = remove_filler_words( &corrected, - output_language, + &output_language, &settings.custom_filler_words, settings.filler_word_removal_enabled, ); @@ -2031,6 +2100,7 @@ mod tests { &settings, false, &evidence, + &supported, ); assert_eq!( @@ -2041,7 +2111,7 @@ mod tests { } #[test] - fn auto_language_without_detection_skips_builtin_filler_removal() { + fn auto_language_without_detection_skips_gated_filler_removal() { let settings = AppSettings { selected_language: "auto".to_string(), ..Default::default() @@ -2049,15 +2119,84 @@ mod tests { let evidence = resolve_output_language_evidence(&settings, "auto", &languages(&["en", "pt"]), false); + // Too short for a reliable text detection, so the gated "um" must + // survive; the universal "uhm" is removed regardless. let result = post_process_transcription_text( - "um this may be Portuguese".to_string(), + "um uhm ok".to_string(), &settings, false, &evidence, + &languages(&["en", "pt"]), ); assert_eq!(evidence, OutputLanguageEvidence::Unknown); - assert_eq!(result, "um this may be Portuguese"); + assert_eq!(result, "um ok"); + } + + #[test] + fn unknown_evidence_with_confident_text_detection_removes_gated_fillers() { + let settings = AppSettings { + selected_language: "auto".to_string(), + ..Default::default() + }; + + let result = post_process_transcription_text( + "um so the weather forecast said it would probably rain throughout the whole weekend" + .to_string(), + &settings, + false, + &OutputLanguageEvidence::Unknown, + &languages(&["en", "pt", "es", "de"]), + ); + + assert_eq!( + result, + "so the weather forecast said it would probably rain throughout the whole weekend" + ); + } + + #[test] + fn unknown_evidence_with_portuguese_text_preserves_um() { + let settings = AppSettings { + selected_language: "auto".to_string(), + ..Default::default() + }; + + let result = post_process_transcription_text( + "eu vi um carro na rua ontem de manhã quando fui ao mercado".to_string(), + &settings, + false, + &OutputLanguageEvidence::Unknown, + &languages(&["en", "pt", "es", "de"]), + ); + + assert_eq!( + result, + "eu vi um carro na rua ontem de manhã quando fui ao mercado" + ); + } + + #[test] + fn model_detected_language_upgrades_unknown_evidence_only() { + assert_eq!( + with_model_detected_language(OutputLanguageEvidence::Unknown, Some("en".to_string())), + OutputLanguageEvidence::ModelDetected("en".to_string()) + ); + assert_eq!( + with_model_detected_language(OutputLanguageEvidence::Unknown, Some("auto".to_string())), + OutputLanguageEvidence::Unknown + ); + assert_eq!( + with_model_detected_language(OutputLanguageEvidence::Unknown, None), + OutputLanguageEvidence::Unknown + ); + assert_eq!( + with_model_detected_language( + OutputLanguageEvidence::UserSelected("pt".to_string()), + Some("en".to_string()) + ), + OutputLanguageEvidence::UserSelected("pt".to_string()) + ); } #[test] diff --git a/src/bindings.ts b/src/bindings.ts index 51e64c97f2..5b79b9b3b5 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -913,7 +913,7 @@ bindings?: Partial<{ [key in string]: ShortcutBinding }>; push_to_talk?: boolean * upgrading from before this key existed are blanked by the migration so they * see the current release's notes — see `apply_settings_migrations`. */ -whats_new_last_seen_version?: string; selected_model?: string; onboarding_completed?: boolean; always_on_microphone?: boolean; selected_microphone?: string | null; clamshell_microphone?: string | null; selected_output_device?: string | null; translate_to_english?: boolean; selected_language?: string; overlay_position?: OverlayPosition; debug_mode?: boolean; log_level?: LogLevel; custom_words?: string[]; model_unload_timeout?: ModelUnloadTimeout; word_correction_threshold?: number; history_limit?: number; recording_retention_period?: RecordingRetentionPeriod; paste_method?: PasteMethod; clipboard_handling?: ClipboardHandling; auto_submit?: boolean; auto_submit_key?: AutoSubmitKey; post_process_enabled?: boolean; 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; mute_while_recording?: boolean; append_trailing_space?: boolean; app_language?: string; theme?: Theme; experimental_enabled?: boolean; lazy_stream_close?: boolean; keyboard_implementation?: KeyboardImplementation; show_tray_icon?: boolean; paste_delay_ms?: number; paste_delay_after_ms?: number; typing_tool?: TypingTool; external_script_path?: string | null; filler_word_removal_enabled?: boolean; custom_filler_words?: string[] | null; transcribe_accelerator?: TranscribeAcceleratorSetting; ort_accelerator?: OrtAcceleratorSetting; transcribe_gpu_device?: number; extra_recording_buffer_ms?: number; vad_enabled?: boolean; +whats_new_last_seen_version?: string; selected_model?: string; onboarding_completed?: boolean; always_on_microphone?: boolean; selected_microphone?: string | null; clamshell_microphone?: string | null; selected_output_device?: string | null; translate_to_english?: boolean; selected_language?: string; overlay_position?: OverlayPosition; debug_mode?: boolean; log_level?: LogLevel; custom_words?: string[]; model_unload_timeout?: ModelUnloadTimeout; word_correction_threshold?: number; history_limit?: number; recording_retention_period?: RecordingRetentionPeriod; paste_method?: PasteMethod; clipboard_handling?: ClipboardHandling; auto_submit?: boolean; auto_submit_key?: AutoSubmitKey; post_process_enabled?: boolean; 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; mute_while_recording?: boolean; append_trailing_space?: boolean; app_language?: string; theme?: Theme; experimental_enabled?: boolean; lazy_stream_close?: boolean; keyboard_implementation?: KeyboardImplementation; show_tray_icon?: boolean; paste_delay_ms?: number; paste_delay_after_ms?: number; typing_tool?: TypingTool; external_script_path?: string | null; filler_word_removal_enabled?: boolean; custom_filler_words?: string[] | null; transcribe_accelerator?: TranscribeAcceleratorSetting; ort_accelerator?: OrtAcceleratorSetting; transcribe_gpu_device?: number; extra_recording_buffer_ms?: number; vad_enabled?: boolean; /** * Which recording overlay to show: None / Minimal / Live. Streaming mode is * not gated on this — that follows model capability. Migrated from the old From 858688f91707a295be1c89753a5d3bbd544003a1 Mon Sep 17 00:00:00 2001 From: CJ Pais Date: Sun, 9 Aug 2026 16:38:33 +0800 Subject: [PATCH 4/4] minor tweaks --- src-tauri/src/audio_toolkit/lang_id.rs | 106 ++++++++++++++++++++--- src-tauri/src/managers/transcription.rs | 109 ++++++++++++++++++------ src/i18n/locales/ar/translation.json | 2 +- src/i18n/locales/bg/translation.json | 2 +- src/i18n/locales/cs/translation.json | 2 +- src/i18n/locales/de/translation.json | 2 +- src/i18n/locales/en/translation.json | 2 +- src/i18n/locales/es/translation.json | 2 +- src/i18n/locales/fr/translation.json | 2 +- src/i18n/locales/he/translation.json | 2 +- src/i18n/locales/hi/translation.json | 2 +- src/i18n/locales/it/translation.json | 2 +- src/i18n/locales/ja/translation.json | 2 +- src/i18n/locales/ko/translation.json | 2 +- src/i18n/locales/ne/translation.json | 2 +- src/i18n/locales/nl/translation.json | 4 +- src/i18n/locales/pl/translation.json | 2 +- src/i18n/locales/pt/translation.json | 2 +- src/i18n/locales/ru/translation.json | 2 +- src/i18n/locales/sv/translation.json | 2 +- src/i18n/locales/tr/translation.json | 2 +- src/i18n/locales/uk/translation.json | 2 +- src/i18n/locales/vi/translation.json | 2 +- src/i18n/locales/zh-TW/translation.json | 2 +- src/i18n/locales/zh/translation.json | 2 +- 25 files changed, 200 insertions(+), 63 deletions(-) diff --git a/src-tauri/src/audio_toolkit/lang_id.rs b/src-tauri/src/audio_toolkit/lang_id.rs index 14cf5e2dec..82834bdb7e 100644 --- a/src-tauri/src/audio_toolkit/lang_id.rs +++ b/src-tauri/src/audio_toolkit/lang_id.rs @@ -18,14 +18,30 @@ use whatlang::{Detector, Lang}; /// removal; the universal tier still applies. const MIN_CONFIDENCE: f64 = 0.9; -/// Whatlang's Mandarin code is `cmn`, which has no ISO 639-1 form; model -/// metadata uses `zh`. -fn whatlang_lang_for_iso639_1(code: &str) -> Option { - let three = match code { - "zh" => "cmn", - other => isolang::Language::from_639_1(other)?.to_639_3(), +/// Converts a model language code to whatlang's ISO 639-3 enum. +/// +/// Model metadata may use ISO 639-1, ISO 639-3, or BCP-47-style regional and +/// script tags. Filler profiles only care about the primary language, so +/// `pt-BR`/`PT_br` normalize to `pt` and `zh-Hant` normalizes to `zh`. +/// Whatlang represents Mandarin as `cmn`, which has no ISO 639-1 form. +fn whatlang_lang_for_model_code(code: &str) -> Option { + let primary = code + .trim() + .split(&['-', '_'][..]) + .next()? + .to_ascii_lowercase(); + + if primary == "zh" { + return Some(Lang::Cmn); + } + + let language = match primary.len() { + 2 => isolang::Language::from_639_1(&primary)?, + 3 => isolang::Language::from_639_3(&primary)?, + _ => return None, }; - Lang::from_code(three) + + Lang::from_code(language.to_639_3()) } fn iso639_1_for_whatlang(lang: Lang) -> Option<&'static str> { @@ -39,18 +55,27 @@ fn iso639_1_for_whatlang(lang: Lang) -> Option<&'static str> { /// model can output. Returns an ISO 639-1 code only for a reliable, /// high-confidence detection; `None` otherwise. pub fn detect_output_language(text: &str, supported_languages: &[String]) -> Option { + // Codes whatlang cannot represent (e.g. Maltese in Parakeet V3's list, + // Cantonese in SenseVoice's) are dropped rather than disabling detection + // for the whole model. Text in a dropped language only causes harm if it + // clears the confidence gate as en/de/fr — the only gated filler profiles — + // which is the same misdetection risk the gate already absorbs for in-list + // confusions like pt vs es. let allowlist: Vec = supported_languages .iter() - .filter_map(|code| whatlang_lang_for_iso639_1(code)) + .filter_map(|code| whatlang_lang_for_model_code(code)) .collect(); - // No usable metadata means no constraint, not no detection. - let detector = if allowlist.is_empty() { + let detector = if supported_languages.is_empty() { + // No published metadata means no constraint, not no detection. Detector::new() + } else if allowlist.is_empty() { + // Metadata exists but none of it is representable: any detection + // would name a language the model cannot output. + return None; } else { Detector::with_allowlist(allowlist) }; - let info = detector.detect(text)?; if !info.is_reliable() || info.confidence() < MIN_CONFIDENCE { return None; @@ -83,8 +108,63 @@ mod tests { } #[test] - fn chinese_maps_between_zh_and_cmn() { - assert_eq!(whatlang_lang_for_iso639_1("zh"), Some(Lang::Cmn)); + fn normalizes_model_language_codes() { + assert_eq!(whatlang_lang_for_model_code("pt-BR"), Some(Lang::Por)); + assert_eq!(whatlang_lang_for_model_code("PT_br"), Some(Lang::Por)); + assert_eq!(whatlang_lang_for_model_code("eng"), Some(Lang::Eng)); + assert_eq!(whatlang_lang_for_model_code("zh-Hant"), Some(Lang::Cmn)); assert_eq!(iso639_1_for_whatlang(Lang::Cmn), Some("zh")); } + + #[test] + fn regional_allowlist_preserves_portuguese_detection() { + let detected = detect_output_language( + "eu vi um carro na rua ontem de manhã quando fui ao mercado", + &langs(&["en", "pt-BR"]), + ); + assert_eq!(detected.as_deref(), Some("pt")); + } + + #[test] + fn unmappable_codes_are_dropped_not_fatal() { + // SenseVoice lists Cantonese (`yue`), which whatlang cannot represent; + // detection must still work for the representable languages. + let detected = detect_output_language( + "um so the weather forecast said it would probably rain throughout the whole weekend", + &langs(&["zh", "yue", "en", "ja", "ko"]), + ); + assert_eq!(detected.as_deref(), Some("en")); + } + + #[test] + fn parakeet_v3_language_list_still_detects() { + // Parakeet V3's metadata includes Maltese (`mt`), unrepresentable in + // whatlang; the remaining 24 languages must stay detectable. + let parakeet_v3 = langs(&[ + "bg", "hr", "cs", "da", "nl", "en", "et", "fi", "fr", "de", "el", "hu", "it", "lv", + "lt", "mt", "pl", "pt", "ro", "sk", "sl", "es", "sv", "ru", "uk", + ]); + let detected = detect_output_language( + "eu vi um carro na rua ontem de manhã quando fui ao mercado", + ¶keet_v3, + ); + assert_eq!(detected.as_deref(), Some("pt")); + } + + #[test] + fn fully_unmappable_metadata_fails_closed() { + // If nothing the model outputs is representable, any answer would name + // a language the model cannot produce. + let text = "eu vi um carro na rua ontem de manhã quando fui ao mercado"; + assert_eq!(detect_output_language(text, &langs(&["yue"])), None); + } + + #[test] + fn missing_metadata_detects_unconstrained() { + let detected = detect_output_language( + "eu vi um carro na rua ontem de manhã quando fui ao mercado", + &[], + ); + assert_eq!(detected.as_deref(), Some("pt")); + } } diff --git a/src-tauri/src/managers/transcription.rs b/src-tauri/src/managers/transcription.rs index 4b0de59558..98d7e96cfa 100644 --- a/src-tauri/src/managers/transcription.rs +++ b/src-tauri/src/managers/transcription.rs @@ -912,7 +912,7 @@ impl TranscriptionManager { ); let output_language = resolve_output_language_evidence( &settings, - &effective_language, + run_plan.language.as_deref(), &languages, run_plan.target_language.as_deref() == Some("en"), ); @@ -1254,6 +1254,7 @@ impl TranscriptionManager { .map(|info| info.supported_languages) .unwrap_or_default(); let mut output_was_translated = false; + let mut applied_language_hint: Option = None; let mut model_detected_language: Option = None; if let LoadedEngine::TranscribeCpp(session) = &engine { let model = session.model(); @@ -1296,6 +1297,7 @@ impl TranscriptionManager { model_supports_translate, ); output_was_translated = run_plan.target_language.as_deref() == Some("en"); + applied_language_hint = run_plan.language.clone(); let run_options = RunOptions { task: run_plan.task, @@ -1353,6 +1355,7 @@ impl TranscriptionManager { "yue" => Some("yue".to_string()), _ => None, }; + applied_language_hint = language.clone(); let params = SenseVoiceParams { language, use_itn: Some(true), @@ -1373,6 +1376,7 @@ impl TranscriptionManager { } else { Some(validated_language.clone()) }; + applied_language_hint = lang.clone(); let options = TranscribeOptions { language: lang, translate: settings.translate_to_english, @@ -1389,6 +1393,7 @@ impl TranscriptionManager { } else { Some(normalize_cjk_language(&validated_language).to_string()) }; + applied_language_hint = lang.clone(); let options = TranscribeOptions { language: lang, ..Default::default() @@ -1446,7 +1451,7 @@ impl TranscriptionManager { let output_language = with_model_detected_language( resolve_output_language_evidence( &settings, - &validated_language, + applied_language_hint.as_deref(), &model_languages, output_was_translated, ), @@ -1648,7 +1653,7 @@ fn effective_language_for_model( /// decision. fn resolve_output_language_evidence( settings: &AppSettings, - effective_language: &str, + applied_language_hint: Option<&str>, supported_languages: &[String], translated_to_english: bool, ) -> OutputLanguageEvidence { @@ -1656,29 +1661,29 @@ fn resolve_output_language_evidence( return OutputLanguageEvidence::TranslatedToEnglish; } - // An explicit, usable user selection is the strongest source-language - // signal. If the selection was unsupported, effective_language is "auto" - // (or a model-required fallback), so it must not be treated as selected. - if settings.selected_language != "auto" - && effective_language != "auto" - && base_language_code(&settings.selected_language) == base_language_code(effective_language) + // Stored language intent is only evidence when this specific engine run + // actually received the hint. Some multilingual engines (notably Parakeet + // V3) always auto-detect and ignore Handy's selection; transcribe-cpp also + // drops a requested hint when the loaded model does not advertise it. + if let Some(language) = applied_language_hint.filter(|lang| !lang.is_empty() && *lang != "auto") { - return OutputLanguageEvidence::UserSelected(effective_language.to_string()); + if settings.selected_language != "auto" + && base_language_code(&settings.selected_language) == base_language_code(language) + { + return OutputLanguageEvidence::UserSelected(language.to_string()); + } + + // The engine may have required a concrete fallback even though the + // user's persisted language was auto or unsupported. + return OutputLanguageEvidence::ModelConstrained(language.to_string()); } - // A single-language model has a known output language even if its metadata - // also advertises language detection and effective_language remains auto. + // A single-language model has a known output language without needing a + // selectable language hint. if let [language] = supported_languages { return OutputLanguageEvidence::ModelConstrained(language.clone()); } - // Models that cannot auto-detect are coerced to a concrete supported - // language. That model-required fallback is still reliable evidence about - // the output, but is distinct from the user's persisted intent. - if effective_language != "auto" { - return OutputLanguageEvidence::ModelConstrained(effective_language.to_string()); - } - OutputLanguageEvidence::Unknown } @@ -2093,7 +2098,7 @@ mod tests { ..Default::default() }; let supported = languages(&["en", "pt"]); - let evidence = resolve_output_language_evidence(&settings, "pt", &supported, false); + let evidence = resolve_output_language_evidence(&settings, Some("pt"), &supported, false); let result = post_process_transcription_text( "eu vi um carro".to_string(), @@ -2117,7 +2122,7 @@ mod tests { ..Default::default() }; let evidence = - resolve_output_language_evidence(&settings, "auto", &languages(&["en", "pt"]), false); + resolve_output_language_evidence(&settings, None, &languages(&["en", "pt"]), false); // Too short for a reliable text detection, so the gated "um" must // survive; the universal "uhm" is removed regardless. @@ -2207,7 +2212,7 @@ mod tests { }; let evidence = - resolve_output_language_evidence(&settings, "auto", &languages(&["en"]), false); + resolve_output_language_evidence(&settings, None, &languages(&["en"]), false); assert_eq!( evidence, @@ -2222,8 +2227,12 @@ mod tests { ..Default::default() }; - let evidence = - resolve_output_language_evidence(&settings, "en", &languages(&["en", "de"]), false); + let evidence = resolve_output_language_evidence( + &settings, + Some("en"), + &languages(&["en", "de"]), + false, + ); assert_eq!( evidence, @@ -2231,6 +2240,50 @@ mod tests { ); } + #[test] + fn ignored_user_language_is_not_output_evidence() { + let settings = AppSettings { + // Parakeet V3 ignores language hints and auto-detects even when a + // selection from the previously active model remains persisted. + selected_language: "en".to_string(), + ..Default::default() + }; + let supported = languages(&["en", "de", "pt"]); + + let evidence = resolve_output_language_evidence(&settings, None, &supported, false); + assert_eq!(evidence, OutputLanguageEvidence::Unknown); + + let result = post_process_transcription_text( + "eu vi um carro".to_string(), + &settings, + false, + &evidence, + &supported, + ); + assert_eq!(result, "eu vi um carro"); + } + + #[test] + fn unapplied_transcribe_cpp_language_is_not_output_evidence() { + let settings = AppSettings { + selected_language: "en".to_string(), + ..Default::default() + }; + let supported = languages(&[]); + let plan = transcribe_cpp_run_plan(false, "en", &supported, false); + + assert_eq!(plan.language, None); + assert_eq!( + resolve_output_language_evidence( + &settings, + plan.language.as_deref(), + &supported, + false, + ), + OutputLanguageEvidence::Unknown + ); + } + #[test] fn translated_output_is_treated_as_english() { let settings = AppSettings { @@ -2238,8 +2291,12 @@ mod tests { ..Default::default() }; - let evidence = - resolve_output_language_evidence(&settings, "pt", &languages(&["en", "pt"]), true); + let evidence = resolve_output_language_evidence( + &settings, + Some("pt"), + &languages(&["en", "pt"]), + true, + ); assert_eq!(evidence, OutputLanguageEvidence::TranslatedToEnglish); } diff --git a/src/i18n/locales/ar/translation.json b/src/i18n/locales/ar/translation.json index 35e9c89ddb..0474b9edda 100644 --- a/src/i18n/locales/ar/translation.json +++ b/src/i18n/locales/ar/translation.json @@ -349,7 +349,7 @@ }, "fillerWordRemoval": { "title": "إزالة كلمات الحشو", - "description": "إزالة كلمات التردد الشائعة عندما يعرف Handy لغة التفريغ. عطّل هذا الخيار للحصول على تفريغ حرفي." + "description": "يزيل كلمات التردد الشائعة من النصوص. عطّل هذا الخيار للاحتفاظ بها." } }, "postProcessing": { diff --git a/src/i18n/locales/bg/translation.json b/src/i18n/locales/bg/translation.json index 3953545695..98789f4fa1 100644 --- a/src/i18n/locales/bg/translation.json +++ b/src/i18n/locales/bg/translation.json @@ -370,7 +370,7 @@ }, "fillerWordRemoval": { "title": "Премахване на думи паразити", - "description": "Премахва често срещаните думи за колебание, когато Handy знае езика на транскрипцията. Изключете за дословна транскрипция." + "description": "Премахва често срещаните думи за колебание от транскрипциите. Изключете, за да ги запазите." } }, "postProcessing": { diff --git a/src/i18n/locales/cs/translation.json b/src/i18n/locales/cs/translation.json index c15d8e7b3b..7f79d0b678 100644 --- a/src/i18n/locales/cs/translation.json +++ b/src/i18n/locales/cs/translation.json @@ -370,7 +370,7 @@ }, "fillerWordRemoval": { "title": "Odstranit výplňová slova", - "description": "Odstraní běžná zaváhací slova, pokud Handy zná jazyk přepisu. Vypněte pro doslovný přepis." + "description": "Odstraňuje běžná slova vyjadřující váhání z přepisů. Vypnutím je zachováte." } }, "postProcessing": { diff --git a/src/i18n/locales/de/translation.json b/src/i18n/locales/de/translation.json index 8d13faadb1..3cd1c852f4 100644 --- a/src/i18n/locales/de/translation.json +++ b/src/i18n/locales/de/translation.json @@ -370,7 +370,7 @@ }, "fillerWordRemoval": { "title": "Füllwörter entfernen", - "description": "Entfernt häufige Verzögerungslaute, wenn Handy die Sprache der Transkription kennt. Deaktivieren für eine wortgetreue Transkription." + "description": "Entfernt häufige Verzögerungslaute aus Transkriptionen. Deaktivieren Sie diese Option, um sie beizubehalten." } }, "postProcessing": { diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index 1ce07f67b2..2d61dc0915 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -370,7 +370,7 @@ }, "fillerWordRemoval": { "title": "Remove Filler Words", - "description": "Remove common hesitation words when Handy knows the transcription language. Disable for verbatim transcription." + "description": "Removes common hesitation words from transcriptions. Turn off to keep them." } }, "postProcessing": { diff --git a/src/i18n/locales/es/translation.json b/src/i18n/locales/es/translation.json index b74d2f2fdd..603f99fa96 100644 --- a/src/i18n/locales/es/translation.json +++ b/src/i18n/locales/es/translation.json @@ -370,7 +370,7 @@ }, "fillerWordRemoval": { "title": "Eliminar muletillas", - "description": "Elimina las muletillas más comunes cuando Handy conoce el idioma de la transcripción. Desactívalo para una transcripción literal." + "description": "Elimina las muletillas más comunes de las transcripciones. Desactívalo para conservarlas." } }, "postProcessing": { diff --git a/src/i18n/locales/fr/translation.json b/src/i18n/locales/fr/translation.json index 014c5075bc..f1e1de5ecd 100644 --- a/src/i18n/locales/fr/translation.json +++ b/src/i18n/locales/fr/translation.json @@ -370,7 +370,7 @@ }, "fillerWordRemoval": { "title": "Supprimer les mots de remplissage", - "description": "Supprime les hésitations courantes lorsque Handy connaît la langue de la transcription. Désactivez cette option pour une transcription mot à mot." + "description": "Supprime les hésitations courantes des transcriptions. Désactivez cette option pour les conserver." } }, "postProcessing": { diff --git a/src/i18n/locales/he/translation.json b/src/i18n/locales/he/translation.json index cbf519b1ef..f69434e94b 100644 --- a/src/i18n/locales/he/translation.json +++ b/src/i18n/locales/he/translation.json @@ -370,7 +370,7 @@ }, "fillerWordRemoval": { "title": "הסרת מילות מילוי", - "description": "מסיר מילות היסוס נפוצות כאשר Handy מזהה את שפת התמלול. השבת לתמלול מילה במילה." + "description": "מסיר מילות היסוס נפוצות מתמלולים. השבת כדי להשאיר אותן." } }, "postProcessing": { diff --git a/src/i18n/locales/hi/translation.json b/src/i18n/locales/hi/translation.json index 5277ff6aa9..ae4da03d06 100644 --- a/src/i18n/locales/hi/translation.json +++ b/src/i18n/locales/hi/translation.json @@ -370,7 +370,7 @@ }, "fillerWordRemoval": { "title": "फिलर शब्द हटाएं", - "description": "जब Handy को ट्रांसक्रिप्शन की भाषा पता हो, तब आम हिचकिचाहट वाले शब्द हटाएं. शब्दशः ट्रांसक्रिप्शन के लिए इसे बंद करें." + "description": "ट्रांसक्रिप्शन से आम हिचकिचाहट वाले शब्द हटाता है। उन्हें बनाए रखने के लिए इसे बंद करें।" } }, "postProcessing": { diff --git a/src/i18n/locales/it/translation.json b/src/i18n/locales/it/translation.json index 657fe1779e..26a88bb916 100644 --- a/src/i18n/locales/it/translation.json +++ b/src/i18n/locales/it/translation.json @@ -370,7 +370,7 @@ }, "fillerWordRemoval": { "title": "Rimuovi le parole riempitive", - "description": "Rimuove le parole di esitazione più comuni quando Handy conosce la lingua della trascrizione. Disattiva per una trascrizione letterale." + "description": "Rimuove le parole di esitazione più comuni dalle trascrizioni. Disattiva per mantenerle." } }, "postProcessing": { diff --git a/src/i18n/locales/ja/translation.json b/src/i18n/locales/ja/translation.json index 7b27f8183e..9c3f010c68 100644 --- a/src/i18n/locales/ja/translation.json +++ b/src/i18n/locales/ja/translation.json @@ -370,7 +370,7 @@ }, "fillerWordRemoval": { "title": "フィラー語を削除", - "description": "Handy が文字起こしの言語を認識している場合に、よくあるつなぎ言葉を削除します。そのまま文字起こしする場合は無効にしてください。" + "description": "文字起こしから一般的なフィラー語を削除します。残すにはオフにしてください。" } }, "postProcessing": { diff --git a/src/i18n/locales/ko/translation.json b/src/i18n/locales/ko/translation.json index 7035da15af..f89bdac974 100644 --- a/src/i18n/locales/ko/translation.json +++ b/src/i18n/locales/ko/translation.json @@ -370,7 +370,7 @@ }, "fillerWordRemoval": { "title": "군더더기 표현 제거", - "description": "Handy가 전사 언어를 알고 있을 때 흔한 머뭇거림 표현을 제거합니다. 있는 그대로 전사하려면 비활성화하세요." + "description": "전사에서 흔한 머뭇거림 표현을 제거합니다. 그대로 두려면 이 옵션을 끄세요." } }, "postProcessing": { diff --git a/src/i18n/locales/ne/translation.json b/src/i18n/locales/ne/translation.json index ace30f61cb..2991883fec 100644 --- a/src/i18n/locales/ne/translation.json +++ b/src/i18n/locales/ne/translation.json @@ -370,7 +370,7 @@ }, "fillerWordRemoval": { "title": "फिलर शब्दहरू हटाउनुहोस्", - "description": "Handy लाई ट्रान्सक्रिप्सनको भाषा थाहा हुँदा सामान्य हिचकिचाहटका शब्दहरू हटाउँछ। शब्दशः ट्रान्सक्रिप्सनका लागि यसलाई निष्क्रिय पार्नुहोस्।" + "description": "ट्रान्सक्रिप्सनबाट सामान्य हिचकिचाहटका शब्दहरू हटाउँछ। तिनलाई राख्न यो विकल्प बन्द गर्नुहोस्।" } }, "postProcessing": { diff --git a/src/i18n/locales/nl/translation.json b/src/i18n/locales/nl/translation.json index d23957afb7..c9681e9291 100644 --- a/src/i18n/locales/nl/translation.json +++ b/src/i18n/locales/nl/translation.json @@ -369,8 +369,8 @@ "description": "Filter stilte uit opnames. Streaming-modellen gebruiken een langere VAD-uitloop; het uitschakelen van VAD neemt de ruwe audio op." }, "fillerWordRemoval": { - "title": "Stopwoorden verwijderen", - "description": "Verwijdert veelvoorkomende twijfelwoorden wanneer Handy de taal van de transcriptie kent. Schakel dit uit voor een woordelijke transcriptie." + "title": "Opvulwoorden verwijderen", + "description": "Verwijdert veelvoorkomende aarzelwoorden uit transcripties. Schakel dit uit om ze te behouden." } }, "postProcessing": { diff --git a/src/i18n/locales/pl/translation.json b/src/i18n/locales/pl/translation.json index e374a92a31..cce2ed3b41 100644 --- a/src/i18n/locales/pl/translation.json +++ b/src/i18n/locales/pl/translation.json @@ -370,7 +370,7 @@ }, "fillerWordRemoval": { "title": "Usuń wyrazy wypełniające", - "description": "Usuwa typowe wtrącenia i wahania, gdy Handy zna język transkrypcji. Wyłącz, aby uzyskać dosłowną transkrypcję." + "description": "Usuwa typowe słowa wyrażające wahanie z transkrypcji. Wyłącz, aby je zachować." } }, "postProcessing": { diff --git a/src/i18n/locales/pt/translation.json b/src/i18n/locales/pt/translation.json index 4c6e814211..9063fd1472 100644 --- a/src/i18n/locales/pt/translation.json +++ b/src/i18n/locales/pt/translation.json @@ -370,7 +370,7 @@ }, "fillerWordRemoval": { "title": "Remover palavras de preenchimento", - "description": "Remove hesitações comuns quando o Handy reconhece o idioma da transcrição. Desative para uma transcrição literal." + "description": "Remove hesitações comuns das transcrições. Desative para mantê-las." } }, "postProcessing": { diff --git a/src/i18n/locales/ru/translation.json b/src/i18n/locales/ru/translation.json index 31b6184409..ed1f46b04c 100644 --- a/src/i18n/locales/ru/translation.json +++ b/src/i18n/locales/ru/translation.json @@ -370,7 +370,7 @@ }, "fillerWordRemoval": { "title": "Удалять слова-паразиты", - "description": "Удаляет распространённые слова-заминки, когда Handy знает язык расшифровки. Отключите для дословной расшифровки." + "description": "Удаляет распространённые слова-заминки из расшифровок. Отключите, чтобы сохранить их." } }, "postProcessing": { diff --git a/src/i18n/locales/sv/translation.json b/src/i18n/locales/sv/translation.json index b5a6204fd9..8b154c4550 100644 --- a/src/i18n/locales/sv/translation.json +++ b/src/i18n/locales/sv/translation.json @@ -370,7 +370,7 @@ }, "fillerWordRemoval": { "title": "Ta bort utfyllnadsord", - "description": "Tar bort vanliga tvekljud när Handy känner till transkriberingens språk. Inaktivera för ordagrann transkribering." + "description": "Tar bort vanliga tvekljud från transkriberingar. Stäng av för att behålla dem." } }, "postProcessing": { diff --git a/src/i18n/locales/tr/translation.json b/src/i18n/locales/tr/translation.json index a86f17444a..13b29ef8bc 100644 --- a/src/i18n/locales/tr/translation.json +++ b/src/i18n/locales/tr/translation.json @@ -370,7 +370,7 @@ }, "fillerWordRemoval": { "title": "Dolgu sözcüklerini kaldır", - "description": "Handy, transkripsiyon dilini bildiğinde yaygın duraksama sözcüklerini kaldırır. Birebir transkripsiyon için devre dışı bırakın." + "description": "Transkripsiyonlardaki yaygın duraksama sözcüklerini kaldırır. Bunları korumak için kapatın." } }, "postProcessing": { diff --git a/src/i18n/locales/uk/translation.json b/src/i18n/locales/uk/translation.json index 8537c3e67a..4647ce0f88 100644 --- a/src/i18n/locales/uk/translation.json +++ b/src/i18n/locales/uk/translation.json @@ -370,7 +370,7 @@ }, "fillerWordRemoval": { "title": "Видаляти слова-паразити", - "description": "Видаляє поширені слова-вагання, коли Handy знає мову транскрипції. Вимкніть для дослівної транскрипції." + "description": "Видаляє поширені слова-вагання з транскрипцій. Вимкніть, щоб зберегти їх." } }, "postProcessing": { diff --git a/src/i18n/locales/vi/translation.json b/src/i18n/locales/vi/translation.json index a05cff8e75..07a978c9a4 100644 --- a/src/i18n/locales/vi/translation.json +++ b/src/i18n/locales/vi/translation.json @@ -370,7 +370,7 @@ }, "fillerWordRemoval": { "title": "Loại bỏ từ đệm", - "description": "Loại bỏ các từ ngập ngừng phổ biến khi Handy biết ngôn ngữ phiên âm. Tắt để có bản ghi nguyên văn." + "description": "Loại bỏ các từ ngập ngừng phổ biến khỏi bản chép lời. Tắt để giữ lại các từ này." } }, "postProcessing": { diff --git a/src/i18n/locales/zh-TW/translation.json b/src/i18n/locales/zh-TW/translation.json index 2792740b35..750590d9e7 100644 --- a/src/i18n/locales/zh-TW/translation.json +++ b/src/i18n/locales/zh-TW/translation.json @@ -370,7 +370,7 @@ }, "fillerWordRemoval": { "title": "移除語助詞", - "description": "當 Handy 已知轉錄語言時,移除常見的語助詞。若需逐字轉錄,請關閉此選項。" + "description": "從轉錄中移除常見的語助詞。關閉此選項即可保留這些詞。" } }, "postProcessing": { diff --git a/src/i18n/locales/zh/translation.json b/src/i18n/locales/zh/translation.json index 315045301b..6fd8b551f0 100644 --- a/src/i18n/locales/zh/translation.json +++ b/src/i18n/locales/zh/translation.json @@ -370,7 +370,7 @@ }, "fillerWordRemoval": { "title": "移除语气词", - "description": "当 Handy 已知转录语言时,移除常见的语气词。如需逐字转录,请关闭此选项。" + "description": "从转录中移除常见的语气词。关闭此选项即可保留这些词。" } }, "postProcessing": {