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:
parent
6522944886
commit
c84e863423
5 changed files with 176 additions and 3 deletions
1
src-tauri/Cargo.lock
generated
1
src-tauri/Cargo.lock
generated
|
|
@ -2308,6 +2308,7 @@ dependencies = [
|
|||
"natural",
|
||||
"once_cell",
|
||||
"rdev",
|
||||
"regex",
|
||||
"reqwest",
|
||||
"rodio",
|
||||
"rubato",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Reference in a new issue