blitztext-app-linux/linux/blitztext/quality.py
mARTin-B78 749ef78383 dictation: spoken newlines + LLM thinking animation (v1.9.8)
- Add expand_spoken_punctuation() in quality.py: converts spoken phrases
  "new line" / "neue Zeile" → \n and "new paragraph" / "neuer Absatz" → \n\n
  before text is typed; xdotool/wtype handle \n as Return
- Daemon: pulse " Thinking..." animation in the overlay while waiting for
  the first LLM token (400ms interval), replaced automatically when streaming
  starts so the user sees activity during cold-start latency

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 14:43:15 +02:00

94 lines
3.3 KiB
Python

"""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
return False
def clean(text: str, *, strip_trailing_punctuation: bool = False) -> str:
text = text.strip()
if strip_trailing_punctuation:
text = text.rstrip(" .,!?;:")
return text
# Spoken phrases that should become newlines/paragraphs in the typed output.
# Matched case-insensitively, whole-phrase (surrounded by word boundaries or
# start/end of string). German and English variants are both covered.
_NEWLINE_PHRASES: list[tuple[re.Pattern[str], str]] = [
# Paragraph break (blank line) — must come before single-newline patterns
(re.compile(r"(?i)\b(new paragraph|neuer absatz|neues absatz|absatz)\b"), "\n\n"),
# Single line break
(re.compile(r"(?i)\b(new line|neue zeile|zeilenumbruch|line break)\b"), "\n"),
]
def expand_spoken_punctuation(text: str) -> str:
"""Replace spoken newline/paragraph commands with actual control characters."""
for pattern, replacement in _NEWLINE_PHRASES:
text = pattern.sub(replacement, text)
# Collapse leading/trailing whitespace per line but preserve intentional newlines
lines = [ln.strip() for ln in text.split("\n")]
return "\n".join(lines).strip()