diff --git a/.codex b/.codex new file mode 100644 index 00000000..e69de29b diff --git a/.gitignore b/.gitignore index a0252c76..c4c1be50 100644 --- a/.gitignore +++ b/.gitignore @@ -77,3 +77,6 @@ examples/download* examples/exp*/ omnivoice.zip frontend/src-tauri/binaries/ffmpeg + +# cuDNN 8 compat libs (auto-installed by scripts/setup_cudnn.py) +cudnn8_compat/ diff --git a/README.md b/README.md index f9eb3469..bf30d2ce 100644 --- a/README.md +++ b/README.md @@ -1,164 +1,190 @@
- OmniVoice Logo + OmniVoice Logo

OmniVoice Studio

-

Your Local Cinematic AI Dubbing Studio

+

Local cinematic AI dubbing. No API keys. No cloud. Just run it.

- Features • - Getting Started • - Roadmap • - Changelog + Features · + Quickstart · + Architecture · + Roadmap +

+

+ Download macOS DMG + Download Windows MSI + Download Linux AppImage + Download Debian .deb


- OmniVoice Studio Interface Demo + OmniVoice Studio — Launchpad
- 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. + - Star History Chart + Star History
- -
- -
- 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 +
+ { + const added = Array.from(e.target.files); + if (added.length) setFiles(prev => [...prev, ...added]); + e.target.value = ''; + }} + /> + + {/* File list */} + {files.length > 0 && ( +
+ FILES ({files.length}) + {files.map((f, i) => ( +
+ + {f.name} + {(f.size / 1024 / 1024).toFixed(1)} MB + +
+ ))} +
+ )} + + {/* Settings */} +
+
+ TARGET LANGUAGES + +
+ +
+ VOICE + +
+ + +
+
+ +
+ + {files.length > 0 && langs.length > 0 + ? `${files.length} video${files.length > 1 ? 's' : ''} × ${langs.length} lang${langs.length > 1 ? 's' : ''} = ${files.length * langs.length} job${files.length * langs.length > 1 ? 's' : ''}` + : 'Select files and languages'} + + + +
+
+
+ ); +} diff --git a/frontend/src/components/DubSegmentRow.css b/frontend/src/components/DubSegmentRow.css new file mode 100644 index 00000000..4e7ba5aa --- /dev/null +++ b/frontend/src/components/DubSegmentRow.css @@ -0,0 +1,53 @@ +/* ═══ DubSegmentRow extracted layout styles ═══ */ +.seg-check { + width: 14px; margin-right: 4px; cursor: pointer; +} +.seg-time { + width: 55px; display: flex; flex-direction: column; +} +.seg-sync-badge { + font-size: 0.5rem; margin-top: 2px; + display: inline-flex; align-items: center; gap: 2px; +} +.seg-rate-badge { + font-size: 0.5rem; margin-top: 2px; + font-variant-numeric: tabular-nums; +} +.seg-speed-badge { + font-size: 0.55rem; margin-left: 2px; +} +.seg-speaker { + width: 50px; font-size: 0.58rem; color: #a89984; +} +.seg-text-col { + flex: 1; display: flex; flex-direction: column; gap: 2px; min-width: 0; +} +.seg-orig-row { + font-size: 0.55rem; color: #6b6657; + display: flex; align-items: center; gap: 4px; + padding: 0 4px; overflow: hidden; +} +.seg-orig-label { + opacity: 0.8; text-transform: uppercase; font-weight: 600; + font-size: 0.5rem; color: #7c6f64; +} +.seg-orig-text { + flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.seg-budget-warn { color: #fabd2f; font-size: 0.5rem; } +.seg-restore-btn { + background: none; border: none; color: #83a598; + cursor: pointer; padding: 0; font-size: 0.55rem; +} +.seg-lang-select { + width: 45px; font-size: 0.55rem; padding: 1px 2px; +} +.seg-profile-select { + width: 90px; font-size: 0.6rem; padding: 1px 3px; +} +.seg-gain-slider { + width: 30px; height: 2px; padding: 0; margin: 0; +} +.seg-actions { + display: flex; gap: 1px; width: 54px; +} diff --git a/frontend/src/components/DubSegmentRow.jsx b/frontend/src/components/DubSegmentRow.jsx index dadfcce1..3a0c5e34 100644 --- a/frontend/src/components/DubSegmentRow.jsx +++ b/frontend/src/components/DubSegmentRow.jsx @@ -7,6 +7,7 @@ import { formatTime } from '../utils/format'; import { LANG_CODES } from '../utils/languages'; import { PRESETS } from '../utils/constants'; import { Menu, Button, Badge } from '../ui'; +import './DubSegmentRow.css'; const CHAR_BUDGET_RATIO = 1.3; @@ -51,24 +52,23 @@ function DubSegmentRow({ onChange={(e) => onSelect(seg.id, idx, e.nativeEvent.shiftKey)} onClick={(e) => onSelect(seg.id, idx, e.shiftKey)} disabled={disabled} - style={{ width: 14, marginRight: 4, cursor: 'pointer', accentColor: '#d3869b' }} + style={{ accentColor: '#d3869b' }} + className="seg-check" title="Select segment (shift+click for range)" /> - + {formatTime(seg.start)}–{formatTime(seg.end)} {seg.speed && seg.speed !== 1.0 && ( - 1 ? '#d3869b' : '#8ec07c', marginLeft: 2 }}> + 1 ? '#d3869b' : '#8ec07c' }}> {seg.speed.toFixed(2)}x )} {SyncIcon && ( Sync: {Math.round(seg.sync_ratio * 100)}% @@ -76,11 +76,8 @@ function DubSegmentRow({ )} {seg.rate_ratio != null && Math.abs(seg.rate_ratio - 1.0) > 0.03 && ( 1.15 ? '#fb4934' : seg.rate_ratio < 0.85 ? '#83a598' : '#a89984', - fontVariantNumeric: 'tabular-nums', - }} + className="seg-rate-badge" + style={{ color: seg.rate_ratio > 1.15 ? '#fb4934' : seg.rate_ratio < 0.85 ? '#83a598' : '#a89984' }} title={`Speech-rate fit: ${seg.rate_ratio.toFixed(2)}× relative to slot${seg.rate_error ? ` (${seg.rate_error})` : ''}`} > 📖 {seg.rate_ratio.toFixed(2)}× @@ -88,9 +85,9 @@ function DubSegmentRow({ )} - {seg.speaker_id || ''} + {seg.speaker_id || ''} - + {seg.text_original && seg.text_original !== seg.text && ( - - orig - + + orig + {seg.text_original} {overBudget && ( - + {Math.round((seg.text.length / seg.text_original.length) * 100)}% )} @@ -123,7 +120,7 @@ function DubSegmentRow({ onClick={() => onRestore(seg.id)} disabled={disabled} title="Restore original text" - style={{ background: 'none', border: 'none', color: '#83a598', cursor: 'pointer', padding: 0, fontSize: '0.55rem' }} + className="seg-restore-btn" > ↺ @@ -132,8 +129,7 @@ function DubSegmentRow({ setQuery(e.target.value)} + placeholder="Search languages…" + spellCheck={false} + /> + +
+ {popularFiltered.length > 0 && ( + <> +
Popular
+ {popularFiltered.map(item => ( + + ))} + + )} +
All Languages
+ {filteredLangs.slice(0, 50).map(lc => ( + + ))} + {filteredLangs.length > 50 && ( +
+ +{filteredLangs.length - 50} more — type to narrow +
+ )} + {filteredLangs.length === 0 && popularFiltered.length === 0 && ( +
No matches
+ )} +
+ + )} + + ); +} diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index 4b6a80ab..ee60589e 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -44,6 +44,7 @@ export default function Sidebar(props) { saveProject, loadProject, deleteProject, handleSelectProfile, handleDeleteProfile, handleOpenVoiceProfile, handleUnlockProfile, handleLockProfile, handlePreviewVoice, + onOpenVoicePreview, restoreHistory, restoreDubHistory, handleSaveHistoryAsProfile, handleNativeExport, revealInFolder, @@ -260,6 +261,15 @@ export default function Sidebar(props) { + {onOpenVoicePreview && ( + + )} {proj.is_locked ? ( + + +
+ + +