diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ff026575..0c523007 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 980894b8..78e5ffc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/LICENSE-NOTICE.md b/LICENSE-NOTICE.md index f4bb1ac6..2ed1563c 100644 --- a/LICENSE-NOTICE.md +++ b/LICENSE-NOTICE.md @@ -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. diff --git a/README.md b/README.md index 48fc7fc0..5927ef56 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,12 @@
+ VoiceStudio ranking on Trendshift + VoiceStudio logo

VoiceStudio

Previously OmniVoice-Studio

-

Local voice cloning, dubbing, dictation, and long-form audio.

-

16 TTS engines · 11 ASR engines · 646-language catalogue · macOS, Windows, and Linux

-

Local-first. No account, API key, subscription, or usage meter for the core workflow.

+

Clone voices, dub video, dictate, and produce long-form audio on your own hardware.

+

16 TTS engines · 11 ASR engines · 646-language catalogue · macOS, Windows, Linux, and Docker

+

No account, API key, subscription, or usage meter for the local workflow.

Install · @@ -36,7 +38,7 @@

> [!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 | ## 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; 5–15 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.11–3.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. @@ -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. @@ -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
How much VRAM do I need? -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–16 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.
Why does a longer reference clip not always improve the clone? -Cloning is zero-shot: the clip is a prompt, not training data. Use 5–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). +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).
Can I use generated audio commercially? -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.
@@ -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 diff --git a/backend/api/public_engine_metadata.py b/backend/api/public_engine_metadata.py index d626c7ab..73da4ec7 100644 --- a/backend/api/public_engine_metadata.py +++ b/backend/api/public_engine_metadata.py @@ -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 diff --git a/backend/api/routers/dub_core.py b/backend/api/routers/dub_core.py index 7554ee6e..3206a064 100644 --- a/backend/api/routers/dub_core.py +++ b/backend/api/routers/dub_core.py @@ -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) diff --git a/backend/api/routers/engines.py b/backend/api/routers/engines.py index 42e8e966..3ee3c9fb 100644 --- a/backend/api/routers/engines.py +++ b/backend/api/routers/engines.py @@ -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) diff --git a/backend/api/routers/settings.py b/backend/api/routers/settings.py index 3939cf62..e3e7152f 100644 --- a/backend/api/routers/settings.py +++ b/backend/api/routers/settings.py @@ -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( diff --git a/backend/core/contained_subprocess.py b/backend/core/contained_subprocess.py index af15e976..aa8c5d93 100644 --- a/backend/core/contained_subprocess.py +++ b/backend/core/contained_subprocess.py @@ -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: diff --git a/backend/core/diagnose.py b/backend/core/diagnose.py index 27a25e85..1f3ed317 100644 --- a/backend/core/diagnose.py +++ b/backend/core/diagnose.py @@ -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( diff --git a/backend/engines/omnivoice_subprocess/__init__.py b/backend/engines/omnivoice_subprocess/__init__.py index 7ce2ac32..e7a85519 100644 --- a/backend/engines/omnivoice_subprocess/__init__.py +++ b/backend/engines/omnivoice_subprocess/__init__.py @@ -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]: diff --git a/backend/mcp_server.py b/backend/mcp_server.py index 18245590..258e8a31 100644 --- a/backend/mcp_server.py +++ b/backend/mcp_server.py @@ -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 ``.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/.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, diff --git a/backend/services/asr_backend.py b/backend/services/asr_backend.py index 8e8c90df..023ef1fc 100644 --- a/backend/services/asr_backend.py +++ b/backend/services/asr_backend.py @@ -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 diff --git a/backend/services/engine_disk_usage.py b/backend/services/engine_disk_usage.py new file mode 100644 index 00000000..ebbc5e79 --- /dev/null +++ b/backend/services/engine_disk_usage.py @@ -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", + }, + } diff --git a/backend/services/engine_evidence.py b/backend/services/engine_evidence.py new file mode 100644 index 00000000..bc6af1ab --- /dev/null +++ b/backend/services/engine_evidence.py @@ -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 diff --git a/backend/services/sherpa_dictation.py b/backend/services/sherpa_dictation.py index 60b26a48..3827f1f7 100644 --- a/backend/services/sherpa_dictation.py +++ b/backend/services/sherpa_dictation.py @@ -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 diff --git a/backend/services/sidecar_install.py b/backend/services/sidecar_install.py index 57adfdd0..45d562d4 100644 --- a/backend/services/sidecar_install.py +++ b/backend/services/sidecar_install.py @@ -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. diff --git a/backend/services/subprocess_backend.py b/backend/services/subprocess_backend.py index 5fde7217..f189bd53 100644 --- a/backend/services/subprocess_backend.py +++ b/backend/services/subprocess_backend.py @@ -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 diff --git a/backend/services/tts_backend.py b/backend/services/tts_backend.py index bf02c7d9..f84bb406 100644 --- a/backend/services/tts_backend.py +++ b/backend/services/tts_backend.py @@ -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 diff --git a/backend/tests/test_contained_subprocess.py b/backend/tests/test_contained_subprocess.py index 2b9f046a..b281f5c7 100644 --- a/backend/tests/test_contained_subprocess.py +++ b/backend/tests/test_contained_subprocess.py @@ -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})] diff --git a/backend/tests/test_omnivoice_subprocess.py b/backend/tests/test_omnivoice_subprocess.py index bfede655..7c01ba29 100644 --- a/backend/tests/test_omnivoice_subprocess.py +++ b/backend/tests/test_omnivoice_subprocess.py @@ -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 diff --git a/docs/engines/README.md b/docs/engines/README.md index 350dddbc..57175b2d 100644 --- a/docs/engines/README.md +++ b/docs/engines/README.md @@ -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 diff --git a/docs/engines/disk-usage.md b/docs/engines/disk-usage.md index 291cd0db..cb05475e 100644 --- a/docs/engines/disk-usage.md +++ b/docs/engines/disk-usage.md @@ -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 diff --git a/docs/engines/openai-compatible-asr.md b/docs/engines/openai-compatible-asr.md index 6ad55a32..decd3cbe 100644 --- a/docs/engines/openai-compatible-asr.md +++ b/docs/engines/openai-compatible-asr.md @@ -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://:8000/v1` | your deployment's model id | if you enabled auth | +| Self-hosted Qwen3-ASR / FunASR (LAN box) | `https://: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. diff --git a/docs/features.yaml b/docs/features.yaml index 4c9d1b4d..82afabb8 100644 --- a/docs/features.yaml +++ b/docs/features.yaml @@ -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: diff --git a/docs/install/docker.md b/docs/install/docker.md index a4098590..eef3425b 100644 --- a/docs/install/docker.md +++ b/docs/install/docker.md @@ -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: diff --git a/docs/install/linux.md b/docs/install/linux.md index f17b9d70..f73f9327 100644 --- a/docs/install/linux.md +++ b/docs/install/linux.md @@ -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). diff --git a/docs/install/troubleshooting.md b/docs/install/troubleshooting.md index 5f238afa..ccb6cdd4 100644 --- a/docs/install/troubleshooting.md +++ b/docs/install/troubleshooting.md @@ -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) diff --git a/docs/install/uninstall.md b/docs/install/uninstall.md index eb1d536a..df76a11b 100644 --- a/docs/install/uninstall.md +++ b/docs/install/uninstall.md @@ -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 diff --git a/docs/install/windows.md b/docs/install/windows.md index 59f635f5..9d3e5f63 100644 --- a/docs/install/windows.md +++ b/docs/install/windows.md @@ -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__x64_en-US.msi` | All users (per-machine) | Yes | `%ProgramFiles%\VoiceStudio` | +| `VoiceStudio_Current_User__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) diff --git a/docs/mcp.json b/docs/mcp.json index b3458225..74d0f301 100644 --- a/docs/mcp.json +++ b/docs/mcp.json @@ -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= 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= 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", diff --git a/docs/mcp.md b/docs/mcp.md index aa498591..6c4ffc38 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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 diff --git a/frontend/src-tauri/src/commands.rs b/frontend/src-tauri/src/commands.rs index 2af15251..985f1fa4 100644 --- a/frontend/src-tauri/src/commands.rs +++ b/frontend/src-tauri/src/commands.rs @@ -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 { + let flags = app.state::(); + 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::(); 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::(); + 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::(); + 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, diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index 6a9c2243..273adab5 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -103,6 +103,9 @@ pub struct AppFlags { pub struct CaptureDispatchState { pub(crate) ready: bool, pub(crate) pending: VecDeque, + registration_counter: u64, + delivery_counter: u64, + active_registration: Option, } 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 { + 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 { + 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, diff --git a/frontend/src-tauri/src/updater_channel.rs b/frontend/src-tauri/src/updater_channel.rs index cf57d679..c2cc8b0f 100644 --- a/frontend/src-tauri/src/updater_channel.rs +++ b/frontend/src-tauri/src/updater_channel.rs @@ -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, 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, S let mut best: Option = None; let mut any_ok = false; let mut first_err: Option = 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, 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, String> { +pub async fn check_update(app: AppHandle, channel: String) -> Result, 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, 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. diff --git a/frontend/src-tauri/tauri.conf.json b/frontend/src-tauri/tauri.conf.json index 157b76d9..593a4830 100644 --- a/frontend/src-tauri/tauri.conf.json +++ b/frontend/src-tauri/tauri.conf.json @@ -99,6 +99,15 @@ "minimumSystemVersion": "13.3", "signingIdentity": "-", "entitlements": "entitlements.plist" + }, + "windows": { + "webviewInstallMode": { + "type": "downloadBootstrapper", + "silent": true + }, + "wix": { + "template": "wix/main.wxs" + } } }, "plugins": { diff --git a/frontend/src-tauri/tauri.per-user.conf.json b/frontend/src-tauri/tauri.per-user.conf.json new file mode 100644 index 00000000..a669693d --- /dev/null +++ b/frontend/src-tauri/tauri.per-user.conf.json @@ -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 + } + } + } +} diff --git a/frontend/src-tauri/wix/main.wxs b/frontend/src-tauri/wix/main.wxs new file mode 100644 index 00000000..126491ca --- /dev/null +++ b/frontend/src-tauri/wix/main.wxs @@ -0,0 +1,392 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{#if allow_downgrades}} + + {{else}} + + {{/if}} + + + Installed AND NOT UPGRADINGPRODUCTCODE + + + + + {{#if banner_path}} + + {{/if}} + {{#if dialog_image_path}} + + {{/if}} + {{#if license}} + + {{/if}} + + + + + + + {{#if homepage}} + + + + {{/if}} + + + + + + + + + + + + + + + + + + WIXUI_EXITDIALOGOPTIONALCHECKBOX = 1 AND NOT AUTOLAUNCHAPP AND NOT Installed + + + + {{#unless license}} + + 1 + 1 + {{/unless}} + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{#each deep_link_protocols as |protocol| ~}} + + + + + + + + + + + {{/each~}} + + + + {{#each file_associations as |association| ~}} + {{#each association.ext as |ext| ~}} + + + + + + {{/each~}} + {{/each~}} + + {{#each binaries as |bin| ~}} + + + + {{/each~}} + {{#if enable_elevated_update_task}} + + + + + + + + + + {{/if}} + {{resources}} + + + + + + + + + + + + + + + + + + + + + {{#each merge_modules as |msm| ~}} + + + + + + + + {{/each~}} + + + + + + {{#each resource_file_ids as |resource_file_id| ~}} + + {{/each~}} + + {{#if enable_elevated_update_task}} + + + + {{/if}} + + + + + + + + + + + {{#each binaries as |bin| ~}} + + {{/each~}} + + + + + {{#each component_group_refs as |id| ~}} + + {{/each~}} + {{#each component_refs as |id| ~}} + + {{/each~}} + {{#each feature_group_refs as |id| ~}} + + {{/each~}} + {{#each feature_refs as |id| ~}} + + {{/each~}} + {{#each merge_refs as |id| ~}} + + {{/each~}} + + + {{#if install_webview}} + + + + + + + + + "1")]]> + + + {{#if download_bootstrapper}} + + + + + "1"]]> + + + {{/if}} + + {{#if webview2_bootstrapper_path}} + + + + + + "1"]]> + + + {{/if}} + + {{#if webview2_installer_path}} + + + + + + "1"]]> + + + {{/if}} + + {{#if minimum_webview2_version}} + + + + + + + + + + + + + "1" + AND INSTALLED_WEBVIEW2_VERSION + AND (INSTALLED_WEBVIEW2_VERSION < MINIMUM_WEBVIEW2_VERSION) + ]]> + + + {{/if}} + + + {{/if}} + + {{#if enable_elevated_update_task}} + + + + + NOT(REMOVE) + + + + + + + (REMOVE = "ALL") AND NOT UPGRADINGPRODUCTCODE + + + {{/if}} + + + AUTOLAUNCHAPP AND AUTOLAUNCHAPP <> "0" AND NOT Installed + + + + + diff --git a/frontend/src/api/dub.ts b/frontend/src/api/dub.ts index 4d515f09..723f08d9 100644 --- a/frontend/src/api/dub.ts +++ b/frontend/src/api/dub.ts @@ -117,10 +117,11 @@ export interface DubImportSrtResponse { export async function dubImportSrt( jobId: string, file: File | Blob, + { signal }: { signal?: AbortSignal } = {}, ): Promise { const fd = new FormData(); fd.append('file', file); - return apiPost(`/dub/import-srt/${jobId}`, fd); + return apiPost(`/dub/import-srt/${jobId}`, fd, { signal }); } export interface ParsedSubtitleCue { diff --git a/frontend/src/api/engines.ts b/frontend/src/api/engines.ts index 466e025d..1a0a92e7 100644 --- a/frontend/src/api/engines.ts +++ b/frontend/src/api/engines.ts @@ -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(`/engines/${encodeURIComponent(engineId)}/health`); } +export async function getEngineDiskUsage(engineId: string): Promise { + return apiJson(`/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), diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 8238e706..3891b370 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -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 diff --git a/frontend/src/components/BootstrapSplash.jsx b/frontend/src/components/BootstrapSplash.jsx index 5dd5d03d..8229dc6d 100644 --- a/frontend/src/components/BootstrapSplash.jsx +++ b/frontend/src/components/BootstrapSplash.jsx @@ -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 { diff --git a/frontend/src/components/CaptureWidget.jsx b/frontend/src/components/CaptureWidget.jsx index 097d6c4a..8304dd65 100644 --- a/frontend/src/components/CaptureWidget.jsx +++ b/frontend/src/components/CaptureWidget.jsx @@ -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. diff --git a/frontend/src/components/CaptureWidget.test.jsx b/frontend/src/components/CaptureWidget.test.jsx index b36349ec..54856314 100644 --- a/frontend/src/components/CaptureWidget.test.jsx +++ b/frontend/src/components/CaptureWidget.test.jsx @@ -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()); + 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; diff --git a/frontend/src/components/EngineCompatibilityMatrix.jsx b/frontend/src/components/EngineCompatibilityMatrix.jsx index f9d1bfe4..7be7e8e7 100644 --- a/frontend/src/components/EngineCompatibilityMatrix.jsx +++ b/frontend/src/components/EngineCompatibilityMatrix.jsx @@ -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 ? : } - 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')} @@ -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. + } + } + }} > - {t('engines.whyUnavailable')} + {b.available ? t('engines.diskDetails') : t('engines.whyUnavailable')} )} @@ -1258,7 +1312,9 @@ export default function EngineCompatibilityMatrix({ loading={installRunning} leading={!installRunning && } 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 })} )} + {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 ( +
+ {t('engines.diskModelDownload')} + {value(estimate.model_download_bytes)} + {t('engines.diskPackageDownload')} + {value(estimate.package_download_bytes)} + {t('engines.diskUniqueInstalled')} + {value(estimate.unique_installed_bytes)} + {t('engines.diskPotentiallyShared')} + {value(estimate.potentially_shared_bytes)} + {t('engines.diskTemporary')} + {value(estimate.temporary_free_bytes)} + {t('engines.diskEstimateConfidence')} + + {t(`engines.diskConfidence_${estimate.confidence || 'unknown'}`)} + + {t('engines.diskDestination')} + + {estimate.destination && estimate.destination !== 'unknown' + ? t(`engines.diskDestination_${estimate.destination}`) + : t('common.unknown')} + {estimate.destination_volume && + estimate.destination_volume !== 'unknown' && ( + {estimate.destination_volume} + )} + + {t('engines.diskActualModel')} + {value(actual.model_bytes)} + {t('engines.diskActualEnvironment')} + {value(actual.environment_bytes)} + {t('engines.diskActualCache')} + {value(actual.cache_bytes)} + {t('engines.diskActualTotal')} + {value(actual.total_owned_bytes)} + {t('engines.diskActualConfidence')} + + {t(`engines.diskConfidence_${actual.confidence || 'unknown'}`)} + + {estimate.deduplication && ( + + {t(`engines.diskDedup_${estimate.deduplication}`)} + + )} +
+ ); + })()} {/* 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 && diff --git a/frontend/src/components/settings/EnginesTab.test.jsx b/frontend/src/components/settings/EnginesTab.test.jsx index 3edcade2..bd4f7313 100644 --- a/frontend/src/components/settings/EnginesTab.test.jsx +++ b/frontend/src/components/settings/EnginesTab.test.jsx @@ -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 diff --git a/frontend/src/hooks/useDubWorkflow.js b/frontend/src/hooks/useDubWorkflow.js index c6a6fb3f..40494ca2 100644 --- a/frontend/src/hooks/useDubWorkflow.js +++ b/frontend/src/hooks/useDubWorkflow.js @@ -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 () => { diff --git a/frontend/src/i18n/locales/ar.json b/frontend/src/i18n/locales/ar.json index 6b30a9b2..a77b3fac 100644 --- a/frontend/src/i18n/locales/ar.json +++ b/frontend/src/i18n/locales/ar.json @@ -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}} (تداخل)", diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 691cc88a..1d963f65 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -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)", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index c2671ed7..0e8ad490 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -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)", diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index 60a4b095..fc0923a5 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -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)", diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index c68a977e..16fb88b5 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -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 l’estimation", + "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 l’environnement 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 d’abord une vidéo – il n’y a aucune tâche à laquelle attacher des sous-titres.", + "import_srt_after_speakers": "SRT mis en attente. L’analyse des locuteurs se terminera d’abord 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)", diff --git a/frontend/src/i18n/locales/hi.json b/frontend/src/i18n/locales/hi.json index e31a249c..acf5015f 100644 --- a/frontend/src/i18n/locales/hi.json +++ b/frontend/src/i18n/locales/hi.json @@ -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}} गिरा दिया गया (ओवरलैप)", diff --git a/frontend/src/i18n/locales/id.json b/frontend/src/i18n/locales/id.json index 13c01da5..a20cd9a5 100644 --- a/frontend/src/i18n/locales/id.json +++ b/frontend/src/i18n/locales/id.json @@ -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)", diff --git a/frontend/src/i18n/locales/it.json b/frontend/src/i18n/locales/it.json index 08f7ba05..5a49c7b6 100644 --- a/frontend/src/i18n/locales/it.json +++ b/frontend/src/i18n/locales/it.json @@ -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 l’ambiente 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 l’analisi 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)", diff --git a/frontend/src/i18n/locales/ja.json b/frontend/src/i18n/locales/ja.json index e3ff43bc..2eaf7a7e 100644 --- a/frontend/src/i18n/locales/ja.json +++ b/frontend/src/i18n/locales/ja.json @@ -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}} が削除されました (重複)", diff --git a/frontend/src/i18n/locales/ko.json b/frontend/src/i18n/locales/ko.json index b991ec7d..2ae9fa58 100644 --- a/frontend/src/i18n/locales/ko.json +++ b/frontend/src/i18n/locales/ko.json @@ -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}} 삭제됨(겹침)", diff --git a/frontend/src/i18n/locales/nl.json b/frontend/src/i18n/locales/nl.json index c182d89b..8056e396 100644 --- a/frontend/src/i18n/locales/nl.json +++ b/frontend/src/i18n/locales/nl.json @@ -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)", diff --git a/frontend/src/i18n/locales/pl.json b/frontend/src/i18n/locales/pl.json index f8a8c16a..5d59bf05 100644 --- a/frontend/src/i18n/locales/pl.json +++ b/frontend/src/i18n/locales/pl.json @@ -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ę)", diff --git a/frontend/src/i18n/locales/pt.json b/frontend/src/i18n/locales/pt.json index 194829c8..edc8d29c 100644 --- a/frontend/src/i18n/locales/pt.json +++ b/frontend/src/i18n/locales/pt.json @@ -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)", diff --git a/frontend/src/i18n/locales/ru.json b/frontend/src/i18n/locales/ru.json index 2d2a8140..ac25998d 100644 --- a/frontend/src/i18n/locales/ru.json +++ b/frontend/src/i18n/locales/ru.json @@ -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}} удалено (перекрытие)", diff --git a/frontend/src/i18n/locales/sv.json b/frontend/src/i18n/locales/sv.json index 402c199e..b125323d 100644 --- a/frontend/src/i18n/locales/sv.json +++ b/frontend/src/i18n/locales/sv.json @@ -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)", diff --git a/frontend/src/i18n/locales/th.json b/frontend/src/i18n/locales/th.json index 4a0cc9ea..5fad6930 100644 --- a/frontend/src/i18n/locales/th.json +++ b/frontend/src/i18n/locales/th.json @@ -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}} ลดลง (ทับซ้อนกัน)", diff --git a/frontend/src/i18n/locales/tr.json b/frontend/src/i18n/locales/tr.json index d16c8989..46bd6346 100644 --- a/frontend/src/i18n/locales/tr.json +++ b/frontend/src/i18n/locales/tr.json @@ -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)", diff --git a/frontend/src/i18n/locales/uk.json b/frontend/src/i18n/locales/uk.json index fa12041c..90624251 100644 --- a/frontend/src/i18n/locales/uk.json +++ b/frontend/src/i18n/locales/uk.json @@ -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}} вилучено (перекриття)", diff --git a/frontend/src/i18n/locales/vi.json b/frontend/src/i18n/locales/vi.json index a0376dfa..5c69f1d3 100644 --- a/frontend/src/i18n/locales/vi.json +++ b/frontend/src/i18n/locales/vi.json @@ -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)", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 79f64acd..e4afdccd 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -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}} 条因重叠被丢弃", diff --git a/frontend/src/i18n/locales/zh-TW.json b/frontend/src/i18n/locales/zh-TW.json index 8cbf61ea..8f8b94fa 100644 --- a/frontend/src/i18n/locales/zh-TW.json +++ b/frontend/src/i18n/locales/zh-TW.json @@ -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}} 掉落(重疊)", diff --git a/frontend/src/test/BootstrapSplashAwaitingSetupStall.test.jsx b/frontend/src/test/BootstrapSplashAwaitingSetupStall.test.jsx index 12912985..3a32e4c6 100644 --- a/frontend/src/test/BootstrapSplashAwaitingSetupStall.test.jsx +++ b/frontend/src/test/BootstrapSplashAwaitingSetupStall.test.jsx @@ -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); }); diff --git a/frontend/src/test/CaptureWidgetMicPreflight.test.jsx b/frontend/src/test/CaptureWidgetMicPreflight.test.jsx index dd1ddf83..a9620cc2 100644 --- a/frontend/src/test/CaptureWidgetMicPreflight.test.jsx +++ b/frontend/src/test/CaptureWidgetMicPreflight.test.jsx @@ -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') { diff --git a/frontend/src/test/CaptureWidgetPillVisible.test.jsx b/frontend/src/test/CaptureWidgetPillVisible.test.jsx index 0ef98f41..84ffee64 100644 --- a/frontend/src/test/CaptureWidgetPillVisible.test.jsx +++ b/frontend/src/test/CaptureWidgetPillVisible.test.jsx @@ -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; }), diff --git a/frontend/src/test/CaptureWidgetSetupRace.test.jsx b/frontend/src/test/CaptureWidgetSetupRace.test.jsx index c1cfc132..b1a0f1ec 100644 --- a/frontend/src/test/CaptureWidgetSetupRace.test.jsx +++ b/frontend/src/test/CaptureWidgetSetupRace.test.jsx @@ -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(); + + 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(); + 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(); diff --git a/frontend/src/test/CaptureWidgetStrandedPill.test.jsx b/frontend/src/test/CaptureWidgetStrandedPill.test.jsx index 8ed23338..e301638d 100644 --- a/frontend/src/test/CaptureWidgetStrandedPill.test.jsx +++ b/frontend/src/test/CaptureWidgetStrandedPill.test.jsx @@ -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; }, diff --git a/frontend/src/test/EngineCompatibilityMatrix.test.jsx b/frontend/src/test/EngineCompatibilityMatrix.test.jsx index a668c8f8..6d6ea5a7 100644 --- a/frontend/src/test/EngineCompatibilityMatrix.test.jsx +++ b/frontend/src/test/EngineCompatibilityMatrix.test.jsx @@ -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( + , + ); + + 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( + , + ); + + await screen.findByText('OmniVoice (test)'); + fireEvent.click(screen.getByRole('button', { name: 'Disk details' })); + await waitFor(() => expect(apiGetDiskUsage).toHaveBeenCalledOnce()); + view.rerender( + , + ); + 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( { 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', diff --git a/frontend/src/test/dubQueuedSrtImport.test.js b/frontend/src/test/dubQueuedSrtImport.test.js new file mode 100644 index 00000000..b2e67fad --- /dev/null +++ b/frontend/src/test/dubQueuedSrtImport.test.js @@ -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([]); + }); +}); diff --git a/scripts/build_windows_user_manifest.py b/scripts/build_windows_user_manifest.py new file mode 100644 index 00000000..358fb429 --- /dev/null +++ b/scripts/build_windows_user_manifest.py @@ -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() diff --git a/scripts/render-per-user-wix.py b/scripts/render-per-user-wix.py new file mode 100644 index 00000000..af8747ce --- /dev/null +++ b/scripts/render-per-user-wix.py @@ -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 = " " +WEBVIEW_ACTIONS_END = " " + +TRANSFORMS = ( + ('InstallScope="perMachine"', 'InstallScope="perUser"'), + ( + 'Id="PrevInstallDirNoName" Root="HKLM"', + 'Id="PrevInstallDirNoName" Root="HKCU"', + ), + ( + 'Id="PrevInstallDirWithName" Root="HKLM"', + 'Id="PrevInstallDirWithName" Root="HKCU"', + ), + ( + '', + '', + ), + ( + '', + '', + ), + ('Value="perMachine"', 'Value="perUser"'), + ( + 'Guid="{{path_component_guid}}"', + 'Guid="41f6d598-8908-4004-9332-291b64fd38be"', + ), + ( + r'', + r'', + ), + ( + ' \n' + ' \n' + ' ', + ' ', + ), + ( + ' "1")]]>', + ' ', + ), +) + + +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] + + " " + + 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() diff --git a/scripts/smoke-per-user-msi.ps1 b/scripts/smoke-per-user-msi.ps1 new file mode 100644 index 00000000..ea6935d9 --- /dev/null +++ b/scripts/smoke-per-user-msi.ps1 @@ -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 + } +} diff --git a/scripts/verify-windows-msi.ps1 b/scripts/verify-windows-msi.ps1 new file mode 100644 index 00000000..1097564a --- /dev/null +++ b/scripts/verify-windows-msi.ps1 @@ -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" diff --git a/tests/backend/api/test_engines_route_shape.py b/tests/backend/api/test_engines_route_shape.py index 4768922e..36bf1462 100644 --- a/tests/backend/api/test_engines_route_shape.py +++ b/tests/backend/api/test_engines_route_shape.py @@ -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.""" diff --git a/tests/backend/services/test_tts_backend_registry.py b/tests/backend/services/test_tts_backend_registry.py index a7ef1843..3981d3a9 100644 --- a/tests/backend/services/test_tts_backend_registry.py +++ b/tests/backend/services/test_tts_backend_registry.py @@ -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: diff --git a/tests/fixtures/api_routes.txt b/tests/fixtures/api_routes.txt index 7a1dc9d6..a8c1ced9 100644 --- a/tests/fixtures/api_routes.txt +++ b/tests/fixtures/api_routes.txt @@ -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 diff --git a/tests/test_admin_route_policy.py b/tests/test_admin_route_policy.py index b41b98ed..22670755 100644 --- a/tests/test_admin_route_policy.py +++ b/tests/test_admin_route_policy.py @@ -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"), diff --git a/tests/test_asr_openai_compat_877.py b/tests/test_asr_openai_compat_877.py index 36b28404..f04058c9 100644 --- a/tests/test_asr_openai_compat_877.py +++ b/tests/test_asr_openai_compat_877.py @@ -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"] diff --git a/tests/test_diagnose.py b/tests/test_diagnose.py index a3ab7836..10d26ff2 100644 --- a/tests/test_diagnose.py +++ b/tests/test_diagnose.py @@ -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) ───────────── diff --git a/tests/test_diagnostic_bundle.py b/tests/test_diagnostic_bundle.py index 8eb97a67..2cd79201 100644 --- a/tests/test_diagnostic_bundle.py +++ b/tests/test_diagnostic_bundle.py @@ -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): diff --git a/tests/test_docker_admin_auth_docs.py b/tests/test_docker_admin_auth_docs.py index 56568dc7..71445fc4 100644 --- a/tests/test_docker_admin_auth_docs.py +++ b/tests/test_docker_admin_auth_docs.py @@ -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 diff --git a/tests/test_dub_export_unique.py b/tests/test_dub_export_unique.py index 9ec20287..5949b055 100644 --- a/tests/test_dub_export_unique.py +++ b/tests/test_dub_export_unique.py @@ -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" diff --git a/tests/test_engine_disk_usage.py b/tests/test_engine_disk_usage.py new file mode 100644 index 00000000..885318f9 --- /dev/null +++ b/tests/test_engine_disk_usage.py @@ -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] diff --git a/tests/test_engine_execution_evidence.py b/tests/test_engine_execution_evidence.py new file mode 100644 index 00000000..29599840 --- /dev/null +++ b/tests/test_engine_execution_evidence.py @@ -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 diff --git a/tests/test_engines.py b/tests/test_engines.py index fa515138..817003e9 100644 --- a/tests/test_engines.py +++ b/tests/test_engines.py @@ -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(): diff --git a/tests/test_mcp_output_mode.py b/tests/test_mcp_output_mode.py index dcae5909..64e871c7 100644 --- a/tests/test_mcp_output_mode.py +++ b/tests/test_mcp_output_mode.py @@ -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") diff --git a/tests/test_sherpa_dictation.py b/tests/test_sherpa_dictation.py index 1f76d820..e14a33e0 100644 --- a/tests/test_sherpa_dictation.py +++ b/tests/test_sherpa_dictation.py @@ -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 diff --git a/tests/test_sidecar_install.py b/tests/test_sidecar_install.py index c438aa70..3cd66196 100644 --- a/tests/test_sidecar_install.py +++ b/tests/test_sidecar_install.py @@ -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 ): diff --git a/tests/test_windows_msi_contract.py b/tests/test_windows_msi_contract.py new file mode 100644 index 00000000..5c5ff763 --- /dev/null +++ b/tests/test_windows_msi_contract.py @@ -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 '' in source + assert '' 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}} '/install'" 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 <> "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 diff --git a/tests/test_windows_per_user_installer.py b/tests/test_windows_per_user_installer.py new file mode 100644 index 00000000..0b4d9326 --- /dev/null +++ b/tests/test_windows_per_user_installer.py @@ -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 '' in user + assert '' 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 diff --git a/tests/test_windows_user_manifest.py b/tests/test_windows_user_manifest.py new file mode 100644 index 00000000..e7342c3b --- /dev/null +++ b/tests/test_windows_user_manifest.py @@ -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", + } diff --git a/tests/test_worker_inbound_transport.py b/tests/test_worker_inbound_transport.py index bcf90e46..87fedac5 100644 --- a/tests/test_worker_inbound_transport.py +++ b/tests/test_worker_inbound_transport.py @@ -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()