Skip to content

Commit 933c233

Browse files
oxoxDevclaude
andauthored
fix(voice): add hallucination filter to chat voice path (tinyhumansai#553) (tinyhumansai#556)
* fix(voice): add hallucination filter to chat voice path and improve detector (tinyhumansai#553) The chat voice transcription pipeline (ops.rs voice_transcribe_bytes) had no hallucination filtering, unlike the desktop dictation server which has had it since inception. This caused Whisper to inject repetitive garbage text ("it... it... it...", "Thank you. Thank you. Thank you.") into chat voice input, especially on short/silent recordings. Changes: - Extract hallucination detection into shared voice/hallucination.rs module - Add hallucination filter to voice_transcribe_bytes (chat voice path) - Improve detector: strip punctuation before word comparison, add dominant-word ratio check (>40%), catch "it... it... it..." patterns - Add 14 unit tests covering exact match, repetition, ratio, and legitimate speech cases Closes tinyhumansai#553 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor(voice): introduce HallucinationMode enum with split pattern lists Split monolithic HALLUCINATION_PATTERNS into ALWAYS_HALLUCINATION (blank-audio, YouTube phrases — filtered in all modes) and DICTATION_ONLY_PATTERNS (single-word noise like "yes", "okay" — filtered only in desktop dictation). Add repeating n-gram detection for looping phrases ("Thank you. Thank you. Thank you.") and raise dominant-word ratio from >40%/3 to >60%/5 to prevent false positives on emphatic speech like "no no no don't do that". Addresses CodeRabbit review on PR tinyhumansai#556. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(voice): use Conversation mode for chat, Dictation mode for desktop Wire HallucinationMode into both voice paths: - ops.rs (chat voice): HallucinationMode::Conversation — conservative filtering allows short replies like "yes", "okay", "thank you" - server.rs (desktop dictation): HallucinationMode::Dictation — aggressive filtering drops single-word noise artifacts - Update server.rs hallucination_detection test for new signature Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(voice): redact user transcript content from debug logs Remove raw text interpolation (normalized, first, word, pattern) from hallucination detection debug logs to prevent leaking sensitive speech content. Retain non-PII metadata (repeat counts, ratios, n-gram length) for diagnostics. Addresses CodeRabbit review on PR tinyhumansai#556. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 219a2ff commit 933c233

5 files changed

Lines changed: 427 additions & 106 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 380 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,380 @@
1+
//! Whisper hallucination detection — shared filter for all voice pipelines.
2+
//!
3+
//! Whisper.cpp outputs "[BLANK_AUDIO]" for silence and stock phrases
4+
//! ("Thank you for watching", etc.) when fed noisy or near-empty audio.
5+
//! This module provides a robust detector that catches:
6+
//!
7+
//! - Exact-match known hallucination phrases
8+
//! - Uniform single-word repetition ("you you you you")
9+
//! - Punctuation-variant repetition ("it... it... it...")
10+
//! - Ratio-based repetition (any single word > 60% of total words)
11+
//!
12+
//! Two modes are supported via [`HallucinationMode`]:
13+
//! - **Dictation** — aggressive filtering (single-word noise artifacts like
14+
//! "yes", "no", "okay" are dropped since they're almost certainly hallucination
15+
//! in a push-to-talk dictation context).
16+
//! - **Conversation** — conservative filtering (short conversational replies
17+
//! like "yes", "okay", "thank you" are allowed through since they're
18+
//! legitimate chat responses).
19+
20+
use log::debug;
21+
22+
const LOG_PREFIX: &str = "[voice][hallucination]";
23+
24+
/// Controls how aggressively the hallucination filter operates.
25+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26+
pub enum HallucinationMode {
27+
/// Desktop dictation (push-to-talk). Aggressive: single-word noise
28+
/// artifacts and short conversational phrases are treated as hallucination.
29+
Dictation,
30+
/// Chat voice input. Conservative: only blank-audio markers, YouTube
31+
/// hallucinations, and repetition patterns are filtered. Short
32+
/// conversational utterances like "yes" or "okay" pass through.
33+
Conversation,
34+
}
35+
36+
/// Blank-audio markers and YouTube-trained hallucination phrases.
37+
/// These are filtered in ALL modes — they are never legitimate speech.
38+
const ALWAYS_HALLUCINATION: &[&str] = &[
39+
// whisper.cpp blank markers
40+
"[blank_audio]",
41+
"[ blank_audio ]",
42+
"[blank audio]",
43+
"(blank audio)",
44+
// Common hallucinations from YouTube-trained models
45+
"thank you for watching",
46+
"thanks for watching",
47+
"thank you for listening",
48+
"thanks for listening",
49+
"thank you so much",
50+
"please subscribe",
51+
"like and subscribe",
52+
"see you next time",
53+
"see you in the next video",
54+
"bye bye",
55+
// Punctuation-only
56+
"...",
57+
".",
58+
",",
59+
"!",
60+
"?",
61+
];
62+
63+
/// Single-word noise artifacts and short phrases that are hallucination
64+
/// in dictation mode but may be valid in conversation mode.
65+
const DICTATION_ONLY_PATTERNS: &[&str] = &[
66+
"thank you",
67+
"thank you.",
68+
"thanks.",
69+
"bye.",
70+
"goodbye.",
71+
// Single-word noise artifacts
72+
"you",
73+
"the",
74+
"i",
75+
"a",
76+
"so",
77+
"okay",
78+
"ok",
79+
"yeah",
80+
"yes",
81+
"no",
82+
"oh",
83+
"hmm",
84+
"huh",
85+
"ah",
86+
];
87+
88+
/// Strip all ASCII punctuation from a word, returning the bare alphabetic core.
89+
fn strip_punctuation(word: &str) -> String {
90+
word.chars().filter(|c| !c.is_ascii_punctuation()).collect()
91+
}
92+
93+
/// Check if whisper output is a known hallucination pattern.
94+
///
95+
/// Detection layers (applied in order):
96+
/// 1. **Exact match** against `ALWAYS_HALLUCINATION` patterns (both modes),
97+
/// plus `DICTATION_ONLY_PATTERNS` when in dictation mode.
98+
/// 2. **Uniform repetition** — all words are the same after punctuation stripping
99+
/// (catches "it... it... it..." and "you you you you").
100+
/// 3. **Dominant-word ratio** — any single word comprising > 60% of total words
101+
/// with at least 5 occurrences (catches massive hallucination loops while
102+
/// allowing natural emphatic phrases like "no no no don't do that").
103+
pub fn is_hallucinated_output(text: &str, mode: HallucinationMode) -> bool {
104+
let normalized = text.trim().to_lowercase();
105+
if normalized.is_empty() {
106+
return false; // handled separately as "empty"
107+
}
108+
109+
// Strip trailing punctuation for matching (whisper often appends periods).
110+
let stripped = normalized.trim_end_matches(|c: char| c.is_ascii_punctuation());
111+
112+
// Layer 1: Exact match against known hallucination phrases.
113+
for pattern in ALWAYS_HALLUCINATION {
114+
if normalized == *pattern || stripped == *pattern {
115+
debug!("{LOG_PREFIX} exact-match hallucination detected");
116+
return true;
117+
}
118+
}
119+
120+
// In dictation mode, also check the aggressive single-word/short-phrase list.
121+
if mode == HallucinationMode::Dictation {
122+
for pattern in DICTATION_ONLY_PATTERNS {
123+
if normalized == *pattern || stripped == *pattern {
124+
debug!("{LOG_PREFIX} dictation-only hallucination detected");
125+
return true;
126+
}
127+
}
128+
}
129+
130+
// Tokenize into words, stripping punctuation from each for comparison.
131+
let raw_words: Vec<&str> = normalized.split_whitespace().collect();
132+
if raw_words.len() < 3 {
133+
return false;
134+
}
135+
136+
let clean_words: Vec<String> = raw_words
137+
.iter()
138+
.map(|w| strip_punctuation(w))
139+
.filter(|w| !w.is_empty())
140+
.collect();
141+
142+
if clean_words.is_empty() {
143+
return false;
144+
}
145+
146+
// Layer 2: Uniform repetition — all cleaned words identical.
147+
let first = &clean_words[0];
148+
if clean_words.iter().all(|w| w == first) {
149+
debug!(
150+
"{LOG_PREFIX} uniform repetition detected (repeats={})",
151+
clean_words.len()
152+
);
153+
return true;
154+
}
155+
156+
// Layer 2b: Repeating n-gram — the entire utterance is a small phrase
157+
// (1-3 words) repeated multiple times. Catches "Thank you. Thank you.
158+
// Thank you." where no single word dominates but the phrase loops.
159+
for ngram_len in 1..=3 {
160+
if clean_words.len() >= ngram_len * 2 && clean_words.len().is_multiple_of(ngram_len) {
161+
let pattern = &clean_words[..ngram_len];
162+
let all_match = clean_words.chunks(ngram_len).all(|chunk| chunk == pattern);
163+
if all_match {
164+
debug!(
165+
"{LOG_PREFIX} repeating {}-gram detected ({} repeats)",
166+
ngram_len,
167+
clean_words.len() / ngram_len
168+
);
169+
return true;
170+
}
171+
}
172+
}
173+
174+
// Layer 3: Dominant-word ratio — any word > 60% of total with at least
175+
// 5 occurrences. This is conservative enough to allow emphatic phrases
176+
// like "no no no don't do that" (3/6 = 50%) while catching hallucination
177+
// loops like "it it it it it it it it hello world" (8/10 = 80%).
178+
let total = clean_words.len();
179+
let mut counts = std::collections::HashMap::<&str, usize>::new();
180+
for w in &clean_words {
181+
*counts.entry(w.as_str()).or_insert(0) += 1;
182+
}
183+
for (word, count) in &counts {
184+
let ratio = *count as f64 / total as f64;
185+
if ratio > 0.6 && *count >= 5 {
186+
debug!(
187+
"{LOG_PREFIX} dominant-word hallucination detected (count={}, total={}, ratio={:.0}%)",
188+
count, total, ratio * 100.0
189+
);
190+
return true;
191+
}
192+
}
193+
194+
false
195+
}
196+
197+
#[cfg(test)]
198+
mod tests {
199+
use super::*;
200+
201+
// --- Exact-match hallucinations (both modes) ---
202+
203+
#[test]
204+
fn exact_match_blank_audio() {
205+
assert!(is_hallucinated_output(
206+
"[blank_audio]",
207+
HallucinationMode::Conversation
208+
));
209+
assert!(is_hallucinated_output(
210+
"[ blank_audio ]",
211+
HallucinationMode::Conversation
212+
));
213+
}
214+
215+
#[test]
216+
fn exact_match_youtube_hallucination() {
217+
assert!(is_hallucinated_output(
218+
"Thank you for watching",
219+
HallucinationMode::Conversation
220+
));
221+
assert!(is_hallucinated_output(
222+
"please subscribe",
223+
HallucinationMode::Conversation
224+
));
225+
}
226+
227+
#[test]
228+
fn exact_match_punctuation_only() {
229+
assert!(is_hallucinated_output(
230+
"...",
231+
HallucinationMode::Conversation
232+
));
233+
assert!(is_hallucinated_output(".", HallucinationMode::Conversation));
234+
}
235+
236+
// --- Dictation-only patterns ---
237+
238+
#[test]
239+
fn dictation_mode_drops_single_words() {
240+
assert!(is_hallucinated_output("you", HallucinationMode::Dictation));
241+
assert!(is_hallucinated_output("okay", HallucinationMode::Dictation));
242+
assert!(is_hallucinated_output(
243+
"Thank you.",
244+
HallucinationMode::Dictation
245+
));
246+
assert!(is_hallucinated_output("yes", HallucinationMode::Dictation));
247+
}
248+
249+
#[test]
250+
fn conversation_mode_allows_short_replies() {
251+
// These are valid chat responses — should NOT be filtered in conversation mode.
252+
assert!(!is_hallucinated_output(
253+
"yes",
254+
HallucinationMode::Conversation
255+
));
256+
assert!(!is_hallucinated_output(
257+
"no",
258+
HallucinationMode::Conversation
259+
));
260+
assert!(!is_hallucinated_output(
261+
"okay",
262+
HallucinationMode::Conversation
263+
));
264+
assert!(!is_hallucinated_output(
265+
"thank you",
266+
HallucinationMode::Conversation
267+
));
268+
assert!(!is_hallucinated_output(
269+
"goodbye",
270+
HallucinationMode::Conversation
271+
));
272+
}
273+
274+
// --- Uniform repetition (both modes) ---
275+
276+
#[test]
277+
fn uniform_repetition_plain() {
278+
assert!(is_hallucinated_output(
279+
"you you you you",
280+
HallucinationMode::Conversation
281+
));
282+
assert!(is_hallucinated_output(
283+
"the the the the the",
284+
HallucinationMode::Conversation
285+
));
286+
}
287+
288+
#[test]
289+
fn uniform_repetition_with_punctuation() {
290+
assert!(is_hallucinated_output(
291+
"it... it... it...",
292+
HallucinationMode::Conversation
293+
));
294+
assert!(is_hallucinated_output(
295+
"it, it, it, it",
296+
HallucinationMode::Conversation
297+
));
298+
assert!(is_hallucinated_output(
299+
"Thank you. Thank you. Thank you.",
300+
HallucinationMode::Conversation
301+
));
302+
}
303+
304+
// --- Dominant-word ratio (stricter thresholds) ---
305+
306+
#[test]
307+
fn dominant_word_massive_repetition() {
308+
// "it" appears 8/10 = 80% with count=8 >= 5 — flagged
309+
assert!(is_hallucinated_output(
310+
"it it it it it it it it hello world",
311+
HallucinationMode::Conversation
312+
));
313+
}
314+
315+
#[test]
316+
fn emphatic_phrase_not_flagged() {
317+
// "no" appears 3/6 = 50% with count=3 < 5 — NOT flagged (natural speech)
318+
assert!(!is_hallucinated_output(
319+
"no no no don't do that",
320+
HallucinationMode::Conversation
321+
));
322+
// "go" appears 3/5 = 60% with count=3 < 5 — NOT flagged
323+
assert!(!is_hallucinated_output(
324+
"go go go turn left",
325+
HallucinationMode::Conversation
326+
));
327+
}
328+
329+
#[test]
330+
fn moderate_repetition_not_flagged() {
331+
// "thank" appears 3/7 = 43% — below 60%, NOT flagged
332+
assert!(!is_hallucinated_output(
333+
"thank you thank you thank you hello",
334+
HallucinationMode::Conversation
335+
));
336+
}
337+
338+
// --- Non-hallucinations (should NOT be flagged) ---
339+
340+
#[test]
341+
fn legitimate_short_sentence() {
342+
assert!(!is_hallucinated_output(
343+
"Can you check the latest price of Bitcoin?",
344+
HallucinationMode::Conversation
345+
));
346+
}
347+
348+
#[test]
349+
fn legitimate_with_repeated_common_word() {
350+
assert!(!is_hallucinated_output(
351+
"I went to the store and the park today",
352+
HallucinationMode::Conversation
353+
));
354+
}
355+
356+
#[test]
357+
fn empty_string() {
358+
assert!(!is_hallucinated_output("", HallucinationMode::Conversation));
359+
assert!(!is_hallucinated_output(
360+
" ",
361+
HallucinationMode::Conversation
362+
));
363+
}
364+
365+
#[test]
366+
fn two_word_input_not_flagged() {
367+
assert!(!is_hallucinated_output(
368+
"hello world",
369+
HallucinationMode::Conversation
370+
));
371+
}
372+
373+
#[test]
374+
fn legitimate_conversation() {
375+
assert!(!is_hallucinated_output(
376+
"Hey team, let's discuss the new feature implementation plan for next sprint",
377+
HallucinationMode::Conversation
378+
));
379+
}
380+
}

src/openhuman/voice/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
77
pub mod audio_capture;
88
pub mod dictation_listener;
9+
pub mod hallucination;
910
pub mod hotkey;
1011
mod ops;
1112
mod postprocess;

0 commit comments

Comments
 (0)