Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a721c1fdcf | ||
|
|
80f10289fe | ||
|
|
a0ad314736 | ||
|
|
1808a373a1 |
@@ -8,6 +8,22 @@ The bundled TTS model package (`pyproject.toml`) is versioned independently.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.3.14] — 2026-07-09
|
||||
|
||||
A fast follow to v0.3.13: **every engine family now has a visible picker.** Settings → Engines showed only a TTS table, with the ASR and LLM pickers hidden behind a low-discoverability tab — so the 10 transcription engines (including the new OpenAI-compatible backend) looked unswitchable without env vars. Now all three families get their own table. Also in: the Linux AppImage's white-screen auto-workaround now checks the WebKitGTK it actually ships (not whatever your system reports), and installing to a different drive on Windows is properly documented.
|
||||
|
||||
### Added
|
||||
|
||||
- **ASR engines get the same Settings picker TTS has.** Settings → Engines now shows a visible picker table per family — TTS, ASR, and LLM — instead of a single TTS-titled table with the other families tucked behind a tab (README even promised a Settings ASR picker that didn't exist). The OpenAI-compatible backend and the 9 local ASR engines become selectable with one click, no env vars needed; an explicit `OMNIVOICE_ASR_BACKEND` still wins over the Settings pick, so pinned setups behave exactly as before. (no issue — UX gap found during #877)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The Linux AppImage's white-screen auto-workaround now checks the right WebKitGTK.** The launcher decided whether to apply the compositing workaround by asking the *system's* `pkg-config` — but the version that actually runs is the *bundled* one, which the AppImage prioritizes. On any machine where the two diverge (e.g. building from source with newer dev packages installed), the detection read the wrong number and could skip a workaround the running library needed. The build now stamps the bundled version into the AppImage at package time, and the launcher reads that stamp — correct by construction. The launcher's shell tests also now run in CI, which they previously never did. (#961 follow-up)
|
||||
|
||||
### Docs
|
||||
|
||||
- **Windows: installing to a different drive is documented** — the wizard's directory picker works for any local drive; mapped network drives are a Windows Installer limitation (not installable-to by design); and the big data (models/voices) moves independently via Settings → Storage or Portable mode. (#938)
|
||||
|
||||
## [0.3.13] — 2026-07-09
|
||||
|
||||
The community-fixes release. Two contributors didn't just report bugs — they diagnosed them to the exact line and submitted the fixes that shipped: **voice cloning on mlx-audio's CSM model works for the first time**, and **macOS live recording finally gets its microphone permission prompt** (both @MahdiHedhli). A third reporter's A/B analysis fixed **cross-language dubs speaking the wrong language**. On top of that: a backend shutdown race that produced confusing crash-on-quit reports is fixed, the Linux AppImage stops shipping a stale WebKitGTK that white-screened current distros, and a new OpenAI-compatible transcription backend opens a path to Qwen3-ASR today. Thank you to everyone who filed, diagnosed, and contributed — this release is mostly yours.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
**OmniVoice Studio**
|
||||
|
||||
OmniVoice Studio is an open-source, fully-local ElevenLabs alternative — a desktop app for voice cloning, voice design, video dubbing, and real-time dictation across 646 languages. It runs entirely on the user's machine (CUDA/MPS/ROCm/CPU auto-detect), with no API keys, no accounts, and no cloud dependencies. It's an active beta with a growing user base who hit it with real workloads (50-video batches, multi-engine setups, edge-OS platforms) and report friction in GitHub Issues and Discord. The latest stable release is **v0.3.5**; `main` rolls ahead at **v0.3.6** (latest release + 1 patch — see the Versioning rule below).
|
||||
OmniVoice Studio is an open-source, fully-local ElevenLabs alternative — a desktop app for voice cloning, voice design, video dubbing, and real-time dictation across 646 languages. It runs entirely on the user's machine (CUDA/MPS/ROCm/CPU auto-detect), with no API keys, no accounts, and no cloud dependencies. It's an active beta with a growing user base who hit it with real workloads (50-video batches, multi-engine setups, edge-OS platforms) and report friction in GitHub Issues and Discord. The current version lives in `frontend/package.json` (the single source of truth — see Versioning); the latest stable tag is on the [Releases page](https://github.com/debpalash/OmniVoice-Studio/releases/latest). With `AUTO_VERSION_BUMP` off (the current owner setting), `main` holds at the released version between releases.
|
||||
|
||||
**Core Value:** **A first-run that actually works.** A user who downloads the installer (or clones the repo) should reach a working voice-cloning or dubbing output without hitting a wall — and when something does go wrong, the error or docs should tell them exactly what to do.
|
||||
|
||||
@@ -16,175 +16,21 @@ Everything else (new engines, fancy features) is downstream of "the thing instal
|
||||
- **Default features must work on every platform (strict rule, 2026-05-20):** A feature that ships in default mode — out-of-the-box, no user customization, no opt-in toggle — must behave identically on macOS, Windows, and Linux. Platform-specific *implementation code* is allowed for OS APIs / shells / packaging, but the user-visible *default behavior* cannot diverge. Platform-only features (e.g., a macOS-only global shortcut, a Windows-only path picker) must go behind explicit user opt-in: Settings toggle, env var, or CLI flag. When a default doesn't work on a platform, that's a P0 bug — either fix it on the missing platform or move it behind opt-in. No third option.
|
||||
- **Backward-compatible project data**: Existing `omnivoice_data/` (user voices, projects, settings) must keep working without manual migration. Any DB schema change goes through alembic with a tested upgrade path.
|
||||
- **Local-first guarantee preserved**: Auto bug reporting (new addition) must be **opt-in**, must submit only to GitHub Issues (no third-party telemetry endpoint), and the app must remain fully functional with reporting disabled. No required cloud calls, accounts, or API keys.
|
||||
- **Beta release cadence (no RC, no ceremony — strict rule, 2026-05-20):** the v0.3.x line has **no release candidates, no 48h soak, no formal release ceremony**. Every fix goes continuous-to-main; the owner tags a patch (`v0.3.Z`) from main whenever the current state is worth cutting. No `-rc` tags. No phased release. No `v0.4` deferrals while the v0.3.x line is open — every open issue and every open community PR gets absorbed into the v0.3.x line or explicitly declined. Users follow `main` for previews; users wanting stable stay on the latest tagged release (currently **v0.3.5**). ROADMAP.md's Phase 6 "Release/Verify/Retro" entries are obsolete unless the user revives them.
|
||||
- **Beta release cadence (no RC, no ceremony — strict rule, 2026-05-20):** the v0.3.x line has **no release candidates, no 48h soak, no formal release ceremony**. Every fix goes continuous-to-main; the owner tags a patch (`v0.3.Z`) from main whenever the current state is worth cutting. No `-rc` tags. No phased release. No `v0.4` deferrals while the v0.3.x line is open — every open issue and every open community PR gets absorbed into the v0.3.x line or explicitly declined. Users follow `main` for previews; users wanting stable stay on the latest tagged release. ROADMAP.md's Phase 6 "Release/Verify/Retro" entries are obsolete unless the user revives them.
|
||||
<!-- GSD:project-end -->
|
||||
|
||||
<!-- GSD:stack-start source:research/STACK.md -->
|
||||
## Technology Stack
|
||||
|
||||
## Recommended Stack — Per Capability
|
||||
### Capability 1 — HuggingFace Token Persistence (issue #35)
|
||||
| Technology | Version | Purpose | Why Recommended |
|
||||
|------------|---------|---------|-----------------|
|
||||
| `huggingface_hub` (already pinned transitively by `transformers>=5.3.0`) | `≥1.12.x` (latest 2026) | Auth + cache + token storage | Canonical, used by every HF library already in the stack. `HfFolder` is **superseded** in v1.x by the higher-level `login()` / `auth_list()` / `auth_switch()` API. |
|
||||
| `keyring` (Python) | `≥25.x` | Optional OS-keychain backing | Only adopt if a future hardening pass wants Keychain/Credential-Manager/SecretService. **Not recommended for this milestone** — adds a native dep (`dbus`, `pywin32`) per platform with no real security win over `0600` file storage in `HF_HOME`. |
|
||||
| Shell | One-liner to persist `HF_TOKEN` |
|
||||
|-------|---------------------------------|
|
||||
| macOS zsh (default since 10.15) | `echo 'export HF_TOKEN=hf_xxx' >> ~/.zshrc && source ~/.zshrc` |
|
||||
| Linux bash | `echo 'export HF_TOKEN=hf_xxx' >> ~/.bashrc && source ~/.bashrc` |
|
||||
| Windows PowerShell (user scope) | `[Environment]::SetEnvironmentVariable("HF_TOKEN","hf_xxx","User")` (new shells only) |
|
||||
| Windows cmd | `setx HF_TOKEN "hf_xxx"` (user scope, new shells only) |
|
||||
- [HF environment variables docs](https://huggingface.co/docs/huggingface_hub/en/package_reference/environment_variables) — HIGH confidence (official, current)
|
||||
- [HF authentication API docs](https://huggingface.co/docs/huggingface_hub/en/package_reference/authentication) — HIGH confidence
|
||||
- [Microsoft `setx` docs](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/setx) — HIGH confidence
|
||||
### Capability 2 — In-App Structured Bug Reporting (opt-in, GitHub Issues)
|
||||
| Technology | Version | Purpose | Why Recommended |
|
||||
|------------|---------|---------|-----------------|
|
||||
| GitHub REST API `POST /repos/{owner}/{repo}/issues` | `2026-03-10` API version | Server-side issue creation | Official, stable. Requires auth. |
|
||||
| **Prefilled-URL pattern** (`github.com/{owner}/{repo}/issues/new?title=…&body=…&labels=…`) | n/a | Zero-auth fallback | **This is the recommended primary path for v0.3.x.** No token needed, no GitHub App registration needed, user's browser opens with a prefilled form, they review and click Submit. They own the issue, the OSS project gets the report, and OmniVoice never holds a credential. |
|
||||
| `gh-app-jwt` + GitHub App (Rust crate `octocrab` or Python `pygithub`) | only if we later want fully-automated submission | Programmatic posting under an app identity | **Defer to a later milestone.** Requires registering a public GitHub App, hosting a token-exchange endpoint, and managing rate-limit quotas — disproportionate for stabilization scope. |
|
||||
| `platform`, `psutil`, `torch.cuda` (already in deps) | already pinned | Capture OS, CPU/GPU/VRAM info | No new deps. |
|
||||
| `httpx` (already in `dev-dependencies`, promote to runtime if needed) | `≥0.28.1` | HTTP for the API call path (if/when we add auth) | Modern async-first, already used in test suite. |
|
||||
- ✓ No token storage in OmniVoice → no security surface
|
||||
- ✓ Opt-in by definition (user has to click Submit on github.com)
|
||||
- ✓ User owns the issue → can be replied to, edited, closed by them
|
||||
- ✓ Zero infra cost — no proxy, no app, no rate-limit management
|
||||
- ✓ Works identically on macOS / Windows / Linux via Tauri's `shell.open`
|
||||
- ✓ Survives our project being forked (just change the URL)
|
||||
- OS name + version (`platform.platform()`)
|
||||
- Python version (`sys.version`)
|
||||
- OmniVoice version (`pyproject.toml`)
|
||||
- Backend git SHA (if installed from source) or installer build ID
|
||||
- CPU model, RAM (`psutil.cpu_count()`, `psutil.virtual_memory()`)
|
||||
- GPU vendor/model/VRAM (`torch.cuda.get_device_name()`, `torch.cuda.mem_get_info()`, MPS detect)
|
||||
- Active TTS engine + list of installed engines
|
||||
- Frontend: bun version, OS shell
|
||||
- Last error message + stack trace if launched from an error toast
|
||||
- Audio file contents (privacy — reference samples may contain user's voice)
|
||||
- File paths containing `/Users/<name>/` (strip home dir → `~/`)
|
||||
- HF token, OpenAI keys, any env var matching `*TOKEN*|*KEY*|*SECRET*`
|
||||
- [GitHub URL query parameters for issues](https://docs.github.com/en/issues/tracking-your-work-with-issues/using-issues/creating-an-issue#creating-an-issue-from-a-url-query) — HIGH confidence
|
||||
- [sindresorhus/new-github-issue-url](https://github.com/sindresorhus/new-github-issue-url) — HIGH (widely used reference impl)
|
||||
- [GitHub REST API: Create an issue](https://docs.github.com/en/rest/issues/issues#create-an-issue) — HIGH confidence (for the future auto-submit path)
|
||||
- [sentry-tauri](https://github.com/timfish/sentry-tauri) — reviewed, **rejected for milestone** due to local-first constraint
|
||||
### Capability 3 — `uv venv` Mirror Fallback for Restricted Networks (issues #57, #60)
|
||||
| Technology | Version | Purpose | Why Recommended |
|
||||
|------------|---------|---------|-----------------|
|
||||
| `uv` (already used) | `≥0.5.x` | Python+venv bootstrap | Existing dep. |
|
||||
| `UV_PYTHON_INSTALL_MIRROR` env var | uv `0.4.x`+ | Override python-build-standalone download URL | **Official, current.** Replaces `https://github.com/astral-sh/python-build-standalone/releases/download/...` in download URL construction. No built-in fallback if mirror fails. |
|
||||
| `UV_PYTHON_PREFERENCE=only-system` (or CLI flag `--python-preference only-system`) | uv `0.4.x`+ | Skip the python-build-standalone download entirely; use the user's system Python | **The reliable escape hatch** when no mirror works. Requires a compatible Python `>=3.11` to already be on PATH. |
|
||||
| `UV_HTTP_TIMEOUT`, `UV_HTTP_CONNECT_TIMEOUT`, `UV_HTTP_RETRIES` | uv `0.4.x`+ | Tune retry behavior for flaky links | Defaults are 30s / 10s / 3 — bump to 120s / 30s / 5 for restricted networks. |
|
||||
# Pseudocode for the bootstrap
|
||||
# Final fallback: don't download Python at all
|
||||
- `UV_DEFAULT_INDEX=https://pypi.tuna.tsinghua.edu.cn/simple` (Tsinghua — fastest in China)
|
||||
- `UV_DEFAULT_INDEX=https://mirrors.aliyun.com/pypi/simple` (Aliyun fallback)
|
||||
- Russia: no major government-blessed PyPI mirror; users typically tunnel via VPN. Document this honestly rather than ship a broken default.
|
||||
- [uv environment variables reference](https://docs.astral.sh/uv/reference/environment/) — HIGH (official)
|
||||
- [uv issue #5224 — python-build-standalone mirror support](https://github.com/astral-sh/uv/issues/5224) — HIGH (the feature was added)
|
||||
- [uv issue #14187 — venv on Chinese network](https://github.com/astral-sh/uv/issues/14187) — HIGH (confirms real user pain, no built-in fallback)
|
||||
- [uv python-versions concepts](https://github.com/astral-sh/uv/blob/main/docs/concepts/python-versions.md) — HIGH (documents `python-preference` semantics)
|
||||
- [dautovri/mirrors-china](https://github.com/dautovri/mirrors-china) — MEDIUM (community-maintained mirror list; verify each URL still works before shipping)
|
||||
### Capability 4 — Supertonic-3 TTS Engine
|
||||
| Technology | Version | Purpose | Why Recommended |
|
||||
|------------|---------|---------|-----------------|
|
||||
| `supertonic` (PyPI) | `1.3.1` (latest, May 18 2026 — Phase 3 Wave 1 to verify constructor signature before bump) | Official Supertonic-3 inference SDK | Authoritative wrapper from Supertone Inc. Wraps the ONNX session orchestration so we don't have to. |
|
||||
| `onnxruntime` | `≥1.17.x` (any recent) | ONNX inference runtime | Already a transitive dep of WhisperX (via CTranslate2 path is separate, but `onnxruntime` itself ships for kittentts and audioseal). Verify with `uv tree` after adding — should resolve cleanly. |
|
||||
| `huggingface_hub` (already pinned) | `≥1.12.x` | Model weight download (~400 MB on first use) | Reuses existing HF token + cache infrastructure. The user's existing `HF_TOKEN` (Capability 1) works for the Supertonic model download too. |
|
||||
| `numpy`, `soundfile` (already pinned) | already pinned | Audio I/O + array math | No new deps. |
|
||||
- `text_encoder.onnx`
|
||||
- `latent_denoiser.onnx`
|
||||
- `voice_decoder.onnx`
|
||||
- 44.1 kHz sample rate, 24-dim latent, 128-dim style
|
||||
- ~99M parameters total
|
||||
- Tokenizer: `AutoTokenizer.from_pretrained(model_path)` — loads from `tokenizer.json` shipped with model
|
||||
- [Supertone/supertonic-3 model card](https://huggingface.co/Supertone/supertonic-3) — HIGH (official)
|
||||
- [supertone-inc/supertonic GitHub](https://github.com/supertone-inc/supertonic) — HIGH (official)
|
||||
- [supertonic PyPI page](https://pypi.org/project/supertonic/) — HIGH (`1.3.1` confirmed 2026-05-18; same publisher, MIT, same 4 deps)
|
||||
- [onnx-community/Supertonic-TTS-ONNX](https://huggingface.co/onnx-community/Supertonic-TTS-ONNX) — HIGH (ONNX file structure details)
|
||||
### Capability 5 — Cross-Platform Documentation Tooling
|
||||
| Technology | Version | Purpose | Why Recommended |
|
||||
|------------|---------|---------|-----------------|
|
||||
| Plain Markdown in `docs/` + GitHub-rendered (current state) | n/a | Install tutorial, troubleshooting | Zero new infra. Renders inline on GitHub for issue-replies. No build step to break. |
|
||||
| Existing `scripts/smoke-test.sh` + Playwright `tests/` (already in `package.json`) | already pinned | Verify install paths actually work | **This is the real solution to "docs drift."** If smoke-test exercises the install path described in docs, docs that drift will break CI. |
|
||||
| **Future** (defer): Astro Starlight | `≥0.30` | Standalone docs site at `docs.omnivoice.studio` | Adopt only when docs exceed ~20 markdown files and need search/versioning. Tauri, the framework OmniVoice already depends on, uses Starlight — well-traveled choice. Material for MkDocs entered maintenance mode in November 2025 per Docsio's 2026 review — **avoid** for new docs. |
|
||||
| Project | What they do |
|
||||
|---------|--------------|
|
||||
| **OBS Studio** | Docs at `obsproject.com/docs` (Sphinx, separate repo). Install paths in README, wiki for community-contributed. CI doesn't gate on docs drift. |
|
||||
| **Audacity** | Manual at `manual.audacityteam.org` (MediaWiki). README is minimal. Install path = "use the installer." No automated sync. |
|
||||
| **Tauri** | Docs at `v2.tauri.app` (Astro Starlight, separate repo `tauri-apps/tauri-docs`). README is minimal. Heavy reliance on community contributions and PR review. |
|
||||
| **VS Code** | Docs at `code.visualstudio.com/docs` (separate repo, Markdown). README is minimal. Manual sync; docs team is staffed. |
|
||||
- [Tauri docs (Astro Starlight)](https://github.com/tauri-apps/tauri-docs) — HIGH (reference for "if we ever move off README")
|
||||
- [OBS Studio docs](https://docs.obsproject.com/) — HIGH (Sphinx, separate site reference)
|
||||
- [Audacity Manual](https://manual.audacityteam.org/) — HIGH (MediaWiki reference)
|
||||
- [Docsio: Material for MkDocs 2026 review (maintenance mode)](https://docsio.co/blog/mkdocs-material) — MEDIUM (third-party review, but signal aligns with project's own GitHub activity)
|
||||
- [Docsio: Starlight 2026 review](https://docsio.co/blog/starlight-docs) — MEDIUM
|
||||
## Installation
|
||||
# No new Python dependencies needed for Capabilities 1, 2, 3, 5.
|
||||
# Only Capability 4 adds a runtime dep:
|
||||
# Verify no regressions:
|
||||
# Should show single versions of each; no duplicates.
|
||||
## Alternatives Considered
|
||||
| Recommended | Alternative | When to Use Alternative |
|
||||
|-------------|-------------|-------------------------|
|
||||
| HF token via in-app Settings → `huggingface_hub.login()` | OS keyring via `keyring` package | Only if a security hardening milestone later demands OS-native credential storage. Not worth the cross-platform native-dep cost for v0.3.x. |
|
||||
| Prefilled-URL GitHub Issues | GitHub App + device flow + authenticated POST | When milestone budget can afford registering a public GitHub App and hosting a token-exchange function. Defer. |
|
||||
| Prefilled-URL GitHub Issues | Sentry / `sentry-tauri` | Never — violates the "no third-party telemetry endpoint" constraint in PROJECT.md. |
|
||||
| `UV_PYTHON_INSTALL_MIRROR` chain + `only-system` fallback | Bundle Python in the Tauri installer | Adds ~30 MB to every installer for ~5% of users. Revisit if the bootstrap is still a top complaint in v0.4. |
|
||||
| In-repo Markdown docs | Astro Starlight standalone site | When docs grow past ~20 pages and need full-text search. Tauri provides a precedent if/when we get there. |
|
||||
| In-repo Markdown docs | MkDocs / Material for MkDocs | **Avoid** for new sites — Material for MkDocs is in maintenance mode as of Nov 2025. |
|
||||
## What NOT to Use
|
||||
| Avoid | Why | Use Instead |
|
||||
|-------|-----|-------------|
|
||||
| `HfFolder.save_token()` directly | Older API; v1.x `login()` does the same plus git-credential integration and is the documented path | `huggingface_hub.login(token=val, add_to_git_credential=False)` |
|
||||
| Setting `HF_TOKEN` via shell rc files as the *only* persistence mechanism | Different per OS, fragile, opaque to the user, breaks in installer-launched processes that don't source shell rc | Write to `$HF_HOME/token` via `login()`. Document env var as override only. |
|
||||
| `setx` for HF token persistence | Doesn't propagate to current shell; common source of "I set it but it's empty" bug reports | `[Environment]::SetEnvironmentVariable(...,"User")` in PowerShell, or the in-app Settings field |
|
||||
| PAT-based GitHub Issues posting from OmniVoice | Would require shipping or asking for a token; breaks local-first promise | Prefilled-URL pattern (user submits from their browser) |
|
||||
| `sentry-tauri` for OmniVoice | Third-party telemetry endpoint — violates PROJECT.md constraint | Local-only `backend.log` rotation + opt-in prefilled-URL reporter |
|
||||
| `hf_transfer` for downloads | Deprecated in favor of `hf-xet` per HF docs | Default `huggingface_hub` (uses `hf-xet` automatically when available) |
|
||||
| `--python-preference managed` (default) without mirror config in restricted-network installers | Hits GitHub CDN, times out, user sees raw `uv` error | Configure `UV_PYTHON_INSTALL_MIRROR` + retry chain + `only-system` final fallback |
|
||||
| Material for MkDocs as a *new* docs choice | Entered maintenance mode November 2025 | If docs site is eventually needed, use Astro Starlight (Tauri precedent) |
|
||||
## Stack Patterns by Variant
|
||||
- Set `UV_PYTHON_INSTALL_MIRROR` to one of the gh-proxy URLs at install time
|
||||
- Set `UV_DEFAULT_INDEX=https://pypi.tuna.tsinghua.edu.cn/simple` (China) or document VPN requirement (Russia)
|
||||
- Fall back to `UV_PYTHON_PREFERENCE=only-system` if all mirrors fail
|
||||
- Increase `UV_HTTP_TIMEOUT=120`, `UV_HTTP_RETRIES=5`
|
||||
- Default path: in-app Settings field → `login()` → file at `$HF_HOME/token`
|
||||
- Power-user path: `export HF_TOKEN=...` in shell rc (documented but not promoted)
|
||||
- Both paths are read at HF library import time; env var wins on conflict
|
||||
- Default path: in-app "Report a bug" → prefilled GitHub Issues URL → user reviews + submits in browser
|
||||
- All optional capture toggles default ON except "include reproduction file" (privacy)
|
||||
- No path posts to any URL except `github.com/{owner}/{repo}/issues/new` (rendered locally as a URL, opened via `shell.open`)
|
||||
- `uv add supertonic` → new TTSBackend subclass in `backend/services/tts_backend.py`
|
||||
- Auto-detected and added to the engine picker in Settings
|
||||
- ~400 MB model download on first synthesize call, cached in `$HF_HUB_CACHE`
|
||||
- Existing IndexTTS/CosyVoice/etc. installs are untouched (no shared model weights)
|
||||
## Version Compatibility
|
||||
| Package A | Compatible With | Notes |
|
||||
|-----------|-----------------|-------|
|
||||
| `supertonic@1.3.1` | `onnxruntime>=1.17`, `numpy>=1.24`, `huggingface_hub>=0.20` | All deps already satisfied transitively by current `pyproject.toml`. |
|
||||
| `huggingface_hub>=1.12` | `transformers>=5.3.0` (current pin) | `HfFolder` retained as deprecated alias; `login()`/`get_token()` are the canonical APIs. |
|
||||
| `uv>=0.5` | `UV_PYTHON_INSTALL_MIRROR`, `UV_PYTHON_PREFERENCE` | Both env vars stable since uv 0.4.x. |
|
||||
| Tauri v2 + `@tauri-apps/api/shell` | `shell.open()` for the prefilled-URL pattern | Already in the desktop app; no new permission needed beyond what the existing "open external link" plugin grants. |
|
||||
## Sources
|
||||
- [Hugging Face Hub environment variables](https://huggingface.co/docs/huggingface_hub/en/package_reference/environment_variables) — HIGH (verified against v1.12.1 docs, current 2026)
|
||||
- [Hugging Face Hub authentication API](https://huggingface.co/docs/huggingface_hub/en/package_reference/authentication) — HIGH (verified `login()` is the canonical 1.x API)
|
||||
- [Microsoft `setx` reference](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/setx) — HIGH (confirms "current shell" gotcha)
|
||||
- [PowerShell `about_Environment_Variables`](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_environment_variables) — HIGH
|
||||
- [uv environment variables reference](https://docs.astral.sh/uv/reference/environment/) — HIGH (verified all mirror + retry env vars)
|
||||
- [uv issue #5224 — python-build-standalone mirror](https://github.com/astral-sh/uv/issues/5224) — HIGH (feature shipped)
|
||||
- [uv issue #14187 — venv on Chinese network](https://github.com/astral-sh/uv/issues/14187) — HIGH (confirms user pain, justifies fallback chain)
|
||||
- [uv `python-preference` semantics](https://github.com/astral-sh/uv/blob/main/docs/concepts/python-versions.md) — HIGH
|
||||
- [Supertone/supertonic-3 model card](https://huggingface.co/Supertone/supertonic-3) — HIGH (official, 99M params, 31 languages, OpenRAIL-M)
|
||||
- [supertone-inc/supertonic GitHub](https://github.com/supertone-inc/supertonic) — HIGH (official inference API)
|
||||
- [supertonic 1.3.1 on PyPI](https://pypi.org/project/supertonic/) — HIGH (released 2026-05-18, MIT code license; bumped from 1.2.3 after Phase 3 research)
|
||||
- [onnx-community/Supertonic-TTS-ONNX](https://huggingface.co/onnx-community/Supertonic-TTS-ONNX) — HIGH (ONNX file structure)
|
||||
- [GitHub Docs: Authenticating to the REST API](https://docs.github.com/en/rest/authentication/authenticating-to-the-rest-api) — HIGH
|
||||
- [GitHub Docs: Generating a user access token for a GitHub App](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-user-access-token-for-a-github-app) — HIGH (device flow reference)
|
||||
- [sindresorhus/new-github-issue-url](https://github.com/sindresorhus/new-github-issue-url) — HIGH (canonical prefilled-URL reference impl)
|
||||
- [sentry-tauri](https://github.com/timfish/sentry-tauri) — MEDIUM (reviewed, rejected on PROJECT.md constraint, not on quality)
|
||||
- [dautovri/mirrors-china](https://github.com/dautovri/mirrors-china) — MEDIUM (community-maintained, verify URLs are still live before pinning in production)
|
||||
- [Tauri 2 docs (Astro Starlight reference)](https://v2.tauri.app/) — HIGH (precedent for docs framework if we ever migrate)
|
||||
- [Docsio: Material for MkDocs entered maintenance mode Nov 2025](https://docsio.co/blog/mkdocs-material) — MEDIUM (third-party review, but signal aligns with the project's own GitHub commit activity)
|
||||
The May-2026 stack research that used to live here served five capabilities that have all since shipped (HF-token Settings panel, prefilled-URL bug reporting, uv mirror fallback for restricted networks, the Supertonic-3 engine, in-repo Markdown docs). Follow the patterns in the code itself; the durable *don'ts* that research established:
|
||||
|
||||
- **No third-party telemetry endpoints, ever** (`sentry-tauri` was evaluated and rejected) — bug reporting stays opt-in via prefilled GitHub-issue URLs.
|
||||
- **No PAT/token-based GitHub posting from the app** — the user submits from their own browser.
|
||||
- **Don't recommend `setx` for env vars on Windows** (silent truncation, no current-shell propagation) — use the in-app Settings panel or PowerShell `[Environment]::SetEnvironmentVariable`.
|
||||
- **Don't adopt Material for MkDocs** for any future docs site (maintenance mode since Nov 2025) — Astro Starlight is the precedent if docs ever outgrow the repo.
|
||||
- **`hf_transfer` is deprecated** — default `huggingface_hub` (hf-xet) handles downloads.
|
||||
|
||||
For anything new: prefer what's already pinned in `pyproject.toml` / `frontend/package.json`, and check `uv tree` for conflicts before adding a dependency.
|
||||
<!-- GSD:stack-end -->
|
||||
|
||||
<!-- GSD:conventions-start source:CONVENTIONS.md -->
|
||||
@@ -222,16 +68,9 @@ No project skills found. Add skills to any of: `.claude/skills/`, `.agents/skill
|
||||
<!-- GSD:skills-end -->
|
||||
|
||||
<!-- GSD:workflow-start source:GSD defaults -->
|
||||
## GSD Workflow Enforcement
|
||||
## Workflow
|
||||
|
||||
Before using Edit, Write, or other file-changing tools, start work through a GSD command so planning artifacts and execution context stay in sync.
|
||||
|
||||
Use these entry points:
|
||||
- `/gsd-quick` for small fixes, doc updates, and ad-hoc tasks
|
||||
- `/gsd-debug` for investigation and bug fixing
|
||||
- `/gsd-execute-phase` for planned phase work
|
||||
|
||||
Do not make direct repo edits outside a GSD workflow unless the user explicitly asks to bypass it.
|
||||
Direct repo edits are authorized (owner decision, 2026-07-08). The GSD command gate that used to live here referenced `/gsd-quick` / `/gsd-debug` / `/gsd-execute-phase` skills that are not installed in this environment; the owner chose to keep working directly rather than restore them. The working conventions that matter are in **Conventions** above — versioning, docs-sync, changelog, localization, fix quality, keep-main-green — plus: gate every merge on the "Tests (backend + frontend)" check passing and the PR being MERGEABLE, and check the open-PR queue before implementing any community-reported fix (contributors may have already submitted one).
|
||||
<!-- GSD:workflow-end -->
|
||||
|
||||
|
||||
|
||||
@@ -322,7 +322,7 @@ Professional-grade voice AI, minus the subscription and the cloud.
|
||||
|
||||
### 🎧 ASR Engines
|
||||
|
||||
**10 engines** — they power dictation, video dubbing, and subtitles. **WhisperX** is the cross-platform default (~100 languages, word-level timing); the rest are opt-in and auto-detected. Switch in **Settings → ASR Engine** or via the `OMNIVOICE_ASR_BACKEND` env var. Nine run fully on-device; one (OpenAI-compatible) is an optional remote client for pointing at Qwen3-ASR or another compatible server — see below.
|
||||
**10 engines** — they power dictation, video dubbing, and subtitles. **WhisperX** is the cross-platform default (~100 languages, word-level timing); the rest are opt-in and auto-detected. Switch in **Settings → Engines** (the ASR Engines table — same picker TTS has), or pin one with the `OMNIVOICE_ASR_BACKEND` env var (the env var wins over the Settings pick). Nine run fully on-device; one (OpenAI-compatible) is an optional remote client for pointing at Qwen3-ASR or another compatible server — see below.
|
||||
|
||||
<details>
|
||||
<summary><b>📊 The full lineup</b> — 10 engines, what each is best at, and compute-type notes</summary>
|
||||
|
||||
@@ -24,7 +24,7 @@ from pathlib import Path
|
||||
# tests/test_app_version.py::test_all_version_files_in_lockstep and bumped by
|
||||
# release.yml's version-bump job, so it stays equal to
|
||||
# pyproject/tauri.conf/Cargo/package.json.
|
||||
_FALLBACK_VERSION = "0.3.13"
|
||||
_FALLBACK_VERSION = "0.3.14"
|
||||
|
||||
|
||||
def _fallback_version() -> str:
|
||||
|
||||
@@ -21,9 +21,11 @@ No install step — configure it directly:
|
||||
4. **API key** is optional — many self-hosted servers accept requests
|
||||
without one. Set it if your server requires auth, or if you're using
|
||||
OpenAI's own API.
|
||||
5. Activate the engine by setting `OMNIVOICE_ASR_BACKEND=openai-compat-asr`
|
||||
before launching. There's no in-app ASR engine picker yet (only TTS
|
||||
engines have one today) — this is the one manual step until that ships.
|
||||
5. Activate the engine in **Settings → Engines** — click **Use** on
|
||||
**OpenAI-compatible ASR** in the ASR Engines table (the same picker TTS
|
||||
engines have). Power users can pin it instead by setting
|
||||
`OMNIVOICE_ASR_BACKEND=openai-compat-asr` before launching — the env var
|
||||
always wins over the Settings pick.
|
||||
|
||||
## Response format
|
||||
|
||||
|
||||
@@ -77,6 +77,28 @@ Download the latest MSI from the
|
||||
run it, follow the wizard. The shortcut lands in the Start menu as
|
||||
**OmniVoice Studio**.
|
||||
|
||||
### Installing to a different drive
|
||||
|
||||
<a id="install-other-drive"></a>
|
||||
|
||||
The wizard's **directory picker** lets you install the app to any **local**
|
||||
drive (D:, E:, …). Two caveats:
|
||||
|
||||
- **Mapped network drives (Z: → a share) are not supported** — this is a
|
||||
Windows Installer limitation, not an OmniVoice bug: MSI custom actions run
|
||||
as a service account that doesn't see per-user drive mappings, so the
|
||||
install fails or rolls back. Install to a local drive instead.
|
||||
- The install location only moves the ~200 MB app itself. The big data
|
||||
(models, voices, projects — tens of GB) lives in the **data directory**,
|
||||
which you move independently: **Settings → Storage → Models directory**
|
||||
in-app, or `OMNIVOICE_DATA_DIR` / [Portable mode](#portable-install) for
|
||||
the whole data tree.
|
||||
|
||||
If an install to a local non-C: drive fails anyway, capture a log with
|
||||
`msiexec /i OmniVoice*.msi /L*V install.log` and
|
||||
[open an issue](https://github.com/debpalash/OmniVoice-Studio/issues) with it
|
||||
— that log shows exactly which step rolled back.
|
||||
|
||||
## Portable install (Windows)
|
||||
|
||||
<a id="portable-install"></a>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "omnivoice-studio",
|
||||
"version": "0.3.13",
|
||||
"version": "0.3.14",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-only",
|
||||
"type": "module",
|
||||
|
||||
Generated
+1
-1
@@ -2941,7 +2941,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "omnivoice-studio"
|
||||
version = "0.3.13"
|
||||
version = "0.3.14"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"dirs-next",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "omnivoice-studio"
|
||||
version = "0.3.13"
|
||||
version = "0.3.14"
|
||||
description = "OmniVoice Studio – AI voice cloning & dubbing desktop app"
|
||||
authors = ["Debpalash"]
|
||||
license = "AGPL-3.0-only"
|
||||
|
||||
@@ -22,9 +22,27 @@ HERE="$(dirname -- "$(readlink -f -- "$0")")"
|
||||
# Sourced by AppRun.test.sh — keep this function pure so unit tests can stub
|
||||
# `pkg-config`, source the file, call _detect_webkit_workaround, and inspect
|
||||
# the resulting environment without exec'ing the binary.
|
||||
#
|
||||
# Version source (#961 follow-up): the WebKitGTK that actually RUNS is the
|
||||
# BUNDLED copy (LD_LIBRARY_PATH below puts $HERE/usr/lib first) — NOT the
|
||||
# host's. Asking the host's pkg-config therefore reads the wrong number
|
||||
# whenever host and bundle diverge (e.g. a user who builds from source has
|
||||
# dev packages installed, so pkg-config answers with their system's healthy
|
||||
# 2.48 while the bundle runs an older lib — skipping a workaround the running
|
||||
# library needs). inject-apprun.sh stamps the bundled version into
|
||||
# .bundled-webkitgtk-version at build time, where it is knowable by
|
||||
# construction; the host pkg-config path survives only as a fallback for
|
||||
# bundles predating the stamp. OMNIVOICE_APPRUN_WK_MARKER exists for the
|
||||
# unit tests to point at a fixture marker.
|
||||
_detect_webkit_workaround() {
|
||||
local wk_version="0.0"
|
||||
if command -v pkg-config >/dev/null 2>&1; then
|
||||
local marker="${OMNIVOICE_APPRUN_WK_MARKER:-$HERE/.bundled-webkitgtk-version}"
|
||||
if [ -r "$marker" ]; then
|
||||
# Empty/unreadable marker content → "0.0" (unknown) → fail-safe workaround,
|
||||
# same philosophy as the missing-pkg-config branch below.
|
||||
wk_version="$(cat "$marker" 2>/dev/null | tr -d '[:space:]')"
|
||||
[ -n "$wk_version" ] || wk_version="0.0"
|
||||
elif command -v pkg-config >/dev/null 2>&1; then
|
||||
wk_version="$(pkg-config --modversion webkit2gtk-4.1 2>/dev/null \
|
||||
|| pkg-config --modversion webkit2gtk-4.0 2>/dev/null \
|
||||
|| echo "0.0")"
|
||||
|
||||
@@ -72,6 +72,56 @@ run_case "2.46 (broken)" "2.46.1" "1"
|
||||
run_case "2.48 (healthy)" "2.48.0" "unset"
|
||||
run_case "pkg-config absent" "0.0" "1" "no"
|
||||
|
||||
# ── Bundled-version marker cases (#961 follow-up) ───────────────────────────
|
||||
# inject-apprun.sh stamps the bundle's actual WebKitGTK version into
|
||||
# .bundled-webkitgtk-version at build time; AppRun must prefer that marker
|
||||
# over the host's pkg-config (which reports the SYSTEM version — wrong
|
||||
# whenever it diverges from the bundled copy, e.g. on a machine with newer
|
||||
# dev packages installed).
|
||||
|
||||
run_marker_case() {
|
||||
local label="$1" marker_content="$2" pkg_output="$3" expected="$4"
|
||||
local marker_file
|
||||
marker_file="$(mktemp)"
|
||||
printf '%s\n' "$marker_content" > "$marker_file"
|
||||
|
||||
local actual
|
||||
actual=$(
|
||||
bash -c '
|
||||
set +e
|
||||
pkg_output="'"$pkg_output"'"
|
||||
export OMNIVOICE_APPRUN_WK_MARKER="'"$marker_file"'"
|
||||
|
||||
pkg-config() { echo "$pkg_output"; }
|
||||
export -f pkg-config
|
||||
|
||||
exec() { :; }
|
||||
export -f exec
|
||||
|
||||
# shellcheck disable=SC1090
|
||||
source "'"$THIS_DIR"'/AppRun" >/dev/null 2>&1 || true
|
||||
echo "${WEBKIT_DISABLE_COMPOSITING_MODE:-unset}"
|
||||
'
|
||||
)
|
||||
rm -f "$marker_file"
|
||||
|
||||
if [[ "$actual" == "$expected" ]]; then
|
||||
echo "PASS [$label]"
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
else
|
||||
echo "FAIL [$label]: expected '$expected' got '$actual'" >&2
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
# Marker says broken → workaround applies, even though host pkg-config says healthy.
|
||||
run_marker_case "marker 2.46 beats host 2.48" "2.46.1" "2.48.0" "1"
|
||||
# Marker says healthy → no workaround, even though host pkg-config says broken
|
||||
# (the exact #961 inversion: from-source user with old system lib, new bundle).
|
||||
run_marker_case "marker 2.48 beats host 2.44" "2.48.0" "2.44.3" "unset"
|
||||
# Empty marker → treated as unknown → fail-safe workaround.
|
||||
run_marker_case "empty marker fails safe" "" "2.48.0" "1"
|
||||
|
||||
echo
|
||||
echo "─── AppRun test summary: $PASS_COUNT pass / $FAIL_COUNT fail ───"
|
||||
if [[ $FAIL_COUNT -ne 0 ]]; then
|
||||
|
||||
@@ -70,6 +70,11 @@ function reasonMentionsLicense(reason) {
|
||||
* arg is set only by mlx-audio's curated-model picker (#981).
|
||||
* - activeId?: string the currently-active backend id for this
|
||||
* family. Used to render the "active" badge.
|
||||
* - showFamilyTabs?: boolean default true. When false, the matrix is
|
||||
* pinned to `family` — no TTS/ASR/LLM switcher, and the header names
|
||||
* the family ("ASR Engines") instead of the generic matrix title.
|
||||
* Settings → Engines stacks one pinned matrix per family so the ASR
|
||||
* and LLM pickers are visible instead of tucked behind a tab.
|
||||
*/
|
||||
const FAMILY_META = {
|
||||
tts: { label: 'TTS', icon: Cpu },
|
||||
@@ -158,8 +163,10 @@ export default function EngineCompatibilityMatrix({
|
||||
family = 'tts',
|
||||
onSelect = null,
|
||||
activeId = null,
|
||||
// Test-friendly overrides — let the RTL suite mock the API layer
|
||||
// without resorting to module-level vi.mock incantations.
|
||||
showFamilyTabs = true,
|
||||
// Injectable API layer — lets the RTL suite mock it without module-level
|
||||
// vi.mock incantations, and lets EnginesTab share one in-flight
|
||||
// GET /engines across its stacked per-family matrices.
|
||||
apiListEngines = listEngines,
|
||||
apiGetEngineHealth = getEngineHealth,
|
||||
apiSelfTestEngine = selfTestEngine,
|
||||
@@ -347,12 +354,19 @@ export default function EngineCompatibilityMatrix({
|
||||
// TTS-05: the license dialog registered for the engine awaiting acceptance
|
||||
// (or null). Capitalized so JSX renders it as a component below.
|
||||
const LicenseDialog = licenseDialogFor ? LICENSE_DIALOGS[licenseDialogFor] : null;
|
||||
// Pinned mode: the header names the family (with its icon) since there is
|
||||
// no switcher to say which family this table is.
|
||||
const familyMeta = FAMILY_META[activeFamily] || FAMILY_META.tts;
|
||||
const TitleIcon = showFamilyTabs ? Layers : familyMeta.icon;
|
||||
|
||||
return (
|
||||
<section className="engine-matrix flex flex-col gap-[var(--space-3,8px)]">
|
||||
<header className="engine-matrix__head flex items-center justify-between gap-[12px]">
|
||||
<h3 className="engine-matrix__title inline-flex items-center gap-[6px] m-0 text-[13px] font-semibold text-[color:var(--chrome-fg,currentColor)]">
|
||||
<Layers size={14} /> {t('engines.matrixTitle')}
|
||||
<TitleIcon size={14} />{' '}
|
||||
{showFamilyTabs
|
||||
? t('engines.matrixTitle')
|
||||
: t('engines.familyMatrixTitle', { family: familyMeta.label })}
|
||||
</h3>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -365,7 +379,7 @@ export default function EngineCompatibilityMatrix({
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
{families.length > 1 && (
|
||||
{showFamilyTabs && families.length > 1 && (
|
||||
<Segmented
|
||||
size="sm"
|
||||
value={activeFamily}
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import React, { useCallback, useRef } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { addBreadcrumb } from '../../utils/breadcrumbs';
|
||||
import { selectEngine } from '../../api/engines';
|
||||
import { listEngines, selectEngine } from '../../api/engines';
|
||||
import { notifyEngineSelected } from '../../utils/engineSelectToast';
|
||||
import EngineCompatibilityMatrix from '../EngineCompatibilityMatrix';
|
||||
import { SETTINGS_SECTION_SURFACE } from './primitives';
|
||||
|
||||
/** One pinned matrix per family, stacked in this order. ASR used to be
|
||||
* reachable only through the matrix's family tabs, which read as a
|
||||
* TTS-only table — README even promised a Settings ASR picker that
|
||||
* didn't exist (UX gap found during #877). Every family now gets a
|
||||
* visible picker; `OMNIVOICE_*_BACKEND` env vars still win over any pick. */
|
||||
const FAMILIES = ['tts', 'asr', 'llm'];
|
||||
|
||||
export default function EnginesTab() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -33,9 +40,32 @@ export default function EnginesTab() {
|
||||
[t],
|
||||
);
|
||||
|
||||
// The stacked matrices all consume the same GET /engines payload — share
|
||||
// one in-flight request so opening the tab probes every engine once, not
|
||||
// once per family. A per-matrix Refresh after the shared promise settles
|
||||
// still triggers a fresh fetch.
|
||||
const inflightList = useRef(null);
|
||||
const listEnginesShared = useCallback(() => {
|
||||
if (!inflightList.current) {
|
||||
inflightList.current = listEngines().finally(() => {
|
||||
inflightList.current = null;
|
||||
});
|
||||
}
|
||||
return inflightList.current;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section className={SETTINGS_SECTION_SURFACE} data-slot="settings-section">
|
||||
<EngineCompatibilityMatrix family="tts" onSelect={onSelect} />
|
||||
</section>
|
||||
<>
|
||||
{FAMILIES.map((family) => (
|
||||
<section key={family} className={SETTINGS_SECTION_SURFACE} data-slot="settings-section">
|
||||
<EngineCompatibilityMatrix
|
||||
family={family}
|
||||
showFamilyTabs={false}
|
||||
onSelect={onSelect}
|
||||
apiListEngines={listEnginesShared}
|
||||
/>
|
||||
</section>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
// Keep toast side-channels out of the test (timers, portals).
|
||||
vi.mock('react-hot-toast', () => ({
|
||||
default: { error: vi.fn(), success: vi.fn() },
|
||||
toast: Object.assign(vi.fn(), { error: vi.fn(), success: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock('../../api/engines', () => ({
|
||||
listEngines: vi.fn(),
|
||||
selectEngine: vi.fn(),
|
||||
getEngineHealth: vi.fn(),
|
||||
selfTestEngine: vi.fn(),
|
||||
}));
|
||||
|
||||
import { listEngines, selectEngine } from '../../api/engines';
|
||||
import EnginesTab from './EnginesTab';
|
||||
|
||||
function entry(id, name) {
|
||||
return {
|
||||
id,
|
||||
display_name: name,
|
||||
available: true,
|
||||
reason: null,
|
||||
install_hint: null,
|
||||
last_error: null,
|
||||
isolation_mode: 'in-process',
|
||||
gpu_compat: ['cpu'],
|
||||
};
|
||||
}
|
||||
|
||||
const ENGINES = {
|
||||
tts: { active: 'omnivoice', backends: [entry('omnivoice', 'OmniVoice (test)')] },
|
||||
asr: {
|
||||
active: 'whisperx',
|
||||
backends: [
|
||||
entry('whisperx', 'WhisperX (test)'),
|
||||
entry('openai-compat-asr', 'OpenAI-compatible ASR (test)'),
|
||||
],
|
||||
},
|
||||
llm: { active: 'off', backends: [entry('off', 'Off (test)')] },
|
||||
};
|
||||
|
||||
describe('EnginesTab', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
listEngines.mockResolvedValue(ENGINES);
|
||||
});
|
||||
|
||||
it('renders a pinned picker per family — TTS, ASR and LLM all visible at once', async () => {
|
||||
render(<EnginesTab />);
|
||||
await waitFor(() => screen.getByText('WhisperX (test)'));
|
||||
|
||||
// One named section per family (the ASR picker used to be tucked behind
|
||||
// a family tab inside a single TTS-titled matrix — no picker to find).
|
||||
expect(screen.getByText('TTS Engines')).toBeInTheDocument();
|
||||
expect(screen.getByText('ASR Engines')).toBeInTheDocument();
|
||||
expect(screen.getByText('LLM Engines')).toBeInTheDocument();
|
||||
// Pinned matrices render no family switcher.
|
||||
expect(document.querySelector('.engine-matrix__tab-family')).toBeNull();
|
||||
});
|
||||
|
||||
it('the stacked matrices share one GET /engines on mount', async () => {
|
||||
render(<EnginesTab />);
|
||||
await waitFor(() => screen.getByText('WhisperX (test)'));
|
||||
expect(listEngines).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('clicking Use on an ASR engine selects it with family="asr"', async () => {
|
||||
selectEngine.mockResolvedValue({
|
||||
family: 'asr',
|
||||
active: 'openai-compat-asr',
|
||||
env_override: false,
|
||||
routing_status: 'cpu_only',
|
||||
effective_device: 'cpu',
|
||||
routing_reason: null,
|
||||
});
|
||||
render(<EnginesTab />);
|
||||
await waitFor(() => screen.getByText('OpenAI-compatible ASR (test)'));
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /use openai-compatible asr \(test\)/i }));
|
||||
await waitFor(() => {
|
||||
expect(selectEngine).toHaveBeenCalledWith('asr', 'openai-compat-asr', undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1554,6 +1554,7 @@
|
||||
"loading": "Loading engines…",
|
||||
"refresh": "Refresh",
|
||||
"matrixTitle": "Engine Compatibility Matrix",
|
||||
"familyMatrixTitle": "{{family}} Engines",
|
||||
"loadFailed": "Failed to load engines: {{message}}",
|
||||
"couldNotLoad": "Could not load engines: {{message}}",
|
||||
"retry": "Retry",
|
||||
|
||||
@@ -817,4 +817,74 @@ describe('EngineCompatibilityMatrix', () => {
|
||||
await waitFor(() => screen.getByText('MLX-Audio (test)'));
|
||||
expect(screen.getByTestId('curated-model-select-mlx-audio')).toBeDisabled();
|
||||
});
|
||||
|
||||
// ── showFamilyTabs={false} — pinned per-family mount (Settings → Engines) ─
|
||||
function multiFamilyResponse() {
|
||||
return {
|
||||
tts: {
|
||||
active: 'omnivoice',
|
||||
backends: [
|
||||
{
|
||||
id: 'omnivoice',
|
||||
display_name: 'OmniVoice (test)',
|
||||
available: true,
|
||||
reason: null,
|
||||
install_hint: null,
|
||||
last_error: null,
|
||||
isolation_mode: 'in-process',
|
||||
gpu_compat: ['cpu'],
|
||||
},
|
||||
],
|
||||
},
|
||||
asr: {
|
||||
active: 'whisperx',
|
||||
backends: [
|
||||
{
|
||||
id: 'whisperx',
|
||||
display_name: 'WhisperX (test)',
|
||||
available: true,
|
||||
reason: null,
|
||||
install_hint: null,
|
||||
last_error: null,
|
||||
isolation_mode: 'in-process',
|
||||
gpu_compat: ['cpu'],
|
||||
},
|
||||
],
|
||||
},
|
||||
llm: { active: 'off', backends: [] },
|
||||
};
|
||||
}
|
||||
|
||||
it('pins to the given family and hides the TTS/ASR/LLM switcher when showFamilyTabs is false', async () => {
|
||||
const apiListEngines = vi.fn().mockResolvedValue(multiFamilyResponse());
|
||||
render(
|
||||
<EngineCompatibilityMatrix
|
||||
family="asr"
|
||||
showFamilyTabs={false}
|
||||
apiListEngines={apiListEngines}
|
||||
apiGetEngineHealth={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => screen.getByText('WhisperX (test)'));
|
||||
// Pinned header names the family instead of the generic matrix title…
|
||||
expect(screen.getByText('ASR Engines')).toBeInTheDocument();
|
||||
// …the TTS family never leaks into the pinned table…
|
||||
expect(screen.queryByText('OmniVoice (test)')).not.toBeInTheDocument();
|
||||
// …and there is no family switcher to wander off to.
|
||||
expect(document.querySelector('.engine-matrix__tab-family')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the family switcher by default (standalone mounts unchanged)', async () => {
|
||||
const apiListEngines = vi.fn().mockResolvedValue(multiFamilyResponse());
|
||||
render(
|
||||
<EngineCompatibilityMatrix
|
||||
family="tts"
|
||||
apiListEngines={apiListEngines}
|
||||
apiGetEngineHealth={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => screen.getByText('OmniVoice (test)'));
|
||||
expect(screen.getByText('Engine Compatibility Matrix')).toBeInTheDocument();
|
||||
expect(document.querySelectorAll('.engine-matrix__tab-family').length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "omnivoice"
|
||||
version = "0.3.13"
|
||||
version = "0.3.14"
|
||||
description = "OmniVoice: Towards Omnilingual Zero-Shot Text-to-Speech with Diffusion Language Models"
|
||||
readme = "README.md"
|
||||
# Free and open-source under the GNU Affero General Public License v3 (see
|
||||
|
||||
@@ -42,6 +42,23 @@ for stage_base in "$STAGE_BASE_RELEASE" "$STAGE_BASE_DEBUG"; do
|
||||
echo "inject-apprun: replacing AppRun in $appdir"
|
||||
cp -f "$APPRUN_SRC" "$appdir/AppRun"
|
||||
chmod 755 "$appdir/AppRun"
|
||||
# Stamp the bundled WebKitGTK version (#961 follow-up). The AppImage
|
||||
# bundles THIS build host's libwebkit2gtk, so the host's pkg-config
|
||||
# answer here is the version the shipped bundle will actually run —
|
||||
# knowable by construction at bundle time, unknowable reliably at
|
||||
# runtime (a user's pkg-config reports their SYSTEM's version, which
|
||||
# LD_LIBRARY_PATH overrides with the bundled copy). AppRun's workaround
|
||||
# auto-detection reads this marker first and only falls back to host
|
||||
# pkg-config when the marker is absent (bundles predating the stamp).
|
||||
wk_bundled="$(pkg-config --modversion webkit2gtk-4.1 2>/dev/null \
|
||||
|| pkg-config --modversion webkit2gtk-4.0 2>/dev/null \
|
||||
|| echo "")"
|
||||
if [ -n "$wk_bundled" ]; then
|
||||
printf '%s\n' "$wk_bundled" > "$appdir/.bundled-webkitgtk-version"
|
||||
echo "inject-apprun: stamped bundled WebKitGTK version: $wk_bundled"
|
||||
else
|
||||
echo "inject-apprun: WARNING — could not read the bundled WebKitGTK version (pkg-config missing?); AppRun will use its runtime fallback" >&2
|
||||
fi
|
||||
found=1
|
||||
fi
|
||||
done
|
||||
|
||||
@@ -247,6 +247,121 @@ def test_select_llm_never_routing_gated(fresh_app, monkeypatch):
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
# ── ASR selection via /engines/select (Settings → Engines ASR picker) ──────
|
||||
#
|
||||
# The ASR family was always wired in _FAMILIES on paper, but no UI called it
|
||||
# and nothing exercised it — the Settings picker now does. Lock the contract:
|
||||
# a pick persists to prefs["asr_backend"], `OMNIVOICE_ASR_BACKEND` still wins
|
||||
# over the pick, and unknown / not-ready ids are 400s.
|
||||
|
||||
|
||||
def _register_fake_asr(asr_mod, engine_id, *, available=True):
|
||||
"""Register a light in-process ASR stub (CPU-only so a forced-CPU host
|
||||
routes it `cpu_only`, never `unavailable`). Returns (cls, restore_fn)."""
|
||||
_avail = available
|
||||
|
||||
class _FakeASR(asr_mod.ASRBackend):
|
||||
id = engine_id
|
||||
display_name = f"Fake {engine_id}"
|
||||
gpu_compat = ("cpu",)
|
||||
|
||||
@classmethod
|
||||
def is_available(cls):
|
||||
return (True, "ready") if _avail else (False, "deps missing (test)")
|
||||
|
||||
def transcribe(self, audio_path, *, word_timestamps=True):
|
||||
raise NotImplementedError
|
||||
|
||||
saved = dict(asr_mod._REGISTRY)
|
||||
asr_mod._REGISTRY[engine_id] = _FakeASR
|
||||
|
||||
def restore():
|
||||
asr_mod._REGISTRY.clear()
|
||||
asr_mod._REGISTRY.update(saved)
|
||||
|
||||
return _FakeASR, restore
|
||||
|
||||
|
||||
def test_select_asr_persists_pref_and_echoes_active(fresh_app, monkeypatch):
|
||||
from core import prefs as _prefs
|
||||
from services import asr_backend as asr_mod
|
||||
|
||||
_force_cpu_host(monkeypatch)
|
||||
monkeypatch.delenv("OMNIVOICE_ASR_BACKEND", raising=False)
|
||||
_, restore = _register_fake_asr(asr_mod, "fake-asr")
|
||||
try:
|
||||
r = _client(fresh_app).post(
|
||||
"/engines/select", json={"family": "asr", "backend_id": "fake-asr"})
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["family"] == "asr"
|
||||
assert body["active"] == "fake-asr"
|
||||
assert body["env_override"] is False
|
||||
assert _prefs.get("asr_backend") == "fake-asr"
|
||||
finally:
|
||||
restore()
|
||||
|
||||
|
||||
def test_select_asr_env_var_still_wins(fresh_app, monkeypatch):
|
||||
"""CRITICAL backward-compat: an existing `OMNIVOICE_ASR_BACKEND` pin keeps
|
||||
winning over a Settings pick — the pick persists to prefs (for when the
|
||||
pin is lifted) but the active id stays the env value, and the response
|
||||
says so via env_override."""
|
||||
from core import prefs as _prefs
|
||||
from services import asr_backend as asr_mod
|
||||
|
||||
_force_cpu_host(monkeypatch)
|
||||
monkeypatch.setenv("OMNIVOICE_ASR_BACKEND", "pytorch-whisper")
|
||||
_, restore = _register_fake_asr(asr_mod, "fake-asr-pinned")
|
||||
try:
|
||||
r = _client(fresh_app).post(
|
||||
"/engines/select", json={"family": "asr", "backend_id": "fake-asr-pinned"})
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["env_override"] is True
|
||||
assert body["active"] == "pytorch-whisper" # env wins
|
||||
assert _prefs.get("asr_backend") == "fake-asr-pinned"
|
||||
finally:
|
||||
restore()
|
||||
|
||||
|
||||
def test_select_asr_unknown_backend_is_400(fresh_app):
|
||||
r = _client(fresh_app).post(
|
||||
"/engines/select", json={"family": "asr", "backend_id": "nope-not-real"})
|
||||
assert r.status_code == 400
|
||||
assert "Unknown asr backend" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_select_asr_unavailable_backend_is_400(fresh_app, monkeypatch):
|
||||
from services import asr_backend as asr_mod
|
||||
|
||||
_force_cpu_host(monkeypatch)
|
||||
_, restore = _register_fake_asr(asr_mod, "fake-asr-down", available=False)
|
||||
try:
|
||||
r = _client(fresh_app).post(
|
||||
"/engines/select", json={"family": "asr", "backend_id": "fake-asr-down"})
|
||||
assert r.status_code == 400
|
||||
assert "not ready" in r.json()["detail"]
|
||||
finally:
|
||||
restore()
|
||||
|
||||
|
||||
def test_get_engines_asr_family_shape(fresh_app):
|
||||
"""GET /engines/asr — the ASR picker's data source: active id + one row
|
||||
per registered backend with availability, reasons and install hints."""
|
||||
r = _client(fresh_app).get("/engines/asr")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert isinstance(body["active"], str) and body["active"]
|
||||
by_id = {b["id"]: b for b in body["backends"]}
|
||||
assert {"whisperx", "faster-whisper", "openai-compat-asr"}.issubset(by_id)
|
||||
# Install hints power the picker's tooltips (parity with TTS).
|
||||
assert by_id["openai-compat-asr"]["install_hint"]
|
||||
for entry in by_id.values():
|
||||
missing = _REQUIRED_KEYS - entry.keys()
|
||||
assert not missing, f"asr entry {entry['id']!r} missing: {missing}"
|
||||
|
||||
|
||||
# ── #981 — mlx-audio curated-model selection via /engines/select ───────────
|
||||
#
|
||||
# mlx-audio multiplexes 7+ curated models behind one backend id. Before this
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Run the AppImage AppRun launcher's shell unit tests under pytest.
|
||||
|
||||
AppRun.test.sh existed but was wired into NO CI job — the launcher's
|
||||
workaround auto-detection (which decides whether shipped Linux builds get
|
||||
WEBKIT_DISABLE_COMPOSITING_MODE) could regress silently. This wrapper rides
|
||||
the standard "Tests (backend + frontend)" gate instead of needing its own
|
||||
workflow step. Covers the #961 follow-up too: the build-time
|
||||
.bundled-webkitgtk-version marker must beat the host's pkg-config answer.
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
_REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
_SCRIPT = os.path.join(_REPO, "frontend", "src-tauri", "appimage", "AppRun.test.sh")
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("bash") is None, reason="bash not available")
|
||||
def test_apprun_shell_suite_passes():
|
||||
proc = subprocess.run(
|
||||
["bash", _SCRIPT], capture_output=True, text=True, timeout=120,
|
||||
)
|
||||
assert proc.returncode == 0, (
|
||||
f"AppRun.test.sh failed (exit {proc.returncode}):\n"
|
||||
f"{proc.stdout}\n{proc.stderr}"
|
||||
)
|
||||
assert "0 fail" in proc.stdout
|
||||
@@ -194,6 +194,53 @@ def test_asr_env_override(monkeypatch):
|
||||
assert asr_backend.active_backend_id() == "pytorch-whisper"
|
||||
|
||||
|
||||
# ── ASR selection resolution (Settings → Engines ASR picker) ────────────────
|
||||
# Same env > prefs > auto-detect contract as TTS. The env var MUST keep
|
||||
# winning so existing `OMNIVOICE_ASR_BACKEND` pins don't change behavior now
|
||||
# that the Settings picker writes the prefs key.
|
||||
|
||||
|
||||
def test_asr_active_backend_prefs_fallback(monkeypatch, tmp_path):
|
||||
from core import prefs as _prefs
|
||||
monkeypatch.setattr(_prefs, "_PREFS_PATH", str(tmp_path / "prefs.json"))
|
||||
monkeypatch.delenv("OMNIVOICE_ASR_BACKEND", raising=False)
|
||||
_prefs.set_("asr_backend", "moonshine")
|
||||
assert asr_backend.active_backend_id() == "moonshine"
|
||||
# Env var must beat prefs.
|
||||
monkeypatch.setenv("OMNIVOICE_ASR_BACKEND", "pytorch-whisper")
|
||||
assert asr_backend.active_backend_id() == "pytorch-whisper"
|
||||
|
||||
|
||||
def test_asr_auto_detects_when_no_env_no_prefs(monkeypatch, tmp_path):
|
||||
from core import prefs as _prefs
|
||||
monkeypatch.setattr(_prefs, "_PREFS_PATH", str(tmp_path / "prefs.json"))
|
||||
monkeypatch.delenv("OMNIVOICE_ASR_BACKEND", raising=False)
|
||||
assert asr_backend.active_backend_id() in {
|
||||
"whisperx", "faster-whisper", "mlx-whisper", "pytorch-whisper",
|
||||
}
|
||||
|
||||
|
||||
def test_get_active_asr_backend_follows_prefs_switch_without_restart(monkeypatch, tmp_path):
|
||||
"""#981 class (fixed on the TTS side): a Settings pick must take effect on
|
||||
the next transcribe, not after an app restart. get_active_asr_backend()
|
||||
re-resolves the id per call, so a prefs write switches immediately."""
|
||||
from core import prefs as _prefs
|
||||
monkeypatch.setattr(_prefs, "_PREFS_PATH", str(tmp_path / "prefs.json"))
|
||||
monkeypatch.delenv("OMNIVOICE_ASR_BACKEND", raising=False)
|
||||
_prefs.set_("asr_backend", "pytorch-whisper")
|
||||
assert isinstance(
|
||||
asr_backend.get_active_asr_backend(), asr_backend.PyTorchWhisperBackend)
|
||||
_prefs.set_("asr_backend", "moonshine")
|
||||
assert isinstance(
|
||||
asr_backend.get_active_asr_backend(), asr_backend.MoonshineASRBackend)
|
||||
|
||||
|
||||
def test_asr_unknown_backend_raises(monkeypatch):
|
||||
monkeypatch.setenv("OMNIVOICE_ASR_BACKEND", "not-a-real-asr")
|
||||
with pytest.raises(ValueError):
|
||||
asr_backend.get_active_asr_backend()
|
||||
|
||||
|
||||
# ── LLM ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user