diff --git a/Cargo.lock b/Cargo.lock index adabb6c8f..d33b668d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4298,7 +4298,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "openhuman" -version = "0.52.7" +version = "0.52.8" dependencies = [ "aes-gcm", "anyhow", diff --git a/src/openhuman/voice/hallucination.rs b/src/openhuman/voice/hallucination.rs new file mode 100644 index 000000000..249535259 --- /dev/null +++ b/src/openhuman/voice/hallucination.rs @@ -0,0 +1,380 @@ +//! Whisper hallucination detection — shared filter for all voice pipelines. +//! +//! Whisper.cpp outputs "[BLANK_AUDIO]" for silence and stock phrases +//! ("Thank you for watching", etc.) when fed noisy or near-empty audio. +//! This module provides a robust detector that catches: +//! +//! - Exact-match known hallucination phrases +//! - Uniform single-word repetition ("you you you you") +//! - Punctuation-variant repetition ("it... it... it...") +//! - Ratio-based repetition (any single word > 60% of total words) +//! +//! Two modes are supported via [`HallucinationMode`]: +//! - **Dictation** — aggressive filtering (single-word noise artifacts like +//! "yes", "no", "okay" are dropped since they're almost certainly hallucination +//! in a push-to-talk dictation context). +//! - **Conversation** — conservative filtering (short conversational replies +//! like "yes", "okay", "thank you" are allowed through since they're +//! legitimate chat responses). + +use log::debug; + +const LOG_PREFIX: &str = "[voice][hallucination]"; + +/// Controls how aggressively the hallucination filter operates. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HallucinationMode { + /// Desktop dictation (push-to-talk). Aggressive: single-word noise + /// artifacts and short conversational phrases are treated as hallucination. + Dictation, + /// Chat voice input. Conservative: only blank-audio markers, YouTube + /// hallucinations, and repetition patterns are filtered. Short + /// conversational utterances like "yes" or "okay" pass through. + Conversation, +} + +/// Blank-audio markers and YouTube-trained hallucination phrases. +/// These are filtered in ALL modes — they are never legitimate speech. +const ALWAYS_HALLUCINATION: &[&str] = &[ + // whisper.cpp blank markers + "[blank_audio]", + "[ blank_audio ]", + "[blank audio]", + "(blank audio)", + // Common hallucinations from YouTube-trained models + "thank you for watching", + "thanks for watching", + "thank you for listening", + "thanks for listening", + "thank you so much", + "please subscribe", + "like and subscribe", + "see you next time", + "see you in the next video", + "bye bye", + // Punctuation-only + "...", + ".", + ",", + "!", + "?", +]; + +/// Single-word noise artifacts and short phrases that are hallucination +/// in dictation mode but may be valid in conversation mode. +const DICTATION_ONLY_PATTERNS: &[&str] = &[ + "thank you", + "thank you.", + "thanks.", + "bye.", + "goodbye.", + // Single-word noise artifacts + "you", + "the", + "i", + "a", + "so", + "okay", + "ok", + "yeah", + "yes", + "no", + "oh", + "hmm", + "huh", + "ah", +]; + +/// Strip all ASCII punctuation from a word, returning the bare alphabetic core. +fn strip_punctuation(word: &str) -> String { + word.chars().filter(|c| !c.is_ascii_punctuation()).collect() +} + +/// Check if whisper output is a known hallucination pattern. +/// +/// Detection layers (applied in order): +/// 1. **Exact match** against `ALWAYS_HALLUCINATION` patterns (both modes), +/// plus `DICTATION_ONLY_PATTERNS` when in dictation mode. +/// 2. **Uniform repetition** — all words are the same after punctuation stripping +/// (catches "it... it... it..." and "you you you you"). +/// 3. **Dominant-word ratio** — any single word comprising > 60% of total words +/// with at least 5 occurrences (catches massive hallucination loops while +/// allowing natural emphatic phrases like "no no no don't do that"). +pub fn is_hallucinated_output(text: &str, mode: HallucinationMode) -> bool { + let normalized = text.trim().to_lowercase(); + if normalized.is_empty() { + return false; // handled separately as "empty" + } + + // Strip trailing punctuation for matching (whisper often appends periods). + let stripped = normalized.trim_end_matches(|c: char| c.is_ascii_punctuation()); + + // Layer 1: Exact match against known hallucination phrases. + for pattern in ALWAYS_HALLUCINATION { + if normalized == *pattern || stripped == *pattern { + debug!("{LOG_PREFIX} exact-match hallucination detected"); + return true; + } + } + + // In dictation mode, also check the aggressive single-word/short-phrase list. + if mode == HallucinationMode::Dictation { + for pattern in DICTATION_ONLY_PATTERNS { + if normalized == *pattern || stripped == *pattern { + debug!("{LOG_PREFIX} dictation-only hallucination detected"); + return true; + } + } + } + + // Tokenize into words, stripping punctuation from each for comparison. + let raw_words: Vec<&str> = normalized.split_whitespace().collect(); + if raw_words.len() < 3 { + return false; + } + + let clean_words: Vec = raw_words + .iter() + .map(|w| strip_punctuation(w)) + .filter(|w| !w.is_empty()) + .collect(); + + if clean_words.is_empty() { + return false; + } + + // Layer 2: Uniform repetition — all cleaned words identical. + let first = &clean_words[0]; + if clean_words.iter().all(|w| w == first) { + debug!( + "{LOG_PREFIX} uniform repetition detected (repeats={})", + clean_words.len() + ); + return true; + } + + // Layer 2b: Repeating n-gram — the entire utterance is a small phrase + // (1-3 words) repeated multiple times. Catches "Thank you. Thank you. + // Thank you." where no single word dominates but the phrase loops. + for ngram_len in 1..=3 { + if clean_words.len() >= ngram_len * 2 && clean_words.len().is_multiple_of(ngram_len) { + let pattern = &clean_words[..ngram_len]; + let all_match = clean_words.chunks(ngram_len).all(|chunk| chunk == pattern); + if all_match { + debug!( + "{LOG_PREFIX} repeating {}-gram detected ({} repeats)", + ngram_len, + clean_words.len() / ngram_len + ); + return true; + } + } + } + + // Layer 3: Dominant-word ratio — any word > 60% of total with at least + // 5 occurrences. This is conservative enough to allow emphatic phrases + // like "no no no don't do that" (3/6 = 50%) while catching hallucination + // loops like "it it it it it it it it hello world" (8/10 = 80%). + let total = clean_words.len(); + let mut counts = std::collections::HashMap::<&str, usize>::new(); + for w in &clean_words { + *counts.entry(w.as_str()).or_insert(0) += 1; + } + for (word, count) in &counts { + let ratio = *count as f64 / total as f64; + if ratio > 0.6 && *count >= 5 { + debug!( + "{LOG_PREFIX} dominant-word hallucination detected (count={}, total={}, ratio={:.0}%)", + count, total, ratio * 100.0 + ); + return true; + } + } + + false +} + +#[cfg(test)] +mod tests { + use super::*; + + // --- Exact-match hallucinations (both modes) --- + + #[test] + fn exact_match_blank_audio() { + assert!(is_hallucinated_output( + "[blank_audio]", + HallucinationMode::Conversation + )); + assert!(is_hallucinated_output( + "[ blank_audio ]", + HallucinationMode::Conversation + )); + } + + #[test] + fn exact_match_youtube_hallucination() { + assert!(is_hallucinated_output( + "Thank you for watching", + HallucinationMode::Conversation + )); + assert!(is_hallucinated_output( + "please subscribe", + HallucinationMode::Conversation + )); + } + + #[test] + fn exact_match_punctuation_only() { + assert!(is_hallucinated_output( + "...", + HallucinationMode::Conversation + )); + assert!(is_hallucinated_output(".", HallucinationMode::Conversation)); + } + + // --- Dictation-only patterns --- + + #[test] + fn dictation_mode_drops_single_words() { + assert!(is_hallucinated_output("you", HallucinationMode::Dictation)); + assert!(is_hallucinated_output("okay", HallucinationMode::Dictation)); + assert!(is_hallucinated_output( + "Thank you.", + HallucinationMode::Dictation + )); + assert!(is_hallucinated_output("yes", HallucinationMode::Dictation)); + } + + #[test] + fn conversation_mode_allows_short_replies() { + // These are valid chat responses — should NOT be filtered in conversation mode. + assert!(!is_hallucinated_output( + "yes", + HallucinationMode::Conversation + )); + assert!(!is_hallucinated_output( + "no", + HallucinationMode::Conversation + )); + assert!(!is_hallucinated_output( + "okay", + HallucinationMode::Conversation + )); + assert!(!is_hallucinated_output( + "thank you", + HallucinationMode::Conversation + )); + assert!(!is_hallucinated_output( + "goodbye", + HallucinationMode::Conversation + )); + } + + // --- Uniform repetition (both modes) --- + + #[test] + fn uniform_repetition_plain() { + assert!(is_hallucinated_output( + "you you you you", + HallucinationMode::Conversation + )); + assert!(is_hallucinated_output( + "the the the the the", + HallucinationMode::Conversation + )); + } + + #[test] + fn uniform_repetition_with_punctuation() { + assert!(is_hallucinated_output( + "it... it... it...", + HallucinationMode::Conversation + )); + assert!(is_hallucinated_output( + "it, it, it, it", + HallucinationMode::Conversation + )); + assert!(is_hallucinated_output( + "Thank you. Thank you. Thank you.", + HallucinationMode::Conversation + )); + } + + // --- Dominant-word ratio (stricter thresholds) --- + + #[test] + fn dominant_word_massive_repetition() { + // "it" appears 8/10 = 80% with count=8 >= 5 — flagged + assert!(is_hallucinated_output( + "it it it it it it it it hello world", + HallucinationMode::Conversation + )); + } + + #[test] + fn emphatic_phrase_not_flagged() { + // "no" appears 3/6 = 50% with count=3 < 5 — NOT flagged (natural speech) + assert!(!is_hallucinated_output( + "no no no don't do that", + HallucinationMode::Conversation + )); + // "go" appears 3/5 = 60% with count=3 < 5 — NOT flagged + assert!(!is_hallucinated_output( + "go go go turn left", + HallucinationMode::Conversation + )); + } + + #[test] + fn moderate_repetition_not_flagged() { + // "thank" appears 3/7 = 43% — below 60%, NOT flagged + assert!(!is_hallucinated_output( + "thank you thank you thank you hello", + HallucinationMode::Conversation + )); + } + + // --- Non-hallucinations (should NOT be flagged) --- + + #[test] + fn legitimate_short_sentence() { + assert!(!is_hallucinated_output( + "Can you check the latest price of Bitcoin?", + HallucinationMode::Conversation + )); + } + + #[test] + fn legitimate_with_repeated_common_word() { + assert!(!is_hallucinated_output( + "I went to the store and the park today", + HallucinationMode::Conversation + )); + } + + #[test] + fn empty_string() { + assert!(!is_hallucinated_output("", HallucinationMode::Conversation)); + assert!(!is_hallucinated_output( + " ", + HallucinationMode::Conversation + )); + } + + #[test] + fn two_word_input_not_flagged() { + assert!(!is_hallucinated_output( + "hello world", + HallucinationMode::Conversation + )); + } + + #[test] + fn legitimate_conversation() { + assert!(!is_hallucinated_output( + "Hey team, let's discuss the new feature implementation plan for next sprint", + HallucinationMode::Conversation + )); + } +} diff --git a/src/openhuman/voice/mod.rs b/src/openhuman/voice/mod.rs index 881094a44..ec77c37db 100644 --- a/src/openhuman/voice/mod.rs +++ b/src/openhuman/voice/mod.rs @@ -6,6 +6,7 @@ pub mod audio_capture; pub mod dictation_listener; +pub mod hallucination; pub mod hotkey; mod ops; mod postprocess; diff --git a/src/openhuman/voice/ops.rs b/src/openhuman/voice/ops.rs index 0a972c53c..936f6ee4d 100644 --- a/src/openhuman/voice/ops.rs +++ b/src/openhuman/voice/ops.rs @@ -16,6 +16,7 @@ use crate::openhuman::local_ai::paths::{ use crate::openhuman::local_ai::whisper_engine; use crate::rpc::RpcOutcome; +use super::hallucination::{is_hallucinated_output, HallucinationMode}; use super::postprocess; use super::types::{VoiceSpeechResult, VoiceStatus, VoiceTtsResult}; @@ -182,6 +183,19 @@ pub async fn voice_transcribe_bytes( transcribe_elapsed.as_millis() ); + // Filter hallucinated output before spending time on LLM cleanup. + if is_hallucinated_output(&raw_text, HallucinationMode::Conversation) { + debug!("{LOG_PREFIX} transcribe_bytes: hallucination detected, returning empty result"); + return Ok(RpcOutcome::single_log( + VoiceSpeechResult { + text: String::new(), + raw_text, + model_id: output.model_id, + }, + "voice transcription filtered (hallucination)", + )); + } + let cleanup_started = Instant::now(); let text = if skip_cleanup { raw_text.clone() diff --git a/src/openhuman/voice/server.rs b/src/openhuman/voice/server.rs index aaaed894b..fd12a78ef 100644 --- a/src/openhuman/voice/server.rs +++ b/src/openhuman/voice/server.rs @@ -650,7 +650,7 @@ async fn process_recording_bg( ); // Gate 3: filter hallucinated/blank output. - if is_hallucinated_output(text) { + if is_hallucinated_output(text, HallucinationMode::Dictation) { warn!( "{LOG_PREFIX} [pipeline={pipeline_id}] stage=gate_hallucination DROPPED text='{}'", truncate_for_log(text, 60) @@ -852,86 +852,8 @@ pub async fn run_standalone( server_arc.run(&app_config).await } -/// Known whisper hallucination patterns. These are common outputs when -/// whisper processes near-silent audio or audio with background noise. -/// Sourced from community lists and OpenWhispr's filtering behavior. -const HALLUCINATION_PATTERNS: &[&str] = &[ - // whisper.cpp blank markers - "[blank_audio]", - "[ blank_audio ]", - "[blank audio]", - "(blank audio)", - // Common hallucinations from YouTube-trained models - "thank you", - "thank you.", - "thanks.", - "thank you for watching", - "thanks for watching", - "thank you for listening", - "thanks for listening", - "thank you so much", - "please subscribe", - "like and subscribe", - "see you next time", - "see you in the next video", - "bye bye", - "bye.", - "goodbye.", - // Single-word noise artifacts - "you", - "the", - "i", - "a", - "so", - "okay", - "ok", - "yeah", - "yes", - "no", - "oh", - "hmm", - "huh", - "ah", - // Punctuation-only - "...", - ".", - ",", - "!", - "?", -]; - -/// Check if whisper output is a known hallucination pattern. -/// -/// Whisper.cpp famously outputs "[BLANK_AUDIO]" for silence and various -/// stock phrases ("Thank you for watching", etc.) when fed noisy or -/// near-empty audio. Filtering these prevents inserting garbage text. -fn is_hallucinated_output(text: &str) -> bool { - let normalized = text.trim().to_lowercase(); - if normalized.is_empty() { - return false; // handled separately as "empty" - } - - // Strip trailing punctuation for matching (whisper often appends periods). - let stripped = normalized.trim_end_matches(|c: char| c.is_ascii_punctuation()); - - // Exact match against known hallucination phrases. - for pattern in HALLUCINATION_PATTERNS { - if normalized == *pattern || stripped == *pattern { - return true; - } - } - - // Detect repeated short phrases (e.g. "you you you you"). - let words: Vec<&str> = normalized.split_whitespace().collect(); - if words.len() >= 3 { - let first = words[0]; - if words.iter().all(|w| *w == first) { - return true; - } - } - - false -} +// Hallucination detection is now in the shared `hallucination` module. +use super::hallucination::{is_hallucinated_output, HallucinationMode}; fn truncate_for_log(s: &str, max: usize) -> String { let truncated: String = s.chars().take(max).collect(); @@ -960,37 +882,41 @@ mod tests { #[test] fn hallucination_detection() { + use super::HallucinationMode; + let mode = HallucinationMode::Dictation; + // Blank audio markers. - assert!(is_hallucinated_output("[BLANK_AUDIO]")); - assert!(is_hallucinated_output(" [blank_audio] ")); - assert!(is_hallucinated_output("[ BLANK_AUDIO ]")); + assert!(is_hallucinated_output("[BLANK_AUDIO]", mode)); + assert!(is_hallucinated_output(" [blank_audio] ", mode)); + assert!(is_hallucinated_output("[ BLANK_AUDIO ]", mode)); // Common hallucinated phrases. - assert!(is_hallucinated_output("Thank you for watching")); - assert!(is_hallucinated_output("thanks for listening")); - assert!(is_hallucinated_output("Thank you.")); - assert!(is_hallucinated_output("Thank you")); - assert!(is_hallucinated_output("Thanks.")); - assert!(is_hallucinated_output("Bye.")); - assert!(is_hallucinated_output("Goodbye.")); + assert!(is_hallucinated_output("Thank you for watching", mode)); + assert!(is_hallucinated_output("thanks for listening", mode)); + assert!(is_hallucinated_output("Thank you.", mode)); + assert!(is_hallucinated_output("Thank you", mode)); + assert!(is_hallucinated_output("Thanks.", mode)); + assert!(is_hallucinated_output("Bye.", mode)); + assert!(is_hallucinated_output("Goodbye.", mode)); // Repeated words. - assert!(is_hallucinated_output("you you you you")); - assert!(is_hallucinated_output("the the the the")); + assert!(is_hallucinated_output("you you you you", mode)); + assert!(is_hallucinated_output("the the the the", mode)); // Punctuation-only. - assert!(is_hallucinated_output("...")); - assert!(is_hallucinated_output(".")); - // Single noise words. - assert!(is_hallucinated_output("you")); - assert!(is_hallucinated_output("Yeah")); - assert!(is_hallucinated_output("Hmm")); - assert!(is_hallucinated_output("Oh.")); + assert!(is_hallucinated_output("...", mode)); + assert!(is_hallucinated_output(".", mode)); + // Single noise words (dictation mode drops these). + assert!(is_hallucinated_output("you", mode)); + assert!(is_hallucinated_output("Yeah", mode)); + assert!(is_hallucinated_output("Hmm", mode)); + assert!(is_hallucinated_output("Oh.", mode)); // Should NOT flag real speech. - assert!(!is_hallucinated_output("Hello, how are you?")); - assert!(!is_hallucinated_output("the quick brown fox")); - assert!(!is_hallucinated_output("I want to order pizza")); + assert!(!is_hallucinated_output("Hello, how are you?", mode)); + assert!(!is_hallucinated_output("the quick brown fox", mode)); + assert!(!is_hallucinated_output("I want to order pizza", mode)); assert!(!is_hallucinated_output( - "thank you for your help with the project" + "thank you for your help with the project", + mode )); - assert!(!is_hallucinated_output("")); + assert!(!is_hallucinated_output("", mode)); } #[tokio::test]