diora-web/podcasts/transcribe.py
mrwnslz ae5341d441 Smarte Transkription C: Transkriptions-Pipeline (podcasts/transcribe.py), Endpunkte /transcribe/, /transcription/pending/, /article/claim/, Transkript-Rückfall auf Whisper, transcripts in /feeds/notifications/
Die Folge wird geladen, zu 16 kHz mono dekodiert und an Pausen in Stücke von etwa acht Minuten geschnitten (FLAC für die eingebaute, Opus für fremde Instanzen), parallel an Whisper geschickt (drei Wiederholungen mit wachsender Pause) und per transcripts.merge geglättet. Ein Daemon-Thread arbeitet die Warteschlange ab, ein Auftrag zugleich; hängengebliebene Aufträge starten nach einem Neustart beim ersten Aufruf neu. Abbrechen löscht die Zeile des Auftrags. Gerät der Reservierung: device im Body oder Header X-Diora-Device (docs/transcription.md).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 10:01:35 +01:00

378 lines
15 KiB
Python

"""Transcribe an episode with a Whisper instance (docs/transcription.md, "Transcribing").
The server downloads the episode (as silence.py does, with the same size limit), decodes it
once to 16 kHz mono, cuts it into pieces of about eight minutes where there is a pause (so no
word is cut), sends the pieces in parallel to the instance's OpenAI-compatible
`/audio/transcriptions` and joins the answers into one transcript, which is kept on the job
(TranscriptionJob.data).
A job runs in a daemon thread, one at a time per server; the others wait as `queued` and are
taken oldest first (by id). A job found `downloading` or `transcribing` when the process has
just started (a restart killed it) is queued again. Cancelling deletes the job's row: the
worker notices when it next writes progress and stops.
"""
import logging
import os
import subprocess
import tempfile
import threading
import time
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
import requests
from django.conf import settings
from django.db import close_old_connections
from django.utils import timezone
from . import chapters, silence, transcripts
from .models import TranscriptionJob, TranscriptionSettings
from .transcription import DEFAULT_MODEL, for_user, token_of
log = logging.getLogger(__name__)
CHUNK_SECONDS = 8 * 60 # about this long a piece
CUT_WINDOW = 30 # a cut moves to the longest pause within this many seconds
MIN_TAIL = 60 # a last piece shorter than this is joined to the one before
RATE = 16000 # Hz, mono, 16-bit
WINDOW = silence.WINDOW
RETRIES = 3
RETRY_PAUSES = (3, 10, 30) # seconds before each retry
UPLOAD_TIMEOUT = (10, 3600) # a CPU instance may need many minutes for a piece
DECODE_TIMEOUT = 3600
OPUS_BITRATE = '32k'
MAX_PROMPT = 500 # characters
ALIVE_CHECK = 5 # seconds between looks whether the job was cancelled
_lock = threading.Lock()
_runner = None # the thread working through the queue, or None
_resumed = False # whether jobs left over from before a restart were queued again
class Cancelled(Exception):
"""The job's row is gone: the user cancelled it."""
class Failed(Exception):
"""The job can't succeed; the message is shown to the user."""
class Permanent(Exception):
"""A request the instance refused for good (a client error): not worth a retry."""
# ---------------------------------------------------------------------------
# Queue
# ---------------------------------------------------------------------------
def kick():
"""Make sure queued jobs are being worked on. Called by the endpoints, so after a restart
the first request brings back what was running."""
global _runner, _resumed
with _lock:
if not _resumed:
_resumed = True
TranscriptionJob.objects.filter(
state__in=[TranscriptionJob.DOWNLOADING, TranscriptionJob.TRANSCRIBING],
).update(state=TranscriptionJob.QUEUED, chunks_done=0, chunks_total=0)
if _runner is not None:
return
if not TranscriptionJob.objects.filter(state=TranscriptionJob.QUEUED).exists():
return
_runner = threading.Thread(target=_loop, name='transcribe', daemon=True)
_runner.start()
def _next_job():
global _runner
with _lock:
job_id = (TranscriptionJob.objects.filter(state=TranscriptionJob.QUEUED)
.order_by('id').values_list('id', flat=True).first())
if job_id is None:
_runner = None # decided under the lock, so a job queued right now starts a new runner
return job_id
def _loop():
global _runner
try:
while True:
close_old_connections()
job_id = _next_job()
if job_id is None:
return
try:
run(job_id)
except Exception: # run() reports its own failures; this keeps the queue going
log.exception('transcription job %s crashed', job_id)
except BaseException:
with _lock:
_runner = None
raise
finally:
close_old_connections()
def run(job_id):
"""Work one job through to `done` or `error`, in this thread."""
started = TranscriptionJob.objects.filter(pk=job_id, state=TranscriptionJob.QUEUED).update(
state=TranscriptionJob.DOWNLOADING, started_at=timezone.now(), finished_at=None,
error='', chunks_done=0, chunks_total=0)
if not started:
return # cancelled while it waited
try:
data = _transcribe(job_id)
_write(job_id, state=TranscriptionJob.DONE, data=data, error='', finished_at=timezone.now())
except Cancelled:
log.info('transcription job %s cancelled', job_id)
except Failed as e:
_fail(job_id, str(e))
except Exception as e:
log.exception('transcription job %s failed', job_id)
_fail(job_id, f'{type(e).__name__}: {e}')
def _fail(job_id, message):
TranscriptionJob.objects.filter(pk=job_id).update(
state=TranscriptionJob.ERROR, error=(message or 'failed')[:500], finished_at=timezone.now())
def _write(job_id, **fields):
"""Update the job; Cancelled when its row is gone."""
if not TranscriptionJob.objects.filter(pk=job_id).update(**fields):
raise Cancelled()
def _alive(job_id):
if not TranscriptionJob.objects.filter(pk=job_id).exists():
raise Cancelled()
# ---------------------------------------------------------------------------
# The work
# ---------------------------------------------------------------------------
def _target(conf):
"""(url, headers, builtin) of the instance the settings point at."""
builtin = conf.engine == TranscriptionSettings.BUILTIN
base = settings.WHISPER_URL if builtin else conf.url
if not base:
raise Failed('Keine eingebaute Instanz eingerichtet' if builtin else 'Keine Adresse eingetragen')
headers = {'User-Agent': 'diora transcription'}
if not builtin and conf.token_encrypted:
headers['Authorization'] = 'Bearer ' + token_of(conf)
return base.rstrip('/') + '/audio/transcriptions', headers, builtin
def _prompt(episode):
parts = [episode.feed.title, episode.title, episode.feed.author]
return '. '.join(p.strip() for p in parts if p and p.strip())[:MAX_PROMPT]
def _transcribe(job_id):
job = TranscriptionJob.objects.select_related('episode__feed').get(pk=job_id)
episode = job.episode
conf = for_user(episode.feed.user)
url, headers, builtin = _target(conf)
options = {'model': conf.model or DEFAULT_MODEL, 'response_format': 'verbose_json'}
if conf.language:
options['language'] = conf.language
prompt = _prompt(episode)
if prompt:
options['prompt'] = prompt
with tempfile.TemporaryDirectory(prefix='diora-transcribe-') as tmp:
source = os.path.join(tmp, 'episode.audio')
_download(episode.audio_url, source, job_id)
pcm = os.path.join(tmp, 'episode.pcm')
_decode(source, pcm)
os.remove(source)
total = os.path.getsize(pcm) // 2 / RATE
if total <= 0:
raise Failed('Kein Ton in der Datei')
pieces = plan(total, pauses_of(pcm))
_write(job_id, state=TranscriptionJob.TRANSCRIBING, chunks_total=len(pieces), chunks_done=0)
results = {}
stop = threading.Event() # set when the job ends early: no piece starts after that
suffix, mime = ('.flac', 'audio/flac') if builtin else ('.ogg', 'audio/ogg')
def work(index):
# Only the HTTP side runs here; the database is touched by this thread alone.
if stop.is_set():
return index, None
start, end = pieces[index]
path = _encode(pcm, start, end, tmp, index, opus=not builtin)
try:
with open(path, 'rb') as f:
body = f.read()
finally:
os.remove(path)
return index, _send(url, headers, builtin, f'piece-{index}{suffix}', mime, body, options, stop)
pool = ThreadPoolExecutor(max_workers=max(1, min(conf.parallel, 8)))
try:
pending = {pool.submit(work, i) for i in range(len(pieces))}
while pending:
finished, pending = wait(pending, timeout=ALIVE_CHECK, return_when=FIRST_COMPLETED)
_alive(job_id) # also while a long piece is out
for f in finished:
index, answer = f.result() # the first failure ends the job
results[index] = answer
if finished:
_write(job_id, chunks_done=len(results))
finally:
stop.set()
pool.shutdown(wait=True, cancel_futures=True)
return build(pieces, [results[i] for i in range(len(pieces))], options['model'], conf.language)
def _download(url, path, job_id):
size = 0
with open(path, 'wb') as out, requests.get(
url, stream=True, timeout=(10, 60), headers={'User-Agent': 'diora transcription'}) as r:
if r.status_code >= 400:
raise Failed(f'Download: HTTP {r.status_code}')
if int(r.headers.get('Content-Length') or 0) > silence.MAX_BYTES:
raise Failed('too large')
for n, chunk in enumerate(r.iter_content(1 << 20)):
size += len(chunk)
if size > silence.MAX_BYTES:
raise Failed('too large')
out.write(chunk)
if n % 8 == 0:
_alive(job_id)
if size == 0:
raise Failed('Download: leere Datei')
def _run_ffmpeg(args, input=None, timeout=DECODE_TIMEOUT):
import imageio_ffmpeg
proc = subprocess.run(
[imageio_ffmpeg.get_ffmpeg_exe(), '-nostdin', '-hide_banner', '-loglevel', 'error', *args],
input=input, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout)
if proc.returncode != 0:
err = proc.stderr.decode('utf-8', 'replace').strip().splitlines()
raise Failed('ffmpeg: ' + (err[-1] if err else f'exit {proc.returncode}')[:200])
def _decode(source, pcm):
"""The audio at `source` as raw 16 kHz mono 16-bit samples in the file `pcm`."""
_run_ffmpeg(['-vn', '-sn', '-dn', '-i', source, '-ac', '1', '-ar', str(RATE), '-f', 's16le', '-y', pcm])
def pauses_of(pcm):
"""The pauses of the decoded audio as [(middle, length)] in seconds, found with
silence.py's rules (what it cuts out of a pause is centred on the pause)."""
size = int(RATE * WINDOW) * 2
levels = []
with open(pcm, 'rb') as f:
while True:
data = f.read(size * 100000)
if not data:
break
for i in range(0, len(data) - size + 1, size):
levels.append(silence._level_db(data[i:i + size]))
return [((a + b) / 2, b - a) for a, b in silence.cuts(levels)]
def plan(total, pauses):
"""[(start, end)] in seconds covering 0..total: pieces of about CHUNK_SECONDS, each cut at
the longest pause within CUT_WINDOW of where it would fall (right there when there is none)."""
cuts = []
last = 0.0
k = 1
while k * CHUNK_SECONDS < total - MIN_TAIL:
target = k * CHUNK_SECONDS
k += 1
near = [p for p in pauses if abs(p[0] - target) <= CUT_WINDOW and p[0] > last + 1]
at = max(near, key=lambda p: p[1])[0] if near else target
if at <= last or at >= total:
continue
cuts.append(at)
last = at
edges = [0.0, *cuts, total]
return [(edges[i], edges[i + 1]) for i in range(len(edges) - 1)]
def _encode(pcm, start, end, tmp, index, opus):
"""The piece [start, end) of the decoded audio as FLAC or Opus (ogg), in a file."""
with open(pcm, 'rb') as f:
f.seek(int(round(start * RATE)) * 2)
data = f.read(int(round((end - start) * RATE)) * 2)
out = os.path.join(tmp, f'piece-{index}.' + ('ogg' if opus else 'flac'))
codec = ['-c:a', 'libopus', '-b:a', OPUS_BITRATE, '-f', 'ogg'] if opus else ['-c:a', 'flac', '-f', 'flac']
_run_ffmpeg(['-f', 's16le', '-ar', str(RATE), '-ac', '1', '-i', 'pipe:0', *codec, '-y', out], input=data)
return out
def _post(url, headers, builtin, name, mime, body, options):
"""One request; the parsed answer. Permanent for a refusal that won't change, RuntimeError
for trouble that may pass."""
files = {'file': (name, body, mime)}
try:
if builtin:
# The operator's own instance (DIORA_WHISPER_URL) may sit on a private network.
r = requests.post(url, headers=headers, files=files, data=options, timeout=UPLOAD_TIMEOUT)
if r.status_code >= 400:
if r.status_code < 500 and r.status_code not in (408, 429):
raise Permanent(f'HTTP {r.status_code}')
raise RuntimeError(f'HTTP {r.status_code}')
else:
try:
r = chapters._open(url, headers, method='POST', timeout=UPLOAD_TIMEOUT, files=files, data=options)
except chapters.ChaptersInvalid as e:
raise Permanent(str(e))
except chapters.ChaptersUnavailable as e:
raise RuntimeError(str(e))
try:
return r.json()
finally:
r.close()
except requests.RequestException as e:
raise RuntimeError(e.__class__.__name__)
except ValueError:
raise RuntimeError('Antwort ist kein JSON')
def _send(url, headers, builtin, name, mime, body, options, stop):
"""A piece to the instance, retried up to RETRIES times with a growing pause."""
for attempt in range(RETRIES + 1):
if stop.is_set():
raise Cancelled()
try:
return _post(url, headers, builtin, name, mime, body, options)
except Permanent as e:
raise Failed(f'Whisper: {e}')
except RuntimeError as e:
if attempt >= RETRIES:
raise Failed(f'Whisper: {e}')
log.info('whisper piece %s failed (%s), retrying', name, e)
time.sleep(RETRY_PAUSES[min(attempt, len(RETRY_PAUSES) - 1)])
def build(pieces, answers, model, language):
"""{segments, source} from the pieces' places and the instance's verbose_json answers."""
fragments = []
detected = ''
for (start, end), answer in zip(pieces, answers):
if not isinstance(answer, dict):
continue
detected = detected or (answer.get('language') or '')
segments = answer.get('segments')
if not isinstance(segments, list): # a plain {"text": …} answer: one segment for the piece
text = (answer.get('text') or '').strip()
segments = [{'start': 0, 'end': end - start, 'text': text}] if text else []
for s in segments:
if not isinstance(s, dict):
continue
text = ' '.join(str(s.get('text') or '').split())
try:
a, b = float(s.get('start', 0)), float(s.get('end', 0))
except (TypeError, ValueError):
continue
if text:
fragments.append((start + a, start + b, '', text))
merged = transcripts.merge(fragments)
if not merged:
raise Failed('Kein Text erkannt')
return {'segments': merged, 'source': {'type': 'whisper', 'model': model, 'language': language or detected}}