Add transcription quality gate (silence + hallucination rejection)

New quality.py: analyze the recorded WAV (duration + RMS, stdlib only) and drop
clips that are too short or too quiet before transcribing; after transcribing,
reject the stock phrases Whisper invents on silence ("Thank you.", "Untertitel
…", "Vielen Dank." etc.) on short clips. New [quality] config section
(min_speech_seconds, silence_rms, reject_hallucinations, strip_trailing_punctuation).
Tested: silence rms 38 rejected, speech rms 4656 passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-06-04 23:02:55 +02:00
parent a104f2cfc4
commit cf7ed562f5
3 changed files with 112 additions and 2 deletions

View File

@ -45,6 +45,11 @@ class Config:
key_stop: str = "<ctrl>" # stop -> paste
key_send: str = "<alt>" # stop -> paste -> Enter
key_cancel: str = "<esc>" # discard
# quality gate
min_speech_seconds: float = 0.4
silence_rms: float = 150.0
reject_hallucinations: bool = True
strip_trailing_punctuation: bool = False
# whisper
model: str = "small"
device: str = "auto" # auto | cuda | cpu
@ -120,6 +125,7 @@ def load(path: Path = CONFIG_PATH) -> Config:
r = data.get("rewrite", {})
rt = data.get("routing", {})
inp = data.get("input", {})
q = data.get("quality", {})
cfg = Config(
recorder=g.get("recorder", "auto"),
@ -146,6 +152,10 @@ def load(path: Path = CONFIG_PATH) -> Config:
key_stop=inp.get("stop", "<ctrl>"),
key_send=inp.get("send", "<alt>"),
key_cancel=inp.get("cancel", "<esc>"),
min_speech_seconds=float(q.get("min_speech_seconds", 0.4)),
silence_rms=float(q.get("silence_rms", 150.0)),
reject_hallucinations=bool(q.get("reject_hallucinations", True)),
strip_trailing_punctuation=bool(q.get("strip_trailing_punctuation", False)),
)
for entry in data.get("workflow", []):
@ -237,6 +247,12 @@ def save(cfg: Config, path: Path = CONFIG_PATH) -> None:
"default": cfg.routing_default,
"threshold": cfg.routing_threshold,
},
"quality": {
"min_speech_seconds": cfg.min_speech_seconds,
"silence_rms": cfg.silence_rms,
"reject_hallucinations": cfg.reject_hallucinations,
"strip_trailing_punctuation": cfg.strip_trailing_punctuation,
},
"stt": {"active": cfg.stt_active},
"stt_engine": [
{k: v for k, v in {
@ -306,6 +322,14 @@ stop = "<ctrl>"
send = "<alt>"
cancel = "<esc>"
[quality]
# Reject silence/too-short clips and the stock phrases Whisper invents on
# silence (e.g. "Thank you.", "Untertitel ...") so you don't paste garbage.
min_speech_seconds = 0.4 # discard clips shorter than this
silence_rms = 150.0 # discard clips quieter than this RMS (0..32767)
reject_hallucinations = true
strip_trailing_punctuation = false
[whisper]
model = "small" # tiny | base | small | medium | large-v3, or a local path
device = "auto" # auto | cuda | cpu (auto tries cuda, falls back to cpu)

View File

@ -11,7 +11,7 @@ import sys
import threading
from typing import Callable
from . import llm, stt
from . import llm, quality, stt
from .config import Config, Workflow
from .llm import LLMError
from .notify import notify
@ -134,6 +134,15 @@ class Daemon:
def _process(self, audio_path, workflow: Workflow, window_id, send_enter: bool = False) -> None:
label = workflow.name
try:
# Quality gate: drop silent / too-short clips before we even transcribe.
duration, rms = quality.analyze_wav(audio_path)
if quality.too_quiet(duration, rms,
min_seconds=self.cfg.min_speech_seconds,
silence_rms=self.cfg.silence_rms):
self._emit("idle", label, "Too quiet")
self._notify("Nothing heard", "No speech detected.", "low")
return
self._emit("busy", label, "Transcribing…")
self._notify(f"{label}", "Transcribing…")
hotwords = ", ".join(self.cfg.all_keywords) if workflow.mode == "route" else ""
@ -146,7 +155,8 @@ class Daemon:
timeout=self.cfg.timeout,
)
if not text:
text = quality.clean(text, strip_trailing_punctuation=self.cfg.strip_trailing_punctuation)
if not text or (self.cfg.reject_hallucinations and quality.is_hallucination(text, duration)):
self._emit("idle", label, "No speech detected")
self._notify("Nothing heard", "No speech detected.", "low")
return

View File

@ -0,0 +1,76 @@
"""Transcription quality gate: reject silence, too-short clips, and the stock
Whisper hallucinations that appear on (near-)silent audio.
Ported in spirit from the macOS app's TranscriptionQualityService. Pure stdlib
(no audioop, which is gone in 3.13): RMS is computed from the WAV samples.
"""
from __future__ import annotations
import array
import math
import re
import wave
from pathlib import Path
# Phrases Whisper commonly invents on silence / noise, across languages.
_HALLUCINATIONS = {
"thank you", "thank you.", "thanks for watching", "thanks for watching.",
"thank you for watching", "please subscribe", "you", "bye", "bye.", ".", "...",
"vielen dank", "vielen dank.", "tschuss", "danke", "danke schon",
"untertitel von stephanie geiges", "untertitelung des zdf",
"untertitel im auftrag des zdf", "amara org", "subtitles by",
}
def analyze_wav(path: Path) -> tuple[float, float]:
"""Return (duration_seconds, rms) for a 16-bit PCM WAV. rms is 0..32767."""
try:
with wave.open(str(path), "rb") as w:
frames = w.getnframes()
rate = w.getframerate() or 16000
width = w.getsampwidth()
raw = w.readframes(frames)
except (wave.Error, OSError, EOFError):
return 0.0, 0.0
duration = frames / rate if rate else 0.0
if width != 2 or not raw:
return duration, 0.0
samples = array.array("h")
samples.frombytes(raw[: len(raw) - (len(raw) % 2)])
if not samples:
return duration, 0.0
rms = math.sqrt(sum(s * s for s in samples) / len(samples))
return duration, rms
def too_quiet(duration: float, rms: float, *, min_seconds: float, silence_rms: float) -> bool:
return duration < min_seconds or rms < silence_rms
def _norm(text: str) -> str:
t = text.lower().strip()
t = re.sub(r"[^\w\s]", " ", t, flags=re.UNICODE)
return re.sub(r"\s+", " ", t).strip()
def is_hallucination(text: str, duration: float) -> bool:
"""True if the text is a known artifact — only treated as such for short clips."""
norm = _norm(text)
if not norm:
return True
if duration <= 2.5 and norm in _HALLUCINATIONS:
return True
# A very short clip that produced only a stock phrase is suspect too.
if duration <= 1.5 and len(norm.split()) <= 2 and norm in _HALLUCINATIONS:
return True
return False
def clean(text: str, *, strip_trailing_punctuation: bool = False) -> str:
text = text.strip()
if strip_trailing_punctuation:
text = text.rstrip(" .,!?;:")
return text