diff --git a/src-tauri/src/audio_toolkit/text.rs b/src-tauri/src/audio_toolkit/text.rs index 0f780bc..29f43ff 100644 --- a/src-tauri/src/audio_toolkit/text.rs +++ b/src-tauri/src/audio_toolkit/text.rs @@ -194,11 +194,40 @@ fn extract_punctuation(word: &str) -> (&str, &str) { (prefix, suffix) } -/// Filler words to remove from transcriptions -const FILLER_WORDS: &[&str] = &[ - "uh", "um", "uhm", "umm", "uhh", "uhhh", "ah", "eh", "hmm", "hm", "mmm", "mm", "mh", "ha", - "ehh", -]; +/// 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] { + 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", + ], + } +} static MULTI_SPACE_PATTERN: Lazy = Lazy::new(|| Regex::new(r"\s{2,}").unwrap()); @@ -242,34 +271,42 @@ fn collapse_stutters(text: &str) -> String { result.join(" ") } -/// Pre-compiled filler word patterns (built lazily) -static FILLER_PATTERNS: Lazy> = Lazy::new(|| { - FILLER_WORDS - .iter() - .map(|word| { - // Match filler word with word boundaries, optionally followed by comma or period - Regex::new(&format!(r"(?i)\b{}\b[,.]?", regex::escape(word))).unwrap() - }) - .collect() -}); - /// Filters transcription output by removing filler words and stutter artifacts. /// /// This function cleans up raw transcription text by: -/// 1. Removing filler words (uh, um, hmm, etc.) +/// 1. Removing filler words based on the app language (or custom list) /// 2. Collapsing repeated 1-2 letter stutters (e.g., "wh wh wh" -> "wh") /// 3. Cleaning up excess whitespace /// /// # Arguments /// * `text` - The raw transcription text to filter +/// * `lang` - The app language code (e.g., "en", "pt-BR") used to select filler words +/// * `custom_filler_words` - Optional user-provided filler word list. `Some(vec)` overrides +/// language defaults; `Some(empty vec)` disables filtering; `None` uses language defaults. /// /// # Returns /// The filtered text with filler words and stutters removed -pub fn filter_transcription_output(text: &str) -> String { +pub fn filter_transcription_output( + text: &str, + lang: &str, + custom_filler_words: &Option>, +) -> String { let mut filtered = text.to_string(); + // Build filler patterns from custom list or language defaults + 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) + .iter() + .map(|word| Regex::new(&format!(r"(?i)\b{}\b[,.]?", regex::escape(word))).unwrap()) + .collect(), + }; + // Remove filler words - for pattern in FILLER_PATTERNS.iter() { + for pattern in &patterns { filtered = pattern.replace_all(&filtered, "").to_string(); } @@ -327,81 +364,144 @@ mod tests { #[test] fn test_filter_filler_words() { - let text = "So um I was thinking uh about this"; - let result = filter_transcription_output(text); + let text = "So uhm I was thinking uh about this"; + let result = filter_transcription_output(text, "en", &None); assert_eq!(result, "So I was thinking about this"); } #[test] fn test_filter_filler_words_case_insensitive() { - let text = "UM this is UH a test"; - let result = filter_transcription_output(text); + let text = "UHM this is UH a test"; + let result = filter_transcription_output(text, "en", &None); assert_eq!(result, "this is a test"); } #[test] fn test_filter_filler_words_with_punctuation() { - let text = "Well, um, I think, uh. that's right"; - let result = filter_transcription_output(text); + let text = "Well, uhm, I think, uh. that's right"; + let result = filter_transcription_output(text, "en", &None); assert_eq!(result, "Well, I think, that's right"); } #[test] fn test_filter_cleans_whitespace() { let text = "Hello world test"; - let result = filter_transcription_output(text); + let result = filter_transcription_output(text, "en", &None); assert_eq!(result, "Hello world test"); } #[test] fn test_filter_trims() { let text = " Hello world "; - let result = filter_transcription_output(text); + let result = filter_transcription_output(text, "en", &None); assert_eq!(result, "Hello world"); } #[test] fn test_filter_combined() { - let text = " Um, so I was, uh, thinking about this "; - let result = filter_transcription_output(text); + let text = " Uhm, so I was, uh, thinking about this "; + let result = filter_transcription_output(text, "en", &None); assert_eq!(result, "so I was, thinking about this"); } #[test] fn test_filter_preserves_valid_text() { let text = "This is a completely normal sentence."; - let result = filter_transcription_output(text); + let result = filter_transcription_output(text, "en", &None); assert_eq!(result, "This is a completely normal sentence."); } #[test] fn test_filter_stutter_collapse() { let text = "w wh wh wh wh wh wh wh wh wh why"; - let result = filter_transcription_output(text); + let result = filter_transcription_output(text, "en", &None); assert_eq!(result, "w wh why"); } #[test] fn test_filter_stutter_short_words() { let text = "I I I I think so so so so"; - let result = filter_transcription_output(text); + let result = filter_transcription_output(text, "en", &None); assert_eq!(result, "I think so"); } #[test] fn test_filter_stutter_mixed_case() { let text = "No NO no NO no"; - let result = filter_transcription_output(text); + let result = filter_transcription_output(text, "en", &None); assert_eq!(result, "No"); } #[test] fn test_filter_stutter_preserves_two_repetitions() { let text = "no no is fine"; - let result = filter_transcription_output(text); + let result = filter_transcription_output(text, "en", &None); assert_eq!(result, "no no is fine"); } + #[test] + fn test_filter_english_removes_um() { + let text = "um I think um this is good"; + let result = filter_transcription_output(text, "en", &None); + assert_eq!(result, "I think this is good"); + } + + #[test] + fn test_filter_portuguese_preserves_um() { + // "um" means "a/an" in Portuguese + let text = "um gato bonito"; + let result = filter_transcription_output(text, "pt", &None); + assert_eq!(result, "um gato bonito"); + } + + #[test] + fn test_filter_spanish_preserves_ha() { + // "ha" means "has" in Spanish + let text = "ha sido un buen día"; + let result = filter_transcription_output(text, "es", &None); + assert_eq!(result, "ha sido un buen día"); + } + + #[test] + fn test_filter_language_code_with_region() { + // "pt-BR" should normalize to "pt" + let text = "um gato bonito"; + let result = filter_transcription_output(text, "pt-BR", &None); + assert_eq!(result, "um gato bonito"); + } + + #[test] + fn test_filter_custom_filler_words_override() { + let custom = Some(vec!["okay".to_string(), "right".to_string()]); + let text = "okay so I think right this works"; + let result = filter_transcription_output(text, "en", &custom); + assert_eq!(result, "so I think this works"); + } + + #[test] + fn test_filter_custom_filler_words_empty_disables() { + let custom = Some(vec![]); + let text = "So uhm I was thinking uh about this"; + let result = filter_transcription_output(text, "en", &custom); + // No filler words removed since custom list is empty + assert_eq!(result, "So uhm I was thinking uh about this"); + } + + #[test] + fn test_filter_unknown_language_uses_fallback() { + 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 + 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_apply_custom_words_ngram_two_words() { let text = "il cui nome è Charge B, che permette"; diff --git a/src-tauri/src/managers/transcription.rs b/src-tauri/src/managers/transcription.rs index e9a3311..e9f5484 100644 --- a/src-tauri/src/managers/transcription.rs +++ b/src-tauri/src/managers/transcription.rs @@ -614,7 +614,11 @@ impl TranscriptionManager { }; // Filter out filler words and hallucinations - let filtered_result = filter_transcription_output(&corrected_result); + let filtered_result = filter_transcription_output( + &corrected_result, + &settings.app_language, + &settings.custom_filler_words, + ); let et = std::time::Instant::now(); let translation_note = if settings.translate_to_english { diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 430ec76..7e242eb 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -360,6 +360,8 @@ pub struct AppSettings { #[serde(default = "default_typing_tool")] pub typing_tool: TypingTool, pub external_script_path: Option, + #[serde(default)] + pub custom_filler_words: Option>, } fn default_model() -> String { @@ -724,6 +726,7 @@ pub fn get_default_settings() -> AppSettings { paste_delay_ms: default_paste_delay_ms(), typing_tool: default_typing_tool(), external_script_path: None, + custom_filler_words: None, } } diff --git a/src/bindings.ts b/src/bindings.ts index 0a9edce..b4d1bbc 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -738,7 +738,7 @@ async isLaptop() : Promise> { /** user-defined types **/ -export type AppSettings = { bindings: Partial<{ [key in string]: ShortcutBinding }>; push_to_talk: boolean; audio_feedback: boolean; audio_feedback_volume?: number; sound_theme?: SoundTheme; start_hidden?: boolean; autostart_enabled?: boolean; update_checks_enabled?: boolean; selected_model?: string; always_on_microphone?: boolean; selected_microphone?: string | null; clamshell_microphone?: string | null; selected_output_device?: string | null; translate_to_english?: boolean; 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?: Partial<{ [key in string]: string }>; 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; experimental_enabled?: boolean; keyboard_implementation?: KeyboardImplementation; show_tray_icon?: boolean; paste_delay_ms?: number; typing_tool?: TypingTool; external_script_path: string | null } +export type AppSettings = { bindings: Partial<{ [key in string]: ShortcutBinding }>; push_to_talk: boolean; audio_feedback: boolean; audio_feedback_volume?: number; sound_theme?: SoundTheme; start_hidden?: boolean; autostart_enabled?: boolean; update_checks_enabled?: boolean; selected_model?: string; always_on_microphone?: boolean; selected_microphone?: string | null; clamshell_microphone?: string | null; selected_output_device?: string | null; translate_to_english?: boolean; 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?: Partial<{ [key in string]: string }>; 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; experimental_enabled?: boolean; keyboard_implementation?: KeyboardImplementation; show_tray_icon?: boolean; paste_delay_ms?: number; typing_tool?: TypingTool; external_script_path: string | null; custom_filler_words?: string[] | null } export type AudioDevice = { index: string; name: string; is_default: boolean } export type AutoSubmitKey = "enter" | "ctrl_enter" | "cmd_enter" export type BindingResponse = { success: boolean; binding: ShortcutBinding | null; error: string | null }