feat: language-aware filler word removal (#971)

* Remove filler words that conflict with European languages

Removed three filler words from the transcription filter that are actual
words in European languages:

- 'um' - Portuguese/Spanish indefinite article meaning 'a/an' (masculine)
  Example: Portuguese 'Isto é um teste' (This is a test)
  Removing this was breaking Portuguese transcriptions

- 'ha' - Spanish/Italian/Norwegian/Swedish auxiliary verb meaning 'has/have'
  Example: Spanish 'Él ha comido' (He has eaten)
  This is a very common verb form in Romance and Scandinavian languages

- 'eh' - Italian interjection and Canadian English discourse marker
  While less critical, this can appear in legitimate Italian speech

The remaining filler words are primarily English
vocalized hesitations that don't conflict with common words in other
European languages.

Updated tests to use 'uhm' instead of 'um' where needed.

Fixes #941

* customize list

---------

Co-authored-by: Alexander Yastrebov <yastrebov.alex@gmail.com>
This commit is contained in:
CJ Pais 2026-03-06 20:24:29 +08:00 committed by GitHub
parent f8c74e9449
commit 615b3c93bb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 143 additions and 36 deletions

View file

@ -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<Regex> = 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<Vec<Regex>> = 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<Vec<String>>,
) -> String {
let mut filtered = text.to_string();
// Build filler patterns from custom list or language defaults
let patterns: Vec<Regex> = 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";

View file

@ -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 {

View file

@ -360,6 +360,8 @@ pub struct AppSettings {
#[serde(default = "default_typing_tool")]
pub typing_tool: TypingTool,
pub external_script_path: Option<String>,
#[serde(default)]
pub custom_filler_words: Option<Vec<String>>,
}
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,
}
}

View file

@ -738,7 +738,7 @@ async isLaptop() : Promise<Result<boolean, string>> {
/** 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 }