Handy/src-tauri/src/audio_toolkit/text.rs
Prasad Chalasani c84e863423
feat: add automatic filler word removal from transcriptions (#589)
* feat: add automatic filler word removal from transcriptions

Add filter_transcription_output() that removes filler words (uh, um, hmm,
etc.) and hallucination patterns ([AUDIO], (pause), <tag>...</tag>) from
transcriptions. Inspired by VoiceInk's approach.

- Add regex-based filter in audio_toolkit/text.rs
- Integrate into transcription pipeline after custom word correction
- Add comprehensive tests for filler word removal
- Add regex crate dependency

* feat: collapse repeated 1-2 letter stutters in transcriptions

Add collapse_stutters() to reduce model hallucination artifacts like
"wh wh wh wh wh why" -> "wh why" or "I I I I think" -> "I think".

Collapses any 1-2 letter word that repeats 3+ times consecutively
to a single instance (case-insensitive matching).

* remove bracket/xml stuff

* format

---------

Co-authored-by: CJ Pais <cj@cjpais.com>
2026-01-18 10:52:39 +08:00

344 lines
11 KiB
Rust

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
///
/// This function corrects words in the input text by finding the best matches
/// from a list of custom words using a combination of:
/// - Levenshtein distance for string similarity
/// - Soundex phonetic matching for pronunciation similarity
///
/// # Arguments
/// * `text` - The input text to correct
/// * `custom_words` - List of custom words to match against
/// * `threshold` - Maximum similarity score to accept (0.0 = exact match, 1.0 = any match)
///
/// # Returns
/// The corrected text with custom words applied
pub fn apply_custom_words(text: &str, custom_words: &[String], threshold: f64) -> String {
if custom_words.is_empty() {
return text.to_string();
}
// Pre-compute lowercase versions to avoid repeated allocations
let custom_words_lower: Vec<String> = custom_words.iter().map(|w| w.to_lowercase()).collect();
let words: Vec<&str> = text.split_whitespace().collect();
let mut corrected_words = Vec::new();
for word in words {
let cleaned_word = word
.trim_matches(|c: char| !c.is_alphabetic())
.to_lowercase();
if cleaned_word.is_empty() {
corrected_words.push(word.to_string());
continue;
}
// Skip extremely long words to avoid performance issues
if cleaned_word.len() > 50 {
corrected_words.push(word.to_string());
continue;
}
let mut best_match: Option<&String> = None;
let mut best_score = f64::MAX;
for (i, custom_word_lower) in custom_words_lower.iter().enumerate() {
// Skip if lengths are too different (optimization)
let len_diff = (cleaned_word.len() as i32 - custom_word_lower.len() as i32).abs();
if len_diff > 5 {
continue;
}
// Calculate Levenshtein distance (normalized by length)
let levenshtein_dist = levenshtein(&cleaned_word, custom_word_lower);
let max_len = cleaned_word.len().max(custom_word_lower.len()) as f64;
let levenshtein_score = if max_len > 0.0 {
levenshtein_dist as f64 / max_len
} else {
1.0
};
// Calculate phonetic similarity using Soundex
let phonetic_match = soundex(&cleaned_word, custom_word_lower);
// Combine scores: favor phonetic matches, but also consider string similarity
let combined_score = if phonetic_match {
levenshtein_score * 0.3 // Give significant boost to phonetic matches
} else {
levenshtein_score
};
// Accept if the score is good enough (configurable threshold)
if combined_score < threshold && combined_score < best_score {
best_match = Some(&custom_words[i]);
best_score = combined_score;
}
}
if let Some(replacement) = best_match {
// Preserve the original case pattern as much as possible
let corrected = preserve_case_pattern(word, replacement);
// Preserve punctuation from original word
let (prefix, suffix) = extract_punctuation(word);
corrected_words.push(format!("{}{}{}", prefix, corrected, suffix));
} else {
corrected_words.push(word.to_string());
}
}
corrected_words.join(" ")
}
/// Preserves the case pattern of the original word when applying a replacement
fn preserve_case_pattern(original: &str, replacement: &str) -> String {
if original.chars().all(|c| c.is_uppercase()) {
replacement.to_uppercase()
} else if original.chars().next().map_or(false, |c| c.is_uppercase()) {
let mut chars: Vec<char> = replacement.chars().collect();
if let Some(first_char) = chars.get_mut(0) {
*first_char = first_char.to_uppercase().next().unwrap_or(*first_char);
}
chars.into_iter().collect()
} else {
replacement.to_string()
}
}
/// Extracts punctuation prefix and suffix from a word
fn extract_punctuation(word: &str) -> (&str, &str) {
let prefix_end = word.chars().take_while(|c| !c.is_alphabetic()).count();
let suffix_start = word
.char_indices()
.rev()
.take_while(|(_, c)| !c.is_alphabetic())
.count();
let prefix = if prefix_end > 0 {
&word[..prefix_end]
} else {
""
};
let suffix = if suffix_start > 0 {
&word[word.len() - suffix_start..]
} else {
""
};
(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<Regex> = 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<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.)
/// 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::*;
#[test]
fn test_apply_custom_words_exact_match() {
let text = "hello world";
let custom_words = vec!["Hello".to_string(), "World".to_string()];
let result = apply_custom_words(text, &custom_words, 0.5);
assert_eq!(result, "Hello World");
}
#[test]
fn test_apply_custom_words_fuzzy_match() {
let text = "helo wrold";
let custom_words = vec!["hello".to_string(), "world".to_string()];
let result = apply_custom_words(text, &custom_words, 0.5);
assert_eq!(result, "hello world");
}
#[test]
fn test_preserve_case_pattern() {
assert_eq!(preserve_case_pattern("HELLO", "world"), "WORLD");
assert_eq!(preserve_case_pattern("Hello", "world"), "World");
assert_eq!(preserve_case_pattern("hello", "WORLD"), "WORLD");
}
#[test]
fn test_extract_punctuation() {
assert_eq!(extract_punctuation("hello"), ("", ""));
assert_eq!(extract_punctuation("!hello?"), ("!", "?"));
assert_eq!(extract_punctuation("...hello..."), ("...", "..."));
}
#[test]
fn test_empty_custom_words() {
let text = "hello world";
let custom_words = vec![];
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");
}
}