Files

236 lines
9.8 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Transcription queue for SDR voice recordings (runs as user sdr, loop).
Every voice recording (per-day .cls.json, class "voice"; also archived ones from .archived.json) since
SDR_TR_START that has no transcript yet is in the queue. Whisper runs on the owner's desktop (RTX 5070,
whisper_server.py) and is reached through the home Caddy: https://whisper.home.example.com (connected to the
server's LAN address directly). While the desktop is off the queue just grows; when /health answers, the
backlog is sent newest first (so live traffic appears quickly), then the rest.
Per recording: ffmpeg -> 16 kHz mono WAV -> POST /v1/audio/transcriptions. Language: auto; anything other
than ru/en/uk is retried as ru (short noisy clips fool the detector). Known Whisper hallucinations on
silence ("Продолжение следует...", subtitle credits) are stored as empty text with "h": 1.
Results: /srv/sdr/transcripts/YYYY-MM-DD.json {file: {"t": text, "l": lang, "at": epoch}} (atomic rewrite);
failures {"err": msg, "n": tries} (given up after 3). Status for the web page and the dashboard:
/srv/sdr/transcripts/status.json.
"""
import http.client
import json
import os
import socket
import ssl
import subprocess
import sys
import time
import uuid
from urllib.parse import quote, urlparse
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from radio.channels import channels # noqa: E402
from radio.config import ARCHIVE_URL, REC_DIR # noqa: E402
from radio.recordings import DAY_RE, FILE_RE, arch_index, cls_index # noqa: E402
HOST = os.environ.get("SDR_TR_HOST", "whisper.home.example.com")
CONNECT = os.environ.get("SDR_TR_CONNECT", "192.168.88.221") # Caddy on the server, SNI = HOST
START = os.environ.get("SDR_TR_START", "2026-10-28")
OUT = os.environ.get("SDR_TR_DIR", "/srv/sdr/transcripts")
IDLE_S = 60 # health re-check while the desktop is down / queue empty
BATCH = 50 # re-scan for new (newer) recordings after this many
MAX_TRIES = 3
VERSION = 2 # results with a lower "v" are redone (v2: 0.6 s silence padding, language policy below)
AUTO_GROUPS = {"avia"} # only aviation can be English; marine/PMR/LPD/rail/hams are Russian -> forced "ru"
LOW_CONF = -1.0 # avg token log-prob below this -> "lc": 1 (shown dimmed on the page)
# Whisper pads the clip itself, but speech that starts at the very first millisecond loses its first word
PAD = "adelay=600:all=1,apad=pad_dur=0.6"
HALLU = ("продолжение следует", "субтитр", "dimatorzok", "редактор субтитров", "спасибо за просмотр",
"подписывайтесь", "thank you for watching", "thanks for watching", "amara.org", "ставьте лайк",
"terima kasih", "редактор а.")
HALLU_EXACT = {"you", "thank you", "bye", "thanks", "okay"}
class Conn(http.client.HTTPSConnection):
"""HTTPS to the server's LAN address with SNI/Host = HOST (new home names resolve to the WAN IP here)."""
def connect(self):
sock = socket.create_connection((CONNECT, 443), self.timeout)
self.sock = self._context.wrap_socket(sock, server_hostname=self.host)
def request(method, path, body=None, headers=None, timeout=10):
c = Conn(HOST, 443, timeout=timeout, context=ssl.create_default_context())
try:
c.request(method, path, body=body, headers=headers or {})
r = c.getresponse()
return r.status, r.read()
finally:
c.close()
def whisper_api():
"""0 = down; 1 = first server version (no confidence, no ru/en choice); 2 = picks ru vs en by confidence."""
try:
st, b = request("GET", "/health", timeout=8)
j = json.loads(b)
return (int(j.get("api", 1)) if j.get("ok") is True else 0) if st == 200 else 0
except (OSError, ValueError, http.client.HTTPException):
return 0
def whisper_up():
return whisper_api() > 0
def load(day):
try:
with open(os.path.join(OUT, day + ".json"), encoding="utf-8") as fh:
return json.load(fh)
except (OSError, ValueError):
return {}
def save(day, d):
p = os.path.join(OUT, day + ".json")
with open(p + ".tmp", "w", encoding="utf-8") as fh:
json.dump(d, fh, ensure_ascii=False, separators=(",", ":"))
os.replace(p + ".tmp", p)
def write_status(**kw):
kw["ts"] = int(time.time())
p = os.path.join(OUT, "status.json")
with open(p + ".tmp", "w", encoding="utf-8") as fh:
json.dump(kw, fh, ensure_ascii=False)
os.replace(p + ".tmp", p)
def redo(r):
if not r:
return True
if "err" in r:
return r.get("n", 0) < MAX_TRIES
return r.get("v", 1) < VERSION
def queue(done):
"""[(day, name, archived)] voice recordings without a final result, newest first."""
items = []
days = sorted(d for d in os.listdir(REC_DIR) if DAY_RE.match(d) and d >= START)
for day in days:
dd = done.setdefault(day, load(day))
local = set(n for n in os.listdir(os.path.join(REC_DIR, day)) if FILE_RE.match(n))
for n, ci in cls_index(day).items():
if n in local and (ci or {}).get("c") == "voice":
if redo(dd.get(n)):
items.append((day, n, False))
for n, ai in arch_index(day).items():
cls = (ai or {}).get("c") or (ai or {}).get("class")
if n not in local and FILE_RE.match(n) and cls == "voice":
if redo(dd.get(n)):
items.append((day, n, True))
items.sort(key=lambda x: x[1].split("_", 1)[1], reverse=True)
return items, days
def wav(day, name, archived):
if archived:
u = urlparse(ARCHIVE_URL)
c = http.client.HTTPConnection(u.hostname, u.port or 80, timeout=30)
c.request("GET", f"/{day}/{quote(name)}")
r = c.getresponse()
if r.status != 200:
raise OSError(f"archive HTTP {r.status}")
src, inp = "pipe:0", r.read()
c.close()
else:
src, inp = os.path.join(REC_DIR, day, name), None
p = subprocess.run(["ffmpeg", "-v", "error", "-i", src,
"-af", PAD, "-ac", "1", "-ar", "16000", "-f", "wav", "pipe:1"],
input=inp, capture_output=True, timeout=60)
if p.returncode != 0 or len(p.stdout) < 1000:
raise OSError("ffmpeg: " + p.stderr.decode(errors="replace")[:200])
return p.stdout
def transcribe(audio, language=""):
b = uuid.uuid4().hex
parts = [f'--{b}\r\nContent-Disposition: form-data; name="file"; filename="a.wav"\r\n'
f"Content-Type: audio/wav\r\n\r\n".encode() + audio + b"\r\n"]
if language:
parts.append(f'--{b}\r\nContent-Disposition: form-data; name="language"\r\n\r\n{language}\r\n'.encode())
body = b"".join(parts) + f"--{b}--\r\n".encode()
st, resp = request("POST", "/v1/audio/transcriptions", body,
{"Content-Type": f"multipart/form-data; boundary={b}"}, timeout=120)
if st != 200:
raise OSError(f"whisper HTTP {st}: {resp[:200]!r}")
return json.loads(resp)
def one(day, name, archived, api):
audio = wav(day, name, archived)
grp = channels().get("channels", {}).get(FILE_RE.match(name)["f"], {}).get("group", "other")
# api 2 chooses ru vs en itself by confidence; api 1 has no such choice -> Russian for everything
r = transcribe(audio, "" if (api >= 2 and grp in AUTO_GROUPS) else "ru")
text = (r.get("text") or "").strip()
res = {"t": text, "l": r.get("language") or "ru", "at": int(time.time()), "v": VERSION}
p = r.get("avg_logprob")
if isinstance(p, (int, float)):
res["p"] = round(p, 3)
if p < LOW_CONF:
res["lc"] = 1
low = text.lower()
if not any(ch.isalnum() for ch in text) or any(h in low for h in HALLU) or low.strip(" .!?,") in HALLU_EXACT:
res["t"], res["h"] = "", 1
return res
def main():
os.makedirs(OUT, exist_ok=True)
stats = {"done_run": 0, "last_done": None}
while True:
done = {}
items, days = queue(done)
total = sum(1 for d in days for r in done[d].values() if "t" in r)
api = whisper_api()
up = api > 0
write_status(endpoint_up=up, api=api, pending=len(items), done=total, since=START, **stats)
if not up or not items:
time.sleep(IDLE_S)
continue
dirty, last_save = set(), time.time()
fails = 0
for k, (day, name, archived) in enumerate(items[:BATCH], 1):
try:
res = one(day, name, archived, api)
fails = 0
stats["done_run"] += 1
stats["last_done"] = int(time.time())
total += 1
except (OSError, ValueError, http.client.HTTPException, subprocess.SubprocessError) as e:
prev = done[day].get(name) or {}
res = {"err": str(e)[:200], "n": prev.get("n", 0) + 1}
fails += 1
if not whisper_up(): # desktop went away mid-batch: keep the item, stop
break
if fails >= 4: # server answers but fails (bug / model loading): do not burn tries
log_err = res["err"]
print("transcribe: серия ошибок, пауза 5 мин:", log_err, flush=True)
res = None
time.sleep(300)
break
if res is None:
continue
done[day][name] = res
dirty.add(day)
if time.time() - last_save > 10:
for d in dirty:
save(d, done[d])
dirty.clear()
last_save = time.time()
write_status(endpoint_up=True, pending=len(items) - k, done=total, since=START, **stats)
for d in dirty:
save(d, done[d])
if __name__ == "__main__":
main()