Merge branch 'main' of github.com:cjpais/Handy

This commit is contained in:
CJ Pais 2026-03-06 20:59:31 +08:00
commit f221f834c3
5 changed files with 157 additions and 50 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 }

View file

@ -19,7 +19,7 @@
"onboarding": {
"subtitle": "Per cominciare, scegli un modello di riconoscimento vocale",
"recommended": "Raccomandato",
"download": "Scaricare",
"download": "Scarica",
"downloading": "Download in corso...",
"customModelDescription": "Non ufficialmente supportato",
"downloadFailed": "Download fallito. Riprova.",
@ -156,7 +156,7 @@
},
"noModelsMatch": "Nessun modello corrisponde a questo filtro.",
"yourModels": "Modelli scaricati",
"availableModels": "Disponibili per il download"
"availableModels": "Disponibili per il Download"
},
"general": {
"title": "Generale",
@ -303,7 +303,7 @@
},
"modelUnload": {
"title": "Disattiva Model",
"description": "Libera automaticamente la memoria della GPU/CPU quando il modello non viene utilizzato per un certo periodo.",
"description": "Libera automaticamente la memoria della GPU/CPU quando il modello non viene utilizzato per un certo periodo",
"options": {
"never": "Mai",
"immediately": "Immediatamente",
@ -353,9 +353,9 @@
},
"model": {
"title": "Modello",
"descriptionApple": "Fornisci un limite numerico facoltativo per i token o mantenere l'impostazione predefinita sul dispositivo.",
"descriptionApple": "Fornisci un limite numerico facoltativo per i token o mantieni l'impostazione predefinita sul dispositivo.",
"descriptionCustom": "Fornisci l'identificatore del modello previsto dal tuo endpoint personalizzato.",
"descriptionDefault": "Scegli un modello reso disponibile dal provider personalizzato.",
"descriptionDefault": "Scegli un modello reso disponibile dal provider selezionato.",
"placeholderApple": "Apple Intelligence",
"placeholderWithOptions": "Cerca o scegli un modello",
"placeholderNoOptions": "Digita il nome di un modello",
@ -368,7 +368,7 @@
"title": "Prompt Selezionato",
"description": "Seleziona un template per migliorare le trascrizioni o creane uno nuovo. Usa ${output} nel prompt per fare riferimento alla trascrizione."
},
"noPrompts": "Nessun prompt disponibile.",
"noPrompts": "Nessun prompt disponibile",
"selectPrompt": "Scegli un prompt",
"createNew": "Crea un nuovo prompt",
"promptLabel": "Etichetta del Prompt",
@ -393,7 +393,7 @@
"save": "Salva la trascrizione",
"unsave": "Rimuovi dai salvataggi",
"delete": "Elimina elemento",
"deleteError": "Errore nell'eliminazione dell'elemento. Per favore, prova di nuovo."
"deleteError": "Errore nell'eliminazione dell'elemento. Riprova."
},
"debug": {
"title": "Debug",
@ -423,14 +423,14 @@
"entries": "elementi"
},
"recordingRetention": {
"title": "Salvataggio delle Registrazioni",
"description": "Per quanto conservare le registrazioni audio",
"title": "Eliminazione Automatica Registrazioni",
"description": "Elimina automaticamente le registrazioni vecchie per liberare spazio",
"never": "Mai",
"preserveLimit": "Conserva {{count}} Registrazioni",
"days3": "Dopo 3 Giorni",
"weeks2": "Dopo 2 Settimane",
"months3": "Dopo 3 Mesi",
"placeholder": "Seleziona periodo di salvataggio..."
"preserveLimit": "Conserva le ultime {{count}}",
"days3": "Dopo 3 giorni",
"weeks2": "Dopo 2 settimane",
"months3": "Dopo 3 mesi",
"placeholder": "Seleziona periodo di conservazione..."
},
"alwaysOnMicrophone": {
"label": "Microfono Sempre Attivo",