diff --git a/linux/blitztext/config.py b/linux/blitztext/config.py index 96e62bb..c749a26 100644 --- a/linux/blitztext/config.py +++ b/linux/blitztext/config.py @@ -45,6 +45,11 @@ class Config: key_stop: str = "" # stop -> paste key_send: str = "" # stop -> paste -> Enter key_cancel: str = "" # 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", ""), key_send=inp.get("send", ""), key_cancel=inp.get("cancel", ""), + 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 = "" send = "" cancel = "" +[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) diff --git a/linux/blitztext/daemon.py b/linux/blitztext/daemon.py index d6b34e4..cd5ea1b 100644 --- a/linux/blitztext/daemon.py +++ b/linux/blitztext/daemon.py @@ -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 diff --git a/linux/blitztext/quality.py b/linux/blitztext/quality.py new file mode 100644 index 0000000..8b6ed5d --- /dev/null +++ b/linux/blitztext/quality.py @@ -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