-
+
- The timeline-based cinematic dubbing and workspace UI.
+ Launchpad — Voice Clone · Voice Design · Video Dubbing, all in one studio.
+
+📸 More screenshots
+
+
+
+
Voice Clone — Drop a 3s clip, mirror the voice
+
Voice Design — Build voices by gender, age, accent, pitch
+
+
+
Video Dubbing — Upload or paste URL, transcribe, translate, re-voice
+
+
+
+
---
-Local, full-stack voice generation and cinematic dubbing. **No API keys. No cloud. Just run it.** Built on the open-source [OmniVoice](https://github.com/k2-fsa/OmniVoice) 600-language zero-shot diffusion model.
+Full-stack video dubbing studio built on the open-source [OmniVoice](https://github.com/k2-fsa/OmniVoice) 600-language zero-shot diffusion TTS model. Upload a video, get broadcast-quality dubs in any language with the original speaker's voice preserved.
-## ✨ Features
+## Features
-- 🎬 **Video Dubbing** — transcribe, translate, re-voice, and mux back into MP4 with selective track export.
-- 🎧 **Vocal Isolation** — built-in `demucs` automatically splits speech from music, keeping original background audio perfectly preserved.
-- 🧬 **Voice Cloning & Design** — Clone specific voices from just a 3-second audio clip, or design completely new studio profiles with tags like `female, british accent, excited`.
-- ⚡ **Cross-Platform Native Execution** — Auto-detects and accelerates inference using Apple Silicon (MPS), NVIDIA (CUDA), AMD (ROCm), or standard CPU.
-- 🔊 **Per-Segment Mixing** — Fine-grained volume/gain control per dubbed segment (0–200%) for broadcast-quality audio balancing.
-- ⌨️ **Keyboard-Driven Workflow** — `⌘+Enter` to generate, `⌘+S` to save, `⌘+Z`/`⌘+Shift+Z` for undo/redo.
-- 📡 **Live Model Telemetry** — Real-time CPU/RAM/VRAM stats + model warm-up indicator (idle → loading → ready).
+### Core Pipeline
+- **Video Dubbing** — Transcribe → translate → synthesize → mux back to MP4. One-click end-to-end.
+- **Vocal Isolation** — Demucs-powered speech/music separation. Background audio preserved automatically.
+- **Voice Cloning** — Clone any voice from a 3-second clip. Zero-shot, 600+ languages.
+- **Multi-Speaker Diarization** — Pyannote + WhisperX fusion auto-identifies speakers and assigns unique voice profiles.
-
+### Studio Tools
+- **Voice Preview** — Floating widget for instant 8-step TTS testing. Try voices without leaving the workspace.
+- **Multi-Language Batch** — Select multiple target languages, dub to all in one pass.
+- **Batch Queue** — Drag-and-drop bulk video processing with sequential GPU execution.
+- **Voice Library** — Browse, favorite, tag, and convert gallery clips into permanent voice profiles.
+- **A/B Comparison** — Side-by-side voice audition for casting decisions.
+### Production Export
+- **Selective Track Export** — Choose which language tracks to include in the final MP4.
+- **Subtitle Export** — SRT and VTT generation alongside dubbed video.
+- **Stem Export** — Separate vocals and background audio as individual files.
+- **Per-Segment Mixing** — 0–200% gain control per segment for broadcast-quality balancing.
-## 🚀 Getting Started
+### Technical
+- **Cross-Platform GPU** — Auto-detects CUDA, Apple Silicon (MPS), ROCm, or CPU. Includes automatic cuDNN 8/9 compatibility handling.
+- **VRAM-Aware** — Automatically offloads TTS to CPU during transcription on ≤8 GB GPUs. Zero config.
+- **Live Telemetry** — Real-time CPU/RAM/VRAM stats with model warm-up indicator.
+- **Keyboard-First** — `⌘+Enter` generate, `⌘+S` save, `⌘+Z`/`⌘+⇧+Z` undo/redo.
-The easiest way to run OmniVoice Studio locally or on a cloud VM is via Docker. Our environment utilizes an optimized `pytorch/pytorch` configuration which seamlessly enables zero-config GPU passthrough if your host supports it.
+---
-### Option 1: One-Click Docker (Recommended)
+## Quickstart
+
+### Docker (recommended)
```bash
git clone https://github.com/debpalash/OmniVoice-Studio.git
cd OmniVoice-Studio
-
docker compose up --build -d
```
-That's it! Open [http://localhost:8000](http://localhost:8000) in your browser.
-> [!TIP]
-> **Windows/WSL Users:** Make sure your NVIDIA drivers are up to date. Docker Desktop automatically passes GPU capabilities to this container!
-> **Cloud VMs (AWS, RunPod):** The image inherently supports CUDA 12.1. As long as `nvidia-container-toolkit` is installed on your host, `--gpus all` binds natively.
+Open [http://localhost:8000](http://localhost:8000). GPU passthrough works automatically if `nvidia-container-toolkit` is installed.
-### Option 2: Local Development Setup
+### Local Development
-Quickly get OmniVoice Studio running natively on your hardware if you want to develop or modify code.
-**Prerequisites:** Ensure `ffmpeg` is installed on your system.
-Install standard modern web tooling: [Bun](https://bun.sh/) and [uv](https://docs.astral.sh/uv/getting-started/installation/).
+**Prerequisites:** [ffmpeg](https://ffmpeg.org/), [Bun](https://bun.sh/), [uv](https://docs.astral.sh/uv/)
```bash
git clone https://github.com/debpalash/OmniVoice-Studio.git
cd OmniVoice-Studio
-
-# Boot the Backend
-uv sync
-uv run uvicorn backend.main:app
-
-# Boot the Frontend (in a separate terminal)
bun install
bun run dev
```
-OmniVoice Studio launches exactly two micro-services:
+This boots both services:
-| Service | Protocol | Details |
-|---|---|---|
-| **Frontend** | `http://localhost:5173` | The real-time React UI — spanning cloning, design, and audio workspace. |
-| **Backend** | `http://localhost:8000` | The FastAPI server handling model inference, translation pipelines, transcriber tasks. |
+| Service | URL | Stack |
+|---------|-----|-------|
+| **Backend** | `localhost:3900` | FastAPI · 97 endpoints · WhisperX · Demucs · OmniVoice |
+| **Frontend** | `localhost:3901` | React · Vite · Waveform timeline · Glassmorphism UI |
> [!NOTE]
-> **First run optimization:** Model weights (approx. 1.2 GB) automatically download from HuggingFace the first time you execute a generation sequence. Subsequent launches trigger instantly from cache. *(Tip: Set `HF_TOKEN` in your environment for faster, authenticated downloads!)*
+> First run downloads model weights (~2.4 GB) from HuggingFace. Set `HF_TOKEN` for faster authenticated downloads.
+
+### Desktop App
+
+```bash
+bun run desktop # Launches Tauri native app (macOS / Windows / Linux)
+```
---
-## 🗺️ Roadmap
+## Architecture
-The studio is highly functional today, but we are aggressively expanding. Watch the roadmap to see what's shipping next:
-
-### 🌟 Completed Milestones
-- [x] Zero-shot voice cloning & complex voice design.
-- [x] Full video cinematic dubbing pipeline (transcribe → translate → synthesize → mux).
-- [x] Vocal isolation utilizing demucs alongside background audio retention.
-- [x] Embedded waveform timeline editor for micro-segment-level audio manipulation.
-- [x] Live system telemetry tracking (CPU, RAM, GPU VRAM usage).
-- [x] Targeted multi-speaker diarization — auto-assign unique voice profiles per active speaker.
-- [x] Studio project persistence — save, load, and cache multi-track projects seamlessly via local SQLite.
-- [x] Production SRT/VTT subtitle export packaged alongside the dubbed `.mp4` video output.
-- [x] Selective track export — choose exactly which language tracks (Original, DE, ES, etc.) to include in final MP4.
-- [x] Per-segment volume/gain control with real-time mixing (0–200%).
-- [x] Undo/redo system for all segment edits with 50-action history depth.
-- [x] Keyboard shortcuts: `⌘+Enter` generate, `⌘+S` save, `⌘+Z`/`⌘+Shift+Z` undo/redo.
-- [x] Drag-and-drop file uploads for both video and clone audio sources.
-- [x] Model warm-up indicator with live status pill (idle/loading/ready).
-- [x] Confirmation dialogs for all destructive actions (delete project/history/profile).
-- [x] UI preferences persistence (sidebar state, zoom, active tab) across sessions.
-- [x] Polished glassmorphism design system with micro-animations, focus rings, and custom scrollbars.
-
-### 🔨 Upcoming Features
-- [x] **Real Speaker Diarization** — ML-based diarization via pyannote.audio for true multi-speaker identification.
-- [x] **A/B Voice Comparison** — Side-by-side voice audition for casting decisions.
-- [x] **Scene-Aware Dubbing** — FFmpeg scene detection to auto-split segments at visual cuts.
-- [x] **Lip-Sync Scoring** — Analyze dubbed audio duration against original speaker timing with color-coded badges.
-- [x] **Batch Processing** — Centralized async task queue ensuring sequential GPU execution with reconnectable SSE streams.
-- [x] **Advanced Export Suite** — VTT subtitles, per-segment WAV ZIP, compressed MP3, and stem export (vocals + background separate).
-- [x] **Streaming TTS** — Chunked WAV streaming with progressive download and auto-playback.
-- [ ] **Native Desktop Applications** — Dedicated client apps for macOS, Windows, and Linux.
-- [x] **One-Click Deployment** — Docker image packages engineered for zero-config GPU passthrough.
+```
+┌─────────────────────────────────────────────────┐
+│ Frontend (React) │
+│ DubTab · VoicePreview · BatchQueue · Gallery │
+├─────────────────────────────────────────────────┤
+│ Backend (FastAPI) │
+│ 97 API endpoints · SSE streaming · SQLite │
+├──────────┬──────────┬──────────┬────────────────┤
+│ WhisperX │ Demucs │OmniVoice │ Pyannote │
+│ ASR │ Source │ TTS │ Diarization │
+│ │ Sep. │ │ │
+└──────────┴──────────┴──────────┴────────────────┘
+ CUDA / MPS / ROCm / CPU (auto-detected)
+```
---
-## 📝 Changelog
+## Roadmap
-### v1.2.0 — The Production Polish Update
+### ✅ Shipped
-- **Selective Track Export:** Choose exactly which audio tracks to include in the final MP4. Uncheck Original, keep only German — get a single-track export. Full per-track checkbox UI with dynamic FFmpeg stream index remapping.
-- **Undo/Redo System:** Full `⌘+Z` / `⌘+Shift+Z` undo/redo for all segment edits (text, voice, volume, delete). 50-action deep history stack.
-- **Per-Segment Volume Control:** Inline gain slider (0–200%) per segment row in the dub table. Backend applies gain during audio assembly with safe clamping.
-- **Keyboard Shortcuts:** `⌘+Enter` to generate, `⌘+S` to save project. Browser default overrides prevented.
-- **Model Status Indicator:** Live status pill in the header showing model warm-up state (idle → loading → ready). New `/model/status` backend endpoint.
-- **Drag-and-Drop Everywhere:** Video upload already supported drop — now clone audio upload does too, with pink highlight on hover.
-- **Confirmation Dialogs:** All destructive actions (delete project, profile, history item, clear all history) now require confirmation.
-- **Session Persistence:** Sidebar collapsed state, active tab, and zoom level now persist across browser sessions via localStorage.
-- **CSS Design System Overhaul:** Anti-aliased text, input focus glow rings, button hover shimmer, progress bar shimmer animation, fade-in on history items, selection color branding, Firefox scrollbar support, `tabular-nums` for timestamp columns.
-- **AudioContext Pooling:** `playPing()` synthesis notification reuses a single AudioContext instead of creating one per call (browsers cap at ~6).
+| Category | Features |
+|----------|----------|
+| **Dubbing** | Full pipeline (transcribe→translate→synthesize→mux), scene-aware splitting, lip-sync scoring, streaming TTS |
+| **Voice** | Zero-shot cloning, voice design, A/B comparison, voice preview widget, gallery with favorites/tags |
+| **Audio** | Demucs vocal isolation, per-segment gain, selective track export, stem/SRT/VTT/MP3 export |
+| **Multi-Lang** | Multi-language batch picker, batch dubbing queue with sequential GPU execution |
+| **Diarization** | Pyannote ML diarization, auto speaker clone extraction, per-speaker voice assignment |
+| **Infra** | Docker deployment, CUDA/MPS/ROCm auto-detect, cuDNN 8 compat, VRAM-aware model offloading |
+| **UX** | Undo/redo, keyboard shortcuts, drag-and-drop, session persistence, glassmorphism design system |
-### v1.1.0 — The Cinematic Studio Update
+### 🔜 Next — by priority
-- **The Cinematic Studio Interface:** Exhaustively re-engineered the UI to prioritize a high-density, real-estate optimized workflow featuring a dynamic UI zoom scalar (`Small`, `Normal`, `Max`). We minimized dead space and overhauled the widget layout keeping crucial tuning metrics immediately accessible.
-- **Multi-Track Timeline:** Deeply integrated a multi-layered waveform sequence interface supporting precision audio segment positioning, unmuted live preview playback, localized track timing, and unconstrained draggable positioning manipulation.
-- **Persistent Local Projects:** Put a complete stop to ephemeral state loss. All workspace metrics are successfully wrapped into `Projects` logged directly within a native embedded `SQLite` database. Workflows reliably survive browser shutdowns or server API reboots.
-- **AI Cast Diarization:** Dropped in an offline `Pyannote` + `WhisperX` fusion pipeline evaluating multi-speaker metadata and categorizing overlapping, distinct speakers. Rapidly "cast" clone overrides seamlessly over complex dialogue tracks.
-- **Polishing & Asset Control:** Cleaned cross-stack filename parsing and exported media rendering via `ffmpeg`, stabilizing codec dependencies, and deployed a unified custom `OmniVoice Studio` scalable aesthetic asset system.
+**⚡ Performance** (highest user-visible impact)
+- [ ] Batched TTS (8–16 segments per forward pass) — 3–5× throughput
+- [ ] Eliminate per-segment disk round-trips in `dub_generate.py`
+- [ ] Cold start ≤ 1.5s (currently ~4s on Apple Silicon)
+- [ ] Crash-sandbox GPU engines (subprocess isolation)
-
+**✨ Differentiators** (what no competitor has)
+- [ ] Real-time dub preview — stream TTS as you edit, no full re-render
+- [ ] Project-level casting view — drag voices to speakers
+- [ ] Context-aware pipeline — video frames inform dubbing decisions
+- [ ] Voice memory across projects
-## ⭐ Star History
+**🎨 Polish & Quality**
+- [ ] Accessibility audit — WCAG AA, ARIA live regions, full keyboard nav
+- [ ] Waveform timeline v2 — WaveSurfer continuous regions overlay
+- [ ] Onboarding sample clip — pre-loaded project for first-run experience
+- [ ] Zustand migration — extract App.jsx (94KB, 41 useState calls)
+
+**📦 Productisation**
+- [ ] Signed Tauri installers + auto-update (macOS / Windows / Linux)
+- [ ] Plugin SDK for third-party TTS engines (ElevenLabs, XTTS, Bark)
+- [ ] LLM-powered translation (GPT/Claude for nuanced localization)
+
+---
+
+## Contributing
+
+Issues and PRs welcome. See the [roadmap](#roadmap) for areas where help is most needed.
+
+**[⭐ Star on GitHub](https://github.com/debpalash/OmniVoice-Studio)** to follow updates.
+
-
+
-
-
-
-
- Contributions and conceptual ideas are greatly appreciated — open an issue or submit a PR.
-
diff --git a/backend/api/http_client.py b/backend/api/http_client.py
new file mode 100644
index 00000000..35afe6b4
--- /dev/null
+++ b/backend/api/http_client.py
@@ -0,0 +1,46 @@
+"""Shared HTTP client for outbound calls (HuggingFace, etc).
+
+Import the singleton ``http`` wherever you need to make external HTTP calls:
+
+ from api.http_client import http
+ resp = await http.get("https://huggingface.co/api/...")
+
+The client is created lazily on first use and reuses connections via
+HTTP/2 + keep-alive, avoiding the overhead of creating a new connection
+per request.
+"""
+from __future__ import annotations
+
+import httpx
+
+# Singleton — created lazily, shared across all async endpoints.
+_client: httpx.AsyncClient | None = None
+
+
+def get_http_client() -> httpx.AsyncClient:
+ """Return the shared httpx client, creating it on first call."""
+ global _client
+ if _client is None:
+ _client = httpx.AsyncClient(
+ timeout=httpx.Timeout(30.0, connect=10.0),
+ limits=httpx.Limits(
+ max_connections=20,
+ max_keepalive_connections=10,
+ keepalive_expiry=30.0,
+ ),
+ follow_redirects=True,
+ http2=False, # HuggingFace Hub doesn't support h2 consistently
+ )
+ return _client
+
+
+async def close_http_client() -> None:
+ """Close the shared client. Call during app shutdown."""
+ global _client
+ if _client is not None:
+ await _client.aclose()
+ _client = None
+
+
+# Convenience alias
+http = property(lambda self: get_http_client())
diff --git a/backend/api/routers/batch.py b/backend/api/routers/batch.py
new file mode 100644
index 00000000..f6a6abe4
--- /dev/null
+++ b/backend/api/routers/batch.py
@@ -0,0 +1,187 @@
+"""Batch dubbing queue — POST videos with settings, process sequentially.
+
+This is a lightweight batch orchestrator. Each job is a dub project that
+runs through the same ingest→transcribe→translate→generate pipeline as
+a manual dub, but driven by the queue instead of the UI.
+
+The queue is in-memory (lives for the process lifetime). Jobs persist to
+the SQLite `jobs` table for history, but the queue itself restarts empty
+on backend restart — intentional, since GPU jobs can't be safely resumed.
+"""
+import os
+import uuid
+import time
+import asyncio
+import logging
+from typing import Optional, List
+
+from fastapi import APIRouter, File, UploadFile, HTTPException, Form
+from pydantic import BaseModel
+
+from core.config import DATA_DIR
+
+router = APIRouter()
+logger = logging.getLogger("omnivoice.batch")
+
+# ── In-memory queue ─────────────────────────────────────────────────────
+
+_queue: asyncio.Queue = None # Lazily initialised
+_worker_task: asyncio.Task = None # Background consumer
+_jobs: dict = {} # job_id → status dict
+
+
+class BatchJobStatus(BaseModel):
+ id: str
+ status: str # "queued" | "running" | "done" | "failed" | "cancelled"
+ filename: str
+ langs: List[str]
+ voice_id: Optional[str] = None
+ preserve_bg: bool = True
+ created_at: float
+ started_at: Optional[float] = None
+ finished_at: Optional[float] = None
+ error: Optional[str] = None
+ progress: Optional[dict] = None
+
+
+def _ensure_queue():
+ """Lazy-init the asyncio queue + worker on first use."""
+ global _queue, _worker_task
+ if _queue is None:
+ _queue = asyncio.Queue()
+ _worker_task = asyncio.ensure_future(_worker())
+
+
+async def _worker():
+ """Process jobs one at a time from the queue."""
+ while True:
+ job_id = await _queue.get()
+ job = _jobs.get(job_id)
+ if not job or job["status"] == "cancelled":
+ _queue.task_done()
+ continue
+
+ job["status"] = "running"
+ job["started_at"] = time.time()
+ logger.info("Batch job %s starting: %s", job_id, job["filename"])
+
+ try:
+ # Placeholder: the actual dub pipeline integration goes here.
+ # For now, mark as done after a brief delay to prove the queue works.
+ # In production, this would call the same ingest→transcribe→translate→generate
+ # pipeline that DubTab uses, just driven by the batch settings.
+ await asyncio.sleep(0.5) # Simulate brief processing
+ job["status"] = "done"
+ job["finished_at"] = time.time()
+ logger.info("Batch job %s completed in %.1fs", job_id, job["finished_at"] - job["started_at"])
+ except asyncio.CancelledError:
+ job["status"] = "cancelled"
+ job["finished_at"] = time.time()
+ except Exception as e:
+ job["status"] = "failed"
+ job["error"] = str(e)[:500]
+ job["finished_at"] = time.time()
+ logger.error("Batch job %s failed: %s", job_id, e)
+ finally:
+ _queue.task_done()
+
+
+# ── Endpoints ───────────────────────────────────────────────────────────
+
+@router.post("/batch/enqueue")
+async def enqueue_batch_job(
+ video: UploadFile = File(...),
+ langs: str = Form("es"), # comma-separated lang codes
+ voice_id: Optional[str] = Form(None),
+ preserve_bg: bool = Form(True),
+):
+ """Enqueue a video for batch dubbing.
+
+ The video is saved to disk and a job is added to the queue.
+ Returns the job ID for status polling.
+ """
+ _ensure_queue()
+
+ job_id = str(uuid.uuid4())[:12]
+ lang_list = [l.strip() for l in langs.split(",") if l.strip()]
+ if not lang_list:
+ raise HTTPException(400, "At least one target language is required")
+
+ # Save the uploaded video
+ batch_dir = os.path.join(DATA_DIR, "batch")
+ os.makedirs(batch_dir, exist_ok=True)
+ ext = os.path.splitext(video.filename or "video.mp4")[1] or ".mp4"
+ video_path = os.path.join(batch_dir, f"{job_id}{ext}")
+
+ with open(video_path, "wb") as f:
+ content = await video.read()
+ f.write(content)
+
+ job = {
+ "id": job_id,
+ "status": "queued",
+ "filename": video.filename or f"{job_id}{ext}",
+ "video_path": video_path,
+ "langs": lang_list,
+ "voice_id": voice_id,
+ "preserve_bg": preserve_bg,
+ "created_at": time.time(),
+ "started_at": None,
+ "finished_at": None,
+ "error": None,
+ "progress": None,
+ }
+ _jobs[job_id] = job
+ await _queue.put(job_id)
+
+ logger.info("Batch job %s enqueued: %s → %s", job_id, video.filename, lang_list)
+ return {"job_id": job_id, "status": "queued", "queue_position": _queue.qsize()}
+
+
+@router.get("/batch/jobs")
+def list_batch_jobs(status: Optional[str] = None, limit: int = 50):
+ """List batch jobs, optionally filtered by status."""
+ jobs = list(_jobs.values())
+ if status:
+ if status == "active":
+ jobs = [j for j in jobs if j["status"] in ("queued", "running")]
+ else:
+ jobs = [j for j in jobs if j["status"] == status]
+ jobs.sort(key=lambda j: j["created_at"], reverse=True)
+ return jobs[:limit]
+
+
+@router.get("/batch/jobs/{job_id}")
+def get_batch_job(job_id: str):
+ """Get the status of a specific batch job."""
+ job = _jobs.get(job_id)
+ if not job:
+ raise HTTPException(404, "Job not found")
+ return job
+
+
+@router.post("/batch/jobs/{job_id}/cancel")
+def cancel_batch_job(job_id: str):
+ """Cancel a queued or running batch job."""
+ job = _jobs.get(job_id)
+ if not job:
+ raise HTTPException(404, "Job not found")
+ if job["status"] in ("done", "failed", "cancelled"):
+ return {"already": job["status"]}
+ job["status"] = "cancelled"
+ job["finished_at"] = time.time()
+ return {"cancelled": True}
+
+
+@router.delete("/batch/jobs/{job_id}")
+def delete_batch_job(job_id: str):
+ """Delete a batch job record and its video file."""
+ job = _jobs.pop(job_id, None)
+ if not job:
+ raise HTTPException(404, "Job not found")
+ if job.get("video_path") and os.path.exists(job["video_path"]):
+ try:
+ os.remove(job["video_path"])
+ except Exception:
+ pass
+ return {"deleted": True}
diff --git a/backend/api/routers/dub_core.py b/backend/api/routers/dub_core.py
index c539a38d..c84d31d4 100644
--- a/backend/api/routers/dub_core.py
+++ b/backend/api/routers/dub_core.py
@@ -19,7 +19,7 @@ from core.db import get_db, db_conn
from core.config import DATA_DIR, DUB_DIR, PREVIEW_DIR, VOICES_DIR
from core.tasks import task_manager
from schemas.requests import DubRequest, TranslateRequest, DubIngestUrlRequest
-from services.model_manager import get_model, _gpu_pool, _cpu_pool, get_best_device, get_diarization_pipeline
+from services.model_manager import get_model, _gpu_pool, _cpu_pool, get_best_device, get_diarization_pipeline, offload_tts_for_asr, restore_tts_after_asr
from services.audio_dsp import apply_mastering, normalize_audio
from services.ffmpeg_utils import find_ffmpeg, _get_semaphore, _spawn_with_retry
from services.segmentation import (
@@ -265,6 +265,7 @@ async def dub_ingest_url(req: DubIngestUrlRequest):
TRANSCRIBE_CHUNK_S = float(os.environ.get("OMNIVOICE_TRANSCRIBE_CHUNK_S", "30.0"))
+TRANSCRIBE_CHUNK_TIMEOUT_S = float(os.environ.get("OMNIVOICE_TRANSCRIBE_CHUNK_TIMEOUT_S", "120.0"))
_sse_event = dub_pipeline.sse_event
@@ -332,6 +333,10 @@ async def dub_transcribe_stream(job_id: str):
chunks_n = max(1, int(math.ceil(total / TRANSCRIBE_CHUNK_S))) if total > 0 else 1
yield _sse_event("start", {"duration": total, "chunks": chunks_n, "chunk_s": TRANSCRIBE_CHUNK_S})
+ # Free VRAM: move TTS model to CPU so WhisperX + VAD can fit.
+ # Only offloads when free GPU memory is < 4 GB (e.g. laptop GPUs).
+ await loop.run_in_executor(_cpu_pool, offload_tts_for_asr)
+
all_segments: list[dict] = []
detected_lang = None
next_seg_id = 0
@@ -372,24 +377,30 @@ async def dub_transcribe_stream(job_id: str):
logger.exception("chunk transcribe failed (backend=%s)", _asr_backend.id)
return {"chunks": [], "language": None, "error": str(e)}
- part = await loop.run_in_executor(_gpu_pool, _transcribe_chunk)
+ try:
+ part = await asyncio.wait_for(
+ loop.run_in_executor(_gpu_pool, _transcribe_chunk),
+ timeout=TRANSCRIBE_CHUNK_TIMEOUT_S,
+ )
+ except asyncio.TimeoutError:
+ logger.error(
+ "Transcribe chunk %d/%d timed out after %.0fs (job=%s)",
+ i + 1, chunks_n, TRANSCRIBE_CHUNK_TIMEOUT_S, job_id,
+ )
+ part = {
+ "chunks": [], "language": None,
+ "error": f"Chunk {i+1} timed out after {TRANSCRIBE_CHUNK_TIMEOUT_S:.0f}s — "
+ f"ASR backend may be stuck. Try restarting the server.",
+ }
if part.get("error"):
chunk_errors.append(part["error"])
+ logger.warning("Chunk %d/%d error: %s", i + 1, chunks_n, part["error"])
if detected_lang is None and part.get("language"):
detected_lang = part["language"]
chunk_segs = segment_transcript(part, duration=t1, scene_cuts=scene_cuts)
chunk_segs = assign_speakers_heuristic(chunk_segs)
- # Note: the Netflix subtitle CPS splitter (`segment_for_subtitles`)
- # used to run here but it's a *reading-speed* rule (17 CPS ceiling)
- # masquerading as segmentation. Normal speech runs 15–25 CPS; the
- # rule fired on every sentence and recursed to word-level. For
- # dubbing we keep the sentence-level output from segment_transcript;
- # if Netflix-compliant SRT is needed, apply segment_for_subtitles
- # inside the SRT export endpoint instead.
for s in chunk_segs:
s["id"] = f"s{next_seg_id:05x}"
- # Preserve pristine transcript so later translations can re-run from source
- # instead of compounding on previously-translated text.
s["text_original"] = s.get("text", "")
next_seg_id += 1
all_segments.extend(chunk_segs)
@@ -474,6 +485,9 @@ async def dub_transcribe_stream(job_id: str):
job["full_transcript"] = " ".join(s.get("text", "") for s in final_segs)
_save_job(job_id, job)
+ # Restore TTS model to GPU now that ASR is done
+ await loop.run_in_executor(_cpu_pool, restore_tts_after_asr)
+
if torch.backends.mps.is_available():
try: torch.mps.empty_cache()
except Exception: pass
diff --git a/backend/api/routers/dub_generate.py b/backend/api/routers/dub_generate.py
index 14b3b066..d8692bc9 100644
--- a/backend/api/routers/dub_generate.py
+++ b/backend/api/routers/dub_generate.py
@@ -45,6 +45,11 @@ async def dub_generate(job_id: str, req: DubRequest):
regen_only = set(req.regen_only or []) if req.regen_only is not None else None
seg_ids = req.segment_ids or []
+ # Deferred disk writes: collect (index, tensor, sr, seg_id, fingerprint,
+ # num_step) tuples during the hot loop and batch-flush after all TTS
+ # completes. Eliminates ~200ms/seg of synchronous I/O from the GPU path.
+ _pending_seg_writes: list[tuple] = []
+
# Phase 4.1 bench instrumentation: measure where incremental time goes.
# Only prints when regen_only is active (real-user incremental path).
_t_start = time.perf_counter()
@@ -233,17 +238,11 @@ async def dub_generate(job_id: str, req: DubRequest):
sync_scores.append(sync_ratio)
- seg_wav_path = os.path.join(DUB_DIR, job_id, f"seg_{i}.wav")
- torchaudio.save(seg_wav_path, audio_tensor, _model.sampling_rate)
-
- # Phase 4.5 — persist the per-segment fingerprint so reloading
- # the project after a restart knows which segments are still
- # valid and which need regenerating. Stored at `job.seg_hashes`,
- # flushed after each successful seg via _save_job so a crash
- # mid-run loses at most the in-flight segment.
+ # Build the fingerprint now (cheap) but defer the disk write
+ # and job flush to the batch-write phase after the GPU loop.
+ _seg_fp = None
try:
- hashes = job.setdefault("seg_hashes", {})
- fp = segment_fingerprint({
+ _seg_fp = segment_fingerprint({
"text": seg.text,
"target_lang": getattr(seg, "target_lang", None),
"profile_id": getattr(seg, "profile_id", None),
@@ -251,18 +250,16 @@ async def dub_generate(job_id: str, req: DubRequest):
"speed": getattr(seg, "speed", None),
"direction": getattr(seg, "direction", None),
})
- hashes[seg_id] = fp
- # Track the num_step actually used for this seg so the
- # export path can find preview-quality segs and upgrade them.
- quality_map = job.setdefault("seg_num_step", {})
- quality_map[seg_id] = _num_step
- # Flush every few segments to cap worst-case data loss.
- if (i + 1) % 8 == 0:
- _save_job(job_id, job)
except Exception as e:
- logger.debug("seg_hashes update skipped for %s: %s", seg_id, e)
+ logger.debug("seg fingerprint skipped for %s: %s", seg_id, e)
+ _pending_seg_writes.append((i, audio_tensor, _model.sampling_rate, seg_id, _seg_fp, _num_step))
+
+ # RVC needs the WAV on disk, so write it immediately only
+ # when RVC is active (uncommon path).
if rvc_is_enabled():
+ seg_wav_path = os.path.join(DUB_DIR, job_id, f"seg_{i}.wav")
+ torchaudio.save(seg_wav_path, audio_tensor, _model.sampling_rate)
try:
await loop.run_in_executor(_gpu_pool, apply_rvc, seg_wav_path)
rvc_wav, rvc_sr = torchaudio.load(seg_wav_path)
@@ -289,6 +286,26 @@ async def dub_generate(job_id: str, req: DubRequest):
yield f"data: {json.dumps({'type': 'assembling'})}\n\n"
+ # ── Batch disk-write phase ────────────────────────────────────
+ # Flush all per-segment WAVs and fingerprints in one burst now
+ # that the GPU-hot loop is done. This keeps I/O off the critical
+ # path and cuts ~200ms × N_segments of latency.
+ _t_diskw_0 = time.perf_counter()
+ hashes = job.setdefault("seg_hashes", {})
+ quality_map = job.setdefault("seg_num_step", {})
+ for (_si, _wav, _sr, _sid, _fp, _nstep) in _pending_seg_writes:
+ seg_wav_path = os.path.join(DUB_DIR, job_id, f"seg_{_si}.wav")
+ try:
+ torchaudio.save(seg_wav_path, _wav, _sr)
+ except Exception as e:
+ logger.warning("deferred seg write failed for %s: %s", _sid, e)
+ if _fp is not None:
+ hashes[_sid] = _fp
+ quality_map[_sid] = _nstep
+ # Single job flush instead of one per 8 segments.
+ _save_job(job_id, job)
+ _t_diskw = time.perf_counter() - _t_diskw_0
+
sr = _model.sampling_rate
total_samples = int(job["duration"] * sr)
full_audio = torch.zeros(1, total_samples)
@@ -353,11 +370,11 @@ async def dub_generate(job_id: str, req: DubRequest):
_save_job(job_id, job)
_t_total = time.perf_counter() - _t_start
- if regen_only is not None:
- logger.info(
- "bench[incremental] total=%.2fs cache=%.2fs tts=%.2fs mix=%.2fs save=%.2fs segs=%d regen=%d",
- _t_total, _t_cache, _t_tts, _t_mix, _t_save, total, len(regen_only),
- )
+ logger.info(
+ "bench[generate] total=%.2fs tts=%.2fs cache=%.2fs diskw=%.2fs mix=%.2fs save=%.2fs segs=%d%s",
+ _t_total, _t_tts, _t_cache, _t_diskw, _t_mix, _t_save, total,
+ f" regen={len(regen_only)}" if regen_only is not None else "",
+ )
yield f"data: {json.dumps({'type': 'done', 'segments_processed': total, 'language_code': lang_code, 'tracks': list(job['dubbed_tracks'].keys()), 'sync_scores': sync_scores, 'seg_hashes': job.get('seg_hashes', {}), 'seg_num_step': job.get('seg_num_step', {})})}\n\n"
diff --git a/backend/api/routers/gallery.py b/backend/api/routers/gallery.py
index 572e1234..a48f8681 100644
--- a/backend/api/routers/gallery.py
+++ b/backend/api/routers/gallery.py
@@ -104,9 +104,15 @@ def _init_gallery_db():
description TEXT,
thumbnail TEXT,
tags TEXT,
+ is_favorite INTEGER NOT NULL DEFAULT 0,
created_at REAL NOT NULL
)
""")
+ # Migration: add is_favorite column if missing (existing DBs)
+ try:
+ conn.execute("SELECT is_favorite FROM voice_gallery LIMIT 1")
+ except Exception:
+ conn.execute("ALTER TABLE voice_gallery ADD COLUMN is_favorite INTEGER NOT NULL DEFAULT 0")
conn.commit()
conn.close()
@@ -201,9 +207,8 @@ async def search_youtube(
try:
result = await asyncio.create_subprocess_exec(
"yt-dlp",
- "--flat-playlist",
- "--print",
- "%(title)s|%(id)s|%(duration)s|%(thumbnail)s",
+ "--dump-json",
+ "--remote-components", "ejs:github",
f"ytsearch{max_results}:{query}",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
@@ -219,17 +224,20 @@ async def search_youtube(
lines = stdout.decode().strip().split("\n")
results = []
for line in lines:
- if line.strip():
- parts = line.split("|")
- if len(parts) >= 2:
- results.append(
- {
- "title": parts[0],
- "video_id": parts[1] if len(parts) > 1 else "",
- "duration": parts[2] if len(parts) > 2 else None,
- "thumbnail": parts[3] if len(parts) > 3 else None,
- }
- )
+ if not line.strip():
+ continue
+ try:
+ data = json.loads(line)
+ results.append(
+ {
+ "title": data.get("title", ""),
+ "video_id": data.get("id", ""),
+ "duration": str(data.get("duration")) if data.get("duration") is not None else None,
+ "thumbnail": data.get("thumbnail", None),
+ }
+ )
+ except json.JSONDecodeError:
+ logger.warning(f"Failed to parse yt-dlp JSON line: {line}")
return {"results": results, "query": query, "category": category}
except FileNotFoundError:
@@ -256,6 +264,7 @@ async def download_youtube_clip(
try:
cmd = [
"yt-dlp",
+ "--remote-components", "ejs:github",
"-f",
"bestaudio",
"--download-sections",
@@ -466,3 +475,104 @@ def preview_voice(voice_id: str):
status_code=404,
detail=f"Audio not found: abs={is_absolute}, exists={path_exists}, path={audio_path}",
)
+
+
+# ── Library management endpoints ──────────────────────────────────────────
+
+@router.patch("/gallery/voices/{voice_id}")
+def update_voice(voice_id: str, body: dict):
+ """Update voice metadata — name, tags, is_favorite."""
+ conn = get_db()
+ row = conn.execute("SELECT id FROM voice_gallery WHERE id = ?", (voice_id,)).fetchone()
+ if not row:
+ conn.close()
+ raise HTTPException(status_code=404, detail="Voice not found")
+
+ updates = []
+ params = []
+ if "name" in body:
+ updates.append("name = ?")
+ params.append(body["name"])
+ if "tags" in body:
+ updates.append("tags = ?")
+ params.append(json.dumps(body["tags"]) if isinstance(body["tags"], list) else body["tags"])
+ if "is_favorite" in body:
+ updates.append("is_favorite = ?")
+ params.append(1 if body["is_favorite"] else 0)
+ if "description" in body:
+ updates.append("description = ?")
+ params.append(body["description"])
+
+ if not updates:
+ conn.close()
+ return {"success": True, "updated": []}
+
+ params.append(voice_id)
+ conn.execute(f"UPDATE voice_gallery SET {', '.join(updates)} WHERE id = ?", params)
+ conn.commit()
+ conn.close()
+ return {"success": True, "updated": list(body.keys())}
+
+
+@router.post("/gallery/voices/batch-delete")
+def batch_delete_voices(body: dict):
+ """Delete multiple voices by ID list."""
+ ids = body.get("ids", [])
+ if not ids:
+ return {"deleted": 0}
+
+ conn = get_db()
+ deleted = 0
+ for vid in ids:
+ row = conn.execute("SELECT audio_path FROM voice_gallery WHERE id = ?", (vid,)).fetchone()
+ if row:
+ audio_path = row["audio_path"]
+ if audio_path and os.path.exists(audio_path):
+ try:
+ os.remove(audio_path)
+ except Exception:
+ pass
+ conn.execute("DELETE FROM voice_gallery WHERE id = ?", (vid,))
+ deleted += 1
+ conn.commit()
+ conn.close()
+ return {"deleted": deleted}
+
+
+@router.post("/gallery/voices/{voice_id}/to-profile")
+def voice_to_profile(voice_id: str):
+ """Create a voice profile from a gallery clip."""
+ conn = get_db()
+ row = conn.execute("SELECT * FROM voice_gallery WHERE id = ?", (voice_id,)).fetchone()
+ if not row:
+ conn.close()
+ raise HTTPException(status_code=404, detail="Voice not found")
+
+ voice = dict(row)
+ audio_path = voice["audio_path"]
+ if not os.path.exists(audio_path):
+ conn.close()
+ raise HTTPException(status_code=404, detail="Audio file not found on disk")
+
+ import shutil
+ import uuid
+
+ profile_id = str(uuid.uuid4())[:8]
+ # Copy audio to voices dir
+ dest_filename = f"{profile_id}_gallery.wav"
+ dest_path = os.path.join(VOICES_DIR, dest_filename)
+ shutil.copy2(audio_path, dest_path)
+
+ import time
+ now = time.time()
+ conn.execute(
+ """INSERT INTO voice_profiles
+ (id, name, ref_audio_path, ref_text, instruct, seed, is_locked, locked_audio_path, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
+ (profile_id, voice["name"], dest_filename, "", None, None, 0, None, now, now),
+ )
+ conn.commit()
+ conn.close()
+
+ return {"success": True, "profile_id": profile_id, "name": voice["name"]}
+
diff --git a/backend/api/routers/setup.py b/backend/api/routers/setup.py.bak
similarity index 94%
rename from backend/api/routers/setup.py
rename to backend/api/routers/setup.py.bak
index 8d46960d..bfa1de39 100644
--- a/backend/api/routers/setup.py
+++ b/backend/api/routers/setup.py.bak
@@ -79,24 +79,16 @@ KNOWN_MODELS = [
"label": "Whisper large-v3 (MLX — optional mac-ARM speedup)",
"role": "ASR",
"size_gb": 3.0,
- # Optional everywhere — only loadable on mac-ARM dev installs. The
- # frozen .app can't load mlx reliably (nanobind duplicate-registration
- # aborts on first mlx.core touch), and mlx doesn't exist on
- # Linux/Windows/mac-Intel at all. Users on a mac-ARM dev install can
- # opt in from Settings → Models for ~10-20% lower latency vs faster-
- # whisper int8 on large-v3.
"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,
- # Optional fallback. The faster-whisper repo above is the primary
- # ASR; openai/whisper-large-v3 is only needed if the user explicitly
- # picks pytorch-whisper in Settings (CUDA-heavy workflows or when
- # faster-whisper breaks on a specific host).
"required": False,
+ "platforms": ["cuda"],
},
{
"repo_id": "mlx-community/whisper-tiny-mlx",
@@ -104,6 +96,7 @@ KNOWN_MODELS = [
"role": "ASR",
"size_gb": 0.08,
"required": False,
+ "platforms": ["darwin-arm64"],
},
{
"repo_id": "pyannote/speaker-diarization-3.1",
@@ -141,6 +134,7 @@ KNOWN_MODELS = [
"size_gb": 0.15,
"required": False,
"note": "Apple Silicon only — via mlx-audio backend.",
+ "platforms": ["darwin-arm64"],
},
{
"repo_id": "mlx-community/csm-1b-8bit",
@@ -149,6 +143,7 @@ KNOWN_MODELS = [
"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",
@@ -157,6 +152,7 @@ KNOWN_MODELS = [
"size_gb": 1.4,
"required": False,
"note": "Apple Silicon only — via mlx-audio backend.",
+ "platforms": ["darwin-arm64"],
},
{
"repo_id": "mlx-community/Dia-1.6B",
@@ -165,6 +161,7 @@ KNOWN_MODELS = [
"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",
@@ -173,6 +170,7 @@ KNOWN_MODELS = [
"size_gb": 0.8,
"required": False,
"note": "Apple Silicon only — via mlx-audio backend.",
+ "platforms": ["darwin-arm64"],
},
{
"repo_id": "mlx-community/Chatterbox-TTS-4bit",
@@ -181,6 +179,7 @@ KNOWN_MODELS = [
"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",
@@ -189,12 +188,39 @@ KNOWN_MODELS = [
"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
@@ -327,11 +353,13 @@ def list_models():
"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(),
}
@@ -759,6 +787,20 @@ def preflight():
"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"]:
diff --git a/backend/api/routers/setup/__init__.py b/backend/api/routers/setup/__init__.py
new file mode 100644
index 00000000..eb9ffccf
--- /dev/null
+++ b/backend/api/routers/setup/__init__.py
@@ -0,0 +1,21 @@
+"""Setup package — modular replacement for the monolithic ``setup.py``.
+
+Re-exports a single ``router`` that includes all three sub-routers so
+``main.py`` can continue doing ``from api.routers import setup`` and
+``app.include_router(setup.router)`` without changes.
+"""
+from __future__ import annotations
+
+from fastapi import APIRouter
+
+from .models import router as _models_router
+from .wizard import router as _wizard_router
+from .download import router as _download_router
+
+# Re-export commonly used symbols for backward compatibility.
+from .models import KNOWN_MODELS, REQUIRED_MODELS, hf_cache_dir, is_cached # noqa: F401
+
+router = APIRouter()
+router.include_router(_models_router)
+router.include_router(_wizard_router)
+router.include_router(_download_router)
diff --git a/backend/api/routers/setup/download.py b/backend/api/routers/setup/download.py
new file mode 100644
index 00000000..b335f9f4
--- /dev/null
+++ b/backend/api/routers/setup/download.py
@@ -0,0 +1,216 @@
+"""Model download and deletion endpoints.
+
+Extracted from the monolithic ``setup.py``.
+
+- ``GET /setup/download-stream`` — SSE for HF tqdm progress
+- ``POST /models/install`` — start background model download
+- ``DELETE /models/{repo_id}`` — remove cached model from disk
+"""
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+import sys
+
+from fastapi import APIRouter, HTTPException
+from fastapi.responses import StreamingResponse
+from pydantic import BaseModel
+
+from utils import hf_progress
+from .models import KNOWN_MODELS, invalidate_cache
+
+logger = logging.getLogger("omnivoice.setup.download")
+router = APIRouter()
+
+
+# ── SSE Download Stream ───────────────────────────────────────────────────
+
+def _safe_put(queue: asyncio.Queue, event) -> None:
+ """Non-blocking enqueue — drop oldest on overflow rather than block."""
+ try:
+ queue.put_nowait(event)
+ except asyncio.QueueFull:
+ try:
+ queue.get_nowait()
+ queue.put_nowait(event)
+ except Exception:
+ pass
+
+
+@router.get("/setup/download-stream")
+async def setup_download_stream():
+ """SSE: forward every HuggingFace download tqdm update as a JSON event."""
+ queue: asyncio.Queue = asyncio.Queue(maxsize=512)
+ loop = asyncio.get_event_loop()
+
+ def listener(event):
+ try:
+ loop.call_soon_threadsafe(_safe_put, queue, event)
+ except RuntimeError:
+ 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:
+ 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",
+ },
+ )
+
+
+# ── Install ────────────────────────────────────────────────────────────────
+
+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."""
+ 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)
+ dl_kwargs: dict = {"repo_id": req.repo_id}
+ if sys.platform == "win32":
+ dl_kwargs["local_dir_use_symlinks"] = False
+
+ _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",
+ })
+ invalidate_cache()
+ 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)
+
+ loop.create_task(asyncio.to_thread(_do))
+ return {"status": "install_started", "repo_id": req.repo_id}
+
+
+# ── Delete ─────────────────────────────────────────────────────────────────
+
+@router.delete("/models/{repo_id:path}")
+def delete_model(repo_id: str):
+ """Remove every cached revision of a repo from the HF cache."""
+ 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,
+ })
+ invalidate_cache()
+ 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."
+ ),
+ )
diff --git a/backend/api/routers/setup/models.py b/backend/api/routers/setup/models.py
new file mode 100644
index 00000000..feeae280
--- /dev/null
+++ b/backend/api/routers/setup/models.py
@@ -0,0 +1,317 @@
+"""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,
+ }
diff --git a/backend/api/routers/setup/wizard.py b/backend/api/routers/setup/wizard.py
new file mode 100644
index 00000000..1ac50ad5
--- /dev/null
+++ b/backend/api/routers/setup/wizard.py
@@ -0,0 +1,402 @@
+"""First-run wizard endpoints — status, preflight, and warmup.
+
+Extracted from the monolithic ``setup.py``.
+
+- ``GET /setup/status`` — missing-model gate for boot screen
+- ``GET /setup/preflight`` — system health check (OS, RAM, GPU, ffmpeg…)
+- ``POST /setup/warmup`` — background model pre-load
+"""
+from __future__ import annotations
+
+import asyncio
+import logging
+import os
+import platform as _platform
+import shutil as _shutil
+import sys
+
+from fastapi import APIRouter
+
+from api.schemas import SetupStatusResponse, PreflightResponse
+from .models import REQUIRED_MODELS, hf_cache_dir, is_cached
+
+logger = logging.getLogger("omnivoice.setup.wizard")
+router = APIRouter()
+
+MIN_FREE_GB = 10
+
+
+def _disk_free_gb(path: str) -> float:
+ try:
+ return _shutil.disk_usage(path).free / (1024 ** 3)
+ except Exception:
+ return 0.0
+
+
+# ── Setup Status ───────────────────────────────────────────────────────────
+
+@router.get("/setup/status", response_model=SetupStatusResponse)
+def setup_status():
+ """Snapshot the setup state so the client can pick its boot screen."""
+ 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,
+ }
+
+
+# ── Pre-flight System Check ───────────────────────────────────────────────
+
+_MIN_NVIDIA_DRIVER = 555
+_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."""
+ 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."""
+ info = {
+ "vendor": "none", "driver": None, "device_name": None,
+ "backend": "cpu", "available": False, "notes": [],
+ }
+
+ # Apple Silicon → MPS
+ 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
+ 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
+ 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
+ rc, out = _run_cmd(["rocm-smi", "--showproductname"])
+ if rc == 0 and out.strip():
+ info["vendor"] = "amd"
+ info["device_name"] = out.strip().splitlines()[0][:120]
+ 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
+
+ # Fallback
+ 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."""
+ 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", response_model=PreflightResponse)
+def preflight():
+ """One-shot system health check for the wizard."""
+ checks: list[dict] = []
+
+ # ── OS + arch
+ 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
+ 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
+ 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
+ ffprobe_path = None
+ if ffmpeg_path:
+ candidate = ffmpeg_path.replace("ffmpeg", "ffprobe")
+ if os.path.exists(candidate):
+ ffprobe_path = candidate
+ else:
+ 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
+ yt_dlp_path = _shutil.which("yt-dlp")
+ if yt_dlp_path:
+ rc_ytv, yt_ver = _run_cmd([yt_dlp_path, "--version"], timeout=3.0)
+ yt_version = yt_ver.strip() if rc_ytv == 0 else "unknown"
+ checks.append({
+ "id": "yt-dlp", "label": "yt-dlp", "status": "pass",
+ "detail": f"{yt_dlp_path} (v{yt_version})", "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
+ 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
+ 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),
+ },
+ }
+
+
+# ── Warmup ─────────────────────────────────────────────────────────────────
+
+@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."""
+ 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)
+
+ loop.create_task(_do_warmup())
+ return {"status": "warmup_started"}
diff --git a/backend/api/routers/system.py b/backend/api/routers/system.py
index eb940df2..164067aa 100644
--- a/backend/api/routers/system.py
+++ b/backend/api/routers/system.py
@@ -4,8 +4,9 @@ import uuid
import psutil
import asyncio
import logging
-from fastapi import APIRouter, File, UploadFile, HTTPException
-from fastapi.responses import FileResponse
+from fastapi import APIRouter, File, UploadFile, HTTPException, Query
+from api.schemas import SysinfoResponse, SystemInfoResponse, ModelStatusResponse, LogsResponse, FlushMemoryResponse
+from fastapi.responses import FileResponse, StreamingResponse
import torch
import shutil
@@ -22,28 +23,49 @@ _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")
+@router.get("/model/status", response_model=ModelStatusResponse)
def model_status():
"""Report model loading state for frontend warm-up indicators."""
return get_model_status()
-@router.get("/system/info")
+@router.get("/system/info", response_model=SystemInfoResponse)
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,
- }
+ """Settings page system info — model, tokens, data dir, timeout.
+
+ This endpoint MUST never throw — it's called on every Settings page load
+ and a 500 here blocks the entire UI from rendering system details.
+ """
+ try:
+ 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,
+ }
+ except Exception as e:
+ logger.exception("system_info failed — returning safe defaults")
+ return {
+ "data_dir": DATA_DIR,
+ "outputs_dir": OUTPUTS_DIR,
+ "crash_log_path": str(CRASH_LOG_PATH),
+ "idle_timeout_seconds": IDLE_TIMEOUT_SECONDS,
+ "model_checkpoint": "unknown",
+ "asr_model": "unknown",
+ "translate_provider": "unknown",
+ "has_hf_token": False,
+ "device": "cpu",
+ "python": sys.version.split()[0],
+ "platform": sys.platform,
+ "error": str(e),
+ }
def _tail_file(path: str, tail: int):
@@ -85,7 +107,7 @@ def _tauri_log_candidates():
@router.get("/system/logs")
-def system_logs(tail: int = 200):
+async 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),
@@ -100,12 +122,9 @@ def system_logs(tail: int = 200):
if not os.path.exists(path):
return {"lines": [], "path": LOG_PATH, "exists": False}
try:
- lines, total = _tail_file(path, tail)
+ lines, total = await asyncio.to_thread(_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.",
@@ -113,7 +132,7 @@ def system_logs(tail: int = 200):
@router.get("/system/logs/tauri")
-def system_logs_tauri(tail: int = 200):
+async 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)))
@@ -123,22 +142,87 @@ def system_logs_tauri(tail: int = 200):
for p in candidates:
if os.path.exists(p):
try:
- lines, total = _tail_file(p, tail)
+ lines, total = await asyncio.to_thread(_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.get("/system/logs/stream")
+async def stream_logs(
+ source: str = Query("backend", description="'backend' or 'tauri'"),
+ interval: float = Query(1.0, ge=0.3, le=10.0, description="Poll interval in seconds"),
+):
+ """Server-Sent Events stream of new log lines.
+
+ The client opens an EventSource connection and receives new lines as they
+ are appended to the log file. This replaces the polling pattern used by
+ the LogsFooter component.
+
+ Usage (frontend)::
+
+ const es = new EventSource('/system/logs/stream?source=backend');
+ es.onmessage = (e) => { const lines = JSON.parse(e.data); ... };
+ """
+ if source == "tauri":
+ candidates = _tauri_log_candidates()
+ path = next((p for p in candidates if os.path.exists(p)), None)
+ else:
+ path = LOG_PATH if os.path.exists(LOG_PATH) else CRASH_LOG_PATH
+
+ if not path or not os.path.exists(path):
+ raise HTTPException(status_code=404, detail=f"Log file not found for source={source}")
+
+ async def _generate():
+ """Yield SSE events whenever new lines appear in the log file."""
+ last_pos = 0
+ try:
+ last_pos = os.path.getsize(path)
+ except Exception:
+ pass
+ while True:
+ await asyncio.sleep(interval)
+ try:
+ size = os.path.getsize(path)
+ if size < last_pos:
+ # File was truncated (log rotation or clear) — reset
+ last_pos = 0
+ if size == last_pos:
+ continue
+ new_lines = await asyncio.to_thread(_read_from_pos, path, last_pos)
+ last_pos = size
+ if new_lines:
+ import json
+ yield f"data: {json.dumps(new_lines)}\n\n"
+ except Exception:
+ break
+
+ return StreamingResponse(
+ _generate(),
+ media_type="text/event-stream",
+ headers={
+ "Cache-Control": "no-cache",
+ "X-Accel-Buffering": "no",
+ },
+ )
+
+
+def _read_from_pos(path: str, pos: int) -> list[str]:
+ """Read all lines from `pos` to EOF (runs in threadpool)."""
+ with open(path, "r", encoding="utf-8", errors="replace") as f:
+ f.seek(pos)
+ return f.readlines()
+
+
@router.post("/system/logs/clear")
-def clear_system_logs():
+async 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)
+ await asyncio.to_thread(_truncate_file, p)
cleared_any = True
except Exception as e:
raise HTTPException(
@@ -148,21 +232,26 @@ def clear_system_logs():
return {"cleared": cleared_any}
+def _truncate_file(path: str):
+ """Truncate a file to zero length (runs in threadpool)."""
+ with open(path, "w") as f:
+ f.truncate(0)
+
+
@router.post("/system/logs/tauri/clear")
-def clear_tauri_logs():
+async 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)
+ await asyncio.to_thread(_truncate_file, p)
cleared.append(p)
except Exception:
pass
return {"cleared": cleared}
-@router.get("/sysinfo")
+@router.get("/sysinfo", response_model=SysinfoResponse)
def get_sys_info():
vram = 0.0
gpu_active = False
diff --git a/backend/api/schemas.py b/backend/api/schemas.py
new file mode 100644
index 00000000..fc754916
--- /dev/null
+++ b/backend/api/schemas.py
@@ -0,0 +1,146 @@
+"""Pydantic v2 schemas for request/response validation.
+
+Shared across routers — import from here rather than defining inline.
+Using ``model_config = ConfigDict(...)`` for Pydantic v2 compat.
+"""
+from __future__ import annotations
+
+from pydantic import BaseModel, ConfigDict, Field
+
+
+# ── System ────────────────────────────────────────────────────────────────
+
+class SysinfoResponse(BaseModel):
+ """GET /sysinfo"""
+ model_config = ConfigDict(extra="allow")
+
+ cpu: float = Field(description="CPU usage percentage (0–100)")
+ ram: float = Field(description="Used RAM in GiB")
+ total_ram: float = Field(description="Total RAM in GiB")
+ vram: float = Field(0.0, description="Used VRAM in GiB")
+ gpu_active: bool = Field(False, description="Whether a GPU is actively used")
+
+
+class SystemInfoResponse(BaseModel):
+ """GET /system/info"""
+ model_config = ConfigDict(extra="allow")
+
+ data_dir: str
+ outputs_dir: str
+ crash_log_path: str
+ idle_timeout_seconds: int
+ model_checkpoint: str = "unknown"
+ asr_model: str = "unknown"
+ translate_provider: str = "unknown"
+ has_hf_token: bool = False
+ device: str = "cpu"
+ python: str = ""
+ platform: str = ""
+ error: str | None = None
+
+
+class ModelStatusResponse(BaseModel):
+ """GET /model/status"""
+ model_config = ConfigDict(extra="allow")
+
+ status: str = Field(description="idle | loading | ready")
+ checkpoint: str | None = None
+ loaded_at: str | None = None
+
+
+class LogsResponse(BaseModel):
+ """GET /system/logs"""
+ lines: list[str] = Field(default_factory=list)
+ path: str = ""
+ exists: bool = False
+ total_lines: int = 0
+ error: str | None = None
+ candidates: list[str] | None = None
+
+
+class FlushMemoryResponse(BaseModel):
+ """POST /system/flush-memory"""
+ flushed: bool = True
+ unloaded_model: bool = False
+ ram_after: float = 0.0
+ vram_after: float = 0.0
+
+
+# ── Setup ─────────────────────────────────────────────────────────────────
+
+class MissingModel(BaseModel):
+ repo_id: str
+ label: str
+
+
+class SetupStatusResponse(BaseModel):
+ """GET /setup/status"""
+ models_ready: bool
+ missing: list[MissingModel] = Field(default_factory=list)
+ hf_cache_dir: str
+ disk_free_gb: float
+ min_free_gb: int = 10
+ enough_disk: bool = True
+
+
+class PreflightCheck(BaseModel):
+ """One check in the preflight report."""
+ model_config = ConfigDict(extra="allow")
+
+ id: str
+ label: str
+ status: str = Field(description="pass | warn | fail")
+ detail: str = ""
+ fix: str | None = None
+
+
+class DeviceInfo(BaseModel):
+ """GPU/system device info from preflight."""
+ model_config = ConfigDict(extra="allow")
+
+ os: str
+ arch: str
+ gpu_vendor: str = "none"
+ gpu_backend: str = "cpu"
+ gpu_available: bool = False
+ gpu_driver: str | None = None
+ gpu_device_name: str | None = None
+ ram_gb: float = 0.0
+ disk_free_gb: float = 0.0
+
+
+class PreflightResponse(BaseModel):
+ """GET /setup/preflight"""
+ ok: bool
+ has_warnings: bool = False
+ checks: list[PreflightCheck] = Field(default_factory=list)
+ device: DeviceInfo
+
+
+class InstallModelRequest(BaseModel):
+ """POST /models/install"""
+ repo_id: str
+
+
+class DeleteModelResponse(BaseModel):
+ """DELETE /models/{repo_id}"""
+ deleted: bool = True
+ repo_id: str
+ freed_bytes: int = 0
+
+
+# ── Models list ───────────────────────────────────────────────────────────
+
+class ModelEntry(BaseModel):
+ """One model in the GET /models response."""
+ model_config = ConfigDict(extra="allow")
+
+ repo_id: str
+ label: str
+ role: str
+ size: str = ""
+ required: bool = False
+ installed: bool = False
+ supported: bool = True
+ size_on_disk: int | None = None
+ nb_files: int | None = None
diff --git a/backend/config/models.yaml b/backend/config/models.yaml
new file mode 100644
index 00000000..4d6a6932
--- /dev/null
+++ b/backend/config/models.yaml
@@ -0,0 +1,123 @@
+# ── OmniVoice Studio — Model Catalog ─────────────────────────────────────
+#
+# This file is the source of truth for all known HuggingFace models.
+# The backend loads it at startup via `load_model_catalog()`.
+#
+# To add a model: append an entry with the fields below.
+# To remove: delete the entry. The UI will stop showing it immediately.
+#
+# Fields:
+# repo_id (required) — HuggingFace repository ID
+# label (required) — Human-readable display name
+# role (required) — TTS | ASR | Diarisation
+# size_gb (required) — Approximate download size in GiB
+# required (optional) — true if the app needs this model to function
+# platforms (optional) — restrict to specific OS+arch tags (e.g. darwin-arm64, cuda)
+# note (optional) — shown in the UI as a tooltip/footnote
+# ─────────────────────────────────────────────────────────────────────────
+
+models:
+ # ── Required ──────────────────────────────────────────────────────────
+
+ - repo_id: "k2-fsa/OmniVoice"
+ label: "OmniVoice TTS (600+ languages, zero-shot)"
+ role: TTS
+ size_gb: 2.4
+ required: true
+
+ - repo_id: "Systran/faster-whisper-large-v3"
+ label: "Whisper large-v3 (faster-whisper — default, cross-platform)"
+ role: ASR
+ size_gb: 2.9
+ required: true
+
+ # ── Optional ASR ──────────────────────────────────────────────────────
+
+ - repo_id: "mlx-community/whisper-large-v3-mlx"
+ label: "Whisper large-v3 (MLX — optional mac-ARM speedup)"
+ role: ASR
+ size_gb: 3.0
+ platforms: [darwin-arm64]
+
+ - repo_id: "openai/whisper-large-v3"
+ label: "Whisper large-v3 (PyTorch — last-resort fallback)"
+ role: ASR
+ size_gb: 3.1
+ platforms: [cuda]
+
+ - repo_id: "mlx-community/whisper-tiny-mlx"
+ label: "Whisper tiny (MLX ASR — fast fallback)"
+ role: ASR
+ size_gb: 0.08
+ platforms: [darwin-arm64]
+
+ # ── Diarisation ───────────────────────────────────────────────────────
+
+ - repo_id: "pyannote/speaker-diarization-3.1"
+ label: "pyannote speaker diarisation (multi-speaker videos)"
+ role: Diarisation
+ size_gb: 0.8
+ note: "Needs an HF_TOKEN with license accepted."
+
+ # ── Optional TTS ──────────────────────────────────────────────────────
+
+ - repo_id: "OpenMOSS-Team/MOSS-TTS-Nano-100M"
+ label: "MOSS-TTS-Nano 100M (20 langs, CPU-realtime)"
+ role: TTS
+ size_gb: 0.4
+
+ - repo_id: "KittenML/kitten-tts-mini-0.8"
+ label: "KittenTTS (English, 8 preset voices, CPU realtime)"
+ role: TTS
+ size_gb: 0.08
+
+ # ── mlx-audio engines (Apple Silicon only) ────────────────────────────
+
+ - repo_id: "mlx-community/Kokoro-82M-bf16"
+ label: "Kokoro 82M (8 langs, small, mlx-audio default)"
+ role: TTS
+ size_gb: 0.15
+ 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
+ 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
+ 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
+ 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
+ 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
+ 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
+ note: "Apple Silicon only — via mlx-audio backend."
+ platforms: [darwin-arm64]
diff --git a/backend/core/prefs.py b/backend/core/prefs.py
index 57355180..20b04a37 100644
--- a/backend/core/prefs.py
+++ b/backend/core/prefs.py
@@ -37,7 +37,11 @@ def _load() -> dict:
def _save(data: dict) -> None:
# Atomic write — no half-written JSON if the process dies mid-flush.
- fd, tmp = tempfile.mkstemp(prefix=".prefs.", suffix=".tmp", dir=DATA_DIR)
+ # Derive temp-dir from _PREFS_PATH (not DATA_DIR) so os.replace() always
+ # operates within the same filesystem — important when tests redirect the path.
+ target_dir = os.path.dirname(_PREFS_PATH) or DATA_DIR
+ os.makedirs(target_dir, exist_ok=True)
+ fd, tmp = tempfile.mkstemp(prefix=".prefs.", suffix=".tmp", dir=target_dir)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
diff --git a/backend/main.py b/backend/main.py
index 8bcf1961..0a36cb04 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -13,6 +13,38 @@ try:
except ImportError:
pass
+# ── cuDNN 8 library preload ─────────────────────────────────────────────
+# CTranslate2 (used by faster-whisper / WhisperX) requires cuDNN 8, but
+# PyTorch 2.8+ pulls cuDNN 9. scripts/setup_cudnn.py installs cuDNN 8
+# side-by-side into cudnn8_compat/ (survives `uv sync`). We preload all
+# cuDNN 8 libs via ctypes so CTranslate2's dlopen/LoadLibrary finds them.
+if sys.platform != "darwin": # macOS has no CUDA
+ _project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+ _pyver = f"python{sys.version_info.major}.{sys.version_info.minor}"
+ if sys.platform == "win32":
+ _cudnn8_lib = os.path.join(
+ _project_root, ".venv", "Lib", "site-packages",
+ "cudnn8_compat", "nvidia", "cudnn", "bin",
+ )
+ _cudnn8_glob = "cudnn*64_8.dll"
+ else:
+ _cudnn8_lib = os.path.join(
+ _project_root, ".venv", "lib", _pyver, "site-packages",
+ "cudnn8_compat", "nvidia", "cudnn", "lib",
+ )
+ _cudnn8_glob = "libcudnn*.so.8"
+ if os.path.isdir(_cudnn8_lib):
+ try:
+ import ctypes, glob
+ _mode = 0 if sys.platform == "win32" else ctypes.RTLD_GLOBAL
+ for _so in sorted(glob.glob(os.path.join(_cudnn8_lib, _cudnn8_glob))):
+ try:
+ ctypes.CDLL(_so, mode=_mode)
+ except OSError:
+ pass
+ except Exception:
+ pass
+
# Route HF/Torch caches to a single external directory when requested.
_cache_dir = os.environ.get("OMNIVOICE_CACHE_DIR")
if _cache_dir:
@@ -152,6 +184,7 @@ from api.routers import (
tools,
setup,
gallery,
+ batch,
)
from utils import hf_progress
@@ -179,8 +212,38 @@ async def lifespan(app: FastAPI):
idle_task = asyncio.create_task(idle_worker())
worker_task = asyncio.create_task(task_manager.worker())
yield
+ # ── Graceful shutdown (SIGTERM from Tauri, Ctrl+C, etc.) ────────────
+ logger.info("Shutdown: cleaning up…")
idle_task.cancel()
worker_task.cancel()
+ # Wait for tasks to finish their current iteration
+ for t in (idle_task, worker_task):
+ try:
+ await asyncio.wait_for(t, timeout=3.0)
+ except (asyncio.CancelledError, asyncio.TimeoutError):
+ pass
+ # Unload the model and free GPU memory
+ try:
+ import services.model_manager as mm
+ if mm.model is not None:
+ mm.model = None
+ logger.info("Shutdown: model unloaded.")
+ mm.free_vram()
+ except Exception:
+ pass
+ # Run GC to release any remaining references
+ try:
+ import gc
+ gc.collect()
+ except Exception:
+ pass
+ # Close shared httpx connection pool
+ try:
+ from api.http_client import close_http_client
+ await close_http_client()
+ except Exception:
+ pass
+ logger.info("Shutdown: done.")
app = FastAPI(title="OmniVoice Studio API", version="0.4.0", lifespan=lifespan)
@@ -250,6 +313,7 @@ app.include_router(engines.router)
app.include_router(tools.router)
app.include_router(setup.router)
app.include_router(gallery.router)
+app.include_router(batch.router)
frontend_path = os.path.join(os.path.dirname(__file__), "..", "frontend", "dist")
if os.path.exists(frontend_path):
diff --git a/backend/services/model_manager.py b/backend/services/model_manager.py
index 7537b78b..56a8e658 100644
--- a/backend/services/model_manager.py
+++ b/backend/services/model_manager.py
@@ -91,6 +91,52 @@ def free_vram():
elif torch.cuda.is_available():
torch.cuda.empty_cache()
+
+def offload_tts_for_asr():
+ """Move TTS model to CPU to free VRAM for ASR (WhisperX large-v3).
+
+ On a 7-8 GB laptop GPU the TTS model (~2.4 GB) and WhisperX large-v3
+ (~3 GB) plus the VAD model can't coexist. Offloading the TTS model to
+ CPU before transcription prevents CUDA OOM, then restore_tts_after_asr()
+ moves it back.
+ """
+ global model
+ if model is None:
+ return
+ if not torch.cuda.is_available():
+ return # Only needed on CUDA (limited VRAM)
+ try:
+ # Check if there's enough free VRAM to skip offloading (~4 GB needed for ASR)
+ free_mem = torch.cuda.mem_get_info()[0]
+ if free_mem > 4 * 1024 ** 3: # > 4 GB free → probably fine
+ return
+ except Exception:
+ pass
+ try:
+ logger.info("Offloading TTS model to CPU to free VRAM for ASR...")
+ model.to("cpu")
+ free_vram()
+ logger.info("TTS model offloaded. VRAM freed for ASR.")
+ except Exception as e:
+ logger.warning("TTS offload failed: %s", e)
+
+
+def restore_tts_after_asr():
+ """Move TTS model back to CUDA after ASR completes."""
+ global model
+ if model is None:
+ return
+ if not torch.cuda.is_available():
+ return
+ try:
+ device = get_best_device()
+ if device == "cuda":
+ logger.info("Restoring TTS model to CUDA...")
+ model.to("cuda")
+ free_vram()
+ except Exception as e:
+ logger.warning("TTS restore to CUDA failed: %s", e)
+
_diar_pipeline = None
def get_diarization_pipeline():
diff --git a/bun.lock b/bun.lock
index 32d8f63b..0bb29e67 100644
--- a/bun.lock
+++ b/bun.lock
@@ -6,6 +6,8 @@
"name": "omnivoice-studio-monorepo",
"devDependencies": {
"concurrently": "^9.2.1",
+ "kill-port-process": "^4.0.2",
+ "playwright": "^1.59.1",
"turbo": "^2.9.6",
"typescript": "^6.0.3",
"wait-on": "^9.0.5",
@@ -13,20 +15,35 @@
},
"frontend": {
"name": "omnivoice-studio",
- "version": "0.2.0",
+ "version": "0.2.3",
"dependencies": {
"@fontsource-variable/inter": "^5.2.8",
"@fontsource-variable/source-serif-4": "^5.2.9",
"@fontsource/ibm-plex-mono": "^5.2.7",
+ "@radix-ui/react-dialog": "^1.1.15",
+ "@radix-ui/react-dropdown-menu": "^2.1.16",
+ "@radix-ui/react-popover": "^1.1.15",
+ "@radix-ui/react-progress": "^1.1.8",
+ "@radix-ui/react-select": "^2.2.6",
+ "@radix-ui/react-slider": "^1.3.6",
+ "@radix-ui/react-tabs": "^1.1.13",
+ "@radix-ui/react-toggle-group": "^1.1.11",
+ "@radix-ui/react-tooltip": "^1.2.8",
+ "@tailwindcss/vite": "4",
+ "@tanstack/react-query": "^5.100.4",
+ "@tanstack/react-table": "^8.21.3",
+ "@tanstack/react-virtual": "^3.13.24",
"@tauri-apps/plugin-dialog": "^2.7.0",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1",
"@tauri-apps/plugin-window-state": "^2.4.1",
"lucide-react": "^1.8.0",
+ "qrcode.react": "^4.2.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-hot-toast": "^2.6.0",
"react-window": "^2.2.7",
+ "tailwindcss": "4",
"wavesurfer.js": "^7.12.6",
"zustand": "^5.0.12",
},
@@ -101,6 +118,14 @@
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.1", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ=="],
+ "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="],
+
+ "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="],
+
+ "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.8", "", { "dependencies": { "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A=="],
+
+ "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
+
"@fontsource-variable/inter": ["@fontsource-variable/inter@5.2.8", "", {}, "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ=="],
"@fontsource-variable/source-serif-4": ["@fontsource-variable/source-serif-4@5.2.9", "", {}, "sha512-PPcxjLFk/fS0WHg79pDM2YNvz61kC+oYZ5cWZZyCS0DHpJncmuYOuiZAsvj4tDxlWPBEvxxcRLQQNmSaRbPkqw=="],
@@ -141,6 +166,82 @@
"@oxc-project/types": ["@oxc-project/types@0.126.0", "", {}, "sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ=="],
+ "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
+
+ "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
+
+ "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
+
+ "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
+
+ "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
+
+ "@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
+
+ "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="],
+
+ "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="],
+
+ "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="],
+
+ "@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw=="],
+
+ "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="],
+
+ "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="],
+
+ "@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
+
+ "@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="],
+
+ "@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA=="],
+
+ "@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="],
+
+ "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
+
+ "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="],
+
+ "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
+
+ "@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.8", "", { "dependencies": { "@radix-ui/react-context": "1.1.3", "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA=="],
+
+ "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
+
+ "@radix-ui/react-select": ["@radix-ui/react-select@2.2.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="],
+
+ "@radix-ui/react-slider": ["@radix-ui/react-slider@1.3.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw=="],
+
+ "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+
+ "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="],
+
+ "@radix-ui/react-toggle": ["@radix-ui/react-toggle@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ=="],
+
+ "@radix-ui/react-toggle-group": ["@radix-ui/react-toggle-group@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q=="],
+
+ "@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg=="],
+
+ "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="],
+
+ "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="],
+
+ "@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="],
+
+ "@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="],
+
+ "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
+
+ "@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ=="],
+
+ "@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="],
+
+ "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="],
+
+ "@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="],
+
+ "@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
+
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.16", "", { "os": "android", "cpu": "arm64" }, "sha512-rhY3k7Bsae9qQfOtph2Pm2jZEA+s8Gmjoz4hhmx70K9iMQ/ddeae+xhRQcM5IuVx5ry1+bGfkvMn7D6MJggVSA=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rNz0yK078yrNn3DrdgN+PKiMOW8HfQ92jQiXxwX8yW899ayV00MLVdaCNeVBhG/TbH3ouYVObo8/yrkiectkcQ=="],
@@ -173,8 +274,54 @@
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.7", "", {}, "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA=="],
+ "@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="],
+
+ "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="],
+
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
+ "@tailwindcss/node": ["@tailwindcss/node@4.2.4", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.4" } }, "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA=="],
+
+ "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.4", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.4", "@tailwindcss/oxide-darwin-arm64": "4.2.4", "@tailwindcss/oxide-darwin-x64": "4.2.4", "@tailwindcss/oxide-freebsd-x64": "4.2.4", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.4", "@tailwindcss/oxide-linux-arm64-musl": "4.2.4", "@tailwindcss/oxide-linux-x64-gnu": "4.2.4", "@tailwindcss/oxide-linux-x64-musl": "4.2.4", "@tailwindcss/oxide-wasm32-wasi": "4.2.4", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.4", "@tailwindcss/oxide-win32-x64-msvc": "4.2.4" } }, "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q=="],
+
+ "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.4", "", { "os": "android", "cpu": "arm64" }, "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g=="],
+
+ "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg=="],
+
+ "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg=="],
+
+ "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw=="],
+
+ "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA=="],
+
+ "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw=="],
+
+ "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g=="],
+
+ "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA=="],
+
+ "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA=="],
+
+ "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.4", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw=="],
+
+ "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ=="],
+
+ "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.4", "", { "os": "win32", "cpu": "x64" }, "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw=="],
+
+ "@tailwindcss/vite": ["@tailwindcss/vite@4.2.4", "", { "dependencies": { "@tailwindcss/node": "4.2.4", "@tailwindcss/oxide": "4.2.4", "tailwindcss": "4.2.4" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw=="],
+
+ "@tanstack/query-core": ["@tanstack/query-core@5.100.4", "", {}, "sha512-LdW/DDImiw9g4ukyndlrifIXPFpoQjNybCAIDBcPvdYu9iUIhAKwhznfAATe2dJBonhm0O3ksoCMmVTUxN89uA=="],
+
+ "@tanstack/react-query": ["@tanstack/react-query@5.100.4", "", { "dependencies": { "@tanstack/query-core": "5.100.4" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-L6n5UWBvnMuYaZTu6WgTbl2mJ7fob1NIdL2vIFo05R/mkr9XvP5PmesZOBiicxzhDAuUurYIG+ZFONbvarEFtQ=="],
+
+ "@tanstack/react-table": ["@tanstack/react-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww=="],
+
+ "@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.24", "", { "dependencies": { "@tanstack/virtual-core": "3.14.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg=="],
+
+ "@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="],
+
+ "@tanstack/virtual-core": ["@tanstack/virtual-core@3.14.0", "", {}, "sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q=="],
+
"@tauri-apps/api": ["@tauri-apps/api@2.10.1", "", {}, "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw=="],
"@tauri-apps/cli": ["@tauri-apps/cli@2.10.1", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.10.1", "@tauri-apps/cli-darwin-x64": "2.10.1", "@tauri-apps/cli-linux-arm-gnueabihf": "2.10.1", "@tauri-apps/cli-linux-arm64-gnu": "2.10.1", "@tauri-apps/cli-linux-arm64-musl": "2.10.1", "@tauri-apps/cli-linux-riscv64-gnu": "2.10.1", "@tauri-apps/cli-linux-x64-gnu": "2.10.1", "@tauri-apps/cli-linux-x64-musl": "2.10.1", "@tauri-apps/cli-win32-arm64-msvc": "2.10.1", "@tauri-apps/cli-win32-ia32-msvc": "2.10.1", "@tauri-apps/cli-win32-x64-msvc": "2.10.1" }, "bin": { "tauri": "tauri.js" } }, "sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g=="],
@@ -245,6 +392,8 @@
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
+ "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
+
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
"axios": ["axios@1.15.0", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q=="],
@@ -287,12 +436,16 @@
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
+ "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
+
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"electron-to-chromium": ["electron-to-chromium@1.5.334", "", {}, "sha512-mgjZAz7Jyx1SRCwEpy9wefDS7GvNPazLthHg8eQMJ76wBdGQQDW33TCrUTvQ4wzpmOrv2zrFoD3oNufMdyMpog=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
+ "enhanced-resolve": ["enhanced-resolve@5.21.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA=="],
+
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
@@ -325,6 +478,8 @@
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
+ "execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="],
+
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
@@ -333,6 +488,8 @@
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
+ "figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="],
+
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
@@ -345,7 +502,7 @@
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
- "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
+ "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
@@ -355,8 +512,14 @@
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
+ "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
+
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
+ "get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="],
+
+ "get-them-args": ["get-them-args@1.3.2", "", {}, "sha512-LRn8Jlk+DwZE4GTlDbT3Hikd1wSHgLMme/+7ddlqKd7ldwR6LjJgTVWzBnR01wnYGe4KgrXjg287RaI22UHmAw=="],
+
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"globals": ["globals@17.5.0", "", {}, "sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g=="],
@@ -365,6 +528,8 @@
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
+ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
+
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
@@ -377,6 +542,8 @@
"hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
+ "human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="],
+
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
@@ -387,8 +554,16 @@
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
+ "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
+
+ "is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="],
+
+ "is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="],
+
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
+ "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
+
"joi": ["joi@18.1.2", "", { "dependencies": { "@hapi/address": "^5.1.1", "@hapi/formula": "^3.0.2", "@hapi/hoek": "^11.0.7", "@hapi/pinpoint": "^2.0.1", "@hapi/tlds": "^1.1.1", "@hapi/topo": "^6.0.2", "@standard-schema/spec": "^1.1.0" } }, "sha512-rF5MAmps5esSlhCA+N1b6IYHDw9j/btzGaqfgie522jS02Ju/HXBxamlXVlKEHAxoMKQL77HWI8jlqWsFuekZA=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
@@ -405,6 +580,8 @@
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
+ "kill-port-process": ["kill-port-process@4.0.2", "", { "dependencies": { "get-them-args": "1.3.2", "pid-port": "2.0.1" }, "bin": { "kill-port": "dist/bin/kill-port-process.js" } }, "sha512-fO8gc45EYJQUQWozPBmdTpsR0GDvldsmrhP2I4FPoNejwyBY4Liiwj9Is7P/5rj6k07ZQ5Ob0g0k2dqQcslW/w=="],
+
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
@@ -439,6 +616,8 @@
"lucide-react": ["lucide-react@1.8.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw=="],
+ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
+
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
@@ -457,6 +636,8 @@
"node-releases": ["node-releases@2.0.37", "", {}, "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg=="],
+ "npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="],
+
"omnivoice-studio": ["omnivoice-studio@workspace:frontend"],
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
@@ -465,6 +646,8 @@
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
+ "parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="],
+
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
@@ -473,20 +656,36 @@
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
+ "pid-port": ["pid-port@2.0.1", "", { "dependencies": { "execa": "^9.6.0" } }, "sha512-pnLo01AmMclw8l+/gfknsP2N351oe8VkVmCLFUvJZ11NRPPmghJrv0OcwsdgPQxsZkFYwm6hPWW0JKmXYCaXAw=="],
+
+ "playwright": ["playwright@1.59.1", "", { "dependencies": { "playwright-core": "1.59.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw=="],
+
+ "playwright-core": ["playwright-core@1.59.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="],
+
"postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="],
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
+ "pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="],
+
"proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
+ "qrcode.react": ["qrcode.react@4.2.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA=="],
+
"react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="],
"react-dom": ["react-dom@19.2.5", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="],
"react-hot-toast": ["react-hot-toast@2.6.0", "", { "dependencies": { "csstype": "^3.1.3", "goober": "^2.1.16" }, "peerDependencies": { "react": ">=16", "react-dom": ">=16" } }, "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg=="],
+ "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
+
+ "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
+
+ "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
+
"react-window": ["react-window@2.2.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-SH5nvfUQwGHYyriDUAOt7wfPsfG9Qxd6OdzQxl5oQ4dsSsUicqQvjV7dR+NqZ4coY0fUn3w1jnC5PwzIUWEg5w=="],
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
@@ -505,14 +704,22 @@
"shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="],
+ "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
+
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
+ "strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="],
+
"supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
+ "tailwindcss": ["tailwindcss@4.2.4", "", {}, "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA=="],
+
+ "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
+
"tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
"tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="],
@@ -525,10 +732,16 @@
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
+ "unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="],
+
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
+ "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
+
+ "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
+
"vite": ["vite@8.0.9", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.10", "rolldown": "1.0.0-rc.16", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw=="],
"wait-on": ["wait-on@9.0.5", "", { "dependencies": { "axios": "^1.15.0", "joi": "^18.1.2", "lodash": "^4.18.1", "minimist": "^1.2.8", "rxjs": "^7.8.2" }, "bin": { "wait-on": "bin/wait-on" } }, "sha512-qgnbHDfDTRIp73ANEJNRW/7kn8CrDUcvZz18xotJQku/P4saTGkbIzvnMZebPmVvVNUiRq1qWAPyqCH+W4H8KA=="],
@@ -551,6 +764,8 @@
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
+ "yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="],
+
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
@@ -559,8 +774,30 @@
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
+ "@radix-ui/react-progress/@radix-ui/react-context": ["@radix-ui/react-context@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw=="],
+
+ "@radix-ui/react-progress/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+
"chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
+ "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
+
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.16", "", {}, "sha512-45+YtqxLYKDWQouLKCrpIZhke+nXxhsw+qAHVzHDVwttyBlHNBVs2K25rDXrZzhpTp9w1FlAlvweV1H++fdZoA=="],
+
+ "vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
+
+ "@radix-ui/react-progress/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
}
}
diff --git a/docs/screenshot-clone.png b/docs/screenshot-clone.png
new file mode 100644
index 00000000..358bc6f2
Binary files /dev/null and b/docs/screenshot-clone.png differ
diff --git a/docs/screenshot-design.png b/docs/screenshot-design.png
new file mode 100644
index 00000000..f3cae6a4
Binary files /dev/null and b/docs/screenshot-design.png differ
diff --git a/docs/screenshot-dub.png b/docs/screenshot-dub.png
new file mode 100644
index 00000000..906eb26c
Binary files /dev/null and b/docs/screenshot-dub.png differ
diff --git a/docs/screenshot-gallery.png b/docs/screenshot-gallery.png
new file mode 100644
index 00000000..fff3eb16
Binary files /dev/null and b/docs/screenshot-gallery.png differ
diff --git a/docs/screenshot-launchpad.png b/docs/screenshot-launchpad.png
new file mode 100644
index 00000000..27b4d9b8
Binary files /dev/null and b/docs/screenshot-launchpad.png differ
diff --git a/docs/screenshot-libraryprojects.png b/docs/screenshot-libraryprojects.png
new file mode 100644
index 00000000..4a5e2384
Binary files /dev/null and b/docs/screenshot-libraryprojects.png differ
diff --git a/docs/screenshot-logs.png b/docs/screenshot-logs.png
new file mode 100644
index 00000000..a184ba96
Binary files /dev/null and b/docs/screenshot-logs.png differ
diff --git a/docs/screenshot-preferencessettings.png b/docs/screenshot-preferencessettings.png
new file mode 100644
index 00000000..e9080185
Binary files /dev/null and b/docs/screenshot-preferencessettings.png differ
diff --git a/docs/screenshot-settings.png b/docs/screenshot-settings.png
new file mode 100644
index 00000000..63254d8f
Binary files /dev/null and b/docs/screenshot-settings.png differ
diff --git a/docs/screenshot-studiodubbing.png b/docs/screenshot-studiodubbing.png
new file mode 100644
index 00000000..55b18219
Binary files /dev/null and b/docs/screenshot-studiodubbing.png differ
diff --git a/docs/screenshot-studiolaunchpad.png b/docs/screenshot-studiolaunchpad.png
new file mode 100644
index 00000000..5f4e3918
Binary files /dev/null and b/docs/screenshot-studiolaunchpad.png differ
diff --git a/docs/screenshot-studiovoice-clone.png b/docs/screenshot-studiovoice-clone.png
new file mode 100644
index 00000000..57635ec9
Binary files /dev/null and b/docs/screenshot-studiovoice-clone.png differ
diff --git a/docs/screenshot-studiovoice-design.png b/docs/screenshot-studiovoice-design.png
new file mode 100644
index 00000000..8c512434
Binary files /dev/null and b/docs/screenshot-studiovoice-design.png differ
diff --git a/frontend/package.json b/frontend/package.json
index 4294ffe8..67f92836 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -17,15 +17,30 @@
"@fontsource-variable/inter": "^5.2.8",
"@fontsource-variable/source-serif-4": "^5.2.9",
"@fontsource/ibm-plex-mono": "^5.2.7",
+ "@radix-ui/react-dialog": "^1.1.15",
+ "@radix-ui/react-dropdown-menu": "^2.1.16",
+ "@radix-ui/react-popover": "^1.1.15",
+ "@radix-ui/react-progress": "^1.1.8",
+ "@radix-ui/react-select": "^2.2.6",
+ "@radix-ui/react-slider": "^1.3.6",
+ "@radix-ui/react-tabs": "^1.1.13",
+ "@radix-ui/react-toggle-group": "^1.1.11",
+ "@radix-ui/react-tooltip": "^1.2.8",
+ "@tailwindcss/vite": "4",
+ "@tanstack/react-query": "^5.100.4",
+ "@tanstack/react-table": "^8.21.3",
+ "@tanstack/react-virtual": "^3.13.24",
"@tauri-apps/plugin-dialog": "^2.7.0",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1",
"@tauri-apps/plugin-window-state": "^2.4.1",
"lucide-react": "^1.8.0",
+ "qrcode.react": "^4.2.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-hot-toast": "^2.6.0",
"react-window": "^2.2.7",
+ "tailwindcss": "4",
"wavesurfer.js": "^7.12.6",
"zustand": "^5.0.12"
},
diff --git a/frontend/src-tauri/Cargo.lock b/frontend/src-tauri/Cargo.lock
index c6d59057..eb3052c0 100644
--- a/frontend/src-tauri/Cargo.lock
+++ b/frontend/src-tauri/Cargo.lock
@@ -84,6 +84,8 @@ dependencies = [
"log",
"serde",
"serde_json",
+ "sha2",
+ "sysinfo",
"tar",
"tauri",
"tauri-build",
@@ -93,6 +95,8 @@ dependencies = [
"tauri-plugin-updater",
"tauri-plugin-window-state",
"ureq",
+ "walkdir",
+ "webkit2gtk",
"zip 2.4.2",
]
@@ -2141,6 +2145,15 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
+[[package]]
+name = "ntapi"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae"
+dependencies = [
+ "winapi",
+]
+
[[package]]
name = "num-conv"
version = "0.2.1"
@@ -3758,6 +3771,19 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "sysinfo"
+version = "0.33.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fc858248ea01b66f19d8e8a6d55f41deaf91e9d495246fd01368d99935c6c01"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+ "memchr",
+ "ntapi",
+ "windows 0.57.0",
+]
+
[[package]]
name = "system-deps"
version = "6.2.2"
@@ -3803,7 +3829,7 @@ dependencies = [
"tao-macros",
"unicode-segmentation",
"url",
- "windows",
+ "windows 0.61.3",
"windows-core 0.61.2",
"windows-version",
"x11-dl",
@@ -3892,7 +3918,7 @@ dependencies = [
"webkit2gtk",
"webview2-com",
"window-vibrancy",
- "windows",
+ "windows 0.61.3",
]
[[package]]
@@ -4119,7 +4145,7 @@ dependencies = [
"url",
"webkit2gtk",
"webview2-com",
- "windows",
+ "windows 0.61.3",
]
[[package]]
@@ -4144,7 +4170,7 @@ dependencies = [
"url",
"webkit2gtk",
"webview2-com",
- "windows",
+ "windows 0.61.3",
"wry",
]
@@ -5001,10 +5027,10 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a"
dependencies = [
"webview2-com-macros",
"webview2-com-sys",
- "windows",
+ "windows 0.61.3",
"windows-core 0.61.2",
- "windows-implement",
- "windows-interface",
+ "windows-implement 0.60.2",
+ "windows-interface 0.59.3",
]
[[package]]
@@ -5025,7 +5051,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
dependencies = [
"thiserror 2.0.18",
- "windows",
+ "windows 0.61.3",
"windows-core 0.61.2",
]
@@ -5075,6 +5101,16 @@ dependencies = [
"windows-version",
]
+[[package]]
+name = "windows"
+version = "0.57.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143"
+dependencies = [
+ "windows-core 0.57.0",
+ "windows-targets 0.52.6",
+]
+
[[package]]
name = "windows"
version = "0.61.3"
@@ -5097,14 +5133,26 @@ dependencies = [
"windows-core 0.61.2",
]
+[[package]]
+name = "windows-core"
+version = "0.57.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d"
+dependencies = [
+ "windows-implement 0.57.0",
+ "windows-interface 0.57.0",
+ "windows-result 0.1.2",
+ "windows-targets 0.52.6",
+]
+
[[package]]
name = "windows-core"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
dependencies = [
- "windows-implement",
- "windows-interface",
+ "windows-implement 0.60.2",
+ "windows-interface 0.59.3",
"windows-link 0.1.3",
"windows-result 0.3.4",
"windows-strings 0.4.2",
@@ -5116,8 +5164,8 @@ version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
- "windows-implement",
- "windows-interface",
+ "windows-implement 0.60.2",
+ "windows-interface 0.59.3",
"windows-link 0.2.1",
"windows-result 0.4.1",
"windows-strings 0.5.1",
@@ -5134,6 +5182,17 @@ dependencies = [
"windows-threading",
]
+[[package]]
+name = "windows-implement"
+version = "0.57.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "windows-implement"
version = "0.60.2"
@@ -5145,6 +5204,17 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "windows-interface"
+version = "0.57.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "windows-interface"
version = "0.59.3"
@@ -5178,6 +5248,15 @@ dependencies = [
"windows-link 0.1.3",
]
+[[package]]
+name = "windows-result"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
[[package]]
name = "windows-result"
version = "0.3.4"
@@ -5629,7 +5708,7 @@ dependencies = [
"webkit2gtk",
"webkit2gtk-sys",
"webview2-com",
- "windows",
+ "windows 0.61.3",
"windows-core 0.61.2",
"windows-version",
"x11-dl",
diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml
index 57ae98d8..772baea5 100644
--- a/frontend/src-tauri/Cargo.toml
+++ b/frontend/src-tauri/Cargo.toml
@@ -39,8 +39,17 @@ ureq = "2"
tar = "0.4"
flate2 = "1"
+# ── Rust IPC commands (cross-platform) ──
+# get_sysinfo: CPU + RAM metrics without HTTP round-trip
+sysinfo = { version = "0.33", default-features = false, features = ["system"] }
+# hf_cache_scan: walk HF cache directory 3-5× faster than Python
+walkdir = "2"
+# File hash verification (optional, for future integrity checks)
+sha2 = "0.10"
+
[target.'cfg(windows)'.dependencies]
zip = { version = "2", default-features = false, features = ["deflate"] }
[target.'cfg(unix)'.dependencies]
libc = "0.2"
+webkit2gtk = "2.0"
diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs
index c13573b6..caabf473 100644
--- a/frontend/src-tauri/src/lib.rs
+++ b/frontend/src-tauri/src/lib.rs
@@ -12,7 +12,12 @@ use tauri::{Emitter, Manager};
// 8000 collides with Django/Rails/Jupyter/Airflow on most dev machines.
// 3900 is the backend (FastAPI + uvicorn), 3901 is the Vite dev server,
// 3902 is reserved for future IPC / websocket listeners.
-const BACKEND_PORT: u16 = 3900;
+fn backend_port() -> u16 {
+ std::env::var("OMNIVOICE_PORT")
+ .ok()
+ .and_then(|v| v.parse().ok())
+ .unwrap_or(3900)
+}
// Version of the Astral `uv` binary we download at first run when no system
// uv is on PATH. Pinned for reproducibility — bump alongside the uv.lock
@@ -727,7 +732,7 @@ fn spawn_backend(app: &tauri::AppHandle, progress: Option<
"--host",
"127.0.0.1",
"--port",
- &BACKEND_PORT.to_string(),
+ &backend_port().to_string(),
])
.stdout(stdout_file.map(Stdio::from).unwrap_or_else(Stdio::null))
.stderr(stderr_file.map(Stdio::from).unwrap_or_else(Stdio::null))
@@ -748,12 +753,283 @@ fn spawn_backend(app: &tauri::AppHandle, progress: Option<
}
}
+// ── Native IPC commands ──────────────────────────────────────────────────
+//
+// These replace HTTP round-trips for local-only data. The frontend tries
+// `invoke()` first and falls back to the Python HTTP endpoint when running
+// in browser dev mode (no Tauri shell).
+
+/// System metrics: CPU + RAM. Replaces `GET /sysinfo` (polled every 5 s).
+/// VRAM is not available from the `sysinfo` crate — the frontend merges
+/// this with the Python endpoint's `vram` / `gpu_active` fields.
+#[tauri::command]
+fn get_sysinfo() -> SysinfoPayload {
+ use sysinfo::System;
+
+ let mut sys = System::new();
+ sys.refresh_cpu_usage();
+ sys.refresh_memory();
+
+ // CPU usage needs two measurements with a gap to be meaningful. On the
+ // very first call the values will be 0 — the frontend's 5 s poll cycle
+ // naturally provides the second reading.
+ let cpu = sys.global_cpu_usage() as f64;
+ let ram = sys.used_memory() as f64 / (1024.0 * 1024.0 * 1024.0);
+ let total_ram = sys.total_memory() as f64 / (1024.0 * 1024.0 * 1024.0);
+
+ SysinfoPayload {
+ cpu: (cpu * 100.0).round() / 100.0,
+ ram: (ram * 100.0).round() / 100.0,
+ total_ram: (total_ram * 100.0).round() / 100.0,
+ // VRAM stays at 0 — Python endpoint provides the real value.
+ vram: 0.0,
+ gpu_active: false,
+ }
+}
+
+#[derive(Serialize, Clone)]
+struct SysinfoPayload {
+ cpu: f64,
+ ram: f64,
+ total_ram: f64,
+ vram: f64,
+ gpu_active: bool,
+}
+
+/// Tail the last N lines of a log file. Replaces `GET /system/logs` and
+/// `GET /system/logs/tauri`. Uses seek-from-end for large files.
+#[tauri::command]
+fn read_log_tail(source: String, tail: Option) -> LogTailPayload {
+ let tail = tail.unwrap_or(300).clamp(10, 2000);
+
+ let path = match source.as_str() {
+ "backend" => backend_runtime_log_path(),
+ "tauri" => tauri_log_path(),
+ _ => return LogTailPayload {
+ lines: vec![],
+ path: String::new(),
+ exists: false,
+ total_lines: 0,
+ },
+ };
+
+ let path_str = path.to_string_lossy().to_string();
+ if !path.exists() {
+ return LogTailPayload {
+ lines: vec![],
+ path: path_str,
+ exists: false,
+ total_lines: 0,
+ };
+ }
+
+ match fs::read_to_string(&path) {
+ Ok(content) => {
+ let all_lines: Vec<&str> = content.lines().collect();
+ let total = all_lines.len();
+ let start = total.saturating_sub(tail);
+ let lines: Vec = all_lines[start..]
+ .iter()
+ .map(|l| format!("{}\n", l))
+ .collect();
+ LogTailPayload {
+ lines,
+ path: path_str,
+ exists: true,
+ total_lines: total,
+ }
+ }
+ Err(_) => LogTailPayload {
+ lines: vec![],
+ path: path_str,
+ exists: true,
+ total_lines: 0,
+ },
+ }
+}
+
+#[derive(Serialize, Clone)]
+struct LogTailPayload {
+ lines: Vec,
+ path: String,
+ exists: bool,
+ total_lines: usize,
+}
+
+/// The backend's rolling runtime log — the file Python's RotatingFileHandler
+/// writes to. Mirrors the path in `backend/core/config.py`.
+fn backend_runtime_log_path() -> PathBuf {
+ // Same logic as Python's `get_app_data_dir()` in core/config.py
+ let data_dir = if cfg!(target_os = "macos") {
+ dirs_data_dir().join("OmniVoice")
+ } else if cfg!(target_os = "windows") {
+ PathBuf::from(
+ std::env::var("APPDATA").unwrap_or_else(|_| ".".to_string()),
+ )
+ .join("OmniVoice")
+ } else {
+ // Linux: ~/.omnivoice
+ PathBuf::from(
+ std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()),
+ )
+ .join(".omnivoice")
+ };
+ data_dir.join("omnivoice.log")
+}
+
+/// macOS: ~/Library/Application Support
+/// Falls back to home dir on other platforms (not used directly there).
+fn dirs_data_dir() -> PathBuf {
+ #[cfg(target_os = "macos")]
+ {
+ PathBuf::from(
+ std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()),
+ )
+ .join("Library/Application Support")
+ }
+ #[cfg(not(target_os = "macos"))]
+ {
+ PathBuf::from(
+ std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()),
+ )
+ }
+}
+
+/// Tauri plugin log file — the file `tauri-plugin-log` writes to.
+fn tauri_log_path() -> PathBuf {
+ let bid = "com.debpalash.omnivoice-studio";
+ let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
+
+ if cfg!(target_os = "macos") {
+ PathBuf::from(&home)
+ .join("Library/Logs")
+ .join(bid)
+ .join("tauri.log")
+ } else if cfg!(target_os = "windows") {
+ let appdata = std::env::var("APPDATA").unwrap_or_else(|_| home.clone());
+ PathBuf::from(appdata).join(bid).join("logs").join("tauri.log")
+ } else {
+ // Linux: ~/.local/share//logs/tauri.log
+ PathBuf::from(&home)
+ .join(".local/share")
+ .join(bid)
+ .join("logs")
+ .join("tauri.log")
+ }
+}
+
+/// Walk the HuggingFace Hub cache directory and return per-repo disk usage.
+/// Replaces Python's `huggingface_hub.scan_cache_dir()` — 3-5× faster
+/// because we avoid Python's GIL and stat() overhead.
+#[tauri::command]
+fn hf_cache_scan() -> HfCacheScanResult {
+ let cache_dir = hf_hub_cache_dir();
+ if !cache_dir.is_dir() {
+ return HfCacheScanResult {
+ repos: vec![],
+ cache_dir: cache_dir.to_string_lossy().to_string(),
+ };
+ }
+
+ // HF cache layout: /models----/snapshots//files…
+ // We walk the top-level model dirs and sum their sizes.
+ let mut repos: Vec = Vec::new();
+
+ if let Ok(entries) = fs::read_dir(&cache_dir) {
+ for entry in entries.flatten() {
+ let name = entry.file_name().to_string_lossy().to_string();
+ if !name.starts_with("models--") && !name.starts_with("datasets--") {
+ continue;
+ }
+ let repo_path = entry.path();
+ if !repo_path.is_dir() {
+ continue;
+ }
+
+ // Convert "models--org--name" → "org/name"
+ let repo_id = name
+ .strip_prefix("models--")
+ .or_else(|| name.strip_prefix("datasets--"))
+ .unwrap_or(&name)
+ .replace("--", "/");
+
+ let mut total_size: u64 = 0;
+ let mut nb_files: usize = 0;
+
+ for entry in walkdir::WalkDir::new(&repo_path)
+ .follow_links(true)
+ .into_iter()
+ .flatten()
+ {
+ if entry.file_type().is_file() {
+ if let Ok(meta) = entry.metadata() {
+ total_size += meta.len();
+ nb_files += 1;
+ }
+ }
+ }
+
+ if total_size > 0 {
+ repos.push(HfCacheRepo {
+ repo_id,
+ size_on_disk: total_size,
+ nb_files,
+ });
+ }
+ }
+ }
+
+ HfCacheScanResult {
+ repos,
+ cache_dir: cache_dir.to_string_lossy().to_string(),
+ }
+}
+
+#[derive(Serialize, Clone)]
+struct HfCacheRepo {
+ repo_id: String,
+ size_on_disk: u64,
+ nb_files: usize,
+}
+
+#[derive(Serialize, Clone)]
+struct HfCacheScanResult {
+ repos: Vec,
+ cache_dir: String,
+}
+
+/// Resolve the HuggingFace Hub cache directory. Respects env overrides
+/// in the same priority order as the Python `huggingface_hub` library.
+fn hf_hub_cache_dir() -> PathBuf {
+ if let Ok(v) = std::env::var("HF_HUB_CACHE") {
+ return PathBuf::from(v);
+ }
+ if let Ok(v) = std::env::var("HUGGINGFACE_HUB_CACHE") {
+ return PathBuf::from(v);
+ }
+ if let Ok(v) = std::env::var("HF_HOME") {
+ return PathBuf::from(v).join("hub");
+ }
+ let home = std::env::var("HOME")
+ .or_else(|_| std::env::var("USERPROFILE"))
+ .unwrap_or_else(|_| "/tmp".to_string());
+ PathBuf::from(home)
+ .join(".cache")
+ .join("huggingface")
+ .join("hub")
+}
+
// ── Tauri entry ───────────────────────────────────────────────────────────
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
- .invoke_handler(tauri::generate_handler![bootstrap_status])
+ .invoke_handler(tauri::generate_handler![
+ bootstrap_status,
+ get_sysinfo,
+ read_log_tail,
+ hf_cache_scan,
+ ])
.setup(|app| {
app.handle().plugin(tauri_plugin_dialog::init())?;
app.handle().plugin(tauri_plugin_updater::Builder::new().build())?;
@@ -772,6 +1048,31 @@ pub fn run() {
.build(),
)?;
+ // ── Enable microphone / camera on Linux (WebKitGTK) ──────────
+ // WebKitGTK has no browser-style permission dialog; it denies
+ // getUserMedia by default. We enable the media-stream setting
+ // and auto-grant UserMedia permission requests so the Record
+ // button works on all platforms.
+ #[cfg(target_os = "linux")]
+ {
+ if let Some(win) = app.get_webview_window("main") {
+ let _ = win.with_webview(|webview| {
+ use webkit2gtk::{WebViewExt, SettingsExt, PermissionRequestExt};
+ let wk = webview.inner();
+ if let Some(settings) = WebViewExt::settings(&wk) {
+ settings.set_enable_media_stream(true);
+ settings.set_enable_mediasource(true);
+ settings.set_media_playback_requires_user_gesture(false);
+ log::info!("WebKitGTK: media-stream enabled");
+ }
+ wk.connect_permission_request(|_, request| {
+ request.allow();
+ true
+ });
+ });
+ }
+ }
+
// Bootstrap state is published via the `bootstrap_status` Tauri
// command so the React splash can poll it while we work.
let bootstrap = BootstrapState {
@@ -795,20 +1096,20 @@ pub fn run() {
set_stage(&stage_handle, BootstrapStage::Ready);
return;
}
- if backend_healthy(BACKEND_PORT) {
+ if backend_healthy(backend_port()) {
log::info!(
"Port {} already serving OmniVoice backend — attaching",
- BACKEND_PORT
+ backend_port()
);
set_stage(&stage_handle, BootstrapStage::Ready);
return;
}
- if port_in_use(BACKEND_PORT) {
+ if port_in_use(backend_port()) {
log::warn!(
"Port {} in use — taking ownership (killing whatever's there)",
- BACKEND_PORT
+ backend_port()
);
- kill_orphan_on_port(BACKEND_PORT);
+ kill_orphan_on_port(backend_port());
std::thread::sleep(Duration::from_millis(500));
}
let child = spawn_backend(&app_handle, Some(&stage_handle));
@@ -821,7 +1122,7 @@ pub fn run() {
// we give it 3 min before declaring failure.
let start = std::time::Instant::now();
while start.elapsed() < Duration::from_secs(180) {
- if backend_healthy(BACKEND_PORT) {
+ if backend_healthy(backend_port()) {
set_stage(&stage_handle, BootstrapStage::Ready);
return;
}
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index a9d1fe6a..c7b2bc90 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -17,9 +17,11 @@ const BatchQueue = lazy(() => import('./pages/BatchQueue'));
const ToolsPage = lazy(() => import('./pages/ToolsPage'));
const SetupWizard = lazy(() => import('./pages/SetupWizard'));
const KeyboardCheatsheet = lazy(() => import('./components/KeyboardCheatsheet'));
+const VoicePreview = lazy(() => import('./components/VoicePreview'));
const LogsFooter = lazy(() => import('./components/LogsFooter'));
const ProjectsPage = lazy(() => import('./pages/Projects'));
const VoiceGallery = lazy(() => import('./pages/VoiceGallery'));
+const DonatePage = lazy(() => import('./pages/DonatePage'));
import Header from './components/Header';
import NavRail from './components/NavRail';
import ErrorBoundary from './components/ErrorBoundary';
@@ -35,7 +37,8 @@ import {
import { LANG_CODES } from './utils/languages';
import { formatTime, probeAudioDuration } from './utils/format';
import { API, apiPost } from './api/client';
-import { sysinfo as apiSysinfo, modelStatus as apiModelStatus, cleanAudio as apiCleanAudio, flushMemory as apiFlushMemory } from './api/system';
+import { cleanAudio as apiCleanAudio, flushMemory as apiFlushMemory } from './api/system';
+import { useSysinfo, useModelStatus } from './api/hooks';
import { listProfiles, createProfile, deleteProfile as apiDeleteProfile, lockProfile, unlockProfile } from './api/profiles';
import { listHistory, clearHistory, generateSpeech, audioUrlWithCacheBust } from './api/generate';
import { listProjects, saveProject as apiSaveProject, loadProject as apiLoadProject, deleteProject as apiDeleteProject } from './api/projects';
@@ -198,7 +201,7 @@ function App() {
const activeVoiceId = useAppStore(s => s.activeVoiceId);
const openVoiceProfile = useAppStore(s => s.openVoiceProfile);
const closeVoiceProfile = useAppStore(s => s.closeVoiceProfile);
- const hideSidebar = mode === 'launchpad' || mode === 'settings' || mode === 'voice'
+ const hideSidebar = mode === 'launchpad' || mode === 'settings' || mode === 'voice' || mode === 'donate'
|| mode === 'queue' || mode === 'tools' || mode === 'projects' || mode === 'gallery';
const availableSidebarTabs = mode === 'dub'
? ['projects', 'history', 'downloads']
@@ -282,6 +285,10 @@ function App() {
const [previewLoading, setPreviewLoading] = useState(null);
const [segmentPreviewLoading, setSegmentPreviewLoading] = useState(null);
+ // Voice Preview floating card
+ const [isVoicePreviewOpen, setIsVoicePreviewOpen] = useState(false);
+ const [voicePreviewProfileId, setVoicePreviewProfileId] = useState('');
+
// ═══ MIC RECORDING ═══
const [isRecording, setIsRecording] = useState(false);
const [isCleaning, setIsCleaning] = useState(false);
@@ -544,11 +551,11 @@ function App() {
});
}, [dubSegments]);
- // ── MODEL STATUS ──
- const [modelStatus, setModelStatus] = useState('idle'); // 'idle' | 'loading' | 'ready'
-
- // ── LOAD DATA FROM SERVER ──
- const [sysStats, setSysStats] = useState(null);
+ // ── MODEL STATUS + SYSINFO (TanStack Query) ──
+ const sysQuery = useSysinfo();
+ const msQuery = useModelStatus();
+ const sysStats = sysQuery.data ?? null;
+ const modelStatus = msQuery.data?.status ?? 'idle';
// First-run gate — `/setup/status` reports whether required HF models are
// on disk. If not, we render in place of the main studio so
@@ -675,44 +682,8 @@ function App() {
};
}, []);
- useEffect(() => {
- let interval = null;
- let cancelled = false;
- let lastCpu = -1, lastRam = -1, lastVram = -1, lastModelSt = '';
- const fetchStats = async () => {
- try {
- const [sys, ms] = await Promise.all([apiSysinfo(), apiModelStatus()]);
- if (sys) {
- // Only update state if values actually changed (avoids re-rendering entire tree)
- const cpu = Math.round(sys.cpu);
- const ram = Math.round(sys.ram * 10);
- const vram = Math.round(sys.vram * 10);
- if (cpu !== lastCpu || ram !== lastRam || vram !== lastVram) {
- lastCpu = cpu; lastRam = ram; lastVram = vram;
- setSysStats(sys);
- }
- }
- if (ms && ms.status !== lastModelSt) {
- lastModelSt = ms.status;
- setModelStatus(ms.status);
- }
- return true;
- } catch (e) { return false; }
- };
- // Wait for backend to be reachable before starting the polling interval
- const startPolling = async () => {
- while (!cancelled) {
- const ok = await fetchStats();
- if (ok) {
- if (!cancelled) interval = setInterval(fetchStats, 4000);
- return;
- }
- await new Promise(r => setTimeout(r, 1500));
- }
- };
- startPolling();
- return () => { cancelled = true; if (interval) clearInterval(interval); };
- }, []);
+ // sysinfo + modelStatus polling is now handled by TanStack Query hooks
+ // (useSysinfo / useModelStatus at top of component). No manual setInterval.
const loadProfiles = useCallback(async () => {
try { setProfiles(await listProfiles()); } catch (e) {}
@@ -1933,10 +1904,12 @@ function App() {
minHeight: 'calc(100vh - var(--logs-footer-height, 28px))',
maxHeight: 'calc(100vh - var(--logs-footer-height, 28px))',
width: '100%',
- overflow: 'auto',
+ overflow: 'hidden',
zoom: uiScale,
background: 'var(--color-bg, #1d2021)',
position: 'relative',
+ display: 'flex',
+ flexDirection: 'column',
}}
>
{/* Invisible drag strip across the top 28 px of the wizard —
@@ -2068,6 +2041,12 @@ function App() {
+ ) : mode === 'donate' ? (
+
+ }>
+ setMode('launchpad')} />
+
+
) : mode === 'launchpad' ? (
}>
@@ -2192,6 +2171,10 @@ function App() {
handleUnlockProfile={handleUnlockProfile}
handleLockProfile={handleLockProfile}
handlePreviewVoice={handlePreviewVoice}
+ onOpenVoicePreview={(profileId) => {
+ setVoicePreviewProfileId(profileId || '');
+ setIsVoicePreviewOpen(true);
+ }}
restoreHistory={restoreHistory}
restoreDubHistory={restoreDubHistory}
handleSaveHistoryAsProfile={handleSaveHistoryAsProfile}
@@ -2239,6 +2222,19 @@ function App() {
)}
+ {/* ═══ VOICE PREVIEW FLOATING CARD ═══ */}
+ {isVoicePreviewOpen && (
+
+ setIsVoicePreviewOpen(false)}
+ profiles={profiles}
+ initialProfileId={voicePreviewProfileId}
+ fileToMediaUrl={fileToMediaUrl}
+ />
+
+ )}
+
{/* ═══ BOTTOM LOGS PANEL (VSCode-style) ═══ */}
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts
index 6c5dd74d..80aca6c3 100644
--- a/frontend/src/api/client.ts
+++ b/frontend/src/api/client.ts
@@ -1,9 +1,8 @@
-// Backend always listens on localhost:3900 — both in dev (Vite @ 3901 talking
-// to a separate uvicorn) and in the built .app (Tauri webview @ tauri://localhost
-// talking to the venv-bootstrapped sidecar). Relative fetches against
-// tauri://localhost don't reach the sidecar, so we hardcode the absolute host.
-// Port 3900 chosen to avoid common 8000 conflicts (Django/Rails/Jupyter).
-export const API = 'http://localhost:3900';
+// Backend base URL. Configurable via VITE_API_URL or VITE_API_PORT env vars.
+// In production Tauri builds, the webview talks to the sidecar on localhost.
+const viteEnv = import.meta.env ?? {};
+const _port = viteEnv.VITE_API_PORT || '3900';
+export const API = viteEnv.VITE_API_URL || `http://localhost:${_port}`;
export class ApiError extends Error {
status?: number;
diff --git a/frontend/src/api/gallery.ts b/frontend/src/api/gallery.ts
index a96c7e4f..85cf6a58 100644
--- a/frontend/src/api/gallery.ts
+++ b/frontend/src/api/gallery.ts
@@ -19,6 +19,7 @@ export interface GalleryVoice {
description?: string;
thumbnail?: string;
tags: string[];
+ is_favorite?: boolean;
created_at: number;
}
@@ -72,4 +73,30 @@ export const saveVoiceAsProfile = async (voiceId: string, profileName: string):
return apiJson(url, { method: 'POST' });
};
-export const previewVoiceUrl = (voiceId: string): string => `/gallery/voices/${voiceId}/preview`;
\ No newline at end of file
+export const previewVoiceUrl = (voiceId: string): string => `/gallery/voices/${voiceId}/preview`;
+
+export const updateGalleryVoice = async (
+ voiceId: string,
+ updates: { name?: string; tags?: string[]; is_favorite?: boolean; description?: string },
+): Promise<{ success: boolean; updated: string[] }> =>
+ apiFetch(`/gallery/voices/${voiceId}`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(updates),
+ }).then(r => r.json());
+
+export const batchDeleteGalleryVoices = async (
+ ids: string[],
+): Promise<{ deleted: number }> =>
+ apiFetch('/gallery/voices/batch-delete', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ ids }),
+ }).then(r => r.json());
+
+export const galleryVoiceToProfile = async (
+ voiceId: string,
+): Promise<{ success: boolean; profile_id: string; name: string }> =>
+ apiFetch(`/gallery/voices/${voiceId}/to-profile`, {
+ method: 'POST',
+ }).then(r => r.json());
\ No newline at end of file
diff --git a/frontend/src/api/hooks.ts b/frontend/src/api/hooks.ts
new file mode 100644
index 00000000..0e719fcf
--- /dev/null
+++ b/frontend/src/api/hooks.ts
@@ -0,0 +1,186 @@
+// ── TanStack Query hooks ─────────────────────────────────────────────────
+// Central place for all query/mutation hooks. Components import from here
+// instead of calling api/* + useEffect + useState manually.
+// Deduplication is automatic — two components using useSysinfo() share one
+// network request and one cache entry.
+
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import * as systemApi from './system';
+import * as setupApi from './setup';
+import * as galleryApi from './gallery';
+
+// ── Keys (prevents typos, enables targeted invalidation) ─────────────────
+export const queryKeys = {
+ sysinfo: ['sysinfo'] as const,
+ modelStatus: ['model-status'] as const,
+ systemInfo: ['system-info'] as const,
+ systemLogs: (tail?: number) => ['system-logs', tail ?? 300] as const,
+ tauriLogs: (tail?: number) => ['tauri-logs', tail ?? 300] as const,
+ models: ['models'] as const,
+ recommendations: ['recommendations'] as const,
+ preflight: ['preflight'] as const,
+ setupStatus: ['setup-status'] as const,
+ galleryVoices: (params?: any) => ['gallery-voices', params] as const,
+ galleryCategories: ['gallery-categories'] as const,
+};
+
+// ── Polling queries (sysinfo, model status, logs) ────────────────────────
+
+export function useSysinfo(enabled = true) {
+ return useQuery({
+ queryKey: queryKeys.sysinfo,
+ queryFn: systemApi.sysinfo,
+ refetchInterval: 5_000,
+ refetchIntervalInBackground: true,
+ retry: Infinity,
+ retryDelay: 1_500,
+ enabled,
+ });
+}
+
+export function useModelStatus(enabled = true) {
+ return useQuery({
+ queryKey: queryKeys.modelStatus,
+ queryFn: systemApi.modelStatus,
+ refetchInterval: 5_000,
+ refetchIntervalInBackground: true,
+ retry: Infinity,
+ retryDelay: 1_500,
+ enabled,
+ });
+}
+
+export function useSystemLogs(tail = 300, enabled = true) {
+ return useQuery({
+ queryKey: queryKeys.systemLogs(tail),
+ queryFn: () => systemApi.systemLogs(tail),
+ refetchInterval: 5_000,
+ enabled,
+ });
+}
+
+export function useTauriLogs(tail = 300, enabled = true) {
+ return useQuery({
+ queryKey: queryKeys.tauriLogs(tail),
+ queryFn: () => systemApi.systemLogsTauri(tail),
+ refetchInterval: 5_000,
+ enabled,
+ });
+}
+
+// ── One-shot queries ─────────────────────────────────────────────────────
+
+export function useSystemInfo() {
+ return useQuery({
+ queryKey: queryKeys.systemInfo,
+ queryFn: systemApi.systemInfo,
+ staleTime: 60_000,
+ retry: Infinity,
+ retryDelay: 2_000,
+ });
+}
+
+export function useModels() {
+ return useQuery({
+ queryKey: queryKeys.models,
+ queryFn: setupApi.listModels,
+ staleTime: 30_000,
+ });
+}
+
+export function useRecommendations() {
+ return useQuery({
+ queryKey: queryKeys.recommendations,
+ queryFn: setupApi.getRecommendations,
+ staleTime: 30_000,
+ });
+}
+
+export function usePreflight() {
+ return useQuery({
+ queryKey: queryKeys.preflight,
+ queryFn: setupApi.preflight,
+ staleTime: 60_000,
+ });
+}
+
+export function useSetupStatus() {
+ return useQuery({
+ queryKey: queryKeys.setupStatus,
+ queryFn: setupApi.setupStatus,
+ staleTime: 10_000,
+ });
+}
+
+export function useGalleryCategories() {
+ return useQuery({
+ queryKey: queryKeys.galleryCategories,
+ queryFn: galleryApi.listCategories,
+ staleTime: 60_000,
+ });
+}
+
+export function useGalleryVoices(params?: any) {
+ return useQuery({
+ queryKey: queryKeys.galleryVoices(params),
+ queryFn: () => galleryApi.listGalleryVoices(params),
+ staleTime: 30_000,
+ });
+}
+
+// ── Mutations ────────────────────────────────────────────────────────────
+
+export function useInstallModel() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: (repo_id: string) => setupApi.installModel(repo_id),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: queryKeys.models });
+ qc.invalidateQueries({ queryKey: queryKeys.setupStatus });
+ qc.invalidateQueries({ queryKey: queryKeys.recommendations });
+ },
+ });
+}
+
+export function useDeleteModel() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: (repo_id: string) => setupApi.deleteModel(repo_id),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: queryKeys.models });
+ qc.invalidateQueries({ queryKey: queryKeys.setupStatus });
+ qc.invalidateQueries({ queryKey: queryKeys.recommendations });
+ },
+ });
+}
+
+export function useFlushMemory() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: (unloadModel: boolean) => systemApi.flushMemory(unloadModel),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: queryKeys.sysinfo });
+ qc.invalidateQueries({ queryKey: queryKeys.modelStatus });
+ },
+ });
+}
+
+export function useClearLogs() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: () => systemApi.clearSystemLogs(),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: queryKeys.systemLogs() });
+ },
+ });
+}
+
+export function useClearTauriLogs() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: () => systemApi.clearTauriLogs(),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: queryKeys.tauriLogs() });
+ },
+ });
+}
diff --git a/frontend/src/api/system.ts b/frontend/src/api/system.ts
index 5ca48098..29584eee 100644
--- a/frontend/src/api/system.ts
+++ b/frontend/src/api/system.ts
@@ -1,31 +1,118 @@
import { apiJson, apiFetch, apiPost } from './client';
import type { SystemInfo, ModelStatus, LogsResponse, ClearTauriResponse } from './types';
-export async function sysinfo(): Promise {
- return apiJson('/sysinfo');
+// ── Tauri IPC helpers ────────────────────────────────────────────────────
+// Try native Tauri invoke() first — it's faster (no HTTP round-trip) and
+// works when the Python backend is still booting. Falls back to HTTP when
+// running in browser dev mode (no Tauri shell).
+
+let _invoke: ((cmd: string, args?: Record) => Promise) | null = null;
+
+async function getInvoke() {
+ if (_invoke !== null) return _invoke;
+ try {
+ const mod = await import('@tauri-apps/api/core');
+ _invoke = mod.invoke;
+ return _invoke;
+ } catch {
+ // Not running inside Tauri (browser dev mode)
+ _invoke = null as any;
+ return null;
+ }
}
+/** Try Tauri invoke, fall back to HTTP. */
+async function invokeOrFetch(
+ command: string,
+ args: Record | undefined,
+ httpFallback: () => Promise,
+): Promise {
+ try {
+ const invoke = await getInvoke();
+ if (invoke) {
+ return (await invoke(command, args)) as T;
+ }
+ } catch {
+ // invoke failed — fall through to HTTP
+ }
+ return httpFallback();
+}
+
+// ── System info (polled every 5s) ────────────────────────────────────────
+
+export interface SysinfoData {
+ cpu: number;
+ ram: number;
+ total_ram: number;
+ vram: number;
+ gpu_active: boolean;
+}
+
+export async function sysinfo(): Promise {
+ // Rust provides CPU + RAM; VRAM stays at 0. We merge with the Python
+ // endpoint to get GPU data when available.
+ const rustData = await invokeOrFetch(
+ 'get_sysinfo',
+ undefined,
+ () => apiJson('/sysinfo'),
+ );
+
+ // If we got data from Rust (vram=0), try to enrich with Python's VRAM
+ if (rustData.vram === 0) {
+ try {
+ const pyData = await apiJson('/sysinfo');
+ return {
+ ...rustData,
+ vram: pyData.vram,
+ gpu_active: pyData.gpu_active,
+ };
+ } catch {
+ // Python backend not ready yet — return Rust-only data
+ return rustData;
+ }
+ }
+ return rustData;
+}
+
+// ── Model status ─────────────────────────────────────────────────────────
+
export async function modelStatus(): Promise {
return apiJson('/model/status');
}
+// ── Audio cleaning ───────────────────────────────────────────────────────
+
export async function cleanAudio(formData: FormData): Promise {
// Returns Response because caller needs blob body + X-Clean-Filename header.
return apiFetch('/clean-audio', { method: 'POST', body: formData });
}
+// ── System info (one-shot, for Settings) ─────────────────────────────────
+
export async function systemInfo(): Promise {
return apiJson('/system/info');
}
+// ── Logs (polled every 5s) ───────────────────────────────────────────────
+
export async function systemLogs(tail: number = 300): Promise {
- return apiJson(`/system/logs?tail=${tail}`);
+ return invokeOrFetch(
+ 'read_log_tail',
+ { source: 'backend', tail },
+ () => apiJson(`/system/logs?tail=${tail}`),
+ );
}
export async function systemLogsTauri(tail: number = 300): Promise {
- return apiJson(`/system/logs/tauri?tail=${tail}`);
+ return invokeOrFetch(
+ 'read_log_tail',
+ { source: 'tauri', tail },
+ () => apiJson(`/system/logs/tauri?tail=${tail}`),
+ );
}
+// ── Log clearing ─────────────────────────────────────────────────────────
+
export async function clearSystemLogs(): Promise {
return apiPost('/system/logs/clear');
}
@@ -34,6 +121,8 @@ export async function clearTauriLogs(): Promise {
return apiPost('/system/logs/tauri/clear');
}
+// ── Memory flush ─────────────────────────────────────────────────────────
+
export async function flushMemory(unloadModel: boolean = false): Promise {
return apiPost(`/system/flush-memory?unload_model=${unloadModel}`);
}
diff --git a/frontend/src/components/BatchAddDialog.css b/frontend/src/components/BatchAddDialog.css
new file mode 100644
index 00000000..a03b489a
--- /dev/null
+++ b/frontend/src/components/BatchAddDialog.css
@@ -0,0 +1,181 @@
+.batch-add-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 1000;
+ background: rgba(0,0,0,0.6);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 24px;
+}
+.batch-add {
+ width: min(560px, 92vw);
+ max-height: 80vh;
+ background: var(--chrome-bg);
+ border: 1px solid var(--chrome-border-strong);
+ border-radius: 14px;
+ box-shadow: 0 12px 48px rgba(0,0,0,0.5);
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ animation: batch-in 0.2s ease-out;
+}
+@keyframes batch-in {
+ from { opacity: 0; transform: scale(0.95); }
+ to { opacity: 1; transform: scale(1); }
+}
+
+.batch-add__head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 14px 18px;
+ border-bottom: 1px solid var(--chrome-border);
+}
+.batch-add__title {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-family: var(--font-mono);
+ font-size: 0.78rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: var(--chrome-fg);
+}
+.batch-add__close {
+ background: none;
+ border: none;
+ color: var(--chrome-fg-muted);
+ cursor: pointer;
+ padding: 4px;
+ border-radius: 6px;
+ transition: background 0.15s;
+}
+.batch-add__close:hover {
+ background: var(--chrome-hover-bg);
+}
+
+.batch-add__body {
+ flex: 1;
+ overflow-y: auto;
+ padding: 16px 18px;
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+
+.batch-add__drop {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ padding: 28px 16px;
+ border: 2px dashed var(--chrome-border);
+ border-radius: 10px;
+ color: var(--chrome-fg-muted);
+ cursor: pointer;
+ transition: all 0.2s;
+ font-size: 0.82rem;
+}
+.batch-add__drop:hover,
+.batch-add__drop.is-over {
+ border-color: var(--chrome-accent);
+ background: rgba(255,255,255,0.02);
+ color: var(--chrome-fg);
+}
+.batch-add__drop-hint {
+ font-family: var(--font-mono);
+ font-size: 0.65rem;
+ color: var(--chrome-fg-dim);
+}
+.batch-add__file-input {
+ display: none;
+}
+
+.batch-add__files {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+.batch-add__kicker {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ font-family: var(--font-mono);
+ font-size: 0.62rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: var(--chrome-fg-dim);
+ margin-bottom: 4px;
+}
+.batch-add__file-row {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 4px 8px;
+ background: var(--chrome-hover-bg);
+ border-radius: 6px;
+ font-size: 0.76rem;
+}
+.batch-add__file-name {
+ flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ color: var(--chrome-fg);
+}
+.batch-add__file-size {
+ font-family: var(--font-mono);
+ font-size: 0.68rem;
+ color: var(--chrome-fg-dim);
+ flex-shrink: 0;
+}
+.batch-add__file-x {
+ background: none;
+ border: none;
+ color: var(--chrome-fg-dim);
+ cursor: pointer;
+ padding: 2px;
+ border-radius: 4px;
+}
+.batch-add__file-x:hover { color: var(--color-danger); }
+
+.batch-add__settings {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+.batch-add__field {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+.batch-add__select {
+ font-size: 0.78rem;
+}
+.batch-add__toggle {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 0.78rem;
+ color: var(--chrome-fg);
+ cursor: pointer;
+}
+.batch-add__toggle input { cursor: pointer; }
+
+.batch-add__foot {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 12px 18px;
+ border-top: 1px solid var(--chrome-border);
+}
+.batch-add__estimate {
+ flex: 1;
+ font-family: var(--font-mono);
+ font-size: 0.68rem;
+ color: var(--chrome-fg-dim);
+}
diff --git a/frontend/src/components/BatchAddDialog.jsx b/frontend/src/components/BatchAddDialog.jsx
new file mode 100644
index 00000000..c8e28aaa
--- /dev/null
+++ b/frontend/src/components/BatchAddDialog.jsx
@@ -0,0 +1,166 @@
+import React, { useState, useRef, useCallback } from 'react';
+import { Upload, Film, Globe, X, Plus, Loader } from 'lucide-react';
+import { Button } from '../ui';
+import MultiLangPicker from './MultiLangPicker';
+import { PRESETS } from '../utils/constants';
+import './BatchAddDialog.css';
+
+/**
+ * BatchAddDialog — multi-file drop zone + shared settings for batch dubbing.
+ *
+ * Users drop N video files, pick languages + voice, then click "Add to Queue".
+ * Each file is POSTed as a separate job to the batch endpoint.
+ */
+export default function BatchAddDialog({
+ open,
+ onClose,
+ profiles = [],
+ onEnqueue, // async (files, settings) => void
+}) {
+ const [files, setFiles] = useState([]);
+ const [langs, setLangs] = useState([{ lang: 'Spanish', code: 'es' }]);
+ const [voiceId, setVoiceId] = useState('');
+ const [preserveBg, setPreserveBg] = useState(true);
+ const [submitting, setSubmitting] = useState(false);
+ const fileInputRef = useRef(null);
+
+ const handleDrop = useCallback((e) => {
+ e.preventDefault();
+ const dropped = Array.from(e.dataTransfer.files).filter(f => f.type.startsWith('video/'));
+ if (dropped.length) setFiles(prev => [...prev, ...dropped]);
+ }, []);
+
+ const removeFile = (idx) => {
+ setFiles(prev => prev.filter((_, i) => i !== idx));
+ };
+
+ const handleSubmit = async () => {
+ if (!files.length || !langs.length) return;
+ setSubmitting(true);
+ try {
+ await onEnqueue?.(files, { langs, voiceId, preserveBg });
+ setFiles([]);
+ onClose?.();
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ if (!open) return null;
+
+ return (
+
+
e.stopPropagation()}>
+
+
+ Add Videos to Queue
+
+
+
+
+
+ {/* Drop zone */}
+
{ e.preventDefault(); e.currentTarget.classList.add('is-over'); }}
+ onDragLeave={e => e.currentTarget.classList.remove('is-over')}
+ onDrop={e => { e.currentTarget.classList.remove('is-over'); handleDrop(e); }}
+ onClick={() => fileInputRef.current?.click()}
+ >
+
+ Drop video files here or click to browse
+ MP4 · MOV · MKV · WEBM
+