Compare commits

..
24 Commits
Author SHA1 Message Date
debpalash 9616ca67c2 Merge feat/frameless-dictation-widget: v0.2.7 — frameless dictation widget, GHCR Docker
# Conflicts:
#	README.md
2026-05-03 09:05:33 +05:30
debpalash cfab2a500a feat: add GHCR Docker workflow, update README with container registry instructions
- New .github/workflows/docker.yml publishes images to ghcr.io on tag push
- README Docker section now leads with 'docker pull' from GHCR
- docker-compose.yml defaults to GHCR image with build-from-source fallback
- Dockerfile: copy README.md for hatchling metadata resolution
2026-05-03 09:04:49 +05:30
6277561639 feat: implement frameless OS-level floating dictation widget (#40)
* feat: implement frameless OS-level floating dictation widget

- Refactor CaptureButton into standalone CaptureWidget
- Add secondary transparent Tauri window configuration
- Map global hotkey to show/hide widget instead of focusing main app
- Implement auto-hide post-paste
- Add social preview image

* docs: up the game with enhanced README

- Use the high-quality social preview image as the hero image
- Bump download release links to v0.2.7
- Highlight the new Frameless Dictation Widget feature

* docs: complete README overhaul for maximum virality

- Add Highlights section with 2-column feature grid
- Move Quickstart to top with one-command install
- Collapse technical details into expandable sections
- Add 'Up Next' roadmap with concrete upcoming features
- Add star call-to-action banner
- Tighten navigation links and section hierarchy

* docs: add beta warning banner

* docs: add star request to beta banner

* docs: rewrite README with cognitive hooks, remove redundant CTAs

- Remove 2 premature star asks (beta banner + highlights)
- Rewrite highlights with loss-aversion framing
- Keep single earned CTA at the very bottom
- Use action-oriented headings that describe outcomes

* docs: rename section to 'Why OmniVoice Studio?'

* docs: rename 'What you get' to 'Features'

* docs: concise scannable features, remove duplicate section

- Each feature is one punchy emoji-led line
- No verbose paragraphs, no redundant collapsibles
- Removed duplicate Features section from merge

* docs: 3-column feature card grid for visual impact

Replaces flat bullet list with 4x3 HTML table grid.
Each feature gets its own visual cell with emoji header,
bold keywords, and 2-line description. Pops on dark mode.

* docs: fix feature grid vertical alignment

* chore: bump version to 0.2.7, add changelog entry

* fix: apply CodeRabbit auto-fixes

Fixed 1 file(s) based on 1 unresolved review comment.

Co-authored-by: CodeRabbit <noreply@coderabbit.ai>

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
2026-05-03 08:50:34 +05:30
debpalash a7ef134175 chore: bump version to 0.2.7, add changelog entry 2026-05-03 08:13:46 +05:30
debpalash da0ad09db0 docs: fix feature grid vertical alignment 2026-05-03 08:00:34 +05:30
debpalash bc4e0a977c docs: 3-column feature card grid for visual impact
Replaces flat bullet list with 4x3 HTML table grid.
Each feature gets its own visual cell with emoji header,
bold keywords, and 2-line description. Pops on dark mode.
2026-05-03 07:55:51 +05:30
debpalash 97bfbfb331 docs: concise scannable features, remove duplicate section
- Each feature is one punchy emoji-led line
- No verbose paragraphs, no redundant collapsibles
- Removed duplicate Features section from merge
2026-05-03 07:49:48 +05:30
debpalash abbaf508e0 docs: rename 'What you get' to 'Features' 2026-05-03 07:44:58 +05:30
debpalash 043e52a285 docs: rename section to 'Why OmniVoice Studio?' 2026-05-03 07:44:18 +05:30
debpalash c47ade780a docs: rewrite README with cognitive hooks, remove redundant CTAs
- Remove 2 premature star asks (beta banner + highlights)
- Rewrite highlights with loss-aversion framing
- Keep single earned CTA at the very bottom
- Use action-oriented headings that describe outcomes
2026-05-03 07:43:16 +05:30
debpalash 9e80643053 docs: add star request to beta banner 2026-05-03 07:40:48 +05:30
debpalash 8afad73ed6 docs: add beta warning banner 2026-05-03 07:39:41 +05:30
debpalash 2a0c420ee9 docs: complete README overhaul for maximum virality
- Add Highlights section with 2-column feature grid
- Move Quickstart to top with one-command install
- Collapse technical details into expandable sections
- Add 'Up Next' roadmap with concrete upcoming features
- Add star call-to-action banner
- Tighten navigation links and section hierarchy
2026-05-03 07:37:57 +05:30
debpalash 46a2dd404d docs: up the game with enhanced README
- Use the high-quality social preview image as the hero image
- Bump download release links to v0.2.7
- Highlight the new Frameless Dictation Widget feature
2026-05-03 07:32:44 +05:30
debpalash 37a03acae3 feat: implement frameless OS-level floating dictation widget
- Refactor CaptureButton into standalone CaptureWidget
- Add secondary transparent Tauri window configuration
- Map global hotkey to show/hide widget instead of focusing main app
- Implement auto-hide post-paste
- Add social preview image
2026-05-03 07:27:43 +05:30
debpalash fba066c3d0 chore: bump version to 0.2.7 2026-05-03 05:46:27 +05:30
Palash Debnath a6aa9d79e6 feat: add CosyVoice 3 TTS backend, engine platform matrix, CONTRIBUTING.md (#39)
* feat: add CosyVoice 3 TTS backend, engine platform matrix, CONTRIBUTING.md

- Add CosyVoiceBackend adapter to tts_backend.py (9 langs + 18 dialects,
  zero-shot voice cloning, instruct mode, Apache-2.0)
- Fix VoxCPM2Backend.is_available() — remove incorrect hard CUDA gate;
  VoxCPM2 supports MPS (Apple Silicon) and CPU fallback
- Add unified TTS Engines table to README with features + platform compat
- Update FAQ to reflect current 6-engine Plugin SDK (was 'not yet')
- Add CONTRIBUTING.md with dev setup, PR workflow, TTS plugin guide,
  code style conventions, and testing commands
- Move Contributing section above FAQ in README

* fix(pr): address CodeRabbit review feedback

- README: Update 'Plugin SDK' to 'built-in backend registry' for clarity
- tts_backend.py: Preserve full language codes for CosyVoice cross-lingual lookup before fallback
2026-05-03 05:45:29 +05:30
Palash Debnath 0ecbf136e7 refactor: codebase cleanup & root folder reorganization (#38)
refactor: codebase cleanup & root folder reorganization
2026-05-03 03:12:07 +05:30
Palash Debnath 2d01dd915f feat: ASR model preload at startup — eliminate 25s first-dictation cold start
feat: ASR model preload at startup — eliminate 25s first-dictation cold start
2026-04-30 19:20:20 +05:30
Palash Debnath 0c1a3829d5 Fix transcription stream drops, IndexError, BrokenPipeError, FK constraint, and Tauri CSP
Fix transcription stream drops and Tauri CSP
2026-04-30 19:20:10 +05:30
debpalash f727f1cff7 feat: enhance ASR performance and reliability with binary bundling, model warmup, sub-stage progress tracking, and optimized polling. 2026-04-30 19:13:30 +05:30
riyaaa-04 81c4b7d1ed Fix transcription stream drops, IndexError, and Tauri CSP 2026-04-30 18:49:42 +05:30
debpalash 41c23f6b3a feat: enhance ASR performance and reliability with binary bundling, model warmup, sub-stage progress tracking, and optimized polling. 2026-04-30 07:46:35 +05:30
debpalash c8d1858420 refactor: bundle uv binary per-platform as Tauri sidecar and remove redundant ffmpeg bootstrap download 2026-04-29 20:34:12 +05:30
75 changed files with 4662 additions and 3037 deletions
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

+68
View File
@@ -0,0 +1,68 @@
# Publish Docker images to GitHub Container Registry (GHCR).
#
# Triggers:
# - push of a tag matching `v*` (e.g. `v0.2.7`) → pushed as :0.2.7 + :latest
# - workflow_dispatch → pushed as :sha-<short> (for testing)
#
# Images land at: ghcr.io/debpalash/omnivoice-studio
name: Docker (GHCR)
on:
push:
tags: ['v*']
workflow_dispatch:
permissions:
contents: read
packages: write
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
build-and-push:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
# QEMU enables cross-platform builds (arm64 on x64 runner).
# Skipped for now — only building linux/amd64.
# - uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Extracts semver tags from the git ref:
# v0.2.7 → 0.2.7, latest
# manual dispatch → sha-abc1234
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix=sha-,format=short
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: deploy/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
+102
View File
@@ -201,6 +201,108 @@ jobs:
# installer ships the repo's pyproject.toml + uv.lock + backend/
# tree as Tauri resources; lib.rs::ensure_venv_ready recreates the
# venv on first launch via `uv sync --frozen --no-dev`.
# Fetch the standalone `uv` binary for the current matrix target and
# drop it at `binaries/uv-<rust-target-triple>{ext}`. tauri.conf.json
# references `binaries/uv` via `bundle.externalBin`, and tauri-bundler
# picks up the per-target file automatically. The runtime then uses
# the bundled binary instead of downloading uv on first launch.
#
# Pinned uv version mirrors the `UV_VERSION` constant in lib.rs; bump
# both together when refreshing.
- name: Bundle uv (${{ matrix.rust_target }})
shell: bash
env:
UV_VERSION: "0.11.7"
TRIPLE: ${{ matrix.rust_target }}
run: |
set -euo pipefail
mkdir -p frontend/src-tauri/binaries
case "$TRIPLE" in
aarch64-apple-darwin|x86_64-apple-darwin|x86_64-unknown-linux-gnu)
ARCHIVE="tar.gz"
;;
x86_64-pc-windows-msvc)
ARCHIVE="zip"
;;
*)
echo "Unsupported target for uv bundling: $TRIPLE"
exit 1
;;
esac
URL="https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/uv-${TRIPLE}.${ARCHIVE}"
echo "Fetching $URL"
WORK=$(mktemp -d)
if [ "$ARCHIVE" = "zip" ]; then
curl -fsSL "$URL" -o "$WORK/uv.zip"
unzip -j -o "$WORK/uv.zip" -d "$WORK"
mv "$WORK/uv.exe" "frontend/src-tauri/binaries/uv-${TRIPLE}.exe"
else
curl -fsSL "$URL" | tar -xz -C "$WORK"
mv "$WORK/uv-${TRIPLE}/uv" "frontend/src-tauri/binaries/uv-${TRIPLE}"
chmod +x "frontend/src-tauri/binaries/uv-${TRIPLE}"
fi
ls -la "frontend/src-tauri/binaries/"
# Download static ffmpeg + ffprobe binaries and drop them into the
# Tauri sidecar directory. Sources:
# macOS: evermeet.cx — individual .zip per binary (x86_64,
# runs fine on Apple Silicon via Rosetta 2)
# Linux/Windows: BtbN/FFmpeg-Builds — single archive with both bins
- name: Bundle ffmpeg + ffprobe (${{ matrix.rust_target }})
shell: bash
env:
TRIPLE: ${{ matrix.rust_target }}
run: |
set -euo pipefail
BINDIR="frontend/src-tauri/binaries"
mkdir -p "$BINDIR"
WORK=$(mktemp -d)
case "$TRIPLE" in
aarch64-apple-darwin|x86_64-apple-darwin)
# evermeet.cx ships each binary as a separate .zip containing
# a single x86_64 Mach-O executable (runs via Rosetta on arm64).
for TOOL in ffmpeg ffprobe; do
if [ "$TOOL" = "ffmpeg" ]; then
URL="https://evermeet.cx/ffmpeg/getrelease/zip"
else
URL="https://evermeet.cx/ffmpeg/getrelease/${TOOL}/zip"
fi
echo "Fetching $TOOL from evermeet.cx"
curl -fsSL "$URL" -o "$WORK/${TOOL}.zip"
unzip -o -j "$WORK/${TOOL}.zip" -d "$WORK"
mv "$WORK/${TOOL}" "$BINDIR/${TOOL}-${TRIPLE}"
chmod +x "$BINDIR/${TOOL}-${TRIPLE}"
done
;;
x86_64-unknown-linux-gnu)
URL="https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linux64-gpl.tar.xz"
echo "Fetching ffmpeg from BtbN (linux64)"
curl -fsSL "$URL" -o "$WORK/ffmpeg.tar.xz"
tar -xJf "$WORK/ffmpeg.tar.xz" -C "$WORK"
# Archive extracts to ffmpeg-master-latest-linux64-gpl/bin/
EXTRACTED=$(find "$WORK" -type d -name "bin" | head -1)
mv "$EXTRACTED/ffmpeg" "$BINDIR/ffmpeg-${TRIPLE}"
mv "$EXTRACTED/ffprobe" "$BINDIR/ffprobe-${TRIPLE}"
chmod +x "$BINDIR/ffmpeg-${TRIPLE}" "$BINDIR/ffprobe-${TRIPLE}"
;;
x86_64-pc-windows-msvc)
URL="https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip"
echo "Fetching ffmpeg from BtbN (win64)"
curl -fsSL "$URL" -o "$WORK/ffmpeg.zip"
unzip -o "$WORK/ffmpeg.zip" -d "$WORK"
EXTRACTED=$(find "$WORK" -type f -name "ffmpeg.exe" | head -1)
EXTRACTED_DIR=$(dirname "$EXTRACTED")
mv "$EXTRACTED_DIR/ffmpeg.exe" "$BINDIR/ffmpeg-${TRIPLE}.exe"
mv "$EXTRACTED_DIR/ffprobe.exe" "$BINDIR/ffprobe-${TRIPLE}.exe"
;;
*)
echo "⚠ No ffmpeg bundling for target: $TRIPLE (will download at first run)"
;;
esac
ls -la "$BINDIR/"
# Extract the matching CHANGELOG.md section so the release body has
# real notes instead of "see commit log". Falls back to a one-liner
# if the tag has no matching `## [X.Y.Z]` section yet — keeps the
+3
View File
@@ -81,3 +81,6 @@ frontend/src-tauri/binaries/ffmpeg
# cuDNN 8 compat libs (auto-installed by scripts/setup_cudnn.py)
cudnn8_compat/
test-results/
# Research repos (local only)
research/
+15
View File
@@ -6,6 +6,18 @@ The format is loosely based on [Keep a Changelog](https://keepachangelog.com/).
Versions track the desktop app (`tauri.conf.json` + `frontend/src-tauri/Cargo.toml`).
The bundled TTS model package (`pyproject.toml`) is versioned independently.
## [0.2.7] — Unreleased
### Added
- **Frameless dictation widget.** Global dictation upgraded from an in-app FAB to a true OS-level floating widget that hovers over any application. Transparent, decorations-free, always-on-top secondary Tauri window activated by `⌘+⇧+Space`. Auto-hides 2.5 s after a successful paste.
- **Standalone `CaptureWidget` component.** Refactored `CaptureButton` into `CaptureWidget`, running on an isolated route (`/?window=widget`).
- **Social preview image.** Added `social-preview.png` for GitHub SEO.
### Changed
- **README overhaul.** Compact 3-column feature grid, reorganized Quickstart (one-command install, Docker, Desktop App tips), updated comparison table, roadmap, and footer CTA.
---
## [0.2.6] — Unreleased
### License
@@ -34,7 +46,10 @@ The bundled TTS model package (`pyproject.toml`) is versioned independently.
- **Microphone access prompt on macOS.** Added an `Info.plist` with `NSMicrophoneUsageDescription` (and `NSCameraUsageDescription` for forward-compat) so getUserMedia no longer fails silently on macOS 10.14+ TCC. Tauri's bundler auto-merges the file at bundle time. Mic-denial toasts now also include platform-specific recovery hints (Settings paths for macOS/Windows, audio-group check for Linux).
### Infrastructure
- **uv bundled per-platform.** Release installers now ship the `uv` binary as a Tauri sidecar (`bundle.externalBin`). First launch no longer requires network access for the uv-download step — bootstrap uses the bundled binary directly. Adds ~12-15 MB per platform installer; falls back to PATH lookup, then standalone download, when the bundled file isn't present (dev builds, future targets). Pinned at `UV_VERSION = "0.11.7"`; bump the constant in [lib.rs](frontend/src-tauri/src/lib.rs) and the matching env var in [release.yml](.github/workflows/release.yml) together to refresh.
- **ffmpeg fetch removed from Tauri bootstrap.** The redundant download from `eugeneware/ffmpeg-static` (saved to `app_data/bin/`) was never used by the backend, which already resolves ffmpeg via `imageio_ffmpeg.get_ffmpeg_exe()` from the pip wheel pulled by `uv sync`. Net effect: one fewer first-run network round-trip, one fewer splash-screen stage, and the splash no longer shows the misleading "Downloading ffmpeg…" line.
- **CI cross-platform check.** PRs now run `cargo check` against the Tauri shell on macOS (Apple Silicon), Windows, and Linux in parallel — surfaces platform-specific Rust regressions before tag push without paying the full ~15 min/platform tauri-bundle cost (full bundling stays in `release.yml` on tag push).
- **Release notes from CHANGELOG.** `release.yml` now extracts the matching `## [X.Y.Z]` section from `CHANGELOG.md` and uses it as the GitHub Release body, replacing the prior placeholder "Auto-generated release. See commit log for changes."
- **Tests:** `tests/test_capture_ws.py` (3 cases) covers the EOF text-frame, empty-binary-frame, and legacy disconnect-finalize paths for `/ws/transcribe`.
### Internal
+201
View File
@@ -0,0 +1,201 @@
# Contributing to OmniVoice Studio
Thanks for your interest in improving OmniVoice Studio! This guide covers everything you need to get started.
## Quick Links
| | |
|---|---|
| 💬 **Chat** | [Discord](https://discord.gg/aRRdVj3de7) |
| 🐛 **Bugs** | [GitHub Issues](https://github.com/debpalash/OmniVoice-Studio/issues) |
| 🏷️ **Good First Issues** | [Filtered list](https://github.com/debpalash/OmniVoice-Studio/labels/good%20first%20issue) |
| 📋 **Roadmap** | [README → Roadmap](README.md#roadmap) |
---
## Development Setup
### Prerequisites
- [Git](https://git-scm.com/)
- [Bun](https://bun.sh/) (frontend package manager)
- [uv](https://docs.astral.sh/uv/) (Python environment manager)
- [ffmpeg](https://ffmpeg.org/) (audio/video processing)
- Python 3.10+ (managed automatically by `uv`)
### Clone & Run
```bash
git clone https://github.com/debpalash/OmniVoice-Studio.git
cd OmniVoice-Studio
bun install
bun run dev
```
This starts both services:
| Service | URL | What it does |
|---------|-----|---|
| **Backend** | `localhost:3900` | FastAPI server — TTS, ASR, diarization, dubbing pipeline |
| **Frontend** | `localhost:3901` | React + Vite UI |
### Desktop App (Tauri)
```bash
bun run desktop
```
Requires [Rust](https://rustup.rs/) and platform-specific Tauri dependencies — see the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/).
---
## Project Structure
```
OmniVoice-Studio/
├── backend/ # Python FastAPI server
│ ├── api/ # Route handlers
│ ├── core/ # Config, prefs, constants
│ └── services/ # TTS engines, ASR, dubbing, audio DSP
│ └── tts_backend.py # ← Multi-engine TTS registry
├── frontend/ # React + Vite
│ ├── src/
│ │ ├── components/ # UI components
│ │ ├── hooks/ # Custom React hooks
│ │ ├── stores/ # Zustand state slices
│ │ └── utils/ # Shared utilities
│ └── src-tauri/ # Rust/Tauri desktop shell
├── deploy/ # Docker, CI configs
├── docs/ # Screenshots, MCP config
└── scripts/ # Build & release scripts
```
---
## How to Contribute
### Bug Reports
Open an [issue](https://github.com/debpalash/OmniVoice-Studio/issues/new) with:
1. **What happened** vs **what you expected**
2. **Steps to reproduce**
3. **OS, GPU, and Python version** (find in Settings → Logs)
4. **Error logs** (Settings → Logs → copy relevant lines)
### Pull Requests
1. **Fork** the repo and create a branch from `main`
2. **Keep PRs focused** — one feature or fix per PR
3. **Run tests** before pushing:
```bash
# Backend tests
uv run pytest backend/ -x -q
# Frontend build check
cd frontend && npx vite build --mode development
```
4. **Write a clear PR title** — it becomes the squash-merge commit message
5. **Don't include** local machine stats, file paths, or private system info in PR descriptions
### Adding a New TTS Engine
OmniVoice's TTS backend is a plugin registry. Adding a new engine takes ~50 lines:
1. Open `backend/services/tts_backend.py`
2. Create a class extending `TTSBackend`:
```python
class MyEngineBackend(TTSBackend):
id = "my-engine"
display_name = "My Engine (description)"
@classmethod
def is_available(cls) -> tuple[bool, str]:
try:
import my_engine # noqa: F401
return True, "ready"
except ImportError:
return False, "my_engine not installed. pip install my-engine"
@property
def sample_rate(self) -> int:
return 24000
@property
def supported_languages(self) -> list[str]:
return ["en", "zh"]
def generate(self, text: str, **kw) -> torch.Tensor:
# ... call your engine, return [1, num_samples] tensor
```
3. Register it in `_REGISTRY` at the bottom of the file
4. That's it — it auto-appears in Settings → TTS Engine
---
## Code Style
### Python (Backend)
- **Formatter**: We don't enforce one globally — match the style of the file you're editing
- **Logging**: Use `logger.warning()` / `logger.error()`, never bare `print()`
- **Exceptions**: Avoid bare `except: pass` — catch specific exceptions
- **Type hints**: Use them for public API functions and class methods
### JavaScript/React (Frontend)
- **Components**: Functional components with hooks
- **State**: Zustand stores in `src/stores/`, organized by slice
- **CSS**: Vanilla CSS in component-level files — no Tailwind
- **Naming**: `PascalCase` for components, `camelCase` for hooks and utils
### Rust (Tauri)
- **Format**: `cargo fmt` before committing
- **Modules**: One concern per file (`bootstrap.rs`, `tools.rs`, `config.rs`, `commands.rs`)
---
## Commit Messages
Write clear, concise messages. The PR title becomes the squash-merge commit.
```
good: fix: prevent CUDA OOM during concurrent transcription + TTS
good: feat: add CosyVoice 3 TTS backend adapter
good: docs: add platform compatibility matrix to README
bad: fixed stuff
bad: update
bad: WIP
```
---
## Testing
```bash
# Run all backend tests
uv run pytest backend/ -x -q
# Run a specific test file
uv run pytest backend/tests/test_api.py -x -q
# Frontend build validation (no test suite yet)
cd frontend && npx vite build --mode development
# Tauri shell check (requires Rust)
cd frontend/src-tauri && cargo check
```
---
## Need Help?
- **Stuck on setup?** Ask in [Discord #help](https://discord.gg/aRRdVj3de7)
- **Not sure where to start?** Check [good first issues](https://github.com/debpalash/OmniVoice-Studio/labels/good%20first%20issue)
- **Want to discuss a big change?** Open a [discussion](https://github.com/debpalash/OmniVoice-Studio/discussions) or Discord thread before coding
Thank you for contributing! 🎙️
+233 -177
View File
@@ -1,8 +1,9 @@
<div align="center">
<img src="docs/logo.png" alt="OmniVoice Logo" width="160" />
<img src="docs/logo.png" alt="OmniVoice Logo" width="120" />
<h1>OmniVoice Studio</h1>
<p><b>The open-source ElevenLabs alternative.</b></p>
<p>Voice cloning · Voice design · Video dubbing — 646 languages, runs 100% locally, forever free.</p>
<h3>The open-source ElevenLabs alternative.</h3>
<p>Real-time dictation, zero-shot voice cloning, and cinematic video dubbing — all on your desktop.<br/>Open-source, no API keys, fully local. <b>646 languages.</b></p>
<p>
<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>
@@ -10,31 +11,210 @@
<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>
<a href="#features">Features</a> ·
<a href="#why-omnivoice-studio">Why OmniVoice Studio?</a> ·
<a href="#tts-engines">TTS Engines</a> ·
<a href="#contributing">Contributing</a> ·
<a href="https://discord.gg/aRRdVj3de7">Discord</a>
</p>
<p>
<a href="https://github.com/debpalash/OmniVoice-Studio/releases/download/v0.2.5/OmniVoice.Studio_0.2.5_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.5/OmniVoice.Studio_0.2.5_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.5/OmniVoice.Studio_0.2.5_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.5/OmniVoice.Studio_0.2.5_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>
<a href="https://github.com/debpalash/OmniVoice-Studio/releases/download/v0.2.7/OmniVoice.Studio_0.2.7_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.7/OmniVoice.Studio_0.2.7_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.7/OmniVoice.Studio_0.2.7_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.7/OmniVoice.Studio_0.2.7_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 — Launchpad" width="100%"/>
<br/>
<sub>Launchpad — Voice Clone · Voice Design · Video Dubbing, all in one place.</sub>
<img src=".github/assets/social-preview.png" alt="OmniVoice Studio — The open-source ElevenLabs alternative" width="100%"/>
</div>
> [!WARNING]
> **OmniVoice Studio is in active beta.** Things may break between releases. For the latest features and fixes, clone the repo and run from source rather than using pre-built installers. Bug reports and PRs are very welcome — [open an issue](https://github.com/debpalash/OmniVoice-Studio/issues) or [join Discord](https://discord.gg/aRRdVj3de7).
<br/>
## Features
<table>
<tr>
<td align="center" width="33%">
<h3>🎙️ Voice Cloning</h3>
<p>3-second clip → mirror any voice.<br/><b>646 languages</b>, zero-shot.</p>
</td>
<td align="center" width="33%">
<h3>🎨 Voice Design</h3>
<p>Gender, age, accent, pitch, speed,<br/>emotion, dialect — <b>dial it in</b>.</p>
</td>
<td align="center" width="33%">
<h3>🎬 Video Dubbing</h3>
<p>YouTube URL or file → transcribe →<br/>translate → re-voice → <b>MP4</b>.</p>
</td>
</tr>
<tr>
<td align="center" valign="top">
<h3>⌨️ Dictation Widget</h3>
<p><code>⌘+⇧+Space</code> from <b>any app</b>.<br/>Transcribes, auto-pastes, disappears.</p>
</td>
<td align="center" valign="top">
<h3>🔊 Vocal Isolation</h3>
<p>Demucs-powered. Splits speech<br/>from music, <b>keeps the background</b>.</p>
</td>
<td align="center" valign="top">
<h3>👥 Speaker Diarization</h3>
<p>Pyannote + WhisperX.<br/><b>Auto-identifies</b> who said what.</p>
</td>
</tr>
<tr>
<td align="center" valign="top">
<h3>📦 Batch Queue</h3>
<p>Drop <b>50 videos</b>, walk away.<br/>Progress bars per job.</p>
</td>
<td align="center" valign="top">
<h3>🤖 MCP Server</h3>
<p>Use OmniVoice from <b>Claude</b>,<br/>Cursor, or any MCP client.</p>
</td>
<td align="center" valign="top">
<h3>🛡️ AI Watermark</h3>
<p>AudioSeal (Meta). <b>Invisible</b>,<br/>survives compression.</p>
</td>
</tr>
<tr>
<td align="center" valign="top">
<h3>🔐 100% Local</h3>
<p>No keys, no cloud, no accounts.<br/><b>Your machine only</b>.</p>
</td>
<td align="center" valign="top">
<h3>⚡ GPU Auto-Detect</h3>
<p>CUDA · MPS · ROCm · CPU.<br/>≤8 GB? <b>Auto-offloads</b>.</p>
</td>
<td align="center" valign="top">
<h3>🧩 Extensible</h3>
<p>Subclass <code>TTSBackend</code>,<br/>add any engine in <b>~50 lines</b>.</p>
</td>
</tr>
</table>
---
## Quickstart
### One-command install
```bash
git clone https://github.com/debpalash/OmniVoice-Studio.git && cd OmniVoice-Studio && bun install && bun run dev
```
That's it. Open [localhost:3901](http://localhost:3901) and start cloning voices.
### Docker
Pull the pre-built image from **GitHub Container Registry** — no build step needed:
```bash
docker pull ghcr.io/debpalash/omnivoice-studio:latest
```
Run it:
```bash
# CPU mode
docker run -d --name omnivoice \
-p 127.0.0.1:3900:3900 \
-v omnivoice-data:/app/omnivoice_data \
ghcr.io/debpalash/omnivoice-studio:latest
# NVIDIA GPU mode
docker run -d --name omnivoice --gpus all \
-p 127.0.0.1:3900:3900 \
-v omnivoice-data:/app/omnivoice_data \
ghcr.io/debpalash/omnivoice-studio:latest
```
Or use **Docker Compose** with the GHCR image:
```bash
# CPU mode
docker compose -f deploy/docker-compose.yml up -d
# GPU mode
docker compose -f deploy/docker-compose.yml --profile gpu up -d
```
Open [http://localhost:3900](http://localhost:3900) once the health check passes. First run downloads ~4 GB of model weights — progress is shown in `docker compose logs -f`.
<details>
<summary><b>Build from source instead of pulling</b></summary>
<br/>
```bash
docker compose -f deploy/docker-compose.yml up --build -d
```
</details>
> **Network access:** the container binds to `127.0.0.1` only. To reach OmniVoice from another machine on your LAN, change the port mapping to `"0.0.0.0:3900:3900"`. OmniVoice ships no built-in authentication — when exposing it beyond your machine, put it behind a reverse proxy with auth (Caddy `basic_auth`, nginx + htpasswd, Tailscale, etc.).
### Desktop App
Pre-built installers (~68 MB) are available on the [**Releases**](https://github.com/debpalash/OmniVoice-Studio/releases/latest) page. On first launch, the app bootstraps a Python environment and downloads model weights automatically — the splash screen shows progress.
```bash
bun run desktop # Build from source (macOS / Windows / Linux)
```
<details>
<summary><b>macOS — "app is damaged and can't be opened"</b></summary>
<br/>
macOS quarantines apps downloaded outside the App Store. After dragging to `/Applications`:
```bash
xattr -cr /Applications/OmniVoice\ Studio.app
```
Open normally after. One-time fix.
</details>
<details>
<summary><b>Windows — first launch takes 510 minutes</b></summary>
<br/>
The app bootstraps a Python virtual environment, installs dependencies, and downloads ffmpeg on first run. The splash screen shows each step. Subsequent launches start in seconds.
</details>
<details>
<summary><b>Linux — AppImage needs FUSE</b></summary>
<br/>
If FUSE isn't available, use the `.deb` package or extract-and-run:
```bash
chmod +x OmniVoice.Studio_*.AppImage
./OmniVoice.Studio_*.AppImage --appimage-extract-and-run
```
</details>
> [!NOTE]
> 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.
| Service | URL | Stack |
|---------|-----|-------|
| **Backend** | `localhost:3900` | FastAPI · 97 endpoints · WhisperX · Demucs · OmniVoice |
| **Frontend** | `localhost:3901` | React · Vite · Waveform timeline · Glassmorphism UI |
---
## Screenshots
<table>
<tr>
<td align="center" width="50%">
@@ -83,7 +263,7 @@
---
## Why Open Source?
## Why OmniVoice Studio?
ElevenLabs charges **$5$330/mo** and processes your audio on their servers. OmniVoice Studio runs **on your hardware, with no usage limits.**
@@ -100,152 +280,7 @@ ElevenLabs charges **$5$330/mo** and processes your audio on their servers. O
| **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 Capture** — Press `⌘+⇧+Space` **from any app** to dictate. Global system-wide hotkey records, transcribes, and auto-pastes into the active text field. Live partial results stream via WebSocket while you speak.
- **Speaker Casting** — Visual speaker-to-voice assignment grid. Auto-cast from video clones or assign saved profiles.
- **Voice Preview** — Floating widget for instant 8-step TTS testing. Try voices without leaving the workspace.
- **Real-time Dub Preview** — Edit a segment's text, preview the audio instantly without full re-render.
- **Multi-Language Batch** — Select multiple target languages, dub to all in one pass.
- **Batch Queue** — Drag-and-drop bulk video processing. Full pipeline: extract → transcribe → translate → generate → mix → export. Real-time progress bars per job.
- **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.
- **Streaming ASR** — WebSocket-based speech-to-text (`/ws/transcribe`) delivers live partial results during recording. 2s buffer interval, configurable.
- **Auto-Paste** — Dictated text is automatically pasted into the active app via system keyboard simulation (macOS Accessibility / Windows SendInput).
- **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.
### MCP Server (AI Agent Integration)
- **Model Context Protocol** — Expose OmniVoice as an AI agent tool for Claude, Cursor, and any MCP-compatible client.
- **5 Tools** — `generate_speech`, `list_voices`, `list_personalities`, `list_languages`, `check_health`.
- **stdio + SSE** — Works locally (Claude Desktop) or remotely (networked agents).
- **Zero config** — Drop `mcp.json` into your client config and go. See [`mcp.json`](mcp.json).
### Audio Effects Chain
- **6 presets** — Broadcast 📻, Cinematic 🎬, Podcast 🎙️, Warm ☀️, Bright ✨, Raw 🔇.
- **Pedalboard-powered** — Spotify's production-grade DSP (EQ, compressor, reverb, noise gate, limiter).
- **API-driven** — `GET /tools/effects` returns presets; custom chains via `apply_effects_chain()`.
### Plugin SDK (Third-Party TTS Engines)
- **Abstract interface** — Subclass `TTSPlugin` to add any TTS engine in ~50 lines.
- **Built-in plugins** — ElevenLabs (cloud) and Bark (local) ship out of the box.
- **Auto-discovery** — Drop a `.py` file in `backend/plugins/`, it registers automatically.
- **API** — `GET /tools/plugins` lists all engines and their availability status.
### GPU Safety
- **Crash sandbox** — GPU-intensive ops can run in subprocess isolation. A CUDA OOM or driver crash kills the worker, not the server.
- **6 color themes** — Gruvbox (default), Midnight Blue, Nord, Solarized, Rosé Pine, Catppuccin Mocha.
---
## Quickstart
### Docker (recommended)
```bash
git clone https://github.com/debpalash/OmniVoice-Studio.git
cd OmniVoice-Studio
# CPU mode
docker compose up --build -d
# Or with NVIDIA GPU
docker compose --profile gpu up --build -d
```
Open [http://localhost:3900](http://localhost:3900) once the health check passes. First run downloads ~4 GB of model weights — progress is shown in `docker compose logs -f`.
> **Network access:** the container binds to `127.0.0.1` only. To reach OmniVoice from another machine on your LAN, change the port mapping in `docker-compose.yml` to `"0.0.0.0:3900:3900"`. OmniVoice ships no built-in authentication — when exposing it beyond your machine, put it behind a reverse proxy with auth (Caddy `basic_auth`, nginx + htpasswd, Tailscale, etc.).
### Local Development
**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
bun install
bun run dev
```
This boots both services:
| Service | URL | Stack |
|---------|-----|-------|
| **Backend** | `localhost:3900` | FastAPI · 97 endpoints · WhisperX · Demucs · OmniVoice |
| **Frontend** | `localhost:3901` | React · Vite · Waveform timeline · Glassmorphism UI |
> [!NOTE]
> 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
Pre-built installers (~68 MB) are available on the [**Releases**](https://github.com/debpalash/OmniVoice-Studio/releases/latest) page. On first launch, the app bootstraps a Python environment and downloads model weights automatically — the splash screen shows progress.
To build from source instead:
```bash
bun run desktop # Launches Tauri native app (macOS / Windows / Linux)
```
<details>
<summary><b>macOS — "app is damaged and can't be opened"</b></summary>
<br/>
macOS quarantines apps downloaded outside the App Store. After dragging to `/Applications`:
```bash
xattr -cr /Applications/OmniVoice\ Studio.app
```
Open normally after. One-time fix.
</details>
<details>
<summary><b>Windows — first launch takes 510 minutes</b></summary>
<br/>
The app bootstraps a Python virtual environment, installs dependencies, and downloads ffmpeg on first run. The splash screen shows each step. Subsequent launches start in seconds.
</details>
<details>
<summary><b>Linux — AppImage needs FUSE</b></summary>
<br/>
If FUSE isn't available, use the `.deb` package or extract-and-run:
```bash
chmod +x OmniVoice.Studio_*.AppImage
./OmniVoice.Studio_*.AppImage --appimage-extract-and-run
```
</details>
OmniVoice Studio gives you professional-grade AI tools without the subscription or the cloud.
---
@@ -263,6 +298,21 @@ chmod +x OmniVoice.Studio_*.AppImage
> [!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).
### TTS Engines
OmniVoice ships a multi-engine TTS backend. The default engine (OmniVoice) is always available; additional engines are opt-in and auto-detected. Switch engines in **Settings → TTS Engine** or via the `OMNIVOICE_TTS_BACKEND` env var.
| Engine | Languages | Clone | Instruct | Linux | macOS ARM | Windows | License |
|--------|:---------:|:-----:|:--------:|:-----:|:---------:|:-------:|:-------:|
| **OmniVoice** (default) | 600+ | ✅ | ✅ | ✅ CUDA/CPU | ✅ MPS | ✅ CUDA/CPU | Built-in |
| **CosyVoice 3** | 9 + 18 dialects | ✅ | ✅ | ✅ CUDA/CPU | ✅ MPS | ✅ CUDA/CPU | Apache-2.0 |
| **MLX-Audio** (Kokoro, Qwen3-TTS, CSM, Dia, …) | Multi | Varies | Varies | ❌ | ✅ Native | ❌ | Varies |
| **VoxCPM2** | 30 | ✅ | ✅ | ✅ CUDA/CPU | ✅ MPS | ✅ CUDA/CPU | Apache-2.0 |
| **MOSS-TTS-Nano** | 20 | ✅ | ❌ | ✅ CUDA/CPU | ✅ CPU | ✅ CUDA/CPU | Apache-2.0 |
| **KittenTTS** | English | ❌ | ❌ | ✅ CPU | ✅ CPU | ✅ CPU | MIT |
> **CUDA** = GPU-accelerated · **MPS** = Apple Silicon Metal · **CPU** = runs everywhere, slower for large models · KittenTTS and MOSS-TTS-Nano run realtime on CPU · MLX-Audio is Apple Silicon only.
---
## Architecture
@@ -302,18 +352,25 @@ chmod +x OmniVoice.Studio_*.AppImage
| **State Management** | Zustand store migration — `uiSlice`, `pillSlice`, `dubSlice`, `generateSlice`, `prefsSlice`, `glossarySlice` |
| **Desktop** | Cross-platform Tauri installers (macOS DMG, Windows MSI, Linux deb/AppImage), auto-update infrastructure |
| **Windows Hardening** | Cross-platform log paths, Triton workaround, HF symlink bypass, 300s health check timeout |
| **Dictation** | Global system-wide hotkey (`⌘+⇧+Space`), streaming ASR via WebSocket, auto-paste into active app |
| **Dictation** | Global system-wide hotkey (`⌘+⇧+Space`), frameless floating widget, streaming ASR via WebSocket, auto-paste |
| **Batch Pipeline** | Full batch TTS: extract → transcribe → translate → generate → mix → export, with live progress tracking |
### 🔜 Roadmap — completed ✅
### 🔜 Up Next
**All planned features have been shipped.**
- 🎬 **Lip-sync v2** — visual speech timing with wav2lip
- 📖 **Audiobook Editor** — chapter-aware long-form narration
- 🌐 **Hosted Demo** — try OmniVoice without installing anything
- 🔌 **Plugin Marketplace** — community-contributed TTS engines and effects
- ~~Onboarding sample clip~~ · ~~Docker DX~~ · ~~Auto-updater~~ · ~~Deferred disk writes~~
- ~~MCP server~~ · ~~Voice personalities~~ · ~~Audio effects chain~~ · ~~i18n framework~~
- ~~Global hotkey dictation~~ · ~~Real-time dub preview~~ · ~~Speaker casting view~~
- ~~Theme system~~ · ~~Plugin SDK~~ · ~~GPU crash sandbox~~ · ~~Waveform v2~~
- ~~Batched TTS~~ · ~~Cold start optimization~~ · ~~Audiobook editor~~ · ~~Context-aware pipeline~~
---
## Contributing
We welcome contributions of all kinds — bug fixes, new TTS engine adapters, UI improvements, docs, and translations.
- 📖 Read the **[Contributing Guide](CONTRIBUTING.md)** for setup, code style, and PR workflow
- 🐛 Browse [good first issues](https://github.com/debpalash/OmniVoice-Studio/labels/good%20first%20issue)
- 💬 Join our [Discord](https://discord.gg/aRRdVj3de7) to discuss ideas or ask for help
---
@@ -352,7 +409,7 @@ Personal, educational, internal-team, and non-commercial use is free under <a hr
<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.
Yes. OmniVoice uses a <b>built-in backend registry</b>. To add an engine in ~50 lines, subclass <code>TTSBackend</code> in <code>backend/services/tts_backend.py</code> and add it to the <code>_REGISTRY</code> dictionary at the bottom. Six engines are built in: OmniVoice, CosyVoice, MLX-Audio (14+ sub-engines), VoxCPM2, MOSS-TTS-Nano, and KittenTTS. See the <a href="#tts-engines">TTS Engines</a> section for details.
</details>
---
@@ -369,12 +426,6 @@ See [`LICENSE`](LICENSE) for the full terms.
---
## 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:
@@ -393,7 +444,12 @@ OmniVoice Studio is built on the shoulders of exceptional open-source work:
<div align="center">
**[⭐ Star on GitHub](https://github.com/debpalash/OmniVoice-Studio)** to follow updates.
<br/>
If you read this far, you're our kind of person.<br/>
**[⭐ Star this repo](https://github.com/debpalash/OmniVoice-Studio)** so others can find it too.
<br/>
<a href="https://star-history.com/#debpalash/OmniVoice-Studio&Date">
<picture>
+31 -6
View File
@@ -381,10 +381,20 @@ async def dub_transcribe_stream(job_id: str):
return {"chunks": [], "language": None, "error": str(e)}
try:
part = await asyncio.wait_for(
loop.run_in_executor(_gpu_pool, _transcribe_chunk),
timeout=TRANSCRIBE_CHUNK_TIMEOUT_S,
)
# wait_for in a loop to yield pings so the EventSource connection doesn't drop
fut = loop.run_in_executor(_gpu_pool, _transcribe_chunk)
waited = 0.0
part = None
while True:
done, pending = await asyncio.wait([fut], timeout=5.0)
if done:
part = done.pop().result()
break
yield _sse_event("ping", {})
waited += 5.0
if waited >= TRANSCRIBE_CHUNK_TIMEOUT_S:
# Re-raise TimeoutError if we exceed the overall limit
raise asyncio.TimeoutError()
except asyncio.TimeoutError:
logger.error(
"Transcribe chunk %d/%d timed out after %.0fs (job=%s)",
@@ -456,7 +466,15 @@ async def dub_transcribe_stream(job_id: str):
logger.error(f"Diarization failed: {e}")
return assign_speakers_heuristic(all_segments)
final_segs = await loop.run_in_executor(_gpu_pool, _diarize)
fut_diar = loop.run_in_executor(_gpu_pool, _diarize)
final_segs = None
while True:
done, pending = await asyncio.wait([fut_diar], timeout=5.0)
if done:
final_segs = done.pop().result()
break
yield _sse_event("ping", {})
job["segments"] = final_segs
# Auto-speaker-clone: sample each detected speaker's voice from the
@@ -467,10 +485,17 @@ async def dub_transcribe_stream(job_id: str):
try:
from services.speaker_clone import extract_speaker_clones, auto_profile_id
vocals_for_clone = job.get("vocals_path") or asr_audio_target
clones = await loop.run_in_executor(
fut_clones = loop.run_in_executor(
_cpu_pool, extract_speaker_clones,
vocals_for_clone, final_segs, os.path.dirname(vocals_for_clone),
)
clones = None
while True:
done, pending = await asyncio.wait([fut_clones], timeout=5.0)
if done:
clones = done.pop().result()
break
yield _sse_event("ping", {})
if clones:
job["speaker_clones"] = clones
# Default each segment's profile_id to its speaker's auto-clone,
+2
View File
@@ -250,6 +250,8 @@ def delete_profile(profile_id: str):
path = os.path.join(VOICES_DIR, row[col])
if os.path.exists(path):
os.remove(path)
# Prevent FOREIGN KEY constraint failure
conn.execute("UPDATE generation_history SET profile_id = NULL WHERE profile_id=?", (profile_id,))
conn.execute("DELETE FROM voice_profiles WHERE id=?", (profile_id,))
conn.commit()
conn.close()
+5 -8
View File
@@ -284,14 +284,11 @@ def preflight():
# ── 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
try:
from services.ffmpeg_utils import find_ffprobe
ffprobe_path = find_ffprobe()
except Exception:
pass
if ffprobe_path:
checks.append({
"id": "ffprobe", "label": "FFprobe", "status": "pass",
+9 -2
View File
@@ -448,8 +448,15 @@ def system_notifications():
})
# 2. Missing ffmpeg
ffmpeg_path = find_ffmpeg()
if not ffmpeg_path or not os.path.exists(ffmpeg_path):
ffmpeg_ok = False
try:
ffmpeg_path = find_ffmpeg()
# find_ffmpeg may return an absolute path or a bare command name.
# Both are valid — only flag missing if find_ffmpeg raises.
ffmpeg_ok = bool(ffmpeg_path)
except Exception:
pass
if not ffmpeg_ok:
notes.append({
"id": "ffmpeg-missing",
"level": "error",
+5 -5
View File
@@ -27,7 +27,7 @@ from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from services import director, speech_rate, incremental
from services.ffmpeg_utils import find_ffmpeg
from services.ffmpeg_utils import find_ffmpeg, find_ffprobe
logger = logging.getLogger("omnivoice.tools")
router = APIRouter()
@@ -48,11 +48,11 @@ async def probe(req: ProbeReq):
status_code=404,
detail="File not found. Provide an absolute path to an existing file.",
)
ffprobe = find_ffmpeg().replace("ffmpeg", "ffprobe")
if not os.path.exists(ffprobe):
ffprobe = find_ffprobe()
if not ffprobe:
raise HTTPException(
status_code=500,
detail="ffprobe binary not available alongside ffmpeg.",
status_code=501,
detail="ffprobe binary not available. Install system ffmpeg or re-run the setup.",
)
proc = await asyncio.create_subprocess_exec(
ffprobe, "-v", "quiet", "-print_format", "json",
+3
View File
@@ -46,6 +46,9 @@ class ModelStatusResponse(BaseModel):
status: str = Field(description="idle | loading | ready")
checkpoint: str | None = None
loaded_at: str | None = None
sub_stage: str | None = Field(None, description="Current loading sub-stage: importing | loading_weights | loading_asr | compiling | ready | error")
detail: str | None = Field(None, description="Human-readable detail of current loading phase")
error: str | None = Field(None, description="Error message if loading failed")
class LogsResponse(BaseModel):
+1 -1
View File
@@ -52,7 +52,7 @@ PREVIEW_DIR = os.path.join(DATA_DIR, "preview")
CRASH_LOG_PATH = os.path.join(DATA_DIR, "crash_log.txt") # only written on unhandled exceptions
LOG_PATH = os.path.join(DATA_DIR, "omnivoice.log") # rolling runtime log — what the Settings UI reads
IDLE_TIMEOUT_SECONDS = int(os.environ.get("OMNIVOICE_IDLE_TIMEOUT", "300"))
IDLE_TIMEOUT_SECONDS = int(os.environ.get("OMNIVOICE_IDLE_TIMEOUT", "900"))
CPU_POOL_WORKERS = int(os.environ.get("OMNIVOICE_CPU_POOL", "0")) or min(8, (os.cpu_count() or 4))
def ensure_dirs():
+35
View File
@@ -121,6 +121,17 @@ logging.basicConfig(
level=os.environ.get("OMNIVOICE_LOG_LEVEL", "INFO"),
format=_LOG_FMT,
)
class AsyncioExceptionFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
if record.levelno == logging.WARNING and "socket.send() raised exception" in record.getMessage():
return False
return True
logging.getLogger("asyncio").addFilter(AsyncioExceptionFilter())
# Silence HF Hub unauthenticated warnings unless specifically requested.
logging.getLogger("huggingface_hub.utils._http").setLevel(logging.ERROR)
if _json_logs:
# Replace every existing handler's formatter with the JSON one.
for _h in logging.getLogger().handlers:
@@ -220,6 +231,30 @@ async def lifespan(app: FastAPI):
worker_task = asyncio.create_task(task_manager.worker())
# Warm the TTS model in the background so first /generate is instant.
preload_task = asyncio.create_task(preload_model())
# Warm the capture ASR engine (MLX Whisper Turbo on Apple Silicon) so
# first dictation is instant — like Ghost Pepper and VoiceBox do.
# Without this, the first capture takes ~25s just to load the model.
async def _preload_capture_asr():
try:
from services.model_manager import _gpu_pool, _loading_detail
loop = asyncio.get_event_loop()
def _warm():
from services.asr_backend import get_capture_asr_backend
_loading_detail["sub_stage"] = "loading_asr"
_loading_detail["detail"] = "Warming up ASR engine…"
backend = get_capture_asr_backend()
logger.info("Capture ASR backend selected: %s", backend.id)
# Actually load model weights into memory — without this the
# first dictation still takes ~25s for weight loading.
if hasattr(backend, 'warmup'):
_loading_detail["detail"] = f"Loading {backend.display_name}"
backend.warmup()
_loading_detail["sub_stage"] = "ready"
_loading_detail["detail"] = "ASR engine ready"
await loop.run_in_executor(_gpu_pool, _warm)
except Exception as e:
logger.warning("Capture ASR preload skipped: %s", e)
capture_preload_task = asyncio.create_task(_preload_capture_asr())
yield
# ── Graceful shutdown (SIGTERM from Tauri, Ctrl+C, etc.) ────────────
logger.info("Shutdown: cleaning up…")
+2 -3
View File
@@ -35,11 +35,10 @@ def _ensure_mcp():
from mcp.server.fastmcp import FastMCP # noqa: F811
return FastMCP
except ImportError:
print(
logger.error(
"MCP SDK not installed. Install with:\n"
" pip install 'mcp[cli]'\n"
"Then re-run this module.",
file=sys.stderr,
"Then re-run this module."
)
sys.exit(1)
+43 -5
View File
@@ -203,7 +203,13 @@ class WhisperXBackend(ASRBackend):
self._ensure_asr()
logger.info("whisperx transcribing %s (word_timestamps=%s)", audio_path, word_timestamps)
audio = whisperx.load_audio(audio_path)
result = self._asr.transcribe(audio)
try:
result = self._asr.transcribe(audio)
except IndexError as e:
# WhisperX pipeline crashes with IndexError if VAD produces 0 segments
logger.info("whisperx transcribe threw IndexError (likely 0 VAD segments). Returning empty result.")
result = {"segments": [], "language": "en"}
lang = result.get("language", "en")
# Forced alignment when available — drastically improves word boundary
@@ -413,6 +419,27 @@ class MLXWhisperBackend(ASRBackend):
]
return result
def warmup(self) -> None:
"""Eagerly load model weights into memory so first transcribe is instant.
mlx_whisper internally caches via a class-level ModelHolder singleton.
Calling ``load_model`` triggers the download (if needed) and loads
weights onto the GPU — subsequent transcribe() calls hit the warm cache.
"""
import time
t0 = time.perf_counter()
try:
from mlx_whisper.transcribe import ModelHolder
import mlx.core as mx
# load_model populates the class-level singleton; after this call
# the model is resident in unified memory.
ModelHolder.get_model(self._model_name, dtype=mx.float16)
dt = time.perf_counter() - t0
logger.info("MLX Whisper model '%s' warmed up in %.1fs", self._model_name, dt)
except Exception as e:
dt = time.perf_counter() - t0
logger.warning("MLX Whisper warmup failed after %.1fs: %s", dt, e)
# ── PyTorch Whisper fallback (CUDA / CPU via pipeline) ─────────────────────
@@ -555,6 +582,9 @@ def get_active_asr_backend(*, asr_pipe=None) -> ASRBackend:
return _REGISTRY[bid]()
_capture_backend: ASRBackend | None = None
def get_capture_asr_backend() -> ASRBackend:
"""Pick the fastest ASR engine for capture / dictation.
@@ -568,17 +598,25 @@ def get_capture_asr_backend() -> ASRBackend:
The caller should also pass ``word_timestamps=False`` to the returned
backend to skip per-word timing and shave another ~30% latency.
Returns a cached singleton so the model stays warm between calls.
"""
global _capture_backend
if _capture_backend is not None:
return _capture_backend
# Prefer MLX Turbo on Apple Silicon
ok, _ = MLXWhisperBackend.is_available()
if ok:
# Use Turbo model for maximum speed
return MLXWhisperBackend(model_name=_MLX_MODEL_TURBO)
_capture_backend = MLXWhisperBackend(model_name=_MLX_MODEL_TURBO)
return _capture_backend
# Fall back to faster-whisper (CPU int8 on non-Apple)
ok, _ = FasterWhisperBackend.is_available()
if ok:
return FasterWhisperBackend()
_capture_backend = FasterWhisperBackend()
return _capture_backend
# Last resort
return PyTorchWhisperBackend()
_capture_backend = PyTorchWhisperBackend()
return _capture_backend
+1 -1
View File
@@ -116,7 +116,7 @@ def apply_mastering(audio_tensor, sample_rate=24000):
except ImportError:
return audio_tensor # Fail gracefully if pedalboard isn't installed
except Exception as e:
print(f"Mastering DSP Error: {e}")
logger.warning("Mastering DSP Error: %s", e)
return audio_tensor
+46 -6
View File
@@ -1,6 +1,7 @@
import asyncio
import errno
import logging
import os
import shutil
logger = logging.getLogger("omnivoice.api")
@@ -18,16 +19,55 @@ def _get_semaphore() -> asyncio.Semaphore:
def find_ffmpeg():
"""Locate an ffmpeg binary.
Resolution order:
1. ``FFMPEG_PATH`` env var (set by Tauri when a sidecar is bundled).
2. ``imageio-ffmpeg`` pip package (ships a static binary per platform).
3. Common system paths / ``PATH``.
Returns the path string, or ``None`` if nothing found.
"""
# 1. Env var injected by Tauri host
env_path = os.environ.get("FFMPEG_PATH")
if env_path and os.path.isfile(env_path):
return env_path
# 2. imageio-ffmpeg bundled static binary
try:
import imageio_ffmpeg
# This will natively extract and return an architecture-specific static FFmpeg binary!
return imageio_ffmpeg.get_ffmpeg_exe()
except Exception as e:
logger.warning(f"imageio_ffmpeg failed to provide static binary: {e}. Falling back to default system path.")
for path in ["/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg", "ffmpeg"]:
if shutil.which(path):
return path
raise RuntimeError("ffmpeg not found in bundle or system path")
logger.warning(f"imageio_ffmpeg unavailable: {e}")
# 3. Well-known system paths + PATH lookup
for path in ["/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg", "ffmpeg"]:
if shutil.which(path):
return path
logger.warning("ffmpeg not found in env, imageio, or system PATH")
return None
def find_ffprobe():
"""Locate an ffprobe binary.
Resolution order:
1. ``FFPROBE_PATH`` env var (set by Tauri when a sidecar is bundled).
2. Derived from ``find_ffmpeg()`` path by replacing ``ffmpeg`` → ``ffprobe``.
3. System ``PATH``.
"""
env_path = os.environ.get("FFPROBE_PATH")
if env_path and os.path.isfile(env_path):
return env_path
try:
ffmpeg_path = find_ffmpeg()
candidate = ffmpeg_path.replace("ffmpeg", "ffprobe")
if os.path.isfile(candidate):
return candidate
except Exception:
pass
system_probe = shutil.which("ffprobe")
if system_probe:
return system_probe
return None
async def _spawn_with_retry(cmd, **kwargs):
+198 -43
View File
@@ -42,32 +42,163 @@ _model_lock = asyncio.Lock()
_last_used = time.time()
_IDLE_TIMEOUT_SECONDS = IDLE_TIMEOUT_SECONDS
def get_best_device():
# ── Loading sub-stage tracker ────────────────────────────────────────
# Updated by _load_model_sync() so get_model_status() can report
# granular progress to the frontend pill.
_loading_detail: dict = {
"sub_stage": None, # importing | loading_weights | loading_asr | compiling | ready | error
"detail": "", # human-readable description
"error": None, # error message string if failed
}
# ── ROCm GFX version overrides ───────────────────────────────────────
# AMD GPUs on ROCm report through torch.cuda but may need
# HSA_OVERRIDE_GFX_VERSION for unsupported GFX IDs.
_ROCM_GFX_OVERRIDES = {
# RDNA 3 (RX 7000 series) — override to gfx1100
"gfx1101": "11.0.0", "gfx1102": "11.0.0", "gfx1103": "11.0.0",
# RDNA 2 (RX 6000 series) — override to gfx1030
"gfx1031": "10.3.0", "gfx1032": "10.3.0", "gfx1034": "10.3.0",
# Vega (RX Vega / Radeon VII) — override to gfx900
"gfx902": "9.0.0", "gfx906": "9.0.6",
}
def _configure_rocm_if_needed(torch):
"""Auto-set HSA_OVERRIDE_GFX_VERSION for AMD GPUs on ROCm.
ROCm-enabled PyTorch reports `torch.cuda.is_available() == True` but
some consumer AMD GPUs have GFX IDs not in the official support matrix.
Setting HSA_OVERRIDE_GFX_VERSION lets them run with the closest
supported architecture.
"""
if os.environ.get("HSA_OVERRIDE_GFX_VERSION"):
return # User already set it manually
try:
device_name = torch.cuda.get_device_name(0).lower()
# Only AMD GPUs need this — skip NVIDIA
if not any(kw in device_name for kw in ("amd", "radeon", "instinct")):
return
# Try to read the GFX version from the device properties
props = torch.cuda.get_device_properties(0)
gcn_arch = getattr(props, "gcnArchName", "") or ""
gfx_id = gcn_arch.split(":")[0].strip().lower()
if gfx_id in _ROCM_GFX_OVERRIDES:
override = _ROCM_GFX_OVERRIDES[gfx_id]
os.environ["HSA_OVERRIDE_GFX_VERSION"] = override
logger.info("ROCm: auto-set HSA_OVERRIDE_GFX_VERSION=%s for %s (%s)",
override, device_name, gfx_id)
except Exception as e:
logger.debug("ROCm GFX auto-config skipped: %s", e)
def check_device_compatibility():
"""Check if PyTorch supports the current GPU's compute capability.
Returns (compatible, warning_message). Compatible is True if OK or
no discrete GPU is present.
"""
torch = _lazy_torch()
if not torch.cuda.is_available():
return True, None
try:
major, minor = torch.cuda.get_device_capability(0)
device_name = torch.cuda.get_device_name(0)
sm_tag = f"sm_{major}{minor}"
arch_list = getattr(torch.cuda, "_get_arch_list", lambda: [])()
if arch_list:
compute_tag = f"compute_{major}{minor}"
if sm_tag not in arch_list and compute_tag not in arch_list:
return False, (
f"{device_name} (compute capability {major}.{minor} / {sm_tag}) "
f"is not supported by this PyTorch build. "
f"Supported architectures: {', '.join(arch_list)}. "
f"Try: pip install torch --index-url https://download.pytorch.org/whl/nightly/cu128"
)
except Exception:
pass
return True, None
def get_best_device():
"""Detect the best available compute device.
Priority: CUDA/ROCm > Intel XPU > DirectML > MPS > CPU
"""
torch = _lazy_torch()
# ── NVIDIA CUDA or AMD ROCm ──────────────────────────────────────
# ROCm-enabled PyTorch reports through torch.cuda, so this covers both.
if torch.cuda.is_available():
_configure_rocm_if_needed(torch)
compatible, warning = check_device_compatibility()
if not compatible:
logger.warning(warning)
return "cuda"
if torch.backends.mps.is_available():
# ── Intel Arc / discrete GPU via IPEX ────────────────────────────
try:
import intel_extension_for_pytorch # noqa: F401
if hasattr(torch, "xpu") and torch.xpu.is_available():
logger.info("Using Intel XPU device: %s", torch.xpu.get_device_name(0))
return "xpu"
except ImportError:
pass
# ── DirectML — universal Windows GPU (AMD, Intel, NVIDIA fallback)
try:
import torch_directml
if torch_directml.device_count() > 0:
logger.info("Using DirectML device (GPU %d)", 0)
return str(torch_directml.device(0))
except ImportError:
pass
# ── Apple Silicon MPS ────────────────────────────────────────────
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
return "mps"
return "cpu"
def _set_loading(sub_stage: str, detail: str = "", error: str | None = None):
"""Update the loading detail dict atomically."""
_loading_detail["sub_stage"] = sub_stage
_loading_detail["detail"] = detail
_loading_detail["error"] = error
def _load_model_sync():
global model
torch = _lazy_torch()
OmniVoice = _lazy_omnivoice()
device = get_best_device()
logger.info("Loading OmniVoice model lazily on device: %s", device)
checkpoint = os.environ.get("OMNIVOICE_MODEL", "k2-fsa/OmniVoice")
_model = OmniVoice.from_pretrained(
checkpoint, device_map=device, dtype=torch.float16, load_asr=True,
)
try:
if device == "cuda":
_model.llm = torch.compile(_model.llm, mode="reduce-overhead")
logger.info("torch.compile applied.")
except Exception as e:
logger.info("torch.compile skipped: %s", e)
logger.info("OmniVoice model loaded successfully.")
return _model
_set_loading("importing", "Importing PyTorch & OmniVoice runtime…")
logger.info("Importing PyTorch & OmniVoice runtime…")
torch = _lazy_torch()
OmniVoice = _lazy_omnivoice()
device = get_best_device()
checkpoint = os.environ.get("OMNIVOICE_MODEL", "k2-fsa/OmniVoice")
_set_loading("loading_weights", f"Loading TTS weights on {device}")
logger.info("Loading OmniVoice model on device: %s", device)
_model = OmniVoice.from_pretrained(
checkpoint, device_map=device, dtype=torch.float16, load_asr=True,
)
try:
if device == "cuda":
_set_loading("compiling", "Compiling model (torch.compile)…")
_model.llm = torch.compile(_model.llm, mode="reduce-overhead")
logger.info("torch.compile applied.")
except Exception as e:
logger.info("torch.compile skipped: %s", e)
_set_loading("ready", "Model ready")
logger.info("OmniVoice model loaded successfully.")
return _model
except Exception as exc:
err_msg = str(exc)
_set_loading("error", "Model loading failed", error=err_msg)
logger.error("Model loading failed: %s", err_msg)
raise
async def get_model():
global model, _last_used
@@ -121,11 +252,22 @@ def get_model_status():
is_loading = (not is_loaded) and _model_lock.locked()
except Exception:
is_loading = False
return {
status = "loading" if is_loading else ("ready" if is_loaded else "idle")
result = {
"loaded": is_loaded,
"loading": is_loading,
"status": "loading" if is_loading else ("ready" if is_loaded else "idle"),
"status": status,
}
# Attach sub-stage detail when loading or after an error
sub = _loading_detail.get("sub_stage")
if sub:
result["sub_stage"] = sub
result["detail"] = _loading_detail.get("detail", "")
err = _loading_detail.get("error")
if err:
result["error"] = err
return result
async def idle_worker():
global model
@@ -136,21 +278,29 @@ async def idle_worker():
if model is not None and time.time() - _last_used > _IDLE_TIMEOUT_SECONDS:
logger.info("Idle timeout reached. Unloading OmniVoice model to free VRAM.")
model = None
import gc
gc.collect()
if torch.backends.mps.is_available():
torch.mps.empty_cache()
elif torch.cuda.is_available():
torch.cuda.empty_cache()
free_vram()
def free_vram():
"""Release cached GPU memory on any accelerator (CUDA, MPS, XPU)."""
torch = _lazy_torch()
import gc
gc.collect()
if torch.backends.mps.is_available():
torch.mps.empty_cache()
elif torch.cuda.is_available():
if torch.cuda.is_available():
torch.cuda.empty_cache()
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
torch.mps.empty_cache()
elif hasattr(torch, "xpu") and torch.xpu.is_available():
torch.xpu.empty_cache()
def _has_dedicated_vram():
"""Check if the current device has limited dedicated VRAM that needs offloading."""
torch = _lazy_torch()
if torch.cuda.is_available():
return True
if hasattr(torch, "xpu") and torch.xpu.is_available():
return True
return False
def offload_tts_for_asr():
@@ -160,18 +310,21 @@ def offload_tts_for_asr():
(~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.
Works on CUDA (NVIDIA + ROCm) and Intel XPU.
"""
global model
torch = _lazy_torch()
if model is None:
return
if not torch.cuda.is_available():
return # Only needed on CUDA (limited VRAM)
if not _has_dedicated_vram():
return # MPS / CPU / DirectML don't benefit from manual offloading
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
# Check if there's enough free VRAM to skip offloading
if torch.cuda.is_available():
free_mem = torch.cuda.mem_get_info()[0]
if free_mem > 8 * 1024 ** 3: # > 8 GB free → skip offload
return
except Exception:
pass
try:
@@ -184,21 +337,21 @@ def offload_tts_for_asr():
def restore_tts_after_asr():
"""Move TTS model back to CUDA after ASR completes."""
"""Move TTS model back to the GPU after ASR completes."""
global model
torch = _lazy_torch()
if model is None:
return
if not torch.cuda.is_available():
if not _has_dedicated_vram():
return
try:
device = get_best_device()
if device == "cuda":
logger.info("Restoring TTS model to CUDA...")
model.to("cuda")
if device in ("cuda", "xpu"):
logger.info("Restoring TTS model to %s...", device)
model.to(device)
free_vram()
except Exception as e:
logger.warning("TTS restore to CUDA failed: %s", e)
logger.warning("TTS restore to %s failed: %s", get_best_device(), e)
_diar_pipeline = None
@@ -214,9 +367,11 @@ def get_diarization_pipeline():
from pyannote.audio import Pipeline
logger.info("Loading Pyannote Diarization Pipeline...")
_diar_pipeline = Pipeline.from_pretrained("pyannote/speaker-diarization-3.1", use_auth_token=hf_token)
if torch.cuda.is_available():
_diar_pipeline.to(torch.device("cuda"))
logger.info("Pyannote Diarization Pipeline loaded successfully.")
device = get_best_device()
# Pyannote supports CUDA and CPU; route XPU/DirectML to CPU
if device in ("cuda",):
_diar_pipeline.to(torch.device(device))
logger.info("Pyannote Diarization Pipeline loaded on %s.", device)
return _diar_pipeline
except Exception as e:
logger.error(f"Failed to load Pyannote pipeline: {e}")
+136 -3
View File
@@ -178,10 +178,9 @@ class VoxCPM2Backend(TTSBackend):
except ImportError:
return False, (
"voxcpm package not installed. Install with `pip install voxcpm` "
"(requires CUDA 12+ and ~8 GB VRAM)."
"(requires Python ≥3.10, PyTorch ≥2.5). CUDA 12 recommended "
"for full speed; MPS (Apple Silicon) and CPU also supported."
)
if not torch.cuda.is_available():
return False, "VoxCPM2 requires a CUDA GPU (CUDA 12+)."
return True, "ready"
@property
@@ -533,11 +532,144 @@ class MLXAudioBackend(TTSBackend):
return wav
# ── CosyVoice adapter (Alibaba FunAudioLLM, Apache-2.0) ────────────────────
class CosyVoiceBackend(TTSBackend):
"""FunAudioLLM CosyVoice — multilingual zero-shot TTS (9 langs + 18 dialects).
Supports v1 (300M), v2 (0.5B), and v3 (0.5B, latest). Installation is
non-trivial (git clone --recursive + SoX) so we ship as an optional
scaffold: ``is_available()`` reports the missing install cleanly.
Set ``OMNIVOICE_COSYVOICE_MODEL`` to the pretrained model directory path
(e.g. ``pretrained_models/Fun-CosyVoice3-0.5B``). The directory must
contain the CosyVoice checkpoint files.
Install:
git clone --recursive https://github.com/FunAudioLLM/CosyVoice.git
cd CosyVoice && pip install -r requirements.txt
# Ubuntu: sudo apt-get install sox libsox-dev
# macOS: brew install sox
"""
id = "cosyvoice"
display_name = "CosyVoice 3 (9 langs, zero-shot, instruct, Apache-2.0)"
# CosyVoice language tags used for cross-lingual synthesis.
LANG_TAGS = {
"zh": "<|zh|>", "en": "<|en|>", "ja": "<|ja|>",
"ko": "<|ko|>", "yue": "<|yue|>", "de": "<|de|>",
"es": "<|es|>", "fr": "<|fr|>", "it": "<|it|>",
"ru": "<|ru|>",
}
def __init__(self):
self._model = None
@classmethod
def is_available(cls) -> tuple[bool, str]:
try:
from cosyvoice.cli.cosyvoice import AutoModel # noqa: F401
return True, "ready"
except ImportError:
return False, (
"cosyvoice package not installed. Install from "
"https://github.com/FunAudioLLM/CosyVoice "
"(git clone --recursive + pip install -r requirements.txt + SoX). "
"Then set OMNIVOICE_COSYVOICE_MODEL to your model directory."
)
@property
def sample_rate(self) -> int:
if self._model is not None:
return self._model.sample_rate
return 24000 # v3 default
@property
def supported_languages(self) -> list[str]:
return ["zh", "en", "ja", "ko", "yue", "de", "es", "fr", "it", "ru"]
def _ensure_loaded(self):
if self._model is not None:
return
ok, msg = self.is_available()
if not ok:
raise RuntimeError(f"CosyVoice unavailable: {msg}")
from cosyvoice.cli.cosyvoice import AutoModel # type: ignore[import-not-found]
model_dir = os.environ.get(
"OMNIVOICE_COSYVOICE_MODEL",
"pretrained_models/Fun-CosyVoice3-0.5B",
)
logger.info("Loading CosyVoice from %s", model_dir)
self._model = AutoModel(model_dir=model_dir)
def generate(self, text: str, **kw) -> torch.Tensor:
import numpy as np
self._ensure_loaded()
ref_audio = kw.get("ref_audio")
ref_text = kw.get("ref_text")
instruct = kw.get("instruct")
language = kw.get("language")
# Pick the right inference method based on what the caller provides:
# 1. instruct + ref_audio → inference_instruct2 (emotion/dialect/speed)
# 2. ref_audio + ref_text → inference_zero_shot (voice cloning)
# 3. ref_audio only → inference_cross_lingual (with lang tag)
# 4. nothing → inference_sft (built-in speakers, v1/SFT model only)
pieces = []
if instruct and ref_audio:
# Instruct mode: "用四川话说<|endofprompt|>"
if not instruct.endswith("<|endofprompt|>"):
instruct = f"{instruct}<|endofprompt|>"
results = self._model.inference_instruct2(
text, instruct, ref_audio, stream=False,
)
elif ref_audio and ref_text:
results = self._model.inference_zero_shot(
text, ref_text, ref_audio, stream=False,
)
elif ref_audio:
# Cross-lingual: prefix text with language tag if available.
lang_tag = ""
if language:
full_lang = language.lower()
lang_key = full_lang[:2] if len(full_lang) > 2 else full_lang
lang_tag = self.LANG_TAGS.get(full_lang) or self.LANG_TAGS.get(lang_key, "")
results = self._model.inference_cross_lingual(
f"{lang_tag}{text}", ref_audio, stream=False,
)
else:
# No ref audio — try SFT with first available speaker.
spks = self._model.list_available_spks()
spk = spks[0] if spks else "中文女"
results = self._model.inference_sft(text, spk, stream=False)
for chunk in results:
wav = chunk.get("tts_speech")
if wav is None:
continue
if isinstance(wav, np.ndarray):
wav = torch.from_numpy(wav).float()
if not isinstance(wav, torch.Tensor):
wav = torch.tensor(wav, dtype=torch.float32)
pieces.append(wav)
if not pieces:
raise RuntimeError("CosyVoice produced no audio")
wav = torch.cat(pieces, dim=-1)
if wav.ndim == 1:
wav = wav.unsqueeze(0)
return wav
# ── Registry ────────────────────────────────────────────────────────────────
_REGISTRY: dict[str, type[TTSBackend]] = {
"omnivoice": OmniVoiceBackend,
"cosyvoice": CosyVoiceBackend,
"kittentts": KittenTTSBackend,
"mlx-audio": MLXAudioBackend,
"voxcpm2": VoxCPM2Backend,
@@ -545,6 +677,7 @@ _REGISTRY: dict[str, type[TTSBackend]] = {
}
def list_backends() -> list[dict]:
"""Enumerate every registered backend with its availability state.
Shape matches what a Settings-UI engine picker wants.
+50 -3
View File
@@ -90,6 +90,23 @@ def emit(event: ProgressEvent) -> None:
_emit(event)
class SafeFileWrapper:
def __init__(self, fp):
self.fp = fp
self._is_safe_wrapper = True
def write(self, s):
try:
self.fp.write(s)
except OSError:
pass
def flush(self):
try:
getattr(self.fp, 'flush', lambda: None)()
except OSError:
pass
def __getattr__(self, name):
return getattr(self.fp, name)
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."""
@@ -124,8 +141,26 @@ def install() -> None:
_last_emit_time: float = 0.0
@staticmethod
def status_printer(file):
if file is not None and not getattr(file, "_is_safe_wrapper", False):
file = SafeFileWrapper(file)
try:
return original.status_printer(file)
except Exception:
return lambda s: None
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if 'file' in kwargs and kwargs['file'] is not None and not getattr(kwargs['file'], "_is_safe_wrapper", False):
kwargs['file'] = SafeFileWrapper(kwargs['file'])
try:
super().__init__(*args, **kwargs)
except OSError:
pass
if hasattr(self, 'fp') and getattr(self, 'fp', None) is not None and not getattr(self.fp, "_is_safe_wrapper", False):
self.fp = SafeFileWrapper(self.fp)
import time as _t
self._last_emit_time = _t.monotonic()
try:
@@ -168,7 +203,10 @@ def install() -> None:
pass
def update(self, n=1):
super().update(n)
try:
super().update(n)
except OSError:
pass
import time as _t
now = _t.monotonic()
# Throttle: emit at most every 0.3s to avoid flooding SSE
@@ -184,7 +222,16 @@ def install() -> None:
if (now - self._last_emit_time) >= 0.5:
self._last_emit_time = now
self._emit_progress()
return super().display(msg, pos)
try:
return super().display(msg, pos)
except OSError:
pass
def close(self):
try:
super().close()
except OSError:
pass
# Stash the original for inspection / uninstall, then swap.
hf_tqdm_module._omnivoice_original_tqdm = original # type: ignore[attr-defined]
+67 -61
View File
@@ -8,14 +8,14 @@
"concurrently": "^9.2.1",
"kill-port-process": "^4.0.2",
"playwright": "^1.59.1",
"turbo": "^2.9.6",
"turbo": "^2.9.7",
"typescript": "^6.0.3",
"wait-on": "^9.0.5",
},
},
"frontend": {
"name": "omnivoice-studio",
"version": "0.2.5",
"version": "0.2.7",
"dependencies": {
"@fontsource-variable/inter": "^5.2.8",
"@fontsource-variable/source-serif-4": "^5.2.9",
@@ -29,40 +29,40 @@
"@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",
"@tailwindcss/vite": "^4.2.4",
"@tanstack/react-query": "^5.100.8",
"@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-dialog": "^2.7.1",
"@tauri-apps/plugin-opener": "^2.5.4",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1",
"@tauri-apps/plugin-window-state": "^2.4.1",
"i18next": "^26.0.8",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^1.8.0",
"lucide-react": "^1.14.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-hot-toast": "^2.6.0",
"react-i18next": "^17.0.6",
"react-window": "^2.2.7",
"tailwindcss": "4",
"tailwindcss": "^4.2.4",
"wavesurfer.js": "^7.12.6",
"zustand": "^5.0.12",
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@tauri-apps/api": "^2.10.1",
"@tauri-apps/cli": "^2.10.1",
"@tauri-apps/api": "^2.11.0",
"@tauri-apps/cli": "^2.11.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.2.1",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.5.0",
"globals": "^17.6.0",
"typescript": "^6.0.3",
"vite": "^8.0.9",
"vite": "^8.0.10",
},
},
},
@@ -101,9 +101,9 @@
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
"@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="],
"@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
"@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="],
"@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
@@ -169,7 +169,7 @@
"@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" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
"@oxc-project/types": ["@oxc-project/types@0.126.0", "", {}, "sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ=="],
"@oxc-project/types": ["@oxc-project/types@0.127.0", "", {}, "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ=="],
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
@@ -247,35 +247,35 @@
"@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-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.17", "", { "os": "android", "cpu": "arm64" }, "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rNz0yK078yrNn3DrdgN+PKiMOW8HfQ92jQiXxwX8yW899ayV00MLVdaCNeVBhG/TbH3ouYVObo8/yrkiectkcQ=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.17", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-r/OmdR00HmD4i79Z//xO06uEPOq5hRXdhw7nzkxQxwSavs3PSHa1ijntdpOiZ2mzOQ3fVVu8C1M19FoNM+dMUQ=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.17", "", { "os": "darwin", "cpu": "x64" }, "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.16", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KcRE5w8h0OnjUatG8pldyD14/CQ5Phs1oxfR+3pKDjboHRo9+MkqQaiIZlZRpsxC15paeXme/I127tUa9TXJ6g=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.17", "", { "os": "freebsd", "cpu": "x64" }, "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.16", "", { "os": "linux", "cpu": "arm" }, "sha512-bT0guA1bpxEJ/ZhTRniQf7rNF8ybvXOuWbNIeLABaV5NGjx4EtOWBTSRGWFU9ZWVkPOZ+HNFP8RMcBokBiZ0Kg=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17", "", { "os": "linux", "cpu": "arm" }, "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-+tHktCHWV8BDQSjemUqm/Jl/TPk3QObCTIjmdDy/nlupcujZghmKK2962LYrqFpWu+ai01AN/REOH3NEpqvYQg=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17", "", { "os": "linux", "cpu": "arm64" }, "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-3fPzdREH806oRLxpTWW1Gt4tQHs0TitZFOECB2xzCFLPKnSOy90gwA7P29cksYilFO6XVRY1kzga0cL2nRjKPg=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.17", "", { "os": "linux", "cpu": "arm64" }, "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg=="],
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.16", "", { "os": "linux", "cpu": "ppc64" }, "sha512-EKwI1tSrLs7YVw+JPJT/G2dJQ1jl9qlTTTEG0V2Ok/RdOenRfBw2PQdLPyjhIu58ocdBfP7vIRN/pvMsPxs/AQ=="],
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17", "", { "os": "linux", "cpu": "ppc64" }, "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA=="],
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.16", "", { "os": "linux", "cpu": "s390x" }, "sha512-Uknladnb3Sxqu6SEcqBldQyJUpk8NleooZEc0MbRBJ4inEhRYWZX0NJu12vNf2mqAq7gsofAxHrGghiUYjhaLQ=="],
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17", "", { "os": "linux", "cpu": "s390x" }, "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.16", "", { "os": "linux", "cpu": "x64" }, "sha512-FIb8+uG49sZBtLTn+zt1AJ20TqVcqWeSIyoVt0or7uAWesgKaHbiBh6OpA/k9v0LTt+PTrb1Lao133kP4uVxkg=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.17", "", { "os": "linux", "cpu": "x64" }, "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.16", "", { "os": "linux", "cpu": "x64" }, "sha512-RuERhF9/EgWxZEXYWCOaViUWHIboceK4/ivdtQ3R0T44NjLkIIlGIAVAuCddFxsZ7vnRHtNQUrt2vR2n2slB2w=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.17", "", { "os": "linux", "cpu": "x64" }, "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.16", "", { "os": "none", "cpu": "arm64" }, "sha512-mXcXnvd9GpazCxeUCCnZ2+YF7nut+ZOEbE4GtaiPtyY6AkhZWbK70y1KK3j+RDhjVq5+U8FySkKRb/+w0EeUwA=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.17", "", { "os": "none", "cpu": "arm64" }, "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA=="],
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.16", "", { "dependencies": { "@emnapi/core": "1.9.2", "@emnapi/runtime": "1.9.2", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-3Q2KQxnC8IJOLqXmUMoYwyIPZU9hzRbnHaoV3Euz+VVnjZKcY8ktnNP8T9R4/GGQtb27C/UYKABxesKWb8lsvQ=="],
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.17", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-tj7XRemQcOcFwv7qhpUxMTBbI5mWMlE4c1Omhg5+h8GuLXzyj8HviYgR+bB2DMDgRqUE+jiDleqSCRjx4aYk/Q=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17", "", { "os": "win32", "cpu": "arm64" }, "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.16", "", { "os": "win32", "cpu": "x64" }, "sha512-PH5DRZT+F4f2PTXRXR8uJxnBq2po/xFtddyabTJVJs/ZYVHqXPEgNIr35IHTEa6bpa0Q8Awg+ymkTaGnKITw4g=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.17", "", { "os": "win32", "cpu": "x64" }, "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.7", "", {}, "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA=="],
@@ -315,9 +315,9 @@
"@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/query-core": ["@tanstack/query-core@5.100.8", "", {}, "sha512-ceYwSFOqjPwET5TA6IOYxzxlGc0ekyH/gfOtWkP0PX43rzX9bxW48Iuw8KAduKCToi4rJAQ6nRy2kAe8gszdmg=="],
"@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-query": ["@tanstack/react-query@5.100.8", "", { "dependencies": { "@tanstack/query-core": "5.100.8" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-iNNEekixXU5vtAGKKZX2lx3jTooG5yNY+kv0wSgEdEYG0Mj0JM5bcuQtC35ZAP3nDopT6jciUK3xeX65U7AnfA=="],
"@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=="],
@@ -327,35 +327,35 @@
"@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/api": ["@tauri-apps/api@2.11.0", "", {}, "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA=="],
"@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=="],
"@tauri-apps/cli": ["@tauri-apps/cli@2.11.0", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.11.0", "@tauri-apps/cli-darwin-x64": "2.11.0", "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.0", "@tauri-apps/cli-linux-arm64-gnu": "2.11.0", "@tauri-apps/cli-linux-arm64-musl": "2.11.0", "@tauri-apps/cli-linux-riscv64-gnu": "2.11.0", "@tauri-apps/cli-linux-x64-gnu": "2.11.0", "@tauri-apps/cli-linux-x64-musl": "2.11.0", "@tauri-apps/cli-win32-arm64-msvc": "2.11.0", "@tauri-apps/cli-win32-ia32-msvc": "2.11.0", "@tauri-apps/cli-win32-x64-msvc": "2.11.0" }, "bin": { "tauri": "tauri.js" } }, "sha512-W5Wbuqsb2pHFPTj4TaRNKTj5rwXhDShPiLSY9T18y4ouSR/NNCptAEFxFsBtyNRgL6Vs1a/q9LzfqqYzEwC+Jw=="],
"@tauri-apps/cli-darwin-arm64": ["@tauri-apps/cli-darwin-arm64@2.10.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Z2OjCXiZ+fbYZy7PmP3WRnOpM9+Fy+oonKDEmUE6MwN4IGaYqgceTjwHucc/kEEYZos5GICve35f7ZiizgqEnQ=="],
"@tauri-apps/cli-darwin-arm64": ["@tauri-apps/cli-darwin-arm64@2.11.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-UfMeDNlgIP252rm/KSTuu8yHatPua5TjtUEUf+jyIzVwBNcIl7Ywkdpfj+e5jVVg3EfCTp+4gwuL1dNpgF8clg=="],
"@tauri-apps/cli-darwin-x64": ["@tauri-apps/cli-darwin-x64@2.10.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-V/irQVvjPMGOTQqNj55PnQPVuH4VJP8vZCN7ajnj+ZS8Kom1tEM2hR3qbbIRoS3dBKs5mbG8yg1WC+97dq17Pw=="],
"@tauri-apps/cli-darwin-x64": ["@tauri-apps/cli-darwin-x64@2.11.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-lY1+aPlgyMN7vgjtCdQ3+WODfZkebAcxnrCrO0HjqDpKSXieDkrJbimqeaoM4RwhTSrCLRHfVYiYrfE5E131tg=="],
"@tauri-apps/cli-linux-arm-gnueabihf": ["@tauri-apps/cli-linux-arm-gnueabihf@2.10.1", "", { "os": "linux", "cpu": "arm" }, "sha512-Hyzwsb4VnCWKGfTw+wSt15Z2pLw2f0JdFBfq2vHBOBhvg7oi6uhKiF87hmbXOBXUZaGkyRDkCHsdzJcIfoJC2w=="],
"@tauri-apps/cli-linux-arm-gnueabihf": ["@tauri-apps/cli-linux-arm-gnueabihf@2.11.0", "", { "os": "linux", "cpu": "arm" }, "sha512-5uCP0AusgN3NrKC8EpkuJwjek1k8pEffBdugJSpXPey/QGbPEb8vZ542n/giJ2mZPjMSllDkdhG2QIDpBY4PpQ=="],
"@tauri-apps/cli-linux-arm64-gnu": ["@tauri-apps/cli-linux-arm64-gnu@2.10.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-OyOYs2t5GkBIvyWjA1+h4CZxTcdz1OZPCWAPz5DYEfB0cnWHERTnQ/SLayQzncrT0kwRoSfSz9KxenkyJoTelA=="],
"@tauri-apps/cli-linux-arm64-gnu": ["@tauri-apps/cli-linux-arm64-gnu@2.11.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-loDPqtRHMSbIcrH2VBd4GgHoQlF7jJnrZj7MxA2lj1cixS/jEgMAPFqj83U6Wvjete4HfYplbE/gCpSFifA9jw=="],
"@tauri-apps/cli-linux-arm64-musl": ["@tauri-apps/cli-linux-arm64-musl@2.10.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-MIj78PDDGjkg3NqGptDOGgfXks7SYJwhiMh8SBoZS+vfdz7yP5jN18bNaLnDhsVIPARcAhE1TlsZe/8Yxo2zqg=="],
"@tauri-apps/cli-linux-arm64-musl": ["@tauri-apps/cli-linux-arm64-musl@2.11.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-DtSE8ZBlB9H+L+eHkfZ3myt00EVEyAB3e41juEHoE2qT88fgVlJvyrwa9SZYc/xTwCS9TnmK+R84tpg+ZsAg7Q=="],
"@tauri-apps/cli-linux-riscv64-gnu": ["@tauri-apps/cli-linux-riscv64-gnu@2.10.1", "", { "os": "linux", "cpu": "none" }, "sha512-X0lvOVUg8PCVaoEtEAnpxmnkwlE1gcMDTqfhbefICKDnOTJ5Est3qL0SrWxizDackIOKBcvtpejrSiVpuJI1kw=="],
"@tauri-apps/cli-linux-riscv64-gnu": ["@tauri-apps/cli-linux-riscv64-gnu@2.11.0", "", { "os": "linux", "cpu": "none" }, "sha512-5QdgS4LD+kntClI1aj2JmwjW38LosNXxwCe8viIHEwqYIWuMPdNEIau6/cLogI38Yzx9DnfCPRfEWLyI+5li8Q=="],
"@tauri-apps/cli-linux-x64-gnu": ["@tauri-apps/cli-linux-x64-gnu@2.10.1", "", { "os": "linux", "cpu": "x64" }, "sha512-2/12bEzsJS9fAKybxgicCDFxYD1WEI9kO+tlDwX5znWG2GwMBaiWcmhGlZ8fi+DMe9CXlcVarMTYc0L3REIRxw=="],
"@tauri-apps/cli-linux-x64-gnu": ["@tauri-apps/cli-linux-x64-gnu@2.11.0", "", { "os": "linux", "cpu": "x64" }, "sha512-5UynPXo3Zq9khjVdAbD+YogeLltdVUeOah2ioSIM3tu6H7wY9vMy6rgGJhv9r5R8ZXmk9GttMippdqYJWrnLnA=="],
"@tauri-apps/cli-linux-x64-musl": ["@tauri-apps/cli-linux-x64-musl@2.10.1", "", { "os": "linux", "cpu": "x64" }, "sha512-Y8J0ZzswPz50UcGOFuXGEMrxbjwKSPgXftx5qnkuMs2rmwQB5ssvLb6tn54wDSYxe7S6vlLob9vt0VKuNOaCIQ=="],
"@tauri-apps/cli-linux-x64-musl": ["@tauri-apps/cli-linux-x64-musl@2.11.0", "", { "os": "linux", "cpu": "x64" }, "sha512-CNz7fHbApz1Zyhhq73jtGn9JqgNEV/lIWnTnUo6h6ujw+mHsTmkLszvJSM8W6JBaDjNpTTFr/RSNoVL5FMwcTg=="],
"@tauri-apps/cli-win32-arm64-msvc": ["@tauri-apps/cli-win32-arm64-msvc@2.10.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-iSt5B86jHYAPJa/IlYw++SXtFPGnWtFJriHn7X0NFBVunF6zu9+/zOn8OgqIWSl8RgzhLGXQEEtGBdR4wzpVgg=="],
"@tauri-apps/cli-win32-arm64-msvc": ["@tauri-apps/cli-win32-arm64-msvc@2.11.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-K+br+VXZ+Xx0n/9FdWohpW5Ugq+2FQUpJScqcPl1hTxXfh3fgjYgt4qA2NgrjlJo+zZPNrmUMl+NLvm0ufEqBQ=="],
"@tauri-apps/cli-win32-ia32-msvc": ["@tauri-apps/cli-win32-ia32-msvc@2.10.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-gXyxgEzsFegmnWywYU5pEBURkcFN/Oo45EAwvZrHMh+zUSEAvO5E8TXsgPADYm31d1u7OQU3O3HsYfVBf2moHw=="],
"@tauri-apps/cli-win32-ia32-msvc": ["@tauri-apps/cli-win32-ia32-msvc@2.11.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-OFV+s3MLZnd75zl0ZAFU5riMpGK4waUEA8ZDuijDsnkU0btz/gHhqh5jVlOn8thyvgdtT3Xyoxqo099MMifH3g=="],
"@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.10.1", "", { "os": "win32", "cpu": "x64" }, "sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg=="],
"@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.11.0", "", { "os": "win32", "cpu": "x64" }, "sha512-AeDTWBd2cOZ6TX133BWsoo+LutG9o0JRcgjMsIfLE13ZugpgCMv/2dJbUiBGeRvbPOGin5A3aYmsArPVV6ZSHQ=="],
"@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-dialog": ["@tauri-apps/plugin-dialog@2.7.1", "", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ=="],
"@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-opener": ["@tauri-apps/plugin-opener@2.5.4", "", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ=="],
"@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=="],
@@ -363,17 +363,17 @@
"@tauri-apps/plugin-window-state": ["@tauri-apps/plugin-window-state@2.4.1", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-OuvdrzyY8Q5Dbzpj+GcrnV1iCeoZbcFdzMjanZMMcAEUNy/6PH5pxZPXpaZLOR7whlzXiuzx0L9EKZbH7zpdRw=="],
"@turbo/darwin-64": ["@turbo/darwin-64@2.9.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-X/56SnVXIQZBLKwniGTwEQTGmtE5brSACnKMBWpY3YafuxVYefrC2acamfjgxP7BG5w3I+6jf0UrLoSzgPcSJg=="],
"@turbo/darwin-64": ["@turbo/darwin-64@2.9.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-wnvOWuVWJ5EUHNKxExEWiGlTeVpLG1L0PCu5MUozyC1P2SHGiWsmpW6/yAuShH91Fa2TAHOvdCRBzriZh4j4Eg=="],
"@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.9.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aalBeSl4agT/QtYGDyf/XLajedWzUC9Vg/pm/YO6QQ93vkQ91Vz5uK1ta5RbVRDozQSz4njxUNqRNmOXDzW+qw=="],
"@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.9.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mA0FIPMwwN3lodDkQYaGxj6PeT7ZaN5aCEbkKn/WB+ZB9yJdVWA4J83GH7t43jqDc5dcnVluVN5UFx3plRiXhA=="],
"@turbo/linux-64": ["@turbo/linux-64@2.9.6", "", { "os": "linux", "cpu": "x64" }, "sha512-YKi05jnNHaD7vevgYwahpzGwbsNNTwzU2c7VZdmdFm7+cGDP4oREUWSsainiMfRqjRuolQxBwRn8wf1jmu+YZA=="],
"@turbo/linux-64": ["@turbo/linux-64@2.9.7", "", { "os": "linux", "cpu": "x64" }, "sha512-fEbUYpgb5l7P+q+5tsWF2gw+/GSjUsuUTcnfm+f0lozUjgcjLKyOat6PgtAChmIFcTPchCL/8rJ3TvkBy01gfA=="],
"@turbo/linux-arm64": ["@turbo/linux-arm64@2.9.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-02o/ZS69cOYEDczXvOB2xmyrtzjQ2hVFtWZK1iqxXUfzMmTjZK4UumrfNnjckSg+gqeBfnPRHa0NstA173Ik3g=="],
"@turbo/linux-arm64": ["@turbo/linux-arm64@2.9.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-VkUjulo9ytfHKUHOS5gy0XPoh4CTKPXWCL8nLdrlHVi9fSut31ECeUqnm/dAbETP5D4xo9mH9XkJ+qMzGe/zmg=="],
"@turbo/windows-64": ["@turbo/windows-64@2.9.6", "", { "os": "win32", "cpu": "x64" }, "sha512-wVdQjvnBI15wB6JrA+43CtUtagjIMmX6XYO758oZHAsCNSxqRlJtdyujih0D8OCnwCRWiGWGI63zAxR0hO6s9g=="],
"@turbo/windows-64": ["@turbo/windows-64@2.9.7", "", { "os": "win32", "cpu": "x64" }, "sha512-/GWdY6/x4aIHqkYJq596Rpdk1x0MkpRPkJcLAoB3yGRwyUms0+u2F1GnV54IbyAZTeKLRWSJKzNC+QwVGdYchA=="],
"@turbo/windows-arm64": ["@turbo/windows-arm64@2.9.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-1XUUyWW0W6FTSqGEhU8RHVqb2wP1SPkr7hIvBlMEwH9jr+sJQK5kqeosLJ/QaUv4ecSAd1ZhIrLoW7qslAzT4A=="],
"@turbo/windows-arm64": ["@turbo/windows-arm64@2.9.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-xBBgxCC5PK2+WZ1PPRZdp+aJ0bMBcEbweXWux3RUHJvX9ZodcoQySkrW6qt+ahb+uk8ZjyQodLfDwtVSoYds1w=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
@@ -465,7 +465,7 @@
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"eslint": ["eslint@10.2.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.5.5", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q=="],
"eslint": ["eslint@10.3.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.5.5", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-XbEXaRva5cF0ZQB8w6MluHA0kZZfV2DuCMJ3ozyEOHLwDpZX2Lmm/7Pp0xdJmI0GL1W05VH5VwIFHEm1Vcw2gw=="],
"eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.1.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" } }, "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g=="],
@@ -529,7 +529,7 @@
"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=="],
"globals": ["globals@17.6.0", "", {}, "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA=="],
"goober": ["goober@2.1.18", "", { "peerDependencies": { "csstype": "^3.0.10" } }, "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw=="],
@@ -627,7 +627,7 @@
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"lucide-react": ["lucide-react@1.8.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw=="],
"lucide-react": ["lucide-react@1.14.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
@@ -703,7 +703,7 @@
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
"rolldown": ["rolldown@1.0.0-rc.16", "", { "dependencies": { "@oxc-project/types": "=0.126.0", "@rolldown/pluginutils": "1.0.0-rc.16" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.16", "@rolldown/binding-darwin-arm64": "1.0.0-rc.16", "@rolldown/binding-darwin-x64": "1.0.0-rc.16", "@rolldown/binding-freebsd-x64": "1.0.0-rc.16", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.16", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.16", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.16", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.16", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.16", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.16", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.16" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-rzi5WqKzEZw3SooTt7cgm4eqIoujPIyGcJNGFL7iPEuajQw7vxMHUkXylu4/vhCkJGXsgRmxqMKXUpT6FEgl0g=="],
"rolldown": ["rolldown@1.0.0-rc.17", "", { "dependencies": { "@oxc-project/types": "=0.127.0", "@rolldown/pluginutils": "1.0.0-rc.17" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.17", "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", "@rolldown/binding-darwin-x64": "1.0.0-rc.17", "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA=="],
"rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="],
@@ -739,7 +739,7 @@
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"turbo": ["turbo@2.9.6", "", { "optionalDependencies": { "@turbo/darwin-64": "2.9.6", "@turbo/darwin-arm64": "2.9.6", "@turbo/linux-64": "2.9.6", "@turbo/linux-arm64": "2.9.6", "@turbo/windows-64": "2.9.6", "@turbo/windows-arm64": "2.9.6" }, "bin": { "turbo": "bin/turbo" } }, "sha512-+v2QJey7ZUeUiuigkU+uFfklvNUyPI2VO2vBpMYJA+a1hKFLFiKtUYlRHdb3P9CrAvMzi0upbjI4WT+zKtqkBg=="],
"turbo": ["turbo@2.9.7", "", { "optionalDependencies": { "@turbo/darwin-64": "2.9.7", "@turbo/darwin-arm64": "2.9.7", "@turbo/linux-64": "2.9.7", "@turbo/linux-arm64": "2.9.7", "@turbo/windows-64": "2.9.7", "@turbo/windows-arm64": "2.9.7" }, "bin": { "turbo": "bin/turbo" } }, "sha512-epxzqVO2s0IxcSWcgb+qKrtco8isfe7g3VtiS6hkYnEK4A9XQDZbrtavQ6MtWR1KoQn+1fUomaQth2rfRHlUlg=="],
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
@@ -757,7 +757,7 @@
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
"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=="],
"vite": ["vite@8.0.10", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.10", "rolldown": "1.0.0-rc.17", "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-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw=="],
"void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="],
@@ -807,11 +807,17 @@
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@tauri-apps/plugin-process/@tauri-apps/api": ["@tauri-apps/api@2.10.1", "", {}, "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw=="],
"@tauri-apps/plugin-updater/@tauri-apps/api": ["@tauri-apps/api@2.10.1", "", {}, "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw=="],
"@tauri-apps/plugin-window-state/@tauri-apps/api": ["@tauri-apps/api@2.10.1", "", {}, "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw=="],
"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=="],
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.17", "", {}, "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg=="],
"vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
+2 -2
View File
@@ -36,8 +36,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
# Install `uv` for blazing-fast reliable pip resolution
RUN pip install --no-cache-dir uv
# Copy python packaging specs
COPY pyproject.toml uv.lock ./
# Copy python packaging specs (README.md required by hatchling metadata)
COPY pyproject.toml uv.lock README.md ./
# Install the project (non-editable — no need for -e in containers).
# Uses `uv` for exponentially faster resolution than plain pip.
@@ -2,8 +2,8 @@
# OmniVoice Studio — Docker Compose
#
# Quick start:
# docker compose up # CPU mode
# docker compose --profile gpu up # NVIDIA GPU mode
# docker compose -f deploy/docker-compose.yml up # CPU mode
# docker compose -f deploy/docker-compose.yml --profile gpu up # GPU mode
#
# First run downloads ~4 GB of models. Progress is shown in logs.
# Open http://localhost:3900 once the health check passes.
@@ -19,7 +19,12 @@
services:
# ── CPU mode (default) ──────────────────────────────────────
omnivoice:
build: .
image: ghcr.io/debpalash/omnivoice-studio:latest
# To build from source instead of pulling, comment out `image:` and
# uncomment the two lines below:
# build:
# context: ..
# dockerfile: deploy/Dockerfile
container_name: omnivoice-studio
ports:
- "127.0.0.1:3900:3900"
@@ -40,7 +45,10 @@ services:
# ── GPU mode — activate with: docker compose --profile gpu up
omnivoice-gpu:
build: .
image: ghcr.io/debpalash/omnivoice-studio:latest
# build:
# context: ..
# dockerfile: deploy/Dockerfile
container_name: omnivoice-studio-gpu
profiles: ["gpu"]
ports:
View File
+21 -17
View File
@@ -8,10 +8,8 @@ Every folder has a single job. Every file at the root earns its place.
OmniVoice/
├── README.md ⟵ user-facing overview
├── ROADMAP.md ⟵ where this project is going
├── STRUCTURE.md ⟵ you are here
├── CHANGELOG.md ⟵ release history
├── LICENSE
├── preview.png ⟵ referenced by README
├── pyproject.toml ⟵ Python project manifest
├── uv.lock ⟵ Python lockfile
@@ -19,10 +17,9 @@ OmniVoice/
├── bun.lock ⟵ JS lockfile
├── turbo.json ⟵ turborepo pipeline
├── Dockerfile single-stage CUDA image
├── docker-compose.yml ⟵ one-click local deployment
├── .dockerignore
├── .dockerignoreDocker build context filter
├── backend.spec ⟵ pyinstaller spec (stays at root by pyinstaller convention)
├── alembic.ini ⟵ DB migration config (stays at root by alembic convention)
├── .env ⟵ user config; gitignored, .env.example is the template
├── .gitignore
@@ -39,7 +36,8 @@ OmniVoice/
│ │ ├── pages/ one file per top-level view
│ │ ├── components/ reusable UI
│ │ ├── api/ typed API clients
│ │ ├── store/ Zustand slices (arrives Phase 1)
│ │ ├── store/ Zustand slices
│ │ ├── hooks/ custom React hooks
│ │ └── utils/
│ ├── src-tauri/ Rust desktop shell
│ └── public/
@@ -62,8 +60,22 @@ OmniVoice/
│ └── frontend/ Node-based frontend tests
├── scripts/ ⟵ dev / build / release shell + python scripts
│ ├── install.sh universal installer
│ ├── run.sh universal launcher
│ ├── smoke-test.sh end-to-end validation
│ └── desktop-prod.sh production desktop build
├── docs/ developer and model documentation
├── deploy/Docker deployment configs
│ ├── Dockerfile single-stage CUDA image
│ └── docker-compose.yml one-click local deployment
├── docs/ ⟵ developer docs, screenshots, branding
│ ├── ROADMAP.md where this project is going
│ ├── STRUCTURE.md you are here
│ ├── mcp.json MCP config template
│ ├── preview.png README hero image
│ ├── logo.png, logo.svg branding assets
│ ├── screenshot-*.png feature screenshots
│ ├── languages.md
│ ├── training.md
│ ├── data_preparation.md
@@ -72,15 +84,7 @@ OmniVoice/
├── design/ ⟵ ASCII mockups of the target UX
│ ├── README.md
── 00-architecture.md
│ ├── 01-launchpad.md
│ ├── 02-dub-studio.md
│ ├── 03-voice-library.md
│ ├── 04-translation-workbench.md
│ ├── 05-batch-queue.md
│ ├── 06-export-center.md
│ ├── 07-tools.md
│ └── 08-settings.md
── 0008-*.md per-feature specs
├── research/ ⟵ reference material, competitor analysis, archived code
│ ├── LEARNINGS.md competitive analysis, what to absorb
View File
View File

Before

Width:  |  Height:  |  Size: 358 KiB

After

Width:  |  Height:  |  Size: 358 KiB

+13 -13
View File
@@ -1,11 +1,11 @@
{
"name": "omnivoice-studio",
"private": true,
"version": "0.2.6",
"version": "0.2.7",
"type": "module",
"scripts": {
"dev": "vite",
"desktop": "TAURI_SKIP_BACKEND=1 tauri dev",
"desktop": "tauri dev",
"build": "vite build",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
@@ -26,39 +26,39 @@
"@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",
"@tailwindcss/vite": "^4.2.4",
"@tanstack/react-query": "^5.100.8",
"@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-dialog": "^2.7.1",
"@tauri-apps/plugin-opener": "^2.5.4",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1",
"@tauri-apps/plugin-window-state": "^2.4.1",
"i18next": "^26.0.8",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^1.8.0",
"lucide-react": "^1.14.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-hot-toast": "^2.6.0",
"react-i18next": "^17.0.6",
"react-window": "^2.2.7",
"tailwindcss": "4",
"tailwindcss": "^4.2.4",
"wavesurfer.js": "^7.12.6",
"zustand": "^5.0.12"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@tauri-apps/api": "^2.10.1",
"@tauri-apps/cli": "^2.10.1",
"@tauri-apps/api": "^2.11.0",
"@tauri-apps/cli": "^2.11.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.2.1",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.5.0",
"globals": "^17.6.0",
"typescript": "^6.0.3",
"vite": "^8.0.9"
"vite": "^8.0.10"
}
}
+391 -590
View File
File diff suppressed because it is too large Load Diff
+12 -20
View File
@@ -1,27 +1,25 @@
[package]
name = "app"
version = "0.2.6"
description = "A Tauri App"
authors = ["you"]
license = ""
name = "omnivoice-studio"
version = "0.2.7"
description = "OmniVoice Studio AI voice cloning & dubbing desktop app"
authors = ["Debpalash"]
license = "AGPL-3.0"
repository = ""
edition = "2021"
rust-version = "1.77.2"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
name = "app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2.5.6", features = [] }
tauri-build = { version = "2.6.0", features = [] }
[dependencies]
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
log = "0.4"
tauri = { version = "2.10.3", features = ["macos-private-api", "protocol-asset", "tray-icon", "image-png"] }
tauri = { version = "2.11.0", features = ["macos-private-api", "protocol-asset", "tray-icon", "image-png"] }
tauri-plugin-log = "2"
tauri-plugin-dialog = "2"
tauri-plugin-window-state = "2.0.0"
@@ -34,24 +32,18 @@ tauri-plugin-single-instance = "2"
# Cross-platform keyboard simulation for auto-paste after dictation
enigo = { version = "0.3", features = ["serde"] }
# First-run bootstrap: the installer ships ~10 MB with only the Tauri
# shell + pyproject.toml + uv.lock + backend source. On first launch the
# Rust setup hook downloads the standalone `uv` binary, creates a venv at
# `app_local_data_dir/venv`, and runs `uv sync` against the bundled
# pyproject.toml — which installs torch, whisperx, faster-whisper, etc.
# ureq fetches the `uv` archive; flate2 + tar extract it on Unix; zip
# does the same on Windows (Astral publishes .zip for Windows only).
# First-run bootstrap: on first launch the Rust setup hook installs `uv`
# via the official Astral installer script (curl|sh on Unix, irm|iex on
# Windows), creates a Python 3.11 venv, and runs `uv sync` against the
# bundled pyproject.toml — which installs torch, whisperx, etc.
# ureq is used for HTTP health checks and ffmpeg downloads.
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"] }
+6
View File
@@ -0,0 +1,6 @@
# Binaries dropped here are either build-time placeholders (created by
# build.rs) or real per-target binaries fetched in CI before the
# tauri-action bundle step. None of them belong in version control.
uv-*
ffmpeg-*
ffprobe-*
+39 -1
View File
@@ -1,3 +1,41 @@
use std::path::PathBuf;
// Create a placeholder `binaries/<name>-<target-triple>` file if one doesn't
// already exist for the current target. Tauri's `bundle.externalBin`
// config is validated at every build (including `cargo check`), and it
// hard-errors when the source binary is missing — which it is in dev,
// because the real binaries are only fetched during release builds in CI.
// The placeholder is empty (zero bytes) and cannot actually be run;
// `find_bundled_*()` at runtime falls back to PATH or pip-bundled binaries
// when the bundled file isn't a real executable. CI overwrites these files
// with the real binaries before the tauri-action bundle step.
fn ensure_sidecar_placeholder(name: &str) {
let triple = std::env::var("TARGET").unwrap_or_default();
if triple.is_empty() {
return;
}
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".into());
let binaries_dir = PathBuf::from(&manifest_dir).join("binaries");
let _ = std::fs::create_dir_all(&binaries_dir);
let suffix = if triple.contains("windows") { ".exe" } else { "" };
let target_path = binaries_dir.join(format!("{}-{}{}", name, triple, suffix));
if !target_path.exists() {
let _ = std::fs::write(&target_path, b"");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = std::fs::metadata(&target_path) {
let mut perms = meta.permissions();
perms.set_mode(0o755);
let _ = std::fs::set_permissions(&target_path, perms);
}
}
}
}
fn main() {
tauri_build::build()
ensure_sidecar_placeholder("uv");
ensure_sidecar_placeholder("ffmpeg");
ensure_sidecar_placeholder("ffprobe");
tauri_build::build();
}
+250
View File
@@ -0,0 +1,250 @@
//! Backend process management: spawn, port probing, log paths.
use std::fs;
use std::io::BufRead;
use std::io::BufReader;
use std::net::{TcpStream, ToSocketAddrs};
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tauri::Manager;
use crate::bootstrap::{
BootstrapStage, emit_log, ensure_venv_ready, set_stage,
};
use crate::config::load_config;
use crate::tools::{resolve_ffmpeg, resolve_ffprobe};
use crate::backend_port;
// ── Port probing ──────────────────────────────────────────────────────────
/// Just "something is listening on :port"
pub fn port_in_use(port: u16) -> bool {
TcpStream::connect_timeout(
&(std::net::Ipv4Addr::LOCALHOST, port).into(),
Duration::from_millis(200),
)
.is_ok()
}
/// Full health check — returns true only if the responder at :port is
/// actually our OmniVoice backend.
pub fn backend_healthy(port: u16) -> bool {
let url = format!("http://127.0.0.1:{}/system/info", port);
match ureq_get_with_timeout(&url, Duration::from_millis(500)) {
Ok(body) => body.contains("\"model_checkpoint\"") || body.contains("\"data_dir\""),
Err(_) => false,
}
}
fn ureq_get_with_timeout(url: &str, timeout: Duration) -> Result<String, String> {
let url = url.strip_prefix("http://").ok_or("only http:// supported")?;
let (host_port, path) = match url.find('/') {
Some(i) => (&url[..i], &url[i..]),
None => (url, "/"),
};
let mut stream = TcpStream::connect_timeout(
&host_port
.to_socket_addrs()
.map_err(|e| e.to_string())?
.next()
.ok_or("unresolvable")?,
timeout,
)
.map_err(|e| e.to_string())?;
stream
.set_read_timeout(Some(timeout))
.map_err(|e| e.to_string())?;
stream
.set_write_timeout(Some(timeout))
.map_err(|e| e.to_string())?;
let req = format!(
"GET {} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\n\r\n",
path, host_port
);
use std::io::{Read, Write};
stream.write_all(req.as_bytes()).map_err(|e| e.to_string())?;
let mut buf = String::new();
stream.read_to_string(&mut buf).map_err(|e| e.to_string())?;
if let Some(idx) = buf.find("\r\n\r\n") {
Ok(buf[idx + 4..].to_string())
} else {
Err("no body".into())
}
}
/// Kill whatever process owns the port.
#[cfg(unix)]
pub fn kill_orphan_on_port(port: u16) {
if let Ok(out) = Command::new("lsof")
.args(["-ti", &format!(":{}", port)])
.output()
{
if out.status.success() {
let pids = String::from_utf8_lossy(&out.stdout);
for pid in pids.split_whitespace() {
if let Ok(pid_n) = pid.parse::<i32>() {
log::warn!("Killing orphan process {} on port {}", pid_n, port);
unsafe {
libc::kill(pid_n, libc::SIGKILL);
}
}
}
}
}
}
#[cfg(not(unix))]
pub fn kill_orphan_on_port(_port: u16) {}
// ── Log paths ─────────────────────────────────────────────────────────────
pub fn backend_log_path() -> PathBuf {
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 {
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")
}
/// Read the last N lines from backend_err.log for diagnostic messages.
pub fn read_error_log_tail(max_lines: usize) -> String {
let err_path = backend_log_path().with_file_name("backend_err.log");
match fs::read_to_string(&err_path) {
Ok(content) => {
let lines: Vec<&str> = content.lines().collect();
let start = lines.len().saturating_sub(max_lines);
lines[start..].join("\n")
}
Err(_) => String::new(),
}
}
// ── Spawn the backend via the bootstrapped venv Python ────────────────────
pub 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!(
"Spawning backend — log: {} · err: {}",
log_path.display(),
err_path.display(),
);
let (python, backend_dir) = match ensure_venv_ready(app, progress) {
Some(x) => x,
None => {
log::error!("Venv bootstrap failed — backend not started");
return None;
}
};
if let Some(p) = progress {
set_stage(p, BootstrapStage::StartingBackend);
}
let stdout_file = fs::File::create(&log_path).ok();
let err_log_file = fs::File::create(&err_path).ok();
let mut env: Vec<(String, String)> = vec![("PYTHONUNBUFFERED".into(), "1".into())];
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 Ok(hf_ep) = std::env::var("HF_ENDPOINT") {
env.push(("HF_ENDPOINT".into(), hf_ep));
} else {
let cfg = load_config(app);
if cfg.region == "china" {
env.push(("HF_ENDPOINT".into(), "https://hf-mirror.com".into()));
}
}
let app_data = app.path().app_local_data_dir().unwrap_or_default();
if let Some(ffmpeg_path) = resolve_ffmpeg(app, &app_data) {
env.push(("FFMPEG_PATH".into(), ffmpeg_path.to_string_lossy().into()));
}
if let Some(ffprobe_path) = resolve_ffprobe(app, &app_data) {
env.push(("FFPROBE_PATH".into(), ffprobe_path.to_string_lossy().into()));
}
let mut cmd = Command::new(&python);
for (k, v) in &env {
cmd.env(k, v);
}
let mut child = match cmd
.args([
"-m",
"uvicorn",
"main:app",
"--app-dir",
backend_dir.to_string_lossy().as_ref(),
"--host",
"127.0.0.1",
"--port",
&backend_port().to_string(),
])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
{
Ok(c) => {
log::info!(
"Backend started via venv python {} (pid {})",
python.display(),
c.id()
);
c
}
Err(e) => {
log::error!("Failed to spawn backend: {}", e);
return None;
}
};
if let Some(stdout_pipe) = child.stdout.take() {
let app_clone = app.clone();
let mut out_file = stdout_file;
std::thread::spawn(move || {
use std::io::Write;
let reader = BufReader::new(stdout_pipe);
for line in reader.lines().flatten() {
log::info!("[backend_stdout] {}", line);
emit_log(&app_clone, "starting_backend", &line);
if let Some(ref mut f) = out_file {
let _ = writeln!(f, "{}", line);
}
}
});
}
if let Some(stderr_pipe) = child.stderr.take() {
let app_clone = app.clone();
std::thread::spawn(move || {
use std::io::Write;
let reader = BufReader::new(stderr_pipe);
let mut log_file = err_log_file;
for line in reader.lines().flatten() {
log::info!("[backend_stderr] {}", line);
emit_log(&app_clone, "starting_backend", &line);
if let Some(ref mut f) = log_file {
let _ = writeln!(f, "{}", line);
}
}
});
}
Some(child)
}
+422
View File
@@ -0,0 +1,422 @@
//! Bootstrap progress tracking, venv creation, and retry commands.
use std::fs;
use std::io::{self, BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use serde::Serialize;
use tauri::{Emitter, Manager};
use crate::config::get_effective_region;
use crate::tools::resolve_uv;
use crate::{BackendState, backend_port};
// ── Bootstrap stages ──────────────────────────────────────────────────────
#[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,
/// 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>>,
pub logs: Arc<Mutex<Vec<LogPayload>>>,
}
pub fn set_stage(state: &Arc<Mutex<BootstrapStage>>, stage: BootstrapStage) {
if let Ok(mut guard) = state.lock() {
*guard = stage;
}
}
// ── Splash log + byte-progress event channel ─────────────────────────────
#[derive(Clone, Serialize)]
pub struct LogPayload {
pub stage: String,
pub line: String,
}
pub fn emit_log<R: tauri::Runtime>(app: &tauri::AppHandle<R>, stage: &str, line: &str) {
let payload = LogPayload { stage: stage.to_string(), line: line.to_string() };
// Buffer the log so the frontend can backfill on mount.
if let Some(state) = app.try_state::<BootstrapState>() {
if let Ok(mut logs) = state.logs.lock() {
logs.push(payload.clone());
}
}
let _ = app.emit("bootstrap-log", payload);
}
/// Stream stdout+stderr of a long-running subprocess line-by-line into the
/// splash log panel.
pub 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 commands ────────────────────────────────────────────────────────
#[tauri::command]
pub fn bootstrap_status(state: tauri::State<'_, BootstrapState>) -> BootstrapStage {
state
.stage
.lock()
.map(|g| g.clone())
.unwrap_or(BootstrapStage::Checking)
}
#[tauri::command]
pub fn get_bootstrap_logs(state: tauri::State<'_, BootstrapState>) -> Vec<LogPayload> {
state
.logs
.lock()
.map(|g| g.clone())
.unwrap_or_default()
}
#[tauri::command]
pub fn retry_bootstrap(app: tauri::AppHandle, state: tauri::State<'_, BootstrapState>) {
if let Ok(mut guard) = state.stage.lock() {
*guard = BootstrapStage::Checking;
}
if let Ok(mut logs) = state.logs.lock() {
logs.clear();
}
let stage_handle = state.stage.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 crate::backend::backend_healthy(backend_port()) {
log::info!("Port {} already serving OmniVoice backend — attaching", backend_port());
set_stage(&stage_handle, BootstrapStage::Ready);
return;
}
if crate::backend::port_in_use(backend_port()) {
log::warn!("Port {} in use — taking ownership", backend_port());
crate::backend::kill_orphan_on_port(backend_port());
std::thread::sleep(Duration::from_millis(500));
}
let child = crate::backend::spawn_backend(&app, Some(&stage_handle));
if let Ok(mut guard) = app.state::<BackendState>().process.lock() {
*guard = child;
}
let start = std::time::Instant::now();
while start.elapsed() < Duration::from_secs(300) {
if crate::backend::backend_healthy(backend_port()) {
set_stage(&stage_handle, BootstrapStage::Ready);
return;
}
let process_dead = if let Ok(mut guard) = app.state::<BackendState>().process.lock() {
match guard.as_mut() {
Some(child) => match child.try_wait() {
Ok(Some(status)) => Some(status.to_string()),
Ok(None) => None,
Err(_) => Some("unknown".to_string()),
},
None => Some("never started".to_string()),
}
} else {
None
};
if let Some(exit_info) = process_dead {
let err_tail = crate::backend::read_error_log_tail(30);
let msg = if err_tail.is_empty() {
format!("Backend process exited ({}) — no error output captured", exit_info)
} else {
format!("Backend process exited ({}):\n{}", exit_info, err_tail)
};
log::error!("Backend died early: {}", msg);
set_stage(&stage_handle, BootstrapStage::Failed { message: msg });
return;
}
std::thread::sleep(Duration::from_millis(500));
}
let err_tail = crate::backend::read_error_log_tail(20);
let msg = if err_tail.is_empty() {
"Backend did not respond within 300 s".to_string()
} else {
format!("Backend did not respond within 300 s. Last stderr output:\n{}", err_tail)
};
set_stage(&stage_handle, BootstrapStage::Failed { message: msg });
});
}
#[tauri::command]
pub fn clean_and_retry_bootstrap(app: tauri::AppHandle, state: tauri::State<'_, BootstrapState>) {
if let Ok(data_dir) = app.path().app_local_data_dir() {
let project_dir = data_dir.join("project");
if project_dir.is_dir() {
log::info!("Clean retry: removing {}", project_dir.display());
let _ = fs::remove_dir_all(&project_dir);
}
}
retry_bootstrap(app, state);
}
// ── Venv bootstrap ────────────────────────────────────────────────────────
pub fn venv_python_path(venv: &Path) -> PathBuf {
if cfg!(windows) {
venv.join("Scripts").join("python.exe")
} else {
venv.join("bin").join("python")
}
}
/// Recursive directory copy that skips `__pycache__` and any dotfile dirs.
pub fn copy_dir_recursive(src: &Path, dst: &Path) -> io::Result<()> {
fs::create_dir_all(dst)?;
for entry in fs::read_dir(src)? {
let entry = entry?;
let src_path = entry.path();
let file_name = entry.file_name();
let name_str = file_name.to_string_lossy();
if src_path.is_dir() {
if name_str == "__pycache__" || name_str.starts_with('.') {
continue;
}
copy_dir_recursive(&src_path, &dst.join(&file_name))?;
} else if name_str.ends_with(".pyc") {
continue;
} else {
fs::copy(&src_path, &dst.join(&file_name))?;
}
}
Ok(())
}
/// Dev-mode fallback: running from the source tree (`bun run dev`).
pub fn find_dev_project_root() -> Option<PathBuf> {
let candidates = [
PathBuf::from("../../"), // from frontend/src-tauri
PathBuf::from("."), // from project root
PathBuf::from(".."), // from frontend/
];
for c in &candidates {
if c.join("backend/main.py").is_file() {
return Some(c.clone());
}
}
None
}
/// Prepare (and on first run, create) the Python venv that will host the
/// backend process. Returns (venv_python, backend_source_dir).
pub 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);
if dev_py.is_file() {
let backend_dir = dev_root.join("backend");
if backend_dir.is_dir() {
return Some((dev_py, backend_dir));
}
}
}
let app_data = app.path().app_local_data_dir().ok()?;
let project_dir = app_data.join("project");
let venv_dir = project_dir.join(".venv");
let venv_py = venv_python_path(&venv_dir);
let backend_dir = project_dir.join("backend");
if venv_py.is_file() && backend_dir.is_dir() {
let uvicorn_check = Command::new(&venv_py)
.args(["-c", "import uvicorn"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
if matches!(uvicorn_check, Ok(ref s) if s.success()) {
return Some((venv_py, backend_dir));
}
log::warn!(
"Venv exists at {} but uvicorn is not importable — re-running uv sync",
venv_dir.display()
);
if let Some(p) = progress {
set_stage(p, BootstrapStage::InstallingDeps);
}
let uv_path = match resolve_uv(app, &app_data, progress) {
Ok(p) => p,
Err(e) => { fail(progress, &e); return None; }
};
let mut repair_cmd = Command::new(&uv_path);
let has_lockfile = project_dir.join("uv.lock").is_file();
if has_lockfile {
repair_cmd.args(["sync", "--frozen", "--no-dev", "--verbose"]);
} else {
repair_cmd.args(["sync", "--no-dev", "--verbose"]);
}
repair_cmd.current_dir(&project_dir);
let repair_status = run_streaming(app, "installing_deps", &mut repair_cmd);
if matches!(repair_status, Ok(ref s) if s.success()) {
return Some((venv_py, backend_dir));
}
fail(progress, &format!("Repair uv sync failed: {:?}", repair_status));
return None;
}
let resource_dir = app.path().resource_dir().ok()?;
let flat = resource_dir.clone();
let up2 = resource_dir.join("_up_").join("_up_");
let (resource_pyproject, resource_uvlock, resource_readme, resource_omnivoice, resource_backend) = if flat.join("pyproject.toml").is_file() {
(flat.join("pyproject.toml"), flat.join("uv.lock"), flat.join("README.md"), flat.join("omnivoice"), flat.join("backend"))
} else if up2.join("pyproject.toml").is_file() {
(up2.join("pyproject.toml"), up2.join("uv.lock"), up2.join("README.md"), up2.join("omnivoice"), up2.join("backend"))
} else {
fail(progress, &format!(
"Missing bootstrap resources — checked flat={} and _up_={}",
flat.display(), up2.display()));
return None;
};
if !resource_pyproject.is_file() || !resource_backend.is_dir() {
fail(progress, &format!(
"Missing bootstrap resources (pyproject={}, backend={})",
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) {
fail(progress, &format!("mkdir {} failed: {}", project_dir.display(), e));
return None;
}
if let Err(e) = fs::copy(&resource_pyproject, project_dir.join("pyproject.toml")) {
fail(progress, &format!("copy pyproject.toml: {}", e));
return None;
}
if resource_uvlock.is_file() {
if let Err(e) = fs::copy(&resource_uvlock, project_dir.join("uv.lock")) {
log::warn!("Could not copy uv.lock (will use non-frozen sync): {}", e);
}
} else {
log::warn!("No uv.lock in bundle — uv sync will resolve from scratch");
}
if resource_readme.is_file() {
let _ = fs::copy(&resource_readme, project_dir.join("README.md"));
} else if !project_dir.join("README.md").exists() {
let _ = fs::write(project_dir.join("README.md"), "# OmniVoice\n");
log::warn!("No README.md in bundle — created stub");
}
let omnivoice_dir = project_dir.join("omnivoice");
if resource_omnivoice.is_dir() {
if let Err(e) = copy_dir_recursive(&resource_omnivoice, &omnivoice_dir) {
log::warn!("Could not copy omnivoice/ source package: {}", e);
}
} else {
log::warn!("No omnivoice/ in bundle — model preload may fail");
}
if let Err(e) = copy_dir_recursive(&resource_backend, &backend_dir) {
fail(progress, &format!("copy backend/: {}", e));
return None;
}
let uv_path = match resolve_uv(app, &app_data, progress) {
Ok(p) => p,
Err(e) => { fail(progress, &e); return None; }
};
log::info!("Bootstrap uv: {}", uv_path.display());
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", "--managed-python"]).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;
}
if let Some(p) = progress {
set_stage(p, BootstrapStage::InstallingDeps);
}
let mut sync_cmd = Command::new(&uv_path);
let has_lockfile = project_dir.join("uv.lock").is_file();
if has_lockfile {
sync_cmd
.args(["sync", "--frozen", "--no-dev", "--verbose"])
.current_dir(&project_dir);
} else {
log::info!("No uv.lock present, running uv sync without --frozen");
sync_cmd
.args(["sync", "--no-dev", "--verbose"])
.current_dir(&project_dir);
}
let effective_region = get_effective_region(app);
if effective_region == "china" {
sync_cmd.env("UV_INDEX_URL", "https://mirrors.aliyun.com/pypi/simple/");
}
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;
}
Some((venv_py, backend_dir))
}
+356
View File
@@ -0,0 +1,356 @@
//! Tauri IPC commands: sysinfo, logs, HF cache, paste, tray, quit, dictation shortcut.
use std::fs;
use std::path::PathBuf;
use std::sync::atomic::Ordering;
use std::time::Duration;
use serde::Serialize;
use tauri::image::Image;
use crate::{AppFlags, TrayHandle, DictationShortcutState};
use crate::{TRAY_ICON_DEFAULT, TRAY_ICON_RECORDING};
use crate::config::{load_config, save_config};
// ── System metrics ────────────────────────────────────────────────────────
#[derive(Serialize, Clone)]
pub struct SysinfoPayload {
cpu: f64,
ram: f64,
total_ram: f64,
vram: f64,
gpu_active: bool,
}
#[tauri::command]
pub fn get_sysinfo() -> SysinfoPayload {
use sysinfo::System;
let mut sys = System::new();
sys.refresh_cpu_usage();
sys.refresh_memory();
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: 0.0,
gpu_active: false,
}
}
// ── Log tail ──────────────────────────────────────────────────────────────
#[derive(Serialize, Clone)]
pub struct LogTailPayload {
lines: Vec<String>,
path: String,
exists: bool,
total_lines: usize,
}
#[tauri::command]
pub 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,
},
}
}
fn backend_runtime_log_path() -> PathBuf {
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 {
PathBuf::from(
std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()),
)
.join(".omnivoice")
};
data_dir.join("omnivoice.log")
}
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()),
)
}
}
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 {
PathBuf::from(&home)
.join(".local/share")
.join(bid)
.join("logs")
.join("tauri.log")
}
}
// ── HuggingFace cache scan ────────────────────────────────────────────────
#[derive(Serialize, Clone)]
struct HfCacheRepo {
repo_id: String,
size_on_disk: u64,
nb_files: usize,
}
#[derive(Serialize, Clone)]
pub struct HfCacheScanResult {
repos: Vec<HfCacheRepo>,
cache_dir: String,
}
#[tauri::command]
pub 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(),
};
}
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;
}
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(),
}
}
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")
}
// ── Simulate paste ────────────────────────────────────────────────────────
use enigo::{Direction, Enigo, Key, Keyboard, Settings as EnigoSettings};
#[tauri::command]
pub fn simulate_paste() -> Result<(), String> {
std::thread::sleep(Duration::from_millis(80));
let mut enigo = Enigo::new(&EnigoSettings::default())
.map_err(|e| format!("Failed to init keyboard sim: {e}"))?;
#[cfg(target_os = "macos")]
{
enigo.key(Key::Meta, Direction::Press)
.map_err(|e| format!("key press failed: {e}"))?;
enigo.key(Key::Unicode('v'), Direction::Click)
.map_err(|e| format!("key click failed: {e}"))?;
enigo.key(Key::Meta, Direction::Release)
.map_err(|e| format!("key release failed: {e}"))?;
}
#[cfg(not(target_os = "macos"))]
{
enigo.key(Key::Control, Direction::Press)
.map_err(|e| format!("key press failed: {e}"))?;
enigo.key(Key::Unicode('v'), Direction::Click)
.map_err(|e| format!("key click failed: {e}"))?;
enigo.key(Key::Control, Direction::Release)
.map_err(|e| format!("key release failed: {e}"))?;
}
Ok(())
}
// ── Tray icon swap ────────────────────────────────────────────────────────
#[tauri::command]
pub fn set_tray_recording(
recording: bool,
tray_handle: tauri::State<'_, TrayHandle>,
) -> Result<(), String> {
let bytes = if recording { TRAY_ICON_RECORDING } else { TRAY_ICON_DEFAULT };
let img = Image::from_bytes(bytes).map_err(|e| format!("decode tray icon: {e}"))?;
let lock = tray_handle.tray.lock().map_err(|_| "tray lock poisoned")?;
if let Some(ref tray) = *lock {
tray.set_icon(Some(img)).map_err(|e| format!("set_icon: {e}"))?;
}
Ok(())
}
// ── Quit ──────────────────────────────────────────────────────────────────
#[tauri::command]
pub fn quit_app(app: tauri::AppHandle, flags: tauri::State<'_, AppFlags>) {
flags.quitting.store(true, Ordering::SeqCst);
app.exit(0);
}
// ── Dictation hotkey ──────────────────────────────────────────────────────
#[tauri::command]
pub fn get_dictation_shortcut(app: tauri::AppHandle) -> String {
load_config(&app).dictation_shortcut
}
#[tauri::command]
pub fn set_dictation_shortcut(
app: tauri::AppHandle,
accelerator: String,
state: tauri::State<'_, DictationShortcutState>,
) -> Result<String, String> {
use std::str::FromStr;
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut};
let parsed = Shortcut::from_str(&accelerator)
.map_err(|e| format!("Invalid shortcut '{accelerator}': {e}"))?;
let gs = app.global_shortcut();
let mut slot = state.current.lock().map_err(|_| "shortcut lock poisoned")?;
let prev = slot.take();
if let Some(ref p) = prev {
let _ = gs.unregister(p.clone());
}
if let Err(e) = gs.register(parsed.clone()) {
if let Some(p) = prev {
if gs.register(p.clone()).is_ok() {
*slot = Some(p);
}
}
return Err(format!("Failed to register '{accelerator}': {e}"));
}
*slot = Some(parsed);
drop(slot);
let mut cfg = load_config(&app);
cfg.dictation_shortcut = accelerator.clone();
save_config(&app, &cfg);
log::info!("Dictation shortcut updated to {accelerator}");
Ok(accelerator)
}
+125
View File
@@ -0,0 +1,125 @@
//! Persistent app configuration (region, dictation shortcut) and region helpers.
use std::fs;
use std::path::PathBuf;
use tauri::Manager;
use std::time::Duration;
use serde::{Deserialize, Serialize};
// ── Persistent app config ─────────────────────────────────────────────────
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AppConfig {
/// Region for download mirrors.
/// "auto" | "global" | "china" | "russia" | "restricted"
///
/// - auto: probe github.com; use ghproxy if unreachable
/// - global: direct downloads (github.com, pypi.org, huggingface.co)
/// - china: ghproxy.net + mirrors.aliyun.com + hf-mirror.com
/// - russia: ghproxy.net for GitHub; direct for PyPI/HF
/// - restricted: ghproxy.net for GitHub (catch-all for MENA, Africa, etc.)
#[serde(default = "default_region")]
pub region: String,
/// Accelerator string for the global dictation hotkey, e.g.
/// "CmdOrCtrl+Shift+Space". Parsed by tauri-plugin-global-shortcut at
/// register time. Falls back to the platform default when missing or
/// unparseable.
#[serde(default = "default_dictation_shortcut")]
pub dictation_shortcut: String,
}
pub fn default_region() -> String { "auto".into() }
pub fn default_dictation_shortcut() -> String { "CmdOrCtrl+Shift+Space".into() }
impl Default for AppConfig {
fn default() -> Self {
Self {
region: default_region(),
dictation_shortcut: default_dictation_shortcut(),
}
}
}
pub fn config_path<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> Option<PathBuf> {
app.path().app_local_data_dir().ok().map(|d: PathBuf| d.join("config.json"))
}
pub fn load_config<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> AppConfig {
config_path(app)
.and_then(|p| fs::read_to_string(&p).ok())
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
pub fn save_config<R: tauri::Runtime>(app: &tauri::AppHandle<R>, cfg: &AppConfig) {
if let Some(p) = config_path(app) {
if let Some(parent) = p.parent() {
let _ = fs::create_dir_all(parent);
}
let _ = fs::write(&p, serde_json::to_string_pretty(cfg).unwrap_or_default());
}
}
// ── Region helpers ────────────────────────────────────────────────────────
pub const VALID_REGIONS: &[&str] = &["auto", "global", "china", "russia", "restricted"];
/// Resolve a raw GitHub URL through the appropriate mirror for the given region.
/// If the region uses a proxy, prepends the proxy prefix.
#[allow(dead_code)] // Used in cfg(linux) and cfg(windows) FFmpeg download blocks
pub fn resolve_github_url(raw_github_url: &str, region: &str) -> String {
match region {
"china" | "russia" | "restricted" => format!("https://ghproxy.net/{}", raw_github_url),
_ => raw_github_url.to_string(),
}
}
/// Probe github.com reachability with a fast HEAD request.
/// Returns the effective region: "global" if reachable, "restricted" if not.
pub fn auto_detect_region() -> String {
log::info!("Auto-detecting region (probing github.com)...");
let agent = ureq::AgentBuilder::new()
.timeout(Duration::from_secs(4))
.build();
match agent.request("HEAD", "https://github.com").call() {
Ok(resp) if resp.status() < 400 => {
log::info!("github.com reachable — using global region");
"global".to_string()
}
_ => {
log::info!("github.com unreachable — using restricted region (ghproxy mirror)");
"restricted".to_string()
}
}
}
/// Get the effective region string, resolving "auto" to a concrete region.
pub fn get_effective_region<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> String {
let region = load_config(app).region;
if region == "auto" {
auto_detect_region()
} else {
region
}
}
// ── Tauri commands ────────────────────────────────────────────────────────
#[tauri::command]
pub fn get_region(app: tauri::AppHandle) -> String {
load_config(&app).region
}
#[tauri::command]
pub fn set_region(app: tauri::AppHandle, region: String) -> String {
let r = if VALID_REGIONS.contains(&region.as_str()) {
region.as_str()
} else {
"auto"
};
let mut cfg = load_config(&app);
cfg.region = r.to_string();
save_config(&app, &cfg);
r.to_string()
}
File diff suppressed because it is too large Load Diff
+439
View File
@@ -0,0 +1,439 @@
//! Sidecar detection, FFmpeg/ffprobe resolution, and on-demand downloads.
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use crate::config::get_effective_region;
#[allow(unused_imports)] // Used in cfg(linux) and cfg(windows) blocks
use crate::config::resolve_github_url;
use crate::bootstrap::{BootstrapStage, set_stage};
// 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
// when the toolchain needs a newer uv.
pub const UV_VERSION: &str = "0.11.7";
// ── Sidecar detection ─────────────────────────────────────────────────────
/// Look for a sidecar binary bundled alongside the app via Tauri's
/// `bundle.externalBin`. Tauri places the per-target sidecar at the same
/// path as the main app executable on Linux/Windows, and inside
/// `Contents/MacOS/` on macOS .app bundles. The bundled file keeps its
/// `<name>-<target-triple>{.exe}` name.
///
/// Returns `None` in dev (`cargo run`) builds where the sidecar wasn't
/// bundled — the caller then falls back to PATH lookup or other strategies.
pub fn find_bundled_sidecar(name: &str) -> Option<PathBuf> {
let exe = std::env::current_exe().ok()?;
let dir = exe.parent()?;
let triple = match (std::env::consts::OS, std::env::consts::ARCH) {
("macos", "aarch64") => "aarch64-apple-darwin",
("macos", "x86_64") => "x86_64-apple-darwin",
("linux", "x86_64") => "x86_64-unknown-linux-gnu",
("windows", "x86_64") => "x86_64-pc-windows-msvc",
_ => return None,
};
let ext = if cfg!(windows) { ".exe" } else { "" };
let candidate = dir.join(format!("{}-{}{}", name, triple, ext));
if !candidate.is_file() {
return None;
}
// build.rs writes a zero-byte placeholder so tauri-build's externalBin
// existence check passes during dev / `cargo check`. Reject it here so
// we don't try to exec an empty file — callers fall back to PATH lookup
// or pip-bundled binaries instead.
let len = std::fs::metadata(&candidate).ok().map(|m| m.len()).unwrap_or(0);
if len < 1024 {
return None;
}
Some(candidate)
}
pub fn find_bundled_uv() -> Option<PathBuf> { find_bundled_sidecar("uv") }
pub fn find_bundled_ffmpeg() -> Option<PathBuf> { find_bundled_sidecar("ffmpeg") }
pub fn find_bundled_ffprobe() -> Option<PathBuf> { find_bundled_sidecar("ffprobe") }
// ── On-demand ffmpeg / ffprobe download ───────────────────────────────────
//
// Sources:
// macOS: evermeet.cx — individual .zip per binary (x86_64, runs via Rosetta on arm64)
// Linux: BtbN/FFmpeg-Builds — single .tar.xz with both binaries
// Windows: BtbN/FFmpeg-Builds — single .zip with both binaries
/// Download and cache static ffmpeg + ffprobe binaries into `dest`.
/// Idempotent: skips the download when both binaries already exist.
#[allow(unused_variables)] // `region` only used in linux/windows cfg blocks
pub fn install_ffmpeg_standalone(dest: &Path, region: &str) -> io::Result<()> {
let ffmpeg_bin = dest.join(if cfg!(windows) { "ffmpeg.exe" } else { "ffmpeg" });
let ffprobe_bin = dest.join(if cfg!(windows) { "ffprobe.exe" } else { "ffprobe" });
if ffmpeg_bin.is_file() && ffprobe_bin.is_file() {
return Ok(());
}
fs::create_dir_all(dest)?;
#[cfg(target_os = "macos")]
{
// Prefer native arm64 ffmpeg via Homebrew — always latest, includes
// ffprobe, zero Rosetta overhead on Apple Silicon.
let brew_candidates = ["/opt/homebrew/bin/brew", "/usr/local/bin/brew"];
let brew_path = brew_candidates.iter().find(|p| PathBuf::from(p).is_file());
if let Some(brew) = brew_path {
log::info!("Installing ffmpeg via Homebrew (native arm64)");
let status = Command::new(brew)
.args(["install", "ffmpeg"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
if matches!(status, Ok(ref s) if s.success()) {
// brew install succeeded — ffmpeg/ffprobe are now on PATH
// at /opt/homebrew/bin/ or /usr/local/bin/. No need to
// cache in tools/ — resolve_ffmpeg will find them via PATH.
return Ok(());
}
log::warn!("brew install ffmpeg failed — falling back to evermeet.cx");
}
// Fallback: evermeet.cx static binaries (x86_64, runs via Rosetta).
for (tool, url) in [
("ffmpeg", "https://evermeet.cx/ffmpeg/getrelease/zip"),
("ffprobe", "https://evermeet.cx/ffmpeg/getrelease/ffprobe/zip"),
] {
let bin_path = dest.join(tool);
if bin_path.is_file() {
continue;
}
log::info!("Downloading {} from evermeet.cx", tool);
let zip_path = dest.join(format!("{}.zip", tool));
let resp = ureq::get(url)
.timeout(Duration::from_secs(120))
.call()
.map_err(|e| io::Error::new(io::ErrorKind::Other, format!("{} download: {}", tool, e)))?;
if resp.status() != 200 {
return Err(io::Error::new(
io::ErrorKind::Other,
format!("{} download HTTP {}", tool, resp.status()),
));
}
let mut zip_file = fs::File::create(&zip_path)?;
io::copy(&mut resp.into_reader(), &mut zip_file)?;
drop(zip_file);
let status = Command::new("unzip")
.args(["-o", "-j"])
.arg(&zip_path)
.arg("-d")
.arg(dest)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()?;
let _ = fs::remove_file(&zip_path);
if !status.success() {
return Err(io::Error::new(io::ErrorKind::Other, format!("unzip {} failed", tool)));
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = fs::metadata(&bin_path) {
let mut perms = meta.permissions();
perms.set_mode(0o755);
let _ = fs::set_permissions(&bin_path, perms);
}
}
}
return Ok(());
}
#[cfg(target_os = "linux")]
{
let url = resolve_github_url(
"https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linux64-gpl.tar.xz",
region,
);
log::info!("Downloading ffmpeg from BtbN (linux64)");
let archive_path = dest.join("ffmpeg.tar.xz");
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 download HTTP {}", resp.status()),
));
}
let mut archive_file = fs::File::create(&archive_path)?;
io::copy(&mut resp.into_reader(), &mut archive_file)?;
drop(archive_file);
let status = Command::new("tar")
.args(["-xJf"])
.arg(&archive_path)
.arg("-C")
.arg(dest)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()?;
let _ = fs::remove_file(&archive_path);
if !status.success() {
return Err(io::Error::new(io::ErrorKind::Other, "tar -xJf ffmpeg failed"));
}
for entry in fs::read_dir(dest)? {
let entry = entry?;
let p = entry.path();
if p.is_dir() {
let bin_dir = p.join("bin");
if bin_dir.is_dir() {
for tool in ["ffmpeg", "ffprobe"] {
let src = bin_dir.join(tool);
if src.is_file() {
let dst = dest.join(tool);
let _ = fs::rename(&src, &dst).or_else(|_| {
fs::copy(&src, &dst).map(|_| ())
});
}
}
let _ = fs::remove_dir_all(&p);
break;
}
}
}
for tool in ["ffmpeg", "ffprobe"] {
let bin = dest.join(tool);
if bin.is_file() {
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = fs::metadata(&bin) {
let mut perms = meta.permissions();
perms.set_mode(0o755);
let _ = fs::set_permissions(&bin, perms);
}
}
}
return Ok(());
}
#[cfg(target_os = "windows")]
{
use std::io::Read;
let url = resolve_github_url(
"https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip",
region,
);
log::info!("Downloading ffmpeg from BtbN (win64)");
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 download HTTP {}", resp.status()),
));
}
let mut buf = Vec::new();
resp.into_reader().read_to_end(&mut buf)?;
let mut archive = zip::ZipArchive::new(std::io::Cursor::new(buf))
.map_err(|e| io::Error::new(io::ErrorKind::Other, format!("zip: {}", e)))?;
for i in 0..archive.len() {
let mut file = archive.by_index(i)
.map_err(|e| io::Error::new(io::ErrorKind::Other, format!("zip entry: {}", e)))?;
let name = file.name().to_string();
let basename = name.rsplit('/').next().unwrap_or(&name);
if basename == "ffmpeg.exe" || basename == "ffprobe.exe" {
let out_path = dest.join(basename);
let mut out_file = fs::File::create(&out_path)?;
io::copy(&mut file, &mut out_file)?;
}
}
return Ok(());
}
// Unsupported platform — not an error, caller falls back to PATH / imageio-ffmpeg.
#[allow(unreachable_code)]
Ok(())
}
/// Resolve a usable ffmpeg binary. Order: bundled sidecar → cached download
/// in app_data/tools → system PATH → on-demand download from the internet.
pub fn resolve_ffmpeg<R: tauri::Runtime>(app: &tauri::AppHandle<R>, app_data: &Path) -> Option<PathBuf> {
if let Some(p) = find_bundled_ffmpeg() {
log::info!("Using bundled ffmpeg at {}", p.display());
return Some(p);
}
let tools_dir = app_data.join("tools");
let cached = tools_dir.join(if cfg!(windows) { "ffmpeg.exe" } else { "ffmpeg" });
if cached.is_file() {
log::info!("Using cached ffmpeg at {}", cached.display());
return Some(cached);
}
if Command::new("ffmpeg").arg("-version").stdout(Stdio::null()).stderr(Stdio::null()).status().map(|s| s.success()).unwrap_or(false) {
log::info!("Using system ffmpeg from PATH");
return Some(PathBuf::from("ffmpeg"));
}
log::info!("No ffmpeg found — auto-installing");
match install_ffmpeg_standalone(&tools_dir, &get_effective_region(app)) {
Ok(()) => {
if cached.is_file() {
log::info!("Installed ffmpeg to {}", cached.display());
return Some(cached);
}
for p in ["/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg"] {
if PathBuf::from(p).is_file() {
log::info!("Installed ffmpeg at {}", p);
return Some(PathBuf::from(p));
}
}
if Command::new("ffmpeg").arg("-version").stdout(Stdio::null()).stderr(Stdio::null()).status().map(|s| s.success()).unwrap_or(false) {
return Some(PathBuf::from("ffmpeg"));
}
log::warn!("ffmpeg install completed but binary not found");
None
}
Err(e) => {
log::warn!("ffmpeg install failed: {} — backend will rely on imageio-ffmpeg", e);
None
}
}
}
/// Resolve a usable ffprobe binary. Same cascade as ffmpeg.
pub fn resolve_ffprobe<R: tauri::Runtime>(app: &tauri::AppHandle<R>, app_data: &Path) -> Option<PathBuf> {
if let Some(p) = find_bundled_ffprobe() {
log::info!("Using bundled ffprobe at {}", p.display());
return Some(p);
}
let tools_dir = app_data.join("tools");
let cached = tools_dir.join(if cfg!(windows) { "ffprobe.exe" } else { "ffprobe" });
if cached.is_file() {
log::info!("Using cached ffprobe at {}", cached.display());
return Some(cached);
}
if Command::new("ffprobe").arg("-version").stdout(Stdio::null()).stderr(Stdio::null()).status().map(|s| s.success()).unwrap_or(false) {
log::info!("Using system ffprobe from PATH");
return Some(PathBuf::from("ffprobe"));
}
if let Ok(()) = install_ffmpeg_standalone(&tools_dir, &get_effective_region(app)) {
if cached.is_file() {
log::info!("Installed ffprobe to {}", cached.display());
return Some(cached);
}
for p in ["/opt/homebrew/bin/ffprobe", "/usr/local/bin/ffprobe"] {
if PathBuf::from(p).is_file() {
log::info!("Installed ffprobe at {}", p);
return Some(PathBuf::from(p));
}
}
if Command::new("ffprobe").arg("-version").stdout(Stdio::null()).stderr(Stdio::null()).status().map(|s| s.success()).unwrap_or(false) {
return Some(PathBuf::from("ffprobe"));
}
}
None
}
// ── uv resolution ─────────────────────────────────────────────────────────
/// Resolve a usable `uv` binary. Order: bundled sidecar (shipped with the
/// release installer via `bundle.externalBin`), system PATH (dev / power
/// users), or — last resort — download via the official Astral installer.
pub fn resolve_uv<R: tauri::Runtime>(
_app: &tauri::AppHandle<R>,
app_data: &Path,
progress: Option<&Arc<Mutex<BootstrapStage>>>,
) -> Result<PathBuf, String> {
if let Some(p) = find_bundled_uv() {
log::info!("Using bundled uv at {}", p.display());
return Ok(p);
}
if Command::new("uv").arg("--version").output().is_ok() {
log::info!("Using system uv from PATH");
return Ok(PathBuf::from("uv"));
}
if let Some(p) = progress {
set_stage(p, BootstrapStage::DownloadingUv { percent: None });
}
install_uv_standalone(&app_data.join("tools"), &get_effective_region(_app))
.map_err(|e| format!("uv install failed: {}", e))
}
/// Install `uv` using the **official Astral installer scripts**.
///
/// Unix: `curl -LsSf https://astral.sh/uv/{version}/install.sh | sh`
/// Windows: `powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/{version}/install.ps1 | iex"`
///
/// The installer handles platform detection, checksums, and extraction
/// automatically. We control the install directory via `UV_INSTALL_DIR`.
/// Idempotent: if the binary is already present, returns its path immediately.
fn install_uv_standalone(dest: &Path, _region: &str) -> io::Result<PathBuf> {
let uv_bin = dest.join(if cfg!(windows) { "uv.exe" } else { "uv" });
if uv_bin.is_file() {
return Ok(uv_bin);
}
fs::create_dir_all(dest)?;
log::info!("Installing uv {} via official installer into {}", UV_VERSION, dest.display());
#[cfg(unix)]
{
let status = Command::new("sh")
.args([
"-c",
&format!(
"curl -LsSf https://astral.sh/uv/{}/install.sh | sh -s -- --no-modify-path",
UV_VERSION
),
])
.env("UV_INSTALL_DIR", dest)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.status()
.map_err(|e| io::Error::new(
io::ErrorKind::Other,
format!("uv installer launch failed (is curl installed?): {}", e),
))?;
if !status.success() {
return Err(io::Error::new(
io::ErrorKind::Other,
format!("uv installer exited with code {:?}", status.code()),
));
}
}
#[cfg(windows)]
{
let script = format!(
"irm https://astral.sh/uv/{}/install.ps1 | iex",
UV_VERSION
);
let status = Command::new("powershell")
.args(["-ExecutionPolicy", "ByPass", "-c", &script])
.env("UV_INSTALL_DIR", dest)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.status()
.map_err(|e| io::Error::new(
io::ErrorKind::Other,
format!("uv PowerShell installer failed: {}", e),
))?;
if !status.success() {
return Err(io::Error::new(
io::ErrorKind::Other,
format!("uv installer exited with code {:?}", status.code()),
));
}
}
if uv_bin.is_file() {
log::info!("uv installed successfully at {}", uv_bin.display());
Ok(uv_bin)
} else {
let alt = dest.join("bin").join(if cfg!(windows) { "uv.exe" } else { "uv" });
if alt.is_file() {
fs::rename(&alt, &uv_bin)?;
log::info!("uv moved from bin/ to {}", uv_bin.display());
return Ok(uv_bin);
}
Err(io::Error::new(
io::ErrorKind::NotFound,
format!("uv binary not found at {} after installer completed", uv_bin.display()),
))
}
}
+23 -4
View File
@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "OmniVoice Studio",
"version": "0.2.6",
"version": "0.2.7",
"identifier": "com.debpalash.omnivoice-studio",
"build": {
"frontendDist": "../dist",
@@ -15,18 +15,32 @@
"windows": [
{
"title": "OmniVoice Studio",
"width": 1600,
"height": 960,
"width": 1920,
"height": 1080,
"minWidth": 900,
"minHeight": 600,
"resizable": true,
"fullscreen": false,
"titleBarStyle": "Overlay",
"hiddenTitle": true
},
{
"label": "widget",
"title": "Dictation Widget",
"url": "/?window=widget",
"width": 350,
"height": 220,
"resizable": false,
"fullscreen": false,
"transparent": true,
"decorations": false,
"alwaysOnTop": true,
"visible": false,
"skipTaskbar": true
}
],
"security": {
"csp": "default-src 'self' 'unsafe-inline' 'unsafe-eval'; connect-src 'self' http://localhost:* ws://localhost:* blob: data:; media-src 'self' blob: data: http://localhost:* asset: https://asset.localhost; img-src 'self' blob: data: asset: https://asset.localhost https://fonts.gstatic.com; font-src 'self' data: https://fonts.googleapis.com https://fonts.gstatic.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;",
"csp": "default-src 'self' 'unsafe-inline' 'unsafe-eval'; connect-src 'self' http://localhost:* ws://localhost:* http://127.0.0.1:* ws://127.0.0.1:* blob: data:; media-src 'self' blob: data: http://localhost:* http://127.0.0.1:* asset: https://asset.localhost; img-src 'self' blob: data: asset: https://asset.localhost https://fonts.gstatic.com; font-src 'self' data: https://fonts.googleapis.com https://fonts.gstatic.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;",
"assetProtocol": {
"enable": true,
"scope": ["**"]
@@ -51,6 +65,11 @@
"../../omnivoice",
"../../backend"
],
"externalBin": [
"binaries/uv",
"binaries/ffmpeg",
"binaries/ffprobe"
],
"macOS": {
"minimumSystemVersion": "12.0"
}
+73 -355
View File
@@ -28,11 +28,14 @@ import Header from './components/Header';
import NavRail from './components/NavRail';
import ErrorBoundary from './components/ErrorBoundary';
import FloatingPill from './components/FloatingPill';
import CaptureButton from './components/CaptureButton';
import useRealtimeEvents from './hooks/useRealtimeEvents';
import { BootstrapSplash, useBootstrapStage } from './components/BootstrapSplash';
import './components/Misc.css';
import { askConfirm } from './utils/dialog';
import useRecording from './hooks/useRecording';
import useSegmentEditing from './hooks/useSegmentEditing';
const LazyFallback = () => <div className="app-lazy-fallback">Loading</div>;
@@ -64,105 +67,7 @@ import {
Layers, Music, Package, DownloadCloud, RefreshCw,
} from 'lucide-react';
// Tauri: pre-import window API to avoid async delays in event handlers
const isTauri = typeof window !== 'undefined' && !!(window.__TAURI_INTERNALS__ || window.__TAURI__);
let tauriWindow = null;
if (isTauri) {
import('@tauri-apps/api/window').then(m => { tauriWindow = m; });
}
const doubleClickMaximize = () => {
if (tauriWindow) tauriWindow.getCurrentWindow().toggleMaximize();
};
/**
* Convert a File object to a media-safe URL.
* In Tauri's WebKit, blob: URLs fail for <video>/<audio> elements.
* We upload to the backend's /preview endpoint and serve via HTTP instead.
* Falls back to createObjectURL for regular browsers.
*/
const _PREVIEW_API = import.meta.env.VITE_OMNIVOICE_API || 'http://localhost:3900';
const fileToMediaUrl = async (file, prevUrls) => {
// Revoke previous blob URLs if they exist
if (prevUrls?.videoUrl?.startsWith('blob:')) URL.revokeObjectURL(prevUrls.videoUrl);
if (prevUrls?.audioUrl?.startsWith('blob:')) URL.revokeObjectURL(prevUrls.audioUrl);
if (isTauri) {
try {
const form = new FormData();
form.append('video', file, file.name || 'media.wav');
const res = await fetch(`${_PREVIEW_API}/preview/upload`, { method: 'POST', body: form });
const data = await res.json();
return {
videoUrl: `${_PREVIEW_API}${data.url}`,
audioUrl: data.audioUrl ? `${_PREVIEW_API}${data.audioUrl}` : `${_PREVIEW_API}${data.url}`
};
} catch (e) {
console.warn('Preview upload failed, falling back to blob URL:', e);
}
}
const url = URL.createObjectURL(file);
return { videoUrl: url, audioUrl: url };
};
/**
* Play audio from a Blob. Uses Web Audio API in Tauri (blob URLs blocked)
* and standard Audio() elsewhere.
*/
const playBlobAudio = async (blob) => {
if (isTauri) {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
// WebKit suspends AudioContext by default must resume before decoding
if (ctx.state === 'suspended') await ctx.resume();
try {
const buf = await blob.arrayBuffer();
const decoded = await ctx.decodeAudioData(buf);
const src = ctx.createBufferSource();
src.buffer = decoded;
src.connect(ctx.destination);
src.start(0);
src.onended = () => ctx.close();
} catch (e) {
console.error('playBlobAudio decode error:', e);
ctx.close();
// Fallback: try the standard Audio() path even in Tauri
try {
const url = URL.createObjectURL(blob);
const a = new Audio(url);
await a.play();
a.onended = () => URL.revokeObjectURL(url);
} catch (e2) {
console.error('playBlobAudio fallback error:', e2);
}
}
} else {
const url = URL.createObjectURL(blob);
const a = new Audio(url);
a.play().catch((e) => console.error('playBlobAudio play error:', e));
a.onended = () => URL.revokeObjectURL(url);
}
};
let _pingCtx = null;
const playPing = () => {
try {
if (!_pingCtx) _pingCtx = new (window.AudioContext || window.webkitAudioContext)();
const ctx = _pingCtx;
if (ctx.state === 'suspended') ctx.resume();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.type = 'sine';
osc.frequency.setValueAtTime(600, ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(900, ctx.currentTime + 0.08);
osc.frequency.exponentialRampToValueAtTime(1200, ctx.currentTime + 0.15);
gain.gain.setValueAtTime(0, ctx.currentTime);
gain.gain.linearRampToValueAtTime(0.18, ctx.currentTime + 0.03);
gain.gain.linearRampToValueAtTime(0, ctx.currentTime + 0.25);
osc.start(ctx.currentTime);
osc.stop(ctx.currentTime + 0.25);
} catch (e) {}
};
import { isTauri, doubleClickMaximize, fileToMediaUrl, playBlobAudio, playPing } from './utils/media';
function App() {
// First-run bootstrap: Rust spawns uv sync in a background thread and
@@ -319,12 +224,10 @@ function App() {
const [voicePreviewProfileId, setVoicePreviewProfileId] = useState('');
// MIC RECORDING
const [isRecording, setIsRecording] = useState(false);
const [isCleaning, setIsCleaning] = useState(false);
const [recordingTime, setRecordingTime] = useState(0);
const mediaRecorderRef = useRef(null);
const recordingChunksRef = useRef([]);
const recordingTimerRef = useRef(null);
const {
isRecording, isCleaning, recordingTime,
startRecording, stopRecording,
} = useRecording(ingestRefAudio);
// DUB STATE
// Phase 2.2 the dub pipeline's 18 useState calls now live in `dubSlice`.
@@ -418,168 +321,22 @@ function App() {
const isSidebarCollapsed = useAppStore(s => s.isSidebarCollapsed);
const setIsSidebarCollapsed = useAppStore(s => s.setIsSidebarCollapsed);
// UNDO / REDO
const undoStack = useRef([]);
const redoStack = useRef([]);
const pushUndo = (segments) => {
undoStack.current.push(JSON.stringify(segments));
if (undoStack.current.length > 50) undoStack.current.shift();
redoStack.current = []; // clear redo on new edit
};
const undo = () => {
if (undoStack.current.length === 0) return;
redoStack.current.push(JSON.stringify(dubSegments));
const prev = JSON.parse(undoStack.current.pop());
setDubSegments(prev);
};
const redo = () => {
if (redoStack.current.length === 0) return;
undoStack.current.push(JSON.stringify(dubSegments));
const next = JSON.parse(redoStack.current.pop());
setDubSegments(next);
};
// Wrap setDubSegments calls that are user-edits with undo tracking
const editSegments = (newSegs) => {
pushUndo(dubSegments);
setDubSegments(newSegs);
};
// Stable handlers for virtualized segment rows. Use functional updates so
// they don't depend on dubSegments identity (avoids row re-renders).
const segmentEditField = useCallback((id, field, value) => {
pushUndo(dubSegments);
setDubSegments(prev => prev.map(s => s.id === id ? { ...s, [field]: value } : s));
}, [dubSegments]);
// Phase 4.2 direction editor per segment. Dialog state lives in App so
// opening one dialog closes any other, and Undo includes direction changes.
const [directionSegId, setDirectionSegId] = useState(null);
const openDirection = useCallback((seg) => setDirectionSegId(seg.id), []);
const closeDirection = useCallback(() => setDirectionSegId(null), []);
const saveDirection = useCallback((value) => {
if (!directionSegId) return;
pushUndo(dubSegments);
setDubSegments(prev => prev.map(s => s.id === directionSegId
? { ...s, direction: value || undefined }
: s));
}, [directionSegId, dubSegments]);
// Phase 4.1 after each successful dub generate, stash the segment
// fingerprints. "What changed since last generate?" reads against this map.
const [lastGenFingerprints, setLastGenFingerprints] = useState({});
const [incrementalPlan, setIncrementalPlan] = useState(null); // {stale:[], fresh:[]}
const recomputeIncremental = useCallback(async () => {
if (!dubSegments.length || !Object.keys(lastGenFingerprints).length) {
setIncrementalPlan(null);
return;
}
try {
const res = await apiPost('/tools/incremental', {
segments: dubSegments.map(s => ({
id: String(s.id), text: s.text, target_lang: s.target_lang,
profile_id: s.profile_id, instruct: s.instruct,
speed: s.speed, direction: s.direction,
})),
stored_hashes: lastGenFingerprints,
});
setIncrementalPlan({ stale: res.stale, fresh: res.fresh });
} catch (e) {
console.warn('incremental plan failed', e);
}
}, [dubSegments, lastGenFingerprints]);
// UNDO / REDO + SEGMENT EDITING
const {
undo, redo, pushUndo, editSegments,
segmentEditField, segmentDelete, segmentRestoreOriginal,
segmentSplit, segmentMerge,
selectedSegIds, setSelectedSegIds,
toggleSegSelect, selectAllSegs, clearSegSelection,
bulkApplyToSelected, bulkDeleteSelected,
directionSegId, openDirection, closeDirection, saveDirection,
lastGenFingerprints, setLastGenFingerprints,
incrementalPlan, setIncrementalPlan,
recomputeIncremental,
} = useSegmentEditing();
useEffect(() => { recomputeIncremental(); }, [recomputeIncremental]);
const segmentDelete = useCallback((id) => {
pushUndo(dubSegments);
setDubSegments(prev => prev.filter(s => s.id !== id));
}, [dubSegments]);
const segmentRestoreOriginal = useCallback((id) => {
pushUndo(dubSegments);
setDubSegments(prev => prev.map(s => s.id === id
? { ...s, text: s.text_original || s.text, translate_error: undefined }
: s));
}, [dubSegments]);
// Segment multi-select
const [selectedSegIds, setSelectedSegIds] = useState(new Set());
const lastSelectedIdxRef = useRef(null);
const toggleSegSelect = useCallback((id, idx, shift) => {
setSelectedSegIds(prev => {
const next = new Set(prev);
if (shift && lastSelectedIdxRef.current !== null) {
const [a, b] = [lastSelectedIdxRef.current, idx].sort((x, y) => x - y);
for (let i = a; i <= b; i++) {
const s = dubSegments[i];
if (s) next.add(s.id);
}
} else {
if (next.has(id)) next.delete(id); else next.add(id);
lastSelectedIdxRef.current = idx;
}
return next;
});
}, [dubSegments]);
const selectAllSegs = useCallback((segs) => {
setSelectedSegIds(new Set(segs.map(s => s.id)));
}, []);
const clearSegSelection = useCallback(() => setSelectedSegIds(new Set()), []);
// Bulk actions
const bulkApplyToSelected = useCallback((patch) => {
if (!selectedSegIds.size) return;
pushUndo(dubSegments);
setDubSegments(prev => prev.map(s => selectedSegIds.has(s.id) ? { ...s, ...patch } : s));
}, [dubSegments, selectedSegIds]);
const bulkDeleteSelected = useCallback(() => {
if (!selectedSegIds.size) return;
if (!confirm(`Delete ${selectedSegIds.size} selected segment${selectedSegIds.size === 1 ? '' : 's'}?`)) return;
pushUndo(dubSegments);
setDubSegments(prev => prev.filter(s => !selectedSegIds.has(s.id)));
setSelectedSegIds(new Set());
}, [dubSegments, selectedSegIds]);
// Split at text cursor. Time split proportional to cursor position in text.
const segmentSplit = useCallback((id, cursorPos) => {
pushUndo(dubSegments);
setDubSegments(prev => {
const idx = prev.findIndex(s => s.id === id);
if (idx < 0) return prev;
const seg = prev[idx];
const text = seg.text || '';
const pos = Math.max(1, Math.min(cursorPos, text.length - 1));
const ratio = text.length > 0 ? pos / text.length : 0.5;
const midT = seg.start + (seg.end - seg.start) * ratio;
const left = { ...seg, id: `${seg.id}_a`, text: text.slice(0, pos).trim(), end: midT, text_original: text.slice(0, pos).trim() };
const right = { ...seg, id: `${seg.id}_b`, text: text.slice(pos).trim(), start: midT, text_original: text.slice(pos).trim() };
return [...prev.slice(0, idx), left, right, ...prev.slice(idx + 1)];
});
}, [dubSegments]);
// Merge segment with its next sibling.
const segmentMerge = useCallback((id) => {
pushUndo(dubSegments);
setDubSegments(prev => {
const idx = prev.findIndex(s => s.id === id);
if (idx < 0 || idx >= prev.length - 1) return prev;
const a = prev[idx];
const b = prev[idx + 1];
const merged = {
...a,
text: `${a.text || ''} ${b.text || ''}`.trim(),
text_original: `${a.text_original || a.text || ''} ${b.text_original || b.text || ''}`.trim(),
end: b.end,
};
return [...prev.slice(0, idx), merged, ...prev.slice(idx + 2)];
});
}, [dubSegments]);
// MODEL STATUS + SYSINFO (TanStack Query)
const sysQuery = useSysinfo();
const msQuery = useModelStatus();
@@ -715,23 +472,42 @@ function App() {
// (useSysinfo / useModelStatus at top of component). No manual setInterval.
// Floating pill for model loading (ASR cold start can take ~120s)
// The backend now reports granular sub-stages: importing loading_weights
// loading_asr compiling ready (or error). We update the pill label
// in real-time so the user knows exactly what's happening.
const modelSubStage = msQuery.data?.sub_stage ?? null;
const modelDetail = msQuery.data?.detail ?? '';
const modelError = msQuery.data?.error ?? null;
const prevModelStatusRef = useRef(modelStatus);
useEffect(() => {
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');
// Transition to loading: show the pill with sub-stage detail
if (modelStatus === 'loading') {
const label = modelDetail || 'Loading model…';
if (prev !== 'loading' && pill.stage === 'idle') {
// First time entering loading show the pill
pill.showPill('loading-model', label);
} else if (pill.stage === 'loading-model') {
// Sub-stage changed update the label live
pill.setPillLabel(label);
}
}
}, [modelStatus]);
// Transition to ready: complete the pill
if (modelStatus === 'ready' && prev === 'loading') {
if (pill.stage === 'loading-model') {
pill.completePill('Model ready');
}
}
// Error during loading: show error state
if (modelSubStage === 'error' && modelError && pill.stage === 'loading-model') {
pill.errorPill(modelError);
}
}, [modelStatus, modelSubStage, modelDetail, modelError]);
const loadProfiles = useCallback(async () => {
try { setProfiles(await listProfiles()); } catch (e) {}
@@ -913,7 +689,11 @@ function App() {
if (selectedProfile) {
formData.append("profile_id", selectedProfile);
} else if (refAudio) {
formData.append("ref_audio", refAudio);
// Safari/WebKit workaround: fetching an in-memory File/Blob via FormData hangs/times out
// Recreating it synchronously from an ArrayBuffer avoids the bug
const arrBuf = await refAudio.arrayBuffer();
const safeBlob = new Blob([arrBuf], { type: refAudio.type });
formData.append("ref_audio", safeBlob, refAudio.name || "audio.wav");
formData.append("ref_text", refText);
}
if (instruct) formData.append("instruct", instruct);
@@ -977,7 +757,9 @@ function App() {
if (!profileName.trim() || !refAudio) return toast.error("Need a name and reference audio");
const formData = new FormData();
formData.append("name", profileName);
formData.append("ref_audio", refAudio);
const arrBuf = await refAudio.arrayBuffer();
const safeBlob = new Blob([arrBuf], { type: refAudio.type });
formData.append("ref_audio", safeBlob, refAudio.name || "profile.wav");
formData.append("ref_text", refText);
formData.append("instruct", instruct);
formData.append("language", language);
@@ -990,7 +772,7 @@ function App() {
};
const handleDeleteProfile = async (id) => {
if (!confirm('Delete this voice profile?')) return;
if (!(await askConfirm('Delete this voice profile?'))) return;
await apiDeleteProfile(id);
if (selectedProfile === id) setSelectedProfile(null);
await loadProfiles();
@@ -1146,72 +928,6 @@ function App() {
}
};
// MIC RECORDING
const startRecording = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm;codecs=opus' });
mediaRecorderRef.current = mediaRecorder;
recordingChunksRef.current = [];
setRecordingTime(0);
mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) recordingChunksRef.current.push(e.data);
};
mediaRecorder.onstop = async () => {
clearInterval(recordingTimerRef.current);
stream.getTracks().forEach(t => t.stop());
const blob = new Blob(recordingChunksRef.current, { type: 'audio/webm' });
if (blob.size < 1000) {
toast.error("Recording too short");
return;
}
// Send to backend for denoising
setIsCleaning(true);
try {
const formData = new FormData();
formData.append("audio", blob, "recording.webm");
const res = await apiCleanAudio(formData);
const cleanBlob = await res.blob();
const cleanFilename = res.headers.get("X-Clean-Filename") || "recording_clean.wav";
const cleanFile = new File([cleanBlob], cleanFilename, { type: "audio/wav" });
await ingestRefAudio(cleanFile);
toast.success("🎙️ Recording cleaned & loaded!");
} catch (e) {
// Fallback: use raw recording without denoising
const rawFile = new File([blob], "recording.webm", { type: "audio/webm" });
await ingestRefAudio(rawFile);
toast.success("Recording loaded (raw — denoising unavailable)");
} finally {
setIsCleaning(false);
}
};
mediaRecorder.start(250); // Collect chunks every 250ms
setIsRecording(true);
// Timer
const st = Date.now();
recordingTimerRef.current = setInterval(() => {
setRecordingTime(((Date.now() - st) / 1000).toFixed(1));
}, 100);
} catch (e) {
toast.error("Microphone access denied");
}
};
const stopRecording = () => {
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.stop();
}
setIsRecording(false);
};
// DUB WORKFLOW
const dubAbortCtrlRef = useRef(null);
@@ -1899,7 +1615,7 @@ function App() {
const deleteProject = async (projectId, e) => {
if (e) e.stopPropagation();
if (!confirm('Delete this project? This cannot be undone.')) return;
if (!(await askConfirm('Delete this project? This cannot be undone.'))) return;
try {
await apiDeleteProject(projectId);
if (activeProjectId === projectId) {
@@ -1951,7 +1667,7 @@ function App() {
};
const deleteHistory = async (id, type) => {
if (!confirm('Delete this history item?')) return;
if (!(await askConfirm('Delete this history item?'))) return;
try {
const endpoint = type === 'dub' ? `${API}/dub/history/${id}` : `${API}/history/${id}`;
await fetch(endpoint, { method: 'DELETE' });
@@ -2033,14 +1749,16 @@ function App() {
style={{ zoom: uiScale }}
>
{pendingTrimFile && (
<Suspense fallback={<LazyFallback />}>
<AudioTrimmer
file={pendingTrimFile}
maxSeconds={CLONE_MAX_SECONDS}
onCancel={() => setPendingTrimFile(null)}
onConfirm={(trimmed) => { setPendingTrimFile(null); setRefAudio(trimmed); setSelectedProfile(null); toast.success('Trimmed audio loaded'); }}
/>
</Suspense>
<ErrorBoundary name="audio-trimmer">
<Suspense fallback={<LazyFallback />}>
<AudioTrimmer
file={pendingTrimFile}
maxSeconds={CLONE_MAX_SECONDS}
onCancel={() => setPendingTrimFile(null)}
onConfirm={(trimmed) => { setPendingTrimFile(null); setRefAudio(trimmed); setSelectedProfile(null); toast.success('Trimmed audio loaded'); }}
/>
</Suspense>
</ErrorBoundary>
)}
<Toaster position="top-center" toastOptions={{
style: { background: 'rgba(40,40,40,0.9)', backdropFilter: 'blur(10px)', color: '#ebdbb2', border: '1px solid rgba(255,255,255,0.08)', fontSize: '0.72rem', padding: '4px 8px' },
@@ -2049,7 +1767,7 @@ function App() {
}}/>
<FloatingPill />
<CaptureButton />
<Header
mode={mode} setMode={setMode}
+1 -1
View File
@@ -2,7 +2,7 @@
// 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 const API = viteEnv.VITE_API_URL || `http://127.0.0.1:${_port}`;
export class ApiError extends Error {
status?: number;
+6 -1
View File
@@ -42,7 +42,12 @@ export function useModelStatus(enabled = true) {
return useQuery({
queryKey: queryKeys.modelStatus,
queryFn: systemApi.modelStatus,
refetchInterval: 10_000,
// Poll every 2s while model is loading for near-real-time sub-stage
// updates in the floating pill; 10s when idle/ready to save bandwidth.
refetchInterval: (query) => {
const status = query.state?.data?.status;
return status === 'loading' ? 2_000 : 10_000;
},
refetchIntervalInBackground: false,
retry: Infinity,
retryDelay: 1_500,
+9 -7
View File
@@ -55,6 +55,8 @@ export default function AudioTrimmer({ file, maxSeconds = 15, onConfirm, onCance
const [startInput, setStartInput] = useState('0.00');
const [endInput, setEndInput] = useState('0.00');
const [audioMeta, setAudioMeta] = useState(null);
useEffect(() => {
stateRef.current = {
start, end, cursor, viewStart, viewEnd,
@@ -77,16 +79,16 @@ export default function AudioTrimmer({ file, maxSeconds = 15, onConfirm, onCance
const buf = await decodeToMonoLowRate(file, 22050);
if (cancelled) return;
bufferRef.current = buf;
setAudioMeta({ duration: buf.duration, sampleRate: buf.sampleRate });
// Prime with a coarse synchronous pass so waveform shows something instantly.
peaksRef.current = computePeaksFromChannel(buf.getChannelData(0), 1024);
const initEnd = Math.min(buf.duration, maxSeconds);
setViewEnd(buf.duration);
setEnd(Math.min(buf.duration, maxSeconds));
setStart(0);
setEnd(initEnd);
setCursor(0);
setViewStart(0);
setViewEnd(buf.duration);
setReady(true);
setDecoding(false);
setReady(true);
// Refine peaks asynchronously without blocking UI.
const refined = await computePeaksAsync(
buf.getChannelData(0),
@@ -539,7 +541,7 @@ export default function AudioTrimmer({ file, maxSeconds = 15, onConfirm, onCance
};
const keyHandlerRef = useRef(onKeyDown);
keyHandlerRef.current = onKeyDown;
useEffect(() => { keyHandlerRef.current = onKeyDown; }, [onKeyDown]);
useEffect(() => {
const node = containerRef.current;
@@ -573,8 +575,8 @@ export default function AudioTrimmer({ file, maxSeconds = 15, onConfirm, onCance
<div className="audio-trimmer__meta">
<span>{decoding
? 'Decoding audio…'
: (bufferRef.current
? `Length ${fmtHMS(bufferRef.current.duration)} · ${bufferRef.current.sampleRate} Hz${peakProgress > 0 && peakProgress < 1 ? ` · rendering waveform ${Math.round(peakProgress * 100)}%` : ''}`
: (audioMeta
? `Length ${fmtHMS(audioMeta.duration)} · ${audioMeta.sampleRate} Hz${peakProgress > 0 && peakProgress < 1 ? ` · rendering waveform ${Math.round(peakProgress * 100)}%` : ''}`
: '…')
}</span>
<span className="audio-trimmer__hint">
+22 -14
View File
@@ -44,28 +44,36 @@
.bootstrap-splash__region {
margin-left: auto;
display: flex;
gap: 0;
border-radius: 6px;
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--chrome-fg, #eee) 12%, transparent);
align-items: center;
}
.bootstrap-splash__region-btn {
background: transparent;
border: none;
.bootstrap-splash__region-select {
background: color-mix(in srgb, var(--chrome-fg, #eee) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--chrome-fg, #eee) 12%, transparent);
color: inherit;
font: inherit;
font-size: 0.72rem;
padding: 0.25rem 0.6rem;
padding: 0.25rem 0.5rem;
border-radius: 6px;
cursor: pointer;
opacity: 0.5;
appearance: none;
-webkit-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23999'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.4rem center;
padding-right: 1.4rem;
transition: all 0.15s ease;
}
.bootstrap-splash__region-btn:hover { opacity: 0.8; }
.bootstrap-splash__region-btn.is-active {
background: color-mix(in srgb, var(--chrome-fg, #eee) 10%, transparent);
opacity: 1;
font-weight: 600;
.bootstrap-splash__region-select:hover {
background-color: color-mix(in srgb, var(--chrome-fg, #eee) 14%, transparent);
}
.bootstrap-splash__region-select:focus {
outline: 1px solid var(--chrome-accent, #8ec07c);
outline-offset: 1px;
}
.bootstrap-splash__region-select option {
background: #1a1a1a;
color: #eee;
}
.bootstrap-splash__status {
+11 -16
View File
@@ -19,7 +19,6 @@ const STAGE_LABEL = {
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',
@@ -30,7 +29,6 @@ const STEPS = [
'downloading_uv',
'creating_venv',
'installing_deps',
'downloading_ffmpeg',
'starting_backend',
];
@@ -68,7 +66,7 @@ export function BootstrapSplash({ stage, message }) {
const [logsOpen, setLogsOpen] = useState(true);
const [copied, setCopied] = useState(false);
const [progress, setProgress] = useState(null);
const [region, setRegionState] = useState('global');
const [region, setRegionState] = useState('auto');
const [retrying, setRetrying] = useState(false);
const logRef = useRef(null);
@@ -206,20 +204,17 @@ export function BootstrapSplash({ stage, message }) {
<h1>OmniVoice Studio</h1>
<span className="bootstrap-splash__version">v{APP_VERSION}</span>
<div className="bootstrap-splash__region">
<button
type="button"
className={`bootstrap-splash__region-btn${region === 'global' ? ' is-active' : ''}`}
onClick={() => handleRegionChange('global')}
<select
className="bootstrap-splash__region-select"
value={region}
onChange={(e) => handleRegionChange(e.target.value)}
>
🌐 Global
</button>
<button
type="button"
className={`bootstrap-splash__region-btn${region === 'china' ? ' is-active' : ''}`}
onClick={() => handleRegionChange('china')}
>
🇨🇳 China
</button>
<option value="auto">🌐 Auto-detect</option>
<option value="global">🌐 Global (direct)</option>
<option value="china">🇨🇳 China (mirror)</option>
<option value="russia">🇷🇺 Russia (mirror)</option>
<option value="restricted">🌍 Restricted (mirror)</option>
</select>
</div>
</div>
<p className="bootstrap-splash__status">{label}</p>
@@ -1,16 +1,14 @@
/* ── CaptureButton — Global dictation FAB ─────────────────────────────── */
.capture-widget {
position: fixed;
/* Sit above the footer status bar (28px collapsed, expands via CSS var) */
bottom: calc(var(--logs-footer-height, 28px) + 12px);
right: 18px;
z-index: 9000;
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
pointer-events: none;
align-items: center;
justify-content: center;
padding: 10px;
box-sizing: border-box;
}
.capture-widget > * {
@@ -69,8 +67,11 @@
border: 1px solid color-mix(in srgb, var(--chrome-border, #3c3836) 60%, transparent);
border-radius: 16px;
padding: 14px 16px;
min-width: 260px;
max-width: 320px;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.35);
animation: capture-slide-up 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Mic, MicOff, Clipboard, X, Loader, Zap, Target, Check } from 'lucide-react';
import { toast } from 'react-hot-toast';
import { useAppStore } from '../store';
import './CaptureButton.css';
import './CaptureWidget.css';
import { API as API_BASE } from '../api/client';
import { addTranscription } from '../pages/Transcriptions';
@@ -33,11 +33,10 @@ const LS_AUTO_COPY = 'omni_capture_auto_copy';
*
* Auto-copies to clipboard so users can immediately V into any app.
*/
export default function CaptureButton() {
export default function CaptureWidget() {
const [state, setState] = useState('idle'); // idle | recording | transcribing | done | error
const [transcript, setTranscript] = useState('');
const [duration, setDuration] = useState(0);
const [expanded, setExpanded] = useState(false);
const [captureMode, setCaptureMode] = useState(() =>
localStorage.getItem(LS_CAPTURE_MODE) || 'fast'
);
@@ -137,6 +136,19 @@ export default function CaptureButton() {
} catch {
toast.success('Copied to clipboard — paste with ⌘V', { duration: 2000 });
}
// Auto-dismiss the floating widget after 2.5 seconds so it gets out of the way
setTimeout(async () => {
setState('idle');
setTranscript('');
setDuration(0);
setCopied(false);
try {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
await getCurrentWindow().hide();
} catch { /* not in Tauri */ }
}, 2500);
} catch { /* clipboard API may fail in some contexts */ }
}
}, [autoCopy]);
@@ -371,12 +383,15 @@ export default function CaptureButton() {
});
}, [transcript]);
const dismiss = () => {
const dismiss = async () => {
setState('idle');
setTranscript('');
setExpanded(false);
setDuration(0);
setCopied(false);
try {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
await getCurrentWindow().hide();
} catch { /* not in Tauri */ }
};
const toggleCapture = () => {
@@ -395,11 +410,9 @@ export default function CaptureButton() {
};
return (
<div className={`capture-widget ${expanded ? 'capture-widget--expanded' : ''}`}>
{/* Expanded panel */}
{expanded && (
<div className="capture-widget">
<div className="capture-panel">
<div className="capture-panel__header">
<div className="capture-panel__header" data-tauri-drag-region>
<span className="capture-panel__title">
{state === 'recording' && '🎙️ Listening…'}
{state === 'transcribing' && '📝 Transcribing…'}
@@ -491,22 +504,10 @@ export default function CaptureButton() {
</button>
</div>
<div className="capture-panel__hint">
<div className="capture-panel__hint" data-tauri-drag-region>
<kbd>{navigator.platform?.includes('Mac') ? '⌘' : 'Ctrl'}</kbd>+<kbd></kbd>+<kbd>Space</kbd>
</div>
</div>
)}
{/* Main FAB button */}
<button
className={`capture-fab ${state === 'recording' ? 'capture-fab--recording' : ''} ${state === 'transcribing' ? 'capture-fab--busy' : ''}`}
onClick={toggleCapture}
disabled={state === 'transcribing'}
title={state === 'recording' ? 'Stop recording' : 'Start dictation (⌘+⇧+Space)'}
aria-label={state === 'recording' ? 'Stop recording' : 'Start voice dictation'}
>
{state === 'recording' ? <MicOff size={20} /> : state === 'transcribing' ? <Loader size={20} className="spinner" /> : <Mic size={20} />}
</button>
</div>
);
}
+2 -2
View File
@@ -16,9 +16,9 @@
.seg-speed-badge {
font-size: 0.55rem; margin-left: 2px;
}
.seg-speaker {
.seg-speaker-input {
width: 45px; flex-shrink: 0; font-size: 0.55rem; color: #a89984;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
padding: 1px 2px; text-align: center;
}
.seg-text-col {
flex: 1 1 0%; display: flex; flex-direction: column; gap: 2px;
+7 -1
View File
@@ -85,7 +85,13 @@ function DubSegmentRow({
)}
</span>
<span className="seg-speaker">{seg.speaker_id || ''}</span>
<input
className="input-base seg-speaker-input"
value={seg.speaker_id || ''}
onChange={(e) => onEditField(seg.id, 'speaker_id', e.target.value)}
disabled={disabled}
title="Speaker ID"
/>
<span className="seg-text-col">
<input
@@ -36,18 +36,21 @@ export default function ReadinessChecklist({ compact = false, showWhenAllPass =
const checks = [];
// Model readiness (from /model/status)
const modelDetail = modelData?.detail || '';
const modelErr = modelData?.error || null;
const modelCheck = {
id: 'asr-model',
label: 'ASR Model',
status: modelStatus === 'ready' ? 'pass'
: modelStatus === 'loading' ? 'loading'
: modelStatus === 'error' ? 'fail'
: modelStatus === 'error' || modelData?.sub_stage === '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,
: modelStatus === 'loading' ? (modelDetail || 'Loading… (this may take 1-2 minutes on first run)')
: (modelData?.sub_stage === 'error' ? (modelErr || 'Failed to load') : 'Not loaded yet — will load on first transcription'),
fix: (modelStatus === 'error' || modelData?.sub_stage === 'error')
? (modelErr ? `Error: ${modelErr}. Check logs and try restarting.` : 'Check logs for model loading errors. Try restarting.')
: null,
};
checks.push(modelCheck);
+30 -26
View File
@@ -4,10 +4,8 @@
.sidebar__tabs {
display: flex;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
/* Match chrome same bg + hairline as top/bottom bars so the sidebar
header reads as part of the frame, not a separate panel. */
gap: var(--space-2);
padding: var(--space-1) var(--space-2);
border-bottom: 1px solid var(--chrome-border);
background: var(--chrome-bg);
flex-shrink: 0;
@@ -21,16 +19,17 @@
.sidebar.is-collapsed .sidebar__tab {
max-width: 100%;
padding: 0;
width: 34px;
height: 34px;
width: 36px;
height: 36px;
flex: none;
}
.sidebar.is-collapsed .sidebar__tab svg {
width: 16px;
height: 16px;
width: 18px;
height: 18px;
}
.sidebar__tab {
position: relative;
flex: 1;
height: var(--chrome-pill-h);
cursor: pointer;
@@ -45,7 +44,7 @@
gap: 5px;
--sidebar-tab-accent: var(--color-brand);
white-space: nowrap;
padding: 0 8px;
padding: 0 10px;
}
.sidebar__tab:hover {
background: var(--chrome-hover-bg);
@@ -60,21 +59,26 @@
outline: none;
box-shadow: var(--focus-ring);
}
.sidebar__tab-label {
font-family: var(--font-sans);
font-size: 10.5px;
font-weight: 600;
letter-spacing: 0.01em;
}
.sidebar__tab-count {
.sidebar__tab-badge {
position: absolute;
top: -2px;
right: -2px;
font-family: var(--chrome-font-mono);
font-size: 9.5px;
opacity: 0.6;
font-size: 8px;
font-weight: 700;
min-width: 14px;
height: 14px;
line-height: 14px;
text-align: center;
padding: 0 3px;
border-radius: 99px;
background: color-mix(in srgb, var(--sidebar-tab-accent) 25%, transparent);
color: var(--sidebar-tab-accent);
}
/* ── Search ─────────────────────────────────────────────────── */
.sidebar__search {
padding: 6px 8px 2px 8px;
padding: 3px 4px 2px 4px;
flex-shrink: 0;
position: relative;
}
@@ -107,7 +111,7 @@
/* ── Save-project button tint ───────────────────────────────── */
.sidebar__save-btn {
margin-bottom: var(--space-4);
margin-bottom: var(--space-2);
color: var(--chrome-fg);
border: 1px solid var(--chrome-border-strong);
background: transparent;
@@ -154,7 +158,7 @@
letter-spacing: var(--chrome-label-track);
text-transform: uppercase;
color: var(--chrome-fg-muted);
margin-bottom: var(--space-3);
margin-bottom: var(--space-2);
display: flex;
justify-content: space-between;
align-items: center;
@@ -165,8 +169,8 @@
/* ── Collapsed-mode icon tiles — chrome square buttons ────────── */
.sidebar__icon-tile {
width: 32px;
height: 32px;
width: 36px;
height: 36px;
flex-shrink: 0;
display: flex;
justify-content: center;
@@ -211,7 +215,7 @@
.sidebar__scroll {
flex: 1;
overflow-y: auto;
padding: 8px;
padding: 3px 4px;
display: flex;
flex-direction: column;
align-items: stretch;
@@ -258,8 +262,8 @@
/* Restore / re-open icon tiles on history + export rows */
.sidebar-tile {
width: 32px;
height: 32px;
width: 36px;
height: 36px;
flex-shrink: 0;
display: flex;
justify-content: center;
+10 -10
View File
@@ -11,6 +11,7 @@ import { clearHistory as clearGenHistory } from '../api/generate';
import { Button } from '../ui';
import { useAppStore } from '../store';
import './Sidebar.css';
import { askConfirm } from '../utils/dialog';
const SIDEBAR_TABS = [
{ id: 'projects', icon: FolderOpen, accent: '#b8bb26' },
@@ -78,7 +79,7 @@ export default function Sidebar(props) {
), [exportHistory, qLower]);
const handleClearHistory = async () => {
if (!confirm(`Clear all ${history.length + dubHistory.length} history items? This cannot be undone.`)) return;
if (!(await askConfirm(`Clear all ${history.length + dubHistory.length} history items? This cannot be undone.`))) return;
await clearGenHistory();
await clearDubHistory();
await loadHistory();
@@ -105,9 +106,8 @@ export default function Sidebar(props) {
style={{ '--sidebar-tab-accent': accent }}
title={`${tabLabel[id]} (${tabCount[id]})`}
>
<Icon size={12} />
{!isSidebarCollapsed && <span className="sidebar__tab-label">{tabLabel[id]}</span>}
{!isSidebarCollapsed && <span className="sidebar__tab-count">{tabCount[id]}</span>}
<Icon size={13} />
{tabCount[id] > 0 && <span className="sidebar__tab-badge">{tabCount[id]}</span>}
</button>
))}
</div>
@@ -168,7 +168,7 @@ export default function Sidebar(props) {
className="sidebar__section-title"
onClick={() => setIsSidebarProjectsCollapsed(!isSidebarProjectsCollapsed)}
>
<span>{mode === 'dub' ? 'Studio Projects (Dubbing)' : (mode === 'clone' ? 'Voice Clones (Audio)' : 'Designed Voices (Synthetic)')}</span>
<span>{mode === 'dub' ? 'Dub Projects' : (mode === 'clone' ? 'Voice Clones' : 'Designed Voices')}</span>
{isSidebarProjectsCollapsed ? <ChevronDown size={12} /> : <ChevronUp size={12} />}
</div>
)}
@@ -298,7 +298,7 @@ export default function Sidebar(props) {
active={activeProjectId === proj.id}
rotSeed={proj.id}
>
<Film size={14} />
<Film size={18} />
</IconTile>
))}
@@ -310,7 +310,7 @@ export default function Sidebar(props) {
active={selectedProfile === proj.id}
rotSeed={proj.id}
>
{mode === 'clone' ? <Fingerprint size={14} /> : <Wand2 size={14} />}
{mode === 'clone' ? <Fingerprint size={18} /> : <Wand2 size={18} />}
{proj.is_locked && <Lock size={8} className="sidebar__icon-tile__lock" />}
</IconTile>
))}
@@ -415,14 +415,14 @@ export default function Sidebar(props) {
{isSidebarCollapsed && filteredDubHistory.map(item => (
<div key={`dub-${item.id}`} title={`Dub: ${item.filename}`} onClick={() => restoreDubHistory(item)}
className="sidebar-tile sidebar-tile--audio">
<Film size={14} />
<Film size={18} />
</div>
))}
{isSidebarCollapsed && filteredHistory.map(item => (
<div key={item.id} title={`${item.mode || 'history'}: ${item.text}`} onClick={() => restoreHistory(item)}
className={`sidebar-tile ${item.mode === 'clone' ? 'sidebar-tile--clone' : 'sidebar-tile--design'}`}>
{item.mode === 'clone' ? <Fingerprint size={14} /> : <Wand2 size={14} />}
{item.mode === 'clone' ? <Fingerprint size={18} /> : <Wand2 size={18} />}
</div>
))}
@@ -485,7 +485,7 @@ export default function Sidebar(props) {
onClick={() => revealInFolder(item.destination_path)}
className={`sidebar-tile ${item.mode === 'audio' ? 'sidebar-tile--audio' : 'sidebar-tile--success'}`}
>
<FolderOpen size={14} />
<FolderOpen size={18} />
</div>
))}
</>
@@ -4,15 +4,15 @@
}
.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%;
flex: 0 0 auto; aspect-ratio: 16 / 9; max-height: 45%;
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;
position: relative; overflow: hidden; flex: 1 1 auto; min-height: 80px; max-height: 160px;
}
.wfm-wave-inner {
height: 100%; min-height: 140px; border-radius: 4px; width: 100%; overflow: hidden;
height: 100%; min-height: 80px; border-radius: 4px; width: 100%; overflow: hidden;
}
.wfm-loading {
position: absolute; inset: 0; display: flex; align-items: center;
+1 -1
View File
@@ -121,7 +121,7 @@ export default function WaveformTimeline({
// Start at the container's measured height; a ResizeObserver below
// keeps WaveSurfer in sync when the column resizes. Fallback to 200
// if layout hasn't settled yet so we never render a flat sliver.
const initialHeight = Math.max(140, waveContainerRef.current.clientHeight || 200);
const initialHeight = Math.max(80, Math.min(waveContainerRef.current.clientHeight || 120, 160));
const minimap = MinimapPlugin.create({
height: 20,
waveColor: 'rgba(168,153,132,0.25)',
+91
View File
@@ -0,0 +1,91 @@
/**
* useRecording microphone recording with auto-cleanup via the backend.
*
* Extracted from App.jsx to reduce its useState/useRef count.
*/
import { useState, useRef } from 'react';
import { toast } from 'react-hot-toast';
import { cleanAudio as apiCleanAudio } from '../api/system';
export default function useRecording(ingestRefAudio) {
const [isRecording, setIsRecording] = useState(false);
const [isCleaning, setIsCleaning] = useState(false);
const [recordingTime, setRecordingTime] = useState(0);
const mediaRecorderRef = useRef(null);
const recordingChunksRef = useRef([]);
const recordingTimerRef = useRef(null);
const startRecording = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm;codecs=opus' });
mediaRecorderRef.current = mediaRecorder;
recordingChunksRef.current = [];
setRecordingTime(0);
mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) recordingChunksRef.current.push(e.data);
};
mediaRecorder.onstop = async () => {
clearInterval(recordingTimerRef.current);
stream.getTracks().forEach(t => t.stop());
const blob = new Blob(recordingChunksRef.current, { type: 'audio/webm' });
if (blob.size < 1000) {
toast.error("Recording too short");
return;
}
// Send to backend for denoising
setIsCleaning(true);
try {
const formData = new FormData();
formData.append("audio", blob, "recording.webm");
const res = await apiCleanAudio(formData);
const cleanBlob = await res.blob();
const cleanFilename = res.headers.get("X-Clean-Filename") || "recording_clean.wav";
const cleanFile = new File([cleanBlob], cleanFilename, { type: "audio/wav" });
await ingestRefAudio(cleanFile);
toast.success("🎙️ Recording cleaned & loaded!");
} catch (e) {
// Fallback: use raw recording without denoising
const rawFile = new File([blob], "recording.webm", { type: "audio/webm" });
await ingestRefAudio(rawFile);
toast.success("Recording loaded (raw — denoising unavailable)");
} finally {
setIsCleaning(false);
}
};
mediaRecorder.start(250); // Collect chunks every 250ms
setIsRecording(true);
// Timer
const st = Date.now();
recordingTimerRef.current = setInterval(() => {
setRecordingTime(((Date.now() - st) / 1000).toFixed(1));
}, 100);
} catch (e) {
toast.error("Microphone access denied");
}
};
const stopRecording = () => {
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.stop();
}
setIsRecording(false);
};
return {
isRecording,
isCleaning,
recordingTime,
startRecording,
stopRecording,
};
}
+195
View File
@@ -0,0 +1,195 @@
/**
* useSegmentEditing undo/redo stack + segment CRUD operations for the dub timeline.
*
* Extracted from App.jsx to reduce its useState/useRef/useCallback count.
* All segment mutations go through this hook so undo tracking is automatic.
*/
import { useState, useRef, useCallback } from 'react';
import { useAppStore } from '../store';
import { askConfirm } from '../utils/dialog';
import { apiPost } from '../api/client';
export default function useSegmentEditing() {
const dubSegments = useAppStore(s => s.dubSegments);
const setDubSegments = useAppStore(s => s.setDubSegments);
// ── Undo / Redo ──
const undoStack = useRef([]);
const redoStack = useRef([]);
const pushUndo = (segments) => {
undoStack.current.push(JSON.stringify(segments));
if (undoStack.current.length > 50) undoStack.current.shift();
redoStack.current = []; // clear redo on new edit
};
const undo = () => {
if (undoStack.current.length === 0) return;
redoStack.current.push(JSON.stringify(dubSegments));
const prev = JSON.parse(undoStack.current.pop());
setDubSegments(prev);
};
const redo = () => {
if (redoStack.current.length === 0) return;
undoStack.current.push(JSON.stringify(dubSegments));
const next = JSON.parse(redoStack.current.pop());
setDubSegments(next);
};
// Wrap setDubSegments calls that are user-edits with undo tracking
const editSegments = (newSegs) => {
pushUndo(dubSegments);
setDubSegments(newSegs);
};
// Stable handlers for virtualized segment rows. Use functional updates so
// they don't depend on dubSegments identity (avoids row re-renders).
const segmentEditField = useCallback((id, field, value) => {
pushUndo(dubSegments);
setDubSegments(prev => prev.map(s => s.id === id ? { ...s, [field]: value } : s));
}, [dubSegments]);
const segmentDelete = useCallback((id) => {
pushUndo(dubSegments);
setDubSegments(prev => prev.filter(s => s.id !== id));
}, [dubSegments]);
const segmentRestoreOriginal = useCallback((id) => {
pushUndo(dubSegments);
setDubSegments(prev => prev.map(s => s.id === id
? { ...s, text: s.text_original || s.text, translate_error: undefined }
: s));
}, [dubSegments]);
// Segment multi-select
const [selectedSegIds, setSelectedSegIds] = useState(new Set());
const lastSelectedIdxRef = useRef(null);
const toggleSegSelect = useCallback((id, idx, shift) => {
setSelectedSegIds(prev => {
const next = new Set(prev);
if (shift && lastSelectedIdxRef.current !== null) {
const [a, b] = [lastSelectedIdxRef.current, idx].sort((x, y) => x - y);
for (let i = a; i <= b; i++) {
const s = dubSegments[i];
if (s) next.add(s.id);
}
} else {
if (next.has(id)) next.delete(id); else next.add(id);
lastSelectedIdxRef.current = idx;
}
return next;
});
}, [dubSegments]);
const selectAllSegs = useCallback((segs) => {
setSelectedSegIds(new Set(segs.map(s => s.id)));
}, []);
const clearSegSelection = useCallback(() => setSelectedSegIds(new Set()), []);
// Bulk actions
const bulkApplyToSelected = useCallback((patch) => {
if (!selectedSegIds.size) return;
pushUndo(dubSegments);
setDubSegments(prev => prev.map(s => selectedSegIds.has(s.id) ? { ...s, ...patch } : s));
}, [dubSegments, selectedSegIds]);
const bulkDeleteSelected = useCallback(async () => {
if (!selectedSegIds.size) return;
if (!(await askConfirm(`Delete ${selectedSegIds.size} selected segment${selectedSegIds.size === 1 ? '' : 's'}?`))) return;
pushUndo(dubSegments);
setDubSegments(prev => prev.filter(s => !selectedSegIds.has(s.id)));
setSelectedSegIds(new Set());
}, [dubSegments, selectedSegIds]);
// Split at text cursor. Time split proportional to cursor position in text.
const segmentSplit = useCallback((id, cursorPos) => {
pushUndo(dubSegments);
setDubSegments(prev => {
const idx = prev.findIndex(s => s.id === id);
if (idx < 0) return prev;
const seg = prev[idx];
const text = seg.text || '';
const pos = Math.max(1, Math.min(cursorPos, text.length - 1));
const ratio = text.length > 0 ? pos / text.length : 0.5;
const midT = seg.start + (seg.end - seg.start) * ratio;
const left = { ...seg, id: `${seg.id}_a`, text: text.slice(0, pos).trim(), end: midT, text_original: text.slice(0, pos).trim() };
const right = { ...seg, id: `${seg.id}_b`, text: text.slice(pos).trim(), start: midT, text_original: text.slice(pos).trim() };
return [...prev.slice(0, idx), left, right, ...prev.slice(idx + 1)];
});
}, [dubSegments]);
// Merge segment with its next sibling.
const segmentMerge = useCallback((id) => {
pushUndo(dubSegments);
setDubSegments(prev => {
const idx = prev.findIndex(s => s.id === id);
if (idx < 0 || idx >= prev.length - 1) return prev;
const a = prev[idx];
const b = prev[idx + 1];
const merged = {
...a,
text: `${a.text || ''} ${b.text || ''}`.trim(),
text_original: `${a.text_original || a.text || ''} ${b.text_original || b.text || ''}`.trim(),
end: b.end,
};
return [...prev.slice(0, idx), merged, ...prev.slice(idx + 2)];
});
}, [dubSegments]);
// Direction editor state
const [directionSegId, setDirectionSegId] = useState(null);
const openDirection = useCallback((seg) => setDirectionSegId(seg.id), []);
const closeDirection = useCallback(() => setDirectionSegId(null), []);
const saveDirection = useCallback((value) => {
if (!directionSegId) return;
pushUndo(dubSegments);
setDubSegments(prev => prev.map(s => s.id === directionSegId
? { ...s, direction: value || undefined }
: s));
}, [directionSegId, dubSegments]);
// Incremental plan — tracks which segments changed since last generate
const [lastGenFingerprints, setLastGenFingerprints] = useState({});
const [incrementalPlan, setIncrementalPlan] = useState(null);
const recomputeIncremental = useCallback(async () => {
if (!dubSegments.length || !Object.keys(lastGenFingerprints).length) {
setIncrementalPlan(null);
return;
}
try {
const res = await apiPost('/tools/incremental', {
segments: dubSegments.map(s => ({
id: String(s.id), text: s.text, target_lang: s.target_lang,
profile_id: s.profile_id, instruct: s.instruct,
speed: s.speed, direction: s.direction,
})),
stored_hashes: lastGenFingerprints,
});
setIncrementalPlan({ stale: res.stale, fresh: res.fresh });
} catch (e) {
console.warn('incremental plan failed', e);
}
}, [dubSegments, lastGenFingerprints]);
return {
// Undo/Redo
undo, redo, pushUndo, editSegments,
// Per-segment operations
segmentEditField, segmentDelete, segmentRestoreOriginal,
segmentSplit, segmentMerge,
// Multi-select
selectedSegIds, setSelectedSegIds,
toggleSegSelect, selectAllSegs, clearSegSelection,
bulkApplyToSelected, bulkDeleteSelected,
// Direction editor
directionSegId, openDirection, closeDirection, saveDirection,
// Incremental plan
lastGenFingerprints, setLastGenFingerprints,
incrementalPlan, setIncrementalPlan,
recomputeIncremental,
};
}
+4 -4
View File
@@ -1916,10 +1916,10 @@ div[role="dialog"].audio-trimmer {
border-radius: var(--chrome-radius-pill) !important;
background: transparent !important;
border: 1px solid var(--chrome-border) !important;
padding: 8px 10px !important;
margin-bottom: 6px;
padding: 6px 8px !important;
margin-bottom: 3px;
position: relative;
display: flex; flex-direction: column; gap: 4px;
display: flex; flex-direction: column; gap: 2px;
transition: background var(--dur-fast), border-color var(--dur-fast);
}
.history-item::before {
@@ -1978,7 +1978,7 @@ div[role="dialog"].audio-trimmer {
/* Action row — visible on hover, always visible on focus-within */
.history-actions {
display: flex; gap: 4px; margin-top: 4px;
display: flex; gap: 4px; margin-top: 2px;
opacity: 0; max-height: 0;
overflow: hidden;
transition: opacity 0.2s, max-height 0.2s;
+12 -1
View File
@@ -27,11 +27,22 @@ const queryClient = new QueryClient({
},
});
import { Suspense, lazy } from 'react';
const CaptureWidget = lazy(() => import('./components/CaptureWidget.jsx'));
export function bootstrapApp() {
const isWidget = window.location.search.includes('window=widget');
createRoot(document.getElementById('root')).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
{isWidget ? (
<Suspense fallback={<div>Loading...</div>}>
<CaptureWidget />
</Suspense>
) : (
<App />
)}
</QueryClientProvider>
</StrictMode>,
);
+9 -8
View File
@@ -308,7 +308,7 @@ export default function DubTab(props) {
e.preventDefault();
e.currentTarget.classList.remove('is-dragging');
const file = e.dataTransfer.files[0];
if (file && file.type.startsWith('video/')) {
if (file && (file.type.startsWith('video/') || file.type.startsWith('audio/') || /\.(mp3|wav|flac|m4a|ogg)$/i.test(file.name))) {
setDubVideoFile(file);
setDubStep('idle');
fileToMediaUrl(file, null).then(urls => setDubLocalBlobUrl(urls));
@@ -318,8 +318,8 @@ export default function DubTab(props) {
<UploadCloud color="#d3869b" size={28} />
</div>
<div className="dub-idle-drop__lines">
<div className="dub-idle-drop__title">Drop video here</div>
<div className="dub-idle-drop__sub">MP4 · MOV · MKV · WEBM</div>
<div className="dub-idle-drop__title">Drop video or audio here</div>
<div className="dub-idle-drop__sub">MP4 · MOV · MKV · WEBM · MP3 · WAV · FLAC · M4A</div>
</div>
<div
className="dub-ingest-row"
@@ -360,7 +360,7 @@ export default function DubTab(props) {
</label>
)}
<input type="file" accept="video/*" id="video-upload" className="dub-hidden-file"
<input type="file" accept="video/*,audio/*,.mp3,.wav,.m4a,.flac,.ogg" id="video-upload" className="dub-hidden-file"
onChange={e => {
const file = e.target.files[0];
if (!file) return;
@@ -576,11 +576,8 @@ export default function DubTab(props) {
</div>
</div>
)}
</div>
{/* RIGHT: Settings + Segment Table */}
<div className="studio-panel dub-panel-col">
{/* Collapsed: one-line summary + Translate All. Expanded: full grid. */}
{/* Translation settings — collapsed or expanded */}
{!settingsOpen && (
<div className="dub-settings-summary">
<button
@@ -755,6 +752,10 @@ export default function DubTab(props) {
</div>
</div>
)}
</div>
{/* RIGHT: Segment Table */}
<div className="studio-panel dub-panel-col">
{dubTranscript && (
<div className="dub-transcript-toggle-wrap">
+9
View File
@@ -126,6 +126,7 @@ export function ModelStoreTab({ info, modelBadge }) {
if (!value) return;
setHfSaving(true);
try {
const { API } = await import('../api/client');
const res = await fetch(`${API}/system/set-env`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -613,6 +614,14 @@ export function ModelStoreTab({ info, modelBadge }) {
<Button size="sm" variant="subtle" onClick={saveHfToken} disabled={hfSaving || !hfToken.trim()} loading={hfSaving}>
Save
</Button>
<a
href="#"
className="models-toolbar__hf-link"
onClick={e => { e.preventDefault(); openExternal('https://huggingface.co/settings/tokens'); }}
title="Open huggingface.co/settings/tokens"
>
Get token
</a>
</div>
)}
{hfTokenSet && (
+11
View File
@@ -324,6 +324,17 @@
background: rgba(142, 192, 124, 0.08);
border: 1px solid rgba(142, 192, 124, 0.2);
}
.models-toolbar__hf-link {
font-size: 0.68rem;
color: #83a598;
text-decoration: none;
white-space: nowrap;
transition: color 0.15s;
}
.models-toolbar__hf-link:hover {
color: #b8bb26;
text-decoration: underline;
}
/* ── Footnote — pinned to bottom ─────────────────────────────────────── */
.setup-wizard__footnote {
+2 -1
View File
@@ -10,6 +10,7 @@ import { searchYoutube, downloadYoutubeClip, deleteGalleryVoice, saveVoiceAsProf
import { useGalleryCategories, useGalleryVoices } from '../api/hooks';
import AudioTrimmer from '../components/AudioTrimmer';
import './VoiceGallery.css';
import { askConfirm } from '../utils/dialog';
// Check if running in Tauri
const isTauri = window.__TAURI__ != null || window.location.protocol === 'tauri:';
@@ -167,7 +168,7 @@ export default function VoiceGallery() {
};
const handleDeleteVoice = async (voice) => {
if (!confirm(`Delete "${voice.name}"?`)) return;
if (!(await askConfirm(`Delete "${voice.name}"?`))) return;
try {
await deleteGalleryVoice(voice.id);
loadVoices();
+3 -2
View File
@@ -11,6 +11,7 @@ import {
import { generateSpeech } from '../api/generate';
import { API } from '../api/client';
import './VoiceProfile.css';
import { askConfirm } from '../utils/dialog';
/**
* VoiceProfile per-voice detail page.
@@ -95,7 +96,7 @@ export default function VoiceProfile({ voiceId, onBack, onOpenProject, onDeleted
};
const onDelete = async () => {
if (!confirm(`Delete "${profile.name}" permanently? This also removes the reference audio on disk.`)) return;
if (!(await askConfirm(`Delete "${profile.name}" permanently? This also removes the reference audio on disk.`))) return;
try {
await deleteProfile(voiceId);
toast.success('Voice deleted');
@@ -106,7 +107,7 @@ export default function VoiceProfile({ voiceId, onBack, onOpenProject, onDeleted
};
const onUnlock = async () => {
if (!confirm('Unlock this voice? Future generations will no longer be bit-reproducible.')) return;
if (!(await askConfirm('Unlock this voice? Future generations will no longer be bit-reproducible.'))) return;
try {
await unlockProfile(voiceId);
await reload();
+7 -3
View File
@@ -20,12 +20,16 @@
}
@keyframes ui-dialog-pop {
from { opacity: 0; transform: translateY(8px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
from { opacity: 0; transform: translate(-50%, -50%) translateY(8px) scale(0.98); }
to { opacity: 1; transform: translate(-50%, -50%) translateY(0) scale(1); }
}
.ui-dialog {
position: relative;
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: calc(var(--z-dialog) + 1);
background: linear-gradient(160deg, rgba(47, 41, 39, 0.95) 0%, rgba(32, 28, 27, 0.95) 100%);
backdrop-filter: var(--glass-blur-md);
-webkit-backdrop-filter: var(--glass-blur-md);
+9
View File
@@ -0,0 +1,9 @@
const isTauri = typeof window !== 'undefined' && !!(window.__TAURI_INTERNALS__ || window.__TAURI__);
export async function askConfirm(message, title = 'Confirm') {
if (isTauri) {
const { confirm } = await import('@tauri-apps/plugin-dialog');
return await confirm(message, { title });
}
return Promise.resolve(window.confirm(message));
}
+116
View File
@@ -0,0 +1,116 @@
/**
* Media utilities shared across the app.
*
* Extracted from App.jsx to reduce file size and enable independent testing.
*/
const isTauri = typeof window !== 'undefined' && !!(window.__TAURI_INTERNALS__ || window.__TAURI__);
// ── Tauri window maximise on double-click ─────────────────────────────
let tauriWindow = null;
if (isTauri) {
import('@tauri-apps/api/window').then(m => { tauriWindow = m; });
}
export const doubleClickMaximize = () => {
if (tauriWindow) tauriWindow.getCurrentWindow().toggleMaximize();
};
// ── File → media URL ──────────────────────────────────────────────────
const _PREVIEW_API = import.meta.env.VITE_OMNIVOICE_API || 'http://localhost:3900';
/**
* Convert a File object to a media-safe URL.
* In Tauri's WebKit, blob: URLs fail for <video>/<audio> elements.
* We upload to the backend's /preview endpoint and serve via HTTP instead.
* Falls back to createObjectURL for regular browsers.
*/
export const fileToMediaUrl = async (file, prevUrls) => {
// Revoke previous blob URLs if they exist
if (prevUrls?.videoUrl?.startsWith('blob:')) URL.revokeObjectURL(prevUrls.videoUrl);
if (prevUrls?.audioUrl?.startsWith('blob:')) URL.revokeObjectURL(prevUrls.audioUrl);
if (isTauri) {
try {
const form = new FormData();
form.append('video', file, file.name || 'media.wav');
const res = await fetch(`${_PREVIEW_API}/preview/upload`, { method: 'POST', body: form });
const data = await res.json();
return {
videoUrl: `${_PREVIEW_API}${data.url}`,
audioUrl: data.audioUrl ? `${_PREVIEW_API}${data.audioUrl}` : `${_PREVIEW_API}${data.url}`
};
} catch (e) {
console.warn('Preview upload failed, falling back to blob URL:', e);
}
}
const url = URL.createObjectURL(file);
return { videoUrl: url, audioUrl: url };
};
// ── Blob audio playback ───────────────────────────────────────────────
/**
* Play audio from a Blob. Uses Web Audio API in Tauri (blob URLs blocked)
* and standard Audio() elsewhere.
*/
export const playBlobAudio = async (blob) => {
if (isTauri) {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
// WebKit suspends AudioContext by default — must resume before decoding
if (ctx.state === 'suspended') await ctx.resume();
try {
const buf = await blob.arrayBuffer();
const decoded = await ctx.decodeAudioData(buf);
const src = ctx.createBufferSource();
src.buffer = decoded;
src.connect(ctx.destination);
src.start(0);
src.onended = () => ctx.close();
} catch (e) {
console.error('playBlobAudio decode error:', e);
ctx.close();
// Fallback: try the standard Audio() path even in Tauri
try {
const url = URL.createObjectURL(blob);
const a = new Audio(url);
await a.play();
a.onended = () => URL.revokeObjectURL(url);
} catch (e2) {
console.error('playBlobAudio fallback error:', e2);
}
}
} else {
const url = URL.createObjectURL(blob);
const a = new Audio(url);
a.play().catch((e) => console.error('playBlobAudio play error:', e));
a.onended = () => URL.revokeObjectURL(url);
}
};
// ── Notification ping ─────────────────────────────────────────────────
let _pingCtx = null;
export const playPing = () => {
try {
if (!_pingCtx) _pingCtx = new (window.AudioContext || window.webkitAudioContext)();
const ctx = _pingCtx;
if (ctx.state === 'suspended') ctx.resume();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.type = 'sine';
osc.frequency.setValueAtTime(600, ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(900, ctx.currentTime + 0.08);
osc.frequency.exponentialRampToValueAtTime(1200, ctx.currentTime + 0.15);
gain.gain.setValueAtTime(0, ctx.currentTime);
gain.gain.linearRampToValueAtTime(0.18, ctx.currentTime + 0.03);
gain.gain.linearRampToValueAtTime(0, ctx.currentTime + 0.25);
osc.start(ctx.currentTime);
osc.stop(ctx.currentTime + 0.25);
} catch (e) {}
};
// Re-export for convenience
export { isTauri };
+5 -2
View File
@@ -19,7 +19,10 @@
"desktop-prod:upgrade": "bash scripts/desktop-prod.sh --keep-data",
"build": "turbo run build",
"start": "turbo run start",
"test:frontend": "node --test tests/frontend/*.test.mjs"
"test:frontend": "node --test tests/frontend/*.test.mjs",
"smoke-test": "bash scripts/smoke-test.sh",
"smoke-test:quick": "bash scripts/smoke-test.sh --skip-build --skip-model",
"smoke-test:upgrade": "bash scripts/smoke-test.sh --keep-data --skip-build"
},
"workspaces": [
"frontend"
@@ -28,7 +31,7 @@
"concurrently": "^9.2.1",
"kill-port-process": "^4.0.2",
"playwright": "^1.59.1",
"turbo": "^2.9.6",
"turbo": "^2.9.7",
"typescript": "^6.0.3",
"wait-on": "^9.0.5"
}
+2 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "omnivoice"
version = "0.2.4"
version = "0.2.7"
description = "OmniVoice: Towards Omnilingual Zero-Shot Text-to-Speech with Diffusion Language Models"
readme = "README.md"
license = "Apache-2.0"
@@ -40,6 +40,7 @@ dependencies = [
"webdataset",
"numpy",
"soundfile",
"setuptools",
"psutil>=7.2.2",
# Pinned to 3.x — pyannote 4.x removed `use_auth_token` from `Inference`
# which whisperx 3.4.2 still passes, blowing up `whisperx.load_model()`
View File
Executable
+153
View File
@@ -0,0 +1,153 @@
#!/bin/sh
# OmniVoice Studio — universal launcher.
#
# Works on macOS, Linux, and WSL. Starts the FastAPI backend, waits for it
# to become healthy, then opens the web UI in the default browser.
#
# Usage:
# ./run.sh # normal launch
# ./run.sh --no-open # skip auto-opening browser
# Press Ctrl+C to shut down.
set -e
# ── Output style ────────────────────────────────────────────────────────────
if [ -n "${NO_COLOR:-}" ]; then
C_OK="" C_DIM="" C_ERR="" C_RST=""
elif [ -t 1 ] || [ -n "${FORCE_COLOR:-}" ]; then
_ESC="$(printf '\033')"
C_OK="${_ESC}[38;5;108m"
C_DIM="${_ESC}[38;5;245m"
C_ERR="${_ESC}[91m"
C_RST="${_ESC}[0m"
else
C_OK="" C_DIM="" C_ERR="" C_RST=""
fi
have() { command -v "$1" >/dev/null 2>&1; }
# ── Parse flags ─────────────────────────────────────────────────────────────
NO_OPEN=false
for arg in "$@"; do
case "$arg" in
--no-open) NO_OPEN=true ;;
esac
done
# ── Resolve script directory ───────────────────────────────────────────────
SCRIPT_DIR="$(cd "$(dirname "$0")" 2>/dev/null && pwd)"
cd "$SCRIPT_DIR"
# ── Detect platform ────────────────────────────────────────────────────────
OS="linux"
if [ "$(uname)" = "Darwin" ]; then
OS="macos"
elif grep -qi microsoft /proc/version 2>/dev/null; then
OS="wsl"
fi
# ── PATH: ensure common shell-installed tools are available ────────────────
export PATH="$HOME/.local/bin:$HOME/.bun/bin:$PATH"
case "$OS" in
macos)
export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"
;;
esac
# ── Sanity check ───────────────────────────────────────────────────────────
if [ ! -d .venv ]; then
printf "${C_ERR}✗ No .venv/ — run ./install.sh first.${C_RST}\n" >&2
exit 1
fi
# ── Log directory (platform-aware) ─────────────────────────────────────────
case "$OS" in
macos) LOG_DIR="$HOME/Library/Application Support/OmniVoice" ;;
*) LOG_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/OmniVoice" ;;
esac
mkdir -p "$LOG_DIR"
LOG_FILE="$LOG_DIR/omnivoice-run.log"
# ── Start backend ──────────────────────────────────────────────────────────
PORT=3900
echo "${C_OK}${C_RST} Starting backend on port ${PORT} (log: ${C_DIM}${LOG_FILE}${C_RST})…"
uv run uvicorn main:app --app-dir backend --host 127.0.0.1 --port "$PORT" \
>"$LOG_FILE" 2>&1 &
BACKEND_PID=$!
# ── Cleanup on exit ────────────────────────────────────────────────────────
cleanup() {
echo ""
echo "${C_DIM}▸ Shutting down backend (pid $BACKEND_PID)…${C_RST}"
kill "$BACKEND_PID" 2>/dev/null || true
wait "$BACKEND_PID" 2>/dev/null || true
}
trap cleanup EXIT INT TERM
# ── Wait for backend health ────────────────────────────────────────────────
echo "${C_DIM}▸ Waiting for backend…${C_RST}"
_deadline=60
_elapsed=0
while [ "$_elapsed" -lt "$_deadline" ]; do
# Try curl first, fall back to wget
if have curl; then
curl -sf "http://127.0.0.1:${PORT}/system/info" -o /dev/null 2>/dev/null && break
elif have wget; then
wget -qO /dev/null "http://127.0.0.1:${PORT}/system/info" 2>/dev/null && break
fi
# Check that backend process is still alive
if ! kill -0 "$BACKEND_PID" 2>/dev/null; then
printf "${C_ERR}✗ Backend process exited unexpectedly.${C_RST}\n" >&2
echo " Last 20 lines of log:" >&2
tail -n 20 "$LOG_FILE" >&2 || true
exit 1
fi
sleep 1
_elapsed=$((_elapsed + 1))
done
# Final health check
_healthy=false
if have curl; then
curl -sf "http://127.0.0.1:${PORT}/system/info" -o /dev/null 2>/dev/null && _healthy=true
elif have wget; then
wget -qO /dev/null "http://127.0.0.1:${PORT}/system/info" 2>/dev/null && _healthy=true
fi
if [ "$_healthy" != true ]; then
printf "${C_ERR}✗ Backend didn't start in ${_deadline}s. See %s for errors.${C_RST}\n" "$LOG_FILE" >&2
tail -n 20 "$LOG_FILE" >&2 || true
exit 1
fi
URL="http://127.0.0.1:${PORT}/"
echo "${C_OK}▸ Backend up.${C_RST} Opening UI at ${C_OK}${URL}${C_RST}"
# ── Open browser (cross-platform) ──────────────────────────────────────────
if [ "$NO_OPEN" != true ]; then
if [ "$OS" = "macos" ] && have open; then
open "$URL"
elif [ "$OS" = "wsl" ]; then
# WSL: use Windows browser via PowerShell or cmd.exe
if have powershell.exe; then
powershell.exe -NoProfile -Command "Start-Process '$URL'" >/dev/null 2>&1 &
elif have cmd.exe; then
cmd.exe /c start "" "$URL" >/dev/null 2>&1 &
elif have xdg-open; then
xdg-open "$URL" >/dev/null 2>&1 &
else
echo " Open in your browser: $URL"
fi
elif have xdg-open; then
xdg-open "$URL" >/dev/null 2>&1 &
else
echo " Open in your browser: $URL"
fi
fi
echo ""
echo "OmniVoice Studio is running."
echo "Press Ctrl+C to shut down."
# Block until user hits Ctrl+C or backend exits.
wait "$BACKEND_PID"
+388
View File
@@ -0,0 +1,388 @@
#!/usr/bin/env bash
# ──────────────────────────────────────────────────────────────────────────
# smoke-test.sh — Automated end-to-end first-launch verification
#
# Simulates a REAL end-user fresh install:
# 1. Wipes all app data (venv, config, tools, logs, HF cache)
# 2. Builds the debug production bundle
# 3. Launches the app in the background
# 4. Polls the backend until it's healthy or timeout
# 5. Runs health checks against every critical endpoint
# 6. Checks device detection, model status, region config
# 7. Kills the app and reports pass/fail
#
# This is what you should run BEFORE every release. It catches:
# - Bootstrap failures (missing deps, bad downloads)
# - GPU detection regressions
# - FFmpeg/ffprobe resolution failures
# - Region mirror misconfig
# - Model loading crashes
#
# Usage:
# bun run smoke-test # full wipe + build + test
# bun run smoke-test:quick # skip build, re-test last binary
# bun run smoke-test:upgrade # keep data, test upgrade path
# ──────────────────────────────────────────────────────────────────────────
set -euo pipefail
APP_ID="com.debpalash.omnivoice-studio"
TAURI_DIR="frontend/src-tauri"
APP_NAME="OmniVoice Studio"
BACKEND_URL="http://127.0.0.1:3900"
# Timeouts (seconds)
BOOTSTRAP_TIMEOUT=600 # 10 min for full venv bootstrap
HEALTH_TIMEOUT=120 # 2 min for backend to become healthy after bootstrap
MODEL_TIMEOUT=180 # 3 min for model to load
# ── Colors ─────────────────────────────────────────────────────────────────
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m' # No Color
pass() { echo -e " ${GREEN}${NC} $1"; }
fail() { echo -e " ${RED}${NC} $1"; FAILURES=$((FAILURES + 1)); }
warn() { echo -e " ${YELLOW}${NC} $1"; }
info() { echo -e " ${CYAN}${NC} $1"; }
header() { echo -e "\n${BOLD}$1${NC}"; }
FAILURES=0
TESTS=0
APP_PID=""
# ── Cleanup on exit ────────────────────────────────────────────────────────
cleanup() {
if [ -n "$APP_PID" ] && kill -0 "$APP_PID" 2>/dev/null; then
info "Killing app (pid $APP_PID)..."
kill "$APP_PID" 2>/dev/null || true
sleep 2
kill -9 "$APP_PID" 2>/dev/null || true
fi
# Also kill any orphaned backend
pkill -f "uvicorn.*3900" 2>/dev/null || true
}
trap cleanup EXIT
# ── Detect platform ───────────────────────────────────────────────────────
OS="$(uname -s)"
case "$OS" in
Darwin) PLATFORM="macos" ;;
Linux) PLATFORM="linux" ;;
*) echo "❌ Unsupported platform: $OS"; exit 1 ;;
esac
if [ "$PLATFORM" = "macos" ]; then
APP_DATA="$HOME/Library/Application Support/${APP_ID}"
OV_DATA="$HOME/Library/Application Support/OmniVoice"
else
APP_DATA="${XDG_DATA_HOME:-$HOME/.local/share}/${APP_ID}"
OV_DATA="${XDG_DATA_HOME:-$HOME/.local/share}/OmniVoice"
fi
HF_CACHE="${HF_HOME:-$HOME/.cache/huggingface}"
# ── Flags ──────────────────────────────────────────────────────────────────
SKIP_BUILD=false
KEEP_DATA=false
SKIP_MODEL=false
for arg in "$@"; do
case "$arg" in
--skip-build) SKIP_BUILD=true ;;
--keep-data) KEEP_DATA=true ;;
--skip-model) SKIP_MODEL=true ;;
-h|--help)
echo "Usage: $0 [--skip-build] [--keep-data] [--skip-model]"
echo ""
echo " --skip-build Skip cargo build, use last compiled binary"
echo " --keep-data Don't wipe app data (test upgrade path)"
echo " --skip-model Skip waiting for TTS model load (saves time)"
exit 0
;;
esac
done
# ══════════════════════════════════════════════════════════════════════════
header "🧪 OmniVoice Studio — End-to-End Smoke Test"
echo " Platform: $PLATFORM | $(date)"
echo ""
# ── Phase 1: Clean ────────────────────────────────────────────────────────
header "Phase 1: Environment Reset"
if [ "$KEEP_DATA" = false ]; then
info "Wiping app data for fresh install simulation..."
for dir in "$APP_DATA" "$OV_DATA"; do
if [ -d "$dir" ]; then
rm -rf "$dir"
pass "Removed: $dir"
fi
done
pass "Clean slate — next launch bootstraps from zero"
else
warn "Keeping existing data (upgrade test mode)"
fi
# Kill any existing backend on port 3900
if lsof -i :3900 >/dev/null 2>&1; then
info "Killing existing process on port 3900..."
kill $(lsof -ti :3900) 2>/dev/null || true
sleep 1
fi
# ── Phase 2: Build ────────────────────────────────────────────────────────
header "Phase 2: Build"
BINARY="${TAURI_DIR}/target/debug/app"
if [ "$SKIP_BUILD" = false ]; then
info "Building debug bundle (this takes 1-3 min)..."
# Remove stale bundle
if [ "$PLATFORM" = "macos" ]; then
APP_BUNDLE="${TAURI_DIR}/target/debug/bundle/macos/${APP_NAME}.app"
[ -d "$APP_BUNDLE" ] && rm -rf "$APP_BUNDLE"
fi
BUILD_LOG=$(mktemp)
cd frontend
set +e
bunx tauri build --debug >"$BUILD_LOG" 2>&1
BUILD_EXIT=$?
set -e
cd ..
if [ $BUILD_EXIT -ne 0 ]; then
if grep -qi "TAURI_SIGNING_PRIVATE_KEY\|private key\|failed to bundle" "$BUILD_LOG"; then
warn "Non-fatal bundle warning (signing/bundling) — binary is fine"
else
echo ""
tail -20 "$BUILD_LOG"
rm -f "$BUILD_LOG"
fail "Build failed with exit code $BUILD_EXIT"
exit 1
fi
fi
rm -f "$BUILD_LOG"
if [ -f "$BINARY" ]; then
pass "Binary built: $BINARY"
else
fail "Binary not found at $BINARY"
exit 1
fi
else
if [ -f "$BINARY" ]; then
pass "Using existing binary: $BINARY (--skip-build)"
else
fail "No binary found. Run without --skip-build first."
exit 1
fi
fi
# ── Phase 3: Launch & Bootstrap ───────────────────────────────────────────
header "Phase 3: Launch & Bootstrap"
info "Starting app..."
"$BINARY" &
APP_PID=$!
info "App PID: $APP_PID"
# Wait for backend to come up
info "Waiting for backend health (timeout: ${BOOTSTRAP_TIMEOUT}s)..."
ELAPSED=0
INTERVAL=5
while [ $ELAPSED -lt $BOOTSTRAP_TIMEOUT ]; do
if curl -sf "${BACKEND_URL}/system/info" >/dev/null 2>&1; then
pass "Backend healthy after ${ELAPSED}s"
break
fi
sleep $INTERVAL
ELAPSED=$((ELAPSED + INTERVAL))
# Check if app crashed
if ! kill -0 "$APP_PID" 2>/dev/null; then
fail "App process died during bootstrap (after ${ELAPSED}s)"
echo ""
# Show crash log if available
CRASH_LOG="$OV_DATA/crash_log.txt"
if [ -f "$CRASH_LOG" ] && [ -s "$CRASH_LOG" ]; then
echo " 📋 Crash log:"
tail -20 "$CRASH_LOG" | sed 's/^/ /'
fi
exit 1
fi
done
if [ $ELAPSED -ge $BOOTSTRAP_TIMEOUT ]; then
fail "Backend did not start within ${BOOTSTRAP_TIMEOUT}s"
exit 1
fi
# ── Phase 4: Health Checks ────────────────────────────────────────────────
header "Phase 4: Health Checks"
check_endpoint() {
local name="$1"
local url="$2"
local jq_filter="${3:-}"
TESTS=$((TESTS + 1))
RESPONSE=$(curl -sf "$url" 2>/dev/null) || { fail "$name — HTTP error"; return; }
if [ -n "$jq_filter" ]; then
VALUE=$(echo "$RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print($jq_filter)" 2>/dev/null)
if [ -n "$VALUE" ] && [ "$VALUE" != "None" ]; then
pass "$name$VALUE"
else
fail "$name — unexpected response"
fi
else
pass "$name → OK"
fi
}
# Core endpoints
check_endpoint "GET /system/info" "${BACKEND_URL}/system/info" "d.get('device','?')"
check_endpoint "GET /sysinfo" "${BACKEND_URL}/sysinfo"
check_endpoint "GET /model/status" "${BACKEND_URL}/model/status" "d.get('status','?')"
# Device detection — the most critical check
TESTS=$((TESTS + 1))
DEVICE=$(curl -sf "${BACKEND_URL}/system/info" | python3 -c "import sys,json; print(json.load(sys.stdin).get('device','unknown'))" 2>/dev/null)
case "$DEVICE" in
mps|cuda|xpu|cpu)
pass "Device detection: $DEVICE"
;;
*)
if echo "$DEVICE" | grep -q "privateuseone"; then
pass "Device detection: DirectML ($DEVICE)"
else
fail "Device detection returned unexpected: $DEVICE"
fi
;;
esac
# Python version check
TESTS=$((TESTS + 1))
PY_VER=$(curl -sf "${BACKEND_URL}/system/info" | python3 -c "import sys,json; print(json.load(sys.stdin).get('python','?'))" 2>/dev/null)
if echo "$PY_VER" | grep -q "^3\.11"; then
pass "Python version: $PY_VER"
else
fail "Python version unexpected: $PY_VER (expected 3.11.x)"
fi
# Platform check
TESTS=$((TESTS + 1))
PLAT=$(curl -sf "${BACKEND_URL}/system/info" | python3 -c "import sys,json; print(json.load(sys.stdin).get('platform','?'))" 2>/dev/null)
if [ "$PLAT" = "darwin" ] || [ "$PLAT" = "linux" ] || [ "$PLAT" = "win32" ]; then
pass "Platform: $PLAT"
else
fail "Platform unexpected: $PLAT"
fi
# FFmpeg check — try the sysinfo endpoint
TESTS=$((TESTS + 1))
FFMPEG_OK=$(curl -sf "${BACKEND_URL}/system/info" | python3 -c "
import sys,json
d=json.load(sys.stdin)
print('ok' if d.get('error') is None else d.get('error','?'))
" 2>/dev/null)
if [ "$FFMPEG_OK" = "ok" ]; then
pass "No startup errors"
else
fail "Startup error: $FFMPEG_OK"
fi
# WebSocket endpoint — curl GET returns 403/400/404 (needs WS upgrade)
# We just verify the route exists by checking it doesn't hard-error.
TESTS=$((TESTS + 1))
WS_CODE=$(curl -s -o /dev/null -w "%{http_code}" "${BACKEND_URL}/ws/events" 2>/dev/null || echo "000")
if [ "$WS_CODE" != "000" ]; then
pass "WebSocket route reachable (/ws/events → HTTP $WS_CODE)"
else
fail "WebSocket route unreachable (connection refused)"
fi
# System notifications endpoint
check_endpoint "GET /system/notifications" "${BACKEND_URL}/system/notifications"
# ── Phase 5: Model Loading (optional) ─────────────────────────────────────
if [ "$SKIP_MODEL" = false ]; then
header "Phase 5: Model Loading"
info "Waiting for TTS model to load (timeout: ${MODEL_TIMEOUT}s)..."
ELAPSED=0
while [ $ELAPSED -lt $MODEL_TIMEOUT ]; do
STATUS=$(curl -sf "${BACKEND_URL}/model/status" | python3 -c "import sys,json; print(json.load(sys.stdin).get('status','?'))" 2>/dev/null)
SUB=$(curl -sf "${BACKEND_URL}/model/status" | python3 -c "import sys,json; print(json.load(sys.stdin).get('sub_stage','?'))" 2>/dev/null)
if [ "$STATUS" = "ready" ]; then
TESTS=$((TESTS + 1))
pass "Model loaded successfully (${ELAPSED}s)"
break
elif [ "$STATUS" = "loading" ]; then
info "Loading... ($SUB) [${ELAPSED}s]"
fi
sleep 10
ELAPSED=$((ELAPSED + 10))
done
if [ $ELAPSED -ge $MODEL_TIMEOUT ]; then
TESTS=$((TESTS + 1))
ERR=$(curl -sf "${BACKEND_URL}/model/status" | python3 -c "import sys,json; print(json.load(sys.stdin).get('error','unknown'))" 2>/dev/null)
fail "Model did not load within ${MODEL_TIMEOUT}s (error: $ERR)"
fi
else
warn "Skipping model load test (--skip-model)"
fi
# ── Phase 6: Region Config ────────────────────────────────────────────────
header "Phase 6: Config Verification"
TESTS=$((TESTS + 1))
CONFIG_FILE="$APP_DATA/config.json"
if [ -f "$CONFIG_FILE" ]; then
REGION=$(python3 -c "import json; print(json.load(open('$CONFIG_FILE')).get('region','?'))" 2>/dev/null)
pass "Region config: $REGION (from $CONFIG_FILE)"
else
# No config file = default "auto" — that's correct for fresh install
pass "Region config: auto (default, no config.json yet)"
fi
# ── Phase 7: Data Directory Structure ─────────────────────────────────────
header "Phase 7: Data Directories"
for dir in "$OV_DATA" "$APP_DATA"; do
TESTS=$((TESTS + 1))
if [ -d "$dir" ]; then
pass "Directory exists: $(basename $dir)/"
else
warn "Directory missing: $dir (may be created on first use)"
fi
done
# ── Results ────────────────────────────────────────────────────────────────
header "═══════════════════════════════════════════════════════════"
echo ""
if [ $FAILURES -eq 0 ]; then
echo -e " ${GREEN}${BOLD}ALL TESTS PASSED${NC} ($TESTS checks)"
echo ""
echo -e " ${CYAN}The app bootstraps correctly from zero and all"
echo -e " endpoints are healthy. This matches the end-user experience.${NC}"
else
echo -e " ${RED}${BOLD}$FAILURES FAILURE(S)${NC} out of $TESTS checks"
echo ""
echo -e " ${RED}Fix the failures above before releasing.${NC}"
fi
echo ""
echo " Logs: ~/Library/Logs/OmniVoice/"
echo " Data: $APP_DATA"
echo ""
exit $FAILURES
Generated
+3 -1
View File
@@ -2913,7 +2913,7 @@ wheels = [
[[package]]
name = "omnivoice"
version = "0.2.4"
version = "0.2.7"
source = { editable = "." }
dependencies = [
{ name = "accelerate" },
@@ -2934,6 +2934,7 @@ dependencies = [
{ name = "pydub" },
{ name = "pyinstaller" },
{ name = "python-multipart" },
{ name = "setuptools" },
{ name = "soundfile" },
{ name = "tensorboardx" },
{ name = "torch", version = "2.8.0", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'linux' and sys_platform != 'win32'" },
@@ -2998,6 +2999,7 @@ requires-dist = [
{ name = "python-multipart" },
{ name = "requests", marker = "extra == 'ui'" },
{ name = "s3prl", marker = "extra == 'eval'" },
{ name = "setuptools" },
{ name = "soundfile" },
{ name = "tensorboardx" },
{ name = "torch", marker = "sys_platform != 'linux' and sys_platform != 'win32'", specifier = ">=2.4" },