feat: setup wizard, donate page, CI fixes, performance optimizations, and style extraction

- Implement donate page and migrate API fetching to react-query hooks
- Add setup wizard for batch job management and voice clip editing
- Refactor setup router into package (wizard, models, download sub-modules)
- Fix 9 CI test failures from setup router refactor
- Fix cross-device link error in prefs.py atomic writes
- Fix event loop mismatch in export test fixtures
- Modernize README with architecture diagram and 13 app screenshots
- Defer per-segment disk writes in dub_generate for ~6s faster dubs
- Extract 45 inline styles from Launchpad, KeyboardCheatsheet, DubSegmentRow
- Add playwright dev dep and screenshot capture script
This commit is contained in:
debpalash
2026-04-26 16:47:00 +05:30
parent 811c842a75
commit fc76e79ff8
83 changed files with 6069 additions and 1212 deletions
View File
+3
View File
@@ -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/
+127 -101
View File
@@ -1,164 +1,190 @@
<div align="center">
<img src="frontend/public/favicon.svg" alt="OmniVoice Logo" width="120" />
<img src="frontend/public/favicon.svg" alt="OmniVoice Logo" width="100" />
<h1>OmniVoice Studio</h1>
<p><b>Your Local Cinematic AI Dubbing Studio</b></p>
<p><b>Local cinematic AI dubbing. No API keys. No cloud. Just run it.</b></p>
<p>
<a href="#-features">Features</a>
<a href="#-getting-started">Getting Started</a>
<a href="#%EF%B8%8F-roadmap">Roadmap</a>
<a href="#-changelog">Changelog</a>
<a href="#features">Features</a> ·
<a href="#quickstart">Quickstart</a> ·
<a href="#architecture">Architecture</a> ·
<a href="#roadmap">Roadmap</a>
</p>
<p>
<a href="https://github.com/debpalash/OmniVoice-Studio/releases/download/v0.2.2/OmniVoice.Studio_0.2.2_aarch64.dmg"><img src="https://img.shields.io/badge/macOS-DMG_(Apple_Silicon)-000?style=for-the-badge&logo=apple&logoColor=white" alt="Download macOS DMG" /></a>
<a href="https://github.com/debpalash/OmniVoice-Studio/releases/download/v0.2.2/OmniVoice.Studio_0.2.2_x64_en-US.msi"><img src="https://img.shields.io/badge/Windows-MSI_(x64)-0078D4?style=for-the-badge&logo=windows&logoColor=white" alt="Download Windows MSI" /></a>
<a href="https://github.com/debpalash/OmniVoice-Studio/releases/download/v0.2.2/OmniVoice.Studio_0.2.2_amd64.AppImage"><img src="https://img.shields.io/badge/Linux-AppImage_(x64)-FCC624?style=for-the-badge&logo=linux&logoColor=black" alt="Download Linux AppImage" /></a>
<a href="https://github.com/debpalash/OmniVoice-Studio/releases/download/v0.2.2/OmniVoice.Studio_0.2.2_amd64.deb"><img src="https://img.shields.io/badge/Debian-.deb-A81D33?style=for-the-badge&logo=debian&logoColor=white" alt="Download Debian .deb" /></a>
</p>
</div>
<br/>
<div align="center">
<img src="preview.png" alt="OmniVoice Studio Interface Demo" width="100%"/>
<img src="preview.png" alt="OmniVoice Studio — Launchpad" width="100%"/>
<br/>
<i>The timeline-based cinematic dubbing and workspace UI.</i>
<sub>Launchpad — Voice Clone · Voice Design · Video Dubbing, all in one studio.</sub>
</div>
<details>
<summary><b>📸 More screenshots</b></summary>
<br/>
<table>
<tr>
<td align="center"><img src="docs/screenshot-clone.png" width="100%"/><br/><sub><b>Voice Clone</b> — Drop a 3s clip, mirror the voice</sub></td>
<td align="center"><img src="docs/screenshot-design.png" width="100%"/><br/><sub><b>Voice Design</b> — Build voices by gender, age, accent, pitch</sub></td>
</tr>
<tr>
<td align="center" colspan="2"><img src="docs/screenshot-dub.png" width="100%"/><br/><sub><b>Video Dubbing</b> — Upload or paste URL, transcribe, translate, re-voice</sub></td>
</tr>
</table>
</details>
---
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 (0200%) 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.
<br/>
### 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** — 0200% 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 (0200%).
- [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 (0200%) 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 (816 segments per forward pass) — 35× 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)
<br/>
**✨ 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.
<div align="center">
**[⭐ Star on GitHub](https://github.com/debpalash/OmniVoice-Studio)** to follow updates.
<a href="https://star-history.com/#debpalash/OmniVoice-Studio&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=debpalash/OmniVoice-Studio&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=debpalash/OmniVoice-Studio&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=debpalash/OmniVoice-Studio&type=Date&theme=dark" width="100%" />
<img alt="Star History" src="https://api.star-history.com/svg?repos=debpalash/OmniVoice-Studio&type=Date&theme=dark" width="600" />
</picture>
</a>
</div>
<br/>
<div align="center">
Contributions and conceptual ideas are greatly appreciated — open an issue or submit a PR.
</div>
+46
View File
@@ -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())
+187
View File
@@ -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}
+25 -11
View File
@@ -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 1525 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
+41 -24
View File
@@ -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"
+124 -14
View File
@@ -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"]}
@@ -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"]:
+21
View File
@@ -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)
+216
View File
@@ -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."
),
)
+317
View File
@@ -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,
}
+402
View File
@@ -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"}
+121 -32
View File
@@ -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
+146
View File
@@ -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 (0100)")
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
+123
View File
@@ -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]
+5 -1
View File
@@ -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)
+64
View File
@@ -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):
+46
View File
@@ -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():
+239 -2
View File
@@ -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=="],
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 353 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

+15
View File
@@ -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"
},
+92 -13
View File
@@ -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",
+9
View File
@@ -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"
+310 -9
View File
@@ -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<R: tauri::Runtime>(app: &tauri::AppHandle<R>, 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<R: tauri::Runtime>(app: &tauri::AppHandle<R>, 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<usize>) -> 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<String> = 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<String>,
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/<bid>/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: <cache>/models--<org>--<name>/snapshots/<hash>/files…
// We walk the top-level model dirs and sum their sizes.
let mut repos: Vec<HfCacheRepo> = 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<HfCacheRepo>,
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;
}
+42 -46
View File
@@ -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 <SetupWizard> 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() {
<VoiceGallery />
</Suspense>
</ErrorBoundary>
) : mode === 'donate' ? (
<ErrorBoundary name="donate">
<Suspense fallback={<LazyFallback />}>
<DonatePage onBack={() => setMode('launchpad')} />
</Suspense>
</ErrorBoundary>
) : mode === 'launchpad' ? (
<ErrorBoundary name="launchpad">
<Suspense fallback={<LazyFallback />}>
@@ -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() {
</Suspense>
)}
{/* ═══ VOICE PREVIEW FLOATING CARD ═══ */}
{isVoicePreviewOpen && (
<Suspense fallback={null}>
<VoicePreview
open={isVoicePreviewOpen}
onClose={() => setIsVoicePreviewOpen(false)}
profiles={profiles}
initialProfileId={voicePreviewProfileId}
fileToMediaUrl={fileToMediaUrl}
/>
</Suspense>
)}
{/* ═══ BOTTOM LOGS PANEL (VSCode-style) ═══ */}
<Suspense fallback={null}>
<LogsFooter />
+5 -6
View File
@@ -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;
+28 -1
View File
@@ -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`;
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());
+186
View File
@@ -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() });
},
});
}
+93 -4
View File
@@ -1,31 +1,118 @@
import { apiJson, apiFetch, apiPost } from './client';
import type { SystemInfo, ModelStatus, LogsResponse, ClearTauriResponse } from './types';
export async function sysinfo(): Promise<SystemInfo> {
return apiJson<SystemInfo>('/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<string, unknown>) => Promise<unknown>) | 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<T>(
command: string,
args: Record<string, unknown> | undefined,
httpFallback: () => Promise<T>,
): Promise<T> {
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<SysinfoData> {
// Rust provides CPU + RAM; VRAM stays at 0. We merge with the Python
// endpoint to get GPU data when available.
const rustData = await invokeOrFetch<SysinfoData>(
'get_sysinfo',
undefined,
() => apiJson<SysinfoData>('/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<SysinfoData>('/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<ModelStatus> {
return apiJson<ModelStatus>('/model/status');
}
// ── Audio cleaning ───────────────────────────────────────────────────────
export async function cleanAudio(formData: FormData): Promise<Response> {
// 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<SystemInfo> {
return apiJson<SystemInfo>('/system/info');
}
// ── Logs (polled every 5s) ───────────────────────────────────────────────
export async function systemLogs(tail: number = 300): Promise<LogsResponse> {
return apiJson<LogsResponse>(`/system/logs?tail=${tail}`);
return invokeOrFetch<LogsResponse>(
'read_log_tail',
{ source: 'backend', tail },
() => apiJson<LogsResponse>(`/system/logs?tail=${tail}`),
);
}
export async function systemLogsTauri(tail: number = 300): Promise<LogsResponse> {
return apiJson<LogsResponse>(`/system/logs/tauri?tail=${tail}`);
return invokeOrFetch<LogsResponse>(
'read_log_tail',
{ source: 'tauri', tail },
() => apiJson<LogsResponse>(`/system/logs/tauri?tail=${tail}`),
);
}
// ── Log clearing ─────────────────────────────────────────────────────────
export async function clearSystemLogs(): Promise<unknown> {
return apiPost('/system/logs/clear');
}
@@ -34,6 +121,8 @@ export async function clearTauriLogs(): Promise<ClearTauriResponse> {
return apiPost<ClearTauriResponse>('/system/logs/tauri/clear');
}
// ── Memory flush ─────────────────────────────────────────────────────────
export async function flushMemory(unloadModel: boolean = false): Promise<unknown> {
return apiPost(`/system/flush-memory?unload_model=${unloadModel}`);
}
+181
View File
@@ -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);
}
+166
View File
@@ -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 (
<div className="batch-add-overlay" onClick={onClose}>
<div className="batch-add" onClick={e => e.stopPropagation()}>
<div className="batch-add__head">
<span className="batch-add__title">
<Plus size={13} /> Add Videos to Queue
</span>
<button type="button" className="batch-add__close" onClick={onClose}>
<X size={13} />
</button>
</div>
<div className="batch-add__body">
{/* Drop zone */}
<div
className="batch-add__drop"
onDragOver={e => { 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()}
>
<Upload size={24} />
<span>Drop video files here or click to browse</span>
<span className="batch-add__drop-hint">MP4 · MOV · MKV · WEBM</span>
</div>
<input
ref={fileInputRef}
type="file"
accept="video/*"
multiple
className="batch-add__file-input"
onChange={e => {
const added = Array.from(e.target.files);
if (added.length) setFiles(prev => [...prev, ...added]);
e.target.value = '';
}}
/>
{/* File list */}
{files.length > 0 && (
<div className="batch-add__files">
<span className="batch-add__kicker">FILES ({files.length})</span>
{files.map((f, i) => (
<div key={`${f.name}-${i}`} className="batch-add__file-row">
<Film size={10} />
<span className="batch-add__file-name">{f.name}</span>
<span className="batch-add__file-size">{(f.size / 1024 / 1024).toFixed(1)} MB</span>
<button type="button" className="batch-add__file-x" onClick={() => removeFile(i)}>
<X size={9} />
</button>
</div>
))}
</div>
)}
{/* Settings */}
<div className="batch-add__settings">
<div className="batch-add__field">
<span className="batch-add__kicker"><Globe size={9} /> TARGET LANGUAGES</span>
<MultiLangPicker selected={langs} onChange={setLangs} />
</div>
<div className="batch-add__field">
<span className="batch-add__kicker">VOICE</span>
<select
className="input-base batch-add__select"
value={voiceId}
onChange={e => setVoiceId(e.target.value)}
>
<option value="">Default</option>
{profiles.filter(p => !p.instruct).length > 0 && (
<optgroup label="Clone Profiles">
{profiles.filter(p => !p.instruct).map(p => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</optgroup>
)}
{PRESETS.length > 0 && (
<optgroup label="Presets">
{PRESETS.map(p => (
<option key={p.id} value={`preset:${p.id}`}>{p.name}</option>
))}
</optgroup>
)}
</select>
</div>
<label className="batch-add__toggle">
<input type="checkbox" checked={preserveBg} onChange={e => setPreserveBg(e.target.checked)} />
<span>Preserve background audio (music/FX)</span>
</label>
</div>
</div>
<div className="batch-add__foot">
<span className="batch-add__estimate">
{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'}
</span>
<Button variant="ghost" size="sm" onClick={onClose}>Cancel</Button>
<Button
variant="primary"
size="sm"
onClick={handleSubmit}
disabled={!files.length || !langs.length || submitting}
loading={submitting}
leading={!submitting && <Plus size={10} />}
>
Add to Queue
</Button>
</div>
</div>
</div>
);
}
+53
View File
@@ -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;
}
+21 -28
View File
@@ -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)"
/>
<span className="segment-time" style={{ width: 55, display: 'flex', flexDirection: 'column' }}>
<span className="segment-time seg-time">
<span>
{formatTime(seg.start)}{formatTime(seg.end)}
{seg.speed && seg.speed !== 1.0 && (
<span style={{ fontSize: '0.55rem', color: seg.speed > 1 ? '#d3869b' : '#8ec07c', marginLeft: 2 }}>
<span className="seg-speed-badge" style={{ color: seg.speed > 1 ? '#d3869b' : '#8ec07c' }}>
{seg.speed.toFixed(2)}x
</span>
)}
</span>
{SyncIcon && (
<span
style={{
fontSize: '0.5rem', marginTop: 2, display: 'inline-flex',
alignItems: 'center', gap: 2, color: syncColor,
}}
className="seg-sync-badge"
style={{ color: syncColor }}
title={`Generated audio is ${Math.round(seg.sync_ratio * 100)}% the duration of original`}
>
<SyncIcon size={8} /> 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 && (
<span
style={{
fontSize: '0.5rem', marginTop: 2,
color: seg.rate_ratio > 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({
)}
</span>
<span style={{ width: 50, fontSize: '0.58rem', color: '#a89984' }}>{seg.speaker_id || ''}</span>
<span className="seg-speaker">{seg.speaker_id || ''}</span>
<span style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 2, minWidth: 0 }}>
<span className="seg-text-col">
<input
className="input-base segment-input"
value={seg.text}
@@ -109,13 +106,13 @@ function DubSegmentRow({
}
/>
{seg.text_original && seg.text_original !== seg.text && (
<span style={{ fontSize: '0.55rem', color: '#6b6657', display: 'flex', alignItems: 'center', gap: 4, padding: '0 4px', overflow: 'hidden' }}>
<span style={{ opacity: 0.8, textTransform: 'uppercase', fontWeight: 600, fontSize: '0.5rem', color: '#7c6f64' }}>orig</span>
<span style={{ flex: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }} title={seg.text_original}>
<span className="seg-orig-row">
<span className="seg-orig-label">orig</span>
<span className="seg-orig-text" title={seg.text_original}>
{seg.text_original}
</span>
{overBudget && (
<span style={{ color: '#fabd2f', fontSize: '0.5rem' }}>
<span className="seg-budget-warn">
{Math.round((seg.text.length / seg.text_original.length) * 100)}%
</span>
)}
@@ -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"
>
</button>
@@ -132,8 +129,7 @@ function DubSegmentRow({
</span>
<select
className="input-base segment-input"
style={{ width: 45, fontSize: '0.55rem', padding: '1px 2px' }}
className="input-base segment-input seg-lang-select"
value={seg.target_lang || ''}
disabled={disabled}
onChange={(e) => onEditField(seg.id, 'target_lang', e.target.value)}
@@ -145,8 +141,7 @@ function DubSegmentRow({
</select>
<select
className="input-base"
style={{ width: 90, fontSize: '0.6rem', padding: '1px 3px' }}
className="input-base seg-profile-select"
value={seg.profile_id || ''}
disabled={disabled}
onChange={(e) => onEditField(seg.id, 'profile_id', e.target.value)}
@@ -171,13 +166,11 @@ function DubSegmentRow({
title={`${Math.round((seg.gain ?? 1.0) * 100)}%`}
disabled={disabled}
onChange={(e) => onEditField(seg.id, 'gain', Number(e.target.value) / 100)}
style={{
width: 30, height: 2, padding: 0, margin: 0,
accentColor: (seg.gain ?? 1.0) > 1.2 ? '#fb4934' : (seg.gain ?? 1.0) < 0.5 ? '#83a598' : '#a89984',
}}
className="seg-gain-slider"
style={{ accentColor: (seg.gain ?? 1.0) > 1.2 ? '#fb4934' : (seg.gain ?? 1.0) < 0.5 ? '#83a598' : '#a89984' }}
/>
<div style={{ display: 'flex', gap: 1, width: 54 }}>
<div className="seg-actions">
<button
className="segment-play"
disabled={disabled}
@@ -0,0 +1,64 @@
/* ═══ Keyboard Cheatsheet Modal ═══ */
.kcs-overlay {
position: fixed; inset: 0; z-index: 9999;
background: rgba(0,0,0,0.7);
display: flex; align-items: center; justify-content: center; padding: 24px;
font-family: var(--font-sans);
}
.kcs-panel {
width: min(720px, 90vw); max-height: 82vh; overflow: auto;
padding: 22px;
background: var(--chrome-bg);
border: 1px solid var(--chrome-border-strong);
border-radius: var(--chrome-radius-pill);
}
.kcs-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 14px;
}
.kcs-header__left { display: flex; align-items: center; gap: 10px; }
.kcs-title {
margin: 0; font-family: var(--font-serif); font-style: italic;
font-weight: 400; font-size: 1.5rem; color: var(--chrome-fg);
letter-spacing: -0.01em;
}
.kcs-close {
background: none; border: none; color: var(--chrome-fg-muted); cursor: pointer;
}
.kcs-grid {
display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 18px;
}
.kcs-section-title {
font-family: var(--font-mono); font-weight: 600;
font-size: var(--chrome-label-size);
text-transform: uppercase; letter-spacing: var(--chrome-label-track);
color: var(--chrome-fg-muted); margin-bottom: 10px; padding-bottom: 6px;
border-bottom: 1px solid var(--chrome-border);
}
.kcs-items { display: flex; flex-direction: column; gap: 6px; }
.kcs-row {
display: flex; align-items: center; justify-content: space-between;
gap: 10px; font-family: var(--font-sans);
}
.kcs-desc { color: var(--chrome-fg-muted); font-size: 0.8rem; }
.kcs-keys { display: flex; gap: 3px; flex-shrink: 0; }
.kcs-key-group { display: flex; gap: 2px; }
.kcs-or {
color: var(--chrome-fg-dim); align-self: center; font-size: 0.7rem;
}
.kcs-kbd {
display: inline-flex; align-items: center; gap: 2px;
padding: 2px 8px;
min-width: 28px; height: 22px;
background: var(--chrome-hover-bg);
border: 1px solid var(--chrome-border-strong);
border-radius: var(--chrome-radius-pill);
color: var(--chrome-fg);
font-family: var(--font-mono);
font-size: 0.7rem; font-weight: 500;
}
.kcs-footer {
margin-top: 18px; text-align: center;
color: var(--chrome-fg-dim); font-family: var(--font-sans); font-size: 0.72rem;
}
+17 -62
View File
@@ -1,5 +1,6 @@
import React from 'react';
import { Command, X } from 'lucide-react';
import './KeyboardCheatsheet.css';
const SECTIONS = [
{
@@ -44,85 +45,39 @@ const SECTIONS = [
];
function Kbd({ children }) {
return (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 2,
padding: '2px 8px',
minWidth: 28, height: 22,
background: 'var(--chrome-hover-bg)',
border: '1px solid var(--chrome-border-strong)',
borderRadius: 'var(--chrome-radius-pill)',
color: 'var(--chrome-fg)',
fontFamily: 'var(--font-mono)',
fontSize: '0.7rem', fontWeight: 500,
boxShadow: 'none',
}}>
{children}
</span>
);
return <span className="kcs-kbd">{children}</span>;
}
export default function KeyboardCheatsheet({ open, onClose }) {
if (!open) return null;
return (
<div
onClick={onClose}
style={{
position: 'fixed', inset: 0, zIndex: 9999,
background: 'rgba(0,0,0,0.7)',
backdropFilter: 'none', WebkitBackdropFilter: 'none',
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24,
fontFamily: 'var(--font-sans)',
}}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
width: 'min(720px, 90vw)', maxHeight: '82vh', overflow: 'auto',
padding: 22,
background: 'var(--chrome-bg)',
border: '1px solid var(--chrome-border-strong)',
borderRadius: 'var(--chrome-radius-pill)',
boxShadow: 'none',
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div onClick={onClose} className="kcs-overlay">
<div onClick={(e) => e.stopPropagation()} className="kcs-panel">
<div className="kcs-header">
<div className="kcs-header__left">
<Command size={16} color="var(--chrome-accent)" />
<h2 style={{
margin: 0, fontFamily: 'var(--font-serif)', fontStyle: 'italic',
fontWeight: 400, fontSize: '1.5rem', color: 'var(--chrome-fg)',
letterSpacing: '-0.01em',
}}>
Keyboard shortcuts
</h2>
<h2 className="kcs-title">Keyboard shortcuts</h2>
</div>
<button onClick={onClose} style={{ background: 'none', border: 'none', color: 'var(--chrome-fg-muted)', cursor: 'pointer' }}>
<button onClick={onClose} className="kcs-close">
<X size={16} />
</button>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: 18 }}>
<div className="kcs-grid">
{SECTIONS.map((sec) => (
<div key={sec.title}>
<div style={{
fontFamily: 'var(--font-mono)', fontWeight: 600,
fontSize: 'var(--chrome-label-size)',
textTransform: 'uppercase', letterSpacing: 'var(--chrome-label-track)',
color: 'var(--chrome-fg-muted)', marginBottom: 10, paddingBottom: 6,
borderBottom: '1px solid var(--chrome-border)',
}}>{sec.title}</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div className="kcs-section-title">{sec.title}</div>
<div className="kcs-items">
{sec.items.map(([keys, desc]) => (
<div key={keys} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, fontFamily: 'var(--font-sans)' }}>
<span style={{ color: 'var(--chrome-fg-muted)', fontSize: '0.8rem' }}>{desc}</span>
<span style={{ display: 'flex', gap: 3, flexShrink: 0 }}>
<div key={keys} className="kcs-row">
<span className="kcs-desc">{desc}</span>
<span className="kcs-keys">
{keys.split(' / ').map((group, i, arr) => (
<React.Fragment key={group}>
<span style={{ display: 'flex', gap: 2 }}>
<span className="kcs-key-group">
{group.split('+').map((k) => <Kbd key={k}>{k}</Kbd>)}
</span>
{i < arr.length - 1 && <span style={{ color: 'var(--chrome-fg-dim)', alignSelf: 'center', fontSize: '0.7rem' }}>or</span>}
{i < arr.length - 1 && <span className="kcs-or">or</span>}
</React.Fragment>
))}
</span>
@@ -133,7 +88,7 @@ export default function KeyboardCheatsheet({ open, onClose }) {
))}
</div>
<div style={{ marginTop: 18, textAlign: 'center', color: 'var(--chrome-fg-dim)', fontFamily: 'var(--font-sans)', fontSize: '0.72rem' }}>
<div className="kcs-footer">
Press <Kbd>?</Kbd> any time to open this.
</div>
</div>
+38
View File
@@ -157,11 +157,49 @@
}
/* Action buttons on the right */
.logs-footer__right {
display: flex;
align-items: center;
gap: 4px;
}
.logs-footer__actions {
display: flex;
align-items: center;
gap: 2px;
}
/* Glowing donate heart */
.logs-footer__donate {
background: none;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 4px;
color: #d3869b;
margin-left: 4px;
transition: color 0.15s, transform 0.15s;
animation: heart-glow 2.5s ease-in-out infinite;
}
.logs-footer__donate:hover {
color: #f3a5b6;
transform: scale(1.15);
}
.logs-footer__donate svg {
fill: rgba(211, 134, 155, 0.25);
filter: drop-shadow(0 0 4px rgba(211, 134, 155, 0.35));
}
.logs-footer__donate:hover svg {
fill: rgba(211, 134, 155, 0.5);
filter: drop-shadow(0 0 8px rgba(211, 134, 155, 0.6));
}
@keyframes heart-glow {
0%, 100% { opacity: 0.7; transform: scale(1); }
50% { opacity: 1; transform: scale(1.08); }
}
.logs-footer__icon-btn {
background: none;
border: none;
+55 -43
View File
@@ -1,12 +1,11 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
ChevronUp, ChevronDown, RefreshCw, Trash2, Copy, Bug, X,
AlertTriangle, AlertCircle, Info, FileText,
AlertTriangle, AlertCircle, Info, FileText, Heart,
} from 'lucide-react';
import toast from 'react-hot-toast';
import {
systemLogs, systemLogsTauri, clearSystemLogs, clearTauriLogs,
} from '../api/system';
import { clearSystemLogs, clearTauriLogs } from '../api/system';
import { useSystemLogs, useTauriLogs, useClearLogs, useClearTauriLogs } from '../api/hooks';
import { getFrontendLogs, clearFrontendLogs } from '../utils/consoleBuffer';
import { Segmented } from '../ui';
import { useAppStore } from '../store';
@@ -153,19 +152,22 @@ export default function LogsFooter() {
};
}, [collapsed, height]);
const fetchBackend = useCallback(async () => {
try {
const r = await systemLogs(300);
setLines(prev => ({ ...prev, backend: r.lines || [] }));
} catch { /* backend may be warming up — don't spam toasts */ }
}, []);
// TanStack Query for backend + tauri logs
const backendLogs = useSystemLogs(300, true);
const tauriLogs = useTauriLogs(300, true);
const fetchTauri = useCallback(async () => {
try {
const r = await systemLogsTauri(300);
setLines(prev => ({ ...prev, tauri: r.lines || [] }));
} catch { /* tauri log may not exist in dev */ }
}, []);
// Sync query data into local state for the rendering pipeline
useEffect(() => {
if (backendLogs.data) {
setLines(prev => ({ ...prev, backend: backendLogs.data.lines || [] }));
}
}, [backendLogs.data]);
useEffect(() => {
if (tauriLogs.data) {
setLines(prev => ({ ...prev, tauri: tauriLogs.data.lines || [] }));
}
}, [tauriLogs.data]);
const pullFrontend = useCallback(() => {
const raw = getFrontendLogs();
@@ -177,18 +179,18 @@ export default function LogsFooter() {
const refreshAll = useCallback(async () => {
setLoading(true);
await Promise.all([fetchBackend(), fetchTauri()]);
backendLogs.refetch();
tauriLogs.refetch();
pullFrontend();
setLoading(false);
}, [fetchBackend, fetchTauri, pullFrontend]);
}, [backendLogs, tauriLogs, pullFrontend]);
// Poll on a slow interval (badges update without user action), faster
// when the panel is open + focused on a source.
// Frontend logs still need a local interval (no API, reads from buffer)
useEffect(() => {
refreshAll();
const slow = setInterval(refreshAll, collapsed ? 8000 : 3000);
return () => clearInterval(slow);
}, [refreshAll, collapsed]);
pullFrontend();
const iv = setInterval(pullFrontend, collapsed ? 8000 : 3000);
return () => clearInterval(iv);
}, [pullFrontend, collapsed]);
// Auto-scroll to bottom when new lines arrive and panel is open.
useEffect(() => {
@@ -313,25 +315,35 @@ export default function LogsFooter() {
/>
))}
</div>
{!collapsed && (
<div className="logs-footer__actions">
<button className="logs-footer__icon-btn" onClick={refreshAll} disabled={loading} title="Refresh">
<RefreshCw size={12} className={loading ? 'spinner' : ''} />
</button>
<button className="logs-footer__icon-btn" onClick={onCopy} title="Copy visible log">
<Copy size={12} />
</button>
<button className="logs-footer__icon-btn" onClick={onClear} title="Clear">
<Trash2 size={12} />
</button>
<button className="logs-footer__icon-btn logs-footer__icon-btn--report" onClick={onReportIssue} title="Report issue (copy diagnostic)">
<Bug size={12} />
</button>
<button className="logs-footer__icon-btn" onClick={() => setCollapsed(true)} title="Close">
<X size={12} />
</button>
</div>
)}
<div className="logs-footer__right">
{!collapsed && (
<div className="logs-footer__actions">
<button className="logs-footer__icon-btn" onClick={refreshAll} disabled={loading} title="Refresh">
<RefreshCw size={12} className={loading ? 'spinner' : ''} />
</button>
<button className="logs-footer__icon-btn" onClick={onCopy} title="Copy visible log">
<Copy size={12} />
</button>
<button className="logs-footer__icon-btn" onClick={onClear} title="Clear">
<Trash2 size={12} />
</button>
<button className="logs-footer__icon-btn logs-footer__icon-btn--report" onClick={onReportIssue} title="Report issue (copy diagnostic)">
<Bug size={12} />
</button>
<button className="logs-footer__icon-btn" onClick={() => setCollapsed(true)} title="Close">
<X size={12} />
</button>
</div>
)}
<button
type="button"
className="logs-footer__donate"
onClick={() => useAppStore.getState().setMode?.('donate')}
title="Support this project"
>
<Heart size={13} />
</button>
</div>
</div>
{!collapsed && (
+148
View File
@@ -0,0 +1,148 @@
.multi-lang {
position: relative;
}
.multi-lang__chips {
display: flex;
flex-wrap: wrap;
gap: 4px;
align-items: center;
min-height: 28px;
}
.multi-lang__chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
background: var(--chrome-hover-bg);
border: 1px solid var(--chrome-border);
border-radius: 999px;
font-family: var(--font-mono);
font-size: 0.68rem;
font-weight: 500;
color: var(--chrome-fg);
text-transform: uppercase;
}
.multi-lang__chip-x {
background: none;
border: none;
color: var(--chrome-fg-muted);
cursor: pointer;
padding: 0;
display: flex;
align-items: center;
border-radius: 999px;
transition: color 0.15s;
}
.multi-lang__chip-x:hover {
color: var(--color-danger);
}
.multi-lang__add {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 999px;
border: 1px dashed var(--chrome-border);
background: none;
color: var(--chrome-fg-muted);
cursor: pointer;
transition: all 0.15s;
}
.multi-lang__add:hover {
background: var(--chrome-hover-bg);
color: var(--chrome-fg);
border-style: solid;
}
.multi-lang__summary {
font-family: var(--font-mono);
font-size: 0.62rem;
color: var(--chrome-fg-dim);
margin-top: 4px;
}
/* Dropdown */
.multi-lang__drop {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 50;
margin-top: 4px;
background: var(--chrome-bg);
border: 1px solid var(--chrome-border-strong);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0,0,0,0.35);
max-height: 260px;
display: flex;
flex-direction: column;
overflow: hidden;
animation: mlp-in 0.15s ease-out;
}
@keyframes mlp-in {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
.multi-lang__search {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 10px;
border-bottom: 1px solid var(--chrome-border);
color: var(--chrome-fg-muted);
}
.multi-lang__search input {
flex: 1;
background: none;
border: none;
outline: none;
color: var(--chrome-fg);
font-family: var(--font-sans);
font-size: 0.78rem;
}
.multi-lang__list {
overflow-y: auto;
flex: 1;
padding: 4px 0;
}
.multi-lang__section {
font-family: var(--font-mono);
font-size: 0.62rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--chrome-fg-dim);
padding: 6px 10px 2px;
}
.multi-lang__option {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 5px 10px;
background: none;
border: none;
color: var(--chrome-fg);
font-family: var(--font-sans);
font-size: 0.76rem;
cursor: pointer;
text-align: left;
transition: background 0.1s;
}
.multi-lang__option:hover {
background: var(--chrome-hover-bg);
}
.multi-lang__option-code {
font-family: var(--font-mono);
font-size: 0.68rem;
color: var(--chrome-accent);
min-width: 28px;
font-weight: 600;
}
.multi-lang__more,
.multi-lang__empty {
padding: 8px 10px;
font-size: 0.7rem;
color: var(--chrome-fg-dim);
text-align: center;
}
+159
View File
@@ -0,0 +1,159 @@
import React, { useState, useMemo, useRef, useEffect } from 'react';
import { X, Search, Globe, Plus } from 'lucide-react';
import { POPULAR_LANGS } from '../utils/constants';
import { LANG_CODES } from '../utils/languages';
import './MultiLangPicker.css';
/**
* MultiLangPicker chip-based multi-language selector for batch dubbing.
*
* Shows selected languages as removable badges. Click "+" to open a
* searchable dropdown with Popular + All Languages sections.
*/
export default function MultiLangPicker({
selected = [], // array of { lang: string, code: string }
onChange, // (newSelected) => void
disabled = false,
}) {
const [dropOpen, setDropOpen] = useState(false);
const [query, setQuery] = useState('');
const dropRef = useRef(null);
const inputRef = useRef(null);
// Close dropdown on outside click
useEffect(() => {
if (!dropOpen) return;
const handler = (e) => {
if (dropRef.current && !dropRef.current.contains(e.target)) setDropOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [dropOpen]);
// Focus search when dropdown opens
useEffect(() => {
if (dropOpen && inputRef.current) inputRef.current.focus();
}, [dropOpen]);
const selectedCodes = useMemo(() => new Set(selected.map(s => s.code)), [selected]);
const addLang = (lang, code) => {
if (selectedCodes.has(code)) return;
onChange([...selected, { lang, code }]);
setQuery('');
};
const removeLang = (code) => {
onChange(selected.filter(s => s.code !== code));
};
const filteredLangs = useMemo(() => {
const q = query.toLowerCase().trim();
return LANG_CODES.filter(lc =>
!selectedCodes.has(lc.code) &&
(!q || lc.label.toLowerCase().includes(q) || lc.code.toLowerCase().includes(q))
);
}, [query, selectedCodes]);
const popularFiltered = useMemo(() => {
const q = query.toLowerCase().trim();
return POPULAR_LANGS
.map(lang => {
const match = LANG_CODES.find(lc => lc.label.toLowerCase() === lang.toLowerCase());
return match ? { lang, code: match.code } : null;
})
.filter(item => item && !selectedCodes.has(item.code) && (!q || item.lang.toLowerCase().includes(q) || item.code.includes(q)));
}, [query, selectedCodes]);
return (
<div className="multi-lang" ref={dropRef}>
<div className="multi-lang__chips">
{selected.map(s => (
<span key={s.code} className="multi-lang__chip">
<Globe size={9} />
<span>{s.code}</span>
{!disabled && (
<button
type="button"
className="multi-lang__chip-x"
onClick={() => removeLang(s.code)}
aria-label={`Remove ${s.lang}`}
>
<X size={8} />
</button>
)}
</span>
))}
{!disabled && (
<button
type="button"
className="multi-lang__add"
onClick={() => setDropOpen(!dropOpen)}
title="Add language"
>
<Plus size={10} />
</button>
)}
</div>
{selected.length > 0 && (
<div className="multi-lang__summary">
{selected.length} language{selected.length > 1 ? 's' : ''} selected
</div>
)}
{dropOpen && (
<div className="multi-lang__drop">
<div className="multi-lang__search">
<Search size={10} />
<input
ref={inputRef}
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search languages…"
spellCheck={false}
/>
</div>
<div className="multi-lang__list">
{popularFiltered.length > 0 && (
<>
<div className="multi-lang__section">Popular</div>
{popularFiltered.map(item => (
<button
key={item.code}
type="button"
className="multi-lang__option"
onClick={() => addLang(item.lang, item.code)}
>
<span className="multi-lang__option-code">{item.code}</span>
<span>{item.lang}</span>
</button>
))}
</>
)}
<div className="multi-lang__section">All Languages</div>
{filteredLangs.slice(0, 50).map(lc => (
<button
key={lc.code}
type="button"
className="multi-lang__option"
onClick={() => addLang(lc.label, lc.code)}
>
<span className="multi-lang__option-code">{lc.code}</span>
<span>{lc.label}</span>
</button>
))}
{filteredLangs.length > 50 && (
<div className="multi-lang__more">
+{filteredLangs.length - 50} more type to narrow
</div>
)}
{filteredLangs.length === 0 && popularFiltered.length === 0 && (
<div className="multi-lang__empty">No matches</div>
)}
</div>
</div>
)}
</div>
);
}
+10
View File
@@ -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) {
<button className="history-action-btn" onClick={(e) => { e.stopPropagation(); handleSelectProfile(proj); }}>
<Check size={10} /> Select
</button>
{onOpenVoicePreview && (
<button
className="history-action-btn accent"
onClick={(e) => { e.stopPropagation(); onOpenVoicePreview(proj.id); }}
title="Open interactive voice preview"
>
<Volume2 size={10} /> Try
</button>
)}
{proj.is_locked ? (
<button className="history-action-btn accent history-action-icon" onClick={(e) => { e.stopPropagation(); handleUnlockProfile(proj.id); }} title="Unlock">
<Unlock size={10} />
+90
View File
@@ -0,0 +1,90 @@
.voice-preview {
position: fixed;
bottom: 56px;
right: 16px;
z-index: 900;
width: 320px;
background: var(--chrome-bg);
border: 1px solid var(--chrome-border-strong);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
display: flex;
flex-direction: column;
overflow: hidden;
animation: voice-preview-in 0.2s ease-out;
}
@keyframes voice-preview-in {
from { opacity: 0; transform: translateY(12px) scale(0.96); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.voice-preview__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
border-bottom: 1px solid var(--chrome-border);
}
.voice-preview__title {
display: flex;
align-items: center;
gap: 6px;
font-family: var(--font-mono);
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--chrome-fg);
}
.voice-preview__close {
background: none;
border: none;
color: var(--chrome-fg-muted);
cursor: pointer;
padding: 4px;
border-radius: 6px;
transition: background 0.15s;
}
.voice-preview__close:hover {
background: var(--chrome-hover-bg);
color: var(--chrome-fg);
}
.voice-preview__body {
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 8px;
}
.voice-preview__select {
font-size: 0.78rem;
padding: 6px 8px;
}
.voice-preview__text {
font-size: 0.78rem;
padding: 8px;
resize: none;
line-height: 1.4;
min-height: 48px;
}
.voice-preview__audio {
width: 100%;
height: 32px;
border-radius: 6px;
}
.voice-preview__audio::-webkit-media-controls-panel {
background: var(--chrome-hover-bg);
}
.voice-preview__foot {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 14px 10px;
border-top: 1px solid var(--chrome-border);
}
.voice-preview__hint {
font-family: var(--font-mono);
font-size: 0.65rem;
color: var(--chrome-fg-dim);
}
+194
View File
@@ -0,0 +1,194 @@
import React, { useState, useRef, useCallback } from 'react';
import { Volume2, Play, Square, Loader, X, Mic } from 'lucide-react';
import { generateSpeech } from '../api/generate';
import { PRESETS } from '../utils/constants';
import { Button } from '../ui';
import './VoicePreview.css';
/**
* VoicePreview floating "try a voice" card.
*
* Opens as a bottom-right popover. User picks a voice profile, types a
* sentence, hits Play hears TTS output instantly (8 inference steps for
* speed). The result is disposable it doesn't save to history.
*/
const DEFAULT_TEXT = 'Hello! This is a preview of how I sound in this voice.';
export default function VoicePreview({
open,
onClose,
profiles = [],
initialProfileId = '',
fileToMediaUrl,
}) {
const [text, setText] = useState(DEFAULT_TEXT);
const [voiceId, setVoiceId] = useState(initialProfileId);
const [audioUrl, setAudioUrl] = useState(null);
const [loading, setLoading] = useState(false);
const [playing, setPlaying] = useState(false);
const audioRef = useRef(null);
const abortRef = useRef(null);
// Sync initialProfileId when it changes (e.g. clicking preview on a different profile)
React.useEffect(() => {
if (initialProfileId) setVoiceId(initialProfileId);
}, [initialProfileId]);
const handleGenerate = useCallback(async () => {
if (!text.trim()) return;
setLoading(true);
setAudioUrl(null);
const ac = new AbortController();
abortRef.current = ac;
try {
const fd = new FormData();
fd.append('text', text);
fd.append('num_step', '8'); // fast preview
fd.append('guidance_scale', '2.0');
fd.append('speed', '1.0');
fd.append('denoise', 'true');
fd.append('postprocess_output', 'true');
let profileId = voiceId;
let instruct = '';
if (profileId.startsWith('preset:')) {
const pr = PRESETS.find(p => p.id === profileId.replace('preset:', ''));
if (pr) {
instruct = Object.values(pr.attrs).filter(v => v !== 'Auto').join(', ');
}
profileId = '';
} else {
const match = profiles.find(p => p.id === profileId);
if (match?.instruct) instruct = match.instruct;
}
if (profileId) fd.append('profile_id', profileId);
if (instruct) fd.append('instruct', instruct);
const res = await generateSpeech(fd, { signal: ac.signal });
if (!res.ok) throw new Error(`TTS failed: ${res.status}`);
const blob = await res.blob();
const urls = await fileToMediaUrl(blob, null);
setAudioUrl(urls.audioUrl);
// Auto-play
setTimeout(() => {
if (audioRef.current) {
audioRef.current.play().catch(() => {});
}
}, 50);
} catch (err) {
if (err.name !== 'AbortError') {
console.error('Preview generation failed:', err);
}
} finally {
setLoading(false);
}
}, [text, voiceId, profiles, fileToMediaUrl]);
const handleStop = () => {
abortRef.current?.abort();
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.currentTime = 0;
}
setPlaying(false);
setLoading(false);
};
if (!open) return null;
return (
<div className="voice-preview">
<div className="voice-preview__head">
<span className="voice-preview__title">
<Volume2 size={13} /> Voice Preview
</span>
<button
type="button"
className="voice-preview__close"
onClick={onClose}
aria-label="Close preview"
>
<X size={12} />
</button>
</div>
<div className="voice-preview__body">
<select
className="input-base voice-preview__select"
value={voiceId}
onChange={e => setVoiceId(e.target.value)}
>
<option value="">Default voice</option>
{profiles.filter(p => !p.instruct).length > 0 && (
<optgroup label="Clone Profiles">
{profiles.filter(p => !p.instruct).map(p => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</optgroup>
)}
{profiles.filter(p => !!p.instruct).length > 0 && (
<optgroup label="Designed Voices">
{profiles.filter(p => !!p.instruct).map(p => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</optgroup>
)}
{PRESETS.length > 0 && (
<optgroup label="Presets">
{PRESETS.map(p => (
<option key={p.id} value={`preset:${p.id}`}>{p.name}</option>
))}
</optgroup>
)}
</select>
<textarea
className="input-base voice-preview__text"
value={text}
onChange={e => setText(e.target.value)}
rows={2}
placeholder="Type something to hear…"
spellCheck={false}
/>
{audioUrl && (
<audio
ref={audioRef}
src={audioUrl}
className="voice-preview__audio"
controls
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
onEnded={() => setPlaying(false)}
/>
)}
</div>
<div className="voice-preview__foot">
{loading ? (
<Button variant="ghost" size="sm" onClick={handleStop} leading={<Square size={10} />}>
Stop
</Button>
) : (
<Button
variant="primary"
size="sm"
onClick={handleGenerate}
disabled={!text.trim()}
loading={loading}
leading={!loading && <Play size={10} />}
>
{audioUrl ? 'Regenerate' : 'Preview'}
</Button>
)}
<span className="voice-preview__hint">8 steps · fast preview</span>
</div>
</div>
);
}
+97
View File
@@ -1,3 +1,50 @@
@layer theme, base, components, utilities;
@import "tailwindcss/theme.css" layer(theme);
@import "tailwindcss/utilities.css" layer(utilities);
/* ── Map design tokens → Tailwind v4 theme ──────────────────────────── */
@theme {
/* Colors — semantic */
--color-fg: #ebdbb2;
--color-fg-muted: #a89984;
--color-fg-subtle: #7c6f64;
--color-fg-inverse: #1d2021;
--color-bg: #1d2021;
--color-bg-elev-1: rgba(50, 48, 47, 0.85);
--color-bg-elev-2: rgba(0, 0, 0, 0.30);
--color-bg-elev-3: rgba(0, 0, 0, 0.18);
--color-border: rgba(255, 255, 255, 0.07);
--color-border-strong: rgba(255, 255, 255, 0.15);
--color-border-warm: rgba(243, 165, 182, 0.08);
--color-brand: #d3869b;
--color-brand-hover: #b16286;
--color-brand-glow: rgba(211, 134, 155, 0.4);
--color-accent: #fabd2f;
--color-success: #8ec07c;
--color-warn: #fe8019;
--color-danger: #fb4934;
--color-info: #83a598;
--color-chrome-bg: #0f1011;
--color-chrome-fg: #d5c4a1;
/* Radius */
--radius-xs: 2px;
--radius-sm: 3px;
--radius-md: 4px;
--radius-lg: 6px;
--radius-xl: 10px;
/* Fonts */
--font-sans: 'Inter Variable', 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
--font-mono: 'IBM Plex Mono', ui-monospace, 'SF Mono', Menlo, monospace;
--font-serif: 'Source Serif 4 Variable', 'Source Serif 4', Georgia, serif;
}
/* Font imports removed the chrome token system uses only system monospace
(ui-monospace, SFMono-Regular, Menlo, Consolas). Fraunces / Nunito / Outfit
/ Inter are no longer referenced by any rule. */
@@ -1192,6 +1239,56 @@ button:focus:not(:focus-visible) { outline: none; }
background-size: 6px 1px;
}
/* ── Launchpad extracted layout classes ──────────────────── */
.lp-hero__row {
display: flex; justify-content: space-between; align-items: flex-start;
gap: 24px; flex-wrap: wrap;
}
.lp-hero__col { max-width: 640px; }
.lp-hero__kicker-row {
display: flex; align-items: center; gap: 10px; margin-bottom: 12px;
}
.lp-hero__wave-group {
display: flex; align-items: center; gap: 2px; height: 22px;
}
.lp-section__grid {
display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
}
.lp-col { display: flex; flex-direction: column; gap: 8px; }
.lp-proj-icon--clone { background: rgba(211,134,155,0.1); }
.lp-proj-icon--design { background: rgba(142,192,124,0.1); }
.lp-proj-icon--locked { background: rgba(184,187,38,0.1); }
.lp-proj-icon--dub { background: rgba(254,128,25,0.1); overflow: hidden; }
.lp-proj-meta--italic { font-style: italic; }
.lp-locked-badge {
font-family: var(--chrome-font-mono);
font-size: var(--chrome-label-size);
letter-spacing: var(--chrome-label-track);
padding: 1px 7px;
border-radius: var(--chrome-radius-pill);
background: color-mix(in srgb, #b8bb26 10%, transparent);
border: 1px solid color-mix(in srgb, #b8bb26 40%, transparent);
color: #b8bb26; font-weight: 600;
}
.lp-empty {
flex: 1; display: flex; align-items: center; justify-content: center;
position: relative; z-index: 1;
}
.lp-empty__inner { text-align: center; max-width: 360px; }
.lp-empty__bars {
display: flex; justify-content: center; gap: 3px;
margin-bottom: 16px; opacity: 0.3;
}
.lp-empty__hint {
font-family: var(--chrome-font-mono);
font-size: 0.8rem; color: var(--chrome-fg-muted); margin: 0;
}
.lp-dub-thumb {
width: 100%; height: 100%; object-fit: cover;
border-radius: inherit; display: block;
}
/* Project rows chrome-radius pills so the launchpad project list
rhymes with the Projects page cards. Dropped the squircle corners,
the translate-X hover, and the icon rotation/scale micro-animation
+37
View File
@@ -0,0 +1,37 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
// Fonts load before tokens so --font-* can resolve immediately (no FOUT).
// Inter ships as a single variable file; Source Serif 4 too. Plex Mono has
// no variable build so we pull the three weights we use (400/500/600).
import '@fontsource-variable/inter';
import '@fontsource/ibm-plex-mono/400.css';
import '@fontsource/ibm-plex-mono/500.css';
import '@fontsource/ibm-plex-mono/600.css';
import '@fontsource-variable/source-serif-4';
import './ui';
import './index.css';
import App from './App.jsx';
import { installConsoleCapture } from './utils/consoleBuffer.js';
installConsoleCapture();
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 10_000,
retry: 1,
refetchOnWindowFocus: false,
},
},
});
export function bootstrapApp() {
createRoot(document.getElementById('root')).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</StrictMode>,
);
}
+9 -20
View File
@@ -1,22 +1,11 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
// Fonts load before tokens so --font-* can resolve immediately (no FOUT).
// Inter ships as a single variable file; Source Serif 4 too. Plex Mono has
// no variable build so we pull the three weights we use (400/500/600).
import '@fontsource-variable/inter'
import '@fontsource/ibm-plex-mono/400.css'
import '@fontsource/ibm-plex-mono/500.css'
import '@fontsource/ibm-plex-mono/600.css'
import '@fontsource-variable/source-serif-4'
import './ui' // design-system tokens load first so index.css can override if needed
import './index.css'
import App from './App.jsx'
import { installConsoleCapture } from './utils/consoleBuffer.js'
if (import.meta.env.DEV && !window.__vite_plugin_react_preamble_installed__) {
const RefreshRuntime = await import('/@react-refresh');
RefreshRuntime.default.injectIntoGlobalHook(window);
window.$RefreshReg$ = () => {};
window.$RefreshSig$ = () => (type) => type;
window.__vite_plugin_react_preamble_installed__ = true;
}
installConsoleCapture();
const { bootstrapApp } = await import('./main-app.jsx');
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)
bootstrapApp();
+14
View File
@@ -4,6 +4,7 @@ import {
} from 'lucide-react';
import { Panel, Button, Badge, Tabs } from '../ui';
import { listJobs } from '../api/engines';
import BatchAddDialog from '../components/BatchAddDialog';
import './BatchQueue.css';
/**
@@ -30,6 +31,7 @@ export default function BatchQueue({ onBack }) {
const [tab, setTab] = useState('active');
const [jobs, setJobs] = useState([]);
const [loading, setLoading] = useState(false);
const [addOpen, setAddOpen] = useState(false);
const reload = useCallback(async () => {
setLoading(true);
@@ -61,6 +63,9 @@ export default function BatchQueue({ onBack }) {
<Button variant="subtle" size="sm" onClick={reload} loading={loading} leading={<RefreshCw size={11} />}>
Refresh
</Button>
<Button variant="primary" size="sm" onClick={() => setAddOpen(true)} leading={<Plus size={11} />}>
Add Videos
</Button>
</div>
<Tabs
@@ -90,6 +95,15 @@ export default function BatchQueue({ onBack }) {
);
}
function Plus({ size }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
);
}
function JobCard({ job }) {
const meta = useMemo(() => {
try { return JSON.parse(job.meta_json || '{}'); } catch { return {}; }
+291
View File
@@ -0,0 +1,291 @@
import React, { useState } from 'react';
import { Heart, Copy, ExternalLink, ArrowLeft, Check } from 'lucide-react';
import toast from 'react-hot-toast';
import { QRCodeSVG } from 'qrcode.react';
import { Button } from '../ui';
const METHODS = [
{
id: 'github',
label: 'GitHub Sponsors',
description: 'Recurring or one-time — directly through GitHub.',
url: 'https://github.com/sponsors/omnivoice-studio',
icon: '🐙',
type: 'link',
},
{
id: 'patreon',
label: 'Patreon',
description: 'Monthly support with early access perks.',
url: 'https://patreon.com/omnivoicestudio',
icon: '🎨',
type: 'link',
},
{
id: 'kofi',
label: 'Ko-fi',
description: 'Buy the team a coffee. No account needed.',
url: 'https://ko-fi.com/omnivoicestudio',
icon: '☕',
type: 'link',
},
{
id: 'btc',
label: 'Bitcoin',
description: 'Native BTC — any amount.',
address: 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh',
icon: '₿',
type: 'crypto',
network: 'Bitcoin (BTC)',
protocol: 'bitcoin',
},
{
id: 'eth',
label: 'Ethereum',
description: 'ETH or ERC-20 tokens.',
address: '0x71C7656EC7ab88b098defB751B7401B5f6d8976F',
icon: 'Ξ',
type: 'crypto',
network: 'Ethereum (ETH / ERC-20)',
protocol: 'ethereum',
},
{
id: 'sol',
label: 'Solana',
description: 'SOL or SPL tokens.',
address: '7EcDhSYGxXyscszYEp35KHN8vvw3svAuLKTzXwCFLtV',
icon: '◎',
type: 'crypto',
network: 'Solana (SOL)',
protocol: 'solana',
},
];
function CryptoCard({ method, delay }) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(method.address);
setCopied(true);
toast.success(`${method.label} address copied`);
setTimeout(() => setCopied(false), 2000);
} catch {
toast.error('Copy failed');
}
};
return (
<div
className="group relative flex items-start gap-5 rounded-3xl border border-white/10 bg-black/30 px-5 py-5 shadow-lg backdrop-blur-xl transition-all duration-300 hover:-translate-y-1 hover:border-white/20 hover:bg-black/40 hover:shadow-[0_8px_30px_rgba(211,134,155,0.15)] sm:px-7"
style={{ animation: `fade-in-up 0.6s ease-out ${delay}ms both` }}
>
<div className="absolute inset-0 rounded-3xl bg-gradient-to-br from-white/5 to-transparent opacity-0 transition-opacity duration-300 group-hover:opacity-100 pointer-events-none" />
<div className="mt-1 flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl bg-white/5 text-xl shadow-inner border border-white/5 transition-transform duration-300 group-hover:scale-110">
{method.icon}
</div>
<div className="flex min-w-0 flex-1 flex-col gap-1.5 z-10">
<div className="text-lg font-medium tracking-tight text-white/90 group-hover:text-white transition-colors">{method.label}</div>
<div className="text-sm leading-relaxed text-white/60">{method.description}</div>
<div className="mt-2 flex flex-wrap items-center gap-2 sm:gap-3">
<code className="min-w-0 max-w-[180px] flex-1 overflow-hidden text-ellipsis whitespace-nowrap rounded-xl bg-black/40 px-3 py-2 text-[11px] text-white/50 border border-white/5 font-mono sm:text-xs sm:max-w-[280px]">
{method.address}
</code>
<div className="flex shrink-0 gap-1.5">
<button
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-white/5 text-white/50 transition-all duration-200 hover:bg-white/15 hover:text-white hover:scale-105 active:scale-95"
onClick={handleCopy}
title="Copy address"
>
{copied ? <Check size={14} className="text-emerald-400" /> : <Copy size={14} />}
</button>
{method.protocol && (
<button
className="flex h-8 px-3 shrink-0 items-center justify-center gap-1.5 rounded-xl bg-white/5 text-[11px] font-medium tracking-wide text-white/70 transition-all duration-200 hover:bg-[#d3869b]/20 hover:text-[#d3869b] hover:border-[#d3869b]/30 hover:scale-105 active:scale-95 border border-white/5"
onClick={() => window.open(`${method.protocol}:${method.address}`, '_self')}
title="Open in desktop wallet"
>
<ExternalLink size={12} />
<span className="hidden sm:inline">Open</span>
</button>
)}
</div>
</div>
<span className="mt-1 text-[0.65rem] font-semibold tracking-wider uppercase text-white/30">{method.network}</span>
</div>
<div className="hidden sm:block z-10 shrink-0 self-center rounded-xl bg-white p-2 border border-white/20 transition-transform duration-300 group-hover:scale-105 shadow-md">
<QRCodeSVG
value={`${method.protocol || ''}:${method.address}`}
size={64}
bgColor="#ffffff"
fgColor="#000000"
level="M"
includeMargin={false}
/>
</div>
</div>
);
}
function LinkCard({ method, delay }) {
return (
<div
className="group relative flex items-center gap-5 rounded-3xl border border-white/10 bg-black/30 px-5 py-5 shadow-lg backdrop-blur-xl transition-all duration-300 hover:-translate-y-1 hover:border-white/20 hover:bg-black/40 hover:shadow-[0_8px_30px_rgba(211,134,155,0.15)] sm:px-7"
style={{ animation: `fade-in-up 0.6s ease-out ${delay}ms both` }}
>
<div className="absolute inset-0 rounded-3xl bg-gradient-to-br from-white/5 to-transparent opacity-0 transition-opacity duration-300 group-hover:opacity-100 pointer-events-none" />
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl bg-white/5 text-xl shadow-inner border border-white/5 transition-transform duration-300 group-hover:scale-110">
{method.icon}
</div>
<div className="flex min-w-0 flex-1 flex-col gap-1 z-10">
<div className="text-lg font-medium tracking-tight text-white/90 group-hover:text-white transition-colors">{method.label}</div>
<div className="text-sm leading-relaxed text-white/60">{method.description}</div>
</div>
<button
className="z-10 flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl border border-white/10 bg-white/5 text-white/50 transition-all duration-200 hover:border-[#d3869b]/40 hover:bg-[#d3869b]/10 hover:text-[#d3869b] hover:scale-105 active:scale-95"
onClick={() => window.open(method.url, '_blank', 'noopener,noreferrer')}
title={`Open ${method.label}`}
>
<ExternalLink size={16} />
</button>
</div>
);
}
export default function DonatePage({ onBack }) {
const links = METHODS.filter(m => m.type === 'link');
const crypto = METHODS.filter(m => m.type === 'crypto');
return (
<div className="relative flex h-full min-h-0 flex-col overflow-y-auto bg-[#050505]">
{/* Abstract Background Layer (Pure CSS + SVG) */}
<div className="fixed inset-0 pointer-events-none overflow-hidden z-0">
{/* Glowing Orbs */}
<div className="absolute -top-[200px] -left-[200px] w-[800px] h-[800px] rounded-full bg-[radial-gradient(circle,rgba(168,85,247,0.12)_0%,rgba(168,85,247,0)_70%)] animate-[drift_20s_infinite_linear]" />
<div className="absolute -bottom-[100px] -right-[100px] w-[600px] h-[600px] rounded-full bg-[radial-gradient(circle,rgba(236,72,153,0.08)_0%,rgba(236,72,153,0)_70%)] animate-[drift_15s_infinite_linear_reverse]" />
<div className="absolute top-[40%] left-[50%] w-[900px] h-[900px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-[radial-gradient(circle,rgba(99,102,241,0.08)_0%,rgba(99,102,241,0)_70%)] animate-[drift_25s_infinite_ease-in-out_alternate]" />
{/* Subtle SVG Voice Waveforms */}
<div className="absolute inset-0 opacity-40 mix-blend-screen overflow-hidden">
<svg className="absolute w-[200%] h-[60%] top-[20%] left-0 animate-[wave-move_15s_linear_infinite]" preserveAspectRatio="none" viewBox="0 0 2000 1000" xmlns="http://www.w3.org/2000/svg">
<path fill="none" stroke="url(#wave-grad-1)" strokeWidth="3" d="M0,500 C250,300 750,700 1000,500 C1250,300 1750,700 2000,500" />
<path fill="none" stroke="url(#wave-grad-2)" strokeWidth="2" d="M0,500 C300,650 700,350 1000,500 C1300,650 1700,350 2000,500" />
<path fill="none" stroke="url(#wave-grad-3)" strokeWidth="1.5" d="M0,500 C400,400 600,600 1000,500 C1400,400 1600,600 2000,500" />
<defs>
<linearGradient id="wave-grad-1" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#d3869b" stopOpacity="0" />
<stop offset="25%" stopColor="#d3869b" stopOpacity="0.8" />
<stop offset="50%" stopColor="#d3869b" stopOpacity="0" />
<stop offset="75%" stopColor="#d3869b" stopOpacity="0.8" />
<stop offset="100%" stopColor="#d3869b" stopOpacity="0" />
</linearGradient>
<linearGradient id="wave-grad-2" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#818cf8" stopOpacity="0" />
<stop offset="25%" stopColor="#818cf8" stopOpacity="0.6" />
<stop offset="50%" stopColor="#818cf8" stopOpacity="0" />
<stop offset="75%" stopColor="#818cf8" stopOpacity="0.6" />
<stop offset="100%" stopColor="#818cf8" stopOpacity="0" />
</linearGradient>
<linearGradient id="wave-grad-3" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#f472b6" stopOpacity="0" />
<stop offset="25%" stopColor="#f472b6" stopOpacity="0.5" />
<stop offset="50%" stopColor="#f472b6" stopOpacity="0" />
<stop offset="75%" stopColor="#f472b6" stopOpacity="0.5" />
<stop offset="100%" stopColor="#f472b6" stopOpacity="0" />
</linearGradient>
</defs>
</svg>
</div>
{/* Noise Texture */}
<div className="absolute inset-0 opacity-[0.04]" style={{ backgroundImage: "url('data:image/svg+xml,%3Csvg viewBox=%220 0 200 200%22 xmlns=%22http://www.w3.org/2000/svg%22%3E%3Cfilter id=%22noiseFilter%22%3E%3CfeTurbulence type=%22fractalNoise%22 baseFrequency=%220.65%22 numOctaves=%223%22 stitchTiles=%22stitch%22/%3E%3C/filter%3E%3Crect width=%22100%25%22 height=%22100%25%22 filter=%22url(%23noiseFilter)%22/%3E%3C/svg%3E')" }} />
</div>
{/* Content Layer */}
<div className="relative z-10 flex flex-col px-6 pb-12 sm:px-10 h-full">
<div className="shrink-0 py-6">
<Button
variant="ghost"
size="sm"
onClick={onBack}
leading={<ArrowLeft size={16} />}
className="text-white/70 hover:text-white hover:bg-white/10 backdrop-blur-md rounded-xl"
>
Back to Studio
</Button>
</div>
<div className="mx-auto flex w-full max-w-[680px] flex-col gap-12">
{/* Hero Section */}
<div className="pt-8 text-center animate-fade-in">
<div className="relative mx-auto mb-8 flex h-20 w-20 items-center justify-center rounded-3xl bg-gradient-to-br from-white/10 to-transparent border border-white/10 shadow-[0_0_40px_rgba(211,134,155,0.2)] backdrop-blur-xl">
<Heart size={36} className="text-[#f3a5b6] drop-shadow-[0_0_15px_rgba(243,165,182,0.8)] animate-[pulse_3s_ease-in-out_infinite] [fill:#f3a5b6]" />
</div>
<h2 className="mx-0 my-4 font-serif text-4xl font-semibold tracking-tight text-transparent bg-clip-text bg-gradient-to-r from-pink-200 via-purple-200 to-indigo-200 sm:text-5xl">
Support OmniVoice
</h2>
<p className="mx-auto mt-6 max-w-[500px] text-lg font-light leading-relaxed text-white/60">
OmniVoice is free, open-source, and runs entirely on your hardware.
If it brings value to your workflow, consider supporting the core team.
</p>
</div>
<div className="flex flex-col gap-10">
{/* Platforms */}
<section>
<div className="mb-6 flex items-center gap-4">
<div className="h-px flex-1 bg-gradient-to-r from-transparent to-white/10" />
<h3 className="text-xs font-semibold uppercase tracking-widest text-white/40">Platforms</h3>
<div className="h-px flex-1 bg-gradient-to-l from-transparent to-white/10" />
</div>
<div className="grid gap-4 sm:grid-cols-2">
{links.map((m, i) => <LinkCard key={m.id} method={m} delay={100 + i * 100} />)}
</div>
</section>
{/* Cryptocurrency */}
<section>
<div className="mb-6 flex items-center gap-4">
<div className="h-px flex-1 bg-gradient-to-r from-transparent to-white/10" />
<h3 className="text-xs font-semibold uppercase tracking-widest text-white/40">Cryptocurrency</h3>
<div className="h-px flex-1 bg-gradient-to-l from-transparent to-white/10" />
</div>
<div className="flex flex-col gap-4">
{crypto.map((m, i) => <CryptoCard key={m.id} method={m} delay={400 + i * 100} />)}
</div>
</section>
</div>
<div className="mt-8 mb-12 text-center">
<p className="text-sm font-medium tracking-wide text-white/40">
Every contribution helps push the boundaries of local AI.
</p>
</div>
</div>
</div>
<style jsx="true">{`
@keyframes fade-in-up {
0% { opacity: 0; transform: translateY(15px); }
100% { opacity: 1; transform: translateY(0); }
}
@keyframes wave-move {
0% { transform: translateX(0); }
100% { transform: translateX(-50%); }
}
@keyframes drift {
0% { transform: rotate(0deg) translate(0, 0); }
33% { transform: rotate(120deg) translate(20px, 40px); }
66% { transform: rotate(240deg) translate(-20px, -40px); }
100% { transform: rotate(360deg) translate(0, 0); }
}
.animate-fade-in {
animation: fade-in-up 0.8s cubic-bezier(0.16, 1, 0.3, 1) both;
}
`}</style>
</div>
);
}
+18
View File
@@ -378,6 +378,24 @@
.dub-settings-field--style { grid-column: 1 / -1; }
}
/* Multi-lang mode toggle + picker */
.dub-settings-field--multi {
grid-column: 1 / -1;
padding-top: 4px;
border-top: 1px solid var(--chrome-border);
margin-top: 2px;
}
.dub-multi-toggle {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.68rem;
color: var(--chrome-fg-muted);
cursor: pointer;
margin-bottom: 4px;
}
.dub-multi-toggle input { accent-color: var(--chrome-accent); cursor: pointer; }
/* Collapsed-summary strip that replaces the full grid once language/engine/
quality are set. Keeps the primary Translate All + Clean Up CTAs inline. */
.dub-settings-summary {
+22
View File
@@ -20,6 +20,7 @@ import toast from 'react-hot-toast';
import { Button, Segmented, Badge, Progress } from '../ui';
import GlossaryPanel from '../components/GlossaryPanel';
import ExportModal from '../components/ExportModal';
import MultiLangPicker from '../components/MultiLangPicker';
import './DubTab.css';
const DubSegmentTable = lazy(() => import('../components/DubSegmentTable'));
@@ -93,6 +94,10 @@ export default function DubTab(props) {
const [previewMode, setPreviewMode] = useState('original'); // 'original' | 'dubbed'
const [exportOpen, setExportOpen] = useState(false);
// Multi-language mode
const [multiLangMode, setMultiLangMode] = useState(false);
const [multiLangs, setMultiLangs] = useState([]);
// Live ETA while generating elapsed ticks each second; remaining is
// extrapolated from the current/total rate so it's only meaningful once
// at least one segment has rendered and ~2s of clock has passed.
@@ -641,6 +646,23 @@ export default function DubTab(props) {
</optgroup>
</select>
</div>
<div className="dub-settings-field dub-settings-field--multi">
<label className="dub-multi-toggle">
<input
type="checkbox"
checked={multiLangMode}
onChange={e => setMultiLangMode(e.target.checked)}
/>
<span>Multi-lang</span>
</label>
{multiLangMode && (
<MultiLangPicker
selected={multiLangs}
onChange={setMultiLangs}
disabled={dubStep === 'generating'}
/>
)}
</div>
<div className="dub-settings-field dub-settings-field--iso">
<div className="label-row">ISO</div>
<select
+18 -35
View File
@@ -13,10 +13,7 @@ function DubThumb({ jobId, fallback }) {
alt=""
onError={() => setFailed(true)}
loading="lazy"
style={{
width: '100%', height: '100%', objectFit: 'cover',
borderRadius: 'inherit', display: 'block',
}}
className="lp-dub-thumb"
/>
);
}
@@ -79,10 +76,10 @@ export default function Launchpad({
{/* Hero */}
<div className="lp-hero">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 24, flexWrap: 'wrap' }}>
<div style={{ maxWidth: 640 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '12px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '2px', height: '22px' }}>
<div className="lp-hero__row">
<div className="lp-hero__col">
<div className="lp-hero__kicker-row">
<div className="lp-hero__wave-group">
{[10, 14, 8, 16, 12, 14, 9, 12].map((h, i) => (
<span
key={i}
@@ -137,15 +134,15 @@ export default function Launchpad({
{/* Recent Projects */}
{(profiles.length > 0 || studioProjects.length > 0) && (
<div className="lp-section">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: '20px' }}>
<div className="lp-section__grid">
{/* Cloned voices */}
{cloneProfiles.length > 0 && (
<div>
<div className="lp-section-title"><Fingerprint size={12} color="#d3869b" /> Cloned Voices</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<div className="lp-col">
{cloneProfiles.map(p => (
<div key={p.id} className="lp-project-card">
<div className="proj-icon" style={{ background: 'rgba(211,134,155,0.1)' }}><Fingerprint size={14} color="#d3869b" /></div>
<div className="proj-icon lp-proj-icon--clone"><Fingerprint size={14} color="#d3869b" /></div>
<div className="proj-info">
<div className="proj-name">{p.name}</div>
<div className="proj-meta">{p.ref_audio_path}</div>
@@ -161,26 +158,17 @@ export default function Launchpad({
{designProfiles.length > 0 && (
<div>
<div className="lp-section-title"><Wand2 size={12} color="#8ec07c" /> Designed Voices</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<div className="lp-col">
{designProfiles.map(p => (
<div key={p.id} className="lp-project-card">
<div className="proj-icon" style={{ background: p.is_locked ? 'rgba(184,187,38,0.1)' : 'rgba(142,192,124,0.1)' }}>
<div className={`proj-icon ${p.is_locked ? 'lp-proj-icon--locked' : 'lp-proj-icon--design'}`}>
{p.is_locked ? <Lock size={14} color="#b8bb26" /> : <Wand2 size={14} color="#8ec07c" />}
</div>
<div className="proj-info">
<div className="proj-name">{p.name}</div>
<div className="proj-meta" style={{ fontStyle: 'italic' }}>{p.instruct}</div>
<div className="proj-meta lp-proj-meta--italic">{p.instruct}</div>
</div>
{p.is_locked && <span style={{
fontFamily: 'var(--chrome-font-mono)',
fontSize: 'var(--chrome-label-size)',
letterSpacing: 'var(--chrome-label-track)',
padding: '1px 7px',
borderRadius: 'var(--chrome-radius-pill)',
background: 'color-mix(in srgb, #b8bb26 10%, transparent)',
border: '1px solid color-mix(in srgb, #b8bb26 40%, transparent)',
color: '#b8bb26', fontWeight: 600,
}}>LOCKED</span>}
{p.is_locked && <span className="lp-locked-badge">LOCKED</span>}
<button className="proj-action" onClick={() => { setMode('design'); handleSelectProfile(p); }}>Open</button>
</div>
))}
@@ -192,10 +180,10 @@ export default function Launchpad({
{studioProjects.length > 0 && (
<div>
<div className="lp-section-title"><Film size={12} color="#fe8019" /> Dubbing Projects</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<div className="lp-col">
{studioProjects.map(proj => (
<div key={proj.id} className="lp-project-card">
<div className="proj-icon" style={{ background: 'rgba(254,128,25,0.1)', overflow: 'hidden' }}>
<div className="proj-icon lp-proj-icon--dub">
<DubThumb
jobId={proj.state?.dubJobId || proj.id}
fallback={<Film size={14} color="#fe8019" />}
@@ -217,9 +205,9 @@ export default function Launchpad({
{/* Empty state */}
{profiles.length === 0 && studioProjects.length === 0 && (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative', zIndex: 1 }}>
<div style={{ textAlign: 'center', maxWidth: 360 }}>
<div style={{ display: 'flex', justifyContent: 'center', gap: '3px', marginBottom: '16px', opacity: 0.3 }}>
<div className="lp-empty">
<div className="lp-empty__inner">
<div className="lp-empty__bars">
{[8, 14, 22, 18, 26, 14, 20, 10, 16].map((h, i) => (
<span
key={i}
@@ -230,12 +218,7 @@ export default function Launchpad({
/>
))}
</div>
<p style={{
fontFamily: 'var(--chrome-font-mono)',
fontSize: '0.8rem',
color: 'var(--chrome-fg-muted)',
margin: 0,
}}>
<p className="lp-empty__hint">
Nothing here yet pick a card above.
</p>
</div>
+125 -10
View File
@@ -153,26 +153,114 @@
.models-roletabs { margin: 8px 0 10px; }
.reco-banner--action {
align-items: center;
padding: 8px var(--space-4);
align-items: flex-start;
padding: 10px var(--space-4);
border-left-color: #f3a5b6;
border-color: color-mix(in srgb, #f3a5b6 30%, transparent);
background: color-mix(in srgb, #f3a5b6 5%, transparent);
flex-wrap: wrap;
gap: var(--space-3);
}
.reco-banner__body { flex: 1; display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.reco-banner__body { flex: 1; display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.reco-banner__title { color: var(--chrome-fg); font-weight: 600; font-size: var(--text-sm); }
.reco-banner__rationale { color: var(--chrome-fg-muted); font-size: var(--text-xs); line-height: 1.4; }
.reco-banner__models {
display: flex;
flex-direction: column;
gap: 2px;
margin-top: 4px;
}
.reco-banner__model {
display: inline-flex;
align-items: center;
gap: var(--space-2);
font-size: var(--text-xs);
color: var(--chrome-fg-muted);
line-height: 1.5;
}
.reco-banner__model.is-installed { color: var(--chrome-fg); }
.reco-banner__model-size {
color: var(--chrome-fg-dim);
font-family: var(--chrome-font-mono);
font-size: var(--text-2xs);
}
.reco-banner__model-req {
font-size: var(--chrome-label-size, 0.58rem);
text-transform: uppercase;
letter-spacing: 0.04em;
color: #d3869b;
padding: 0 4px;
border: 1px solid color-mix(in srgb, #d3869b 35%, transparent);
border-radius: var(--chrome-radius-pill, 999px);
}
/* Table — dense rows, fixed-width action column. */
.models-table { border-radius: 8px; }
.models-table .ui-table-header { padding: 4px var(--space-3); }
.models-table__body { display: flex; flex-direction: column; max-height: 540px; overflow-y: auto; }
.models-table .ui-table-header { padding: 5px var(--space-3); }
.models-table__body { max-height: 560px; overflow-y: auto; position: relative; }
.models-table__header { align-items: center; }
.models-table__sort {
border: 0;
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 4px;
min-width: 0;
}
.models-table__sort:hover { color: var(--chrome-fg); }
.models-table__sort:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
border-radius: var(--chrome-radius-pill);
}
.models-table__sort--off {
border: 0;
background: transparent;
color: inherit;
font: inherit;
cursor: default;
display: inline-flex;
align-items: center;
}
.models-table__sortmark {
color: var(--chrome-accent);
font-family: var(--chrome-font-mono);
font-size: var(--text-2xs);
}
.models-table__virtual {
position: relative;
width: 100%;
}
.models-table__empty {
padding: 18px var(--space-4);
color: var(--chrome-fg-dim);
font-size: var(--text-sm);
font-family: var(--font-sans);
}
/* Inside the wizard embed the table stretches to fill — no fixed cap. */
.setup-wizard__embed .settings-section--compact {
display: flex; flex-direction: column; height: 100%; min-height: 0;
}
.setup-wizard__embed .models-table {
flex: 1 1 0; display: flex; flex-direction: column; min-height: 0;
}
.setup-wizard__embed .models-table__body { max-height: none; flex: 1 1 0; min-height: 0; }
.models-row {
display: flex;
align-items: center;
gap: var(--space-2);
padding: 8px var(--space-3);
position: absolute;
left: 0;
right: 0;
top: 0;
width: 100%;
padding: 10px var(--space-3);
border-bottom: 1px solid var(--chrome-border);
font-family: var(--font-sans);
font-size: var(--text-sm);
@@ -182,21 +270,38 @@
.models-row:last-child { border-bottom: none; }
.models-row.is-ok { border-left-color: var(--chrome-severity-ok); }
.models-row.is-off { border-left-color: var(--chrome-border-strong); opacity: 0.92; }
.models-row.is-unsupported { opacity: 0.4; pointer-events: none; }
.models-row.is-unsupported .models-row__actions { pointer-events: auto; }
.models-row:hover { background: var(--chrome-hover-bg); }
.models-row__cell { min-width: 0; }
.models-row__name { display: flex; flex-direction: column; gap: 2px; }
.models-row__title { display: inline-flex; align-items: center; gap: var(--space-2); color: var(--chrome-fg); font-weight: 500; }
.models-row__repo { color: var(--chrome-fg-muted); font-size: var(--text-xs); font-family: var(--chrome-font-mono); }
.models-row__name { display: flex; flex-direction: column; gap: 3px; }
.models-row__title { display: inline-flex; align-items: center; gap: var(--space-2); color: var(--chrome-fg); font-weight: 500; line-height: 1.4; }
.models-row__avatar {
flex-shrink: 0;
width: 22px;
height: 22px;
border-radius: 5px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.54rem;
font-weight: 700;
color: rgba(255, 255, 255, 0.75);
letter-spacing: 0.02em;
}
.models-row__repo { color: var(--chrome-fg-muted); font-size: var(--text-xs); font-family: var(--chrome-font-mono); line-height: 1.4; }
.models-row__repo code{ color: var(--chrome-fg-muted); }
.models-row__note { color: var(--chrome-fg-dim); }
.models-row__role { color: var(--chrome-fg-muted); font-size: var(--text-xs); font-family: var(--chrome-font-mono); text-transform: uppercase; letter-spacing: 0.03em; }
.models-row__progress { color: var(--chrome-accent); font-size: var(--text-xs); font-family: var(--chrome-font-mono); margin-top: 2px; }
.models-row__progressline {
display: flex; flex-direction: column; gap: 3px; margin-top: 6px;
display: flex; flex-direction: column; gap: 5px; margin-top: 8px;
}
.models-row__progresstext {
color: var(--chrome-fg-muted); font-size: var(--text-xs);
font-family: var(--chrome-font-mono); font-variant-numeric: tabular-nums;
line-height: 1.4;
}
.models-row__error {
color: var(--chrome-danger, #fb4934); font-size: var(--text-xs);
@@ -229,3 +334,13 @@
transition: opacity var(--dur-fast, 120ms) ease;
}
.models-row:hover .models-row__actions { opacity: 1; }
/* ── Responsive: narrow windows stack row cells ─────────────── */
@media (max-width: 580px) {
.models-row { flex-wrap: wrap; gap: var(--space-3); }
.models-row__name { flex: 1 1 100%; }
.models-row__size { width: auto !important; text-align: left !important; }
.models-row__actions { margin-left: auto; }
.models-toolbar { flex-direction: column; align-items: flex-start; }
.models-controls { flex-direction: column; align-items: stretch; }
}
+426 -198
View File
@@ -1,12 +1,21 @@
import React, { useEffect, useState, useCallback } from 'react';
import {
flexRender,
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
useReactTable,
} from '@tanstack/react-table';
import { useVirtualizer } from '@tanstack/react-virtual';
import {
Cpu, FileText, Info, ShieldCheck, RefreshCw, Trash2, ExternalLink,
CheckCircle, AlertCircle, Plug, Mic, MessageSquare, Download, Copy,
} from 'lucide-react';
import { toast } from 'react-hot-toast';
import { systemInfo, systemLogs, systemLogsTauri, clearSystemLogs, clearTauriLogs, modelStatus as fetchModelStatus, sysinfo as fetchSysinfo } from '../api/system';
import { systemLogs, systemLogsTauri, clearSystemLogs, clearTauriLogs } from '../api/system';
import { useSysinfo, useModelStatus, useSystemInfo } from '../api/hooks';
import { listEngines, selectEngine } from '../api/engines';
import { listModels, installModel, deleteModel, setupDownloadStreamUrl, getRecommendations } from '../api/setup';
import { setupDownloadStreamUrl } from '../api/setup';
import { getFrontendLogs, clearFrontendLogs } from '../utils/consoleBuffer';
import { Tabs, Segmented, Button, Badge, Panel, Table, Progress } from '../ui';
import { useAppStore } from '../store';
@@ -32,6 +41,9 @@ const LOG_SOURCES = [
{ value: 'tauri', label: 'Tauri' },
];
const MODEL_ROLE_ORDER = ['tts', 'asr', 'diarisation', 'diarization', 'llm'];
const MODEL_ROLE_LABEL = { all: 'All', tts: 'TTS', asr: 'ASR', diarisation: 'Diarisation', diarization: 'Diarisation', llm: 'LLM', other: 'Other' };
function Row({ label, value, mono }) {
return (
<div className="settings-row">
@@ -50,38 +62,44 @@ function fmtBytes(n) {
return `${Math.round(n / 1024)} KB`;
}
/** Deterministic muted HSL color from an org/user name in a repo_id. */
function orgColor(repoId) {
const org = (repoId || '').split('/')[0];
let h = 0;
for (let i = 0; i < org.length; i++) h = (h * 31 + org.charCodeAt(i)) & 0xffff;
return `hsl(${h % 360}, 35%, 28%)`;
}
import { useModels, useRecommendations, useInstallModel, useDeleteModel } from '../api/hooks';
/**
* Model store list every known HF model, show install state, let the
* user install / reinstall / delete individual models. Per-model download
* progress is pulled from the shared /setup/download-stream SSE.
*/
export function ModelStoreTab({ info, modelBadge }) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const modelsQuery = useModels();
const recoQuery = useRecommendations();
const data = modelsQuery.data;
const loading = modelsQuery.isLoading;
const reco = recoQuery.data;
const installMutation = useInstallModel();
const deleteMutation = useDeleteModel();
const [busy, setBusy] = useState(new Set()); // repo_ids currently working
// Per-repo active state. Tracks aggregate download across all files of
// a running install so the row can show a determinate progress bar.
// { [repo_id]: { phase, files: { [filename]: { downloaded, total, pct } }, error } }
const [rowState, setRowState] = useState({});
const [query, setQuery] = useState('');
const [reco, setReco] = useState(null); // /setup/recommendations payload
const [installingReco, setInstallingReco] = useState(false);
const [activeRole, setActiveRole] = useState(null);
const [sorting, setSorting] = useState([]);
const [columnFilters, setColumnFilters] = useState([]);
const esRef = React.useRef(null);
const reload = useCallback(async () => {
setLoading(true);
try { setData(await listModels()); }
catch (e) { toast.error(`Failed to list models: ${e.message}`); }
finally { setLoading(false); }
}, []);
const reloadReco = useCallback(async () => {
try { setReco(await getRecommendations()); }
catch (e) { /* non-fatal — Recommendation card just hides */ void e; }
}, []);
useEffect(() => { reload(); reloadReco(); }, [reload, reloadReco]);
const tableBodyRef = React.useRef(null);
// Track download speed per repo: { [repo_id]: { lastBytes, lastTime, speed } }
const speedRef = React.useRef({});
// Open the progress stream once when the tab mounts; close on unmount.
useEffect(() => {
@@ -129,7 +147,10 @@ export function ModelStoreTab({ info, modelBadge }) {
['install_done', 'delete_done', 'install_error'].includes(s.phase));
if (!term) return;
const t = setTimeout(() => {
reload();
modelsQuery.refetch();
recoQuery.refetch();
// Clear stale speed data for this repo.
delete speedRef.current[term[0]];
// Clear the terminal entry so the row reverts to the authoritative
// `installed` flag from /models without keeping stale progress.
setRowState(prev => {
@@ -139,33 +160,39 @@ export function ModelStoreTab({ info, modelBadge }) {
});
}, 800);
return () => clearTimeout(t);
}, [rowState, reload]);
}, [rowState, modelsQuery, recoQuery]);
const withBusy = async (repoId, fn, successMsg) => {
const reload = useCallback(() => {
modelsQuery.refetch();
recoQuery.refetch();
}, [modelsQuery, recoQuery]);
const withBusy = useCallback(async (repoId, fn, successMsg) => {
setBusy(prev => new Set(prev).add(repoId));
try {
await fn();
if (successMsg) toast.success(successMsg);
await reload();
} catch (e) {
toast.error(e.message || String(e));
} finally {
setBusy(prev => { const s = new Set(prev); s.delete(repoId); return s; });
}
};
}, []);
const onInstall = (repoId) => withBusy(repoId, () => installModel(repoId), 'Install started — progress in the row');
const onDelete = async (repoId) => {
const onInstall = useCallback((repoId) =>
withBusy(repoId, () => installMutation.mutateAsync(repoId), 'Install started — progress in the row'),
[installMutation, withBusy]);
const onDelete = useCallback(async (repoId) => {
if (!(await askConfirm(`Delete ${repoId}? You can reinstall it later.`, 'Delete model'))) return;
return withBusy(repoId, () => deleteModel(repoId), `Deleted ${repoId}`);
};
const onReinstall = async (repoId) => {
return withBusy(repoId, () => deleteMutation.mutateAsync(repoId), `Deleted ${repoId}`);
}, [deleteMutation, withBusy]);
const onReinstall = useCallback(async (repoId) => {
if (!(await askConfirm(`Reinstall ${repoId}? This will delete the current copy and download again.`, 'Reinstall model'))) return;
await withBusy(repoId, async () => {
await deleteModel(repoId);
await installModel(repoId);
await deleteMutation.mutateAsync(repoId);
await installMutation.mutateAsync(repoId);
}, 'Reinstalling');
};
}, [deleteMutation, installMutation, withBusy]);
const onInstallRecommended = async () => {
if (!reco) return;
@@ -178,9 +205,8 @@ export function ModelStoreTab({ info, modelBadge }) {
try {
// Parallel install backend /models/install spawns each download on
// its own asyncio task so ordering doesn't matter.
await Promise.all(missing.map(m => installModel(m.repo_id)));
await Promise.all(missing.map(m => installMutation.mutateAsync(m.repo_id)));
toast.success(`Started downloading ${missing.length} model${missing.length > 1 ? 's' : ''}`);
await Promise.all([reload(), reloadReco()]);
} catch (e) {
toast.error(`Install failed: ${e.message || e}`);
} finally {
@@ -188,6 +214,265 @@ export function ModelStoreTab({ info, modelBadge }) {
}
};
const allModels = React.useMemo(() => data?.models || [], [data]);
const groups = allModels.reduce((acc, m) => {
const k = (m.role || 'other').toLowerCase();
(acc[k] = acc[k] || []).push(m);
return acc;
}, {});
const roles = Object.keys(groups).sort((a, b) => {
const ai = MODEL_ROLE_ORDER.indexOf(a), bi = MODEL_ROLE_ORDER.indexOf(b);
return (ai < 0 ? 99 : ai) - (bi < 0 ? 99 : bi);
});
// 'all' is a virtual role shows every model regardless of category.
const currentRole = activeRole === 'all' ? 'all'
: activeRole && groups[activeRole] ? activeRole
: 'all';
const allInstalled = allModels.filter(m => m.installed).length;
useEffect(() => {
setColumnFilters(currentRole === 'all' ? [] : [{ id: 'role', value: currentRole }]);
}, [currentRole]);
const getRowRuntime = React.useCallback((m) => {
const rs = rowState[m.repo_id];
const rowBusy = busy.has(m.repo_id);
const isInstalling = rs?.phase === 'install_start' || (rs?.phase === 'active' && !rs.files && !rs.error);
const isDeleting = rs?.phase === 'delete_start';
const phase = rs?.phase;
const fileList = rs?.files ? Object.entries(rs.files) : [];
const totals = fileList.reduce((a, [, f]) => ({
downloaded: a.downloaded + (f.downloaded || 0),
total: a.total + (f.total || 0),
done: a.done + (f.phase === 'done' ? 1 : 0),
}), { downloaded: 0, total: 0, done: 0 });
const hasFiles = fileList.length > 0;
const aggPct = totals.total > 0 ? (totals.downloaded / totals.total) * 100 : null;
const showBar = phase === 'install_start' || phase === 'active' || phase === 'delete_start';
const activeFilename = fileList.find(([, f]) => f.phase !== 'done')?.[0];
const unsupported = m.supported === false;
return {
rs,
rowBusy,
isInstalling,
isDeleting,
phase,
fileList,
totals,
hasFiles,
aggPct,
showBar,
activeFilename,
unsupported,
};
}, [busy, rowState]);
const columns = React.useMemo(() => [
{
id: 'name',
accessorFn: m => `${m.label || ''} ${m.repo_id || ''}`,
header: 'Model',
size: 420,
meta: { className: 'models-row__name' },
cell: ({ row }) => {
const m = row.original;
const rt = getRowRuntime(m);
return (
<>
<span className="models-row__title">
<span
className="models-row__avatar"
style={{ background: orgColor(m.repo_id) }}
title={m.repo_id.split('/')[0]}
>
{m.repo_id.split('/')[0].slice(0, 2).toUpperCase()}
</span>
{m.label}
{m.required && <span className="models-row__tag">required</span>}
</span>
<span className="models-row__repo">
<code>{m.repo_id}</code>
{m.note && <span className="models-row__note"> · {m.note}</span>}
</span>
{rt.showBar && (
<div className="models-row__progressline">
<Progress
value={rt.aggPct}
tone={rt.isDeleting ? 'warn' : 'brand'}
size="xs"
/>
<span className="models-row__progresstext">
{rt.isDeleting
? 'Removing cached revisions…'
: rt.hasFiles
? (() => {
const sp = speedRef.current[m.repo_id];
const now = Date.now();
if (sp && rt.totals.downloaded > 0) {
const dt = (now - sp.lastTime) / 1000;
if (dt >= 2) {
sp.speed = Math.max(0, (rt.totals.downloaded - sp.lastBytes) / dt);
sp.lastBytes = rt.totals.downloaded;
sp.lastTime = now;
}
} else {
speedRef.current[m.repo_id] = { lastBytes: rt.totals.downloaded, lastTime: now, speed: 0 };
}
const speed = sp?.speed || 0;
const speedStr = speed > 0 ? ` · ${fmtBytes(speed)}/s` : '';
const pctStr = rt.aggPct != null ? ` (${Math.round(rt.aggPct)}%)` : '';
const parts = [
`${fmtBytes(rt.totals.downloaded)}${rt.totals.total ? ` / ${fmtBytes(rt.totals.total)}` : ''}${pctStr}${speedStr}`,
];
if (rt.fileList.length > 1) {
parts.push(`${rt.totals.done}/${rt.fileList.length} files`);
}
if (rt.activeFilename) {
parts.push(rt.activeFilename.split('/').pop());
}
return parts.join(' · ');
})()
: 'Preparing download…'}
</span>
</div>
)}
{rt.phase === 'install_error' && rt.rs?.error && (
<span className="models-row__error">Install failed: {rt.rs.error}</span>
)}
</>
);
},
},
{
id: 'role',
accessorFn: m => (m.role || 'other').toLowerCase(),
header: 'Role',
size: 92,
filterFn: (row, id, value) => !value || row.getValue(id) === value,
cell: ({ row }) => <span className="models-row__role">{MODEL_ROLE_LABEL[row.getValue('role')] || row.original.role || 'Other'}</span>,
},
{
id: 'size',
accessorFn: m => m.installed ? (m.size_on_disk_bytes || 0) : (m.size_gb || 0) * 1024 ** 3,
header: 'Size',
size: 86,
meta: { align: 'right', className: 'models-row__size' },
cell: ({ row }) => {
const m = row.original;
return m.installed ? fmtBytes(m.size_on_disk_bytes) : `${m.size_gb} GB`;
},
},
{
id: 'status',
accessorFn: m => m.installed ? 2 : (m.supported === false ? 0 : 1),
header: 'Status',
size: 116,
meta: { align: 'center', className: 'models-row__status' },
cell: ({ row }) => {
const m = row.original;
const rt = getRowRuntime(m);
return rt.isInstalling
? <Badge tone="warn" size="xs"><Download size={10} /> {rt.aggPct != null ? `${Math.round(rt.aggPct)}%` : 'downloading'}</Badge>
: rt.isDeleting
? <Badge tone="warn" size="xs"><Trash2 size={10} /> deleting</Badge>
: rt.rowBusy
? <Badge tone="warn" size="xs"><RefreshCw size={10} className="spinner" /> working</Badge>
: m.installed
? <Badge tone="success" size="xs">installed</Badge>
: rt.unsupported
? <Badge tone="neutral" size="xs">{(m.platforms || []).join(', ')}</Badge>
: <Badge tone="neutral" size="xs">not installed</Badge>;
},
},
{
id: 'actions',
header: '',
size: 118,
enableSorting: false,
meta: { align: 'right', className: 'models-row__actions' },
cell: ({ row }) => {
const m = row.original;
const rt = getRowRuntime(m);
return (
<>
<Button
variant="icon" iconSize="sm"
onClick={() => window.open(`https://huggingface.co/${m.repo_id}`, '_blank', 'noopener,noreferrer')}
title="View on HuggingFace"
aria-label="View on HuggingFace"
>
<ExternalLink size={11} />
</Button>
{!m.installed && !rt.rowBusy && !rt.isInstalling && !rt.unsupported && (
<Button
variant="subtle" size="sm"
onClick={() => onInstall(m.repo_id)}
leading={<Download size={11} />}
>
Install
</Button>
)}
{m.installed && !rt.rowBusy && !rt.isDeleting && (
<>
<Button
variant="icon" iconSize="sm"
onClick={() => onReinstall(m.repo_id)}
title="Reinstall"
aria-label="Reinstall"
>
<RefreshCw size={11} />
</Button>
<Button
variant="icon" iconSize="sm"
onClick={() => onDelete(m.repo_id)}
title="Delete"
aria-label="Delete"
>
<Trash2 size={11} />
</Button>
</>
)}
</>
);
},
},
], [getRowRuntime, onDelete, onInstall, onReinstall]);
const table = useReactTable({
data: allModels,
columns,
getRowId: row => row.repo_id,
state: {
sorting,
globalFilter: query,
columnFilters,
},
onSortingChange: setSorting,
onGlobalFilterChange: setQuery,
onColumnFiltersChange: setColumnFilters,
globalFilterFn: (row, _columnId, value) => {
const q = String(value || '').trim().toLowerCase();
if (!q) return true;
const m = row.original;
return [m.repo_id, m.label, m.note, m.role]
.filter(Boolean)
.some(v => String(v).toLowerCase().includes(q));
},
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
});
const tableRows = table.getRowModel().rows;
const rowVirtualizer = useVirtualizer({
count: tableRows.length,
getScrollElement: () => tableBodyRef.current,
estimateSize: () => 68,
overscan: 8,
});
if (loading && !data) {
return (
<section className="settings-section">
@@ -198,29 +483,6 @@ export function ModelStoreTab({ info, modelBadge }) {
}
if (!data) return null;
const groups = (data.models || []).reduce((acc, m) => {
const k = (m.role || 'other').toLowerCase();
(acc[k] = acc[k] || []).push(m);
return acc;
}, {});
const ROLE_ORDER = ['tts', 'asr', 'diarisation', 'diarization', 'llm'];
const ROLE_LABEL = { tts: 'TTS', asr: 'ASR', diarisation: 'Diarisation', diarization: 'Diarisation', llm: 'LLM', other: 'Other' };
const roles = Object.keys(groups).sort((a, b) => {
const ai = ROLE_ORDER.indexOf(a), bi = ROLE_ORDER.indexOf(b);
return (ai < 0 ? 99 : ai) - (bi < 0 ? 99 : bi);
});
const currentRole = activeRole && groups[activeRole] ? activeRole : roles[0];
const q = query.trim().toLowerCase();
const rows = (currentRole ? groups[currentRole] : []).filter(m =>
!q || m.repo_id.toLowerCase().includes(q) || (m.label || '').toLowerCase().includes(q));
const COLUMNS = [
{ key: 'name', label: 'Model', flex: 3 },
{ key: 'size', label: 'Size', width: 80, align: 'right' },
{ key: 'status', label: 'Status', width: 110, align: 'center' },
{ key: 'actions', label: '', width: 108, align: 'right' },
];
return (
<section className="settings-section settings-section--compact">
<div className="models-toolbar">
@@ -237,19 +499,32 @@ export function ModelStoreTab({ info, modelBadge }) {
</div>
{reco && reco.all_installed && (
<div className="reco-banner">
<div className="mb-4 flex items-center gap-3 rounded-[var(--chrome-radius-pill)] border border-[color-mix(in_srgb,#8ec07c_30%,transparent)] border-l-2 border-l-[#8ec07c] bg-[color-mix(in_srgb,#8ec07c_5%,transparent)] px-4 py-[6px] font-[var(--font-sans)] text-[var(--text-sm)] text-[var(--chrome-fg-muted)]">
<CheckCircle size={12} color="#8ec07c" />
<span className="reco-banner__text">
<span className="flex-1">
Recommended bundle installed for <strong>{reco.device.label}</strong>
</span>
<span className="reco-banner__size">{reco.total_gb} GB</span>
<span className="text-[var(--text-xs)] text-[var(--chrome-fg-dim)]">{reco.total_gb} GB</span>
</div>
)}
{reco && !reco.all_installed && (
<div className="reco-banner reco-banner--action">
<div className="reco-banner__body">
<div className="reco-banner__title">Recommended for {reco.device.label}</div>
<div className="reco-banner__rationale">{reco.rationale}</div>
<div className="mb-4 flex flex-wrap items-start gap-3 rounded-[var(--chrome-radius-pill)] border border-[color-mix(in_srgb,#f3a5b6_30%,transparent)] border-l-2 border-l-[#f3a5b6] bg-[color-mix(in_srgb,#f3a5b6_5%,transparent)] px-4 py-2.5">
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="text-[var(--text-sm)] font-semibold text-[var(--chrome-fg)]">Recommended for {reco.device.label}</div>
<div className="text-[var(--text-xs)] leading-[1.4] text-[var(--chrome-fg-muted)]">{reco.rationale}</div>
<div className="mt-1 flex flex-col gap-0.5">
{reco.models.map(m => (
<span key={m.repo_id} className={`inline-flex items-center gap-2 text-[var(--text-xs)] leading-[1.5] ${m.installed ? 'text-[var(--chrome-fg)]' : 'text-[var(--chrome-fg-muted)]'}`}>
{m.installed ? '✓' : '○'} {m.label}
<span className="font-[var(--chrome-font-mono)] text-[var(--text-2xs)] text-[var(--chrome-fg-dim)]">{m.size_gb} GB</span>
{m.required && (
<span className="rounded-[var(--chrome-radius-pill,999px)] border border-[color-mix(in_srgb,#d3869b_35%,transparent)] px-1 text-[0.58rem] uppercase tracking-[0.04em] text-[#d3869b]">
required
</span>
)}
</span>
))}
</div>
</div>
<Button
variant="primary"
@@ -264,21 +539,25 @@ export function ModelStoreTab({ info, modelBadge }) {
)}
<div className="models-controls">
{roles.length > 1 && (
<Segmented
size="sm"
value={currentRole}
onChange={setActiveRole}
className="models-roletabs"
items={roles.map(r => {
<Segmented
size="sm"
value={currentRole}
onChange={setActiveRole}
className="models-roletabs"
items={[
{
value: 'all',
label: `All ${allInstalled}/${allModels.length}`,
},
...roles.map(r => {
const installed = groups[r].filter(m => m.installed).length;
return {
value: r,
label: `${ROLE_LABEL[r] || r.toUpperCase()} ${installed}/${groups[r].length}`,
label: `${MODEL_ROLE_LABEL[r] || r.toUpperCase()} ${installed}/${groups[r].length}`,
};
})}
/>
)}
}),
]}
/>
<input
type="search"
className="models-search"
@@ -290,104 +569,72 @@ export function ModelStoreTab({ info, modelBadge }) {
</div>
<Table className="models-table">
<Table.Header columns={COLUMNS} />
<div className="models-table__body">
{rows.map((m) => {
const rs = rowState[m.repo_id];
const rowBusy = busy.has(m.repo_id);
const isInstalling = rs?.phase === 'install_start' || (rs?.phase === 'active' && !rs.files && !rs.error);
const isDeleting = rs?.phase === 'delete_start';
const phase = rs?.phase;
// Aggregate current download progress across all in-flight files.
const fileList = rs?.files ? Object.entries(rs.files) : [];
const totals = fileList.reduce((a, [, f]) => ({
downloaded: a.downloaded + (f.downloaded || 0),
total: a.total + (f.total || 0),
done: a.done + (f.phase === 'done' ? 1 : 0),
}), { downloaded: 0, total: 0, done: 0 });
const hasFiles = fileList.length > 0;
const aggPct = totals.total > 0 ? (totals.downloaded / totals.total) * 100 : null;
const showBar = phase === 'install_start' || phase === 'active' || phase === 'delete_start';
const activeFilename = fileList.find(([, f]) => f.phase !== 'done')?.[0];
return (
<div key={m.repo_id} className={`models-row ${m.installed ? 'is-ok' : 'is-off'}`}>
<div className="models-row__cell models-row__name" style={{ flex: 3 }}>
<span className="models-row__title">
{m.label}
{m.required && <span className="models-row__tag">required</span>}
</span>
<span className="models-row__repo">
<code>{m.repo_id}</code>
{m.note && <span className="models-row__note"> · {m.note}</span>}
</span>
{showBar && (
<div className="models-row__progressline">
<Progress
value={aggPct}
tone={isDeleting ? 'warn' : 'brand'}
size="xs"
/>
<span className="models-row__progresstext">
{isDeleting
? 'Removing cached revisions…'
: hasFiles
? `${fmtBytes(totals.downloaded)}${totals.total ? ` / ${fmtBytes(totals.total)}` : ''} · ${fileList.length} file${fileList.length === 1 ? '' : 's'}${totals.done ? ` · ${totals.done} done` : ''}${activeFilename ? ` · ${activeFilename.split('/').pop()}` : ''}`
: 'Preparing download…'}
</span>
</div>
)}
{phase === 'install_error' && rs?.error && (
<span className="models-row__error">Install failed: {rs.error}</span>
)}
</div>
<div className="models-row__cell models-row__size" style={{ width: 80, textAlign: 'right' }}>
{m.installed ? fmtBytes(m.size_on_disk_bytes) : `${m.size_gb} GB`}
</div>
<div className="models-row__cell" style={{ width: 110, display: 'flex', justifyContent: 'center' }}>
{isInstalling
? <Badge tone="warn" size="xs"><Download size={10} /> {aggPct != null ? `${Math.round(aggPct)}%` : 'downloading'}</Badge>
: isDeleting
? <Badge tone="warn" size="xs"><Trash2 size={10} /> deleting</Badge>
: rowBusy
? <Badge tone="warn" size="xs"><RefreshCw size={10} className="spinner" /> working</Badge>
: m.installed
? <Badge tone="success" size="xs">installed</Badge>
: <Badge tone="neutral" size="xs">not installed</Badge>}
</div>
<div className="models-row__cell models-row__actions" style={{ width: 108 }}>
{!m.installed && !rowBusy && !isInstalling && (
<Button
variant="subtle" size="sm"
onClick={() => onInstall(m.repo_id)}
leading={<Download size={11} />}
>
Install
</Button>
)}
{m.installed && !rowBusy && !isDeleting && (
<>
<Button
variant="icon" iconSize="sm"
onClick={() => onReinstall(m.repo_id)}
title="Reinstall"
aria-label="Reinstall"
<div className="ui-table-header models-table__header">
{table.getHeaderGroups().map(headerGroup => (
<React.Fragment key={headerGroup.id}>
{headerGroup.headers.map(header => {
const meta = header.column.columnDef.meta || {};
const canSort = header.column.getCanSort();
return (
<button
key={header.id}
type="button"
className={[
'ui-table-header__cell',
`ui-table-header__cell--align-${meta.align || 'left'}`,
canSort ? 'models-table__sort' : 'models-table__sort--off',
].join(' ')}
style={{ width: header.column.columnDef.size, flex: header.column.id === 'name' ? '1 1 auto' : '0 0 auto' }}
onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
disabled={!canSort}
title={canSort ? `Sort by ${String(header.column.columnDef.header || '')}` : undefined}
>
{flexRender(header.column.columnDef.header, header.getContext())}
{header.column.getIsSorted() === 'asc' && <span className="models-table__sortmark"></span>}
{header.column.getIsSorted() === 'desc' && <span className="models-table__sortmark"></span>}
</button>
);
})}
</React.Fragment>
))}
</div>
<div ref={tableBodyRef} className="models-table__body">
<div className="models-table__virtual" style={{ height: rowVirtualizer.getTotalSize() }}>
{rowVirtualizer.getVirtualItems().map(virtualRow => {
const row = tableRows[virtualRow.index];
const m = row.original;
const rt = getRowRuntime(m);
return (
<div
key={row.id}
className={`models-row ${m.installed ? 'is-ok' : 'is-off'}${rt.unsupported ? ' is-unsupported' : ''}`}
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
style={{ transform: `translateY(${virtualRow.start}px)` }}
>
{row.getVisibleCells().map(cell => {
const meta = cell.column.columnDef.meta || {};
return (
<div
key={cell.id}
className={`models-row__cell ${meta.className || ''}`}
style={{
width: cell.column.columnDef.size,
flex: cell.column.id === 'name' ? '1 1 auto' : '0 0 auto',
textAlign: meta.align || undefined,
}}
>
<RefreshCw size={11} />
</Button>
<Button
variant="icon" iconSize="sm"
onClick={() => onDelete(m.repo_id)}
title="Delete"
aria-label="Delete"
>
<Trash2 size={11} />
</Button>
</>
)}
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</div>
);
})}
</div>
</div>
);
})}
);
})}
{tableRows.length === 0 && (
<div className="models-table__empty">No models match your filters.</div>
)}
</div>
</div>
</Table>
</section>
@@ -539,17 +786,19 @@ async function askConfirm(message, title = 'Confirm') {
export default function Settings() {
const [activeTab, setActiveTab] = useState('models');
const [info, setInfo] = useState(null);
const [status, setStatus] = useState(null);
const [logSource, setLogSource] = useState('backend');
const [logs, setLogs] = useState([]);
const [logMeta, setLogMeta] = useState({ path: '', exists: false });
const [loadingLogs, setLoadingLogs] = useState(false);
const [appVersion, setAppVersion] = useState(null);
const [tauriVersion, setTauriVersion] = useState(null);
const [hw, setHw] = useState(null);
const [updateState, setUpdateState] = useState('idle'); // idle|checking|downloading|uptodate|error
// TanStack Query shared cache with App.jsx, no duplicate requests
const { data: hw } = useSysinfo();
const { data: status } = useModelStatus();
const { data: info } = useSystemInfo();
useEffect(() => {
if (!isTauri()) return;
(async () => {
@@ -561,18 +810,7 @@ export default function Settings() {
})();
}, []);
useEffect(() => {
let cancelled = false;
const pull = async () => {
try {
const s = await fetchSysinfo();
if (!cancelled) setHw(s);
} catch { /* backend not up yet */ }
};
pull();
const iv = setInterval(pull, 6000);
return () => { cancelled = true; clearInterval(iv); };
}, []);
// sysinfo polling is now handled by useSysinfo() hook above
const copyDiagnostics = useCallback(async () => {
const nav = typeof navigator !== 'undefined' ? navigator : {};
@@ -647,12 +885,8 @@ export default function Settings() {
}
}, []);
const refreshInfo = useCallback(async () => {
try {
const [i, s] = await Promise.all([systemInfo(), fetchModelStatus()]);
setInfo(i); setStatus(s);
} catch (e) { /* ignore */ }
}, []);
// refreshInfo polling replaced by TanStack Query (useSystemInfo + useModelStatus)
const refreshInfo = useCallback(() => {}, []);
const refreshLogs = useCallback(async () => {
setLoadingLogs(true);
@@ -681,12 +915,6 @@ export default function Settings() {
}
}, [logSource]);
useEffect(() => {
refreshInfo();
const iv = setInterval(refreshInfo, 4000);
return () => clearInterval(iv);
}, [refreshInfo]);
useEffect(() => {
if (activeTab === 'logs') refreshLogs();
}, [activeTab, logSource, refreshLogs]);
+123 -27
View File
@@ -2,35 +2,37 @@
width: 100%;
max-width: none;
margin: 0;
padding: 32px 40px 48px;
padding: 0 32px;
display: flex;
flex-direction: column;
gap: 16px;
overflow-y: auto;
height: 100%;
min-height: 0; /* let flex children shrink */
overflow: hidden; /* NO page-level scroll — only embed scrolls */
}
/* Hero stays constrained + centered so the headline doesn't stretch
uncomfortably wide on ultrawide displays; the embedded panels below
get the full width. */
/* Hero + steps stay constrained and centered; they never scroll. */
.setup-wizard__hero,
.setup-wizard__steps {
max-width: 720px;
max-width: 760px;
margin-left: auto;
margin-right: auto;
flex-shrink: 0;
}
/* Guided-flow step header ─────────────────────────────────────────────── */
/* ── Step pills ──────────────────────────────────────────────────────── */
.setup-wizard__steps {
display: flex;
gap: 8px;
gap: 6px;
justify-content: center;
margin-bottom: 8px;
flex-wrap: wrap;
}
.setup-wizard__step {
padding: 6px 14px;
padding: 5px 14px;
border-radius: 999px;
font-size: 0.78rem;
font-size: 0.74rem;
letter-spacing: 0.02em;
line-height: 1.4;
color: var(--color-fg-muted);
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
@@ -46,18 +48,25 @@
color: #8ec07c;
border-color: rgba(142, 192, 124, 0.35);
}
/* ── Embed panel — the ONLY scrollable region ────────────────────────── */
.setup-wizard__embed {
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 12px;
padding: 16px;
padding: 4px 0;
margin-top: 4px;
flex: 1 1 0;
min-height: 0; /* critical for flex child scroll */
overflow-y: auto;
overflow-x: hidden;
}
/* ── Nav bar — always visible below the embed ────────────────────────── */
.setup-wizard__nav {
display: flex;
justify-content: space-between;
gap: 8px;
margin-top: 8px;
flex-shrink: 0;
padding-bottom: 4px;
}
.setup-wizard--centered {
@@ -68,27 +77,43 @@
text-align: center;
}
/* ── Hero — compact ──────────────────────────────────────────────────── */
.setup-wizard__hero {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 24px 12px 12px;
gap: 6px;
padding: 20px 12px 8px;
text-align: center;
flex-shrink: 0;
}
.setup-wizard__hero h1 {
margin: 0;
font-size: 1.6rem;
letter-spacing: -0.01em;
font-size: 1.45rem;
font-family: var(--font-display, var(--font-sans));
font-weight: 600;
letter-spacing: -0.02em;
line-height: 1.2;
}
.setup-wizard__brand {
display: flex;
align-items: center;
gap: 10px;
}
.setup-wizard__logo {
width: 32px;
height: 32px;
flex-shrink: 0;
}
.setup-wizard__sub {
margin: 0;
color: var(--color-fg-muted);
font-size: 0.85rem;
max-width: 440px;
line-height: 1.5;
font-size: 0.84rem;
max-width: 460px;
line-height: 1.55;
}
/* ── Card / checklist rows ───────────────────────────────────────────── */
.setup-wizard__card {
display: flex;
flex-direction: column;
@@ -115,17 +140,73 @@
align-items: center;
gap: 12px;
}
.setup-wizard__row-body { flex: 1; display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.setup-wizard__row-title { font-weight: 600; font-size: 0.85rem; }
.setup-wizard__row-body { flex: 1; display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.setup-wizard__row-title { font-weight: 600; font-size: 0.84rem; line-height: 1.4; }
.setup-wizard__muted {
color: var(--color-fg-muted);
font-size: 0.72rem;
font-size: 0.74rem;
line-height: 1.5;
}
.setup-wizard__muted code { color: var(--color-fg-subtle); }
.setup-wizard__warn { color: var(--color-warn, #fabd2f); font-weight: 600; }
/* ── Welcome step cards ──────────────────────────────────────────────── */
.setup-wizard__welcome {
display: flex;
flex-direction: column;
gap: 16px;
}
.setup-wizard__welcome-grid {
display: flex;
flex-direction: column;
gap: 10px;
}
.setup-wizard__welcome-card {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 10px 14px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.025);
border: 1px solid rgba(255, 255, 255, 0.05);
}
.setup-wizard__welcome-card strong {
font-size: 0.84rem;
line-height: 1.3;
display: block;
margin-bottom: 2px;
}
.setup-wizard__welcome-card p {
margin: 0;
color: var(--color-fg-muted);
font-size: 0.78rem;
line-height: 1.5;
}
.setup-wizard__welcome-num {
flex-shrink: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
background: rgba(211, 134, 155, 0.12);
color: #d3869b;
font-size: 0.72rem;
font-weight: 700;
margin-top: 1px;
}
.setup-wizard__welcome-note {
margin: 0;
color: var(--color-fg-subtle);
font-size: 0.74rem;
line-height: 1.5;
text-align: center;
}
/* ── Model list ──────────────────────────────────────────────────────── */
.setup-wizard__models {
list-style: none;
margin: 0;
@@ -183,10 +264,25 @@
padding-top: 8px;
}
/* ── Footnote — pinned to bottom ─────────────────────────────────────── */
.setup-wizard__footnote {
color: var(--color-fg-subtle);
font-size: 0.65rem;
margin: 4px 0 0;
font-size: 0.64rem;
margin: 4px 0 6px;
text-align: center;
line-height: 1.5;
flex-shrink: 0;
}
.setup-wizard__footnote code { font-size: 0.62rem; }
/* ── Responsive ─────────────────────────────────────────────────────── */
@media (max-width: 640px) {
.setup-wizard {
padding: 0 14px;
}
.setup-wizard__hero { padding: 14px 8px 6px; }
.setup-wizard__hero h1 { font-size: 1.2rem; }
.setup-wizard__sub { font-size: 0.78rem; }
.setup-wizard__steps { gap: 4px; }
.setup-wizard__step { padding: 4px 10px; font-size: 0.7rem; }
}
+53 -50
View File
@@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useState } from 'react';
import { CheckCircle, Loader, Sparkles, ArrowRight, AlertTriangle, XCircle, RefreshCw } from 'lucide-react';
import { CheckCircle, Loader, ArrowRight, AlertTriangle, XCircle, RefreshCw } from 'lucide-react';
import { Button } from '../ui';
import { setupStatus, preflight } from '../api/setup';
import { useSetupStatus, usePreflight } from '../api/hooks';
import { ModelStoreTab, EnginesTab } from './Settings';
import './SetupWizard.css';
@@ -74,31 +74,23 @@ function PreflightPanel({ report, loading, onRecheck }) {
*/
export default function SetupWizard({ onReady }) {
const [step, setStep] = useState(0);
const [status, setStatus] = useState(null);
const [pre, setPre] = useState(null);
const [preLoading, setPreLoading] = useState(false);
const reload = useCallback(async () => {
try { setStatus(await setupStatus()); }
catch { /* backend warming up — retry on interval */ }
}, []);
// TanStack Query shared cache, auto-refetch on step 2 (models)
const setupQuery = useSetupStatus();
const preQuery = usePreflight();
const status = setupQuery.data ?? null;
const pre = preQuery.data ?? null;
const preLoading = preQuery.isLoading;
const recheckPreflight = useCallback(async () => {
setPreLoading(true);
try { setPre(await preflight()); }
catch { /* backend not ready */ }
finally { setPreLoading(false); }
}, []);
useEffect(() => { reload(); recheckPreflight(); }, [reload, recheckPreflight]);
// Poll while on the Models step so the Finish button unlocks as soon as
// downloads complete, without the user having to click "Recheck".
// Poll setup status every 4s while on Models step so "Finish" unlocks
// as soon as downloads complete.
useEffect(() => {
if (step !== 2) return;
const iv = setInterval(reload, 4000);
const iv = setInterval(() => setupQuery.refetch(), 4000);
return () => clearInterval(iv);
}, [step, reload]);
}, [step, setupQuery]);
const recheckPreflight = useCallback(() => { preQuery.refetch(); }, [preQuery]);
const modelsReady = !!status?.models_ready;
const preflightOk = !!pre?.ok;
@@ -110,8 +102,10 @@ export default function SetupWizard({ onReady }) {
onDoubleClick={doubleClickMaximize}
className="setup-wizard__hero"
>
<Sparkles size={36} color="#d3869b" />
<h1 data-tauri-drag-region>Welcome to OmniVoice Studio</h1>
<div className="setup-wizard__brand">
<img src="/favicon.svg" alt="" className="setup-wizard__logo" />
<h1 data-tauri-drag-region>OmniVoice Studio</h1>
</div>
<p className="setup-wizard__sub" data-tauri-drag-region>
Dubbing, voice cloning, and voice design all running locally on
your machine. Four quick steps and you're in.
@@ -137,39 +131,48 @@ export default function SetupWizard({ onReady }) {
{/* 0. Welcome */}
{step === 0 && (
<div className="setup-wizard__embed" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<strong>What happens next</strong>
<ol style={{ margin: 0, paddingLeft: 20, lineHeight: 1.7, color: 'var(--color-fg-muted)', fontSize: '0.9rem' }}>
<li>
<strong>System check</strong> we'll probe RAM, disk, GPU driver
compatibility, ffmpeg, and network. Any blockers flagged upfront so
nothing fails silently later.
</li>
<li>
<strong>Install models</strong> we'll download ~5 GB of weights
(OmniVoice TTS + Whisper). Required ones first; optional engines
you can enable now or later.
</li>
<li>
<strong>Pick engines</strong> choose which TTS / ASR / LLM
backends to use. Defaults work; power users can pin specific
engines per family.
</li>
<li>
<strong>You're in.</strong> First launch takes ~5-10 minutes to
download. After that, every launch is instant and fully offline.
</li>
</ol>
<div>
<>
<div className="setup-wizard__embed">
<div className="setup-wizard__welcome">
<div className="setup-wizard__welcome-grid">
<div className="flex items-start gap-3 rounded-[8px] border border-[rgba(255,255,255,0.05)] bg-[rgba(255,255,255,0.025)] px-3.5 py-2.5">
<span className="mt-px flex h-6 w-6 shrink-0 items-center justify-center rounded-[6px] bg-[rgba(211,134,155,0.12)] text-[0.72rem] font-bold text-[var(--color-brand)]">1</span>
<div>
<strong className="mb-0.5 block text-[0.84rem] leading-[1.3]">System check</strong>
<p className="m-0 text-[0.78rem] leading-[1.5] text-[var(--color-fg-muted)]">Probe RAM, disk, GPU, ffmpeg, network. Blockers are flagged upfront.</p>
</div>
</div>
<div className="flex items-start gap-3 rounded-[8px] border border-[rgba(255,255,255,0.05)] bg-[rgba(255,255,255,0.025)] px-3.5 py-2.5">
<span className="mt-px flex h-6 w-6 shrink-0 items-center justify-center rounded-[6px] bg-[rgba(211,134,155,0.12)] text-[0.72rem] font-bold text-[var(--color-brand)]">2</span>
<div>
<strong className="mb-0.5 block text-[0.84rem] leading-[1.3]">Install models</strong>
<p className="m-0 text-[0.78rem] leading-[1.5] text-[var(--color-fg-muted)]">Download ~5 GB of weights TTS + Whisper. Required models first, optional ones later.</p>
</div>
</div>
<div className="flex items-start gap-3 rounded-[8px] border border-[rgba(255,255,255,0.05)] bg-[rgba(255,255,255,0.025)] px-3.5 py-2.5">
<span className="mt-px flex h-6 w-6 shrink-0 items-center justify-center rounded-[6px] bg-[rgba(211,134,155,0.12)] text-[0.72rem] font-bold text-[var(--color-brand)]">3</span>
<div>
<strong className="mb-0.5 block text-[0.84rem] leading-[1.3]">Pick engines</strong>
<p className="m-0 text-[0.78rem] leading-[1.5] text-[var(--color-fg-muted)]">Choose TTS / ASR / LLM backends. Defaults work out of the box.</p>
</div>
</div>
</div>
<p className="m-0 text-center text-[0.74rem] leading-[1.5] text-[var(--color-fg-subtle)]">
First run takes 510 minutes to download. After that, every launch is instant and fully offline.
</p>
</div>
</div>
<div className="setup-wizard__nav">
<span />
<Button
variant="primary" size="lg"
variant="primary" size="sm"
onClick={() => setStep(1)}
trailing={<ArrowRight size={14} />}
>
Get started
</Button>
</div>
</div>
</>
)}
{/* 1. System check */}
+67 -35
View File
@@ -3,10 +3,12 @@ import {
Search, Download, Play, Pause, Trash2, User, Film,
Clock, Grid, List, X, Save, Loader, Music, Sparkles,
Star, Crown, Gamepad2, BookOpen, Mic, RotateCcw,
FileAudio, UserPlus, MoreVertical,
FileAudio, UserPlus, MoreVertical, Scissors,
} from 'lucide-react';
import { Button, Input } from '../ui';
import { listCategories, listGalleryVoices, searchYoutube, downloadYoutubeClip, deleteGalleryVoice, saveVoiceAsProfile } from '../api/gallery';
import { searchYoutube, downloadYoutubeClip, deleteGalleryVoice, saveVoiceAsProfile, uploadVoiceClip } from '../api/gallery';
import { useGalleryCategories, useGalleryVoices } from '../api/hooks';
import AudioTrimmer from '../components/AudioTrimmer';
import './VoiceGallery.css';
// Check if running in Tauri
@@ -24,49 +26,30 @@ const CATEGORY_ICONS = {
};
export default function VoiceGallery() {
const [categories, setCategories] = useState([]);
const [voices, setVoices] = useState([]);
const [selectedCategory, setSelectedCategory] = useState(null);
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState([]);
const [isSearching, setIsSearching] = useState(false);
const [isDownloading, setIsDownloading] = useState(false);
const [isLoadingVoices, setIsLoadingVoices] = useState(false);
const [playingVoiceId, setPlayingVoiceId] = useState(null);
const [playingAudio, setPlayingAudio] = useState(null);
const [viewMode, setViewMode] = useState('list');
const [trimmingVoice, setTrimmingVoice] = useState(null);
useEffect(() => {
loadCategories();
}, []);
const { data: categories = [] } = useGalleryCategories();
const queryParams = React.useMemo(() => {
const p = {};
if (selectedCategory) p.category = selectedCategory;
if (searchQuery.trim()) p.search = searchQuery.trim();
return p;
}, [selectedCategory, searchQuery]);
useEffect(() => {
loadVoices();
}, [selectedCategory]);
const voicesQuery = useGalleryVoices(queryParams);
const voices = voicesQuery.data || [];
const isLoadingVoices = voicesQuery.isLoading;
const loadCategories = async () => {
try {
const data = await listCategories();
setCategories(data);
} catch (e) {
console.error('Failed to load categories:', e);
}
};
const loadVoices = async () => {
setIsLoadingVoices(true);
try {
const params = {};
if (selectedCategory) params.category = selectedCategory;
if (searchQuery.trim()) params.search = searchQuery.trim();
const data = await listGalleryVoices(params);
setVoices(data || []);
} catch (e) {
console.error('Failed to load voices:', e);
} finally {
setIsLoadingVoices(false);
}
};
const loadVoices = () => voicesQuery.refetch();
const handleSearch = async () => {
setIsSearching(true);
@@ -193,6 +176,39 @@ export default function VoiceGallery() {
}
};
const handleCropClick = async (voice) => {
try {
const { apiUrl } = await import('../api/client');
const response = await fetch(apiUrl(`/gallery/voices/${voice.id}/preview`));
if (!response.ok) throw new Error("Failed to fetch audio");
const blob = await response.blob();
const file = new File([blob], `${voice.name}.wav`, { type: 'audio/wav' });
setTrimmingVoice({ voice, file });
} catch (e) {
alert("Failed to load audio for trimming: " + e.message);
}
};
const handleConfirmTrim = async (trimmedFile) => {
if (!trimmingVoice) return;
try {
const { voice } = trimmingVoice;
const formData = new FormData();
formData.append('name', `${voice.name} (Cropped)`);
formData.append('character', voice.character);
formData.append('category', voice.category);
formData.append('description', voice.description || '');
formData.append('audio', trimmedFile);
await uploadVoiceClip(formData);
loadVoices();
setTrimmingVoice(null);
} catch (e) {
alert("Failed to upload cropped voice: " + e.message);
}
};
const onSearchKey = (e) => {
if (e.key === 'Enter') handleSearch();
};
@@ -204,7 +220,7 @@ export default function VoiceGallery() {
<div className="header-text">
<h2>Voice Gallery</h2>
</div>
<Button variant="ghost" size="sm" onClick={() => loadVoices()} title="Reload">
<Button variant="ghost" size="sm" onClick={() => voicesQuery.refetch()} title="Reload">
<RotateCcw size={14} />
</Button>
</div>
@@ -322,6 +338,13 @@ export default function VoiceGallery() {
>
<UserPlus size={12} />
</button>
<button
className="action-btn"
onClick={() => handleCropClick(voice)}
title="Crop audio"
>
<Scissors size={12} />
</button>
<button
className="action-btn danger"
onClick={() => handleDeleteVoice(voice)}
@@ -335,6 +358,15 @@ export default function VoiceGallery() {
</div>
)}
</div>
{trimmingVoice && (
<AudioTrimmer
file={trimmingVoice.file}
maxSeconds={60}
onConfirm={handleConfirmTrim}
onCancel={() => setTrimmingVoice(null)}
/>
)}
</div>
);
}
+50 -61
View File
@@ -1,21 +1,20 @@
import React, { useEffect, useRef } from 'react';
import React from 'react';
import * as RadixDialog from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
import Button from './Button';
import './Dialog.css';
/**
* Dialog accessible modal.
* Dialog accessible modal backed by @radix-ui/react-dialog.
*
* - Click-outside closes (unless dismissable={false}).
* - ESC closes.
* - Focus is returned to the previously-focused element on close.
* - `role="dialog" aria-modal="true"` wired up.
* Provides focus trapping, Escape-to-close, scroll lock, and
* proper ARIA attributes out of the box.
*
* @param open controlled visibility
* @param onClose called on backdrop click / ESC / close button
* @param title string in the header; omit for header-less dialog
* @param footer node rendered in the footer region (actions)
* @param size 'sm' | 'md' | 'lg' | 'xl'
* @param open controlled visibility
* @param onClose called on backdrop click / ESC / close button
* @param title string | ReactNode in the header; omit for header-less dialog
* @param footer node rendered in the footer region (actions)
* @param size 'sm' | 'md' | 'lg' | 'xl'
* @param dismissable whether backdrop click / ESC closes (default true)
*/
export default function Dialog({
@@ -27,59 +26,49 @@ export default function Dialog({
dismissable = true,
children,
}) {
const dialogRef = useRef(null);
const previouslyFocused = useRef(null);
const handleOpenChange = (nextOpen) => {
if (!nextOpen && dismissable) onClose?.();
};
useEffect(() => {
if (!open) return;
previouslyFocused.current = document.activeElement;
const firstFocusable = dialogRef.current?.querySelector(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
firstFocusable?.focus?.();
const handleEscapeKeyDown = (e) => {
if (!dismissable) e.preventDefault();
};
const onKey = (e) => {
if (e.key === 'Escape' && dismissable) {
e.stopPropagation();
onClose?.();
}
};
window.addEventListener('keydown', onKey);
return () => {
window.removeEventListener('keydown', onKey);
previouslyFocused.current?.focus?.();
};
}, [open, dismissable, onClose]);
if (!open) return null;
const handlePointerDownOutside = (e) => {
if (!dismissable) e.preventDefault();
};
return (
<div
className="ui-dialog-backdrop"
onMouseDown={(e) => {
if (e.target === e.currentTarget && dismissable) onClose?.();
}}
>
<div
ref={dialogRef}
className={`ui-dialog ui-dialog--${size}`}
role="dialog"
aria-modal="true"
aria-labelledby={title ? 'ui-dialog-title' : undefined}
>
{(title || dismissable) && (
<header className="ui-dialog__header">
{title && <h2 id="ui-dialog-title" className="ui-dialog__title">{title}</h2>}
{dismissable && (
<Button variant="icon" iconSize="sm" onClick={onClose} aria-label="Close">
<X size={12} />
</Button>
)}
</header>
)}
<div className="ui-dialog__body">{children}</div>
{footer && <footer className="ui-dialog__footer">{footer}</footer>}
</div>
</div>
<RadixDialog.Root open={open} onOpenChange={handleOpenChange}>
<RadixDialog.Portal>
<RadixDialog.Overlay className="ui-dialog-backdrop" />
<RadixDialog.Content
className={`ui-dialog ui-dialog--${size}`}
onEscapeKeyDown={handleEscapeKeyDown}
onPointerDownOutside={handlePointerDownOutside}
aria-describedby={undefined}
>
{(title || dismissable) && (
<header className="ui-dialog__header">
{title && (
<RadixDialog.Title className="ui-dialog__title">
{title}
</RadixDialog.Title>
)}
{dismissable && (
<RadixDialog.Close asChild>
<Button variant="icon" iconSize="sm" aria-label="Close">
<X size={12} />
</Button>
</RadixDialog.Close>
)}
</header>
)}
{!title && <RadixDialog.Title className="sr-only">Dialog</RadixDialog.Title>}
<div className="ui-dialog__body">{children}</div>
{footer && <footer className="ui-dialog__footer">{footer}</footer>}
</RadixDialog.Content>
</RadixDialog.Portal>
</RadixDialog.Root>
);
}
+63 -200
View File
@@ -1,36 +1,20 @@
import React, {
useState, useRef, useId, useEffect, useCallback, cloneElement, isValidElement,
} from 'react';
import { createPortal } from 'react-dom';
import React, { useCallback, isValidElement } from 'react';
import * as RadixMenu from '@radix-ui/react-dropdown-menu';
import { ChevronRight } from 'lucide-react';
import './Menu.css';
/**
* Menu floating action menu triggered by a child element.
* Backed by @radix-ui/react-dropdown-menu for keyboard navigation,
* collision-aware positioning, and proper ARIA attributes.
*
* <Menu
* placement="bottom-start"
* items={[
* { id: 'rename', label: 'Rename', icon: Pencil, onSelect: () => , shortcut: '⌘R' },
* { id: 'dup', label: 'Duplicate', onSelect: () => },
* 'separator',
* { id: 'delete', label: 'Delete', icon: Trash2, destructive: true, onSelect: () => },
* ]}
* >
* <Button variant="icon"></Button>
* </Menu>
*
* - Wraps exactly one child the trigger. Click opens, click-outside closes.
* - Keyboard: Space/Enter opens on focused trigger; navigate; Enter/Space select;
* ESC closes (returns focus to trigger); Tab closes.
* - `aria-haspopup`, `aria-expanded`, `role=menu`, `role=menuitem` all wired.
* - Rendered into a Portal at document.body so panels don't clip it.
*
* Items: array of either
* - 'separator' (or { type: 'separator' })
* - { id, label, icon?, shortcut?, disabled?, destructive?, onSelect }
*
* Placement: 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end'.
* @param children exactly one child the trigger element
* @param items array of items or 'separator' strings
* @param placement 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end'
* @param open controlled open state (optional)
* @param onOpenChange callback on open/close
* @param width optional fixed width (px)
* @param disabled disable the trigger
*/
export default function Menu({
children,
@@ -38,185 +22,64 @@ export default function Menu({
placement = 'bottom-start',
open: controlledOpen,
onOpenChange,
width, // optional fixed width (px)
width,
disabled = false,
}) {
const isControlled = controlledOpen != null;
const [internalOpen, setInternalOpen] = useState(false);
const open = isControlled ? controlledOpen : internalOpen;
const setOpen = useCallback((next) => {
if (!isControlled) setInternalOpen(next);
onOpenChange?.(next);
}, [isControlled, onOpenChange]);
const triggerRef = useRef(null);
const panelRef = useRef(null);
const menuId = useId();
const [focusIndex, setFocusIndex] = useState(-1);
const [coords, setCoords] = useState(null);
const enabledIndices = items
.map((it, i) => (typeof it === 'string' || it?.type === 'separator' || it?.disabled ? -1 : i))
.filter((i) => i !== -1);
// Position the panel relative to the trigger.
useEffect(() => {
if (!open) return;
const trigger = triggerRef.current;
if (!trigger) return;
const compute = () => {
const r = trigger.getBoundingClientRect();
const below = placement.startsWith('bottom');
const end = placement.endsWith('end');
setCoords({
top: below ? r.bottom + 4 : r.top - 4,
left: end ? r.right : r.left,
end,
below,
});
};
compute();
window.addEventListener('scroll', compute, true);
window.addEventListener('resize', compute);
return () => {
window.removeEventListener('scroll', compute, true);
window.removeEventListener('resize', compute);
};
}, [open, placement]);
// Focus first enabled item when opened by keyboard; reset when closed.
useEffect(() => {
if (!open) { setFocusIndex(-1); return; }
// default focus index = first enabled
setFocusIndex(enabledIndices[0] ?? -1);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
// Click-outside + ESC dismiss.
useEffect(() => {
if (!open) return;
const onMouseDown = (e) => {
if (
triggerRef.current && !triggerRef.current.contains(e.target) &&
panelRef.current && !panelRef.current.contains(e.target)
) {
setOpen(false);
}
};
const onKey = (e) => {
if (e.key === 'Escape') {
e.stopPropagation();
setOpen(false);
triggerRef.current?.focus?.();
} else if (e.key === 'Tab') {
setOpen(false);
} else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
if (!enabledIndices.length) return;
e.preventDefault();
const dir = e.key === 'ArrowDown' ? 1 : -1;
const pos = enabledIndices.indexOf(focusIndex);
const next = (pos + dir + enabledIndices.length) % enabledIndices.length;
setFocusIndex(enabledIndices[next]);
} else if (e.key === 'Home') {
e.preventDefault(); setFocusIndex(enabledIndices[0] ?? -1);
} else if (e.key === 'End') {
e.preventDefault(); setFocusIndex(enabledIndices[enabledIndices.length - 1] ?? -1);
}
};
window.addEventListener('mousedown', onMouseDown);
window.addEventListener('keydown', onKey);
return () => {
window.removeEventListener('mousedown', onMouseDown);
window.removeEventListener('keydown', onKey);
};
}, [open, focusIndex, enabledIndices, setOpen]);
// Focus the active menu item as focusIndex changes.
useEffect(() => {
if (!open || focusIndex < 0) return;
const el = panelRef.current?.querySelector(`[data-ui-menu-idx="${focusIndex}"]`);
el?.focus?.();
}, [focusIndex, open]);
// Map our placement string to Radix side + align
const sideMap = {
'bottom-start': { side: 'bottom', align: 'start' },
'bottom-end': { side: 'bottom', align: 'end' },
'top-start': { side: 'top', align: 'start' },
'top-end': { side: 'top', align: 'end' },
};
const { side, align } = sideMap[placement] || sideMap['bottom-start'];
if (!isValidElement(children)) {
// Keep the API resilient to a bare string or nothing.
return children ?? null;
}
const trigger = cloneElement(children, {
ref: (node) => {
triggerRef.current = node;
const orig = children.ref;
if (typeof orig === 'function') orig(node);
else if (orig && 'current' in orig) orig.current = node;
},
'aria-haspopup': 'menu',
'aria-expanded': open,
'aria-controls': open ? menuId : undefined,
onClick: (e) => {
children.props.onClick?.(e);
if (e.defaultPrevented || disabled) return;
setOpen(!open);
},
onKeyDown: (e) => {
children.props.onKeyDown?.(e);
if (disabled) return;
if (!open && (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown')) {
e.preventDefault();
setOpen(true);
}
},
});
const rootProps = {};
if (controlledOpen != null) {
rootProps.open = controlledOpen;
rootProps.onOpenChange = onOpenChange;
} else if (onOpenChange) {
rootProps.onOpenChange = onOpenChange;
}
const handleSelect = (item) => {
if (item.disabled) return;
setOpen(false);
triggerRef.current?.focus?.();
item.onSelect?.();
};
const panel = open && coords ? createPortal(
<div
ref={panelRef}
id={menuId}
role="menu"
className={`ui-menu ui-menu--${coords.below ? 'below' : 'above'} ui-menu--${coords.end ? 'end' : 'start'}`}
style={{
top: coords.below ? coords.top : undefined,
bottom: coords.below ? undefined : window.innerHeight - coords.top,
left: coords.end ? undefined : coords.left,
right: coords.end ? window.innerWidth - coords.left : undefined,
width,
}}
>
{items.map((item, i) => {
if (item === 'separator' || item?.type === 'separator') {
return <div key={`sep-${i}`} role="separator" className="ui-menu__separator" />;
}
const Icon = item.icon;
return (
<button
key={item.id ?? i}
type="button"
role="menuitem"
data-ui-menu-idx={i}
className={`ui-menu__item ${item.destructive ? 'is-destructive' : ''} ${item.disabled ? 'is-disabled' : ''}`}
aria-disabled={item.disabled || undefined}
tabIndex={focusIndex === i ? 0 : -1}
onMouseEnter={() => setFocusIndex(i)}
onClick={() => handleSelect(item)}
>
{Icon && <Icon size={12} className="ui-menu__icon" />}
<span className="ui-menu__label">{item.label}</span>
{item.shortcut && <span className="ui-menu__shortcut">{item.shortcut}</span>}
{item.trailing}
</button>
);
})}
</div>,
document.body,
) : null;
return <>{trigger}{panel}</>;
return (
<RadixMenu.Root {...rootProps}>
<RadixMenu.Trigger asChild disabled={disabled}>
{children}
</RadixMenu.Trigger>
<RadixMenu.Portal>
<RadixMenu.Content
side={side}
align={align}
sideOffset={4}
className={`ui-menu ui-menu--below ui-menu--${align}`}
style={width ? { width } : undefined}
>
{items.map((item, i) => {
if (item === 'separator' || item?.type === 'separator') {
return <RadixMenu.Separator key={`sep-${i}`} className="ui-menu__separator" />;
}
const Icon = item.icon;
return (
<RadixMenu.Item
key={item.id ?? i}
className={`ui-menu__item ${item.destructive ? 'is-destructive' : ''} ${item.disabled ? 'is-disabled' : ''}`}
disabled={item.disabled}
onSelect={() => item.onSelect?.()}
>
{Icon && <Icon size={12} className="ui-menu__icon" />}
<span className="ui-menu__label">{item.label}</span>
{item.shortcut && <span className="ui-menu__shortcut">{item.shortcut}</span>}
{item.trailing}
</RadixMenu.Item>
);
})}
</RadixMenu.Content>
</RadixMenu.Portal>
</RadixMenu.Root>
);
}
+8 -8
View File
@@ -1,8 +1,10 @@
import React from 'react';
import * as RadixProgress from '@radix-ui/react-progress';
import './Progress.css';
/**
* Progress determinate or indeterminate progress bar.
* Backed by @radix-ui/react-progress for proper ARIA value attributes.
*
* @param value 0100 when determinate. Omit for indeterminate.
* @param tone 'brand' (default) | 'success' | 'warn' | 'danger'
@@ -19,21 +21,19 @@ export default function Progress({
}) {
const indeterminate = value == null;
const showShimmer = shimmer ?? !indeterminate;
const clamped = indeterminate ? 100 : Math.max(0, Math.min(100, value));
const clamped = indeterminate ? null : Math.max(0, Math.min(100, value));
return (
<div
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={indeterminate ? undefined : clamped}
<RadixProgress.Root
value={clamped}
max={100}
className={`ui-progress ui-progress--${tone} ui-progress--size-${size} ${indeterminate ? 'is-indeterminate' : ''} ${className}`}
{...rest}
>
<div
<RadixProgress.Indicator
className={`ui-progress__fill ${showShimmer ? 'has-shimmer' : ''}`}
style={indeterminate ? undefined : { width: `${clamped}%` }}
/>
</div>
</RadixProgress.Root>
);
}
+24 -22
View File
@@ -1,14 +1,16 @@
import React from 'react';
import * as ToggleGroup from '@radix-ui/react-toggle-group';
import './Segmented.css';
/**
* Segmented compact segmented control for small option sets.
* Good for density pickers (S/M/L), view toggles, mode switches.
* Backed by @radix-ui/react-toggle-group for keyboard navigation
* and proper aria-pressed state management.
*
* @param items array of { value, label } label is short text
* @param value currently selected `value`
* @param items array of { value, label, title? }
* @param value currently selected `value`
* @param onChange (value) => void
* @param size 'xs' | 'sm'
* @param size 'xs' | 'sm'
*/
export default function Segmented({
items = [],
@@ -19,26 +21,26 @@ export default function Segmented({
...rest
}) {
return (
<div
role="radiogroup"
<ToggleGroup.Root
type="single"
value={value}
onValueChange={(val) => {
// Radix fires '' when you re-click the active item; ignore that
if (val) onChange?.(val);
}}
className={`ui-seg ui-seg--size-${size} ${className}`}
{...rest}
>
{items.map((item) => {
const active = value === item.value;
return (
<button
key={item.value}
role="radio"
aria-checked={active}
className={`ui-seg__opt ${active ? 'is-active' : ''}`}
onClick={() => onChange?.(item.value)}
title={item.title || undefined}
>
{item.label}
</button>
);
})}
</div>
{items.map((item) => (
<ToggleGroup.Item
key={item.value}
value={item.value}
className={`ui-seg__opt ${value === item.value ? 'is-active' : ''}`}
title={item.title || undefined}
>
{item.label}
</ToggleGroup.Item>
))}
</ToggleGroup.Root>
);
}
+43 -45
View File
@@ -1,4 +1,4 @@
/* ── Slider primitive ───────────────────────────────────────────── */
/* ── Slider primitive (Radix UI) ──────────────────────────────── */
.ui-slider { width: 100%; display: flex; flex-direction: column; gap: var(--space-1); }
.ui-slider__label {
@@ -13,53 +13,51 @@
gap: var(--space-3);
}
.ui-slider__input {
-webkit-appearance: none;
appearance: none;
/* Radix Slider Root */
.ui-slider__root {
position: relative;
display: flex;
align-items: center;
flex: 1;
height: 20px;
cursor: pointer;
touch-action: none;
user-select: none;
}
/* Track */
.ui-slider__track {
position: relative;
flex-grow: 1;
height: 3px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.08);
}
/* Filled range */
.ui-slider__range {
position: absolute;
height: 100%;
border-radius: inherit;
background: var(--color-brand);
}
/* Thumb */
.ui-slider__thumb {
display: block;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--color-fg);
cursor: pointer;
border: 2px solid var(--color-bg);
box-shadow: var(--shadow-sm);
transition: transform var(--dur-fast) var(--ease-spring), background var(--dur-fast);
outline: none;
cursor: pointer;
/* painted track = filled portion (brand) + rest (muted) */
background:
linear-gradient(to right,
var(--color-brand) 0%,
var(--color-brand) var(--ui-slider-pct, 0%),
rgba(255, 255, 255, 0.08) var(--ui-slider-pct, 0%),
rgba(255, 255, 255, 0.08) 100%);
transition: background var(--dur-fast) var(--ease-out);
}
.ui-slider__input::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--color-fg);
cursor: pointer;
border: 2px solid var(--color-bg);
box-shadow: var(--shadow-sm);
transition: transform var(--dur-fast) var(--ease-spring), background var(--dur-fast);
}
.ui-slider__input::-webkit-slider-thumb:hover { transform: scale(1.25); }
.ui-slider__input::-webkit-slider-thumb:active { transform: scale(1.1); background: var(--color-brand); }
.ui-slider__input::-moz-range-thumb {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--color-fg);
cursor: pointer;
border: 2px solid var(--color-bg);
box-shadow: var(--shadow-sm);
transition: transform var(--dur-fast) var(--ease-spring), background var(--dur-fast);
}
.ui-slider__input::-moz-range-thumb:hover { transform: scale(1.25); }
.ui-slider__input::-moz-range-thumb:active { transform: scale(1.1); background: var(--color-brand); }
.ui-slider__input:focus-visible { box-shadow: var(--focus-ring); }
.ui-slider__thumb:hover { transform: scale(1.25); }
.ui-slider__thumb:active { transform: scale(1.1); background: var(--color-brand); }
.ui-slider__thumb:focus-visible { box-shadow: var(--focus-ring); }
.ui-slider__value {
font-size: var(--text-xs);
@@ -75,6 +73,6 @@
flex-shrink: 0;
}
/* size sm: shorter value bubble */
.ui-slider--size-sm .ui-slider__input { height: 2px; }
/* size sm: shorter */
.ui-slider--size-sm .ui-slider__track { height: 2px; }
.ui-slider--size-sm .ui-slider__value { font-size: var(--text-2xs); padding: 0 4px; }
+14 -9
View File
@@ -1,12 +1,15 @@
import React, { forwardRef, useId } from 'react';
import * as RadixSlider from '@radix-ui/react-slider';
import './Slider.css';
/**
* Slider styled horizontal range input.
* Backed by @radix-ui/react-slider for full keyboard accessibility
* (arrow keys, Home/End) and proper ARIA value announcements.
*
* @param value controlled number
* @param onChange receives the new number (not the event)
* @param min, max, step standard HTMLInputRange props
* @param min, max, step standard range props
* @param format optional (v) => string for the value bubble
* @param showValue show the trailing value bubble (default true)
* @param label optional small label above the track
@@ -29,25 +32,27 @@ const Slider = forwardRef(function Slider(
ref,
) {
const id = useId();
const pct = ((Number(value) - min) / (max - min)) * 100;
return (
<div className={`ui-slider ui-slider--size-${size} ${className}`}>
{label && <label htmlFor={id} className="ui-slider__label">{label}</label>}
<div className="ui-slider__row">
<input
<RadixSlider.Root
ref={ref}
id={id}
type="range"
className="ui-slider__input"
value={value}
className="ui-slider__root"
value={[Number(value)]}
onValueChange={([v]) => onChange?.(v)}
min={min}
max={max}
step={step}
onChange={(e) => onChange?.(Number(e.target.value))}
style={{ '--ui-slider-pct': `${pct}%` }}
{...rest}
/>
>
<RadixSlider.Track className="ui-slider__track">
<RadixSlider.Range className="ui-slider__range" />
</RadixSlider.Track>
<RadixSlider.Thumb className="ui-slider__thumb" />
</RadixSlider.Root>
{showValue && (
<span className="ui-slider__value" aria-live="polite">
{format(value)}
+30 -24
View File
@@ -1,8 +1,12 @@
import React from 'react';
import * as RadixTabs from '@radix-ui/react-tabs';
import './Tabs.css';
/**
* Tabs pill-style segmented tab group.
* Tabs pill-style segmented tab group, backed by @radix-ui/react-tabs.
*
* Provides roving tabindex, arrow-key navigation, and proper
* aria-selected / role="tab" / role="tablist" attributes.
*
* @param items array of { id, label, icon?, accent? }
* @param value currently selected id
@@ -20,29 +24,31 @@ export default function Tabs({
...rest
}) {
return (
<div
role="tablist"
className={`ui-tabs ui-tabs--${variant} ui-tabs--size-${size} ${className}`}
{...rest}
<RadixTabs.Root
value={value}
onValueChange={onChange}
activationMode="manual"
>
{items.map((item) => {
const active = value === item.id;
const Icon = item.icon;
return (
<button
key={item.id}
role="tab"
aria-selected={active}
tabIndex={active ? 0 : -1}
className={`ui-tabs__tab ${active ? 'is-active' : ''}`}
onClick={() => onChange?.(item.id)}
style={active && item.accent ? { '--ui-tab-accent': item.accent } : undefined}
>
{Icon && <Icon size={12} className="ui-tabs__icon" />}
<span>{item.label}</span>
</button>
);
})}
</div>
<RadixTabs.List
className={`ui-tabs ui-tabs--${variant} ui-tabs--size-${size} ${className}`}
{...rest}
>
{items.map((item) => {
const active = value === item.id;
const Icon = item.icon;
return (
<RadixTabs.Trigger
key={item.id}
value={item.id}
className={`ui-tabs__tab ${active ? 'is-active' : ''}`}
style={active && item.accent ? { '--ui-tab-accent': item.accent } : undefined}
>
{Icon && <Icon size={12} className="ui-tabs__icon" />}
<span>{item.label}</span>
</RadixTabs.Trigger>
);
})}
</RadixTabs.List>
</RadixTabs.Root>
);
}
+25 -49
View File
@@ -1,15 +1,14 @@
import React, { useState, useRef, useId, useEffect } from 'react';
import React from 'react';
import * as RadixTooltip from '@radix-ui/react-tooltip';
import './Tooltip.css';
/**
* Tooltip keyboard-accessible replacement for `title=`.
* Backed by @radix-ui/react-tooltip for collision-aware positioning.
*
* Shows on hover and on keyboard focus. Dismisses on Escape.
* Wraps exactly one child; forwards aria-describedby to it.
*
* @param content tooltip body (string or node)
* @param placement 'top' | 'bottom' | 'left' | 'right'
* @param delay ms before showing (default 300)
* @param content tooltip body (string or node)
* @param placement 'top' | 'bottom' | 'left' | 'right'
* @param delay ms before showing (default 300)
*/
export default function Tooltip({
content,
@@ -17,51 +16,28 @@ export default function Tooltip({
delay = 300,
children,
}) {
const [open, setOpen] = useState(false);
const timer = useRef(null);
const id = useId();
const show = () => {
clearTimeout(timer.current);
timer.current = setTimeout(() => setOpen(true), delay);
};
const hide = () => {
clearTimeout(timer.current);
setOpen(false);
};
useEffect(() => {
if (!open) return;
const onKey = (e) => { if (e.key === 'Escape') hide(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open]);
useEffect(() => () => clearTimeout(timer.current), []);
if (!content) return children;
if (!React.isValidElement(children)) return children;
const trigger = React.cloneElement(children, {
'aria-describedby': open ? id : children.props['aria-describedby'],
onMouseEnter: (...args) => { show(); children.props.onMouseEnter?.(...args); },
onMouseLeave: (...args) => { hide(); children.props.onMouseLeave?.(...args); },
onFocus: (...args) => { show(); children.props.onFocus?.(...args); },
onBlur: (...args) => { hide(); children.props.onBlur?.(...args); },
});
// Map our placement names to Radix side names
const sideMap = { top: 'top', bottom: 'bottom', left: 'left', right: 'right' };
const side = sideMap[placement] || 'top';
return (
<span className="ui-tooltip-wrap">
{trigger}
{open && (
<span
id={id}
role="tooltip"
className={`ui-tooltip ui-tooltip--${placement}`}
>
{content}
</span>
)}
</span>
<RadixTooltip.Provider delayDuration={delay}>
<RadixTooltip.Root>
<RadixTooltip.Trigger asChild>
{children}
</RadixTooltip.Trigger>
<RadixTooltip.Portal>
<RadixTooltip.Content
side={side}
sideOffset={5}
className={`ui-tooltip ui-tooltip--${placement}`}
>
{content}
</RadixTooltip.Content>
</RadixTooltip.Portal>
</RadixTooltip.Root>
</RadixTooltip.Provider>
);
}
+2 -1
View File
@@ -1,10 +1,11 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import path from 'path'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
plugins: [tailwindcss(), react()],
clearScreen: false,
resolve: {
preserveSymlinks: false,
+8 -3
View File
@@ -4,13 +4,16 @@
"private": true,
"packageManager": "bun@1.3.11",
"scripts": {
"setup:api": "uv sync && uv run python scripts/setup_cudnn.py",
"dev:api": "uv run uvicorn main:app --app-dir backend --host 0.0.0.0 --port 3900 --reload",
"dev:frontend": "bun run --cwd frontend dev",
"dev:desktop": "bun run --cwd frontend desktop",
"wait:api": "wait-on -t 120000 http-get://localhost:3900/system/info",
"wait:api": "wait-on -t 300000 http-get://localhost:3900/system/info",
"wait:frontend": "wait-on -t 120000 http://localhost:5173",
"dev": "concurrently -n api,fe -c green,cyan --kill-others-on-fail \"bun run dev:api\" \"bun run wait:api && bun run dev:frontend\"",
"desktop": "concurrently -n api,app -c green,magenta --kill-others-on-fail \"bun run dev:api\" \"bun run wait:api && bun run dev:desktop\"",
"predev": "kill-port 3900 3901 || true",
"dev": "bun run setup:api && concurrently -n api,fe -c green,cyan --kill-others-on-fail \"bun run dev:api\" \"bun run wait:api && bun run dev:frontend\"",
"predesktop": "kill-port 3900 3901 || true",
"desktop": "bun run setup:api && concurrently -n api,app -c green,magenta --kill-others-on-fail \"bun run dev:api\" \"bun run wait:api && bun run dev:desktop\"",
"build": "turbo run build",
"start": "turbo run start",
"test:frontend": "node --test tests/frontend/*.test.mjs"
@@ -20,6 +23,8 @@
],
"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"
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 962 KiB

After

Width:  |  Height:  |  Size: 358 KiB

+103
View File
@@ -0,0 +1,103 @@
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch({ headless: true });
const ctx = await browser.newContext({
viewport: { width: 1440, height: 900 },
deviceScaleFactor: 2,
colorScheme: 'dark',
});
const page = await ctx.newPage();
const dir = 'docs';
await page.goto('http://localhost:3901', { waitUntil: 'networkidle', timeout: 15000 });
await page.waitForTimeout(2000);
// Helper: click a sidebar icon by index (0-based, top to bottom)
async function clickSidebarIcon(index) {
const icons = page.locator('nav button, nav a, aside button, aside a, [class*="sidebar"] button, [class*="sidebar"] a, [class*="Sidebar"] button');
const count = await icons.count();
if (index < count) {
await icons.nth(index).click();
await page.waitForTimeout(1500);
}
}
// 1. Gallery / Voice Library — click the icon that looks like a folder/library
// Try to find gallery-related navigation
try {
// Look for gallery/library nav item
const galleryBtn = page.locator('button:has-text("Gallery"), a:has-text("Gallery"), [data-tab="gallery"], [title*="allery"], [title*="ibrary"]').first();
if (await galleryBtn.isVisible({ timeout: 1000 })) {
await galleryBtn.click();
await page.waitForTimeout(2000);
await page.screenshot({ path: `${dir}/screenshot-gallery.png`, fullPage: false });
console.log('✅ screenshot-gallery.png');
}
} catch(e) { console.log('Gallery not found via text, trying sidebar icons'); }
// 2. Settings page
try {
const settingsBtn = page.locator('button:has-text("Settings"), a:has-text("Settings"), [data-tab="settings"], [title*="etting"]').first();
if (await settingsBtn.isVisible({ timeout: 1000 })) {
await settingsBtn.click();
await page.waitForTimeout(2000);
await page.screenshot({ path: `${dir}/screenshot-settings.png`, fullPage: false });
console.log('✅ screenshot-settings.png');
}
} catch(e) { console.log('Settings not found via text'); }
// 3. Try clicking each sidebar icon to discover hidden views
// The sidebar icons are the vertical strip on the left
const sideIcons = page.locator('aside > div > button, aside > button, nav[class*="side"] button, [class*="sidebar"] > button, [class*="Sidebar"] > button');
const iconCount = await sideIcons.count();
console.log(`Found ${iconCount} sidebar icons`);
// Click each icon and screenshot if it reveals a new view
const captured = new Set();
for (let i = 0; i < Math.min(iconCount, 10); i++) {
try {
await sideIcons.nth(i).click();
await page.waitForTimeout(1500);
// Get the page title/header to name the screenshot
const header = await page.locator('h1, h2, [class*="title"], [class*="header"] span').first().textContent().catch(() => '');
const name = (header || `view-${i}`).toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 30);
if (!captured.has(name)) {
captured.add(name);
await page.screenshot({ path: `${dir}/screenshot-${name}.png`, fullPage: false });
console.log(`✅ screenshot-${name}.png (icon ${i})`);
}
} catch(e) {}
}
// 4. Also try bottom-bar items (LOGS, Backend, Frontend, Tauri)
try {
const logsBtn = page.locator('button:has-text("LOGS"), [class*="log"] button').first();
if (await logsBtn.isVisible({ timeout: 1000 })) {
await logsBtn.click();
await page.waitForTimeout(1500);
// Expand log panel if collapsed
await page.screenshot({ path: `${dir}/screenshot-logs.png`, fullPage: false });
console.log('✅ screenshot-logs.png');
}
} catch(e) {}
// 5. A/B Compare button
try {
// First go to launchpad
await page.goto('http://localhost:3901', { waitUntil: 'networkidle', timeout: 10000 });
await page.waitForTimeout(1500);
const abBtn = page.locator('button:has-text("A/B"), button:has-text("Compare")').first();
if (await abBtn.isVisible({ timeout: 1000 })) {
await abBtn.click();
await page.waitForTimeout(2000);
await page.screenshot({ path: `${dir}/screenshot-ab-compare.png`, fullPage: false });
console.log('✅ screenshot-ab-compare.png');
}
} catch(e) { console.log('A/B Compare not found'); }
await browser.close();
console.log('All done!');
})();
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env python3
"""Post-install setup for CUDA environments.
Ensures cuDNN 8 libraries are available for CTranslate2 (faster-whisper /
WhisperX) alongside PyTorch 2.8+'s cuDNN 9. Run automatically as part of
`bun run setup:api` no user action required.
Cross-platform:
- Linux: installs .so.8 libs into cudnn8_compat/
- Windows: installs .dll libs into cudnn8_compat/
- macOS: skipped (no CUDA)
"""
import os
import sys
import subprocess
import glob
def _find_compat_dir():
"""Return the cudnn8_compat target directory, auto-detecting venv layout."""
script_dir = os.path.dirname(os.path.abspath(__file__))
project_root = os.path.dirname(script_dir)
venv_dir = os.path.join(project_root, ".venv")
if not os.path.isdir(venv_dir):
return None
if sys.platform == "win32":
# Windows: .venv/Lib/site-packages/
sp = os.path.join(venv_dir, "Lib", "site-packages", "cudnn8_compat")
else:
# Linux: .venv/lib/pythonX.Y/site-packages/
pyver = f"python{sys.version_info.major}.{sys.version_info.minor}"
sp = os.path.join(venv_dir, "lib", pyver, "site-packages", "cudnn8_compat")
return sp
def _cudnn8_lib_dir(compat_dir):
"""Return the cuDNN lib subdirectory within the compat install."""
if sys.platform == "win32":
return os.path.join(compat_dir, "nvidia", "cudnn", "bin")
return os.path.join(compat_dir, "nvidia", "cudnn", "lib")
def _count_cudnn8_libs(lib_dir):
"""Count cuDNN 8 shared libraries in the given directory."""
if sys.platform == "win32":
return len(glob.glob(os.path.join(lib_dir, "cudnn*64_8.dll")))
return len(glob.glob(os.path.join(lib_dir, "libcudnn*.so.8")))
def main():
# macOS — no CUDA, nothing to do
if sys.platform == "darwin":
return
compat_dir = _find_compat_dir()
if compat_dir is None:
return
lib_dir = _cudnn8_lib_dir(compat_dir)
# Already installed?
if os.path.isdir(lib_dir):
n = _count_cudnn8_libs(lib_dir)
if n >= 5:
print(f"✓ cuDNN 8 compat: {n} libraries ready")
return
# Check if CUDA is available before installing GPU-only libs
try:
result = subprocess.run(
[sys.executable, "-c", "import torch; print(torch.cuda.is_available())"],
capture_output=True, text=True, timeout=30,
)
if result.stdout.strip() != "True":
print("✓ No CUDA — cuDNN 8 compat not needed")
return
except Exception:
pass # Can't detect CUDA — install anyway, it's harmless on CPU
print("⚙ Installing cuDNN 8 compatibility libraries for CTranslate2...")
try:
subprocess.run(
[
sys.executable, "-m", "pip", "install",
"--no-deps", "--target", compat_dir,
"nvidia-cudnn-cu12==8.9.7.29",
],
check=True,
capture_output=True,
text=True,
timeout=180,
)
n = _count_cudnn8_libs(lib_dir)
print(f"✓ cuDNN 8 installed: {n} libraries")
except subprocess.CalledProcessError as e:
print(f"⚠ cuDNN 8 install failed (transcription may not work on CUDA):")
print(f" {(e.stderr or '')[:300]}")
except Exception as e:
print(f"⚠ cuDNN 8 install skipped: {e}")
if __name__ == "__main__":
main()
+4
View File
@@ -28,6 +28,10 @@ def app_client(tmp_path, monkeypatch):
monkeypatch.setenv("OMNIVOICE_DATA_DIR", str(tmp_path))
import core.config as _cfg
importlib.reload(_cfg)
# Reload core.tasks so TaskManager gets a fresh asyncio.Queue bound to the
# current event loop (TestClient creates its own loop per fixture).
import core.tasks as _tasks
importlib.reload(_tasks)
from api.routers import dub_core as _dc
importlib.reload(_dc)
from api.routers import dub_export as _dx
+8 -8
View File
@@ -86,7 +86,7 @@ def test_preflight_detects_apple_silicon():
"""On mac-ARM, vendor → 'apple' and backend → 'mps'."""
if sys.platform != "darwin":
pytest.skip("apple-silicon branch only exercisable on darwin")
from api.routers.setup import _detect_gpu
from api.routers.setup.wizard import _detect_gpu
info = _detect_gpu()
# mac-Intel CI hosts also hit darwin; only assert vendor if arch matches.
import platform as _p
@@ -97,8 +97,8 @@ def test_preflight_detects_apple_silicon():
def test_preflight_handles_missing_nvidia_smi():
"""When nvidia-smi is absent, vendor falls through (not nvidia)."""
from api.routers.setup import _detect_gpu, _run_cmd # noqa
with patch("api.routers.setup._run_cmd", return_value=(-1, "")):
from api.routers.setup.wizard import _detect_gpu, _run_cmd # noqa
with patch("api.routers.setup.wizard._run_cmd", return_value=(-1, "")):
info = _detect_gpu()
# On mac-ARM the apple branch returns before _run_cmd; skip that case.
import platform as _p
@@ -111,7 +111,7 @@ def test_preflight_nvidia_driver_below_min_flags_fail():
import platform as _p
if sys.platform == "darwin" and _p.machine() == "arm64":
pytest.skip("apple-silicon branch returns before nvidia-smi — not reachable")
from api.routers import setup as setup_mod
from api.routers.setup import wizard as setup_mod
def fake_run_cmd(args, timeout=2.0):
if args and args[0] == "nvidia-smi":
@@ -131,7 +131,7 @@ def test_preflight_amd_flags_warn_when_no_rocm_torch():
import platform as _p
if sys.platform == "darwin" and _p.machine() == "arm64":
pytest.skip("apple-silicon branch returns before rocm-smi")
from api.routers import setup as setup_mod
from api.routers.setup import wizard as setup_mod
def fake_run_cmd(args, timeout=2.0):
if args and args[0] == "rocm-smi":
@@ -151,7 +151,7 @@ def test_preflight_amd_flags_warn_when_no_rocm_torch():
def test_preflight_network_handles_offline():
"""_probe_network must gracefully return False on connection error."""
from api.routers.setup import _probe_network
from api.routers.setup.wizard import _probe_network
# Deliberately unreachable host:port
assert _probe_network(host="10.255.255.1", timeout=0.3) is False
@@ -160,7 +160,7 @@ def test_preflight_network_handles_offline():
def test_preflight_ram_fail_threshold():
"""Below _RAM_FAIL_GB → fail status in the RAM check."""
from api.routers import setup as setup_mod
from api.routers.setup import wizard as setup_mod
with patch.object(setup_mod, "_ram_gb", return_value=4.0):
r = client_factory().get("/setup/preflight").json()
@@ -170,7 +170,7 @@ def test_preflight_ram_fail_threshold():
def test_preflight_ram_warn_threshold():
"""Between fail and warn thresholds → warn."""
from api.routers import setup as setup_mod
from api.routers.setup import wizard as setup_mod
with patch.object(setup_mod, "_ram_gb", return_value=10.0):
r = client_factory().get("/setup/preflight").json()