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:
parent
a104f2cfc4
commit
cf7ed562f5
@ -45,6 +45,11 @@ class Config:
|
|||||||
key_stop: str = "<ctrl>" # stop -> paste
|
key_stop: str = "<ctrl>" # stop -> paste
|
||||||
key_send: str = "<alt>" # stop -> paste -> Enter
|
key_send: str = "<alt>" # stop -> paste -> Enter
|
||||||
key_cancel: str = "<esc>" # discard
|
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
|
# whisper
|
||||||
model: str = "small"
|
model: str = "small"
|
||||||
device: str = "auto" # auto | cuda | cpu
|
device: str = "auto" # auto | cuda | cpu
|
||||||
@ -120,6 +125,7 @@ def load(path: Path = CONFIG_PATH) -> Config:
|
|||||||
r = data.get("rewrite", {})
|
r = data.get("rewrite", {})
|
||||||
rt = data.get("routing", {})
|
rt = data.get("routing", {})
|
||||||
inp = data.get("input", {})
|
inp = data.get("input", {})
|
||||||
|
q = data.get("quality", {})
|
||||||
|
|
||||||
cfg = Config(
|
cfg = Config(
|
||||||
recorder=g.get("recorder", "auto"),
|
recorder=g.get("recorder", "auto"),
|
||||||
@ -146,6 +152,10 @@ def load(path: Path = CONFIG_PATH) -> Config:
|
|||||||
key_stop=inp.get("stop", "<ctrl>"),
|
key_stop=inp.get("stop", "<ctrl>"),
|
||||||
key_send=inp.get("send", "<alt>"),
|
key_send=inp.get("send", "<alt>"),
|
||||||
key_cancel=inp.get("cancel", "<esc>"),
|
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", []):
|
for entry in data.get("workflow", []):
|
||||||
@ -237,6 +247,12 @@ def save(cfg: Config, path: Path = CONFIG_PATH) -> None:
|
|||||||
"default": cfg.routing_default,
|
"default": cfg.routing_default,
|
||||||
"threshold": cfg.routing_threshold,
|
"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": {"active": cfg.stt_active},
|
||||||
"stt_engine": [
|
"stt_engine": [
|
||||||
{k: v for k, v in {
|
{k: v for k, v in {
|
||||||
@ -306,6 +322,14 @@ stop = "<ctrl>"
|
|||||||
send = "<alt>"
|
send = "<alt>"
|
||||||
cancel = "<esc>"
|
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]
|
[whisper]
|
||||||
model = "small" # tiny | base | small | medium | large-v3, or a local path
|
model = "small" # tiny | base | small | medium | large-v3, or a local path
|
||||||
device = "auto" # auto | cuda | cpu (auto tries cuda, falls back to cpu)
|
device = "auto" # auto | cuda | cpu (auto tries cuda, falls back to cpu)
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import sys
|
|||||||
import threading
|
import threading
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
from . import llm, stt
|
from . import llm, quality, stt
|
||||||
from .config import Config, Workflow
|
from .config import Config, Workflow
|
||||||
from .llm import LLMError
|
from .llm import LLMError
|
||||||
from .notify import notify
|
from .notify import notify
|
||||||
@ -134,6 +134,15 @@ class Daemon:
|
|||||||
def _process(self, audio_path, workflow: Workflow, window_id, send_enter: bool = False) -> None:
|
def _process(self, audio_path, workflow: Workflow, window_id, send_enter: bool = False) -> None:
|
||||||
label = workflow.name
|
label = workflow.name
|
||||||
try:
|
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._emit("busy", label, "Transcribing…")
|
||||||
self._notify(f"⌛ {label}", "Transcribing…")
|
self._notify(f"⌛ {label}", "Transcribing…")
|
||||||
hotwords = ", ".join(self.cfg.all_keywords) if workflow.mode == "route" else ""
|
hotwords = ", ".join(self.cfg.all_keywords) if workflow.mode == "route" else ""
|
||||||
@ -146,7 +155,8 @@ class Daemon:
|
|||||||
timeout=self.cfg.timeout,
|
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._emit("idle", label, "No speech detected")
|
||||||
self._notify("Nothing heard", "No speech detected.", "low")
|
self._notify("Nothing heard", "No speech detected.", "low")
|
||||||
return
|
return
|
||||||
|
|||||||
76
linux/blitztext/quality.py
Normal file
76
linux/blitztext/quality.py
Normal 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
|
||||||
Loading…
Reference in New Issue
Block a user