"""Vosk-based fallback transcription: stream PCM from ffmpeg into a Vosk recognizer.""" from __future__ import annotations import json import logging import os import shutil import subprocess import sys import threading from pathlib import Path try: from vosk import KaldiRecognizer, Model except Exception: Model = None KaldiRecognizer = None logger = logging.getLogger("transcribe") class VoskModelManager: """Loads a Vosk model. Captures the model's stderr to Python logs while loading.""" def __init__(self, model_path: Path): if Model is None or KaldiRecognizer is None: raise RuntimeError("vosk is not installed in runtime") self.model = self._load_with_stderr_capture(model_path) @staticmethod def _load_with_stderr_capture(path: Path): log = logging.getLogger("vosk.loader") r_fd, w_fd = os.pipe() saved_stderr = os.dup(sys.stderr.fileno()) os.dup2(w_fd, sys.stderr.fileno()) os.close(w_fd) def reader(fd: int) -> None: with os.fdopen(fd, "rb") as fh: for raw in iter(fh.readline, b""): log.info(raw.decode(errors="ignore").rstrip()) t = threading.Thread(target=reader, args=(r_fd,), daemon=True) t.start() try: return Model(str(path)) finally: try: os.dup2(saved_stderr, sys.stderr.fileno()) finally: os.close(saved_stderr) t.join(timeout=2) def transcribe_via_ffmpeg( media_path: Path, model_path: Path, sample_rate: int = 16000, on_progress=None, ) -> str: """Decode the source media to PCM s16le mono via ffmpeg and feed it to Vosk in chunks. on_progress: callback(minutes: int) — вызывается каждые ~5 минут распознанного аудио. """ if Model is None or KaldiRecognizer is None: raise RuntimeError("vosk is not installed in runtime") if shutil.which("ffmpeg") is None: raise RuntimeError("ffmpeg is not available in PATH") if not media_path.exists(): raise FileNotFoundError(media_path) mgr = VoskModelManager(model_path) recognizer = KaldiRecognizer(mgr.model, sample_rate) try: recognizer.SetWords(True) except Exception: pass cmd = [ "ffmpeg", "-hide_banner", "-loglevel", "error", "-i", str(media_path), "-f", "s16le", "-acodec", "pcm_s16le", "-ac", "1", "-ar", str(sample_rate), "-vn", "-", ] logger.info("Spawning ffmpeg streaming decode for %s", media_path.name) proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) if proc.stdout is None: proc.kill() raise RuntimeError("ffmpeg did not provide stdout") try: bytes_read = 0 last_logged_mb = 0 while True: chunk = proc.stdout.read(4000) if not chunk: break recognizer.AcceptWaveform(chunk) bytes_read += len(chunk) # PCM s16le mono: sample_rate * 2 байта в секунду minutes = bytes_read // (sample_rate * 2 * 60) if minutes >= last_logged_mb + 5: last_logged_mb = minutes logger.info("Transcribed %d minutes of audio so far", minutes) if on_progress is not None: try: on_progress(minutes) except Exception: pass result = json.loads(recognizer.FinalResult()) text = result.get("text", "") logger.info("Transcription finished, length=%d", len(text)) return text finally: try: proc.kill() except Exception: pass