Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7ef134175 | ||
|
|
da0ad09db0 | ||
|
|
bc4e0a977c | ||
|
|
97bfbfb331 | ||
|
|
abbaf508e0 | ||
|
|
043e52a285 | ||
|
|
c47ade780a | ||
|
|
9e80643053 | ||
|
|
8afad73ed6 | ||
|
|
2a0c420ee9 | ||
|
|
46a2dd404d | ||
|
|
37a03acae3 | ||
|
|
fba066c3d0 | ||
|
|
a6aa9d79e6 | ||
|
|
0ecbf136e7 | ||
|
|
2d01dd915f | ||
|
|
0c1a3829d5 | ||
|
|
f727f1cff7 | ||
|
|
81c4b7d1ed | ||
|
|
41c23f6b3a | ||
|
|
c8d1858420 | ||
|
|
d6b1dc1b49 | ||
|
|
c654cd9e4a | ||
|
|
79d4f3b53d | ||
|
|
5e35e6d0d8 | ||
|
|
3a8c1dff76 | ||
|
|
080858b834 | ||
|
|
835280dc3e | ||
|
|
3b0dfabff8 | ||
|
|
326ad9956b | ||
|
|
425acc6799 | ||
|
|
888652f5bb | ||
|
|
79826e19bc |
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
@@ -85,3 +85,80 @@ jobs:
|
||||
- name: Run frontend node:test
|
||||
working-directory: frontend
|
||||
run: node --experimental-strip-types --no-warnings --test ../tests/frontend/*.test.mjs
|
||||
|
||||
# ── Cross-platform Tauri shell check ────────────────────────────────────
|
||||
# Catches platform-specific Rust regressions on PR (cfg(target_os=...)
|
||||
# gates, missing Windows/macOS deps, etc.) without spending the 15+ min
|
||||
# per-platform that a full `tauri build` takes. `cargo check` is the
|
||||
# lightest gate that exercises type-checking + linking for each target.
|
||||
# Full bundling stays in release.yml on tag push.
|
||||
tauri-cross-platform:
|
||||
name: Tauri shell check (${{ matrix.label }})
|
||||
needs: test
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: macos-14
|
||||
label: macOS
|
||||
rust_target: aarch64-apple-darwin
|
||||
- os: windows-2022
|
||||
label: Windows
|
||||
rust_target: x86_64-pc-windows-msvc
|
||||
- os: ubuntu-22.04
|
||||
label: Linux
|
||||
rust_target: x86_64-unknown-linux-gnu
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Rust (stable)
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.rust_target }}
|
||||
|
||||
# Per-target cache key so we don't conflict with the release matrix.
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: frontend/src-tauri -> target
|
||||
key: ${{ matrix.rust_target }}-check
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
|
||||
# Linux is the only host with non-trivial Tauri build deps —
|
||||
# webkit2gtk + libayatana-appindicator + xdo. Mirror release.yml.
|
||||
- name: Linux system deps
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
build-essential curl wget file libxdo-dev libssl-dev \
|
||||
libayatana-appindicator3-dev librsvg2-dev \
|
||||
libasound2-dev
|
||||
|
||||
- name: Cache bun deps
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('frontend/bun.lock', 'bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-
|
||||
|
||||
- name: Install frontend deps
|
||||
working-directory: frontend
|
||||
run: bun install
|
||||
|
||||
# tauri-build's setup hook reads tauri.conf.json's `frontendDist`
|
||||
# ("../dist"), which only exists after a frontend build. Without this,
|
||||
# `cargo check` would fail on a fresh checkout because the embedded
|
||||
# asset map can't resolve.
|
||||
- name: Build frontend (for tauri.conf.json frontendDist)
|
||||
working-directory: frontend
|
||||
run: bun run build
|
||||
|
||||
- name: Cargo check (Tauri shell)
|
||||
working-directory: frontend/src-tauri
|
||||
run: cargo check --target ${{ matrix.rust_target }} --message-format=short
|
||||
|
||||
@@ -201,6 +201,136 @@ 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
|
||||
# release publishable even when CHANGELOG hasn't been updated.
|
||||
- name: Extract CHANGELOG section for tag
|
||||
id: changelog
|
||||
shell: bash
|
||||
run: |
|
||||
TAG="${GITHUB_REF_NAME#v}"
|
||||
BODY=""
|
||||
if [ -f CHANGELOG.md ]; then
|
||||
BODY=$(awk -v tag="$TAG" '
|
||||
/^## \[/ {
|
||||
if (in_section) exit
|
||||
if ($0 ~ "\\[" tag "\\]") { in_section = 1; next }
|
||||
}
|
||||
in_section { print }
|
||||
' CHANGELOG.md | sed -e :a -e '/^\n*$/{$d;N;ba' -e '}')
|
||||
fi
|
||||
if [ -z "$BODY" ]; then
|
||||
BODY="Auto-generated release for ${GITHUB_REF_NAME}. See [CHANGELOG.md](https://github.com/${GITHUB_REPOSITORY}/blob/main/CHANGELOG.md) and the commit log for details."
|
||||
fi
|
||||
{
|
||||
echo 'body<<RELEASE_BODY_EOF'
|
||||
echo "$BODY"
|
||||
echo 'RELEASE_BODY_EOF'
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build + release (Tauri)
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
@@ -216,7 +346,7 @@ jobs:
|
||||
args: --target ${{ matrix.rust_target }} --bundles ${{ matrix.bundles }}
|
||||
tagName: ${{ github.ref_name }}
|
||||
releaseName: "OmniVoice Studio ${{ github.ref_name }}"
|
||||
releaseBody: "Auto-generated release. See commit log for changes."
|
||||
releaseBody: ${{ steps.changelog.outputs.body }}
|
||||
releaseDraft: ${{ inputs.draft || 'true' }}
|
||||
prerelease: false
|
||||
updaterJsonPreferNsis: false
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to OmniVoice Studio.
|
||||
|
||||
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
|
||||
- **Relicensed Studio under [Functional Source License (FSL-1.1-ALv2)](https://fsl.software/).** Free for personal, educational, internal-team, and non-commercial use. Each release converts automatically to Apache License, Version 2.0 on the second anniversary of its publication.
|
||||
- The bundled `omnivoice/` Python TTS model package remains separately licensed under Apache 2.0 by its upstream authors — not relicensed here.
|
||||
- In-app **Commercial License** page no longer publishes pricing tiers. Pricing is being finalized; the page now invites quote requests and links the FSL terms.
|
||||
|
||||
### Added
|
||||
- **Single-instance enforcement.** Launching a second copy now focuses the existing window instead of starting a second backend that races for port 3900. Powered by `tauri-plugin-single-instance`.
|
||||
- **Close-to-tray.** Clicking the window X (or `Cmd+W` on macOS) now hides the window and keeps the backend + tray menu alive. The tray "Quit" item is the only path that fully exits and shuts down the Python backend (cleanup moved to `RunEvent::ExitRequested`).
|
||||
- **Recording-state tray icon.** Tray icon flips to a red-dot variant while a dictation recording is active and reverts when it stops or errors out.
|
||||
- **Customizable global dictation hotkey.** New **Settings → Capture** tab. Record any modifier-plus-key combo, save it, and it's persisted in `config.json` and re-registered on every launch. Failed registrations (combo already taken by the OS) roll back to the previously-working binding instead of leaving the user with no shortcut.
|
||||
- **WebSocket-final dictation path.** Capture now treats the streaming `final` message as the source of truth and skips the duplicate HTTP `POST /transcribe` that used to run on every dictation. Audio is transcribed once instead of twice — typical dictation latency roughly halved. New EOF text-frame protocol (server also accepts an empty binary frame as EOF). HTTP POST kept as fallback for WS error / timeout / WS-never-opened.
|
||||
- **Chunk queueing during WS handshake.** The first 250 ms of audio is no longer dropped from the server's `final` transcript. `MediaRecorder` chunks captured while the WebSocket is still in `CONNECTING` state are queued and drained in `ws.onopen`.
|
||||
|
||||
### Changed
|
||||
- **Docker default bind is loopback.** `docker-compose.yml` now publishes `127.0.0.1:3900:3900` instead of `3900:3900` — the API is no longer reachable from the LAN out of the box. To expose it deliberately, change the mapping to `0.0.0.0:3900:3900`. README documents the trade-off and recommends a reverse proxy with auth (Caddy `basic_auth`, nginx + htpasswd, Tailscale) for any non-loopback exposure.
|
||||
- **Donate page trimmed.** Removed Patreon and the Bitcoin / Ethereum / Solana cryptocurrency cards. Removed the bundled `qrcode.react` dependency. The "Commercial License" CTA moves from the bottom of the page to the top-right of the page header.
|
||||
- **WS dictation hostname** now derived from the configured `API_BASE` instead of a hardcoded `localhost:3900`, so deployments behind reverse proxies route correctly.
|
||||
- **HTTP POST fallback timeout** scales with recording length (`max(15s, recordedMs + 10s)`) so long-form dictations don't trip the fallback and run the model twice.
|
||||
|
||||
### Fixed
|
||||
- **Backend was killed on every window close** even if the user only intended to dismiss the window. Backend shutdown now fires only on real-quit (`RunEvent::ExitRequested`), not on the close-to-hide path.
|
||||
- **Hotkey rollback.** `set_dictation_shortcut` previously left the user with no global shortcut if `register(new)` failed after `unregister(old)` succeeded. The previous binding is now restored on failure.
|
||||
- **WebSocket dictation pipeline lost the first audio chunk.** `MediaRecorder` was started before the WebSocket finished its handshake, so the first 250 ms chunk — which carries the WebM EBML header — was dropped from the WS stream. Every subsequent server-side ffmpeg conversion then failed with `exit status 183` ("Invalid data found when processing input"), partials never appeared, and the HTTP fallback only fired after the full timeout. The WebSocket is now constructed before the recorder, every chunk is queued through `wsPendingRef` until `ws.onopen` drains it, and a server `error` message (or unexpected `onclose` after the recorder has stopped) fires the HTTP fallback immediately instead of waiting out the timeout.
|
||||
- **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
|
||||
- New Tauri commands: `quit_app`, `set_tray_recording`, `get_dictation_shortcut`, `set_dictation_shortcut`.
|
||||
- New Tauri state: `AppFlags { quitting }`, `TrayHandle { tray }`, `DictationShortcutState { current }`.
|
||||
- New deps: `tauri-plugin-single-instance` 2.x, `tauri/image-png` feature flag (enables `Image::from_bytes` for in-memory tray-icon swap).
|
||||
|
||||
---
|
||||
|
||||
## [0.2.5] — 2026-04-29
|
||||
|
||||
Region selector, realtime download speed, retry buttons, recheck top-right, HF mirror support, splash bootstrap-log backfill. See git log `v0.2.4..v0.2.5` for the full set.
|
||||
|
||||
## Earlier releases
|
||||
|
||||
See [GitHub Releases](https://github.com/debpalash/OmniVoice-Studio/releases) for prior versions.
|
||||
+201
@@ -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! 🎙️
|
||||
@@ -1,82 +1,136 @@
|
||||
OmniVoice Studio — Dual License
|
||||
# Functional Source License, Version 1.1, ALv2 Future License
|
||||
|
||||
Copyright (c) 2024-present Palash Debnath and contributors.
|
||||
## Abbreviation
|
||||
|
||||
This software is licensed under a dual-license model:
|
||||
FSL-1.1-ALv2
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
## Notice
|
||||
|
||||
1. PERSONAL & NON-COMMERCIAL USE — FREE
|
||||
Copyright 2024-present Palash Debnath and OmniVoice Studio contributors.
|
||||
|
||||
You may use, copy, modify, and distribute this software free of
|
||||
charge for any personal, educational, research, or non-commercial
|
||||
purpose, subject to the following conditions:
|
||||
OmniVoice Studio is **free for personal, educational, research, and
|
||||
non-commercial use** under the terms below. Two years after each release is
|
||||
published, that release converts automatically to the Apache License,
|
||||
Version 2.0 (see "Grant of Future License").
|
||||
|
||||
• You include this license notice in all copies or substantial
|
||||
portions of the software.
|
||||
• You do not use the software, or any derivative of it, to provide
|
||||
a commercial product or service (see Section 2).
|
||||
• You provide attribution to "OmniVoice Studio" in any public-facing
|
||||
derivative work.
|
||||
**Business / enterprise users** that fall outside the Permitted Purposes
|
||||
below — primarily those building a competing product or service on top of
|
||||
OmniVoice Studio — need a commercial license. Pricing tiers are coming
|
||||
soon. For inquiries in the meantime, contact `OmniVoice@palash.dev`.
|
||||
|
||||
"Non-commercial" means use that is not intended for or directed toward
|
||||
commercial advantage or monetary compensation. This includes personal
|
||||
projects, academic research, open-source contributions, and internal
|
||||
evaluation within an organization (up to 30 days).
|
||||
### Scope
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
These terms cover the OmniVoice Studio application — the Tauri desktop
|
||||
shell (`frontend/src-tauri/`), the React frontend (`frontend/src/`), the
|
||||
FastAPI backend (`backend/`), and supporting build / packaging scripts
|
||||
(`scripts/`, `Dockerfile`, `docker-compose.yml`, `.github/`).
|
||||
|
||||
2. COMMERCIAL USE — PAID LICENSE REQUIRED
|
||||
The bundled `omnivoice/` Python package — the underlying TTS model by
|
||||
Han Zhu — is **separately licensed under Apache License 2.0** by its
|
||||
upstream authors and is not relicensed here. See `pyproject.toml`.
|
||||
|
||||
A separate commercial license is required for any use that does not
|
||||
qualify as personal or non-commercial under Section 1. This includes,
|
||||
but is not limited to:
|
||||
Third-party dependencies retain their own licenses. See `Cargo.lock`,
|
||||
`bun.lock`, and `uv.lock` for the resolved set.
|
||||
|
||||
• Using the software to provide a paid product or service.
|
||||
• Embedding the software in a product sold or licensed to third
|
||||
parties.
|
||||
• Using the software in a revenue-generating business beyond the
|
||||
30-day evaluation period.
|
||||
• Offering the software as part of a managed, hosted, or SaaS
|
||||
platform.
|
||||
### Reference
|
||||
|
||||
To obtain a commercial license, contact:
|
||||
The full canonical text of the FSL-1.1-ALv2 follows verbatim. The
|
||||
authoritative copy lives at <https://fsl.software/>.
|
||||
|
||||
Email: OmniVoice@palash.dev
|
||||
Web: https://github.com/debpalash/OmniVoice-Studio
|
||||
---
|
||||
|
||||
Commercial licenses are available for teams and enterprises of all
|
||||
sizes. Pricing scales with usage — solo creators and small studios
|
||||
are priced affordably.
|
||||
## Terms and Conditions
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
### Licensor ("We")
|
||||
|
||||
3. CONTRIBUTIONS
|
||||
The party offering the Software under these Terms and Conditions.
|
||||
|
||||
By submitting a pull request or other contribution to this project,
|
||||
you agree to license your contribution under the same dual-license
|
||||
terms described herein, and you grant the copyright holder a
|
||||
perpetual, worldwide, royalty-free license to use, reproduce, modify,
|
||||
and distribute your contribution under both the non-commercial and
|
||||
commercial licenses.
|
||||
### The Software
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
The "Software" is each version of the software that we make available under
|
||||
these Terms and Conditions, as indicated by our inclusion of these Terms and
|
||||
Conditions with the Software.
|
||||
|
||||
4. NO WARRANTY
|
||||
### License Grant
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
|
||||
BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN
|
||||
ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN
|
||||
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
Subject to your compliance with this License Grant and the Patents,
|
||||
Redistribution and Trademark clauses below, we hereby grant you the right to
|
||||
use, copy, modify, create derivative works, publicly perform, publicly display
|
||||
and redistribute the Software for any Permitted Purpose identified below.
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
### Permitted Purpose
|
||||
|
||||
5. TERMINATION
|
||||
A Permitted Purpose is any purpose other than a Competing Use. A Competing Use
|
||||
means making the Software available to others in a commercial product or
|
||||
service that:
|
||||
|
||||
Your rights under this license terminate automatically if you fail to
|
||||
comply with its terms. Upon termination, you must cease all use of the
|
||||
software and destroy all copies in your possession.
|
||||
1. substitutes for the Software;
|
||||
|
||||
2. substitutes for any other product or service we offer using the Software
|
||||
that exists as of the date we make the Software available; or
|
||||
|
||||
3. offers the same or substantially similar functionality as the Software.
|
||||
|
||||
Permitted Purposes specifically include using the Software:
|
||||
|
||||
1. for your internal use and access;
|
||||
|
||||
2. for non-commercial education;
|
||||
|
||||
3. for non-commercial research; and
|
||||
|
||||
4. in connection with professional services that you provide to a licensee
|
||||
using the Software in accordance with these Terms and Conditions.
|
||||
|
||||
### Patents
|
||||
|
||||
To the extent your use for a Permitted Purpose would necessarily infringe our
|
||||
patents, the license grant above includes a license under our patents. If you
|
||||
make a claim against any party that the Software infringes or contributes to
|
||||
the infringement of any patent, then your patent license to the Software ends
|
||||
immediately.
|
||||
|
||||
### Redistribution
|
||||
|
||||
The Terms and Conditions apply to all copies, modifications and derivatives of
|
||||
the Software.
|
||||
|
||||
If you redistribute any copies, modifications or derivatives of the Software,
|
||||
you must include a copy of or a link to these Terms and Conditions and not
|
||||
remove any copyright notices provided in or with the Software.
|
||||
|
||||
### Disclaimer
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR
|
||||
PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT.
|
||||
|
||||
IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE
|
||||
SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES,
|
||||
EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE.
|
||||
|
||||
### Trademarks
|
||||
|
||||
Except for displaying the License Details and identifying us as the origin of
|
||||
the Software, you have no right under these Terms and Conditions to use our
|
||||
trademarks, trade names, service marks or product names.
|
||||
|
||||
## Grant of Future License
|
||||
|
||||
We hereby irrevocably grant you an additional license to use the Software under
|
||||
the Apache License, Version 2.0 that is effective on the second anniversary of
|
||||
the date we make the Software available. On or after that date, you may use the
|
||||
Software under the Apache License, Version 2.0, in which case the following
|
||||
will apply:
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License"); you may not use
|
||||
this file except in compliance with the License.
|
||||
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software distributed
|
||||
under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
|
||||
CONDITIONS OF ANY KIND, either express or implied. See the License for the
|
||||
specific language governing permissions and limitations under the License.
|
||||
|
||||
@@ -1,40 +1,186 @@
|
||||
<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>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Dual_(Free_%2B_Commercial)-blue?style=flat-square" alt="License" /></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-FSL--1.1--ALv2-blue?style=flat-square" alt="License" /></a>
|
||||
<a href="https://github.com/debpalash/OmniVoice-Studio/issues"><img src="https://img.shields.io/github/issues/debpalash/OmniVoice-Studio?style=flat-square&color=ef4444" alt="Issues" /></a>
|
||||
<a href="https://discord.gg/aRRdVj3de7"><img src="https://img.shields.io/badge/Discord-Join_Community-5865F2?style=flat-square&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a href="https://github.com/debpalash/OmniVoice-Studio/releases/latest">Download</a> ·
|
||||
<a href="#features">Features</a> ·
|
||||
<a href="#quickstart">Quickstart</a> ·
|
||||
<a href="#why-open-source">Why Open Source?</a> ·
|
||||
<a href="#roadmap">Roadmap</a>
|
||||
<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
|
||||
|
||||
```bash
|
||||
# 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.).
|
||||
|
||||
### Desktop App
|
||||
|
||||
Pre-built installers (~6–8 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 5–10 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 +229,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,150 +246,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** — 0–200% 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`.
|
||||
|
||||
### 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 (~6–8 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 5–10 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.
|
||||
|
||||
---
|
||||
|
||||
@@ -261,6 +264,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
|
||||
@@ -300,18 +318,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
|
||||
|
||||
---
|
||||
|
||||
@@ -338,7 +363,7 @@ Yes. MPS acceleration is auto-detected. MLX-optimized Whisper models are availab
|
||||
<details>
|
||||
<summary><b>Can I use this commercially?</b></summary>
|
||||
<br/>
|
||||
Personal and non-commercial use is free. Commercial use requires a paid license — see <a href="#license">License</a>. 30-day free evaluation for businesses.
|
||||
Personal, educational, internal-team, and non-commercial use is free under <a href="https://fsl.software/">FSL-1.1-ALv2</a>. Building a competing product or service on top of OmniVoice Studio requires a commercial license — see <a href="#license">License</a>. Pricing tiers coming soon. Each release converts to Apache 2.0 two years after publication.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -350,24 +375,20 @@ Personal and non-commercial use is free. Commercial use requires a paid license
|
||||
<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>
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
**Personal, educational, and non-commercial use** — completely free. No restrictions, no limits.
|
||||
OmniVoice Studio is source-available under the [**Functional Source License (FSL-1.1-ALv2)**](https://fsl.software/).
|
||||
|
||||
**Commercial use** (SaaS, paid products, enterprise) — requires a paid license. 30-day free evaluation included.
|
||||
**Free** for personal, educational, research, internal team, and non-commercial use. Each release **converts to Apache 2.0 automatically two years after publication**.
|
||||
|
||||
See [`LICENSE`](LICENSE) for the full terms. For commercial inquiries, reach out at **OmniVoice@palash.dev**.
|
||||
**Business / enterprise** users building a competing product or service on top of OmniVoice Studio need a commercial license. **Pricing tiers coming soon.** For inquiries in the meantime, reach out at **OmniVoice@palash.dev**.
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
Issues and PRs welcome. See the [roadmap](#roadmap) for areas where help is most needed. Join our [Discord](https://discord.gg/aRRdVj3de7) to discuss ideas, get help, or find what to work on.
|
||||
See [`LICENSE`](LICENSE) for the full terms.
|
||||
|
||||
---
|
||||
|
||||
@@ -389,7 +410,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>
|
||||
|
||||
@@ -50,22 +50,64 @@ async def ws_transcribe(websocket: WebSocket):
|
||||
last_audio_time = time.monotonic()
|
||||
running = True
|
||||
partial_text = ""
|
||||
# Track whether the client initiated the disconnect. When True the
|
||||
# WebSocket is already in a closed/closing state and any attempt to
|
||||
# call `send_json()` will raise "Unexpected ASGI message".
|
||||
client_disconnected = False
|
||||
|
||||
async def receive_audio():
|
||||
"""Receive audio frames from the client."""
|
||||
nonlocal total_bytes, last_audio_time, running
|
||||
"""Receive audio frames from the client.
|
||||
|
||||
Two end-of-stream signals: (a) text frame ``"EOF"`` (preferred —
|
||||
keeps the socket open so the ``final`` message can still be sent
|
||||
before the client closes), or (b) socket disconnect (legacy path).
|
||||
The EOF protocol exists so the client can use the WS ``final``
|
||||
message as the authoritative result and skip the duplicate HTTP
|
||||
POST that used to run on every dictation.
|
||||
"""
|
||||
nonlocal total_bytes, last_audio_time, running, client_disconnected
|
||||
try:
|
||||
while running:
|
||||
data = await websocket.receive_bytes()
|
||||
audio_chunks.append(data)
|
||||
total_bytes += len(data)
|
||||
last_audio_time = time.monotonic()
|
||||
msg = await websocket.receive()
|
||||
msg_type = msg.get("type")
|
||||
if msg_type == "websocket.disconnect":
|
||||
client_disconnected = True
|
||||
running = False
|
||||
break
|
||||
if msg_type != "websocket.receive":
|
||||
continue
|
||||
data = msg.get("bytes")
|
||||
if data is not None:
|
||||
if len(data) == 0:
|
||||
# Empty binary frame also acts as EOF — connection stays open.
|
||||
running = False
|
||||
break
|
||||
audio_chunks.append(data)
|
||||
total_bytes += len(data)
|
||||
last_audio_time = time.monotonic()
|
||||
continue
|
||||
if msg.get("text") == "EOF":
|
||||
# Client signals end-of-audio but stays connected for `final`.
|
||||
running = False
|
||||
break
|
||||
except WebSocketDisconnect:
|
||||
client_disconnected = True
|
||||
running = False
|
||||
except Exception as e:
|
||||
logger.debug("WS receive ended: %s", e)
|
||||
client_disconnected = True
|
||||
running = False
|
||||
|
||||
async def _safe_send(payload: dict) -> bool:
|
||||
"""Send JSON to the client, returning False if the connection is gone."""
|
||||
if client_disconnected:
|
||||
return False
|
||||
try:
|
||||
await websocket.send_json(payload)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def process_partials():
|
||||
"""Periodically transcribe the accumulated buffer for partial results."""
|
||||
nonlocal partial_text, running
|
||||
@@ -89,7 +131,7 @@ async def ws_transcribe(websocket: WebSocket):
|
||||
text = await _transcribe_buffer(audio_chunks[:])
|
||||
if text and text != partial_text:
|
||||
partial_text = text
|
||||
await websocket.send_json({
|
||||
await _safe_send({
|
||||
"type": "partial",
|
||||
"text": text,
|
||||
})
|
||||
@@ -113,42 +155,32 @@ async def ws_transcribe(websocket: WebSocket):
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
|
||||
# Final transcription on complete buffer
|
||||
# Final transcription on complete buffer — skip if client already gone.
|
||||
if total_bytes > MIN_BUFFER_BYTES:
|
||||
try:
|
||||
result = await _transcribe_buffer_full(audio_chunks)
|
||||
await websocket.send_json({
|
||||
"type": "final",
|
||||
**result,
|
||||
})
|
||||
if not await _safe_send({"type": "final", **result}):
|
||||
logger.debug("Skipped final send — client already disconnected")
|
||||
except Exception as e:
|
||||
logger.error("Final transcription failed: %s", e)
|
||||
try:
|
||||
await websocket.send_json({
|
||||
"type": "error",
|
||||
"detail": str(e),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
await _safe_send({"type": "error", "detail": str(e)})
|
||||
else:
|
||||
await _safe_send({
|
||||
"type": "final",
|
||||
"text": "",
|
||||
"segments": [],
|
||||
"language": "unknown",
|
||||
"duration_s": 0,
|
||||
"transcription_time_s": 0,
|
||||
"engine": "none",
|
||||
})
|
||||
|
||||
if not client_disconnected:
|
||||
try:
|
||||
await websocket.send_json({
|
||||
"type": "final",
|
||||
"text": "",
|
||||
"segments": [],
|
||||
"language": "unknown",
|
||||
"duration_s": 0,
|
||||
"transcription_time_s": 0,
|
||||
"engine": "none",
|
||||
})
|
||||
await websocket.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
await websocket.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def _transcribe_buffer(chunks: list[bytes]) -> str:
|
||||
"""Quick partial transcription of the current audio buffer."""
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -113,6 +113,30 @@ async def install_model(req: InstallModelRequest):
|
||||
if sys.platform == "win32":
|
||||
dl_kwargs["local_dir_use_symlinks"] = False
|
||||
|
||||
# Emit a 'resolving' heartbeat every 2s while snapshot_download
|
||||
# resolves repo metadata (before any tqdm bars appear).
|
||||
import threading
|
||||
import time as _t
|
||||
_resolving = threading.Event()
|
||||
|
||||
def _heartbeat():
|
||||
_step = 0
|
||||
while not _resolving.is_set():
|
||||
_resolving.wait(2.0)
|
||||
if _resolving.is_set():
|
||||
break
|
||||
_step += 1
|
||||
hf_progress.emit({
|
||||
"repo_id": req.repo_id,
|
||||
"filename": req.repo_id,
|
||||
"downloaded": 0, "total": 0, "pct": 0.0,
|
||||
"phase": "resolving",
|
||||
"step": _step,
|
||||
})
|
||||
|
||||
hb = threading.Thread(target=_heartbeat, daemon=True)
|
||||
hb.start()
|
||||
|
||||
_max_attempts = 5
|
||||
_attempt = 0
|
||||
while True:
|
||||
@@ -136,8 +160,9 @@ async def install_model(req: InstallModelRequest):
|
||||
"attempt": _attempt,
|
||||
"error": str(net_err),
|
||||
})
|
||||
import time as _t
|
||||
_t.sleep(_backoff)
|
||||
# Stop heartbeat once download completes
|
||||
_resolving.set()
|
||||
logger.info("model install done: %s", req.repo_id)
|
||||
hf_progress.emit({
|
||||
"repo_id": req.repo_id,
|
||||
@@ -147,6 +172,7 @@ async def install_model(req: InstallModelRequest):
|
||||
})
|
||||
invalidate_cache()
|
||||
except Exception as e:
|
||||
_resolving.set()
|
||||
logger.warning("model install failed for %s: %s", req.repo_id, e)
|
||||
hf_progress.emit({
|
||||
"repo_id": req.repo_id,
|
||||
|
||||
@@ -27,8 +27,22 @@ MIN_FREE_GB = 10
|
||||
|
||||
|
||||
def _disk_free_gb(path: str) -> float:
|
||||
"""Return free GB on the volume containing *path*.
|
||||
|
||||
If *path* doesn't exist yet (e.g. after a fresh wipe), walk up to the
|
||||
nearest existing ancestor so ``shutil.disk_usage`` can still probe the
|
||||
correct mount point.
|
||||
"""
|
||||
try:
|
||||
return _shutil.disk_usage(path).free / (1024 ** 3)
|
||||
from pathlib import Path
|
||||
p = Path(path).resolve()
|
||||
# Walk up until we find a directory that exists
|
||||
while not p.exists():
|
||||
parent = p.parent
|
||||
if parent == p: # root
|
||||
break
|
||||
p = parent
|
||||
return _shutil.disk_usage(str(p)).free / (1024 ** 3)
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
@@ -270,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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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…")
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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."""
|
||||
@@ -122,12 +139,30 @@ def install() -> None:
|
||||
class TrackedTqdm(original): # type: ignore[misc,valid-type]
|
||||
"""tqdm subclass that emits a progress event on every update."""
|
||||
|
||||
_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)
|
||||
# Emit once on construction so the UI can show the file
|
||||
# before a single byte is read. Some tqdm variants don't
|
||||
# populate `desc` / `n` as attributes — use getattr so a
|
||||
# patched tqdm never crashes the whole model load.
|
||||
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:
|
||||
desc = getattr(self, "desc", None)
|
||||
total = int(getattr(self, "total", 0) or 0)
|
||||
@@ -139,26 +174,65 @@ def install() -> None:
|
||||
"phase": "start",
|
||||
})
|
||||
except Exception:
|
||||
# Never let progress telemetry break a real download.
|
||||
pass
|
||||
|
||||
def update(self, n=1):
|
||||
super().update(n)
|
||||
def _emit_progress(self):
|
||||
"""Emit current state as a progress event."""
|
||||
try:
|
||||
desc = getattr(self, "desc", None)
|
||||
total = int(getattr(self, "total", 0) or 0)
|
||||
done = int(getattr(self, "n", 0) or 0)
|
||||
pct = (done / total) if total > 0 else 0.0
|
||||
_emit({
|
||||
# Pull rate from tqdm's own calculations if available
|
||||
rate = None
|
||||
try:
|
||||
rate = self.format_dict.get("rate")
|
||||
except Exception:
|
||||
pass
|
||||
event = {
|
||||
"filename": str(desc or "download"),
|
||||
"downloaded": done,
|
||||
"total": total,
|
||||
"pct": pct,
|
||||
"phase": "done" if (total > 0 and done >= total) else "progress",
|
||||
})
|
||||
}
|
||||
if rate and rate > 0:
|
||||
event["rate"] = rate # bytes/sec from tqdm
|
||||
_emit(event)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def update(self, n=1):
|
||||
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
|
||||
if (now - self._last_emit_time) >= 0.3:
|
||||
self._last_emit_time = now
|
||||
self._emit_progress()
|
||||
|
||||
def display(self, msg=None, pos=None):
|
||||
"""tqdm calls display() on its refresh cycle; piggyback for
|
||||
periodic emits even when update() intervals are large."""
|
||||
import time as _t
|
||||
now = _t.monotonic()
|
||||
if (now - self._last_emit_time) >= 0.5:
|
||||
self._last_emit_time = now
|
||||
self._emit_progress()
|
||||
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]
|
||||
hf_tqdm_module.tqdm = TrackedTqdm # type: ignore[assignment]
|
||||
|
||||
@@ -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.4",
|
||||
"version": "0.2.7",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@fontsource-variable/source-serif-4": "^5.2.9",
|
||||
@@ -29,41 +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",
|
||||
"qrcode.react": "^4.2.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",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -102,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=="],
|
||||
|
||||
@@ -170,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=="],
|
||||
|
||||
@@ -248,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=="],
|
||||
|
||||
@@ -316,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=="],
|
||||
|
||||
@@ -328,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=="],
|
||||
|
||||
@@ -364,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=="],
|
||||
|
||||
@@ -466,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=="],
|
||||
|
||||
@@ -530,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=="],
|
||||
|
||||
@@ -628,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=="],
|
||||
|
||||
@@ -686,8 +685,6 @@
|
||||
|
||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
|
||||
"qrcode.react": ["qrcode.react@4.2.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA=="],
|
||||
|
||||
"react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="],
|
||||
|
||||
"react-dom": ["react-dom@19.2.5", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="],
|
||||
@@ -706,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=="],
|
||||
|
||||
@@ -742,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=="],
|
||||
|
||||
@@ -760,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=="],
|
||||
|
||||
@@ -810,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=="],
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ RUN bun run --cwd frontend build
|
||||
# ==========================================
|
||||
# Runtime Stage: Python & PyTorch Backend
|
||||
# ==========================================
|
||||
FROM pytorch/pytorch:2.4.0-cuda12.1-cudnn9-runtime AS runtime
|
||||
FROM pytorch/pytorch:2.8.0-cuda12.8-cudnn9-runtime AS runtime
|
||||
WORKDIR /app
|
||||
|
||||
# Enable unbuffered logs and optimizations
|
||||
@@ -39,9 +39,9 @@ RUN pip install --no-cache-dir uv
|
||||
# Copy python packaging specs
|
||||
COPY pyproject.toml uv.lock ./
|
||||
|
||||
# Native wheels from PyPI embed CUDA matching `torch >= 2.4` standard index
|
||||
# By installing via `uv`, the process completes exponentially faster
|
||||
RUN uv pip install --system --no-cache -e .
|
||||
# Install the project (non-editable — no need for -e in containers).
|
||||
# Uses `uv` for exponentially faster resolution than plain pip.
|
||||
RUN uv pip install --system --no-cache .
|
||||
|
||||
# Copy application source
|
||||
COPY backend/ ./backend/
|
||||
@@ -2,20 +2,29 @@
|
||||
# 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.
|
||||
#
|
||||
# SECURITY: The port is bound to 127.0.0.1 by default — only this
|
||||
# machine can reach the API. To expose OmniVoice on your LAN (or
|
||||
# through a reverse proxy / tunnel), change the port mapping to
|
||||
# "0.0.0.0:3900:3900" or "3900:3900". OmniVoice itself ships no
|
||||
# authentication — if you expose it, put it behind a reverse proxy
|
||||
# with auth (Caddy basic_auth, nginx + htpasswd, Tailscale, etc.).
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
services:
|
||||
# ── CPU mode (default) ──────────────────────────────────────
|
||||
omnivoice:
|
||||
build: .
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/Dockerfile
|
||||
container_name: omnivoice-studio
|
||||
ports:
|
||||
- "3900:3900"
|
||||
- "127.0.0.1:3900:3900"
|
||||
volumes:
|
||||
- omnivoice-data:/app/omnivoice_data
|
||||
environment:
|
||||
@@ -33,11 +42,13 @@ services:
|
||||
|
||||
# ── GPU mode — activate with: docker compose --profile gpu up
|
||||
omnivoice-gpu:
|
||||
build: .
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/Dockerfile
|
||||
container_name: omnivoice-studio-gpu
|
||||
profiles: ["gpu"]
|
||||
ports:
|
||||
- "3900:3900"
|
||||
- "127.0.0.1:3900:3900"
|
||||
volumes:
|
||||
- omnivoice-data:/app/omnivoice_data
|
||||
environment:
|
||||
@@ -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
|
||||
├── .dockerignore ⟵ Docker 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
|
||||
│ └── 00–08-*.md per-feature specs
|
||||
│
|
||||
├── research/ ⟵ reference material, competitor analysis, archived code
|
||||
│ ├── LEARNINGS.md competitive analysis, what to absorb
|
||||
|
Before Width: | Height: | Size: 358 KiB After Width: | Height: | Size: 358 KiB |
+13
-14
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "omnivoice-studio",
|
||||
"private": true,
|
||||
"version": "0.2.5",
|
||||
"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,40 +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",
|
||||
"qrcode.react": "^4.2.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"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+450
-581
File diff suppressed because it is too large
Load Diff
@@ -1,27 +1,25 @@
|
||||
[package]
|
||||
name = "app"
|
||||
version = "0.2.5"
|
||||
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"] }
|
||||
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"
|
||||
@@ -29,28 +27,23 @@ tauri-plugin-updater = "2"
|
||||
tauri-plugin-process = "2"
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-global-shortcut = "2"
|
||||
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"] }
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!--
|
||||
macOS shows this string in the system dialog when the app first
|
||||
requests microphone access. Without it, getUserMedia() in the
|
||||
WebView fails silently on macOS 10.14+ (TCC blocks the access
|
||||
and returns NotAllowedError to JS). This file is auto-merged
|
||||
into the app's Info.plist by tauri-bundler at bundle time.
|
||||
-->
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>OmniVoice needs microphone access for live dictation and voice recording. Audio is processed entirely on your machine — nothing is sent to any external server.</string>
|
||||
|
||||
<!--
|
||||
Same story for camera. We don't currently use it, but if a future
|
||||
feature ever calls getUserMedia({ video: true }) the system will
|
||||
need this string. Cheap to ship now; avoids a future TCC denial.
|
||||
-->
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>OmniVoice may use the camera for upcoming video features. Video stays on your machine.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -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-*
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(®ion.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()
|
||||
}
|
||||
+179
-1360
File diff suppressed because it is too large
Load Diff
@@ -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()),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "OmniVoice Studio",
|
||||
"version": "0.2.5",
|
||||
"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": ["**"]
|
||||
@@ -47,8 +61,15 @@
|
||||
"resources": [
|
||||
"../../pyproject.toml",
|
||||
"../../uv.lock",
|
||||
"../../README.md",
|
||||
"../../omnivoice",
|
||||
"../../backend"
|
||||
],
|
||||
"externalBin": [
|
||||
"binaries/uv",
|
||||
"binaries/ffmpeg",
|
||||
"binaries/ffprobe"
|
||||
],
|
||||
"macOS": {
|
||||
"minimumSystemVersion": "12.0"
|
||||
}
|
||||
|
||||
+78
-358
@@ -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' });
|
||||
@@ -1975,9 +1691,11 @@ function App() {
|
||||
// flash the empty studio before the wizard has a chance to mount.
|
||||
if (!setupChecked) {
|
||||
return (
|
||||
<div className="app-container sidebar-hidden app-startup" style={{ zoom: uiScale }}>
|
||||
<div className="app-startup__title">OmniVoice Studio</div>
|
||||
<div>Starting backend…</div>
|
||||
<div style={{ zoom: uiScale }}>
|
||||
<BootstrapSplash stage={bootstrapStage} message={bootstrapMessage} />
|
||||
<Suspense fallback={null}>
|
||||
<LogsFooter />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2031,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' },
|
||||
@@ -2047,7 +1767,7 @@ function App() {
|
||||
}}/>
|
||||
|
||||
<FloatingPill />
|
||||
<CaptureButton />
|
||||
|
||||
|
||||
<Header
|
||||
mode={mode} setMode={setMode}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
@@ -237,3 +245,43 @@
|
||||
.bootstrap-splash__copy-btn:hover {
|
||||
background: color-mix(in srgb, var(--chrome-fg, #eee) 14%, transparent);
|
||||
}
|
||||
|
||||
/* ── Error hints + retry actions ── */
|
||||
.bootstrap-splash__hints {
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.9;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.bootstrap-splash__hints strong { display: block; margin-bottom: 0.35rem; }
|
||||
.bootstrap-splash__hints ul {
|
||||
margin: 0; padding-left: 1.25rem;
|
||||
display: flex; flex-direction: column; gap: 0.25rem;
|
||||
}
|
||||
.bootstrap-splash__hints li { opacity: 0.85; }
|
||||
|
||||
.bootstrap-splash__actions {
|
||||
display: flex; gap: 0.5rem; margin-top: 1rem;
|
||||
}
|
||||
.bootstrap-splash__retry-btn {
|
||||
flex: 1;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid color-mix(in srgb, var(--chrome-fg, #eee) 15%, transparent);
|
||||
background: color-mix(in srgb, var(--chrome-accent, #8ec07c) 15%, transparent);
|
||||
color: var(--chrome-fg, #eee);
|
||||
font: inherit; font-size: 0.82rem; font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.bootstrap-splash__retry-btn:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--chrome-accent, #8ec07c) 25%, transparent);
|
||||
}
|
||||
.bootstrap-splash__retry-btn:disabled { opacity: 0.5; cursor: wait; }
|
||||
.bootstrap-splash__retry-btn--danger {
|
||||
background: color-mix(in srgb, #ef4444 12%, transparent);
|
||||
border-color: color-mix(in srgb, #ef4444 30%, transparent);
|
||||
}
|
||||
.bootstrap-splash__retry-btn--danger:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, #ef4444 22%, transparent);
|
||||
}
|
||||
|
||||
@@ -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, 5–10 min.',
|
||||
downloading_ffmpeg: 'Downloading ffmpeg…',
|
||||
starting_backend: 'Starting backend…',
|
||||
ready: 'Ready',
|
||||
failed: 'Setup failed',
|
||||
@@ -30,12 +29,26 @@ const STEPS = [
|
||||
'downloading_uv',
|
||||
'creating_venv',
|
||||
'installing_deps',
|
||||
'downloading_ffmpeg',
|
||||
'starting_backend',
|
||||
];
|
||||
|
||||
const MAX_LOG_LINES = 200;
|
||||
|
||||
/** Scan logs + error message for known failure patterns and return actionable hints. */
|
||||
function detectHints(message, logs) {
|
||||
const hints = [];
|
||||
const all = (message || '') + '\n' + logs.map(l => l.line).join('\n');
|
||||
if (/README\.md/i.test(all)) hints.push('README.md was missing from the bundle. This is now auto-fixed — retry should work.');
|
||||
if (/uv.*download|uv.*install/i.test(all) && /timeout|connection/i.test(all)) hints.push('Network timeout downloading uv. Check your internet connection or try the China mirror.');
|
||||
if (/uv sync failed/i.test(all)) hints.push('Dependency install failed. "Clean & Retry" will delete the cached venv and start fresh.');
|
||||
if (/hatchling|build_editable/i.test(all)) hints.push('Python build backend error. "Clean & Retry" removes the broken venv so it rebuilds from scratch.');
|
||||
if (/ffmpeg/i.test(all) && /download|timeout/i.test(all)) hints.push('ffmpeg download failed. This is non-fatal — retry or install ffmpeg manually.');
|
||||
if (/port.*in use|address.*in use/i.test(all)) hints.push('Port 3900 is already in use. Close other instances of OmniVoice or apps using that port.');
|
||||
if (/no error output/i.test(all)) hints.push('Backend crashed silently. "Clean & Retry" often fixes corrupt venv issues.');
|
||||
if (hints.length === 0) hints.push('Try "Retry" first. If it fails again, "Clean & Retry" will rebuild the environment from scratch.');
|
||||
return hints;
|
||||
}
|
||||
|
||||
function formatBytes(n) {
|
||||
if (!n || n < 0) return '';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
@@ -50,12 +63,36 @@ export function BootstrapSplash({ stage, message }) {
|
||||
const stepIndex = Math.max(0, STEPS.indexOf(stage));
|
||||
const isFailed = stage === 'failed';
|
||||
const [logs, setLogs] = useState([]);
|
||||
const [logsOpen, setLogsOpen] = useState(true); // always open by default
|
||||
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);
|
||||
|
||||
const handleRetry = async () => {
|
||||
if (retrying) return;
|
||||
setRetrying(true);
|
||||
try {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
setLogs([]);
|
||||
await invoke('retry_bootstrap');
|
||||
} catch (e) { console.error('retry failed', e); }
|
||||
finally { setRetrying(false); }
|
||||
};
|
||||
|
||||
const handleCleanRetry = async () => {
|
||||
if (retrying) return;
|
||||
if (!confirm('This will delete the cached Python environment and re-download all dependencies (~5-10 min). Continue?')) return;
|
||||
setRetrying(true);
|
||||
try {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
setLogs([]);
|
||||
await invoke('clean_and_retry_bootstrap');
|
||||
} catch (e) { console.error('clean retry failed', e); }
|
||||
finally { setRetrying(false); }
|
||||
};
|
||||
|
||||
// Load persisted region on mount.
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || !('__TAURI_INTERNALS__' in window)) return;
|
||||
@@ -167,25 +204,38 @@ 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>
|
||||
{isFailed ? (
|
||||
<pre className="bootstrap-splash__error">{message || 'Unknown error'}</pre>
|
||||
<>
|
||||
<pre className="bootstrap-splash__error">{message || 'Unknown error'}</pre>
|
||||
<div className="bootstrap-splash__hints">
|
||||
<strong>💡 What to try:</strong>
|
||||
<ul>
|
||||
{detectHints(message, logs).map((h, i) => <li key={i}>{h}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="bootstrap-splash__actions">
|
||||
<button className="bootstrap-splash__retry-btn" onClick={handleRetry} disabled={retrying}>
|
||||
{retrying ? '⏳ Retrying…' : '🔄 Retry'}
|
||||
</button>
|
||||
<button className="bootstrap-splash__retry-btn bootstrap-splash__retry-btn--danger" onClick={handleCleanRetry} disabled={retrying}>
|
||||
🧹 Clean & Retry
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="bootstrap-splash__bar">
|
||||
|
||||
+11
-10
@@ -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);
|
||||
}
|
||||
+230
-86
@@ -2,11 +2,20 @@ 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';
|
||||
|
||||
// Flip the system tray icon between default and red-dot. No-op when not
|
||||
// running inside the Tauri shell (e.g. browser webui, Docker).
|
||||
async function setTrayRecording(recording) {
|
||||
try {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
await invoke('set_tray_recording', { recording });
|
||||
} catch { /* not in Tauri */ }
|
||||
}
|
||||
|
||||
const CAPTURE_MODES = [
|
||||
{ id: 'fast', label: 'Turbo', desc: 'MLX Whisper Turbo — fastest', icon: <Zap size={12} /> },
|
||||
{ id: 'accurate', label: 'Accurate', desc: 'WhisperX — best word timing', icon: <Target size={12} /> },
|
||||
@@ -24,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'
|
||||
);
|
||||
@@ -45,6 +53,20 @@ export default function CaptureButton() {
|
||||
const streamRef = useRef(null);
|
||||
const timerRef = useRef(null);
|
||||
const wsRef = useRef(null);
|
||||
// Chunks captured before the WebSocket finishes its handshake — drained
|
||||
// in `ws.onopen` so the server's `final` transcript covers the full
|
||||
// recording (no missing first 250 ms).
|
||||
const wsPendingRef = useRef([]);
|
||||
// Set when the WebSocket delivers a `final` message. Used to dedupe
|
||||
// against the HTTP POST fallback so we don't transcribe twice.
|
||||
const wsHadFinalRef = useRef(false);
|
||||
// Cancellable timer that fires the HTTP POST fallback if WS `final`
|
||||
// never arrives in time.
|
||||
const fallbackTimerRef = useRef(null);
|
||||
// Wall-clock start of the current recording. Read by stopRecording to
|
||||
// size the WS-fallback timeout against actual recording length without
|
||||
// closing over the (stale) `duration` state.
|
||||
const startTimeRef = useRef(0);
|
||||
|
||||
// Keyboard shortcut: Ctrl+Shift+Space (or ⌘+Shift+Space on Mac)
|
||||
useEffect(() => {
|
||||
@@ -90,6 +112,47 @@ export default function CaptureButton() {
|
||||
clearInterval(timerRef.current);
|
||||
}, [state]);
|
||||
|
||||
// Render a transcription result (from either the WS `final` message or
|
||||
// the HTTP POST fallback). Idempotent — guarded by wsHadFinalRef so a
|
||||
// late HTTP response can't overwrite a WS final that already landed.
|
||||
const applyResult = useCallback(async (data) => {
|
||||
setTranscript(data.text || '');
|
||||
setLastEngine(data.engine || '');
|
||||
setLastTime(data.transcription_time_s || 0);
|
||||
setState('done');
|
||||
|
||||
if (data.text) {
|
||||
addTranscription(data);
|
||||
}
|
||||
|
||||
if (data.text && autoCopy) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(data.text);
|
||||
setCopied(true);
|
||||
try {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
await invoke('simulate_paste');
|
||||
toast.success('Pasted into active app', { duration: 2000 });
|
||||
} 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]);
|
||||
|
||||
const startRecording = useCallback(async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
@@ -97,29 +160,121 @@ export default function CaptureButton() {
|
||||
});
|
||||
streamRef.current = stream;
|
||||
chunksRef.current = [];
|
||||
wsPendingRef.current = [];
|
||||
wsHadFinalRef.current = false;
|
||||
if (fallbackTimerRef.current) {
|
||||
clearTimeout(fallbackTimerRef.current);
|
||||
fallbackTimerRef.current = null;
|
||||
}
|
||||
|
||||
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
|
||||
? 'audio/webm;codecs=opus'
|
||||
: 'audio/webm';
|
||||
|
||||
// Open the WebSocket BEFORE starting the recorder so wsRef is set by
|
||||
// the time the first `ondataavailable` fires. Otherwise the very
|
||||
// first 250 ms chunk — which carries the WebM EBML header — is
|
||||
// dropped from the WS stream, every subsequent chunk decodes as
|
||||
// malformed WebM, and ffmpeg fails with exit 183 on every partial.
|
||||
try {
|
||||
const wsProto = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const wsHost = API_BASE.replace(/^https?:\/\//, '').replace(/\/$/, '')
|
||||
|| `${window.location.hostname}:3900`;
|
||||
const wsUrl = `${wsProto}://${wsHost}/ws/transcribe`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
ws.onopen = () => {
|
||||
// Drain chunks captured during the handshake.
|
||||
for (const buf of wsPendingRef.current) {
|
||||
try { ws.send(buf); } catch {}
|
||||
}
|
||||
wsPendingRef.current = [];
|
||||
};
|
||||
ws.onmessage = (evt) => {
|
||||
try {
|
||||
const msg = JSON.parse(evt.data);
|
||||
if (msg.type === 'partial') {
|
||||
setPartialText(msg.text || '');
|
||||
} else if (msg.type === 'final') {
|
||||
wsHadFinalRef.current = true;
|
||||
if (fallbackTimerRef.current) {
|
||||
clearTimeout(fallbackTimerRef.current);
|
||||
fallbackTimerRef.current = null;
|
||||
}
|
||||
applyResult(msg);
|
||||
try { ws.close(); } catch {}
|
||||
} else if (msg.type === 'error') {
|
||||
// Server failed (e.g. ffmpeg couldn't decode the partial
|
||||
// buffer). Don't wait the full timeout — fire the HTTP
|
||||
// fallback right away so the user still gets a transcript.
|
||||
if (fallbackTimerRef.current) {
|
||||
clearTimeout(fallbackTimerRef.current);
|
||||
fallbackTimerRef.current = null;
|
||||
}
|
||||
try { ws.close(); } catch {}
|
||||
wsRef.current = null;
|
||||
if (!wsHadFinalRef.current) sendForTranscription();
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
ws.onerror = () => { wsRef.current = null; };
|
||||
ws.onclose = () => {
|
||||
wsRef.current = null;
|
||||
// If the socket closed before delivering `final` and the
|
||||
// recorder has already stopped, the fallback timer is the only
|
||||
// thing left — kick the HTTP path now instead of waiting it
|
||||
// out.
|
||||
if (
|
||||
!wsHadFinalRef.current
|
||||
&& mediaRecorderRef.current
|
||||
&& mediaRecorderRef.current.state === 'inactive'
|
||||
) {
|
||||
if (fallbackTimerRef.current) {
|
||||
clearTimeout(fallbackTimerRef.current);
|
||||
fallbackTimerRef.current = null;
|
||||
}
|
||||
sendForTranscription();
|
||||
}
|
||||
};
|
||||
wsRef.current = ws;
|
||||
} catch {
|
||||
// WebSocket not available — will fallback to HTTP POST
|
||||
wsRef.current = null;
|
||||
}
|
||||
|
||||
const recorder = new MediaRecorder(stream, { mimeType });
|
||||
recorder.ondataavailable = (e) => {
|
||||
if (e.data.size > 0) {
|
||||
chunksRef.current.push(e.data);
|
||||
// Stream chunk to WebSocket for partial results
|
||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||
e.data.arrayBuffer().then(buf => {
|
||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(buf);
|
||||
}
|
||||
});
|
||||
}
|
||||
// Stream every chunk to the WS — queueing through wsPendingRef
|
||||
// until ws.onopen drains it. This guarantees the first chunk
|
||||
// (which carries the WebM EBML header) reaches the server even
|
||||
// if it arrives during the handshake window.
|
||||
e.data.arrayBuffer().then(buf => {
|
||||
const ws = wsRef.current;
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(buf);
|
||||
} else {
|
||||
wsPendingRef.current.push(buf);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
recorder.onstop = () => sendForTranscription();
|
||||
// recorder.onstop frees the mic and (only as fallback) kicks the HTTP
|
||||
// POST. The WebSocket `final` path is preferred — see ws.onmessage.
|
||||
recorder.onstop = () => {
|
||||
if (wsHadFinalRef.current) return;
|
||||
if (!wsRef.current) {
|
||||
// WS never opened — HTTP POST is the only path.
|
||||
sendForTranscription();
|
||||
}
|
||||
// Otherwise: the fallback timer set in stopRecording will fire if
|
||||
// the WS final never arrives.
|
||||
};
|
||||
mediaRecorderRef.current = recorder;
|
||||
recorder.start(250); // collect in 250ms chunks
|
||||
|
||||
startTimeRef.current = Date.now();
|
||||
setState('recording');
|
||||
setDuration(0);
|
||||
setTranscript('');
|
||||
@@ -128,33 +283,24 @@ export default function CaptureButton() {
|
||||
setCopied(false);
|
||||
setLastEngine('');
|
||||
setLastTime(0);
|
||||
|
||||
// Open WebSocket for streaming partial results
|
||||
try {
|
||||
const wsProto = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const wsUrl = `${wsProto}://${window.location.hostname}:3900/ws/transcribe`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
ws.onmessage = (evt) => {
|
||||
try {
|
||||
const msg = JSON.parse(evt.data);
|
||||
if (msg.type === 'partial') {
|
||||
setPartialText(msg.text || '');
|
||||
}
|
||||
// final/error handled after stopRecording
|
||||
} catch {}
|
||||
};
|
||||
ws.onerror = () => { wsRef.current = null; };
|
||||
ws.onclose = () => { wsRef.current = null; };
|
||||
wsRef.current = ws;
|
||||
} catch {
|
||||
// WebSocket not available — will fallback to HTTP POST
|
||||
wsRef.current = null;
|
||||
}
|
||||
setTrayRecording(true);
|
||||
} catch (err) {
|
||||
toast.error('Microphone access denied. Check browser permissions.');
|
||||
// Platform-specific recovery hint — getUserMedia rejects with
|
||||
// NotAllowedError when the OS or user has blocked mic access.
|
||||
const isMac = typeof navigator !== 'undefined'
|
||||
&& /Mac|iPad|iPhone|iPod/.test(navigator.platform || '');
|
||||
const isWindows = typeof navigator !== 'undefined'
|
||||
&& /Win/.test(navigator.platform || '');
|
||||
const hint = isMac
|
||||
? 'macOS: open System Settings → Privacy & Security → Microphone and enable OmniVoice.'
|
||||
: isWindows
|
||||
? 'Windows: open Settings → Privacy & security → Microphone and allow OmniVoice.'
|
||||
: 'Linux: check that your user is in the audio group and the WebView has mic access.';
|
||||
toast.error(`Microphone access denied. ${hint}`, { duration: 6000 });
|
||||
setTrayRecording(false);
|
||||
setState('error');
|
||||
}
|
||||
}, []);
|
||||
}, [applyResult]);
|
||||
|
||||
const stopRecording = useCallback(() => {
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
|
||||
@@ -164,15 +310,46 @@ export default function CaptureButton() {
|
||||
streamRef.current.getTracks().forEach(t => t.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
// Close WebSocket to trigger final transcription
|
||||
if (wsRef.current) {
|
||||
try { wsRef.current.close(); } catch {}
|
||||
wsRef.current = null;
|
||||
// Signal end-of-audio to the WS but DO NOT close — we want the server's
|
||||
// `final` message to arrive over the same socket. The HTTP POST fallback
|
||||
// timer below covers the case where final never lands.
|
||||
const ws = wsRef.current;
|
||||
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
|
||||
const sendEof = () => { try { ws.send('EOF'); } catch {} };
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
sendEof();
|
||||
} else {
|
||||
// Wait for open before sending EOF, otherwise the message is dropped.
|
||||
ws.addEventListener('open', sendEof, { once: true });
|
||||
}
|
||||
// Fallback: if WS final doesn't arrive in time, use HTTP POST.
|
||||
// Cleared in ws.onmessage when `final` lands. Timeout scales with
|
||||
// recording length so long-form dictation (where the server's final
|
||||
// pass naturally takes longer) doesn't trip the fallback and run the
|
||||
// model twice. Floor of 15 s covers slow first-call cold starts.
|
||||
const recorded = startTimeRef.current
|
||||
? Date.now() - startTimeRef.current
|
||||
: 0;
|
||||
const ms = Math.max(15000, recorded + 10000);
|
||||
if (fallbackTimerRef.current) clearTimeout(fallbackTimerRef.current);
|
||||
fallbackTimerRef.current = setTimeout(() => {
|
||||
fallbackTimerRef.current = null;
|
||||
if (!wsHadFinalRef.current) {
|
||||
try { wsRef.current?.close(); } catch {}
|
||||
wsRef.current = null;
|
||||
sendForTranscription();
|
||||
}
|
||||
}, ms);
|
||||
}
|
||||
setTrayRecording(false);
|
||||
setState('transcribing');
|
||||
}, []);
|
||||
|
||||
const sendForTranscription = useCallback(async () => {
|
||||
// Race-guard: WS final may have landed between when this was scheduled
|
||||
// and now. Skip the duplicate HTTP transcription.
|
||||
if (wsHadFinalRef.current) return;
|
||||
|
||||
const blob = new Blob(chunksRef.current, { type: 'audio/webm' });
|
||||
const formData = new FormData();
|
||||
formData.append('audio', blob, 'capture.webm');
|
||||
@@ -188,38 +365,16 @@ export default function CaptureButton() {
|
||||
throw new Error(detail.detail || `HTTP ${res.status}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
setTranscript(data.text || '');
|
||||
setLastEngine(data.engine || '');
|
||||
setLastTime(data.transcription_time_s || 0);
|
||||
setState('done');
|
||||
|
||||
// Persist to Transcriptions page history
|
||||
if (data.text) {
|
||||
addTranscription(data);
|
||||
}
|
||||
|
||||
// Auto-copy to clipboard for instant paste into other apps
|
||||
if (data.text && autoCopy) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(data.text);
|
||||
setCopied(true);
|
||||
// Auto-paste: simulate ⌘V into the previously active app
|
||||
try {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
await invoke('simulate_paste');
|
||||
toast.success('Pasted into active app', { duration: 2000 });
|
||||
} catch {
|
||||
// Not in Tauri or accessibility permission missing
|
||||
toast.success('Copied to clipboard — paste with ⌘V', { duration: 2000 });
|
||||
}
|
||||
} catch { /* clipboard API may fail in some contexts */ }
|
||||
}
|
||||
// Re-check guard — a WS final could land while we awaited the POST.
|
||||
if (wsHadFinalRef.current) return;
|
||||
await applyResult(data);
|
||||
} catch (err) {
|
||||
if (wsHadFinalRef.current) return;
|
||||
toast.error(`Transcription failed: ${err.message}`);
|
||||
setState('error');
|
||||
setTranscript('');
|
||||
}
|
||||
}, [captureMode, autoCopy]);
|
||||
}, [captureMode, applyResult]);
|
||||
|
||||
const copyToClipboard = useCallback(() => {
|
||||
navigator.clipboard.writeText(transcript).then(() => {
|
||||
@@ -228,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 = () => {
|
||||
@@ -252,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…'}
|
||||
@@ -348,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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -40,7 +40,16 @@
|
||||
}
|
||||
.swiz-checklist { display: flex; flex-direction: column; gap: 6px; }
|
||||
.swiz-check-icon { flex-shrink: 0; padding-top: 2px; }
|
||||
.swiz-check-footer { display: flex; justify-content: flex-end; padding-top: 4px; }
|
||||
.swiz-check-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding-bottom: 4px; margin-bottom: 2px;
|
||||
border-bottom: 1px solid var(--chrome-border, rgba(255,255,255,0.06));
|
||||
}
|
||||
.swiz-check-header__label {
|
||||
font-size: 0.78rem; font-weight: 600;
|
||||
color: var(--color-fg-muted, #a89984);
|
||||
text-transform: uppercase; letter-spacing: 0.04em;
|
||||
}
|
||||
.swiz-missing { text-align: center; font-size: 0.78rem; margin: 0; }
|
||||
.swiz-status-loading {
|
||||
display: flex; gap: 8px; align-items: center;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>,
|
||||
);
|
||||
|
||||
@@ -13,11 +13,15 @@
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
/* ── Back button ───────────────────────────────────────────── */
|
||||
.donate-page__back {
|
||||
/* ── Top bar (Back + Commercial License) ──────────────────── */
|
||||
.donate-page__topbar {
|
||||
padding: 16px 44px 0;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* ── Content container ─────────────────────────────────────── */
|
||||
@@ -112,12 +116,6 @@
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.donate-grid--crypto {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* ── Shared card chrome ───────────────────────────────────── */
|
||||
.donate-card {
|
||||
position: relative;
|
||||
@@ -227,97 +225,6 @@
|
||||
background: color-mix(in srgb, var(--card-hue, #d3869b) 8%, transparent);
|
||||
}
|
||||
|
||||
/* ── Crypto-specific elements ────────────────────────────── */
|
||||
.donate-card__addr-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.donate-card__addr {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
max-width: 260px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: var(--chrome-font-mono);
|
||||
font-size: 0.65rem;
|
||||
color: var(--chrome-fg-dim);
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
background: var(--chrome-hover-bg);
|
||||
border: 1px solid var(--chrome-border);
|
||||
}
|
||||
.donate-card__addr-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.donate-card__addr-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
background: transparent;
|
||||
border: 1px solid var(--chrome-border);
|
||||
color: var(--chrome-fg-muted);
|
||||
cursor: pointer;
|
||||
transition: background var(--dur-fast), color var(--dur-fast), border-color var(--dur-fast);
|
||||
}
|
||||
.donate-card__addr-btn:hover {
|
||||
background: var(--chrome-hover-bg);
|
||||
color: var(--chrome-fg);
|
||||
border-color: var(--chrome-border-strong);
|
||||
}
|
||||
.donate-card__addr-btn--open {
|
||||
width: auto;
|
||||
padding: 0 8px;
|
||||
font-family: var(--chrome-font-mono);
|
||||
font-size: var(--chrome-label-size);
|
||||
font-weight: 600;
|
||||
letter-spacing: var(--chrome-label-track);
|
||||
}
|
||||
.donate-card__open-label {
|
||||
display: none;
|
||||
}
|
||||
@media (min-width: 480px) {
|
||||
.donate-card__open-label { display: inline; }
|
||||
}
|
||||
.donate-card__network {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-family: var(--chrome-font-mono);
|
||||
font-size: var(--chrome-label-size);
|
||||
font-weight: 600;
|
||||
letter-spacing: var(--chrome-label-track);
|
||||
text-transform: uppercase;
|
||||
color: var(--chrome-fg-dim);
|
||||
}
|
||||
|
||||
/* QR code */
|
||||
.donate-card__qr {
|
||||
display: none;
|
||||
flex-shrink: 0;
|
||||
padding: 4px;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--chrome-border);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
transition: transform var(--dur-base);
|
||||
}
|
||||
@media (min-width: 560px) {
|
||||
.donate-card__qr { display: block; }
|
||||
}
|
||||
.donate-card:hover .donate-card__qr {
|
||||
transform: scale(1.04);
|
||||
}
|
||||
|
||||
/* ── Footer ───────────────────────────────────────────────── */
|
||||
.donate-footer {
|
||||
text-align: center;
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Heart, Copy, ExternalLink, ArrowLeft, Check, Building2 } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import React from 'react';
|
||||
import { Heart, ExternalLink, ArrowLeft, Building2 } from 'lucide-react';
|
||||
import { Button } from '../ui';
|
||||
import { openExternal } from '../api/external';
|
||||
import './DonatePage.css';
|
||||
@@ -13,15 +11,6 @@ const METHODS = [
|
||||
description: 'Recurring or one-time — directly through GitHub.',
|
||||
url: 'https://github.com/debpalash',
|
||||
icon: '🐙',
|
||||
type: 'link',
|
||||
},
|
||||
{
|
||||
id: 'patreon',
|
||||
label: 'Patreon',
|
||||
description: 'Monthly support with early access perks.',
|
||||
url: 'https://patreon.com/omnivoicestudio',
|
||||
icon: '🎨',
|
||||
type: 'link',
|
||||
},
|
||||
{
|
||||
id: 'kofi',
|
||||
@@ -29,7 +18,6 @@ const METHODS = [
|
||||
description: 'Buy the team a coffee. No account needed.',
|
||||
url: 'https://ko-fi.com/debpalash',
|
||||
icon: '☕',
|
||||
type: 'link',
|
||||
},
|
||||
{
|
||||
id: 'paypal',
|
||||
@@ -37,93 +25,9 @@ const METHODS = [
|
||||
description: 'Quick one-time or recurring via PayPal.',
|
||||
url: 'https://paypal.me/palashCoder',
|
||||
icon: '💳',
|
||||
type: 'link',
|
||||
},
|
||||
{
|
||||
id: 'btc',
|
||||
label: 'Bitcoin',
|
||||
description: 'Native BTC — any amount.',
|
||||
address: 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh',
|
||||
icon: '₿',
|
||||
type: 'crypto',
|
||||
network: 'Bitcoin (BTC)',
|
||||
protocol: 'bitcoin',
|
||||
},
|
||||
{
|
||||
id: 'eth',
|
||||
label: 'Ethereum',
|
||||
description: 'ETH or ERC-20 tokens.',
|
||||
address: '0x71C7656EC7ab88b098defB751B7401B5f6d8976F',
|
||||
icon: 'Ξ',
|
||||
type: 'crypto',
|
||||
network: 'Ethereum (ETH / ERC-20)',
|
||||
protocol: 'ethereum',
|
||||
},
|
||||
{
|
||||
id: 'sol',
|
||||
label: 'Solana',
|
||||
description: 'SOL or SPL tokens.',
|
||||
address: '7EcDhSYGxXyscszYEp35KHN8vvw3svAuLKTzXwCFLtV',
|
||||
icon: '◎',
|
||||
type: 'crypto',
|
||||
network: 'Solana (SOL)',
|
||||
protocol: 'solana',
|
||||
},
|
||||
];
|
||||
|
||||
function CryptoCard({ method, style }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(method.address);
|
||||
setCopied(true);
|
||||
toast.success(`${method.label} address copied`);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
toast.error('Copy failed');
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="donate-card lp-glow-card" style={style}>
|
||||
<span className="donate-card__glow" aria-hidden="true" />
|
||||
<div className="donate-card__icon">{method.icon}</div>
|
||||
<div className="donate-card__body">
|
||||
<div className="donate-card__label">{method.label}</div>
|
||||
<div className="donate-card__desc">{method.description}</div>
|
||||
<div className="donate-card__addr-row">
|
||||
<code className="donate-card__addr">{method.address}</code>
|
||||
<div className="donate-card__addr-actions">
|
||||
<button className="donate-card__addr-btn" onClick={handleCopy} title="Copy address">
|
||||
{copied ? <Check size={13} /> : <Copy size={13} />}
|
||||
</button>
|
||||
{method.protocol && (
|
||||
<button
|
||||
className="donate-card__addr-btn donate-card__addr-btn--open"
|
||||
onClick={() => openExternal(`${method.protocol}:${method.address}`)}
|
||||
title="Open in desktop wallet"
|
||||
>
|
||||
<ExternalLink size={11} />
|
||||
<span className="donate-card__open-label">Open</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="donate-card__network">{method.network}</span>
|
||||
</div>
|
||||
<div className="donate-card__qr">
|
||||
<QRCodeSVG
|
||||
value={`${method.protocol || ''}:${method.address}`}
|
||||
size={48}
|
||||
bgColor="#ffffff"
|
||||
fgColor="#000000"
|
||||
level="M"
|
||||
includeMargin={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkCard({ method, style }) {
|
||||
return (
|
||||
<button
|
||||
@@ -146,9 +50,6 @@ function LinkCard({ method, style }) {
|
||||
}
|
||||
|
||||
export default function DonatePage({ onBack, onEnterprise }) {
|
||||
const links = METHODS.filter(m => m.type === 'link');
|
||||
const crypto = METHODS.filter(m => m.type === 'crypto');
|
||||
|
||||
return (
|
||||
<div className="donate-page">
|
||||
{/* Aurora backdrop — same as Launchpad */}
|
||||
@@ -158,8 +59,8 @@ export default function DonatePage({ onBack, onEnterprise }) {
|
||||
<span className="lp-aurora__blob lp-aurora__blob--amber" />
|
||||
</div>
|
||||
|
||||
{/* Back button */}
|
||||
<div className="donate-page__back">
|
||||
{/* Top bar: Back (left) + Commercial License (right) */}
|
||||
<div className="donate-page__topbar">
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
@@ -168,6 +69,17 @@ export default function DonatePage({ onBack, onEnterprise }) {
|
||||
>
|
||||
Back to Studio
|
||||
</Button>
|
||||
{onEnterprise && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={onEnterprise}
|
||||
leading={<Building2 size={14} />}
|
||||
trailing={<ExternalLink size={12} />}
|
||||
>
|
||||
Commercial License
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="donate-page__content">
|
||||
@@ -192,7 +104,7 @@ export default function DonatePage({ onBack, onEnterprise }) {
|
||||
<span>Platforms</span>
|
||||
</div>
|
||||
<div className="donate-grid donate-grid--links">
|
||||
{links.map((m, i) => (
|
||||
{METHODS.map((m, i) => (
|
||||
<LinkCard
|
||||
key={m.id}
|
||||
method={m}
|
||||
@@ -202,40 +114,9 @@ export default function DonatePage({ onBack, onEnterprise }) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Cryptocurrency */}
|
||||
<section className="donate-section">
|
||||
<div className="donate-section__title">
|
||||
<span>Cryptocurrency</span>
|
||||
</div>
|
||||
<div className="donate-grid donate-grid--crypto">
|
||||
{crypto.map((m, i) => (
|
||||
<CryptoCard
|
||||
key={m.id}
|
||||
method={m}
|
||||
style={{ '--anim-i': i + 3, '--card-hue': '#fe8019' }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="donate-footer">
|
||||
Every contribution helps push the boundaries of local AI. ♥
|
||||
</div>
|
||||
|
||||
{/* Enterprise CTA */}
|
||||
{onEnterprise && (
|
||||
<div className="donate-enterprise-cta">
|
||||
<button type="button" className="donate-card donate-card--link" onClick={onEnterprise} style={{ '--card-hue': '#fe8019' }}>
|
||||
<span className="donate-card__glow" aria-hidden="true" />
|
||||
<div className="donate-card__icon"><Building2 size={16} /></div>
|
||||
<div className="donate-card__body">
|
||||
<div className="donate-card__label">Commercial License</div>
|
||||
<div className="donate-card__desc">Using OmniVoice in a product or business? See enterprise plans.</div>
|
||||
</div>
|
||||
<div className="donate-card__arrow"><ExternalLink size={14} /></div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -137,137 +137,41 @@
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── Pricing tiers ────────────────────────────────────────── */
|
||||
.ent-tiers {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
.ent-tier {
|
||||
position: relative;
|
||||
padding: 22px 20px;
|
||||
/* ── Pricing — coming-soon panel ──────────────────────────── */
|
||||
.ent-coming-soon {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding: 24px 22px;
|
||||
border: 1px solid var(--chrome-border);
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
transition: background var(--dur-fast), border-color var(--dur-fast), transform var(--dur-base);
|
||||
background: color-mix(in srgb, #fe8019 5%, transparent);
|
||||
text-align: center;
|
||||
align-items: center;
|
||||
animation: entCardIn 0.5s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
.ent-tier:hover {
|
||||
transform: translateY(-2px);
|
||||
background: color-mix(in srgb, var(--tier-accent) 4%, transparent);
|
||||
border-color: color-mix(in srgb, var(--tier-accent) 35%, transparent);
|
||||
box-shadow: 0 12px 28px -14px color-mix(in srgb, var(--tier-accent) 30%, transparent);
|
||||
}
|
||||
.ent-tier--best {
|
||||
border-color: color-mix(in srgb, var(--tier-accent) 40%, transparent);
|
||||
background: color-mix(in srgb, var(--tier-accent) 4%, transparent);
|
||||
}
|
||||
.ent-tier__badge {
|
||||
position: absolute;
|
||||
top: -9px;
|
||||
right: 14px;
|
||||
font-family: var(--chrome-font-mono);
|
||||
font-size: var(--chrome-label-size);
|
||||
font-weight: 600;
|
||||
letter-spacing: var(--chrome-label-track);
|
||||
text-transform: uppercase;
|
||||
padding: 2px 10px;
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
background: color-mix(in srgb, var(--tier-accent) 15%, var(--chrome-bg));
|
||||
border: 1px solid color-mix(in srgb, var(--tier-accent) 45%, transparent);
|
||||
color: var(--tier-accent);
|
||||
}
|
||||
.ent-tier__icon-wrap {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
background: color-mix(in srgb, var(--tier-accent) 10%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--tier-accent) 25%, transparent);
|
||||
color: var(--tier-accent);
|
||||
}
|
||||
.ent-tier__name {
|
||||
font-family: var(--chrome-font-mono);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--chrome-fg);
|
||||
letter-spacing: var(--chrome-label-track);
|
||||
text-transform: uppercase;
|
||||
.ent-coming-soon p {
|
||||
margin: 0;
|
||||
}
|
||||
.ent-tier__price {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 3px;
|
||||
}
|
||||
.ent-tier__amount {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 1.8rem;
|
||||
font-weight: 400;
|
||||
color: var(--chrome-fg);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.ent-tier__period {
|
||||
font-family: var(--chrome-font-mono);
|
||||
font-size: 0.68rem;
|
||||
color: var(--chrome-fg-dim);
|
||||
}
|
||||
.ent-tier__desc {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.72rem;
|
||||
max-width: 540px;
|
||||
color: var(--chrome-fg-muted);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
.ent-tier__perks {
|
||||
list-style: none;
|
||||
margin: 8px 0 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.ent-tier__perks li {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.72rem;
|
||||
color: var(--chrome-fg-muted);
|
||||
line-height: 1.45;
|
||||
}
|
||||
.ent-tier__check {
|
||||
color: var(--tier-accent);
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.ent-tier__cta {
|
||||
.ent-coming-soon__cta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
padding: 8px 16px;
|
||||
gap: 6px;
|
||||
padding: 10px 18px;
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
background: color-mix(in srgb, var(--tier-accent) 12%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--tier-accent) 35%, transparent);
|
||||
color: var(--tier-accent);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.75rem;
|
||||
background: color-mix(in srgb, #fe8019 18%, transparent);
|
||||
border: 1px solid color-mix(in srgb, #fe8019 50%, transparent);
|
||||
color: var(--chrome-fg);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: background var(--dur-fast), border-color var(--dur-fast), transform var(--dur-base);
|
||||
transition: background var(--dur-fast), border-color var(--dur-fast), transform var(--dur-fast);
|
||||
}
|
||||
.ent-tier__cta:hover {
|
||||
background: color-mix(in srgb, var(--tier-accent) 20%, transparent);
|
||||
border-color: color-mix(in srgb, var(--tier-accent) 55%, transparent);
|
||||
.ent-coming-soon__cta:hover {
|
||||
background: color-mix(in srgb, #fe8019 28%, transparent);
|
||||
border-color: color-mix(in srgb, #fe8019 70%, transparent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,112 +1,21 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
ArrowLeft, Shield, Zap, Users, Headphones, Code, Globe,
|
||||
BarChart3, Building2, Mail, ExternalLink, Check,
|
||||
Building2, Mail,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '../ui';
|
||||
import { openExternal } from '../api/external';
|
||||
import './EnterprisePage.css';
|
||||
|
||||
const TIERS = [
|
||||
{
|
||||
id: 'startup',
|
||||
name: 'Startup',
|
||||
price: '$249',
|
||||
period: '/year',
|
||||
accent: '#8ec07c',
|
||||
best: false,
|
||||
description: 'For small teams shipping content with AI voices.',
|
||||
perks: [
|
||||
'Commercial use license for up to 5 seats',
|
||||
'Remove invisible watermark from exports',
|
||||
'Priority bug fixes via email',
|
||||
'Invoice + receipt for accounting',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'business',
|
||||
name: 'Business',
|
||||
price: '$999',
|
||||
period: '/year',
|
||||
accent: '#d3869b',
|
||||
best: true,
|
||||
description: 'For production teams that need reliability and support.',
|
||||
perks: [
|
||||
'Everything in Startup',
|
||||
'Unlimited seats within one organization',
|
||||
'Dedicated Slack/Discord channel with core team',
|
||||
'48-hour response SLA on critical issues',
|
||||
'Custom model fine-tuning guidance',
|
||||
'Early access to beta features & engines',
|
||||
'Logo on README + website acknowledgments',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'enterprise',
|
||||
name: 'Enterprise',
|
||||
price: 'Custom',
|
||||
period: '',
|
||||
accent: '#fe8019',
|
||||
best: false,
|
||||
description: 'On-prem deployment, SLA, and dedicated engineering.',
|
||||
perks: [
|
||||
'Everything in Business',
|
||||
'On-premise deployment support',
|
||||
'Custom SLA (up to 4-hour response)',
|
||||
'Dedicated integration engineer',
|
||||
'Private model hosting & training',
|
||||
'Custom API/SDK development',
|
||||
'Source code escrow',
|
||||
'Multi-year volume discounts',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const WHY_ITEMS = [
|
||||
{ icon: Shield, label: 'Full IP ownership', desc: 'Your voices, your data, your servers. No third-party dependency.' },
|
||||
{ icon: Zap, label: 'Zero per-minute costs', desc: 'One flat annual fee. Generate millions of minutes without usage caps.' },
|
||||
{ icon: Zap, label: 'Zero per-minute costs', desc: 'Flat licensing. Generate millions of minutes without usage caps.' },
|
||||
{ icon: Users, label: 'Team-wide access', desc: 'Share across your org. No per-seat API key management.' },
|
||||
{ icon: Headphones, label: 'Direct support', desc: 'Talk to the engineers who built it, not a helpdesk.' },
|
||||
{ icon: Code, label: 'Open source core', desc: 'Audit the code. Fork if needed. No vendor lock-in, ever.' },
|
||||
{ icon: Code, label: 'Source-available core', desc: 'Audit the code. Fork if needed. Apache 2.0 two years after release — no vendor lock-in.' },
|
||||
{ icon: Globe, label: '646 languages', desc: 'Ship global content from one tool. No third-party locale add-ons.' },
|
||||
];
|
||||
|
||||
function TierCard({ tier }) {
|
||||
return (
|
||||
<div
|
||||
className={`ent-tier ${tier.best ? 'ent-tier--best' : ''}`}
|
||||
style={{ '--tier-accent': tier.accent }}
|
||||
>
|
||||
{tier.best && <span className="ent-tier__badge">Most popular</span>}
|
||||
<div className="ent-tier__icon-wrap">
|
||||
<Building2 size={18} />
|
||||
</div>
|
||||
<h3 className="ent-tier__name">{tier.name}</h3>
|
||||
<div className="ent-tier__price">
|
||||
<span className="ent-tier__amount">{tier.price}</span>
|
||||
{tier.period && <span className="ent-tier__period">{tier.period}</span>}
|
||||
</div>
|
||||
<p className="ent-tier__desc">{tier.description}</p>
|
||||
<ul className="ent-tier__perks">
|
||||
{tier.perks.map((p, i) => (
|
||||
<li key={i}>
|
||||
<Check size={12} className="ent-tier__check" />
|
||||
{p}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button
|
||||
type="button"
|
||||
className="ent-tier__cta"
|
||||
onClick={() => openExternal(`mailto:OmniVoice@palash.dev?subject=OmniVoice ${tier.name} License&body=Hi Palash,%0A%0AI'm interested in the ${tier.name} license for OmniVoice Studio.%0A%0AOrganization:%0ATeam size:%0AUse case:%0A`)}
|
||||
>
|
||||
<Mail size={13} />
|
||||
{tier.price === 'Custom' ? 'Contact Sales' : 'Get Started'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function EnterprisePage({ onBack }) {
|
||||
return (
|
||||
<div className="enterprise-page">
|
||||
@@ -137,9 +46,18 @@ export default function EnterprisePage({ onBack }) {
|
||||
<span className="lp-hero__sweep" aria-hidden="true" />
|
||||
</h2>
|
||||
<p className="ent-hero__subtitle">
|
||||
OmniVoice Studio is free for personal and non-commercial use.
|
||||
For commercial products, SaaS, and enterprise — grab a license
|
||||
that fits your team. <strong>30-day free evaluation included.</strong>
|
||||
OmniVoice Studio is source-available under the{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="ent-cta-footer__link"
|
||||
onClick={() => openExternal('https://fsl.software/')}
|
||||
>
|
||||
Functional Source License
|
||||
</button>
|
||||
{' '}— free for personal, educational, and non-commercial use,
|
||||
and converts to Apache 2.0 two years after each release.
|
||||
Building a competing product or service on top of OmniVoice?
|
||||
<strong> Pricing tiers coming soon — get in touch in the meantime.</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -159,13 +77,26 @@ export default function EnterprisePage({ onBack }) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Pricing Tiers */}
|
||||
{/* Pricing — coming soon */}
|
||||
<section className="ent-tiers-section">
|
||||
<div className="ent-section-title">
|
||||
<span>Plans</span>
|
||||
<span>Pricing</span>
|
||||
</div>
|
||||
<div className="ent-tiers">
|
||||
{TIERS.map(t => <TierCard key={t.id} tier={t} />)}
|
||||
<div className="ent-coming-soon">
|
||||
<p>
|
||||
<strong>Tiers and pricing are still being finalized.</strong>{' '}
|
||||
Until they're public, every commercial deployment is being
|
||||
quoted individually so we can right-size for your team and
|
||||
workload.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="ent-coming-soon__cta"
|
||||
onClick={() => openExternal('mailto:OmniVoice@palash.dev?subject=OmniVoice Commercial License Inquiry&body=Hi Palash,%0A%0AI%27d like to talk about a commercial license for OmniVoice Studio.%0A%0AOrganization:%0ATeam size:%0AUse case:%0A')}
|
||||
>
|
||||
<Mail size={13} />
|
||||
Request a quote
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -177,19 +108,19 @@ export default function EnterprisePage({ onBack }) {
|
||||
<div className="ent-faq__list">
|
||||
<details className="ent-faq__item">
|
||||
<summary>Do I need a license for internal tools?</summary>
|
||||
<p>If the tool generates revenue or is used in a commercial product — yes. Internal R&D and prototyping during the 30-day evaluation period is free.</p>
|
||||
<p>Internal use by your employees and contractors is a Permitted Purpose under the FSL — no license required. A commercial license is needed when you make OmniVoice available to others as part of a competing product or service (resale, hosted SaaS, white-label).</p>
|
||||
</details>
|
||||
<details className="ent-faq__item">
|
||||
<summary>Can I try before I buy?</summary>
|
||||
<p>Absolutely. Every plan includes a 30-day free evaluation. No credit card required — just email us and we'll activate it.</p>
|
||||
<summary>Can I try before committing?</summary>
|
||||
<p>Yes. The full app is free to download and run locally for evaluation under the FSL. When you're ready to discuss a commercial deployment, email us and we'll work through the details together.</p>
|
||||
</details>
|
||||
<details className="ent-faq__item">
|
||||
<summary>What about the watermark?</summary>
|
||||
<p>The invisible AudioSeal watermark is embedded by default. Commercial licensees can disable it in Settings → Privacy. Free/personal use always includes the watermark.</p>
|
||||
</details>
|
||||
<details className="ent-faq__item">
|
||||
<summary>Do you offer multi-year discounts?</summary>
|
||||
<p>Yes — Enterprise tier includes volume and multi-year pricing. Contact us for a custom quote.</p>
|
||||
<summary>Does the source ever become Apache 2.0?</summary>
|
||||
<p>Yes. Each release converts automatically to the Apache License, Version 2.0 on the second anniversary of its publication. That means today's release is Apache 2.0 in two years, no action required from us — the FSL guarantees it irrevocably.</p>
|
||||
</details>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
+269
-45
@@ -10,6 +10,7 @@ import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import {
|
||||
Cpu, FileText, Info, ShieldCheck, RefreshCw, Trash2, ExternalLink,
|
||||
CheckCircle, AlertCircle, Plug, Mic, MessageSquare, Download, Copy, Building2, KeyRound,
|
||||
Keyboard,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { openExternal } from '../api/external';
|
||||
@@ -25,6 +26,7 @@ import './Settings.css';
|
||||
const TABS = [
|
||||
{ id: 'models', label: 'Models', icon: Cpu, accent: '#f3a5b6' },
|
||||
{ id: 'engines', label: 'Engines', icon: Plug, accent: '#d3869b' },
|
||||
{ id: 'capture', label: 'Capture', icon: Keyboard, accent: '#83a598' },
|
||||
{ id: 'credentials', label: 'Credentials', icon: KeyRound, accent: '#fe8019' },
|
||||
{ id: 'logs', label: 'Logs', icon: FileText, accent: '#fabd2f' },
|
||||
{ id: 'about', label: 'About', icon: Info, accent: '#8ec07c' },
|
||||
@@ -58,7 +60,8 @@ function Row({ label, value, mono }) {
|
||||
}
|
||||
|
||||
function fmtBytes(n) {
|
||||
if (!n || n <= 0) return '—';
|
||||
if (n == null || n < 0) return '—';
|
||||
if (n === 0) return '0 B';
|
||||
if (n >= 1024 ** 3) return `${(n / 1024 ** 3).toFixed(2)} GB`;
|
||||
if (n >= 1024 ** 2) return `${(n / 1024 ** 2).toFixed(1)} MB`;
|
||||
return `${Math.round(n / 1024)} KB`;
|
||||
@@ -102,6 +105,16 @@ export function ModelStoreTab({ info, modelBadge }) {
|
||||
const tableBodyRef = React.useRef(null);
|
||||
// Track download speed per repo: { [repo_id]: { lastBytes, lastTime, speed } }
|
||||
const speedRef = React.useRef({});
|
||||
// Tick counter — forces re-render every second while a download is active
|
||||
// so speed/ETA displays update smoothly between SSE events.
|
||||
const [, setTick] = useState(0);
|
||||
useEffect(() => {
|
||||
const hasActive = Object.values(rowState).some(s =>
|
||||
['install_start', 'active', 'delete_start'].includes(s.phase));
|
||||
if (!hasActive) return;
|
||||
const iv = setInterval(() => setTick(t => t + 1), 1000);
|
||||
return () => clearInterval(iv);
|
||||
}, [rowState]);
|
||||
|
||||
// HF token inline — compact input in the toolbar
|
||||
const [hfToken, setHfToken] = useState('');
|
||||
@@ -113,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' },
|
||||
@@ -148,6 +162,13 @@ export function ModelStoreTab({ info, modelBadge }) {
|
||||
if (ev.phase === 'install_start' || ev.phase === 'delete_start') {
|
||||
return { ...prev, [ev.repo_id]: { phase: ev.phase, files: {}, error: null } };
|
||||
}
|
||||
// Heartbeat from backend while resolving repo metadata
|
||||
if (ev.phase === 'resolving') {
|
||||
return { ...prev, [ev.repo_id]: { ...cur, phase: 'resolving', resolvingStep: ev.step || 0 } };
|
||||
}
|
||||
if (ev.phase === 'install_retry') {
|
||||
return { ...prev, [ev.repo_id]: { ...cur, phase: 'install_retry', retryAttempt: ev.attempt, error: ev.error } };
|
||||
}
|
||||
if (ev.phase === 'install_done') {
|
||||
return { ...prev, [ev.repo_id]: { ...cur, phase: 'install_done' } };
|
||||
}
|
||||
@@ -163,6 +184,7 @@ export function ModelStoreTab({ info, modelBadge }) {
|
||||
total: ev.total || 0,
|
||||
pct: ev.pct || 0,
|
||||
phase: ev.phase,
|
||||
rate: ev.rate || 0,
|
||||
}};
|
||||
return { ...prev, [ev.repo_id]: { ...cur, phase: 'active', files } };
|
||||
});
|
||||
@@ -278,9 +300,13 @@ export function ModelStoreTab({ info, modelBadge }) {
|
||||
total: a.total + (f.total || 0),
|
||||
done: a.done + (f.phase === 'done' ? 1 : 0),
|
||||
}), { downloaded: 0, total: 0, done: 0 });
|
||||
// Sum backend-reported rate from active (non-done) files
|
||||
const backendRate = fileList
|
||||
.filter(([, f]) => f.phase !== 'done' && f.rate > 0)
|
||||
.reduce((s, [, f]) => s + f.rate, 0);
|
||||
const hasFiles = fileList.length > 0;
|
||||
const aggPct = totals.total > 0 ? (totals.downloaded / totals.total) * 100 : null;
|
||||
const showBar = phase === 'install_start' || phase === 'active' || phase === 'delete_start';
|
||||
const showBar = ['install_start', 'resolving', 'install_retry', 'active', 'delete_start'].includes(phase);
|
||||
const activeFilename = fileList.find(([, f]) => f.phase !== 'done')?.[0];
|
||||
const unsupported = m.supported === false;
|
||||
|
||||
@@ -297,6 +323,7 @@ export function ModelStoreTab({ info, modelBadge }) {
|
||||
showBar,
|
||||
activeFilename,
|
||||
unsupported,
|
||||
backendRate,
|
||||
};
|
||||
}, [busy, rowState]);
|
||||
|
||||
@@ -335,49 +362,70 @@ export function ModelStoreTab({ info, modelBadge }) {
|
||||
size="xs"
|
||||
/>
|
||||
<span className="models-row__progresstext">
|
||||
{rt.isDeleting
|
||||
? 'Removing cached revisions…'
|
||||
: rt.hasFiles
|
||||
? (() => {
|
||||
const sp = speedRef.current[m.repo_id];
|
||||
const now = Date.now();
|
||||
if (sp && rt.totals.downloaded > 0) {
|
||||
const dt = (now - sp.lastTime) / 1000;
|
||||
if (dt >= 2) {
|
||||
sp.speed = Math.max(0, (rt.totals.downloaded - sp.lastBytes) / dt);
|
||||
sp.lastBytes = rt.totals.downloaded;
|
||||
sp.lastTime = now;
|
||||
}
|
||||
} else {
|
||||
speedRef.current[m.repo_id] = { lastBytes: rt.totals.downloaded, lastTime: now, speed: 0 };
|
||||
}
|
||||
const speed = sp?.speed || 0;
|
||||
const remaining = rt.totals.total - rt.totals.downloaded;
|
||||
const etaSec = speed > 0 ? remaining / speed : 0;
|
||||
const etaStr = etaSec > 0
|
||||
? etaSec < 60 ? `~${Math.ceil(etaSec)}s left`
|
||||
: etaSec < 3600 ? `~${Math.ceil(etaSec / 60)}m left`
|
||||
: `~${(etaSec / 3600).toFixed(1)}h left`
|
||||
: '';
|
||||
const pctStr = rt.aggPct != null ? `${Math.round(rt.aggPct)}%` : '';
|
||||
const parts = [
|
||||
`${fmtBytes(rt.totals.downloaded)} / ${rt.totals.total ? fmtBytes(rt.totals.total) : '?'}`,
|
||||
pctStr,
|
||||
speed > 0 ? `${fmtBytes(speed)}/s` : null,
|
||||
etaStr || null,
|
||||
].filter(Boolean);
|
||||
const extra = [];
|
||||
if (rt.fileList.length > 1) {
|
||||
extra.push(`${rt.totals.done}/${rt.fileList.length} files`);
|
||||
}
|
||||
if (rt.activeFilename) {
|
||||
extra.push(rt.activeFilename.split('/').pop());
|
||||
}
|
||||
return extra.length
|
||||
? `${parts.join(' · ')} ⸱ ${extra.join(' · ')}`
|
||||
: parts.join(' · ');
|
||||
})()
|
||||
: 'Preparing download…'}
|
||||
{(() => {
|
||||
if (rt.isDeleting) return 'Removing cached revisions…';
|
||||
if (!rt.hasFiles) {
|
||||
if (rt.phase === 'resolving') {
|
||||
const dots = '.'.repeat((rt.rs?.resolvingStep || 0) % 4);
|
||||
return `Resolving repo metadata${dots}`;
|
||||
}
|
||||
if (rt.phase === 'install_retry') {
|
||||
return `Retry attempt ${rt.rs?.retryAttempt || '?'} — ${rt.rs?.error || 'reconnecting'}`;
|
||||
}
|
||||
return 'Connecting to HuggingFace…';
|
||||
}
|
||||
|
||||
// We have file events — compute speed
|
||||
const sp = speedRef.current[m.repo_id];
|
||||
const now = Date.now();
|
||||
if (sp && rt.totals.downloaded > 0) {
|
||||
const dt = (now - sp.lastTime) / 1000;
|
||||
if (dt >= 1) {
|
||||
sp.speed = Math.max(0, (rt.totals.downloaded - sp.lastBytes) / dt);
|
||||
sp.lastBytes = rt.totals.downloaded;
|
||||
sp.lastTime = now;
|
||||
}
|
||||
} else {
|
||||
speedRef.current[m.repo_id] = { lastBytes: rt.totals.downloaded, lastTime: now, speed: 0 };
|
||||
}
|
||||
const speed = rt.backendRate > 0 ? rt.backendRate : (sp?.speed || 0);
|
||||
|
||||
// If total is unknown and nothing downloaded yet → still resolving
|
||||
if (rt.totals.total === 0 && rt.totals.downloaded === 0) {
|
||||
const activeFile = rt.activeFilename?.split('/').pop();
|
||||
return activeFile
|
||||
? `Resolving ${rt.fileList.length} file${rt.fileList.length > 1 ? 's' : ''}… · ${activeFile}`
|
||||
: `Resolving ${rt.fileList.length} file${rt.fileList.length > 1 ? 's' : ''}…`;
|
||||
}
|
||||
|
||||
// Build the info line
|
||||
const remaining = rt.totals.total - rt.totals.downloaded;
|
||||
const etaSec = speed > 0 && rt.totals.total > 0 ? remaining / speed : 0;
|
||||
const etaStr = etaSec > 0
|
||||
? etaSec < 60 ? `~${Math.ceil(etaSec)}s`
|
||||
: etaSec < 3600 ? `~${Math.ceil(etaSec / 60)}m`
|
||||
: `~${(etaSec / 3600).toFixed(1)}h`
|
||||
: '';
|
||||
const dlStr = fmtBytes(rt.totals.downloaded) || '0 B';
|
||||
const totalStr = rt.totals.total > 0 ? fmtBytes(rt.totals.total) : '…';
|
||||
const pctStr = rt.aggPct != null && rt.aggPct > 0 ? `${Math.round(rt.aggPct)}%` : '';
|
||||
const speedStr = speed > 0 ? `${fmtBytes(speed)}/s` : '';
|
||||
|
||||
const parts = [
|
||||
`${dlStr} / ${totalStr}`,
|
||||
pctStr,
|
||||
speedStr || (rt.totals.downloaded > 0 ? 'measuring…' : ''),
|
||||
etaStr,
|
||||
].filter(Boolean);
|
||||
|
||||
const extra = [];
|
||||
if (rt.fileList.length > 1) extra.push(`${rt.totals.done}/${rt.fileList.length} files`);
|
||||
if (rt.activeFilename) extra.push(rt.activeFilename.split('/').pop());
|
||||
|
||||
return extra.length
|
||||
? `${parts.join(' · ')} ⸱ ${extra.join(' · ')}`
|
||||
: parts.join(' · ');
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -566,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 && (
|
||||
@@ -1061,6 +1117,8 @@ export default function Settings() {
|
||||
|
||||
{activeTab === 'engines' && <EnginesTab />}
|
||||
|
||||
{activeTab === 'capture' && <HotkeyTab />}
|
||||
|
||||
{activeTab === 'credentials' && <CredentialsTab info={info} />}
|
||||
|
||||
{activeTab === 'logs' && (
|
||||
@@ -1237,6 +1295,172 @@ const CREDENTIAL_FIELDS = [
|
||||
},
|
||||
];
|
||||
|
||||
// Convert a KeyboardEvent into a tauri-plugin-global-shortcut accelerator
|
||||
// string, e.g. "CmdOrCtrl+Shift+Space". Returns null when only modifiers
|
||||
// are held (the user hasn't picked a "real" key yet).
|
||||
function keyEventToAccelerator(e) {
|
||||
const isMacLike = typeof navigator !== 'undefined'
|
||||
&& /Mac|iPad|iPhone|iPod/.test(navigator.platform || '');
|
||||
const mods = [];
|
||||
if (e.metaKey) mods.push(isMacLike ? 'Cmd' : 'Super');
|
||||
if (e.ctrlKey) mods.push('Ctrl');
|
||||
if (e.altKey) mods.push('Alt');
|
||||
if (e.shiftKey) mods.push('Shift');
|
||||
|
||||
// e.code is the physical key — already in the shape tauri expects for
|
||||
// Letter/Digit/Function keys ("KeyA", "Digit1", "F5"). Strip the prefix
|
||||
// so we get "A" / "1" / "F5" which matches the accelerator grammar.
|
||||
let key = e.code;
|
||||
if (!key) return null;
|
||||
if (key.startsWith('Key')) key = key.slice(3);
|
||||
else if (key.startsWith('Digit')) key = key.slice(5);
|
||||
// Skip pure modifier keys — we want the user to pick a real trigger.
|
||||
if (/^(Meta|Control|Alt|Shift|OS)(Left|Right)?$/.test(key)) return null;
|
||||
|
||||
if (mods.length === 0) return null;
|
||||
return [...mods, key].join('+');
|
||||
}
|
||||
|
||||
function HotkeyTab() {
|
||||
const [current, setCurrent] = useState('');
|
||||
const [recording, setRecording] = useState(false);
|
||||
const [pending, setPending] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const tauri = isTauri();
|
||||
|
||||
// Load the saved shortcut on mount.
|
||||
useEffect(() => {
|
||||
if (!tauri) return;
|
||||
(async () => {
|
||||
try {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
const v = await invoke('get_dictation_shortcut');
|
||||
setCurrent(v || '');
|
||||
} catch (e) {
|
||||
toast.error(`Could not load shortcut: ${e?.message || e}`);
|
||||
}
|
||||
})();
|
||||
}, [tauri]);
|
||||
|
||||
// While recording, swallow keystrokes globally and convert the next real
|
||||
// press into an accelerator string. Escape cancels.
|
||||
useEffect(() => {
|
||||
if (!recording) return;
|
||||
const onKeyDown = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.key === 'Escape') {
|
||||
setRecording(false);
|
||||
setPending('');
|
||||
return;
|
||||
}
|
||||
const accel = keyEventToAccelerator(e);
|
||||
if (accel) {
|
||||
setPending(accel);
|
||||
setRecording(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown, true);
|
||||
return () => window.removeEventListener('keydown', onKeyDown, true);
|
||||
}, [recording]);
|
||||
|
||||
const save = async () => {
|
||||
if (!pending || pending === current) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
const saved = await invoke('set_dictation_shortcut', { accelerator: pending });
|
||||
setCurrent(saved);
|
||||
setPending('');
|
||||
toast.success(`Dictation shortcut set to ${saved}`);
|
||||
} catch (e) {
|
||||
// Common cause: the OS or another app already owns the combo. Surface
|
||||
// the raw error so the user can pick something else.
|
||||
toast.error(`Couldn't register: ${e?.message || e}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetDefault = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
const saved = await invoke('set_dictation_shortcut', {
|
||||
accelerator: 'CmdOrCtrl+Shift+Space',
|
||||
});
|
||||
setCurrent(saved);
|
||||
setPending('');
|
||||
toast.success('Reset to default');
|
||||
} catch (e) {
|
||||
toast.error(`Reset failed: ${e?.message || e}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="settings-section">
|
||||
<h2><Keyboard size={16} color="#83a598" /> Capture & Dictation</h2>
|
||||
|
||||
{!tauri && (
|
||||
<p className="settings-prose">
|
||||
Global hotkeys only work in the desktop app. The web UI uses an
|
||||
in-page <kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>Space</kbd> shortcut
|
||||
while the window has focus.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="settings-row">
|
||||
<span className="label">Active shortcut</span>
|
||||
<span className="value settings-row__mono">{current || '—'}</span>
|
||||
</div>
|
||||
|
||||
<div className="settings-row">
|
||||
<span className="label">{recording ? 'Press a key combo…' : 'New shortcut'}</span>
|
||||
<span className="value settings-row__mono">
|
||||
{recording ? '⌨︎ listening (Esc to cancel)' : (pending || '—')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 12, flexWrap: 'wrap' }}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
onClick={() => { setPending(''); setRecording(true); }}
|
||||
disabled={!tauri || saving}
|
||||
leading={<Keyboard size={12} />}
|
||||
>
|
||||
{recording ? 'Recording…' : 'Record shortcut'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={save}
|
||||
disabled={!tauri || !pending || pending === current}
|
||||
loading={saving}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
onClick={resetDefault}
|
||||
disabled={!tauri || saving}
|
||||
>
|
||||
Reset to default
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="settings-prose" style={{ marginTop: 12 }}>
|
||||
The hotkey works system-wide while OmniVoice is running — it focuses
|
||||
the window and starts dictation. Avoid combos already claimed by the
|
||||
OS (on macOS, <code>⌘+Space</code> is Spotlight and <code>⌘+⇧+Space</code>
|
||||
cycles input sources). If registration fails, pick a different combo.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function CredentialsTab({ info }) {
|
||||
const [values, setValues] = useState({});
|
||||
const [saving, setSaving] = useState(null);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -36,6 +36,12 @@ function PreflightPanel({ report, loading, onRecheck }) {
|
||||
if (!report) return null;
|
||||
return (
|
||||
<div className="swiz-checklist">
|
||||
<div className="swiz-check-header">
|
||||
<span className="swiz-check-header__label">System preflight</span>
|
||||
<Button variant="ghost" size="sm" onClick={onRecheck} leading={<RefreshCw size={12} />}>
|
||||
Re-check
|
||||
</Button>
|
||||
</div>
|
||||
{report.checks.map((c) => (
|
||||
<div key={c.id} className="setup-wizard__row" style={{ alignItems: 'flex-start', padding: '6px 2px' }}>
|
||||
<span className="swiz-check-icon">{CHECK_ICON[c.status] || null}</span>
|
||||
@@ -54,11 +60,6 @@ function PreflightPanel({ report, loading, onRecheck }) {
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="swiz-check-footer">
|
||||
<Button variant="ghost" size="sm" onClick={onRecheck} leading={<RefreshCw size={12} />}>
|
||||
Re-check
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 };
|
||||
+8
-2
@@ -14,9 +14,15 @@
|
||||
"dev": "bun run setup:api && concurrently -n api,fe -c green,cyan --kill-others-on-fail \"bun run dev:api\" \"bun run wait:api && bun run dev:frontend\"",
|
||||
"predesktop": "kill-port 3900 3901 || true",
|
||||
"desktop": "bun run setup:api && concurrently -n api,app -c green,magenta --kill-others-on-fail \"bun run dev:api\" \"bun run wait:api && bun run dev:desktop\"",
|
||||
"desktop-prod": "bash scripts/desktop-prod.sh",
|
||||
"desktop-prod:run": "bash scripts/desktop-prod.sh --skip-build",
|
||||
"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"
|
||||
@@ -25,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
@@ -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()`
|
||||
|
||||
Executable
+198
@@ -0,0 +1,198 @@
|
||||
#!/usr/bin/env bash
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# desktop-prod.sh — Build & launch OmniVoice Studio as a "fresh install"
|
||||
#
|
||||
# This gives you the EXACT same experience as a user downloading the
|
||||
# installer (DMG on macOS, AppImage on Linux):
|
||||
# • Full Rust bootstrap (venv creation, uv sync, model setup)
|
||||
# • Splash screen with live logs
|
||||
# • Region selector, version badge, etc.
|
||||
#
|
||||
# Usage:
|
||||
# bun desktop-prod # build debug + wipe + launch
|
||||
# bun desktop-prod:run # re-launch last build (skip compile)
|
||||
# bun desktop-prod:upgrade # rebuild, but keep data (test upgrade)
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
set -euo pipefail
|
||||
|
||||
APP_ID="com.debpalash.omnivoice-studio"
|
||||
TAURI_DIR="frontend/src-tauri"
|
||||
APP_NAME="OmniVoice Studio"
|
||||
|
||||
# ── Detect platform ───────────────────────────────────────────────────────
|
||||
OS="$(uname -s)"
|
||||
case "$OS" in
|
||||
Darwin) PLATFORM="macos" ;;
|
||||
Linux) PLATFORM="linux" ;;
|
||||
*) echo "❌ Unsupported platform: $OS"; exit 1 ;;
|
||||
esac
|
||||
|
||||
# ── Platform-specific paths ───────────────────────────────────────────────
|
||||
if [ "$PLATFORM" = "macos" ]; then
|
||||
APP_DATA="$HOME/Library/Application Support/${APP_ID}"
|
||||
TAURI_LOGS="$HOME/Library/Logs/${APP_ID}"
|
||||
WEBKIT_DATA="$HOME/Library/WebKit/${APP_ID}"
|
||||
else
|
||||
# Linux: XDG conventions
|
||||
APP_DATA="${XDG_DATA_HOME:-$HOME/.local/share}/${APP_ID}"
|
||||
TAURI_LOGS="${XDG_DATA_HOME:-$HOME/.local/share}/${APP_ID}/logs"
|
||||
WEBKIT_DATA="${XDG_DATA_HOME:-$HOME/.local/share}/${APP_ID}/webview"
|
||||
fi
|
||||
|
||||
# HF cache — where downloaded models live
|
||||
HF_CACHE="${HF_HOME:-$HOME/.cache/huggingface}"
|
||||
|
||||
# ── Flags ──────────────────────────────────────────────────────────────────
|
||||
SKIP_BUILD=false
|
||||
KEEP_DATA=false
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--skip-build) SKIP_BUILD=true ;;
|
||||
--keep-data) KEEP_DATA=true ;;
|
||||
-h|--help)
|
||||
echo "Usage: $0 [--skip-build] [--keep-data]"
|
||||
echo ""
|
||||
echo " --skip-build Skip cargo build, use last compiled binary"
|
||||
echo " --keep-data Don't wipe app data (test upgrade path)"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── Wipe app data for fresh-install simulation ─────────────────────────────
|
||||
if [ "$KEEP_DATA" = false ]; then
|
||||
echo "🧹 Cleaning all OmniVoice data for fresh prod emulation..."
|
||||
echo ""
|
||||
|
||||
# 1. App data (venv, config, bundled backend)
|
||||
if [ -d "${APP_DATA}" ]; then
|
||||
echo " ✗ App data: ${APP_DATA}"
|
||||
rm -rf "${APP_DATA}"
|
||||
else
|
||||
echo " ○ App data: (already clean)"
|
||||
fi
|
||||
|
||||
# 2. HF model cache (downloaded .safetensors, tokenizers, etc.)
|
||||
if [ -d "${HF_CACHE}" ]; then
|
||||
HF_SIZE=$(du -sh "${HF_CACHE}" 2>/dev/null | cut -f1)
|
||||
echo " ✗ HF cache: ${HF_CACHE} (${HF_SIZE})"
|
||||
rm -rf "${HF_CACHE}"
|
||||
else
|
||||
echo " ○ HF cache: (already clean)"
|
||||
fi
|
||||
|
||||
# 3. Tauri log dir
|
||||
if [ -d "${TAURI_LOGS}" ]; then
|
||||
echo " ✗ Tauri logs: ${TAURI_LOGS}"
|
||||
rm -rf "${TAURI_LOGS}"
|
||||
else
|
||||
echo " ○ Tauri logs: (already clean)"
|
||||
fi
|
||||
|
||||
# 4. WebView cache / local storage
|
||||
if [ -d "${WEBKIT_DATA}" ]; then
|
||||
echo " ✗ WebKit data: ${WEBKIT_DATA}"
|
||||
rm -rf "${WEBKIT_DATA}"
|
||||
else
|
||||
echo " ○ WebKit data: (already clean)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo " ✅ All clean — next launch bootstraps from zero."
|
||||
else
|
||||
echo "📦 Keeping existing app data (upgrade test mode)"
|
||||
fi
|
||||
|
||||
# ── Build debug binary ─────────────────────────────────────────────────────
|
||||
if [ "$SKIP_BUILD" = false ]; then
|
||||
echo ""
|
||||
echo "🔨 Building debug bundle (this takes 1-3 min first time)..."
|
||||
|
||||
# Remove stale bundle so we never accidentally launch old code
|
||||
if [ "$PLATFORM" = "macos" ]; then
|
||||
APP_BUNDLE="${TAURI_DIR}/target/debug/bundle/macos/${APP_NAME}.app"
|
||||
[ -d "$APP_BUNDLE" ] && rm -rf "$APP_BUNDLE"
|
||||
fi
|
||||
|
||||
# Linux: linuxdeploy uses FUSE to mount itself; if FUSE is unavailable
|
||||
# (containers, some hardened kernels), set APPIMAGE_EXTRACT_AND_RUN=1 to
|
||||
# extract-and-run instead. Safe to always set on Linux.
|
||||
if [ "$PLATFORM" = "linux" ]; then
|
||||
export APPIMAGE_EXTRACT_AND_RUN=1
|
||||
fi
|
||||
|
||||
# The build creates the bundle successfully, but then may fail trying
|
||||
# to sign the updater artifact (no TAURI_SIGNING_PRIVATE_KEY) or to
|
||||
# run linuxdeploy. The binary itself is fine — tolerate known errors.
|
||||
BUILD_LOG=$(mktemp)
|
||||
cd frontend
|
||||
set +e
|
||||
bunx tauri build --debug 2>&1 | tee "$BUILD_LOG"
|
||||
BUILD_EXIT=$?
|
||||
set -e
|
||||
cd ..
|
||||
if [ $BUILD_EXIT -ne 0 ]; then
|
||||
# Known-harmless failures:
|
||||
# - Missing TAURI_SIGNING_PRIVATE_KEY (updater signing)
|
||||
# - "failed to run linuxdeploy" (AppImage bundling — binary still works)
|
||||
if grep -qi "TAURI_SIGNING_PRIVATE_KEY\|private key\|failed to run linuxdeploy\|failed to bundle" "$BUILD_LOG"; then
|
||||
echo "⚠️ Non-fatal bundle error — binary is fine (see above for details)."
|
||||
else
|
||||
echo "❌ Build failed with exit code $BUILD_EXIT"
|
||||
rm -f "$BUILD_LOG"
|
||||
exit $BUILD_EXIT
|
||||
fi
|
||||
fi
|
||||
rm -f "$BUILD_LOG"
|
||||
|
||||
echo "✅ Build complete."
|
||||
else
|
||||
echo "⏭️ Skipping build (--skip-build)"
|
||||
fi
|
||||
|
||||
# ── Find and launch the app ────────────────────────────────────────────────
|
||||
if [ "$PLATFORM" = "macos" ]; then
|
||||
APP_BUNDLE="${TAURI_DIR}/target/debug/bundle/macos/${APP_NAME}.app"
|
||||
BINARY="${TAURI_DIR}/target/debug/app"
|
||||
|
||||
if [ -d "$APP_BUNDLE" ]; then
|
||||
echo ""
|
||||
echo "🚀 Launching ${APP_NAME} (.app bundle)..."
|
||||
echo " Bundle: ${APP_BUNDLE}"
|
||||
open "$APP_BUNDLE"
|
||||
elif [ -f "$BINARY" ]; then
|
||||
echo ""
|
||||
echo "🚀 Launching ${APP_NAME} (raw binary — no .app bundle)..."
|
||||
echo " Binary: ${BINARY}"
|
||||
"$BINARY" &
|
||||
else
|
||||
echo "❌ No bundle or binary found. Run without --skip-build first."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
# Linux: prefer AppImage, fall back to raw binary
|
||||
APPIMAGE=$(find "${TAURI_DIR}/target/debug/bundle/appimage" -name "*.AppImage" -type f 2>/dev/null | head -1)
|
||||
BINARY="${TAURI_DIR}/target/debug/app"
|
||||
|
||||
if [ -n "$APPIMAGE" ] && [ -f "$APPIMAGE" ]; then
|
||||
echo ""
|
||||
echo "🚀 Launching ${APP_NAME} (AppImage)..."
|
||||
echo " AppImage: ${APPIMAGE}"
|
||||
chmod +x "$APPIMAGE"
|
||||
"$APPIMAGE" &
|
||||
elif [ -f "$BINARY" ]; then
|
||||
echo ""
|
||||
echo "🚀 Launching ${APP_NAME} (raw binary)..."
|
||||
echo " Binary: ${BINARY}"
|
||||
"$BINARY" &
|
||||
else
|
||||
echo "❌ No AppImage or binary found. Run without --skip-build first."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo " App data: ${APP_DATA}"
|
||||
echo ""
|
||||
echo "✅ App launched. Check the splash screen for bootstrap logs."
|
||||
echo " To re-run without rebuilding: bun desktop-prod:run"
|
||||
Executable
+153
@@ -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"
|
||||
Executable
+388
@@ -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
|
||||
@@ -0,0 +1,98 @@
|
||||
"""
|
||||
Tests for the streaming-ASR WebSocket endpoint.
|
||||
|
||||
Focus: the EOF text-frame protocol (added so the React `CaptureButton` can
|
||||
treat the WS `final` message as the source of truth and skip the duplicate
|
||||
HTTP POST that used to run on every dictation). Ground truth: an EOF text
|
||||
frame must let the server deliver `final` over the still-open socket
|
||||
*without* the client having to disconnect first.
|
||||
|
||||
The ASR backends are mocked — we're testing protocol, not transcription
|
||||
quality.
|
||||
"""
|
||||
import os
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("OMNIVOICE_MODEL", "test")
|
||||
os.environ.setdefault("OMNIVOICE_DISABLE_FILE_LOG", "1")
|
||||
# Tighten the partial-tick so the test doesn't sit waiting 2 s for the
|
||||
# silence path.
|
||||
os.environ["OMNIVOICE_STREAM_INTERVAL"] = "0.1"
|
||||
os.environ["OMNIVOICE_STREAM_SILENCE"] = "0.2"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
# Stub the heavy transcription helpers so the test stays in-process.
|
||||
from api.routers import capture_ws as cw
|
||||
|
||||
async def fake_partial(_chunks):
|
||||
return "hello"
|
||||
|
||||
async def fake_full(_chunks):
|
||||
return {
|
||||
"text": "hello world",
|
||||
"segments": [{"start": 0.0, "end": 1.0, "text": "hello world"}],
|
||||
"language": "en",
|
||||
"duration_s": 1.0,
|
||||
"transcription_time_s": 0.01,
|
||||
"engine": "stub",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(cw, "_transcribe_buffer", fake_partial)
|
||||
monkeypatch.setattr(cw, "_transcribe_buffer_full", fake_full)
|
||||
|
||||
from main import app
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _audio_chunk(n_bytes: int = 20_000) -> bytes:
|
||||
# MIN_BUFFER_BYTES is 16_000 — give the server enough to trigger a partial
|
||||
# AND a final.
|
||||
return b"\x00" * n_bytes
|
||||
|
||||
|
||||
def test_eof_text_frame_triggers_final_without_disconnect(client):
|
||||
"""Client sends audio + 'EOF' text frame, expects `final` over open socket."""
|
||||
with client.websocket_connect("/ws/transcribe") as ws:
|
||||
ws.send_bytes(_audio_chunk())
|
||||
ws.send_text("EOF")
|
||||
# Drain whatever the server sends (partials may or may not arrive
|
||||
# depending on timing). The first message we care about is `final`.
|
||||
final = None
|
||||
for _ in range(10):
|
||||
msg = ws.receive_json()
|
||||
if msg.get("type") == "final":
|
||||
final = msg
|
||||
break
|
||||
assert final is not None, "server never delivered final after EOF"
|
||||
assert final["text"] == "hello world"
|
||||
assert final["engine"] == "stub"
|
||||
|
||||
|
||||
def test_legacy_disconnect_still_finalizes(client):
|
||||
"""Closing the socket without EOF should still deliver final (legacy path)."""
|
||||
# Even if the client closes, the server runs final and *attempts* to send
|
||||
# before the close handshake completes. Whether the test client receives
|
||||
# it is timing-dependent — we mostly care that no exception bubbles up
|
||||
# and the server doesn't deadlock.
|
||||
with client.websocket_connect("/ws/transcribe") as ws:
|
||||
ws.send_bytes(_audio_chunk())
|
||||
# Just close — don't wait. Endpoint should clean up gracefully.
|
||||
|
||||
|
||||
def test_empty_binary_frame_acts_as_eof(client):
|
||||
"""An empty binary frame is the same end-of-audio signal as 'EOF' text."""
|
||||
with client.websocket_connect("/ws/transcribe") as ws:
|
||||
ws.send_bytes(_audio_chunk())
|
||||
ws.send_bytes(b"")
|
||||
final = None
|
||||
for _ in range(10):
|
||||
msg = ws.receive_json()
|
||||
if msg.get("type") == "final":
|
||||
final = msg
|
||||
break
|
||||
assert final is not None
|
||||
assert final["engine"] == "stub"
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user