mirror of
https://github.com/Luchexx/claude-video.git
synced 2026-11-02 17:29:52 +00:00
Add opt-in local transcription with managed installation, cache warm-up, and a one-time backend choice while preserving native-caption priority. Correct frame timing and coverage, caption provenance and download ownership, configuration parsing, provider pairing, and partial reports. Update cross-host instructions, packaging, and test-gated release CI. Validate 198 isolated tests, real local English and Spanish inference, network-denied warm-cache inference, live captions/media, and packaging. Record platform and account-dependent acceptance limits separately.
148 lines
6.9 KiB
Python
148 lines
6.9 KiB
Python
"""Subprocess-only WhisperX 3.8.6 adapter. Never imports Torch into watch."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import queue
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
|
|
from config import ConfigError, get_config, positive_number
|
|
from runtime import diagnostic
|
|
from transcribe import normalize_segments
|
|
|
|
MODELS = {'tiny', 'base', 'small', 'medium', 'large', 'large-v1', 'large-v2', 'large-v3',
|
|
'large-v3-turbo', 'turbo', 'tiny.en', 'base.en', 'small.en', 'medium.en'}
|
|
|
|
|
|
def settings(cfg=None) -> dict:
|
|
cfg = dict(get_config(backend_override='whisperx') if cfg is None else cfg)
|
|
if cfg['whisperx_model'] not in MODELS:
|
|
raise ConfigError('WATCH_WHISPERX_MODEL must be a supported Whisper model name (default small).')
|
|
if cfg['whisperx_device'] not in ('cpu', 'cuda'):
|
|
raise ConfigError('WATCH_WHISPERX_DEVICE must be cpu or cuda (CUDA is untested).')
|
|
if cfg['whisperx_compute_type'] not in ('default', 'int8', 'float16', 'float32'):
|
|
raise ConfigError('WATCH_WHISPERX_COMPUTE_TYPE must be default, int8, float16, or float32.')
|
|
batch = positive_number(cfg['whisperx_batch_size'], 'WATCH_WHISPERX_BATCH_SIZE')
|
|
if batch != int(batch):
|
|
raise ConfigError('WATCH_WHISPERX_BATCH_SIZE must be a positive integer.')
|
|
cfg['whisperx_batch_size'] = int(batch)
|
|
cfg['whisperx_timeout'] = positive_number(cfg['whisperx_timeout'], 'WATCH_WHISPERX_TIMEOUT') if cfg['whisperx_timeout'] else None
|
|
if cfg['whisperx_language'] and not re.fullmatch('[a-z]{2}', cfg['whisperx_language']):
|
|
raise ConfigError('WATCH_WHISPERX_LANGUAGE must be a two-letter lowercase code.')
|
|
return cfg
|
|
|
|
|
|
def sentinel_for(executable: Path) -> Path:
|
|
return executable.parent.parent / '.deps-ok'
|
|
|
|
|
|
def executable_path(cfg: dict, *, require_ready=True) -> Path:
|
|
value = cfg['whisperx_bin']
|
|
if not value or not Path(value).is_absolute() or not Path(value).is_file():
|
|
raise SystemExit('WhisperX executable missing. Run setup.py --install-whisperx.')
|
|
executable = Path(value)
|
|
if require_ready and not sentinel_for(executable).is_file():
|
|
raise SystemExit('WhisperX venv is incomplete (no .deps-ok). Run setup.py --install-whisperx to rebuild it.')
|
|
return executable
|
|
|
|
|
|
def command(executable: Path, audio: Path, output: Path, cfg: dict) -> list[str]:
|
|
cmd = [str(executable), str(audio.resolve()), '--model', cfg['whisperx_model'],
|
|
'--device', cfg['whisperx_device'], '--compute_type', cfg['whisperx_compute_type'],
|
|
'--batch_size', str(cfg['whisperx_batch_size']), '--output_dir', str(output.resolve()),
|
|
'--output_format', 'json', '--task', 'transcribe', '--no_align', '--vad_method', 'silero', '--verbose', 'False']
|
|
if cfg['whisperx_language']:
|
|
cmd += ['--language', cfg['whisperx_language']]
|
|
return cmd
|
|
|
|
|
|
def child_env() -> dict[str, str]:
|
|
env = dict(os.environ)
|
|
env.update(PYTHONIOENCODING='utf-8', PYTHONWARNINGS='ignore', PYANNOTE_METRICS_ENABLED='0')
|
|
return env
|
|
|
|
|
|
def run_inference(cmd: list[str], timeout: float | None = None) -> None:
|
|
"""Stream bounded byte diagnostics while enforcing an optional wall-clock timeout."""
|
|
try:
|
|
process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=child_env())
|
|
except OSError as exc:
|
|
raise SystemExit(f'WhisperX executable is not runnable ({type(exc).__name__}); rerun setup.py --install-whisperx.') from None
|
|
messages = queue.Queue(maxsize=64)
|
|
def read():
|
|
try:
|
|
while chunk := process.stdout.read1(4096):
|
|
messages.put(chunk)
|
|
finally:
|
|
messages.put(None)
|
|
reader = threading.Thread(target=read, daemon=True)
|
|
reader.start()
|
|
deadline = time.monotonic() + timeout if timeout is not None else None
|
|
tail = b''
|
|
try:
|
|
while True:
|
|
if deadline is not None and time.monotonic() >= deadline:
|
|
raise TimeoutError()
|
|
try:
|
|
chunk = messages.get(timeout=0.1)
|
|
except queue.Empty:
|
|
continue
|
|
if chunk is None:
|
|
break
|
|
tail = (tail + chunk)[-16000:]
|
|
text = diagnostic(chunk, 4096)
|
|
if text:
|
|
print(f'[whisperx] {text}', file=sys.stderr, flush=True)
|
|
process.wait(timeout=max(0.01, deadline - time.monotonic()) if deadline is not None else None)
|
|
except (TimeoutError, subprocess.TimeoutExpired):
|
|
process.kill()
|
|
process.wait()
|
|
raise SystemExit('WhisperX timed out and was stopped. Increase WATCH_WHISPERX_TIMEOUT or leave it unset.') from None
|
|
except KeyboardInterrupt:
|
|
process.kill()
|
|
process.wait()
|
|
raise SystemExit('WhisperX cancelled and stopped; no cloud fallback was attempted.') from None
|
|
finally:
|
|
# Drain queued diagnostics so the reader cannot remain blocked after cancellation.
|
|
while reader.is_alive():
|
|
try:
|
|
messages.get(timeout=0.1)
|
|
except queue.Empty:
|
|
pass
|
|
reader.join()
|
|
process.stdout.close()
|
|
if process.returncode != 0:
|
|
detail = diagnostic(tail)
|
|
lower = tail.decode('utf-8', errors='replace').lower()
|
|
if any(token in lower for token in ('out of memory', 'bad_alloc', 'cuda', 'cudnn', 'cublas')) or process.returncode in (-9, 137):
|
|
hint = 'Memory or device failure; use cpu/int8, a smaller model, or lower batch size. Small needs at least 8 GB system RAM.'
|
|
elif any(token in lower for token in ('huggingface', 'connection', 'offline', 'download', 'localentrynotfound')):
|
|
hint = 'Model download/cache failure; reconnect and rerun setup.py --install-whisperx to warm the caches.'
|
|
else:
|
|
hint = 'Check the error below and rerun setup.py --install-whisperx if dependencies are broken.'
|
|
raise SystemExit(f'WhisperX failed (exit {process.returncode}). {hint}\n{detail}')
|
|
|
|
|
|
def transcribe_audio(audio: Path, cfg=None, *, require_ready=True):
|
|
cfg = settings(cfg)
|
|
executable = executable_path(cfg, require_ready=require_ready)
|
|
print(f"[watch] local WhisperX {cfg['whisperx_model']} transcription; CPU runs may take several minutes.", file=sys.stderr, flush=True)
|
|
with tempfile.TemporaryDirectory(prefix='watch-whisperx-', dir=audio.parent) as temporary:
|
|
output = Path(temporary)
|
|
run_inference(command(executable, audio, output, cfg), cfg['whisperx_timeout'])
|
|
try:
|
|
data = json.loads((output / f'{audio.stem}.json').read_text(encoding='utf-8'))
|
|
segments = normalize_segments(data)
|
|
except (OSError, ValueError, TypeError) as exc:
|
|
raise SystemExit(f'WhisperX output missing or malformed ({type(exc).__name__}).') from None
|
|
# 3.8.6 overwrites JSON language with alignment language even with --no_align.
|
|
if segments.no_speech:
|
|
print('[watch] WhisperX detected no speech.', file=sys.stderr)
|
|
return segments
|