Files
VoiceStudio/backend/api/routers/system.py
T
debpalashandClaude Opus 4.7 994c6cf065 feat(backend): setup wizard router, translation engines, export options, client-disconnect handling
- Add setup router (backend/api/routers/setup.py) for first-run wizard:
  system checks, engine probes, model downloads with progress
- Add translation engines service with pluggable backends
- Add utils/hf_progress for HuggingFace download progress streaming
- Add PyInstaller runtime hooks (numpy compat, torch compiler disable)
- Global exception handler short-circuits h11 LocalProtocolError and
  Starlette ClientDisconnect with HTTP 499 to silence noisy stack traces
  when users scrub or cancel video mid-stream
- /dub/download-mp3 accepts bitrate query param (clamped 64–320kbps)
- Refactor ASR/TTS backends, dub pipeline, engine management
- Update backend.spec for PyInstaller packaging
- Bump pyproject version to 0.2.0; refresh uv.lock

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:49:04 +05:30

302 lines
10 KiB
Python

import os
import sys
import uuid
import psutil
import asyncio
import logging
from fastapi import APIRouter, File, UploadFile, HTTPException
from fastapi.responses import FileResponse
import torch
import shutil
from core.config import OUTPUTS_DIR, DATA_DIR, CRASH_LOG_PATH, LOG_PATH, IDLE_TIMEOUT_SECONDS
from services.model_manager import get_model_status, get_best_device
from services.ffmpeg_utils import find_ffmpeg, run_ffmpeg
router = APIRouter()
logger = logging.getLogger("omnivoice.api")
# Cache device checks at module load — they don't change at runtime
_is_mac = hasattr(torch.backends, "mps") and torch.backends.mps.is_available()
_is_cuda = torch.cuda.is_available()
# Prime psutil's internal CPU counter so the first non-blocking call returns useful data
psutil.cpu_percent(interval=None)
@router.get("/model/status")
def model_status():
"""Report model loading state for frontend warm-up indicators."""
return get_model_status()
@router.get("/system/info")
def system_info():
"""Settings page system info — model, tokens, data dir, timeout."""
return {
"data_dir": DATA_DIR,
"outputs_dir": OUTPUTS_DIR,
"crash_log_path": CRASH_LOG_PATH,
"idle_timeout_seconds": IDLE_TIMEOUT_SECONDS,
"model_checkpoint": os.environ.get("OMNIVOICE_MODEL", "k2-fsa/OmniVoice"),
"asr_model": os.environ.get("ASR_MODEL", "Systran/faster-whisper-large-v3"),
"translate_provider": os.environ.get("TRANSLATE_PROVIDER", "google"),
"has_hf_token": bool(os.environ.get("HF_TOKEN")),
"device": get_best_device(),
"python": sys.version.split()[0],
"platform": sys.platform,
}
def _tail_file(path: str, tail: int):
"""Read the last `tail` lines from `path`. Returns (lines, total)."""
with open(path, "r", encoding="utf-8", errors="replace") as f:
all_lines = f.readlines()
return all_lines[-tail:], len(all_lines)
def _tauri_log_candidates():
"""Likely paths for Tauri-side logs, most useful first.
`tauri-plugin-log` writes to `~/Library/Logs/<bundle_id>/<file_name>.log`
by default on macOS. Our bundle id is `com.debpalash.omnivoice-studio`
(see frontend/src-tauri/tauri.conf.json). lib.rs also redirects the
spawned backend's stdout/stderr to `~/Library/Logs/OmniVoice/backend.log`
which is where `print()` calls and uvicorn startup banners land.
"""
home = os.path.expanduser("~")
bid = "com.debpalash.omnivoice-studio"
if sys.platform == "darwin":
return [
os.path.join(home, "Library/Logs", bid, "tauri.log"),
os.path.join(home, "Library/Logs", bid, "OmniVoice Studio.log"),
os.path.join(home, "Library/Logs/OmniVoice/backend.log"),
os.path.join(home, "Library/Logs/OmniVoice/backend_err.log"),
]
if sys.platform.startswith("linux"):
return [
os.path.join(home, ".local/share", bid, "logs", "tauri.log"),
os.path.join(home, ".config", bid, "logs", "tauri.log"),
]
if sys.platform.startswith("win"):
appdata = os.environ.get("APPDATA", home)
return [
os.path.join(appdata, bid, "logs", "tauri.log"),
]
return []
@router.get("/system/logs")
def system_logs(tail: int = 200):
"""Tail the rolling runtime log — everything Python logged since last rotation.
Back-stop: if the rolling log doesn't exist yet (fresh install, disk error),
fall back to the crash log so the UI always has something to show.
"""
try:
tail = max(10, min(2000, int(tail)))
except Exception:
tail = 200
path = LOG_PATH if os.path.exists(LOG_PATH) else CRASH_LOG_PATH
if not os.path.exists(path):
return {"lines": [], "path": LOG_PATH, "exists": False}
try:
lines, total = _tail_file(path, tail)
return {"lines": lines, "path": path, "exists": True, "total_lines": total}
except Exception as e:
# The log file exists but we can't read it — usually a permission
# issue or the file got truncated mid-read. Point the user at the
# path so they can inspect or delete manually.
raise HTTPException(
status_code=500,
detail=f"Could not read log at {path}: {e}. Check file permissions or delete it manually.",
)
@router.get("/system/logs/tauri")
def system_logs_tauri(tail: int = 200):
"""Tail the Tauri plugin log (or backend stdout redirect, whichever exists)."""
try:
tail = max(10, min(2000, int(tail)))
except Exception:
tail = 200
candidates = _tauri_log_candidates()
for p in candidates:
if os.path.exists(p):
try:
lines, total = _tail_file(p, tail)
return {"lines": lines, "path": p, "exists": True, "total_lines": total}
except Exception as e:
return {"lines": [], "path": p, "exists": True, "error": str(e)}
return {"lines": [], "path": None, "exists": False, "candidates": candidates}
@router.post("/system/logs/clear")
def clear_system_logs():
"""Truncate the rolling runtime log and the crash log (what the Backend tab reads)."""
cleared_any = False
for p in (LOG_PATH, CRASH_LOG_PATH):
if os.path.exists(p):
try:
with open(p, "w") as f:
f.truncate(0)
cleared_any = True
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Could not clear log at {p}: {e}. The file may be open in another process or read-only — close tailing tools and retry.",
)
return {"cleared": cleared_any}
@router.post("/system/logs/tauri/clear")
def clear_tauri_logs():
"""Truncate whichever Tauri-side log files we know about. OS-level rotation may recreate them."""
cleared = []
for p in _tauri_log_candidates():
if os.path.exists(p):
try:
with open(p, "w") as f:
f.truncate(0)
cleared.append(p)
except Exception:
pass
return {"cleared": cleared}
@router.get("/sysinfo")
def get_sys_info():
vram = 0.0
gpu_active = False
try:
if _is_mac:
alloc = getattr(torch.mps, "current_allocated_memory", None)
driver = getattr(torch.mps, "driver_allocated_memory", None)
if driver:
vram = driver() / (1024**3)
elif alloc:
vram = alloc() / (1024**3)
elif _is_cuda:
vram = torch.cuda.memory_allocated() / (1024**3)
except Exception:
pass
if vram > 0.01:
gpu_active = True
vm = psutil.virtual_memory()
return {
"cpu": psutil.cpu_percent(interval=None),
"ram": vm.used / (1024**3),
"total_ram": vm.total / (1024**3),
"vram": round(vram, 2),
"gpu_active": gpu_active
}
@router.post("/system/flush-memory")
async def flush_memory(unload_model: bool = False):
"""Aggressively release RAM/VRAM by clearing caches and running GC.
When unload_model=true, the TTS model is fully unloaded and will be
re-loaded lazily on the next generation request.
"""
import gc
from services.model_manager import free_vram, model as _current_model
freed_model = False
if unload_model:
import services.model_manager as mm
async with mm._model_lock:
if mm.model is not None:
mm.model = None
freed_model = True
# Multi-pass GC to break reference cycles
gc.collect(generation=2)
gc.collect(generation=1)
gc.collect(generation=0)
free_vram()
# Snapshot after flush
vram_after = 0.0
try:
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
driver = getattr(torch.mps, "driver_allocated_memory", None)
if driver:
vram_after = driver() / (1024**3)
elif torch.cuda.is_available():
vram_after = torch.cuda.memory_allocated() / (1024**3)
except Exception:
pass
ram_after = psutil.virtual_memory().used / (1024**3)
return {
"flushed": True,
"unloaded_model": freed_model,
"ram_after": round(ram_after, 2),
"vram_after": round(vram_after, 2),
}
@router.post("/clean-audio")
async def clean_audio(audio: UploadFile = File(...)):
"""Accept a raw mic recording, run demucs vocal isolation, return clean WAV."""
clean_id = str(uuid.uuid4())[:8]
tmp_dir = os.path.join(OUTPUTS_DIR, f"_clean_{clean_id}")
os.makedirs(tmp_dir, exist_ok=True)
try:
return await _do_clean_audio(audio, tmp_dir, clean_id)
finally:
shutil.rmtree(tmp_dir, ignore_errors=True)
async def _do_clean_audio(audio, tmp_dir, clean_id):
raw_path = os.path.join(tmp_dir, "raw.wav")
with open(raw_path, "wb") as f:
f.write(await audio.read())
converted_path = os.path.join(tmp_dir, "converted.wav")
ffmpeg = find_ffmpeg()
try:
rc, _, _ = await run_ffmpeg(
[ffmpeg, "-y", "-i", raw_path, "-ar", "24000", "-ac", "1", converted_path],
timeout=120.0,
)
except asyncio.TimeoutError:
rc = -1
if rc != 0:
converted_path = raw_path
clean_path = converted_path
try:
rc, _, _ = await run_ffmpeg(
[sys.executable, "-m", "demucs.separate", "--two-stems", "vocals", "-n", "htdemucs",
"-d", get_best_device(), converted_path, "-o", tmp_dir],
timeout=900.0,
)
if rc == 0:
demucs_out = os.path.join(tmp_dir, "htdemucs", "converted")
vocals_file = os.path.join(demucs_out, "vocals.wav")
if os.path.exists(vocals_file):
clean_path = vocals_file
except asyncio.TimeoutError:
logger.warning("Demucs timed out for mic audio, using raw")
except Exception as e:
logger.warning(f"Demucs failed for mic audio, using raw: {e}")
clean_filename = f"mic_{clean_id}.wav"
final_path = os.path.join(OUTPUTS_DIR, clean_filename)
try:
await run_ffmpeg(
[ffmpeg, "-y", "-i", clean_path, "-ar", "24000", "-ac", "1", final_path],
timeout=120.0,
)
except asyncio.TimeoutError:
pass
if not os.path.exists(final_path):
shutil.copy2(clean_path, final_path)
return FileResponse(final_path, media_type="audio/wav", filename=clean_filename,
headers={"X-Clean-Filename": clean_filename})