Merge feat/catalogue-one-page (with main) into feat/engine-list-detail

Conflicts: RecoBanner (deleted here: the recommendation card is gone),
ModelStoreTab + EngineCompatibilityMatrix (this branch's rewrite kept),
supertonic3 backend (main's own-venv check kept, message without the
retired "→ Engines" step), CHANGELOG (base layout + the #2020 line).

Carried over from main and the review:
- The list row hides Install while only the license review is left
  (main's #2017 rule, now in the row; Accept lives in the panel).
- Engine action aria labels go through i18n (engines.aria*, all 21
  locales) instead of hardcoded English.
- Every "Model Catalogue → Engines/Models" path main added, plus the
  frontend strings that still named the retired panes, now point at the
  one-page catalogue.
- test_engine_unavailable_reason_1866 reads the license matcher from its
  new home, engines/engineDisplay.js.
This commit is contained in:
Palash Debnath
2026-09-10 12:17:02 -07:00
82 changed files with 3524 additions and 211 deletions
+72 -12
View File
@@ -890,8 +890,8 @@ jobs:
# ── Compute SHA-256 checksums (Phase 0 GATE-05) ───────────────────
# Native OS tools: shasum -a 256 (POSIX) / Get-FileHash (Windows).
# Writes SHA256SUMS-<label>.txt for the user-verifiable path AND
# captures the content into $GITHUB_OUTPUT for body append.
# Writes SHA256SUMS-<label>.txt, attached to the release below. The
# release-notes-checksums job puts every leg's file into the notes.
- name: Compute SHA-256 checksums
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
id: checksums
@@ -944,15 +944,74 @@ jobs:
echo "checksums_file=$OUT" >> "$GITHUB_OUTPUT"
- name: Append checksums to release + attach SHA256SUMS file
# Attach only. The notes are one shared text and the publish is one
# decision, so both belong to the single release-notes-checksums job
# that runs after the whole matrix (see there for why).
- name: Attach SHA256SUMS file
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
append_body: true
body_path: ${{ steps.checksums.outputs.checksums_file }}
files: ${{ steps.checksums.outputs.checksums_file }}
fail_on_unmatched_files: true
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.ref_name }}
FILE: ${{ steps.checksums.outputs.checksums_file }}
run: |
set -euo pipefail
gh release upload "$TAG" "$FILE" --clobber --repo "$GITHUB_REPOSITORY"
# ── Checksums into the notes, then publish (the single writer) ───────────
# Every build leg used to append its checksums to the shared release notes
# with softprops/action-gh-release. Two things went wrong:
# - The appends were concurrent read-modify-writes, so a leg that read the
# notes before another wrote them lost its section. v0.5.1 and v0.5.2
# both shipped without the macOS Apple Silicon checksums in the notes.
# - softprops defaults to draft: false, so the FIRST leg to finish
# published tauri-action's draft while the other installers and the
# complete latest.json were still being built (v0.5.2 went public at
# 17:27; its latest.json was finished at 17:38).
# This job is the only writer of the notes and the only publisher. It runs
# once every leg, the manifest repair and the uninstall scripts are done,
# writes the four platforms' checksums in a fixed order, and fails if one is
# missing, so a failed platform leaves the release a draft.
release-notes-checksums:
needs: [build, repair-updater-manifest, uninstall-scripts]
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-22.04
timeout-minutes: 10
permissions:
contents: write
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
TAG: ${{ github.ref_name }}
steps:
- name: Write every platform's checksums into the notes, then publish
shell: bash
run: |
set -euo pipefail
WORK="$(mktemp -d)"
gh release download "$TAG" --repo "$REPO" --pattern 'SHA256SUMS-*.txt' --dir "$WORK"
# The checksum sections and the Contributors strip are always the
# tail of the notes; drop them so a re-run rebuilds rather than
# stacks (contributors-strip re-appends its strip after this job).
gh release view "$TAG" --repo "$REPO" --json body --jq .body \
| awk '/^### .* artifacts$/ || /^## Contributors$/ {exit} {print}' > "$WORK/notes.md"
missing=0
for label in "macOS Apple Silicon" "macOS Intel" "Windows x64" "Linux x64"; do
file="$WORK/SHA256SUMS-${label// /.}.txt"
if [ -f "$file" ]; then
cat "$file" >> "$WORK/notes.md"
else
echo "::error::The release has no checksums for $label"
missing=1
fi
done
[ "$missing" = 0 ] || exit 1
gh release edit "$TAG" --repo "$REPO" --notes-file "$WORK/notes.md"
if [[ "$TAG" == *-* ]]; then
gh release edit "$TAG" --repo "$REPO" --draft=false --prerelease
else
gh release edit "$TAG" --repo "$REPO" --draft=false --latest
fi
# ── Uninstall scripts as release assets (#1089) ───────────────────────────
# The in-app uninstaller (Settings → Storage → Remove all data) is the primary
@@ -1049,11 +1108,12 @@ jobs:
# MUST append via `gh release edit` on the EXISTING release (never a second
# softprops publish — that races tauri-action's per-matrix draft and splits
# installers across two releases; see uninstall-scripts). `needs: [build]`
# guarantees the release + all checksum appends already landed, and this job
# guarantees the release exists and release-notes-checksums has written the
# notes, and this job
# is single (no matrix) so there is no write race. Idempotent: it strips any
# prior "## Contributors" block before re-appending, so re-runs don't stack.
contributors-strip:
needs: [build]
needs: [build, release-notes-checksums]
if: >-
github.event_name == 'push'
&& startsWith(github.ref, 'refs/tags/v')
+52 -67
View File
@@ -9,7 +9,28 @@ the frozen-backend fallback mirror it for their toolchains.
## [Unreleased]
**Highlights**
- The Model Catalogue is one page: what you use now on top, then each family's engines and weights (#2013)
- VoxCPM2 installs in one click into its own environment, with the CUDA build of PyTorch on NVIDIA GPUs (#2021)
- MOSS-TTS-Nano installs in one click into its own environment, pinned to a reviewed upstream commit it works with (#2022)
### Changed
- Model Catalogue is one page: a setup summary (speech, transcription, dictation, language model) on top, one TTS / ASR / LLM switch, and each family's downloadable weights listed under its engines; the separate Models pane and the Settings → Voice → Engines / Models signposts are gone, the models directory and voice previews moved to Settings → Storage and the HF mirror to Network (#2013)
- The engine list is one line per engine (engine, device it runs on, status, one action) with a detail panel for everything else; each engine's weights install from its panel, so the separate weights list and recommendation card are gone (#2020)
### CI
- A tagged release is published only after every platform's installers and checksums are attached, and its notes list all four platforms' checksums (#2029)
## [0.5.2] — 2026-09-10
**Highlights**
- Supertonic-3 and PocketTTS show their license Accept button again, so they can be enabled (#2017)
- An engine that can't run on your platform says so, instead of telling you to install it (#2018)
- MOSS-TTS-v1.5, Confucius4-TTS, dots.tts, Supertonic-3 and PocketTTS install in one click, each in its own environment, so switching engines and back never breaks a working one (#2015, #2016)
- A pronunciation entry that is stored but not applied yet says so, instead of looking like it did not match (#1949)
- A bare 500 report now names the backend error class, so two unrelated faults stop filing the same issue (#1773)
- A rejected dubbing source language now names the code it rejected (#1960)
@@ -38,15 +59,12 @@ the frozen-backend fallback mirror it for their toolchains.
- The Engines menu's Transcription tab picks the dictation model under Sherpa-ONNX, and that choice now also drives Sherpa transcription (#1952)
- A failure with no stage attached no longer borrows another stage's advice, so a text-to-speech error stops telling you the video server dropped the download (#1943)
- A generation failure that the app cannot classify now names the backend error class, so two unrelated faults stop arriving as the same untriageable report (#1800)
- Transcriptions dictation wakes the desktop recorder, presents one contextual start action, and centers its microphone icon with the label (#1902)
- Colab transcription and dubbing now include an explicit ASR model setup step (#1922) — thanks @nidhi-singh02!
- Apple Silicon now shows one canonical OmniVoice choice in the engine picker while retaining its automatic crash-isolated sidecar runtime (#1913)
- Validate current-user Windows installers under a standard account on hosted runners (#1883)
- Model downloads survive a flaky connection instead of restarting from zero (#1940)
- `bun run dev` recovers on Windows instead of demanding Task Manager (#1941)
- The desktop app builds and opens from a fresh clone again (#1818) — thanks @flutterkage2k!
- GPUs with less VRAM than the engine needs no longer get half the compute-time budget a CPU gets (#1806) — thanks @VishvakR!
- Gallery voice previews play again — the quality guard was rejecting good renders as silent (#1819) — thanks @flutterkage2k!
@@ -54,16 +72,23 @@ the frozen-backend fallback mirror it for their toolchains.
- Voice modes use themed tabs, with Synthesize and Convert pinned below their scrolling forms (#1823)
- Fix current-user Windows installer validation and nested resource cleanup (#1873)
- Keep generated frontend assets available while building the current-user Windows installer (#1881)
- Voice cloning now starts with a clear upload-or-record choice, reveals recording and reference details only when needed, and keeps sampling controls under Production Overrides (#1817)
- The first-run welcome line uses an instruction accepted by OmniVoice and VoiceDesign engines (#1861) — thanks @psiberfunk!
- audio.cpp joins the engine lineup as an opt-in CPU backend for Breeze-TTS-2 (English + Chinese, clone + voice design, explicit Model Catalogue install, no Python venv) (#1891)
- audio.cpp uses installed native CUDA, HIP, Metal, and Vulkan providers and preserves device routing across remote workers (#1926)
- Show estimated and measured model, dependency, cache, and temporary disk costs in the engine catalogue (#1718)
- Preview builds now stay newer than Stable even when automatic post-release version bumps are disabled (#1762)
- CosyVoice setup guidance now separates downloaded model files from the runtime that makes the engine available (#1761)
- MCP tools can now keep audio out of agent context by returning files and accepting base-path-confined file inputs (#1760) — thanks @agudmund!
- Hear a dub line as you type it — an opt-in live preview streams TTS for the edited segment (#1769) — thanks @mvanhorn!
- Studio gains a Convert method: re-say any clip in one of your saved voices, speech to speech, fully local (#1765) — thanks @mvanhorn!
- Hardsub video export gains an opt-in karaoke word-highlight caption style (#1764) — thanks @mvanhorn!
- The batch queue can now watch a folder: new videos dropped into it are dubbed automatically (#1768) — thanks @mvanhorn!
- The audiobook player now shows the chapter text and highlights the word being narrated (#1766) — thanks @mvanhorn!
- The dub editor gains a casting board: drag voice chips onto speakers, dropdowns stay in sync (#1767) — thanks @mvanhorn!
### Changed
- The engine list is one line per engine (engine, device it runs on, status, one action) with a detail panel for everything else; each engine's weights install from its panel, so the separate weights list and recommendation card are gone (#2020)
- Model Catalogue is one page: a setup summary (speech, transcription, dictation, language model) on top, one TTS / ASR / LLM switch, and each family's downloadable weights listed under its engines; the separate Models pane and the Settings → Voice → Engines / Models signposts are gone, the models directory and voice previews moved to Settings → Storage and the HF mirror to Network (#2013)
- Tauri 2.11.5 with refreshed plugins (dialog, updater, log, opener, positioner, single-instance), React 19.3, TanStack Query 5.102, lucide 1.43, posthog-js 1.428, and the rest of the npm workspace on current minors; jsdom 30, jest-dom 7, concurrently 10, taze 21 (#1952)
- eslint ignores `src-tauri/`, so a local Tauri build no longer floods `lint:hooks` with parse errors from generated assets (#1952)
- Casting uses responsive SVG voice cards and searchable speaker menus that stay above surrounding panels (#1823)
@@ -85,44 +110,56 @@ the frozen-backend fallback mirror it for their toolchains.
- Voice tabs and upload/record controls have subtle SVG motion; Text adds clipboard paste and the upload area fills available height (#1823)
- The title-bar label cycles through active speech, transcription, and LLM engines; bundled model labels correctly say OmniVoice (#1823)
- The top-bar Engines panel groups Speech, Transcription, and LLM choices into tabs, with compact memory controls and no duplicate pickers (#1823)
- Voice Design simplified: the 12-row fine-grained block collapses to one summary line with a five-field editor, English accent and Chinese dialect merge into a single field, and the starting-point chips now show 5 with an overflow toggle (#1793)
### Added
- The audiobook result is now a synced-lyrics player: chapter text follows playback with the current word highlighted and click-to-seek, timed from the render's own chapter durations with a karaoke-style even split — no ASR pass, fully local (#1766) — thanks @mvanhorn!
- The dub CAST strip expands into a project-level casting board: drag voice chips (clone profiles, design presets, Default) onto speaker rows — or pick from a keyboard listbox — writing the same per-speaker cast fields as the existing dropdowns (#1767) — thanks @mvanhorn!
- Studio's new Convert method turns a dropped or recorded clip into an existing voice profile's voice, with optional source-duration matching (#1765) — thanks @mvanhorn!
- Opt-in watch folder on the batch queue: pick a directory once and new videos are auto-enqueued with your last Add-to-queue settings, with pause/stop controls and copy-in-progress protection — files upload as bytes, paths never leave the app (#1768) — thanks @mvanhorn!
- Hardsub export can now burn karaoke word-highlight captions: an opt-in Line | Karaoke control renders a word-timed ASS sweep from timings persisted at transcription, with an even-split fallback for older jobs and translated tracks, plus a `GET /dub/ass/{job_id}` sidecar (#1764) — thanks @mvanhorn!
- Windows releases now include an independently updatable per-user MSI that installs and uninstalls without elevation (#1713)
- Dub segments can now stream live TTS while you edit a translated line — opt-in toggle, existing `/ws/tts` socket, shared generation admission, exports still render at full quality (#1769) — thanks @mvanhorn!
- Engine status and diagnostic bundles now record loaded execution provider, device, precision, fallback stage, accelerator identity, runtime versions, and parent-process memory visibility (#1717)
### Docs
- PowerShell Docker setup now generates the administrator key without requiring Python on the host (#1993) — thanks @yangfan-yf-yf!
- The torch upgrade an RTX 50-series card needs is written down, with the second pin file the resolver checks and the command that proves the kernels are there (#1931)
- Docker quick starts now explain the AMD64-only images and direct Apple Silicon users to the native macOS app (#1921) — thanks @yangfan-yf-yf!
- audio.cpp (Breeze-TTS-2) is now a documented opt-in engine: prebuilt binary install, explicit GGUF download, voice modes, and the weights' research/non-commercial terms (#1891)
- `docs/STRUCTURE.md` describes the tree as it is today, and a test now keeps its counts honest (#1981) — thanks @Dawcraft!
- Local gigastt is now documented as a supported OpenAI-compatible ASR endpoint, with loopback privacy distinguished from remote servers (#1736) — thanks @ekhodzitsky!
- The CosyVoice guide now states that packaged builds have no one-click runtime installer and records the exact readiness checks exposed by [Discussion 1631](https://github.com/debpalash/VoiceStudio/discussions/1631) (#1761)
- A production private-API guide now covers pinned containers, root credentials, network isolation, streaming proxies, health checks, upgrades, and benchmark evidence (#1720)
- RX 6700 XT/gfx1031 over WSL2 ROCDXG is now explicitly unverified until a published end-to-end GPU workload proves the mapped path (#1716)
### Fixed
- One-click engine installs no longer inherit VoiceStudio's own PyTorch pin, which made MOSS-TTS-v1.5 and Confucius4 impossible to install (#2024)
- Uninstalling a translation engine no longer removes a package VoiceStudio or another engine still needs (#2019)
- Closing the dictation pill on Windows removes it from the screen: an empty dark rectangle used to stay there, always on top, until the app was quit (#2009)
- The dictation pill on Windows no longer sits inside a bordered card wider than the pill itself (#2009)
- Dictation uses the model you picked instead of one remembered from before the backend started, so it stops reporting no speech-to-text model while one is installed — and when none is, the main window offers the download (#2012)
- The remote-worker loop-responsiveness tests no longer turn a build red over milliseconds of scheduling noise on shared CI hardware (#1990)
- Remote GPU workers work when the machine running VoiceStudio is on Windows: a staged input is now identified the same way on every operating system, instead of with a path only Windows can read (#2005)
- The pronunciation list badges an IPA or CMU entry as not applied yet, so you can see it without running a test (#1949) — thanks @utkarsha741!
- A remote-worker test no longer fails at random on Windows CI: it waited for a background thread by spinning the event loop that thread's work needed (#1990)
- The isolated backend test session passes on a stock Windows checkout, and CI now runs it there so it stays that way (#1990)
- Windows contributors can run the test suite without Developer Mode: tests that create a symlink now skip instead of failing with `WinError 1314` (#1990)
- The crash details dialog now says what the exit code means and what to try, instead of showing a raw number and a log (#1927)
- A crash report now carries the backend's actual last words: the log tail is captured after the dying process's final output lands, not the instant it exits (#1850)
- The first-run setup screen no longer mislabels a step when the bootstrap restarts itself: Rust now says which attempt each stage and log line belongs to, instead of the screen guessing from a once-a-second poll (#1900)
- A port-3900 conflict now names who is actually holding it, and gives the command that ends an orphaned backend, instead of telling you to quit an app that has no window (#1933) — thanks @Chang-Jin-Lee!
- Windows desktop launches no longer freeze at "Loading ML runtime (PyTorch)": the parent-liveness watchdog polls the stdin pipe instead of leaving a read pending, which deadlocked numpy's OpenBLAS initializer (#1952)
- Windows desktop launches no longer freeze at "Loading ML runtime (PyTorch)": the parent-liveness watchdog polls the stdin pipe instead of leaving a read pending, which deadlocked numpy's OpenBLAS initializer (#1952, #1955)
- `bun desktop-prod` and `bun desktop-fresh` find Rust and uv from a terminal opened before they were installed, as `bun desktop` already did; a missing Rust toolchain fails up front with the install steps (#1952)
- Windows desktop launches no longer freeze at "Loading ML runtime (PyTorch)": the parent-liveness watchdog polls the stdin pipe instead of leaving a read pending, which deadlocked numpy's OpenBLAS initializer (#1955)
- Voice synthesis progress no longer races to a fabricated 95%; it stays indeterminate until the active generation path reports real progress (#1907) — thanks @psiberfunk!
- The Backend log tab keeps showing history across a log rollover, instead of going nearly empty until new lines arrive (#1920)
- Clearing the logs now empties the rotated log files too, so it frees the space it appears to (#1920)
- An error thrown by a browser extension no longer offers to file itself as a VoiceStudio bug (#1901)
- Clearing the desktop logs no longer wipes the backend's stderr, which is the only record a native crash leaves behind and is meant to survive a respawn (#1510)
- Long audiobook chapters now use the same device- and text-length-aware synthesis timeout as other TTS routes (#1910) — thanks @psiberfunk!
- Interrupted audiobook renders can resume cached chapters after tab navigation, and their chapter cache is available from the recovery card (#1911) — thanks @psiberfunk!
- System-check details and storage paths beginning with a number or a slash no longer render with their leading text moved to the end of the line (#1848) — thanks @psiberfunk!
- An unavailable engine's row now links to that engine's guide, so the generic "check installation and configuration" message has somewhere to send you (#1866) — thanks @psiberfunk!
@@ -135,27 +172,22 @@ the frozen-backend fallback mirror it for their toolchains.
- `dev-backend.mjs` stops the backend by process tree on Windows, so an orphaned uvicorn no longer holds port 3900 and turns a source reload into three phantom crashes (#1941)
- `clear-dev-ports.mjs` can free a stuck development port on Windows again, bound to the inspected process instance so a recycled pid is never terminated (#1941)
- Checkout-ownership matching no longer resolves POSIX paths with the host's separator, which made the guard's own test fail on Windows (#1941)
- Install documentation help now prints correctly on Windows consoles using legacy encodings (#1815) — thanks @dajiaohuang!
- Saved transcriptions with missing or invalid timestamps now remain readable (#1799) — thanks @yunaremaia and @tvbht!
- Transcribing with an engine that reports no segment end no longer fails with a server error; the null timing is passed through the way the segment list already expects (#1904) — thanks @aeroglu!
- Copying a saved transcription now uses the shared clipboard helper and reports failed copies accurately (#1803) — thanks @tvbht!
- Voice reference preparation reclaims allocator memory before one bounded retry, then reports persistent GPU out-of-memory failures (#1811)
- `bun run desktop` now opens on a fresh clone: the Vite alias for `@tauri-apps/plugin-dialog` no longer assumes a nested `frontend/node_modules`, which bun's workspace hoisting leaves empty (#1818) — thanks @flutterkage2k!
- Slow backend startups remain running with progress updates, and Retry interrupts startup without stale timeout failures (#1809)
- Backend connection errors report crashes only when recorded evidence exists, and diagnostic waits honor cancellation (#1810)
- A CUDA or ROCm GPU with less VRAM than the engine needs now gets the CPU compute-time budget instead of the shorter accelerated one, since it pages to system RAM and renders slower than the CPU would — applied to local generation, voice conversion, and remote worker deadlines alike (#1806) — thanks @VishvakR!
- Gallery previews no longer fail with "the voice engine returned no audible audio" on perfectly good renders: the degenerate-buzz guard measured spectral flatness over the whole clip (so the value tracked clip length) against a threshold calibrated on a synthetic signal, and rejected real speech in every language tested (#1819) — thanks @flutterkage2k!
- Speak tilde separators in integer, signed, and decimal ranges in English, Korean, Japanese, and Chinese (#1821) — thanks @flutterkage2k!
- Keep recording and conversion work safe while switching methods, synchronize dubbing language controls, and localize timeline controls and timing warnings (#1841)
- Audiobook is now a Write → Cast → Produce tab workspace matching the voice workspace, with the warnings/progress/result rail pinned below (#1841)
- Gallery uses a workspace header with zone tabs, hairline section dividers, theme-token cards, and borderless import rows (#1841)
- Gallery cards reset native button faces, cluster icon actions in the header so Use voice never wraps, and use a roomier grid floor (#1841)
- Gallery filters gain name search, removable iconified pills with clear-all, and dimension icons on every facet (#1841)
- Dubbing playback starts before waveform decoding, automatic cast names are readable, and transcript timestamps have more room (#1823)
- The title-bar engine button stays compact and stable while cycling labels, with engine names aligned right (#1823)
- Long dubbing segment errors wrap in a bounded scrollable notice instead of widening the editor (#1823)
@@ -172,62 +204,15 @@ the frozen-backend fallback mirror it for their toolchains.
- Confucius accelerator routing tolerates failed device probes, and dots.tts keeps safe default precision on non-CUDA hosts (#1831) — thanks @li-lizhe!
- On macOS, the header status dot and kicker no longer render underneath the overlaid traffic lights (#1863) — thanks @psiberfunk!
- The capture widget can hide after recording and recover from being left visible while idle (#1865) — thanks @psiberfunk!
- macOS retains the shared desktop window sizing, resize limits, and file-drop behavior when native chrome is applied (#1865) — thanks @psiberfunk!
- On macOS, the header no longer shows Windows-style minimize/maximize/close buttons alongside the native traffic lights (#1865) — thanks @psiberfunk!
- Release retries replace their own partially uploaded installers without colliding with existing assets (#1871)
- Timed-out voice engines finish process cleanup before retrying, and old timeout callbacks cannot kill replacement engines (#1872)
- Fast macOS process exits no longer turn a completed shutdown into a permission error (#1809)
- The bootstrap splash no longer shows fabricated first-run install steps on a warm start or repair sync — a step now renders done only once it was actually observed (#1894)
- A deliberate, clean quit killed by the desktop shell's short shutdown grace no longer gets reported as a crash on next launch — the run sentinel now clears before the slower shutdown steps instead of after (#1895)
- Model Catalogue engine rows stack into one column on narrow shells instead of clipping actions off-screen (#1891)
- Simplified Chinese locale completed: all 486 missing keys translated and the parity ratchet tightened to zero (#1877) — thanks @yearth!
## [0.5.2] — 2026-09-02
**Highlights**
- Show estimated and measured model, dependency, cache, and temporary disk costs in the engine catalogue (#1718)
- Preview builds now stay newer than Stable even when automatic post-release version bumps are disabled (#1762)
- CosyVoice setup guidance now separates downloaded model files from the runtime that makes the engine available (#1761)
- MCP tools can now keep audio out of agent context by returning files and accepting base-path-confined file inputs (#1760) — thanks @agudmund!
- Hear a dub line as you type it — an opt-in live preview streams TTS for the edited segment (#1769) — thanks @mvanhorn!
- Studio gains a Convert method: re-say any clip in one of your saved voices, speech to speech, fully local (#1765) — thanks @mvanhorn!
- Hardsub video export gains an opt-in karaoke word-highlight caption style (#1764) — thanks @mvanhorn!
- The batch queue can now watch a folder: new videos dropped into it are dubbed automatically (#1768) — thanks @mvanhorn!
- The audiobook player now shows the chapter text and highlights the word being narrated (#1766) — thanks @mvanhorn!
- The dub editor gains a casting board: drag voice chips onto speakers, dropdowns stay in sync (#1767) — thanks @mvanhorn!
### Changed
- Voice Design simplified: the 12-row fine-grained block collapses to one summary line with a five-field editor, English accent and Chinese dialect merge into a single field, and the starting-point chips now show 5 with an overflow toggle (#1793)
### Added
- The audiobook result is now a synced-lyrics player: chapter text follows playback with the current word highlighted and click-to-seek, timed from the render's own chapter durations with a karaoke-style even split — no ASR pass, fully local (#1766) — thanks @mvanhorn!
- The dub CAST strip expands into a project-level casting board: drag voice chips (clone profiles, design presets, Default) onto speaker rows — or pick from a keyboard listbox — writing the same per-speaker cast fields as the existing dropdowns (#1767) — thanks @mvanhorn!
- Studio's new Convert method turns a dropped or recorded clip into an existing voice profile's voice, with optional source-duration matching (#1765) — thanks @mvanhorn!
- Opt-in watch folder on the batch queue: pick a directory once and new videos are auto-enqueued with your last Add-to-queue settings, with pause/stop controls and copy-in-progress protection — files upload as bytes, paths never leave the app (#1768) — thanks @mvanhorn!
- Hardsub export can now burn karaoke word-highlight captions: an opt-in Line | Karaoke control renders a word-timed ASS sweep from timings persisted at transcription, with an even-split fallback for older jobs and translated tracks, plus a `GET /dub/ass/{job_id}` sidecar (#1764) — thanks @mvanhorn!
- Windows releases now include an independently updatable per-user MSI that installs and uninstalls without elevation (#1713)
- Dub segments can now stream live TTS while you edit a translated line — opt-in toggle, existing `/ws/tts` socket, shared generation admission, exports still render at full quality (#1769) — thanks @mvanhorn!
- Engine status and diagnostic bundles now record loaded execution provider, device, precision, fallback stage, accelerator identity, runtime versions, and parent-process memory visibility (#1717)
### Docs
- Local gigastt is now documented as a supported OpenAI-compatible ASR endpoint, with loopback privacy distinguished from remote servers (#1736) — thanks @ekhodzitsky!
- The CosyVoice guide now states that packaged builds have no one-click runtime installer and records the exact readiness checks exposed by [Discussion 1631](https://github.com/debpalash/VoiceStudio/discussions/1631) (#1761)
- A production private-API guide now covers pinned containers, root credentials, network isolation, streaming proxies, health checks, upgrades, and benchmark evidence (#1720)
- RX 6700 XT/gfx1031 over WSL2 ROCDXG is now explicitly unverified until a published end-to-end GPU workload proves the mapped path (#1716)
### Fixed
- The generation compute-time budget is now a Settings control (Performance & Device) instead of an env-var-only setting the timeout error recommended with no UI path — the error copy points there too, and long CPU/MPS renders get an upfront heads-up before they start (#1787)
- Windows: the backend can now start when the install path contains non-English characters (e.g. a CJK username) on a non-UTF-8 system code page — a new or broken Python environment now builds at an ASCII-safe path automatically (a healthy existing one is never relocated), and a specific error message names the cause and a working fix if the interpreter still crashes in `site` (#1783)
- Exports and other native-picker actions no longer 403 with "Invalid or expired desktop authorization" when the desktop app and backend resolve different data directories, e.g. dev mode or a custom data folder (#1781)
+61 -1
View File
@@ -43,6 +43,29 @@ _UNAVAILABLE_NOT_INSTALLED = (
"This engine's package isn't installed yet. Install it from "
"Model Catalogue."
)
# An engine gated behind an in-app license review (Supertonic-3, PocketTTS).
# The Model Catalogue shows its Accept button only when the reason matches
# /license not accepted/i (EngineCompatibilityMatrix.reasonMentionsLicense), so
# this sentence must keep those words: collapsing it into the generic line hid
# the only way to enable those engines.
_UNAVAILABLE_LICENSE = (
"License not accepted yet. Review and accept it in "
"Model Catalogue to enable this engine."
)
# An engine that cannot run on this machine at all: Apple-Silicon-only MLX,
# PyTorch with no Intel Mac build. "Isn't installed yet" or "check
# installation" sent people after an install that could never work.
_UNAVAILABLE_PLATFORM = (
"This engine doesn't run on this computer's platform. Its guide lists "
"the platforms it supports."
)
# Apple Silicon whose PyTorch cannot use the GPU (MPS): the platform is
# right, the installation is not. MLX-Audio / MLX-Whisper need MPS (#390).
_UNAVAILABLE_NO_MPS = (
"This engine needs Apple's GPU (MPS), and this installation's PyTorch "
"can't use it. Updating macOS or reinstalling VoiceStudio usually "
"restores it."
)
_UNAVAILABLE_NEEDS_CONFIG = (
"This engine needs to be configured before it can run. Open "
"Model Catalogue to finish setting it up."
@@ -52,10 +75,40 @@ _UNAVAILABLE_FILE_MISSING = (
"Model Catalogue."
)
# The same two cases for an engine the app cannot install for you. "Install it
# from Model Catalogue" sent people to a page with no Install button
# for that engine — most of the catalogue — which reads as the app being
# broken. The row's own guide link (``docs_url``) is the real next step.
_UNAVAILABLE_NOT_INSTALLED_MANUAL = (
"This engine isn't installed yet, and it has no one-click install. "
"Its guide lists the install steps."
)
_UNAVAILABLE_FILE_MISSING_MANUAL = (
"A file this engine needs is missing or unreadable. Its guide lists the "
"install steps."
)
_MANUAL_INSTALL_VARIANT = {
_UNAVAILABLE_NOT_INSTALLED: _UNAVAILABLE_NOT_INSTALLED_MANUAL,
_UNAVAILABLE_FILE_MISSING: _UNAVAILABLE_FILE_MISSING_MANUAL,
}
# Matched against the lowered probe text. Ordered most specific first: a
# missing file often also says "not installed", and the file case has the more
# useful remedy of the two.
_UNAVAILABLE_SIGNATURES = (
# First: its probe text also says "Open Model Catalogue", and the
# license is the one gap only the user can close.
(_UNAVAILABLE_LICENSE, ("license not accepted",)),
# Before the install and file checks: a platform reason often also says
# "unavailable" or names a missing wheel, and no install can fix it. Not
# "apple silicon only": mlx-audio says that on an M-series Mac too, when
# the package is merely missing and installing does help.
(_UNAVAILABLE_PLATFORM, (
"requires apple silicon", "not supported on this platform",
"unavailable on intel macs", "no macos x86_64 wheel",
"no windows install", "not supported on windows",
)),
(_UNAVAILABLE_NO_MPS, ("torch mps unavailable",)),
(_UNAVAILABLE_FILE_MISSING, (
"file is missing", "file is empty", "file is unreadable",
"script missing", "binary", "not found at",
@@ -94,7 +147,14 @@ def public_backends(entries: list[dict]) -> list[dict]:
for entry in entries:
item = dict(entry)
if item.get("reason") is not None:
item["reason"] = _public_unavailable_reason(item["reason"])
reason = _public_unavailable_reason(item["reason"])
# Only a row that explicitly says it has NO one-click install gets
# the manual wording. Rows without the field (ASR, LLM,
# translation — some of which have installers of their own) keep
# the line that points at Model Catalogue.
if item.get("one_click_install") is False:
reason = _MANUAL_INSTALL_VARIANT.get(reason, reason)
item["reason"] = reason
if item.get("last_error") is not None:
item["last_error"] = _PREVIOUS_FAILURE
if item.get("routing_reason") is not None:
+10
View File
@@ -204,6 +204,11 @@ async def uninstall_translation_engine(engine_id: str):
pkg = entry.get("pip_package")
if not pkg:
return {"status": "no_op", "engine": engine_id}
# The builtin flag is a promise someone has to remember to make; this
# check does not depend on it (#2019).
blocked = translation_engines.uninstall_blocker(engine_id)
if blocked:
raise HTTPException(status_code=blocked[0], detail=blocked[1])
rc, out = await translation_engines.run_pip(["uninstall", "-y", pkg])
if rc != 0:
raise HTTPException(status_code=500, detail=f"pip uninstall {pkg} failed ({rc}): {out[-1000:]}")
@@ -246,6 +251,11 @@ def install_sidecar_engine(engine_id: str):
from services import sidecar_install
try:
return sidecar_install.start_install(engine_id)
except sidecar_install.HostUnsupported as exc:
# The engine has an installer, but not one that can work on this
# machine. 409, not 404: the route is right, the host is the problem,
# and the message (a VoiceStudio-owned sentence) says what to do.
raise HTTPException(status_code=409, detail=str(exc))
except KeyError:
raise HTTPException(
status_code=404,
+43
View File
@@ -0,0 +1,43 @@
"""The PyTorch wheel index VoiceStudio installs CUDA builds from.
A local-version pin such as ``torch==2.9.1+cu128`` exists only on PyTorch's
own index, never on PyPI. The app's own ``pyproject.toml`` routes torch there
through ``[tool.uv.sources]``, but a sidecar engine is installed with
``uv pip install`` into its own venv, which knows nothing about that config —
so every CUDA-pinned sidecar install has to name the index itself.
MOSS-TTS-v1.5's install did not, and its ``[torch-runtime]`` extra
(``torch==2.9.1+cu128``) could never resolve: ``uv pip compile`` reports it
unsatisfiable without this index and resolves it with it. One definition here,
imported by the one-click installer and by the engine's own bootstrap, so the
two cannot drift apart again. ``tests/test_sidecar_install.py`` pins the URL
to the ``pytorch-cuda`` index declared in the app's ``pyproject.toml``.
"""
PYTORCH_CU128_INDEX_URL = "https://download.pytorch.org/whl/cu128"
# `unsafe-best-match`: the PyTorch index also mirrors common dependencies
# (numpy, pillow, sympy, …) at a narrower range of versions than PyPI. uv's
# default first-index strategy would stop at whichever index lists a name first
# and could pin an old mirror copy or fail outright. The index is PyTorch's
# official one, so the dependency-confusion risk the name warns about does not
# apply to it.
UV_PIP_CU128_ARGS: tuple[str, ...] = (
"--extra-index-url",
PYTORCH_CU128_INDEX_URL,
"--index-strategy",
"unsafe-best-match",
)
PYTORCH_CPU_INDEX_URL = "https://download.pytorch.org/whl/cpu"
# For an engine that runs torch only on the CPU (PocketTTS). On Linux, PyPI's
# torch is the CUDA build and pulls ~15 NVIDIA packages the engine never uses;
# this index serves `+cpu` builds for Linux and Windows and the regular build
# for macOS.
UV_PIP_CPU_ARGS: tuple[str, ...] = (
"--extra-index-url",
PYTORCH_CPU_INDEX_URL,
"--index-strategy",
"unsafe-best-match",
)
@@ -0,0 +1,89 @@
"""moss-tts-nano-subprocess: MOSS-TTS-Nano from its own venv (one-click install).
The in-process engine needs ``moss_tts_nano`` installed into VoiceStudio's own
environment, with upstream's exact pins (torch 2.7.0, transformers 4.57.1)
landing there too. It also looks for a model class the package no longer
exports: at the commit pinned here, ``moss_tts_nano`` exports only
``__version__``, and the entry point is the top-level
``moss_tts_nano_runtime.NanoTTSService``. The one-click installer clones that
reviewed commit into ``DATA_DIR/engines/moss-tts-nano/`` with its own venv,
and this class runs upstream's runtime there in a sidecar.
The engine id stays ``moss-tts-nano``. ``tts_backend._effective_backend_class``
resolves to this class once that venv exists, and to the in-process
``MossTTSNanoBackend`` otherwise.
"""
from __future__ import annotations
import math
import os
from pathlib import Path
from services.subprocess_backend import SubprocessBackend
VENV_ENV_VAR = "OMNIVOICE_MOSS_TTS_NANO_DIR"
def own_venv_python() -> "Path | None":
"""The venv the one-click installer made for MOSS-TTS-Nano, if any."""
from services.sidecar_install import engine_venv_python
return engine_venv_python(VENV_ENV_VAR)
class MossTTSNanoSubprocessBackend(SubprocessBackend):
"""MOSS-TTS-Nano in a killable sidecar running the engine's own venv."""
id = "moss-tts-nano"
display_name = "MOSS-TTS-Nano (20 langs, CPU realtime, 48 kHz)"
gpu_compat = ("cuda", "cpu")
_DEFAULT_SAMPLE_RATE = 48_000
@classmethod
def is_available(cls) -> tuple[bool, str]:
if own_venv_python() is None:
return False, (
"moss_tts_nano package not installed. Install it from "
"Model Catalogue."
)
return True, "ready"
@classmethod
def venv_python(cls) -> Path:
py = own_venv_python()
if py is None:
raise RuntimeError(
"MOSS-TTS-Nano's environment is missing. Reinstall it from "
"Model Catalogue."
)
return py
@classmethod
def sidecar_script(cls) -> Path:
return Path(__file__).resolve().parent / "main.py"
@property
def recv_timeout_s(self) -> float:
# A cold load downloads the model and its audio tokenizer; the sidecar
# heartbeats progress frames meanwhile, and each re-arms this deadline.
try:
v = float(os.environ.get("OMNIVOICE_MOSS_TTS_NANO_RECV_TIMEOUT_S", "900"))
except (TypeError, ValueError):
return 900.0
if not math.isfinite(v): # reject inf/nan so the deadline can't be disabled
return 900.0
return max(30.0, v)
@property
def sample_rate(self) -> int:
# The sidecar resamples to this rate if upstream ever returns another.
return self._DEFAULT_SAMPLE_RATE
@property
def supported_languages(self) -> list[str]:
from services.tts_backend import MossTTSNanoBackend
return MossTTSNanoBackend.supported_languages.fget(self)
__all__ = ["MossTTSNanoSubprocessBackend", "VENV_ENV_VAR", "own_venv_python"]
@@ -0,0 +1,254 @@
"""moss-tts-nano sidecar: MOSS-TTS-Nano in the engine's own venv (one-click install).
Launched as ``<engine venv python> main.py`` by MossTTSNanoSubprocessBackend.
The venv holds the reviewed upstream checkout, installed editable, and its
pinned dependencies, so this file imports nothing from the app. It drives the
runtime that checkout ships, ``moss_tts_nano_runtime.NanoTTSService``.
Wire protocol: identical to the other sidecars (engines/pockettts/main.py).
Progress frames are sent while the model loads and through the first
synthesis, which is when upstream fetches its audio tokenizer. Later calls
send none, so the parent's watchdog still catches a generation that wedges.
"""
from __future__ import annotations
import base64
import contextlib
import json
import os
import re
import struct
import sys
import tempfile
import threading
import time
import traceback
# Mirrors services/subprocess_backend.py::MAX_FRAME_BYTES.
MAX_FRAME_BYTES = 64 * 1024 * 1024
#: The rate the engine reports; upstream's output is resampled to it if needed.
NANO_SAMPLE_RATE = 48_000
_HEARTBEAT_S = 5.0
#: ref_audio must be a local file path, not a URL (local-first; no SSRF).
_URL_RE = re.compile(r"^[a-z][a-z0-9+.\-]*://", re.IGNORECASE)
#: A download failure worth retrying; anything else propagates at once.
_TRANSIENT_MARKERS = (
"connection", "timed out", "timeout", "peer closed", "incomplete",
"remoteprotocolerror", "temporarily unavailable",
)
_SERVICE = None
_WARM = False
# -- wire protocol -----------------------------------------------------------
_send_lock = threading.Lock()
def _send(stream, obj: dict) -> None:
body = json.dumps(obj, separators=(",", ":")).encode("utf-8")
with _send_lock:
stream.write(struct.pack("!I", len(body)))
stream.write(body)
stream.flush()
def _recv(stream):
header = stream.read(4)
if len(header) < 4:
return None # EOF
(n,) = struct.unpack("!I", header)
if n > MAX_FRAME_BYTES:
raise IOError(f"frame too large: {n}")
body = bytearray()
while len(body) < n:
chunk = stream.read(n - len(body))
if not chunk:
raise IOError("short read")
body.extend(chunk)
return json.loads(bytes(body).decode("utf-8"))
def _measure_vram_mb() -> float:
try:
import torch # noqa: PLC0415
if torch.cuda.is_available():
return float(torch.cuda.memory_allocated()) / (1024 * 1024)
except Exception: # noqa: BLE001 — a probe, never fatal
pass
return 0.0
# -- loading -----------------------------------------------------------------
def _with_retries(action):
"""Run ``action``, retrying a transient download failure with a short
backoff, the way the app's own loader does for in-process engines."""
try:
attempts = max(1, int(os.environ.get("OMNIVOICE_MODEL_LOAD_RETRIES", "3")))
except ValueError:
attempts = 3
for attempt in range(1, attempts + 1):
try:
return action()
except Exception as exc: # noqa: BLE001 — classified below
text = f"{type(exc).__name__}: {exc}".lower()
if attempt == attempts or not any(m in text for m in _TRANSIENT_MARKERS):
raise
time.sleep(2.0 * attempt)
raise AssertionError("unreachable")
@contextlib.contextmanager
def _heartbeat(stdout, stage: str):
"""Progress frames every few seconds while a download may be running."""
stop = threading.Event()
def beat() -> None:
pct = 1
while not stop.wait(_HEARTBEAT_S):
pct = min(pct + 1, 99)
_send(stdout, {"op": "progress", "stage": stage, "percent": pct})
thread = threading.Thread(target=beat, daemon=True)
thread.start()
try:
yield
finally:
stop.set()
thread.join(timeout=_HEARTBEAT_S + 1)
def _load_service(stdout):
global _SERVICE
if _SERVICE is not None:
return _SERVICE
_send(stdout, {"op": "progress", "stage": "loading_model", "percent": 0})
with _heartbeat(stdout, "loading_model"):
from moss_tts_nano.defaults import ( # type: ignore[import-not-found] # noqa: PLC0415
DEFAULT_AUDIO_TOKENIZER_PATH,
DEFAULT_CHECKPOINT_PATH,
)
from moss_tts_nano_runtime import NanoTTSService # type: ignore[import-not-found] # noqa: PLC0415
service = NanoTTSService(
checkpoint_path=os.environ.get("OMNIVOICE_MOSS_TTS_MODEL", DEFAULT_CHECKPOINT_PATH),
audio_tokenizer_path=os.environ.get(
"OMNIVOICE_MOSS_TTS_TOKENIZER", DEFAULT_AUDIO_TOKENIZER_PATH
),
# Upstream writes every synthesis to a file; keep them out of the
# install folder (and see _handle_synthesize: one file, reused).
output_dir=tempfile.mkdtemp(prefix="moss-tts-nano-"),
)
_with_retries(lambda: service.preload(load_model=True))
_SERVICE = service
_send(stdout, {"op": "progress", "stage": "loading_model", "percent": 100})
return _SERVICE
# -- synthesis ---------------------------------------------------------------
def _mono_pcm_b64(result: dict) -> tuple[str, int]:
"""Upstream's waveform, downmixed to mono at NANO_SAMPLE_RATE, as base64
int16 PCM. The in-process engine downmixed the same way."""
import numpy as np # noqa: PLC0415
wav = np.asarray(result["waveform_numpy"], dtype=np.float32)
if wav.ndim == 2: # (samples, channels): the layout upstream returns
wav = wav.mean(axis=1)
sr = int(result.get("sample_rate") or NANO_SAMPLE_RATE)
if sr != NANO_SAMPLE_RATE:
import torch # noqa: PLC0415
import torchaudio # noqa: PLC0415
wav = torchaudio.functional.resample(torch.from_numpy(wav), sr, NANO_SAMPLE_RATE).numpy()
wav = np.clip(wav, -1.0, 1.0)
pcm = (wav * 32767.0).astype(np.int16).tobytes()
return base64.b64encode(pcm).decode("ascii"), int(wav.shape[-1])
def _handle_synthesize(msg: dict, stdout) -> None:
global _WARM
text = msg.get("text")
if not text or not isinstance(text, str):
raise ValueError("synthesize: missing or non-string 'text'")
ref_audio = msg.get("ref_audio") or None
if ref_audio and _URL_RE.match(str(ref_audio)):
raise ValueError(
"ref_audio must be a local file path; URLs are not accepted (local-first)."
)
service = _load_service(stdout)
kwargs = {
"text": text,
# Reference cloning, as the in-process engine did; with no clip,
# upstream uses its default voice preset.
"mode": "voice_clone",
"prompt_audio_path": ref_audio,
"output_audio_path": os.path.join(str(service.output_dir), "last.wav"),
}
if _WARM:
result = service.synthesize(**kwargs)
else:
with _heartbeat(stdout, "loading_model"):
result = _with_retries(lambda: service.synthesize(**kwargs))
_WARM = True
pcm_b64, n_samples = _mono_pcm_b64(result)
_send(stdout, {
"op": "audio",
"audio_pcm_b64": pcm_b64,
"sample_rate": NANO_SAMPLE_RATE,
"n_samples": n_samples,
})
# -- main loop ---------------------------------------------------------------
def main() -> int:
stdin = sys.stdin.buffer
# Frames go down a PRIVATE fd, and fd 1 is pointed at stderr (#1428): the
# libraries this loads print to fd 1, and those bytes would otherwise
# interleave with the length-prefixed frames.
_frame_fd = os.dup(1)
os.dup2(2, 1)
stdout = os.fdopen(_frame_fd, "wb")
_send(stdout, {"op": "ready", "engine": "moss-tts-nano", "sample_rate": NANO_SAMPLE_RATE})
while True:
try:
msg = _recv(stdin)
except Exception as exc: # noqa: BLE001
_send(stdout, {
"op": "error",
"stage": "recv",
"message": f"{type(exc).__name__}: {exc}",
"traceback": traceback.format_exc(),
})
return 1
if msg is None:
return 0
op = msg.get("op") if isinstance(msg, dict) else None
try:
if op == "ping":
_send(stdout, {"op": "pong", "vram_mb": _measure_vram_mb()})
elif op == "synthesize":
_handle_synthesize(msg, stdout)
elif op == "shutdown":
return 0
else:
_send(stdout, {"op": "error", "stage": "dispatch", "message": f"unknown op: {op!r}"})
except Exception as exc: # noqa: BLE001
_send(stdout, {
"op": "error",
"stage": op or "unknown",
"message": f"{type(exc).__name__}: {exc}",
"traceback": traceback.format_exc(),
})
if __name__ == "__main__":
sys.exit(main())
+11 -5
View File
@@ -222,8 +222,7 @@ def _bootstrap_engines_venv(clone_dir: Path) -> Path:
Runs ``uv venv <engines_venv>`` then ``uv pip install --python
<engines_venv>/bin/python -e "<clone>[torch-runtime]"``. Verifies the
result by re-probing the import — a successful uv invocation that still
can't import the stack indicates a deeper environment problem (e.g. the
``+cu128`` torch-runtime extra can't resolve on a non-CUDA host) and we
can't import the stack indicates a deeper environment problem, and we
raise with whatever stderr we captured plus a docs pointer.
"""
uv = _locate_uv()
@@ -254,6 +253,8 @@ def _bootstrap_engines_venv(clone_dir: Path) -> Path:
f"{exc.stderr.decode('utf-8', errors='replace') if exc.stderr else exc}"
) from exc
from core.torch_indexes import UV_PIP_CU128_ARGS
python_path = _venv_python_path(_ENGINES_VENV_DIR)
try:
subprocess.run(
@@ -261,6 +262,10 @@ def _bootstrap_engines_venv(clone_dir: Path) -> Path:
uv, "pip", "install",
"--python", str(python_path),
"-e", f"{clone_dir}[torch-runtime]",
# The extra pins torch==2.9.1+cu128, which exists only on
# PyTorch's index — without it this could never resolve, on
# any host (core.torch_indexes).
*UV_PIP_CU128_ARGS,
],
check=True,
timeout=_UV_PIP_INSTALL_TIMEOUT_S,
@@ -270,9 +275,10 @@ def _bootstrap_engines_venv(clone_dir: Path) -> Path:
except subprocess.CalledProcessError as exc:
raise RuntimeError(
"uv pip install -e failed during MOSS-TTS-v1.5 bootstrap "
f"({clone_dir}). On a non-CUDA host the upstream '[torch-runtime]' "
"extra (cu128) cannot resolve — set up the venv manually per "
"docs/engines/moss-tts-v15.md. Error: "
# uv's own error names what failed; the PyTorch index is always
# supplied now, so a guess about the host would only mislead.
f"({clone_dir}). See docs/engines/moss-tts-v15.md for the manual "
"install. Error: "
f"{exc.stderr.decode('utf-8', errors='replace') if exc.stderr else exc}"
) from exc
+24 -14
View File
@@ -48,6 +48,15 @@ from services.subprocess_backend import SubprocessBackend
logger = logging.getLogger("omnivoice.engines.pockettts")
_VENV_ENV_VAR = "OMNIVOICE_POCKETTTS_DIR"
def _own_venv_python() -> "Path | None":
"""The venv the one-click installer made for this engine, if any."""
from services.sidecar_install import engine_venv_python
return engine_venv_python(_VENV_ENV_VAR)
if TYPE_CHECKING:
import torch # noqa: F401
@@ -121,16 +130,17 @@ class PocketTTSBackend(SubprocessBackend):
def is_available(cls) -> tuple[bool, str]:
if platform_error := cls._platform_error():
return False, platform_error
# Optional-dep gate: the pocket-tts wheel is installed only when the user
# opted in. The interpreter is the parent's own (sys.executable), so
# there is no separate venv to validate.
try:
import pocket_tts # type: ignore[import-not-found] # noqa: F401
except Exception as e:
return False, (
f"pocket_tts package not installed or failed to import ({e}). "
f"Enable in Settings -> Engines (uv sync --extra pockettts)."
)
# Installed either into its own venv by the one-click installer, which
# verified `import pocket_tts` there before saving the path, or into the
# app's environment by `uv sync --extra pockettts`.
if _own_venv_python() is None:
try:
import pocket_tts # type: ignore[import-not-found] # noqa: F401
except Exception as e:
return False, (
f"pocket_tts package not installed or failed to import ({e}). "
"Install it from Model Catalogue."
)
# The model repository has an additional gated-access agreement and
# prohibited-use conditions beyond its CC-BY-4.0 license. Keep first
@@ -145,10 +155,10 @@ class PocketTTSBackend(SubprocessBackend):
@classmethod
def venv_python(cls) -> Path:
# Parent interpreter: pocket-tts deps (torch>=2.5, scipy, beartype) sit
# happily at the parent's pins, so this isolates for crash recovery, not
# dependency pins (same rationale as omnivoice-subprocess).
return Path(sys.executable)
# Its own venv when the one-click installer made one. Otherwise the
# parent interpreter, where `uv sync --extra pockettts` installs it
# (its deps sit happily at the parent's pins).
return _own_venv_python() or Path(sys.executable)
@classmethod
def sidecar_script(cls) -> Path:
+23 -12
View File
@@ -48,6 +48,15 @@ if TYPE_CHECKING:
logger = logging.getLogger("omnivoice.supertonic3")
_VENV_ENV_VAR = "OMNIVOICE_SUPERTONIC3_DIR"
def _own_venv_python() -> "Path | None":
"""The venv the one-click installer made for this engine, if any."""
from services.sidecar_install import engine_venv_python
return engine_venv_python(_VENV_ENV_VAR)
# Absolute path to the sidecar script ‑‑ same pattern as IndexTTS's
# ``INDEXTTS_SIDECAR_SCRIPT``. SubprocessBackend spawns it with the
@@ -80,11 +89,11 @@ class Supertonic3Backend(SubprocessBackend):
@classmethod
def venv_python(cls) -> Path:
"""Supertonic-3 lives in the main OmniVoice venv ‑‑ no dedicated
venv. ``sys.executable`` is the parent interpreter, which is the
same Python that ``uv sync --extra supertonic`` populated.
"""Its own venv when the one-click installer made one. Otherwise the
parent interpreter, the same Python ``uv sync --extra supertonic``
populated.
"""
return Path(sys.executable)
return _own_venv_python() or Path(sys.executable)
@classmethod
def sidecar_script(cls) -> Path:
@@ -96,14 +105,16 @@ class Supertonic3Backend(SubprocessBackend):
def is_available(cls) -> tuple[bool, str]:
# 1. Optional-dep gate (TTS-02). The ``supertonic`` wheel is only
# installed when the user opted in via ``--extra supertonic``.
try:
import supertonic # type: ignore[import-not-found] # noqa: F401
except ImportError:
return False, (
"supertonic package not installed. Enable in "
"Model Catalogue (installs `supertonic` via `uv add --optional "
"supertonic supertonic==1.3.1`)."
)
# Its own venv (made by the one-click installer, which verified the
# import there) or the app's environment (`uv sync --extra`).
if _own_venv_python() is None:
try:
import supertonic # type: ignore[import-not-found] # noqa: F401
except ImportError:
return False, (
"supertonic package not installed. Install it from "
"Model Catalogue."
)
# 2. License acceptance gate (TTS-05). Defence in depth: the
# settings_store helper handles the read; we just refuse
+11 -3
View File
@@ -137,9 +137,17 @@ def _resolve_pinned_sha() -> str:
# Final fallback ‑‑ relative import for when the file is invoked
# via ``python backend/engines/supertonic3/sidecar.py`` rather
# than via ``python -m backend.engines.supertonic3.sidecar``.
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from engines.supertonic3.constants import PINNED_REVISION_SHA # type: ignore[import-not-found]
return PINNED_REVISION_SHA
# Load constants.py by path. Importing it as `engines.supertonic3…`
# runs the package __init__, which imports the app's backend, and that
# is absent from the engine's own venv (one-click install).
import importlib.util
spec = importlib.util.spec_from_file_location(
"_supertonic3_constants", Path(__file__).resolve().with_name("constants.py"),
)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module) # type: ignore[union-attr]
return module.PINNED_REVISION_SHA
# ── model loading (lazy, on first synthesize) ─────────────────────────────
@@ -0,0 +1,106 @@
"""voxcpm2-subprocess: VoxCPM2 from its own venv (one-click install).
VoxCPM2 used to run only in-process, which meant installing ``voxcpm``, and a
torch of its choosing, into VoiceStudio's own environment. The one-click
installer now gives it a venv under ``DATA_DIR/engines/voxcpm2/``, and this
class runs the model there in a sidecar, so nothing it installs can touch the
app or another engine.
The engine id stays ``voxcpm2``. ``tts_backend._effective_backend_class``
resolves to this class once that venv exists and to the in-process
``VoxCPM2Backend`` otherwise, so an install made with ``pip install voxcpm``
keeps working as it always has. What the app sees is the same: voice design,
48 kHz output, its own mastering, the same languages. The parent still
prepares the reference clip and trims the silent tail, as the in-process
engine does.
"""
from __future__ import annotations
import math
import os
from pathlib import Path
from typing import TYPE_CHECKING
from services.subprocess_backend import SubprocessBackend
if TYPE_CHECKING:
import torch # noqa: F401
VENV_ENV_VAR = "OMNIVOICE_VOXCPM2_DIR"
def own_venv_python() -> "Path | None":
"""The venv the one-click installer made for VoxCPM2, if any."""
from services.sidecar_install import engine_venv_python
return engine_venv_python(VENV_ENV_VAR)
class VoxCPM2SubprocessBackend(SubprocessBackend):
"""VoxCPM2 in a killable sidecar running the engine's own venv."""
id = "voxcpm2"
display_name = "VoxCPM2 (30 langs, studio 48 kHz, voice design)"
supports_voice_design = True
applies_own_mastering = True # native 48 kHz studio output — skip apply_mastering()
gpu_compat = ("cuda", "mps", "cpu")
_DEFAULT_SAMPLE_RATE = 48_000
@classmethod
def is_available(cls) -> tuple[bool, str]:
if own_venv_python() is None:
return False, (
"voxcpm package not installed. Install it from Model Catalogue."
)
return True, "ready"
@classmethod
def venv_python(cls) -> Path:
py = own_venv_python()
if py is None:
raise RuntimeError(
"VoxCPM2's environment is missing. Reinstall it from "
"Model Catalogue."
)
return py
@classmethod
def sidecar_script(cls) -> Path:
return Path(__file__).resolve().parent / "main.py"
@property
def recv_timeout_s(self) -> float:
# A cold load downloads several GB of weights; the sidecar heartbeats
# progress frames meanwhile, and each one re-arms this deadline.
try:
v = float(os.environ.get("OMNIVOICE_VOXCPM2_RECV_TIMEOUT_S", "900"))
except (TypeError, ValueError):
return 900.0
if not math.isfinite(v): # reject inf/nan so the deadline can't be disabled
return 900.0
return max(30.0, v)
@property
def sample_rate(self) -> int:
return self._DEFAULT_SAMPLE_RATE
@property
def supported_languages(self) -> list[str]:
from services.tts_backend import VoxCPM2Backend
return VoxCPM2Backend.supported_languages.fget(self)
def generate(self, text: str, **kw) -> "torch.Tensor":
# The same preparation and finishing as VoxCPM2Backend.generate: the
# reference clip is trimmed and capped here (the model no longer does
# it), and the output's long silent tail is cut.
from services.audio_dsp import trim_trailing_silence
from services.tts_backend import _prepare_voxcpm_ref
if kw.get("ref_audio"):
kw["ref_audio"] = _prepare_voxcpm_ref(kw["ref_audio"])
wav = super().generate(text, **kw)
return trim_trailing_silence(wav, self.sample_rate)
__all__ = ["VENV_ENV_VAR", "VoxCPM2SubprocessBackend", "own_venv_python"]
+263
View File
@@ -0,0 +1,263 @@
"""voxcpm2 sidecar: VoxCPM2 in the engine's own venv (one-click install).
Launched as ``<engine venv python> main.py`` by VoxCPM2SubprocessBackend. It
imports nothing from the app: the venv holds only ``voxcpm`` and what it
depends on (torch, torchaudio, numpy), so this file must stay importable with
the standard library plus those. The parent prepares the reference clip and
trims the output's silent tail, exactly as the in-process engine does; this
process only loads the model and synthesizes.
Wire protocol: length-prefixed JSON over stdio, identical to the other
sidecars (engines/pockettts/main.py). A ``ready`` frame comes first, then one
``audio`` (or ``error``) frame per ``synthesize``, with ``progress`` frames
while a cold load runs so the parent's watchdog stays armed.
"""
from __future__ import annotations
import base64
import json
import os
import re
import struct
import sys
import threading
import time
import traceback
# Mirrors services/subprocess_backend.py::MAX_FRAME_BYTES.
MAX_FRAME_BYTES = 64 * 1024 * 1024
#: VoxCPM2's studio output rate; the in-process engine assumes the same.
VOXCPM2_SAMPLE_RATE = 48_000
#: Emit a progress frame at least this often during a cold load (a multi-GB
#: first download) so the parent's recv watchdog doesn't kill a healthy sidecar.
_HEARTBEAT_S = 5.0
#: ref_audio must be a local file path, not a URL (local-first; no SSRF).
_URL_RE = re.compile(r"^[a-z][a-z0-9+.\-]*://", re.IGNORECASE)
#: A download failure worth retrying (the HF cache resumes, so a retry
#: continues rather than restarts). Anything else propagates at once.
_TRANSIENT_MARKERS = (
"connection", "timed out", "timeout", "peer closed", "incomplete",
"remoteprotocolerror", "temporarily unavailable",
)
_MODEL = None
# -- wire protocol -----------------------------------------------------------
#: Serializes _send across threads (the cold-load heartbeat + the main loop) so
#: concurrent length+body writes can't interleave and corrupt the framing.
_send_lock = threading.Lock()
def _send(stream, obj: dict) -> None:
body = json.dumps(obj, separators=(",", ":")).encode("utf-8")
with _send_lock:
stream.write(struct.pack("!I", len(body)))
stream.write(body)
stream.flush()
def _recv(stream):
header = stream.read(4)
if len(header) < 4:
return None # EOF
(n,) = struct.unpack("!I", header)
if n > MAX_FRAME_BYTES:
raise IOError(f"frame too large: {n}")
body = bytearray()
while len(body) < n:
chunk = stream.read(n - len(body))
if not chunk:
raise IOError("short read")
body.extend(chunk)
return json.loads(bytes(body).decode("utf-8"))
def _measure_vram_mb() -> float:
try:
import torch # noqa: PLC0415
if torch.cuda.is_available():
return float(torch.cuda.memory_allocated()) / (1024 * 1024)
except Exception: # noqa: BLE001 — a probe, never fatal
pass
return 0.0
# -- model loading (lazy, on the first synthesize) ---------------------------
def _with_retries(load):
"""Run ``load``, retrying a transient download failure with a short
backoff, the way the app's own loader does for in-process engines."""
try:
attempts = max(1, int(os.environ.get("OMNIVOICE_MODEL_LOAD_RETRIES", "3")))
except ValueError:
attempts = 3
for attempt in range(1, attempts + 1):
try:
return load()
except Exception as exc: # noqa: BLE001 — classified below
text = f"{type(exc).__name__}: {exc}".lower()
if attempt == attempts or not any(m in text for m in _TRANSIENT_MARKERS):
raise
time.sleep(2.0 * attempt)
raise AssertionError("unreachable")
def _load_model(stdout):
global _MODEL
if _MODEL is not None:
return _MODEL
_send(stdout, {"op": "progress", "stage": "loading_model", "percent": 0})
stop = threading.Event()
def _heartbeat() -> None:
pct = 1
while not stop.wait(_HEARTBEAT_S):
pct = min(pct + 1, 99)
_send(stdout, {"op": "progress", "stage": "loading_model", "percent": pct})
hb = threading.Thread(target=_heartbeat, daemon=True)
hb.start()
try:
from voxcpm import VoxCPM # type: ignore[import-not-found] # noqa: PLC0415
checkpoint = os.environ.get("OMNIVOICE_VOXCPM_MODEL", "openbmb/VoxCPM2")
_MODEL = _with_retries(
lambda: VoxCPM.from_pretrained(checkpoint, load_denoiser=False)
)
finally:
stop.set()
hb.join(timeout=_HEARTBEAT_S + 1)
_send(stdout, {"op": "progress", "stage": "loading_model", "percent": 100})
return _MODEL
def _sample_rate(model) -> int:
for owner in (model, getattr(model, "tts_model", None)):
sr = getattr(owner, "sample_rate", None)
if isinstance(sr, int) and sr > 0:
return sr
return VOXCPM2_SAMPLE_RATE
def _at_engine_rate(wav, sample_rate: int):
"""The waveform at VOXCPM2_SAMPLE_RATE. The parent reads the PCM at that
fixed rate (it trims the tail and labels the audio with it), so a model
reporting another rate is resampled here rather than mislabelled."""
if sample_rate == VOXCPM2_SAMPLE_RATE:
return wav
import torch # noqa: PLC0415
import torchaudio # noqa: PLC0415
tensor = torch.as_tensor(wav.detach().cpu() if hasattr(wav, "detach") else wav,
dtype=torch.float32).reshape(-1)
return torchaudio.functional.resample(tensor, sample_rate, VOXCPM2_SAMPLE_RATE)
def _to_pcm_b64(wav) -> tuple[str, int]:
"""A float waveform in [-1, 1] (numpy or torch) as base64 int16 PCM."""
import numpy as np # noqa: PLC0415
if hasattr(wav, "detach"):
wav = wav.detach().float().cpu().numpy()
arr = np.asarray(wav, dtype=np.float32).squeeze()
if arr.ndim > 1:
raise ValueError(f"expected mono audio (1-D after squeeze), got shape {arr.shape}")
arr = np.clip(arr, -1.0, 1.0)
pcm = (arr * 32767.0).astype(np.int16).tobytes()
return base64.b64encode(pcm).decode("ascii"), int(arr.shape[-1])
def _handle_synthesize(msg: dict, stdout) -> None:
"""One synthesize request. The mapping mirrors VoxCPM2Backend.generate."""
text = msg.get("text")
if not text or not isinstance(text, str):
raise ValueError("synthesize: missing or non-string 'text'")
ref_audio = msg.get("ref_audio") or None
if ref_audio and _URL_RE.match(str(ref_audio)):
raise ValueError(
"ref_audio must be a local file path; URLs are not accepted (local-first)."
)
model = _load_model(stdout)
description = msg.get("description")
cfg_value = msg.get("guidance_scale", 2.0)
timesteps = msg.get("num_step", 10)
if description and not ref_audio:
# Voice design: a voice from a text description, no reference clip.
wav = model.generate(
text=text,
voice_description=description,
cfg_value=cfg_value,
inference_timesteps=timesteps,
)
else:
instruct = msg.get("instruct")
ref_text = msg.get("ref_text")
wav = model.generate(
text=f"({instruct}){text}" if instruct else text,
cfg_value=cfg_value,
inference_timesteps=timesteps,
reference_wav_path=ref_audio,
prompt_wav_path=ref_audio if ref_text else None,
prompt_text=ref_text,
)
pcm_b64, n_samples = _to_pcm_b64(_at_engine_rate(wav, _sample_rate(model)))
_send(stdout, {
"op": "audio",
"audio_pcm_b64": pcm_b64,
"sample_rate": VOXCPM2_SAMPLE_RATE,
"n_samples": n_samples,
})
# -- main loop ---------------------------------------------------------------
def main() -> int:
stdin = sys.stdin.buffer
# Frames go down a PRIVATE fd, and fd 1 is pointed at stderr (#1428): the
# libraries this loads print to fd 1 (tqdm, native torch output), and those
# bytes would otherwise interleave with the length-prefixed frames.
_frame_fd = os.dup(1)
os.dup2(2, 1)
stdout = os.fdopen(_frame_fd, "wb")
# Ready handshake fires BEFORE any heavy import.
_send(stdout, {"op": "ready", "engine": "voxcpm2", "sample_rate": VOXCPM2_SAMPLE_RATE})
while True:
try:
msg = _recv(stdin)
except Exception as exc: # noqa: BLE001
_send(stdout, {
"op": "error",
"stage": "recv",
"message": f"{type(exc).__name__}: {exc}",
"traceback": traceback.format_exc(),
})
return 1
if msg is None:
return 0
op = msg.get("op") if isinstance(msg, dict) else None
try:
if op == "ping":
_send(stdout, {"op": "pong", "vram_mb": _measure_vram_mb()})
elif op == "synthesize":
_handle_synthesize(msg, stdout)
elif op == "shutdown":
return 0
else:
_send(stdout, {"op": "error", "stage": "dispatch", "message": f"unknown op: {op!r}"})
except Exception as exc: # noqa: BLE001
_send(stdout, {
"op": "error",
"stage": op or "unknown",
"message": f"{type(exc).__name__}: {exc}",
"traceback": traceback.format_exc(),
})
if __name__ == "__main__":
sys.exit(main())
+419 -21
View File
@@ -126,6 +126,39 @@ class SidecarSpec:
invalidate: Callable[[], None] = field(default=lambda: None)
# Cheap "is a healthy install already present?" probe (file existence only).
installed_probe: Callable[[], bool] = field(default=lambda: False)
# Extra `uv venv` arguments — an interpreter pin for an upstream that
# declares one, e.g. ("--python", "3.10").
venv_args: tuple[str, ...] = ()
# `uv pip install` target, "{checkout}" substituted. Each upstream installs
# differently (editable, editable with an extra, a requirements file, a
# constraints file); the default is the editable install IndexTTS uses.
install_args: tuple[str, ...] = ("-e", "{checkout}")
# Add PyTorch's CUDA index on a CUDA host. Plain PyPI torch is CPU-only on
# Windows, and `+cuNNN` local-version pins exist nowhere else.
uses_cuda_index: bool = False
# Python that proves the venv works; "{checkout}" / "{checkout_repr}"
# substituted. None means `import <probe_module>`.
probe_code: Optional[str] = None
# The file whose presence proves a fetched checkout is the whole
# repository. Most upstreams ship a pyproject.toml; Confucius4 ships
# only requirements.txt and setup.py.
source_manifest: str = "pyproject.toml"
# False for an engine that is a PyPI package, not a repository: nothing
# is fetched, and the managed root holds only the engine's own venv.
has_source: bool = True
# Add PyTorch's CPU index on every host, for an engine that only ever
# runs torch on the CPU (see core.torch_indexes).
cpu_torch_index: bool = False
# torch/torchaudio pins for an upstream that leaves torch unpinned. Left
# to the resolver, PyPI's newest torch (CPU-only on Windows) pairs with a
# CUDA torchaudio from the other index. The host picks the build of the
# pinned pair: `+cu128` on a CUDA host, `+cpu` on other Windows and Linux
# hosts, plain on macOS.
torch_pins: tuple[str, ...] = ()
# Can the one-click install work on THIS machine? (ok, reason). Consulted
# before an Install button is offered and again when an install starts, so
# a host the upstream does not support never gets a job that can only fail.
host_supported: Callable[[], tuple[bool, str]] = field(default=lambda: (True, ""))
def _indextts_invalidate() -> None:
@@ -138,6 +171,98 @@ def _indextts_installed() -> bool:
return is_indextts_installed()
def _moss_invalidate() -> None:
from engines.moss_tts_v15 import bootstrap
bootstrap.invalidate()
def _moss_installed() -> bool:
from engines.moss_tts_v15.bootstrap import is_moss_tts_v15_installed
return is_moss_tts_v15_installed()
def _confucius4_invalidate() -> None:
from engines.confucius4 import bootstrap
bootstrap.invalidate()
def _confucius4_installed() -> bool:
from engines.confucius4.bootstrap import is_confucius4_installed
return is_confucius4_installed()
def _dots_invalidate() -> None:
from engines.dots_tts import bootstrap
bootstrap.invalidate()
def _dots_installed() -> bool:
from engines.dots_tts.bootstrap import is_dots_tts_installed
return is_dots_tts_installed()
def _host_family() -> str:
"""The accelerator family this host runs, or "cpu" when it cannot tell."""
try:
from core.device_caps import detect_host_caps
return str(detect_host_caps().family)
except Exception: # noqa: BLE001 — a probe failure must not break installs
return "cpu"
def _torch_pin_args(spec: "SidecarSpec") -> list[str]:
from core.torch_indexes import UV_PIP_CPU_ARGS, UV_PIP_CU128_ARGS
if _host_family() == "cuda":
return [f"{pin}+cu128" for pin in spec.torch_pins] + list(UV_PIP_CU128_ARGS)
if sys.platform in ("win32", "linux"):
return [f"{pin}+cpu" for pin in spec.torch_pins] + list(UV_PIP_CPU_ARGS)
return list(spec.torch_pins)
def _moss_host() -> tuple[bool, str]:
if _host_family() == "cuda":
return True, ""
return False, (
"MOSS-TTS-v1.5's one-click install uses its CUDA build of PyTorch, and "
"this machine has no NVIDIA GPU available. Its guide covers a manual "
"CPU install."
)
def _dots_host() -> tuple[bool, str]:
if sys.platform != "win32":
return True, ""
return False, (
"dots.tts publishes no Windows install. Run VoiceStudio on Linux or "
"macOS, or under WSL2, to use it."
)
def _no_intel_mac(message: str) -> Callable[[], tuple[bool, str]]:
"""A host gate for an engine whose pinned PyTorch has no Intel Mac build
(PyTorch stopped publishing macOS x86_64 wheels after 2.2)."""
def gate() -> tuple[bool, str]:
import platform
if sys.platform == "darwin" and platform.machine().lower() == "x86_64":
return False, message
return True, ""
return gate
def _in_app_env(module: str) -> Callable[[], bool]:
"""An install made with ``uv sync --extra`` lives in the app's own
environment. It counts as installed, so the installer never provisions a
second copy over one that works."""
def probe() -> bool:
import importlib.util
try:
return importlib.util.find_spec(module) is not None
except (ImportError, ValueError):
return False
return probe
SPECS: dict[str, SidecarSpec] = {
"indextts2": SidecarSpec(
engine_id="indextts2",
@@ -170,9 +295,219 @@ SPECS: dict[str, SidecarSpec] = {
invalidate=_indextts_invalidate,
installed_probe=_indextts_installed,
),
# Pinned to the upstream commits current on 2026-09-10. Weights are not
# fetched here: each engine downloads them into the shared HF cache on its
# first synthesis, as its manual install always has.
"moss-tts-v15": SidecarSpec(
engine_id="moss-tts-v15",
display_name="MOSS-TTS-v1.5",
repo_url="https://github.com/OpenMOSS/MOSS-TTS.git",
tarball_url=(
"https://github.com/OpenMOSS/MOSS-TTS/archive/"
"934d6826b084c46a0d033402174d5f8ac4ed2519.tar.gz"
),
checkout_dirname="MOSS-TTS",
env_var="OMNIVOICE_MOSS_TTS_V15_DIR",
probe_module="transformers",
probe_code="import transformers, torch",
source_revision="934d6826b084c46a0d033402174d5f8ac4ed2519",
source_required_path="pyproject.toml",
venv_args=("--python", "3.11"),
install_args=("-e", "{checkout}[torch-runtime]"),
uses_cuda_index=True,
host_supported=_moss_host,
docs_path="docs/engines/moss-tts-v15.md",
# ~7 GB CUDA torch venv now, ~16 GB of weights on first synthesis.
required_bytes=24 * _GIB,
dependency_bytes=8 * _GIB,
temporary_free_bytes=8 * _GIB,
disk_confidence="estimated",
invalidate=_moss_invalidate,
installed_probe=_moss_installed,
),
"confucius4-tts": SidecarSpec(
engine_id="confucius4-tts",
display_name="Confucius4-TTS",
repo_url="https://github.com/netease-youdao/Confucius4-TTS.git",
tarball_url=(
"https://github.com/netease-youdao/Confucius4-TTS/archive/"
"4fb32c481302d8858c3aec6a1c2a8b4cea8894c0.tar.gz"
),
checkout_dirname="Confucius4-TTS",
env_var="OMNIVOICE_CONFUCIUS4_TTS_DIR",
probe_module="confuciustts",
# Upstream is not pip-installable; the package resolves from the
# checkout on sys.path, exactly as the engine's sidecar imports it.
probe_code="import sys; sys.path.insert(0, {checkout_repr}); import confuciustts",
source_revision="4fb32c481302d8858c3aec6a1c2a8b4cea8894c0",
# No pyproject.toml upstream: requirements.txt is its manifest.
source_manifest="requirements.txt",
source_required_path="setup.py",
venv_args=("--python", "3.10"),
install_args=("-r", "{checkout}/requirements.txt"),
# torch==2.7.0: CPU-only from PyPI on Windows; the CUDA index supplies
# 2.7.0+cu128, which satisfies the same pin.
uses_cuda_index=True,
docs_path="docs/engines/confucius4-tts.md",
# ~7 GB venv now, ~5 GB of weights on first synthesis.
required_bytes=14 * _GIB,
dependency_bytes=8 * _GIB,
temporary_free_bytes=8 * _GIB,
disk_confidence="estimated",
invalidate=_confucius4_invalidate,
installed_probe=_confucius4_installed,
),
"dots-tts": SidecarSpec(
engine_id="dots-tts",
display_name="dots.tts",
repo_url="https://github.com/rednote-hilab/dots.tts.git",
tarball_url=(
"https://github.com/rednote-hilab/dots.tts/archive/"
"32407a55228630475c48ecdb2c4e2c0f9c09e030.tar.gz"
),
checkout_dirname="dots.tts",
env_var="OMNIVOICE_DOTS_TTS_DIR",
probe_module="dots_tts.runtime",
source_revision="32407a55228630475c48ecdb2c4e2c0f9c09e030",
source_required_path="constraints/recommended.txt",
# Upstream requires-python is >=3.10,<3.13.
venv_args=("--python", "3.11"),
install_args=("-e", "{checkout}", "-c", "{checkout}/constraints/recommended.txt"),
host_supported=_dots_host,
docs_path="docs/engines/dots-tts.md",
# ~7 GB venv now, ~9 GB checkpoint on first synthesis.
required_bytes=18 * _GIB,
dependency_bytes=8 * _GIB,
temporary_free_bytes=8 * _GIB,
disk_confidence="estimated",
invalidate=_dots_invalidate,
installed_probe=_dots_installed,
),
# PyPI packages rather than repositories: nothing to clone, and the managed
# root holds only the engine's own venv. The pins are the app's own
# optional extras (a test ties the two together), so the engine runs the
# same wheel whichever way it was installed.
"supertonic3": SidecarSpec(
engine_id="supertonic3",
display_name="Supertonic-3",
repo_url="",
tarball_url="",
checkout_dirname="supertonic3",
env_var="OMNIVOICE_SUPERTONIC3_DIR",
probe_module="supertonic",
has_source=False,
venv_args=("--python", "3.11"),
install_args=("supertonic==1.3.1",),
docs_path="docs/engines/supertonic3.md",
# onnxruntime + numpy + huggingface_hub, no torch. The ~400 MB of
# weights download on first synthesis into the shared HF cache.
required_bytes=1 * _GIB,
installed_probe=_in_app_env("supertonic"),
),
"pockettts": SidecarSpec(
engine_id="pockettts",
display_name="PocketTTS",
repo_url="",
tarball_url="",
checkout_dirname="pockettts",
env_var="OMNIVOICE_POCKETTTS_DIR",
probe_module="pocket_tts",
has_source=False,
venv_args=("--python", "3.11"),
install_args=("pocket-tts==2.1.0",),
cpu_torch_index=True,
docs_path="docs/engines/pockettts.md",
# CPU torch + scipy. The gated weights download on first use.
required_bytes=3 * _GIB,
installed_probe=_in_app_env("pocket_tts"),
host_supported=_no_intel_mac(
"PocketTTS needs a PyTorch version that has no Intel Mac build."
),
),
# Run in a sidecar from its own venv (engines/voxcpm2_subprocess). voxcpm
# leaves torch unpinned, so the pair is pinned here; each build of it was
# resolved with voxcpm==2.0.3 on 2026-09-10 (Windows and Linux: +cu128 and
# +cpu; Apple Silicon: plain). Weights download on first synthesis.
"voxcpm2": SidecarSpec(
engine_id="voxcpm2",
display_name="VoxCPM2",
repo_url="",
tarball_url="",
checkout_dirname="voxcpm2",
env_var="OMNIVOICE_VOXCPM2_DIR",
probe_module="voxcpm",
has_source=False,
venv_args=("--python", "3.11"),
install_args=("voxcpm==2.0.3",),
torch_pins=("torch==2.11.0", "torchaudio==2.11.0"),
docs_path="docs/engines/voxcpm2.md",
# CUDA torch (~5 GB unpacked) + transformers.
required_bytes=10 * _GIB,
installed_probe=_in_app_env("voxcpm"),
host_supported=_no_intel_mac(
"VoxCPM2 needs a PyTorch version that has no Intel Mac build."
),
),
# Upstream is unpinned and its entry point has moved before (#1287), so the
# install pins a reviewed commit (2026-09-06) and the sidecar drives the
# runtime that commit ships (moss_tts_nano_runtime.NanoTTSService). Its
# pyproject pins torch==2.7.0 exactly, so the CUDA index yields +cu128.
"moss-tts-nano": SidecarSpec(
engine_id="moss-tts-nano",
display_name="MOSS-TTS-Nano",
repo_url="https://github.com/OpenMOSS/MOSS-TTS-Nano.git",
tarball_url=(
"https://github.com/OpenMOSS/MOSS-TTS-Nano/archive/"
"8b7bcc9341b3b4ef3a3a58ba1338a7d85ff133eb.tar.gz"
),
checkout_dirname="MOSS-TTS-Nano",
env_var="OMNIVOICE_MOSS_TTS_NANO_DIR",
probe_module="moss_tts_nano_runtime",
source_revision="8b7bcc9341b3b4ef3a3a58ba1338a7d85ff133eb",
source_required_path="moss_tts_nano_runtime.py",
docs_path="docs/engines/moss-tts-nano.md",
venv_args=("--python", "3.11"),
uses_cuda_index=True,
# torch 2.7 (CUDA build on NVIDIA hosts) + transformers + onnxruntime.
# The model and its audio tokenizer download on first synthesis.
required_bytes=8 * _GIB,
installed_probe=_in_app_env("moss_tts_nano"),
host_supported=_no_intel_mac(
"MOSS-TTS-Nano pins a PyTorch version that has no Intel Mac build."
),
),
}
class HostUnsupported(RuntimeError):
"""The one-click install cannot work on this machine. The message is a
VoiceStudio-owned sentence from the spec, safe to show the user."""
def host_support(spec: SidecarSpec) -> tuple[bool, str]:
"""Whether *spec*'s install can work here. A probe that raises counts as
unsupported: offering a button that fails is worse than not offering it."""
try:
ok, why = spec.host_supported()
except Exception: # noqa: BLE001
return False, (
f"Could not check whether {spec.display_name} can be installed on "
f"this machine. Its guide ({spec.docs_path}) has the manual steps."
)
return bool(ok), (why or "")
def installable_engine_ids() -> frozenset[str]:
"""Engines that get an Install button on THIS host."""
return frozenset(eid for eid, spec in SPECS.items() if host_support(spec)[0])
def _expand(value: str, checkout: Path) -> str:
return value.replace("{checkout_repr}", repr(str(checkout))).replace(
"{checkout}", str(checkout)
)
def get_spec(engine_id: str) -> Optional[SidecarSpec]:
return SPECS.get(engine_id)
@@ -196,6 +531,27 @@ def managed_checkout(spec: SidecarSpec) -> Path:
return managed_root(spec) / spec.checkout_dirname
def engine_venv_python(env_var: str) -> Optional[Path]:
"""The interpreter of the install *env_var* points at, if it has one.
For engines that can live in the app's environment or in a venv of their
own (PocketTTS, Supertonic-3): they prefer their own, and fall back to the
app's interpreter for an install made with ``uv sync --extra``.
"""
env_dir = os.environ.get(env_var)
if not env_dir:
return None
py = _venv_python(Path(env_dir) / ".venv")
# The interpreter alone proves nothing: a reinstall that failed partway
# leaves it behind. The completion marker is written only after the
# engine's import probe passed in this venv, and removed when a new
# dependency step starts, so it is the probe's verdict without running a
# multi-second import on every engine-list refresh.
if not py.is_file() or not (Path(env_dir) / _INSTALL_COMPLETE_MARKER).is_file():
return None
return py
def _legacy_managed_checkouts(spec: SidecarSpec) -> tuple[Path, ...]:
"""App-owned predecessor checkouts retained during in-place upgrades."""
if spec.engine_id == "indextts2":
@@ -272,13 +628,20 @@ def _default_uv_cache_root() -> Path:
return Path(os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache") / "uv"
def uv_subprocess_env(cache_parent: Path) -> "dict[str, str] | None":
def uv_subprocess_env(cache_parent: Path) -> "dict[str, str]":
"""Environment for ``uv`` subprocesses that install into *cache_parent*'s volume.
Returns ``None`` (inherit the parent environment untouched) when uv's
default cache already shares a volume with *cache_parent* or the user
pinned both variables themselves. Otherwise returns a copy of
``os.environ`` with the *unset* one(s) of ``UV_CACHE_DIR`` /
Always a copy of ``os.environ`` with ``UV_NO_CONFIG=1``: an engine's
install resolves its own requirements, never VoiceStudio's. The backend
runs inside the app's tree, so uv would otherwise discover the app's
``pyproject.toml`` and apply its ``[tool.uv] constraint-dependencies``
(``torch==2.8.0``) to the engine's venv. An engine pinning another torch
(MOSS-TTS-v1.5, Confucius4) could then never resolve, and one that pins
none got the app's torch instead of its own. Mirrors still apply: they
arrive as ``UV_INDEX_URL``, an environment variable, not a config file.
When uv's default cache is on another volume than *cache_parent*, the
copy also places the *unset* one(s) of ``UV_CACHE_DIR`` /
``UV_PYTHON_INSTALL_DIR`` placed inside *cache_parent*, so downloads, the
unpacked wheel cache, managed Pythons, and the venv all stay on the
target volume — and same-volume hardlink installs work again. The two
@@ -291,17 +654,15 @@ def uv_subprocess_env(cache_parent: Path) -> "dict[str, str] | None":
pass the directory that should hold the shared ``.uv-cache`` — typically
the common parent of the engine venvs on that volume.
"""
if _same_volume(cache_parent, _default_uv_cache_root()):
return None
env = dict(os.environ)
overrode = False
env["UV_NO_CONFIG"] = "1"
if _same_volume(cache_parent, _default_uv_cache_root()):
return env
if not env.get("UV_CACHE_DIR"): # explicit user choice always wins
env["UV_CACHE_DIR"] = str(Path(cache_parent) / ".uv-cache")
overrode = True
if not env.get("UV_PYTHON_INSTALL_DIR"):
env["UV_PYTHON_INSTALL_DIR"] = str(Path(cache_parent) / ".uv-python")
overrode = True
return env if overrode else None
return env
# ── Disk preflight ─────────────────────────────────────────────────────────
@@ -520,9 +881,13 @@ def _healthy(spec: SidecarSpec) -> bool:
return False
if not _venv_python(checkout / ".venv").is_file():
return False
if spec.weights_repo_id and not _weights_present(spec):
return False
return True
if spec.weights_repo_id:
return _weights_present(spec)
# Nothing downloaded after the dependencies proves they finished; only
# the marker the import probe writes does. IndexTTS (weights) predates
# the marker and keeps its own check, so no existing install is asked
# to reinstall.
return (checkout / _INSTALL_COMPLETE_MARKER).is_file()
def _persist(spec: SidecarSpec) -> None:
@@ -548,6 +913,9 @@ def start_install(engine_id: str) -> dict:
spec = get_spec(engine_id)
if spec is None:
raise KeyError(engine_id)
ok, why = host_support(spec)
if not ok:
raise HostUnsupported(why)
with _jobs_lock:
existing = _jobs.get(engine_id)
if existing and existing["state"] == "running":
@@ -679,6 +1047,11 @@ def _step_preflight(spec: SidecarSpec, job: dict) -> None:
def _step_fetch_source(spec: SidecarSpec, job: dict) -> None:
step = _job_step(job, "fetch_source")
checkout = managed_checkout(spec)
if not spec.has_source:
checkout.mkdir(parents=True, exist_ok=True)
step["state"] = "done"
step["detail"] = "PyPI package, no source to fetch"
return
if _source_present(spec, checkout):
step["state"] = "done"
step["detail"] = "source already present"
@@ -717,7 +1090,7 @@ def _step_fetch_source(spec: SidecarSpec, job: dict) -> None:
_fetch_tarball(spec, job, checkout)
if not _source_layout_ok(spec, checkout):
raise _StepError(
f"Fetched source at {checkout} has no pyproject.toml — the download "
f"Fetched source at {checkout} has no {spec.source_manifest} — the download "
"appears incomplete or the upstream layout changed.",
"Re-run the install; if it keeps failing, clone the repository "
f"manually and set {spec.env_var} to the clone (see the engine docs).",
@@ -727,10 +1100,14 @@ def _step_fetch_source(spec: SidecarSpec, job: dict) -> None:
_SOURCE_REVISION_MARKER = ".voicestudio_source_revision"
# Written once the import probe passes. For an engine with no weights
# download, the venv interpreter existing proves nothing: a dependency
# install that died halfway leaves one behind.
_INSTALL_COMPLETE_MARKER = ".voicestudio_install_complete"
def _source_layout_ok(spec: SidecarSpec, checkout: Path) -> bool:
if not (checkout / "pyproject.toml").is_file():
if not (checkout / spec.source_manifest).is_file():
return False
return not spec.source_required_path or (checkout / spec.source_required_path).is_file()
@@ -743,6 +1120,8 @@ def _write_source_marker(spec: SidecarSpec, checkout: Path) -> None:
def _source_present(spec: SidecarSpec, checkout: Path) -> bool:
if not spec.has_source:
return checkout.is_dir()
if not _source_layout_ok(spec, checkout):
return False
if not spec.source_revision:
@@ -836,8 +1215,8 @@ def _step_create_venv(spec: SidecarSpec, job: dict) -> None:
# uv_subprocess_env. The cache parent is the shared engines root, so
# every sidecar engine reuses one cache.
uv_env = uv_subprocess_env(Path(DATA_DIR) / "engines")
rc = _run_logged(job, [uv, "venv", str(venv_dir)], timeout=_UV_VENV_TIMEOUT_S,
env=uv_env)
rc = _run_logged(job, [uv, "venv", str(venv_dir), *spec.venv_args],
timeout=_UV_VENV_TIMEOUT_S, env=uv_env)
if rc != 0 or not py.is_file():
raise _StepError(
f"uv venv failed (exit {rc}) at {venv_dir}.",
@@ -857,17 +1236,30 @@ def _step_install_deps(spec: SidecarSpec, job: dict) -> None:
"""
checkout = managed_checkout(spec)
py = _venv_python(checkout / ".venv")
# A reinstall that fails must not leave the previous run's marker.
(checkout / _INSTALL_COMPLETE_MARKER).unlink(missing_ok=True)
uv = _locate_uv()
_log(job, f"Installing {spec.display_name} into its venv (this can take several minutes) …")
target = [_expand(arg, checkout) for arg in spec.install_args]
if spec.torch_pins:
target += _torch_pin_args(spec)
elif spec.cpu_torch_index:
from core.torch_indexes import UV_PIP_CPU_ARGS
target += list(UV_PIP_CPU_ARGS)
elif spec.uses_cuda_index and _host_family() == "cuda":
from core.torch_indexes import UV_PIP_CU128_ARGS
target += list(UV_PIP_CU128_ARGS)
# Always `--python <this engine's venv>`: the install can only ever land in
# the venv this engine owns, never the app's interpreter.
rc = _run_logged(
job,
[uv, "pip", "install", "--python", str(py), "-e", str(checkout)],
[uv, "pip", "install", "--python", str(py), *target],
timeout=_UV_PIP_INSTALL_TIMEOUT_S,
env=uv_subprocess_env(Path(DATA_DIR) / "engines"),
)
if rc != 0:
raise _StepError(
f"uv pip install -e failed (exit {rc}).",
f"uv pip install failed (exit {rc}).",
"Usually a network hiccup — re-run the install to resume. Behind a "
"proxy, set HTTPS_PROXY in Settings → Environment first.",
)
@@ -879,8 +1271,13 @@ def _step_verify(spec: SidecarSpec, job: dict) -> None:
py = _venv_python(checkout / ".venv")
_log(job, f"Verifying `import {spec.probe_module}` inside the venv …")
try:
probe = (
_expand(spec.probe_code, checkout)
if spec.probe_code
else f"import {spec.probe_module}"
)
proc = subprocess.run(
[str(py), "-c", f"import {spec.probe_module}"],
[str(py), "-c", probe],
capture_output=True, timeout=_IMPORT_PROBE_TIMEOUT_S,
)
except (subprocess.TimeoutExpired, OSError) as exc:
@@ -898,6 +1295,7 @@ def _step_verify(spec: SidecarSpec, job: dict) -> None:
"the engine docs.",
)
_job_step(job, "verify")["detail"] = f"import {spec.probe_module} OK"
(checkout / _INSTALL_COMPLETE_MARKER).write_text(f"{spec.probe_module}\n", encoding="utf-8")
_log(job, "Venv verified.")
+63
View File
@@ -17,6 +17,8 @@ from __future__ import annotations
import asyncio
import importlib
import logging
import functools
import re
import os
import shutil
import subprocess
@@ -91,6 +93,8 @@ REGISTRY: dict[str, dict] = {
"probe_module": "openai",
"category": "llm",
"needs_key": True,
# A core dependency: Settings → LLM Providers uses it too.
"builtin": True,
"notes": (
"Uses the LLM provider you configure in Settings → LLM Providers "
"(route it via the 'Dub translation' skill in Settings → LLM Skills): "
@@ -181,6 +185,65 @@ def list_engines() -> list[dict]:
return out
def _normalize(name: str) -> str:
"""A distribution name in PEP 503 form (deep_translator == deep-translator)."""
return re.sub(r"[-_.]+", "-", name).lower()
@functools.lru_cache(maxsize=1)
def _app_dependency_names() -> frozenset[str]:
"""Distribution names VoiceStudio itself requires, normalized.
Read from the installed package metadata, so it follows the lockfile with
no second list to keep in step. Without metadata this guards nothing
rather than failing.
"""
try:
from importlib.metadata import requires
reqs = requires("omnivoice") or []
except Exception: # noqa: BLE001
return frozenset()
names = set()
for req in reqs:
if "extra ==" in req:
continue
names.add(_normalize(re.split(r"[\s;<>=!~\[@(]", req, maxsplit=1)[0]))
return frozenset(names)
def uninstall_blocker(engine_id: str) -> "tuple[int, str] | None":
"""Why removing this engine's package would break something, or None.
`pip uninstall` acts on the app's own environment. A package VoiceStudio
depends on (openai, argostranslate) would break the app, and a package
other translation engines share (deep_translator backs four) would break
those engines too.
"""
entry = REGISTRY.get(engine_id)
pkg = entry.get("pip_package") if entry else None
if not pkg:
return None
if _normalize(pkg) in _app_dependency_names():
return 400, (
f"{entry['display_name']} uses {pkg}, which VoiceStudio itself "
"depends on. Uninstalling it would break the app."
)
sharing = [
other["display_name"]
for other_id, other in REGISTRY.items()
if other_id != engine_id
and other.get("pip_package")
and _normalize(other["pip_package"]) == _normalize(pkg)
]
if sharing:
return 409, (
f"{entry['display_name']} shares {pkg} with {', '.join(sharing)}. "
"Uninstalling it would stop those working too."
)
return None
def get_engine(engine_id: str) -> dict | None:
return REGISTRY.get(engine_id)
+21 -2
View File
@@ -2463,8 +2463,11 @@ def _sidecar_installable_ids() -> frozenset[str]:
button into their matrix rows.
"""
try:
from services.sidecar_install import SPECS
return frozenset(SPECS)
# Host-aware: an engine whose installer cannot work on THIS machine
# (dots.tts on Windows, a CUDA-only install on a CPU host) must not get
# an Install button that can only fail.
from services.sidecar_install import installable_engine_ids
return installable_engine_ids()
except Exception: # pragma: no cover — defensive only
return frozenset()
@@ -2654,12 +2657,28 @@ def list_backends(*, include_hidden: bool = False) -> list[dict]:
return out
# In-process engines that also run from a venv of their own once the
# one-click installer has made one: engine id -> (sidecar module, class). Each
# module exposes own_venv_python(); an install made into the app's environment
# keeps running in-process.
_OWN_VENV_SIDECARS: dict[str, tuple[str, str]] = {
"voxcpm2": ("engines.voxcpm2_subprocess", "VoxCPM2SubprocessBackend"),
"moss-tts-nano": ("engines.moss_tts_nano_subprocess", "MossTTSNanoSubprocessBackend"),
}
def _effective_backend_class(
backend_id: str,
backend_cls: type[TTSBackend],
host_family: str | None = None,
) -> type[TTSBackend]:
"""Resolve host-specific containment without changing the configured id."""
sidecar = _OWN_VENV_SIDECARS.get(backend_id)
if sidecar is not None:
import importlib
module = importlib.import_module(sidecar[0])
return getattr(module, sidecar[1]) if module.own_venv_python() is not None else backend_cls
if backend_id != "omnivoice":
return backend_cls
if host_family is None:
+10 -5
View File
@@ -77,15 +77,20 @@ The `Desktop Release` workflow fires on tag push. It builds four targets in para
|---|---|---|
| macOS Apple Silicon | macos-14 | `.dmg` + updater `.app.tar.gz` |
| macOS Intel | macos-13 | `.dmg` + updater `.app.tar.gz` |
| Windows x64 | windows-2022 | `.msi` + `.exe` + updater `.nsis.zip` |
| Linux x64 | ubuntu-22.04 | `.AppImage` + `.deb` + updater `.AppImage.tar.gz` |
| Windows x64 | windows-2022 | `.msi`, machine-wide and per-user, each with its updater `.sig` |
| Linux x64 | ubuntu-22.04 | `.AppImage` + updater `.AppImage.sig` |
Each runner signs the updater payload with the stored `TAURI_SIGNING_PRIVATE_KEY`, merges into a single `latest.json`, and attaches everything to the draft release.
Workflow runtime: **~20-40 minutes** (PyInstaller + four platform builds). Follow progress at:
`https://github.com/debpalash/VoiceStudio/actions`
When it finishes, the draft release needs manual publishing — GitHub → Releases → **Edit** the draft → **Publish release**. Once published, existing clients detect the update on their next launch.
The release stays a draft while the platforms build. Once every platform, the
updater-manifest repair and the uninstall scripts are done, the
`release-notes-checksums` job writes all four platforms' checksums into the
notes and publishes it, with no manual step. A failed platform leaves the
release a draft, so nothing half-built goes public. Existing clients detect
the update on their next launch.
## 5b. Deployment channels — all must ship (hard rule, owner-set 2026-07-16)
@@ -95,7 +100,7 @@ bug to fix immediately, not backlog.
| Channel | Source | Produced by | How to verify |
|---|---|---|---|
| GitHub Release: installers + signed `latest.json` (**Stable** updater channel) | the `vX.Y.Z` tag | `release.yml` on tag push | Release page has dmg (arm+intel), msi/exe, AppImage/deb, `latest.json`; body = the CHANGELOG section (not the auto-generated fallback), followed by per-platform checksums and a **Contributors** avatar strip (owner + every PR author for the tag — the `contributors-strip` job) |
| GitHub Release: installers + signed `latest.json` (**Stable** updater channel) | the `vX.Y.Z` tag | `release.yml` on tag push | Release page has dmg (arm+intel), msi (machine-wide and per-user), AppImage, `latest.json` and `latest-user.json`; body = the CHANGELOG section (not the auto-generated fallback), followed by per-platform checksums and a **Contributors** avatar strip (owner + every PR author for the tag — the `contributors-strip` job) |
| **Preview** updater channel (rolling `preview` prerelease) | **`main` only** | `release.yml` nightly cron / manual dispatch | preview `latest.json` uses main's version when it is ahead; otherwise it advances the stable patch, then appends `-N` so it semver-sorts above stable |
| GHCR CUDA image: `:X.Y.Z`, `:X.Y`, `:stable` | the tag | `docker.yml` on tag push | `docker manifest inspect ghcr.io/debpalash/omnivoice-studio:X.Y.Z` |
| GHCR ROCm image: `:X.Y.Z-rocm`, `:X.Y-rocm`, `:stable-rocm` | the tag | `docker.yml` on tag push | same, with `-rocm` suffix |
@@ -133,7 +138,7 @@ You should see platform-keyed download URLs + minisign signatures. If that JSON
**Option B — full end-to-end:**
1. Install v0.1.0 on a fresh machine (or clean-installed Applications).
2. Cut v0.2.0 (bump, tag, push, wait for CI, publish draft).
2. Cut v0.2.0 (bump, tag, push, wait for CI; the workflow publishes the release).
3. Launch the installed v0.1.0. Within seconds, the dialog should appear.
4. Accept → app downloads, verifies, replaces, relaunches as v0.2.0.
+2 -1
View File
@@ -43,7 +43,8 @@ VoiceStudio/
│ │ audio DSP, GPU gateway, engine routing, model lifecycle
│ ├── engines/ per-engine adapters: indextts, supertonic3, confucius4,
│ │ dots_tts, moss_tts_v15, pockettts, audiocpp,
│ │ omnivoice_gguf, omnivoice_subprocess, _asr_sidecar, _echo
│ │ omnivoice_gguf, omnivoice_subprocess, _asr_sidecar, _echo,
│ │ voxcpm2_subprocess, moss_tts_nano_subprocess
│ ├── worker/ remote / distributed workers — scheduler, pool, routing,
│ │ breaker, capacity, plus protocol/ and inbound/
│ ├── mcp_shim/ MCP server entry point (docs/mcp.md)
+13 -1
View File
@@ -25,6 +25,18 @@ is an LLM-based multilingual / cross-lingual zero-shot voice-cloning TTS.
Like IndexTTS-2 / MOSS-TTS-v1.5 / dots.tts, it runs in its **own subprocess venv**
so its dependency stack never touches the default VoiceStudio interpreter.
## One-click install
**Model Catalogue → Confucius4-TTS → Install** does the steps below
for you, on Windows, Linux and macOS. It installs into its own folder under VoiceStudio's data directory, with its own Python environment. Nothing it installs touches VoiceStudio itself or any other engine, so you can switch to it and back without breaking what already worked. **Uninstall** in the same row removes only that folder. On an NVIDIA machine it installs
the CUDA build of PyTorch; elsewhere it installs the CPU build. The ~5 GB of
weights still download on first synthesis.
The first synthesis downloads the weights, which takes a while on a slow
connection. The generation stays alive while the download makes progress;
if a stalled download runs out of time, raise the compute-time budget in
**Settings → Performance & Device** and try again.
## Install
```bash
@@ -56,7 +68,7 @@ Then point VoiceStudio at the clone and restart:
- **macOS/Linux:** `export OMNIVOICE_CONFUCIUS4_TTS_DIR=/path/to/Confucius4-TTS`
- **Windows (PowerShell):** `[Environment]::SetEnvironmentVariable("OMNIVOICE_CONFUCIUS4_TTS_DIR","C:\path\to\Confucius4-TTS","User")`
Select **Confucius4-TTS** in Model Catalogue (TTS tab → **Use**). The first synthesize triggers
Select **Confucius4-TTS** in Model Catalogue (TTS tab → **Use**). The first synthesis triggers
the weight downloads above, then generates.
### Optional overrides
+11
View File
@@ -26,6 +26,17 @@ pins `transformers>=5.3` — the same isolation primitive used by
which VoiceStudio does not auto-wire.
- **VRAM:** ~9 GB checkpoint; a 1216 GB CUDA GPU is the realistic target.
## One-click install
On Linux and macOS, **Model Catalogue → dots.tts → Install** does the
steps below for you. It installs into its own folder under VoiceStudio's data directory, with its own Python environment. Nothing it installs touches VoiceStudio itself or any other engine, so you can switch to it and back without breaking what already worked. **Uninstall** in the same row removes only that folder. It is not offered on Windows, where upstream
publishes no install. The ~9 GB checkpoint still downloads on first synthesis.
The first synthesis downloads the weights, which takes a while on a slow
connection. The generation stays alive while the download makes progress;
if a stalled download runs out of time, raise the compute-time budget in
**Settings → Performance & Device** and try again.
## Install
dots.tts is **not** bundled (large checkpoint + conflicting `transformers`).
+19
View File
@@ -58,6 +58,9 @@ shows unavailable with a "does not expose a usable model class" message,
pull the latest upstream and re-run `uv pip install -e .`, or open an issue
with the version you have.
The one-click install is not affected: it pins a reviewed commit
(`8b7bcc93`, 2026-09-06) and drives the runtime that commit ships.
## Known limits
- No voice design, no instruct, no speed control — cloning from a reference
@@ -65,6 +68,22 @@ with the version you have.
- Quality sits below the large engines; see
[benchmarks.md](../benchmarks.md).
## One-click install
Click **Install** in **Model Catalogue → MOSS-TTS-Nano**.
VoiceStudio clones a reviewed upstream commit into its own folder under the
data directory, gives it its own Python environment (the CUDA build of
PyTorch on an NVIDIA GPU), and runs it there in a separate process.
Nothing it installs touches VoiceStudio itself or any other engine, and
**Uninstall** in the same row removes only that folder. The button is not
offered on Intel Macs, where the PyTorch version it pins has no build.
The first synthesis downloads the model and its audio tokenizer. The
generation stays alive while the download makes progress; if a stalled
download runs out of time, raise the compute-time budget in
**Settings → Performance & Device** and try again.
## Troubleshooting
- "moss_tts_nano package not installed": run the clone + `uv pip install -e .`
+17 -1
View File
@@ -30,6 +30,18 @@ interpreter, so MOSS runs behind
covered by mocked loader tests; physical-device synthesis has not been
validated by this change.
## One-click install
On a machine with an NVIDIA GPU, **Model Catalogue → MOSS-TTS-v1.5 →
Install** does every step below for you. It installs into its own folder under VoiceStudio's data directory, with its own Python environment. Nothing it installs touches VoiceStudio itself or any other engine, so you can switch to it and back without breaking what already worked. **Uninstall** in the same row removes only that folder. The ~16 GB of weights still
download on first synthesis. On a CPU-only host the button is not offered; use
the manual install.
The first synthesis downloads the weights, which takes a while on a slow
connection. The generation stays alive while the download makes progress;
if a stalled download runs out of time, raise the compute-time budget in
**Settings → Performance & Device** and try again.
## Install
MOSS-TTS-v1.5 is **not** bundled (the model is large and the package pins a
@@ -50,9 +62,13 @@ into an isolated venv on demand.
```bash
cd MOSS-TTS
uv venv .venv
uv pip install -e ".[torch-runtime]"
uv pip install -e ".[torch-runtime]" --extra-index-url https://download.pytorch.org/whl/cu128 --index-strategy unsafe-best-match
```
The extra pins `torch==2.9.1+cu128`, which is published only on PyTorch's
own index, so the `--extra-index-url` is required — without it uv reports
the requirements as unsatisfiable on every host.
On a **non-CUDA / CPU host** (e.g. Apple Silicon), install plain
`torch`/`torchaudio`/`transformers==5.0.0` into the venv instead of the
`+cu128` extra (the auto-bootstrap below only targets CUDA hosts).
+10 -3
View File
@@ -24,7 +24,13 @@ for this model.
uv sync --extra pockettts
```
(Or enable it from **Model Catalogue**.)
Or click **Install** in **Model Catalogue → PocketTTS**. That
installs the same pinned package into the engine's own Python environment
under VoiceStudio's data directory, with the CPU build of PyTorch, because
PocketTTS never uses a GPU. Nothing it installs touches VoiceStudio itself
or any other engine, and **Uninstall** in the same row removes only that
folder. An install made with `uv sync` keeps working as it is. The button
is not offered on Intel Macs (see Platform notes).
2. **Accept the license in-app**
([#1306](https://github.com/debpalash/VoiceStudio/issues/1306)). The code
@@ -50,8 +56,9 @@ for this model.
- Output is 24 kHz mono.
- Six languages, one model per language, chosen by the `language` you
request; cloning takes a short reference clip.
- Runs in a crash-isolated sidecar process (parent Python environment): a
wedged generation is hard-killed by a watchdog and its memory reclaimed —
- Runs in a crash-isolated sidecar process: from its own environment after
a one-click install, otherwise from VoiceStudio's (where `uv sync --extra
pockettts` puts it). A wedged generation is hard-killed by a watchdog and its memory reclaimed —
something an in-process engine cannot do.
- The first use downloads the gated weights; the sidecar heartbeats
progress during the download so the watchdog doesn't fire.
+8 -5
View File
@@ -19,8 +19,11 @@ crashes and cold init never block the rest of VoiceStudio.
uv sync --extra supertonic
```
(Or enable it from **Model Catalogue**, which installs the
pinned `supertonic` wheel for you.)
Or click **Install** in **Model Catalogue → Supertonic-3**. That
installs the same pinned wheel into the engine's own Python environment
under VoiceStudio's data directory. Nothing it installs touches VoiceStudio
itself or any other engine, and **Uninstall** in the same row removes only
that folder. An install made with `uv sync` keeps working as it is.
2. **Accept the license in-app.** First use is gated behind an explicit
acceptance dialog: the inference SDK is MIT, but the model weights are
@@ -45,9 +48,9 @@ log line.
## Behaviour notes
- Output is 44.1 kHz mono.
- Runs as a long-lived sidecar in the parent Python environment (its
dependencies — onnxruntime, numpy, soundfile — already match
VoiceStudio's pins); subsequent calls reuse the warm ONNX session.
- Runs as a long-lived sidecar: from its own environment after a one-click
install, otherwise from VoiceStudio's (where `uv sync --extra supertonic`
puts it). Subsequent calls reuse the warm ONNX session.
- `speed` is clamped to 0.72.0; quality steps clamp to 512.
- Language is an ISO 639-1 code; Auto engages the SDK's multilingual
fallback.
+14
View File
@@ -65,6 +65,20 @@ now retried once with a fresh client. See
- Language coverage is 30 languages; for anything else use the default
[OmniVoice](omnivoice.md) engine ([languages.md](../languages.md)).
## One-click install
Click **Install** in **Model Catalogue → VoxCPM2**. VoiceStudio
puts VoxCPM2 in its own Python environment under its data directory and runs
it there, in a separate process. It installs the CUDA build of PyTorch on an
NVIDIA GPU, the CPU build on other Windows and Linux machines, and the
regular build on Apple Silicon.
Nothing it installs touches VoiceStudio itself or any other engine, and
**Uninstall** in the same row removes only that folder. An existing
`pip install voxcpm` setup keeps working as it is. The button is not offered
on Intel Macs, where no PyTorch build it needs exists. The model weights
download on first use.
## Troubleshooting
- Engine shows unavailable: the `voxcpm` package isn't installed — run the
+108 -9
View File
@@ -794,24 +794,114 @@ fn mark_pill_noactivate(win: &tauri::WebviewWindow) {
/// Show the pill without granting it foreground activation.
///
/// The only correct way to show it on Windows (#982): a plain `show()` steals
/// foreground from the app being dictated into, and the paste then lands in the
/// pill instead of the user's document. `show_dictation_pill` is the call site.
/// Two steps, and both are load-bearing.
///
/// `win.show()` is what tells TAURI the window is visible. Raw `ShowWindow`
/// alone puts it on screen behind Tauri's back, and Tauri goes on believing it
/// is hidden — so `isVisible()` answers `false` while the user is looking at
/// the thing, `hide()` becomes a no-op on a window it thinks is already
/// hidden, and the capture widget's idle reconcile (which asks `isVisible()`
/// before deciding to clean up) concludes there is nothing to clean up. The
/// result is an empty dark rectangle stranded on the desktop after the pill is
/// dismissed, with no way to remove it short of quitting the app.
///
/// `SW_SHOWNOACTIVATE` is what keeps the foreground where it belongs (#982): a
/// pill that steals focus makes the paste land in the pill instead of the
/// user's document. `WS_EX_NOACTIVATE` is already on the window from
/// `mark_pill_noactivate` at creation, which is what makes the `show()` above
/// safe — the style bit, not the show flag, is what actually refuses
/// activation. The flag stays anyway: it costs nothing and holds even if the
/// style bit could not be applied (`hwnd()` can fail).
#[cfg(target_os = "windows")]
pub(crate) fn show_pill_noactivate(win: &tauri::WebviewWindow) {
use windows::Win32::UI::WindowsAndMessaging::{ShowWindow, SW_SHOWNOACTIVATE};
let Ok(hwnd) = win.hwnd() else {
log::warn!("pill: could not resolve HWND for non-activating show (#982)");
show_pill_noactivate_with(
|| win.show().map_err(|error| error.to_string()),
|| {
let hwnd = win.hwnd().map_err(|_| "no HWND".to_string())?;
unsafe {
let _ = ShowWindow(hwnd, SW_SHOWNOACTIVATE);
}
Ok(())
},
)
}
/// The ordering itself, with both shows as parameters.
///
/// Split out so a test can pin the contract that the bug broke: Tauri's own
/// `show` must run, and it must run FIRST. A native-only show is what left an
/// empty pill window stranded on the desktop.
///
/// And when Tauri's show FAILS, the native show must not run at all (Greptile).
/// Showing it natively anyway puts an always-on-top window on screen that
/// Tauri believes is hidden — the exact stranded-window bug, reached by a
/// different door. A pill that does not appear is the lesser failure: the
/// tray's red dot still says the user is being recorded, and nothing is left
/// behind that cannot be removed.
pub(crate) fn show_pill_noactivate_with<T, N>(show_tauri: T, show_native: N)
where
T: FnOnce() -> Result<(), String>,
N: FnOnce() -> Result<(), String>,
{
if let Err(error) = show_tauri() {
log::warn!("pill: Tauri show failed; not showing it natively either, or it could never be hidden: {error}");
return;
};
unsafe {
let _ = ShowWindow(hwnd, SW_SHOWNOACTIVATE);
}
if let Err(error) = show_native() {
log::warn!("pill: non-activating show failed ({error}) (#982)");
}
}
#[cfg(test)]
mod pill_noactivate_tests {
use super::{with_noactivate_style, WS_EX_NOACTIVATE_BIT};
use super::{show_pill_noactivate_with, with_noactivate_style, WS_EX_NOACTIVATE_BIT};
#[test]
fn showing_the_pill_tells_tauri_before_it_tells_windows() {
// The bug: only the raw Win32 show ran, so the window went on screen
// behind Tauri's back. Tauri then answered `isVisible()` with false
// while the user was looking at it, `hide()` did nothing on a window
// it believed was already hidden, and the widget's idle reconcile —
// which asks `isVisible()` before cleaning up — concluded there was
// nothing to clean up. An empty rectangle stayed on the desktop until
// the app was quit.
use std::cell::RefCell;
let order = RefCell::new(Vec::new());
show_pill_noactivate_with(
|| {
order.borrow_mut().push("tauri");
Ok(())
},
|| {
order.borrow_mut().push("native");
Ok(())
},
);
assert_eq!(
order.into_inner(),
["tauri", "native"],
"Tauri's own show must run, and run first"
);
}
#[test]
fn a_failing_tauri_show_does_not_fall_back_to_a_native_one() {
// Greptile: a native-only show after Tauri's show failed puts an
// always-on-top window on screen that Tauri believes is hidden, so
// neither dismiss() nor the idle reconcile can ever remove it — the
// stranded-window bug again. A pill that does not appear is the lesser
// failure; the tray's red dot still signals recording.
let mut native_ran = false;
show_pill_noactivate_with(
|| Err("no window".to_string()),
|| {
native_ran = true;
Ok(())
},
);
assert!(!native_ran, "a native show after a failed Tauri show strands an unhidable window");
}
#[test]
fn adds_noactivate_bit_without_clobbering_existing_style() {
@@ -1140,6 +1230,15 @@ pub fn run() {
.resizable(false)
.transparent(true)
.decorations(false)
// No window shadow. On Windows, Tauri's default (`true`) gives
// an undecorated window a 1px white border and, on Windows 11,
// rounded corners — drawn around the WHOLE 460x164 window, not
// the pill inside it, which is at most 284px wide. The result
// is a visible card framing empty space around the capsule,
// there whether the pill is showing or not. The capsule draws
// its own edge and shadow in CSS; the window must draw nothing.
// (Unsupported on Linux, where it was never the problem.)
.shadow(false)
.always_on_top(true)
.visible(false)
.focused(false)
+1
View File
@@ -37,6 +37,7 @@
"fullscreen": false,
"transparent": true,
"decorations": false,
"shadow": false,
"alwaysOnTop": true,
"visible": false,
"skipTaskbar": true,
+1
View File
@@ -27,6 +27,7 @@
"fullscreen": false,
"transparent": true,
"decorations": false,
"shadow": false,
"alwaysOnTop": true,
"visible": false,
"skipTaskbar": true,
+1 -1
View File
@@ -487,7 +487,7 @@ export async function apiFetch(path: string, opts: ApiFetchOptions = {}): Promis
'The local VoiceStudio backend is running but stopped responding. This usually means a ' +
'job (a generation or a transcription) is stuck holding the engine — often a model ' +
'too heavy for the available memory on this machine. Check Settings → Logs → Backend ' +
'for the last thing it was doing; a smaller model or engine (Model Catalogue → Models) is ' +
'for the last thing it was doing; a smaller model or engine (the engine Weights list in Model Catalogue) is ' +
'the usual fix. Restarting the app clears it for now.',
{ status: 0, detail: failureDetail },
);
+54 -11
View File
@@ -369,6 +369,10 @@ function errorLabel(t, info) {
return t('capture.paste_error');
case 'mic':
return t('capture.mic_denied');
case 'asr_missing':
// Not "Transcription failed: " nothing was transcribed or failed; a
// model is absent, and the fix is to install one.
return t('asr_missing.message');
default:
return t('capture.transcription_failed', { message: info?.message || '' });
}
@@ -421,6 +425,7 @@ export default function CaptureWidget({ onDismiss }) {
// re-subscribing on every pref change.
const modeRef = useRef(dictationMode);
const enabledRef = useRef(dictationEnabled);
/** @type {React.MutableRefObject<null | { pending: boolean, ok: boolean, promise: Promise<void> | null }>} */
const prefsHydrationRef = useRef(null);
useEffect(() => {
modeRef.current = dictationMode;
@@ -428,16 +433,42 @@ export default function CaptureWidget({ onDismiss }) {
useEffect(() => {
enabledRef.current = dictationEnabled;
}, [dictationEnabled]);
const ensureDictationPrefsHydrated = useCallback(() => {
if (!prefsHydrationRef.current) {
prefsHydrationRef.current = Promise.resolve()
// Load the persisted dictation prefs into THIS window's store.
//
// The widget is its own window with its own store, created at app start
// usually before the backend is listening. It used to hydrate exactly once
// and memoize the promise whether or not the load worked, so a startup
// failure pinned the store's seed model (`sherpa-whisper-tiny`) for the life
// of the app. The main window, which loaded fine, checked the model the user
// actually picked and said "ready"; the widget then asked the server for the
// seed, which was not installed, and the pill reported "No speech-to-text
// model is installed" with Parakeet sitting on disk. Nothing in the main
// window could tell, because the loader marked itself loaded either way.
//
// So: only a load the backend actually answered is kept. A failed one is
// retried by the next caller. And a capture start passes `fresh`, which
// re-reads even after a success the model can be changed from the main
// window (Transcriptions, Settings) and this store is not the one that
// changed. An in-flight load is always shared rather than duplicated.
const ensureDictationPrefsHydrated = useCallback(
({ fresh = false } = {}) => {
const current = prefsHydrationRef.current;
if (current && (current.pending || (current.ok && !fresh))) return current.promise;
const entry = { pending: true, ok: false, promise: null };
prefsHydrationRef.current = entry;
entry.promise = Promise.resolve()
.then(() => loadDictationPrefs())
.then((loaded) => {
// A loader that predates the boolean resolves undefined on success.
entry.ok = loaded !== false;
})
.catch((err) => {
// The store keeps its cross-platform seeds when the backend is not
// ready. Readiness must still resolve so the native hotkey can work.
console.warn('dictation prefs hydration failed:', err);
})
.then(() => {
entry.pending = false;
// Zustand updates before loadDictationPrefs resolves, but React's
// selector effects may render later. Synchronise the long-lived
// native listener refs now so its first event cannot use seed prefs.
@@ -449,9 +480,10 @@ export default function CaptureWidget({ onDismiss }) {
modeRef.current = prefs.dictationMode;
}
});
}
return prefsHydrationRef.current;
}, [loadDictationPrefs]);
return entry.promise;
},
[loadDictationPrefs],
);
// `state` follows the same rule, and for a sharper reason than the prefs do.
// The tray listener used to depend on [state], so every single state change
// tore the Tauri listener down and re-attached it through an `await import()`
@@ -787,7 +819,9 @@ export default function CaptureWidget({ onDismiss }) {
await completeDelivery(event, 'Dictation output session is missing');
return;
}
await ensureDictationPrefsHydrated();
// Fresh: the model may have been changed from the main window
// since this store last loaded (see ensureDictationPrefsHydrated).
await ensureDictationPrefsHydrated({ fresh: true });
if (!enabledRef.current) {
// The hotkey is inert, but Rust has already shown the window.
// Put it back rather than leaving an empty capsule on screen.
@@ -1708,8 +1742,14 @@ export default function CaptureWidget({ onDismiss }) {
stopCaptureGraph();
setTrayRecording(false);
setModelStatus(null);
toastAsrModelMissing(asrMissingPayload(msg));
setErrorInfo({ kind: 'transcription', message: t('asr_missing.message') });
const missing = asrMissingPayload(msg);
// In the desktop app this window has no <Toaster>, so a toast
// here rendered nowhere; the main window shows the install
// action instead (dictationNotice, kind 'asr_missing'). The
// browser build mounts this widget inside the main window,
// where the local toast IS the only one.
if (!inTauri()) toastAsrModelMissing(missing);
setErrorInfo({ kind: 'asr_missing', message: t('asr_missing.message'), missing });
setState('error');
void finishAttemptOutputSession();
} else if (sherpaModeRef.current || aecModeRef.current || pcmModeRef.current) {
@@ -2087,8 +2127,8 @@ export default function CaptureWidget({ onDismiss }) {
const missing = asrMissingPayload(err);
if (missing) {
// Typed 409: no ASR model installed download CTA, not a dead end.
toastAsrModelMissing(missing);
setErrorInfo({ kind: 'transcription', message: t('asr_missing.message') });
if (!inTauri()) toastAsrModelMissing(missing);
setErrorInfo({ kind: 'asr_missing', message: t('asr_missing.message'), missing });
setState('error');
setTranscript('');
await finishOutputSession(sessionId);
@@ -2220,6 +2260,9 @@ export default function CaptureWidget({ onDismiss }) {
// user to the permissions pane sends them somewhere nothing is wrong
// the same condition the pill's own mic button carried.
deniedByOs: !!errorInfo?.deniedByOs,
// The install recommendation, so the main window can offer the one-click
// download the pill has no room for.
missing: errorInfo?.missing,
});
}, [state, errorInfo, t]);
@@ -297,6 +297,57 @@ describe('CaptureWidget', () => {
expect(ws.url).toContain('model=sherpa-parakeet-tdt-v3');
});
// The widget is its own window with its own store, created before the
// backend is listening. A failed first load used to be memoized, pinning the
// seed model for the life of the app: the main window checked the model the
// user picked and said "ready", while the pill asked the server for the seed
// and reported "No speech-to-text model is installed" with Parakeet on disk.
it('retries a failed prefs load instead of pinning the seed model', async () => {
mocks.state.dictationModelId = 'sherpa-whisper-tiny'; // the store's seed
let calls = 0;
mocks.state.loadDictationPrefs = async () => {
calls += 1;
if (calls === 1) throw new Error('backend not listening yet');
mocks.state.dictationModelId = 'sherpa-parakeet-tdt-v3';
return true;
};
vi.spyOn(console, 'warn').mockImplementation(() => {});
render(withI18n(<CaptureWidget />));
await waitFor(() =>
expect(
mocks.holder.calls.some(([command]) => command === 'mark_dictation_capture_ready'),
).toBe(true),
);
const ws = await startNativeSession('after-startup-failure');
expect(calls).toBeGreaterThanOrEqual(2);
expect(ws.url).toContain('model=sherpa-parakeet-tdt-v3');
expect(ws.url).not.toContain('sherpa-whisper-tiny');
});
it('picks up a model changed in another window at the next capture', async () => {
mocks.state.dictationModelId = 'sherpa-whisper-tiny';
let persisted = 'sherpa-whisper-tiny';
mocks.state.loadDictationPrefs = async () => {
mocks.state.dictationModelId = persisted;
return true;
};
render(withI18n(<CaptureWidget />));
await waitFor(() =>
expect(
mocks.holder.calls.some(([command]) => command === 'mark_dictation_capture_ready'),
).toBe(true),
);
// The user installs and selects Parakeet from Transcriptions, in the main
// window. This window's store is not the one that changed.
persisted = 'sherpa-parakeet-tdt-v3';
const ws = await startNativeSession('after-model-switch');
expect(ws.url).toContain('model=sherpa-parakeet-tdt-v3');
});
it('releases the exact native listener registration on unmount', async () => {
const view = render(withI18n(<CaptureWidget />));
await waitFor(() =>
@@ -23,7 +23,14 @@ import { cn } from '@/lib/utils';
import EngineMark from './EngineMark';
import useEngineInventory, { FORCE_WAIT_TIMEOUT_MS } from './engines/useEngineInventory';
import EngineDetail from './engines/EngineDetail';
import { LABEL, LICENSE_DIALOGS, fmtDiskBytes, runsOn, statusOf } from './engines/engineDisplay';
import {
LABEL,
LICENSE_DIALOGS,
fmtDiskBytes,
reasonMentionsLicense,
runsOn,
statusOf,
} from './engines/engineDisplay';
export { FORCE_WAIT_TIMEOUT_MS, fmtDiskBytes };
@@ -346,12 +353,14 @@ export default function EngineCompatibilityMatrix({
size="sm"
variant="subtle"
onClick={() => inv.selectEngine(b.id)}
aria-label={`Use ${b.display_name}`}
aria-label={t('engines.ariaUse', { engine: b.display_name })}
>
{t('engines.use')}
</Button>
)}
{!b.available && b.one_click_install && (
{/* Hidden while a license review is all that is left: the
engine is installed, and Accept (in the panel) is next. */}
{!b.available && b.one_click_install && !reasonMentionsLicense(b.reason) && (
<Button
size="sm"
variant="subtle"
@@ -15,6 +15,7 @@ import { apiJson } from '../../api/client';
import { useEngines, useInstallModel, useRecommendations } from '../../api/hooks';
import { useAppStore } from '../../store';
import { Badge, Button } from '../../ui';
import { failedInstalls, installFailureMessage } from '../settings/models/installResults';
/**
* SetupSummary the Model Catalogue's first screen: what the app will use
@@ -149,15 +150,13 @@ export default function SetupSummary({ onChange }) {
missing.map((m) => installMutation.mutateAsync(m.repo_id)),
);
setInstalling(false);
const failed = results
.map((r, i) => (r.status === 'rejected' ? { repo: missing[i].repo_id, err: r.reason } : null))
.filter(Boolean);
const failed = failedInstalls(results, missing);
const started = results.length - failed.length;
if (started > 0) toast.success(t('models.started_downloading', { count: started }));
if (failed.length > 0) {
toast.error(
t('models.install_failed', {
message: failed.map((f) => `${f.repo}: ${f.err?.message || f.err}`).join(' · '),
message: installFailureMessage(failed),
}),
);
}
@@ -209,7 +209,7 @@ export default function EngineDetail({
disabled={!!health?.inflight}
loading={!!health?.inflight}
leading={!health?.inflight && <Activity size={11} />}
aria-label={`Test ${b.display_name}`}
aria-label={t('engines.ariaTest', { engine: b.display_name })}
>
{health?.inflight ? t('engines.testing') : t('engines.testEngine')}
</Button>
@@ -221,7 +221,7 @@ export default function EngineDetail({
disabled={!!health?.inflight}
loading={!!health?.inflight}
leading={!health?.inflight && <RefreshCw size={11} />}
aria-label={`Re-check ${b.display_name}`}
aria-label={t('engines.ariaRecheck', { engine: b.display_name })}
>
{health?.inflight ? t('engines.rechecking') : t('engines.recheck')}
</Button>
@@ -247,7 +247,7 @@ export default function EngineDetail({
disabled={!!selfTest?.inflight}
loading={!!selfTest?.inflight}
leading={!selfTest?.inflight && <Volume2 size={11} />}
aria-label={`Self-test ${b.display_name}`}
aria-label={t('engines.ariaSelfTest', { engine: b.display_name })}
>
{selfTest?.inflight ? t('engines.selfTesting') : t('engines.selfTest')}
</Button>
@@ -280,7 +280,7 @@ export default function EngineDetail({
disabled={inv.unloadingId === b.id}
loading={inv.unloadingId === b.id}
title={t('engines.inMemoryTitle')}
aria-label={`Unload ${b.display_name}`}
aria-label={t('engines.ariaUnload', { engine: b.display_name })}
>
{inv.unloadingId === b.id ? t('engines.unloading') : t('engines.unload')}
</Button>
@@ -301,7 +301,7 @@ export default function EngineDetail({
size="sm"
variant="subtle"
onClick={() => inv.setLicenseDialogFor(b.id)}
aria-label={`Review and accept ${b.display_name} license`}
aria-label={t('engines.ariaAcceptLicense', { engine: b.display_name })}
>
{t('engines.acceptLicense')}
</Button>
@@ -1,5 +1,5 @@
/**
* Model Catalogue Engines (ASR tab) OpenAI-compatible remote ASR panel (#877).
* Model Catalogue (ASR tab) OpenAI-compatible remote ASR panel (#877).
*
* A path to Qwen3-ASR, a self-hosted FunASR/SenseVoice server, LM Studio /
* llama.cpp-style local servers, or OpenAI's own Whisper API. Configures the
@@ -93,7 +93,7 @@ export default function AsrOpenAICompatPanel({ onSaved = null }) {
setApiKey('');
setServer({ base_url: d.base_url || '', model: d.model || '' });
setSaved(true);
// Tell the host (Model Catalogue Engines) so the matrix refetches and the
// Tell the host (Model Catalogue) so the matrix refetches and the
// engine's row can flip unavailable available without a manual Refresh.
onSaved?.();
return true;
@@ -9,7 +9,7 @@ import useModelDownloads from './models/useModelDownloads';
import AsrOpenAICompatPanel from './AsrOpenAICompatPanel';
import { SETTINGS_SECTION_SURFACE } from './primitives';
/** Model Catalogue → Engines: ONE section, one matrix, a TTS / ASR / LLM tab strip.
/** Model Catalogue: ONE section, one matrix, a TTS / ASR / LLM tab strip.
*
* The page used to stack three pinned per-family matrices; with every row
* free to grow (wrapping names, stacked badges, inline failure prose) a
@@ -1,5 +1,5 @@
/**
* Model Catalogue Models Voice previews.
* the engine Weights list in Model Catalogue Voice previews.
*
* One line and two controls for the pre-rendered voice gallery: a consent
* toggle and a manual "Check now". The toggle is the *only* thing that ever
@@ -0,0 +1,23 @@
/**
* Bulk-install bookkeeping shared by every "install several models" button
* (setup summary, recommendation card, model store). Pairs each
* Promise.allSettled result with the model it was for BEFORE filtering, so a
* failure is always reported against the right repository filtering first
* and then reading `missing[i]` would name the wrong one.
*/
/** Failed installs as `{ repo, error }`, in request order. */
export function failedInstalls(results, models) {
return (results || [])
.map((r, i) =>
r?.status === 'rejected'
? { repo: models[i]?.repo_id ?? String(models[i]), error: r.reason }
: null,
)
.filter(Boolean);
}
/** One line naming every failed repository and why. */
export function installFailureMessage(failed) {
return failed.map((f) => `${f.repo}: ${f.error?.message || f.error}`).join(' · ');
}
@@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest';
import { failedInstalls, installFailureMessage } from './installResults';
const models = [{ repo_id: 'a/first' }, { repo_id: 'b/second' }, { repo_id: 'c/third' }];
describe('failedInstalls', () => {
it('names the repository each rejection belongs to, even when earlier requests succeeded', () => {
// The bug class: filtering rejected results first and then indexing the
// request list pairs 'c/third's error with 'a/first'.
const results = [
{ status: 'fulfilled', value: {} },
{ status: 'fulfilled', value: {} },
{ status: 'rejected', reason: new Error('gated repo') },
];
const failed = failedInstalls(results, models);
expect(failed).toEqual([{ repo: 'c/third', error: results[2].reason }]);
expect(installFailureMessage(failed)).toBe('c/third: gated repo');
});
it('keeps request order across several failures and accepts non-Error reasons', () => {
const results = [
{ status: 'rejected', reason: 'offline' },
{ status: 'fulfilled', value: {} },
{ status: 'rejected', reason: new Error('disk full') },
];
expect(installFailureMessage(failedInstalls(results, models))).toBe(
'a/first: offline · c/third: disk full',
);
});
it('returns nothing when every install started', () => {
expect(failedInstalls([{ status: 'fulfilled' }], models)).toEqual([]);
expect(failedInstalls(undefined, models)).toEqual([]);
});
});
+1 -1
View File
@@ -460,7 +460,7 @@ export default function useDubWorkflow({
// "ASR failed to load" there sent #1242's reporter after a model
// that had loaded fine.
streamDropError(
'Transcribe stream ended before any segments arrived, and the backend could not be reached to say why — check the backend log, and Model Catalogue → Models if the ASR model was still downloading.',
'Transcribe stream ended before any segments arrived, and the backend could not be reached to say why — check the backend log, and the engine Weights list in Model Catalogue if the ASR model was still downloading.',
).then(reject, reject);
});
}),
+6
View File
@@ -783,6 +783,12 @@
"loading": "جارٍ تحميل المحركات…",
"refresh": "تحديث",
"matrixTitle": "المحركات",
"ariaTest": "اختبار {{engine}}",
"ariaRecheck": "إعادة فحص {{engine}}",
"ariaSelfTest": "اختبار ذاتي لـ {{engine}}",
"ariaUnload": "إلغاء تحميل {{engine}}",
"ariaUse": "استخدام {{engine}}",
"ariaAcceptLicense": "مراجعة ترخيص {{engine}} وقبوله",
"weights": "الأوزان",
"noWeights": "لا توجد أوزان في الكتالوج — يجلب هذا المحرك ما يحتاجه عند أول استخدام.",
"colRunsOn": "يعمل على",
+6
View File
@@ -783,6 +783,12 @@
"loading": "Motoren werden geladen…",
"refresh": "Aktualisieren",
"matrixTitle": "Engines",
"ariaTest": "{{engine}} testen",
"ariaRecheck": "{{engine}} erneut prüfen",
"ariaSelfTest": "Selbsttest für {{engine}}",
"ariaUnload": "{{engine}} entladen",
"ariaUse": "{{engine}} verwenden",
"ariaAcceptLicense": "Lizenz von {{engine}} prüfen und akzeptieren",
"weights": "Gewichte",
"noWeights": "Keine Katalog-Gewichte — diese Engine lädt beim ersten Einsatz selbst, was sie braucht.",
"colRunsOn": "Läuft auf",
+7 -1
View File
@@ -2122,6 +2122,12 @@
"loading": "Loading engines…",
"refresh": "Refresh",
"matrixTitle": "Engines",
"ariaTest": "Test {{engine}}",
"ariaRecheck": "Re-check {{engine}}",
"ariaSelfTest": "Self-test {{engine}}",
"ariaUnload": "Unload {{engine}}",
"ariaUse": "Use {{engine}}",
"ariaAcceptLicense": "Review and accept {{engine}} license",
"weights": "Weights",
"noWeights": "No catalogue weights — this engine fetches what it needs on first use.",
"colRunsOn": "Runs on",
@@ -2245,7 +2251,7 @@
"unexpected": "Unexpected error: {{message}}",
"backend_shutting_down": "VoiceStudio is shutting down. Reopen the app and try again.",
"crash_broken_env": "It died while loading its own Python dependencies, so this is not about memory or your GPU — the environment is incomplete or was left half-updated. Use \"Clean & Retry\" in Settings → Logs → Backend, which rebuilds it from scratch; that repairs it in place, without touching your voices or projects. If it still fails afterwards, the crash details name the exact package that would not import.",
"crash_vram_default": "On smaller GPUs the usual cause is running out of VRAM while loading the ASR model on top of the TTS model: flush the TTS model first, or pick a smaller ASR model in Model Catalogue → Models.",
"crash_vram_default": "On smaller GPUs the usual cause is running out of VRAM while loading the ASR model on top of the TTS model: flush the TTS model first, or pick a smaller ASR model in the engine's Weights list in Model Catalogue.",
"stream_cut_backend_alive": "The stream ended early, but the backend is still running — so it did not crash. In a served or containerised setup this is usually a reverse proxy or load balancer buffering or timing out the connection: disable response buffering for this route (nginx: proxy_buffering off; X-Accel-Buffering: no) and raise its read timeout. Running the desktop app directly, or on localhost without a proxy, will confirm it."
},
"crash": {
+6
View File
@@ -783,6 +783,12 @@
"loading": "Cargando motores…",
"refresh": "Actualizar",
"matrixTitle": "Motores",
"ariaTest": "Probar {{engine}}",
"ariaRecheck": "Volver a comprobar {{engine}}",
"ariaSelfTest": "Autoprueba de {{engine}}",
"ariaUnload": "Liberar {{engine}} de la memoria",
"ariaUse": "Usar {{engine}}",
"ariaAcceptLicense": "Revisar y aceptar la licencia de {{engine}}",
"weights": "Pesos",
"noWeights": "Sin pesos en el catálogo — este motor descarga lo que necesita en el primer uso.",
"colRunsOn": "Se ejecuta en",
+6
View File
@@ -783,6 +783,12 @@
"loading": "Chargement des moteurs…",
"refresh": "Actualiser",
"matrixTitle": "Moteurs",
"ariaTest": "Tester {{engine}}",
"ariaRecheck": "Revérifier {{engine}}",
"ariaSelfTest": "Autotest de {{engine}}",
"ariaUnload": "Décharger {{engine}}",
"ariaUse": "Utiliser {{engine}}",
"ariaAcceptLicense": "Examiner et accepter la licence de {{engine}}",
"weights": "Poids",
"noWeights": "Aucun poids au catalogue — ce moteur récupère ce qu'il lui faut au premier usage.",
"colRunsOn": "S'exécute sur",
+6
View File
@@ -783,6 +783,12 @@
"loading": "इंजन लोड हो रहे हैं...",
"refresh": "ताज़ा करें",
"matrixTitle": "इंजन",
"ariaTest": "{{engine}} का परीक्षण करें",
"ariaRecheck": "{{engine}} दोबारा जाँचें",
"ariaSelfTest": "{{engine}} का स्व-परीक्षण",
"ariaUnload": "{{engine}} अनलोड करें",
"ariaUse": "{{engine}} का उपयोग करें",
"ariaAcceptLicense": "{{engine}} का लाइसेंस देखें और स्वीकारें",
"weights": "वेट्स",
"noWeights": "कैटलॉग में कोई वेट्स नहीं — यह इंजन पहली बार उपयोग पर ज़रूरी चीज़ें स्वयं लाता है।",
"colRunsOn": "चलता है",
+6
View File
@@ -783,6 +783,12 @@
"loading": "Memuat mesin…",
"refresh": "Segarkan",
"matrixTitle": "Mesin",
"ariaTest": "Uji {{engine}}",
"ariaRecheck": "Periksa ulang {{engine}}",
"ariaSelfTest": "Uji mandiri {{engine}}",
"ariaUnload": "Lepas muat {{engine}}",
"ariaUse": "Gunakan {{engine}}",
"ariaAcceptLicense": "Tinjau dan terima lisensi {{engine}}",
"weights": "Bobot",
"noWeights": "Tidak ada bobot katalog — mesin ini mengambil yang dibutuhkan saat pertama dipakai.",
"colRunsOn": "Berjalan di",
+6
View File
@@ -783,6 +783,12 @@
"loading": "Caricamento motori…",
"refresh": "Aggiorna",
"matrixTitle": "Motori",
"ariaTest": "Prova {{engine}}",
"ariaRecheck": "Ricontrolla {{engine}}",
"ariaSelfTest": "Autotest di {{engine}}",
"ariaUnload": "Libera {{engine}} dalla memoria",
"ariaUse": "Usa {{engine}}",
"ariaAcceptLicense": "Esamina e accetta la licenza di {{engine}}",
"weights": "Pesi",
"noWeights": "Nessun peso a catalogo — questo motore scarica ciò che serve al primo utilizzo.",
"colRunsOn": "Gira su",
+6
View File
@@ -783,6 +783,12 @@
"loading": "エンジンをロード中…",
"refresh": "リフレッシュ",
"matrixTitle": "エンジン",
"ariaTest": "{{engine}} をテスト",
"ariaRecheck": "{{engine}} を再確認",
"ariaSelfTest": "{{engine}} のセルフテスト",
"ariaUnload": "{{engine}} をアンロード",
"ariaUse": "{{engine}} を使用",
"ariaAcceptLicense": "{{engine}} のライセンスを確認して同意",
"weights": "重み",
"noWeights": "カタログの重みはありません — このエンジンは初回使用時に必要なものを取得します。",
"colRunsOn": "実行環境",
+6
View File
@@ -1027,6 +1027,12 @@
"loading": "엔진 로드 중…",
"refresh": "새로고침",
"matrixTitle": "엔진",
"ariaTest": "{{engine}} 테스트",
"ariaRecheck": "{{engine}} 다시 확인",
"ariaSelfTest": "{{engine}} 자체 테스트",
"ariaUnload": "{{engine}} 언로드",
"ariaUse": "{{engine}} 사용",
"ariaAcceptLicense": "{{engine}} 라이선스 검토 및 동의",
"weights": "가중치",
"noWeights": "카탈로그 가중치 없음 — 이 엔진은 첫 사용 시 필요한 것을 직접 가져옵니다.",
"colRunsOn": "실행 장치",
+6
View File
@@ -783,6 +783,12 @@
"loading": "Motoren laden…",
"refresh": "Vernieuwen",
"matrixTitle": "Motoren",
"ariaTest": "{{engine}} testen",
"ariaRecheck": "{{engine}} opnieuw controleren",
"ariaSelfTest": "Zelftest van {{engine}}",
"ariaUnload": "{{engine}} ontladen",
"ariaUse": "{{engine}} gebruiken",
"ariaAcceptLicense": "Licentie van {{engine}} bekijken en accepteren",
"weights": "Gewichten",
"noWeights": "Geen catalogusgewichten — deze engine haalt bij het eerste gebruik zelf op wat nodig is.",
"colRunsOn": "Draait op",
+6
View File
@@ -783,6 +783,12 @@
"loading": "Ładowanie silników…",
"refresh": "Odśwież",
"matrixTitle": "Silniki",
"ariaTest": "Testuj {{engine}}",
"ariaRecheck": "Sprawdź ponownie {{engine}}",
"ariaSelfTest": "Autotest {{engine}}",
"ariaUnload": "Zwolnij {{engine}} z pamięci",
"ariaUse": "Użyj {{engine}}",
"ariaAcceptLicense": "Przejrzyj i zaakceptuj licencję {{engine}}",
"weights": "Wagi",
"noWeights": "Brak wag w katalogu — ten silnik pobiera potrzebne pliki przy pierwszym użyciu.",
"colRunsOn": "Działa na",
+6
View File
@@ -783,6 +783,12 @@
"loading": "Carregando motores…",
"refresh": "Atualizar",
"matrixTitle": "Motores",
"ariaTest": "Testar {{engine}}",
"ariaRecheck": "Verificar {{engine}} novamente",
"ariaSelfTest": "Autoteste de {{engine}}",
"ariaUnload": "Liberar {{engine}} da memória",
"ariaUse": "Usar {{engine}}",
"ariaAcceptLicense": "Revisar e aceitar a licença de {{engine}}",
"weights": "Pesos",
"noWeights": "Sem pesos no catálogo — este motor baixa o que precisa no primeiro uso.",
"colRunsOn": "Executa em",
+6
View File
@@ -783,6 +783,12 @@
"loading": "Загрузка двигателей…",
"refresh": "Обновить",
"matrixTitle": "Движки",
"ariaTest": "Проверить {{engine}}",
"ariaRecheck": "Перепроверить {{engine}}",
"ariaSelfTest": "Самопроверка {{engine}}",
"ariaUnload": "Выгрузить {{engine}}",
"ariaUse": "Использовать {{engine}}",
"ariaAcceptLicense": "Просмотреть и принять лицензию {{engine}}",
"weights": "Веса",
"noWeights": "Весов в каталоге нет — движок сам загрузит нужное при первом запуске.",
"colRunsOn": "Работает на",
+6
View File
@@ -783,6 +783,12 @@
"loading": "Laddar motorer...",
"refresh": "Uppdatera",
"matrixTitle": "Motorer",
"ariaTest": "Testa {{engine}}",
"ariaRecheck": "Kontrollera {{engine}} igen",
"ariaSelfTest": "Självtest av {{engine}}",
"ariaUnload": "Ladda ur {{engine}}",
"ariaUse": "Använd {{engine}}",
"ariaAcceptLicense": "Granska och godkänn licensen för {{engine}}",
"weights": "Vikter",
"noWeights": "Inga katalogvikter — motorn hämtar själv det den behöver vid första användningen.",
"colRunsOn": "Körs på",
+6
View File
@@ -783,6 +783,12 @@
"loading": "กำลังโหลดเครื่องยนต์...",
"refresh": "รีเฟรช",
"matrixTitle": "เอนจิน",
"ariaTest": "ทดสอบ {{engine}}",
"ariaRecheck": "ตรวจสอบ {{engine}} อีกครั้ง",
"ariaSelfTest": "ทดสอบตัวเองของ {{engine}}",
"ariaUnload": "ยกเลิกการโหลด {{engine}}",
"ariaUse": "ใช้ {{engine}}",
"ariaAcceptLicense": "ตรวจสอบและยอมรับใบอนุญาตของ {{engine}}",
"weights": "น้ำหนักโมเดล",
"noWeights": "ไม่มีน้ำหนักในแค็ตตาล็อก — เอนจินนี้จะดึงสิ่งที่ต้องใช้เมื่อใช้งานครั้งแรก",
"colRunsOn": "ทำงานบน",
+6
View File
@@ -783,6 +783,12 @@
"loading": "Motorlar yükleniyor…",
"refresh": "Yenile",
"matrixTitle": "Motorlar",
"ariaTest": "{{engine}} test et",
"ariaRecheck": "{{engine}} yeniden denetle",
"ariaSelfTest": "{{engine}} öz testi",
"ariaUnload": "{{engine}} bellekten kaldır",
"ariaUse": "{{engine}} kullan",
"ariaAcceptLicense": "{{engine}} lisansını incele ve kabul et",
"weights": "Ağırlıklar",
"noWeights": "Katalog ağırlığı yok — bu motor ilk kullanımda gerekeni kendisi indirir.",
"colRunsOn": "Çalıştığı yer",
+6
View File
@@ -783,6 +783,12 @@
"loading": "Завантаження двигунів…",
"refresh": "Оновити",
"matrixTitle": "Рушії",
"ariaTest": "Перевірити {{engine}}",
"ariaRecheck": "Перевірити {{engine}} ще раз",
"ariaSelfTest": "Самоперевірка {{engine}}",
"ariaUnload": "Вивантажити {{engine}}",
"ariaUse": "Використати {{engine}}",
"ariaAcceptLicense": "Переглянути й прийняти ліцензію {{engine}}",
"weights": "Ваги",
"noWeights": "Ваг у каталозі немає — рушій сам завантажить потрібне під час першого використання.",
"colRunsOn": "Працює на",
+6
View File
@@ -783,6 +783,12 @@
"loading": "Đang tải động cơ…",
"refresh": "Làm mới",
"matrixTitle": "Engine",
"ariaTest": "Kiểm tra {{engine}}",
"ariaRecheck": "Kiểm tra lại {{engine}}",
"ariaSelfTest": "Tự kiểm tra {{engine}}",
"ariaUnload": "Gỡ {{engine}} khỏi bộ nhớ",
"ariaUse": "Dùng {{engine}}",
"ariaAcceptLicense": "Xem và chấp nhận giấy phép {{engine}}",
"weights": "Trọng số",
"noWeights": "Không có trọng số trong danh mục — engine này tự tải khi dùng lần đầu.",
"colRunsOn": "Chạy trên",
+6
View File
@@ -979,6 +979,12 @@
"loading": "加载引擎中…",
"refresh": "刷新",
"matrixTitle": "引擎",
"ariaTest": "测试 {{engine}}",
"ariaRecheck": "重新检查 {{engine}}",
"ariaSelfTest": "{{engine}} 自检",
"ariaUnload": "卸载 {{engine}}",
"ariaUse": "使用 {{engine}}",
"ariaAcceptLicense": "查看并接受 {{engine}} 许可证",
"weights": "权重",
"noWeights": "目录中没有权重 — 此引擎会在首次使用时自行获取所需文件。",
"colRunsOn": "运行于",
+6
View File
@@ -783,6 +783,12 @@
"loading": "正在載入引擎...",
"refresh": "重新整理",
"matrixTitle": "引擎",
"ariaTest": "測試 {{engine}}",
"ariaRecheck": "重新檢查 {{engine}}",
"ariaSelfTest": "{{engine}} 自我測試",
"ariaUnload": "卸載 {{engine}}",
"ariaUse": "使用 {{engine}}",
"ariaAcceptLicense": "檢視並接受 {{engine}} 授權",
"weights": "權重",
"noWeights": "目錄中沒有權重 — 此引擎會在首次使用時自行取得所需檔案。",
"colRunsOn": "執行於",
+11
View File
@@ -7665,6 +7665,17 @@ button.dub-stepper__action:focus-visible {
color: white;
}
/* An error is a status line, not a transcript: two lines at most. A long
message used to wrap into a four-line block that spilled past the capsule's
edge. The label's `title` carries the full text, and anything the user must
act on also goes to the main window (utils/dictationNotice). */
.capture-pill--error .capture-pill__preview > span {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* In-app reduced motion (#1857)
Settings Appearance Reduce motion sets `data-motion="reduce"` on the
root. This is the same blanket rule the `prefers-reduced-motion` blocks
+7 -1
View File
@@ -235,7 +235,11 @@ export interface PrefsSlice {
setDictationMode: (mode: DictationMode) => void;
setDictationModelId: (id: string) => void;
/** Hydrate from GET /dictation/prefs (called once on app init). */
loadDictationPrefs: () => Promise<void>;
/** Resolves true when the backend answered, false when the seeds were
* kept because it did not. `dictationLoaded` is set either way (so the
* Settings panel never spins forever); this is how a caller can tell
* the two apart and retry. */
loadDictationPrefs: () => Promise<boolean>;
/**
* Auto-play the output preview as soon as a render finishes (Voice Clone /
@@ -387,11 +391,13 @@ export const createPrefsSlice: StateCreator<PrefsSlice, [], [], PrefsSlice> = (s
try {
const p = await apiJson<any>('/dictation/prefs');
set({ ..._dictationFromPrefs(p), dictationLoaded: true });
return true;
} catch {
// Backend not ready / older build without the route — keep the seeds and
// mark loaded so the panel renders defaults rather than a perpetual
// spinner. A later manual interaction will retry the write-through.
set({ dictationLoaded: true });
return false;
}
},
@@ -1,5 +1,5 @@
/**
* Model Catalogue Engines (ASR tab) OpenAI-compatible remote ASR panel.
* Model Catalogue (ASR tab) OpenAI-compatible remote ASR panel.
*
* The single Save button persists base URL + model + API key. Regression
* coverage for the dirty/saved lifecycle: Save is disabled while the fields
@@ -34,6 +34,7 @@ vi.mock('@tauri-apps/api/event', () => ({
eventUnlisteners.push(unlisten);
return unlisten;
}),
emit: emitMock,
}));
vi.mock('@tauri-apps/api/window', () => ({
getCurrentWindow: () => ({ hide: async () => {} }),
@@ -46,7 +47,10 @@ vi.mock('../api/client', () => ({
}));
vi.mock('../pages/Transcriptions', () => ({ addTranscription: vi.fn() }));
const { toastAsrMock } = vi.hoisted(() => ({ toastAsrMock: vi.fn() }));
const { toastAsrMock, emitMock } = vi.hoisted(() => ({
toastAsrMock: vi.fn(),
emitMock: vi.fn(async () => {}),
}));
vi.mock('../utils/asrModelMissing', () => ({
// Matches the real payload extraction for the WS frame shape.
asrMissingPayload: (err) =>
@@ -301,7 +305,21 @@ describe('CaptureWidget — connect-time asr_model_missing during mic setup', ()
await waitFor(() => {
expect(screen.getByText(/No speech-to-text model/)).toBeInTheDocument();
});
expect(toastAsrMock).toHaveBeenCalled();
// In the desktop app this window has no <Toaster>, so a local toast here
// rendered nowhere. The install action goes to the main window instead,
// carrying the recommendation so it can offer the one-click download.
expect(toastAsrMock).not.toHaveBeenCalled();
await waitFor(() =>
expect(emitMock).toHaveBeenCalledWith(
'dictation-notice',
expect.objectContaining({
kind: 'asr_missing',
missing: expect.objectContaining({
recommended: expect.objectContaining({ repo_id: 'x/y' }),
}),
}),
),
);
// Mic graph setup completes AFTER the error the tail must abort:
// release the worklet, keep the error state, never flip the tray on.
@@ -187,7 +187,9 @@ describe('EngineCompatibilityMatrix', () => {
await waitForRow('kittentts');
expect(nameOf('kittentts')).toHaveClass('text-muted-foreground');
expect(nameOf('omnivoice')).toHaveClass('text-foreground');
expect(within(rowOf('kittentts')).getByText('Needs setup')).toHaveClass('text-muted-foreground');
expect(within(rowOf('kittentts')).getByText('Needs setup')).toHaveClass(
'text-muted-foreground',
);
});
it('shows separate estimates and measured disk categories on demand', async () => {
@@ -1662,3 +1664,43 @@ describe('EngineCompatibilityMatrix', () => {
}
});
});
describe('EngineCompatibilityMatrix one-click install', () => {
function renderWithRow(reason) {
const res = makeEnginesResponse();
res.tts.backends.push({
id: 'pockettts',
display_name: 'PocketTTS (test)',
available: false,
reason,
one_click_install: true,
install_hint: '',
last_error: null,
isolation_mode: 'subprocess',
gpu_compat: ['cpu'],
});
render(
<EngineCompatibilityMatrix
family="tts"
apiListEngines={vi.fn().mockResolvedValue(res)}
apiGetEngineHealth={vi.fn()}
apiInstallStatus={vi.fn().mockResolvedValue({ state: 'idle' })}
/>,
);
return waitFor(() => screen.getByText('PocketTTS (test)'));
}
it('offers Install for an engine that is not installed yet', async () => {
await renderWithRow(
"This engine's package isn't installed yet. Install it from Model Catalogue.",
);
expect(screen.getByTestId('install-pockettts')).toBeInTheDocument();
});
it('offers only the license review once the engine is installed', async () => {
await renderWithRow(
'License not accepted yet. Review and accept it in Model Catalogue to enable this engine.',
);
expect(screen.queryByTestId('install-pockettts')).not.toBeInTheDocument();
});
});
+4 -4
View File
@@ -387,7 +387,7 @@ export function crashCauseHint(
defaultValue:
'It was force-killed (signal 9), which usually means the operating system ran out of ' +
'memory (RAM) and stopped it. Close memory-heavy apps, pick a smaller ASR model in ' +
'Model Catalogue → Models, or flush the TTS model before transcribing.',
'the engine Weights list in Model Catalogue, or flush the TTS model before transcribing.',
});
}
// Ordered deliberately, between the two explicit-fact branches.
@@ -426,8 +426,8 @@ export function crashCauseHint(
'It crashed inside the compute stack rather than running out of memory — that points ' +
'at a GPU driver that does not match the bundled CUDA runtime, or a model file that ' +
'downloaded incompletely. Update your GPU driver, then re-download the model from ' +
'Model Catalogue → Models (it repairs a partial download in place). If it keeps happening, ' +
'switch to a crash-isolated engine in Model Catalogue → Engines — "VoiceStudio (subprocess)" ' +
'the engine Weights list in Model Catalogue (it repairs a partial download in place). If it keeps happening, ' +
'switch to a crash-isolated engine in Model Catalogue — "VoiceStudio (subprocess)" ' +
'for synthesis, "Faster-Whisper (crash-isolated subprocess)" for transcription. Those ' +
'run the model in a separate process, so a crash like this takes down that process ' +
'instead of the whole backend.',
@@ -437,7 +437,7 @@ export function crashCauseHint(
defaultValue:
'On smaller GPUs the usual cause is running out of VRAM while loading the ASR model on ' +
'top of the TTS model: flush the TTS model first, or pick a smaller ASR model in ' +
'Model Catalogue → Models.',
'the engine Weights list in Model Catalogue.',
});
}
+8
View File
@@ -20,6 +20,7 @@
import toast from 'react-hot-toast';
import i18next from 'i18next';
import { inTauri, openAccessibilitySettings, openMicrophoneSettings } from './permissions';
import { toastAsrModelMissing } from './asrModelMissing';
export const DICTATION_NOTICE_EVENT = 'dictation-notice';
@@ -51,6 +52,13 @@ export async function listenDictationNotice(handler) {
* fixes it the rest are informational, because there is nothing to click.
*/
export function showDictationNotice(notice) {
// No speech model installed: the same one-click download toast every other
// ASR surface shows, not a bare label. The widget cannot show it itself
// its window has no <Toaster> in the desktop app.
if (notice?.kind === 'asr_missing') {
toastAsrModelMissing(notice.missing || {});
return;
}
const label = notice?.label;
if (!label) return; // nothing worth interrupting the user for
const opener =
@@ -0,0 +1,57 @@
/**
* dictationNotice the main window's half of the widget's error bridge.
*
* A missing speech model used to arrive here as a bare label. The widget had
* already tried to show the install toast itself, but in the desktop app its
* window has no <Toaster>, so that toast rendered nowhere and the main window
* showed "Transcription failed: No speech-to-text model is installed" with no
* way to install one.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
const mocks = vi.hoisted(() => ({
toastAsrModelMissing: vi.fn(),
toastError: vi.fn(),
}));
vi.mock('./asrModelMissing', () => ({ toastAsrModelMissing: mocks.toastAsrModelMissing }));
vi.mock('react-hot-toast', () => ({
default: Object.assign(vi.fn(), { error: mocks.toastError, dismiss: vi.fn() }),
}));
vi.mock('./permissions', () => ({
inTauri: () => true,
openAccessibilitySettings: vi.fn(),
openMicrophoneSettings: vi.fn(),
}));
import { showDictationNotice } from './dictationNotice';
describe('showDictationNotice', () => {
beforeEach(() => {
mocks.toastAsrModelMissing.mockReset();
mocks.toastError.mockReset();
});
it('offers the model download for a missing speech model', () => {
const missing = { recommended: { repo_id: 'x/parakeet', label: 'Parakeet', size_gb: 0.6 } };
showDictationNotice({ kind: 'asr_missing', label: 'ignored', missing });
expect(mocks.toastAsrModelMissing).toHaveBeenCalledWith(missing);
// One toast, not the install toast plus a bare duplicate.
expect(mocks.toastError).not.toHaveBeenCalled();
});
it('still explains a missing model when no recommendation came with it', () => {
showDictationNotice({ kind: 'asr_missing' });
expect(mocks.toastAsrModelMissing).toHaveBeenCalledWith({});
});
it('keeps plain labels for everything else', () => {
showDictationNotice({ kind: 'transcription', label: 'Transcription failed: boom' });
expect(mocks.toastAsrModelMissing).not.toHaveBeenCalled();
expect(mocks.toastError).toHaveBeenCalledWith('Transcription failed: boom', { duration: 8000 });
});
});
+2 -2
View File
@@ -25,7 +25,7 @@
* don't). The engine still gets selected; the user just finds out now instead
* of five minutes from now.
*
* Shared by Model Catalogue Engines and the first-run WizardLibrary so both paths
* Shared by Model Catalogue and the first-run WizardLibrary so both paths
* consume the echo identically.
*/
import { toast } from 'react-hot-toast';
@@ -38,7 +38,7 @@ export function notifyEngineSelected(r, t, family = 'tts') {
// else — benign cpu_only (a DirectML host explains itself on a normal pick)
// and unavailable — is information, not a warning.
const notice = routingNotice(r);
// Every engine pick lands here (Model Catalogue → Engines and the first-run
// Every engine pick lands here (Model Catalogue and the first-run
// WizardLibrary both call it), which makes it the one place that reliably
// knows the active engine just changed: drop the preflight's cached
// /engines response, and hand it whatever caveat we are about to show so it
+28
View File
@@ -158,6 +158,34 @@ def test_repo_changelog_is_quiet_style():
# ── linter self-tests: each rule must actually fire ──────────────────────────
def _duplicate_versions(text):
"""Versions with more than one section, read by the app's own parser
(core.changelog), which strips whitespace and a leading "v" and skips
headings such as [Unreleased], so `[v0.5.2]` and `[0.5.2]` are one version."""
from core.changelog import parse_changelog
versions = [r["version"] for r in parse_changelog(text, limit_versions=10**6)]
return sorted({v for v in versions if versions.count(v) > 1})
def test_every_version_has_one_section():
"""release.yml publishes the FIRST `## [X.Y.Z]` section as the release
body and stops at the next heading, so a second section for the same
version silently drops out of the notes (v0.5.2 was prepared twice)."""
with open(_REPO_CHANGELOG, encoding="utf-8") as fh:
dupes = _duplicate_versions(fh.read())
assert not dupes, f"CHANGELOG.md has more than one section for: {dupes}"
def test_duplicate_versions_are_found_however_the_heading_is_written():
text = (
"# Changelog\n\n## [Unreleased]\n\n## [v0.5.2] — 2026-09-10\n\n- a (#1)\n\n"
"## [ 0.5.2 ] — 2026-09-02\n\n- b (#2)\n\n## [0.5.1] — 2026-08-28\n\n- c (#3)\n"
)
assert _duplicate_versions(text) == ["0.5.2"]
_GOOD = """# Changelog
## [Unreleased]
+120 -1
View File
@@ -30,7 +30,6 @@ def _reason(diagnostic):
"funasr not installed. Install with: uv pip install funasr",
"kittentts not installed: No module named 'kittentts'",
"omnivoice package missing: cannot import name",
"mlx-whisper unavailable: not supported on this platform",
],
)
def test_a_missing_package_says_so(diagnostic):
@@ -106,3 +105,123 @@ def test_the_input_row_is_not_mutated():
original = {"id": "e", "reason": "voxcpm package not installed."}
public_backends([original])
assert original["reason"] == "voxcpm package not installed."
@pytest.mark.parametrize(
"diagnostic",
[
# The engines' own wording (Supertonic3Backend / PocketTTSBackend).
"Supertonic-3 license not accepted. Open Model Catalogue → "
"Supertonic-3 and click Accept to enable. (MIT code license + OpenRAIL-M "
"model license.)",
"PocketTTS license not accepted. Open Model Catalogue → "
"PocketTTS and review the MIT code license, CC-BY-4.0 model license, "
"and gated-access conditions before enabling it.",
],
)
@pytest.mark.parametrize("one_click", [None, True, False])
def test_a_license_gate_keeps_the_words_the_accept_button_needs(diagnostic, one_click):
"""The Accept button renders only when the reason matches the matrix's
/license not accepted/i. Collapsing the reason into the generic line left
Supertonic-3 and PocketTTS with no way to be enabled."""
import re
from pathlib import Path
row = {"id": "e", "reason": diagnostic}
if one_click is not None:
row["one_click_install"] = one_click
reason = public_backends([row])[0]["reason"]
# The engine list's display helpers own the matcher since the list +
# detail split (engines/engineDisplay.js, used by the row and the panel).
source = (
Path(__file__).resolve().parents[1]
/ "frontend/src/components/engines/engineDisplay.js"
).read_text(encoding="utf-8")
m = re.search(r"function reasonMentionsLicense\(reason\)[^}]*?return /([^/]+)/(\w*)\.test", source, re.S)
assert m, "engineDisplay.reasonMentionsLicense changed shape"
flags = re.I if "i" in m.group(2) else 0
assert re.search(m.group(1), reason, flags), reason
@pytest.mark.parametrize(
"diagnostic",
[
"MLX requires Apple Silicon; this host is win32/AMD64",
"MLX requires Apple Silicon; this Mac is Intel",
"mlx-whisper unavailable: not supported on this platform",
"PocketTTS is unavailable on Intel Macs because its required PyTorch "
"version has no macOS x86_64 wheel.",
"dots.tts is not supported on Windows — upstream targets Linux and macOS.",
],
)
@pytest.mark.parametrize("one_click", [None, False])
def test_a_platform_gap_is_not_reported_as_an_install_gap(diagnostic, one_click):
"""No install can fix these, so neither "isn't installed yet" nor "check
installation" is true."""
row = {"id": "e", "reason": diagnostic}
if one_click is not None:
row["one_click_install"] = one_click
reason = public_backends([row])[0]["reason"]
assert "platform" in reason
assert "install" not in reason.lower()
def test_the_real_mlx_gate_is_classified_as_a_platform_gap():
from core import device_caps
ok, why = device_caps.mlx_supported()
# Only the non-Apple branch is a platform gap. Apple Silicon without MPS,
# or without torch, is not; the literal cases below cover those anywhere.
if ok or not why.startswith("MLX requires Apple Silicon"):
pytest.skip("this host is Apple Silicon")
assert "platform" in _reason(why)
def test_apple_silicon_without_mps_is_told_what_is_missing():
"""The platform is right here; the installation's PyTorch is not. Neither
the platform sentence nor the generic line says that."""
reason = _reason(
"Apple Silicon detected but torch MPS unavailable; "
"reinstall torch with MPS support"
)
assert "MPS" in reason
assert "platform" not in reason
assert "Check installation and configuration" not in reason
def test_a_missing_mlx_package_on_apple_silicon_is_still_an_install_gap():
# The same engine on a Mac it does support: there, installing does help.
diagnostic = (
"mlx-audio unavailable: No module named 'mlx_audio'. This backend is "
"Apple Silicon only — available on mac-ARM dev installs; not shipped on "
"Linux/Windows/mac-Intel."
)
assert "isn't installed yet" in _reason(diagnostic)
def _reason_for(diagnostic, **row):
return public_backends([{"id": "e", "reason": diagnostic, **row}])[0]["reason"]
@pytest.mark.parametrize(
("diagnostic", "one_click", "points_at"),
[
("voxcpm package not installed.", True, "Model Catalogue"),
("voxcpm package not installed.", False, "guide"),
("file is missing", True, "Model Catalogue"),
("file is missing", False, "guide"),
],
)
def test_the_next_step_matches_whether_the_app_can_install_it(diagnostic, one_click, points_at):
"""Pointing at Model Catalogue for an engine with no Install button sent
people to a page that could not help them."""
reason = _reason_for(diagnostic, one_click_install=one_click)
assert points_at in reason
if not one_click:
assert "Model Catalogue" not in reason
def test_rows_without_the_install_field_keep_the_catalogue_wording():
# ASR / LLM / translation rows carry no one_click_install field.
assert "Model Catalogue" in _reason_for("transformers not installed")
+156
View File
@@ -0,0 +1,156 @@
"""MOSS-TTS-Nano from its own venv: the sidecar, and the switch to it.
At the pinned upstream commit, `moss_tts_nano` exports only `__version__` and
the entry point is the top-level `moss_tts_nano_runtime.NanoTTSService`. These
tests run the sidecar against a fake of that runtime.
"""
import importlib.util
import io
import json
import re
import struct
import sys
import types
from pathlib import Path
import numpy as np
import pytest
_MAIN = Path(__file__).resolve().parents[1] / "backend/engines/moss_tts_nano_subprocess/main.py"
def _load_sidecar(monkeypatch, calls, *, sample_rate=48000, channels=2, samples=960):
class NanoTTSService:
def __init__(self, **kw):
calls.append(("init", kw))
self.output_dir = Path(kw["output_dir"])
def preload(self, **kw):
calls.append(("preload", kw))
def synthesize(self, **kw):
calls.append(("synthesize", kw))
shape = (samples, channels) if channels > 1 else (samples,)
return {"waveform_numpy": np.full(shape, 0.25, dtype=np.float32), "sample_rate": sample_rate}
runtime = types.ModuleType("moss_tts_nano_runtime")
runtime.NanoTTSService = NanoTTSService
package = types.ModuleType("moss_tts_nano")
defaults = types.ModuleType("moss_tts_nano.defaults")
defaults.DEFAULT_CHECKPOINT_PATH = "OpenMOSS-Team/MOSS-TTS-Nano"
defaults.DEFAULT_AUDIO_TOKENIZER_PATH = "OpenMOSS-Team/MOSS-Audio-Tokenizer-Nano"
package.defaults = defaults
monkeypatch.setitem(sys.modules, "moss_tts_nano_runtime", runtime)
monkeypatch.setitem(sys.modules, "moss_tts_nano", package)
monkeypatch.setitem(sys.modules, "moss_tts_nano.defaults", defaults)
spec = importlib.util.spec_from_file_location("_nano_sidecar_under_test", _MAIN)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def _frames(buf):
data, out, i = buf.getvalue(), [], 0
while i < len(data):
(n,) = struct.unpack("!I", data[i:i + 4])
out.append(json.loads(data[i + 4:i + 4 + n]))
i += 4 + n
return out
def test_drives_upstreams_runtime_and_downmixes_to_mono(monkeypatch):
calls = []
sidecar = _load_sidecar(monkeypatch, calls)
out = io.BytesIO()
sidecar._handle_synthesize({"op": "synthesize", "text": "hello"}, out)
init = dict(calls)["init"]
assert init["checkpoint_path"] == "OpenMOSS-Team/MOSS-TTS-Nano"
assert init["audio_tokenizer_path"] == "OpenMOSS-Team/MOSS-Audio-Tokenizer-Nano"
assert ("preload", {"load_model": True}) in calls
synth = dict(calls)["synthesize"]
assert synth["text"] == "hello"
assert synth["mode"] == "voice_clone"
assert synth["prompt_audio_path"] is None
# One output file, reused, inside the temp dir — not one per call.
assert Path(synth["output_audio_path"]).parent == Path(init["output_dir"])
audio = _frames(out)[-1]
assert audio["op"] == "audio"
assert audio["sample_rate"] == 48000 and audio["n_samples"] == 960
def test_passes_the_reference_clip_and_rejects_a_url(monkeypatch, tmp_path):
calls = []
sidecar = _load_sidecar(monkeypatch, calls)
ref = tmp_path / "ref.wav"
ref.write_bytes(b"x")
sidecar._handle_synthesize({"text": "hi", "ref_audio": str(ref)}, io.BytesIO())
assert dict(calls)["synthesize"]["prompt_audio_path"] == str(ref)
with pytest.raises(ValueError, match="local file path"):
sidecar._handle_synthesize({"text": "hi", "ref_audio": "https://x.test/a.wav"}, io.BytesIO())
def test_resamples_to_the_rate_the_engine_reports(monkeypatch):
sidecar = _load_sidecar(monkeypatch, [], sample_rate=24000, channels=1, samples=960)
out = io.BytesIO()
sidecar._handle_synthesize({"text": "hi"}, out)
audio = _frames(out)[-1]
assert audio["sample_rate"] == 48000
assert audio["n_samples"] == 1920
def test_only_the_cold_call_sends_progress(monkeypatch):
"""Progress frames keep the watchdog armed through downloads; a warm call
sending them would stop it from catching a generation that wedges."""
sidecar = _load_sidecar(monkeypatch, [])
first, second = io.BytesIO(), io.BytesIO()
sidecar._handle_synthesize({"text": "one"}, first)
sidecar._handle_synthesize({"text": "two"}, second)
assert any(f["op"] == "progress" for f in _frames(first))
assert [f["op"] for f in _frames(second)] == ["audio"]
def test_the_sidecar_imports_nothing_from_the_app():
src = _MAIN.read_text(encoding="utf-8")
for name in ("services", "core", "engines", "api", "backend", "utils"):
assert not re.search(rf"^\s*(from|import) {name}\b", src, re.M), name
def test_the_class_switches_to_the_sidecar_once_its_venv_exists(monkeypatch, tmp_path):
from engines.moss_tts_nano_subprocess import MossTTSNanoSubprocessBackend
from services import tts_backend
from services.sidecar_install import _INSTALL_COMPLETE_MARKER, _venv_python
monkeypatch.setenv("OMNIVOICE_MOSS_TTS_NANO_DIR", "")
monkeypatch.delenv("OMNIVOICE_MOSS_TTS_NANO_DIR")
assert tts_backend.get_backend_class("moss-tts-nano") is tts_backend.MossTTSNanoBackend
py = _venv_python(tmp_path / ".venv")
py.parent.mkdir(parents=True)
py.write_text("#!fake\n")
(tmp_path / _INSTALL_COMPLETE_MARKER).write_text("x\n", encoding="utf-8")
monkeypatch.setenv("OMNIVOICE_MOSS_TTS_NANO_DIR", str(tmp_path))
cls = tts_backend.get_backend_class("moss-tts-nano")
assert cls is MossTTSNanoSubprocessBackend
assert cls.venv_python() == py
assert cls.is_available() == (True, "ready")
for attr in ("id", "display_name", "gpu_compat"):
assert getattr(cls, attr) == getattr(tts_backend.MossTTSNanoBackend, attr), attr
assert cls().supported_languages == tts_backend.MossTTSNanoBackend().supported_languages
def test_every_own_venv_engine_has_an_installer_that_sets_its_path():
"""The resolver switches on the env var the engine's module reads; the
installer must be the thing that sets that same variable."""
import importlib
from services import sidecar_install, tts_backend
for engine_id, (module_name, class_name) in tts_backend._OWN_VENV_SIDECARS.items():
module = importlib.import_module(module_name)
spec = sidecar_install.get_spec(engine_id)
assert spec is not None, engine_id
assert spec.env_var == module.VENV_ENV_VAR, engine_id
assert getattr(module, class_name).id == engine_id
+47
View File
@@ -264,3 +264,50 @@ def test_availability_text_does_not_exclude_declared_devices(monkeypatch):
assert available is installed
assert "CUDA or CPU only" not in reason
assert "CUDA when present, else CPU" not in reason
def test_bootstrap_install_names_the_pytorch_cuda_index(monkeypatch, tmp_path):
"""#2015: the [torch-runtime] extra pins torch==2.9.1+cu128, which exists
only on PyTorch's index — without it the install could never resolve."""
from core.torch_indexes import UV_PIP_CU128_ARGS
from engines.moss_tts_v15 import bootstrap
ran = []
monkeypatch.setattr(bootstrap, "_ENGINES_VENV_DIR", tmp_path / ".venv")
monkeypatch.setattr(bootstrap, "_locate_uv", lambda: "/fake/uv")
monkeypatch.setattr(bootstrap, "_uv_env", lambda: None)
monkeypatch.setattr(bootstrap, "_venv_can_import_moss", lambda p: "yes")
monkeypatch.setattr(bootstrap.subprocess, "run", lambda argv, **k: ran.append(argv))
bootstrap._bootstrap_engines_venv(tmp_path / "MOSS-TTS")
pip = next(a for a in ran if a[1:3] == ["pip", "install"])
i = pip.index("--extra-index-url")
assert tuple(pip[i:i + len(UV_PIP_CU128_ARGS)]) == UV_PIP_CU128_ARGS
venv_python = bootstrap._venv_python_path(tmp_path / ".venv")
assert pip[pip.index("--python") + 1] == str(venv_python)
def test_bootstrap_install_failure_reports_uvs_error_not_a_host_guess(monkeypatch, tmp_path):
"""The PyTorch index is always supplied now, so blaming "a non-CUDA host"
would mislead; uv's own error says what failed."""
import subprocess
from engines.moss_tts_v15 import bootstrap
def fake_run(argv, **kwargs):
if argv[1:3] == ["pip", "install"]:
raise subprocess.CalledProcessError(1, argv, stderr=b"resolver: no wheel for torchcodec")
monkeypatch.setattr(bootstrap, "_ENGINES_VENV_DIR", tmp_path / ".venv")
monkeypatch.setattr(bootstrap, "_locate_uv", lambda: "/fake/uv")
monkeypatch.setattr(bootstrap, "_uv_env", lambda: None)
monkeypatch.setattr(bootstrap.subprocess, "run", fake_run)
with pytest.raises(RuntimeError) as err:
bootstrap._bootstrap_engines_venv(tmp_path / "MOSS-TTS")
message = str(err.value)
assert "resolver: no wheel for torchcodec" in message
assert "non-CUDA" not in message
assert "docs/engines/moss-tts-v15.md" in message
+23
View File
@@ -282,3 +282,26 @@ def test_license_api_accepts_pockettts_and_rejects_unknown(settings_mod, mock_se
def settings_mod():
import importlib
return importlib.import_module("api.routers.settings")
def test_prefers_the_venv_its_one_click_install_made(monkeypatch, tmp_path, mock_settings_store):
"""Its own venv when the installer made one; otherwise the app's
interpreter, where `uv sync --extra pockettts` installs it."""
from pathlib import Path
from services.sidecar_install import _INSTALL_COMPLETE_MARKER, _venv_python
mock_settings_store["pockettts"] = True
monkeypatch.delenv("OMNIVOICE_POCKETTTS_DIR", raising=False)
assert _backend_cls().venv_python() == Path(sys.executable)
py = _venv_python(tmp_path / ".venv")
py.parent.mkdir(parents=True)
py.write_text("#!fake\n")
(tmp_path / _INSTALL_COMPLETE_MARKER).write_text("x\n", encoding="utf-8")
monkeypatch.setenv("OMNIVOICE_POCKETTTS_DIR", str(tmp_path))
assert _backend_cls().venv_python() == py
# Available without pocket_tts importable in the app's own environment.
monkeypatch.setitem(sys.modules, "pocket_tts", None)
if _backend_cls()._platform_error() is None:
assert _backend_cls().is_available() == (True, "ready (CPU-only)")
+65
View File
@@ -0,0 +1,65 @@
"""Release notes and publishing have one writer, after every platform has built.
Every build leg used to append its checksums to the shared release notes with
softprops/action-gh-release. The concurrent read-modify-writes lost a section
(the macOS Apple Silicon one, in both v0.5.1 and v0.5.2), and softprops'
default draft: false published the draft when the FIRST leg finished, before
the other installers and the complete latest.json were attached.
"""
import re
from pathlib import Path
WORKFLOW = (Path(__file__).resolve().parents[1] / ".github/workflows/release.yml").read_text(
encoding="utf-8"
)
def _job(name: str) -> str:
"""The text of one top-level job, up to the next job."""
start = WORKFLOW.index(f"\n {name}:\n")
nxt = re.search(r"\n [a-z0-9_-]+:\n", WORKFLOW[start + 1:])
return WORKFLOW[start: start + 1 + nxt.start()] if nxt else WORKFLOW[start:]
def _code(text: str) -> str:
"""Without YAML comment lines: an explanation may name what it replaced."""
return "\n".join(line for line in text.splitlines() if not line.lstrip().startswith("#"))
def _matrix_labels() -> list[str]:
return re.findall(r'^\s+label: "([^"]+)"$', _job("build"), flags=re.M)
def test_no_build_leg_writes_the_notes_or_publishes():
build = _code(_job("build"))
assert "softprops/action-gh-release" not in build
assert "append_body" not in build
assert "--draft=false" not in build
def test_one_job_writes_every_platforms_checksums_in_matrix_order():
job = _job("release-notes-checksums")
assert "needs: [build, repair-updater-manifest, uninstall-scripts]" in job
labels = _matrix_labels()
assert labels, "the build matrix no longer declares platform labels"
positions = [job.index(f'"{label}"') for label in labels]
assert positions == sorted(positions), "sections must follow the matrix order"
def test_only_that_job_publishes():
job = _code(_job("release-notes-checksums"))
assert job.count("--draft=false") >= 1
assert _code(WORKFLOW).count("--draft=false") == job.count("--draft=false")
def test_the_contributors_strip_edits_the_notes_after_them():
assert "needs: [build, release-notes-checksums]" in _job("contributors-strip")
def test_a_missing_platform_stops_before_the_notes_or_the_publish():
"""A release missing one platform's checksums must stay a draft: the job
exits before it rewrites the notes or publishes anything."""
job = _code(_job("release-notes-checksums"))
assert "missing=1" in job
gate = job.index('[ "$missing" = 0 ] || exit 1')
assert gate < job.index("--notes-file") < job.index("--draft=false")
+395
View File
@@ -49,6 +49,12 @@ def _clean_state(monkeypatch, tmp_path):
monkeypatch.delenv("OMNIVOICE_INDEXTTS_DIR", raising=False)
monkeypatch.delenv("OMNIVOICE_FAKE_SIDE_DIR", raising=False)
monkeypatch.delenv("OMNIVOICE_DESKTOP_CONTAINED", raising=False)
# Set-then-delete: a bare delenv of an unset var records nothing to
# restore, so a path an install test persists would leak into later
# suites (an engine would then find a venv that no longer exists).
for spec in si.SPECS.values():
monkeypatch.setenv(spec.env_var, "")
monkeypatch.delenv(spec.env_var)
yield
@@ -929,3 +935,392 @@ def test_router_uninstall_maps_refusals_to_http_errors(monkeypatch):
with pytest.raises(HTTPException) as ei:
engines_router.uninstall_sidecar_engine("fake-side")
assert ei.value.status_code == 400
# ── isolation: switching engines can never corrupt another engine ─────────
#
# Every one-click engine owns DATA_DIR/engines/<id>/ — its checkout and its
# .venv — and switching the active engine only changes a pref. So going back
# to an engine that worked is safe for exactly as long as no install ever
# writes outside its own root. These tests pin that for every spec, including
# ones added later.
_ALL_SPEC_IDS = sorted(si.SPECS)
_PYTORCH_INDEX = "https://download.pytorch.org/whl/cu128"
def _capture_install_argvs(monkeypatch, family="cuda"):
argvs = []
monkeypatch.setattr(si, "_locate_uv", lambda: "/fake/uv")
monkeypatch.setattr(si, "_host_family", lambda: family)
monkeypatch.setattr(si, "_run_logged", _fake_run_logged(argvs))
return argvs
@pytest.mark.parametrize("engine_id", _ALL_SPEC_IDS)
def test_every_spec_installs_only_into_its_own_venv(monkeypatch, engine_id):
spec = si.get_spec(engine_id)
argvs = _capture_install_argvs(monkeypatch)
job = si._new_job(engine_id)
si._step_create_venv(spec, job)
si._step_install_deps(spec, job)
assert si.managed_root(spec) == Path(si.DATA_DIR) / "engines" / engine_id
venv = si.managed_checkout(spec) / ".venv"
venv_cmd = next(a for a in argvs if a[1] == "venv")
assert venv_cmd[2] == str(venv)
pip = next(a for a in argvs if a[1:3] == ["pip", "install"])
# The interpreter uv installs into is this engine's venv — never the app's.
assert pip[3:5] == ["--python", str(si._venv_python(venv))]
for argv in argvs:
assert sys.executable not in argv
assert not any(sys.prefix in part for part in argv)
def test_managed_roots_never_overlap():
roots = {eid: si.managed_root(si.get_spec(eid)) for eid in _ALL_SPEC_IDS}
for a, ra in roots.items():
for b, rb in roots.items():
if a != b:
assert ra != rb and ra not in rb.parents and rb not in ra.parents, (a, b)
def test_uninstalling_one_engine_leaves_every_other_engine_intact(monkeypatch):
for eid in _ALL_SPEC_IDS:
py = si._venv_python(si.managed_checkout(si.get_spec(eid)) / ".venv")
py.parent.mkdir(parents=True)
py.write_text("#!fake\n")
monkeypatch.setattr("core.prefs.get", lambda k, d=None: None)
monkeypatch.setattr("core.prefs.delete", lambda k: None)
assert si.uninstall("moss-tts-v15")["status"] == "uninstalled"
assert not si.managed_root(si.get_spec("moss-tts-v15")).exists()
for eid in _ALL_SPEC_IDS:
if eid != "moss-tts-v15":
spec = si.get_spec(eid)
assert si._venv_python(si.managed_checkout(spec) / ".venv").is_file(), eid
# ── per-engine install recipes ────────────────────────────────────────────
@pytest.mark.parametrize(
("engine_id", "venv_args", "install_args", "env_var"),
[
("moss-tts-v15", ["--python", "3.11"], ["-e", "{c}[torch-runtime]"],
"OMNIVOICE_MOSS_TTS_V15_DIR"),
("confucius4-tts", ["--python", "3.10"], ["-r", "{c}/requirements.txt"],
"OMNIVOICE_CONFUCIUS4_TTS_DIR"),
("dots-tts", ["--python", "3.11"],
["-e", "{c}", "-c", "{c}/constraints/recommended.txt"],
"OMNIVOICE_DOTS_TTS_DIR"),
],
)
def test_new_specs_install_recipe(monkeypatch, engine_id, venv_args, install_args, env_var):
spec = si.get_spec(engine_id)
# The env var must be the one the engine's own bootstrap reads, or the
# install lands in a directory the engine never looks at.
assert spec.env_var == env_var
argvs = _capture_install_argvs(monkeypatch, family="cpu")
job = si._new_job(engine_id)
si._step_create_venv(spec, job)
si._step_install_deps(spec, job)
checkout = str(si.managed_checkout(spec))
venv_cmd = next(a for a in argvs if a[1] == "venv")
assert venv_cmd[3:] == venv_args
pip = next(a for a in argvs if a[1:3] == ["pip", "install"])
assert pip[5:] == [arg.replace("{c}", checkout) for arg in install_args]
@pytest.mark.parametrize("family", ["cuda", "cpu", "rocm", "mps"])
@pytest.mark.parametrize("engine_id", _ALL_SPEC_IDS)
def test_cuda_index_is_added_only_for_cuda_pinned_specs_on_cuda_hosts(
monkeypatch, engine_id, family
):
from core.torch_indexes import UV_PIP_CU128_ARGS
spec = si.get_spec(engine_id)
argvs = _capture_install_argvs(monkeypatch, family=family)
si._step_install_deps(spec, si._new_job(engine_id))
pip = next(a for a in argvs if a[1:3] == ["pip", "install"])
has_index = _PYTORCH_INDEX in pip
assert has_index == (family == "cuda" and (spec.uses_cuda_index or bool(spec.torch_pins)))
if has_index:
i = pip.index("--extra-index-url")
assert tuple(pip[i:i + len(UV_PIP_CU128_ARGS)]) == UV_PIP_CU128_ARGS
def test_torch_index_matches_the_apps_own_pytorch_cuda_index():
"""The sidecar index must be the one the app's own torch comes from."""
import tomllib
from core.torch_indexes import PYTORCH_CU128_INDEX_URL
pyproject = Path(__file__).resolve().parents[1] / "pyproject.toml"
indexes = tomllib.loads(pyproject.read_text(encoding="utf-8"))["tool"]["uv"]["index"]
cuda = next(ix for ix in indexes if ix["name"] == "pytorch-cuda")
assert PYTORCH_CU128_INDEX_URL == cuda["url"] == _PYTORCH_INDEX
@pytest.mark.parametrize("engine_id", _ALL_SPEC_IDS)
def test_verify_probe_runs_in_the_engines_venv_and_compiles(monkeypatch, engine_id):
spec = si.get_spec(engine_id)
# The venv, and so the checkout, exist by the time verify runs.
si.managed_checkout(spec).mkdir(parents=True)
ran = []
def fake_run(argv, **kwargs):
ran.append(argv)
return SimpleNamespace(returncode=0, stderr=b"", stdout=b"")
monkeypatch.setattr(si.subprocess, "run", fake_run)
si._step_verify(spec, si._new_job(engine_id))
checkout = si.managed_checkout(spec)
assert ran[0][0] == str(si._venv_python(checkout / ".venv"))
code = ran[0][2]
compile(code, "<probe>", "exec") # a Windows path must not break the literal
assert "{checkout" not in code
if engine_id == "confucius4-tts":
assert repr(str(checkout)) in code
# ── host gates: no Install button that can only fail ─────────────────────
@pytest.mark.parametrize(
("family", "platform", "machine", "expected"),
[
("cuda", "linux", "x86_64", {"moss-tts-v15", "dots-tts", "pockettts", "voxcpm2", "moss-tts-nano"}),
("cuda", "win32", "AMD64", {"moss-tts-v15", "pockettts", "voxcpm2", "moss-tts-nano"}),
("cpu", "win32", "AMD64", {"pockettts", "voxcpm2", "moss-tts-nano"}),
("mps", "darwin", "arm64", {"dots-tts", "pockettts", "voxcpm2", "moss-tts-nano"}),
# Intel Mac: PyTorch publishes no build PocketTTS, VoxCPM2 or
# MOSS-TTS-Nano can use.
("cpu", "darwin", "x86_64", {"dots-tts"}),
],
)
def test_installable_engine_ids_follow_the_host(monkeypatch, family, platform, machine, expected):
import platform as platform_mod
monkeypatch.setattr(si, "_host_family", lambda: family)
monkeypatch.setattr(si.sys, "platform", platform)
monkeypatch.setattr(platform_mod, "machine", lambda: machine)
# Offered on every host: IndexTTS 2.5, Confucius4, Supertonic-3.
expected = set(expected) | {"indextts2", "confucius4-tts", "supertonic3"}
assert si.installable_engine_ids() == frozenset(expected)
def test_a_host_probe_that_raises_counts_as_unsupported():
def boom():
raise RuntimeError("probe exploded")
spec = _mk_spec(host_supported=boom)
ok, why = si.host_support(spec)
assert not ok
assert spec.docs_path in why and "exploded" not in why
def test_start_install_refuses_an_unsupported_host(monkeypatch):
monkeypatch.setattr(si, "_host_family", lambda: "cpu")
with pytest.raises(si.HostUnsupported, match="NVIDIA"):
si.start_install("moss-tts-v15")
assert "moss-tts-v15" not in si._jobs
def test_router_maps_unsupported_host_to_409(monkeypatch):
from fastapi import HTTPException
from api.routers import engines as engines_router
monkeypatch.setattr(si.sys, "platform", "win32")
with pytest.raises(HTTPException) as ei:
engines_router.install_sidecar_engine("dots-tts")
assert ei.value.status_code == 409
assert "Windows" in ei.value.detail
def test_list_backends_offers_install_only_where_it_can_work(monkeypatch):
from services import tts_backend
monkeypatch.setattr(si, "_host_family", lambda: "cpu")
monkeypatch.setattr(si.sys, "platform", "win32")
rows = {r["id"]: r for r in tts_backend.list_backends()}
assert rows["confucius4-tts"]["one_click_install"] is True
assert rows["moss-tts-v15"]["one_click_install"] is False
assert rows["dots-tts"]["one_click_install"] is False
# ── PyPI-package engines (Supertonic-3, PocketTTS) ─────────────────────────
@pytest.mark.parametrize(
("engine_id", "package", "env_var"),
[
("supertonic3", "supertonic==1.3.1", "OMNIVOICE_SUPERTONIC3_DIR"),
("pockettts", "pocket-tts==2.1.0", "OMNIVOICE_POCKETTTS_DIR"),
],
)
def test_pypi_engines_install_the_apps_own_pin_without_fetching_source(
monkeypatch, engine_id, package, env_var
):
import tomllib
spec = si.get_spec(engine_id)
assert spec.env_var == env_var and not spec.has_source
# The same pin as the app's optional extra, so the engine runs the same
# wheel whether it was installed here or with `uv sync --extra`.
pyproject = Path(__file__).resolve().parents[1] / "pyproject.toml"
extras = tomllib.loads(pyproject.read_text(encoding="utf-8"))["project"]["optional-dependencies"]
assert package in {req.split(";")[0].strip() for reqs in extras.values() for req in reqs}
monkeypatch.delenv(env_var, raising=False)
argvs = _capture_install_argvs(monkeypatch, family="cpu")
monkeypatch.setattr(si, "disk_free_bytes", lambda p: 100 * _GIB)
monkeypatch.setattr(si.shutil, "which", lambda n: None)
_stub_verify_ok(monkeypatch)
monkeypatch.setattr("core.prefs.set_", lambda k, v: None)
job = _run(spec)
assert job["state"] == "succeeded", (job["error"], list(job["log"]))
assert not any(os.path.basename(a[0]).startswith("git") for a in argvs)
pip = next(a for a in argvs if a[1:3] == ["pip", "install"])
assert pip[5] == package
assert os.environ[env_var] == str(si.managed_checkout(spec))
assert si._healthy(spec)
@pytest.mark.parametrize("family", ["cuda", "cpu", "rocm", "mps"])
def test_pockettts_installs_cpu_torch_on_every_host(monkeypatch, family):
from core.torch_indexes import UV_PIP_CPU_ARGS
argvs = _capture_install_argvs(monkeypatch, family=family)
si._step_install_deps(si.get_spec("pockettts"), si._new_job("pockettts"))
pip = next(a for a in argvs if a[1:3] == ["pip", "install"])
i = pip.index("--extra-index-url")
assert tuple(pip[i:i + len(UV_PIP_CPU_ARGS)]) == UV_PIP_CPU_ARGS
assert pip.count("--extra-index-url") == 1
def test_an_extra_already_in_the_app_env_counts_as_installed(monkeypatch):
"""A `uv sync --extra supertonic` install keeps working and is never
provisioned over."""
import importlib.util as ilu
monkeypatch.delenv("OMNIVOICE_SUPERTONIC3_DIR", raising=False)
real = ilu.find_spec
monkeypatch.setattr(
ilu, "find_spec", lambda name, *a: object() if name == "supertonic" else real(name, *a)
)
assert si.start_install("supertonic3")["status"] == "already_installed"
assert "supertonic3" not in si._jobs
def test_engine_venv_python_needs_a_real_interpreter(monkeypatch, tmp_path):
monkeypatch.delenv("OMNIVOICE_FAKE_SIDE_DIR", raising=False)
assert si.engine_venv_python("OMNIVOICE_FAKE_SIDE_DIR") is None
monkeypatch.setenv("OMNIVOICE_FAKE_SIDE_DIR", str(tmp_path))
assert si.engine_venv_python("OMNIVOICE_FAKE_SIDE_DIR") is None # no venv yet
py = si._venv_python(tmp_path / ".venv")
py.parent.mkdir(parents=True)
py.write_text("#!fake\n")
# An interpreter without the marker is a failed or unfinished install.
assert si.engine_venv_python("OMNIVOICE_FAKE_SIDE_DIR") is None
(tmp_path / si._INSTALL_COMPLETE_MARKER).write_text("x\n", encoding="utf-8")
assert si.engine_venv_python("OMNIVOICE_FAKE_SIDE_DIR") == py
# The root of each pinned upstream commit, as GitHub lists it (2026-09-10).
_UPSTREAM_ROOT_FILES = {
"moss-tts-v15": ("pyproject.toml", "README.md", "LICENSE", "MANIFEST.in"),
"confucius4-tts": ("requirements.txt", "setup.py", "README.md", "LICENSE", "server.py"),
"dots-tts": ("pyproject.toml", "README.md", "LICENSE", "constraints/recommended.txt"),
"moss-tts-nano": ("pyproject.toml", "moss_tts_nano_runtime.py", "requirements.txt",
"README.md", "LICENSE"),
}
@pytest.mark.parametrize("engine_id", sorted(_UPSTREAM_ROOT_FILES))
def test_a_real_upstream_layout_passes_source_validation(monkeypatch, engine_id):
"""Confucius4 has no pyproject.toml. Source validation demanded one of every
checkout, so its install could never get past fetching the source."""
spec = si.get_spec(engine_id)
def fake_git(job, argv, *, timeout, env=None):
if argv[1] == "clone":
checkout = Path(argv[-1])
for rel in _UPSTREAM_ROOT_FILES[engine_id]:
(checkout / rel).parent.mkdir(parents=True, exist_ok=True)
(checkout / rel).write_text("x\n")
return 0
def no_tarball(*args, **kwargs):
pytest.fail("a valid clone fell back to the source tarball")
monkeypatch.setattr(si.shutil, "which", lambda n: "/usr/bin/git" if n == "git" else None)
monkeypatch.setattr(si, "_run_logged", fake_git)
monkeypatch.setattr(si, "_fetch_tarball", no_tarball)
job = si._new_job(engine_id)
si._step_fetch_source(spec, job)
assert si._job_step(job, "fetch_source")["detail"] == "git clone"
assert si._source_present(spec, si.managed_checkout(spec))
def test_a_failed_dependency_install_is_repaired_not_reported_installed(monkeypatch):
"""A venv whose dependency install died halfway still has its interpreter.
Counting that as installed made a retry answer already_installed, and the
engine then failed at its first import."""
spec = _mk_spec(repo_url="", tarball_url="", has_source=False)
monkeypatch.setitem(si.SPECS, "fake-side", spec)
monkeypatch.setattr(si, "_locate_uv", lambda: "/fake/uv")
monkeypatch.setattr(si, "disk_free_bytes", lambda p: 100 * _GIB)
monkeypatch.setattr("core.prefs.set_", lambda k, v: None)
_stub_verify_ok(monkeypatch)
argvs = []
ok_run = _fake_run_logged(argvs)
def pip_fails(job, argv, *, timeout, env=None):
rc = ok_run(job, argv, timeout=timeout, env=env)
return 1 if argv[1:3] == ["pip", "install"] else rc
# A complete install is healthy.
monkeypatch.setattr(si, "_run_logged", ok_run)
assert _run(spec)["state"] == "succeeded"
assert si._healthy(spec)
# A reinstall whose dependency step fails is not, though the venv remains.
monkeypatch.setattr(si, "_run_logged", pip_fails)
assert _run(spec)["state"] == "failed"
assert si._venv_python(si.managed_checkout(spec) / ".venv").is_file()
assert not si._healthy(spec)
# And the next run repairs it.
monkeypatch.setattr(si, "_run_logged", ok_run)
assert _run(spec)["state"] == "succeeded"
assert si._healthy(spec)
@pytest.mark.parametrize(
("family", "platform", "suffix", "index"),
[
("cuda", "win32", "+cu128", "https://download.pytorch.org/whl/cu128"),
("cuda", "linux", "+cu128", "https://download.pytorch.org/whl/cu128"),
("cpu", "win32", "+cpu", "https://download.pytorch.org/whl/cpu"),
("rocm", "linux", "+cpu", "https://download.pytorch.org/whl/cpu"),
("mps", "darwin", "", None),
],
)
def test_torch_pins_follow_the_host(monkeypatch, family, platform, suffix, index):
"""voxcpm leaves torch unpinned, and resolving it with the CUDA index
paired PyPI's newest torch (CPU-only on Windows) with a CUDA torchaudio.
The spec pins the pair; the host decides which build of it."""
spec = si.get_spec("voxcpm2")
assert spec.torch_pins
argvs = _capture_install_argvs(monkeypatch, family=family)
monkeypatch.setattr(si.sys, "platform", platform)
si._step_install_deps(spec, si._new_job("voxcpm2"))
pip = next(a for a in argvs if a[1:3] == ["pip", "install"])
for pin in spec.torch_pins:
assert f"{pin}{suffix}" in pip
if index:
assert pip.count("--extra-index-url") == 1
assert pip[pip.index("--extra-index-url") + 1] == index
else:
assert "--extra-index-url" not in pip
@@ -46,6 +46,8 @@ EXPECTED_SIDECARS = {
ENGINES / "moss_tts_v15" / "main.py",
ENGINES / "omnivoice_subprocess" / "main.py",
ENGINES / "pockettts" / "main.py",
ENGINES / "voxcpm2_subprocess" / "main.py",
ENGINES / "moss_tts_nano_subprocess" / "main.py",
ENGINES / "supertonic3" / "sidecar.py",
}
+42
View File
@@ -381,3 +381,45 @@ def test_extra_env_carries_revision(mock_settings_store):
assert os.environ.get("SUPERTONIC3_REVISION") == constants.PINNED_REVISION_SHA
# And the property surfaces the same value.
assert backend._sidecar_env["SUPERTONIC3_REVISION"] == constants.PINNED_REVISION_SHA
def test_prefers_the_venv_its_one_click_install_made(monkeypatch, tmp_path, mock_settings_store):
"""Its own venv when the installer made one; otherwise the app's
interpreter, where `uv sync --extra supertonic` installs it."""
from pathlib import Path
from engines.supertonic3.backend import Supertonic3Backend
from services.sidecar_install import _INSTALL_COMPLETE_MARKER, _venv_python
mock_settings_store["supertonic3"] = True
monkeypatch.delenv("OMNIVOICE_SUPERTONIC3_DIR", raising=False)
assert Supertonic3Backend.venv_python() == Path(sys.executable)
py = _venv_python(tmp_path / ".venv")
py.parent.mkdir(parents=True)
py.write_text("#!fake\n")
(tmp_path / _INSTALL_COMPLETE_MARKER).write_text("x\n", encoding="utf-8")
monkeypatch.setenv("OMNIVOICE_SUPERTONIC3_DIR", str(tmp_path))
assert Supertonic3Backend.venv_python() == py
# Available without supertonic importable in the app's own environment.
monkeypatch.setitem(sys.modules, "supertonic", None)
ok, msg = Supertonic3Backend.is_available()
assert ok is True, msg
def test_sidecar_resolves_its_pin_without_the_app_backend(monkeypatch):
"""In its own venv the app's backend package is absent. The fallback must
not import `engines.supertonic3`, whose __init__ imports the backend."""
import importlib.util
from pathlib import Path
from engines.supertonic3 import constants
monkeypatch.delenv("SUPERTONIC3_REVISION", raising=False)
monkeypatch.setitem(sys.modules, "engines", None)
monkeypatch.setitem(sys.modules, "backend", None)
path = Path(constants.__file__).with_name("sidecar.py")
spec = importlib.util.spec_from_file_location("_st3_sidecar_under_test", path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
assert module._resolve_pinned_sha() == constants.PINNED_REVISION_SHA
+76
View File
@@ -0,0 +1,76 @@
"""Uninstalling a translation engine must never break the app or another engine (#2019).
`pip uninstall` acts on VoiceStudio's own environment. The LLM engine's package
(openai) is a core dependency of the app, and deep_translator backs four online
engines at once, so removing either through one engine broke something else.
"""
import asyncio
import pytest
from fastapi import HTTPException
def _router():
# Resolved at call time: other suites purge `services` from sys.modules, so
# the module the router holds is the one to patch.
from api.routers import engines as engines_router
return engines_router, engines_router.translation_engines
def _uninstall(monkeypatch, engine_id, *, pip=None):
engines_router, te = _router()
monkeypatch.setattr(te, "is_frozen", lambda: False)
async def no_pip(args, timeout=600.0):
pytest.fail(f"pip ran: {args}")
monkeypatch.setattr(te, "run_pip", pip or no_pip)
return asyncio.run(engines_router.uninstall_translation_engine(engine_id))
def test_every_engine_backed_by_an_app_dependency_is_builtin():
_, te = _router()
core = te._app_dependency_names()
assert "openai" in core and "argostranslate" in core # the metadata is readable here
for engine_id, entry in te.REGISTRY.items():
pkg = entry.get("pip_package")
if pkg and te._normalize(pkg) in core:
assert entry.get("builtin"), engine_id
def test_a_package_the_app_depends_on_is_never_uninstalled(monkeypatch):
# Even through an entry nobody marked builtin.
_, te = _router()
monkeypatch.setitem(te.REGISTRY, "x-llm", {"id": "x-llm", "display_name": "X", "pip_package": "openai"})
with pytest.raises(HTTPException) as err:
_uninstall(monkeypatch, "x-llm")
assert err.value.status_code == 400
assert "VoiceStudio itself" in err.value.detail
def test_a_package_other_engines_share_is_never_uninstalled(monkeypatch):
with pytest.raises(HTTPException) as err:
_uninstall(monkeypatch, "google")
assert err.value.status_code == 409
for name in ("DeepL", "Microsoft", "MyMemory"):
assert name in err.value.detail
def test_names_compare_in_normalized_form():
_, te = _router()
assert te._normalize("deep_translator") == te._normalize("Deep-Translator") == "deep-translator"
def test_an_unshared_optional_package_can_still_be_uninstalled(monkeypatch):
_, te = _router()
monkeypatch.setitem(te.REGISTRY, "solo", {"id": "solo", "display_name": "Solo", "pip_package": "solo-translator"})
ran = []
async def fake_pip(args, timeout=600.0):
ran.append(args)
return 0, "ok"
res = _uninstall(monkeypatch, "solo", pip=fake_pip)
assert res["status"] == "uninstalled"
assert ran == [["uninstall", "-y", "solo-translator"]]
+135 -7
View File
@@ -73,12 +73,13 @@ def test_same_volume_false_across_devices(tmp_path, monkeypatch):
# ── uv_subprocess_env ──────────────────────────────────────────────────────
def test_uv_env_is_none_on_the_default_cache_volume(tmp_path, monkeypatch):
"""Same volume as uv's default cache → inherit env untouched (default
installs stay byte-identical)."""
def test_uv_env_moves_no_cache_on_the_default_cache_volume(tmp_path, monkeypatch):
"""Same volume as uv's default cache → the cache stays where uv puts it."""
monkeypatch.delenv("UV_CACHE_DIR", raising=False)
monkeypatch.delenv("UV_PYTHON_INSTALL_DIR", raising=False)
monkeypatch.setattr(si, "_default_uv_cache_root", lambda: tmp_path / "uv")
assert si.uv_subprocess_env(tmp_path / "engines") is None
env = si.uv_subprocess_env(tmp_path / "engines")
assert "UV_CACHE_DIR" not in env and "UV_PYTHON_INSTALL_DIR" not in env
def test_uv_env_colocates_cache_on_a_foreign_volume(tmp_path, monkeypatch):
@@ -111,12 +112,14 @@ def test_uv_env_respects_user_pinned_cache_dir(tmp_path, monkeypatch):
assert env["UV_PYTHON_INSTALL_DIR"] == str(tmp_path / "engines" / ".uv-python")
def test_uv_env_is_none_when_both_vars_pinned(tmp_path, monkeypatch):
"""Both pinned → nothing left to override → inherit env untouched."""
def test_uv_env_keeps_both_vars_when_both_pinned(tmp_path, monkeypatch):
"""Both pinned → the user's values stand."""
monkeypatch.setenv("UV_CACHE_DIR", str(tmp_path / "my-cache"))
monkeypatch.setenv("UV_PYTHON_INSTALL_DIR", str(tmp_path / "my-pythons"))
monkeypatch.setattr(si, "_same_volume", lambda a, b: False)
assert si.uv_subprocess_env(tmp_path / "engines") is None
env = si.uv_subprocess_env(tmp_path / "engines")
assert env["UV_CACHE_DIR"] == str(tmp_path / "my-cache")
assert env["UV_PYTHON_INSTALL_DIR"] == str(tmp_path / "my-pythons")
def test_uv_env_respects_user_pinned_python_dir(tmp_path, monkeypatch):
@@ -244,3 +247,128 @@ def test_every_bootstrap_uv_call_passes_env(path):
"subprocess calls in _bootstrap_engines_venv without env= "
f"(cross-drive uv cache class): {offenders}"
)
# ── Engine installs never inherit the app's uv config ─────────────────────
#
# The backend runs inside VoiceStudio's tree, so a uv process it starts
# discovers the app's pyproject.toml and applies its [tool.uv]
# constraint-dependencies (torch==2.8.0). Resolved that way,
# torch==2.9.1+cu128 (MOSS-TTS-v1.5) and torch==2.7.0 (Confucius4) are
# unsatisfiable. Every engine install and bootstrap gets its environment from
# uv_subprocess_env; these pin that it always opts out of config discovery.
@pytest.mark.parametrize("same_volume", [True, False])
@pytest.mark.parametrize("pinned", [(), ("UV_CACHE_DIR",), ("UV_CACHE_DIR", "UV_PYTHON_INSTALL_DIR")])
def test_uv_env_always_ignores_the_apps_uv_config(tmp_path, monkeypatch, same_volume, pinned):
for var in ("UV_CACHE_DIR", "UV_PYTHON_INSTALL_DIR"):
if var in pinned:
monkeypatch.setenv(var, str(tmp_path / var.lower()))
else:
monkeypatch.delenv(var, raising=False)
monkeypatch.setattr(si, "_same_volume", lambda a, b: same_volume)
env = si.uv_subprocess_env(tmp_path / "engines")
assert env["UV_NO_CONFIG"] == "1"
def test_uv_env_keeps_the_mirror_setting(tmp_path, monkeypatch):
"""Region and custom mirrors reach uv as UV_INDEX_URL, which config opt-out
leaves alone."""
monkeypatch.setenv("UV_INDEX_URL", "https://mirror.example/simple")
env = si.uv_subprocess_env(tmp_path / "engines")
assert env["UV_INDEX_URL"] == "https://mirror.example/simple"
@pytest.mark.parametrize(
"module",
[
"engines.indextts.bootstrap",
"engines.moss_tts_v15.bootstrap",
"engines.confucius4.bootstrap",
"engines.dots_tts.bootstrap",
],
)
def test_every_engine_bootstrap_ignores_the_apps_uv_config(module):
import importlib
env = importlib.import_module(module)._uv_env()
assert env is not None and env["UV_NO_CONFIG"] == "1", module
def test_one_click_install_steps_ignore_the_apps_uv_config(tmp_path, monkeypatch):
envs = []
def capture(job, argv, *, timeout, env=None):
envs.append((argv[1], env))
if argv[1] == "venv":
py = si._venv_python(Path(argv[2]))
py.parent.mkdir(parents=True, exist_ok=True)
py.write_text("#!fake\n")
return 0
monkeypatch.setattr(si, "DATA_DIR", str(tmp_path / "data"))
monkeypatch.setattr(si, "_locate_uv", lambda: "/fake/uv")
monkeypatch.setattr(si, "_run_logged", capture)
spec = si.get_spec("indextts2")
si.managed_checkout(spec).mkdir(parents=True)
job = si._new_job(spec.engine_id)
si._step_create_venv(spec, job)
si._step_install_deps(spec, job)
assert [step for step, _ in envs] == ["venv", "pip"]
for step, env in envs:
assert env is not None and env["UV_NO_CONFIG"] == "1", step
# Bootstraps that install with uv. audio.cpp's bootstrap only probes a prebuilt
# binary, so it has nothing to scan.
_BOOTSTRAPS = sorted(
p for p in (Path(__file__).resolve().parents[1] / "backend" / "engines").glob("*/bootstrap.py")
if "_locate_uv(" in p.read_text(encoding="utf-8")
)
def _uv_runs(tree):
"""(call, env keyword) for every subprocess.run that starts uv, whether
the argv list is inline or built in a variable first."""
runs = []
for func in ast.walk(tree):
if not isinstance(func, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
lists = {}
for node in ast.walk(func):
if isinstance(node, ast.Assign) and isinstance(node.value, ast.List):
for target in node.targets:
if isinstance(target, ast.Name):
lists[target.id] = node.value
for node in ast.walk(func):
if not (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
and node.func.attr == "run" and isinstance(node.func.value, ast.Name)
and node.func.value.id == "subprocess" and node.args):
continue
argv = node.args[0]
if isinstance(argv, ast.Name):
argv = lists.get(argv.id)
if (isinstance(argv, ast.List) and argv.elts and isinstance(argv.elts[0], ast.Name)
and argv.elts[0].id == "uv"):
env = next((k.value for k in node.keywords if k.arg == "env"), None)
runs.append((node, env))
return runs
@pytest.mark.parametrize("path", _BOOTSTRAPS, ids=lambda p: p.parent.name)
def test_every_bootstrap_uv_call_gets_the_isolated_env(path):
"""_uv_env() carrying UV_NO_CONFIG is not enough on its own: a uv call that
passed os.environ, or nothing, would bring the app's pins back."""
runs = _uv_runs(ast.parse(path.read_text(encoding="utf-8")))
assert runs, f"{path}: found no uv subprocess call; the scan no longer matches this file"
for call, env in runs:
assert (isinstance(env, ast.Call) and isinstance(env.func, ast.Name)
and env.func.id == "_uv_env"), (
f"{path}:{call.lineno}: uv subprocess must pass env=_uv_env()"
)
def test_the_bootstrap_scan_covers_every_engine_that_bootstraps_with_uv():
names = {p.parent.name for p in _BOOTSTRAPS}
assert {"indextts", "moss_tts_v15", "confucius4", "dots_tts"} <= names
+230
View File
@@ -0,0 +1,230 @@
"""VoxCPM2 from its own venv: the sidecar, and the switch to it.
The sidecar runs in a venv that holds only voxcpm and its dependencies, so it
must import nothing from the app, and it must drive the model exactly as the
in-process VoxCPM2Backend does. These tests run it with a fake `voxcpm`.
"""
import importlib.util
import io
import json
import re
import struct
import sys
import types
from pathlib import Path
import numpy as np
import pytest
_MAIN = Path(__file__).resolve().parents[1] / "backend/engines/voxcpm2_subprocess/main.py"
def _load_sidecar(monkeypatch, calls):
class FakeModel:
sample_rate = 48000
def generate(self, **kw):
calls.append(kw)
return np.zeros(480, dtype=np.float32)
class VoxCPM:
@classmethod
def from_pretrained(cls, checkpoint, **kw):
calls.append({"from_pretrained": checkpoint, **kw})
return FakeModel()
fake = types.ModuleType("voxcpm")
fake.VoxCPM = VoxCPM
monkeypatch.setitem(sys.modules, "voxcpm", fake)
spec = importlib.util.spec_from_file_location("_voxcpm2_sidecar_under_test", _MAIN)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def _frames(buf):
data, out, i = buf.getvalue(), [], 0
while i < len(data):
(n,) = struct.unpack("!I", data[i:i + 4])
out.append(json.loads(data[i + 4:i + 4 + n]))
i += 4 + n
return out
def test_voice_design_maps_to_voice_description(monkeypatch):
calls = []
sidecar = _load_sidecar(monkeypatch, calls)
out = io.BytesIO()
sidecar._handle_synthesize({"op": "synthesize", "text": "hello", "description": "warm, low"}, out)
assert calls[-1] == {
"text": "hello",
"voice_description": "warm, low",
"cfg_value": 2.0,
"inference_timesteps": 10,
}
audio = _frames(out)[-1]
assert audio["op"] == "audio"
assert audio["sample_rate"] == 48000 and audio["n_samples"] == 480
def test_clone_mode_passes_the_reference_and_prompt(monkeypatch, tmp_path):
calls = []
sidecar = _load_sidecar(monkeypatch, calls)
ref = tmp_path / "ref.wav"
ref.write_bytes(b"x")
sidecar._handle_synthesize(
{"text": "hi", "ref_audio": str(ref), "ref_text": "hello there", "instruct": "calm",
"guidance_scale": 3.0, "num_step": 12},
io.BytesIO(),
)
assert calls[-1] == {
"text": "(calm)hi",
"cfg_value": 3.0,
"inference_timesteps": 12,
"reference_wav_path": str(ref),
"prompt_wav_path": str(ref),
"prompt_text": "hello there",
}
def test_a_reference_without_its_transcript_is_not_a_prompt(monkeypatch, tmp_path):
calls = []
sidecar = _load_sidecar(monkeypatch, calls)
ref = tmp_path / "ref.wav"
ref.write_bytes(b"x")
sidecar._handle_synthesize({"text": "hi", "ref_audio": str(ref)}, io.BytesIO())
assert calls[-1]["reference_wav_path"] == str(ref)
assert calls[-1]["prompt_wav_path"] is None
def test_loads_the_configured_checkpoint_without_the_denoiser(monkeypatch):
calls = []
monkeypatch.setenv("OMNIVOICE_VOXCPM_MODEL", "local/ckpt")
sidecar = _load_sidecar(monkeypatch, calls)
sidecar._handle_synthesize({"text": "hi"}, io.BytesIO())
assert calls[0] == {"from_pretrained": "local/ckpt", "load_denoiser": False}
def test_rejects_a_url_reference(monkeypatch):
sidecar = _load_sidecar(monkeypatch, [])
with pytest.raises(ValueError, match="local file path"):
sidecar._handle_synthesize({"text": "hi", "ref_audio": "https://x.test/y.wav"}, io.BytesIO())
def test_retries_a_transient_download_failure_only(monkeypatch):
sidecar = _load_sidecar(monkeypatch, [])
monkeypatch.setattr(sidecar.time, "sleep", lambda s: None)
attempts = []
def flaky():
attempts.append(1)
if len(attempts) < 3:
raise OSError("peer closed connection without sending complete message body")
return "model"
assert sidecar._with_retries(flaky) == "model"
assert len(attempts) == 3
broken_calls = []
def broken():
broken_calls.append(1)
raise ValueError("bad config")
with pytest.raises(ValueError):
sidecar._with_retries(broken)
assert len(broken_calls) == 1 # a permanent error is not retried
def test_the_sidecar_imports_nothing_from_the_app():
src = _MAIN.read_text(encoding="utf-8")
for name in ("services", "core", "engines", "api", "backend", "utils"):
assert not re.search(rf"^\s*(from|import) {name}\b", src, re.M), name
def test_the_class_switches_to_the_sidecar_once_its_venv_exists(monkeypatch, tmp_path):
from engines.voxcpm2_subprocess import VoxCPM2SubprocessBackend
from services import tts_backend
from services.sidecar_install import _INSTALL_COMPLETE_MARKER, _venv_python
monkeypatch.setenv("OMNIVOICE_VOXCPM2_DIR", "")
monkeypatch.delenv("OMNIVOICE_VOXCPM2_DIR")
assert tts_backend.get_backend_class("voxcpm2") is tts_backend.VoxCPM2Backend
py = _venv_python(tmp_path / ".venv")
py.parent.mkdir(parents=True)
py.write_text("#!fake\n")
(tmp_path / _INSTALL_COMPLETE_MARKER).write_text("x\n", encoding="utf-8")
monkeypatch.setenv("OMNIVOICE_VOXCPM2_DIR", str(tmp_path))
cls = tts_backend.get_backend_class("voxcpm2")
assert cls is VoxCPM2SubprocessBackend
assert cls.venv_python() == py
assert cls.is_available() == (True, "ready")
# The same engine to the rest of the app.
for attr in ("id", "display_name", "supports_voice_design", "applies_own_mastering", "gpu_compat"):
assert getattr(cls, attr) == getattr(tts_backend.VoxCPM2Backend, attr), attr
assert cls().supported_languages == tts_backend.VoxCPM2Backend().supported_languages
def test_the_sidecar_class_prepares_the_reference_and_trims_the_tail(monkeypatch):
import torch
import services.audio_dsp as dsp
from engines.voxcpm2_subprocess import VoxCPM2SubprocessBackend
from services import tts_backend
sent = {}
def fake_generate(self, text, **kw):
sent.update(kw)
return torch.zeros(1, 4800)
trimmed = {}
def fake_trim(wav, sr):
trimmed["sr"] = sr
return wav[:, :10]
# Patch the class the engine actually inherits from: other suites purge
# `services` from sys.modules, so a fresh import of subprocess_backend
# can be a different module than the one the engine subclassed.
monkeypatch.setattr(VoxCPM2SubprocessBackend.__bases__[0], "generate", fake_generate)
monkeypatch.setattr(tts_backend, "_prepare_voxcpm_ref", lambda p: p + ".prepared.wav")
monkeypatch.setattr(dsp, "trim_trailing_silence", fake_trim)
out = VoxCPM2SubprocessBackend().generate("hi", ref_audio="/clip.wav")
assert sent["ref_audio"] == "/clip.wav.prepared.wav"
assert trimmed["sr"] == 48000
assert tuple(out.shape) == (1, 10)
def test_output_is_resampled_to_the_48_khz_the_parent_assumes(monkeypatch):
"""The parent trims and labels the PCM at a fixed 48 kHz, so a model that
reports another rate must be resampled, not passed through."""
sidecar = _load_sidecar(monkeypatch, [])
sidecar._handle_synthesize({"text": "warm up"}, io.BytesIO())
type(sidecar._MODEL).sample_rate = 24000
out = io.BytesIO()
sidecar._handle_synthesize({"text": "hi"}, out)
audio = _frames(out)[-1]
assert audio["sample_rate"] == 48000
assert audio["n_samples"] == 960 # 480 samples at 24 kHz
def test_a_failed_install_leaves_voxcpm2_in_process(monkeypatch, tmp_path):
"""A venv interpreter without the completion marker (a reinstall that
failed partway) must not hide the working in-process engine."""
from services import tts_backend
from services.sidecar_install import _venv_python
py = _venv_python(tmp_path / ".venv")
py.parent.mkdir(parents=True)
py.write_text("#!fake\n")
monkeypatch.setenv("OMNIVOICE_VOXCPM2_DIR", str(tmp_path))
assert tts_backend.get_backend_class("voxcpm2") is tts_backend.VoxCPM2Backend