- Wayland support: text delivery using wtype or ydotool (paste.py) - Testing: added Pytest coverage for routing, quality, and config logic - CI/CD: added test-linux job to .github/workflows/ci.yml - Feature: Hands-free dictation using an external wyoming-openwakeword server, respecting the /tmp/wake_muted toggle.
74 lines
2.3 KiB
Python
74 lines
2.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
|