- Implement donate page and migrate API fetching to react-query hooks - Add setup wizard for batch job management and voice clip editing - Refactor setup router into package (wizard, models, download sub-modules) - Fix 9 CI test failures from setup router refactor - Fix cross-device link error in prefs.py atomic writes - Fix event loop mismatch in export test fixtures - Modernize README with architecture diagram and 13 app screenshots - Defer per-segment disk writes in dub_generate for ~6s faster dubs - Extract 45 inline styles from Launchpad, KeyboardCheatsheet, DubSegmentRow - Add playwright dev dep and screenshot capture script
1019 lines
37 KiB
Plaintext
1019 lines
37 KiB
Plaintext
"""First-run setup endpoints — model presence + live download progress.
|
||
|
||
`GET /setup/status` reports whether the primary model weights are cached on
|
||
disk + how much disk space remains. The frontend uses this on boot to decide
|
||
whether to show a setup wizard or the main UI.
|
||
|
||
`GET /setup/download-stream` is SSE that forwards every tqdm update emitted
|
||
by `huggingface_hub` through the monkey-patch in `utils/hf_progress`. The
|
||
frontend subscribes once and renders per-file progress bars until the wizard
|
||
completes.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import json
|
||
import logging
|
||
import os
|
||
import platform as _platform
|
||
import shutil
|
||
import sys
|
||
from typing import Optional
|
||
|
||
from fastapi import APIRouter, HTTPException
|
||
from fastapi.responses import StreamingResponse
|
||
from pydantic import BaseModel
|
||
|
||
from utils import hf_progress
|
||
|
||
logger = logging.getLogger("omnivoice.setup")
|
||
router = APIRouter()
|
||
|
||
# Minimum free disk space before we'd even attempt a full model download.
|
||
# Rough budget: ~6 GB for OmniVoice + Whisper-large-v3 + scratch; leave 4 GB
|
||
# of headroom so the machine isn't pinned on disk after install.
|
||
MIN_FREE_GB = 10
|
||
|
||
# Where HuggingFace caches downloads by default. If the user has overridden
|
||
# via HF_HOME or HUGGINGFACE_HUB_CACHE, we honour it — nothing to move.
|
||
def _hf_cache_dir() -> str:
|
||
return (
|
||
os.environ.get("HF_HUB_CACHE")
|
||
or os.environ.get("HUGGINGFACE_HUB_CACHE")
|
||
or os.environ.get("HF_HOME")
|
||
or os.path.expanduser("~/.cache/huggingface")
|
||
)
|
||
|
||
|
||
def _disk_free_gb(path: str) -> float:
|
||
try:
|
||
return shutil.disk_usage(path).free / (1024 ** 3)
|
||
except Exception:
|
||
return 0.0
|
||
|
||
|
||
# Every model the app knows about. `required=True` means the app doesn't
|
||
# function end-to-end without it (wizard blocks on these). `required=False`
|
||
# models are optional — ship with them uninstalled, user opts in from
|
||
# Settings > Models.
|
||
KNOWN_MODELS = [
|
||
{
|
||
"repo_id": "k2-fsa/OmniVoice",
|
||
"label": "OmniVoice TTS (600+ languages, zero-shot)",
|
||
"role": "TTS",
|
||
"size_gb": 2.4,
|
||
"required": True,
|
||
},
|
||
{
|
||
# Cross-platform default ASR. CTranslate2-converted whisper-large-v3,
|
||
# loads via faster-whisper (asr_backend.py:FasterWhisperBackend).
|
||
# Works on Linux/Windows/mac-Intel/mac-ARM with no mlx dependency.
|
||
"repo_id": "Systran/faster-whisper-large-v3",
|
||
"label": "Whisper large-v3 (faster-whisper — default, cross-platform)",
|
||
"role": "ASR",
|
||
"size_gb": 2.9,
|
||
"required": True,
|
||
},
|
||
{
|
||
"repo_id": "mlx-community/whisper-large-v3-mlx",
|
||
"label": "Whisper large-v3 (MLX — optional mac-ARM speedup)",
|
||
"role": "ASR",
|
||
"size_gb": 3.0,
|
||
"required": False,
|
||
"platforms": ["darwin-arm64"],
|
||
},
|
||
{
|
||
"repo_id": "openai/whisper-large-v3",
|
||
"label": "Whisper large-v3 (PyTorch — last-resort fallback)",
|
||
"role": "ASR",
|
||
"size_gb": 3.1,
|
||
"required": False,
|
||
"platforms": ["cuda"],
|
||
},
|
||
{
|
||
"repo_id": "mlx-community/whisper-tiny-mlx",
|
||
"label": "Whisper tiny (MLX ASR — fast fallback)",
|
||
"role": "ASR",
|
||
"size_gb": 0.08,
|
||
"required": False,
|
||
"platforms": ["darwin-arm64"],
|
||
},
|
||
{
|
||
"repo_id": "pyannote/speaker-diarization-3.1",
|
||
"label": "pyannote speaker diarisation (multi-speaker videos)",
|
||
"role": "Diarisation",
|
||
"size_gb": 0.8,
|
||
"required": False,
|
||
"note": "Needs an HF_TOKEN with license accepted.",
|
||
},
|
||
{
|
||
"repo_id": "OpenMOSS-Team/MOSS-TTS-Nano-100M",
|
||
"label": "MOSS-TTS-Nano 100M (20 langs, CPU-realtime)",
|
||
"role": "TTS",
|
||
"size_gb": 0.4,
|
||
"required": False,
|
||
},
|
||
{
|
||
# Lightweight English "Turbo" TTS. Optional — the wizard doesn't
|
||
# auto-download this; users opt in from Settings → Models when they
|
||
# want fast English narration without voice cloning.
|
||
"repo_id": "KittenML/kitten-tts-mini-0.8",
|
||
"label": "KittenTTS (English, 8 preset voices, CPU realtime)",
|
||
"role": "TTS",
|
||
"size_gb": 0.08,
|
||
"required": False,
|
||
},
|
||
# ── mlx-audio engines (mac-ARM only; opt-in from Settings → Models) ──
|
||
# These come through backend.services.tts_backend:MLXAudioBackend. The
|
||
# backend is only available on Apple Silicon; non-mac users never see
|
||
# these download buttons as active because the backend is unavailable.
|
||
{
|
||
"repo_id": "mlx-community/Kokoro-82M-bf16",
|
||
"label": "Kokoro 82M (8 langs, small, mlx-audio default)",
|
||
"role": "TTS",
|
||
"size_gb": 0.15,
|
||
"required": False,
|
||
"note": "Apple Silicon only — via mlx-audio backend.",
|
||
"platforms": ["darwin-arm64"],
|
||
},
|
||
{
|
||
"repo_id": "mlx-community/csm-1b-8bit",
|
||
"label": "CSM 1B (voice cloning, mlx-audio)",
|
||
"role": "TTS",
|
||
"size_gb": 1.1,
|
||
"required": False,
|
||
"note": "Apple Silicon only — via mlx-audio backend.",
|
||
"platforms": ["darwin-arm64"],
|
||
},
|
||
{
|
||
"repo_id": "mlx-community/Qwen3-TTS-12Hz-1.7B-VoiceDesign-4bit",
|
||
"label": "Qwen3-TTS 1.7B 4bit (voice design, mlx-audio)",
|
||
"role": "TTS",
|
||
"size_gb": 1.4,
|
||
"required": False,
|
||
"note": "Apple Silicon only — via mlx-audio backend.",
|
||
"platforms": ["darwin-arm64"],
|
||
},
|
||
{
|
||
"repo_id": "mlx-community/Dia-1.6B",
|
||
"label": "Dia 1.6B (expressive, mlx-audio)",
|
||
"role": "TTS",
|
||
"size_gb": 3.2,
|
||
"required": False,
|
||
"note": "Apple Silicon only — via mlx-audio backend.",
|
||
"platforms": ["darwin-arm64"],
|
||
},
|
||
{
|
||
"repo_id": "mlx-community/Llama-OuteTTS-1.0-1B-4bit",
|
||
"label": "Llama-OuteTTS 1.0 1B 4bit (voice clone, mlx-audio)",
|
||
"role": "TTS",
|
||
"size_gb": 0.8,
|
||
"required": False,
|
||
"note": "Apple Silicon only — via mlx-audio backend.",
|
||
"platforms": ["darwin-arm64"],
|
||
},
|
||
{
|
||
"repo_id": "mlx-community/Chatterbox-TTS-4bit",
|
||
"label": "Chatterbox TTS 4bit (mlx-audio)",
|
||
"role": "TTS",
|
||
"size_gb": 0.5,
|
||
"required": False,
|
||
"note": "Apple Silicon only — via mlx-audio backend.",
|
||
"platforms": ["darwin-arm64"],
|
||
},
|
||
{
|
||
"repo_id": "mlx-community/MeloTTS-English-v3-MLX",
|
||
"label": "MeloTTS English v3 (mlx-audio)",
|
||
"role": "TTS",
|
||
"size_gb": 0.2,
|
||
"required": False,
|
||
"note": "Apple Silicon only — via mlx-audio backend.",
|
||
"platforms": ["darwin-arm64"],
|
||
},
|
||
]
|
||
# Back-compat tuple view for code that expects (repo_id, label) pairs.
|
||
REQUIRED_MODELS = [(m["repo_id"], m["label"]) for m in KNOWN_MODELS if m["required"]]
|
||
|
||
|
||
def _current_platform_tags() -> list[str]:
|
||
"""Return platform tags that the current host supports.
|
||
|
||
Models declare a `platforms` list (e.g. ["darwin-arm64", "cuda"]). A model
|
||
is supported if its list intersects with the host's tags, or if the model
|
||
has no `platforms` key (= cross-platform)."""
|
||
tags = [sys.platform] # "linux", "darwin", "win32"
|
||
arch = _platform.machine()
|
||
tags.append(f"{sys.platform}-{arch}") # "darwin-arm64", "linux-x86_64"
|
||
try:
|
||
import torch
|
||
if torch.cuda.is_available():
|
||
tags.append("cuda")
|
||
except Exception:
|
||
pass
|
||
return tags
|
||
|
||
|
||
def _model_supported(model: dict) -> bool:
|
||
"""Check if a model is supported on the current platform."""
|
||
plats = model.get("platforms")
|
||
if not plats:
|
||
return True # no restriction → cross-platform
|
||
return bool(set(plats) & set(_current_platform_tags()))
|
||
|
||
|
||
def _is_cached(repo_id: str) -> bool:
|
||
"""Best-effort check: does HF have this repo in its cache on disk?
|
||
We don't validate the specific file set — presence of the repo dir is
|
||
close enough for a first-run gate."""
|
||
try:
|
||
from huggingface_hub import scan_cache_dir
|
||
info = scan_cache_dir()
|
||
for entry in info.repos:
|
||
if entry.repo_id == repo_id and entry.size_on_disk > 0:
|
||
return True
|
||
return False
|
||
except Exception as e:
|
||
logger.debug("scan_cache_dir failed: %s", e)
|
||
# Pessimistic: if we can't tell, report missing so the wizard appears
|
||
# and the user sees progress instead of a silent hang.
|
||
return False
|
||
|
||
|
||
@router.get("/setup/status")
|
||
def setup_status():
|
||
"""Snapshot the setup state so the client can pick its boot screen.
|
||
|
||
Returns everything the wizard needs to decide: missing model list, disk
|
||
headroom, HF cache path (for the user's information + "clear cache" ops).
|
||
"""
|
||
missing = [
|
||
{"repo_id": rid, "label": label}
|
||
for (rid, label) in REQUIRED_MODELS
|
||
if not _is_cached(rid)
|
||
]
|
||
cache = _hf_cache_dir()
|
||
free_gb = _disk_free_gb(cache)
|
||
return {
|
||
"models_ready": len(missing) == 0,
|
||
"missing": missing,
|
||
"hf_cache_dir": cache,
|
||
"disk_free_gb": round(free_gb, 2),
|
||
"min_free_gb": MIN_FREE_GB,
|
||
"enough_disk": free_gb >= MIN_FREE_GB,
|
||
}
|
||
|
||
|
||
@router.get("/setup/download-stream")
|
||
async def setup_download_stream():
|
||
"""SSE: forward every HuggingFace download tqdm update as a JSON event.
|
||
|
||
The client connects on mount, then kicks a separate `POST /setup/download`
|
||
(or invokes a normal ASR/TTS call that triggers the download). This
|
||
endpoint stays open until the client closes it.
|
||
"""
|
||
# Buffered queue so fast-emitting tqdm updates don't drop events on slow
|
||
# clients. Bounded so a stuck consumer can't grow memory indefinitely.
|
||
queue: asyncio.Queue = asyncio.Queue(maxsize=512)
|
||
loop = asyncio.get_event_loop()
|
||
|
||
def listener(event):
|
||
# tqdm lives on a background thread (hf's downloader). We need to
|
||
# marshal events onto the FastAPI event loop before enqueueing.
|
||
try:
|
||
loop.call_soon_threadsafe(_safe_put, queue, event)
|
||
except RuntimeError:
|
||
# Loop closed between events — client has gone away, safe to drop.
|
||
pass
|
||
|
||
listener_id = hf_progress.register_listener(listener)
|
||
|
||
async def gen():
|
||
try:
|
||
while True:
|
||
try:
|
||
event = await asyncio.wait_for(queue.get(), timeout=30.0)
|
||
except asyncio.TimeoutError:
|
||
# Heartbeat every 30 s so intermediaries don't time out.
|
||
yield ": keepalive\n\n"
|
||
continue
|
||
yield f"data: {json.dumps(event)}\n\n"
|
||
finally:
|
||
hf_progress.unregister_listener(listener_id)
|
||
|
||
return StreamingResponse(
|
||
gen(),
|
||
media_type="text/event-stream",
|
||
headers={
|
||
"Cache-Control": "no-cache, no-transform",
|
||
"X-Accel-Buffering": "no",
|
||
},
|
||
)
|
||
|
||
|
||
def _safe_put(queue: asyncio.Queue, event) -> None:
|
||
"""Non-blocking enqueue — drop oldest on overflow rather than block the
|
||
tqdm thread."""
|
||
try:
|
||
queue.put_nowait(event)
|
||
except asyncio.QueueFull:
|
||
try:
|
||
queue.get_nowait()
|
||
queue.put_nowait(event)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
@router.get("/models")
|
||
def list_models():
|
||
"""Catalogue every known model + its on-disk install state.
|
||
|
||
The frontend Models tab reads this to draw install/delete buttons. We
|
||
don't walk disk for every model — instead `scan_cache_dir()` returns
|
||
*everything* HF has cached, and we look up each known repo in that map.
|
||
One os-walk regardless of model count.
|
||
"""
|
||
cached_by_repo: dict[str, dict] = {}
|
||
try:
|
||
from huggingface_hub import scan_cache_dir
|
||
info = scan_cache_dir()
|
||
for entry in info.repos:
|
||
cached_by_repo[entry.repo_id] = {
|
||
"size_on_disk": entry.size_on_disk,
|
||
"last_accessed": entry.last_accessed,
|
||
"nb_files": entry.nb_files,
|
||
}
|
||
except Exception as e:
|
||
logger.warning("scan_cache_dir failed: %s", e)
|
||
|
||
out = []
|
||
for m in KNOWN_MODELS:
|
||
cached = cached_by_repo.get(m["repo_id"])
|
||
out.append({
|
||
**m,
|
||
"installed": cached is not None and cached["size_on_disk"] > 0,
|
||
"size_on_disk_bytes": cached["size_on_disk"] if cached else 0,
|
||
"nb_files": cached["nb_files"] if cached else 0,
|
||
"supported": _model_supported(m),
|
||
})
|
||
return {
|
||
"models": out,
|
||
"total_installed_bytes": sum(m["size_on_disk_bytes"] for m in out),
|
||
"hf_cache_dir": _hf_cache_dir(),
|
||
"platform_tags": _current_platform_tags(),
|
||
}
|
||
|
||
|
||
class InstallModelRequest(BaseModel):
|
||
repo_id: str
|
||
|
||
|
||
@router.post("/models/install")
|
||
async def install_model(req: InstallModelRequest):
|
||
"""Download one HF repo snapshot; progress goes through the shared
|
||
`/setup/download-stream` SSE feed. Returns immediately so the UI can
|
||
start listening to the stream.
|
||
|
||
Matching by repo_id only — no version pinning today. HF's default-branch
|
||
"main" / "refs/heads/main" is what snapshot_download picks."""
|
||
if req.repo_id not in [m["repo_id"] for m in KNOWN_MODELS]:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=(
|
||
f"Unknown model: {req.repo_id!r}. Known: "
|
||
+ ", ".join(m["repo_id"] for m in KNOWN_MODELS)
|
||
),
|
||
)
|
||
loop = asyncio.get_event_loop()
|
||
|
||
def _do():
|
||
token = hf_progress.current_repo_id.set(req.repo_id)
|
||
hf_progress.emit({
|
||
"repo_id": req.repo_id,
|
||
"filename": req.repo_id,
|
||
"downloaded": 0, "total": 0, "pct": 0.0,
|
||
"phase": "install_start",
|
||
})
|
||
try:
|
||
from huggingface_hub import snapshot_download
|
||
from huggingface_hub.utils import (
|
||
HfHubHTTPError,
|
||
LocalEntryNotFoundError,
|
||
)
|
||
logger.info("model install starting: %s", req.repo_id)
|
||
# On Windows, NTFS symlinks require Developer Mode or Admin —
|
||
# most first-run installs don't have either. The global env var
|
||
# HF_HUB_DISABLE_SYMLINKS=1 (set in main.py) covers implicit
|
||
# downloads, but we also pass the kwarg here as a belt-and-braces
|
||
# guard for older huggingface_hub versions that don't read the var.
|
||
dl_kwargs: dict = {"repo_id": req.repo_id}
|
||
if sys.platform == "win32":
|
||
dl_kwargs["local_dir_use_symlinks"] = False
|
||
|
||
# Resume on transient network failures. snapshot_download writes
|
||
# `.incomplete` shards into the HF cache and resumes from them on
|
||
# the next call automatically — re-invoking with the same args
|
||
# picks up where it left off, so each retry only re-fetches what's
|
||
# missing.
|
||
_max_attempts = 5
|
||
_attempt = 0
|
||
while True:
|
||
_attempt += 1
|
||
try:
|
||
snapshot_download(**dl_kwargs)
|
||
break
|
||
except (HfHubHTTPError, LocalEntryNotFoundError, OSError) as net_err:
|
||
if _attempt >= _max_attempts:
|
||
raise
|
||
_backoff = min(30, 2 ** _attempt)
|
||
logger.warning(
|
||
"model install %s: attempt %d/%d failed (%s); retry in %ds",
|
||
req.repo_id, _attempt, _max_attempts, net_err, _backoff,
|
||
)
|
||
hf_progress.emit({
|
||
"repo_id": req.repo_id,
|
||
"filename": req.repo_id,
|
||
"downloaded": 0, "total": 0, "pct": 0.0,
|
||
"phase": "install_retry",
|
||
"attempt": _attempt,
|
||
"error": str(net_err),
|
||
})
|
||
import time as _t
|
||
_t.sleep(_backoff)
|
||
logger.info("model install done: %s", req.repo_id)
|
||
hf_progress.emit({
|
||
"repo_id": req.repo_id,
|
||
"filename": req.repo_id,
|
||
"downloaded": 0, "total": 0, "pct": 1.0,
|
||
"phase": "install_done",
|
||
})
|
||
except Exception as e:
|
||
logger.warning("model install failed for %s: %s", req.repo_id, e)
|
||
hf_progress.emit({
|
||
"repo_id": req.repo_id,
|
||
"filename": req.repo_id,
|
||
"downloaded": 0, "total": 0, "pct": 0.0,
|
||
"phase": "install_error",
|
||
"error": str(e),
|
||
})
|
||
finally:
|
||
hf_progress.current_repo_id.reset(token)
|
||
|
||
# Non-blocking — client polls /models or listens on the SSE.
|
||
loop.create_task(asyncio.to_thread(_do))
|
||
return {"status": "install_started", "repo_id": req.repo_id}
|
||
|
||
|
||
@router.delete("/models/{repo_id:path}")
|
||
def delete_model(repo_id: str):
|
||
"""Remove every cached revision of a repo from the HF cache. Frees disk
|
||
+ lets the user re-install a fresh copy via POST /models/install."""
|
||
hf_progress.emit({
|
||
"repo_id": repo_id,
|
||
"filename": repo_id,
|
||
"downloaded": 0, "total": 0, "pct": 0.0,
|
||
"phase": "delete_start",
|
||
})
|
||
try:
|
||
from huggingface_hub import scan_cache_dir
|
||
info = scan_cache_dir()
|
||
commits = [
|
||
rev.commit_hash
|
||
for entry in info.repos if entry.repo_id == repo_id
|
||
for rev in entry.revisions
|
||
]
|
||
if not commits:
|
||
raise HTTPException(
|
||
status_code=404,
|
||
detail=(
|
||
f"Model {repo_id!r} isn't installed. Nothing to delete — "
|
||
"run POST /models/install first if you want a fresh download."
|
||
),
|
||
)
|
||
strategy = info.delete_revisions(*commits)
|
||
strategy.execute()
|
||
hf_progress.emit({
|
||
"repo_id": repo_id,
|
||
"filename": repo_id,
|
||
"downloaded": 0, "total": 0, "pct": 1.0,
|
||
"phase": "delete_done",
|
||
"freed_bytes": strategy.expected_freed_size,
|
||
})
|
||
return {
|
||
"deleted": True,
|
||
"repo_id": repo_id,
|
||
"freed_bytes": strategy.expected_freed_size,
|
||
}
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
raise HTTPException(
|
||
status_code=500,
|
||
detail=(
|
||
f"Could not delete {repo_id}: {e}. "
|
||
"Close any process using the model (e.g. the app's main dub job) and retry."
|
||
),
|
||
)
|
||
|
||
|
||
# ── Pre-flight system check ───────────────────────────────────────────────
|
||
#
|
||
# Single endpoint that probes every runtime requirement we care about
|
||
# (OS, RAM, disk, ffmpeg, GPU driver, network) so the wizard can show a
|
||
# pass/warn/fail list instead of silently falling back to CPU when a user's
|
||
# GPU driver is stale or ROCm isn't configured.
|
||
#
|
||
# Each check returns {id, label, status, detail, fix?}. status is one of
|
||
# "pass" / "warn" / "fail". The wizard blocks step advancement on any fail
|
||
# but lets warns through.
|
||
|
||
# Minimum NVIDIA driver for the cu128 torch wheels we ship. Users below this
|
||
# get CUDA loaded but kernel launches fail with "no kernel image" errors —
|
||
# catch it here with a clear message instead.
|
||
_MIN_NVIDIA_DRIVER = 555
|
||
|
||
# RAM thresholds (GB). Below _RAM_FAIL_GB the app will OOM on first dub.
|
||
_RAM_FAIL_GB = 8
|
||
_RAM_WARN_GB = 12
|
||
|
||
|
||
def _run_cmd(args: list[str], timeout: float = 2.0) -> tuple[int, str]:
|
||
"""Run a subprocess synchronously with a short timeout. Returns (rc, stdout).
|
||
Never raises — missing binary or timeout returns (-1, '')."""
|
||
import subprocess
|
||
try:
|
||
out = subprocess.run(
|
||
args, capture_output=True, text=True, timeout=timeout, check=False,
|
||
)
|
||
return out.returncode, out.stdout
|
||
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
|
||
return -1, ""
|
||
|
||
|
||
def _detect_gpu() -> dict:
|
||
"""Best-effort detection of GPU vendor + driver + compute backend.
|
||
|
||
Returns: {vendor, driver, device_name, backend, available, notes}
|
||
vendor: 'nvidia' | 'amd' | 'apple' | 'intel' | 'none'
|
||
backend: 'cuda' | 'rocm' | 'mps' | 'cpu'
|
||
available: bool — whether torch can actually use it
|
||
"""
|
||
info = {
|
||
"vendor": "none", "driver": None, "device_name": None,
|
||
"backend": "cpu", "available": False, "notes": [],
|
||
}
|
||
|
||
# Apple Silicon → MPS (Metal). No external driver to probe.
|
||
if sys.platform == "darwin" and _platform.machine() == "arm64":
|
||
info["vendor"] = "apple"
|
||
info["backend"] = "mps"
|
||
info["device_name"] = "Apple Silicon GPU (Metal)"
|
||
try:
|
||
import torch
|
||
info["available"] = bool(torch.backends.mps.is_available())
|
||
except Exception:
|
||
info["available"] = False
|
||
return info
|
||
|
||
# NVIDIA — nvidia-smi is the authoritative source on both Linux + Windows.
|
||
rc, out = _run_cmd([
|
||
"nvidia-smi",
|
||
"--query-gpu=driver_version,name",
|
||
"--format=csv,noheader",
|
||
])
|
||
if rc == 0 and out.strip():
|
||
line = out.strip().splitlines()[0]
|
||
parts = [p.strip() for p in line.split(",")]
|
||
driver = parts[0] if parts else None
|
||
name = parts[1] if len(parts) > 1 else None
|
||
info.update({"vendor": "nvidia", "driver": driver, "device_name": name})
|
||
try:
|
||
import torch
|
||
info["available"] = bool(torch.cuda.is_available())
|
||
info["backend"] = "cuda" if info["available"] else "cpu"
|
||
except Exception:
|
||
pass
|
||
# Driver sanity — compare major version against bundled cu128 minimum.
|
||
try:
|
||
major = int((driver or "0").split(".")[0])
|
||
if major < _MIN_NVIDIA_DRIVER:
|
||
info["notes"].append(
|
||
f"NVIDIA driver {driver} below {_MIN_NVIDIA_DRIVER} required "
|
||
f"by the bundled CUDA 12.8 runtime — GPU will fail to launch "
|
||
f"kernels. Update drivers before dubbing."
|
||
)
|
||
info["available"] = False
|
||
except Exception:
|
||
pass
|
||
return info
|
||
|
||
# AMD — rocm-smi ships with ROCm on Linux.
|
||
rc, out = _run_cmd(["rocm-smi", "--showproductname"])
|
||
if rc == 0 and out.strip():
|
||
info["vendor"] = "amd"
|
||
info["device_name"] = out.strip().splitlines()[0][:120]
|
||
# Check if torch was built with ROCm support. The CUDA-flavoured
|
||
# wheels we ship don't include ROCm — users need `uv sync` against
|
||
# the pytorch-rocm index manually.
|
||
try:
|
||
import torch
|
||
has_hip = getattr(torch.version, "hip", None) is not None
|
||
if has_hip and torch.cuda.is_available():
|
||
info["backend"] = "rocm"
|
||
info["available"] = True
|
||
else:
|
||
info["backend"] = "cpu"
|
||
info["notes"].append(
|
||
"AMD GPU detected but torch was installed with CUDA wheels. "
|
||
"Re-run `uv sync --index-url https://download.pytorch.org/whl/rocm6.1` "
|
||
"to enable ROCm acceleration."
|
||
)
|
||
except Exception:
|
||
info["notes"].append("AMD GPU detected but torch not importable.")
|
||
return info
|
||
|
||
# No discrete GPU detected. If torch still reports cuda.is_available (WSL
|
||
# passthrough, rare), honour it; otherwise fall back to CPU.
|
||
try:
|
||
import torch
|
||
if torch.cuda.is_available():
|
||
info["vendor"] = "unknown"
|
||
info["backend"] = "cuda"
|
||
info["available"] = True
|
||
info["notes"].append(
|
||
"torch.cuda.is_available() is True but no nvidia-smi/rocm-smi "
|
||
"found — running through WSL or virtual GPU?"
|
||
)
|
||
except Exception:
|
||
pass
|
||
return info
|
||
|
||
|
||
def _probe_network(host: str = "huggingface.co", timeout: float = 2.0) -> bool:
|
||
"""Tiny TCP connect test — avoids hitting the CDN + no SSL handshake."""
|
||
import socket
|
||
try:
|
||
with socket.create_connection((host, 443), timeout=timeout):
|
||
return True
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def _ram_gb() -> float:
|
||
try:
|
||
import psutil
|
||
return psutil.virtual_memory().total / (1024 ** 3)
|
||
except Exception:
|
||
return 0.0
|
||
|
||
|
||
@router.get("/setup/preflight")
|
||
def preflight():
|
||
"""One-shot system health check. The wizard renders these as a pass/warn/
|
||
fail list before letting the user proceed to model install.
|
||
|
||
Checks: OS + arch, Python runtime, RAM, disk, ffmpeg, ffprobe, GPU vendor
|
||
+ driver, torch compute backend, network reach to huggingface, HF cache
|
||
writable. Each entry is safe to ignore individually — the wizard treats
|
||
'warn' as pass-through and only blocks on 'fail'.
|
||
"""
|
||
import shutil as _shutil
|
||
|
||
checks: list[dict] = []
|
||
|
||
# ── OS + arch (info-only)
|
||
arch = _platform.machine()
|
||
os_ver = _platform.platform(terse=True)
|
||
checks.append({
|
||
"id": "os", "label": "Operating system", "status": "pass",
|
||
"detail": f"{os_ver} ({arch})", "fix": None,
|
||
})
|
||
|
||
# ── Python runtime
|
||
checks.append({
|
||
"id": "python", "label": "Python runtime", "status": "pass",
|
||
"detail": f"Python {sys.version.split()[0]}", "fix": None,
|
||
})
|
||
|
||
# ── RAM
|
||
ram = _ram_gb()
|
||
if ram == 0:
|
||
ram_status, ram_detail, ram_fix = (
|
||
"warn", "Could not detect system RAM.",
|
||
"Install psutil in the backend environment or ignore this warning.",
|
||
)
|
||
elif ram < _RAM_FAIL_GB:
|
||
ram_status, ram_detail, ram_fix = (
|
||
"fail", f"{ram:.1f} GB total (need ≥ {_RAM_FAIL_GB} GB)",
|
||
"The app will OOM on first dub. Close other apps or upgrade RAM.",
|
||
)
|
||
elif ram < _RAM_WARN_GB:
|
||
ram_status, ram_detail, ram_fix = (
|
||
"warn", f"{ram:.1f} GB total ({_RAM_WARN_GB}+ GB recommended)",
|
||
"Long videos may hit swap. Keep other apps closed during dubbing.",
|
||
)
|
||
else:
|
||
ram_status, ram_detail, ram_fix = ("pass", f"{ram:.1f} GB total", None)
|
||
checks.append({
|
||
"id": "ram", "label": "System RAM", "status": ram_status,
|
||
"detail": ram_detail, "fix": ram_fix,
|
||
})
|
||
|
||
# ── Disk free (HF cache partition)
|
||
cache = _hf_cache_dir()
|
||
free = _disk_free_gb(cache)
|
||
if free < MIN_FREE_GB:
|
||
disk = {
|
||
"status": "fail",
|
||
"detail": f"{free:.1f} GB free at {cache} (need ≥ {MIN_FREE_GB} GB)",
|
||
"fix": f"Free up disk space or set HF_HOME to a larger partition.",
|
||
}
|
||
else:
|
||
disk = {"status": "pass", "detail": f"{free:.1f} GB free at {cache}", "fix": None}
|
||
checks.append({"id": "disk", **{"label": "Disk space", **disk}})
|
||
|
||
# ── HF cache writable
|
||
try:
|
||
os.makedirs(cache, exist_ok=True)
|
||
writable = os.access(cache, os.W_OK)
|
||
except Exception:
|
||
writable = False
|
||
checks.append({
|
||
"id": "hf_cache_writable", "label": "HuggingFace cache writable",
|
||
"status": "pass" if writable else "fail",
|
||
"detail": cache,
|
||
"fix": None if writable else
|
||
f"Fix write permissions on {cache} or point HF_HOME elsewhere.",
|
||
})
|
||
|
||
# ── FFmpeg (required)
|
||
ffmpeg_path = None
|
||
try:
|
||
from services.ffmpeg_utils import find_ffmpeg
|
||
ffmpeg_path = find_ffmpeg()
|
||
except Exception as e:
|
||
checks.append({
|
||
"id": "ffmpeg", "label": "FFmpeg", "status": "fail",
|
||
"detail": str(e)[:200],
|
||
"fix": "Install ffmpeg via your package manager "
|
||
"(brew install ffmpeg / apt install ffmpeg / choco install ffmpeg).",
|
||
})
|
||
else:
|
||
checks.append({
|
||
"id": "ffmpeg", "label": "FFmpeg", "status": "pass",
|
||
"detail": ffmpeg_path, "fix": None,
|
||
})
|
||
|
||
# ── FFprobe (warn — some endpoints need it)
|
||
ffprobe_path = None
|
||
if ffmpeg_path:
|
||
candidate = ffmpeg_path.replace("ffmpeg", "ffprobe")
|
||
if os.path.exists(candidate):
|
||
ffprobe_path = candidate
|
||
else:
|
||
# System PATH fallback
|
||
system_probe = _shutil.which("ffprobe")
|
||
if system_probe:
|
||
ffprobe_path = system_probe
|
||
if ffprobe_path:
|
||
checks.append({
|
||
"id": "ffprobe", "label": "FFprobe", "status": "pass",
|
||
"detail": ffprobe_path, "fix": None,
|
||
})
|
||
else:
|
||
checks.append({
|
||
"id": "ffprobe", "label": "FFprobe", "status": "warn",
|
||
"detail": "Not bundled alongside ffmpeg.",
|
||
"fix": "File-probe endpoint (/tools/probe) will 501. "
|
||
"Install system ffmpeg (includes ffprobe) to enable it.",
|
||
})
|
||
|
||
# ── yt-dlp (warn — gallery needs it)
|
||
yt_dlp_path = _shutil.which("yt-dlp")
|
||
if yt_dlp_path:
|
||
checks.append({
|
||
"id": "yt-dlp", "label": "yt-dlp", "status": "pass",
|
||
"detail": yt_dlp_path, "fix": None,
|
||
})
|
||
else:
|
||
checks.append({
|
||
"id": "yt-dlp", "label": "yt-dlp", "status": "warn",
|
||
"detail": "Not found in system PATH.",
|
||
"fix": "YouTube clip downloads in Voice Gallery will fail. Download the standalone binary from https://github.com/yt-dlp/yt-dlp/releases and place it in your PATH.",
|
||
})
|
||
|
||
# ── GPU + compute backend
|
||
gpu = _detect_gpu()
|
||
if gpu["vendor"] == "apple" and gpu["available"]:
|
||
gpu_status, gpu_fix = "pass", None
|
||
gpu_detail = f"{gpu['device_name']} — Metal (MPS) ready"
|
||
elif gpu["vendor"] == "nvidia" and gpu["available"]:
|
||
gpu_status, gpu_fix = "pass", None
|
||
gpu_detail = f"{gpu['device_name']} (driver {gpu['driver']}) — CUDA ready"
|
||
elif gpu["vendor"] == "nvidia" and not gpu["available"]:
|
||
gpu_status = "fail"
|
||
gpu_detail = (
|
||
f"{gpu['device_name']} found but CUDA not usable "
|
||
f"(driver {gpu['driver']}). " + " ".join(gpu["notes"])
|
||
)
|
||
gpu_fix = (
|
||
f"Update NVIDIA drivers to ≥ R{_MIN_NVIDIA_DRIVER} "
|
||
"(https://www.nvidia.com/Download/index.aspx). Or run CPU-only "
|
||
"by continuing past this step — dubbing will be ~10× slower."
|
||
)
|
||
elif gpu["vendor"] == "amd":
|
||
gpu_status = "warn"
|
||
gpu_detail = (
|
||
f"{gpu['device_name']} — ROCm "
|
||
+ ("ready" if gpu["available"] else "not configured")
|
||
)
|
||
gpu_fix = (
|
||
None if gpu["available"] else
|
||
"AMD support is experimental. Re-run `uv sync --index-url "
|
||
"https://download.pytorch.org/whl/rocm6.1` to enable. App works "
|
||
"on CPU otherwise (slower)."
|
||
)
|
||
else:
|
||
gpu_status = "warn"
|
||
gpu_detail = "No compatible GPU detected — running CPU-only."
|
||
gpu_fix = (
|
||
"Dubbing will work but ~10× slower than GPU. If you have an "
|
||
"NVIDIA/AMD card, check drivers are installed."
|
||
)
|
||
checks.append({
|
||
"id": "gpu", "label": "GPU acceleration",
|
||
"status": gpu_status, "detail": gpu_detail, "fix": gpu_fix,
|
||
})
|
||
|
||
# ── Network reach to huggingface.co (required for first-run downloads)
|
||
net_ok = _probe_network()
|
||
checks.append({
|
||
"id": "network", "label": "Network (huggingface.co)",
|
||
"status": "pass" if net_ok else "fail",
|
||
"detail": "Reachable" if net_ok else "Unreachable on port 443",
|
||
"fix": None if net_ok else
|
||
"Check internet connection, VPN, or corporate firewall "
|
||
"whitelist for huggingface.co.",
|
||
})
|
||
|
||
# Aggregate
|
||
any_fail = any(c["status"] == "fail" for c in checks)
|
||
any_warn = any(c["status"] == "warn" for c in checks)
|
||
|
||
return {
|
||
"ok": not any_fail,
|
||
"has_warnings": any_warn,
|
||
"checks": checks,
|
||
"device": {
|
||
"os": sys.platform,
|
||
"arch": arch,
|
||
"gpu_vendor": gpu["vendor"],
|
||
"gpu_backend": gpu["backend"],
|
||
"gpu_available": gpu["available"],
|
||
"gpu_driver": gpu["driver"],
|
||
"gpu_device_name": gpu["device_name"],
|
||
"ram_gb": round(ram, 1),
|
||
"disk_free_gb": round(free, 1),
|
||
},
|
||
}
|
||
|
||
|
||
@router.get("/setup/recommendations")
|
||
def recommendations():
|
||
"""Return a curated model preset for the caller's device + architecture.
|
||
|
||
The Settings / first-run Models tab uses this to render a prominent
|
||
"Install recommended" card so users don't have to pick from 14 models.
|
||
Logic mirrors the engine availability matrix:
|
||
- mac-ARM gets the rich mlx-audio stack (Kokoro) + MLX-Whisper speedup
|
||
- mac-Intel + Linux + Windows get the cross-platform subset
|
||
- CUDA hosts optionally get the pytorch-whisper fallback baked in
|
||
"""
|
||
is_mac_arm = sys.platform == "darwin" and _platform.machine() == "arm64"
|
||
is_mac_intel = sys.platform == "darwin" and _platform.machine() == "x86_64"
|
||
is_linux = sys.platform.startswith("linux")
|
||
is_windows = sys.platform == "win32"
|
||
|
||
has_cuda = False
|
||
try:
|
||
import torch
|
||
has_cuda = bool(torch.cuda.is_available())
|
||
except Exception:
|
||
pass
|
||
|
||
# Device label — used as the card title.
|
||
if is_mac_arm:
|
||
device_label = f"Apple Silicon ({_platform.machine()})"
|
||
elif is_mac_intel:
|
||
device_label = "macOS Intel (x86_64)"
|
||
elif is_windows:
|
||
device_label = "Windows x64" + (" + CUDA" if has_cuda else "")
|
||
elif is_linux:
|
||
device_label = "Linux x64" + (" + CUDA" if has_cuda else "")
|
||
else:
|
||
device_label = f"{sys.platform} / {_platform.machine()}"
|
||
|
||
# Pick the preset for this device.
|
||
if is_mac_arm:
|
||
recommended_ids = [
|
||
"k2-fsa/OmniVoice", # required — 600+ lang zero-shot
|
||
"Systran/faster-whisper-large-v3", # required — WhisperX ASR
|
||
"mlx-community/whisper-large-v3-mlx", # optional mac speedup
|
||
"mlx-community/Kokoro-82M-bf16", # mlx-audio fast TTS
|
||
"KittenML/kitten-tts-mini-0.8", # English turbo tier
|
||
]
|
||
rationale = (
|
||
"Apple Silicon gets the full stack: OmniVoice for multilingual clone + "
|
||
"WhisperX (faster-whisper weights) for cross-platform ASR + MLX-Whisper "
|
||
"for the Apple-optimised speedup + Kokoro (mlx-audio) for fast local "
|
||
"English + KittenTTS as a CPU-realtime backup."
|
||
)
|
||
else:
|
||
recommended_ids = [
|
||
"k2-fsa/OmniVoice", # required
|
||
"Systran/faster-whisper-large-v3", # required
|
||
"KittenML/kitten-tts-mini-0.8", # English turbo — cross-platform
|
||
]
|
||
if has_cuda:
|
||
# A CUDA box can actually run pytorch-whisper well; ship it as a
|
||
# fallback so the user can pin it in Settings → Engines later.
|
||
recommended_ids.append("openai/whisper-large-v3")
|
||
rationale = (
|
||
"Cross-platform stack + pytorch-whisper as a CUDA-accelerated "
|
||
"ASR fallback. MLX / mlx-audio are Apple-Silicon-only and don't "
|
||
"apply here."
|
||
)
|
||
else:
|
||
rationale = (
|
||
"Cross-platform stack: OmniVoice (multilingual clone) + WhisperX "
|
||
"(faster-whisper ASR) + KittenTTS (English turbo, CPU-realtime). "
|
||
"Clean install, every model runs on CPU."
|
||
)
|
||
|
||
# Cross-reference against KNOWN_MODELS so we can attach size + label to
|
||
# each recommended entry, and flag which ones are already installed.
|
||
known_by_id = {m["repo_id"]: m for m in KNOWN_MODELS}
|
||
cached_ids: set[str] = set()
|
||
try:
|
||
from huggingface_hub import scan_cache_dir
|
||
info = scan_cache_dir()
|
||
cached_ids = {
|
||
entry.repo_id for entry in info.repos if entry.size_on_disk > 0
|
||
}
|
||
except Exception:
|
||
pass
|
||
|
||
entries = []
|
||
for rid in recommended_ids:
|
||
meta = known_by_id.get(rid, {})
|
||
entries.append({
|
||
"repo_id": rid,
|
||
"label": meta.get("label", rid),
|
||
"role": meta.get("role", ""),
|
||
"size_gb": meta.get("size_gb", 0),
|
||
"required": bool(meta.get("required", False)),
|
||
"note": meta.get("note"),
|
||
"installed": rid in cached_ids,
|
||
})
|
||
|
||
# Headline number for the "Install recommended (~X GB)" CTA — only
|
||
# count models not yet on disk so users with a warm cache see a low
|
||
# remaining number instead of the full bundle size.
|
||
to_download_gb = sum(e["size_gb"] for e in entries if not e["installed"])
|
||
all_installed = all(e["installed"] for e in entries)
|
||
|
||
return {
|
||
"device": {
|
||
"os": sys.platform,
|
||
"arch": _platform.machine(),
|
||
"is_mac_arm": is_mac_arm,
|
||
"is_mac_intel": is_mac_intel,
|
||
"is_linux": is_linux,
|
||
"is_windows": is_windows,
|
||
"has_cuda": has_cuda,
|
||
"label": device_label,
|
||
},
|
||
"rationale": rationale,
|
||
"models": entries,
|
||
"download_gb_remaining": round(to_download_gb, 2),
|
||
"total_gb": round(sum(e["size_gb"] for e in entries), 2),
|
||
"all_installed": all_installed,
|
||
}
|
||
|
||
|
||
@router.post("/setup/warmup")
|
||
async def setup_warmup():
|
||
"""Trigger a model load in the background so the first dub doesn't pay
|
||
the cold-start tax. Progress flows through the SSE stream."""
|
||
loop = asyncio.get_event_loop()
|
||
|
||
async def _do_warmup():
|
||
try:
|
||
from services.model_manager import get_model
|
||
await get_model()
|
||
except Exception as e:
|
||
logger.warning("setup/warmup: model load failed: %s", e)
|
||
|
||
# Don't await — let it run in the background; client watches SSE.
|
||
loop.create_task(_do_warmup())
|
||
return {"status": "warmup_started"}
|