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>
This commit is contained in:
Prasad Chalasani 2026-01-17 21:52:39 -05:00 committed by GitHub
parent 6522944886
commit c84e863423
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 176 additions and 3 deletions

1
src-tauri/Cargo.lock generated
View file

@ -2308,6 +2308,7 @@ dependencies = [
"natural",
"once_cell",
"rdev",
"regex",
"reqwest",
"rodio",
"rubato",

View file

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

View file

@ -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};

View file

@ -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<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::*;
@ -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");
}
}

View file

@ -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");