- 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
318 lines
11 KiB
Python
318 lines
11 KiB
Python
"""Model catalog, platform detection, and cache introspection.
|
|
|
|
Extracted from the monolithic ``setup.py`` to keep concerns separate:
|
|
- ``KNOWN_MODELS`` loaded from ``config/models.yaml``
|
|
- ``GET /models`` endpoint (with 10 s response cache)
|
|
- ``GET /setup/recommendations`` device-aware preset endpoint
|
|
- ``ModelCatalog`` dependency for use with ``Depends()``
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import platform as _platform
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, Depends
|
|
|
|
logger = logging.getLogger("omnivoice.setup.models")
|
|
router = APIRouter()
|
|
|
|
# ── Model Catalog (loaded from YAML) ──────────────────────────────────────
|
|
|
|
_YAML_PATH = Path(__file__).resolve().parents[3] / "config" / "models.yaml"
|
|
|
|
|
|
def _load_models_from_yaml() -> list[dict]:
|
|
"""Load model catalog from config/models.yaml.
|
|
|
|
Falls back to an empty list if the file is missing or unreadable.
|
|
The YAML file is read once at import time — restart to pick up edits.
|
|
"""
|
|
try:
|
|
import yaml # PyYAML is already a transitive dep of huggingface_hub
|
|
with open(_YAML_PATH, "r", encoding="utf-8") as f:
|
|
data = yaml.safe_load(f)
|
|
models = data.get("models", [])
|
|
logger.info("Loaded %d models from %s", len(models), _YAML_PATH)
|
|
return models
|
|
except FileNotFoundError:
|
|
logger.warning("models.yaml not found at %s — using empty catalog", _YAML_PATH)
|
|
return []
|
|
except Exception as e:
|
|
logger.error("Failed to load models.yaml: %s — using empty catalog", e)
|
|
return []
|
|
|
|
|
|
KNOWN_MODELS = _load_models_from_yaml()
|
|
|
|
# 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.get("required")]
|
|
|
|
|
|
# ── Dependency Injection ───────────────────────────────────────────────────
|
|
# Use `catalog: ModelCatalog = Depends(get_model_catalog)` in endpoint params
|
|
# for testable, mockable access to the model registry.
|
|
|
|
class ModelCatalog:
|
|
"""Injectable service wrapping the model catalog + cache scanner."""
|
|
|
|
def __init__(self, models: list[dict] | None = None):
|
|
self.models = models if models is not None else KNOWN_MODELS
|
|
self._by_id = {m["repo_id"]: m for m in self.models}
|
|
self._required = [(m["repo_id"], m["label"]) for m in self.models if m.get("required")]
|
|
|
|
def get(self, repo_id: str) -> dict | None:
|
|
return self._by_id.get(repo_id)
|
|
|
|
@property
|
|
def required(self) -> list[tuple[str, str]]:
|
|
return self._required
|
|
|
|
@property
|
|
def all(self) -> list[dict]:
|
|
return self.models
|
|
|
|
def supported_on_host(self, model: dict) -> bool:
|
|
return _model_supported(model)
|
|
|
|
|
|
# Singleton — shared across all requests.
|
|
_catalog = ModelCatalog()
|
|
|
|
|
|
def get_model_catalog() -> ModelCatalog:
|
|
"""FastAPI dependency — inject with ``Depends(get_model_catalog)``."""
|
|
return _catalog
|
|
|
|
|
|
# ── Platform Detection ─────────────────────────────────────────────────────
|
|
|
|
def _current_platform_tags() -> list[str]:
|
|
"""Return platform tags that the current host supports."""
|
|
tags = [sys.platform]
|
|
arch = _platform.machine()
|
|
tags.append(f"{sys.platform}-{arch}")
|
|
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
|
|
return bool(set(plats) & set(_current_platform_tags()))
|
|
|
|
|
|
# ── HF Cache Helpers ───────────────────────────────────────────────────────
|
|
|
|
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 is_cached(repo_id: str) -> bool:
|
|
"""Best-effort check: does HF have this repo in its cache on disk?"""
|
|
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)
|
|
return False
|
|
|
|
|
|
# ── Response Cache ─────────────────────────────────────────────────────────
|
|
# Simple TTL dict cache to avoid re-scanning the HF cache directory on every
|
|
# frontend poll. Entries expire after ``_CACHE_TTL`` seconds.
|
|
|
|
_CACHE_TTL = 10.0 # seconds
|
|
_cache: dict[str, tuple[float, object]] = {}
|
|
|
|
|
|
def _cached(key: str, ttl: float = _CACHE_TTL):
|
|
"""Return cached value if still valid, else None."""
|
|
entry = _cache.get(key)
|
|
if entry and (time.monotonic() - entry[0]) < ttl:
|
|
return entry[1]
|
|
return None
|
|
|
|
|
|
def _set_cache(key: str, value: object) -> None:
|
|
_cache[key] = (time.monotonic(), value)
|
|
|
|
|
|
def invalidate_cache() -> None:
|
|
"""Called after install/delete to bust the models cache."""
|
|
_cache.clear()
|
|
|
|
|
|
# ── Endpoints ──────────────────────────────────────────────────────────────
|
|
|
|
@router.get("/models")
|
|
def list_models():
|
|
"""Catalogue every known model + its on-disk install state.
|
|
|
|
Uses a 10 s response cache to avoid repeated ``scan_cache_dir()`` disk
|
|
walks when the frontend polls.
|
|
"""
|
|
cached_response = _cached("models")
|
|
if cached_response is not None:
|
|
return cached_response
|
|
|
|
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),
|
|
})
|
|
response = {
|
|
"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(),
|
|
}
|
|
_set_cache("models", response)
|
|
return response
|
|
|
|
|
|
@router.get("/setup/recommendations")
|
|
def recommendations():
|
|
"""Return a curated model preset for the caller's device + architecture."""
|
|
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",
|
|
"Systran/faster-whisper-large-v3",
|
|
"mlx-community/whisper-large-v3-mlx",
|
|
"mlx-community/Kokoro-82M-bf16",
|
|
"KittenML/kitten-tts-mini-0.8",
|
|
]
|
|
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",
|
|
"Systran/faster-whisper-large-v3",
|
|
"KittenML/kitten-tts-mini-0.8",
|
|
]
|
|
if has_cuda:
|
|
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."
|
|
)
|
|
|
|
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,
|
|
})
|
|
|
|
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,
|
|
}
|