Compare commits

..
4 Commits
Author SHA1 Message Date
a721c1fdcf release: freeze v0.3.14 — version bump, lockfiles, changelog (#1027)
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 09:12:24 +05:30
80f10289fe feat(asr): ASR engines get the same Settings picker TTS has (env var still wins) (#1026)
Settings → Engines now stacks one pinned Engine Compatibility Matrix per
family (TTS, ASR, LLM) instead of a single TTS-titled table with the other
families tucked behind a low-discoverability tab. The backend select/prefs
path (family="asr" → prefs.asr_backend, env > prefs > auto-detect) already
worked but was unexercised and undocumented — it's now locked by API and
resolution-order tests, and README + the openai-compat-asr doc stop
promising a picker that didn't exist / denying one that now does.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 04:43:16 +05:30
a0ad314736 docs: CLAUDE.md refresh — de-rot versions, compress shipped stack research, replace the dead GSD gate (#1025)
Three classes of staleness that actively misled agent sessions:

- The Project section hardcoded "latest stable v0.3.5 / main at v0.3.6"
  — eight releases behind. Now points at the sources of truth
  (frontend/package.json, the Releases page) and documents the current
  AUTO_VERSION_BUMP-off holding behavior instead of a version literal
  that rots every release.
- ~165 lines of May-2026 stack research for five capabilities that have
  ALL since shipped (HF-token panel, prefilled-URL bug reporting, uv
  mirror fallback, Supertonic-3, in-repo docs). Compressed to the
  durable don'ts it established (no telemetry endpoints, no app-side
  GitHub tokens, no setx, no MkDocs, no hf_transfer) plus a pointer to
  prefer what's already pinned.
- The GSD Workflow Enforcement gate referenced /gsd-quick//gsd-debug/
  /gsd-execute-phase skills that exist nowhere in this environment; the
  owner explicitly chose direct edits over restoring them (2026-07-08).
  It cost a real mid-task detour when a subagent correctly refused to
  work under an unsatisfiable rule. Replaced with the owner decision
  and the working conventions that actually bind (merge gating,
  check-the-PR-queue-first).

244 → 83 lines. GSD section markers preserved so the generating tool
can still find its blocks.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 04:25:14 +05:30
1808a373a1 fix(linux): AppRun workaround detection reads the BUNDLED WebKitGTK version, not the host's (#961 follow-up) (#1024)
The launcher decided whether to export WEBKIT_DISABLE_COMPOSITING_MODE
by asking the host's pkg-config — but LD_LIBRARY_PATH makes the
BUNDLED libwebkit2gtk the one that actually runs, so on any machine
where the two diverge the detection read the wrong number. This was
the second bug identified during #961's investigation (the reporter
built from source, so their dev packages answered pkg-config with a
healthy version while the shipped bundle ran an older lib) and was
explicitly deferred in #1007 as not-safely-fixable at runtime.

The fix makes it knowable by construction instead: inject-apprun.sh
runs at bundle time ON the build host whose libwebkit2gtk gets
bundled, so it stamps that version into .bundled-webkitgtk-version
inside the AppDir. AppRun reads the stamp first and only falls back to
host pkg-config for bundles predating it. Empty/unreadable stamp fails
safe (workaround on), same philosophy as the missing-pkg-config path.

Tests: 3 new cases in AppRun.test.sh — marker-beats-host in both
directions (broken-marker/healthy-host and the #961 inversion,
healthy-marker/broken-host) plus empty-marker fail-safe. Also wires
AppRun.test.sh into pytest (tests/test_apprun_launcher.py) — it was
previously run by NO CI job, so the launcher could regress silently.

Also documents Windows install-to-another-drive behavior in
docs/install/windows.md (#938): local drives work via the wizard's
directory picker, mapped network drives are a Windows Installer
limitation, and the data directory moves independently of the app.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 03:53:46 +05:30
22 changed files with 552 additions and 194 deletions
+16
View File
@@ -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.
+13 -174
View File
@@ -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 -->
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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:
+5 -3
View File
@@ -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
+22
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "omnivoice-studio",
"version": "0.3.13",
"version": "0.3.14",
"private": true,
"license": "AGPL-3.0-only",
"type": "module",
+1 -1
View File
@@ -2941,7 +2941,7 @@ dependencies = [
[[package]]
name = "omnivoice-studio"
version = "0.3.13"
version = "0.3.14"
dependencies = [
"arboard",
"dirs-next",
+1 -1
View File
@@ -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"
+19 -1
View File
@@ -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);
});
});
});
+1
View File
@@ -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
View File
@@ -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
+17
View File
@@ -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
+29
View File
@@ -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
+47
View File
@@ -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 ─────────────────────────────────────────────────────────────────────
Generated
+1 -1
View File
@@ -3207,7 +3207,7 @@ wheels = [
[[package]]
name = "omnivoice"
version = "0.3.13"
version = "0.3.14"
source = { editable = "." }
dependencies = [
{ name = "accelerate" },