diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 3c29ee4..7cf44c1 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2308,6 +2308,7 @@ dependencies = [ "natural", "once_cell", "rdev", + "regex", "reqwest", "rodio", "rubato", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b2e0450..e765c00 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -63,6 +63,7 @@ futures-util = "0.3" rustfft = "6.4.0" strsim = "0.11.0" natural = "0.5.0" +regex = "1" chrono = "0.4" rusqlite = { version = "0.37", features = ["bundled"] } tar = "0.4.44" diff --git a/src-tauri/src/audio_toolkit/mod.rs b/src-tauri/src/audio_toolkit/mod.rs index 6975fd9..5aaa3ff 100644 --- a/src-tauri/src/audio_toolkit/mod.rs +++ b/src-tauri/src/audio_toolkit/mod.rs @@ -7,6 +7,6 @@ pub mod vad; pub use audio::{ list_input_devices, list_output_devices, save_wav_file, AudioRecorder, CpalDeviceInfo, }; -pub use text::apply_custom_words; +pub use text::{apply_custom_words, filter_transcription_output}; 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 6f30ae9..3c04776 100644 --- a/src-tauri/src/audio_toolkit/text.rs +++ b/src-tauri/src/audio_toolkit/text.rs @@ -1,4 +1,6 @@ use natural::phonetics::soundex; +use once_cell::sync::Lazy; +use regex::Regex; use strsim::levenshtein; /// Applies custom word corrections to transcribed text using fuzzy matching @@ -132,6 +134,95 @@ 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", +]; + +static MULTI_SPACE_PATTERN: Lazy = Lazy::new(|| Regex::new(r"\s{2,}").unwrap()); + +/// Collapses repeated 1-2 letter words (3+ repetitions) to a single instance. +/// E.g., "wh wh wh wh" -> "wh", "I I I I" -> "I" +fn collapse_stutters(text: &str) -> String { + let words: Vec<&str> = text.split_whitespace().collect(); + if words.is_empty() { + return text.to_string(); + } + + let mut result: Vec<&str> = Vec::new(); + let mut i = 0; + + while i < words.len() { + let word = words[i]; + let word_lower = word.to_lowercase(); + + // Only process 1-2 letter words + if word_lower.len() <= 2 && word_lower.chars().all(|c| c.is_alphabetic()) { + // Count consecutive repetitions (case-insensitive) + let mut count = 1; + while i + count < words.len() && words[i + count].to_lowercase() == word_lower { + count += 1; + } + + // If 3+ repetitions, collapse to single instance + if count >= 3 { + result.push(word); + i += count; + } else { + result.push(word); + i += 1; + } + } else { + result.push(word); + i += 1; + } + } + + 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.) +/// 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 +/// +/// # Returns +/// The filtered text with filler words and stutters removed +pub fn filter_transcription_output(text: &str) -> String { + let mut filtered = text.to_string(); + + // Remove filler words + for pattern in FILLER_PATTERNS.iter() { + filtered = pattern.replace_all(&filtered, "").to_string(); + } + + // Collapse repeated 1-2 letter words (stutter artifacts like "wh wh wh wh") + filtered = collapse_stutters(&filtered); + + // Clean up multiple spaces to single space + filtered = MULTI_SPACE_PATTERN.replace_all(&filtered, " ").to_string(); + + // Trim leading/trailing whitespace + filtered.trim().to_string() +} + #[cfg(test)] mod tests { use super::*; @@ -173,4 +264,81 @@ mod tests { let result = apply_custom_words(text, &custom_words, 0.5); assert_eq!(result, "hello world"); } + + #[test] + fn test_filter_filler_words() { + let text = "So um I was thinking uh about this"; + let result = filter_transcription_output(text); + 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); + 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); + 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); + assert_eq!(result, "Hello world test"); + } + + #[test] + fn test_filter_trims() { + let text = " Hello world "; + let result = filter_transcription_output(text); + 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); + 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); + 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); + 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); + 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); + assert_eq!(result, "No"); + } + + #[test] + fn test_filter_stutter_preserves_two_repetitions() { + let text = "no no is fine"; + let result = filter_transcription_output(text); + assert_eq!(result, "no no is fine"); + } } diff --git a/src-tauri/src/managers/transcription.rs b/src-tauri/src/managers/transcription.rs index 54adbe2..4287533 100644 --- a/src-tauri/src/managers/transcription.rs +++ b/src-tauri/src/managers/transcription.rs @@ -1,4 +1,4 @@ -use crate::audio_toolkit::apply_custom_words; +use crate::audio_toolkit::{apply_custom_words, filter_transcription_output}; use crate::managers::model::{EngineType, ModelManager}; use crate::settings::{get_settings, ModelUnloadTimeout}; use anyhow::Result; @@ -440,6 +440,9 @@ impl TranscriptionManager { result.text }; + // Filter out filler words and hallucinations + let filtered_result = filter_transcription_output(&corrected_result); + let et = std::time::Instant::now(); let translation_note = if settings.translate_to_english { " (translated)" @@ -452,7 +455,7 @@ impl TranscriptionManager { translation_note ); - let final_result = corrected_result.trim().to_string(); + let final_result = filtered_result; if final_result.is_empty() { info!("Transcription result is empty");