Merge remote-tracking branch 'origin/main' into review/pr-1760

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
Palash Debnath
2026-09-02 06:07:57 +05:30
98 changed files with 4606 additions and 311 deletions
+52 -1
View File
@@ -649,6 +649,46 @@ jobs:
updaterJsonPreferNsis: false
includeUpdaterJson: true
- name: Build per-user Windows MSI
if: runner.os == 'Windows'
shell: bash
working-directory: frontend
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
set -euo pipefail
python ../scripts/render-per-user-wix.py \
--source src-tauri/wix/main.wxs \
--output src-tauri/target/wix-per-user/main.wxs
bunx tauri build --target ${{ matrix.rust_target }} --bundles msi \
--config src-tauri/tauri.per-user.conf.json
DIR="src-tauri/target/${{ matrix.rust_target }}/release/bundle/msi"
while IFS= read -r artifact; do
safe=${artifact// (Current User)/_Current_User}
[ "$safe" = "$artifact" ] || mv "$artifact" "$safe"
done < <(find "$DIR" -maxdepth 1 -type f -name '*Current*User*.msi*')
- name: Publish per-user Windows updater channel
if: runner.os == 'Windows'
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ (needs.preview-gate.outputs.is_preview == 'true') && 'preview' || github.ref_name }}
run: |
set -euo pipefail
DIR="frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle/msi"
MSI=$(find "$DIR" -name '*Current*User*.msi' -type f | head -1)
[ -n "$MSI" ] || { echo "per-user MSI missing"; find "$DIR" -type f; exit 1; }
[ -f "$MSI.sig" ] || { echo "per-user MSI signature missing"; exit 1; }
VERSION=$(jq -r .version frontend/package.json)
python scripts/build_windows_user_manifest.py \
--repo "$GITHUB_REPOSITORY" --tag "$RELEASE_TAG" --version "$VERSION" \
--asset "$(basename "$MSI")" --signature-file "$MSI.sig" \
--output latest-user.json
gh release upload "$RELEASE_TAG" "$MSI" "$MSI.sig" latest-user.json \
--clobber --repo "$GITHUB_REPOSITORY"
# ── Installer smoke (Phase 0 GATE-03) ─────────────────────────────
# Structural verification of the installed/extracted bundle. The thin
# uv-venv installer ships NO frozen backend binary (the venv is built on
@@ -717,8 +757,9 @@ jobs:
shell: bash
run: |
set -euo pipefail
MSI=$(find frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle/msi -name "*.msi" | head -1)
MSI=$(find frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle/msi -name "*.msi" ! -name '*Current*User*' | head -1)
echo "Smoke-testing MSI: $MSI"
powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/verify-windows-msi.ps1 -MsiPath "$(cygpath -w "$MSI")"
# /quiet = no UI, /norestart = don't reboot the runner if a dep asks
msiexec.exe //i "$(cygpath -w "$MSI")" //quiet //norestart
INSTALL="/c/Program Files/VoiceStudio"
@@ -731,6 +772,16 @@ jobs:
find "$INSTALL" -type f -path '*backend*main.py' | grep -q . || fail "backend source main.py missing"
echo "OK — MSI installed shell + uv + backend resources"
- name: Per-user installer smoke (Windows, non-admin account)
if: runner.os == 'Windows'
timeout-minutes: 8
shell: bash
run: |
set -euo pipefail
MSI=$(find frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle/msi -name '*Current*User*.msi' | head -1)
powershell.exe -NoProfile -ExecutionPolicy Bypass \
-File scripts/smoke-per-user-msi.ps1 -MsiPath "$(cygpath -w "$MSI")"
# linuxdeploy re-links .DirIcon as an ABSOLUTE symlink into the build
# machine AFTER tauri's files-map has placed the real icon bytes — the
# exact bug #1518 guarded against, resurfacing on the first real tag
+15 -1
View File
@@ -10,25 +10,39 @@ the frozen-backend fallback mirror it for their toolchains.
**Highlights**
- Show estimated and measured model, dependency, cache, and temporary disk costs in the engine catalogue (#1718)
- CosyVoice setup guidance now separates downloaded model files from the runtime that makes the engine available.
- MCP tools can now keep audio out of agent context by returning files and accepting base-path-confined file inputs (#1760) — thanks @agudmund!
### Changed
### Added
- The MCP server gains an output mode and a file lane: `OMNIVOICE_MCP_OUTPUT_MODE` (`resources` — the original base64 default — `files`, or `both`) lets `generate_speech` return a URL to the render plus a WAV written under `OMNIVOICE_MCP_BASE_PATH` instead of base64 inline, and `transcribe` / `clone_voice` accept `audio_path` / `ref_audio_path` read from inside that same base path, which acts as the security boundary — so an LLM agent never has to carry audio through its context.
- Windows releases now include an independently updatable per-user MSI that installs and uninstalls without elevation (#1713)
- 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).
- 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
- OpenAI-compatible ASR now requires HTTPS outside loopback and refuses redirects so audio stays on the configured origin (#1736)
- Windows isolated engines now retain direct Job ownership without an extra Python supervisor process that can deadlock the child loader (#1734)
- The setup splash now waits through the backend's full startup budget instead of reporting slow Windows CUDA initialization as stuck after two minutes (#1749)
- Dubbing jobs can now reuse every source-language code produced by automatic ASR detection without a 400 error on the next upload (#1737)
- Incomplete Sherpa-ONNX model snapshots now self-repair before recognizer startup instead of failing on a missing ONNX file (#1733)
- OmniVoice subprocess startup now allows slow packaged Windows Python runtimes to signal readiness before termination (#1711)
- SRT files selected during source analysis now wait for speaker cloning, then replace transcript text without losing voices (#1709)
- Windows MSI deployments can now prohibit WebView2 bootstrap with `DISABLEWEBVIEW2BOOTSTRAP=1`, and `AUTOLAUNCHAPP=0` reliably suppresses first launch (#1714)
- Subtitle rows now provide 100 ms timing steppers and flag adjacent overlaps without requiring precise timeline dragging (#1710)
- Repair-sync failures now retain uv's final dependency error instead of reporting only an opaque exit status (#1705)
- YouTube ingest now retries yt-dlp's transient “page needs to be reloaded” response (#1706)
- Dictation model readiness now follows the live Hugging Face cache selected in Settings (#1707)
- Dictation capture now queues native events whenever its webview listener unmounts or reloads instead of emitting them to nobody (#1707)
- Desktop-contained backends now exit when their owning app disappears instead of surviving as stale port-3900 processes (#1707)
## [0.5.1] — 2026-08-28
+10 -4
View File
@@ -10,10 +10,10 @@ Copyright 2024-present Palash Debnath and VoiceStudio contributors.
VoiceStudio is **free and open-source software, licensed under the GNU
Affero General Public License, Version 3 (AGPL-3.0)**. You are free to use,
copy, modify, and redistribute it — and that **includes commercial and internal
business use**: run the app, use its outputs commercially, sell the audio you
produce with it, provide professional/client services with it, and deploy it
within your organization.
copy, modify, and redistribute it. That **includes commercial and internal
business use** of the application itself. Model weights, tokenizers, and other
third-party assets retain their own terms; this application license does not
grant or summarize rights under those separate terms.
Because this is the **Affero** GPL, one additional obligation applies: if you
modify VoiceStudio and make that modified version available to others over
@@ -41,6 +41,12 @@ is **separately licensed under Apache License 2.0** by its upstream authors and
is not relicensed here. Apache License 2.0 is compatible with, and may be
combined under, the GNU AGPL-3.0. See `pyproject.toml`.
Downloaded model weights are not relicensed by VoiceStudio. The default
`k2-fsa/OmniVoice` model card identifies its code as Apache-2.0 and pretrained
weights as CC-BY-NC. Its `audio_tokenizer/LICENSE` contains separate Boson
Higgs Audio 2 and Meta Llama community terms. A commercial license for
VoiceStudio-owned code does not replace any of those terms.
Third-party dependencies retain their own licenses. See `Cargo.lock`,
`bun.lock`, and `uv.lock` for the resolved set.
+45 -40
View File
@@ -1,10 +1,12 @@
<div align="center">
<a href="https://trendshift.io/repositories/28176?utm_source=repository-badge&amp;utm_medium=badge&amp;utm_campaign=badge-repository-28176" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/28176" alt="VoiceStudio ranking on Trendshift" width="250" height="55" /></a>
<img src="docs/logo.png" alt="VoiceStudio logo" width="120" height="120" />
<h1>VoiceStudio</h1>
<p><sub>Previously OmniVoice-Studio</sub></p>
<h3>Local voice cloning, dubbing, dictation, and long-form audio.</h3>
<p>16 TTS engines · 11 ASR engines · 646-language catalogue · macOS, Windows, and Linux</p>
<p><strong>Local-first.</strong> No account, API key, subscription, or usage meter for the core workflow.</p>
<h3>Clone voices, dub video, dictate, and produce long-form audio on your own hardware.</h3>
<p>16 TTS engines · 11 ASR engines · 646-language catalogue · macOS, Windows, Linux, and Docker</p>
<p>No account, API key, subscription, or usage meter for the local workflow.</p>
<p>
<a href="#install">Install</a> ·
@@ -36,7 +38,7 @@
</div>
> [!WARNING]
> **Active beta.** Use the [latest release](https://github.com/debpalash/VoiceStudio/releases/latest) for stable work or `main` for current fixes. Report problems through [GitHub Issues](https://github.com/debpalash/VoiceStudio/issues).
> **Active beta.** Use the [latest release](https://github.com/debpalash/VoiceStudio/releases/latest) for stable work. `main` contains the newest fixes and may change between releases. Report problems through [GitHub Issues](https://github.com/debpalash/VoiceStudio/issues).
## At a glance
@@ -49,28 +51,30 @@
| **Compute** | CUDA · Apple Silicon MPS/MLX · ROCm on Linux · CPU · optional remote workers |
| **Interfaces** | Desktop app · local REST/SSE/WebSocket API · OpenAI-compatible audio API · MCP Server |
| **Storage** | Voices, projects, settings, and outputs stay on the machine by default |
| **License** | AGPL-3.0; optional engines keep their own model licenses |
| **License** | AGPL-3.0 application; downloaded models keep their upstream terms |
<a id="install"></a>
## Install
Download a package from the [latest release](https://github.com/debpalash/VoiceStudio/releases/latest), then follow the platform guide.
| Platform | Package | Guide |
|---|---|---|
| macOS 13.3+ | DMG, Apple Silicon | [Install on macOS](docs/install/macos.md) |
| Windows 10/11 | MSI, x64 | [Install on Windows](docs/install/windows.md) |
| macOS 13.3+ | Apple Silicon DMG | [Install on macOS](docs/install/macos.md) |
| Windows 10/11 | x64 MSI; choose the current-user build when listed to install without admin access | [Install on Windows](docs/install/windows.md#install-pre-built-msi) |
| Linux | AppImage, x86_64 with glibc 2.39+ | [Install on Linux](docs/install/linux.md) |
| Docker | CUDA, ROCm, or CPU; worker-only GPU profiles | [Run with Docker](docs/install/docker.md) |
| Docker | CUDA, ROCm, CPU, and worker-only GPU profiles | [Run with Docker](docs/install/docker.md) |
Download packages from the [latest release](https://github.com/debpalash/VoiceStudio/releases/latest). First launch creates a managed Python environment and downloads the default model. Later launches reuse both.
First launch creates a managed Python environment and downloads the default model. Later launches reuse both.
> [!NOTE]
> On macOS, first launch needs a one-time right-click **Open** approval. Intel Macs cannot run the local Python backend; use a [remote backend](docs/install/macos.md) instead.
> On macOS, first launch needs a one-time right-click, then **Open** approval. Intel Macs cannot run the local Python backend; use a [remote backend](docs/install/macos.md) instead.
### First voice
1. Launch VoiceStudio and open **Voice Cloning**.
2. Add a clean voice sample. Three seconds works; 515 seconds usually gives a better prompt.
2. Add a clean voice sample. Three seconds works; 5 to 15 seconds usually gives a better prompt.
3. Enter text, choose a language, then select **Generate**.
### Run from source
@@ -157,7 +161,7 @@ Requirements vary by engine. These values cover the default local workflow.
| **Disk** | 10 GB free | 20 GB+ SSD |
| **GPU** | Optional; CPU mode is supported | NVIDIA CUDA or Apple Silicon |
| **VRAM** | 4 GB when using a GPU | 8 GB+; large optional engines need more |
| **Python from source** | 3.11+ | 3.113.12 |
| **Python from source** | 3.11+ | 3.11 or 3.12 |
ROCm is Linux-only and opt-in. Windows AMD/Ryzen AI uses CPU. Systems with limited VRAM offload work to CPU when required. See [performance](docs/performance.md), [benchmarks](docs/benchmarks.md), and [engine disk usage](docs/engines/disk-usage.md).
@@ -173,22 +177,22 @@ Engine support is capability-specific. Check cloning, language, platform, memory
| Engine | Languages | Clone | Instruct | Linux | macOS ARM | Windows | License |
|---|:---:|:---:|:---:|:---:|:---:|:---:|---|
| **VoiceStudio** (default, powered by k2-fsa/OmniVoice) | 600+ | Yes | Yes | CUDA/CPU | MPS | CUDA/CPU | [AGPL-3.0](LICENSE) app · [Apache-2.0](LICENSE-NOTICE.md) model |
| **VoiceStudio** (default, powered by k2-fsa/OmniVoice) | 600+ | Yes | Yes | CUDA/CPU | MPS | CUDA/CPU | [AGPL-3.0](LICENSE) app · [Apache-2.0 code, CC-BY-NC weights](https://huggingface.co/k2-fsa/OmniVoice#license |
| **CosyVoice 3** | 9 + 18 dialects | Yes | Yes | CUDA/CPU | CPU | CUDA/CPU | Apache-2.0 |
| **GPT-SoVITS** | 5 | Yes | | CUDA/CPU | | CUDA/CPU | MIT |
| **GPT-SoVITS** | 5 | Yes | No | CUDA/CPU | No | CUDA/CPU | MIT |
| **VoxCPM2** | 30 | Yes | Yes | CUDA/CPU | MPS | CUDA/CPU | Apache-2.0 |
| **MOSS-TTS-Nano** | 20 | Yes | | CUDA/CPU | CPU | CUDA/CPU | Apache-2.0 |
| **KittenTTS** | English | | | CPU | CPU | CPU | MIT |
| **MLX-Audio** | Model-dependent | Varies | Varies | | MLX | | Varies |
| **Sherpa-ONNX** | 20+ | | | CUDA/CPU | CPU | CUDA/CPU | Apache-2.0 |
| **IndexTTS 2.5** ⚡ | ZH · EN · JA · ES · AR | Yes | | CUDA/CPU | CPU | CUDA/CPU | Bilibili model license¹ |
| **OmniVoice GGUF** ⚡ | 600+ | Yes | Yes | CUDA/CPU | MPS/CPU | CUDA/CPU | [AGPL-3.0](LICENSE) app · [Apache-2.0](LICENSE-NOTICE.md) model |
| **OmniVoice (subprocess)** ⚡ | 600+ | Yes | Yes | CUDA/CPU | MPS | CUDA/CPU | [AGPL-3.0](LICENSE) app · [Apache-2.0](LICENSE-NOTICE.md) model |
| **PocketTTS** ⚡ | EN · FR · DE · PT · IT · ES | Yes | | CPU | CPU | CPU | CC-BY-4.0, gated² |
| **Supertonic 3** ⚡ | 31 | | | CPU | CPU | CPU | OpenRAIL-M |
| **MOSS-TTS-v1.5** ⚡ | 31 | Yes | | CUDA/CPU | CPU | CUDA/CPU | Apache-2.0 |
| **dots.tts** ⚡ | 24 | Yes | | CUDA/CPU | CPU | | Apache-2.0 |
| **Confucius4-TTS** ⚡ | 14 | Yes | | CUDA/CPU | CPU | CUDA/CPU | Apache-2.0 |
| **MOSS-TTS-Nano** | 20 | Yes | No | CUDA/CPU | CPU | CUDA/CPU | Apache-2.0 |
| **KittenTTS** | English | No | No | CPU | CPU | CPU | MIT |
| **MLX-Audio** | Model-dependent | Varies | Varies | No | MLX | No | Varies |
| **Sherpa-ONNX** | 20+ | No | No | CUDA/CPU | CPU | CUDA/CPU | Apache-2.0 |
| **IndexTTS 2.5** ⚡ | ZH · EN · JA · ES · AR | Yes | No | CUDA/CPU | CPU | CUDA/CPU | Bilibili model license¹ |
| **OmniVoice GGUF** ⚡ | 600+ | Yes | Yes | CUDA/CPU | MPS/CPU | CUDA/CPU | [AGPL-3.0](LICENSE) app · [review the derivative model terms](https://huggingface.co/Serveurperso/OmniVoice-GGUF#license |
| **OmniVoice (subprocess)** ⚡ | 600+ | Yes | Yes | CUDA/CPU | MPS | CUDA/CPU | [AGPL-3.0](LICENSE) app · [Apache-2.0 code, CC-BY-NC weights](https://huggingface.co/k2-fsa/OmniVoice#license |
| **PocketTTS** ⚡ | EN · FR · DE · PT · IT · ES | Yes | No | CPU | CPU | CPU | CC-BY-4.0, gated² |
| **Supertonic 3** ⚡ | 31 | No | No | CPU | CPU | CPU | OpenRAIL-M |
| **MOSS-TTS-v1.5** ⚡ | 31 | Yes | No | CUDA/CPU | CPU | CUDA/CPU | Apache-2.0 |
| **dots.tts** ⚡ | 24 | Yes | No | CUDA/CPU | CPU | No | Apache-2.0 |
| **Confucius4-TTS** ⚡ | 14 | Yes | No | CUDA/CPU | CPU | CUDA/CPU | Apache-2.0 |
⚡ Installed or registered on demand.
@@ -196,6 +200,8 @@ Engine support is capability-specific. Check cloning, language, platform, memory
² PocketTTS shows its gated-access and CC-BY-4.0 terms before first use.
³ The OmniVoice snapshot also includes an audio tokenizer under separate [Boson Higgs Audio 2 and Meta Llama community terms](https://huggingface.co/k2-fsa/OmniVoice/blob/main/audio_tokenizer/LICENSE). VoiceStudio's application license does not replace model or tokenizer terms.
Clone-less engines cannot preserve a reference speaker in dubbing or pinned-voice batch jobs. VoiceStudio rejects those jobs instead of silently changing engines. Heavy engines have separate memory and platform limits; check their engine guide first.
<a id="asr-engines"></a>
@@ -214,7 +220,7 @@ Clone-less engines cannot preserve a reference speaker in dubbing or pinned-voic
| **Moonshine** | `moonshine` | English | Low-power, low-latency ONNX |
| **FunASR** | `funasr` | 50+ | VAD and inline diarization |
| **sherpa-onnx** (live dictation) | `sherpa-onnx-asr` | Model-dependent | Streaming CPU dictation |
| **OpenAI-compatible** ⚠️ remote | `openai-compat-asr` | Server-dependent | Qwen3-ASR or another compatible endpoint; audio leaves the machine |
| **OpenAI-compatible** ⚠️ configured server | `openai-compat-asr` | Server-dependent | Local gigastt/Qwen3-ASR or a remote endpoint; audio goes only to that server |
WhisperX and Faster-Whisper retry with `int8` when efficient `float16` is unavailable. Pin `ASR_COMPUTE_TYPE=int8` or `float32` only if automatic selection still fails.
@@ -249,8 +255,8 @@ FastAPI backend
- The desktop talks to a loopback-only backend on `localhost:3900`.
- Loopback API calls need no server key. Remote access requires a share PIN or API key.
- Remote workers and OpenAI-compatible ASR are opt-in. The UI identifies when audio leaves the machine.
- Analytics is off until consent. If enabled, it sends allowlisted, content-free usage metadata—not text, audio, file names, or projects.
- Remote workers and OpenAI-compatible ASR are opt-in. Loopback ASR may use HTTP and keeps audio on the machine; non-loopback endpoints require HTTPS, and redirects are not followed.
- Analytics is off until consent. If enabled, it sends allowlisted, content-free usage metadata. It never sends text, audio, file names, or projects.
<a id="api"></a>
@@ -285,12 +291,11 @@ with client.audio.speech.with_streaming_response.create(
response.stream_to_file("speech.wav")
```
The bundled Rust control sidecar also lets Herdr, coding agents, VS Code,
desktop apps, and TUIs trigger the existing system-wide dictation flow or reuse
its safe native insertion. See the [speech platform guide](docs/speech-platform.md).
The full API reference is in **Settings → OpenAPI Reference**. For LAN,
Tailscale, or proxy access, read [API authentication](docs/api-auth.md) before
exposing the backend.
The bundled Rust control sidecar lets Herdr, coding agents, VS Code, desktop apps,
and TUIs trigger the system-wide dictation flow or reuse its native text
insertion. See the [speech platform guide](docs/speech-platform.md). The full API
reference is in **Settings → OpenAPI Reference**. For LAN, Tailscale, or proxy
access, read [API authentication](docs/api-auth.md) before exposing the backend.
### Agent skills
@@ -335,19 +340,19 @@ Apple Silicon is supported with MPS and MLX options. Intel Macs cannot run the l
<details>
<summary><strong>How much VRAM do I need?</strong></summary>
A GPU is optional. Use 4 GB VRAM as the minimum for accelerated work and 8 GB+ for the default multi-stage workflow. Large optional engines can require 1216 GB or more. Check the [benchmarks](docs/benchmarks.md) and engine guide.
A GPU is optional. Use 4 GB VRAM as the minimum for accelerated work and 8 GB+ for the default multi-stage workflow. Large optional engines can require 12 to 16 GB or more. Check the [benchmarks](docs/benchmarks.md) and engine guide.
</details>
<details>
<summary><strong>Why does a longer reference clip not always improve the clone?</strong></summary>
Cloning is zero-shot: the clip is a prompt, not training data. Use 515 seconds of one speaker, close to the microphone, without music, noise, or reverb. Match the tone and pace you want in the output. For training, see [data preparation](docs/data_preparation.md) and [training](docs/training.md).
Cloning is zero-shot: the clip is a prompt, not training data. Use 5 to 15 seconds of one speaker, close to the microphone, without music, noise, or reverb. Match the tone and pace you want in the output. For training, see [data preparation](docs/data_preparation.md) and [training](docs/training.md).
</details>
<details>
<summary><strong>Can I use generated audio commercially?</strong></summary>
Yes under VoiceStudio's AGPL-3.0 terms. Optional engines and model weights may use different licenses; review the selected engine's license before commercial use.
VoiceStudio's application license does not restrict generated audio, but it does not grant rights under a model's separate terms. The default OmniVoice repository labels its pretrained weights CC-BY-NC and includes a tokenizer under separate community terms. Review the selected model terms before commercial use.
</details>
<details>
@@ -377,9 +382,9 @@ VoiceStudio is free and has no paid tier. Donations fund development and infrast
## License
VoiceStudio is licensed under [AGPL-3.0](LICENSE). You may run it, modify it, use it internally, and sell generated audio. If you modify VoiceStudio and provide that modified version as a network service, AGPL requires you to offer the corresponding source under the same license. A commercial license is available for proprietary embedding; contact **VoiceStudio@palash.dev**. See [LICENSE-NOTICE.md](LICENSE-NOTICE.md) for the plain-language scope.
VoiceStudio is licensed under [AGPL-3.0](LICENSE). You may run it, modify it, and use it internally. The application license itself does not restrict selling generated audio, but downloaded model and tokenizer terms may. If you modify VoiceStudio and provide that modified version as a network service, AGPL requires you to offer the corresponding source under the same license. A commercial license for VoiceStudio-owned code is available for proprietary embedding; it does not relicense third-party models. Contact **VoiceStudio@palash.dev**. See [LICENSE-NOTICE.md](LICENSE-NOTICE.md) for the plain-language scope.
Optional engines and downloaded models retain their own licenses. The bundled `omnivoice/` model remains Apache-2.0 upstream.
Optional engines and downloaded models retain their own licenses. The bundled `omnivoice/` Python code is Apache-2.0 upstream; the default downloaded weights and audio tokenizer use separate terms.
## Acknowledgments
+7
View File
@@ -49,6 +49,13 @@ def public_backends(entries: list[dict]) -> list[dict]:
item["routing_reason"] = _public_routing_reason(
item.get("routing_status"), item["routing_reason"]
)
evidence = item.get("execution_evidence")
if isinstance(evidence, dict) and evidence.get("cpu_fallback_reason") is not None:
evidence = dict(evidence)
evidence["cpu_fallback_reason"] = _public_routing_reason(
"cpu_fallback", evidence["cpu_fallback_reason"]
)
item["execution_evidence"] = evidence
safe.append(item)
return safe
+23 -9
View File
@@ -518,9 +518,12 @@ _ingest_gen = dub_pipeline.ingest_pipeline
#: container so a mislabelled video can't slip past the video-skipping branch.
_AUDIO_EXTS = {".wav", ".mp3", ".m4a", ".aac", ".flac", ".ogg", ".opus", ".wma"}
# Source-language choices exposed by the first-party dub UI. Keeping this an
# allow-list rejects language names and private-use BCP-47 tags before they are
# persisted as ASR overrides. Values are normalized to lowercase below.
# Source-language choices exposed by the first-party dub UI, plus every
# language code Whisper can write back after auto-detection. A restored job
# may reuse that detected value as the next upload's override, so rejecting our
# own persisted codes strands otherwise valid dubbing sessions (#1737).
# Keeping this an allow-list still rejects language names and private-use
# BCP-47 tags. Values are normalized to lowercase below.
_DUB_SOURCE_LANG_CODES = frozenset({
"af", "sq", "am", "ar", "hy", "az", "eu", "be", "bn", "bs", "bg",
"my", "ca", "cmn-hans", "cmn-hant", "hr", "cs", "da", "nl", "en",
@@ -531,6 +534,8 @@ _DUB_SOURCE_LANG_CODES = frozenset({
"ru", "sm", "gd", "sr", "sn", "sd", "si", "sk", "sl", "so", "es",
"su", "sw", "sv", "tg", "ta", "te", "th", "tr", "uk", "ur", "uz",
"vi", "cy", "xh", "yi", "yo", "zu",
"as", "ba", "bo", "br", "fo", "lb", "ln", "mg", "nn", "oc", "sa",
"tk", "tl", "tt", "yue", "zh",
})
@@ -544,6 +549,15 @@ def _source_lang_override(value: str | None) -> str | None:
return code
def _detected_source_lang(value: str | None) -> str:
"""Normalize an ASR language without truncating valid three-letter codes."""
code = (value or "en").split("_", 1)[0].strip().lower()
if code in _DUB_SOURCE_LANG_CODES:
return code
short = code[:2]
return short if short in _DUB_SOURCE_LANG_CODES else "en"
@router.post("/dub/upload")
async def dub_upload(
video: UploadFile = File(...),
@@ -1809,9 +1823,9 @@ async def dub_transcribe_stream(
except Exception as e:
logger.warning("speaker_clone extraction skipped: %s", e)
job["source_lang"] = job.get("source_lang_override") or (
(detected_lang or "en").split("_")[0][:2] or "en"
).lower()
job["source_lang"] = job.get("source_lang_override") or _detected_source_lang(
detected_lang
)
job["full_transcript"] = " ".join(s.get("text", "") for s in final_segs)
_save_job(job_id, job)
@@ -2008,9 +2022,9 @@ async def dub_transcribe(job_id: str, num_speakers: Optional[int] = None):
except Exception as e:
logger.warning("Failed to unload ASR backend: %s", e)
job["source_lang"] = job.get("source_lang_override") or (
(detected_lang or "en").split("_")[0][:2] or "en"
).lower()
job["source_lang"] = job.get("source_lang_override") or _detected_source_lang(
detected_lang
)
scene_cuts = job.get("scene_cuts") or []
segments = segment_transcript(result, duration=job.get("duration", 0.0), scene_cuts=scene_cuts)
+15
View File
@@ -75,6 +75,21 @@ def list_tts_backends():
return _family_payload("tts", tts_backend)
@router.get(
"/engines/{engine_id}/disk-usage",
dependencies=[Depends(require_admin_action)],
)
def engine_disk_usage(engine_id: str):
"""Measure owned engine bytes only when a catalogue row is opened."""
try:
tts_backend.get_backend_class(engine_id)
except ValueError:
raise HTTPException(status_code=404, detail="Unknown TTS engine")
from services.engine_disk_usage import disk_usage_for
return disk_usage_for(engine_id)
@router.get("/engines/asr")
def list_asr_backends():
return _family_payload("asr", asr_backend)
+4 -3
View File
@@ -1035,9 +1035,10 @@ def set_asr_openai_compat(body: _ASROpenAICompatBody):
from services import asr_backend, settings_store
if body.base_url is not None:
url = body.base_url.strip().rstrip("/")
if url and not url.startswith(("http://", "https://")):
raise HTTPException(status_code=400, detail="Base URL must start with http(s)://")
try:
url = asr_backend.normalize_openai_compat_asr_base_url(body.base_url)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
settings_store.set_text(asr_backend._ASR_OPENAI_COMPAT_BASE_URL_KEY, url)
if body.model is not None:
settings_store.set_text(
+141 -34
View File
@@ -3,13 +3,14 @@
The desktop owns the backend with an OS process group/Job. Engine and
installer operations also need an independently terminable subtree: killing
only their direct child on a timeout leaves uv/git/model workers holding pipes
and mutating files. A small direct-child supervisor bridges both lifetimes.
and mutating files.
On POSIX the supervisor is the unreaped leader of a nested process group. A
control-pipe EOF (including kernel EOF when the backend dies) kills that group;
the parent also drains the group before reaping its stable leader. On Windows
the supervisor assigns the operation, while suspended, to a nested
kill-on-close Job. The outer desktop Job still contains both levels.
On POSIX a small supervisor is the unreaped leader of a nested process group.
A control-pipe EOF (including kernel EOF when the backend dies) kills that
group; the parent also drains the group before reaping its stable leader. On
Windows the backend retains a nested kill-on-close Job directly and assigns
the suspended operation before resuming it. The outer desktop Job remains the
terminal fallback.
Standalone/server launches use the same nested owner, preserving their
independently terminable subtree without relying on ``taskkill`` or discovery.
@@ -263,42 +264,148 @@ class OwnedPopen:
pass
def spawn_owned(argv: list[str], **kwargs: Any) -> "subprocess.Popen | OwnedPopen":
class WindowsJobPopen:
"""Popen-compatible handle whose child tree lives in a retained Job.
Windows Job handles already provide the stable ownership that POSIX needs
a supervisor process group for. Keeping the handle in the backend means an
abrupt backend exit closes it in the kernel and kills the whole operation
tree, without inserting a second Python process in the sidecar loader path
(#1734).
"""
def __init__(self, proc: subprocess.Popen, job: Any, kernel32: Any) -> None:
self._proc = proc
self._job = job
self._kernel32 = kernel32
self._lock = threading.RLock()
self.stdin = proc.stdin
self.stdout = proc.stdout
self.stderr = proc.stderr
@property
def pid(self) -> int:
return self._proc.pid
@property
def args(self) -> Any:
return self._proc.args
@property
def returncode(self) -> Optional[int]:
return self._proc.returncode
def _close_job(self, *, terminate: bool) -> None:
job, self._job = self._job, None
if job is None:
return
try:
if terminate:
self._kernel32.TerminateJobObject(job, 1)
finally:
self._kernel32.CloseHandle(job)
def poll(self) -> Optional[int]:
with self._lock:
rc = self._proc.poll()
if rc is None:
return None
# A successful direct child may leave helpers behind. Match the
# supervisor contract by draining the retained Job before return.
self._close_job(terminate=True)
return rc
def wait(self, timeout: Optional[float] = None) -> int:
try:
rc = self._proc.wait(timeout=timeout)
except subprocess.TimeoutExpired:
raise
with self._lock:
self._close_job(terminate=True)
return rc
def terminate(self) -> None:
with self._lock:
self._close_job(terminate=True)
def kill(self) -> None:
self.terminate()
def __getattr__(self, name: str) -> Any:
return getattr(self._proc, name)
def __del__(self) -> None:
try:
self._close_job(terminate=True)
except Exception:
pass # interpreter shutdown; closing the OS handle is best-effort
def _spawn_windows_owned(argv: list[str], kwargs: dict[str, Any]) -> WindowsJobPopen:
"""Start *argv* suspended, assign its tree to a Job, then resume it."""
import ctypes
job, kernel32, wintypes = _windows_job()
child: Optional[subprocess.Popen] = None
popen_kwargs = dict(kwargs)
supplied_env = popen_kwargs.get("env")
operation_env = dict(os.environ if supplied_env is None else supplied_env)
operation_env.pop(_DRAIN_FD_ENV, None)
operation_env.pop(_DESKTOP_MARKER, None)
popen_kwargs["env"] = operation_env
supplied_flags = int(popen_kwargs.pop("creationflags", 0))
popen_kwargs["creationflags"] = supplied_flags | 0x08000000 | 0x00000004
try:
child = subprocess.Popen(argv, **popen_kwargs)
assign = kernel32.AssignProcessToJobObject
assign.argtypes = (wintypes.HANDLE, wintypes.HANDLE)
assign.restype = wintypes.BOOL
if not assign(job, wintypes.HANDLE(child._handle)):
raise OSError(ctypes.get_last_error(), "AssignProcessToJobObject")
_resume_windows_process(kernel32, wintypes, child.pid)
return WindowsJobPopen(child, job, kernel32)
except BaseException:
kernel32.TerminateJobObject(job, 1)
if child is not None:
try:
child.kill()
except OSError:
pass # the suspended child may already have exited
try:
child.wait(timeout=5)
except (OSError, subprocess.TimeoutExpired):
pass # Job termination remains the authoritative cleanup
kernel32.CloseHandle(job)
raise
def spawn_owned(
argv: list[str], **kwargs: Any
) -> "subprocess.Popen | OwnedPopen | WindowsJobPopen":
"""Spawn an operation with a stable, independently terminable owner."""
drain_fd = backend_drain_fd(required=True) if os.name == "posix" else None
if os.name == "nt":
return _spawn_windows_owned(argv, kwargs)
drain_fd = backend_drain_fd(required=True)
control_read, control_write = os.pipe()
result_read, result_write = os.pipe()
control_token = control_read
result_token = result_write
if os.name == "nt":
import msvcrt
control_token = msvcrt.get_osfhandle(control_read)
result_token = msvcrt.get_osfhandle(result_write)
wrapper_argv = _supervisor_argv(
control_token,
result_token,
control_read,
result_write,
argv,
)
wrapper_kwargs = dict(kwargs)
if os.name == "posix":
wrapper_kwargs["start_new_session"] = True
pass_fds = [control_read, result_write]
if drain_fd is not None:
pass_fds.append(drain_fd)
if wrapper_kwargs.get("env") is not None:
wrapper_env = dict(wrapper_kwargs["env"])
wrapper_env[_DESKTOP_MARKER] = "1"
wrapper_env[_DRAIN_FD_ENV] = str(drain_fd)
wrapper_kwargs["env"] = wrapper_env
wrapper_kwargs["pass_fds"] = tuple(pass_fds)
else:
# Python's Windows fd inheritance requires inheritable CRT handles.
# All unrelated descriptors are non-inheritable by default (PEP 446).
os.set_handle_inheritable(control_token, True)
os.set_handle_inheritable(result_token, True)
wrapper_kwargs["close_fds"] = False
wrapper_kwargs["start_new_session"] = True
pass_fds = [control_read, result_write]
if drain_fd is not None:
pass_fds.append(drain_fd)
if wrapper_kwargs.get("env") is not None:
wrapper_env = dict(wrapper_kwargs["env"])
wrapper_env[_DESKTOP_MARKER] = "1"
wrapper_env[_DRAIN_FD_ENV] = str(drain_fd)
wrapper_kwargs["env"] = wrapper_env
wrapper_kwargs["pass_fds"] = tuple(pass_fds)
try:
proc = subprocess.Popen(wrapper_argv, **wrapper_kwargs)
except BaseException:
+64
View File
@@ -21,6 +21,7 @@ Check shape:
"""
from __future__ import annotations
import importlib
import os
import platform
import shutil
@@ -367,10 +368,44 @@ def run_diagnostics(include_network: bool = True, deep: bool = False) -> dict:
counts = {OK: 0, WARN: 0, FAIL: 0}
for c in checks:
counts[c["status"]] += 1
engine_execution = []
for family in ("tts", "asr"):
active = "unknown"
try:
module = importlib.import_module(f"services.{family}_backend")
active = module.active_backend_id()
row = next((item for item in module.list_backends() if item.get("id") == active), None)
if row is not None:
engine_execution.append({
"family": family,
"engine_id": active,
**row["execution_evidence"],
})
except Exception: # noqa: BLE001 - evidence must not break diagnostics
# Preserve the other family's successful evidence and make this
# collection failure explicit without exposing exception text.
engine_execution.append({
"family": family,
"engine_id": active,
"implementation_variant": None,
"declared_device_families": [],
"evidence_state": "collection_failed",
"actual_execution_provider": None,
"actual_execution_device": None,
"gpu_name": None,
"gpu_architecture": None,
"precision_or_quantization": None,
"cpu_fallback_reason": None,
"cpu_fallback_stage": None,
"parent_memory_observable": None,
"runtime_versions": {},
})
return {
"app_version": APP_VERSION,
"platform": scrub_text(platform.platform()),
"checks": checks,
"engine_execution": engine_execution,
"summary": {
"ok": counts[FAIL] == 0,
"passed": counts[OK],
@@ -395,6 +430,35 @@ def format_text(report: dict) -> str:
lines.append(f"{tag[c['status']]} {c['label']}: {c['detail']}")
if c.get("hint"):
lines.append(f" hint: {c['hint']}")
if report.get("engine_execution"):
lines.append("")
lines.append("Engine execution evidence:")
for item in report["engine_execution"]:
if item.get("actual_execution_provider"):
provider = item["actual_execution_provider"]
elif item.get("evidence_state") == "subprocess_loaded_provider_unreported":
provider = "loaded child; provider not reported"
else:
provider = "not loaded"
precision = item.get("precision_or_quantization") or "unknown"
device = item.get("actual_execution_device") or "unknown"
gpu = item.get("gpu_name") or "none"
architecture = item.get("gpu_architecture") or "unknown"
fallback_stage = item.get("cpu_fallback_stage") or "none"
fallback_reason = item.get("cpu_fallback_reason") or "none"
versions = ",".join(
f"{name}={version}"
for name, version in sorted(item.get("runtime_versions", {}).items())
) or "none"
visible = "yes" if item.get("parent_memory_observable") else "no"
lines.append(
f" {item['family']}:{item['engine_id']} provider={provider}; "
f"device={device}; gpu={gpu}; architecture={architecture}; "
f"precision={precision}; fallback-stage={fallback_stage}; "
f"fallback-reason={fallback_reason}; runtimes={versions}; "
f"evidence-state={item.get('evidence_state', 'unknown')}; "
f"parent-memory-visible={visible}"
)
s = report["summary"]
lines.append("")
lines.append(
@@ -52,6 +52,11 @@ class OmniVoiceSubprocessBackend(SubprocessBackend):
# Match OmniVoiceBackend: the measured floor below which a render that
# should take seconds runs for minutes (the #1226/#1222 4 GB reports).
min_vram_gb = 6.0
# Packaged Windows hosts can spend more than the base 30 seconds starting
# the shared Python runtime before this stdlib-only sidecar emits ready.
# Keep the bound below the 300-second generation budget while avoiding the
# repeated false kill captured in #1711.
spawn_ready_timeout_s = 120.0
@classmethod
def is_available(cls) -> tuple[bool, str]:
+92 -17
View File
@@ -37,6 +37,8 @@ import base64
import json
import logging
import os
import re
import stat
import sys
logger = logging.getLogger("omnivoice.mcp")
@@ -91,6 +93,7 @@ def _sniff_audio_ext(raw: bytes) -> str:
_OUTPUT_MODES = ("resources", "files", "both")
_MAX_INPUT_BYTES = 200 * 1024 * 1024
_SAFE_AUDIO_ID = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
def _output_mode() -> str:
@@ -134,15 +137,74 @@ def _resolve_under_base(path: str) -> str:
"names a directory"
)
candidate = os.path.realpath(os.path.join(base, os.path.expanduser(path)))
try:
inside = os.path.commonpath([base, candidate]) == base
except ValueError: # different drives on Windows: nothing in common
inside = False
if not inside:
if not _path_is_under_base(base, candidate):
raise ValueError(f"{path!r} resolves outside OMNIVOICE_MCP_BASE_PATH")
return candidate
def _opened_file_is_confined(fd: int, resolved: str, base: str) -> bool:
"""Verify that an opened descriptor still names a file under ``base``."""
proc_fd = f"/proc/self/fd/{fd}"
if os.path.exists(proc_fd):
return _path_is_under_base(base, os.path.realpath(proc_fd))
try:
current = os.path.realpath(resolved)
return _path_is_under_base(base, current) and os.path.samestat(
os.fstat(fd), os.stat(current, follow_symlinks=False)
)
except OSError:
return False
def _path_is_under_base(base: str, candidate: str) -> bool:
try:
common = os.path.commonpath([base, candidate])
except ValueError: # different drives on Windows
return False
return os.path.normcase(common) == os.path.normcase(base)
def _open_under_base(path: str, flags: int, *, mode: int = 0o600) -> tuple[int, str]:
"""Open ``path`` without following a component replaced after validation."""
base = _base_path()
if base is None:
raise ValueError(
"OMNIVOICE_MCP_BASE_PATH is not set; file paths are refused until it "
"names a directory"
)
resolved = _resolve_under_base(path)
relative = os.path.relpath(resolved, base)
parts = [part for part in relative.split(os.sep) if part not in ("", ".")]
if not parts or parts[0] == os.pardir:
raise ValueError(f"{path!r} resolves outside OMNIVOICE_MCP_BASE_PATH")
no_follow = getattr(os, "O_NOFOLLOW", 0)
close_on_exec = getattr(os, "O_CLOEXEC", 0)
binary = getattr(os, "O_BINARY", 0)
file_flags = flags | no_follow | close_on_exec | binary
supports_dir_fd = os.open in getattr(os, "supports_dir_fd", ())
directory_flag = getattr(os, "O_DIRECTORY", 0)
if supports_dir_fd and directory_flag:
directory_flags = os.O_RDONLY | directory_flag | no_follow | close_on_exec
directory_fd = os.open(base, directory_flags)
try:
for component in parts[:-1]:
next_fd = os.open(component, directory_flags, dir_fd=directory_fd)
os.close(directory_fd)
directory_fd = next_fd
fd = os.open(parts[-1], file_flags, mode, dir_fd=directory_fd)
finally:
os.close(directory_fd)
else:
fd = os.open(resolved, file_flags, mode)
if not _opened_file_is_confined(fd, resolved, base):
os.close(fd)
raise ValueError(f"{path!r} resolves outside OMNIVOICE_MCP_BASE_PATH")
return fd, resolved
def _read_input_audio(
audio_base64: "str | None",
audio_path: "str | None",
@@ -159,34 +221,45 @@ def _read_input_audio(
return None, f"pass exactly one of {label} or the matching *_path argument"
if audio_path:
try:
resolved = _resolve_under_base(audio_path)
fd, _resolved = _open_under_base(audio_path, os.O_RDONLY)
except ValueError as e:
return None, str(e)
if not os.path.isfile(resolved):
except FileNotFoundError:
return None, f"no such file under OMNIVOICE_MCP_BASE_PATH: {audio_path!r}"
if os.path.getsize(resolved) > _MAX_INPUT_BYTES:
except OSError as e:
return None, f"could not safely read {audio_path!r}: {e}"
with os.fdopen(fd, "rb") as handle:
info = os.fstat(handle.fileno())
if not stat.S_ISREG(info.st_mode):
return None, f"{audio_path!r} is not a regular file"
if info.st_size > _MAX_INPUT_BYTES:
return None, too_big
raw = handle.read(_MAX_INPUT_BYTES + 1)
if len(raw) > _MAX_INPUT_BYTES:
return None, too_big
with open(resolved, "rb") as f:
return f.read(), None
# Base64 is always larger than the bytes it carries, so the encoded
# length is a safe lower bound on the decoded size.
if len(audio_base64) > _MAX_INPUT_BYTES:
return None, too_big
if not raw:
return None, f"{label} is empty"
return raw, None
raw = _decode_ref_audio(audio_base64)
if raw is None:
return None, f"{label} is not valid base64"
if not raw:
return None, f"{label} is empty"
if len(raw) > _MAX_INPUT_BYTES:
return None, too_big
return raw, None
def _write_output(audio_id: str, raw: bytes) -> str:
"""Land a render under the base path as ``<audio_id>.wav``; returns the path."""
if not _SAFE_AUDIO_ID.fullmatch(audio_id):
raise ValueError("backend returned an invalid X-Audio-Id header")
base = _base_path()
os.makedirs(base, exist_ok=True)
path = os.path.join(base, f"{audio_id}.wav")
with open(path, "wb") as f:
f.write(raw)
filename = f"{audio_id}.wav"
fd, path = _open_under_base(filename, os.O_WRONLY | os.O_CREAT | os.O_EXCL)
with os.fdopen(fd, "wb") as handle:
handle.write(raw)
return path
@@ -219,6 +292,8 @@ def _speech_result(audio_id: str, gen_time, duration, raw: bytes, api_base: str)
The backend already keeps every render on disk and serves it at
``/audio/<audio_id>.wav``, so files mode costs nothing but a URL - plus one
write when a base path invites the WAV into the agent's own directory."""
if not _SAFE_AUDIO_ID.fullmatch(audio_id):
raise ValueError("backend returned an invalid X-Audio-Id header")
mode = _output_mode()
out = {
"audio_id": audio_id,
+120 -10
View File
@@ -24,12 +24,15 @@ faster-whisper because it's available on every platform we ship to).
from __future__ import annotations
import asyncio
import ipaddress
import logging
import os
import re
import contextlib
import threading
import time
import weakref
from urllib.parse import urlsplit
from utils.containment import contain_system_exit
from abc import ABC, abstractmethod
@@ -304,6 +307,16 @@ class ASRBackend(ABC):
# broken GPU path, strictly worse than the honest `cpu_fallback`.)
gpu_compat: tuple[str, ...] = ("cpu",)
def execution_evidence_loaded(self) -> bool:
"""Whether this instance has live model state worth reporting."""
if getattr(self, "runs_out_of_process", False):
proc = getattr(self, "_proc", None)
return proc is not None and proc.poll() is None
return any(
getattr(self, attr, None) is not None
for attr in ("_model", "_asr", "_pipeline", "_pipe", "_transcriber", "_rec")
)
@classmethod
@abstractmethod
def is_available(cls) -> tuple[bool, str]:
@@ -950,6 +963,8 @@ class FasterWhisperBackend(ASRBackend):
# (after the #551 compute_type / #255 OOM→CPU fallback chain).
self._device: str | None = None
self._compute_type: str | None = None
self._fallback_reason: str | None = None
self._fallback_stage: str | None = None
@classmethod
def is_available(cls) -> tuple[bool, str]:
@@ -1027,6 +1042,8 @@ class FasterWhisperBackend(ASRBackend):
except Exception: # noqa: BLE001 — cache clear is best-effort
pass
device = "cpu"
self._fallback_reason = "CUDA memory was exhausted while loading the engine"
self._fallback_stage = "model_load"
candidates = _compute_type_candidates(device)
compute_type = candidates[0]
continue
@@ -1985,6 +2002,42 @@ _ASR_OPENAI_COMPAT_MODEL_KEY = "asr.openai_compat.model"
_ASR_OPENAI_COMPAT_SECRET_NAME = "asr_openai_compat_key"
def normalize_openai_compat_asr_base_url(value: str) -> str:
"""Normalize a safe ASR endpoint, allowing plain HTTP only on loopback."""
base = (value or "").strip().rstrip("/")
if not base:
return ""
try:
parsed = urlsplit(base)
_ = parsed.port
except (TypeError, ValueError) as exc:
raise ValueError("Invalid OpenAI-compatible ASR base URL") from exc
scheme = parsed.scheme.lower()
if (
scheme not in {"http", "https"}
or not parsed.hostname
or parsed.username is not None
or parsed.password is not None
or parsed.query
or parsed.fragment
):
raise ValueError(
"OpenAI-compatible ASR base URL must be a credential-free HTTP(S) URL"
)
host = parsed.hostname.lower()
loopback = host == "localhost"
if not loopback:
try:
address = ipaddress.ip_address(host)
address = getattr(address, "ipv4_mapped", None) or address
loopback = address.is_loopback
except ValueError:
loopback = False
if scheme == "http" and not loopback:
raise ValueError("Non-loopback OpenAI-compatible ASR endpoints require HTTPS")
return base
def resolve_openai_compat_asr_base_url() -> str:
from services import settings_store
return (
@@ -2048,7 +2101,7 @@ def probe_openai_compat_server(
maps to a translated message:
not_configured no base URL anywhere
invalid_url base URL without an http(s):// scheme
invalid_url malformed URL or non-loopback HTTP endpoint
ok 2xx ``model_found`` says whether the configured
model appears in the server's list (None = unknown)
ok_no_models 404/405/501 reachable, but no /models endpoint
@@ -2063,7 +2116,7 @@ def probe_openai_compat_server(
from core.scrub import scrub_text
base = (base_url if base_url is not None else resolve_openai_compat_asr_base_url()).strip().rstrip("/")
configured_base = base_url if base_url is not None else resolve_openai_compat_asr_base_url()
mdl = (model if model is not None else resolve_openai_compat_asr_model()).strip()
if api_key is None:
key = resolve_openai_compat_asr_api_key()
@@ -2079,9 +2132,11 @@ def probe_openai_compat_server(
"model_found": None,
"detail": None,
}
if not base:
if not configured_base.strip():
return out
if not base.startswith(("http://", "https://")):
try:
base = normalize_openai_compat_asr_base_url(configured_base)
except ValueError:
out["status"] = "invalid_url"
return out
@@ -2092,7 +2147,7 @@ def probe_openai_compat_server(
try:
with httpx.Client(
timeout=httpx.Timeout(timeout_s, connect=min(5.0, timeout_s)),
follow_redirects=True,
follow_redirects=False,
) as client:
resp = client.get(f"{base}/models", headers=headers)
except httpx.TimeoutException as exc:
@@ -2155,13 +2210,20 @@ class OpenAICompatASRBackend(ASRBackend):
gpu_compat = ("cpu",) # network client only — no local compute
def __init__(self):
self._base_url = resolve_openai_compat_asr_base_url()
self._base_url = normalize_openai_compat_asr_base_url(
resolve_openai_compat_asr_base_url()
)
self._model = resolve_openai_compat_asr_model()
@classmethod
def is_available(cls) -> tuple[bool, str]:
if not resolve_openai_compat_asr_base_url():
base_url = resolve_openai_compat_asr_base_url()
if not base_url:
return False, "Configure a server endpoint in Model Catalogue → Engines"
try:
normalize_openai_compat_asr_base_url(base_url)
except ValueError as exc:
return False, str(exc)
try:
import openai # noqa: F401
except ImportError:
@@ -2169,13 +2231,18 @@ class OpenAICompatASRBackend(ASRBackend):
return True, "ready"
def _client(self):
from openai import OpenAI
from openai import DefaultHttpxClient, OpenAI
api_key = resolve_openai_compat_asr_api_key() or "not-needed"
# max_retries=0: mirrors llm_skills.resolve_skill_client — a
# rate-limited/slow server retrying inside the SDK would blow past
# whatever bounded timeout the caller (dub transcribe, dictation)
# expects from a single call.
return OpenAI(base_url=self._base_url, api_key=api_key, max_retries=0)
return OpenAI(
base_url=self._base_url,
api_key=api_key,
max_retries=0,
http_client=DefaultHttpxClient(follow_redirects=False),
)
def transcribe(self, audio_path: str, *, word_timestamps: bool = True) -> dict:
logger.info(
@@ -2354,6 +2421,8 @@ _LAST_ERRORS: dict[str, str] = {}
# failing ASR wholesale. Per-process by design: repairing the env requires a
# reinstall / ``uv sync --reinstall`` and an app restart anyway.
_DEEP_IMPORT_BROKEN: dict[str, str] = {}
_RUNTIME_EVIDENCE: dict[str, dict] = {}
_RUNTIME_INSTANCES: weakref.WeakValueDictionary[str, "ASRBackend"] = weakref.WeakValueDictionary()
def _deep_import_reason(cls: type["ASRBackend"], exc: ImportError) -> str:
@@ -2384,6 +2453,7 @@ def list_backends() -> list[dict]:
"""
from core.device_caps import detect_host_caps
from core.scrub import scrub_text
from services.engine_evidence import snapshot as execution_snapshot
from services.engine_routing import routing_fields
caps = detect_host_caps()
@@ -2408,6 +2478,24 @@ def list_backends() -> list[dict]:
_LAST_ERRORS[bid] = scrub_text(msg)
isolation = "subprocess" if getattr(cls, "_is_subprocess_isolated", False) else "in-process"
gpu_compat = getattr(cls, "gpu_compat", ("cpu",))
routing = routing_fields(gpu_compat, caps)
# Cached load-time facts are valid only while their exact backend still
# owns live model state. Recompute from that instance so unload/reaping
# cannot leave ghost GPU/provider evidence in diagnostics.
instance = (
_ISOLATED_INSTANCES.get(bid)
if isolation == "subprocess"
else _RUNTIME_INSTANCES.get(bid)
)
execution_evidence = execution_snapshot(
engine_id=bid,
engine_cls=cls,
instance=instance,
routing=routing,
caps=caps,
)
if execution_evidence["evidence_state"] == "not_loaded":
_RUNTIME_EVIDENCE.pop(bid, None)
out.append({
"id": bid,
"display_name": cls.display_name,
@@ -2419,7 +2507,14 @@ def list_backends() -> list[dict]:
"last_error": _LAST_ERRORS.get(bid),
"isolation_mode": isolation,
"gpu_compat": list(gpu_compat),
**routing_fields(gpu_compat, caps),
**routing,
"execution_evidence": execution_evidence or execution_snapshot(
engine_id=bid,
engine_cls=cls,
instance=None,
routing=routing,
caps=caps,
),
})
return out
@@ -2660,6 +2755,21 @@ def load_active_asr_backend(*, asr_pipe=None) -> ASRBackend:
raise ASRModelMissingError(missing)
try:
backend.ensure_loaded()
from core.device_caps import detect_host_caps
from services.engine_evidence import snapshot as execution_snapshot
from services.engine_routing import routing_fields
cls = type(backend)
caps = detect_host_caps()
routing = routing_fields(getattr(cls, "gpu_compat", ("cpu",)), caps)
_RUNTIME_EVIDENCE[bid] = execution_snapshot(
engine_id=bid,
engine_cls=cls,
instance=backend,
routing=routing,
caps=caps,
)
_RUNTIME_INSTANCES[bid] = backend
return backend
except ImportError as e:
# ModuleNotFoundError and its ImportError parent ("cannot import
+230
View File
@@ -0,0 +1,230 @@
"""Structured pre-install and measured disk costs for TTS engines."""
from __future__ import annotations
import os
import threading
import time
from functools import lru_cache
from pathlib import Path
_GIB = 1024**3
_CACHE_TTL_SECONDS = 10.0
_measurement_cache: dict[str, tuple[float, dict]] = {}
_measurement_lock = threading.Lock()
# Catalogue/build estimates. ``None`` is deliberate: unknown costs must stay
# visible instead of being silently treated as zero.
_ESTIMATES: dict[str, dict] = {
"omnivoice": {
"package_download_bytes": None,
"unique_installed_bytes": None,
"potentially_shared_bytes": None,
"temporary_free_bytes": None,
"confidence": "estimated",
"destination": "hf_model_cache",
"deduplication": None,
},
"kittentts": {
"package_download_bytes": None,
"unique_installed_bytes": None,
"potentially_shared_bytes": None,
"temporary_free_bytes": None,
"confidence": "estimated",
"destination": "hf_model_cache",
"deduplication": None,
},
}
_MODEL_REPOS = {
"omnivoice": "k2-fsa/OmniVoice",
"kittentts": "KittenML/kitten-tts-mini-0.8",
}
def _volume_root(path: Path) -> str:
"""Mount point/drive containing a possibly not-yet-created destination."""
try:
current = path.expanduser().resolve()
while not current.exists() and current.parent != current:
current = current.parent
device = current.stat().st_dev
while current.parent != current and current.parent.stat().st_dev == device:
current = current.parent
return str(current)
except OSError:
return "unknown"
def _hf_cache_path() -> Path:
configured = (
os.environ.get("HF_HUB_CACHE")
or os.environ.get("HUGGINGFACE_HUB_CACHE")
or os.environ.get("HF_HOME")
)
return Path(configured) if configured else Path.home() / ".cache" / "huggingface"
@lru_cache(maxsize=None)
def _catalog_model_bytes(engine_id: str) -> int | None:
"""Resolve the weight estimate from config/models.yaml, its source of truth."""
repo_id = _MODEL_REPOS.get(engine_id)
if repo_id is None:
return None
try:
import yaml
catalog_path = Path(__file__).resolve().parents[1] / "config" / "models.yaml"
entries = yaml.safe_load(catalog_path.read_text(encoding="utf-8"))["models"]
model = next(item for item in entries if item["repo_id"] == repo_id)
return round(float(model["size_gb"]) * _GIB)
except (OSError, KeyError, StopIteration, TypeError, ValueError):
return None
def _dir_size(path: Path) -> int:
total = 0
try:
for root, _dirs, files in os.walk(path):
for filename in files:
try:
total += os.path.getsize(os.path.join(root, filename))
except OSError:
continue
except OSError:
return 0
return total
def _sidecar_estimate(engine_id: str) -> dict | None:
try:
from services.sidecar_install import get_spec, managed_root
spec = get_spec(engine_id)
except Exception:
return None
if spec is None:
return None
model_bytes = spec.weights_bytes
dependency_bytes = spec.dependency_bytes
return {
"model_download_bytes": model_bytes,
"package_download_bytes": dependency_bytes,
"unique_installed_bytes": spec.required_bytes,
"potentially_shared_bytes": spec.potentially_shared_bytes,
"temporary_free_bytes": spec.temporary_free_bytes,
"confidence": spec.disk_confidence,
"destination": "engine_data",
"destination_volume": _volume_root(managed_root(spec)),
"deduplication": "uv_same_volume",
}
def estimate_for(engine_id: str) -> dict:
estimate = _sidecar_estimate(engine_id) or _ESTIMATES.get(engine_id)
if estimate is not None:
return {
"model_download_bytes": _catalog_model_bytes(engine_id),
"destination_volume": _volume_root(_hf_cache_path()),
**estimate,
}
return {
"model_download_bytes": None,
"package_download_bytes": None,
"unique_installed_bytes": None,
"potentially_shared_bytes": None,
"temporary_free_bytes": None,
"confidence": "unknown",
"destination": "unknown",
"destination_volume": "unknown",
"deduplication": None,
}
def _measure_sidecar(engine_id: str) -> dict | None:
try:
from services.sidecar_install import get_spec, managed_checkout, managed_root
spec = get_spec(engine_id)
except Exception:
return None
if spec is None:
return None
checkout = managed_checkout(spec)
if not checkout.is_dir():
return None
model = _dir_size(checkout / spec.weights_subdir)
environment = _dir_size(checkout / ".venv")
total = _dir_size(managed_root(spec))
shared_cache = _dir_size(managed_root(spec).parent / ".uv-cache")
return {
"model_bytes": model,
"environment_bytes": environment,
"cache_bytes": shared_cache,
"total_owned_bytes": total,
"confidence": "measured",
}
def _measure_model_cache(engine_id: str) -> dict | None:
repo_id = _MODEL_REPOS.get(engine_id)
if repo_id is None:
return None
try:
from huggingface_hub import scan_cache_dir
repo = next((item for item in scan_cache_dir().repos if item.repo_id == repo_id), None)
except Exception:
return None
if repo is None or repo.size_on_disk <= 0:
return None
size = int(repo.size_on_disk)
return {
"model_bytes": size,
# The model lives in this cache; cache overhead is not separately
# attributable without double-counting the same hardlinked blobs.
"environment_bytes": None,
"cache_bytes": 0,
"total_owned_bytes": size,
"confidence": "measured",
}
def actual_for(engine_id: str) -> dict:
now = time.monotonic()
cached = _measurement_cache.get(engine_id)
if cached and now - cached[0] < _CACHE_TTL_SECONDS:
return dict(cached[1])
# A cache miss can recursively walk a sidecar and the shared uv cache.
# Coalesce concurrent requests so callers cannot multiply that work.
with _measurement_lock:
now = time.monotonic()
cached = _measurement_cache.get(engine_id)
if cached and now - cached[0] < _CACHE_TTL_SECONDS:
return dict(cached[1])
actual = _measure_sidecar(engine_id) or _measure_model_cache(engine_id) or {
"model_bytes": None,
"environment_bytes": None,
"cache_bytes": None,
"total_owned_bytes": None,
"confidence": "unknown",
}
_measurement_cache[engine_id] = (now, actual)
return dict(actual)
def disk_usage_for(engine_id: str) -> dict:
"""Stable API shape consumed by the engine catalogue."""
return {"estimate": estimate_for(engine_id), "actual": actual_for(engine_id)}
def disk_summary_for(engine_id: str) -> dict:
"""Cheap list payload; measurement is deferred until the row is opened."""
return {
"estimate": estimate_for(engine_id),
"actual": {
"model_bytes": None,
"environment_bytes": None,
"cache_bytes": None,
"total_owned_bytes": None,
"confidence": "unknown",
},
}
+119
View File
@@ -0,0 +1,119 @@
"""Sanitized, reproducible execution evidence for TTS and ASR engines."""
from __future__ import annotations
import importlib.metadata
import platform
from typing import Any
def _version(distribution: str) -> str | None:
try:
return importlib.metadata.version(distribution)
except importlib.metadata.PackageNotFoundError:
return None
def _value(instance: object, *names: str) -> str | None:
for name in names:
try:
value = getattr(instance, name, None)
if value is not None and not callable(value):
text = str(value).strip()
if text and len(text) <= 80 and "/" not in text and "\\" not in text:
return text
except Exception:
continue
return None
def runtime_versions(engine_id: str) -> dict[str, str]:
"""Relevant installed library versions, never paths or environment values."""
names = {"python": platform.python_version()}
candidates = ["torch"]
low = engine_id.lower()
if "faster" in low or "whisperx" in low:
candidates.extend(["ctranslate2", "faster-whisper"])
if "sherpa" in low or "moonshine" in low:
candidates.append("onnxruntime")
if "mlx" in low:
candidates.append("mlx")
for name in candidates:
if (version := _version(name)) is not None:
names[name] = version
return names
def snapshot(
*,
engine_id: str,
engine_cls: type,
instance: object | None,
routing: dict[str, Any],
caps: object,
) -> dict[str, Any]:
"""Return fixed-shape evidence; actual fields stay null until an instance loads."""
isolated = bool(
getattr(engine_cls, "_is_subprocess_isolated", False)
or getattr(engine_cls, "runs_out_of_process", False)
)
loaded = False
probe_failed = False
if instance is not None:
try:
contract = getattr(instance, "execution_evidence_loaded", False)
loaded = bool(contract() if callable(contract) else contract)
except Exception: # noqa: BLE001 - third-party lifecycle descriptors may raise
probe_failed = True
actual_device = None
provider = None
precision = None
if loaded:
actual_device = _value(instance, "_device", "device", "execution_device")
provider = _value(instance, "_provider", "provider", "execution_provider")
precision = _value(
instance, "_compute_type", "compute_type", "_dtype", "dtype", "quantization"
)
if provider is None and actual_device is not None:
provider = actual_device
runtime_fallback_reason = _value(instance, "_fallback_reason", "fallback_reason") if loaded else None
runtime_fallback_stage = _value(instance, "_fallback_stage", "fallback_stage") if loaded else None
status = routing.get("routing_status")
fallback = status == "cpu_fallback" or runtime_fallback_reason is not None
evidence_state = "not_loaded"
if probe_failed:
evidence_state = "probe_error"
elif loaded:
evidence_state = "loaded"
if isolated and provider is None and actual_device is None:
evidence_state = "subprocess_loaded_provider_unreported"
return {
"implementation_variant": f"{engine_cls.__module__}.{engine_cls.__name__}",
"declared_device_families": list(getattr(engine_cls, "gpu_compat", ("cpu",))),
"evidence_state": evidence_state,
"actual_execution_provider": provider,
"actual_execution_device": actual_device,
"gpu_name": getattr(caps, "device_name", "") or None,
"gpu_architecture": _gpu_architecture(getattr(caps, "family", "cpu")),
"precision_or_quantization": precision,
"cpu_fallback_reason": runtime_fallback_reason or (routing.get("routing_reason") if fallback else None),
"cpu_fallback_stage": runtime_fallback_stage or ("routing_preflight" if fallback else None),
"parent_memory_observable": not isolated,
"runtime_versions": runtime_versions(engine_id),
}
def _gpu_architecture(family: str) -> str | None:
if family not in {"cuda", "rocm"}:
return "apple-silicon" if family == "mps" else None
try:
import torch
if family == "rocm":
props = torch.cuda.get_device_properties(0)
return str(getattr(props, "gcnArchName", "") or "") or None
major, minor = torch.cuda.get_device_capability(0)
return f"sm_{major}{minor}"
except Exception:
return None
+59 -4
View File
@@ -273,6 +273,13 @@ def sherpa_available() -> tuple[bool, str]:
return False, f"sherpa-onnx unavailable ({type(e).__name__}): {e}"
def _usable_model_file(path: str) -> bool:
try:
return os.path.isfile(path) and os.path.getsize(path) > 0
except OSError:
return False
def _resolve_model_dir(spec: SherpaModelSpec, *, download: bool = True) -> str:
"""Return the local directory containing this model's ONNX assets.
@@ -285,6 +292,7 @@ def _resolve_model_dir(spec: SherpaModelSpec, *, download: bool = True) -> str:
from services.hf_revisions import revision_for
wanted = list(spec.files.values())
cache_dir = _live_hub_cache_dir()
# Probe the revision an existing installation actually resolved. Older
# releases followed ``main`` and may therefore have a different snapshot;
# retaining it preserves offline upgrades. Any network fetch still uses
@@ -294,12 +302,57 @@ def _resolve_model_dir(spec: SherpaModelSpec, *, download: bool = True) -> str:
return installed
if not download:
raise FileNotFoundError(f"No complete cached snapshot for {spec.repo_id}")
# A Windows cache can retain a snapshot entry whose target blob vanished,
# or a zero-byte ONNX placeholder left by an interrupted download. Hub may
# then treat that entry as already materialized and return the same broken
# snapshot. Repair those entries before asking for another download so the
# recognizer never receives a path to a file that does not resolve (#1733).
from services.hf_cache_repair import (
find_dangling_entries,
repair_repo_cache,
repo_cache_dir,
)
if find_dangling_entries(repo_cache_dir(spec.repo_id, cache_dir)):
repair = repair_repo_cache(spec.repo_id, cache_dir)
installed = _installed_snapshot(spec)
if installed:
return installed
if not repair.get("ok"):
logger.warning(
"sherpa dictation: cache repair for %s failed: %s",
spec.repo_id,
repair.get("error") or repair.get("outcome") or "unknown error",
)
logger.info("sherpa dictation: downloading %s on first use", spec.repo_id)
return snapshot_download(
snapshot = snapshot_download(
repo_id=spec.repo_id,
revision=revision_for(spec.repo_id),
allow_patterns=wanted,
cache_dir=_live_hub_cache_dir(),
cache_dir=cache_dir,
)
missing = [
name for name in wanted
if not _usable_model_file(os.path.join(snapshot, name))
]
if not missing:
return snapshot
# Verify after the Hub reports success. This catches hosts where a broken
# snapshot entry short-circuits snapshot_download. The generic repair
# removes only broken entries, preserves blobs, and retries the immutable
# installed revision.
repair = repair_repo_cache(spec.repo_id, cache_dir)
installed = _installed_snapshot(spec)
if installed:
return installed
detail = repair.get("error") or repair.get("outcome") or "repair did not restore them"
raise FileNotFoundError(
f"Sherpa model cache is incomplete for {spec.repo_id}; missing "
f"{', '.join(missing)}. Cache repair failed: {detail}. Reinstall this "
"model from Model Catalogue."
)
@@ -324,8 +377,10 @@ def _installed_snapshot(spec: SherpaModelSpec) -> str | None:
"snapshots",
revision,
)
if all(os.path.isfile(os.path.join(snapshot, filename))
for filename in spec.files.values()):
if all(
_usable_model_file(os.path.join(snapshot, filename))
for filename in spec.files.values()
):
return snapshot
return None
+45 -6
View File
@@ -60,7 +60,7 @@ from pathlib import Path
from typing import Callable, Optional
from core.config import DATA_DIR
from core.contained_subprocess import OwnedPopen, spawn_owned
from core.contained_subprocess import OwnedPopen, WindowsJobPopen, spawn_owned
logger = logging.getLogger("omnivoice.sidecar_install")
@@ -116,6 +116,11 @@ class SidecarSpec:
weights_config_names: tuple[str, ...] = ("config.yaml",)
docs_path: str = "docs/engines" # where the manual-install fallback lives
required_bytes: int = 12 * _GIB # conservative source+venv+weights estimate for preflight
weights_bytes: Optional[int] = None
dependency_bytes: Optional[int] = None
potentially_shared_bytes: Optional[int] = None
temporary_free_bytes: Optional[int] = None
disk_confidence: str = "unknown"
# Called after a successful install/uninstall so the engine's memoised
# venv resolution re-probes (import inside the lambda — never at module load).
invalidate: Callable[[], None] = field(default=lambda: None)
@@ -157,6 +162,11 @@ SPECS: dict[str, SidecarSpec] = {
# ~6 GB weights. Deliberately conservative; the preflight subtracts
# whatever a partial install already put on disk.
required_bytes=12 * _GIB,
weights_bytes=6 * _GIB,
dependency_bytes=6 * _GIB,
potentially_shared_bytes=None,
temporary_free_bytes=12 * _GIB,
disk_confidence="estimated",
invalidate=_indextts_invalidate,
installed_probe=_indextts_installed,
),
@@ -312,6 +322,25 @@ def _dir_size_bytes(path: Path) -> int:
return total
def _preserved_install_bytes(spec: SidecarSpec, checkout: Path) -> tuple[int, int]:
"""Return bytes preserved for the final install and dependency peak.
Resumable weights reduce the final download requirement, but they do not
reduce uv's separate environment-build peak. Only source and a usable
existing venv count against that peak.
"""
if not _source_present(spec, checkout):
return 0, 0
weights_dir = checkout / spec.weights_subdir
weights = _dir_size_bytes(weights_dir) if spec.weights_repo_id else 0
venv_dir = checkout / ".venv"
venv = _dir_size_bytes(venv_dir)
source = max(0, _dir_size_bytes(checkout) - weights - venv)
usable_venv = venv if _venv_python(venv_dir).is_file() else 0
return source + usable_venv + weights, source + usable_venv
def disk_free_bytes(path: Path) -> int:
"""Free bytes on the volume backing *path* (nearest existing ancestor).
Never raises; 0 when the volume can't be probed."""
@@ -334,8 +363,17 @@ def disk_space_error(spec: SidecarSpec) -> Optional[str]:
root = managed_root(spec)
# A preserved predecessor is not a partial copy of the new install: the
# upgrade needs its full space until the new sidecar is verified.
already = _dir_size_bytes(managed_checkout(spec))
remaining = max(0, spec.required_bytes - already)
checkout = managed_checkout(spec)
# Credit only bytes the later steps preserve. An invalid layout or revision
# marker makes _step_fetch_source delete the whole checkout.
preserved, dependency_peak_credit = _preserved_install_bytes(spec, checkout)
remaining = max(0, spec.required_bytes - preserved)
if spec.temporary_free_bytes is not None:
# Resumable model weights are unrelated to uv's dependency-build peak.
remaining = max(
remaining,
max(0, spec.temporary_free_bytes - dependency_peak_credit),
)
free = disk_free_bytes(root)
if free <= 0:
return None # can't probe → never block on missing information
@@ -1019,7 +1057,8 @@ def _run_logged(job: dict, argv: list[str], *, timeout: float,
would hang past the timeout waiting for pipe EOF.
"""
# ``spawn_owned`` creates the local timeout group/Job before the operation
# starts and links it to backend death through its control pipe.
# starts. POSIX links it to backend death through a control pipe; Windows
# retains a kill-on-close Job handle in this backend process.
popen_kwargs = _install_containment_kwargs()
try:
proc = spawn_owned(
@@ -1058,14 +1097,14 @@ def _run_logged(job: dict, argv: list[str], *, timeout: float,
def _kill_tree(proc: "subprocess.Popen") -> None:
"""Kill an operation through its stable nested group/Job owner."""
if isinstance(proc, OwnedPopen):
if isinstance(proc, (OwnedPopen, WindowsJobPopen)):
# The retained supervisor/process-group or nested Job is the stable
# per-operation owner. Do not fall back to a direct PID kill.
proc.kill()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
pass
return
return
# A test double or a legacy caller without the nested owner can only be
# stopped through its stable direct-process handle.
+5 -4
View File
@@ -32,9 +32,9 @@ Threat-model summary (see Plan 02-01 frontmatter):
AUTH-05 installed (``HFTokenRedactor``) on the root logger.
T-02-04 compromised sidecar emitting unexpected ops: parent allowlist
``PARENT_INBOUND_OPS`` rejects everything else.
T-02-05 nested containment: a retained supervisor process group/Job owns
each engine operation and is linked to backend death by a control
pipe, while still permitting independent timeout teardown.
T-02-05 nested containment: a retained POSIX supervisor process group or
Windows Job owns each engine operation, while still permitting
independent timeout teardown and cleanup on backend death.
"""
from __future__ import annotations
@@ -363,6 +363,7 @@ class SubprocessBackend(TTSBackend):
# be a different class object from the one the subclass closed over.
# A duck-typed marker survives that.
_is_subprocess_isolated: bool = True
spawn_ready_timeout_s: float = SPAWN_READY_TIMEOUT_S
# Generation happens in the sidecar: parent-side accelerator counters
# can't see its allocations (see TTSBackend.runs_out_of_process).
@@ -524,7 +525,7 @@ class SubprocessBackend(TTSBackend):
# Block on the ready handshake. A sidecar that fails to emit ready
# within SPAWN_READY_TIMEOUT_S is killed and the failure is raised.
try:
frame = self._recv_with_timeout(SPAWN_READY_TIMEOUT_S)
frame = self._recv_with_timeout(self.spawn_ready_timeout_s)
except Exception:
self._force_kill()
raise
+24 -1
View File
@@ -407,6 +407,13 @@ class TTSBackend(ABC):
# entirely (it drives the shared model_manager singleton).
_MODEL_ATTRS: tuple[str, ...] = ("_model", "_tts")
def execution_evidence_loaded(self) -> bool:
"""Whether this instance has live model state worth reporting."""
if self.runs_out_of_process:
proc = getattr(self, "_proc", None)
return proc is not None and proc.poll() is None
return any(getattr(self, attr, None) is not None for attr in self._MODEL_ATTRS)
def unload(self) -> None:
"""Release the heavy model this backend holds, and free device caches.
@@ -2392,6 +2399,8 @@ def list_backends() -> list[dict]:
# Routing is host-aware but the host caps are constant per process, so probe
# ONCE here and resolve each engine's effective device against the same caps.
from core.device_caps import detect_host_caps
from services.engine_disk_usage import disk_summary_for
from services.engine_evidence import snapshot as execution_snapshot
from services.engine_routing import routing_fields
caps = detect_host_caps()
installable = _sidecar_installable_ids()
@@ -2425,6 +2434,12 @@ def list_backends() -> list[dict]:
# descriptor, not a bool, so report None (= model-dependent) there
# instead of an always-truthy false positive.
_clone = getattr(cls, "supports_cloning", True)
routing = routing_fields(gpu_compat, caps, getattr(cls, "min_vram_gb", 0.0))
loaded_instance = None
if _active_instance_id == bid:
loaded_instance = _active_instance
if loaded_instance is None:
loaded_instance = _ENGINE_INSTANCES.get(cls)
out.append({
"id": bid,
"display_name": cls.display_name,
@@ -2444,6 +2459,7 @@ def list_backends() -> list[dict]:
# in-app (Settings renders an Install button instead of leading
# with the manual setup snippet).
"one_click_install": bid in installable,
"disk_usage": disk_summary_for(bid),
"last_error": _LAST_ERRORS.get(bid),
"isolation_mode": isolation,
"gpu_compat": list(gpu_compat),
@@ -2451,7 +2467,14 @@ def list_backends() -> list[dict]:
"min_vram_gb": getattr(cls, "min_vram_gb", 0.0) or None,
# effective_device / routing_status / routing_reason (scrubbed);
# the reason now also carries the under-provisioned-GPU caveat.
**routing_fields(gpu_compat, caps, getattr(cls, "min_vram_gb", 0.0)),
**routing,
"execution_evidence": execution_snapshot(
engine_id=bid,
engine_cls=cls,
instance=loaded_instance,
routing=routing,
caps=caps,
),
})
# #981: mlx-audio multiplexes 7+ curated models behind one backend id
# — surface the roster + the currently-active pick so Settings can
@@ -257,3 +257,90 @@ def test_windows_assignment_failure_kills_suspended_unowned_child(monkeypatch):
names = [event[0] for event in events]
assert names.index("assign") < names.index("terminate") < names.index("kill")
assert names.index("kill") < names.index("wait") < names.index("write")
def test_windows_direct_job_owner_assigns_before_resume(monkeypatch):
"""Windows skips the extra Python wrapper but retains pre-start Job ownership."""
events = []
job = 99
kernel = type("Kernel", (), {})()
kernel.AssignProcessToJobObject = _Call(
lambda assigned_job, process: events.append(("assign", assigned_job, process)) or True
)
kernel.TerminateJobObject = _Call(
lambda assigned_job, code: events.append(("terminate", assigned_job, code)) or True
)
kernel.CloseHandle = _Call(
lambda handle: events.append(("close", getattr(handle, "value", handle))) or True
)
monkeypatch.setattr(owned, "_windows_job", lambda: (job, kernel, wintypes))
monkeypatch.setattr(
owned,
"_resume_windows_process",
lambda _kernel, _types, pid: events.append(("resume", pid)),
)
class Child:
_handle = 77
pid = 123
args = ["operation.exe"]
stdin = None
stdout = object()
stderr = object()
returncode = None
def poll(self):
return self.returncode
def wait(self, timeout=None):
events.append(("wait", timeout))
return self.returncode
def kill(self):
events.append(("kill",))
child = Child()
def fake_popen(argv, **kwargs):
events.append(("spawn", argv, kwargs))
return child
monkeypatch.setattr(owned.subprocess, "Popen", fake_popen)
proc = owned._spawn_windows_owned(
["operation.exe"],
{
"env": {
"KEEP": "yes",
"OMNIVOICE_DESKTOP_CONTAINED": "1",
"OMNIVOICE_DESKTOP_DRAIN_FD": "42",
},
"creationflags": 0x00000200,
},
)
names = [event[0] for event in events]
assert names[:3] == ["spawn", "assign", "resume"]
spawn_argv, spawn_kwargs = events[0][1:]
assert spawn_argv == ["operation.exe"]
assert spawn_kwargs["creationflags"] == 0x08000204
assert spawn_kwargs["env"] == {"KEEP": "yes"}
assert proc.stdout is child.stdout
child.returncode = 0
assert proc.poll() == 0
assert [event[0] for event in events][-2:] == ["terminate", "close"]
def test_spawn_owned_selects_direct_windows_job_path(monkeypatch):
sentinel = object()
calls = []
monkeypatch.setattr(owned.os, "name", "nt")
monkeypatch.setattr(
owned,
"_spawn_windows_owned",
lambda argv, kwargs: calls.append((argv, kwargs)) or sentinel,
)
assert owned.spawn_owned(["sidecar.exe"], text=True) is sentinel
assert calls == [(["sidecar.exe"], {"text": True})]
@@ -273,6 +273,42 @@ def test_omnivoice_subprocess_recv_timeout_overrides_default():
assert b.recv_timeout_s == 300.0 # aligns with the generate budget
def test_omnivoice_subprocess_has_longer_spawn_budget_than_other_sidecars():
assert _PlainBackend.spawn_ready_timeout_s == 30.0
assert OmniVoiceSubprocessBackend.spawn_ready_timeout_s == 120.0
def test_spawn_uses_backend_specific_ready_timeout(monkeypatch, tmp_path):
_use_stub(monkeypatch, tmp_path / "unused.py")
backend = OmniVoiceSubprocessBackend()
observed = []
class StubProcess:
stderr = io.BytesIO()
@staticmethod
def poll():
return None
monkeypatch.setattr(
"services.subprocess_backend.spawn_owned",
lambda *_args, **_kwargs: StubProcess(),
)
monkeypatch.setattr(
backend,
"_recv_with_timeout",
lambda timeout: observed.append(timeout) or {"op": "ready"},
)
monkeypatch.setattr("services.subprocess_backend._ensure_reaper_running", lambda: None)
try:
backend._spawn()
finally:
backend._proc = None
assert observed == [120.0]
def test_omnivoice_subprocess_recv_timeout_env_override(monkeypatch):
monkeypatch.setenv("OMNIVOICE_SIDECAR_RECV_TIMEOUT_S", "120")
assert OmniVoiceSubprocessBackend().recv_timeout_s == 120.0
+1 -1
View File
@@ -54,7 +54,7 @@ approval), [Windows](../install/windows.md), [Linux](../install/linux.md),
| Moonshine | [moonshine](moonshine.md) | CPU | edge/low-power, no timestamps | `pip install` (see guide) |
| FunASR (SenseVoice) | [funasr](funasr.md) | CUDA · CPU | 50+ languages, inline diarization | `pip install funasr` |
| Sherpa-ONNX dictation | [sherpa-onnx-asr](sherpa-onnx-asr.md) | CPU | live streaming dictation | curated model download |
| OpenAI-compatible (remote) | [openai-compatible-asr](openai-compatible-asr.md) | network | offloading to a server (audio leaves the machine) | Model Catalogue |
| OpenAI-compatible (local or remote) | [openai-compatible-asr](openai-compatible-asr.md) | network | a configured endpoint; loopback stays local | Model Catalogue |
Speaker diarization is not an engine registry of its own — the dub pipeline
uses pyannote (HF-gated; see [diarization](../features/diarization.md)) and
+9
View File
@@ -1,5 +1,14 @@
# Engine venvs & disk usage
The Model Catalogue now exposes a structured disk breakdown before install:
model-weight download, package download, unique installed bytes, potentially
shared bytes, temporary free-space requirement, destination volume, and the
estimate confidence. Missing package or deduplication measurements are shown as
unknown rather than zero. Opening an installed engine's disk details measures
its model, environment, shared cache, and app-owned total separately. These
values come from `config/models.yaml` and the sidecar installer specification;
the UI does not maintain its own size table.
Most engines run in-process in VoiceStudio's main environment. A few
(**IndexTTS2**, **MOSS-TTS-v1.5**, **dots.tts**, and any engine whose
dependencies conflict with the parent's `torch`/`transformers` pins) run in a
+16 -12
View File
@@ -1,11 +1,11 @@
# VoiceStudio OpenAI-Compatible Remote ASR
# VoiceStudio: OpenAI-Compatible ASR
Point transcription at **any** server exposing an OpenAI-compatible
`POST /v1/audio/transcriptions` endpoint LM Studio or a llama.cpp-style
local server, a self-hosted Qwen3-ASR/FunASR/SenseVoice box on your network,
Groq, or OpenAI's own Whisper API. Unlike every other ASR engine, this one
runs no model locally: it's a pure network client, so it needs no install
and claims no GPU.
`POST /v1/audio/transcriptions` endpoint: gigastt, LM Studio, or a
llama.cpp-style server on the same machine; a self-hosted
Qwen3-ASR/FunASR/SenseVoice box on your network; Groq; or OpenAI's Whisper
API. VoiceStudio is a pure client in this mode, so the configured server owns
model installation and compute.
## Setup
@@ -39,13 +39,18 @@ picks local engines, and the app works fully with this engine unconfigured.
| Server | Server URL | Model | API key |
| --- | --- | --- | --- |
| [gigastt](https://github.com/ekhodzitsky/gigastt) (local Russian specialist) | `http://127.0.0.1:9876/v1` | `gigaam-v3-rnnt` | none |
| LM Studio (local) | `http://localhost:1234/v1` | the model name shown in LM Studio | none |
| llama.cpp / whisper.cpp server (local) | `http://localhost:8080/v1` | whatever the server loads (often ignored) | none |
| speaches / faster-whisper-server (local) | `http://localhost:8000/v1` | e.g. `Systran/faster-whisper-large-v3` | none |
| Self-hosted Qwen3-ASR / FunASR (LAN box) | `http://<host>:8000/v1` | your deployment's model id | if you enabled auth |
| Self-hosted Qwen3-ASR / FunASR (LAN box) | `https://<host>:8000/v1` | your deployment's model id | if you enabled auth |
| Groq | `https://api.groq.com/openai/v1` | `whisper-large-v3` | required |
| OpenAI | `https://api.openai.com/v1` | `whisper-1` | required |
Plain HTTP is accepted only for exact loopback hosts such as `localhost`,
`127.0.0.1`, and `::1`. Every non-loopback endpoint must use HTTPS. VoiceStudio
does not follow redirects from transcription or connection-probe requests.
Local servers vary in which endpoints they implement — if **Test
connection** reports the server is reachable but doesn't list models,
transcription may still work; run a small dictation or dub-transcribe to
@@ -60,8 +65,7 @@ path returns word-level timestamps — that's not part of this API.
## Privacy note
Unlike every other ASR engine in VoiceStudio, audio sent through this backend
leaves your machine — to whatever server **you** configured, and nowhere
else. If that's a self-hosted server on your own network, nothing leaves
your control; if it's a third-party API (Groq, OpenAI's, or someone
else's), review their data handling before sending anything sensitive.
Audio goes only to the server **you** configure. A loopback URL such as the
gigastt example keeps it on the same machine and may use HTTP. LAN and public
endpoints require HTTPS, and redirects are not followed. Review the configured
server's data handling before sending anything sensitive.
+1 -1
View File
@@ -82,7 +82,7 @@ asr_engines:
- id: sherpa-onnx-asr
readme: "**sherpa-onnx** (live dictation)"
- id: openai-compat-asr
readme: "**OpenAI-compatible** ⚠️ remote"
readme: "**OpenAI-compatible** ⚠️ configured server"
# Doc files that must exist (the install path users are sent to).
docs:
+27
View File
@@ -123,6 +123,33 @@ on `127.0.0.1` and do not run untrusted workloads in this container. See AMD's
[`librocdxg` WSL container instructions](https://github.com/ROCm/librocdxg#4-container-launch--wsl-specific-flags)
for the driver/runtime compatibility matrix.
#### WSL2 architecture compatibility matrix
VoiceStudio classifies the architecture result separately from device-node
visibility. `/dev/dxg` alone is not proof of acceleration; a supported claim
also needs the runtime probe, application routing, a completed workload, and
GPU-utilization evidence.
| Classification | Evidence required | VoiceStudio behavior |
|---|---|---|
| **Supported** | The native GFX tag is in the shipped PyTorch architecture list, and the named hardware has a published successful workload with GPU-utilization evidence. | Report the measured provider and device from Settings and diagnostics. |
| **Best-effort override** | The native tag is absent, a mapped target is present in the PyTorch build, and `HSA_OVERRIDE_GFX_VERSION` is applied. No hardware validation is implied. | Attempt the mapped kernels; capture execution evidence and treat failures as unsupported for that host. |
| **Unverified** | The bridge or override is configured, but no published end-to-end result exists for the named card and stack. | Do not advertise the card as supported; run the checks below before relying on it. |
| **Unsupported** | Neither the native tag nor a usable mapped target is present, or the runtime/workload rejects the device. | Use an intentional CPU route or a different supported accelerator. |
| Hardware / architecture | Current classification | Detail |
|---|---|---|
| AMD Radeon RX 6700 XT / `gfx1031` through WSL2 ROCDXG | **Unverified** | VoiceStudio can map `gfx1031` to `gfx1030` when that target exists in the PyTorch build, but no RX 6700 XT end-to-end validation has been published. |
For an RX 6700 XT result to move out of **Unverified**, record the Windows AMD
driver, WSL kernel/distribution, image and ROCDXG/ROCm versions,
`torch.version.hip`, device name/count and compiled architecture list, effective
HSA override, `rocminfo`, VoiceStudio self-check and engine-routing output, and
one successful PyTorch TTS and ASR workload with utilization plus cold/warm
latency. Record whether either workload fell back to CPU and, when it did, the
CPU fallback stage and reason reported by VoiceStudio. A CPU-only completion
does not qualify as successful GPU validation.
The same flags work with **Podman** (`podman run --device /dev/kfd
--device /dev/dri …`); in a **Quadlet** unit that's two `AddDevice=` lines:
+2 -2
View File
@@ -7,8 +7,8 @@ working VoiceStudio install on a Debian / Ubuntu / Fedora / Arch host.
### Using the AppImage
- **Linux x86_64** with a desktop session (X11 or Wayland) capable of running
a Tauri / WebKitGTK app.
- **Linux x86_64 with glibc 2.39+** and a desktop session (X11 or Wayland)
capable of running a Tauri / WebKitGTK app.
- **~10 GB free disk** for the app, its Python environment, and model weights.
- Optional: an **NVIDIA driver** for CUDA GPU acceleration — the app runs
CPU-only without one. For AMD GPUs see [AMD GPU (ROCm)](#amd-gpu-rocm).
+9
View File
@@ -30,6 +30,15 @@ Before digging through the entries below, let the app diagnose itself:
tails) you can drag straight onto the GitHub issue. Home paths and
anything token-shaped are redacted before they leave your machine.
The report's `engine_execution` rows distinguish declared compatibility
from observed runtime state. `evidence_state: not_loaded` means no actual
provider can yet be claimed. Run the deep self-check for TTS, or issue a
representative ASR request for ASR evidence. Loaded rows include the execution provider/device, precision
or quantization when the engine exposes it, GPU identity, CPU-fallback stage,
relevant library versions, and whether parent-process memory counters cover
the engine. Subprocess engines report memory visibility as false because
their accelerator allocations belong to the child process.
## 1. `pkg_resources` missing (ModuleNotFoundError)
<a id="pkg_resources-missing"></a>
+3 -1
View File
@@ -164,7 +164,9 @@ normal per-platform step:
- **macOS:** drag **VoiceStudio.app** from `/Applications` to the Trash.
- **Windows:** **Settings → Apps → Installed apps → VoiceStudio →
Uninstall** (or via "Add or remove programs").
Uninstall** (or via "Add or remove programs"). The non-elevated artifact
appears as **VoiceStudio (Current User)** and can be removed by that user
without administrator approval.
- **Linux (AppImage):** delete the `.AppImage` file. If you integrated it into
your menu (e.g. with AppImageLauncher or a hand-written `.desktop` file),
also remove `~/.local/share/applications/*omnivoice*.desktop` and any icon
+62 -3
View File
@@ -80,9 +80,23 @@ model weights. The splash screen shows progress.
## Install (pre-built MSI)
Download the latest MSI from the
[Releases page](https://github.com/debpalash/VoiceStudio/releases/latest),
run it, follow the wizard. The shortcut lands in the Start menu as
**VoiceStudio**.
[Releases page](https://github.com/debpalash/VoiceStudio/releases/latest).
| Artifact name | Scope | Administrator required | Default location |
|---|---|---|---|
| `VoiceStudio_<version>_x64_en-US.msi` | All users (per-machine) | Yes | `%ProgramFiles%\VoiceStudio` |
| `VoiceStudio_Current_User_<version>_x64_en-US.msi` | Current user only | No | `%LOCALAPPDATA%\VoiceStudio (Current User)` |
Run the artifact matching the required scope and follow the wizard. The
per-user artifact can be installed, updated, and removed by a standard Windows
account. It has a separate Windows Installer upgrade identity, shortcut name,
and signed updater manifest, so it cannot upgrade or uninstall the per-machine
copy (or vice versa). Both copies use the same VoiceStudio data directory; do
not run them simultaneously against the same projects.
Automatic updates preserve the installed scope. Managed deployments should
continue to use the per-machine MSI. Users without elevation should choose the
artifact containing `Current_User`.
### Installing to a different drive
@@ -116,6 +130,51 @@ If an install to a local non-C: drive fails anyway, capture a log with
[open an issue](https://github.com/debpalash/VoiceStudio/issues) with it
— that log shows exactly which step rolled back.
## Managed WebView2 installation
The MSI checks the `pv` version value in both the machine-wide and current-user
Edge Update registry keys for the WebView2 Runtime product
`{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}`. When no `pv` version is detected and
the bootstrapper was not explicitly allowed (or was explicitly disabled), the
install fails without making a network request and directs the operator to the
offline runtime. `ALLOWWEBVIEW2BOOTSTRAP=1` instead selects the opt-in path
documented below.
Managed or offline deployments can prohibit that network action:
```powershell
msiexec /i VoiceStudio_0.5.1_x64_en-US.msi DISABLEWEBVIEW2BOOTSTRAP=1 AUTOLAUNCHAPP=0 /qn /L*V "%TEMP%\VoiceStudio-install.log"
```
The per-user artifact never contains a WebView2 download or installer action.
It does not require an elevated terminal, and fails closed when the runtime is
absent:
```powershell
msiexec /i VoiceStudio_Current_User_0.5.1_x64_en-US.msi DISABLEWEBVIEW2BOOTSTRAP=1 AUTOLAUNCHAPP=0 /qn /L*V "%TEMP%\VoiceStudio-user-install.log"
```
For the per-machine artifact, `DISABLEWEBVIEW2BOOTSTRAP=1` leaves detection
enabled but prevents every WebView2 PowerShell, download, and installer action.
For the per-user artifact that property is redundant because those actions are
omitted from the MSI. If the runtime is absent, either MSI fails before copying
VoiceStudio and tells the operator to deploy the
[Microsoft Evergreen Runtime](https://developer.microsoft.com/microsoft-edge/webview2/#download-section)
first. `/L*V` records detection and action selection in the named Windows
Installer log.
An interactive administrator may explicitly permit Microsoft's network
bootstrapper for the per-machine artifact by setting
`ALLOWWEBVIEW2BOOTSTRAP=1`. Only then does that MSI download
`https://go.microsoft.com/fwlink/p/?LinkId=2124703` with PowerShell and invoke
it silently with `/install`; `DISABLEWEBVIEW2BOOTSTRAP=1` always wins if both
properties are supplied. `ALLOWWEBVIEW2BOOTSTRAP` has no effect on the
per-user artifact.
`AUTOLAUNCHAPP=0` is independent: it prevents VoiceStudio from launching after
a successful silent install. It does not change WebView2 detection; on the
per-machine artifact it also does not disable an otherwise permitted setup.
## Portable install (Windows)
<a id="portable-install"></a>
+1 -1
View File
@@ -1,7 +1,7 @@
{
"_comment": "MCP client config for VoiceStudio. See docs/mcp.md for both connection modes.",
"_streamable_http": "If your MCP client speaks Streamable HTTP, point it directly at the running app: http://localhost:3900/mcp — no separate process needed (the server is mounted on the backend). Send an X-OmniVoice-Client-Id header to bind this agent to a specific voice.",
"_output_mode": "To keep audio out of the agent's context, set OMNIVOICE_MCP_OUTPUT_MODE=files and OMNIVOICE_MCP_BASE_PATH=<the agent's working directory> on the BACKEND's environment for the mounted endpoint (or on a standalone `python -m backend.mcp_server` entry's env). See docs/mcp.md, 'Output mode and file inputs'.",
"_output_mode": "To keep audio out of the agent's context, set OMNIVOICE_MCP_OUTPUT_MODE=files and OMNIVOICE_MCP_BASE_PATH=<a directory visible to both backend and agent> on the BACKEND's environment for the mounted endpoint (or on a standalone `python -m backend.mcp_server` entry's env). The agent's working directory is valid only when it is mounted into the backend at the same path. See docs/mcp.md, 'Output mode and file inputs'.",
"mcpServers": {
"omnivoice": {
"command": "python",
+11 -4
View File
@@ -30,12 +30,19 @@ or another tool by path:
| `OMNIVOICE_MCP_TIMEOUT_S` | seconds (default `120`) | How long a tool waits on the backend. CPU hosts render a paragraph in minutes and serialize generations, so an agent queued behind another render can outlast the default; raise it in step with `OMNIVOICE_GENERATE_TIMEOUT_S`. |
| `OMNIVOICE_MCP_BASE_PATH` | a directory | The **security boundary** for file-shaped traffic. `transcribe(audio_path=…)` and `clone_voice(ref_audio_path=…)` read only from inside it (relative paths resolve against it, absolute paths must already lie within it, symlinks are resolved before the check), and files mode writes only into it. With no base path configured, path arguments are refused with a reason. |
Input files are opened through confined, no-follow descriptors after path
validation, so replacing a checked file or parent directory cannot redirect a
read outside the base path.
Set them on the **backend's** environment for the mounted `/mcp` endpoint
(the launcher, a service file, Docker `-e`), or on the server entry's `env`
when running `python -m backend.mcp_server` standalone. A recommended agent
setup: `OMNIVOICE_MCP_OUTPUT_MODE=files` with the base path pointing at the
agent's own working directory — nothing large ever enters its context, and
every render is a file it can name.
when running `python -m backend.mcp_server` standalone. The base path must be
visible to both the backend and the agent. If they run in different containers
or filesystem namespaces, mount one shared directory at the same path in both;
`output_path` is reported in the backend's namespace. The agent's working
directory is suitable only when that shared mount exists. With this setup,
`OMNIVOICE_MCP_OUTPUT_MODE=files` keeps every render out of agent context while
still returning a path the agent can use.
## Connecting
+36 -3
View File
@@ -1049,14 +1049,23 @@ mod pill_placement_tests {
}
#[tauri::command]
pub fn mark_dictation_capture_ready(app: tauri::AppHandle) {
pub fn begin_dictation_capture_registration(app: tauri::AppHandle) -> Result<u64, String> {
let flags = app.state::<AppFlags>();
let mut capture = flags
.capture
.lock()
.map_err(|_| "Dictation capture state lock poisoned".to_string())?;
Ok(capture.begin_registration())
}
#[tauri::command]
pub fn mark_dictation_capture_ready(app: tauri::AppHandle, registration_id: u64) {
let flags = app.state::<AppFlags>();
let Ok(mut capture) = flags.capture.lock() else {
log::warn!("Dictation capture state lock poisoned");
return;
};
capture.ready = true;
let pending = std::mem::take(&mut capture.pending);
let pending = capture.mark_registration_ready(registration_id);
drop(capture);
for event in pending {
if let Err(error) = app.emit(event.name, event.payload) {
@@ -1068,6 +1077,30 @@ pub fn mark_dictation_capture_ready(app: tauri::AppHandle) {
}
}
#[tauri::command]
pub fn acknowledge_dictation_capture_delivery(
app: tauri::AppHandle,
registration_id: u64,
delivery_id: u64,
) {
let flags = app.state::<AppFlags>();
let Ok(mut capture) = flags.capture.lock() else {
log::warn!("Dictation capture state lock poisoned");
return;
};
capture.acknowledge(registration_id, delivery_id);
}
#[tauri::command]
pub fn end_dictation_capture_registration(app: tauri::AppHandle, registration_id: u64) {
let flags = app.state::<AppFlags>();
let Ok(mut capture) = flags.capture.lock() else {
log::warn!("Dictation capture state lock poisoned");
return;
};
capture.end_registration(registration_id);
}
#[tauri::command]
pub fn set_dictation_shortcut(
app: tauri::AppHandle,
+125 -11
View File
@@ -103,6 +103,9 @@ pub struct AppFlags {
pub struct CaptureDispatchState {
pub(crate) ready: bool,
pub(crate) pending: VecDeque<CaptureEvent>,
registration_counter: u64,
delivery_counter: u64,
active_registration: Option<u64>,
}
impl Default for CaptureDispatchState {
@@ -110,6 +113,65 @@ impl Default for CaptureDispatchState {
Self {
ready: false,
pending: VecDeque::new(),
registration_counter: 0,
delivery_counter: 0,
active_registration: None,
}
}
}
impl CaptureDispatchState {
pub(crate) fn begin_registration(&mut self) -> u64 {
self.registration_counter = self.registration_counter.wrapping_add(1).max(1);
self.active_registration = Some(self.registration_counter);
self.ready = false;
self.registration_counter
}
pub(crate) fn mark_registration_ready(
&mut self,
registration_id: u64,
) -> VecDeque<CaptureEvent> {
if self.active_registration != Some(registration_id) {
return VecDeque::new();
}
self.ready = true;
self.pending
.iter()
.cloned()
.map(|mut event| {
event.payload.registration_id = registration_id;
event
})
.collect()
}
pub(crate) fn enqueue(&mut self, mut event: CaptureEvent) -> Option<CaptureEvent> {
self.delivery_counter = self.delivery_counter.wrapping_add(1).max(1);
event.payload.delivery_id = self.delivery_counter;
self.pending.push_back(event.clone());
let registration_id = self.active_registration.filter(|_| self.ready)?;
event.payload.registration_id = registration_id;
Some(event)
}
pub(crate) fn acknowledge(&mut self, registration_id: u64, delivery_id: u64) {
if self.active_registration != Some(registration_id) {
return;
}
if let Some(index) = self
.pending
.iter()
.position(|event| event.payload.delivery_id == delivery_id)
{
self.pending.remove(index);
}
}
pub(crate) fn end_registration(&mut self, registration_id: u64) {
if self.active_registration == Some(registration_id) {
self.active_registration = None;
self.ready = false;
}
}
}
@@ -118,8 +180,11 @@ impl Default for CaptureDispatchState {
#[serde(rename_all = "camelCase")]
pub(crate) struct DictationCapturePayload {
pub(crate) session_id: u64,
pub(crate) delivery_id: u64,
pub(crate) registration_id: u64,
}
#[derive(Clone)]
pub(crate) struct CaptureEvent {
pub(crate) name: &'static str,
pub(crate) payload: DictationCapturePayload,
@@ -155,13 +220,18 @@ fn dispatch_dictation_capture_from(app: &tauri::AppHandle, action: &str, origin:
};
let capture_event = CaptureEvent {
name: event,
payload: DictationCapturePayload { session_id },
payload: DictationCapturePayload {
session_id,
delivery_id: 0,
registration_id: 0,
},
};
let Ok(mut capture) = flags.capture.lock() else {
log::warn!("Dictation capture state lock poisoned");
return;
};
if capture.ready {
if let Some(capture_event) = capture.enqueue(capture_event) {
drop(capture);
// A press that reaches Rust but produces no recording is otherwise
// indistinguishable from one the compositor never delivered, so say
// which side of the handshake the press left on.
@@ -174,7 +244,6 @@ fn dispatch_dictation_capture_from(app: &tauri::AppHandle, action: &str, origin:
log::warn!(
"Dictation capture '{action}' queued — the capture window has not registered yet"
);
capture.pending.push_back(capture_event);
}
}
@@ -184,6 +253,17 @@ mod dictation_capture_tests {
dictation_capture_event, CaptureDispatchState, CaptureEvent, DictationCapturePayload,
};
fn capture_event(name: &'static str) -> CaptureEvent {
CaptureEvent {
name,
payload: DictationCapturePayload {
session_id: 7,
delivery_id: 0,
registration_id: 0,
},
}
}
#[test]
fn toggle_starts_when_idle_and_stops_when_recording() {
assert_eq!(dictation_capture_event("toggle", false), "tray-dictate");
@@ -193,17 +273,48 @@ mod dictation_capture_tests {
#[test]
fn readiness_queue_preserves_press_then_release() {
let mut state = CaptureDispatchState::default();
state.pending.push_back(CaptureEvent {
name: "tray-dictate",
payload: DictationCapturePayload { session_id: 7 },
});
state.pending.push_back(CaptureEvent {
name: "tray-dictate-stop",
payload: DictationCapturePayload { session_id: 7 },
});
state.enqueue(capture_event("tray-dictate"));
state.enqueue(capture_event("tray-dictate-stop"));
let names: Vec<_> = state.pending.into_iter().map(|event| event.name).collect();
assert_eq!(names, ["tray-dictate", "tray-dictate-stop"]);
}
#[test]
fn unacknowledged_delivery_survives_listener_replacement() {
let mut state = CaptureDispatchState::default();
state.enqueue(capture_event("tray-dictate"));
let stale = state.begin_registration();
let first_delivery = state.mark_registration_ready(stale);
let delivery_id = first_delivery[0].payload.delivery_id;
state.end_registration(stale);
let current = state.begin_registration();
let retried = state.mark_registration_ready(current);
assert_eq!(retried.len(), 1);
assert_eq!(retried[0].payload.delivery_id, delivery_id);
assert_eq!(retried[0].payload.registration_id, current);
state.acknowledge(stale, delivery_id);
assert_eq!(state.pending.len(), 1);
state.acknowledge(current, delivery_id);
assert!(state.pending.is_empty());
}
#[test]
fn stale_listener_cannot_claim_or_clear_a_newer_registration() {
let mut state = CaptureDispatchState::default();
let stale = state.begin_registration();
let current = state.begin_registration();
assert!(state.mark_registration_ready(stale).is_empty());
assert!(!state.ready);
state.mark_registration_ready(current);
assert!(state.ready);
state.end_registration(stale);
assert!(state.ready);
state.end_registration(current);
assert!(!state.ready);
}
}
pub const TRAY_ICON_DEFAULT: &[u8] = include_bytes!("../icons/32x32.png");
@@ -612,7 +723,10 @@ pub fn run() {
commands::get_effective_dictation_shortcut,
commands::set_dictation_shortcut,
commands::request_dictation_capture,
commands::begin_dictation_capture_registration,
commands::mark_dictation_capture_ready,
commands::acknowledge_dictation_capture_delivery,
commands::end_dictation_capture_registration,
commands::show_dictation_pill,
commands::get_launch_as_widget,
commands::set_launch_as_widget,
+55 -13
View File
@@ -22,6 +22,22 @@ const STABLE_MANIFEST: &str =
"https://github.com/debpalash/VoiceStudio/releases/latest/download/latest.json";
const PREVIEW_MANIFEST: &str =
"https://github.com/debpalash/VoiceStudio/releases/download/preview/latest.json";
const STABLE_PER_USER_MANIFEST: &str =
"https://github.com/debpalash/VoiceStudio/releases/latest/download/latest-user.json";
const PREVIEW_PER_USER_MANIFEST: &str =
"https://github.com/debpalash/VoiceStudio/releases/download/preview/latest-user.json";
fn is_per_user_bundle(app: &AppHandle) -> bool {
app.package_info().name.ends_with("(Current User)")
}
fn scoped_manifests(per_user: bool) -> (&'static str, &'static str) {
if per_user {
(STABLE_PER_USER_MANIFEST, PREVIEW_PER_USER_MANIFEST)
} else {
(STABLE_MANIFEST, PREVIEW_MANIFEST)
}
}
/// Cross-channel ordering of VoiceStudio build versions (#326).
///
@@ -101,8 +117,9 @@ fn newest_of(a: Update, b: Update) -> Update {
/// A manifest fetch error is non-fatal as long as the other manifest answers;
/// an error is returned only when every manifest fails.
async fn best_update(app: &AppHandle, channel: &str) -> Result<Option<Update>, String> {
let (stable_manifest, preview_manifest) = scoped_manifests(is_per_user_bundle(app));
if channel != "preview" {
return build_updater(app, STABLE_MANIFEST, false)?
return build_updater(app, stable_manifest, false)?
.check()
.await
.map_err(|e| e.to_string());
@@ -111,7 +128,7 @@ async fn best_update(app: &AppHandle, channel: &str) -> Result<Option<Update>, S
let mut best: Option<Update> = None;
let mut any_ok = false;
let mut first_err: Option<String> = None;
for manifest in [PREVIEW_MANIFEST, STABLE_MANIFEST] {
for manifest in [preview_manifest, stable_manifest] {
match build_updater(app, manifest, true)?.check().await {
Ok(candidate) => {
any_ok = true;
@@ -133,6 +150,18 @@ async fn best_update(app: &AppHandle, channel: &str) -> Result<Option<Update>, S
Ok(best)
}
#[cfg(test)]
mod installer_scope_tests {
use super::{scoped_manifests, STABLE_MANIFEST, STABLE_PER_USER_MANIFEST};
#[test]
fn installer_scopes_never_share_an_update_manifest() {
assert_eq!(scoped_manifests(false).0, STABLE_MANIFEST);
assert_eq!(scoped_manifests(true).0, STABLE_PER_USER_MANIFEST);
assert_ne!(scoped_manifests(false), scoped_manifests(true));
}
}
#[derive(Serialize, Clone)]
pub struct UpdateMeta {
pub version: String,
@@ -149,10 +178,7 @@ struct ProgressPayload {
/// Non-blocking availability check for the given channel. Returns the update
/// metadata when a newer build exists, or `None` when already up to date.
#[tauri::command]
pub async fn check_update(
app: AppHandle,
channel: String,
) -> Result<Option<UpdateMeta>, String> {
pub async fn check_update(app: AppHandle, channel: String) -> Result<Option<UpdateMeta>, String> {
Ok(best_update(&app, &channel).await?.map(|u| UpdateMeta {
version: u.version.clone(),
current_version: u.current_version.clone(),
@@ -176,8 +202,8 @@ pub async fn install_update(app: AppHandle, channel: String) -> Result<(), Strin
.download_and_install(
move |chunk, total| {
downloaded += chunk;
let _ = app_for_chunk
.emit("update://progress", ProgressPayload { downloaded, total });
let _ =
app_for_chunk.emit("update://progress", ProgressPayload { downloaded, total });
},
|| {},
)
@@ -242,8 +268,15 @@ pub async fn list_releases(_channel: String) -> Result<Vec<ReleaseInfo>, String>
.chars()
.take(10)
.collect(),
prerelease: it.get("prerelease").and_then(|v| v.as_bool()).unwrap_or(false),
notes: it.get("body").and_then(|v| v.as_str()).unwrap_or("").to_string(),
prerelease: it
.get("prerelease")
.and_then(|v| v.as_bool())
.unwrap_or(false),
notes: it
.get("body")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
});
}
}
@@ -295,8 +328,14 @@ mod tests {
fn newer_preview_builds_are_offered_numerically() {
assert!(remote_is_newer(&v("0.3.5-42"), &v("0.3.5-41")));
assert!(!remote_is_newer(&v("0.3.5-9"), &v("0.3.5-41")));
assert!(remote_is_newer(&v("0.3.0-preview.5"), &v("0.3.0-preview.4")));
assert!(!remote_is_newer(&v("0.3.0-preview.4"), &v("0.3.0-preview.5")));
assert!(remote_is_newer(
&v("0.3.0-preview.5"),
&v("0.3.0-preview.4")
));
assert!(!remote_is_newer(
&v("0.3.0-preview.4"),
&v("0.3.0-preview.5")
));
}
/// Strict ordering: the exact same build (either channel) is never
@@ -305,7 +344,10 @@ mod tests {
fn equal_versions_are_not_offered() {
assert!(!remote_is_newer(&v("0.3.5"), &v("0.3.5")));
assert!(!remote_is_newer(&v("0.3.5-41"), &v("0.3.5-41")));
assert_eq!(cross_channel_cmp(&v("0.3.5-41"), &v("0.3.5-41")), Ordering::Equal);
assert_eq!(
cross_channel_cmp(&v("0.3.5-41"), &v("0.3.5-41")),
Ordering::Equal
);
}
/// The base version always dominates the suffix.
+9
View File
@@ -99,6 +99,15 @@
"minimumSystemVersion": "13.3",
"signingIdentity": "-",
"entitlements": "entitlements.plist"
},
"windows": {
"webviewInstallMode": {
"type": "downloadBootstrapper",
"silent": true
},
"wix": {
"template": "wix/main.wxs"
}
}
},
"plugins": {
@@ -0,0 +1,15 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "VoiceStudio (Current User)",
"bundle": {
"targets": ["msi"],
"createUpdaterArtifacts": true,
"windows": {
"wix": {
"upgradeCode": "f27de3a8-a9dc-4a3d-84bb-e98f1bf82393",
"template": "target/wix-per-user/main.wxs",
"enableElevatedUpdateTask": false
}
}
}
}
+392
View File
@@ -0,0 +1,392 @@
<?if $(sys.BUILDARCH)="x86"?>
<?define Win64 = "no" ?>
<?define PlatformProgramFilesFolder = "ProgramFilesFolder" ?>
<?elseif $(sys.BUILDARCH)="x64"?>
<?define Win64 = "yes" ?>
<?define PlatformProgramFilesFolder = "ProgramFiles64Folder" ?>
<?elseif $(sys.BUILDARCH)="arm64"?>
<?define Win64 = "yes" ?>
<?define PlatformProgramFilesFolder = "ProgramFiles64Folder" ?>
<?else?>
<?error Unsupported value of sys.BUILDARCH=$(sys.BUILDARCH)?>
<?endif?>
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">
<Product
Id="*"
Name="{{product_name}}"
UpgradeCode="{{upgrade_code}}"
Language="!(loc.TauriLanguage)"
Manufacturer="{{manufacturer}}"
Version="{{version}}">
<Package Id="*"
Keywords="Installer"
InstallerVersion="450"
Languages="0"
Compressed="yes"
InstallScope="perMachine"
SummaryCodepage="!(loc.TauriCodepage)"/>
<!-- https://docs.microsoft.com/en-us/windows/win32/msi/reinstallmode -->
<!-- reinstall all files; rewrite all registry entries; reinstall all shortcuts -->
<Property Id="REINSTALLMODE" Value="amus" />
<!-- Auto launch app after installation, useful for passive mode which usually used in updates -->
<Property Id="AUTOLAUNCHAPP" Secure="yes" />
<!-- Managed-deployment switches. Explicit allow is required for network bootstrap. -->
<Property Id="ALLOWWEBVIEW2BOOTSTRAP" Secure="yes" />
<Property Id="DISABLEWEBVIEW2BOOTSTRAP" Secure="yes" />
<!-- Property to forward cli args to the launched app to not lose those of the pre-update instance -->
<Property Id="LAUNCHAPPARGS" Secure="yes" />
{{#if allow_downgrades}}
<MajorUpgrade Schedule="afterInstallInitialize" AllowDowngrades="yes" />
{{else}}
<MajorUpgrade Schedule="afterInstallInitialize" DowngradeErrorMessage="!(loc.DowngradeErrorMessage)" AllowSameVersionUpgrades="yes" />
{{/if}}
<InstallExecuteSequence>
<RemoveShortcuts>Installed AND NOT UPGRADINGPRODUCTCODE</RemoveShortcuts>
</InstallExecuteSequence>
<Media Id="1" Cabinet="app.cab" EmbedCab="yes" />
{{#if banner_path}}
<WixVariable Id="WixUIBannerBmp" Value="{{banner_path}}" />
{{/if}}
{{#if dialog_image_path}}
<WixVariable Id="WixUIDialogBmp" Value="{{dialog_image_path}}" />
{{/if}}
{{#if license}}
<WixVariable Id="WixUILicenseRtf" Value="{{license}}" />
{{/if}}
<Icon Id="ProductIcon" SourceFile="{{icon_path}}"/>
<Property Id="ARPPRODUCTICON" Value="ProductIcon" />
<Property Id="ARPNOREPAIR" Value="yes" Secure="yes" /> <!-- Remove repair -->
<SetProperty Id="ARPNOMODIFY" Value="1" After="InstallValidate" Sequence="execute"/>
{{#if homepage}}
<Property Id="ARPURLINFOABOUT" Value="{{homepage}}"/>
<Property Id="ARPHELPLINK" Value="{{homepage}}"/>
<Property Id="ARPURLUPDATEINFO" Value="{{homepage}}"/>
{{/if}}
<!-- NOTE: The order of RegistrySearch elements below matters. In WIX, when multiple
RegistrySearch elements are listed under a single Property, the LAST successful
match wins. We list the NSIS default-key search first and the MSI InstallDir
search second so that the MSI-specific path takes priority when both keys exist. -->
<Property Id="INSTALLDIR">
<!-- First attempt: Search for the default key value (this is how the nsis installer stores the path) -->
<RegistrySearch Id="PrevInstallDirNoName" Root="HKLM" Key="Software\\{{manufacturer}}\\{{product_name}}" Type="raw" />
<!-- Second attempt: Search for "InstallDir" which takes priority if found (this is how the msi installer stores the path) -->
<RegistrySearch Id="PrevInstallDirWithName" Root="HKLM" Key="Software\\{{manufacturer}}\\{{product_name}}" Name="InstallDir" Type="raw" />
</Property>
<!-- launch app checkbox -->
<Property Id="WIXUI_EXITDIALOGOPTIONALCHECKBOXTEXT" Value="!(loc.LaunchApp)" />
<Property Id="WIXUI_EXITDIALOGOPTIONALCHECKBOX" Value="1"/>
<CustomAction Id="LaunchApplication" Impersonate="yes" FileKey="Path" ExeCommand="[LAUNCHAPPARGS]" Return="asyncNoWait" />
<UI>
<!-- launch app checkbox -->
<Publish Dialog="ExitDialog" Control="Finish" Event="DoAction" Value="LaunchApplication">WIXUI_EXITDIALOGOPTIONALCHECKBOX = 1 AND NOT AUTOLAUNCHAPP AND NOT Installed</Publish>
<Property Id="WIXUI_INSTALLDIR" Value="INSTALLDIR" />
{{#unless license}}
<!-- Skip license dialog -->
<Publish Dialog="WelcomeDlg"
Control="Next"
Event="NewDialog"
Value="InstallDirDlg"
Order="2">1</Publish>
<Publish Dialog="InstallDirDlg"
Control="Back"
Event="NewDialog"
Value="WelcomeDlg"
Order="2">1</Publish>
{{/unless}}
</UI>
<UIRef Id="WixUI_InstallDir" />
<Directory Id="TARGETDIR" Name="SourceDir">
<Directory Id="DesktopFolder" Name="Desktop">
<Component Id="ApplicationShortcutDesktop" Guid="*">
<Shortcut Id="ApplicationDesktopShortcut" Name="{{product_name}}" Description="Runs {{product_name}}" Target="[!Path]" WorkingDirectory="INSTALLDIR" />
<RemoveFolder Id="DesktopFolder" On="uninstall" />
<RegistryValue Root="HKCU" Key="Software\\{{manufacturer}}\\{{product_name}}" Name="Desktop Shortcut" Type="integer" Value="1" KeyPath="yes" />
</Component>
</Directory>
<Directory Id="$(var.PlatformProgramFilesFolder)" Name="PFiles">
<Directory Id="INSTALLDIR" Name="{{product_name}}"/>
</Directory>
<Directory Id="ProgramMenuFolder">
<Directory Id="ApplicationProgramsFolder" Name="{{product_name}}"/>
</Directory>
</Directory>
<DirectoryRef Id="INSTALLDIR">
<Component Id="RegistryEntries" Guid="*">
<RegistryKey Root="HKLM" Key="Software\\{{manufacturer}}\\{{product_name}}">
<RegistryValue Name="InstallDir" Type="string" Value="[INSTALLDIR]" KeyPath="yes" />
<RegistryValue Name="InstallScope" Type="string" Value="perMachine" />
</RegistryKey>
<!-- Change the Root to HKCU for perUser installations -->
{{#each deep_link_protocols as |protocol| ~}}
<RegistryKey Root="HKLM" Key="Software\Classes\\{{protocol}}">
<RegistryValue Type="string" Name="URL Protocol" Value=""/>
<RegistryValue Type="string" Value="URL:{{bundle_id}} protocol"/>
<RegistryKey Key="DefaultIcon">
<RegistryValue Type="string" Value="&quot;[!Path]&quot;,0" />
</RegistryKey>
<RegistryKey Key="shell\open\command">
<RegistryValue Type="string" Value="&quot;[!Path]&quot; &quot;%1&quot;" />
</RegistryKey>
</RegistryKey>
{{/each~}}
</Component>
<Component Id="Path" Guid="{{path_component_guid}}" Win64="$(var.Win64)">
<File Id="Path" Source="{{main_binary_path}}" KeyPath="yes" Checksum="yes"/>
{{#each file_associations as |association| ~}}
{{#each association.ext as |ext| ~}}
<ProgId Id="{{../../product_name}}.{{ext}}" Advertise="yes" Description="{{association.description}}">
<Extension Id="{{ext}}" Advertise="yes">
<Verb Id="open" Command="Open with {{../../product_name}}" Argument="&quot;%1&quot;" />
</Extension>
</ProgId>
{{/each~}}
{{/each~}}
</Component>
{{#each binaries as |bin| ~}}
<Component Id="{{ bin.id }}" Guid="{{bin.guid}}" Win64="$(var.Win64)">
<File Id="Bin_{{ bin.id }}" Source="{{bin.path}}" KeyPath="yes"/>
</Component>
{{/each~}}
{{#if enable_elevated_update_task}}
<Component Id="UpdateTask" Guid="C492327D-9720-4CD5-8DB8-F09082AF44BE" Win64="$(var.Win64)">
<File Id="UpdateTask" Source="update.xml" KeyPath="yes" Checksum="yes"/>
</Component>
<Component Id="UpdateTaskInstaller" Guid="011F25ED-9BE3-50A7-9E9B-3519ED2B9932" Win64="$(var.Win64)">
<File Id="UpdateTaskInstaller" Source="install-task.ps1" KeyPath="yes" Checksum="yes"/>
</Component>
<Component Id="UpdateTaskUninstaller" Guid="D4F6CC3F-32DC-5FD0-95E8-782FFD7BBCE1" Win64="$(var.Win64)">
<File Id="UpdateTaskUninstaller" Source="uninstall-task.ps1" KeyPath="yes" Checksum="yes"/>
</Component>
{{/if}}
{{resources}}
<Component Id="CMP_UninstallShortcut" Guid="*">
<Shortcut Id="UninstallShortcut"
Name="Uninstall {{product_name}}"
Description="Uninstalls {{product_name}}"
Target="[System64Folder]msiexec.exe"
Arguments="/x [ProductCode]" />
<RemoveFolder Id="INSTALLDIR"
On="uninstall" />
<RegistryValue Root="HKCU"
Key="Software\\{{manufacturer}}\\{{product_name}}"
Name="Uninstaller Shortcut"
Type="integer"
Value="1"
KeyPath="yes" />
</Component>
</DirectoryRef>
<DirectoryRef Id="ApplicationProgramsFolder">
<Component Id="ApplicationShortcut" Guid="*">
<Shortcut Id="ApplicationStartMenuShortcut"
Name="{{product_name}}"
Description="Runs {{product_name}}"
Target="[!Path]"
Icon="ProductIcon"
WorkingDirectory="INSTALLDIR">
<ShortcutProperty Key="System.AppUserModel.ID" Value="{{bundle_id}}"/>
</Shortcut>
<RemoveFolder Id="ApplicationProgramsFolder" On="uninstall"/>
<RegistryValue Root="HKCU" Key="Software\\{{manufacturer}}\\{{product_name}}" Name="Start Menu Shortcut" Type="integer" Value="1" KeyPath="yes"/>
</Component>
</DirectoryRef>
{{#each merge_modules as |msm| ~}}
<DirectoryRef Id="TARGETDIR">
<Merge Id="{{ msm.name }}" SourceFile="{{ msm.path }}" DiskId="1" Language="!(loc.TauriLanguage)" />
</DirectoryRef>
<Feature Id="{{ msm.name }}" Title="{{ msm.name }}" AllowAdvertise="no" Display="hidden" Level="1">
<MergeRef Id="{{ msm.name }}"/>
</Feature>
{{/each~}}
<Feature
Id="MainProgram"
Title="Application"
Description="!(loc.InstallAppFeature)"
Level="1"
ConfigurableDirectory="INSTALLDIR"
AllowAdvertise="no"
Display="expand"
Absent="disallow">
<ComponentRef Id="RegistryEntries"/>
{{#each resource_file_ids as |resource_file_id| ~}}
<ComponentRef Id="{{ resource_file_id }}"/>
{{/each~}}
{{#if enable_elevated_update_task}}
<ComponentRef Id="UpdateTask" />
<ComponentRef Id="UpdateTaskInstaller" />
<ComponentRef Id="UpdateTaskUninstaller" />
{{/if}}
<Feature Id="ShortcutsFeature"
Title="Shortcuts"
Level="1">
<ComponentRef Id="Path"/>
<ComponentRef Id="CMP_UninstallShortcut" />
<ComponentRef Id="ApplicationShortcut" />
<ComponentRef Id="ApplicationShortcutDesktop" />
</Feature>
<Feature
Id="Environment"
Title="PATH Environment Variable"
Description="!(loc.PathEnvVarFeature)"
Level="1"
Absent="allow">
<ComponentRef Id="Path"/>
{{#each binaries as |bin| ~}}
<ComponentRef Id="{{ bin.id }}"/>
{{/each~}}
</Feature>
</Feature>
<Feature Id="External" AllowAdvertise="no" Absent="disallow">
{{#each component_group_refs as |id| ~}}
<ComponentGroupRef Id="{{ id }}"/>
{{/each~}}
{{#each component_refs as |id| ~}}
<ComponentRef Id="{{ id }}"/>
{{/each~}}
{{#each feature_group_refs as |id| ~}}
<FeatureGroupRef Id="{{ id }}"/>
{{/each~}}
{{#each feature_refs as |id| ~}}
<FeatureRef Id="{{ id }}"/>
{{/each~}}
{{#each merge_refs as |id| ~}}
<MergeRef Id="{{ id }}"/>
{{/each~}}
</Feature>
{{#if install_webview}}
<!-- WebView2 -->
<!-- Pinned from tauri-cli 2.11.4; local changes are guarded by tests. -->
<Property Id="INSTALLED_WEBVIEW2_VERSION">
<RegistrySearch Id="Webview2VersionSystemx64" Root="HKLM" Key="SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" Name="pv" Type="raw" />
<RegistrySearch Id="Webview2VersionSystemx86" Root="HKLM" Key="SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" Name="pv" Type="raw" />
<RegistrySearch Id="Webview2VersionUser" Root="HKCU" Key="SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" Name="pv" Type="raw"/>
</Property>
<Condition Message="Microsoft Edge WebView2 Runtime is required. Install the Evergreen Standalone Runtime first, or explicitly set ALLOWWEBVIEW2BOOTSTRAP=1."><![CDATA[Installed OR REMOVE OR INSTALLED_WEBVIEW2_VERSION OR (ALLOWWEBVIEW2BOOTSTRAP = "1" AND DISABLEWEBVIEW2BOOTSTRAP <> "1")]]></Condition>
<!-- BEGIN WEBVIEW_INSTALL_ACTIONS -->
{{#if download_bootstrapper}}
<!-- Download webview bootstrapper mode -->
<CustomAction Id='DownloadAndInvokeBootstrapper' Directory="INSTALLDIR" Execute="deferred" ExeCommand='powershell.exe -NoProfile -windowstyle hidden try [\{] [\[]Net.ServicePointManager[\]]::SecurityProtocol = [\[]Net.SecurityProtocolType[\]]::Tls12 [\}] catch [\{][\}]; Invoke-WebRequest -Uri "https://go.microsoft.com/fwlink/p/?LinkId=2124703" -OutFile "$env:TEMP\MicrosoftEdgeWebview2Setup.exe" ; Start-Process -FilePath "$env:TEMP\MicrosoftEdgeWebview2Setup.exe" -ArgumentList ({{webview_installer_args}} &apos;/install&apos;) -Wait' Return='check'/>
<InstallExecuteSequence>
<Custom Action='DownloadAndInvokeBootstrapper' Before='InstallFinalize'>
<![CDATA[NOT(REMOVE OR INSTALLED_WEBVIEW2_VERSION) AND ALLOWWEBVIEW2BOOTSTRAP = "1" AND DISABLEWEBVIEW2BOOTSTRAP <> "1"]]>
</Custom>
</InstallExecuteSequence>
{{/if}}
{{#if webview2_bootstrapper_path}}
<!-- Embedded webview bootstrapper mode -->
<Binary Id="MicrosoftEdgeWebview2Setup.exe" SourceFile="{{webview2_bootstrapper_path}}"/>
<CustomAction Id='InvokeBootstrapper' BinaryKey='MicrosoftEdgeWebview2Setup.exe' Execute="deferred" ExeCommand='{{webview_installer_args}} /install' Return='check' />
<InstallExecuteSequence>
<Custom Action='InvokeBootstrapper' Before='InstallFinalize'>
<![CDATA[NOT(REMOVE OR INSTALLED_WEBVIEW2_VERSION) AND ALLOWWEBVIEW2BOOTSTRAP = "1" AND DISABLEWEBVIEW2BOOTSTRAP <> "1"]]>
</Custom>
</InstallExecuteSequence>
{{/if}}
{{#if webview2_installer_path}}
<!-- Embedded offline installer -->
<Binary Id="MicrosoftEdgeWebView2RuntimeInstaller.exe" SourceFile="{{webview2_installer_path}}"/>
<CustomAction Id='InvokeStandalone' BinaryKey='MicrosoftEdgeWebView2RuntimeInstaller.exe' Execute="deferred" ExeCommand='{{webview_installer_args}} /install' Return='check' />
<InstallExecuteSequence>
<Custom Action='InvokeStandalone' Before='InstallFinalize'>
<![CDATA[NOT(REMOVE OR INSTALLED_WEBVIEW2_VERSION) AND ALLOWWEBVIEW2BOOTSTRAP = "1" AND DISABLEWEBVIEW2BOOTSTRAP <> "1"]]>
</Custom>
</InstallExecuteSequence>
{{/if}}
{{#if minimum_webview2_version}}
<!-- Update WebView2 if minimum version requirement not met -->
<Property Id="MINIMUM_WEBVIEW2_VERSION" Value="{{minimum_webview2_version}}" />
<Property Id="EDGEUPDATE_PATH">
<RegistrySearch Id="EdgeUpdateLocalMachine64" Root="HKLM" Key="SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate" Name="path" Type="raw" />
<RegistrySearch Id="EdgeUpdateLocalMachine32" Root="HKLM" Key="SOFTWARE\Microsoft\EdgeUpdate" Name="path" Type="raw"/>
<RegistrySearch Id="EdgeUpdateCurrentUser" Root="HKCU" Key="SOFTWARE\Microsoft\EdgeUpdate" Name="path" Type="raw"/>
</Property>
<!-- Chromium updater docs: https://source.chromium.org/chromium/chromium/src/+/main:docs/updater/user_manual.md -->
<!-- Modified from "HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\Microsoft EdgeWebView\ModifyPath" -->
<CustomAction Id="UpdateWebView2ViaEdgeUpdate" Execute="deferred" Property="EDGEUPDATE_PATH" Return="check" Impersonate="no" ExeCommand="/install appguid={F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}&amp;needsadmin=true" />
<InstallExecuteSequence>
<Custom Action='UpdateWebView2ViaEdgeUpdate' Before='InstallFinalize'>
<![CDATA[
NOT REMOVE
AND ALLOWWEBVIEW2BOOTSTRAP = "1"
AND DISABLEWEBVIEW2BOOTSTRAP <> "1"
AND INSTALLED_WEBVIEW2_VERSION
AND (INSTALLED_WEBVIEW2_VERSION < MINIMUM_WEBVIEW2_VERSION)
]]>
</Custom>
</InstallExecuteSequence>
{{/if}}
<!-- END WEBVIEW_INSTALL_ACTIONS -->
{{/if}}
{{#if enable_elevated_update_task}}
<!-- Install an elevated update task within Windows Task Scheduler -->
<CustomAction
Id="CreateUpdateTask"
Return="check"
Directory="INSTALLDIR"
Execute="commit"
Impersonate="yes"
ExeCommand="powershell.exe -WindowStyle hidden .\install-task.ps1" />
<InstallExecuteSequence>
<Custom Action='CreateUpdateTask' Before='InstallFinalize'>
NOT(REMOVE)
</Custom>
</InstallExecuteSequence>
<!-- Remove elevated update task during uninstall -->
<CustomAction
Id="DeleteUpdateTask"
Return="check"
Directory="INSTALLDIR"
ExeCommand="powershell.exe -WindowStyle hidden .\uninstall-task.ps1" />
<InstallExecuteSequence>
<Custom Action="DeleteUpdateTask" Before='InstallFinalize'>
(REMOVE = "ALL") AND NOT UPGRADINGPRODUCTCODE
</Custom>
</InstallExecuteSequence>
{{/if}}
<InstallExecuteSequence>
<Custom Action="LaunchApplication" After="InstallFinalize">AUTOLAUNCHAPP AND AUTOLAUNCHAPP &lt;&gt; "0" AND NOT Installed</Custom>
</InstallExecuteSequence>
<SetProperty Id="ARPINSTALLLOCATION" Value="[INSTALLDIR]" After="CostFinalize"/>
</Product>
</Wix>
+2 -1
View File
@@ -117,10 +117,11 @@ export interface DubImportSrtResponse {
export async function dubImportSrt(
jobId: string,
file: File | Blob,
{ signal }: { signal?: AbortSignal } = {},
): Promise<DubImportSrtResponse> {
const fd = new FormData();
fd.append('file', file);
return apiPost<DubImportSrtResponse>(`/dub/import-srt/${jobId}`, fd);
return apiPost<DubImportSrtResponse>(`/dub/import-srt/${jobId}`, fd, { signal });
}
export interface ParsedSubtitleCue {
+5
View File
@@ -2,6 +2,7 @@ import { apiJson, apiPost } from './client';
import type {
AllEnginesResponse,
EngineFamily,
EngineDiskUsage,
EngineHealthResponse,
EngineSelfTestResponse,
SelectEngineResponse,
@@ -72,6 +73,10 @@ export async function getEngineHealth(engineId: string): Promise<EngineHealthRes
return apiJson<EngineHealthResponse>(`/engines/${encodeURIComponent(engineId)}/health`);
}
export async function getEngineDiskUsage(engineId: string): Promise<EngineDiskUsage> {
return apiJson<EngineDiskUsage>(`/engines/${encodeURIComponent(engineId)}/disk-usage`);
}
/**
* Run a bounded, real tiny-synthesis on an AVAILABLE, IN-PROCESS TTS engine
* proves the engine actually emits audio (duration + sample-rate + samples),
+26
View File
@@ -66,6 +66,32 @@ export interface EngineBackend {
// Settings can render a model picker. Absent on every other backend.
curated_models?: CuratedModel[];
active_model_id?: string;
disk_usage?: EngineDiskUsage;
}
export interface EngineDiskEstimate {
model_download_bytes: number | null;
package_download_bytes: number | null;
unique_installed_bytes: number | null;
potentially_shared_bytes: number | null;
temporary_free_bytes: number | null;
confidence: 'exact' | 'measured' | 'estimated' | 'unknown';
destination: string;
destination_volume: string;
deduplication: string | null;
}
export interface EngineDiskActual {
model_bytes: number | null;
environment_bytes: number | null;
cache_bytes: number | null;
total_owned_bytes: number | null;
confidence: 'measured' | 'unknown';
}
export interface EngineDiskUsage {
estimate: EngineDiskEstimate;
actual: EngineDiskActual;
}
// #981 — one of mlx-audio's curated models (see backend
+6 -1
View File
@@ -938,7 +938,12 @@ export function useBootstrapStage(pollMs = 1000) {
// person can leave.
const stallBudgetMs = (stage) => {
if (stage === 'awaiting_setup') return Infinity;
return stage === 'installing_deps' ? 20 * 60 * 1000 : 120 * 1000;
if (stage === 'installing_deps') return 20 * 60 * 1000;
// Rust owns the backend launch and waits up to five minutes so slow
// torch/CUDA imports can finish. Keep the splash alive beyond that
// window; otherwise it reports a false failure at two minutes while
// the supervised backend is still healthy and making progress (#1749).
return stage === 'starting_backend' ? 6 * 60 * 1000 : 120 * 1000;
};
const invoke = async () => {
try {
+50 -7
View File
@@ -683,11 +683,51 @@ export default function CaptureWidget({ onDismiss }) {
useEffect(() => {
if (!inTauri()) return; // browser webui the keyboard fallback below runs
let unlistenStart, unlistenStop;
let registrationId;
let endedRegistrationId;
let cancelled = false;
const teardownRegistration = async () => {
const stopStart = unlistenStart;
const stopStop = unlistenStop;
unlistenStart = undefined;
unlistenStop = undefined;
try {
stopStart?.();
} catch (err) {
console.warn('tray-dictate unlisten failed:', err);
}
try {
stopStop?.();
} catch (err) {
console.warn('tray-dictate-stop unlisten failed:', err);
}
if (registrationId && endedRegistrationId !== registrationId) {
endedRegistrationId = registrationId;
await tauriInvoke('end_dictation_capture_registration', { registrationId });
}
};
const acknowledgeDelivery = (event) => {
const deliveryId = event?.payload?.deliveryId;
const eventRegistrationId = event?.payload?.registrationId;
if (eventRegistrationId != null && eventRegistrationId !== registrationId) return false;
if (deliveryId != null) {
void tauriInvoke('acknowledge_dictation_capture_delivery', {
registrationId,
deliveryId,
}).catch((err) => console.warn('dictation delivery acknowledgement failed:', err));
}
return true;
};
(async () => {
try {
registrationId = await tauriInvoke('begin_dictation_capture_registration');
if (cancelled) {
await teardownRegistration();
return;
}
const { listen } = await import('@tauri-apps/api/event');
unlistenStart = await listen('tray-dictate', async (event) => {
if (!acknowledgeDelivery(event)) return;
const now = Date.now();
if (now - nativeEventAtRef.current.start < 150) return;
nativeEventAtRef.current.start = now;
@@ -790,7 +830,8 @@ export default function CaptureWidget({ onDismiss }) {
startRecordingRef.current?.(true, sessionId);
}
});
unlistenStop = await listen('tray-dictate-stop', async () => {
unlistenStop = await listen('tray-dictate-stop', async (event) => {
if (!acknowledgeDelivery(event)) return;
const now = Date.now();
if (now - nativeEventAtRef.current.stop < 150) return;
nativeEventAtRef.current.stop = now;
@@ -803,15 +844,18 @@ export default function CaptureWidget({ onDismiss }) {
}
});
await ensureDictationPrefsHydrated();
const { invoke } = await import('@tauri-apps/api/core');
await invoke('mark_dictation_capture_ready');
if (cancelled) {
await teardownRegistration();
return;
}
await tauriInvoke('mark_dictation_capture_ready', { registrationId });
// Unmounted while the dynamic import was in flight drop the
// subscriptions we just created rather than leaking them.
if (cancelled) {
unlistenStart?.();
unlistenStop?.();
await teardownRegistration();
}
} catch (err) {
await teardownRegistration().catch(() => {});
// Hotkey wiring failed inside Tauri dictation still works via the
// in-page shortcut, but say so in the console for bug reports.
console.warn('tray-dictate listen failed:', err);
@@ -820,8 +864,7 @@ export default function CaptureWidget({ onDismiss }) {
return () => {
cancelled = true;
nativeStartSequenceRef.current += 1;
if (unlistenStart) unlistenStart();
if (unlistenStop) unlistenStop();
void teardownRegistration();
};
// Attach ONCE see stateRef above. Adding a dependency here reintroduces
// the dropped-press window that stranded the widget.
@@ -57,6 +57,9 @@ const mocks = vi.hoisted(() => {
copyText: vi.fn(async () => {}),
invoke: async (cmd, args) => {
holder.calls.push([cmd, args]);
if (cmd === 'begin_dictation_capture_registration') {
return holder.calls.filter(([command]) => command === cmd).length;
}
if (cmd === 'check_accessibility') return holder.a11y;
if (cmd === 'simulate_paste') return holder.paste(cmd, args);
if (cmd === 'copy_dictation_output_session') return holder.copy(cmd, args);
@@ -294,6 +297,24 @@ describe('CaptureWidget', () => {
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(() =>
expect(
mocks.holder.calls.some(([command]) => command === 'mark_dictation_capture_ready'),
).toBe(true),
);
const ready = mocks.holder.calls.findLast(
([command]) => command === 'mark_dictation_capture_ready',
);
view.unmount();
await waitFor(() =>
expect(mocks.holder.calls).toContainEqual(['end_dictation_capture_registration', ready[1]]),
);
});
it('honors a hold-mode release while microphone startup is pending', async () => {
mocks.state.dictationMode = 'hold';
let resolveMicrophone;
@@ -21,6 +21,7 @@ import {
selfTestEngine,
installSidecarEngine,
getSidecarInstallStatus,
getEngineDiskUsage,
} from '../api/engines';
import { listLoadedModels, unloadLoadedModel } from '../api/system';
import { useAppStore } from '../store';
@@ -49,6 +50,23 @@ function reasonMentionsLicense(reason) {
return /license not accepted/i.test(reason);
}
export function fmtDiskBytes(value, unknownLabel, locale) {
if (value == null) return unknownLabel;
const [divisor, unit, digits] =
value >= 1024 ** 3
? [1024 ** 3, 'gigabyte', 2]
: value >= 1024 ** 2
? [1024 ** 2, 'megabyte', 1]
: [1024, 'kilobyte', 0];
return new Intl.NumberFormat(locale, {
style: 'unit',
unit,
unitDisplay: 'short',
minimumFractionDigits: digits,
maximumFractionDigits: digits,
}).format(value / divisor);
}
/**
* Engine Compatibility Matrix (Plan 02-04 / ENGINE-06).
*
@@ -233,6 +251,7 @@ function normalizeEntry(entry) {
// null/absent on every other backend, which never renders a picker.
curated_models: Array.isArray(entry.curated_models) ? entry.curated_models : null,
active_model_id: entry.active_model_id || null,
disk_usage: entry.disk_usage || null,
};
}
@@ -269,8 +288,9 @@ export default function EngineCompatibilityMatrix({
// One-click sidecar install layer same injection story as the rest.
apiInstallEngine = installSidecarEngine,
apiInstallStatus = getSidecarInstallStatus,
apiGetDiskUsage = getEngineDiskUsage,
}) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const [localData, setLocalData] = useState(null);
const [localLoading, setLocalLoading] = useState(true);
const [localError, setLocalError] = useState(null);
@@ -298,6 +318,13 @@ export default function EngineCompatibilityMatrix({
// (one at a time). The panel renders BELOW the row as its own block, so
// sibling rows keep their fixed two-line height and stay aligned.
const [expandedId, setExpandedId] = useState(null);
const [diskByEngine, setDiskByEngine] = useState({});
const diskGenerationRef = useRef(0);
const invalidateDiskUsage = useCallback(() => {
diskGenerationRef.current += 1;
setDiskByEngine({});
}, []);
// Memory residency: engine id its /model/loaded entry (TTS entries and
// sidecars carry engine_id). Advisory load failures leave it empty and
// the matrix renders exactly as before (no residency chips).
@@ -323,6 +350,7 @@ export default function EngineCompatibilityMatrix({
}, [apiListLoadedModels]);
const reload = useCallback(async () => {
invalidateDiskUsage();
if (sharedRefetch) {
const result = await sharedRefetch();
if (result.error) {
@@ -343,7 +371,7 @@ export default function EngineCompatibilityMatrix({
}
}
refreshResidency();
}, [apiListEngines, refreshResidency, sharedRefetch, t]);
}, [apiListEngines, invalidateDiskUsage, refreshResidency, sharedRefetch, t]);
useEffect(() => {
if (isShared) {
@@ -827,15 +855,17 @@ export default function EngineCompatibilityMatrix({
// Unavailable-row detail material for the expansion panel.
// One-click-installable rows always have a panel it hosts the
// install progress and the demoted manual-install fallback.
const hasDiskDetails = activeFamily === 'tts' && !!b.disk_usage;
const hasDetails =
!b.available &&
!!(
b.reason ||
b.install_hint ||
b.last_error ||
b.setup_snippet ||
b.one_click_install
);
hasDiskDetails ||
(!b.available &&
!!(
b.reason ||
b.install_hint ||
b.last_error ||
b.setup_snippet ||
b.one_click_install
));
const install = installByEngine[b.id] || null;
const installJob = install?.job || null;
const installRunning = installJob?.state === 'running';
@@ -860,7 +890,9 @@ export default function EngineCompatibilityMatrix({
variant="subtle"
onClick={() => copySetup(b.id, b.setup_snippet)}
leading={copiedId === b.id ? <Check size={11} /> : <Copy size={11} />}
aria-label={t('engines.copySetup', { engine: b.display_name })}
aria-label={t('engines.copySetup', {
engine: b.display_name,
})}
>
{copiedId === b.id ? t('engines.copied') : t('engines.copy')}
</Button>
@@ -1024,7 +1056,9 @@ export default function EngineCompatibilityMatrix({
value={b.active_model_id || ''}
disabled={!onSelect || !b.available}
onChange={(e) => changeModel(b.id, e.target.value)}
aria-label={t('engines.curatedModelAria', { engine: b.display_name })}
aria-label={t('engines.curatedModelAria', {
engine: b.display_name,
})}
data-testid={`curated-model-select-${b.id}`}
>
{b.curated_models.map((m) => (
@@ -1076,7 +1110,27 @@ export default function EngineCompatibilityMatrix({
aria-expanded={expanded}
aria-controls={panelId}
data-testid={`why-toggle-${b.id}`}
onClick={() => setExpandedId(expanded ? null : b.id)}
onClick={async () => {
if (expanded) {
setExpandedId(null);
return;
}
setExpandedId(b.id);
if (hasDiskDetails && !diskByEngine[b.id]) {
const generation = diskGenerationRef.current;
try {
const usage = await apiGetDiskUsage(b.id);
if (diskGenerationRef.current === generation) {
setDiskByEngine((current) => ({
...current,
[b.id]: usage,
}));
}
} catch {
// Estimates remain useful when measurement is unavailable.
}
}
}}
>
<ChevronRight
size={10}
@@ -1085,7 +1139,7 @@ export default function EngineCompatibilityMatrix({
expanded && 'rotate-90',
)}
/>
{t('engines.whyUnavailable')}
{b.available ? t('engines.diskDetails') : t('engines.whyUnavailable')}
</button>
)}
</span>
@@ -1258,7 +1312,9 @@ export default function EngineCompatibilityMatrix({
loading={installRunning}
leading={!installRunning && <Download size={11} />}
data-testid={`install-${b.id}`}
aria-label={t('engines.installAria', { engine: b.display_name })}
aria-label={t('engines.installAria', {
engine: b.display_name,
})}
>
{installRunning
? t('engines.installing')
@@ -1432,6 +1488,62 @@ export default function EngineCompatibilityMatrix({
{t('engines.lastError', { error: b.last_error })}
</span>
)}
{hasDiskDetails &&
(() => {
const usage = diskByEngine[b.id] || b.disk_usage;
const estimate = usage?.estimate || {};
const actual = usage?.actual || {};
const value = (bytes) =>
fmtDiskBytes(bytes, t('common.unknown'), i18n.resolvedLanguage);
return (
<div
className="engine-matrix__disk mt-[4px] grid grid-cols-[max-content_1fr] gap-x-[12px] gap-y-[2px]"
data-testid={`disk-usage-${b.id}`}
>
<span>{t('engines.diskModelDownload')}</span>
<strong>{value(estimate.model_download_bytes)}</strong>
<span>{t('engines.diskPackageDownload')}</span>
<strong>{value(estimate.package_download_bytes)}</strong>
<span>{t('engines.diskUniqueInstalled')}</span>
<strong>{value(estimate.unique_installed_bytes)}</strong>
<span>{t('engines.diskPotentiallyShared')}</span>
<strong>{value(estimate.potentially_shared_bytes)}</strong>
<span>{t('engines.diskTemporary')}</span>
<strong>{value(estimate.temporary_free_bytes)}</strong>
<span>{t('engines.diskEstimateConfidence')}</span>
<strong>
{t(`engines.diskConfidence_${estimate.confidence || 'unknown'}`)}
</strong>
<span>{t('engines.diskDestination')}</span>
<strong>
{estimate.destination && estimate.destination !== 'unknown'
? t(`engines.diskDestination_${estimate.destination}`)
: t('common.unknown')}
{estimate.destination_volume &&
estimate.destination_volume !== 'unknown' && (
<code className="ml-[6px]">{estimate.destination_volume}</code>
)}
</strong>
<span>{t('engines.diskActualModel')}</span>
<strong>{value(actual.model_bytes)}</strong>
<span>{t('engines.diskActualEnvironment')}</span>
<strong>{value(actual.environment_bytes)}</strong>
<span>{t('engines.diskActualCache')}</span>
<strong>{value(actual.cache_bytes)}</strong>
<span>{t('engines.diskActualTotal')}</span>
<strong>{value(actual.total_owned_bytes)}</strong>
<span>{t('engines.diskActualConfidence')}</span>
<strong>
{t(`engines.diskConfidence_${actual.confidence || 'unknown'}`)}
</strong>
{estimate.deduplication && (
<span className="col-span-2 mt-[2px] text-[color:var(--chrome-fg-muted,#888)]">
{t(`engines.diskDedup_${estimate.deduplication}`)}
</span>
)}
</div>
);
})()}
{/* One-click install progress: per-step states + the
live log tail while the provisioner job runs, error
+ remediation on failure. Poll-driven (1.5 s). */}
@@ -1461,7 +1573,9 @@ export default function EngineCompatibilityMatrix({
: s.state === 'error'
? '[!]'
: '[ ]'}{' '}
{t(`engines.installStep_${s.id}`, { defaultValue: s.id })}
{t(`engines.installStep_${s.id}`, {
defaultValue: s.id,
})}
{s.id === 'fetch_weights' &&
s.state === 'running' &&
installJob.weights_progress?.pct != null &&
@@ -16,6 +16,7 @@ vi.mock('../../api/engines', () => ({
selfTestEngine: vi.fn(),
installSidecarEngine: vi.fn(),
getSidecarInstallStatus: vi.fn(),
getEngineDiskUsage: vi.fn(),
}));
// Residency layer (/model/loaded) mocked so the matrix never hits the
+121 -42
View File
@@ -49,6 +49,18 @@ export function isExpiredDubJobError(err) {
);
}
export function shouldQueueSrtImport(dubStep, sourceAnalysisComplete = false) {
return !sourceAnalysisComplete && ['uploading', 'transcribing'].includes(dubStep);
}
export async function applyQueuedSrtImport(pendingRef, jobId, signal, performImport) {
const file = pendingRef.current;
if (!file) return false;
const imported = await performImport(jobId, file, signal);
if (imported && pendingRef.current === file) pendingRef.current = null;
return imported;
}
/**
* Encapsulates the entire dub pipeline workflow:
* upload prep transcribe translate generate export
@@ -113,6 +125,7 @@ export default function useDubWorkflow({
const dubClientJobIdRef = useRef(null);
const asrInstallTaskRef = useRef(null);
const retryTranscribeRef = useRef(null);
const pendingSrtRef = useRef(null);
const _showMissingAsr = useCallback(
(payload) => {
@@ -191,6 +204,7 @@ export default function useDubWorkflow({
// clear the dead id/state, drop any pill, and prompt a fresh upload with a
// calm info toast — never a bug-report prompt, since this is expected.
const _resetStaleDubSession = useCallback(() => {
pendingSrtRef.current = null;
setDubJobId('');
setDubTaskId('');
setDubSegments([]);
@@ -212,6 +226,95 @@ export default function useDubWorkflow({
);
}, [setDubJobId, setDubTaskId, setDubSegments, setDubError, setDubStep]);
const _performSrtImport = useCallback(
async (jobId, file, signal) => {
if (
signal?.aborted ||
useAppStore.getState().dubJobId !== jobId ||
pendingSrtRef.current !== file
)
return false;
try {
setDubError('');
const res = await dubImportSrt(jobId, file, { signal });
if (
signal?.aborted ||
useAppStore.getState().dubJobId !== jobId ||
pendingSrtRef.current !== file
)
return false;
const segs = (res && res.segments) || [];
setDubSegments(
segs.map((s) => ({
...s,
id: s.id != null ? String(s.id) : String(Math.random()),
})),
);
setDubStep('editing');
const stats = res?.stats || {};
const noteParts = [
t('dub_workflow.imported_cues', {
count: stats.imported ?? segs.length,
file: file.name || '.srt',
}),
];
if (stats.skipped_malformed)
noteParts.push(t('dub_workflow.skipped_malformed', { count: stats.skipped_malformed }));
if (stats.dropped_overlap)
noteParts.push(t('dub_workflow.dropped_overlap', { count: stats.dropped_overlap }));
if (stats.clamped_to_duration)
noteParts.push(
t('dub_workflow.clamped_to_duration', { count: stats.clamped_to_duration }),
);
toast.success(noteParts.join(' · '), { duration: 6000 });
loadProjects();
return true;
} catch (err) {
if (err?.name === 'AbortError') throw err;
if (
signal?.aborted ||
useAppStore.getState().dubJobId !== jobId ||
pendingSrtRef.current !== file
)
return false;
if (isExpiredDubJobError(err)) {
_resetStaleDubSession();
return false;
}
const msg = err?.message || t('dub_workflow.srt_import_failed');
setDubError(msg);
setDubStep('editing');
toast.error(msg);
return false;
}
},
[setDubError, setDubSegments, setDubStep, loadProjects, _resetStaleDubSession],
);
const _applyQueuedSrt = useCallback(
async (jobId, ctrl) => {
let queuedSrt = pendingSrtRef.current;
if (!queuedSrt) {
setDubStep('editing');
return true;
}
while (true) {
const imported = await applyQueuedSrtImport(
pendingSrtRef,
jobId,
ctrl?.signal,
_performSrtImport,
);
if (ctrl?.signal.aborted || useAppStore.getState().dubJobId !== jobId) return false;
const replacement = pendingSrtRef.current;
if (!replacement) return imported;
if (replacement === queuedSrt) return false;
queuedSrt = replacement;
}
},
[setDubStep, _performSrtImport],
);
// Timer for transcribe elapsed
useEffect(() => {
if (!transcribeStart) {
@@ -529,6 +632,7 @@ export default function useDubWorkflow({
setDubError('');
setDubFailure(null);
setDubTracks([]);
pendingSrtRef.current = null;
setDubPrepStage('download');
setDubPrepProgress({ percent: null, speedBps: null, etaS: null, stageStartedAt: Date.now() });
const ctrl = new AbortController();
@@ -573,7 +677,7 @@ export default function useDubWorkflow({
});
await _waitForTranscribe(data.job_id, ctrl);
setTranscribeStart(null);
setDubStep('editing');
await _applyQueuedSrt(data.job_id, ctrl);
useAppStore.getState().completePill(t('dub_workflow.transcription_complete'));
loadProjects();
loadProfiles();
@@ -611,6 +715,7 @@ export default function useDubWorkflow({
setDubSegments,
_waitForPrep,
_waitForTranscribe,
_applyQueuedSrt,
loadProjects,
loadProfiles,
_resetStaleDubSession,
@@ -634,6 +739,7 @@ export default function useDubWorkflow({
setDubError('');
setDubFailure(null);
setDubTracks([]);
pendingSrtRef.current = null;
setDubPrepStage('download');
setDubPrepProgress({ percent: null, speedBps: null, etaS: null, stageStartedAt: Date.now() });
const ctrl = new AbortController();
@@ -672,7 +778,7 @@ export default function useDubWorkflow({
});
await _waitForTranscribe(data.job_id, ctrl);
setTranscribeStart(null);
setDubStep('editing');
await _applyQueuedSrt(data.job_id, ctrl);
useAppStore.getState().completePill(t('dub_workflow.transcription_complete'));
loadProjects();
loadProfiles();
@@ -716,6 +822,7 @@ export default function useDubWorkflow({
setDubSegments,
_waitForPrep,
_waitForTranscribe,
_applyQueuedSrt,
loadProjects,
loadProfiles,
_resetStaleDubSession,
@@ -725,6 +832,7 @@ export default function useDubWorkflow({
);
const handleDubAbort = useCallback(async () => {
pendingSrtRef.current = null;
const pendingInstall = asrInstallTaskRef.current;
if (pendingInstall) {
pendingInstall.ctrl.abort();
@@ -750,7 +858,7 @@ export default function useDubWorkflow({
try {
await _waitForTranscribe(dubJobId, ctrl);
setTranscribeStart(null);
setDubStep('editing');
await _applyQueuedSrt(dubJobId, ctrl);
loadProjects();
} catch (err) {
setTranscribeStart(null);
@@ -775,6 +883,7 @@ export default function useDubWorkflow({
setDubSegments,
setDubStep,
_waitForTranscribe,
_applyQueuedSrt,
loadProjects,
_resetStaleDubSession,
_showMissingAsr,
@@ -784,51 +893,21 @@ export default function useDubWorkflow({
}, [handleDubRetryTranscribe]);
const handleDubImportSrt = useCallback(
async (file) => {
if (!dubJobId) {
async (file, { jobId = dubJobId, sourceAnalysisComplete = false, signal } = {}) => {
if (!jobId) {
toast.error(t('dub_workflow.import_srt_no_job'));
return;
}
if (!file) return;
try {
setDubError('');
const res = await dubImportSrt(dubJobId, file);
const segs = (res && res.segments) || [];
setDubSegments(
segs.map((s) => ({
...s,
id: s.id != null ? String(s.id) : String(Math.random()),
})),
);
setDubStep('editing');
const stats = res?.stats || {};
const noteParts = [
t('dub_workflow.imported_cues', {
count: stats.imported ?? segs.length,
file: file.name || '.srt',
}),
];
if (stats.skipped_malformed)
noteParts.push(t('dub_workflow.skipped_malformed', { count: stats.skipped_malformed }));
if (stats.dropped_overlap)
noteParts.push(t('dub_workflow.dropped_overlap', { count: stats.dropped_overlap }));
if (stats.clamped_to_duration)
noteParts.push(
t('dub_workflow.clamped_to_duration', { count: stats.clamped_to_duration }),
);
toast.success(noteParts.join(' · '), { duration: 6000 });
loadProjects();
} catch (err) {
if (isExpiredDubJobError(err)) {
_resetStaleDubSession();
return;
}
const msg = err?.message || t('dub_workflow.srt_import_failed');
setDubError(msg);
toast.error(msg);
if (shouldQueueSrtImport(dubStep, sourceAnalysisComplete)) {
pendingSrtRef.current = file;
toast(t('dub_workflow.import_srt_after_speakers'));
return false;
}
pendingSrtRef.current = file;
return applyQueuedSrtImport(pendingSrtRef, jobId, signal, _performSrtImport);
},
[dubJobId, setDubError, setDubSegments, setDubStep, loadProjects, _resetStaleDubSession],
[dubJobId, dubStep, _performSrtImport],
);
const handleCleanupSegments = useCallback(async () => {
+21
View File
@@ -743,6 +743,26 @@
"engineCompatLabel": "{{family}} توافق المحرك",
"active": "نشط",
"whyUnavailable": "ما الذي يحتاجه",
"diskDetails": "تفاصيل القرص",
"diskModelDownload": "تنزيل النموذج",
"diskPackageDownload": "تنزيل الحزم",
"diskUniqueInstalled": "المساحة المثبتة الفريدة",
"diskPotentiallyShared": "المساحة المحتمل مشاركتها",
"diskTemporary": "المساحة الحرة المؤقتة",
"diskDestination": "الوجهة",
"diskDestination_hf_model_cache": "وحدة تخزين ذاكرة نماذج Hugging Face المؤقتة",
"diskDestination_engine_data": "وحدة تخزين بيانات محركات VoiceStudio",
"diskActualModel": "حجم النموذج الفعلي",
"diskActualEnvironment": "حجم البيئة الفعلي",
"diskActualCache": "حجم الذاكرة المؤقتة المشتركة الفعلي",
"diskActualTotal": "إجمالي المساحة المملوكة الفعلي",
"diskEstimateConfidence": "موثوقية التقدير",
"diskActualConfidence": "موثوقية القياس",
"diskConfidence_exact": "دقيق",
"diskConfidence_measured": "مقاس",
"diskConfidence_estimated": "تقديري",
"diskConfidence_unknown": "غير معروف",
"diskDedup_uv_same_volume": "يزيل uv تكرار ملفات wheel المتطابقة فقط عندما تكون ذاكرته المؤقتة وبيئة المحرك على وحدة التخزين نفسها.",
"sectionReady": "جاهزة للاستخدام",
"sectionMore": "أضف المزيد من المحركات",
"lastError": "الخطأ الأخير: {{error}}",
@@ -2036,6 +2056,7 @@
"retry_cancelled": "تم إلغاء إعادة المحاولة",
"transcription_failed": "فشل النسخ: {{message}}",
"import_srt_no_job": "قم بتحميل مقطع فيديو أو استيعابه أولاً - لا توجد مهمة لإرفاق الترجمة بها.",
"import_srt_after_speakers": "تم وضع ملف SRT في قائمة الانتظار. سيكتمل تحليل المتحدثين أولاً للحفاظ على الأصوات المستنسخة.",
"imported_cues": "تم استيراد {{count}} إشارة (إشارات) من {{file}}",
"skipped_malformed": "تم تخطي {{count}} (مشوه)",
"dropped_overlap": "تم إسقاط {{count}} (تداخل)",
+21
View File
@@ -743,6 +743,26 @@
"engineCompatLabel": "{{family}} Motorkompatibilität",
"active": "aktiv",
"whyUnavailable": "Was benötigt wird",
"diskDetails": "Speicherplatzdetails",
"diskModelDownload": "Modelldownload",
"diskPackageDownload": "Paketdownload",
"diskUniqueInstalled": "Einzigartig installiert",
"diskPotentiallyShared": "Potenziell gemeinsam genutzt",
"diskTemporary": "Temporärer freier Speicher",
"diskDestination": "Ziel",
"diskDestination_hf_model_cache": "Hugging-Face-Modellcache-Laufwerk",
"diskDestination_engine_data": "VoiceStudio-Engine-Datenlaufwerk",
"diskActualModel": "Tatsächliches Modell",
"diskActualEnvironment": "Tatsächliche Umgebung",
"diskActualCache": "Tatsächlicher gemeinsamer Cache",
"diskActualTotal": "Tatsächlich eigener Gesamtbedarf",
"diskEstimateConfidence": "Schätzgenauigkeit",
"diskActualConfidence": "Messgenauigkeit",
"diskConfidence_exact": "Exakt",
"diskConfidence_measured": "Gemessen",
"diskConfidence_estimated": "Geschätzt",
"diskConfidence_unknown": "Unbekannt",
"diskDedup_uv_same_volume": "uv dedupliziert identische Wheels nur, wenn Cache und Engine-Umgebung auf demselben Laufwerk liegen.",
"sectionReady": "Einsatzbereit",
"sectionMore": "Weitere Motoren hinzufügen",
"lastError": "Letzter Fehler: {{error}}",
@@ -2036,6 +2056,7 @@
"retry_cancelled": "Wiederholungsversuch abgebrochen",
"transcription_failed": "Transkription fehlgeschlagen: {{message}}",
"import_srt_no_job": "Laden Sie zuerst ein Video hoch oder nehmen Sie es auf es gibt keinen Job, dem Sie Untertitel hinzufügen müssen.",
"import_srt_after_speakers": "SRT vorgemerkt. Zuerst wird die Sprecheranalyse abgeschlossen, damit geklonte Stimmen erhalten bleiben.",
"imported_cues": "{{count}} Cue(s) von {{file}} importiert",
"skipped_malformed": "{{count}} übersprungen (fehlerhaft)",
"dropped_overlap": "{{count}} gelöscht (Überlappung)",
+21
View File
@@ -2049,6 +2049,26 @@
"engineCompatLabel": "{{family}} engine compatibility",
"active": "active",
"whyUnavailable": "What it needs",
"diskDetails": "Disk details",
"diskModelDownload": "Model download",
"diskPackageDownload": "Package download",
"diskUniqueInstalled": "Unique installed",
"diskPotentiallyShared": "Potentially shared",
"diskTemporary": "Temporary free space",
"diskDestination": "Destination",
"diskDestination_hf_model_cache": "Hugging Face model-cache volume",
"diskDestination_engine_data": "VoiceStudio engine-data volume",
"diskActualModel": "Actual model",
"diskActualEnvironment": "Actual environment",
"diskActualCache": "Actual shared cache",
"diskActualTotal": "Actual owned total",
"diskEstimateConfidence": "Estimate confidence",
"diskActualConfidence": "Measurement confidence",
"diskConfidence_exact": "Exact",
"diskConfidence_measured": "Measured",
"diskConfidence_estimated": "Estimated",
"diskConfidence_unknown": "Unknown",
"diskDedup_uv_same_volume": "uv deduplicates identical wheels only when its cache and the engine environment are on the same volume.",
"sectionReady": "Ready to use",
"sectionMore": "Add more engines",
"lastError": "Last error: {{error}}",
@@ -2707,6 +2727,7 @@
"session_expired": "This dub session expired or was cleaned up — re-upload your video to start a new one.",
"transcription_failed": "Transcription failed: {{message}}",
"import_srt_no_job": "Upload or ingest a video first — there is no job to attach subtitles to.",
"import_srt_after_speakers": "SRT queued. Speaker analysis will finish first so cloned voices are preserved.",
"imported_cues": "Imported {{count}} cue(s) from {{file}}",
"skipped_malformed": "{{count}} skipped (malformed)",
"dropped_overlap": "{{count}} dropped (overlap)",
+21
View File
@@ -743,6 +743,26 @@
"engineCompatLabel": "{{family}} compatibilidad del motor",
"active": "activo",
"whyUnavailable": "Qué necesita",
"diskDetails": "Detalles de disco",
"diskModelDownload": "Descarga del modelo",
"diskPackageDownload": "Descarga de paquetes",
"diskUniqueInstalled": "Instalación exclusiva",
"diskPotentiallyShared": "Posible espacio compartido",
"diskTemporary": "Espacio temporal libre",
"diskDestination": "Destino",
"diskDestination_hf_model_cache": "Volumen de caché de modelos de Hugging Face",
"diskDestination_engine_data": "Volumen de datos de motores de VoiceStudio",
"diskActualModel": "Modelo real",
"diskActualEnvironment": "Entorno real",
"diskActualCache": "Caché compartida real",
"diskActualTotal": "Total propio real",
"diskEstimateConfidence": "Confianza de la estimación",
"diskActualConfidence": "Confianza de la medición",
"diskConfidence_exact": "Exacta",
"diskConfidence_measured": "Medida",
"diskConfidence_estimated": "Estimada",
"diskConfidence_unknown": "Desconocida",
"diskDedup_uv_same_volume": "uv deduplica wheels idénticos solo si su caché y el entorno del motor están en el mismo volumen.",
"sectionReady": "Listos para usar",
"sectionMore": "Añadir más motores",
"lastError": "Último error: {{error}}",
@@ -2036,6 +2056,7 @@
"retry_cancelled": "Reintento cancelado",
"transcription_failed": "Error de transcripción: {{message}}",
"import_srt_no_job": "Primero cargue o ingiera un video: no hay ningún trabajo al que adjuntar subtítulos.",
"import_srt_after_speakers": "SRT en cola. Primero finalizará el análisis de hablantes para conservar las voces clonadas.",
"imported_cues": "{{count}} cue(s) importadas de {{file}}",
"skipped_malformed": "{{count}} omitido (mal formado)",
"dropped_overlap": "{{count}} caído (superposición)",
+21
View File
@@ -743,6 +743,26 @@
"engineCompatLabel": "Compatibilité moteur {{family}}",
"active": "actif",
"whyUnavailable": "Ce qu'il lui faut",
"diskDetails": "Détails du disque",
"diskModelDownload": "Téléchargement du modèle",
"diskPackageDownload": "Téléchargement des paquets",
"diskUniqueInstalled": "Installation unique",
"diskPotentiallyShared": "Potentiellement partagé",
"diskTemporary": "Espace libre temporaire",
"diskDestination": "Destination",
"diskDestination_hf_model_cache": "Volume du cache de modèles Hugging Face",
"diskDestination_engine_data": "Volume de données des moteurs VoiceStudio",
"diskActualModel": "Modèle réel",
"diskActualEnvironment": "Environnement réel",
"diskActualCache": "Cache partagé réel",
"diskActualTotal": "Total réellement détenu",
"diskEstimateConfidence": "Fiabilité de lestimation",
"diskActualConfidence": "Fiabilité de la mesure",
"diskConfidence_exact": "Exacte",
"diskConfidence_measured": "Mesurée",
"diskConfidence_estimated": "Estimée",
"diskConfidence_unknown": "Inconnue",
"diskDedup_uv_same_volume": "uv déduplique les wheels identiques uniquement si son cache et lenvironnement du moteur sont sur le même volume.",
"sectionReady": "Prêts à l'emploi",
"sectionMore": "Ajouter d'autres moteurs",
"lastError": "Dernière erreur : {{error}}",
@@ -2036,6 +2056,7 @@
"retry_cancelled": "Nouvelle tentative annulée",
"transcription_failed": "Échec de la transcription : {{message}}",
"import_srt_no_job": "Téléchargez ou ingérez dabord une vidéo il ny a aucune tâche à laquelle attacher des sous-titres.",
"import_srt_after_speakers": "SRT mis en attente. Lanalyse des locuteurs se terminera dabord afin de préserver les voix clonées.",
"imported_cues": "Cues {{count}} importées de {{file}}",
"skipped_malformed": "{{count}} ignoré (mal formé)",
"dropped_overlap": "{{count}} supprimé (chevauchement)",
+21
View File
@@ -743,6 +743,26 @@
"engineCompatLabel": "{{family}} इंजन अनुकूलता",
"active": "सक्रिय",
"whyUnavailable": "इसे क्या चाहिए",
"diskDetails": "डिस्क विवरण",
"diskModelDownload": "मॉडल डाउनलोड",
"diskPackageDownload": "पैकेज डाउनलोड",
"diskUniqueInstalled": "अद्वितीय स्थापित आकार",
"diskPotentiallyShared": "संभावित साझा आकार",
"diskTemporary": "अस्थायी खाली स्थान",
"diskDestination": "गंतव्य",
"diskDestination_hf_model_cache": "Hugging Face मॉडल कैश वॉल्यूम",
"diskDestination_engine_data": "VoiceStudio इंजन डेटा वॉल्यूम",
"diskActualModel": "वास्तविक मॉडल",
"diskActualEnvironment": "वास्तविक परिवेश",
"diskActualCache": "वास्तविक साझा कैश",
"diskActualTotal": "वास्तविक स्वामित्व कुल",
"diskEstimateConfidence": "अनुमान की विश्वसनीयता",
"diskActualConfidence": "माप की विश्वसनीयता",
"diskConfidence_exact": "सटीक",
"diskConfidence_measured": "मापा गया",
"diskConfidence_estimated": "अनुमानित",
"diskConfidence_unknown": "अज्ञात",
"diskDedup_uv_same_volume": "uv समान wheels की प्रतियां तभी हटाता है जब उसका कैश और इंजन परिवेश एक ही वॉल्यूम पर हों।",
"sectionReady": "उपयोग के लिए तैयार",
"sectionMore": "और इंजन जोड़ें",
"lastError": "अंतिम त्रुटि: {{error}}",
@@ -2036,6 +2056,7 @@
"retry_cancelled": "पुनः प्रयास रद्द कर दिया गया",
"transcription_failed": "प्रतिलेखन विफल: {{message}}",
"import_srt_no_job": "पहले एक वीडियो अपलोड या इंजेस्ट करें - इसमें उपशीर्षक संलग्न करना कोई काम नहीं है।",
"import_srt_after_speakers": "SRT कतार में है। क्लोन की गई आवाज़ों को सुरक्षित रखने के लिए पहले वक्ता विश्लेषण पूरा होगा।",
"imported_cues": "{{file}} से आयातित {{count}} संकेत",
"skipped_malformed": "{{count}} छोड़ दिया गया (विकृत)",
"dropped_overlap": "{{count}} गिरा दिया गया (ओवरलैप)",
+21
View File
@@ -743,6 +743,26 @@
"engineCompatLabel": "{{family}} kompatibilitas mesin",
"active": "aktif",
"whyUnavailable": "Apa yang dibutuhkan",
"diskDetails": "Rincian disk",
"diskModelDownload": "Unduhan model",
"diskPackageDownload": "Unduhan paket",
"diskUniqueInstalled": "Terpasang unik",
"diskPotentiallyShared": "Berpotensi dibagikan",
"diskTemporary": "Ruang kosong sementara",
"diskDestination": "Tujuan",
"diskDestination_hf_model_cache": "Volume cache model Hugging Face",
"diskDestination_engine_data": "Volume data mesin VoiceStudio",
"diskActualModel": "Model aktual",
"diskActualEnvironment": "Lingkungan aktual",
"diskActualCache": "Cache bersama aktual",
"diskActualTotal": "Total milik aktual",
"diskEstimateConfidence": "Keyakinan estimasi",
"diskActualConfidence": "Keyakinan pengukuran",
"diskConfidence_exact": "Tepat",
"diskConfidence_measured": "Terukur",
"diskConfidence_estimated": "Perkiraan",
"diskConfidence_unknown": "Tidak diketahui",
"diskDedup_uv_same_volume": "uv mendeduplikasi wheel identik hanya jika cache dan lingkungan mesin berada pada volume yang sama.",
"sectionReady": "Siap digunakan",
"sectionMore": "Tambahkan mesin lainnya",
"lastError": "Kesalahan terakhir: {{error}}",
@@ -2036,6 +2056,7 @@
"retry_cancelled": "Coba lagi dibatalkan",
"transcription_failed": "Transkripsi gagal: {{message}}",
"import_srt_no_job": "Unggah atau serap video terlebih dahulu — tidak ada tugas untuk melampirkan subtitle.",
"import_srt_after_speakers": "SRT masuk antrean. Analisis pembicara akan diselesaikan lebih dahulu agar suara kloning tetap dipertahankan.",
"imported_cues": "{{count}} isyarat yang diimpor dari {{file}}",
"skipped_malformed": "{{count}} dilewati (format salah)",
"dropped_overlap": "{{count}} terjatuh (tumpang tindih)",
+21
View File
@@ -743,6 +743,26 @@
"engineCompatLabel": "{{family}} compatibilità motore",
"active": "attivo",
"whyUnavailable": "Cosa serve",
"diskDetails": "Dettagli disco",
"diskModelDownload": "Download del modello",
"diskPackageDownload": "Download dei pacchetti",
"diskUniqueInstalled": "Installazione univoca",
"diskPotentiallyShared": "Potenzialmente condiviso",
"diskTemporary": "Spazio libero temporaneo",
"diskDestination": "Destinazione",
"diskDestination_hf_model_cache": "Volume della cache modelli Hugging Face",
"diskDestination_engine_data": "Volume dei dati dei motori VoiceStudio",
"diskActualModel": "Modello effettivo",
"diskActualEnvironment": "Ambiente effettivo",
"diskActualCache": "Cache condivisa effettiva",
"diskActualTotal": "Totale effettivamente occupato",
"diskEstimateConfidence": "Affidabilità della stima",
"diskActualConfidence": "Affidabilità della misura",
"diskConfidence_exact": "Esatta",
"diskConfidence_measured": "Misurata",
"diskConfidence_estimated": "Stimata",
"diskConfidence_unknown": "Sconosciuta",
"diskDedup_uv_same_volume": "uv deduplica wheel identiche solo quando la cache e lambiente del motore sono sullo stesso volume.",
"sectionReady": "Pronti all'uso",
"sectionMore": "Aggiungi altri motori",
"lastError": "Ultimo errore: {{error}}",
@@ -2036,6 +2056,7 @@
"retry_cancelled": "Nuovo tentativo annullato",
"transcription_failed": "Trascrizione non riuscita: {{message}}",
"import_srt_no_job": "Carica o importa prima un video: non esiste un lavoro a cui allegare i sottotitoli.",
"import_srt_after_speakers": "SRT in coda. Prima verrà completata lanalisi dei parlanti per conservare le voci clonate.",
"imported_cues": "Cue {{count}} importati da {{file}}",
"skipped_malformed": "{{count}} saltato (formato non valido)",
"dropped_overlap": "{{count}} eliminato (sovrapposizione)",
+21
View File
@@ -743,6 +743,26 @@
"engineCompatLabel": "{{family}} エンジンの互換性",
"active": "アクティブな",
"whyUnavailable": "必要なもの",
"diskDetails": "ディスクの詳細",
"diskModelDownload": "モデルのダウンロード",
"diskPackageDownload": "パッケージのダウンロード",
"diskUniqueInstalled": "固有のインストール容量",
"diskPotentiallyShared": "共有可能な容量",
"diskTemporary": "一時的な空き容量",
"diskDestination": "保存先",
"diskDestination_hf_model_cache": "Hugging Face モデルキャッシュのボリューム",
"diskDestination_engine_data": "VoiceStudio エンジンデータのボリューム",
"diskActualModel": "実際のモデル",
"diskActualEnvironment": "実際の環境",
"diskActualCache": "実際の共有キャッシュ",
"diskActualTotal": "実際の専有合計",
"diskEstimateConfidence": "推定の信頼度",
"diskActualConfidence": "測定の信頼度",
"diskConfidence_exact": "正確",
"diskConfidence_measured": "測定済み",
"diskConfidence_estimated": "推定",
"diskConfidence_unknown": "不明",
"diskDedup_uv_same_volume": "uv は、キャッシュとエンジン環境が同じボリュームにある場合のみ、同一の wheel を重複排除します。",
"sectionReady": "すぐに使える",
"sectionMore": "エンジンを追加",
"lastError": "最後のエラー: {{error}}",
@@ -2036,6 +2056,7 @@
"retry_cancelled": "再試行がキャンセルされました",
"transcription_failed": "転写に失敗しました: {{message}}",
"import_srt_no_job": "まずビデオをアップロードまたは取り込みます。字幕を付ける作業はありません。",
"import_srt_after_speakers": "SRTをキューに追加しました。クローン音声を保持するため、先に話者分析を完了します。",
"imported_cues": "{{file}} から {{count}} キューをインポートしました",
"skipped_malformed": "{{count}} がスキップされました (不正な形式)",
"dropped_overlap": "{{count}} が削除されました (重複)",
+21
View File
@@ -743,6 +743,26 @@
"engineCompatLabel": "{{family}} 엔진 호환성",
"active": "활성",
"whyUnavailable": "필요한 것",
"diskDetails": "디스크 세부 정보",
"diskModelDownload": "모델 다운로드",
"diskPackageDownload": "패키지 다운로드",
"diskUniqueInstalled": "고유 설치 용량",
"diskPotentiallyShared": "공유 가능 용량",
"diskTemporary": "임시 여유 공간",
"diskDestination": "대상",
"diskDestination_hf_model_cache": "Hugging Face 모델 캐시 볼륨",
"diskDestination_engine_data": "VoiceStudio 엔진 데이터 볼륨",
"diskActualModel": "실제 모델",
"diskActualEnvironment": "실제 환경",
"diskActualCache": "실제 공유 캐시",
"diskActualTotal": "실제 소유 합계",
"diskEstimateConfidence": "예상 신뢰도",
"diskActualConfidence": "측정 신뢰도",
"diskConfidence_exact": "정확함",
"diskConfidence_measured": "측정됨",
"diskConfidence_estimated": "예상됨",
"diskConfidence_unknown": "알 수 없음",
"diskDedup_uv_same_volume": "uv는 캐시와 엔진 환경이 같은 볼륨에 있을 때만 동일한 wheel을 중복 제거합니다.",
"sectionReady": "바로 사용 가능",
"sectionMore": "엔진 추가",
"lastError": "마지막 오류: {{error}}",
@@ -2036,6 +2056,7 @@
"retry_cancelled": "재시도가 취소되었습니다.",
"transcription_failed": "스크립트 작성 실패: {{message}}",
"import_srt_no_job": "먼저 비디오를 업로드하거나 처리하십시오. 자막을 첨부할 작업이 없습니다.",
"import_srt_after_speakers": "SRT가 대기열에 추가되었습니다. 복제된 음성을 유지하기 위해 화자 분석을 먼저 완료합니다.",
"imported_cues": "{{file}}에서 {{count}} 큐를 가져왔습니다.",
"skipped_malformed": "{{count}} 건너뛰기(잘못된 형식)",
"dropped_overlap": "{{count}} 삭제됨(겹침)",
+21
View File
@@ -743,6 +743,26 @@
"engineCompatLabel": "{{family}} motorcompatibiliteit",
"active": "actief",
"whyUnavailable": "Wat het nodig heeft",
"diskDetails": "Schijfdetails",
"diskModelDownload": "Modeldownload",
"diskPackageDownload": "Pakketdownload",
"diskUniqueInstalled": "Uniek geïnstalleerd",
"diskPotentiallyShared": "Mogelijk gedeeld",
"diskTemporary": "Tijdelijke vrije ruimte",
"diskDestination": "Bestemming",
"diskDestination_hf_model_cache": "Volume voor Hugging Face-modelcache",
"diskDestination_engine_data": "Volume voor VoiceStudio-enginegegevens",
"diskActualModel": "Werkelijk model",
"diskActualEnvironment": "Werkelijke omgeving",
"diskActualCache": "Werkelijke gedeelde cache",
"diskActualTotal": "Werkelijk eigen totaal",
"diskEstimateConfidence": "Betrouwbaarheid schatting",
"diskActualConfidence": "Betrouwbaarheid meting",
"diskConfidence_exact": "Exact",
"diskConfidence_measured": "Gemeten",
"diskConfidence_estimated": "Geschat",
"diskConfidence_unknown": "Onbekend",
"diskDedup_uv_same_volume": "uv dedupliceert identieke wheels alleen als de cache en engineomgeving op hetzelfde volume staan.",
"sectionReady": "Klaar voor gebruik",
"sectionMore": "Meer motoren toevoegen",
"lastError": "Laatste fout: {{error}}",
@@ -2036,6 +2056,7 @@
"retry_cancelled": "Nieuwe poging geannuleerd",
"transcription_failed": "Transcriptie mislukt: {{message}}",
"import_srt_no_job": "Upload of neem eerst een video op - er is geen taak om ondertitels aan toe te voegen.",
"import_srt_after_speakers": "SRT staat in de wachtrij. De sprekeranalyse wordt eerst voltooid zodat gekloonde stemmen behouden blijven.",
"imported_cues": "{{count}} cue(s) geïmporteerd uit {{file}}",
"skipped_malformed": "{{count}} overgeslagen (misvormd)",
"dropped_overlap": "{{count}} weggevallen (overlap)",
+21
View File
@@ -743,6 +743,26 @@
"engineCompatLabel": "{{family}} kompatybilność silnika",
"active": "aktywny",
"whyUnavailable": "Czego potrzebuje",
"diskDetails": "Szczegóły dysku",
"diskModelDownload": "Pobieranie modelu",
"diskPackageDownload": "Pobieranie pakietów",
"diskUniqueInstalled": "Unikalnie zainstalowane",
"diskPotentiallyShared": "Potencjalnie współdzielone",
"diskTemporary": "Tymczasowe wolne miejsce",
"diskDestination": "Miejsce docelowe",
"diskDestination_hf_model_cache": "Wolumin pamięci modeli Hugging Face",
"diskDestination_engine_data": "Wolumin danych silników VoiceStudio",
"diskActualModel": "Rzeczywisty model",
"diskActualEnvironment": "Rzeczywiste środowisko",
"diskActualCache": "Rzeczywista współdzielona pamięć podręczna",
"diskActualTotal": "Rzeczywista suma własna",
"diskEstimateConfidence": "Wiarygodność szacunku",
"diskActualConfidence": "Wiarygodność pomiaru",
"diskConfidence_exact": "Dokładne",
"diskConfidence_measured": "Zmierzone",
"diskConfidence_estimated": "Szacowane",
"diskConfidence_unknown": "Nieznane",
"diskDedup_uv_same_volume": "uv deduplikuje identyczne wheels tylko wtedy, gdy pamięć podręczna i środowisko silnika są na tym samym woluminie.",
"sectionReady": "Gotowe do użycia",
"sectionMore": "Dodaj więcej silników",
"lastError": "Ostatni błąd: {{error}}",
@@ -2036,6 +2056,7 @@
"retry_cancelled": "Ponowna próba anulowana",
"transcription_failed": "Transkrypcja nie powiodła się: {{message}}",
"import_srt_no_job": "Najpierw prześlij lub pobierz film — nie ma zadania, do którego można by dołączyć napisy.",
"import_srt_after_speakers": "Plik SRT dodano do kolejki. Najpierw zakończy się analiza mówców, aby zachować sklonowane głosy.",
"imported_cues": "Zaimportowano {{count}} pamięci z {{file}}",
"skipped_malformed": "{{count}} pominięty (zniekształcony)",
"dropped_overlap": "{{count}} upuszczony (nakładanie się)",
+21
View File
@@ -743,6 +743,26 @@
"engineCompatLabel": "{{family}} compatibilidade do motor",
"active": "ativo",
"whyUnavailable": "O que falta",
"diskDetails": "Detalhes do disco",
"diskModelDownload": "Download do modelo",
"diskPackageDownload": "Download de pacotes",
"diskUniqueInstalled": "Instalação exclusiva",
"diskPotentiallyShared": "Possivelmente compartilhado",
"diskTemporary": "Espaço livre temporário",
"diskDestination": "Destino",
"diskDestination_hf_model_cache": "Volume de cache de modelos do Hugging Face",
"diskDestination_engine_data": "Volume de dados dos mecanismos do VoiceStudio",
"diskActualModel": "Modelo real",
"diskActualEnvironment": "Ambiente real",
"diskActualCache": "Cache compartilhado real",
"diskActualTotal": "Total próprio real",
"diskEstimateConfidence": "Confiança da estimativa",
"diskActualConfidence": "Confiança da medição",
"diskConfidence_exact": "Exata",
"diskConfidence_measured": "Medida",
"diskConfidence_estimated": "Estimada",
"diskConfidence_unknown": "Desconhecida",
"diskDedup_uv_same_volume": "O uv deduplica wheels idênticos apenas quando o cache e o ambiente do mecanismo estão no mesmo volume.",
"sectionReady": "Prontos para usar",
"sectionMore": "Adicionar mais motores",
"lastError": "Último erro: {{error}}",
@@ -2036,6 +2056,7 @@
"retry_cancelled": "Retry cancelled",
"transcription_failed": "Falha na transcrição: {{message}}",
"import_srt_no_job": "Carregue ou ingira um vídeo primeiro não há trabalho para anexar legendas.",
"import_srt_after_speakers": "SRT na fila. A análise dos falantes será concluída primeiro para preservar as vozes clonadas.",
"imported_cues": "Sugestão(s) {{count}} importada(s) de {{file}}",
"skipped_malformed": "{{count}} ignorado (malformado)",
"dropped_overlap": "{{count}} caiu (sobreposição)",
+21
View File
@@ -743,6 +743,26 @@
"engineCompatLabel": "Совместимость с двигателем {{family}}",
"active": "активный",
"whyUnavailable": "Что нужно",
"diskDetails": "Сведения о диске",
"diskModelDownload": "Загрузка модели",
"diskPackageDownload": "Загрузка пакетов",
"diskUniqueInstalled": "Уникально установлено",
"diskPotentiallyShared": "Возможно совместное использование",
"diskTemporary": "Временное свободное место",
"diskDestination": "Назначение",
"diskDestination_hf_model_cache": "Том кэша моделей Hugging Face",
"diskDestination_engine_data": "Том данных движков VoiceStudio",
"diskActualModel": "Фактическая модель",
"diskActualEnvironment": "Фактическое окружение",
"diskActualCache": "Фактический общий кэш",
"diskActualTotal": "Фактический собственный итог",
"diskEstimateConfidence": "Достоверность оценки",
"diskActualConfidence": "Достоверность измерения",
"diskConfidence_exact": "Точно",
"diskConfidence_measured": "Измерено",
"diskConfidence_estimated": "Оценочно",
"diskConfidence_unknown": "Неизвестно",
"diskDedup_uv_same_volume": "uv дедуплицирует одинаковые wheels, только если кэш и окружение движка находятся на одном томе.",
"sectionReady": "Готовы к использованию",
"sectionMore": "Добавить больше двигателей",
"lastError": "Последняя ошибка: {{error}}",
@@ -2036,6 +2056,7 @@
"retry_cancelled": "Повторная попытка отменена",
"transcription_failed": "Транскрипция не удалась: {{message}}",
"import_srt_no_job": "Сначала загрузите или импортируйте видео — прикрепить субтитры к нему не нужно.",
"import_srt_after_speakers": "SRT поставлен в очередь. Сначала завершится анализ говорящих, чтобы сохранить клонированные голоса.",
"imported_cues": "Импортированы реплики {{count}} из {{file}}.",
"skipped_malformed": "{{count}} пропущен (неверный формат)",
"dropped_overlap": "{{count}} удалено (перекрытие)",
+21
View File
@@ -743,6 +743,26 @@
"engineCompatLabel": "{{family}} motorkompatibilitet",
"active": "aktiv",
"whyUnavailable": "Vad som krävs",
"diskDetails": "Diskdetaljer",
"diskModelDownload": "Modellhämtning",
"diskPackageDownload": "Pakethämtning",
"diskUniqueInstalled": "Unikt installerat",
"diskPotentiallyShared": "Möjligen delat",
"diskTemporary": "Tillfälligt ledigt utrymme",
"diskDestination": "Mål",
"diskDestination_hf_model_cache": "Volym för Hugging Face-modellcache",
"diskDestination_engine_data": "Volym för VoiceStudio-motordata",
"diskActualModel": "Faktisk modell",
"diskActualEnvironment": "Faktisk miljö",
"diskActualCache": "Faktisk delad cache",
"diskActualTotal": "Faktisk egen totalsumma",
"diskEstimateConfidence": "Uppskattningens tillförlitlighet",
"diskActualConfidence": "Mätningens tillförlitlighet",
"diskConfidence_exact": "Exakt",
"diskConfidence_measured": "Uppmätt",
"diskConfidence_estimated": "Uppskattad",
"diskConfidence_unknown": "Okänd",
"diskDedup_uv_same_volume": "uv deduplicerar identiska wheels endast när cachen och motormiljön finns på samma volym.",
"sectionReady": "Redo att använda",
"sectionMore": "Lägg till fler motorer",
"lastError": "Senaste fel: {{error}}",
@@ -2036,6 +2056,7 @@
"retry_cancelled": "Försök igen avbröts",
"transcription_failed": "Transkription misslyckades: {{message}}",
"import_srt_no_job": "Ladda upp eller mata in en video först det finns inget jobb att bifoga undertexter till.",
"import_srt_after_speakers": "SRT har köats. Talaranalysen slutförs först så att klonade röster bevaras.",
"imported_cues": "Importerade {{count}} signal(er) från {{file}}",
"skipped_malformed": "{{count}} hoppade över (felformat)",
"dropped_overlap": "{{count}} tappade (överlappning)",
+21
View File
@@ -743,6 +743,26 @@
"engineCompatLabel": "{{family}} ความเข้ากันได้ของเครื่องยนต์",
"active": "ใช้งานอยู่",
"whyUnavailable": "สิ่งที่ต้องมี",
"diskDetails": "รายละเอียดดิสก์",
"diskModelDownload": "ดาวน์โหลดโมเดล",
"diskPackageDownload": "ดาวน์โหลดแพ็กเกจ",
"diskUniqueInstalled": "พื้นที่ติดตั้งเฉพาะ",
"diskPotentiallyShared": "พื้นที่ที่อาจใช้ร่วมกัน",
"diskTemporary": "พื้นที่ว่างชั่วคราว",
"diskDestination": "ปลายทาง",
"diskDestination_hf_model_cache": "โวลุ่มแคชโมเดล Hugging Face",
"diskDestination_engine_data": "โวลุ่มข้อมูลเอนจิน VoiceStudio",
"diskActualModel": "โมเดลจริง",
"diskActualEnvironment": "สภาพแวดล้อมจริง",
"diskActualCache": "แคชที่ใช้ร่วมกันจริง",
"diskActualTotal": "ยอดรวมที่เป็นเจ้าของจริง",
"diskEstimateConfidence": "ความน่าเชื่อถือของค่าประมาณ",
"diskActualConfidence": "ความน่าเชื่อถือของการวัด",
"diskConfidence_exact": "แม่นยำ",
"diskConfidence_measured": "วัดแล้ว",
"diskConfidence_estimated": "โดยประมาณ",
"diskConfidence_unknown": "ไม่ทราบ",
"diskDedup_uv_same_volume": "uv จะลดข้อมูล wheel ที่ซ้ำกันเฉพาะเมื่อแคชและสภาพแวดล้อมของเอนจินอยู่ในโวลุ่มเดียวกัน",
"sectionReady": "พร้อมใช้งาน",
"sectionMore": "เพิ่มเครื่องยนต์อื่น ๆ",
"lastError": "ข้อผิดพลาดครั้งล่าสุด: {{error}}",
@@ -2036,6 +2056,7 @@
"retry_cancelled": "ลองอีกครั้ง ยกเลิก",
"transcription_failed": "การถอดเสียงล้มเหลว: {{message}}",
"import_srt_no_job": "อัปโหลดหรือนำเข้าวิดีโอก่อน ไม่มีงานให้แนบคำบรรยาย",
"import_srt_after_speakers": "เพิ่ม SRT ลงในคิวแล้ว ระบบจะวิเคราะห์ผู้พูดให้เสร็จก่อนเพื่อรักษาเสียงที่โคลนไว้",
"imported_cues": "นำเข้า {{count}} คิวจาก {{file}}",
"skipped_malformed": "{{count}} ข้ามไป (มีรูปแบบไม่ถูกต้อง)",
"dropped_overlap": "{{count}} ลดลง (ทับซ้อนกัน)",
+21
View File
@@ -743,6 +743,26 @@
"engineCompatLabel": "{{family}} motor uyumluluğu",
"active": "aktif",
"whyUnavailable": "Neye ihtiyacı var",
"diskDetails": "Disk ayrıntıları",
"diskModelDownload": "Model indirmesi",
"diskPackageDownload": "Paket indirmesi",
"diskUniqueInstalled": "Benzersiz kurulum",
"diskPotentiallyShared": "Olası paylaşılan alan",
"diskTemporary": "Geçici boş alan",
"diskDestination": "Hedef",
"diskDestination_hf_model_cache": "Hugging Face model önbelleği birimi",
"diskDestination_engine_data": "VoiceStudio motor verileri birimi",
"diskActualModel": "Gerçek model",
"diskActualEnvironment": "Gerçek ortam",
"diskActualCache": "Gerçek paylaşılan önbellek",
"diskActualTotal": "Gerçek sahip olunan toplam",
"diskEstimateConfidence": "Tahmin güvenilirliği",
"diskActualConfidence": "Ölçüm güvenilirliği",
"diskConfidence_exact": "Kesin",
"diskConfidence_measured": "Ölçüldü",
"diskConfidence_estimated": "Tahmini",
"diskConfidence_unknown": "Bilinmiyor",
"diskDedup_uv_same_volume": "uv, aynı wheel dosyalarını yalnızca önbelleği ve motor ortamı aynı birimdeyse tekilleştirir.",
"sectionReady": "Kullanıma hazır",
"sectionMore": "Daha fazla motor ekle",
"lastError": "Son hata: {{error}}",
@@ -2036,6 +2056,7 @@
"retry_cancelled": "Yeniden deneme iptal edildi",
"transcription_failed": "Transkripsiyon başarısız oldu: {{message}}",
"import_srt_no_job": "Önce bir video yükleyin veya alın; altyazı eklenecek bir iş yoktur.",
"import_srt_after_speakers": "SRT sıraya alındı. Klonlanmış sesleri korumak için önce konuşmacı analizi tamamlanacak.",
"imported_cues": "{{file}}'den {{count}} işaret(ler) içe aktarıldı",
"skipped_malformed": "{{count}} atlandı (hatalı biçimlendirilmiş)",
"dropped_overlap": "{{count}} düştü (örtüşme)",
+21
View File
@@ -743,6 +743,26 @@
"engineCompatLabel": "{{family}} сумісність з двигуном",
"active": "активний",
"whyUnavailable": "Що потрібно",
"diskDetails": "Відомості про диск",
"diskModelDownload": "Завантаження моделі",
"diskPackageDownload": "Завантаження пакетів",
"diskUniqueInstalled": "Унікально встановлено",
"diskPotentiallyShared": "Можливо спільне",
"diskTemporary": "Тимчасове вільне місце",
"diskDestination": "Призначення",
"diskDestination_hf_model_cache": "Том кешу моделей Hugging Face",
"diskDestination_engine_data": "Том даних рушіїв VoiceStudio",
"diskActualModel": "Фактична модель",
"diskActualEnvironment": "Фактичне середовище",
"diskActualCache": "Фактичний спільний кеш",
"diskActualTotal": "Фактичний власний підсумок",
"diskEstimateConfidence": "Достовірність оцінки",
"diskActualConfidence": "Достовірність вимірювання",
"diskConfidence_exact": "Точно",
"diskConfidence_measured": "Виміряно",
"diskConfidence_estimated": "Оцінено",
"diskConfidence_unknown": "Невідомо",
"diskDedup_uv_same_volume": "uv дедуплікує однакові wheels, лише якщо кеш і середовище рушія розташовані на одному томі.",
"sectionReady": "Готові до використання",
"sectionMore": "Додати більше двигунів",
"lastError": "Остання помилка: {{error}}",
@@ -2036,6 +2056,7 @@
"retry_cancelled": "Повторну спробу скасовано",
"transcription_failed": "Помилка транскрипції: {{message}}",
"import_srt_no_job": "Спершу завантажте або завантажте відео — немає завдання додавати субтитри.",
"import_srt_after_speakers": "SRT додано до черги. Спершу завершиться аналіз мовців, щоб зберегти клоновані голоси.",
"imported_cues": "Імпортовано {{count}} репліки з {{file}}",
"skipped_malformed": "{{count}} пропущено (неправильно)",
"dropped_overlap": "{{count}} вилучено (перекриття)",
+21
View File
@@ -743,6 +743,26 @@
"engineCompatLabel": "{{family}} khả năng tương thích động cơ",
"active": "hoạt động",
"whyUnavailable": "Cần những gì",
"diskDetails": "Chi tiết ổ đĩa",
"diskModelDownload": "Tải mô hình",
"diskPackageDownload": "Tải gói",
"diskUniqueInstalled": "Dung lượng cài đặt riêng",
"diskPotentiallyShared": "Có thể dùng chung",
"diskTemporary": "Dung lượng trống tạm thời",
"diskDestination": "Đích",
"diskDestination_hf_model_cache": "Ổ đĩa bộ nhớ đệm mô hình Hugging Face",
"diskDestination_engine_data": "Ổ đĩa dữ liệu động cơ VoiceStudio",
"diskActualModel": "Mô hình thực tế",
"diskActualEnvironment": "Môi trường thực tế",
"diskActualCache": "Bộ nhớ đệm dùng chung thực tế",
"diskActualTotal": "Tổng dung lượng sở hữu thực tế",
"diskEstimateConfidence": "Độ tin cậy của ước tính",
"diskActualConfidence": "Độ tin cậy của phép đo",
"diskConfidence_exact": "Chính xác",
"diskConfidence_measured": "Đã đo",
"diskConfidence_estimated": "Ước tính",
"diskConfidence_unknown": "Không xác định",
"diskDedup_uv_same_volume": "uv chỉ khử trùng lặp các wheel giống nhau khi bộ nhớ đệm và môi trường động cơ nằm trên cùng một ổ đĩa.",
"sectionReady": "Sẵn sàng sử dụng",
"sectionMore": "Thêm động cơ khác",
"lastError": "Lỗi cuối cùng: {{error}}",
@@ -2036,6 +2056,7 @@
"retry_cancelled": "Đã hủy thử lại",
"transcription_failed": "Phiên âm không thành công: {{message}}",
"import_srt_no_job": "Trước tiên hãy tải lên hoặc nhập video — không cần phải đính kèm phụ đề.",
"import_srt_after_speakers": "Đã xếp SRT vào hàng đợi. Phân tích người nói sẽ hoàn tất trước để giữ nguyên giọng nói nhân bản.",
"imported_cues": "Đã nhập {{count}} tín hiệu từ {{file}}",
"skipped_malformed": "{{count}} bị bỏ qua (không đúng định dạng)",
"dropped_overlap": "{{count}} bị rơi (chồng chéo)",
+21
View File
@@ -700,6 +700,26 @@
"engineCompatLabel": "{{family}} 引擎兼容性",
"active": "当前",
"whyUnavailable": "所需条件",
"diskDetails": "磁盘详情",
"diskModelDownload": "模型下载",
"diskPackageDownload": "软件包下载",
"diskUniqueInstalled": "独占安装空间",
"diskPotentiallyShared": "可能共享的空间",
"diskTemporary": "临时可用空间",
"diskDestination": "目标位置",
"diskDestination_hf_model_cache": "Hugging Face 模型缓存卷",
"diskDestination_engine_data": "VoiceStudio 引擎数据卷",
"diskActualModel": "实际模型空间",
"diskActualEnvironment": "实际环境空间",
"diskActualCache": "实际共享缓存",
"diskActualTotal": "实际独占总量",
"diskEstimateConfidence": "估算置信度",
"diskActualConfidence": "测量置信度",
"diskConfidence_exact": "精确",
"diskConfidence_measured": "已测量",
"diskConfidence_estimated": "估算",
"diskConfidence_unknown": "未知",
"diskDedup_uv_same_volume": "仅当 uv 缓存和引擎环境位于同一卷时,uv 才能对相同的 wheel 去重。",
"sectionReady": "即可使用",
"sectionMore": "添加更多引擎",
"lastError": "最后一个错误:{{error}}",
@@ -2043,6 +2063,7 @@
"retry_cancelled": "重试已取消",
"transcription_failed": "转录失败:{{message}}",
"import_srt_no_job": "请先上传或导入视频——尚无作业可附加字幕。",
"import_srt_after_speakers": "SRT 已加入队列。系统会先完成说话人分析,以保留克隆声音。",
"imported_cues": "从 {{file}} 导入了 {{count}} 条字幕提示",
"skipped_malformed": "{{count}} 条已跳过(格式错误)",
"dropped_overlap": "{{count}} 条因重叠被丢弃",
+21
View File
@@ -743,6 +743,26 @@
"engineCompatLabel": "{{family}} 引擎相容性",
"active": "活躍的",
"whyUnavailable": "所需條件",
"diskDetails": "磁碟詳細資料",
"diskModelDownload": "模型下載",
"diskPackageDownload": "套件下載",
"diskUniqueInstalled": "獨占安裝空間",
"diskPotentiallyShared": "可能共用的空間",
"diskTemporary": "暫時可用空間",
"diskDestination": "目的地",
"diskDestination_hf_model_cache": "Hugging Face 模型快取磁碟區",
"diskDestination_engine_data": "VoiceStudio 引擎資料磁碟區",
"diskActualModel": "實際模型空間",
"diskActualEnvironment": "實際環境空間",
"diskActualCache": "實際共用快取",
"diskActualTotal": "實際獨占總量",
"diskEstimateConfidence": "估算可信度",
"diskActualConfidence": "測量可信度",
"diskConfidence_exact": "精確",
"diskConfidence_measured": "已測量",
"diskConfidence_estimated": "估算",
"diskConfidence_unknown": "未知",
"diskDedup_uv_same_volume": "只有在 uv 快取與引擎環境位於同一磁碟區時,uv 才能對相同的 wheel 去除重複。",
"sectionReady": "隨時可用",
"sectionMore": "新增更多引擎",
"lastError": "最後一個錯誤:{{error}}",
@@ -2036,6 +2056,7 @@
"retry_cancelled": "重試已取消",
"transcription_failed": "轉錄失敗:{{message}}",
"import_srt_no_job": "首先上传或摄取视频 - 没有附加字幕的作业。",
"import_srt_after_speakers": "SRT 已加入佇列。系統會先完成說話者分析,以保留複製語音。",
"imported_cues": "從 {{file}} 導入了 {{count}} 提示",
"skipped_malformed": "{{count}} 已跳過(格式錯誤)",
"dropped_overlap": "{{count}} 掉落(重疊)",
@@ -102,7 +102,7 @@ describe('useBootstrapStage — awaiting_setup is human-gated (#1376)', () => {
expect(result.current.stage).toBe('installing_deps');
});
it('a machine-owned stage that genuinely wedges still fails', async () => {
it('lets Rust finish its backend startup budget before declaring a wedge', async () => {
// The other half of the contract. Exempting awaiting_setup must not
// disarm the stall detector for stages nothing but the app can advance
// that would trade this bug for the info-less infinite spinner (#879).
@@ -114,10 +114,28 @@ describe('useBootstrapStage — awaiting_setup is human-gated (#1376)', () => {
await act(async () => {});
expect(result.current.stage).toBe('starting_backend');
// The shell waits five minutes for slow torch/CUDA imports. The splash
// must not replace that live launch with its old two-minute false failure.
await act(async () => {
await vi.advanceTimersByTimeAsync(3 * 60 * 1000);
});
expect(result.current.stage).toBe('starting_backend');
expect(result.current.message ?? '').not.toMatch(/stuck/i);
// Pin both sides of the six-minute fallback boundary.
await act(async () => {
await vi.advanceTimersByTimeAsync(3 * 60 * 1000 - 1_000);
});
expect(result.current.stage).toBe('starting_backend');
expect(result.current.message ?? '').not.toMatch(/stuck/i);
// A frontend-only deadlock remains bounded if the shell never advances.
await act(async () => {
await vi.advanceTimersByTimeAsync(2_000);
});
expect(result.current.stage).toBe('failed');
expect(result.current.message).toMatch(/stuck/i);
});
@@ -68,6 +68,7 @@ import { requestDictationCapture } from '../utils/dictationCapture';
/** Route the invoke mock per command. */
function stubInvoke({ mic = 'granted' } = {}) {
invokeMock.mockImplementation(async (cmd, payload) => {
if (cmd === 'begin_dictation_capture_registration') return 1;
if (cmd === 'check_microphone') return mic;
if (cmd === 'check_accessibility') return true;
if (cmd === 'request_dictation_capture') {
@@ -48,6 +48,7 @@ vi.mock('react-hot-toast', () => ({ toast: { error: vi.fn() } }));
vi.mock('@tauri-apps/api/core', () => ({
invoke: vi.fn(async (cmd) => {
mocks.holder.invoked.push(cmd);
if (cmd === 'begin_dictation_capture_registration') return 1;
if (cmd === 'check_accessibility') return mocks.holder.a11y;
return undefined;
}),
@@ -10,7 +10,7 @@ import React from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
const { toastMock, eventHandlers, eventState } = vi.hoisted(() => ({
const { toastMock, eventHandlers, eventState, eventUnlisteners } = vi.hoisted(() => ({
toastMock: Object.assign(vi.fn(), {
error: vi.fn(),
success: vi.fn(),
@@ -19,6 +19,7 @@ const { toastMock, eventHandlers, eventState } = vi.hoisted(() => ({
}),
eventHandlers: {},
eventState: { pendingStart: false },
eventUnlisteners: [],
}));
vi.mock('react-hot-toast', () => ({ default: toastMock, toast: toastMock }));
@@ -29,7 +30,9 @@ vi.mock('@tauri-apps/api/core', () => ({
vi.mock('@tauri-apps/api/event', () => ({
listen: vi.fn(async (name, handler) => {
eventHandlers[name] = handler;
return () => delete eventHandlers[name];
const unlisten = vi.fn(() => delete eventHandlers[name]);
eventUnlisteners.push(unlisten);
return unlisten;
}),
}));
vi.mock('@tauri-apps/api/window', () => ({
@@ -119,6 +122,7 @@ beforeEach(() => {
window.__TAURI_INTERNALS__ = {};
invokeMock.mockReset();
invokeMock.mockImplementation(async (cmd) => {
if (cmd === 'begin_dictation_capture_registration') return 1;
if (cmd === 'check_microphone') return 'granted';
if (cmd === 'check_accessibility') return true;
if (cmd === 'mark_dictation_capture_ready' && eventState.pendingStart) {
@@ -130,6 +134,7 @@ beforeEach(() => {
return undefined;
});
eventState.pendingStart = false;
eventUnlisteners.length = 0;
FakeWS.instances = [];
storeState.dictationModelId = 'sherpa-parakeet-v3';
realWebSocket = globalThis.WebSocket;
@@ -155,6 +160,38 @@ afterEach(() => {
});
describe('CaptureWidget — connect-time asr_model_missing during mic setup', () => {
it('removes both native listeners when registration readiness fails', async () => {
invokeMock.mockImplementation(async (cmd) => {
if (cmd === 'begin_dictation_capture_registration') return 1;
if (cmd === 'mark_dictation_capture_ready') throw new Error('registration failed');
return undefined;
});
render(<CaptureWidget />);
await waitFor(() => expect(eventUnlisteners).toHaveLength(2));
await waitFor(() =>
expect(eventUnlisteners.every((unlisten) => unlisten.mock.calls.length)).toBe(true),
);
expect(invokeMock).toHaveBeenCalledWith('end_dictation_capture_registration', {
registrationId: 1,
});
});
it('acknowledges a queued native event after the listener receives it', async () => {
render(<CaptureWidget />);
await waitFor(() => expect(eventHandlers['tray-dictate-stop']).toBeTypeOf('function'));
await eventHandlers['tray-dictate-stop']({
payload: { sessionId: 7, deliveryId: 9, registrationId: 1 },
});
expect(invokeMock).toHaveBeenCalledWith('acknowledge_dictation_capture_delivery', {
registrationId: 1,
deliveryId: 9,
});
});
it('turns a PCM-fallback socket failure into a terminal error', async () => {
storeState.dictationModelId = 'whisperx';
render(<CaptureWidget />);
@@ -62,6 +62,7 @@ vi.mock('../utils/copyText', () => ({ copyText: vi.fn(async () => {}) }));
vi.mock('react-hot-toast', () => ({ toast: { error: vi.fn() } }));
vi.mock('@tauri-apps/api/core', () => ({
invoke: async (cmd) => {
if (cmd === 'begin_dictation_capture_registration') return 1;
if (cmd === 'check_accessibility') return mocks.holder.a11y;
return undefined;
},
@@ -1,6 +1,6 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
// Mock the toast import the component depends on keeps the test free
// of side-effect side-channels (toast() schedules timers we don't want).
@@ -19,6 +19,7 @@ vi.mock('../api/system', () => ({
import EngineCompatibilityMatrix, {
FORCE_WAIT_TIMEOUT_MS,
fmtDiskBytes,
} from '../components/EngineCompatibilityMatrix';
/** Build a minimal AllEnginesResponse with the three rows the plan calls for. */
@@ -69,6 +70,10 @@ describe('EngineCompatibilityMatrix', () => {
vi.useRealTimers();
});
it('formats disk sizes with the active locale', () => {
expect(fmtDiskBytes(1.5 * 1024 ** 3, 'unknown', 'de-DE')).toMatch(/^1,50\sGB$/u);
});
it('lists available engines first, keeping registration order inside each group', async () => {
// The fixture is deliberately interleaved (available, UNavailable,
// available). A matrix that renders it in payload order buries a usable
@@ -139,6 +144,100 @@ describe('EngineCompatibilityMatrix', () => {
]);
});
it('shows separate estimates and measured disk categories on demand', async () => {
const response = makeEnginesResponse();
response.tts.backends[0].disk_usage = {
estimate: {
model_download_bytes: 2 * 1024 ** 3,
package_download_bytes: null,
unique_installed_bytes: 3 * 1024 ** 3,
potentially_shared_bytes: null,
temporary_free_bytes: 4 * 1024 ** 3,
destination: 'hf_model_cache',
confidence: 'estimated',
deduplication: null,
},
actual: {},
};
const apiGetDiskUsage = vi.fn().mockResolvedValue({
estimate: response.tts.backends[0].disk_usage.estimate,
actual: {
model_bytes: 1024 ** 3,
environment_bytes: null,
cache_bytes: 0,
total_owned_bytes: 1024 ** 3,
confidence: 'measured',
},
});
render(
<EngineCompatibilityMatrix
family="tts"
apiListEngines={vi.fn().mockResolvedValue(response)}
apiGetEngineHealth={vi.fn()}
apiGetDiskUsage={apiGetDiskUsage}
/>,
);
await screen.findByText('OmniVoice (test)');
fireEvent.click(screen.getByRole('button', { name: 'Disk details' }));
await waitFor(() => expect(apiGetDiskUsage).toHaveBeenCalledWith('omnivoice'));
const details = await screen.findByTestId('disk-usage-omnivoice');
expect(within(details).getByText('Model download')).toBeInTheDocument();
expect(within(details).getByText('Package download')).toBeInTheDocument();
expect(within(details).getByText('Actual environment')).toBeInTheDocument();
expect(within(details).getByText('Estimate confidence')).toBeInTheDocument();
expect(within(details).getByText('Estimated')).toBeInTheDocument();
expect(within(details).getByText('Measurement confidence')).toBeInTheDocument();
expect(within(details).getByText('Measured')).toBeInTheDocument();
expect(within(details).getAllByText('1.00 GB')).toHaveLength(2);
expect(within(details).getAllByText('unknown').length).toBeGreaterThan(0);
});
it('ignores a disk measurement that finishes after engine data reloads', async () => {
const response = makeEnginesResponse();
response.tts.backends[0].disk_usage = {
estimate: {
model_download_bytes: 2 * 1024 ** 3,
confidence: 'estimated',
},
actual: {},
};
let resolveMeasurement;
const apiGetDiskUsage = vi.fn(() => new Promise((resolve) => (resolveMeasurement = resolve)));
const apiListEngines = vi.fn().mockResolvedValue(response);
const view = render(
<EngineCompatibilityMatrix
family="tts"
reloadToken={0}
apiListEngines={apiListEngines}
apiGetEngineHealth={vi.fn()}
apiGetDiskUsage={apiGetDiskUsage}
/>,
);
await screen.findByText('OmniVoice (test)');
fireEvent.click(screen.getByRole('button', { name: 'Disk details' }));
await waitFor(() => expect(apiGetDiskUsage).toHaveBeenCalledOnce());
view.rerender(
<EngineCompatibilityMatrix
family="tts"
reloadToken={1}
apiListEngines={apiListEngines}
apiGetEngineHealth={vi.fn()}
apiGetDiskUsage={apiGetDiskUsage}
/>,
);
await waitFor(() => expect(apiListEngines).toHaveBeenCalledTimes(2));
await act(async () => {
resolveMeasurement({
estimate: response.tts.backends[0].disk_usage.estimate,
actual: { model_bytes: 9 * 1024 ** 3, confidence: 'measured' },
});
});
expect(screen.queryByText('9.00 GB')).not.toBeInTheDocument();
});
it('shows isolation_mode badge per row (subprocess for IndexTTS, in-process for the others)', async () => {
const apiListEngines = vi.fn().mockResolvedValue(makeEnginesResponse());
render(
@@ -247,7 +346,9 @@ describe('EngineCompatibilityMatrix', () => {
await waitFor(() => screen.getByText('IndexTTS2 (test)'));
const indexRow = screen.getByText('IndexTTS2 (test)').closest('[role="row"]');
const testBtn = within(indexRow).getByRole('button', { name: /test indextts2/i });
const testBtn = within(indexRow).getByRole('button', {
name: /test indextts2/i,
});
fireEvent.click(testBtn);
await waitFor(() => {
@@ -279,7 +380,9 @@ describe('EngineCompatibilityMatrix', () => {
await waitFor(() => screen.getByText('IndexTTS2 (test)'));
const indexRow = screen.getByText('IndexTTS2 (test)').closest('[role="row"]');
const testBtn = within(indexRow).getByRole('button', { name: /test indextts2/i });
const testBtn = within(indexRow).getByRole('button', {
name: /test indextts2/i,
});
fireEvent.click(testBtn);
await waitFor(() => {
@@ -291,7 +394,12 @@ describe('EngineCompatibilityMatrix', () => {
expect(apiGetEngineHealth).toHaveBeenCalledTimes(1);
// Release the promise so the test doesn't leak a pending microtask.
resolveHealth({ id: 'indextts2', ok: true, message: 'pong', latency_ms: 50 });
resolveHealth({
id: 'indextts2',
ok: true,
message: 'pong',
latency_ms: 50,
});
});
// #21 routing display
@@ -336,7 +444,11 @@ describe('EngineCompatibilityMatrix', () => {
routing_reason: 'requires cuda; this host has cpu',
}),
// Legacy payload: no routing_* keys render exactly as before.
base({ id: 'legacy', display_name: 'Legacy TTS', gpu_compat: ['cpu'] }),
base({
id: 'legacy',
display_name: 'Legacy TTS',
gpu_compat: ['cpu'],
}),
],
},
asr: { active: '', backends: [] },
@@ -482,9 +594,12 @@ describe('EngineCompatibilityMatrix', () => {
// P3-B: in-process health check reads as a liveness/deps check
it('labels an in-process health check "deps OK" while subprocess shows real ms', async () => {
const apiListEngines = vi.fn().mockResolvedValue(makeEnginesResponse());
const apiGetEngineHealth = vi
.fn()
.mockResolvedValue({ id: 'omnivoice', ok: true, message: 'import ok', latency_ms: 0 });
const apiGetEngineHealth = vi.fn().mockResolvedValue({
id: 'omnivoice',
ok: true,
message: 'import ok',
latency_ms: 0,
});
render(
<EngineCompatibilityMatrix
family="tts"
@@ -598,7 +713,9 @@ describe('EngineCompatibilityMatrix', () => {
expect(screen.queryByText('Supertonic-3 — License Acceptance')).not.toBeInTheDocument();
fireEvent.click(
screen.getByRole('button', { name: /review and accept supertonic-3 license/i }),
screen.getByRole('button', {
name: /review and accept supertonic-3 license/i,
}),
);
await waitFor(() => {
expect(screen.getByText('Supertonic-3 — License Acceptance')).toBeInTheDocument();
@@ -634,7 +751,11 @@ describe('EngineCompatibilityMatrix', () => {
);
await waitFor(() => screen.getByText('PocketTTS'));
fireEvent.click(screen.getByRole('button', { name: /review and accept pockettts license/i }));
fireEvent.click(
screen.getByRole('button', {
name: /review and accept pockettts license/i,
}),
);
await waitFor(() => {
expect(screen.getByText('PocketTTS License Acceptance')).toBeInTheDocument();
expect(screen.getByText('Review the access conditions')).toBeInTheDocument();
@@ -829,7 +950,11 @@ describe('EngineCompatibilityMatrix', () => {
label: 'Kokoro (default, fast)',
repo_id: 'mlx-community/Kokoro-82M-bf16',
},
{ key: 'csm', label: 'CSM (voice cloning)', repo_id: 'mlx-community/csm-1b-8bit' },
{
key: 'csm',
label: 'CSM (voice cloning)',
repo_id: 'mlx-community/csm-1b-8bit',
},
{
key: 'outetts',
label: 'OuteTTS',
@@ -0,0 +1,396 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useAppStore } from '../store';
const dubApi = vi.hoisted(() => ({
dubUpload: vi.fn(),
dubIngestUrl: vi.fn(),
dubAbort: vi.fn(),
dubCleanupSegments: vi.fn(),
dubTranslate: vi.fn(),
dubGenerate: vi.fn(),
tasksStreamUrl: vi.fn((taskId) => `/tasks/${taskId}`),
tasksCancel: vi.fn(),
transcribeStreamUrl: vi.fn((jobId) => `/transcribe/${jobId}`),
dubImportSrt: vi.fn(),
}));
vi.mock('../api/dub', () => ({
...dubApi,
DUB_COOKIE_TRANSPORT_ERROR: 'cookie_transport_error',
DUB_COOKIE_SIZE_ERROR: 'cookie_size_error',
}));
const setupApi = vi.hoisted(() => ({
cancelInstallModel: vi.fn(),
}));
vi.mock('../api/setup', () => ({
...setupApi,
installModel: vi.fn(),
listModels: vi.fn(),
setupDownloadStreamUrl: () => '/setup/download-stream',
}));
vi.mock('../api/client', () => ({
apiPost: vi.fn(),
apiFetch: vi.fn(),
apiJson: vi.fn(),
API: '',
}));
import useDubWorkflow, { shouldQueueSrtImport } from '../hooks/useDubWorkflow';
const baseState = useAppStore.getState();
let streams;
class FakeEventSource {
static CLOSED = 2;
constructor(url) {
this.url = url;
this.readyState = 1;
this.listeners = new Map();
streams.push(this);
}
addEventListener(name, handler) {
this.listeners.set(name, handler);
}
close() {
this.readyState = FakeEventSource.CLOSED;
}
emit(name, data = {}) {
const event = { data: JSON.stringify(data) };
if (name === 'message') this.onmessage?.(event);
else this.listeners.get(name)?.(event);
}
}
function renderWorkflow() {
return renderHook(() =>
useDubWorkflow({
loadProjects: vi.fn(),
loadProfiles: vi.fn(),
loadDubHistory: vi.fn(),
setLastGenFingerprints: vi.fn(),
}),
);
}
describe('SRT import during source-speaker analysis', () => {
beforeEach(() => {
streams = [];
globalThis.EventSource = FakeEventSource;
useAppStore.setState(baseState, true);
useAppStore.setState({ dubJobId: '', dubStep: 'idle', dubSegments: [] });
for (const mock of Object.values(dubApi)) mock.mockReset?.();
dubApi.tasksStreamUrl.mockImplementation((taskId) => `/tasks/${taskId}`);
dubApi.transcribeStreamUrl.mockImplementation((jobId) => `/transcribe/${jobId}`);
dubApi.dubAbort.mockResolvedValue({});
dubApi.dubImportSrt.mockResolvedValue({
segments: [{ id: 'srt', text: 'selected subtitle' }],
stats: { imported: 1 },
});
setupApi.cancelInstallModel.mockReset().mockResolvedValue({});
});
it('queues only while source analysis is incomplete', () => {
expect(shouldQueueSrtImport('uploading')).toBe(true);
expect(shouldQueueSrtImport('transcribing')).toBe(true);
expect(shouldQueueSrtImport('transcribing', true)).toBe(false);
expect(shouldQueueSrtImport('editing')).toBe(false);
});
it.each([
['upload', 'job-upload'],
['URL ingest', 'job-url'],
])('applies the selected file after %s analysis completes', async (source, jobId) => {
const file = new File(['subtitle'], 'selected.srt');
const { result } = renderWorkflow();
let operation;
if (source === 'upload') {
dubApi.dubUpload.mockResolvedValue({ job_id: jobId, task_id: `prep-${jobId}` });
act(() => {
operation = result.current.handleDubUpload(new File(['video'], 'source.mp4'));
});
} else {
dubApi.dubIngestUrl.mockResolvedValue({ job_id: jobId, task_id: `prep-${jobId}` });
act(() => {
operation = result.current.handleDubIngestUrl('https://example.test/video');
});
}
await waitFor(() => expect(streams).toHaveLength(1));
act(() => streams[0].emit('message', { type: 'ready' }));
await waitFor(() => expect(useAppStore.getState().dubStep).toBe('transcribing'));
await act(async () => result.current.handleDubImportSrt(file));
expect(dubApi.dubImportSrt).not.toHaveBeenCalled();
await waitFor(() => expect(streams).toHaveLength(2));
act(() => {
streams[1].emit('final', { segments: [{ id: 'asr', text: 'generated' }] });
streams[1].emit('done');
});
await act(async () => operation);
expect(dubApi.dubImportSrt).toHaveBeenCalledWith(jobId, file, {
signal: expect.any(AbortSignal),
});
expect(useAppStore.getState().dubSegments).toEqual([
expect.objectContaining({ id: 'srt', text: 'selected subtitle' }),
]);
expect(useAppStore.getState().dubStep).toBe('editing');
});
it('retains the queued file through transcription and import failures until retry succeeds', async () => {
useAppStore.setState({ dubJobId: 'job-retry', dubStep: 'transcribing' });
const file = new File(['subtitle'], 'retry.srt');
dubApi.dubImportSrt
.mockRejectedValueOnce(new Error('SRT import failed'))
.mockResolvedValueOnce({
segments: [{ id: 'srt', text: 'selected subtitle' }],
stats: { imported: 1 },
});
const { result } = renderWorkflow();
await act(async () => result.current.handleDubImportSrt(file));
let failedAttempt;
act(() => {
failedAttempt = result.current.handleDubRetryTranscribe();
});
await waitFor(() => expect(streams).toHaveLength(1));
act(() => streams[0].emit('error', { detail: 'transcription failed' }));
await act(async () => failedAttempt);
expect(dubApi.dubImportSrt).not.toHaveBeenCalled();
let importFailure;
act(() => {
importFailure = result.current.handleDubRetryTranscribe();
});
await waitFor(() => expect(streams).toHaveLength(2));
act(() => {
streams[1].emit('final', { segments: [{ id: 'asr', text: 'generated' }] });
streams[1].emit('done');
});
await act(async () => importFailure);
expect(dubApi.dubImportSrt).toHaveBeenCalledOnce();
expect(useAppStore.getState().dubError).toBe('SRT import failed');
expect(useAppStore.getState().dubStep).toBe('editing');
let successfulRetry;
act(() => {
successfulRetry = result.current.handleDubRetryTranscribe();
});
await waitFor(() => expect(streams).toHaveLength(3));
act(() => {
streams[2].emit('final', { segments: [{ id: 'asr', text: 'generated again' }] });
streams[2].emit('done');
});
await act(async () => successfulRetry);
expect(dubApi.dubImportSrt).toHaveBeenLastCalledWith('job-retry', file, {
signal: expect.any(AbortSignal),
});
expect(dubApi.dubImportSrt).toHaveBeenCalledTimes(2);
expect(useAppStore.getState().dubSegments[0].text).toBe('selected subtitle');
});
it('replaces a queued file when the user imports a newer SRT after transcription fails', async () => {
useAppStore.setState({ dubJobId: 'job-replace', dubStep: 'transcribing' });
const oldFile = new File(['old'], 'old.srt');
const newFile = new File(['new'], 'new.srt');
const { result } = renderWorkflow();
await act(async () => result.current.handleDubImportSrt(oldFile));
let failedAttempt;
act(() => {
failedAttempt = result.current.handleDubRetryTranscribe();
});
await waitFor(() => expect(streams).toHaveLength(1));
act(() => streams[0].emit('error', { detail: 'transcription failed' }));
await act(async () => failedAttempt);
await waitFor(() => expect(useAppStore.getState().dubStep).toBe('idle'));
await act(async () => result.current.handleDubImportSrt(newFile));
expect(dubApi.dubImportSrt).toHaveBeenCalledOnce();
expect(dubApi.dubImportSrt.mock.calls[0][1]).toBe(newFile);
let retry;
act(() => {
retry = result.current.handleDubRetryTranscribe();
});
await waitFor(() => expect(streams).toHaveLength(2));
act(() => {
streams[1].emit('final', { segments: [{ id: 'asr', text: 'generated' }] });
streams[1].emit('done');
});
await act(async () => retry);
expect(dubApi.dubImportSrt).toHaveBeenCalledOnce();
expect(useAppStore.getState().dubSegments[0].text).toBe('generated');
});
it('ignores an older manual import that finishes after a newer one', async () => {
useAppStore.setState({ dubJobId: 'job-current', dubStep: 'editing', dubSegments: [] });
const oldFile = new File(['old'], 'old.srt');
const newFile = new File(['new'], 'new.srt');
const resolvers = new Map();
dubApi.dubImportSrt.mockImplementation(
(_jobId, file) =>
new Promise((resolve) => {
resolvers.set(file.name, resolve);
}),
);
const { result } = renderWorkflow();
let oldImport;
let newImport;
act(() => {
oldImport = result.current.handleDubImportSrt(oldFile);
newImport = result.current.handleDubImportSrt(newFile);
});
await waitFor(() => expect(dubApi.dubImportSrt).toHaveBeenCalledTimes(2));
resolvers.get('new.srt')({ segments: [{ id: 'new', text: 'new subtitle' }] });
await act(async () => newImport);
resolvers.get('old.srt')({ segments: [{ id: 'old', text: 'old subtitle' }] });
await act(async () => oldImport);
expect(useAppStore.getState().dubSegments).toEqual([
expect.objectContaining({ id: 'new', text: 'new subtitle' }),
]);
});
it('imports a replacement selected while the deferred import is awaiting', async () => {
const oldFile = new File(['old'], 'old.srt');
const newFile = new File(['new'], 'new.srt');
const resolvers = new Map();
dubApi.dubUpload.mockResolvedValue({ job_id: 'job-replace-live', task_id: 'prep-live' });
dubApi.dubImportSrt.mockImplementation(
(_jobId, file) =>
new Promise((resolve) => {
resolvers.set(file.name, resolve);
}),
);
const { result } = renderWorkflow();
let upload;
act(() => {
upload = result.current.handleDubUpload(new File(['video'], 'source.mp4'));
});
await waitFor(() => expect(streams).toHaveLength(1));
act(() => streams[0].emit('message', { type: 'ready' }));
await waitFor(() => expect(useAppStore.getState().dubStep).toBe('transcribing'));
await act(async () => result.current.handleDubImportSrt(oldFile));
await waitFor(() => expect(streams).toHaveLength(2));
act(() => {
streams[1].emit('final', { segments: [{ id: 'asr', text: 'generated' }] });
streams[1].emit('done');
});
await waitFor(() => expect(dubApi.dubImportSrt).toHaveBeenCalledOnce());
await act(async () => result.current.handleDubImportSrt(newFile));
act(() => resolvers.get('old.srt')({ segments: [{ id: 'old', text: 'old subtitle' }] }));
await waitFor(() => expect(dubApi.dubImportSrt).toHaveBeenCalledTimes(2));
expect(dubApi.dubImportSrt.mock.calls[1][1]).toBe(newFile);
act(() => resolvers.get('new.srt')({ segments: [{ id: 'new', text: 'new subtitle' }] }));
await act(async () => upload);
expect(useAppStore.getState().dubSegments).toEqual([
expect.objectContaining({ id: 'new', text: 'new subtitle' }),
]);
expect(useAppStore.getState().dubStep).toBe('editing');
});
it('ignores an older manual import failure after a newer import succeeds', async () => {
useAppStore.setState({ dubJobId: 'job-current', dubStep: 'editing', dubSegments: [] });
const oldFile = new File(['old'], 'old.srt');
const newFile = new File(['new'], 'new.srt');
const promises = new Map();
dubApi.dubImportSrt.mockImplementation(
(_jobId, file) =>
new Promise((resolve, reject) => {
promises.set(file.name, { resolve, reject });
}),
);
const { result } = renderWorkflow();
let oldImport;
let newImport;
act(() => {
oldImport = result.current.handleDubImportSrt(oldFile);
newImport = result.current.handleDubImportSrt(newFile);
});
await waitFor(() => expect(dubApi.dubImportSrt).toHaveBeenCalledTimes(2));
promises.get('new.srt').resolve({ segments: [{ id: 'new', text: 'new subtitle' }] });
await act(async () => newImport);
promises.get('old.srt').reject(new Error('old import failed'));
await act(async () => oldImport);
expect(useAppStore.getState().dubSegments).toEqual([
expect.objectContaining({ id: 'new', text: 'new subtitle' }),
]);
expect(useAppStore.getState().dubError).toBe('');
});
it('aborts a deferred import without applying its result', async () => {
const file = new File(['subtitle'], 'abort.srt');
dubApi.dubUpload.mockResolvedValue({ job_id: 'job-abort', task_id: 'prep-abort' });
dubApi.dubImportSrt.mockImplementation(
(_jobId, _file, { signal }) =>
new Promise((_resolve, reject) => {
signal.addEventListener(
'abort',
() => reject(Object.assign(new Error('aborted'), { name: 'AbortError' })),
{ once: true },
);
}),
);
const { result } = renderWorkflow();
let upload;
act(() => {
upload = result.current.handleDubUpload(new File(['video'], 'source.mp4'));
});
await waitFor(() => expect(streams).toHaveLength(1));
act(() => streams[0].emit('message', { type: 'ready' }));
await waitFor(() => expect(useAppStore.getState().dubStep).toBe('transcribing'));
await act(async () => result.current.handleDubImportSrt(file));
await waitFor(() => expect(streams).toHaveLength(2));
act(() => {
streams[1].emit('final', { segments: [{ id: 'asr', text: 'generated' }] });
streams[1].emit('done');
});
await waitFor(() => expect(dubApi.dubImportSrt).toHaveBeenCalledOnce());
await act(async () => result.current.handleDubAbort());
await act(async () => upload);
expect(useAppStore.getState().dubSegments[0].text).toBe('generated');
expect(useAppStore.getState().dubStep).toBe('idle');
});
it('ignores a completed import after another job replaces it', async () => {
useAppStore.setState({ dubJobId: 'job-old', dubStep: 'editing', dubSegments: [] });
let resolveImport;
dubApi.dubImportSrt.mockImplementation(
() =>
new Promise((resolve) => {
resolveImport = resolve;
}),
);
const { result } = renderWorkflow();
let importOperation;
act(() => {
importOperation = result.current.handleDubImportSrt(new File(['subtitle'], 'old.srt'));
});
await waitFor(() => expect(dubApi.dubImportSrt).toHaveBeenCalledOnce());
act(() => useAppStore.setState({ dubJobId: 'job-new', dubSegments: [] }));
resolveImport({ segments: [{ id: 'stale', text: 'stale subtitle' }] });
await act(async () => importOperation);
expect(useAppStore.getState().dubJobId).toBe('job-new');
expect(useAppStore.getState().dubSegments).toEqual([]);
});
});
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env python3
"""Build the updater manifest for the non-elevated Windows MSI channel."""
from __future__ import annotations
import datetime
from pathlib import Path
from urllib.parse import quote
def build_manifest(*, repo: str, tag: str, version: str, asset: str, signature: str) -> dict:
base = f"https://github.com/{repo}/releases/download/{quote(tag, safe='')}/"
entry = {"signature": signature.strip(), "url": base + quote(asset)}
return {
"version": version,
"notes": "VoiceStudio update for the per-user Windows installation.",
"pub_date": datetime.datetime.now(datetime.timezone.utc)
.isoformat(timespec="milliseconds")
.replace("+00:00", "Z"),
"platforms": {
"windows-x86_64": entry,
"windows-x86_64-msi": entry,
},
}
def write_manifest(path: Path, manifest: dict) -> None:
import json
path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
def main() -> None:
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("--repo", required=True)
parser.add_argument("--tag", required=True)
parser.add_argument("--version", required=True)
parser.add_argument("--asset", required=True)
parser.add_argument("--signature-file", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
write_manifest(
args.output,
build_manifest(
repo=args.repo,
tag=args.tag,
version=args.version,
asset=args.asset,
signature=args.signature_file.read_text(encoding="utf-8"),
),
)
if __name__ == "__main__":
main()
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""Render the supported per-user MSI template from the canonical WiX source."""
from __future__ import annotations
import argparse
from pathlib import Path
WEBVIEW_ACTIONS_START = " <!-- BEGIN WEBVIEW_INSTALL_ACTIONS -->"
WEBVIEW_ACTIONS_END = " <!-- END WEBVIEW_INSTALL_ACTIONS -->"
TRANSFORMS = (
('InstallScope="perMachine"', 'InstallScope="perUser"'),
(
'Id="PrevInstallDirNoName" Root="HKLM"',
'Id="PrevInstallDirNoName" Root="HKCU"',
),
(
'Id="PrevInstallDirWithName" Root="HKLM"',
'Id="PrevInstallDirWithName" Root="HKCU"',
),
(
'<Directory Id="$(var.PlatformProgramFilesFolder)" Name="PFiles">',
'<Directory Id="LocalAppDataFolder" Name="LocalAppData">',
),
(
'<RegistryKey Root="HKLM" Key="Software\\\\{{manufacturer}}\\\\{{product_name}}">',
'<RegistryKey Root="HKCU" Key="Software\\\\{{manufacturer}}\\\\{{product_name}}">',
),
('Value="perMachine"', 'Value="perUser"'),
(
'Guid="{{path_component_guid}}"',
'Guid="41f6d598-8908-4004-9332-291b64fd38be"',
),
(
r'<RegistryKey Root="HKLM" Key="Software\Classes\\{{protocol}}">',
r'<RegistryKey Root="HKCU" Key="Software\Classes\\{{protocol}}">',
),
(
' <!-- Managed-deployment switches. Explicit allow is required for network bootstrap. -->\n'
' <Property Id="ALLOWWEBVIEW2BOOTSTRAP" Secure="yes" />\n'
' <Property Id="DISABLEWEBVIEW2BOOTSTRAP" Secure="yes" />',
' <!-- The current-user bundle never installs or updates WebView2. -->',
),
(
' <Condition Message="Microsoft Edge WebView2 Runtime is required. Install the Evergreen Standalone Runtime first, or explicitly set ALLOWWEBVIEW2BOOTSTRAP=1."><![CDATA[Installed OR REMOVE OR INSTALLED_WEBVIEW2_VERSION OR (ALLOWWEBVIEW2BOOTSTRAP = "1" AND DISABLEWEBVIEW2BOOTSTRAP <> "1")]]></Condition>',
' <Condition Message="Microsoft Edge WebView2 Runtime is required. Install the Evergreen Runtime for the current user first."><![CDATA[Installed OR REMOVE OR INSTALLED_WEBVIEW2_VERSION]]></Condition>',
),
)
def render(source: str) -> str:
rendered = source
for old, new in TRANSFORMS:
count = rendered.count(old)
if count != 1:
raise ValueError(f"expected exactly one WiX token, found {count}: {old}")
rendered = rendered.replace(old, new)
start_count = rendered.count(WEBVIEW_ACTIONS_START)
end_count = rendered.count(WEBVIEW_ACTIONS_END)
if (start_count, end_count) != (1, 1):
raise ValueError(
"expected exactly one marked WebView2 action block, "
f"found start={start_count}, end={end_count}"
)
start = rendered.index(WEBVIEW_ACTIONS_START)
end = rendered.index(WEBVIEW_ACTIONS_END, start) + len(WEBVIEW_ACTIONS_END)
rendered = (
rendered[:start]
+ " <!-- WebView2 is a prerequisite for current-user installs. -->"
+ rendered[end:]
)
return rendered
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--source", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(render(args.source.read_text(encoding="utf-8")), encoding="utf-8")
if __name__ == "__main__":
main()
+37
View File
@@ -0,0 +1,37 @@
param(
[Parameter(Mandatory = $true)]
[string]$MsiPath
)
$ErrorActionPreference = "Stop"
$user = "VoiceStudioMsiTest"
$password = "VsMsi-Test-42!"
$secure = ConvertTo-SecureString $password -AsPlainText -Force
$credential = New-Object System.Management.Automation.PSCredential("$env:COMPUTERNAME\$user", $secure)
$resolved = (Resolve-Path $MsiPath).Path
$createdUser = $false
try {
net user $user $password /add | Out-Null
if ($LASTEXITCODE -ne 0) { throw "test-user creation exited $LASTEXITCODE" }
$createdUser = $true
$install = Start-Process msiexec.exe -Credential $credential -LoadUserProfile -Wait -PassThru -ArgumentList @(
"/i", "`"$resolved`"", "/qn", "/norestart", "DISABLEWEBVIEW2BOOTSTRAP=1", "AUTOLAUNCHAPP=0"
)
if ($install.ExitCode -ne 0) { throw "standard-user install exited $($install.ExitCode)" }
$root = "C:\Users\$user\AppData\Local\VoiceStudio (Current User)"
if (-not (Test-Path "$root\omnivoice-studio.exe")) { throw "per-user shell missing at $root" }
if (-not (Test-Path "$root\uv.exe")) { throw "per-user uv sidecar missing at $root" }
$uninstall = Start-Process msiexec.exe -Credential $credential -LoadUserProfile -Wait -PassThru -ArgumentList @(
"/x", "`"$resolved`"", "/qn", "/norestart"
)
if ($uninstall.ExitCode -ne 0) { throw "standard-user uninstall exited $($uninstall.ExitCode)" }
if (Test-Path "$root\omnivoice-studio.exe") { throw "per-user shell remains after uninstall" }
}
finally {
if ($createdUser) {
net user $user /delete 2>$null | Out-Null
}
}
+53
View File
@@ -0,0 +1,53 @@
param(
[Parameter(Mandatory = $true)]
[string]$MsiPath
)
$ErrorActionPreference = "Stop"
$resolved = (Resolve-Path $MsiPath).Path
$installer = New-Object -ComObject WindowsInstaller.Installer
$database = $installer.OpenDatabase($resolved, 0)
function Get-MsiValue([string]$Query) {
$view = $database.OpenView($Query)
try {
$view.Execute()
$record = $view.Fetch()
if ($null -eq $record) { return $null }
return $record.StringData(1)
}
finally {
$view.Close()
}
}
function Assert-Contains([string]$Value, [string]$Expected, [string]$Label) {
if ([string]::IsNullOrEmpty($Value) -or -not $Value.Contains($Expected)) {
throw "$Label missing '$Expected' (actual: '$Value')"
}
}
$secure = Get-MsiValue "SELECT ``Value`` FROM ``Property`` WHERE ``Property``='SecureCustomProperties'"
Assert-Contains $secure "ALLOWWEBVIEW2BOOTSTRAP" "secure MSI properties"
Assert-Contains $secure "DISABLEWEBVIEW2BOOTSTRAP" "secure MSI properties"
$bootstrapCondition = Get-MsiValue "SELECT ``Condition`` FROM ``InstallExecuteSequence`` WHERE ``Action``='DownloadAndInvokeBootstrapper'"
Assert-Contains $bootstrapCondition 'ALLOWWEBVIEW2BOOTSTRAP = "1"' "WebView2 bootstrap condition"
Assert-Contains $bootstrapCondition 'DISABLEWEBVIEW2BOOTSTRAP <> "1"' "WebView2 bootstrap condition"
$launchCondition = Get-MsiValue "SELECT ``Condition`` FROM ``InstallExecuteSequence`` WHERE ``Action``='LaunchApplication'"
Assert-Contains $launchCondition 'AUTOLAUNCHAPP <> "0"' "launch condition"
$runtimeCondition = Get-MsiValue "SELECT ``Condition`` FROM ``LaunchCondition`` WHERE ``Condition``='Installed OR REMOVE OR INSTALLED_WEBVIEW2_VERSION OR (ALLOWWEBVIEW2BOOTSTRAP = `"1`" AND DISABLEWEBVIEW2BOOTSTRAP <> `"1`")'"
Assert-Contains $runtimeCondition "INSTALLED_WEBVIEW2_VERSION" "fail-closed runtime condition"
$bootstrapCommand = Get-MsiValue "SELECT ``Target`` FROM ``CustomAction`` WHERE ``Action``='DownloadAndInvokeBootstrapper'"
Assert-Contains $bootstrapCommand "https://go.microsoft.com/fwlink/p/?LinkId=2124703" "WebView2 bootstrap URL"
Assert-Contains $bootstrapCommand "Start-Process" "WebView2 silent invocation"
$machineDetection = Get-MsiValue "SELECT ``Key`` FROM ``RegLocator`` WHERE ``Signature_``='Webview2VersionSystemx86'"
$userDetection = Get-MsiValue "SELECT ``Key`` FROM ``RegLocator`` WHERE ``Signature_``='Webview2VersionUser'"
Assert-Contains $machineDetection "Microsoft\EdgeUpdate\Clients" "machine WebView2 detection"
Assert-Contains $userDetection "Microsoft\EdgeUpdate\Clients" "user WebView2 detection"
Write-Host "Verified managed WebView2 and AUTOLAUNCHAPP contracts in $resolved"
+15 -3
View File
@@ -20,7 +20,6 @@ from __future__ import annotations
import re
import sys
from time import perf_counter
import pytest
@@ -549,17 +548,30 @@ def test_select_mlx_audio_repo_id_accepts_underscore_prefixes(fresh_app, monkeyp
)
def test_select_mlx_audio_rejects_malformed_repo_ids(fresh_app, monkeypatch, model_id):
_make_mlx_audio_available(monkeypatch)
started = perf_counter()
r = _client(fresh_app).post(
"/engines/select",
json={"family": "tts", "backend_id": "mlx-audio", "model_id": model_id},
)
assert r.status_code == 400
assert perf_counter() - started < 0.5
assert len(r.content) < 256
assert model_id[:100] not in r.text
def test_hf_repo_id_size_bound_precedes_library_validation(fresh_app, monkeypatch):
from api.routers import engines as engines_router
def unexpected_validation(_value):
raise AssertionError("oversized repo id reached the library validator")
monkeypatch.setattr(
engines_router.hf_utils,
"validate_repo_id",
unexpected_validation,
)
assert not engines_router._is_hf_repo_id("-" * 100_000)
def test_select_mlx_audio_without_model_id_does_not_touch_pref(fresh_app, monkeypatch):
"""Selecting mlx-audio without a model_id (e.g. an older frontend) must
leave any existing mlx_audio_model_id pref untouched."""
@@ -173,6 +173,11 @@ def test_list_backends_shape(registry_sandbox):
# below the floor gets a caveat in `routing_reason` BEFORE it spends
# the full compute budget finding out its card is too small.
"min_vram_gb",
# Structured estimated/measured storage costs for pre-install
# decisions and post-install accounting (#1718).
"disk_usage",
# Sanitized actual-vs-declared provider/device evidence (#1717).
"execution_evidence",
}
mlx_audio_extra = {"curated_models", "active_model_id"}
for entry in out:
+1
View File
@@ -89,6 +89,7 @@ GET /engines/sidecar/{engine_id}/install/status
GET /engines/sonitranslate/status
GET /engines/translation
GET /engines/tts
GET /engines/{engine_id}/disk-usage
GET /engines/{engine_id}/health
GET /export/history
GET /gallery/categories
+1
View File
@@ -125,6 +125,7 @@ def test_managed_sidecar_install_stays_desktop_only():
@pytest.mark.parametrize(
("filename", "function_name"),
[
("engines.py", "engine_disk_usage"),
("engines.py", "engine_health"),
("settings.py", "list_llm_provider_models"),
("system.py", "system_diagnose"),
+46 -4
View File
@@ -117,6 +117,17 @@ def test_available_once_base_url_configured(asr_mod, ss):
assert ok is True
def test_insecure_stored_url_cannot_bypass_settings_validation(asr_mod, ss):
ss.set_text(asr_mod._ASR_OPENAI_COMPAT_BASE_URL_KEY, "http://asr.example/v1")
ok, reason = asr_mod.OpenAICompatASRBackend.is_available()
assert ok is False
assert "require HTTPS" in reason
with pytest.raises(ValueError, match="require HTTPS"):
asr_mod.OpenAICompatASRBackend()
# ── response adaptation ─────────────────────────────────────────────────────
@@ -180,6 +191,9 @@ def test_client_disables_sdk_retries(asr_mod, ss, monkeypatch, tmp_path):
audio.write_bytes(b"RIFF....WAVEfmt ")
asr_mod.OpenAICompatASRBackend().transcribe(str(audio))
assert captured_kwargs[0]["max_retries"] == 0
transport = captured_kwargs[0]["http_client"]
assert transport.follow_redirects is False
transport.close()
# ── settings endpoints ───────────────────────────────────────────────────────
@@ -235,6 +249,22 @@ def test_rejects_a_base_url_without_scheme(settings_mod):
)
def test_rejects_plain_http_for_non_loopback_server(settings_mod):
from fastapi import HTTPException
with pytest.raises(HTTPException, match="require HTTPS"):
settings_mod.set_asr_openai_compat(
settings_mod._ASROpenAICompatBody(base_url="http://asr.example/v1")
)
def test_accepts_https_for_non_loopback_server(settings_mod):
state = settings_mod.set_asr_openai_compat(
settings_mod._ASROpenAICompatBody(base_url="https://asr.example/v1/")
)
assert state["base_url"] == "https://asr.example/v1"
def test_registered_in_backend_list(asr_mod):
assert "openai-compat-asr" in asr_mod._REGISTRY
assert asr_mod._REGISTRY["openai-compat-asr"] is asr_mod.OpenAICompatASRBackend
@@ -249,16 +279,16 @@ def test_engine_reads_fresh_config_per_call(asr_mod, ss):
next transcribe the backend is instantiated fresh per call
(get_active_asr_backend) and reads settings_store in __init__, so no
backend restart is ever required after a config change."""
ss.set_text(asr_mod._ASR_OPENAI_COMPAT_BASE_URL_KEY, "http://old:1/v1")
ss.set_text(asr_mod._ASR_OPENAI_COMPAT_BASE_URL_KEY, "https://old.example/v1")
ss.set_text(asr_mod._ASR_OPENAI_COMPAT_MODEL_KEY, "old-model")
first = asr_mod.OpenAICompatASRBackend()
assert first._base_url == "http://old:1/v1"
assert first._base_url == "https://old.example/v1"
assert first._model == "old-model"
ss.set_text(asr_mod._ASR_OPENAI_COMPAT_BASE_URL_KEY, "http://new:2/v1")
ss.set_text(asr_mod._ASR_OPENAI_COMPAT_BASE_URL_KEY, "https://new.example/v1")
ss.set_text(asr_mod._ASR_OPENAI_COMPAT_MODEL_KEY, "new-model")
second = asr_mod.OpenAICompatASRBackend()
assert second._base_url == "http://new:2/v1"
assert second._base_url == "https://new.example/v1"
assert second._model == "new-model"
@@ -320,6 +350,17 @@ def test_probe_rejects_schemeless_url_before_any_network(asr_mod, monkeypatch):
assert out == {**out, "ok": False, "status": "invalid_url"}
def test_probe_rejects_non_loopback_http_before_any_network(asr_mod, monkeypatch):
import httpx
def _boom(**kw): # pragma: no cover — must never be constructed
raise AssertionError("network client constructed for an insecure URL")
monkeypatch.setattr(httpx, "Client", _boom)
out = asr_mod.probe_openai_compat_server(base_url="http://asr.example/v1")
assert out == {**out, "ok": False, "status": "invalid_url"}
def test_probe_ok_reports_latency_and_model_found(asr_mod, ss, monkeypatch):
ss.set_text(asr_mod._ASR_OPENAI_COMPAT_BASE_URL_KEY, "http://localhost:8080/v1/")
ss.set_text(asr_mod._ASR_OPENAI_COMPAT_MODEL_KEY, "qwen3-asr")
@@ -335,6 +376,7 @@ def test_probe_ok_reports_latency_and_model_found(asr_mod, ss, monkeypatch):
assert out["model_found"] is True
assert isinstance(out["latency_ms"], float)
assert captured["url"] == "http://localhost:8080/v1/models" # trailing / trimmed
assert captured["client_kwargs"]["follow_redirects"] is False
# No key configured → the probe must not invent an Authorization header.
assert "Authorization" not in captured["headers"]
+34 -1
View File
@@ -13,7 +13,7 @@ def report():
def test_report_shape(report):
assert set(report) == {"app_version", "platform", "checks", "summary"}
assert set(report) == {"app_version", "platform", "checks", "engine_execution", "summary"}
ids = [c["id"] for c in report["checks"]]
assert len(ids) == len(set(ids)), "check ids must be unique"
for c in report["checks"]:
@@ -72,6 +72,39 @@ def test_format_text_ascii_and_exit_signal(report):
assert ("looks healthy" in text) == report["summary"]["ok"]
def test_asr_evidence_failure_does_not_drop_tts_evidence(monkeypatch):
from services import asr_backend, tts_backend
evidence = {
"evidence_state": "loaded",
"actual_execution_provider": "cpu",
"actual_execution_device": "cpu",
"precision_or_quantization": "float32",
"cpu_fallback_reason": None,
"cpu_fallback_stage": None,
"runtime_versions": {"python": "3.11"},
"parent_memory_observable": True,
}
monkeypatch.setattr(tts_backend, "active_backend_id", lambda: "tts-ok")
monkeypatch.setattr(
tts_backend,
"list_backends",
lambda: [{"id": "tts-ok", "available": True, "execution_evidence": evidence}],
)
monkeypatch.setattr(asr_backend, "active_backend_id", lambda: "asr-broken")
def fail_asr_registry():
raise RuntimeError("registry failed")
monkeypatch.setattr(asr_backend, "list_backends", fail_asr_registry)
result = run_diagnostics(include_network=False)
rows = {row["family"]: row for row in result["engine_execution"]}
assert rows["tts"]["engine_id"] == "tts-ok"
assert rows["tts"]["evidence_state"] == "loaded"
assert rows["asr"]["engine_id"] == "asr-broken"
assert rows["asr"]["evidence_state"] == "collection_failed"
# ── Deep synthesis check (mocked — no real model load in CI) ─────────────
+48
View File
@@ -2,6 +2,7 @@
import json
import os
import zipfile
from types import SimpleNamespace
import pytest
@@ -47,6 +48,53 @@ def test_bundle_members_and_meta(bundle_env):
assert meta["app_version"]
report = json.loads(zf.read("self_check.json"))
assert report["summary"]["passed"] >= 1
assert "engine_execution" in report
text_report = zf.read("self_check.txt").decode()
if report["engine_execution"]:
assert "Engine execution evidence:" in text_report
row = report["engine_execution"][0]
assert f"{row['family']}:{row['engine_id']}" in text_report
assert f"evidence-state={row['evidence_state']}" in text_report
assert "device=" in text_report
assert "precision=" in text_report
assert "fallback-stage=" in text_report
def test_asr_import_failure_preserves_tts_execution_evidence(monkeypatch):
from core import diagnose
evidence = {
"implementation_variant": "fake",
"declared_device_families": ["cpu"],
"evidence_state": "loaded",
"actual_execution_provider": "cpu",
"actual_execution_device": "cpu",
"gpu_name": None,
"gpu_architecture": None,
"precision_or_quantization": "fp32",
"cpu_fallback_reason": None,
"cpu_fallback_stage": None,
"parent_memory_observable": True,
"runtime_versions": {},
}
tts = SimpleNamespace(
active_backend_id=lambda: "fake-tts",
list_backends=lambda: [{"id": "fake-tts", "execution_evidence": evidence}],
)
real_import = diagnose.importlib.import_module
def import_family(name):
if name == "services.tts_backend":
return tts
if name == "services.asr_backend":
raise ImportError("unavailable")
return real_import(name)
monkeypatch.setattr(diagnose.importlib, "import_module", import_family)
rows = diagnose.run_diagnostics(include_network=False)["engine_execution"]
assert next(row for row in rows if row["family"] == "tts")["engine_id"] == "fake-tts"
assert next(row for row in rows if row["family"] == "asr")["evidence_state"] == "collection_failed"
def test_bundle_log_tails_are_scrubbed(bundle_env):
+24
View File
@@ -72,3 +72,27 @@ def test_wsl_rocm_command_carries_the_complete_dxg_bridge():
"--security-opt seccomp=unconfined",
):
assert required in section
def test_wsl_rocm_matrix_marks_rx_6700_xt_unverified():
text = (ROOT / "docs/install/docker.md").read_text(encoding="utf-8")
section = text.split("#### WSL2 architecture compatibility matrix", 1)[1].split(
"###", 1
)[0]
for classification in (
"Supported",
"Best-effort override",
"Unverified",
"Unsupported",
):
assert f"| **{classification}** |" in section
rx_row = next(line for line in section.splitlines() if "RX 6700 XT" in line)
assert "`gfx1031`" in rx_row
assert "**Unverified**" in rx_row
assert "`gfx1030`" in rx_row
assert "`/dev/dxg` alone is not proof of acceleration" in section
assert "CPU fallback stage and reason" in section
assert "CPU-only completion" in section
assert "successful GPU validation" in section
+17
View File
@@ -339,3 +339,20 @@ class TestAudioOnlyDubbing:
assert response.status_code == 202
assert queued[0][5]["source_lang"] == "fr"
def test_asr_detected_source_languages_can_be_reused_as_overrides(self, app_client):
_client, dc, _dx, _tmp = app_client
detected_codes = {
"as", "ba", "bo", "br", "fo", "lb", "ln", "mg", "nn", "oc",
"sa", "tk", "tl", "tt", "yue", "zh",
}
for code in detected_codes:
assert dc._source_lang_override(code) == code
def test_asr_detected_cantonese_code_is_not_truncated(self, app_client):
_client, dc, _dx, _tmp = app_client
assert dc._detected_source_lang("yue") == "yue"
assert dc._detected_source_lang("es_ES") == "es"
assert dc._detected_source_lang("unknown-language") == "en"
+113
View File
@@ -0,0 +1,113 @@
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor
from threading import Event
import pytest
@pytest.fixture
def disk_modules():
from services import engine_disk_usage
from services.sidecar_install import SPECS
return engine_disk_usage, SPECS
def test_in_process_model_exposes_unknown_dependency_cost_explicitly(monkeypatch, disk_modules):
engine_disk_usage, _ = disk_modules
monkeypatch.setattr(engine_disk_usage, "_measure_model_cache", lambda _engine_id: None)
usage = engine_disk_usage.disk_usage_for("omnivoice")
assert usage["estimate"]["model_download_bytes"] > 0
assert usage["estimate"]["package_download_bytes"] is None
assert usage["estimate"]["confidence"] == "estimated"
assert usage["estimate"]["destination_volume"]
def test_lightweight_optional_engine_has_weight_estimate_not_fake_package_zero(monkeypatch, disk_modules):
engine_disk_usage, _ = disk_modules
monkeypatch.setattr(engine_disk_usage, "_measure_model_cache", lambda _engine_id: None)
usage = engine_disk_usage.disk_usage_for("kittentts")
assert usage["estimate"]["model_download_bytes"] == round(0.08 * 1024**3)
assert usage["estimate"]["package_download_bytes"] is None
def test_separate_torch_sidecar_uses_installer_build_metadata(monkeypatch, tmp_path, disk_modules):
engine_disk_usage, SPECS = disk_modules
spec = SPECS["indextts2"]
monkeypatch.setattr("services.sidecar_install.DATA_DIR", tmp_path)
usage = engine_disk_usage.disk_usage_for(spec.engine_id)
assert usage["estimate"]["model_download_bytes"] == spec.weights_bytes
assert usage["estimate"]["package_download_bytes"] == spec.dependency_bytes
assert usage["estimate"]["unique_installed_bytes"] == spec.required_bytes
assert usage["estimate"]["temporary_free_bytes"] == spec.temporary_free_bytes
assert usage["estimate"]["deduplication"] == "uv_same_volume"
def test_installed_sidecar_reports_separate_measured_categories(monkeypatch, tmp_path, disk_modules):
engine_disk_usage, SPECS = disk_modules
spec = SPECS["indextts2"]
monkeypatch.setattr("services.sidecar_install.DATA_DIR", tmp_path)
checkout = tmp_path / "engines" / spec.engine_id / spec.checkout_dirname
for relative, payload in (
(Path(spec.weights_subdir) / "model.bin", b"weights"),
(Path(".venv") / "package.py", b"environment"),
(Path("source.py"), b"source"),
):
path = checkout / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(payload)
cache = tmp_path / "engines" / ".uv-cache" / "wheel"
cache.parent.mkdir(parents=True)
cache.write_bytes(b"shared")
engine_disk_usage._measurement_cache.clear()
actual = engine_disk_usage.actual_for(spec.engine_id)
assert actual["model_bytes"] == len(b"weights")
assert actual["environment_bytes"] == len(b"environment")
assert actual["cache_bytes"] == len(b"shared")
assert actual["total_owned_bytes"] == len(b"weights") + len(b"environment") + len(b"source")
assert actual["confidence"] == "measured"
def test_disk_measurement_route_rejects_unknown_engine(monkeypatch):
from api.routers import engines
from fastapi import HTTPException
def unknown_backend(_engine_id):
raise ValueError("unknown")
monkeypatch.setattr(
engines.tts_backend,
"get_backend_class",
unknown_backend,
)
with pytest.raises(HTTPException) as caught:
engines.engine_disk_usage("unknown")
assert caught.value.status_code == 404
def test_concurrent_disk_measurements_are_coalesced(monkeypatch, disk_modules):
engine_disk_usage, _ = disk_modules
calls = 0
entered = Event()
release = Event()
def measure(_engine_id):
nonlocal calls
calls += 1
entered.set()
assert release.wait(timeout=1)
return {"total_owned_bytes": 7, "confidence": "measured"}
engine_disk_usage._measurement_cache.clear()
monkeypatch.setattr(engine_disk_usage, "_measure_sidecar", measure)
with ThreadPoolExecutor(max_workers=2) as pool:
first = pool.submit(engine_disk_usage.actual_for, "coalesce")
assert entered.wait(timeout=1)
second = pool.submit(engine_disk_usage.actual_for, "coalesce")
assert not second.done()
release.set()
results = [first.result(), second.result()]
assert calls == 1
assert [result["total_owned_bytes"] for result in results] == [7, 7]
+272
View File
@@ -0,0 +1,272 @@
from dataclasses import dataclass
@dataclass
class _Caps:
family: str = "rocm"
device_name: str = "AMD Radeon RX 6700 XT"
class _TorchEngine:
execution_evidence_loaded = True
gpu_compat = ("rocm", "cpu")
_device = "cuda:0"
_dtype = "float16"
class _FasterWhisper:
execution_evidence_loaded = True
gpu_compat = ("cuda", "cpu")
_device = "cpu"
_compute_type = "int8"
class _OnnxEngine:
execution_evidence_loaded = True
gpu_compat = ("cpu",)
_provider = "CPUExecutionProvider"
_dtype = "int8"
class _SidecarEngine:
execution_evidence_loaded = True
gpu_compat = ("rocm", "cpu")
runs_out_of_process = True
_device = "cuda:0"
class _LoadFallbackEngine:
execution_evidence_loaded = True
gpu_compat = ("cuda", "cpu")
_device = "cpu"
_fallback_reason = "CUDA memory was exhausted while loading the engine"
_fallback_stage = "model_load"
def _snap(cls, routing):
from services.engine_evidence import snapshot
return snapshot(
engine_id=cls.__name__, engine_cls=cls, instance=cls(), routing=routing, caps=_Caps()
)
def test_loaded_rocm_torch_engine_reports_actual_device():
evidence = _snap(_TorchEngine, {"routing_status": "accelerated", "routing_reason": None})
assert evidence["actual_execution_device"] == "cuda:0"
assert evidence["precision_or_quantization"] == "float16"
assert evidence["gpu_name"] == "AMD Radeon RX 6700 XT"
def test_faster_whisper_cpu_fallback_names_reason_and_stage():
evidence = _snap(
_FasterWhisper,
{"routing_status": "cpu_fallback", "routing_reason": "ROCm is unsupported"},
)
assert evidence["actual_execution_device"] == "cpu"
assert evidence["cpu_fallback_reason"] == "ROCm is unsupported"
assert evidence["cpu_fallback_stage"] == "routing_preflight"
def test_cpu_onnx_and_subprocess_observability_are_explicit():
cpu = _snap(_OnnxEngine, {"routing_status": "cpu_only", "routing_reason": None})
sidecar = _snap(_SidecarEngine, {"routing_status": "accelerated", "routing_reason": None})
assert cpu["actual_execution_provider"] == "CPUExecutionProvider"
assert cpu["parent_memory_observable"] is True
assert sidecar["parent_memory_observable"] is False
def test_subprocess_state_follows_live_child_not_wrapper_presence():
from services.engine_evidence import snapshot
class _Process:
def __init__(self, returncode):
self.returncode = returncode
def poll(self):
return self.returncode
class _OpaqueSidecar:
gpu_compat = ("cpu",)
runs_out_of_process = True
def __init__(self, process):
self._proc = process
def execution_evidence_loaded(self):
return self._proc is not None and self._proc.poll() is None
routing = {"routing_status": "cpu_only", "routing_reason": None}
for process, expected in (
(None, "not_loaded"),
(_Process(1), "not_loaded"),
(_Process(None), "subprocess_loaded_provider_unreported"),
):
evidence = snapshot(
engine_id="opaque",
engine_cls=_OpaqueSidecar,
instance=_OpaqueSidecar(process),
routing=routing,
caps=_Caps(),
)
assert evidence["evidence_state"] == expected
def test_public_inventory_replaces_nested_private_fallback_detail():
from api.public_engine_metadata import public_backends
entry = {
"routing_status": "cpu_fallback",
"routing_reason": "/home/alice/private driver error",
"execution_evidence": {"cpu_fallback_reason": "/home/alice/private driver error"},
}
public = public_backends([entry])[0]
expected = "GPU acceleration is unavailable; this engine will use CPU."
assert public["routing_reason"] == expected
assert public["execution_evidence"]["cpu_fallback_reason"] == expected
def test_constructed_in_process_backend_is_not_loaded_until_contract_says_so():
from services.engine_evidence import snapshot
class _Lazy:
gpu_compat = ("cpu",)
execution_evidence_loaded = False
_device = "cpu"
routing = {"routing_status": "cpu_only", "routing_reason": None}
evidence = snapshot(
engine_id="lazy", engine_cls=_Lazy, instance=_Lazy(), routing=routing, caps=_Caps()
)
assert evidence["evidence_state"] == "not_loaded"
assert evidence["actual_execution_device"] is None
def test_post_load_fallback_overrides_preflight_prediction():
evidence = _snap(
_LoadFallbackEngine,
{"routing_status": "accelerated", "routing_reason": None},
)
assert evidence["actual_execution_device"] == "cpu"
assert evidence["cpu_fallback_stage"] == "model_load"
assert "memory" in evidence["cpu_fallback_reason"]
def test_lifecycle_probe_lookup_and_call_failures_are_explicit():
from services.engine_evidence import snapshot
class _RaisingDescriptor:
gpu_compat = ("cpu",)
@property
def execution_evidence_loaded(self):
raise RuntimeError("descriptor failed")
class _RaisingCallable:
gpu_compat = ("cpu",)
def execution_evidence_loaded(self):
raise RuntimeError("probe failed")
routing = {"routing_status": "cpu_only", "routing_reason": None}
for cls in (_RaisingDescriptor, _RaisingCallable):
evidence = snapshot(
engine_id="broken-probe",
engine_cls=cls,
instance=cls(),
routing=routing,
caps=_Caps(),
)
assert evidence["evidence_state"] == "probe_error"
assert evidence["actual_execution_device"] is None
def test_public_runtime_fallback_overrides_accelerated_preflight_category():
from api.public_engine_metadata import public_backends
public = public_backends(
[{
"routing_status": "accelerated",
"routing_reason": None,
"execution_evidence": {
"cpu_fallback_reason": "/private/model load failed with hf_secret",
"cpu_fallback_stage": "model_load",
},
}]
)[0]
assert public["execution_evidence"]["cpu_fallback_reason"] == (
"GPU acceleration is unavailable; this engine will use CPU."
)
def test_stopped_asr_sidecar_invalidates_cached_loaded_evidence(monkeypatch):
from services import asr_backend
class _StoppedProcess:
def poll(self):
return 0
class _StoppedSidecar:
id = "stopped"
display_name = "Stopped sidecar"
gpu_compat = ("cpu",)
_is_subprocess_isolated = True
runs_out_of_process = True
def __init__(self):
self._proc = _StoppedProcess()
@classmethod
def is_available(cls):
return True, "ready"
def execution_evidence_loaded(self):
return self._proc.poll() is None
monkeypatch.setattr(asr_backend, "_REGISTRY", {"stopped": _StoppedSidecar})
monkeypatch.setattr(asr_backend, "_ISOLATED_INSTANCES", {"stopped": _StoppedSidecar()})
monkeypatch.setattr(
asr_backend,
"_RUNTIME_EVIDENCE",
{"stopped": {"evidence_state": "loaded", "actual_execution_device": "cuda:0"}},
)
row = asr_backend.list_backends()[0]
assert row["execution_evidence"]["evidence_state"] == "not_loaded"
assert "stopped" not in asr_backend._RUNTIME_EVIDENCE
def test_unloaded_in_process_asr_invalidates_cached_loaded_evidence(monkeypatch):
from services import asr_backend
class _Backend:
id = "released"
display_name = "Released backend"
gpu_compat = ("cuda", "cpu")
def __init__(self):
self._model = object()
@classmethod
def is_available(cls):
return True, "ready"
def execution_evidence_loaded(self):
return self._model is not None
def unload(self):
self._model = None
instance = _Backend()
monkeypatch.setattr(asr_backend, "_REGISTRY", {"released": _Backend})
monkeypatch.setattr(asr_backend, "_RUNTIME_INSTANCES", {"released": instance})
monkeypatch.setattr(
asr_backend,
"_RUNTIME_EVIDENCE",
{"released": {"evidence_state": "loaded", "actual_execution_device": "cuda:0"}},
)
instance.unload()
row = asr_backend.list_backends()[0]
assert row["execution_evidence"]["evidence_state"] == "not_loaded"
assert row["execution_evidence"]["actual_execution_device"] is None
assert "released" not in asr_backend._RUNTIME_EVIDENCE
+15
View File
@@ -20,6 +20,13 @@ def test_tts_registry_lists_all_backends():
assert {"omnivoice", "voxcpm2", "moss-tts-nano"}.issubset(ids)
for r in rows:
assert set(r) >= {"id", "display_name", "available", "reason"}
evidence = r["execution_evidence"]
assert evidence["implementation_variant"]
assert evidence["declared_device_families"] == r["gpu_compat"]
assert evidence["evidence_state"] in {
"loaded", "not_loaded", "subprocess_loaded_provider_unreported"
}
assert evidence["runtime_versions"]["python"]
def test_tts_voxcpm2_unavailable_message_is_actionable():
@@ -195,6 +202,14 @@ def test_asr_registry_lists_backends():
rows = asr_backend.list_backends()
ids = {r["id"] for r in rows}
assert {"mlx-whisper", "pytorch-whisper"}.issubset(ids)
required = {
"actual_execution_provider",
"actual_execution_device",
"cpu_fallback_reason",
"cpu_fallback_stage",
"runtime_versions",
}
assert all(required.issubset(row["execution_evidence"]) for row in rows)
def test_asr_auto_detects():
+76
View File
@@ -112,6 +112,53 @@ def test_read_input_base64_lane_keeps_data_uri_tolerance_and_labels():
assert raw is None and err == "ref_audio_base64 is not valid base64"
def test_base64_limit_applies_to_decoded_bytes(monkeypatch):
import mcp_server
monkeypatch.setattr(mcp_server, "_MAX_INPUT_BYTES", 3)
encoded = base64.b64encode(b"abc").decode()
assert len(encoded) > mcp_server._MAX_INPUT_BYTES
raw, err = mcp_server._read_input_audio(encoded, None)
assert err is None and raw == b"abc"
oversized = base64.b64encode(b"abcd").decode()
raw, err = mcp_server._read_input_audio(oversized, None)
assert raw is None and err == "audio exceeds 200 MB limit"
def test_concurrent_parent_replacement_cannot_escape_base(
monkeypatch, tmp_path
):
import mcp_server
base = tmp_path / "base"
lane = base / "lane"
lane.mkdir(parents=True)
(lane / "clip.wav").write_bytes(b"inside")
outside = tmp_path / "outside"
outside.mkdir()
(outside / "clip.wav").write_bytes(b"secret")
parked = base / "parked"
monkeypatch.setenv("OMNIVOICE_MCP_BASE_PATH", str(base))
real_resolve = mcp_server._resolve_under_base
def replace_parent_after_resolution(path):
resolved = real_resolve(path)
lane.rename(parked)
try:
lane.symlink_to(outside, target_is_directory=True)
except OSError as exc: # Windows without Developer Mode/admin rights
pytest.skip(f"directory symlinks unavailable: {exc}")
return resolved
monkeypatch.setattr(
mcp_server, "_resolve_under_base", replace_parent_after_resolution
)
raw, err = mcp_server._read_input_audio(None, "lane/clip.wav")
assert raw is None
assert "outside" in err or "safely read" in err
# ── the generate_speech reply shape ─────────────────────────────────────────
def test_speech_result_resources_is_the_original_contract(monkeypatch):
@@ -136,6 +183,35 @@ def test_speech_result_files_returns_url_and_writes_under_base(monkeypatch, tmp_
assert f.read() == b"RIFF"
def test_speech_result_rejects_traversal_audio_id(monkeypatch, tmp_path):
from mcp_server import _speech_result
monkeypatch.setenv("OMNIVOICE_MCP_OUTPUT_MODE", "files")
monkeypatch.setenv("OMNIVOICE_MCP_BASE_PATH", str(tmp_path))
with pytest.raises(ValueError, match="invalid X-Audio-Id"):
_speech_result("../../escape", 1.5, 2.0, b"RIFF", "http://localhost:3900")
assert not (tmp_path.parent / "escape.wav").exists()
def test_speech_result_does_not_follow_existing_output_symlink(
monkeypatch, tmp_path
):
from mcp_server import _speech_result
outside = tmp_path.parent / "outside.wav"
outside.write_bytes(b"keep")
link = tmp_path / "ab12cd34.wav"
try:
link.symlink_to(outside)
except OSError as exc: # Windows without Developer Mode/admin rights
pytest.skip(f"file symlinks unavailable: {exc}")
monkeypatch.setenv("OMNIVOICE_MCP_OUTPUT_MODE", "files")
monkeypatch.setenv("OMNIVOICE_MCP_BASE_PATH", str(tmp_path))
with pytest.raises(ValueError, match="outside OMNIVOICE_MCP_BASE_PATH"):
_speech_result("ab12cd34", 1.5, 2.0, b"replace", "http://localhost:3900")
assert outside.read_bytes() == b"keep"
def test_speech_result_files_without_base_is_url_only_with_a_note(monkeypatch):
from mcp_server import _speech_result
monkeypatch.setenv("OMNIVOICE_MCP_OUTPUT_MODE", "files")
+45 -2
View File
@@ -208,12 +208,17 @@ def test_model_resolution_pins_offline_probe_and_download(monkeypatch, tmp_path)
monkeypatch.setenv("HF_HUB_CACHE", str(tmp_path))
calls = []
downloaded = tmp_path / "downloaded"
def fake_snapshot(**kwargs):
calls.append(kwargs)
return "/cache/pinned"
downloaded.mkdir()
for filename in spec.files.values():
(downloaded / filename).write_bytes(b"model")
return str(downloaded)
monkeypatch.setattr(huggingface_hub, "snapshot_download", fake_snapshot)
assert sd._resolve_model_dir(spec) == "/cache/pinned"
assert sd._resolve_model_dir(spec) == str(downloaded)
assert calls == [{
"repo_id": spec.repo_id,
"revision": hf_revisions.revision_for(spec.repo_id),
@@ -222,6 +227,44 @@ def test_model_resolution_pins_offline_probe_and_download(monkeypatch, tmp_path)
}]
def test_model_resolution_repairs_broken_snapshot_before_loading(monkeypatch, tmp_path):
"""A zero-byte ONNX entry must be repaired before sherpa receives it (#1733)."""
from services import hf_cache_repair, sherpa_dictation as sd
import huggingface_hub
spec = sd.get_spec("sherpa-whisper-tiny")
revision = "6" * 40
repo = tmp_path / "models--csukuangfj--sherpa-onnx-whisper-tiny"
ref = repo / "refs" / "main"
ref.parent.mkdir(parents=True)
ref.write_text(revision + "\n", encoding="ascii")
snapshot = repo / "snapshots" / revision
snapshot.mkdir(parents=True)
broken = snapshot / spec.files["encoder"]
broken.write_bytes(b"")
for role in ("decoder", "tokens"):
(snapshot / spec.files[role]).write_bytes(b"model")
monkeypatch.setenv("HF_HUB_CACHE", str(tmp_path))
repairs = []
def fake_repair(repo_id, cache_dir):
repairs.append((repo_id, cache_dir))
broken.write_bytes(b"restored model")
return {"ok": True, "outcome": "healed_with_copies", "error": ""}
monkeypatch.setattr(hf_cache_repair, "repair_repo_cache", fake_repair)
monkeypatch.setattr(
huggingface_hub,
"snapshot_download",
lambda **_kwargs: pytest.fail("a repaired snapshot must be reused"),
)
assert sd._resolve_model_dir(spec) == str(snapshot)
assert repairs == [(spec.repo_id, str(tmp_path))]
assert broken.read_bytes() == b"restored model"
def test_model_resolution_probes_preserved_legacy_snapshot(monkeypatch, tmp_path):
from services import hf_revisions, sherpa_dictation as sd
import huggingface_hub
+75 -1
View File
@@ -195,12 +195,62 @@ def test_disk_preflight_subtracts_partial_install(monkeypatch, tmp_path):
# only the remainder (+headroom) must fit, so a resume isn't blocked.
spec = _mk_spec(required_bytes=1 * _GIB)
root = si.managed_root(spec)
checkout = si.managed_checkout(spec)
root.mkdir(parents=True)
monkeypatch.setattr(si, "_dir_size_bytes", lambda p: int(0.9 * _GIB))
monkeypatch.setattr(si, "_source_present", lambda _spec, _checkout: True)
monkeypatch.setattr(
si,
"_dir_size_bytes",
lambda path: int(0.9 * _GIB) if path == checkout else 0,
)
monkeypatch.setattr(si, "disk_free_bytes", lambda p: (si.MIN_FREE_GB + 1) * _GIB)
assert si.disk_space_error(spec) is None
def test_disk_preflight_subtracts_partial_install_from_peak_requirement(monkeypatch):
spec = _mk_spec(required_bytes=1 * _GIB, temporary_free_bytes=4 * _GIB)
checkout = si.managed_checkout(spec)
monkeypatch.setattr(si, "_source_present", lambda _spec, _checkout: True)
monkeypatch.setattr(
si,
"_dir_size_bytes",
lambda path: int(0.9 * _GIB) if path == checkout else 0,
)
monkeypatch.setattr(si, "disk_free_bytes", lambda _path: (si.MIN_FREE_GB + 2) * _GIB)
assert "3.1 GB" in si.disk_space_error(spec)
def test_disk_preflight_does_not_credit_weights_against_dependency_peak(monkeypatch):
spec = _mk_spec(
required_bytes=12 * _GIB,
temporary_free_bytes=12 * _GIB,
weights_repo_id="Example/Weights",
)
checkout = si.managed_checkout(spec)
weights = checkout / spec.weights_subdir
monkeypatch.setattr(si, "_source_present", lambda _spec, _checkout: True)
monkeypatch.setattr(
si,
"_dir_size_bytes",
lambda path: 7 * _GIB if path == checkout else 6 * _GIB if path == weights else 0,
)
monkeypatch.setattr(si, "disk_free_bytes", lambda _path: (si.MIN_FREE_GB + 10) * _GIB)
assert "11.0 GB" in si.disk_space_error(spec)
def test_disk_preflight_does_not_credit_checkout_that_fetch_will_delete(monkeypatch):
spec = _mk_spec(required_bytes=4 * _GIB, source_revision="new-revision")
checkout = si.managed_checkout(spec)
checkout.mkdir(parents=True)
(checkout / "pyproject.toml").write_text("[project]\nname='fake'\n")
(checkout / si._SOURCE_REVISION_MARKER).write_text("stale-revision\n")
monkeypatch.setattr(si, "_dir_size_bytes", lambda _path: 3 * _GIB)
monkeypatch.setattr(si, "disk_free_bytes", lambda _path: (si.MIN_FREE_GB + 2) * _GIB)
assert "4.0 GB" in si.disk_space_error(spec)
def test_missing_uv_is_actionable(monkeypatch):
spec = _mk_spec()
monkeypatch.setattr(si, "_locate_uv", lambda: None)
@@ -323,6 +373,30 @@ def test_desktop_windows_timeout_never_taskkills_a_reusable_pid(monkeypatch):
assert calls == {"handle_kill": True}
def test_windows_job_timeout_waits_for_terminated_tree():
events = []
def timed_out_wait(timeout=None):
events.append(("wait", timeout))
raise subprocess.TimeoutExpired("operation.exe", timeout)
child = SimpleNamespace(
stdin=None,
stdout=None,
stderr=None,
wait=timed_out_wait,
)
kernel = SimpleNamespace(
TerminateJobObject=lambda job, code: events.append(("terminate", job, code)),
CloseHandle=lambda job: events.append(("close", job)),
)
proc = si.WindowsJobPopen(child, 99, kernel)
si._kill_tree(proc)
assert events == [("terminate", 99, 1), ("close", 99), ("wait", 5)]
def test_desktop_installer_timeout_kills_nested_helper_before_it_can_mutate(
monkeypatch, tmp_path
):
+70
View File
@@ -0,0 +1,70 @@
from pathlib import Path
TEMPLATE = Path("frontend/src-tauri/wix/main.wxs")
def _source() -> str:
return TEMPLATE.read_text(encoding="utf-8")
def test_webview_detection_covers_machine_and_user_installs():
source = _source()
key = r"SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}"
assert f'Root="HKLM" Key="{key}"' in source
assert f'Root="HKCU" Key="{key}"' in source
assert r"SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients" in source
assert source.count('Property Id="INSTALLED_WEBVIEW2_VERSION"') == 1
def test_managed_webview_switch_is_public_secure_and_fail_closed():
source = _source()
assert '<Property Id="ALLOWWEBVIEW2BOOTSTRAP" Secure="yes" />' in source
assert '<Property Id="DISABLEWEBVIEW2BOOTSTRAP" Secure="yes" />' in source
assert "Evergreen Standalone Runtime" in source
action_condition = (
'NOT(REMOVE OR INSTALLED_WEBVIEW2_VERSION) AND '
'ALLOWWEBVIEW2BOOTSTRAP = "1" AND DISABLEWEBVIEW2BOOTSTRAP <> "1"'
)
assert source.count(action_condition) == 3
launch_condition = (
'Installed OR REMOVE OR INSTALLED_WEBVIEW2_VERSION OR '
'(ALLOWWEBVIEW2BOOTSTRAP = "1" AND DISABLEWEBVIEW2BOOTSTRAP <> "1")'
)
assert launch_condition in source
def test_webview_download_and_silent_invocation_are_pinned():
source = _source()
assert "https://go.microsoft.com/fwlink/p/?LinkId=2124703" in source
assert "Invoke-WebRequest" in source
assert "Start-Process" in source
assert "{{webview_installer_args}} &apos;/install&apos;" in source
def test_wix_template_contains_no_literal_newline_escapes():
assert r"\n" not in _source()
def test_autolaunch_zero_is_explicitly_false():
source = _source()
assert 'AUTOLAUNCHAPP AND AUTOLAUNCHAPP &lt;&gt; "0" AND NOT Installed' in source
assert (
"WIXUI_EXITDIALOGOPTIONALCHECKBOX = 1 AND NOT AUTOLAUNCHAPP "
"AND NOT Installed"
) in source
def test_release_smoke_inspects_the_built_msi():
workflow = Path(".github/workflows/release.yml").read_text(encoding="utf-8")
verifier = Path("scripts/verify-windows-msi.ps1").read_text(encoding="utf-8")
assert "verify-windows-msi.ps1" in workflow
tables = (
"Property",
"InstallExecuteSequence",
"LaunchCondition",
"CustomAction",
"RegLocator",
)
for table in tables:
assert f"``{table}``" in verifier
+82
View File
@@ -0,0 +1,82 @@
import importlib.util
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SOURCE = ROOT / "frontend/src-tauri/wix/main.wxs"
SCRIPT = ROOT / "scripts/render-per-user-wix.py"
CONFIG = ROOT / "frontend/src-tauri/tauri.per-user.conf.json"
def _renderer():
spec = importlib.util.spec_from_file_location("render_per_user_wix", SCRIPT)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def test_machine_and_per_user_templates_have_distinct_scopes_and_roots():
machine = SOURCE.read_text(encoding="utf-8")
user = _renderer().render(machine)
assert 'InstallScope="perMachine"' in machine
assert 'InstallScope="perUser"' in user
assert 'Directory Id="$(var.PlatformProgramFilesFolder)"' in machine
assert 'Directory Id="LocalAppDataFolder"' in user
assert 'Name="InstallScope" Type="string" Value="perMachine"' in machine
assert 'Name="InstallScope" Type="string" Value="perUser"' in user
assert 'Id="PrevInstallDirNoName" Root="HKLM"' in machine
assert 'Id="PrevInstallDirNoName" Root="HKCU"' in user
assert 'Id="PrevInstallDirWithName" Root="HKLM"' in machine
assert 'Id="PrevInstallDirWithName" Root="HKCU"' in user
assert '<RegistryKey Root="HKCU" Key="Software\\\\{{manufacturer}}\\\\{{product_name}}">' in user
assert '<RegistryKey Root="HKCU" Key="Software\\Classes\\\\{{protocol}}">' in user
assert 'Guid="{{path_component_guid}}"' in machine
assert 'Guid="41f6d598-8908-4004-9332-291b64fd38be"' in user
def test_per_user_bundle_has_separate_identity_and_no_elevated_update_task():
config = json.loads(CONFIG.read_text(encoding="utf-8"))
assert config["productName"].endswith("(Current User)")
wix = config["bundle"]["windows"]["wix"]
assert wix["upgradeCode"] == "f27de3a8-a9dc-4a3d-84bb-e98f1bf82393"
assert wix["enableElevatedUpdateTask"] is False
assert wix["template"] == "target/wix-per-user/main.wxs"
def test_per_user_template_never_contains_webview_install_actions():
machine = SOURCE.read_text(encoding="utf-8")
user = _renderer().render(machine)
assert "https://go.microsoft.com/fwlink/p/?LinkId=2124703" in machine
assert "ALLOWWEBVIEW2BOOTSTRAP" in machine
for forbidden in (
"https://go.microsoft.com/fwlink/p/?LinkId=2124703",
"ALLOWWEBVIEW2BOOTSTRAP",
"DownloadAndInvokeBootstrapper",
"InvokeBootstrapper",
"InvokeStandalone",
"UpdateWebView2ViaEdgeUpdate",
):
assert forbidden not in user
assert "Installed OR REMOVE OR INSTALLED_WEBVIEW2_VERSION" in user
def test_release_builds_publishes_and_smokes_as_a_standard_user():
workflow = (ROOT / ".github/workflows/release.yml").read_text(encoding="utf-8")
smoke = (ROOT / "scripts/smoke-per-user-msi.ps1").read_text(encoding="utf-8")
updater = (ROOT / "frontend/src-tauri/src/updater_channel.rs").read_text(encoding="utf-8")
assert "render-per-user-wix.py" in workflow
assert "tauri.per-user.conf.json" in workflow
assert 'artifact// (Current User)/_Current_User' in workflow
assert "latest-user.json" in workflow
assert "smoke-per-user-msi.ps1" in workflow
assert "Start-Process msiexec.exe -Credential" in smoke
assert 'if ($LASTEXITCODE -ne 0)' in smoke
assert 'if ($createdUser)' in smoke
assert "standard-user uninstall" in smoke
assert "latest/download/latest-user.json" in updater
assert "releases/download/preview/latest-user.json" in updater
+27
View File
@@ -0,0 +1,27 @@
import importlib.util
from pathlib import Path
SCRIPT = Path(__file__).resolve().parents[1] / "scripts/build_windows_user_manifest.py"
SPEC = importlib.util.spec_from_file_location("build_windows_user_manifest", SCRIPT)
assert SPEC and SPEC.loader
MODULE = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(MODULE)
def test_per_user_manifest_points_only_to_scoped_signed_msi():
manifest = MODULE.build_manifest(
repo="debpalash/VoiceStudio",
tag="v1.2.3",
version="1.2.3",
asset="VoiceStudio_Current_User_1.2.3_x64_en-US.msi",
signature="signed\n",
)
entry = manifest["platforms"]["windows-x86_64"]
assert manifest["version"] == "1.2.3"
assert entry["signature"] == "signed"
assert entry["url"].endswith("/VoiceStudio_Current_User_1.2.3_x64_en-US.msi")
assert set(manifest["platforms"]) == {
"windows-x86_64",
"windows-x86_64-msi",
}
+37 -33
View File
@@ -1298,7 +1298,7 @@ async def test_blocked_result_read_does_not_stall_key_revocation(
def read(self, size):
read_started.set()
if not release_read.wait(timeout=2):
if not release_read.wait(timeout=10):
raise TimeoutError("test did not release the artifact read")
return self._handle.read(size)
@@ -1322,9 +1322,8 @@ async def test_blocked_result_read_does_not_stall_key_revocation(
stream = servicer.FetchResult(
pb.ArtifactRef(artifact_id=result.artifact_id), Context()
)
watchdog = Timer(0.5, release_read.set)
watchdog = Timer(5, release_read.set)
watchdog.start()
started_at = asyncio.get_running_loop().time()
fetching = asyncio.create_task(anext(stream))
async def wait_for_read():
@@ -1335,7 +1334,7 @@ async def test_blocked_result_read_does_not_stall_key_revocation(
await asyncio.wait_for(wait_for_read(), timeout=1)
assert servicer.revoke_key(issued.key.key_id) is True
cleanup = servicer._key_retirements[issued.key.key_id]
assert asyncio.get_running_loop().time() - started_at < 0.2
assert not release_read.is_set(), "result reading blocked the gRPC event loop"
finally:
release_read.set()
watchdog.cancel()
@@ -1415,7 +1414,7 @@ async def test_blocked_input_write_does_not_stall_key_revocation(
def blocked_write(handle, payload):
write_started.set()
if not release_write.wait(timeout=2):
if not release_write.wait(timeout=10):
raise TimeoutError("test did not release the artifact write")
real_write_all(handle, payload)
@@ -1440,9 +1439,11 @@ async def test_blocked_input_write_does_not_stall_key_revocation(
raise RuntimeError(message)
monkeypatch.setattr(listener_module, "_write_all", blocked_write)
watchdog = Timer(0.5, release_write.set)
# Deadlock escape for the regression path. The assertion below checks
# ordering, not runner speed: revocation must finish before this releases
# the blocked write.
watchdog = Timer(5, release_write.set)
watchdog.start()
started_at = asyncio.get_running_loop().time()
uploading = asyncio.create_task(servicer.PushInput(chunks(), Context()))
async def wait_for_write():
@@ -1453,7 +1454,7 @@ async def test_blocked_input_write_does_not_stall_key_revocation(
await asyncio.wait_for(wait_for_write(), timeout=1)
assert servicer.revoke_key(issued.key.key_id) is True
cleanup = servicer._key_retirements[issued.key.key_id]
assert asyncio.get_running_loop().time() - started_at < 0.2
assert not release_write.is_set(), "input writing blocked the gRPC event loop"
finally:
release_write.set()
watchdog.cancel()
@@ -1490,7 +1491,7 @@ async def test_input_admission_and_mkdir_do_not_block_the_listener_loop(
def blocked_begin(*args, **kwargs):
admission_started.set()
if not release_admission.wait(timeout=2):
if not release_admission.wait(timeout=10):
raise TimeoutError("test did not release input admission")
return real_begin(*args, **kwargs)
@@ -1516,15 +1517,16 @@ async def test_input_admission_and_mkdir_do_not_block_the_listener_loop(
async def abort(self, _code, message):
raise RuntimeError(message)
watchdog = Timer(0.5, release_admission.set)
watchdog = Timer(5, release_admission.set)
watchdog.start()
started_at = asyncio.get_running_loop().time()
uploading = asyncio.create_task(servicer.PushInput(chunks(), Context()))
try:
await asyncio.wait_for(
asyncio.to_thread(admission_started.wait), timeout=1
)
assert asyncio.get_running_loop().time() - started_at < 0.2
assert not release_admission.is_set(), (
"input admission blocked the gRPC event loop"
)
finally:
release_admission.set()
watchdog.cancel()
@@ -1557,7 +1559,7 @@ async def test_artifact_untrack_cleanup_does_not_block_the_listener_loop(
def blocked_retry(key_id):
cleanup_started.set()
if not release_cleanup.wait(timeout=2):
if not release_cleanup.wait(timeout=10):
raise TimeoutError("test did not release ACK retry cleanup")
real_retry(key_id)
@@ -1577,15 +1579,14 @@ async def test_artifact_untrack_cleanup_does_not_block_the_listener_loop(
async def abort(self, _code, message):
raise RuntimeError(message)
watchdog = Timer(0.5, release_cleanup.set)
watchdog = Timer(5, release_cleanup.set)
watchdog.start()
started_at = asyncio.get_running_loop().time()
uploading = asyncio.create_task(servicer.PushInput(chunks(), Context()))
try:
await asyncio.wait_for(
asyncio.to_thread(cleanup_started.wait), timeout=1
)
assert asyncio.get_running_loop().time() - started_at < 0.2
assert not release_cleanup.is_set(), "input cleanup blocked the gRPC event loop"
finally:
release_cleanup.set()
watchdog.cancel()
@@ -2191,14 +2192,13 @@ async def test_result_ack_deletion_does_not_block_the_attach_loop(
def blocked_acked(artifact_id, *, key_id):
cleanup_started.set()
if not release_cleanup.wait(timeout=2):
if not release_cleanup.wait(timeout=10):
raise TimeoutError("test did not release result ACK cleanup")
real_acked(artifact_id, key_id=key_id)
monkeypatch.setattr(inbound.artifacts, "result_acked", blocked_acked)
watchdog = Timer(0.5, release_cleanup.set)
watchdog = Timer(5, release_cleanup.set)
watchdog.start()
started_at = asyncio.get_running_loop().time()
handling = asyncio.create_task(
protocol.handle_server_message(
pb.ServerMessage(result_ack=pb.ResultAckMessage(ref=ref))
@@ -2208,7 +2208,9 @@ async def test_result_ack_deletion_does_not_block_the_attach_loop(
await asyncio.wait_for(
asyncio.to_thread(cleanup_started.wait), timeout=1
)
assert asyncio.get_running_loop().time() - started_at < 0.2
assert not release_cleanup.is_set(), (
"result cleanup blocked the gRPC event loop"
)
finally:
release_cleanup.set()
watchdog.cancel()
@@ -2753,14 +2755,13 @@ async def test_result_publish_sweep_does_not_block_the_listener_loop(
def blocked_sweep(*args, **kwargs):
sweep_started.set()
if not release_sweep.wait(timeout=2):
if not release_sweep.wait(timeout=10):
raise TimeoutError("test did not release the staging sweep")
real_sweep(*args, **kwargs)
monkeypatch.setattr(store, "_sweep_locked", blocked_sweep)
watchdog = Timer(0.5, release_sweep.set)
watchdog = Timer(5, release_sweep.set)
watchdog.start()
started_at = asyncio.get_running_loop().time()
publish = asyncio.create_task(
store.publish(
pb.TaskRef(task_id="task", attempt_id="attempt"),
@@ -2771,7 +2772,9 @@ async def test_result_publish_sweep_does_not_block_the_listener_loop(
)
try:
await asyncio.wait_for(asyncio.to_thread(sweep_started.wait), timeout=1)
assert asyncio.get_running_loop().time() - started_at < 0.2
assert not release_sweep.is_set(), (
"artifact sweeping blocked the gRPC event loop"
)
finally:
release_sweep.set()
watchdog.cancel()
@@ -2927,14 +2930,13 @@ async def test_duplicate_input_validation_never_blocks_other_panel_admission(
def blocked_matches(path, expected_digest, expected_size):
if path == committed:
validation_started.set()
if not release_validation.wait(timeout=2):
if not release_validation.wait(timeout=10):
raise TimeoutError("test did not release duplicate validation")
return real_matches(path, expected_digest, expected_size)
monkeypatch.setattr(artifacts_module, "_file_matches", blocked_matches)
watchdog = Timer(0.5, release_validation.set)
watchdog = Timer(5, release_validation.set)
watchdog.start()
started_at = asyncio.get_running_loop().time()
committing = asyncio.create_task(
store.commit_input_async(
ref, retry, digest, len(payload), key_id="panel"
@@ -2947,14 +2949,15 @@ async def test_duplicate_input_validation_never_blocks_other_panel_admission(
try:
await asyncio.wait_for(wait_for_validation(), timeout=1)
elapsed = asyncio.get_running_loop().time() - started_at
other = store.begin_input(
pb.ArtifactRef(artifact_id="other", filename="reference.wav"),
key_id="other-panel",
reserve_bytes=1,
)
store.discard_input(other)
assert elapsed < 0.2, "duplicate hashing stalled the gRPC event loop"
assert not release_validation.is_set(), (
"duplicate hashing blocked the gRPC event loop"
)
finally:
release_validation.set()
watchdog.cancel()
@@ -3319,15 +3322,14 @@ async def test_cancelled_result_pull_drains_off_loop_write_before_unlink(
def blocked_write(handle, payload):
write_started.set()
if not release_write.wait(timeout=2):
if not release_write.wait(timeout=10):
raise TimeoutError("test did not release the fetched-result write")
real_write_all(handle, payload)
monkeypatch.setattr(connector_module, "_write_all", blocked_write)
destination = tmp_path / "partial.wav"
watchdog = Timer(0.5, release_write.set)
watchdog = Timer(5, release_write.set)
watchdog.start()
started_at = asyncio.get_running_loop().time()
fetching = asyncio.create_task(
connection.fetch_result(
pb.ArtifactRef(artifact_id="a1"), str(destination)
@@ -3340,10 +3342,12 @@ async def test_cancelled_result_pull_drains_off_loop_write_before_unlink(
try:
await asyncio.wait_for(wait_for_write(), timeout=1)
assert asyncio.get_running_loop().time() - started_at < 0.2
fetching.cancel()
await asyncio.sleep(0)
assert not fetching.done(), "cancellation abandoned an active file write"
assert not release_write.is_set(), (
"fetched-result writing blocked the gRPC event loop"
)
finally:
release_write.set()
watchdog.cancel()