diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 81e72d8244..4729c86873 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" @@ -2463,6 +2481,7 @@ dependencies = [ "handy-keys", "hf-hub", "hound", + "isolang", "libc", "log", "natural", @@ -2509,6 +2528,7 @@ dependencies = [ "transcribe-cpp", "transcribe-rs", "vad-rs", + "whatlang", "windows 0.61.3", "winreg 0.55.0", ] @@ -2539,7 +2559,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]] @@ -3063,6 +3093,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" @@ -7962,6 +8001,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" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 3265568920..bada35340f 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..82834bdb7e --- /dev/null +++ b/src-tauri/src/audio_toolkit/lang_id.rs @@ -0,0 +1,170 @@ +//! 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; + +/// 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(language.to_639_3()) +} + +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 { + // 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_model_code(code)) + .collect(); + + 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; + } + + 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 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/audio_toolkit/mod.rs b/src-tauri/src/audio_toolkit/mod.rs index 291753a22b..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,9 @@ 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 lang_id::detect_output_language; +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..b212301ea6 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) } -/// Returns filler words appropriate for the given language code. +/// Evidence for the language of the text being cleaned. /// -/// 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] { +/// 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), + /// 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, +} + +impl OutputLanguageEvidence { + fn language(&self) -> Option<&str> { + match self { + Self::UserSelected(language) + | Self::ModelConstrained(language) + | Self::ModelDetected(language) + | Self::TextDetected(language) => Some(language), + Self::TranslatedToEnglish => Some("en"), + Self::Unknown => None, + } + } +} + +/// 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" => &[ - "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", - ], + "en" => &["um", "ah", "eh", "ha"], + "de" => &["äh", "ähm"], + "fr" => &["euh"], + _ => &[], } } @@ -341,59 +361,95 @@ 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 +/// 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 -/// * `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 + // 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 => get_filler_words_for_language(lang) + 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(), }; // 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 +628,107 @@ mod tests { } #[test] - fn test_filter_unknown_language_uses_fallback() { + 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, "I think 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_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"; + 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 6b3e2fa042..3b24fde941 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -646,6 +646,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 bf169c05cd..30e3af4c42 100644 --- a/src-tauri/src/managers/transcription.rs +++ b/src-tauri/src/managers/transcription.rs @@ -1,4 +1,7 @@ -use crate::audio_toolkit::{apply_custom_words, filter_transcription_output}; +use crate::audio_toolkit::{ + apply_custom_words, detect_output_language, normalize_transcription_output, + remove_filler_words, OutputLanguageEvidence, +}; use crate::managers::audio::AudioRecordingManager; use crate::managers::model::{EngineType, ModelManager}; use crate::settings::{ @@ -99,10 +102,17 @@ 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, + /// 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 /// the [`TranscriptionManager`] (opens/closes the route) and the audio recorder's /// per-frame callback (feeds frames). The recorder holds an `Arc` @@ -900,6 +910,12 @@ impl TranscriptionManager { &languages, supports_translate, ); + let output_language = resolve_output_language_evidence( + &settings, + run_plan.language.as_deref(), + &languages, + run_plan.target_language.as_deref() == Some("en"), + ); let run_options = RunOptions { task: run_plan.task, language: run_plan.language, @@ -911,8 +927,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 +999,23 @@ impl TranscriptionManager { update.audio_committed_ms, update.buffered_ms, ); - Some(stream.text().full) + // 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().full, + output_language, + supported_languages: languages.clone(), + }) } Err(e) => { perf.record_compute(finalize_start.elapsed()); @@ -995,7 +1027,7 @@ impl TranscriptionManager { } }; let chars = match &result { - Some(text) => text.len(), + Some(finalized) => finalized.text.len(), _ => 0, }; perf.log_finalized(chars); @@ -1062,8 +1094,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 +1110,13 @@ 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, + &finalized.supported_languages, + ); self.maybe_unload_immediately("streaming transcription"); Ok(Some(filtered)) @@ -1186,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 = { + let (result, output_language, model_languages) = { let mut engine_guard = self.lock_engine(); // Take the engine out so we own it during transcription. @@ -1210,7 +1248,14 @@ 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; + let mut applied_language_hint: Option = None; + let mut model_detected_language: Option = None; if let LoadedEngine::TranscribeCpp(session) = &engine { let model = session.model(); let caps = model.capabilities(); @@ -1251,6 +1296,8 @@ impl TranscriptionManager { &model_languages, 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, @@ -1269,7 +1316,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) }) @@ -1303,6 +1355,7 @@ impl TranscriptionManager { "yue" => Some("yue".to_string()), _ => None, }; + applied_language_hint = language.clone(); let params = SenseVoiceParams { language, use_itn: Some(true), @@ -1317,11 +1370,13 @@ 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 { Some(validated_language.clone()) }; + applied_language_hint = lang.clone(); let options = TranscribeOptions { language: lang, translate: settings.translate_to_english, @@ -1338,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() @@ -1350,7 +1406,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 +1446,20 @@ impl TranscriptionManager { panic_msg )); } - } + }; + + let output_language = with_model_detected_language( + resolve_output_language_evidence( + &settings, + applied_language_hint.as_deref(), + &model_languages, + output_was_translated, + ), + model_detected_language, + ); + debug!("Output language evidence: {:?}", output_language); + + (text, output_language, model_languages) }; // Apply fuzzy word correction if custom words are configured — UNLESS the @@ -1398,7 +1467,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); + 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 { @@ -1552,6 +1627,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 +1648,62 @@ 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, + applied_language_hint: Option<&str>, + supported_languages: &[String], + translated_to_english: bool, +) -> OutputLanguageEvidence { + if translated_to_english { + return OutputLanguageEvidence::TranslatedToEnglish; + } + + // 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") + { + 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 without needing a + // selectable language hint. + if let [language] = supported_languages { + return OutputLanguageEvidence::ModelConstrained(language.clone()); + } + + 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, @@ -1609,6 +1744,8 @@ fn post_process_transcription_text( raw: String, 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 { @@ -1621,11 +1758,33 @@ fn post_process_transcription_text( raw }; - filter_transcription_output( + // 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, - &settings.app_language, + &output_language, &settings.custom_filler_words, - ) + settings.filler_word_removal_enabled, + ); + + normalize_transcription_output(&without_fillers) }) } @@ -2035,6 +2194,217 @@ 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, Some("pt"), &supported, false); + + let result = post_process_transcription_text( + "eu vi um carro".to_string(), + &settings, + false, + &evidence, + &supported, + ); + + assert_eq!( + evidence, + OutputLanguageEvidence::UserSelected("pt".to_string()) + ); + assert_eq!(result, "eu vi um carro"); + } + + #[test] + fn auto_language_without_detection_skips_gated_filler_removal() { + let settings = AppSettings { + selected_language: "auto".to_string(), + ..Default::default() + }; + let evidence = + 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. + let result = post_process_transcription_text( + "um uhm ok".to_string(), + &settings, + false, + &evidence, + &languages(&["en", "pt"]), + ); + + assert_eq!(evidence, OutputLanguageEvidence::Unknown); + 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] + 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, None, &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, + Some("en"), + &languages(&["en", "de"]), + false, + ); + + assert_eq!( + evidence, + OutputLanguageEvidence::ModelConstrained("en".to_string()) + ); + } + + #[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 { + selected_language: "pt".to_string(), + ..Default::default() + }; + + let evidence = resolve_output_language_evidence( + &settings, + Some("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 7b73ba0e3c..f11d30e90c 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -456,6 +456,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)] @@ -540,6 +542,10 @@ fn default_vad_enabled() -> bool { true } +fn default_filler_word_removal_enabled() -> bool { + true +} + fn default_debug_mode() -> bool { false } @@ -898,6 +904,7 @@ pub fn get_default_settings() -> AppSettings { reliable_paste: false, 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(), @@ -1142,6 +1149,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()); } @@ -1259,6 +1267,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 ff3bc01e11..6ca0f9638b 100644 --- a/src-tauri/src/shortcut/mod.rs +++ b/src-tauri/src/shortcut/mod.rs @@ -1259,6 +1259,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 98127f979c..98d191d1d3 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -359,6 +359,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 }) }; @@ -951,7 +959,7 @@ selected_channel?: number | null; clamshell_microphone?: string | null; selected * after the target app actually reads the transcript, instead of after a * fixed delay. See `paste_tx`. macOS and Windows only. */ -reliable_paste?: boolean; 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; +reliable_paste?: boolean; 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 5397d1082a..a10240afff 100644 --- a/src/components/settings/index.ts +++ b/src/components/settings/index.ts @@ -24,6 +24,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/ar/translation.json b/src/i18n/locales/ar/translation.json index ebef21ec53..283c574721 100644 --- a/src/i18n/locales/ar/translation.json +++ b/src/i18n/locales/ar/translation.json @@ -353,6 +353,10 @@ "voiceActivityDetection": { "title": "كشف النشاط الصوتي", "description": "تصفية الصمت من التسجيلات. النماذج التي تدعم البث تستخدم فترة VAD أطول؛ وتعطيل VAD يسجّل الصوت الخام." + }, + "fillerWordRemoval": { + "title": "إزالة كلمات الحشو", + "description": "يزيل كلمات التردد الشائعة من النصوص. عطّل هذا الخيار للاحتفاظ بها." } }, "postProcessing": { diff --git a/src/i18n/locales/bg/translation.json b/src/i18n/locales/bg/translation.json index 79f498f708..f0bb022a93 100644 --- a/src/i18n/locales/bg/translation.json +++ b/src/i18n/locales/bg/translation.json @@ -376,6 +376,10 @@ "voiceActivityDetection": { "title": "Откриване на гласова активност", "description": "Филтрира тишината от записите. Моделите с поддръжка на стрийминг използват по-дълга опашка на VAD; изключването на VAD записва необработен звук." + }, + "fillerWordRemoval": { + "title": "Премахване на думи паразити", + "description": "Премахва често срещаните думи за колебание от транскрипциите. Изключете, за да ги запазите." } }, "postProcessing": { diff --git a/src/i18n/locales/cs/translation.json b/src/i18n/locales/cs/translation.json index cbd6fb17c6..1331583da9 100644 --- a/src/i18n/locales/cs/translation.json +++ b/src/i18n/locales/cs/translation.json @@ -376,6 +376,10 @@ "voiceActivityDetection": { "title": "Detekce hlasové aktivity", "description": "Odfiltruje ticho z nahrávek. Modely podporující streaming používají delší doběh VAD; vypnutí VAD nahrává surový zvuk." + }, + "fillerWordRemoval": { + "title": "Odstranit výplňová slova", + "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/da/translation.json b/src/i18n/locales/da/translation.json index 16b2375823..0b7dd30f16 100644 --- a/src/i18n/locales/da/translation.json +++ b/src/i18n/locales/da/translation.json @@ -376,6 +376,10 @@ "voiceActivityDetection": { "title": "Stemmeaktivitetsdetektion", "description": "Filtrer stilhed fra optagelser. Streaming-modeller bruger en længere VAD-hale; deaktivering af VAD optager rå lyd." + }, + "fillerWordRemoval": { + "title": "Fjern fyldord", + "description": "Fjerner almindelige tøveord fra transskriptioner. Slå fra for at beholde dem." } }, "postProcessing": { diff --git a/src/i18n/locales/de/translation.json b/src/i18n/locales/de/translation.json index 4d8aa8238a..8041fab81f 100644 --- a/src/i18n/locales/de/translation.json +++ b/src/i18n/locales/de/translation.json @@ -376,6 +376,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 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 d4d32a5dab..2bedc69e9a 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -376,6 +376,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": "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 108388c8c4..abb7ac322d 100644 --- a/src/i18n/locales/es/translation.json +++ b/src/i18n/locales/es/translation.json @@ -376,6 +376,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 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 6bdf9da07c..e48656888b 100644 --- a/src/i18n/locales/fr/translation.json +++ b/src/i18n/locales/fr/translation.json @@ -376,6 +376,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 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 415cb8226c..009288ccca 100644 --- a/src/i18n/locales/he/translation.json +++ b/src/i18n/locales/he/translation.json @@ -376,6 +376,10 @@ "voiceActivityDetection": { "title": "זיהוי פעילות קולית", "description": "סינון שקט מההקלטות. מודלים התומכים בסטרימינג משתמשים בזנב VAD ארוך יותר; ביטול VAD מקליט אודיו גולמי." + }, + "fillerWordRemoval": { + "title": "הסרת מילות מילוי", + "description": "מסיר מילות היסוס נפוצות מתמלולים. השבת כדי להשאיר אותן." } }, "postProcessing": { diff --git a/src/i18n/locales/hi/translation.json b/src/i18n/locales/hi/translation.json index ee6f0d6c5a..396c801587 100644 --- a/src/i18n/locales/hi/translation.json +++ b/src/i18n/locales/hi/translation.json @@ -376,6 +376,10 @@ "voiceActivityDetection": { "title": "वॉइस एक्टिविटी डिटेक्शन", "description": "रिकॉर्डिंग से बिना आवाज़ वाले हिस्से हटाएं. स्ट्रीमिंग वाले मॉडल लंबी VAD टेल इस्तेमाल करते हैं; VAD बंद करने पर कच्चा ऑडियो रिकॉर्ड होता है." + }, + "fillerWordRemoval": { + "title": "फिलर शब्द हटाएं", + "description": "ट्रांसक्रिप्शन से आम हिचकिचाहट वाले शब्द हटाता है। उन्हें बनाए रखने के लिए इसे बंद करें।" } }, "postProcessing": { diff --git a/src/i18n/locales/it/translation.json b/src/i18n/locales/it/translation.json index 80aefa3be9..7c9fdf0e8d 100644 --- a/src/i18n/locales/it/translation.json +++ b/src/i18n/locales/it/translation.json @@ -376,6 +376,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 dalle trascrizioni. Disattiva per mantenerle." } }, "postProcessing": { diff --git a/src/i18n/locales/ja/translation.json b/src/i18n/locales/ja/translation.json index 636892af4b..4e47da3ef0 100644 --- a/src/i18n/locales/ja/translation.json +++ b/src/i18n/locales/ja/translation.json @@ -376,6 +376,10 @@ "voiceActivityDetection": { "title": "音声区間検出 (VAD)", "description": "録音から無音部分を除去します。ストリーミング対応モデルはより長い音声検出区間を使用します。VAD を無効にすると音声をそのまま録音します。" + }, + "fillerWordRemoval": { + "title": "フィラー語を削除", + "description": "文字起こしから一般的なフィラー語を削除します。残すにはオフにしてください。" } }, "postProcessing": { diff --git a/src/i18n/locales/ko/translation.json b/src/i18n/locales/ko/translation.json index 557686ce5f..d67ccab199 100644 --- a/src/i18n/locales/ko/translation.json +++ b/src/i18n/locales/ko/translation.json @@ -376,6 +376,10 @@ "voiceActivityDetection": { "title": "음성 활동 감지", "description": "녹음에서 무음 구간을 걸러냅니다. 스트리밍 지원 모델은 더 긴 VAD 테일을 사용하며, VAD를 끄면 원본 오디오가 그대로 녹음됩니다." + }, + "fillerWordRemoval": { + "title": "군더더기 표현 제거", + "description": "전사에서 흔한 머뭇거림 표현을 제거합니다. 그대로 두려면 이 옵션을 끄세요." } }, "postProcessing": { diff --git a/src/i18n/locales/ne/translation.json b/src/i18n/locales/ne/translation.json index e9572e7cf9..7620858455 100644 --- a/src/i18n/locales/ne/translation.json +++ b/src/i18n/locales/ne/translation.json @@ -376,6 +376,10 @@ "voiceActivityDetection": { "title": "भ्वाइस एक्टिभिटी डिटेक्सन", "description": "रेकर्डिङबाट मौनता हटाउँछ। स्ट्रिमिङ-सक्षम मोडेलहरूले लामो VAD टेल प्रयोग गर्छन्; VAD अफ गर्दा कच्चा अडियो रेकर्ड हुन्छ।" + }, + "fillerWordRemoval": { + "title": "फिलर शब्दहरू हटाउनुहोस्", + "description": "ट्रान्सक्रिप्सनबाट सामान्य हिचकिचाहटका शब्दहरू हटाउँछ। तिनलाई राख्न यो विकल्प बन्द गर्नुहोस्।" } }, "postProcessing": { diff --git a/src/i18n/locales/nl/translation.json b/src/i18n/locales/nl/translation.json index af549bd3f5..ffe5b66a9e 100644 --- a/src/i18n/locales/nl/translation.json +++ b/src/i18n/locales/nl/translation.json @@ -376,6 +376,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": "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 5e3daed778..c7708d69d1 100644 --- a/src/i18n/locales/pl/translation.json +++ b/src/i18n/locales/pl/translation.json @@ -376,6 +376,10 @@ "voiceActivityDetection": { "title": "Wykrywanie aktywności głosowej", "description": "Odfiltrowuje ciszę z nagrań. Modele obsługujące streaming używają dłuższego marginesu VAD; wyłączenie VAD nagrywa surowy dźwięk." + }, + "fillerWordRemoval": { + "title": "Usuń wyrazy wypełniające", + "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 3f4dd94a00..ea58c05024 100644 --- a/src/i18n/locales/pt/translation.json +++ b/src/i18n/locales/pt/translation.json @@ -376,6 +376,10 @@ "voiceActivityDetection": { "title": "Detecção de atividade de voz", "description": "Filtra o silêncio das gravações. Modelos com suporte a 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 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 5b85e04880..7e28d4de81 100644 --- a/src/i18n/locales/ru/translation.json +++ b/src/i18n/locales/ru/translation.json @@ -376,6 +376,10 @@ "voiceActivityDetection": { "title": "Определение голосовой активности", "description": "Отфильтровывает тишину из записей. Модели с поддержкой потоковой обработки используют более длинный «хвост» VAD; отключение VAD записывает необработанный звук." + }, + "fillerWordRemoval": { + "title": "Удалять слова-паразиты", + "description": "Удаляет распространённые слова-заминки из расшифровок. Отключите, чтобы сохранить их." } }, "postProcessing": { diff --git a/src/i18n/locales/sv/translation.json b/src/i18n/locales/sv/translation.json index e92f075efa..8287ef28f1 100644 --- a/src/i18n/locales/sv/translation.json +++ b/src/i18n/locales/sv/translation.json @@ -376,6 +376,10 @@ "voiceActivityDetection": { "title": "Röstaktivitetsdetektering", "description": "Filtrerar bort tystnad från inspelningar. Modeller med streamingstöd 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 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 34cf6c5858..4636e9aa31 100644 --- a/src/i18n/locales/tr/translation.json +++ b/src/i18n/locales/tr/translation.json @@ -376,6 +376,10 @@ "voiceActivityDetection": { "title": "Ses Etkinliği Algılama", "description": "Kayıtlardaki sessizliği filtreler. Akış destekli modeller daha uzun bir VAD kuyruğu kullanır; VAD devre dışı bırakıldığında ham ses kaydedilir." + }, + "fillerWordRemoval": { + "title": "Dolgu sözcüklerini kaldır", + "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 8f8149c189..7529d1720d 100644 --- a/src/i18n/locales/uk/translation.json +++ b/src/i18n/locales/uk/translation.json @@ -376,6 +376,10 @@ "voiceActivityDetection": { "title": "Виявлення голосової активності", "description": "Відфільтровує тишу із записів. Моделі з підтримкою потокової обробки використовують довший «хвіст» VAD; вимкнення VAD записує необроблений звук." + }, + "fillerWordRemoval": { + "title": "Видаляти слова-паразити", + "description": "Видаляє поширені слова-вагання з транскрипцій. Вимкніть, щоб зберегти їх." } }, "postProcessing": { diff --git a/src/i18n/locales/vi/translation.json b/src/i18n/locales/vi/translation.json index b94dee384d..5b2303c7cf 100644 --- a/src/i18n/locales/vi/translation.json +++ b/src/i18n/locales/vi/translation.json @@ -376,6 +376,10 @@ "voiceActivityDetection": { "title": "Phát hiện hoạt động giọng nói", "description": "Lọc bỏ khoảng lặng khỏi bản ghi. Các mô hình hỗ trợ truyền trực tiếp 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 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 4d694ed8f9..7d6c24ec70 100644 --- a/src/i18n/locales/zh-TW/translation.json +++ b/src/i18n/locales/zh-TW/translation.json @@ -376,6 +376,10 @@ "voiceActivityDetection": { "title": "語音活動偵測", "description": "過濾錄音中的靜音。支援串流的模型會使用較長的 VAD 尾段;停用 VAD 則會錄製原始音訊" + }, + "fillerWordRemoval": { + "title": "移除語助詞", + "description": "從轉錄中移除常見的語助詞。關閉此選項即可保留這些詞。" } }, "postProcessing": { diff --git a/src/i18n/locales/zh/translation.json b/src/i18n/locales/zh/translation.json index d816fc7db9..12c578d329 100644 --- a/src/i18n/locales/zh/translation.json +++ b/src/i18n/locales/zh/translation.json @@ -376,6 +376,10 @@ "voiceActivityDetection": { "title": "语音活动检测", "description": "过滤录音中的静音。支持流式的模型会使用更长的 VAD 尾段;停用 VAD 则会录制原始音频。" + }, + "fillerWordRemoval": { + "title": "移除语气词", + "description": "从转录中移除常见的语气词。关闭此选项即可保留这些词。" } }, "postProcessing": { diff --git a/src/stores/settingsStore.ts b/src/stores/settingsStore.ts index 5538908aed..aa1868d862 100644 --- a/src/stores/settingsStore.ts +++ b/src/stores/settingsStore.ts @@ -162,6 +162,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) =>