Compare commits

...
16 Commits
Author SHA1 Message Date
Palash Debnath 936e39ece5 fix: cross-platform backend log path + Windows startup hardening (#31)
Three fixes for the Windows MSI first-launch failure:

1. **backend_log_path() was macOS-only** — used $HOME + Library/Logs
   which doesn't exist on Windows. Now uses %LOCALAPPDATA% on Windows,
   ~/Library/Logs on macOS, and XDG_STATE_HOME on Linux. Without this,
   stdout/stderr went to Stdio::null() and all backend crash output was
   silently lost.

2. **Add TORCHDYNAMO_DISABLE=1 on Windows** — prevents PyTorch from
   trying to download Triton (which has no Windows support), avoiding a
   hang during first torch.compile() call (#26 workaround).

3. **Increase health timeout from 180s to 300s** — first-run PyTorch
   import on Windows can take 120+ seconds for CUDA kernel JIT, plus
   uv sync + torch + model loading. 3 min wasn't enough.

Fixes #30
2026-04-28 10:33:26 +05:30
Palash Debnath 6c427d4451 fix: resolve Tauri _up_ resource paths for Windows/Linux MSI bootstrap (#29)
Tauri v2 replaces `../` with `_up_/` when bundling resources into MSI
and deb installers. The `../../pyproject.toml` config path becomes
`$RESOURCE/_up_/_up_/pyproject.toml` at runtime, but the Rust bootstrap
only checked the flat `$RESOURCE/pyproject.toml` path.

This worked on macOS (.app bundles flatten into Contents/Resources/) but
failed on Windows MSI and Linux deb with:
  Missing bootstrap resources (pyproject=..., backend=...)

Fix: try both the flat path and the _up_/_up_ prefixed path, with
diagnostic logging if neither is found.

Fixes #28
2026-04-28 09:28:15 +05:30
debpalash 3adf239548 chore: bump version to v0.2.4 2026-04-27 22:16:55 +05:30
Palash Debnath 34610ca091 feat: real-time WebSocket event bus + sidebar reactivity fixes (#27)
## Core Infrastructure
- Add backend event bus (core/event_bus.py) — in-memory pub/sub with
  emit(), subscribe(), unsubscribe()
- Add WebSocket endpoint /ws/events (api/routers/events.py) with 25s
  keepalive pings and auto-cleanup on disconnect
- Add frontend hook useRealtimeEvents.js — single WS connection with
  exponential backoff reconnect (2s→60s)

## Backend Event Integration
- projects.py: emit on create/update/delete
- profiles.py: emit on create/update/lock/unlock/delete
- dub_core.py: emit on clear/delete history
- dub_pipeline.py: emit on save_job (every pipeline write)
- exports.py: emit on export/record
- generation.py: emit on generate/clear/delete
- gallery.py: emit on save-as-profile/to-profile

## Frontend Improvements
- Replace 45s polling interval with instant WS-based invalidation
- Fix critical bug: apiModelStatus was undefined, causing loadAll()
  to loop forever — sidebar data never loaded on startup
- Add websockets to main deps (was optional, got removed by uv sync)
- Reduce model/status polling from 5s to 10s, disable background
  polling for logs
- Add ReadinessChecklist and FloatingPill components
- Default UI scale changed from S (1.0) to M (1.3)

## Dependencies
- Add websockets>=16.0 to main dependencies for uvicorn WS support

Closes #3 (native desktop app exists via Tauri)
Closes #5 (Dockerfile already uses root bun.lock)
Resolves #26 (Triton workaround documented)
2026-04-27 22:16:28 +05:30
debpalash bbebf5281a refactor: redesign DubTab layout using flexbox, update column widths in DubSegmentTable, and add Linux webkit2gtk dependency. 2026-04-27 07:57:19 +05:30
debpalash 8d84c7f679 fix(ui): Optimise segment table column distribution and fix flex stretch layout bug 2026-04-27 00:37:02 +05:30
debpalash 393dd7e8b5 fix: Fix frontend typecheck errors and raise TTS VRAM offload threshold to prevent CUDA OOM 2026-04-27 00:29:32 +05:30
debpalash f8b4673e1f fix(ui): Fix segment row layout collapse, memory bugs, enterprise page, and UI enhancements 2026-04-27 00:21:47 +05:30
debpalash 93e79db9e6 style: extract 22 inline styles from CheckpointBanner, DirectionDialog, SetupWizard, App, CompareModal, AudioTrimmer
- CheckpointBanner: 7→1 (dynamic accent border-left stays)
- DirectionDialog: 5→0
- SetupWizard: 9→3 (dynamic fix-text color stays)
- App.jsx: 6→3 (dynamic zoom stays)
- CompareModal: 2→1 (dynamic accent color stays)
- AudioTrimmer: 1→0

New Misc.css shared file for remaining small-component classes.
Total inline style count: 65→43 (cumulative 127→43, 66% reduction)
All remaining 43 are genuinely dynamic (CSS vars, computed colors,
animation delays, column widths, progress bars).
2026-04-26 17:17:44 +05:30
debpalash bdafd86b2b style: extract 17 inline styles from WaveformTimeline and ErrorBoundary into CSS
- WaveformTimeline: 10→0 inline styles (layout, loading, overlay, error)
- ErrorBoundary: 7→0 inline styles (wrapper, card, title, trace, retry)
- Shared CSS file for both components (WaveformErrorBoundary.css)

Total inline style count: 82→65 (cumulative 127→65, 49% reduction)
2026-04-26 17:07:48 +05:30
debpalash b36bb8495e chore: update license copyright name and contact email 2026-04-26 17:05:36 +05:30
debpalash fc76e79ff8 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
2026-04-26 16:47:00 +05:30
debpalash 811c842a75 feat: implement Voice Gallery feature with backend routing, API client, and frontend navigation integration 2026-04-25 19:29:29 +05:30
debpalash 901eb040a8 feat: add live bootstrap progress bars and log inspection to splash screen 2026-04-25 17:22:51 +05:30
debpalash 4a8b06c25e feat: implement structured progress tracking for model downloads and add local environment variable loading support. 2026-04-24 18:51:53 +05:30
Palash DebnathandClaude Opus 4.7 787c146f61 feat(bootstrap): splash UI with live progress during first-run setup (#25)
v0.2.2 users upgrading from a PyInstaller build saw "Failed to load
engines: Load failed" because the app window opened before the
first-run `uv sync` (5-10 min) could finish populating the venv. The
webview just hung on a blank state.

Wire the setup through properly:

Rust (src-tauri/src/lib.rs):
- New `BootstrapStage` enum: checking → downloading_uv →
  creating_venv → installing_deps → starting_backend → ready (or
  failed { message }). `#[serde(tag = "stage")]` so it serialises as
  a tagged union the frontend can switch on.
- `BootstrapState` exposed via `bootstrap_status` Tauri command so
  React can poll progress.
- `setup()` no longer blocks on `ensure_venv_ready`. Instead spawns a
  background thread that walks the bootstrap, writes stage updates to
  the mutex, then waits up to 60 s for the backend port to answer and
  flips stage to `ready`.
- `ensure_venv_ready` + `spawn_backend` take the progress mutex
  (Option<&Arc<Mutex<…>>>) and set the right stage at each step.
  They now take AppHandle<R> instead of &App<R> so the background
  thread can hold them.

React (frontend/src/components/BootstrapSplash.{jsx,css}):
- Self-contained splash component + `useBootstrapStage()` hook that
  polls the Rust command every 1 s, short-circuits to 'ready' in the
  Vite dev server / non-Tauri contexts.
- Renders a progress bar + step list keyed to BootstrapStage. On
  Failed, shows the Rust-side message.

App.jsx:
- Calls `useBootstrapStage()`, blocks the main UI render until stage
  === 'ready'.

Also bumps pyproject/package/cargo/tauri.conf versions 0.2.2 → 0.2.3.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 17:02:33 +05:30
138 changed files with 12914 additions and 1727 deletions
View File
+4
View File
@@ -77,3 +77,7 @@ examples/download*
examples/exp*/
omnivoice.zip
frontend/src-tauri/binaries/ffmpeg
# cuDNN 8 compat libs (auto-installed by scripts/setup_cudnn.py)
cudnn8_compat/
test-results/
+10 -11
View File
@@ -2,19 +2,18 @@
# Builder Stage: Compile React Frontend
# ==========================================
FROM oven/bun:1-alpine AS frontend-builder
WORKDIR /app/frontend
WORKDIR /app
# Copy frontend specifications
COPY frontend/package.json ./
COPY frontend/bun.lock ./
# Monorepo — bun workspace with lockfile at repo root. Copy manifests first
# so `bun install` caches independently of source edits.
COPY package.json bun.lock ./
COPY frontend/package.json ./frontend/
# Install dependencies fast
RUN bun install --frozen-lockfile
# Copy frontend source and build static files
COPY frontend/ ./
# Output goes to /app/frontend/dist
RUN bun run build
# Build static files (output lands in /app/frontend/dist)
COPY frontend/ ./frontend/
RUN bun run --cwd frontend build
# ==========================================
# Runtime Stage: Python & PyTorch Backend
@@ -52,10 +51,10 @@ COPY omnivoice/ ./omnivoice/
COPY --from=frontend-builder /app/frontend/dist ./frontend/dist
# Expose the single unified API and UI port
EXPOSE 8000
EXPOSE 3900
# Mount points for persistent data (sqlite db, user voices, huggingface cache)
VOLUME ["/app/omnivoice_data"]
# Bind to 0.0.0.0 for external access
ENTRYPOINT ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"]
ENTRYPOINT ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "3900"]
+59 -178
View File
@@ -1,201 +1,82 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
OmniVoice Studio — Dual License
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
Copyright (c) 2024-present Palash Debnath and contributors.
1. Definitions.
This software is licensed under a dual-license model:
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
1. PERSONAL & NON-COMMERCIAL USE — FREE
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
You may use, copy, modify, and distribute this software free of
charge for any personal, educational, research, or non-commercial
purpose, subject to the following conditions:
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
• You include this license notice in all copies or substantial
portions of the software.
• You do not use the software, or any derivative of it, to provide
a commercial product or service (see Section 2).
• You provide attribution to "OmniVoice Studio" in any public-facing
derivative work.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Non-commercial" means use that is not intended for or directed toward
commercial advantage or monetary compensation. This includes personal
projects, academic research, open-source contributions, and internal
evaluation within an organization (up to 30 days).
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
2. COMMERCIAL USE — PAID LICENSE REQUIRED
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
A separate commercial license is required for any use that does not
qualify as personal or non-commercial under Section 1. This includes,
but is not limited to:
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
• Using the software to provide a paid product or service.
• Embedding the software in a product sold or licensed to third
parties.
• Using the software in a revenue-generating business beyond the
30-day evaluation period.
• Offering the software as part of a managed, hosted, or SaaS
platform.
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
To obtain a commercial license, contact:
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
Email: OmniVoice@palash.dev
Web: https://github.com/debpalash/OmniVoice-Studio
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
Commercial licenses are available for teams and enterprises of all
sizes. Pricing scales with usage — solo creators and small studios
are priced affordably.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
3. CONTRIBUTIONS
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
By submitting a pull request or other contribution to this project,
you agree to license your contribution under the same dual-license
terms described herein, and you grant the copyright holder a
perpetual, worldwide, royalty-free license to use, reproduce, modify,
and distribute your contribution under both the non-commercial and
commercial licenses.
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
4. NO WARRANTY
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN
ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
5. TERMINATION
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 Xiaomi Corp.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Your rights under this license terminate automatically if you fail to
comply with its terms. Upon termination, you must cease all use of the
software and destroy all copies in your possession.
+282 -103
View File
@@ -1,164 +1,343 @@
<div align="center">
<img src="frontend/public/favicon.svg" alt="OmniVoice Logo" width="120" />
<img src="docs/logo.png" alt="OmniVoice Logo" width="160" />
<h1>OmniVoice Studio</h1>
<p><b>Your Local Cinematic AI Dubbing Studio</b></p>
<p><b>The open-source ElevenLabs alternative.</b></p>
<p>Voice cloning · Voice design · Video dubbing — 646 languages, runs 100% locally, forever free.</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="https://github.com/debpalash/OmniVoice-Studio/stargazers"><img src="https://img.shields.io/github/stars/debpalash/OmniVoice-Studio?style=flat-square&color=f59e0b" alt="Stars" /></a>
<a href="https://github.com/debpalash/OmniVoice-Studio/releases/latest"><img src="https://img.shields.io/github/v/release/debpalash/OmniVoice-Studio?style=flat-square&color=10b981" alt="Release" /></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Dual_(Free_%2B_Commercial)-blue?style=flat-square" alt="License" /></a>
<a href="https://github.com/debpalash/OmniVoice-Studio/issues"><img src="https://img.shields.io/github/issues/debpalash/OmniVoice-Studio?style=flat-square&color=ef4444" alt="Issues" /></a>
<a href="https://discord.gg/aRRdVj3de7"><img src="https://img.shields.io/badge/Discord-Join_Community-5865F2?style=flat-square&logo=discord&logoColor=white" alt="Discord" /></a>
</p>
<p>
<a href="https://github.com/debpalash/OmniVoice-Studio/releases/latest">Download</a> ·
<a href="#features">Features</a> ·
<a href="#quickstart">Quickstart</a> ·
<a href="#why-open-source">Why Open Source?</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 place.</sub>
</div>
---
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.
## ✨ 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).
<br/>
<table>
<tr>
<td align="center" width="50%">
<img src="docs/screenshot-clone.png" alt="Voice Clone" width="100%"/>
<br/><b>Voice Clone</b><br/>
<sub>Drop a 3-second clip → mirror any voice. 646 languages, zero-shot.</sub>
</td>
<td align="center" width="50%">
<img src="docs/screenshot-design.png" alt="Voice Design" width="100%"/>
<br/><b>Voice Design</b><br/>
<sub>Build new voices from scratch — gender, age, accent, pitch, style.</sub>
</td>
</tr>
<tr>
<td align="center">
<img src="docs/screenshot-dub.png" alt="Video Dubbing" width="100%"/>
<br/><b>Video Dubbing</b><br/>
<sub>Upload or paste a YouTube URL. Transcribe, translate, re-voice, export.</sub>
</td>
<td align="center">
<img src="docs/screenshot-gallery.png" alt="Voice Gallery" width="100%"/>
<br/><b>Voice Gallery</b><br/>
<sub>Search YouTube, browse categories, download clips, build your library.</sub>
</td>
</tr>
<tr>
<td align="center">
<img src="docs/screenshot-settings.png" alt="Settings — Models" width="100%"/>
<br/><b>Settings → Models</b><br/>
<sub>15 models. One-click install. Auto-detects your platform (CUDA / MPS / CPU).</sub>
</td>
<td align="center">
<img src="docs/screenshot-libraryprojects.png" alt="Projects" width="100%"/>
<br/><b>Projects</b><br/>
<sub>Dub projects, voice profiles, generation history, exports — all searchable.</sub>
</td>
</tr>
<tr>
<td align="center" colspan="2">
<img src="docs/screenshot-logs.png" alt="Settings — Logs" width="100%"/>
<br/><b>Settings → Logs</b><br/>
<sub>Live backend, frontend, and Tauri runtime logs. Filter, refresh, clear.</sub>
</td>
</tr>
</table>
## 🚀 Getting Started
---
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.
## Why Open Source?
### Option 1: One-Click Docker (Recommended)
ElevenLabs charges **$5$330/mo** and processes your audio on their servers. OmniVoice Studio runs **on your hardware, with no usage limits.**
| | **ElevenLabs** | **OmniVoice Studio** |
|---|---|---|
| **Pricing** | $5$330/mo, per-character billing | Free for personal use · [Commercial license](#license) for business |
| **Voice Cloning** | ✅ 3s clip | ✅ 3s clip, zero-shot |
| **Voice Design** | ✅ Gender, age | ✅ Gender, age, accent, pitch, style, dialect |
| **Languages** | 32 | **646** |
| **Video Dubbing** | ✅ Cloud-only | ✅ Fully local |
| **Data Privacy** | Audio sent to cloud | **Nothing leaves your machine** |
| **API Keys** | Required | Not needed |
| **GPU Support** | N/A (cloud) | CUDA · Apple Silicon · ROCm · CPU |
| **Desktop App** | ❌ | ✅ macOS · Windows · Linux |
| **Customizable** | ❌ Closed | ✅ Fork it, extend it, ship it |
Built on the [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
### Core Pipeline
- **Video Dubbing** — Transcribe → translate → synthesize → mux back to MP4. One-click end-to-end.
- **Vocal Isolation** — Demucs-powered speech/music separation. Background audio preserved automatically.
- **Voice Cloning** — Clone any voice from a 3-second clip. Zero-shot, 600+ languages.
- **Multi-Speaker Diarization** — Pyannote + WhisperX fusion auto-identifies speakers and assigns unique voice profiles.
### Studio Tools
- **Voice Preview** — Floating widget for instant 8-step TTS testing. Try voices without leaving the workspace.
- **Multi-Language Batch** — Select multiple target languages, dub to all in one pass.
- **Batch Queue** — Drag-and-drop bulk video processing with sequential GPU execution.
- **Voice Library** — Browse, favorite, tag, and convert gallery clips into permanent voice profiles.
- **A/B Comparison** — Side-by-side voice audition for casting decisions.
### Production Export
- **Selective Track Export** — Choose which language tracks to include in the final MP4.
- **Subtitle Export** — SRT and VTT generation alongside dubbed video.
- **Stem Export** — Separate vocals and background audio as individual files.
- **Per-Segment Mixing** — 0200% gain control per segment for broadcast-quality balancing.
### 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.
### AI Provenance
- **Invisible Watermark** — AudioSeal-powered (Meta) neural watermark embedded in every generated audio. Imperceptible, survives compression/editing.
- **Detection API** — Upload any audio to `/watermark/detect` to verify OmniVoice origin with confidence score.
- **Video Branding** — Optional logo overlay on exported MP4s (5s fade-out, bottom-right).
- **Configurable** — Toggle invisible/visible watermarks independently in Settings → Privacy.
---
## 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). This works out of the box — no account needed. For faster downloads, optionally set `HF_TOKEN=hf_...` in your environment ([get a free token here](https://huggingface.co/settings/tokens)).
>
> **Having issues?** Join our [Discord](https://discord.gg/aRRdVj3de7) for setup help and troubleshooting.
### Desktop App
```bash
bun run desktop # Launches Tauri native app (macOS / Windows / Linux)
```
---
## 🗺️ Roadmap
## System Requirements
The studio is highly functional today, but we are aggressively expanding. Watch the roadmap to see what's shipping next:
| | **Minimum** | **Recommended** |
|---|---|---|
| **OS** | Windows 10, macOS 12+, Ubuntu 20.04+ | Any modern 64-bit OS |
| **RAM** | 8 GB | 16 GB+ |
| **VRAM (GPU)** | 4 GB (auto-offloads TTS to CPU) | 8 GB+ (NVIDIA RTX 3060+) |
| **Disk** | 10 GB free (models + cache) | 20 GB+ SSD |
| **Python** | 3.10+ (managed by `uv`) | 3.113.12 |
| **GPU** | Optional — CPU works | NVIDIA CUDA · Apple Silicon MPS · AMD ROCm |
### 🌟 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.
> [!TIP]
> On GPUs with **≤8 GB VRAM**, OmniVoice automatically offloads TTS to CPU during transcription — no config needed. A dedicated GPU is not required; the entire pipeline runs on CPU (just slower).
---
## 📝 Changelog
## Architecture
### v1.2.0 — The Production Polish Update
```
┌─────────────────────────────────────────────────┐
│ 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)
```
- **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).
---
### v1.1.0 — The Cinematic Studio Update
## Roadmap
- **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.
### ✅ Shipped
| 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 |
| **AI Provenance** | AudioSeal invisible watermarking (SynthID-like), video logo overlay, watermark detection API |
| **UX** | Undo/redo, keyboard shortcuts, drag-and-drop, session persistence, glassmorphism design system |
### 🔜 Next — by priority
**⚡ 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)
**✨ 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
**🎨 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)
---
## FAQ
<details>
<summary><b>Is this really as good as ElevenLabs?</b></summary>
<br/>
For voice cloning and dubbing, yes — OmniVoice uses a state-of-the-art diffusion TTS model with 646 languages (ElevenLabs supports 32). Quality is comparable for most use cases. Where ElevenLabs wins is in their polished cloud API and pre-made voice library. OmniVoice wins on privacy, cost, language coverage, and customizability.
</details>
## ⭐ Star History
<details>
<summary><b>Does it work on Apple Silicon (M1/M2/M3/M4)?</b></summary>
<br/>
Yes. MPS acceleration is auto-detected. MLX-optimized Whisper models are available for faster transcription on Apple hardware.
</details>
<details>
<summary><b>How much VRAM do I need?</b></summary>
<br/>
<b>4 GB minimum.</b> With ≤8 GB, the TTS model is automatically offloaded to CPU during transcription. With 8+ GB, everything runs on GPU simultaneously. No GPU at all? CPU mode works — just slower (~3× for TTS).
</details>
<details>
<summary><b>Can I use this commercially?</b></summary>
<br/>
Personal and non-commercial use is free. Commercial use requires a paid license — see <a href="#license">License</a>. 30-day free evaluation for businesses.
</details>
<details>
<summary><b>What languages are supported?</b></summary>
<br/>
646 languages for TTS via the OmniVoice model. Transcription (WhisperX) supports 99 languages. Translation coverage depends on the target language pair.
</details>
<details>
<summary><b>Can I add my own TTS engine?</b></summary>
<br/>
Not yet — a Plugin SDK is on the <a href="#roadmap">roadmap</a>. The architecture is modular, so integration is straightforward for contributors.
</details>
---
## License
**Personal, educational, and non-commercial use** — completely free. No restrictions, no limits.
**Commercial use** (SaaS, paid products, enterprise) — requires a paid license. 30-day free evaluation included.
See [`LICENSE`](LICENSE) for the full terms. For commercial inquiries, reach out at **OmniVoice@palash.dev**.
---
## Contributing
Issues and PRs welcome. See the [roadmap](#roadmap) for areas where help is most needed. Join our [Discord](https://discord.gg/aRRdVj3de7) to discuss ideas, get help, or find what to work on.
---
## Acknowledgments
OmniVoice Studio is built on the shoulders of exceptional open-source work:
| Project | Role |
|---------|------|
| [**OmniVoice (k2-fsa)**](https://github.com/k2-fsa/OmniVoice) | Zero-shot diffusion TTS engine — the core voice synthesis model |
| [**WhisperX**](https://github.com/m-bain/whisperX) | Word-level speech recognition and alignment |
| [**Demucs (Meta)**](https://github.com/facebookresearch/demucs) | Music source separation for vocal isolation |
| [**Pyannote**](https://github.com/pyannote/pyannote-audio) | Speaker diarization — who said what |
| [**CTranslate2**](https://github.com/OpenNMT/CTranslate2) | Optimized Transformer inference on CPU and GPU |
| [**AudioSeal (Meta)**](https://github.com/facebookresearch/audioseal) | Invisible neural audio watermarking for AI provenance |
| [**Tauri**](https://tauri.app) | Native desktop app framework |
---
<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}
+39 -11
View File
@@ -18,8 +18,9 @@ from fastapi.responses import FileResponse, Response, StreamingResponse, JSONRes
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 core import event_bus
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 (
@@ -107,6 +108,7 @@ def clear_dub_history():
safe = _safe_job_dir(jid)
if safe and os.path.isdir(safe):
shutil.rmtree(safe, ignore_errors=True)
event_bus.emit("dub_history")
return {"cleared": True, "count": len(ids)}
@router.delete("/dub/history/{history_id}")
@@ -117,6 +119,7 @@ def delete_single_dub_history(history_id: str):
if safe and os.path.isdir(safe):
shutil.rmtree(safe, ignore_errors=True)
_dub_jobs.pop(history_id, None)
event_bus.emit("dub_history", {"action": "deleted", "id": history_id})
return {"deleted": True}
@router.post("/preview/upload")
@@ -265,6 +268,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 +336,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 +380,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 +488,15 @@ 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
if _asr_backend:
try:
_asr_backend.unload()
except Exception as e:
logger.warning("Failed to unload ASR backend: %s", e)
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
@@ -571,6 +594,11 @@ async def dub_transcribe(job_id: str):
s.setdefault("text_original", s.get("text", ""))
job["full_transcript"] = " ".join(s["text"] for s in segments)
try:
_asr.unload()
except Exception as e:
logger.warning("Failed to unload ASR backend: %s", e)
if torch.backends.mps.is_available():
torch.mps.empty_cache()
+46 -24
View File
@@ -16,6 +16,7 @@ from services.model_manager import get_model, _gpu_pool
from services.audio_dsp import apply_mastering, normalize_audio
from services.rvc import apply_rvc, is_enabled as rvc_is_enabled
from services.incremental import segment_fingerprint
from services.watermark import embed_watermark
from api.routers.dub_core import _get_job, _save_job
logger = logging.getLogger("omnivoice.dub")
@@ -45,6 +46,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 +239,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 +251,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 +287,28 @@ 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:
# Apply invisible watermark before writing to disk
_wav = embed_watermark(_wav, _sr)
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)
@@ -339,6 +359,8 @@ async def dub_generate(job_id: str, req: DubRequest):
lang_code = req.language_code or "und"
track_path = os.path.join(DUB_DIR, job_id, f"dubbed_{lang_code}.wav")
_t_save_0 = time.perf_counter()
# Apply invisible watermark to the final assembled track
full_audio = embed_watermark(full_audio, sr)
torchaudio.save(track_path, full_audio, sr)
_t_save = time.perf_counter() - _t_save_0
_t_mix = _t_save_0 - _t_loop_end
@@ -353,11 +375,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"
+143 -18
View File
@@ -23,12 +23,70 @@ TRANSLATE_CODES = {
FLORES_CODES = {
"en": "eng_Latn", "es": "spa_Latn", "fr": "fra_Latn", "de": "deu_Latn",
"it": "ita_Latn", "pt": "por_Latn", "ru": "rus_Cyrl", "ja": "jpn_Jpan",
"ko": "kor_Hang", "zh": "zho_Hans", "zh-CN": "zho_Hans", "ar": "arb_Arab",
"ko": "kor_Hang", "zh": "zho_Hans", "zh-CN": "zho_Hans", "ar": "arb_Arab",
"hi": "hin_Deva", "tr": "tur_Latn", "pl": "pol_Latn", "nl": "nld_Latn",
"sv": "swe_Latn", "th": "tha_Thai", "vi": "vie_Latn", "id": "ind_Latn",
"uk": "ukr_Cyrl",
}
# Human-readable language names for LLM prompts. Empirically a tiny / 7B
# local LLM produces Devanagari Hindi reliably when told "translate into
# Hindi" but drifts to German / English / phonetic-Latin when told
# "translate into hi". The two-letter ISO codes "hi" / "de" / "fr" can
# overlap with everyday tokens ("hi" = greeting), which throws off small
# instruction-tuned models. Pass the full name in the prompt so the model
# can't misread it.
LANG_NAMES = {
"en": "English", "es": "Spanish", "fr": "French", "de": "German",
"it": "Italian", "pt": "Portuguese", "ru": "Russian", "ja": "Japanese",
"ko": "Korean", "zh": "Chinese (Simplified)", "zh-CN": "Chinese (Simplified)",
"ar": "Arabic", "hi": "Hindi", "tr": "Turkish", "pl": "Polish",
"nl": "Dutch", "sv": "Swedish", "th": "Thai", "vi": "Vietnamese",
"id": "Indonesian", "uk": "Ukrainian",
}
# Per-language script enforcement. Maps language code → required Unicode
# block(s) the translation must contain. Used as a sanity gate after the
# LLM responds: if the output contains <50% characters from the expected
# block, we treat the translation as corrupted and retry. The block names
# here are the keys recognised by Python's `unicodedata.name()` lookup or
# regex Unicode property classes.
LANG_REQUIRED_SCRIPT = {
"hi": ("DEVANAGARI", (0x0900, 0x097F)),
"ar": ("ARABIC", (0x0600, 0x06FF)),
"zh": ("CJK", (0x4E00, 0x9FFF)),
"zh-CN": ("CJK", (0x4E00, 0x9FFF)),
"ja": ("JAPANESE", (0x3040, 0x30FF)),
"ko": ("HANGUL", (0xAC00, 0xD7AF)),
"th": ("THAI", (0x0E00, 0x0E7F)),
"ru": ("CYRILLIC", (0x0400, 0x04FF)),
"uk": ("CYRILLIC", (0x0400, 0x04FF)),
}
def _script_ratio(text: str, code: str) -> float:
"""Fraction of letters in `text` that fall inside the script block we
expect for `code`. Punctuation/digits/whitespace are excluded from the
denominator so a Hindi sentence ending in "." still scores 1.0."""
info = LANG_REQUIRED_SCRIPT.get(code)
if not info:
return 1.0
_, (lo, hi) = info
letters = [c for c in text if c.isalpha()]
if not letters:
return 1.0
inside = sum(1 for c in letters if lo <= ord(c) <= hi)
return inside / len(letters)
def _looks_like_target(text: str, code: str, threshold: float = 0.5) -> bool:
"""Sanity gate for non-Latin targets. True if `text` is *plausibly* in
the target language by script. Only meaningful for languages with a
distinctive script (Indic, CJK, Arabic, etc.); Latin-script targets
always return True since we can't distinguish English from German by
codepoints alone."""
return _script_ratio(text, code) >= threshold
_nllb_model = None
_nllb_tokenizer = None
_nllb_device = None
@@ -154,22 +212,89 @@ async def dub_translate(req: TranslateRequest):
from openai import OpenAI
client = OpenAI(base_url=base_url, api_key=api_key or "local")
def _translate_llm(seg):
try:
if not seg.text or not seg.text.strip():
return {"id": seg.id, "text": seg.text}
tgt = seg.target_lang if seg.target_lang else req.target_lang
res = client.chat.completions.create(
model=model_name,
messages=[
{"role": "system", "content": f"You are a professional dubbing translator. Translate the user's text from {src_lang} into {tgt}. Reply ONLY with the translated text, do not add any quotes, notes, or explanations."},
{"role": "user", "content": seg.text}
]
def _build_prompt(src_code: str, tgt_code: str) -> str:
"""Build a system prompt that resists hallucinations on small
local LLMs. Three things matter:
1. Use full language names (Hindi, German) not ISO codes —
tiny models read 'hi' as a greeting and drift.
2. For non-Latin targets, name the required script explicitly
so the model can't fall back to phonetic Latin or another
target it knows better (Hindi → German is a common drift
we've actually observed).
3. End with a strict format guard so the model can't prepend
'Translation:' or quote the output.
"""
src_name = LANG_NAMES.get(src_code, src_code)
tgt_name = LANG_NAMES.get(tgt_code, tgt_code)
script_clause = ""
info = LANG_REQUIRED_SCRIPT.get(tgt_code)
if info:
script_name, _ = info
script_clause = (
f" The output MUST be written in {script_name} script "
f"only — do not use Latin/Roman letters, do not "
f"transliterate, do not output any other language."
)
out_text = res.choices[0].message.content.strip()
return {"id": seg.id, "text": out_text}
except Exception as e:
return {"id": seg.id, "text": seg.text, "error": str(e)}
return (
f"You are a professional dubbing translator. "
f"Translate the user's text from {src_name} into "
f"{tgt_name}.{script_clause} "
f"Reply ONLY with the translated {tgt_name} text, do not "
f"add quotes, notes, headers, explanations, or commentary."
)
def _translate_llm(seg):
if not seg.text or not seg.text.strip():
return {"id": seg.id, "text": seg.text}
tgt_code = seg.target_lang if seg.target_lang else req.target_lang
system_msg = _build_prompt(src_lang, tgt_code)
last_err = None
# Up to 2 attempts: if the first response fails the
# script-ratio gate (e.g. Hindi target but mostly Latin
# output), retry once with a more emphatic instruction.
for attempt in range(2):
sys_for_attempt = system_msg
if attempt == 1:
sys_for_attempt = (
system_msg
+ " Your previous attempt produced output in the "
"wrong language or script. Output ONLY the "
f"{LANG_NAMES.get(tgt_code, tgt_code)} translation."
)
try:
res = client.chat.completions.create(
model=model_name,
temperature=0.2, # less drift than default 1.0
messages=[
{"role": "system", "content": sys_for_attempt},
{"role": "user", "content": seg.text},
],
)
out_text = (res.choices[0].message.content or "").strip()
if not out_text:
last_err = "empty LLM response"
continue
if not _looks_like_target(out_text, tgt_code):
last_err = (
f"LLM output script_ratio={_script_ratio(out_text, tgt_code):.2f} "
f"below threshold for {tgt_code}"
)
logger.warning(
"translate %s: attempt %d wrong script (%s); retrying",
seg.id, attempt + 1, last_err,
)
continue
return {"id": seg.id, "text": out_text}
except Exception as e:
last_err = f"{type(e).__name__}: {e}"
logger.warning(
"translate %s: LLM attempt %d failed: %s",
seg.id, attempt + 1, e,
)
# Both attempts failed — keep source text + flag error so the
# frontend can surface "fallback to literal" warning.
return {"id": seg.id, "text": seg.text, "error": last_err or "llm-failed"}
tasks = [loop.run_in_executor(_cpu_pool, _translate_llm, seg) for seg in req.segments]
translated = await asyncio.gather(*tasks)
@@ -240,8 +365,8 @@ async def dub_translate(req: TranslateRequest):
def _build_translator(src, tgt):
if provider == "deepl":
from deep_translator import DeepL
return DeepL(api_key=api_key, source=src, target=tgt)
from deep_translator import DeeplTranslator
return DeeplTranslator(api_key=api_key, source=src, target=tgt)
if provider == "mymemory":
from deep_translator import MyMemoryTranslator
return MyMemoryTranslator(source=src, target=tgt)
+52
View File
@@ -0,0 +1,52 @@
"""WebSocket endpoint for real-time sidebar events.
A single ``/ws/events`` connection replaces all sidebar polling. The
frontend connects once and receives JSON messages like:
{"kind": "projects", "ts": 1714200000.0}
{"kind": "profiles", "ts": 1714200001.2, "id": "abc123"}
On each message the frontend invalidates the matching TanStack Query
cache key, which triggers a single targeted refetch.
"""
from __future__ import annotations
import asyncio
import logging
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from core import event_bus
router = APIRouter()
logger = logging.getLogger("omnivoice.events")
@router.websocket("/ws/events")
async def ws_events(ws: WebSocket):
"""Fan-out event stream for sidebar reactivity.
Protocol:
- Server → Client: JSON event dicts (``kind``, ``ts``, optional fields)
- Client → Server: ping/pong only (no app-level messages expected)
- Server sends ``{"kind": "ping"}`` every 25 s as a keepalive
"""
await ws.accept()
q = await event_bus.subscribe()
logger.info("WS client connected (%d total)", len(event_bus._listeners))
try:
while True:
# Wait for an event or send a keepalive ping every 25s
try:
event_str = await asyncio.wait_for(q.get(), timeout=25.0)
await ws.send_text(event_str)
except asyncio.TimeoutError:
# Keepalive — prevents proxies/firewalls from killing idle connections
await ws.send_text('{"kind":"ping"}')
except WebSocketDisconnect:
pass
except Exception as e:
logger.debug("WS client error: %s", e)
finally:
await event_bus.unsubscribe(q)
logger.info("WS client disconnected (%d remaining)", len(event_bus._listeners))
+29 -1
View File
@@ -8,6 +8,7 @@ from fastapi import APIRouter, HTTPException
from core.db import get_db
from core.config import OUTPUTS_DIR
from core import event_bus
from schemas.requests import ExportRequest, ExportRecordRequest, RevealRequest
router = APIRouter()
@@ -59,7 +60,32 @@ def export_file(req: ExportRequest):
src = _safe_source(req.source_filename)
dest = _safe_destination(req.destination_path)
try:
shutil.copy2(src, dest)
# Video exports: overlay OmniVoice logo if visible watermark is enabled
if src.lower().endswith(".mp4"):
from services.watermark import is_visible_video_enabled, get_ffmpeg_overlay_args
logo_path = os.path.join(os.path.dirname(__file__), "..", "..", "..", "docs", "logo.png")
logo_path = os.path.realpath(logo_path)
if is_visible_video_enabled() and os.path.exists(logo_path):
overlay_args = get_ffmpeg_overlay_args(logo_path)
if overlay_args:
try:
subprocess.run(
["ffmpeg", "-y", "-i", src, "-i", logo_path]
+ overlay_args
+ ["-codec:a", "copy", dest],
check=True,
capture_output=True,
timeout=120,
)
except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
# Fallback: plain copy if ffmpeg overlay fails
shutil.copy2(src, dest)
else:
shutil.copy2(src, dest)
else:
shutil.copy2(src, dest)
else:
shutil.copy2(src, dest)
except OSError as e:
raise HTTPException(status_code=500, detail=str(e))
@@ -73,6 +99,7 @@ def export_file(req: ExportRequest):
conn.commit()
finally:
conn.close()
event_bus.emit("export_history", {"action": "exported", "id": export_id})
return {"success": True, "id": export_id}
@@ -88,6 +115,7 @@ def record_export(req: ExportRecordRequest):
conn.commit()
finally:
conn.close()
event_bus.emit("export_history", {"action": "recorded", "id": export_id})
return {"success": True, "id": export_id}
+581
View File
@@ -0,0 +1,581 @@
import os
import json
import uuid
import time
import asyncio
import logging
import subprocess
from typing import Optional, List
from pathlib import Path
from fastapi import APIRouter, File, Form, UploadFile, HTTPException, Query
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
from pydantic import BaseModel
from core.db import get_db
from core.config import VOICES_DIR, OUTPUTS_DIR
from core import event_bus
logger = logging.getLogger("omnivoice.gallery")
router = APIRouter()
VOICE_GALLERY_DIR = Path(os.path.join(OUTPUTS_DIR, "voice_gallery"))
VOICE_GALLERY_DIR.mkdir(parents=True, exist_ok=True)
CATEGORIES = [
{
"id": "disney",
"name": "Disney",
"icon": "🎬",
"description": "Disney characters, Pixar, and animated films",
},
{
"id": "anime",
"name": "Anime",
"icon": "🎌",
"description": "Japanese anime characters",
},
{
"id": "marvel",
"name": "Marvel/DC",
"icon": "🦸",
"description": "Superhero movies and TV shows",
},
{
"id": "celebs",
"name": "Celebrities",
"icon": "",
"description": "Famous actors and personalities",
},
{
"id": "politicians",
"name": "Politicians",
"icon": "🏛️",
"description": "World leaders and politicians",
},
{
"id": "news",
"name": "News Anchors",
"icon": "📰",
"description": "News broadcasters",
},
{
"id": "gaming",
"name": "Gaming",
"icon": "🎮",
"description": "Video game characters",
},
{
"id": "books",
"name": "Books/Movies",
"icon": "📚",
"description": "Literary and film characters",
},
]
class VoiceEntry(BaseModel):
id: str
name: str
character: str
category: str
source_type: str # "youtube", "upload", "preset"
source_url: Optional[str] = None
audio_path: str
duration: float
description: Optional[str] = None
thumbnail: Optional[str] = None
tags: List[str] = []
created_at: float
def _init_gallery_db():
"""Initialize the voice gallery table."""
conn = get_db()
conn.execute("""
CREATE TABLE IF NOT EXISTS voice_gallery (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
character TEXT NOT NULL,
category TEXT NOT NULL,
source_type TEXT NOT NULL,
source_url TEXT,
audio_path TEXT NOT NULL,
duration REAL NOT NULL,
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()
@router.get("/gallery/categories")
def list_categories():
"""List all voice gallery categories."""
return CATEGORIES
@router.get("/gallery/voices")
def list_voices(
category: Optional[str] = Query(None, description="Filter by category"),
search: Optional[str] = Query(None, description="Search by name or character"),
limit: int = Query(50, ge=1, le=200),
):
"""List voices in the gallery, optionally filtered by category or search."""
conn = get_db()
query = "SELECT * FROM voice_gallery"
params = []
conditions = []
if category:
conditions.append("category = ?")
params.append(category)
if search:
conditions.append("(name LIKE ? OR character LIKE ? OR description LIKE ?)")
params.extend([f"%{search}%", f"%{search}%", f"%{search}%"])
if conditions:
query += " WHERE " + " AND ".join(conditions)
query += " ORDER BY created_at DESC LIMIT ?"
params.append(limit)
rows = conn.execute(query, params).fetchall()
conn.close()
results = []
for row in rows:
r = dict(row)
r["tags"] = json.loads(r.get("tags", "[]") or "[]")
results.append(r)
return results
@router.get("/gallery/voices/{voice_id}")
def get_voice(voice_id: str):
"""Get a specific voice from the gallery."""
conn = get_db()
row = conn.execute(
"SELECT * FROM voice_gallery WHERE id = ?", (voice_id,)
).fetchone()
conn.close()
if not row:
raise HTTPException(status_code=404, detail="Voice not found")
r = dict(row)
r["tags"] = json.loads(r.get("tags", "[]") or "[]")
return r
@router.delete("/gallery/voices/{voice_id}")
def delete_voice(voice_id: str):
"""Delete a voice from the gallery."""
conn = get_db()
row = conn.execute(
"SELECT audio_path FROM voice_gallery WHERE id = ?", (voice_id,)
).fetchone()
if not row:
conn.close()
raise HTTPException(status_code=404, detail="Voice not found")
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 = ?", (voice_id,))
conn.commit()
conn.close()
return {"success": True}
@router.post("/gallery/search/youtube")
async def search_youtube(
query: str = Query(..., description="Character or celebrity name to search"),
category: str = Query(..., description="Category to associate results with"),
max_results: int = Query(5, ge=1, le=20),
):
"""Search YouTube for character/celebrity clips using yt-dlp."""
try:
result = await asyncio.create_subprocess_exec(
"yt-dlp",
"--dump-json",
"--remote-components", "ejs:github",
f"ytsearch{max_results}:{query}",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await result.communicate()
if result.returncode != 0:
logger.error(f"yt-dlp search failed: {stderr.decode()}")
raise HTTPException(
status_code=500, detail=f"YouTube search failed: {stderr.decode()}"
)
lines = stdout.decode().strip().split("\n")
results = []
for line in lines:
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:
raise HTTPException(status_code=500, detail="yt-dlp not installed")
except Exception as e:
logger.error(f"YouTube search error: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/gallery/download")
async def download_youtube_clip(
video_url: str = Query(..., description="YouTube video URL"),
start_time: float = Query(0, ge=0, description="Start time in seconds"),
duration: float = Query(10, ge=1, le=30, description="Clip duration in seconds"),
character_name: str = Query(..., description="Character/celebrity name"),
category: str = Query(..., description="Category"),
description: str = Query("", description="Optional description"),
):
"""Download a clip from YouTube for voice cloning."""
voice_id = str(uuid.uuid4())[:8]
output_path = str(VOICE_GALLERY_DIR / f"{voice_id}.wav")
temp_path = str(VOICE_GALLERY_DIR / f"{voice_id}.%(ext)s")
try:
cmd = [
"yt-dlp",
"--remote-components", "ejs:github",
"-f",
"bestaudio",
"--download-sections",
f"*{start_time:.1f}-{start_time + duration:.1f}",
"-x",
"--audio-format",
"wav",
"--audio-quality",
"0",
"-o",
temp_path,
video_url,
]
result = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await result.communicate()
if result.returncode != 0:
logger.error(f"yt-dlp download failed: {stderr.decode()}")
raise HTTPException(
status_code=500, detail=f"Download failed: {stderr.decode()}"
)
# Find the downloaded file (yt-dlp replaces %s with actual extension)
downloaded_files = list(VOICE_GALLERY_DIR.glob(f"{voice_id}.*"))
if not downloaded_files:
raise HTTPException(status_code=500, detail="Downloaded file not found")
actual_path = downloaded_files[0]
# Rename to output_path
final_path = Path(output_path)
actual_path.rename(final_path)
conn = get_db()
conn.execute(
"""
INSERT INTO voice_gallery
(id, name, character, category, source_type, source_url, audio_path, duration, description, tags, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
voice_id,
character_name,
character_name,
category,
"youtube",
video_url,
output_path,
duration,
description,
json.dumps([character_name.lower(), category]),
time.time(),
),
)
conn.commit()
conn.close()
return {
"success": True,
"voice_id": voice_id,
"audio_path": output_path,
"duration": duration,
}
except FileNotFoundError:
raise HTTPException(status_code=500, detail="yt-dlp not installed")
except Exception as e:
logger.error(f"Download error: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/gallery/upload")
async def upload_voice_clip(
name: str = Form(...),
character: str = Form(...),
category: str = Form(...),
description: str = Form(""),
audio: UploadFile = File(...),
):
"""Upload a voice clip directly to the gallery."""
voice_id = str(uuid.uuid4())[:8]
ext = os.path.splitext(audio.filename or ".wav")[1]
audio_path = str(VOICE_GALLERY_DIR / f"{voice_id}{ext}")
with open(audio_path, "wb") as f:
f.write(await audio.read())
try:
import soundfile as sf
info = sf.info(audio_path)
duration = info.frames / info.samplerate
except Exception:
duration = 10.0
conn = get_db()
conn.execute(
"""
INSERT INTO voice_gallery
(id, name, character, category, source_type, source_url, audio_path, duration, description, tags, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
voice_id,
name,
character,
category,
"upload",
None,
audio_path,
duration,
description,
json.dumps([character.lower(), category]),
time.time(),
),
)
conn.commit()
conn.close()
return {
"id": voice_id,
"name": name,
"audio_path": audio_path,
"duration": duration,
}
@router.post("/gallery/voices/{voice_id}/save-as-profile")
async def save_voice_as_profile(
voice_id: str,
profile_name: str = Query(..., description="Name for the voice profile"),
):
"""Save a gallery voice as a voice profile for cloning."""
conn = get_db()
row = conn.execute(
"SELECT * FROM voice_gallery WHERE id = ?", (voice_id,)
).fetchone()
conn.close()
if not row:
raise HTTPException(status_code=404, detail="Voice not found")
profile_id = str(uuid.uuid4())[:8]
import shutil
ext = os.path.splitext(row["audio_path"])[1]
new_audio_path = os.path.join(VOICES_DIR, f"{profile_id}{ext}")
shutil.copy(row["audio_path"], new_audio_path)
conn = get_db()
conn.execute(
"""
INSERT INTO voice_profiles (id, name, ref_audio_path, ref_text, instruct, language, seed, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
profile_id,
profile_name,
f"{profile_id}{ext}",
row["description"] or "",
row["character"] or "",
"Auto",
None,
time.time(),
),
)
conn.commit()
conn.close()
event_bus.emit("profiles", {"action": "created", "id": profile_id})
return {"profile_id": profile_id, "name": profile_name}
@router.get("/gallery/voices/{voice_id}/preview")
def preview_voice(voice_id: str):
"""Get a voice clip for preview playback."""
conn = get_db()
row = conn.execute(
"SELECT audio_path FROM voice_gallery WHERE id = ?", (voice_id,)
).fetchone()
conn.close()
if not row:
raise HTTPException(status_code=404, detail="Voice not found")
audio_path = row["audio_path"]
# Debug logging
is_absolute = os.path.isabs(audio_path)
path_exists = os.path.exists(audio_path) if audio_path else False
# If absolute path, serve directly or redirect
if is_absolute and path_exists:
# Get just the relative path from outputs dir
outputs_path = str(OUTPUTS_DIR)
if audio_path.startswith(outputs_path):
# Remove outputs_dir prefix to get relative path within outputs
rel_path = os.path.relpath(audio_path, outputs_path)
# The audio_path is like: /Users/user4/.../outputs/voice_gallery/file.wav
# rel_path becomes: voice_gallery/file.wav
# We want to serve from /audio/ so: /audio/voice_gallery/file.wav
return RedirectResponse(f"/audio/{rel_path}")
return FileResponse(audio_path, media_type="audio/wav")
raise HTTPException(
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()
event_bus.emit("profiles", {"action": "created", "id": profile_id})
return {"success": True, "profile_id": profile_id, "name": voice["name"]}
+4
View File
@@ -17,6 +17,7 @@ from core.db import get_db, db_conn
from core.config import OUTPUTS_DIR, VOICES_DIR
from services.model_manager import get_model, _gpu_pool
from services.audio_dsp import apply_mastering, normalize_audio
from core import event_bus
router = APIRouter()
logger = logging.getLogger("omnivoice.generate")
@@ -155,6 +156,7 @@ async def generate_speech(
language or "Auto", instruct or "", resolved_profile_id,
audio_filename, audio_dur, gen_time, used_seed, time.time())
)
event_bus.emit("generation_history", {"action": "created", "id": audio_id})
buffer = io.BytesIO()
torchaudio.save(buffer, audio_tensor, _model.sampling_rate, format="wav")
@@ -227,6 +229,7 @@ def clear_history():
with contextlib.suppress(OSError):
os.remove(p)
conn.execute("DELETE FROM generation_history")
event_bus.emit("generation_history")
return {"cleared": True}
@router.delete("/history/{history_id}")
@@ -239,4 +242,5 @@ def delete_single_history(history_id: str):
with contextlib.suppress(OSError):
os.remove(p)
conn.execute("DELETE FROM generation_history WHERE id=?", (history_id,))
event_bus.emit("generation_history", {"action": "deleted", "id": history_id})
return {"deleted": True}
+6
View File
@@ -9,6 +9,7 @@ from pydantic import BaseModel
from core.db import get_db, db_conn
from core.config import VOICES_DIR, OUTPUTS_DIR
from core import event_bus
router = APIRouter()
@@ -50,6 +51,7 @@ async def create_profile(
)
conn.commit()
conn.close()
event_bus.emit("profiles", {"action": "created", "id": profile_id})
return {"id": profile_id, "name": name}
@router.get("/profiles/{profile_id}")
@@ -99,6 +101,7 @@ def update_profile(profile_id: str, patch: ProfileUpdate):
row = conn.execute(
"SELECT * FROM voice_profiles WHERE id = ?", (profile_id,),
).fetchone()
event_bus.emit("profiles", {"action": "updated", "id": profile_id})
return dict(row)
@@ -200,6 +203,7 @@ async def lock_profile(
)
conn.commit()
conn.close()
event_bus.emit("profiles", {"action": "locked", "id": profile_id})
return {"locked": True, "profile_id": profile_id, "locked_audio_path": locked_filename}
@router.post("/profiles/{profile_id}/unlock")
@@ -224,6 +228,7 @@ async def unlock_profile(profile_id: str):
)
conn.commit()
conn.close()
event_bus.emit("profiles", {"action": "unlocked", "id": profile_id})
return {"unlocked": True, "profile_id": profile_id}
@router.delete("/profiles/{profile_id}")
@@ -239,4 +244,5 @@ def delete_profile(profile_id: str):
conn.execute("DELETE FROM voice_profiles WHERE id=?", (profile_id,))
conn.commit()
conn.close()
event_bus.emit("profiles", {"action": "deleted", "id": profile_id})
return {"deleted": profile_id}
+4
View File
@@ -4,6 +4,7 @@ import json
from fastapi import APIRouter, HTTPException
from core.db import get_db
from core import event_bus
from schemas.requests import ProjectSaveRequest
router = APIRouter()
@@ -45,6 +46,7 @@ async def create_project(req: ProjectSaveRequest):
)
conn.commit()
conn.close()
event_bus.emit("projects", {"action": "created", "id": project_id})
return {"id": project_id, "name": req.name, "created_at": now}
@router.put("/projects/{project_id}")
@@ -61,6 +63,7 @@ async def update_project(project_id: str, req: ProjectSaveRequest):
)
conn.commit()
conn.close()
event_bus.emit("projects", {"action": "updated", "id": project_id})
return {"id": project_id, "name": req.name, "updated_at": now}
@router.delete("/projects/{project_id}")
@@ -69,4 +72,5 @@ async def delete_project(project_id: str):
conn.execute("DELETE FROM studio_projects WHERE id=?", (project_id,))
conn.commit()
conn.close()
event_bus.emit("projects", {"action": "deleted", "id": project_id})
return {"deleted": project_id}
@@ -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",
@@ -114,8 +107,8 @@ KNOWN_MODELS = [
"note": "Needs an HF_TOKEN with license accepted.",
},
{
"repo_id": "OpenMOSS-Team/MOSS-TTS-Nano",
"label": "MOSS-TTS-Nano (20 langs, CPU-realtime)",
"repo_id": "OpenMOSS-Team/MOSS-TTS-Nano-100M",
"label": "MOSS-TTS-Nano 100M (20 langs, CPU-realtime)",
"role": "TTS",
"size_gb": 0.4,
"required": False,
@@ -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,14 +143,16 @@ 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-1.7B-4bit",
"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,
"required": False,
"note": "Apple Silicon only — via mlx-audio backend.",
"platforms": ["darwin-arm64"],
},
{
"repo_id": "mlx-community/Dia-1.6B",
@@ -165,20 +161,66 @@ KNOWN_MODELS = [
"size_gb": 3.2,
"required": False,
"note": "Apple Silicon only — via mlx-audio backend.",
"platforms": ["darwin-arm64"],
},
{
"repo_id": "mlx-community/OuteTTS-0.3-500M",
"label": "OuteTTS 0.3 500M (voice clone, mlx-audio)",
"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": 1.0,
"size_gb": 0.8,
"required": False,
"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,
"required": False,
"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,
"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
@@ -311,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(),
}
@@ -342,13 +386,77 @@ async def install_model(req: InstallModelRequest):
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)
snapshot_download(repo_id=req.repo_id)
# On Windows, NTFS symlinks require Developer Mode or Admin —
# most first-run installs don't have either. The global env var
# HF_HUB_DISABLE_SYMLINKS=1 (set in main.py) covers implicit
# downloads, but we also pass the kwarg here as a belt-and-braces
# guard for older huggingface_hub versions that don't read the var.
dl_kwargs: dict = {"repo_id": req.repo_id}
if sys.platform == "win32":
dl_kwargs["local_dir_use_symlinks"] = False
# Resume on transient network failures. snapshot_download writes
# `.incomplete` shards into the HF cache and resumes from them on
# the next call automatically — re-invoking with the same args
# picks up where it left off, so each retry only re-fetches what's
# missing.
_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",
})
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)
# Non-blocking — client polls /models or listens on the SSE.
loop.create_task(asyncio.to_thread(_do))
@@ -359,6 +467,12 @@ async def install_model(req: InstallModelRequest):
def delete_model(repo_id: str):
"""Remove every cached revision of a repo from the HF cache. Frees disk
+ lets the user re-install a fresh copy via POST /models/install."""
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()
@@ -377,6 +491,13 @@ def delete_model(repo_id: str):
)
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,
})
return {
"deleted": True,
"repo_id": repo_id,
@@ -666,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
+90
View File
@@ -0,0 +1,90 @@
"""
Watermark detection API upload audio, check if it was generated by OmniVoice.
"""
import os
import tempfile
import logging
import torchaudio
from fastapi import APIRouter, UploadFile, File, HTTPException
from services.watermark import detect_watermark, is_enabled, _check_available
from core.prefs import get as pref_get, set_ as pref_set
logger = logging.getLogger("omnivoice.watermark_api")
router = APIRouter()
@router.post("/watermark/detect")
async def detect_audio_watermark(file: UploadFile = File(...)):
"""
Upload an audio file and check whether it contains an OmniVoice watermark.
Returns confidence score, decoded message, and source attribution.
"""
if not _check_available():
raise HTTPException(
status_code=503,
detail="AudioSeal is not installed. Run `uv pip install audioseal` to enable watermark detection.",
)
# Accept common audio formats
allowed = {".wav", ".mp3", ".flac", ".ogg", ".m4a", ".aac", ".opus"}
ext = os.path.splitext(file.filename or "upload.wav")[1].lower()
if ext not in allowed:
raise HTTPException(
status_code=400,
detail=f"Unsupported format '{ext}'. Upload one of: {', '.join(sorted(allowed))}",
)
# Write to temp file for torchaudio to load
try:
with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as tmp:
content = await file.read()
tmp.write(content)
tmp_path = tmp.name
waveform, sr = torchaudio.load(tmp_path)
result = detect_watermark(waveform, sr)
return result
except Exception as e:
logger.error("Watermark detection failed: %s", e)
raise HTTPException(status_code=500, detail=str(e))
finally:
try:
os.unlink(tmp_path)
except (OSError, UnboundLocalError):
pass
@router.get("/watermark/status")
def watermark_status():
"""Return current watermark configuration."""
return {
"invisible_enabled": is_enabled(),
"visible_audio_enabled": pref_get("watermark.visible_audio", False),
"visible_video_enabled": pref_get("watermark.visible_video", True),
"audioseal_available": _check_available(),
}
@router.post("/watermark/settings")
def update_watermark_settings(
invisible: bool | None = None,
visible_audio: bool | None = None,
visible_video: bool | None = None,
):
"""Update watermark preferences."""
if invisible is not None:
pref_set("watermark.invisible", invisible)
if visible_audio is not None:
pref_set("watermark.visible_audio", visible_audio)
if visible_video is not None:
pref_set("watermark.visible_video", visible_video)
return {
"invisible_enabled": pref_get("watermark.invisible", True),
"visible_audio_enabled": pref_get("watermark.visible_audio", False),
"visible_video_enabled": pref_get("watermark.visible_video", True),
}
+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]
+30
View File
@@ -13,6 +13,36 @@ def get_app_data_dir():
else:
return os.path.expanduser("~/.omnivoice")
def _ensure_short_hf_cache_on_windows():
"""Redirect HuggingFace cache to a short path on Windows.
The default ``~/.cache/huggingface/hub/models--org--name/snapshots/<hash>/``
path regularly exceeds the 260-char MAX_PATH limit on NTFS, causing
``FileNotFoundError`` or truncated downloads on first install. We shorten
it to ``%LOCALAPPDATA%\\OmniVoice\\hf_cache`` (~40 chars) so even the
deepest blob path stays well under the limit.
Respects any explicit override the user already set via
``OMNIVOICE_CACHE_DIR``, ``HF_HOME``, or ``HF_HUB_CACHE``.
"""
if sys.platform != "win32":
return
# Don't override if the user (or main.py's OMNIVOICE_CACHE_DIR block)
# already pointed the cache somewhere specific.
if os.environ.get("OMNIVOICE_CACHE_DIR") or os.environ.get("HF_HOME") or os.environ.get("HF_HUB_CACHE"):
return
local_app = os.environ.get("LOCALAPPDATA", "")
if not local_app:
return
short_cache = os.path.join(local_app, "OmniVoice", "hf_cache")
os.makedirs(short_cache, exist_ok=True)
os.environ["HF_HOME"] = short_cache
os.environ["HF_HUB_CACHE"] = short_cache
_ensure_short_hf_cache_on_windows()
DATA_DIR = get_app_data_dir()
VOICES_DIR = os.path.join(DATA_DIR, "voices") # Reference audio for profiles
OUTPUTS_DIR = os.path.join(DATA_DIR, "outputs") # Generated audio files
+85
View File
@@ -0,0 +1,85 @@
"""In-memory pub/sub event bus for real-time UI updates.
Any backend code that mutates sidebar-visible data (projects, profiles,
history) calls ``emit(kind, payload)`` and the WebSocket endpoint fans it
out to all connected frontends. This replaces the 45 s polling band-aid
with instant push.
Events are fire-and-forget, no persistence needed the frontend uses
the event as a "hey, refetch this" signal rather than carrying the full
data payload.
"""
from __future__ import annotations
import asyncio
import json
import logging
import time
from typing import Any
logger = logging.getLogger("omnivoice.events")
# All connected WebSocket listener queues
_listeners: list[asyncio.Queue] = []
_lock = asyncio.Lock()
async def subscribe() -> asyncio.Queue:
"""Register a new listener. Returns a Queue that receives event dicts."""
q: asyncio.Queue = asyncio.Queue(maxsize=64)
async with _lock:
_listeners.append(q)
return q
async def unsubscribe(q: asyncio.Queue) -> None:
"""Remove a listener."""
async with _lock:
try:
_listeners.remove(q)
except ValueError:
pass
def emit(kind: str, payload: dict[str, Any] | None = None) -> None:
"""Broadcast an event to all connected frontends.
Safe to call from sync or async context uses fire-and-forget
scheduling into the running event loop.
``kind`` is one of: projects, profiles, dub_history, export_history,
generation_history, model_status, glossary.
"""
event = {
"kind": kind,
"ts": time.time(),
**(payload or {}),
}
event_str = json.dumps(event)
try:
loop = asyncio.get_running_loop()
loop.create_task(_broadcast(event_str))
except RuntimeError:
# No event loop running (unlikely in FastAPI context but safe)
logger.debug("No event loop — event dropped: %s", kind)
async def _broadcast(event_str: str) -> None:
"""Push event to all listener queues. Drop if full (slow consumer)."""
async with _lock:
dead: list[asyncio.Queue] = []
for q in _listeners:
try:
q.put_nowait(event_str)
except asyncio.QueueFull:
# Slow consumer — drop oldest, then push
try:
q.get_nowait()
q.put_nowait(event_str)
except Exception:
dead.append(q)
for q in dead:
try:
_listeners.remove(q)
except ValueError:
pass
+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)
+146 -9
View File
@@ -3,10 +3,48 @@ import sys
try:
import dotenv
dotenv.load_dotenv()
# Also load the durable per-user config so env vars set once survive
# Tauri/Finder launches that don't inherit a shell environment.
_user_env = os.path.expanduser("~/.config/omnivoice/env")
if os.path.isfile(_user_env):
dotenv.load_dotenv(_user_env, override=False)
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:
@@ -15,6 +53,30 @@ if _cache_dir:
os.environ["HF_HUB_CACHE"] = _cache_dir
os.environ["TORCH_HOME"] = _cache_dir
# ── Windows symlink fix ─────────────────────────────────────────────────────
# HuggingFace Hub creates NTFS symlinks in its cache to deduplicate blobs
# across model revisions. On Windows, symlink creation requires either
# Developer Mode enabled or an elevated (Administrator) shell. Without
# either, `snapshot_download` / `hf_hub_download` raises:
# OSError: [WinError 1314] A required privilege is not held by the client
# Setting HF_HUB_DISABLE_SYMLINKS_WARNING silences the console spam, and the
# newer HF_HUB_DISABLE_SYMLINKS (huggingface_hub ≥ 0.21) forces file copies
# instead — slightly more disk but always works on first install.
if sys.platform == "win32":
os.environ.setdefault("HF_HUB_DISABLE_SYMLINKS_WARNING", "1")
os.environ.setdefault("HF_HUB_DISABLE_SYMLINKS", "1")
# ── HF Xet → legacy LFS fallback ────────────────────────────────────────────
# huggingface_hub ≥ 1.5 routes large file downloads through the Xet content-
# addressed protocol (hf_xet runtime), which has its own internal progress
# reporting that bypasses our `tqdm` monkey-patch in `utils.hf_progress`.
# As a result the SetupWizard install rows show no byte progress while the
# download is actually running. Force the legacy LFS path until we add a
# proper hf_xet progress hook — this still streams via the standard tqdm
# wrapper that our patch intercepts. Override-able by the user.
os.environ.setdefault("HF_HUB_DISABLE_XET", "1")
# Prevent torchaudio from lazy-importing torchcodec (broken on some installs).
# Proper fix = exclude torchcodec in pyproject.toml; this is a belt-and-braces guard.
os.environ.setdefault("TORCHAUDIO_USE_TORCHCODEC", "0")
@@ -42,11 +104,12 @@ class _JsonFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
import json as _json
payload = {
"t": self.formatTime(record, datefmt="%Y-%m-%dT%H:%M:%S"),
"t": self.formatTime(record, datefmt="%Y-%m-%dT%H:%M:%S"),
"level": record.levelname,
"name": record.name,
"msg": record.getMessage(),
"name": record.name,
"msg": record.getMessage(),
}
if record.exc_info:
payload["exc"] = self.formatException(record.exc_info)
@@ -67,13 +130,21 @@ if _json_logs:
# Attached to root so uvicorn, fastapi, and every `omnivoice.*` namespace land here.
# Not attached under _disable_file_log to keep CI/headless tests quiet.
if not os.environ.get("OMNIVOICE_DISABLE_FILE_LOG"):
from core.config import LOG_PATH as _LOG_PATH # local import — avoids circular import at module top
from core.config import (
LOG_PATH as _LOG_PATH,
) # local import — avoids circular import at module top
try:
_file_handler = RotatingFileHandler(
_LOG_PATH, maxBytes=2 * 1024 * 1024, backupCount=3, encoding="utf-8",
_LOG_PATH,
maxBytes=2 * 1024 * 1024,
backupCount=3,
encoding="utf-8",
)
_file_handler.setLevel(logging.INFO)
_file_handler.setFormatter(_JsonFormatter() if _json_logs else logging.Formatter(_LOG_FMT))
_file_handler.setFormatter(
_JsonFormatter() if _json_logs else logging.Formatter(_LOG_FMT)
)
logging.getLogger().addHandler(_file_handler)
except Exception as _e: # disk full, permission denied, etc. — don't block startup
logging.getLogger("omnivoice.api").warning("Runtime log file disabled: %s", _e)
@@ -98,7 +169,25 @@ from core.tasks import task_manager
from core import job_store
from services.model_manager import idle_worker
from api.routers import system, profiles, exports, generation, dub_core, dub_generate, dub_export, dub_translate, projects, glossary, engines, tools, setup
from api.routers import (
system,
profiles,
exports,
generation,
dub_core,
dub_generate,
dub_export,
dub_translate,
projects,
glossary,
engines,
tools,
setup,
gallery,
batch,
watermark,
events,
)
from utils import hf_progress
# Install the HuggingFace tqdm patch early — every downstream library import
@@ -106,9 +195,13 @@ from utils import hf_progress
# the patched class, not the original.
hf_progress.install()
@asynccontextmanager
async def lifespan(app: FastAPI):
init_db()
from api.routers.gallery import _init_gallery_db
_init_gallery_db()
# Any job still in pending/running at startup is orphaned — a previous
# process didn't finish it. Flip to failed with a clear message so the
# UI doesn't show a fake spinner.
@@ -121,18 +214,53 @@ 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)
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
# Client disconnected mid-stream (browser canceled a <video>/range fetch).
# The response is already partially sent — trying to wrap it in a 500 just
# produces a second protocol error. Log a one-liner and bail.
exc_name = type(exc).__name__
if exc_name in ("LocalProtocolError", "ClientDisconnect") or "Content-Length" in str(exc):
if exc_name in (
"LocalProtocolError",
"ClientDisconnect",
) or "Content-Length" in str(exc):
logger.info("Client disconnect during %s (%s)", request.url, exc_name)
return Response(status_code=499)
try:
@@ -155,6 +283,7 @@ async def global_exception_handler(request: Request, exc: Exception):
headers["Vary"] = "Origin"
return JSONResponse({"detail": str(exc)}, status_code=500, headers=headers)
_allowed = os.environ.get(
"OMNIVOICE_ALLOWED_ORIGINS",
"http://localhost:3901,http://127.0.0.1:3901,tauri://localhost,http://tauri.localhost",
@@ -164,7 +293,8 @@ app.add_middleware(
CORSMiddleware,
allow_origins=[o.strip() for o in _allowed if o.strip()],
allow_credentials=True,
allow_methods=["*"], allow_headers=["*"],
allow_methods=["*"],
allow_headers=["*"],
expose_headers=["Content-Disposition"],
)
@@ -184,17 +314,24 @@ app.include_router(glossary.router)
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)
app.include_router(watermark.router)
app.include_router(events.router)
frontend_path = os.path.join(os.path.dirname(__file__), "..", "frontend", "dist")
if os.path.exists(frontend_path):
app.mount("/", StaticFiles(directory=frontend_path, html=True), name="frontend")
else:
@app.get("/")
def _dev_fallback():
return RedirectResponse(url="http://localhost:3901")
if __name__ == "__main__":
import uvicorn
# Port 3900 picked to dodge common 8000 conflicts (Django/Rails/Jupyter).
# Rust sidecar launcher in lib.rs::BACKEND_PORT must stay in sync.
uvicorn.run(app, host="0.0.0.0", port=3900)
+26
View File
@@ -50,6 +50,10 @@ class ASRBackend(ABC):
that already speak the shape plug in with zero adapter work.
"""
def unload(self) -> None:
"""Release the model from memory."""
pass
# ── WhisperX (cross-platform default — forced-alignment word timing) ────────
@@ -240,6 +244,17 @@ class WhisperXBackend(ASRBackend):
"language": lang,
}
def unload(self) -> None:
self._asr = None
self._align_cache.clear()
import gc
gc.collect()
try:
import torch
if torch.cuda.is_available():
torch.cuda.empty_cache()
except Exception:
pass
# ── Faster-Whisper (cross-platform fallback) ────────────────────────────────
@@ -339,6 +354,17 @@ class FasterWhisperBackend(ASRBackend):
}
return out
def unload(self) -> None:
self._asr = None
import gc
gc.collect()
try:
import torch
if torch.cuda.is_available():
torch.cuda.empty_cache()
except Exception:
pass
# ── MLX Whisper (Apple Silicon optional) ────────────────────────────────────
+3
View File
@@ -44,6 +44,7 @@ from fastapi import HTTPException
from services.ffmpeg_utils import find_ffmpeg, _get_semaphore, _spawn_with_retry
from services.model_manager import get_best_device
from core.db import db_conn, get_db
from core import event_bus
logger = logging.getLogger("omnivoice.dub_pipeline")
@@ -230,6 +231,8 @@ def save_job(job_id: str, job: dict, filename: str = "", duration: float = 0.0,
)
except Exception as e:
logger.error("Failed to persist dub job %s: %s", job_id, e)
return
event_bus.emit("dub_history", {"action": "saved", "id": job_id})
# ── Ingest pipeline (download → extract → demucs → scene → thumb) ──────────
+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 (WhisperX + context needs >6GB safely)
free_mem = torch.cuda.mem_get_info()[0]
if free_mem > 8 * 1024 ** 3: # > 8 GB free → plenty of room, skip offload
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():
+48 -3
View File
@@ -59,8 +59,38 @@ _ADAPT_PROMPT = """\
You are a cinematic dubbing writer. Rewrite the literal translation using the
editor's critique so it sounds natural, in-character, and fits the speaker's
time slot. Keep meaning faithful but prefer native idiom over word-for-word
accuracy. Reply ONLY with the adapted translation no quotes, no headers,
no code fences, no commentary."""
accuracy. The output MUST be written in the same target language and script
as the literal translation never switch language or transliterate.
Reply ONLY with the adapted translation no quotes, no headers, no code
fences, no commentary."""
# Per-language script ranges, mirrored from dub_translate.LANG_REQUIRED_SCRIPT
# so the cinematic refine path can reject LLM outputs that drifted off the
# target script. Kept local instead of imported because the routers package
# also imports this services module — circular-import risk otherwise.
_SCRIPT_RANGES = {
"hi": (0x0900, 0x097F),
"ar": (0x0600, 0x06FF),
"zh": (0x4E00, 0x9FFF),
"zh-CN": (0x4E00, 0x9FFF),
"ja": (0x3040, 0x30FF),
"ko": (0xAC00, 0xD7AF),
"th": (0x0E00, 0x0E7F),
"ru": (0x0400, 0x04FF),
"uk": (0x0400, 0x04FF),
}
def _looks_like_target_script(text: str, code: str, threshold: float = 0.5) -> bool:
rng = _SCRIPT_RANGES.get(code)
if not rng:
return True
lo, hi = rng
letters = [c for c in text if c.isalpha()]
if not letters:
return True
inside = sum(1 for c in letters if lo <= ord(c) <= hi)
return (inside / len(letters)) >= threshold
def _llm_client():
@@ -219,7 +249,22 @@ def cinematic_refine_sync(
"error": f"adapt: {e}",
}
final = adapted.strip() or literal_text
final = (adapted or "").strip() or literal_text
# Refuse adaptations that drifted off the target script (e.g. local LLM
# rewrote a Devanagari line in Latin/German). Caller still gets the
# critique so the UI can show what happened, but the live text falls
# back to the literal translation rather than corrupting the dub.
if final is not literal_text and not _looks_like_target_script(final, target_lang):
logger.warning(
"cinematic adapt produced wrong-script output for %s — falling back to literal",
target_lang,
)
return {
"text": literal_text,
"literal": literal_text,
"critique": critique,
"error": f"adapt-wrong-script:{target_lang}",
}
return {
"text": final,
"literal": literal_text,
+4 -4
View File
@@ -441,11 +441,11 @@ class MLXAudioBackend(TTSBackend):
CURATED_MODELS = {
"kokoro": "mlx-community/Kokoro-82M-bf16",
"csm": "mlx-community/csm-1b-8bit",
"qwen3-tts": "mlx-community/Qwen3-TTS-1.7B-4bit",
"qwen3-tts": "mlx-community/Qwen3-TTS-12Hz-1.7B-VoiceDesign-4bit",
"dia": "mlx-community/Dia-1.6B",
"chatterbox": "mlx-community/Chatterbox",
"melotts": "mlx-community/MeloTTS",
"outetts": "mlx-community/OuteTTS-0.3-500M",
"chatterbox": "mlx-community/Chatterbox-TTS-4bit",
"melotts": "mlx-community/MeloTTS-English-v3-MLX",
"outetts": "mlx-community/Llama-OuteTTS-1.0-1B-4bit",
}
DEFAULT_MODEL_KEY = "kokoro"
+298
View File
@@ -0,0 +1,298 @@
"""
Invisible + visible audio watermarking for OmniVoice Studio.
Two layers:
1. **Invisible** AudioSeal (Meta) embeds imperceptible neural watermarks
that survive compression, resampling, and editing. Encodes a 16-bit
message identifying OmniVoice as the source.
2. **Visible** Optional audio signature tone prepended to exports;
ffmpeg-based logo overlay for video exports.
Usage:
from services.watermark import embed_watermark, detect_watermark
# Embed (returns same shape tensor, watermarked)
watermarked = embed_watermark(waveform, sample_rate)
# Detect (returns dict with confidence + metadata)
result = detect_watermark(waveform, sample_rate)
"""
from __future__ import annotations
import logging
import math
import struct
import torch
import numpy as np
from typing import Optional
from core.prefs import resolve
logger = logging.getLogger("omnivoice.watermark")
# ── Lazy-loaded AudioSeal models ──────────────────────────────────────────
# Loaded on first use so cold-start isn't penalised when watermarking is off.
_generator = None
_detector = None
_audioseal_available: Optional[bool] = None
# 16-bit message: "OM" in ASCII = 0x4F 0x4D = 0100_1111 0100_1101
# This is our signature — every OmniVoice-generated audio carries it.
OMNI_MESSAGE = [0, 1, 0, 0, 1, 1, 1, 1, 0, 1, 0, 0, 1, 1, 0, 1]
def _check_available() -> bool:
"""Check if AudioSeal is installed and importable."""
global _audioseal_available
if _audioseal_available is None:
try:
import audioseal # noqa: F401
_audioseal_available = True
except ImportError:
_audioseal_available = False
logger.info("audioseal not installed — invisible watermarking disabled")
return _audioseal_available
def _get_generator():
"""Lazy-load the AudioSeal generator model."""
global _generator
if _generator is None:
from audioseal import AudioSeal
_generator = AudioSeal.load_generator("audioseal_wm_16bits")
_generator.eval()
logger.info("AudioSeal generator loaded (16-bit message mode)")
return _generator
def _get_detector():
"""Lazy-load the AudioSeal detector model."""
global _detector
if _detector is None:
from audioseal import AudioSeal
_detector = AudioSeal.load_detector("audioseal_detector_16bits")
_detector.eval()
logger.info("AudioSeal detector loaded (16-bit message mode)")
return _detector
def is_enabled() -> bool:
"""Check if invisible watermarking is enabled in user preferences."""
return resolve("watermark.invisible", default=True) is not False
def is_visible_audio_enabled() -> bool:
"""Check if audible branding tone is enabled for exports."""
return resolve("watermark.visible_audio", default=False) is True
def is_visible_video_enabled() -> bool:
"""Check if video logo overlay is enabled for exports."""
return resolve("watermark.visible_video", default=True) is not False
# ── Invisible Watermark ───────────────────────────────────────────────────
@torch.no_grad()
def embed_watermark(
waveform: torch.Tensor,
sample_rate: int,
message: Optional[list[int]] = None,
) -> torch.Tensor:
"""
Embed an imperceptible watermark into the audio waveform.
Args:
waveform: Audio tensor of shape (channels, samples) or (1, channels, samples)
sample_rate: Sample rate of the audio
message: Optional 16-bit message (list of 0/1). Defaults to OMNI_MESSAGE.
Returns:
Watermarked waveform (same shape as input).
"""
if not is_enabled() or not _check_available():
return waveform
try:
generator = _get_generator()
msg = torch.tensor(message or OMNI_MESSAGE, dtype=torch.int32).unsqueeze(0)
# AudioSeal expects (batch, channels, samples) — normalise input
original_shape = waveform.shape
if waveform.dim() == 2:
audio = waveform.unsqueeze(0) # (1, C, S)
elif waveform.dim() == 1:
audio = waveform.unsqueeze(0).unsqueeze(0) # (1, 1, S)
else:
audio = waveform
# AudioSeal operates at 16kHz internally; it handles resampling, but
# we need to inform it of the source rate for correct embedding.
watermarked = generator(audio, sample_rate=sample_rate, message=msg)
# Restore original shape
if len(original_shape) == 2:
watermarked = watermarked.squeeze(0)
elif len(original_shape) == 1:
watermarked = watermarked.squeeze(0).squeeze(0)
return watermarked
except Exception as e:
logger.warning("Watermark embedding failed (passing through original): %s", e)
return waveform
@torch.no_grad()
def detect_watermark(
waveform: torch.Tensor,
sample_rate: int,
) -> dict:
"""
Detect whether audio contains an OmniVoice watermark.
Args:
waveform: Audio tensor of shape (channels, samples)
sample_rate: Sample rate of the audio
Returns:
Dict with keys:
is_watermarked: bool
confidence: float (0.01.0)
message_bits: str (decoded 16-bit message)
is_omnivoice: bool (true if message matches OMNI_MESSAGE)
"""
if not _check_available():
return {
"is_watermarked": False,
"confidence": 0.0,
"message_bits": "",
"is_omnivoice": False,
"error": "audioseal not installed",
}
try:
detector = _get_detector()
# Normalise shape to (batch, channels, samples)
if waveform.dim() == 2:
audio = waveform.unsqueeze(0)
elif waveform.dim() == 1:
audio = waveform.unsqueeze(0).unsqueeze(0)
else:
audio = waveform
result = detector.detect_watermark(audio, sample_rate=sample_rate, message_threshold=0.5)
# result is (detection_confidence, decoded_message)
confidence = float(result[0]) if isinstance(result, tuple) else 0.0
decoded_msg = result[1] if isinstance(result, tuple) and len(result) > 1 else None
# Decode message bits
message_bits = ""
is_omnivoice = False
if decoded_msg is not None:
try:
bits = decoded_msg.squeeze().tolist()
if isinstance(bits, list):
message_bits = "".join(str(int(b > 0.5)) for b in bits)
decoded_list = [int(b > 0.5) for b in bits]
is_omnivoice = decoded_list == OMNI_MESSAGE
except Exception:
pass
return {
"is_watermarked": confidence > 0.5,
"confidence": round(confidence, 4),
"message_bits": message_bits,
"is_omnivoice": is_omnivoice,
"source": "OmniVoice Studio" if is_omnivoice else "unknown",
}
except Exception as e:
logger.warning("Watermark detection failed: %s", e)
return {
"is_watermarked": False,
"confidence": 0.0,
"message_bits": "",
"is_omnivoice": False,
"error": str(e),
}
# ── Visible Audio Brand ──────────────────────────────────────────────────
def generate_brand_tone(sample_rate: int = 24000, duration_s: float = 0.4) -> torch.Tensor:
"""
Generate a short, distinctive audio signature tone.
A soft ascending three-note chime (C5E5G5) that serves as the
OmniVoice "sound logo". Gentle enough for professional use.
Returns:
Tensor of shape (1, samples).
"""
notes_hz = [523.25, 659.25, 783.99] # C5, E5, G5
note_dur = duration_s / len(notes_hz)
samples_per_note = int(note_dur * sample_rate)
total_samples = samples_per_note * len(notes_hz)
tone = torch.zeros(1, total_samples)
t = torch.linspace(0, note_dur, samples_per_note)
for idx, freq in enumerate(notes_hz):
# Sine wave with exponential decay envelope
envelope = torch.exp(-t * 6.0) * 0.15 # quiet — 15% amplitude
wave = torch.sin(2 * math.pi * freq * t) * envelope
start = idx * samples_per_note
tone[0, start : start + samples_per_note] = wave
# Fade out the last 20%
fade_len = int(total_samples * 0.2)
if fade_len > 0:
tone[0, -fade_len:] *= torch.linspace(1.0, 0.0, fade_len)
return tone
def apply_audio_brand(
waveform: torch.Tensor,
sample_rate: int,
) -> torch.Tensor:
"""
Prepend the OmniVoice brand tone to a waveform (for final exports only).
Returns:
Tensor with brand tone + original audio concatenated.
"""
if not is_visible_audio_enabled():
return waveform
brand = generate_brand_tone(sample_rate=sample_rate)
# Add 100ms silence gap between brand and content
gap = torch.zeros(1, int(0.1 * sample_rate))
return torch.cat([brand, gap, waveform], dim=-1)
# ── Video Logo Overlay ────────────────────────────────────────────────────
def get_ffmpeg_overlay_args(logo_path: str, duration_s: float = 5.0) -> list[str]:
"""
Build ffmpeg filter args to overlay the OmniVoice logo in the bottom-right
corner with a fade-out after `duration_s` seconds.
Returns:
List of ffmpeg filter_complex args.
"""
if not is_visible_video_enabled():
return []
# Scale logo to 64px height, place bottom-right with 20px padding,
# fade out after duration_s seconds.
filter_str = (
f"[1:v]scale=-1:64,format=rgba,"
f"fade=t=out:st={duration_s - 1}:d=1:alpha=1[logo];"
f"[0:v][logo]overlay=W-w-20:H-h-20:enable='lte(t,{duration_s})'"
)
return ["-filter_complex", filter_str]
+20
View File
@@ -18,6 +18,7 @@ Usage:
"""
from __future__ import annotations
import contextvars
import itertools
import logging
import threading
@@ -25,6 +26,14 @@ from typing import Callable, Optional
logger = logging.getLogger("omnivoice.hf_progress")
# Context-scoped active repo_id. Set in the install/delete handler so every
# tqdm event fired while a snapshot_download runs can be stamped with the
# originating repo, letting the frontend route per-file events to the right
# row instead of heuristically matching filename substrings.
current_repo_id: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar(
"omnivoice_hf_progress_repo_id", default=None,
)
# Event shape forwarded to listeners. Typed loosely on purpose — SSE encodes
# it as JSON so consumers read the dict directly.
# {
@@ -61,6 +70,11 @@ def unregister_listener(lid: int) -> None:
def _emit(event: ProgressEvent) -> None:
"""Fan out to all registered listeners. Never raise — a bad listener
shouldn't break a download."""
# Stamp the active repo_id so frontends can route events to the right
# row. Only set when this emit is happening inside an install handler.
rid = current_repo_id.get()
if rid is not None and "repo_id" not in event:
event = {**event, "repo_id": rid}
with _listener_lock:
listeners = list(_listeners.values())
for cb in listeners:
@@ -70,6 +84,12 @@ def _emit(event: ProgressEvent) -> None:
logger.debug("hf_progress listener raised: %s", e)
def emit(event: ProgressEvent) -> None:
"""Public emit — lets non-tqdm operations (delete, verify, etc.) push
lifecycle events onto the same SSE stream."""
_emit(event)
def install() -> None:
"""Monkey-patch `huggingface_hub`'s tqdm so every download reports to our
listeners. Safe to call multiple times second call is a no-op."""
+242 -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,36 @@
},
"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-opener": "^2.5.3",
"@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 +119,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 +167,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 +275,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=="],
@@ -203,6 +351,8 @@
"@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.7.0", "", { "dependencies": { "@tauri-apps/api": "^2.10.1" } }, "sha512-4nS/hfGMGCXiAS3LtVjH9AgsSAPJeG/7R+q8agTFqytjnMa4Zq95Bq8WzVDkckpanX+yyRHXnRtrKXkANKDHvw=="],
"@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.5.3", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ=="],
"@tauri-apps/plugin-process": ["@tauri-apps/plugin-process@2.3.1", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA=="],
"@tauri-apps/plugin-updater": ["@tauri-apps/plugin-updater@2.10.1", "", { "dependencies": { "@tauri-apps/api": "^2.10.1" } }, "sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA=="],
@@ -245,6 +395,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 +439,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 +481,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 +491,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 +505,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 +515,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 +531,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 +545,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 +557,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 +583,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 +619,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 +639,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 +649,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 +659,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 +707,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 +735,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 +767,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 +777,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=="],
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ services:
container_name: omnivoice-studio
restart: unless-stopped
ports:
- "8000:8000"
- "3900:3900"
volumes:
# Map the backend data directory to host for persistent SQLite, voices, and history
- ./omnivoice_data:/app/omnivoice_data
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

+61
View File
@@ -0,0 +1,61 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<defs>
<!-- Background gradient -->
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#1e1e2e"/>
<stop offset="100%" stop-color="#13131f"/>
</linearGradient>
<!-- Waveform pink gradient -->
<linearGradient id="wave" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#e8a4b8"/>
<stop offset="50%" stop-color="#d3869b"/>
<stop offset="100%" stop-color="#c07090"/>
</linearGradient>
<!-- Glow filter -->
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur in="SourceGraphic" stdDeviation="8" result="blur"/>
<feColorMatrix in="blur" type="matrix" values="1 0 0 0 0 0 0.4 0 0 0 0 0 0.5 0 0 0 0 0 0.6 0" result="glow"/>
<feMerge>
<feMergeNode in="glow"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
<!-- Subtle inner shadow -->
<filter id="inset" x="-10%" y="-10%" width="120%" height="120%">
<feGaussianBlur in="SourceAlpha" stdDeviation="6" result="blur"/>
<feOffset dx="0" dy="3" result="offset"/>
<feComposite in="SourceGraphic" in2="offset" operator="over"/>
</filter>
<!-- Edge highlight -->
<linearGradient id="edge" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.12"/>
<stop offset="50%" stop-color="#ffffff" stop-opacity="0.03"/>
<stop offset="100%" stop-color="#000000" stop-opacity="0.15"/>
</linearGradient>
</defs>
<!-- Outer rounded square -->
<rect x="16" y="16" width="480" height="480" rx="96" ry="96" fill="url(#bg)"/>
<!-- Edge/border highlight -->
<rect x="16" y="16" width="480" height="480" rx="96" ry="96" fill="none" stroke="url(#edge)" stroke-width="2"/>
<!-- Central waveform bars (audio visualizer style) -->
<g transform="translate(256, 256)" filter="url(#glow)">
<!-- 7 bars, symmetric heights, rounded caps -->
<rect x="-120" y="-30" width="20" height="60" rx="10" fill="url(#wave)" opacity="0.7"/>
<rect x="-84" y="-55" width="20" height="110" rx="10" fill="url(#wave)" opacity="0.85"/>
<rect x="-48" y="-80" width="20" height="160" rx="10" fill="url(#wave)" opacity="0.95"/>
<rect x="-10" y="-100" width="20" height="200" rx="10" fill="url(#wave)"/>
<rect x="28" y="-75" width="20" height="150" rx="10" fill="url(#wave)" opacity="0.95"/>
<rect x="64" y="-50" width="20" height="100" rx="10" fill="url(#wave)" opacity="0.85"/>
<rect x="100" y="-25" width="20" height="50" rx="10" fill="url(#wave)" opacity="0.7"/>
</g>
<!-- Subtle circle ring behind bars -->
<circle cx="256" cy="256" r="140" fill="none" stroke="#d3869b" stroke-width="1.5" opacity="0.15"/>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

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

+17 -1
View File
@@ -1,7 +1,7 @@
{
"name": "omnivoice-studio",
"private": true,
"version": "0.2.2",
"version": "0.2.4",
"type": "module",
"scripts": {
"dev": "vite",
@@ -17,15 +17,31 @@
"@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-opener": "^2.5.3",
"@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"
},
+61 -7
View File
@@ -1,7 +1,61 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#d3869b" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" opacity="0.3" fill="#d3869b"/>
<circle cx="12" cy="12" r="10" />
<path d="M12 6v12" />
<path d="M8 9v6" />
<path d="M16 9v6" />
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<defs>
<!-- Background gradient -->
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#1e1e2e"/>
<stop offset="100%" stop-color="#13131f"/>
</linearGradient>
<!-- Waveform pink gradient -->
<linearGradient id="wave" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#e8a4b8"/>
<stop offset="50%" stop-color="#d3869b"/>
<stop offset="100%" stop-color="#c07090"/>
</linearGradient>
<!-- Glow filter -->
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur in="SourceGraphic" stdDeviation="8" result="blur"/>
<feColorMatrix in="blur" type="matrix" values="1 0 0 0 0 0 0.4 0 0 0 0 0 0.5 0 0 0 0 0 0.6 0" result="glow"/>
<feMerge>
<feMergeNode in="glow"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
<!-- Subtle inner shadow -->
<filter id="inset" x="-10%" y="-10%" width="120%" height="120%">
<feGaussianBlur in="SourceAlpha" stdDeviation="6" result="blur"/>
<feOffset dx="0" dy="3" result="offset"/>
<feComposite in="SourceGraphic" in2="offset" operator="over"/>
</filter>
<!-- Edge highlight -->
<linearGradient id="edge" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.12"/>
<stop offset="50%" stop-color="#ffffff" stop-opacity="0.03"/>
<stop offset="100%" stop-color="#000000" stop-opacity="0.15"/>
</linearGradient>
</defs>
<!-- Outer rounded square -->
<rect x="16" y="16" width="480" height="480" rx="96" ry="96" fill="url(#bg)"/>
<!-- Edge/border highlight -->
<rect x="16" y="16" width="480" height="480" rx="96" ry="96" fill="none" stroke="url(#edge)" stroke-width="2"/>
<!-- Central waveform bars (audio visualizer style) -->
<g transform="translate(256, 256)" filter="url(#glow)">
<!-- 7 bars, symmetric heights, rounded caps -->
<rect x="-120" y="-30" width="20" height="60" rx="10" fill="url(#wave)" opacity="0.7"/>
<rect x="-84" y="-55" width="20" height="110" rx="10" fill="url(#wave)" opacity="0.85"/>
<rect x="-48" y="-80" width="20" height="160" rx="10" fill="url(#wave)" opacity="0.95"/>
<rect x="-10" y="-100" width="20" height="200" rx="10" fill="url(#wave)"/>
<rect x="28" y="-75" width="20" height="150" rx="10" fill="url(#wave)" opacity="0.95"/>
<rect x="64" y="-50" width="20" height="100" rx="10" fill="url(#wave)" opacity="0.85"/>
<rect x="100" y="-25" width="20" height="50" rx="10" fill="url(#wave)" opacity="0.7"/>
</g>
<!-- Subtle circle ring behind bars -->
<circle cx="256" cy="256" r="140" fill="none" stroke="#d3869b" stroke-width="1.5" opacity="0.15"/>
</svg>

Before

Width:  |  Height:  |  Size: 354 B

After

Width:  |  Height:  |  Size: 2.8 KiB

+551 -14
View File
@@ -77,22 +77,27 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "app"
version = "0.2.0"
version = "0.2.4"
dependencies = [
"flate2",
"libc",
"log",
"serde",
"serde_json",
"sha2",
"sysinfo",
"tar",
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-log",
"tauri-plugin-opener",
"tauri-plugin-process",
"tauri-plugin-updater",
"tauri-plugin-window-state",
"ureq",
"walkdir",
"webkit2gtk",
"zip 2.4.2",
]
@@ -111,6 +116,137 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "async-broadcast"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
dependencies = [
"event-listener",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-channel"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
dependencies = [
"concurrent-queue",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-executor"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
dependencies = [
"async-task",
"concurrent-queue",
"fastrand",
"futures-lite",
"pin-project-lite",
"slab",
]
[[package]]
name = "async-io"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
dependencies = [
"autocfg",
"cfg-if",
"concurrent-queue",
"futures-io",
"futures-lite",
"parking",
"polling",
"rustix",
"slab",
"windows-sys 0.61.2",
]
[[package]]
name = "async-lock"
version = "3.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
dependencies = [
"event-listener",
"event-listener-strategy",
"pin-project-lite",
]
[[package]]
name = "async-process"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
dependencies = [
"async-channel",
"async-io",
"async-lock",
"async-signal",
"async-task",
"blocking",
"cfg-if",
"event-listener",
"futures-lite",
"rustix",
]
[[package]]
name = "async-recursion"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "async-signal"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
dependencies = [
"async-io",
"async-lock",
"atomic-waker",
"cfg-if",
"futures-core",
"futures-io",
"rustix",
"signal-hook-registry",
"slab",
"windows-sys 0.61.2",
]
[[package]]
name = "async-task"
version = "4.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
[[package]]
name = "async-trait"
version = "0.1.89"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "atk"
version = "0.18.2"
@@ -218,6 +354,19 @@ dependencies = [
"objc2",
]
[[package]]
name = "blocking"
version = "1.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21"
dependencies = [
"async-channel",
"async-task",
"futures-io",
"futures-lite",
"piper",
]
[[package]]
name = "borsh"
version = "1.6.1"
@@ -462,6 +611,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "concurrent-queue"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "convert_case"
version = "0.4.0"
@@ -848,6 +1006,33 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
[[package]]
name = "endi"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
[[package]]
name = "enumflags2"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
dependencies = [
"enumflags2_derive",
"serde",
]
[[package]]
name = "enumflags2_derive"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "env_filter"
version = "0.1.4"
@@ -885,6 +1070,27 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "event-listener"
version = "5.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
dependencies = [
"concurrent-queue",
"parking",
"pin-project-lite",
]
[[package]]
name = "event-listener-strategy"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
dependencies = [
"event-listener",
"pin-project-lite",
]
[[package]]
name = "fastrand"
version = "2.4.1"
@@ -1048,6 +1254,19 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
[[package]]
name = "futures-lite"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
dependencies = [
"fastrand",
"futures-core",
"futures-io",
"parking",
"pin-project-lite",
]
[[package]]
name = "futures-macro"
version = "0.3.32"
@@ -1436,6 +1655,12 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hermit-abi"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
[[package]]
name = "hex"
version = "0.4.3"
@@ -1764,6 +1989,25 @@ dependencies = [
"serde",
]
[[package]]
name = "is-docker"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
dependencies = [
"once_cell",
]
[[package]]
name = "is-wsl"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
dependencies = [
"is-docker",
"once_cell",
]
[[package]]
name = "itoa"
version = "1.0.18"
@@ -2141,6 +2385,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"
@@ -2329,6 +2582,18 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "open"
version = "5.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f3bab717c29a857abf75fcef718d441ec7cb2725f937343c734740a985d37fd"
dependencies = [
"dunce",
"is-wsl",
"libc",
"pathdiff",
]
[[package]]
name = "openssl-probe"
version = "0.2.1"
@@ -2341,6 +2606,16 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "ordered-stream"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
dependencies = [
"futures-core",
"pin-project-lite",
]
[[package]]
name = "osakit"
version = "0.3.1"
@@ -2380,6 +2655,12 @@ dependencies = [
"system-deps",
]
[[package]]
name = "parking"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
[[package]]
name = "parking_lot"
version = "0.12.5"
@@ -2403,6 +2684,12 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "pathdiff"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
[[package]]
name = "percent-encoding"
version = "2.3.2"
@@ -2602,6 +2889,17 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "piper"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
dependencies = [
"atomic-waker",
"fastrand",
"futures-io",
]
[[package]]
name = "pkg-config"
version = "0.3.33"
@@ -2640,6 +2938,20 @@ dependencies = [
"miniz_oxide",
]
[[package]]
name = "polling"
version = "3.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
dependencies = [
"cfg-if",
"concurrent-queue",
"hermit-abi",
"pin-project-lite",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "potential_utf"
version = "0.1.5"
@@ -3544,6 +3856,16 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]]
name = "simd-adler32"
version = "0.3.9"
@@ -3758,6 +4080,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 +4138,7 @@ dependencies = [
"tao-macros",
"unicode-segmentation",
"url",
"windows",
"windows 0.61.3",
"windows-core 0.61.2",
"windows-version",
"x11-dl",
@@ -3892,7 +4227,7 @@ dependencies = [
"webkit2gtk",
"webview2-com",
"window-vibrancy",
"windows",
"windows 0.61.3",
]
[[package]]
@@ -4039,6 +4374,28 @@ dependencies = [
"time",
]
[[package]]
name = "tauri-plugin-opener"
version = "2.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc624469b06f59f5a29f874bbc61a2ed737c0f9c23ef09855a292c389c42e83f"
dependencies = [
"dunce",
"glob",
"objc2-app-kit",
"objc2-foundation",
"open",
"schemars 0.8.22",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
"url",
"windows 0.61.3",
"zbus",
]
[[package]]
name = "tauri-plugin-process"
version = "2.3.1"
@@ -4119,7 +4476,7 @@ dependencies = [
"url",
"webkit2gtk",
"webview2-com",
"windows",
"windows 0.61.3",
]
[[package]]
@@ -4144,7 +4501,7 @@ dependencies = [
"url",
"webkit2gtk",
"webview2-com",
"windows",
"windows 0.61.3",
"wry",
]
@@ -4523,9 +4880,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"pin-project-lite",
"tracing-attributes",
"tracing-core",
]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "tracing-core"
version = "0.1.36"
@@ -4575,6 +4944,17 @@ version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "uds_windows"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.61.2",
]
[[package]]
name = "unic-char-property"
version = "0.9.0"
@@ -5001,10 +5381,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 +5405,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 +5455,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 +5487,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 +5518,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 +5536,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 +5558,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 +5602,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"
@@ -5477,6 +5910,9 @@ name = "winnow"
version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
dependencies = [
"memchr",
]
[[package]]
name = "winnow"
@@ -5629,7 +6065,7 @@ dependencies = [
"webkit2gtk",
"webkit2gtk-sys",
"webview2-com",
"windows",
"windows 0.61.3",
"windows-core 0.61.2",
"windows-version",
"x11-dl",
@@ -5698,6 +6134,67 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zbus"
version = "5.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca82f95dbd3943a40a53cfded6c2d0a2ca26192011846a1810c4256ef92c60bc"
dependencies = [
"async-broadcast",
"async-executor",
"async-io",
"async-lock",
"async-process",
"async-recursion",
"async-task",
"async-trait",
"blocking",
"enumflags2",
"event-listener",
"futures-core",
"futures-lite",
"hex",
"libc",
"ordered-stream",
"rustix",
"serde",
"serde_repr",
"tracing",
"uds_windows",
"uuid",
"windows-sys 0.61.2",
"winnow 0.7.15",
"zbus_macros",
"zbus_names",
"zvariant",
]
[[package]]
name = "zbus_macros"
version = "5.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222"
dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 2.0.117",
"zbus_names",
"zvariant",
"zvariant_utils",
]
[[package]]
name = "zbus_names"
version = "4.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f"
dependencies = [
"serde",
"winnow 0.7.15",
"zvariant",
]
[[package]]
name = "zerocopy"
version = "0.8.48"
@@ -5824,3 +6321,43 @@ dependencies = [
"log",
"simd-adler32",
]
[[package]]
name = "zvariant"
version = "5.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b"
dependencies = [
"endi",
"enumflags2",
"serde",
"winnow 0.7.15",
"zvariant_derive",
"zvariant_utils",
]
[[package]]
name = "zvariant_derive"
version = "5.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c"
dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 2.0.117",
"zvariant_utils",
]
[[package]]
name = "zvariant_utils"
version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9"
dependencies = [
"proc-macro2",
"quote",
"serde",
"syn 2.0.117",
"winnow 0.7.15",
]
+13 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "app"
version = "0.2.2"
version = "0.2.4"
description = "A Tauri App"
authors = ["you"]
license = ""
@@ -27,6 +27,7 @@ tauri-plugin-dialog = "2"
tauri-plugin-window-state = "2.0.0"
tauri-plugin-updater = "2"
tauri-plugin-process = "2"
tauri-plugin-opener = "2"
# First-run bootstrap: the installer ships ~10 MB with only the Tauri
# shell + pyproject.toml + uv.lock + backend source. On first launch the
@@ -39,8 +40,19 @@ 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"
[target.'cfg(target_os = "linux")'.dependencies]
webkit2gtk = "2.0"
+2 -1
View File
@@ -18,6 +18,7 @@
"dialog:allow-ask",
"updater:default",
"process:default",
"process:allow-restart"
"process:allow-restart",
"opener:default"
]
}
+744 -68
View File
@@ -1,17 +1,23 @@
use std::fs;
use std::io::{self, Read};
use std::io::{self, BufRead, BufReader, Read};
use std::net::TcpStream;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::Mutex;
use std::time::Duration;
use tauri::Manager;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use serde::Serialize;
use tauri::{Emitter, Manager};
// Unique port range (3900-3902) chosen to avoid common conflicts:
// 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
@@ -22,6 +28,138 @@ pub struct BackendState {
pub process: Mutex<Option<Child>>,
}
// ── Bootstrap progress (for the React splash screen) ─────────────────────
#[derive(Clone, Serialize, Debug)]
#[serde(tag = "stage", rename_all = "snake_case")]
pub enum BootstrapStage {
/// Working out whether we need to bootstrap at all.
Checking,
/// Fetching the standalone `uv` binary from astral-sh/uv releases.
DownloadingUv { percent: Option<u8> },
/// Creating the Python 3.11 venv.
CreatingVenv,
/// Running `uv sync --frozen --no-dev`. Biggest time sink on first run
/// (~5-10 min to pull torch + whisperx + faster-whisper + demucs).
InstallingDeps,
/// Fetching the per-platform static ffmpeg binary from the
/// ffmpeg-static GitHub release. ~30-70 MB.
DownloadingFfmpeg { percent: Option<u8> },
/// Venv ready, spawning uvicorn. Should be <5 s.
StartingBackend,
/// Backend is listening and healthy. Frontend can leave the splash.
Ready,
/// Something blew up; message carries the reason.
Failed { message: String },
}
pub struct BootstrapState {
pub stage: Arc<Mutex<BootstrapStage>>,
}
fn set_stage(state: &Arc<Mutex<BootstrapStage>>, stage: BootstrapStage) {
if let Ok(mut guard) = state.lock() {
*guard = stage;
}
}
// ── Splash log + byte-progress event channel ─────────────────────────────
//
// Two Tauri events drive the splash UI's log panel + per-stage progress
// bar. The splash polls `bootstrap_status` for the coarse stage label and
// listens on these for live detail.
#[derive(Clone, Serialize)]
struct LogPayload {
stage: String,
line: String,
}
#[derive(Clone, Serialize)]
struct ProgressPayload {
stage: String,
bytes_done: u64,
bytes_total: u64,
percent: Option<u8>,
}
fn emit_log<R: tauri::Runtime>(app: &tauri::AppHandle<R>, stage: &str, line: &str) {
let _ = app.emit(
"bootstrap-log",
LogPayload { stage: stage.to_string(), line: line.to_string() },
);
}
fn emit_progress<R: tauri::Runtime>(
app: &tauri::AppHandle<R>,
stage: &str,
done: u64,
total: u64,
) {
let percent = if total > 0 {
Some(((done as f64 / total as f64) * 100.0).min(100.0) as u8)
} else {
None
};
let _ = app.emit(
"bootstrap-progress",
ProgressPayload {
stage: stage.to_string(),
bytes_done: done,
bytes_total: total,
percent,
},
);
}
/// Stream stdout+stderr of a long-running subprocess line-by-line into the
/// splash log panel. Replaces blocking `.status()` calls so the user sees
/// `uv sync` chatter during the 510 min pip resolve. Returns the exit
/// status once the child exits.
fn run_streaming<R: tauri::Runtime>(
app: &tauri::AppHandle<R>,
stage: &str,
cmd: &mut Command,
) -> io::Result<std::process::ExitStatus> {
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
let mut child = cmd.spawn()?;
let stdout = child.stdout.take();
let stderr = child.stderr.take();
let app_out = app.clone();
let app_err = app.clone();
let stage_out = stage.to_string();
let stage_err = stage.to_string();
let h_out = std::thread::spawn(move || {
if let Some(s) = stdout {
for line in BufReader::new(s).lines().flatten() {
log::info!("[{}] {}", stage_out, line);
emit_log(&app_out, &stage_out, &line);
}
}
});
let h_err = std::thread::spawn(move || {
if let Some(s) = stderr {
for line in BufReader::new(s).lines().flatten() {
log::info!("[{}] {}", stage_err, line);
emit_log(&app_err, &stage_err, &line);
}
}
});
let status = child.wait()?;
let _ = h_out.join();
let _ = h_err.join();
Ok(status)
}
#[tauri::command]
fn bootstrap_status(state: tauri::State<'_, BootstrapState>) -> BootstrapStage {
state
.stage
.lock()
.map(|g| g.clone())
.unwrap_or(BootstrapStage::Checking)
}
// ── Port probing ──────────────────────────────────────────────────────────
/// Just "something is listening on :port"
@@ -245,8 +383,19 @@ fn copy_dir_recursive(src: &Path, dst: &Path) -> io::Result<()> {
/// Dev mode wins: if `.venv` exists at the project root, reuse it (matches
/// the behaviour of `bun run dev`). Otherwise copy the bundled pyproject.toml
/// + uv.lock + backend/ from Tauri resources into `app_local_data_dir/project`
/// and run `uv venv` + `uv sync --frozen --no-dev` there.
fn ensure_venv_ready<R: tauri::Runtime>(app: &tauri::App<R>) -> Option<(PathBuf, PathBuf)> {
/// and run `uv venv` + `uv sync --frozen --no-dev` there. All subprocess
/// stdout/stderr is streamed to the splash log panel via Tauri events.
fn ensure_venv_ready<R: tauri::Runtime>(app: &tauri::AppHandle<R>, progress: Option<&Arc<Mutex<BootstrapStage>>>) -> Option<(PathBuf, PathBuf)> {
let fail = |progress: Option<&Arc<Mutex<BootstrapStage>>>, msg: &str| {
log::error!("{}", msg);
if let Some(p) = progress {
set_stage(p, BootstrapStage::Failed { message: msg.to_string() });
}
};
if let Some(p) = progress {
set_stage(p, BootstrapStage::Checking);
}
if let Some(dev_root) = find_dev_project_root() {
let dev_venv = dev_root.join(".venv");
let dev_py = venv_python_path(&dev_venv);
@@ -269,32 +418,49 @@ fn ensure_venv_ready<R: tauri::Runtime>(app: &tauri::App<R>) -> Option<(PathBuf,
}
let resource_dir = app.path().resource_dir().ok()?;
let resource_pyproject = resource_dir.join("pyproject.toml");
let resource_uvlock = resource_dir.join("uv.lock");
let resource_backend = resource_dir.join("backend");
// Tauri v2 replaces `../` with `_up_/` in bundled resource paths. So
// `../../pyproject.toml` from tauri.conf.json becomes
// `$RESOURCE/_up_/_up_/pyproject.toml` on Windows MSI and Linux deb.
// macOS .app bundles flatten resources into Contents/Resources/ directly.
// Try both layouts so the bootstrap works across all platforms.
let flat = resource_dir.clone();
let up2 = resource_dir.join("_up_").join("_up_");
let (resource_pyproject, resource_uvlock, resource_backend) = if flat.join("pyproject.toml").is_file() {
(flat.join("pyproject.toml"), flat.join("uv.lock"), flat.join("backend"))
} else if up2.join("pyproject.toml").is_file() {
(up2.join("pyproject.toml"), up2.join("uv.lock"), up2.join("backend"))
} else {
fail(progress, &format!(
"Missing bootstrap resources — checked flat={} and _up_={}\n pyproject.toml: flat={}, up2={}",
flat.display(), up2.display(),
flat.join("pyproject.toml").display(),
up2.join("pyproject.toml").display()));
return None;
};
if !resource_pyproject.is_file() || !resource_backend.is_dir() {
log::warn!(
fail(progress, &format!(
"Missing bootstrap resources (pyproject={}, backend={})",
resource_pyproject.display(),
resource_backend.display()
);
resource_pyproject.display(), resource_backend.display()));
return None;
}
log::info!("First-run venv bootstrap in {}", project_dir.display());
if let Err(e) = fs::create_dir_all(&project_dir) {
log::error!("mkdir {} failed: {}", project_dir.display(), e);
fail(progress, &format!("mkdir {} failed: {}", project_dir.display(), e));
return None;
}
if let Err(e) = fs::copy(&resource_pyproject, project_dir.join("pyproject.toml")) {
log::error!("copy pyproject.toml: {}", e);
fail(progress, &format!("copy pyproject.toml: {}", e));
return None;
}
if resource_uvlock.is_file() {
let _ = fs::copy(&resource_uvlock, project_dir.join("uv.lock"));
}
if let Err(e) = copy_dir_recursive(&resource_backend, &backend_dir) {
log::error!("copy backend/: {}", e);
fail(progress, &format!("copy backend/: {}", e));
return None;
}
@@ -302,31 +468,42 @@ fn ensure_venv_ready<R: tauri::Runtime>(app: &tauri::App<R>) -> Option<(PathBuf,
// standalone binary into `app_data/tools`.
let uv_path = match Command::new("uv").arg("--version").output() {
Ok(_) => PathBuf::from("uv"),
Err(_) => match install_uv_standalone(&app_data.join("tools")) {
Ok(p) => p,
Err(e) => {
log::error!("uv install failed: {}", e);
return None;
Err(_) => {
if let Some(p) = progress {
set_stage(p, BootstrapStage::DownloadingUv { percent: None });
}
},
match install_uv_standalone(&app_data.join("tools")) {
Ok(p) => p,
Err(e) => {
fail(progress, &format!("uv install failed: {}", e));
return None;
}
}
}
};
log::info!("Bootstrap uv: {}", uv_path.display());
let status = Command::new(&uv_path)
.args(["venv", "--python", "3.11"])
.current_dir(&project_dir)
.status();
if !matches!(status, Ok(s) if s.success()) {
log::error!("uv venv failed: {:?}", status);
if let Some(p) = progress {
set_stage(p, BootstrapStage::CreatingVenv);
}
let mut venv_cmd = Command::new(&uv_path);
venv_cmd.args(["venv", "--python", "3.11"]).current_dir(&project_dir);
let status = run_streaming(app, "creating_venv", &mut venv_cmd);
if !matches!(status, Ok(ref s) if s.success()) {
fail(progress, &format!("uv venv failed: {:?}", status));
return None;
}
let sync_status = Command::new(&uv_path)
.args(["sync", "--frozen", "--no-dev"])
.current_dir(&project_dir)
.status();
if !matches!(sync_status, Ok(s) if s.success()) {
log::error!("uv sync failed: {:?}", sync_status);
if let Some(p) = progress {
set_stage(p, BootstrapStage::InstallingDeps);
}
let mut sync_cmd = Command::new(&uv_path);
sync_cmd
.args(["sync", "--frozen", "--no-dev", "--verbose"])
.current_dir(&project_dir);
let sync_status = run_streaming(app, "installing_deps", &mut sync_cmd);
if !matches!(sync_status, Ok(ref s) if s.success()) {
fail(progress, &format!("uv sync failed: {:?}", sync_status));
return None;
}
@@ -350,16 +527,170 @@ fn find_dev_project_root() -> Option<PathBuf> {
}
fn backend_log_path() -> PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
let log_dir = PathBuf::from(&home).join("Library/Logs/OmniVoice");
// Cross-platform log directory:
// macOS: ~/Library/Logs/OmniVoice
// Linux: $XDG_STATE_HOME/OmniVoice or ~/.local/state/OmniVoice
// Windows: %LOCALAPPDATA%\OmniVoice\Logs
let log_dir = if cfg!(target_os = "macos") {
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
PathBuf::from(home).join("Library/Logs/OmniVoice")
} else if cfg!(target_os = "windows") {
let base = std::env::var("LOCALAPPDATA")
.or_else(|_| std::env::var("USERPROFILE").map(|u| format!("{}\\AppData\\Local", u)))
.unwrap_or_else(|_| "C:\\Temp".to_string());
PathBuf::from(base).join("OmniVoice").join("Logs")
} else {
// Linux / other Unix
let base = std::env::var("XDG_STATE_HOME")
.or_else(|_| std::env::var("HOME").map(|h| format!("{}/.local/state", h)))
.unwrap_or_else(|_| "/tmp".to_string());
PathBuf::from(base).join("OmniVoice")
};
let _ = fs::create_dir_all(&log_dir);
log_dir.join("backend.log")
}
// ── ffmpeg static binary fetch (cross-platform, no extraction) ───────────
//
// We pull a single statically-linked binary per host platform from the
// long-lived `ffmpeg-static` GitHub release (MIT, ffmpeg-6.0). One binary,
// no archive — just download, chmod +x, done. URLs intentionally pinned
// to a specific tag for reproducibility; bump `FFMPEG_TAG` to upgrade.
const FFMPEG_TAG: &str = "b6.0";
fn ffmpeg_download_url() -> Option<&'static str> {
match (std::env::consts::OS, std::env::consts::ARCH) {
("macos", "aarch64") => Some("ffmpeg-darwin-arm64"),
("macos", "x86_64") => Some("ffmpeg-darwin-x64"),
("linux", "x86_64") => Some("ffmpeg-linux-x64"),
("linux", "aarch64") => Some("ffmpeg-linux-arm64"),
("windows", "x86_64") => Some("ffmpeg-win32-x64.exe"),
_ => None,
}
}
/// Download the static ffmpeg binary into `app_data/bin/ffmpeg[.exe]`.
/// Idempotent: if the file exists and is executable, no-ops. Streams byte
/// progress to the splash via `bootstrap-progress`.
fn install_ffmpeg<R: tauri::Runtime>(
app: &tauri::AppHandle<R>,
dest_dir: &Path,
progress: Option<&Arc<Mutex<BootstrapStage>>>,
) -> io::Result<PathBuf> {
let bin_name = if cfg!(windows) { "ffmpeg.exe" } else { "ffmpeg" };
let final_path = dest_dir.join(bin_name);
if final_path.is_file() {
// Treat any non-zero file as good enough — the user can delete it
// to force a re-download. Avoids bullying users on flaky networks.
if let Ok(meta) = fs::metadata(&final_path) {
if meta.len() > 1_000_000 {
return Ok(final_path);
}
}
}
let asset = ffmpeg_download_url().ok_or_else(|| {
io::Error::new(io::ErrorKind::Unsupported, "no ffmpeg binary for this platform")
})?;
let url = format!(
"https://github.com/eugeneware/ffmpeg-static/releases/download/{}/{}",
FFMPEG_TAG, asset
);
fs::create_dir_all(dest_dir)?;
let tmp_path = dest_dir.join(format!("{}.part", bin_name));
let _ = fs::remove_file(&tmp_path);
if let Some(p) = progress {
set_stage(p, BootstrapStage::DownloadingFfmpeg { percent: Some(0) });
}
emit_log(app, "downloading_ffmpeg", &format!("GET {}", url));
let resp = ureq::get(&url)
.timeout(Duration::from_secs(300))
.call()
.map_err(|e| io::Error::new(io::ErrorKind::Other, format!("ffmpeg download: {}", e)))?;
if resp.status() != 200 {
return Err(io::Error::new(
io::ErrorKind::Other,
format!("ffmpeg HTTP {} from {}", resp.status(), url),
));
}
let total: u64 = resp
.header("Content-Length")
.and_then(|v| v.parse().ok())
.unwrap_or(0);
let mut reader = resp.into_reader();
let mut out = fs::File::create(&tmp_path)?;
let mut buf = [0u8; 64 * 1024];
let mut done: u64 = 0;
let mut last_emit = Instant::now();
loop {
let n = reader.read(&mut buf)?;
if n == 0 { break; }
use std::io::Write;
out.write_all(&buf[..n])?;
done += n as u64;
if last_emit.elapsed() > Duration::from_millis(150) {
emit_progress(app, "downloading_ffmpeg", done, total);
if let Some(p) = progress {
let pct = if total > 0 {
Some(((done as f64 / total as f64) * 100.0) as u8)
} else { None };
set_stage(p, BootstrapStage::DownloadingFfmpeg { percent: pct });
}
last_emit = Instant::now();
}
}
drop(out);
emit_progress(app, "downloading_ffmpeg", done, total.max(done));
fs::rename(&tmp_path, &final_path)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&final_path)?.permissions();
perms.set_mode(0o755);
fs::set_permissions(&final_path, perms)?;
}
emit_log(app, "downloading_ffmpeg",
&format!("ffmpeg ready at {} ({} bytes)", final_path.display(), done));
Ok(final_path)
}
/// Resolve the ffmpeg path to inject into the backend env. Order: app-data
/// download (preferred — controlled), bundled resource (legacy), system
/// PATH (None — let the backend find it). Triggers a fresh download into
/// `app_data/bin/` if nothing usable is on disk.
fn ensure_ffmpeg_ready<R: tauri::Runtime>(
app: &tauri::AppHandle<R>,
progress: Option<&Arc<Mutex<BootstrapStage>>>,
) -> Option<PathBuf> {
let app_data = app.path().app_local_data_dir().ok()?;
let bin_dir = app_data.join("bin");
let installed = bin_dir.join(if cfg!(windows) { "ffmpeg.exe" } else { "ffmpeg" });
if installed.is_file() {
return Some(installed);
}
if let Some(bundled) = find_bundled_ffmpeg(app) {
return Some(bundled);
}
match install_ffmpeg(app, &bin_dir, progress) {
Ok(p) => Some(p),
Err(e) => {
emit_log(app, "downloading_ffmpeg", &format!("ffmpeg fetch failed: {}", e));
log::warn!("ffmpeg fetch failed: {} — backend will fall back to system PATH", e);
None
}
}
}
/// Stage the bundled ffmpeg binary and return its absolute path. The path is
/// exported via `OMNIVOICE_FFMPEG` so the Python backend uses it over a
/// system install. Returns None if the bundled binary isn't present.
fn find_bundled_ffmpeg<R: tauri::Runtime>(app: &tauri::App<R>) -> Option<PathBuf> {
fn find_bundled_ffmpeg<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> Option<PathBuf> {
let dir = app.path().resource_dir().ok()?;
let candidates = [
dir.join("bin/ffmpeg"),
@@ -379,7 +710,7 @@ fn find_bundled_ffmpeg<R: tauri::Runtime>(app: &tauri::App<R>) -> Option<PathBuf
// ── Spawn the backend via the bootstrapped venv Python ────────────────────
fn spawn_backend<R: tauri::Runtime>(app: &tauri::App<R>) -> Option<Child> {
fn spawn_backend<R: tauri::Runtime>(app: &tauri::AppHandle<R>, progress: Option<&Arc<Mutex<BootstrapStage>>>) -> Option<Child> {
let log_path = backend_log_path();
let err_path = log_path.with_file_name("backend_err.log");
log::info!(
@@ -388,7 +719,7 @@ fn spawn_backend<R: tauri::Runtime>(app: &tauri::App<R>) -> Option<Child> {
err_path.display(),
);
let (python, backend_dir) = match ensure_venv_ready(app) {
let (python, backend_dir) = match ensure_venv_ready(app, progress) {
Some(x) => x,
None => {
log::error!("Venv bootstrap failed — backend not started");
@@ -396,11 +727,28 @@ fn spawn_backend<R: tauri::Runtime>(app: &tauri::App<R>) -> Option<Child> {
}
};
// Fetch ffmpeg before flipping to StartingBackend so the splash shows
// the real-time download. Failure isn't fatal — we'll fall back to the
// system PATH and let the backend log the missing-ffmpeg error itself.
let ffmpeg_path = ensure_ffmpeg_ready(app, progress);
if let Some(p) = progress {
set_stage(p, BootstrapStage::StartingBackend);
}
let stdout_file = fs::File::create(&log_path).ok();
let stderr_file = fs::File::create(&err_path).ok();
let mut env: Vec<(String, String)> = vec![("PYTHONUNBUFFERED".into(), "1".into())];
if let Some(ff) = find_bundled_ffmpeg(app) {
// Windows: Triton doesn't exist, so torch.compile tries to download it
// and fails. TORCHDYNAMO_DISABLE skips torch.compile entirely. Also
// disable HF symlinks (NTFS symlinks need Developer Mode / admin).
if cfg!(target_os = "windows") {
env.push(("TORCHDYNAMO_DISABLE".into(), "1".into()));
env.push(("HF_HUB_DISABLE_SYMLINKS_WARNING".into(), "1".into()));
env.push(("HF_HUB_DISABLE_SYMLINKS".into(), "1".into()));
}
if let Some(ff) = ffmpeg_path {
env.push(("OMNIVOICE_FFMPEG".into(), ff.to_string_lossy().into_owned()));
let path_sep = if cfg!(windows) { ";" } else { ":" };
env.push((
@@ -428,7 +776,7 @@ fn spawn_backend<R: tauri::Runtime>(app: &tauri::App<R>) -> Option<Child> {
"--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))
@@ -449,15 +797,288 @@ fn spawn_backend<R: tauri::Runtime>(app: &tauri::App<R>) -> Option<Child> {
}
}
// ── 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,
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())?;
app.handle().plugin(tauri_plugin_process::init())?;
app.handle().plugin(tauri_plugin_opener::init())?;
app.handle()
.plugin(tauri_plugin_window_state::Builder::default().build())?;
app.handle().plugin(
@@ -472,37 +1093,92 @@ pub fn run() {
.build(),
)?;
// ── Port-reuse dance ──
// 1. TAURI_SKIP_BACKEND=1 → never spawn (for devs running uvicorn manually).
// 2. Port already serving a healthy OmniVoice backend → attach so
// you can keep a manual `uv run uvicorn` running alongside
// `bun run tauri dev`.
// 3. Otherwise → spawn (kill orphan first if port held by corpse).
// spawn_backend triggers the first-run venv bootstrap if needed.
let skip_spawn = std::env::var("TAURI_SKIP_BACKEND").is_ok();
let child = if skip_spawn {
log::info!("TAURI_SKIP_BACKEND set — not spawning");
None
} else if backend_healthy(BACKEND_PORT) {
log::info!(
"Port {} already serving OmniVoice backend — attaching",
BACKEND_PORT
);
None
} else {
if port_in_use(BACKEND_PORT) {
// ── 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 {
stage: Arc::new(Mutex::new(BootstrapStage::Checking)),
};
let stage_handle = bootstrap.stage.clone();
app.manage(bootstrap);
app.manage(BackendState {
process: Mutex::new(None),
});
// Spawn the bootstrap + backend launch in a background thread so
// setup() returns immediately and the webview can render the
// splash screen. Previously this was synchronous, so on first
// launch the webview was blank for 5-10 minutes.
let app_handle = app.handle().clone();
std::thread::spawn(move || {
let skip_spawn = std::env::var("TAURI_SKIP_BACKEND").is_ok();
if skip_spawn {
log::info!("TAURI_SKIP_BACKEND set — not spawning");
set_stage(&stage_handle, BootstrapStage::Ready);
return;
}
if backend_healthy(backend_port()) {
log::info!(
"Port {} already serving OmniVoice backend — attaching",
backend_port()
);
set_stage(&stage_handle, BootstrapStage::Ready);
return;
}
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));
}
spawn_backend(app)
};
app.manage(BackendState {
process: Mutex::new(child),
let child = spawn_backend(&app_handle, Some(&stage_handle));
if let Ok(mut guard) = app_handle.state::<BackendState>().process.lock() {
*guard = child;
}
// Poll the port until the backend actually responds, then flip
// the splash to Ready. Bounded wait — first-run cold starts
// on Windows can hit 120+ s while torch imports + JIT compiles
// CUDA kernels, so we give it 5 min before declaring failure.
let start = std::time::Instant::now();
while start.elapsed() < Duration::from_secs(300) {
if backend_healthy(backend_port()) {
set_stage(&stage_handle, BootstrapStage::Ready);
return;
}
std::thread::sleep(Duration::from_millis(500));
}
set_stage(
&stage_handle,
BootstrapStage::Failed {
message: "Backend did not respond within 300 s".to_string(),
},
);
});
Ok(())
})
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "OmniVoice Studio",
"version": "0.2.2",
"version": "0.2.4",
"identifier": "com.debpalash.omnivoice-studio",
"build": {
"frontendDist": "../dist",
+140 -65
View File
@@ -17,13 +17,22 @@ 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'));
const EnterprisePage = lazy(() => import('./pages/EnterprisePage'));
import Header from './components/Header';
import NavRail from './components/NavRail';
import ErrorBoundary from './components/ErrorBoundary';
import FloatingPill from './components/FloatingPill';
import useRealtimeEvents from './hooks/useRealtimeEvents';
import { BootstrapSplash, useBootstrapStage } from './components/BootstrapSplash';
const LazyFallback = () => <div style={{ padding: 12, color: '#6b6657', fontSize: '0.7rem' }}>Loading</div>;
import './components/Misc.css';
const LazyFallback = () => <div className="app-lazy-fallback">Loading</div>;
import { Toaster, toast } from 'react-hot-toast';
import ALL_LANGUAGES from './languages.json';
@@ -33,7 +42,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, modelStatus as apiModelStatus } 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';
@@ -153,6 +163,12 @@ const playPing = () => {
};
function App() {
// First-run bootstrap: Rust spawns uv sync in a background thread and
// publishes progress via the `bootstrap_status` Tauri command. Hook below
// polls every 1 s; until `ready`, we render BootstrapSplash instead of the
// normal app shell, so the user sees real progress instead of a hung UI.
const { stage: bootstrapStage, message: bootstrapMessage } = useBootstrapStage();
// UI navigation state now lives in the Zustand `uiSlice` (Phase 2.2).
// Mode + uiScale + sidebar-collapsed persist across reloads automatically
// via the store's `partialize`; active project / voice ids stay transient.
@@ -190,8 +206,8 @@ 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'
|| mode === 'queue' || mode === 'tools' || mode === 'projects';
const hideSidebar = mode === 'launchpad' || mode === 'settings' || mode === 'voice' || mode === 'donate'
|| mode === 'queue' || mode === 'tools' || mode === 'projects' || mode === 'gallery' || mode === 'enterprise';
const availableSidebarTabs = mode === 'dub'
? ['projects', 'history', 'downloads']
: (mode === 'clone' || mode === 'design')
@@ -274,6 +290,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);
@@ -536,11 +556,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
@@ -667,44 +687,27 @@ function App() {
};
}, []);
// sysinfo + modelStatus polling is now handled by TanStack Query hooks
// (useSysinfo / useModelStatus at top of component). No manual setInterval.
// Floating pill for model loading (ASR cold start can take ~120s)
const prevModelStatusRef = useRef(modelStatus);
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));
const prev = prevModelStatusRef.current;
prevModelStatusRef.current = modelStatus;
const pill = useAppStore.getState();
// Only show pill if model transitions to loading and pill isn't already
// showing something more important (e.g. active dubbing).
if (modelStatus === 'loading' && prev !== 'loading' && pill.stage === 'idle') {
pill.showPill('loading-model', 'Loading ASR model…');
}
if (modelStatus === 'ready' && prev === 'loading') {
// Only dismiss if the pill is still showing the model-loading state
if (pill.stage === 'loading-model' && pill.label.includes('ASR')) {
pill.completePill('ASR model ready');
}
};
startPolling();
return () => { cancelled = true; if (interval) clearInterval(interval); };
}, []);
}
}, [modelStatus]);
const loadProfiles = useCallback(async () => {
try { setProfiles(await listProfiles()); } catch (e) {}
@@ -726,6 +729,17 @@ function App() {
try { setExportHistory(await listExportHistory()); } catch (e) {}
}, []);
// Real-time sidebar updates via WebSocket
// Replaces polling the backend pushes an event on every DB mutation and
// we simply re-fetch the affected list. Reconnects automatically.
useRealtimeEvents({
projects: () => loadProjects(),
profiles: () => loadProfiles(),
dub_history: () => loadDubHistory(),
export_history: () => loadExportHistory(),
generation_history: () => loadHistory(),
});
useEffect(() => {
// Wait for backend to come alive before loading data (handles Tauri startup race)
let cancelled = false;
@@ -1312,30 +1326,38 @@ function App() {
const clientJobId = Math.random().toString(36).slice(2, 10);
dubClientJobIdRef.current = clientJobId;
setDubJobId(clientJobId);
useAppStore.getState().showPill('loading-model', 'Preparing video…', { cancellable: true });
try {
const data = await dubUpload(dubVideoFile, clientJobId, { signal: ctrl.signal });
setDubJobId(data.job_id); if (data.filename) setDubFilename(data.filename);
setDubTaskId(data.task_id);
setDubPrepStage('extract');
useAppStore.getState().showPill('loading-model', 'Extracting audio & scenes…', { cancellable: true });
await _waitForPrep(data.task_id, ctrl);
setDubStep('transcribing');
setDubPrepStage(null);
setTranscribeStart(Date.now());
setDubSegments([]);
useAppStore.getState().showPill('transcribing', 'Transcribing audio…', { cancellable: true });
await _waitForTranscribe(data.job_id, ctrl);
setTranscribeStart(null);
setDubStep('editing');
useAppStore.getState().completePill('Transcription complete');
loadProjects(); // refresh sidebar
loadProfiles(); // speaker clones may have been auto-created
} catch (err) {
setDubPrepStage(null);
if (err.name === 'AbortError') {
toast('Upload cancelled');
setDubStep('idle');
useAppStore.getState().dismissPill();
} else {
setDubError(err.message); setDubStep('idle');
toast.error('Upload failed: ' + err.message);
useAppStore.getState().errorPill(err.message);
}
setTranscribeStart(null);
} finally {
@@ -1352,6 +1374,7 @@ function App() {
const clientJobId = Math.random().toString(36).slice(2, 10);
dubClientJobIdRef.current = clientJobId;
setDubJobId(clientJobId);
useAppStore.getState().showPill('loading-model', 'Downloading video…', { cancellable: true });
try {
const data = await dubIngestUrl(clean, clientJobId, {
signal: ctrl.signal,
@@ -1360,26 +1383,33 @@ function App() {
});
setDubJobId(data.job_id);
setDubTaskId(data.task_id);
useAppStore.getState().showPill('loading-model', 'Extracting audio & scenes…', { cancellable: true });
await _waitForPrep(data.task_id, ctrl);
setDubStep('transcribing');
setDubPrepStage(null);
setTranscribeStart(Date.now());
setDubSegments([]);
useAppStore.getState().showPill('transcribing', 'Transcribing audio…', { cancellable: true });
await _waitForTranscribe(data.job_id, ctrl);
setTranscribeStart(null);
setDubStep('editing');
useAppStore.getState().completePill('Transcription complete');
loadProjects(); // refresh sidebar
loadProfiles(); // speaker clones may have been auto-created
toast.success('Ingested ' + clean.slice(0, 60));
} catch (err) {
setDubPrepStage(null);
if (err.name === 'AbortError') {
toast('Ingest cancelled');
setDubStep('idle');
useAppStore.getState().dismissPill();
} else {
setDubError(err.message); setDubStep('idle');
toast.error('URL ingest failed: ' + err.message);
useAppStore.getState().errorPill(err.message);
}
setTranscribeStart(null);
} finally {
@@ -1410,6 +1440,7 @@ function App() {
await _waitForTranscribe(dubJobId, ctrl);
setTranscribeStart(null);
setDubStep('editing');
loadProjects(); // refresh sidebar
} catch (err) {
setTranscribeStart(null);
if (err.name === 'AbortError') {
@@ -1520,6 +1551,8 @@ function App() {
setDubStep('generating');
setDubProgress({ current: 0, total: dubSegments.length, text: '' });
setDubError('');
const genLabel = regenOnly ? `Regenerating ${regenOnly.length} segment${regenOnly.length > 1 ? 's' : ''}` : 'Generating dub…';
useAppStore.getState().showPill('generating', genLabel, { cancellable: true });
try {
const body = {
segment_ids: dubSegments.map(s => String(s.id)),
@@ -1570,7 +1603,12 @@ function App() {
if (line.startsWith('data: ')) {
try {
const evt = JSON.parse(line.slice(6));
if (evt.type === 'progress') setDubProgress({ current: evt.current + 1, total: evt.total, text: evt.text });
if (evt.type === 'progress') {
setDubProgress({ current: evt.current + 1, total: evt.total, text: evt.text });
const pct = Math.round(((evt.current + 1) / evt.total) * 100);
useAppStore.getState().setPillProgress(pct);
useAppStore.getState().setPillLabel(`Generating dub… ${evt.current + 1}/${evt.total}`);
}
else if (evt.type === 'done') {
setDubStep('done');
setDubTracks(evt.tracks || []);
@@ -1621,9 +1659,16 @@ function App() {
if (!wasCancelled) {
if (dubStep !== 'done') setDubStep('done');
loadDubHistory();
loadProjects(); // refresh sidebar with updated project state
playPing();
useAppStore.getState().completePill('Dub complete');
} else {
useAppStore.getState().dismissPill();
}
} catch (err) { setDubError(err.message); setDubStep('editing'); setDubTaskId(null); }
} catch (err) {
setDubError(err.message); setDubStep('editing'); setDubTaskId(null);
useAppStore.getState().errorPill(err.message);
}
};
const handleDubStop = async () => {
@@ -1906,8 +1951,8 @@ function App() {
// flash the empty studio before the wizard has a chance to mount.
if (!setupChecked) {
return (
<div className="app-container sidebar-hidden" style={{ zoom: uiScale, display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh', flexDirection: 'column', gap: 12, color: '#a89984', fontSize: 13 }}>
<div style={{ fontSize: 18, color: '#ebdbb2' }}>OmniVoice Studio</div>
<div className="app-container sidebar-hidden app-startup" style={{ zoom: uiScale }}>
<div className="app-startup__title">OmniVoice Studio</div>
<div>Starting backend</div>
</div>
);
@@ -1918,18 +1963,8 @@ function App() {
// studio layout reserves for the main content column.
return (
<div
style={{
/* Same pattern as .app-container: shrink by whatever the
LogsFooter is currently occupying so it never covers the
wizard footer buttons / content. */
minHeight: 'calc(100vh - var(--logs-footer-height, 28px))',
maxHeight: 'calc(100vh - var(--logs-footer-height, 28px))',
width: '100%',
overflow: 'auto',
zoom: uiScale,
background: 'var(--color-bg, #1d2021)',
position: 'relative',
}}
className="app-wizard-wrap"
style={{ zoom: uiScale }}
>
{/* Invisible drag strip across the top 28 px of the wizard
matches the macOS traffic-light zone so the window can be
@@ -1943,10 +1978,7 @@ function App() {
).catch(() => {});
}
}}
style={{
position: 'fixed', top: 0, left: 0, right: 0,
height: 28, zIndex: 10,
}}
className="app-wizard-dragstrip"
/>
<Suspense fallback={<LazyFallback />}>
<SetupWizard onReady={() => setSetupNeeded(false)} />
@@ -1958,6 +1990,12 @@ function App() {
);
}
// Block the main UI until Rust reports the backend is ready. In dev web
// (no Tauri), the hook returns 'ready' immediately so this is a no-op.
if (bootstrapStage !== 'ready') {
return <BootstrapSplash stage={bootstrapStage} message={bootstrapMessage} />;
}
return (
<div
className={[
@@ -1984,6 +2022,8 @@ function App() {
success: { iconTheme: { primary: '#b8bb26', secondary: '#fff' } }
}}/>
<FloatingPill />
<Header
mode={mode} setMode={setMode}
sysStats={sysStats} modelStatus={modelStatus}
@@ -2048,6 +2088,24 @@ function App() {
/>
</Suspense>
</ErrorBoundary>
) : mode === 'gallery' ? (
<ErrorBoundary name="gallery">
<Suspense fallback={<LazyFallback />}>
<VoiceGallery />
</Suspense>
</ErrorBoundary>
) : mode === 'donate' ? (
<ErrorBoundary name="donate">
<Suspense fallback={<LazyFallback />}>
<DonatePage onBack={() => setMode('launchpad')} onEnterprise={() => setMode('enterprise')} />
</Suspense>
</ErrorBoundary>
) : mode === 'enterprise' ? (
<ErrorBoundary name="enterprise">
<Suspense fallback={<LazyFallback />}>
<EnterprisePage onBack={() => setMode('launchpad')} />
</Suspense>
</ErrorBoundary>
) : mode === 'launchpad' ? (
<ErrorBoundary name="launchpad">
<Suspense fallback={<LazyFallback />}>
@@ -2172,6 +2230,10 @@ function App() {
handleUnlockProfile={handleUnlockProfile}
handleLockProfile={handleLockProfile}
handlePreviewVoice={handlePreviewVoice}
onOpenVoicePreview={(profileId) => {
setVoicePreviewProfileId(profileId || '');
setIsVoicePreviewOpen(true);
}}
restoreHistory={restoreHistory}
restoreDubHistory={restoreDubHistory}
handleSaveHistoryAsProfile={handleSaveHistoryAsProfile}
@@ -2219,6 +2281,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;
+34
View File
@@ -0,0 +1,34 @@
/**
* openExternal open a URL in the user's default browser.
*
* In a Tauri desktop app `window.open()` is blocked by the webview.
* This helper uses `@tauri-apps/plugin-opener` when available and
* falls back to `window.open()` for browser-based dev mode.
*/
const isTauri =
typeof window !== 'undefined' &&
!!((window as any).__TAURI_INTERNALS__ || (window as any).__TAURI__);
let _openUrl: ((url: string) => Promise<void>) | null = null;
/**
* Open an external URL in the system default browser.
* @param {string} url the URL to open
*/
export async function openExternal(url: string) {
if (isTauri) {
try {
if (!_openUrl) {
const mod = await import('@tauri-apps/plugin-opener');
_openUrl = mod.openUrl as (url: string) => Promise<void>;
}
await _openUrl(url);
return;
} catch (err) {
console.warn('[openExternal] Tauri opener failed, falling back:', err);
}
}
// Fallback for browser dev mode
window.open(url, '_blank', 'noopener,noreferrer');
}
+102
View File
@@ -0,0 +1,102 @@
import { apiJson, apiPost, apiFetch } from './client';
export interface GalleryCategory {
id: string;
name: string;
icon: string;
description: string;
}
export interface GalleryVoice {
id: string;
name: string;
character: string;
category: string;
source_type: string;
source_url?: string;
audio_path: string;
duration: number;
description?: string;
thumbnail?: string;
tags: string[];
is_favorite?: boolean;
created_at: number;
}
export const listCategories = (): Promise<GalleryCategory[]> => apiJson('/gallery/categories');
export const listGalleryVoices = (params?: { category?: string; search?: string; limit?: number }): Promise<GalleryVoice[]> => {
const query = params ? '?' + new URLSearchParams(params as Record<string, string>).toString() : '';
return apiJson(`/gallery/voices${query}`);
};
export const getGalleryVoice = (voiceId: string): Promise<GalleryVoice> => apiJson(`/gallery/voices/${voiceId}`);
export const deleteGalleryVoice = (voiceId: string): Promise<{ success: boolean }> =>
apiFetch(`/gallery/voices/${voiceId}`, { method: 'DELETE' }).then(r => r.json());
export interface YoutubeSearchResult {
title: string;
video_id: string;
duration: string | null;
thumbnail: string | null;
}
export const searchYoutube = async (
query: string,
category: string,
maxResults: number = 5
): Promise<{ results: YoutubeSearchResult[]; query: string; category: string }> => {
const url = `/gallery/search/youtube?query=${encodeURIComponent(query)}&category=${encodeURIComponent(category)}&max_results=${maxResults}`;
return apiJson(url, { method: 'POST' });
};
export interface DownloadParams {
video_url: string;
start_time: number;
duration: number;
character_name: string;
category: string;
description?: string;
}
export const downloadYoutubeClip = async (params: DownloadParams): Promise<{ success: boolean; voice_id: string }> => {
const url = `/gallery/download?video_url=${encodeURIComponent(params.video_url)}&start_time=${params.start_time}&duration=${params.duration}&character_name=${encodeURIComponent(params.character_name)}&category=${encodeURIComponent(params.category)}&description=${encodeURIComponent(params.description || '')}`;
return apiJson(url, { method: 'POST' });
};
export const uploadVoiceClip = async (formData: FormData): Promise<{ id: string; name: string }> =>
apiPost('/gallery/upload', formData);
export const saveVoiceAsProfile = async (voiceId: string, profileName: string): Promise<{ profile_id: string; name: string }> => {
const url = `/gallery/voices/${voiceId}/save-as-profile?profile_name=${encodeURIComponent(profileName)}`;
return apiJson(url, { method: 'POST' });
};
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());
+188
View File
@@ -0,0 +1,188 @@
// ── 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: 10_000,
refetchIntervalInBackground: false,
retry: Infinity,
retryDelay: 1_500,
enabled,
});
}
export function useSystemLogs(tail = 300, enabled = true) {
return useQuery({
queryKey: queryKeys.systemLogs(tail),
queryFn: () => systemApi.systemLogs(tail),
refetchInterval: 10_000,
refetchIntervalInBackground: false,
enabled,
});
}
export function useTauriLogs(tail = 300, enabled = true) {
return useQuery({
queryKey: queryKeys.tauriLogs(tail),
queryFn: () => systemApi.systemLogsTauri(tail),
refetchInterval: 10_000,
refetchIntervalInBackground: false,
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() });
},
});
}
+103 -4
View File
@@ -1,31 +1,128 @@
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;
}
// Cache VRAM from Python — it changes much slower than CPU/RAM, so we
// only refresh it every 15s instead of every 5s poll cycle.
let _vramCache: { vram: number; gpu_active: boolean; ts: number } | null = null;
const VRAM_CACHE_TTL = 15_000;
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), enrich with Python's VRAM data
// but only re-fetch every 15s to avoid hammering the backend.
if (rustData.vram === 0) {
const now = Date.now();
if (!_vramCache || now - _vramCache.ts > VRAM_CACHE_TTL) {
try {
const pyData = await apiJson<SysinfoData>('/sysinfo');
_vramCache = { vram: pyData.vram, gpu_active: pyData.gpu_active, ts: now };
} catch {
// Python backend not ready yet — return Rust-only data
return rustData;
}
}
return {
...rustData,
vram: _vramCache.vram,
gpu_active: _vramCache.gpu_active,
};
}
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 +131,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}`);
}
+1 -1
View File
@@ -650,7 +650,7 @@ export default function AudioTrimmer({ file, maxSeconds = 15, onConfirm, onCance
onClick={togglePlay}
disabled={!ready}
leading={playing ? <Pause size={12} /> : <Play size={12} />}
style={{ color: 'var(--color-success)', borderColor: 'rgba(142,192,124,0.3)', background: 'rgba(142,192,124,0.08)' }}
className="audio-trimmer__play-btn"
>
{playing ? 'Pause' : 'Preview selection'}
</Button>
+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>
);
}
+172
View File
@@ -0,0 +1,172 @@
.bootstrap-splash {
position: fixed;
inset: 0;
display: grid;
place-items: center;
background: var(--chrome-bg, #141414);
color: var(--chrome-fg, #eee);
font-family: 'Inter Variable', 'Inter', system-ui, sans-serif;
z-index: 9999;
padding: 2rem;
}
.bootstrap-splash__card {
width: 100%;
max-width: 560px;
background: color-mix(in srgb, var(--chrome-fg, #eee) 4%, transparent);
border: 1px solid color-mix(in srgb, var(--chrome-fg, #eee) 10%, transparent);
border-radius: 14px;
padding: 2rem;
text-align: left;
}
.bootstrap-splash__card h1 {
margin: 0 0 0.5rem;
font-size: 1.25rem;
font-weight: 600;
letter-spacing: -0.01em;
}
.bootstrap-splash__status {
margin: 0 0 1.25rem;
font-size: 0.95rem;
opacity: 0.85;
}
.bootstrap-splash__bar {
height: 4px;
width: 100%;
border-radius: 3px;
background: color-mix(in srgb, var(--chrome-fg, #eee) 8%, transparent);
overflow: hidden;
margin-bottom: 1.25rem;
}
.bootstrap-splash__bar-fill {
height: 100%;
background: var(--chrome-accent, #8ec07c);
transition: width 0.4s ease;
}
.bootstrap-splash__steps {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
font-size: 0.85rem;
}
.bootstrap-splash__steps li {
padding-left: 1.5rem;
position: relative;
opacity: 0.5;
}
.bootstrap-splash__steps li::before {
content: '○';
position: absolute;
left: 0;
top: 0;
}
.bootstrap-splash__steps li.done {
opacity: 0.6;
}
.bootstrap-splash__steps li.done::before {
content: '✓';
color: var(--chrome-accent, #8ec07c);
}
.bootstrap-splash__steps li.active {
opacity: 1;
font-weight: 500;
}
.bootstrap-splash__steps li.active::before {
content: '●';
color: var(--chrome-accent, #8ec07c);
animation: bootstrap-pulse 1.4s ease-in-out infinite;
}
@keyframes bootstrap-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.bootstrap-splash__error {
font-family: 'IBM Plex Mono', ui-monospace, monospace;
font-size: 0.8rem;
background: color-mix(in srgb, #ef4444 12%, transparent);
border: 1px solid color-mix(in srgb, #ef4444 35%, transparent);
color: #fca5a5;
padding: 0.75rem 1rem;
border-radius: 8px;
white-space: pre-wrap;
word-break: break-word;
}
.bootstrap-splash__sub-progress {
margin: -0.5rem 0 1rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.bootstrap-splash__sub-bar {
height: 3px;
width: 100%;
border-radius: 2px;
background: color-mix(in srgb, var(--chrome-fg, #eee) 6%, transparent);
overflow: hidden;
}
.bootstrap-splash__sub-bar-fill {
height: 100%;
background: color-mix(in srgb, var(--chrome-accent, #8ec07c) 70%, transparent);
transition: width 0.2s ease;
}
.bootstrap-splash__sub-label {
font-family: 'IBM Plex Mono', ui-monospace, monospace;
font-size: 0.72rem;
opacity: 0.65;
}
.bootstrap-splash__log-toggle {
margin-top: 1.25rem;
background: none;
border: none;
color: inherit;
opacity: 0.65;
font: inherit;
font-size: 0.78rem;
padding: 0.25rem 0;
cursor: pointer;
text-align: left;
}
.bootstrap-splash__log-toggle:hover { opacity: 1; }
.bootstrap-splash__log-count {
opacity: 0.6;
font-variant-numeric: tabular-nums;
}
.bootstrap-splash__logs {
margin: 0.5rem 0 0;
max-height: 220px;
overflow-y: auto;
font-family: 'IBM Plex Mono', ui-monospace, monospace;
font-size: 0.72rem;
line-height: 1.45;
background: color-mix(in srgb, var(--chrome-fg, #eee) 4%, transparent);
border: 1px solid color-mix(in srgb, var(--chrome-fg, #eee) 8%, transparent);
border-radius: 8px;
padding: 0.6rem 0.75rem;
white-space: pre-wrap;
word-break: break-word;
opacity: 0.85;
}
+220
View File
@@ -0,0 +1,220 @@
/**
* First-run bootstrap splash.
*
* Two data sources drive this UI:
* 1. `bootstrap_status` Tauri command (polled every 1 s) coarse stage.
* 2. `bootstrap-log` + `bootstrap-progress` Tauri events live stdout
* from `uv sync`, ffmpeg byte counts, etc. The log panel shows the
* last N lines so users can see *something* happening during the 510
* min dependency install.
*/
import { useEffect, useRef, useState } from 'react';
import './BootstrapSplash.css';
const STAGE_LABEL = {
checking: 'Checking environment…',
downloading_uv: 'Downloading uv (Python package manager)…',
creating_venv: 'Creating Python virtual environment…',
installing_deps: 'Installing dependencies — first run, 510 min.',
downloading_ffmpeg: 'Downloading ffmpeg…',
starting_backend: 'Starting backend…',
ready: 'Ready',
failed: 'Setup failed',
};
const STEPS = [
'checking',
'downloading_uv',
'creating_venv',
'installing_deps',
'downloading_ffmpeg',
'starting_backend',
];
const MAX_LOG_LINES = 200;
function formatBytes(n) {
if (!n || n < 0) return '';
const units = ['B', 'KB', 'MB', 'GB'];
let i = 0;
let v = n;
while (v >= 1024 && i < units.length - 1) { v /= 1024; i += 1; }
return `${v.toFixed(v < 10 ? 1 : 0)} ${units[i]}`;
}
export function BootstrapSplash({ stage, message }) {
const label = STAGE_LABEL[stage] || stage;
const stepIndex = Math.max(0, STEPS.indexOf(stage));
const isFailed = stage === 'failed';
const [logs, setLogs] = useState([]);
const [logsOpen, setLogsOpen] = useState(false);
const [progress, setProgress] = useState(null); // { stage, bytes_done, bytes_total, percent }
const logRef = useRef(null);
// Subscribe to live log + progress events from the Rust bootstrap.
useEffect(() => {
if (typeof window === 'undefined') return;
if (!('__TAURI_INTERNALS__' in window)) return;
let unlistenLog = null;
let unlistenProgress = null;
let cancelled = false;
(async () => {
try {
const { listen } = await import('@tauri-apps/api/event');
if (cancelled) return;
unlistenLog = await listen('bootstrap-log', (e) => {
const { stage: s, line } = e.payload || {};
if (!line) return;
setLogs((prev) => {
const next = prev.concat([{ stage: s, line, t: Date.now() }]);
return next.length > MAX_LOG_LINES
? next.slice(next.length - MAX_LOG_LINES)
: next;
});
});
unlistenProgress = await listen('bootstrap-progress', (e) => {
setProgress(e.payload || null);
});
} catch {
/* not in Tauri or listen unavailable — silent */
}
})();
return () => {
cancelled = true;
if (unlistenLog) unlistenLog();
if (unlistenProgress) unlistenProgress();
};
}, []);
// Auto-scroll the log panel to the latest line whenever it opens or
// new lines arrive.
useEffect(() => {
if (logsOpen && logRef.current) {
logRef.current.scrollTop = logRef.current.scrollHeight;
}
}, [logs, logsOpen]);
const stageProgress = progress && progress.stage === stage ? progress : null;
const pctFromBytes = stageProgress?.percent != null ? stageProgress.percent : null;
return (
<div className="bootstrap-splash">
<div className="bootstrap-splash__card">
<h1>OmniVoice Studio</h1>
<p className="bootstrap-splash__status">{label}</p>
{isFailed ? (
<pre className="bootstrap-splash__error">{message || 'Unknown error'}</pre>
) : (
<>
<div className="bootstrap-splash__bar">
<div
className="bootstrap-splash__bar-fill"
style={{ width: `${((stepIndex + 1) / STEPS.length) * 100}%` }}
/>
</div>
{stageProgress && (
<div className="bootstrap-splash__sub-progress">
<div className="bootstrap-splash__sub-bar">
<div
className="bootstrap-splash__sub-bar-fill"
style={{ width: `${pctFromBytes ?? 0}%` }}
/>
</div>
<span className="bootstrap-splash__sub-label">
{formatBytes(stageProgress.bytes_done)}
{stageProgress.bytes_total > 0
? ` / ${formatBytes(stageProgress.bytes_total)}`
: ''}
{pctFromBytes != null ? ` (${pctFromBytes}%)` : ''}
</span>
</div>
)}
<ol className="bootstrap-splash__steps">
{STEPS.map((s, i) => (
<li
key={s}
className={
i < stepIndex ? 'done' :
i === stepIndex ? 'active' :
'pending'
}
>
{STAGE_LABEL[s]}
</li>
))}
</ol>
</>
)}
<button
type="button"
className="bootstrap-splash__log-toggle"
onClick={() => setLogsOpen((v) => !v)}
>
{logsOpen ? '▾ Hide logs' : '▸ Show logs'}
{logs.length > 0 && (
<span className="bootstrap-splash__log-count"> ({logs.length})</span>
)}
</button>
{logsOpen && (
<pre className="bootstrap-splash__logs" ref={logRef}>
{logs.length === 0
? 'Waiting for output…'
: logs.map((l, i) => `[${l.stage}] ${l.line}`).join('\n')}
</pre>
)}
</div>
</div>
);
}
/**
* Hook: polls the Rust `bootstrap_status` command every pollMs ms. Returns
* the current stage (string) + message. In a non-Tauri context (dev web),
* returns 'ready' immediately so the splash never mounts.
*/
export function useBootstrapStage(pollMs = 1000) {
const [state, setState] = useState({ stage: 'checking', message: null });
useEffect(() => {
if (typeof window === 'undefined') { setState({ stage: 'ready', message: null }); return; }
if (!('__TAURI_INTERNALS__' in window)) { setState({ stage: 'ready', message: null }); return; }
if (import.meta.env.DEV) { setState({ stage: 'ready', message: null }); return; }
let cancelled = false;
let timer = null;
const invoke = async () => {
try {
const { invoke: tauriInvoke } = await import('@tauri-apps/api/core');
return tauriInvoke;
} catch {
return null;
}
};
(async () => {
const tauriInvoke = await invoke();
if (!tauriInvoke) { setState({ stage: 'ready', message: null }); return; }
const tick = async () => {
if (cancelled) return;
try {
const res = await tauriInvoke('bootstrap_status');
if (cancelled) return;
// Rust returns { stage: 'ready' } or { stage: 'failed', message: '' } etc.
setState({ stage: res.stage || 'ready', message: res.message || null });
if (res.stage !== 'ready' && res.stage !== 'failed') {
timer = setTimeout(tick, pollMs);
}
} catch {
setState({ stage: 'ready', message: null });
}
};
tick();
})();
return () => {
cancelled = true;
if (timer) clearTimeout(timer);
};
}, [pollMs]);
return state;
}
+9 -33
View File
@@ -1,6 +1,7 @@
import React from 'react';
import { CheckCircle, ArrowRight, X, Sparkles, Languages, Mic } from 'lucide-react';
import { Button } from '../ui';
import './Misc.css';
/**
* Phase 4.3 between-stage checkpoint banner.
@@ -50,48 +51,23 @@ export default function CheckpointBanner({ stage, count, onContinue, onDismiss,
return (
<div
className="checkpoint-banner"
style={{
// Accent shows through as a left-edge bar instead of a gradient wash,
// and the fill stays flat chrome so the banner rhymes with the rest
// of the studio strips.
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '8px 12px',
marginBottom: 6,
borderRadius: 'var(--chrome-radius-pill)',
background: 'var(--chrome-bg)',
border: '1px solid var(--chrome-border)',
borderLeft: `2px solid ${cfg.accent}`,
}}
className="checkpoint-banner ckpt-banner"
style={{ borderLeft: `2px solid ${cfg.accent}` }}
role="status"
>
<Icon size={14} color={cfg.accent} style={{ flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 1 }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 6 }}>
<span style={{
fontFamily: 'var(--chrome-font-mono)',
fontSize: 'var(--chrome-label-size)',
letterSpacing: 'var(--chrome-label-track)',
textTransform: 'uppercase',
fontWeight: 600,
color: 'var(--chrome-fg)',
}}>
<Icon size={14} color={cfg.accent} className="ckpt-icon" />
<div className="ckpt-body">
<div className="ckpt-head">
<span className="ckpt-title">
{cfg.title}
</span>
{typeof count === 'number' && (
<span style={{
fontFamily: 'var(--chrome-font-mono)',
fontSize: 'var(--chrome-label-size)',
color: 'var(--chrome-fg-muted)',
fontVariantNumeric: 'tabular-nums',
}}>
<span className="ckpt-count">
{count} segment{count === 1 ? '' : 's'}
</span>
)}
</div>
<span style={{ fontSize: '0.64rem', color: 'var(--chrome-fg-muted)', lineHeight: 1.35 }}>
<span className="ckpt-hint">
{cfg.hint}
</span>
</div>
+1 -1
View File
@@ -121,7 +121,7 @@ export default function CompareModal({
value={compareText}
onChange={e => setCompareText(e.target.value)}
rows={2}
style={{ resize: 'none' }}
className="compare-textarea--noresize"
/>
</Field>
+6 -5
View File
@@ -3,6 +3,7 @@ import { Sparkles, X } from 'lucide-react';
import { toast } from 'react-hot-toast';
import { Dialog, Button, Textarea, Field, Badge } from '../ui';
import { apiPost } from '../api/client';
import './Misc.css';
/**
* DirectionDialog Phase 4.2 per-segment direction editor.
@@ -63,7 +64,7 @@ export default function DirectionDialog({ open, seg, onSave, onClose }) {
variant="ghost" size="sm"
onClick={() => { setText(''); }}
leading={<X size={11} />}
style={{ marginRight: 'auto' }}
className="dir-clear-btn"
>
Clear
</Button>
@@ -91,7 +92,7 @@ export default function DirectionDialog({ open, seg, onSave, onClose }) {
/>
</Field>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginTop: 8 }}>
<div className="dir-preview-actions">
<Button
variant="subtle" size="sm"
onClick={runPreview}
@@ -117,8 +118,8 @@ export default function DirectionDialog({ open, seg, onSave, onClose }) {
</div>
<div>
<strong>Rate bias:</strong> <code>{preview.rate_bias?.toFixed?.(2)}</code>
{preview.rate_bias > 1.05 && <> · <span style={{ color: 'var(--color-brand)' }}>speeds up</span></>}
{preview.rate_bias < 0.95 && <> · <span style={{ color: 'var(--color-info)' }}>slows down</span></>}
{preview.rate_bias > 1.05 && <> · <span className="dir-rate-up">speeds up</span></>}
{preview.rate_bias < 0.95 && <> · <span className="dir-rate-down">slows down</span></>}
</div>
{Object.keys(preview.tokens || {}).length > 0 && (
<details>
@@ -127,7 +128,7 @@ export default function DirectionDialog({ open, seg, onSave, onClose }) {
</details>
)}
{preview.error && (
<div style={{ color: 'var(--color-warn)', fontSize: '0.7rem' }}>
<div className="dir-error">
{preview.error}
</div>
)}
+60
View File
@@ -0,0 +1,60 @@
/* ═══ DubSegmentRow extracted layout styles ═══ */
.seg-check {
width: 16px; flex-shrink: 0; margin-right: 2px; cursor: pointer;
}
.seg-time {
width: 50px; flex-shrink: 0; 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: 45px; flex-shrink: 0; font-size: 0.55rem; color: #a89984;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.seg-text-col {
flex: 1 1 0%; display: flex; flex-direction: column; gap: 2px;
min-width: 80px; overflow: hidden;
}
.seg-text-col .segment-input {
width: 100%; 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: 42px; flex-shrink: 0; font-size: 0.5rem; padding: 1px 2px;
}
.seg-profile-select {
width: 60px; flex-shrink: 0; font-size: 0.55rem; padding: 1px 2px;
overflow: hidden; text-overflow: ellipsis;
}
.seg-gain-slider {
width: 40px !important; max-width: 40px; flex-shrink: 0; flex-grow: 0;
height: 3px; padding: 0; margin: 0;
}
.seg-actions {
display: flex; gap: 1px; width: 42px; flex-shrink: 0;
}
+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 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}
@@ -1,5 +1,10 @@
/* Table body specific styles — chrome comes from ui/Table. */
.dub-segment-table__header {
padding: 3px 4px !important;
gap: 3px !important;
}
.dub-segment-table__body { flex: 1; min-height: 0; }
.dub-segment-table__select-all {
+7 -6
View File
@@ -8,13 +8,13 @@ const BASE_ROW_HEIGHT = 28;
const ROW_HEIGHT_WITH_ORIG = 44;
const COLUMNS = [
{ key: 'time', label: 'Time', width: 55 },
{ key: 'spkr', label: 'Spkr', width: 50 },
{ key: 'time', label: 'Time', width: 50 },
{ key: 'spkr', label: 'Spkr', width: 45 },
{ key: 'text', label: 'Text', flex: 1 },
{ key: 'lang', label: 'Lang', width: 45 },
{ key: 'voice', label: 'Voice', width: 90 },
{ key: 'vol', label: 'Vol', width: 30, title: 'Volume (0200%)' },
{ key: 'act', label: '', width: 54 },
{ key: 'lang', label: 'Lang', width: 42 },
{ key: 'voice', label: 'Voice', width: 60 },
{ key: 'vol', label: 'Vol', width: 40, title: 'Volume (0200%)' },
{ key: 'act', label: '', width: 42 },
];
export default function DubSegmentTable({
@@ -124,6 +124,7 @@ export default function DubSegmentTable({
</Table.Toolbar>
<Table.Header
className="dub-segment-table__header"
columns={COLUMNS}
leading={
<span className="dub-segment-table__select-all">
+8 -31
View File
@@ -1,5 +1,6 @@
import React from 'react';
import { AlertCircle, RefreshCw } from 'lucide-react';
import './WaveformErrorBoundary.css';
export default class ErrorBoundary extends React.Component {
constructor(props) {
@@ -24,43 +25,19 @@ export default class ErrorBoundary extends React.Component {
const msg = this.state.error?.message || String(this.state.error);
return (
<div style={{
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 32, fontFamily: 'var(--font-sans)',
}}>
<div style={{
maxWidth: 520, width: '100%',
padding: 22, textAlign: 'center',
background: 'var(--chrome-bg)',
border: '1px solid color-mix(in srgb, var(--chrome-severity-err) 35%, transparent)',
borderLeft: '2px solid var(--chrome-severity-err)',
borderRadius: 'var(--chrome-radius-pill)',
boxShadow: 'none',
}}>
<AlertCircle size={32} color="var(--chrome-severity-err)" style={{ marginBottom: 10 }} />
<h2 style={{
fontFamily: 'var(--font-serif)', fontStyle: 'italic', fontSize: '1.6rem', fontWeight: 400,
color: 'var(--chrome-fg)', margin: '0 0 6px', letterSpacing: '-0.01em',
}}>
<div className="errbnd-wrap">
<div className="errbnd-card">
<AlertCircle size={32} color="var(--chrome-severity-err)" className="errbnd-icon" />
<h2 className="errbnd-title">
This tab hit a snag.
</h2>
<p style={{ color: 'var(--chrome-fg-muted)', fontSize: '0.82rem', margin: '0 0 12px', lineHeight: 1.5 }}>
<p className="errbnd-desc">
Don't worry the rest of the app still works. You can switch tabs, or try again below.
</p>
<pre style={{
textAlign: 'left', fontSize: '0.72rem', color: 'var(--chrome-severity-err)',
background: 'var(--chrome-hover-bg)', padding: '8px 10px', borderRadius: 'var(--chrome-radius-pill)',
border: '1px solid var(--chrome-border)',
maxHeight: 140, overflow: 'auto', margin: '0 0 14px',
fontFamily: 'var(--font-mono)',
}}>{msg}</pre>
<pre className="errbnd-trace">{msg}</pre>
<button
onClick={this.reset}
className="btn-primary"
style={{
padding: '6px 14px', fontSize: '0.78rem', fontWeight: 500,
display: 'inline-flex', alignItems: 'center', gap: 6,
}}
className="btn-primary errbnd-retry"
>
<RefreshCw size={12} /> Try again
</button>
+26
View File
@@ -334,3 +334,29 @@
display: inline-flex;
gap: var(--space-2);
}
/* ── License notice ─────────────────────────────────────────── */
.export-modal__license-notice {
display: flex;
align-items: center;
gap: 6px;
padding: 5px var(--space-3);
font-family: var(--chrome-font-mono);
font-size: var(--chrome-label-size);
letter-spacing: var(--chrome-label-track);
color: var(--chrome-fg-dim);
border-top: 1px solid var(--chrome-border);
}
.export-modal__license-link {
background: none;
border: none;
color: var(--chrome-accent);
font: inherit;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
padding: 0;
}
.export-modal__license-link:hover {
color: var(--chrome-fg);
}
+8 -1
View File
@@ -2,7 +2,7 @@ import React, { useMemo, useState, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import {
Film, Volume2, FileText, Package, Music, Layers, Download,
Check, Globe, Zap, X,
Check, Globe, Zap, X, Building2,
} from 'lucide-react';
import { Button, Segmented, Badge } from '../ui';
import './ExportModal.css';
@@ -36,6 +36,7 @@ export default function ExportModal({
triggerDownload,
handleDubDownload, handleDubAudioDownload, handleAudioExport,
segmentCount = 0,
onEnterprise,
}) {
const [tab, setTab] = useState('video');
@@ -374,6 +375,12 @@ export default function ExportModal({
)}
</div>
{/* Commercial license notice */}
<div className="export-modal__license-notice">
<Building2 size={11} />
<span>Commercial use requires a <button type="button" className="export-modal__license-link" onClick={() => { onClose(); onEnterprise?.(); }}>license</button>.</span>
</div>
{/* Summary footer */}
<div className="export-modal__summary">
<div className="export-modal__summary-left">
+196
View File
@@ -0,0 +1,196 @@
/* Floating Status Pill
Always-on-top pill that tracks long-running operations (ASR model load,
dubbing progress, export). Inspired by VoiceBox 0.5.0's CapturePill.
*/
.floating-pill {
position: fixed;
top: 48px;
right: 16px;
z-index: var(--z-toast);
display: flex;
align-items: center;
gap: var(--space-4);
padding: var(--space-3) var(--space-5) var(--space-3) var(--space-4);
min-width: 220px;
max-width: 360px;
background: var(--color-bg-elev-1);
backdrop-filter: var(--glass-blur-md);
-webkit-backdrop-filter: var(--glass-blur-md);
border: 1px solid var(--color-border-strong);
border-radius: var(--radius-pill);
box-shadow: var(--shadow-lg), 0 0 0 1px rgba(0,0,0,0.15);
font-family: var(--font-ui);
font-size: var(--text-sm);
color: var(--color-fg);
/* Slide-in animation */
animation: pill-enter 0.35s var(--ease-spring) both;
pointer-events: auto;
user-select: none;
cursor: default;
}
.floating-pill--exiting {
animation: pill-exit 0.25s var(--ease-out) both;
}
@keyframes pill-enter {
from {
opacity: 0;
transform: translateX(24px) scale(0.92);
filter: blur(4px);
}
to {
opacity: 1;
transform: translateX(0) scale(1);
filter: blur(0);
}
}
@keyframes pill-exit {
to {
opacity: 0;
transform: translateX(16px) scale(0.95);
filter: blur(2px);
}
}
@media (prefers-reduced-motion: reduce) {
.floating-pill { animation: none; opacity: 1; }
.floating-pill--exiting { animation: none; opacity: 0; }
}
/* ── Stage indicator dot ────────────────────────────────────────────── */
.floating-pill__dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
animation: pill-dot-pulse 1.5s ease-in-out infinite;
}
.floating-pill__dot--loading-model { background: var(--color-accent); }
.floating-pill__dot--transcribing { background: var(--color-info); }
.floating-pill__dot--translating { background: var(--color-brand); }
.floating-pill__dot--generating { background: var(--color-warn); }
.floating-pill__dot--exporting { background: var(--color-success); }
.floating-pill__dot--refining { background: var(--color-info); }
.floating-pill__dot--recording { background: var(--color-danger); }
.floating-pill__dot--done { background: var(--color-success); animation: none; }
.floating-pill__dot--error { background: var(--color-danger); animation: none; }
@keyframes pill-dot-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.4; transform: scale(0.7); }
}
/* ── Content area ───────────────────────────────────────────────────── */
.floating-pill__content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.floating-pill__label {
font-weight: var(--weight-medium);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.floating-pill__meta {
display: flex;
align-items: center;
gap: var(--space-3);
font-size: var(--text-xs);
color: var(--color-fg-muted);
}
.floating-pill__timer {
font-family: var(--font-mono);
font-size: var(--text-2xs);
color: var(--color-fg-subtle);
letter-spacing: 0.03em;
}
.floating-pill__error {
font-size: var(--text-xs);
color: var(--color-danger);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── Mini progress bar ──────────────────────────────────────────────── */
.floating-pill__progress {
width: 100%;
height: 3px;
border-radius: 2px;
background: var(--color-bg-elev-2);
overflow: hidden;
margin-top: 2px;
}
.floating-pill__progress-fill {
height: 100%;
border-radius: 2px;
background: var(--color-brand);
transition: width 0.3s var(--ease-out);
}
.floating-pill__progress-fill--indeterminate {
width: 40% !important;
animation: pill-progress-sweep 1.4s ease-in-out infinite;
}
@keyframes pill-progress-sweep {
0% { transform: translateX(-100%); }
100% { transform: translateX(300%); }
}
/* ── Dismiss button ─────────────────────────────────────────────────── */
.floating-pill__dismiss {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 50%;
border: none;
background: transparent;
color: var(--color-fg-subtle);
cursor: pointer;
flex-shrink: 0;
transition: background var(--dur-fast) var(--ease-out),
color var(--dur-fast) var(--ease-out);
}
.floating-pill__dismiss:hover {
background: rgba(255,255,255,0.08);
color: var(--color-fg);
}
/* ── Done stage — green tint ────────────────────────────────────────── */
.floating-pill--done {
border-color: rgba(142, 192, 124, 0.25);
}
.floating-pill--done .floating-pill__label {
color: var(--color-success);
}
/* ── Error stage — red tint ─────────────────────────────────────────── */
.floating-pill--error {
border-color: rgba(251, 73, 52, 0.25);
}
+137
View File
@@ -0,0 +1,137 @@
import React, { useEffect, useRef, useState } from 'react';
import { X, CheckCircle, AlertCircle } from 'lucide-react';
import { useAppStore } from '../store';
import './FloatingPill.css';
/**
* FloatingPill always-on-top status indicator for long-running operations.
*
* Inspired by VoiceBox 0.5.0's CapturePill. Walks through a state machine
* (loading-model transcribing translating generating done) with a
* live elapsed timer, mini progress bar, and dismiss button.
*
* Reads entirely from the pillSlice in the Zustand store any part of the
* app can trigger it via `useAppStore.getState().showPill(...)`.
*/
function formatElapsed(ms) {
const secs = Math.floor(ms / 1000);
const mins = Math.floor(secs / 60);
const s = secs % 60;
if (mins > 0) return `${mins}:${String(s).padStart(2, '0')}`;
return `${s}s`;
}
const STAGE_LABELS = {
'loading-model': '🧠',
'recording': '🎙️',
'transcribing': '📝',
'translating': '🌐',
'generating': '🔊',
'exporting': '📦',
'refining': '✨',
'done': '✅',
'error': '❌',
};
export default function FloatingPill() {
const visible = useAppStore(s => s.visible);
const stage = useAppStore(s => s.stage);
const label = useAppStore(s => s.label);
const progress = useAppStore(s => s.progress);
const startedAt = useAppStore(s => s.startedAt);
const error = useAppStore(s => s.error);
const cancellable = useAppStore(s => s.cancellable);
const dismissPill = useAppStore(s => s.dismissPill);
const [elapsed, setElapsed] = useState(0);
const [exiting, setExiting] = useState(false);
const timerRef = useRef(null);
// Elapsed timer
useEffect(() => {
if (!startedAt || stage === 'done' || stage === 'error' || stage === 'idle') {
setElapsed(0);
return;
}
const tick = () => setElapsed(Date.now() - startedAt);
tick();
timerRef.current = setInterval(tick, 1000);
return () => clearInterval(timerRef.current);
}, [startedAt, stage]);
// Handle dismiss with exit animation
const handleDismiss = () => {
setExiting(true);
setTimeout(() => {
setExiting(false);
dismissPill();
}, 250);
};
if (!visible) return null;
const stageEmoji = STAGE_LABELS[stage] || '⏳';
const isDone = stage === 'done';
const isError = stage === 'error';
const isActive = !isDone && !isError && stage !== 'idle';
return (
<div
className={[
'floating-pill',
exiting ? 'floating-pill--exiting' : '',
isDone ? 'floating-pill--done' : '',
isError ? 'floating-pill--error' : '',
].filter(Boolean).join(' ')}
role="status"
aria-live="polite"
>
{/* Stage indicator dot */}
<span className={`floating-pill__dot floating-pill__dot--${stage}`} />
{/* Content */}
<div className="floating-pill__content">
<span className="floating-pill__label">
{stageEmoji} {label}
</span>
{/* Meta row: timer + progress text */}
<div className="floating-pill__meta">
{isActive && elapsed > 0 && (
<span className="floating-pill__timer">{formatElapsed(elapsed)}</span>
)}
{progress !== null && isActive && (
<span>{Math.round(progress)}%</span>
)}
{isError && error && (
<span className="floating-pill__error" title={error}>{error}</span>
)}
</div>
{/* Mini progress bar */}
{isActive && (
<div className="floating-pill__progress">
<div
className={[
'floating-pill__progress-fill',
progress === null ? 'floating-pill__progress-fill--indeterminate' : '',
].filter(Boolean).join(' ')}
style={progress !== null ? { width: `${progress}%` } : undefined}
/>
</div>
)}
</div>
{/* Dismiss / cancel button */}
<button
className="floating-pill__dismiss"
onClick={handleDismiss}
title={cancellable ? 'Cancel' : 'Dismiss'}
aria-label={cancellable ? 'Cancel operation' : 'Dismiss status'}
>
<X size={12} />
</button>
</div>
);
}
+8 -7
View File
@@ -1,14 +1,15 @@
import React, { useState } from 'react';
import { Globe, Fingerprint, Wand2, Film, FolderOpen, RefreshCw, Settings2, ChevronRight, Zap } from 'lucide-react';
import { Globe, Fingerprint, Wand2, Film, FolderOpen, RefreshCw, Settings2, ChevronRight, Zap, Building2 } from 'lucide-react';
import { Button, Badge } from '../ui';
const VIEW_META = {
launchpad: { label: 'Launchpad', Icon: Globe, accent: '#f3a5b6', kicker: 'Studio' },
clone: { label: 'Voice Clone', Icon: Fingerprint, accent: '#d3869b', kicker: 'Studio' },
design: { label: 'Voice Design', Icon: Wand2, accent: '#8ec07c', kicker: 'Studio' },
dub: { label: 'Dubbing', Icon: Film, accent: '#fe8019', kicker: 'Studio' },
projects: { label: 'Projects', Icon: FolderOpen, accent: '#83a598', kicker: 'Library' },
settings: { label: 'Settings', Icon: Settings2, accent: '#fabd2f', kicker: 'Preferences' },
launchpad: { label: 'Launchpad', Icon: Globe, accent: '#f3a5b6', kicker: 'Studio' },
clone: { label: 'Voice Clone', Icon: Fingerprint, accent: '#d3869b', kicker: 'Studio' },
design: { label: 'Voice Design', Icon: Wand2, accent: '#8ec07c', kicker: 'Studio' },
dub: { label: 'Dubbing', Icon: Film, accent: '#fe8019', kicker: 'Studio' },
projects: { label: 'Projects', Icon: FolderOpen, accent: '#83a598', kicker: 'Library' },
settings: { label: 'Settings', Icon: Settings2, accent: '#fabd2f', kicker: 'Preferences' },
enterprise: { label: 'Commercial License', Icon: Building2, accent: '#fe8019', kicker: 'Licensing' },
};
function WaveBars({ color = '#f3a5b6', active }) {
@@ -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>
+63
View File
@@ -62,6 +62,7 @@
gap: 8px;
min-width: 0;
flex: 1;
overflow: hidden;
}
.logs-footer__title {
color: #a89984;
@@ -157,11 +158,73 @@
}
/* Action buttons on the right */
.logs-footer__right {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.logs-footer__actions {
display: flex;
align-items: center;
gap: 2px;
}
/* Discord button */
.logs-footer__discord {
background: none;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
flex-shrink: 0;
border-radius: 4px;
color: #7289da;
opacity: 0.6;
transition: color 0.15s, opacity 0.15s, transform 0.15s;
}
.logs-footer__discord:hover {
opacity: 1;
color: #5865F2;
transform: scale(1.1);
}
/* Glowing donate heart */
.logs-footer__donate {
background: none;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
flex-shrink: 0;
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;
+89 -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';
@@ -115,6 +114,32 @@ function SourcePill({ source, counts, active, onClick }) {
);
}
// Seasonal / random donate heart
// Christmas (Dec), Diwali (~Oct-Nov), Valentine's (Feb), Eid (~Mar-Apr),
// default pool rotates daily based on day-of-year.
const HEART_POOL = ['❤️', '🩷', '💜', '💙', '🧡', '💛', '🩵', '💖', '💗'];
const SEASONAL = [
{ month: 12, emoji: '🎄', color: '#e74c3c', title: 'Merry Christmas! Support this project' },
{ month: 2, emoji: '💝', color: '#ff6b81', title: 'Happy Valentine\'s! Support this project' },
// Diwali window roughly Kartik Amavasya (OctNov)
{ month: 10, emoji: '🪔', color: '#f5a623', title: 'Happy Diwali! Support this project' },
{ month: 11, emoji: '✨', color: '#f5a623', title: 'Happy Diwali! Support this project' },
];
function DonateHeart() {
const now = new Date();
const month = now.getMonth() + 1;
const dayOfYear = Math.floor((now - new Date(now.getFullYear(), 0, 0)) / 86400000);
const seasonal = SEASONAL.find(s => s.month === month);
if (seasonal) {
return <span style={{ fontSize: 14, lineHeight: 1 }} title={seasonal.title}>{seasonal.emoji}</span>;
}
// Rotate through the pool daily
const pick = HEART_POOL[dayOfYear % HEART_POOL.length];
return <span style={{ fontSize: 14, lineHeight: 1 }}>{pick}</span>;
}
export default function LogsFooter() {
// Always start collapsed on every launch per-session toggling works
// but nothing persists. Kill the legacy key on the way out so users
@@ -153,19 +178,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 +205,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 +341,43 @@ 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__discord"
onClick={() => { import('../api/external').then(m => m.openExternal('https://discord.gg/aRRdVj3de7')); }}
title="Join our Discord"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.947 2.418-2.157 2.418z"/></svg>
</button>
<button
type="button"
className="logs-footer__donate"
onClick={() => useAppStore.getState().setMode?.('donate')}
title="Support this project"
>
<DonateHeart />
</button>
</div>
</div>
{!collapsed && (
+78
View File
@@ -0,0 +1,78 @@
/* ═══ CheckpointBanner extracted styles ═══ */
.ckpt-banner {
display: flex; align-items: center; gap: 10px;
padding: 8px 12px; margin-bottom: 6px;
border-radius: var(--chrome-radius-pill);
background: var(--chrome-bg);
border: 1px solid var(--chrome-border);
}
.ckpt-icon { flex-shrink: 0; }
.ckpt-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1px; }
.ckpt-head { display: flex; align-items: baseline; gap: 6px; }
.ckpt-title {
font-family: var(--chrome-font-mono);
font-size: var(--chrome-label-size);
letter-spacing: var(--chrome-label-track);
text-transform: uppercase;
font-weight: 600;
color: var(--chrome-fg);
}
.ckpt-count {
font-family: var(--chrome-font-mono);
font-size: var(--chrome-label-size);
color: var(--chrome-fg-muted);
font-variant-numeric: tabular-nums;
}
.ckpt-hint { font-size: 0.64rem; color: var(--chrome-fg-muted); line-height: 1.35; }
/* ═══ DirectionDialog extracted styles ═══ */
.dir-preview-actions { display: flex; gap: 8px; align-items: center; margin-top: 8px; }
.dir-clear-btn { margin-right: auto; }
.dir-rate-up { color: var(--color-brand); }
.dir-rate-down { color: var(--color-info); }
.dir-error { color: var(--color-warn); font-size: 0.7rem; }
/* ═══ SetupWizard preflight extracted styles ═══ */
.swiz-loading {
display: flex; gap: 8px; align-items: center;
justify-content: center; padding: 20px;
color: var(--color-fg-muted);
}
.swiz-checklist { display: flex; flex-direction: column; gap: 6px; }
.swiz-check-icon { flex-shrink: 0; padding-top: 2px; }
.swiz-check-footer { display: flex; justify-content: flex-end; padding-top: 4px; }
.swiz-missing { text-align: center; font-size: 0.78rem; margin: 0; }
.swiz-status-loading {
display: flex; gap: 8px; align-items: center;
justify-content: center; color: var(--color-fg-muted);
}
/* ═══ App.jsx startup / wizard extracted styles ═══ */
.app-startup {
display: flex; align-items: center; justify-content: center;
min-height: 100vh; flex-direction: column; gap: 12px;
color: #a89984; font-size: 13px;
}
.app-startup__title { font-size: 18px; color: #ebdbb2; }
.app-wizard-wrap {
min-height: calc(100vh - var(--logs-footer-height, 28px));
max-height: calc(100vh - var(--logs-footer-height, 28px));
width: 100%; overflow: hidden;
background: var(--color-bg, #1d2021);
position: relative; display: flex; flex-direction: column;
}
.app-wizard-dragstrip {
position: fixed; top: 0; left: 0; right: 0;
height: 28px; z-index: 10;
}
.app-lazy-fallback { padding: 12px; color: #6b6657; font-size: 0.7rem; }
/* ═══ CompareModal extracted ═══ */
.compare-textarea--noresize { resize: none; }
/* ═══ AudioTrimmer play button ═══ */
.audio-trimmer__play-btn {
color: var(--color-success);
border-color: rgba(142,192,124,0.3);
background: rgba(142,192,124,0.08);
}
+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: var(--z-overlay);
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>
);
}
+2
View File
@@ -1,6 +1,7 @@
import React from 'react';
import {
Globe, Fingerprint, Wand2, Film, FolderOpen, Settings2, ArrowLeftRight,
Library,
} from 'lucide-react';
const ITEMS = [
@@ -8,6 +9,7 @@ const ITEMS = [
{ id: 'clone', label: 'Clone', Icon: Fingerprint, accent: '#d3869b' },
{ id: 'design', label: 'Design', Icon: Wand2, accent: '#8ec07c' },
{ id: 'dub', label: 'Dub', Icon: Film, accent: '#fe8019' },
{ id: 'gallery', label: 'Gallery', Icon: Library, accent: '#b8bb26' },
{ id: 'projects', label: 'Projects', Icon: FolderOpen, accent: '#83a598' },
];
const FOOTER_ITEMS = [
@@ -0,0 +1,112 @@
/* Readiness Checklist
VoiceBox-style system readiness panel showing pass/warn/fail gates
for ASR model, TTS engine, LLM, ffmpeg, GPU, etc.
*/
.readiness-checklist {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-5);
background: var(--color-bg-elev-1);
backdrop-filter: var(--glass-blur-sm);
-webkit-backdrop-filter: var(--glass-blur-sm);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
font-family: var(--font-ui);
font-size: var(--text-sm);
}
.readiness-checklist__title {
font-weight: var(--weight-semibold);
font-size: var(--text-md);
color: var(--color-fg);
margin: 0 0 var(--space-2) 0;
display: flex;
align-items: center;
gap: var(--space-3);
}
.readiness-checklist__title-icon {
font-size: var(--text-lg);
}
.readiness-checklist__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.readiness-checklist__item {
display: flex;
align-items: flex-start;
gap: var(--space-3);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-sm);
transition: background var(--dur-fast) var(--ease-out);
}
.readiness-checklist__item:hover {
background: var(--color-bg-elev-3);
}
/* ── Status icons ───────────────────────────────────────────────────── */
.readiness-checklist__status {
flex-shrink: 0;
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
margin-top: 1px;
}
.readiness-checklist__status--pass { color: var(--color-success); }
.readiness-checklist__status--warn { color: var(--color-accent); }
.readiness-checklist__status--fail { color: var(--color-danger); }
.readiness-checklist__status--loading {
color: var(--color-fg-subtle);
animation: rc-spin 1s linear infinite;
}
@keyframes rc-spin {
to { transform: rotate(360deg); }
}
/* ── Content ────────────────────────────────────────────────────────── */
.readiness-checklist__label {
font-weight: var(--weight-medium);
color: var(--color-fg);
}
.readiness-checklist__detail {
font-size: var(--text-xs);
color: var(--color-fg-muted);
margin-top: 1px;
}
.readiness-checklist__fix {
font-size: var(--text-xs);
color: var(--color-accent);
margin-top: 2px;
}
/* ── Compact summary when all pass ──────────────────────────────────── */
.readiness-checklist__all-pass {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
background: rgba(142, 192, 124, 0.08);
border: 1px solid rgba(142, 192, 124, 0.15);
border-radius: var(--radius-md);
color: var(--color-success);
font-weight: var(--weight-medium);
font-size: var(--text-sm);
}
@@ -0,0 +1,155 @@
import React from 'react';
import { CheckCircle, AlertTriangle, XCircle, Loader } from 'lucide-react';
import { usePreflight, useModelStatus } from '../api/hooks';
import './ReadinessChecklist.css';
/**
* ReadinessChecklist VoiceBox-style system readiness panel.
*
* Consumes the existing /setup/preflight endpoint (OS, RAM, GPU, ffmpeg,
* yt-dlp, network) plus /model/status, and renders a compact pass/warn/fail
* checklist. Mirrors into Settings and renders as empty-state on the
* launchpad when no project is loaded.
*
* Hides itself when all gates are green (user doesn't need to see
* "everything is fine" every time they open the app).
*/
const StatusIcon = ({ status, size = 14 }) => {
switch (status) {
case 'pass': return <CheckCircle size={size} />;
case 'warn': return <AlertTriangle size={size} />;
case 'fail': return <XCircle size={size} />;
case 'loading': return <Loader size={size} />;
default: return <Loader size={size} />;
}
};
export default function ReadinessChecklist({ compact = false, showWhenAllPass = false }) {
const { data: preflight, isLoading: preflightLoading } = usePreflight();
const { data: modelData, isLoading: modelLoading } = useModelStatus();
const isLoading = preflightLoading || modelLoading;
const modelStatus = modelData?.status ?? 'idle';
// Build the checklist from preflight data + model status
const checks = [];
// Model readiness (from /model/status)
const modelCheck = {
id: 'asr-model',
label: 'ASR Model',
status: modelStatus === 'ready' ? 'pass'
: modelStatus === 'loading' ? 'loading'
: modelStatus === 'error' ? 'fail'
: 'warn',
detail: modelStatus === 'ready' ? 'Loaded and ready'
: modelStatus === 'loading' ? 'Loading… (this may take 1-2 minutes on first run)'
: modelStatus === 'error' ? 'Failed to load'
: 'Not loaded yet — will load on first transcription',
fix: modelStatus === 'error' ? 'Check logs for model loading errors. Try restarting.' : null,
};
checks.push(modelCheck);
// Add preflight checks
if (preflight?.checks) {
// Filter to the most relevant checks for the checklist
const relevant = ['gpu', 'ffmpeg', 'yt-dlp', 'ram'];
for (const check of preflight.checks) {
if (relevant.includes(check.id)) {
checks.push(check);
}
}
}
// LLM configuration (check for translate endpoint)
const llmCheck = {
id: 'llm',
label: 'LLM (Cinematic)',
status: 'warn',
detail: 'Configure TRANSLATE_BASE_URL for Cinematic translation quality',
fix: 'Set TRANSLATE_BASE_URL and TRANSLATE_API_KEY environment variables. Works with Ollama, OpenAI, LM Studio, etc.',
};
// If we have preflight and there's a network check passing, LLM is at least possible
if (preflight?.checks) {
const netCheck = preflight.checks.find(c => c.id === 'network');
if (netCheck?.status === 'pass') {
llmCheck.detail = 'Optional — set TRANSLATE_BASE_URL for Cinematic quality';
}
}
checks.push(llmCheck);
// Determine if all critical checks pass
const allPass = checks.every(c => c.status === 'pass' || c.status === 'warn');
const anyFail = checks.some(c => c.status === 'fail');
const criticalFails = checks.filter(c => c.status === 'fail');
// Hide when everything is fine (unless explicitly asked to show)
if (!showWhenAllPass && allPass && !isLoading) return null;
if (isLoading) {
return (
<div className="readiness-checklist">
<div className="readiness-checklist__title">
<span className="readiness-checklist__title-icon">🔍</span>
Checking system
</div>
</div>
);
}
if (compact) {
// Compact mode: just show failing/warning items
const issues = checks.filter(c => c.status !== 'pass');
if (issues.length === 0) {
return (
<div className="readiness-checklist__all-pass">
<CheckCircle size={14} />
All systems ready
</div>
);
}
return (
<div className="readiness-checklist">
<ul className="readiness-checklist__list">
{issues.map(check => (
<li key={check.id} className="readiness-checklist__item">
<span className={`readiness-checklist__status readiness-checklist__status--${check.status}`}>
<StatusIcon status={check.status} />
</span>
<div>
<div className="readiness-checklist__label">{check.label}</div>
{check.fix && <div className="readiness-checklist__fix">{check.fix}</div>}
</div>
</li>
))}
</ul>
</div>
);
}
return (
<div className="readiness-checklist">
<div className="readiness-checklist__title">
<span className="readiness-checklist__title-icon">
{anyFail ? '⚠️' : '✅'}
</span>
System Readiness
</div>
<ul className="readiness-checklist__list">
{checks.map(check => (
<li key={check.id} className="readiness-checklist__item">
<span className={`readiness-checklist__status readiness-checklist__status--${check.status}`}>
<StatusIcon status={check.status} />
</span>
<div>
<div className="readiness-checklist__label">{check.label}</div>
<div className="readiness-checklist__detail">{check.detail}</div>
{check.fix && <div className="readiness-checklist__fix">💡 {check.fix}</div>}
</div>
</li>
))}
</ul>
</div>
);
}
+5
View File
@@ -111,6 +111,11 @@
.sidebar__empty-title { font-size: 0.82rem; margin: 0 0 var(--space-2); color: var(--chrome-fg); font-weight: 500; letter-spacing: 0.02em; }
.sidebar__empty-sub { font-size: 0.7rem; margin: 0; color: var(--chrome-fg-dim); line-height: 1.5; }
/* Hide text-heavy blocks when sidebar is collapsed to icon-only width */
.sidebar.is-collapsed .sidebar__empty { display: none; }
.sidebar.is-collapsed .sidebar__subtitle { display: none; }
.sidebar.is-collapsed .sidebar__search { display: none; }
/* Section label matches the status-bar's "LOGS" uppercase treatment.
Labels are displays (not buttons) but this one IS clickable (collapses
the section), so the chevron on the right signals interactivity while
+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>
);
}
@@ -0,0 +1,69 @@
/* ═══ WaveformTimeline extracted layout styles ═══ */
.wfm-layout {
display: flex; flex-direction: column; flex: 1; min-height: 0;
}
.wfm-stack { display: flex; flex-direction: column; gap: 4px; flex: 1; min-height: 0; }
.wfm-video-preview {
flex: 0 0 auto; aspect-ratio: 16 / 9; max-height: 55%;
background: #000; border-radius: 4px; overflow: hidden;
border: 1px solid rgba(255,255,255,0.05); display: flex;
}
.wfm-wave-wrap {
position: relative; overflow: hidden; flex: 1 1 auto; min-height: 140px;
}
.wfm-wave-inner {
height: 100%; min-height: 140px; border-radius: 4px; width: 100%; overflow: hidden;
}
.wfm-loading {
position: absolute; inset: 0; display: flex; align-items: center;
justify-content: center; background: rgba(0,0,0,0.45);
border-radius: 4px; z-index: 3; gap: 6px;
}
.wfm-loading__text { font-size: 0.65rem; color: #a89984; }
.wfm-overlay {
position: absolute; inset: 0; border-radius: 4px; z-index: 4;
background: rgba(29,32,33,0.85); backdrop-filter: blur(3px);
display: flex; flex-direction: column; align-items: center;
justify-content: center; gap: 6px; padding: 8px;
}
.wfm-controls { flex-shrink: 0; margin-top: 3px; }
.wfm-error {
display: flex; align-items: center; justify-content: center;
padding: 8px; background: rgba(0,0,0,0.15); border-radius: 4px;
border: 1px solid rgba(255,255,255,0.04); color: #a89984; font-size: 0.7rem;
}
/* ═══ ErrorBoundary extracted styles ═══ */
.errbnd-wrap {
flex: 1; display: flex; align-items: center; justify-content: center;
padding: 32px; font-family: var(--font-sans);
}
.errbnd-card {
max-width: 520px; width: 100%; padding: 22px; text-align: center;
background: var(--chrome-bg);
border: 1px solid color-mix(in srgb, var(--chrome-severity-err) 35%, transparent);
border-left: 2px solid var(--chrome-severity-err);
border-radius: var(--chrome-radius-pill);
}
.errbnd-icon { margin-bottom: 10px; }
.errbnd-title {
font-family: var(--font-serif); font-style: italic;
font-size: 1.6rem; font-weight: 400;
color: var(--chrome-fg); margin: 0 0 6px; letter-spacing: -0.01em;
}
.errbnd-desc {
color: var(--chrome-fg-muted); font-size: 0.82rem;
margin: 0 0 12px; line-height: 1.5;
}
.errbnd-trace {
text-align: left; font-size: 0.72rem; color: var(--chrome-severity-err);
background: var(--chrome-hover-bg); padding: 8px 10px;
border-radius: var(--chrome-radius-pill);
border: 1px solid var(--chrome-border);
max-height: 140px; overflow: auto; margin: 0 0 14px;
font-family: var(--font-mono);
}
.errbnd-retry {
padding: 6px 14px; font-size: 0.78rem; font-weight: 500;
display: inline-flex; align-items: center; gap: 6px;
}
+18 -29
View File
@@ -2,6 +2,7 @@ import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react'
import WaveSurfer from 'wavesurfer.js';
import RegionsPlugin from 'wavesurfer.js/dist/plugins/regions.esm.js';
import { Play, Pause, ZoomIn, ZoomOut, SkipBack, Loader } from 'lucide-react';
import './WaveformErrorBoundary.css';
const REGION_COLORS = [
'rgba(211,134,155,0.3)',
@@ -266,7 +267,13 @@ export default function WaveformTimeline({
// Zoom
useEffect(() => {
if (wsRef.current && ready) wsRef.current.zoom(zoom);
if (wsRef.current && ready) {
try {
wsRef.current.zoom(zoom);
} catch (err) {
console.warn('WaveSurfer zoom failed:', err);
}
}
}, [zoom, ready]);
// Sync regions skips when dragging or fingerprint unchanged
@@ -325,11 +332,7 @@ export default function WaveformTimeline({
if (loadError) {
return (
<div className="waveform-timeline">
<div style={{
display:'flex', alignItems:'center', justifyContent:'center',
padding:8, background:'rgba(0,0,0,0.15)', borderRadius:4,
border:'1px solid rgba(255,255,255,0.04)', color:'#a89984', fontSize:'0.7rem',
}}>
<div className="wfm-error">
Could not load audio from this file
</div>
</div>
@@ -337,50 +340,36 @@ export default function WaveformTimeline({
}
return (
<div className="waveform-timeline" style={{display:'flex', flexDirection:'column', flex:1, minHeight:0}}>
<div className="waveform-timeline wfm-layout">
{/* Video + Waveform stacked vertically */}
<div style={{display:'flex', flexDirection:'column', gap:4, flex:1, minHeight:0}}>
<div className="wfm-stack">
{/* Video preview pinned to its aspect ratio so we don't letterbox
into huge black bars. Waveform gets the remaining height. */}
{videoSrc && (
<div
ref={videoContainerRef}
style={{
flex:'0 0 auto', aspectRatio:'16 / 9', maxHeight:'55%',
background:'#000', borderRadius:4, overflow:'hidden',
border:'1px solid rgba(255,255,255,0.05)', display: 'flex',
}}
className="wfm-video-preview"
/>
)}
{/* Waveform — fills the rest. This is the actual editing surface. */}
<div style={{position:'relative', overflow:'hidden', flex:'1 1 auto', minHeight:140}}>
<div className="wfm-wave-wrap">
<div
ref={waveContainerRef}
className="waveform-container"
style={{height:'100%', minHeight:140, borderRadius:4, width:'100%', overflow:'hidden'}}
className="waveform-container wfm-wave-inner"
/>
{/* Loading shimmer */}
{!ready && !loadError && (
<div style={{
position:'absolute', inset:0, display:'flex', alignItems:'center',
justifyContent:'center', background:'rgba(0,0,0,0.45)',
borderRadius:4, zIndex:3, gap:6,
}}>
<div className="wfm-loading">
<Loader className="spinner" size={12} color="#d3869b"/>
<span style={{fontSize:'0.65rem', color:'#a89984'}}>Loading waveform</span>
<span className="wfm-loading__text">Loading waveform</span>
</div>
)}
{/* Overlay slot — transcription / dubbing progress */}
{overlayContent && (
<div style={{
position:'absolute', inset:0, borderRadius:4, zIndex:4,
background:'rgba(29,32,33,0.85)', backdropFilter:'blur(3px)',
display:'flex', flexDirection:'column', alignItems:'center',
justifyContent:'center', gap:6, padding:8,
}}>
<div className="wfm-overlay">
{overlayContent}
</div>
)}
@@ -388,7 +377,7 @@ export default function WaveformTimeline({
</div>
{/* Controls */}
<div className="waveform-controls" style={{flexShrink:0, marginTop:3}}>
<div className="waveform-controls wfm-controls">
<div className="waveform-controls-left">
<button className="waveform-btn" onClick={() => seekTo(0)} title="Restart"><SkipBack size={11}/></button>
<button className="waveform-btn waveform-btn-play" onClick={togglePlay} disabled={!ready}>
+106
View File
@@ -0,0 +1,106 @@
/**
* useRealtimeEvents WebSocket connection to /ws/events for live sidebar updates.
*
* Connects once on mount, automatically reconnects with exponential backoff,
* and dispatches invalidation signals to the parent callbacks.
*
* Events from backend:
* { kind: "projects", action: "created"|"updated"|"deleted", id: "..." }
* { kind: "profiles", action: "created"|"updated"|"locked"|"unlocked"|"deleted", id: "..." }
* { kind: "dub_history", action: "saved"|"deleted", id: "..." }
* { kind: "export_history", action: "exported"|"recorded", id: "..." }
* { kind: "ping" } // keepalive, ignored
*/
import { useEffect, useRef, useCallback } from 'react';
import { API } from '../api/client';
const WS_EVENTS_URL = API.replace(/^http/, 'ws') + '/ws/events';
/**
* @param {Object} handlers - Map of event kind callback
* @param {Function} handlers.projects - Called when projects list changes
* @param {Function} handlers.profiles - Called when profiles list changes
* @param {Function} handlers.dub_history - Called when dub history changes
* @param {Function} handlers.export_history - Called when export history changes
*/
export default function useRealtimeEvents(handlers) {
const wsRef = useRef(null);
const handlersRef = useRef(handlers);
const reconnectTimerRef = useRef(null);
const retryCountRef = useRef(0);
const mountedRef = useRef(true);
// Keep handlers ref current without causing reconnects
useEffect(() => { handlersRef.current = handlers; });
const connect = useCallback(() => {
if (!mountedRef.current) return;
// Don't double-connect
if (wsRef.current && wsRef.current.readyState <= 1) return;
try {
const ws = new WebSocket(WS_EVENTS_URL);
wsRef.current = ws;
ws.onopen = () => {
retryCountRef.current = 0;
console.debug('[ws/events] connected');
};
ws.onmessage = (e) => {
try {
const event = JSON.parse(e.data);
const kind = event.kind;
if (kind === 'ping') return; // keepalive, ignore
const handler = handlersRef.current?.[kind];
if (handler) {
handler(event);
}
} catch (err) {
console.warn('[ws/events] bad message:', e.data, err);
}
};
ws.onclose = (e) => {
wsRef.current = null;
if (!mountedRef.current) return;
// Exponential backoff: 2s, 4s, 8s, 16s, max 60s
const delay = Math.min(2000 * Math.pow(2, retryCountRef.current), 60_000);
retryCountRef.current++;
if (retryCountRef.current <= 5) {
console.debug(`[ws/events] closed (code=${e.code}), reconnecting in ${delay}ms`);
}
reconnectTimerRef.current = setTimeout(connect, delay);
};
ws.onerror = () => {
// onerror is always followed by onclose, so we just let onclose handle reconnect
ws.close();
};
} catch (err) {
console.warn('[ws/events] connection failed:', err);
const delay = Math.min(1000 * Math.pow(2, retryCountRef.current), 30_000);
retryCountRef.current++;
reconnectTimerRef.current = setTimeout(connect, delay);
}
}, []);
useEffect(() => {
mountedRef.current = true;
connect();
return () => {
mountedRef.current = false;
if (reconnectTimerRef.current) {
clearTimeout(reconnectTimerRef.current);
reconnectTimerRef.current = null;
}
if (wsRef.current) {
wsRef.current.onclose = null; // prevent reconnect on unmount
wsRef.current.close();
wsRef.current = null;
}
};
}, [connect]);
}
+101 -3
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. */
@@ -710,8 +757,9 @@ audio::-webkit-media-controls-time-remaining-display { color: var(--chrome-fg);
text-transform: uppercase;
}
.segment-row {
display: flex; align-items: center; gap: 4px;
padding: 2px 6px;
display: flex; align-items: center; gap: 3px;
padding: 2px 4px;
box-sizing: border-box;
border-bottom: 1px solid rgba(255,255,255,0.02);
transition: background var(--transition-fast);
}
@@ -730,7 +778,7 @@ audio::-webkit-media-controls-time-remaining-display { color: var(--chrome-fg);
box-shadow: inset 2px 0 0 var(--chrome-accent);
}
.segment-time {
width: 60px; flex-shrink: 0;
width: 48px; flex-shrink: 0;
font-size: 0.62rem; font-family: var(--chrome-font-mono); color: var(--chrome-fg-muted);
font-variant-numeric: tabular-nums;
}
@@ -1192,6 +1240,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();

Some files were not shown because too many files have changed in this diff Show More