Compare commits

..
241 changed files with 2393 additions and 24341 deletions
-24
View File
@@ -271,17 +271,6 @@ jobs:
working-directory: frontend/src-tauri
run: cargo test --lib --target ${{ matrix.rust_target }} --message-format=short
# Backend-lifecycle fault-injection harness: real child processes die
# scripted deaths through the OMNIVOICE_BACKEND_CMD seam, and each
# scenario asserts the user-visible diagnosis names the actual cause
# (port conflict / traceback root cause / spawn failure / timeout /
# crash-loop exhaustion / signal 9 / deliberate replace / deferred-
# startup step). Serial: the scenarios share process-global state
# (env vars, crash store, kill-intended flag) by design.
- name: Cargo test (backend lifecycle harness)
working-directory: frontend/src-tauri
run: cargo test --test backend_lifecycle --target ${{ matrix.rust_target }} --message-format=short -- --test-threads=1
# ── Cross-platform Python runtime smoke (Phase 0 GATE-02) ───────────────
# Loads the frozen tests/fixtures/omnivoice_data/ fixture and boots the
# FastAPI app in-process via TestClient on macOS/Windows/Linux. Catches
@@ -376,19 +365,6 @@ jobs:
echo "choco attempt $i did not produce ffmpeg — retrying in $((i * 30))s"
sleep $((i * 30))
done
# Chocolatey is one distribution channel, not the dependency. When
# its feed is down across every retry (2026-08-13: three attempts,
# three 'installed 0/1'), fall back to the static gyan.dev release
# build GitHub mirror — the same binary, no feed in the path.
if ! command -v ffmpeg >/dev/null 2>&1; then
echo "::warning::choco feed down — falling back to static ffmpeg build"
curl -fsSL --retry 3 -o /tmp/ffmpeg.zip \
https://github.com/GyanD/codexffmpeg/releases/download/7.1/ffmpeg-7.1-essentials_build.zip
unzip -q /tmp/ffmpeg.zip -d /tmp/ffmpeg
bindir=$(dirname "$(find /tmp/ffmpeg -name ffmpeg.exe | head -1)")
echo "$bindir" >> "$GITHUB_PATH"
export PATH="$bindir:$PATH"
fi
ffmpeg -version
- name: System deps (Linux)
-100
View File
@@ -731,62 +731,6 @@ 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"
# 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
# build (v0.5.0). The seam tauri-action leaves us is post-upload: repack
# the AppImage with the icon as a REGULAR FILE, re-sign it (the updater
# signature covered the old bytes), and clobber the draft release's
# asset + the linux signature inside latest.json. The smoke below then
# validates the repaired artifact, not the broken one.
- name: Repair AppImage .DirIcon, re-sign, re-upload
if: runner.os == 'Linux'
timeout-minutes: 10
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
# Data, not shell source (zizmor template-injection): a crafted ref
# must never expand inside a script that holds the signing key.
TAG: ${{ (needs.preview-gate.outputs.is_preview == 'true') && 'preview' || github.ref_name }}
run: |
set -euo pipefail
APPIMAGE=$(find frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle/appimage -name "*.AppImage" | head -1)
APPIMAGE=$(realpath "$APPIMAGE")
WORK="$(mktemp -d)"; cd "$WORK"
"$APPIMAGE" --appimage-extract >/dev/null
ROOT="$WORK/squashfs-root"
ICON=$(readlink -f "$ROOT/.DirIcon" 2>/dev/null || true)
if [ -n "$ICON" ] && [ -f "$ICON" ] && case "$ICON" in "$ROOT"/*) true;; *) false;; esac; then
echo ".DirIcon already resolves inside the bundle — no repair needed"
exit 0
fi
# The real bytes are at the AppDir root (linuxdeploy put them there
# before mislinking). Ship a regular file: nothing left to dangle.
SRC=$(find "$ROOT" -maxdepth 1 -name "*.png" | head -1)
[ -n "$SRC" ] || SRC=$(find "$ROOT/usr/share/icons" -name "*.png" | head -1)
[ -n "$SRC" ] || { echo "no icon bytes found in bundle"; exit 1; }
rm -f "$ROOT/.DirIcon"
cp "$SRC" "$ROOT/.DirIcon"
# Pinned immutable release + checksum: this binary runs with the
# updater signing key and a release-write token in its environment,
# so a mutable 'continuous' asset is not acceptable supply chain.
AIT_URL="https://github.com/AppImage/appimagetool/releases/download/1.9.1/appimagetool-x86_64.AppImage"
AIT_SHA256="ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0"
curl -fsSL --retry 3 -o "$WORK/appimagetool" "$AIT_URL"
echo "$AIT_SHA256 $WORK/appimagetool" | sha256sum -c - || { echo "appimagetool checksum mismatch"; exit 1; }
chmod +x "$WORK/appimagetool"
# Same FUSE-less trick the build itself uses.
APPIMAGE_EXTRACT_AND_RUN=1 ARCH=x86_64 "$WORK/appimagetool" --no-appstream "$ROOT" "$APPIMAGE"
cd "$GITHUB_WORKSPACE/frontend"
bunx tauri signer sign "$APPIMAGE"
gh release upload "$TAG" "$APPIMAGE" "$APPIMAGE.sig" --clobber --repo "$GITHUB_REPOSITORY"
# latest.json is NOT patched here: every tauri-action leg re-uploads
# the shared manifest, so an in-leg patch races the other platforms —
# the repair-updater-manifest job below is the single final writer.
echo "repacked, re-signed, re-uploaded"
- name: Installer smoke (Linux)
if: runner.os == 'Linux'
timeout-minutes: 5
@@ -900,50 +844,6 @@ jobs:
# the tag (v0.3.20 shipped with only the Linux AppImage that way). `needs:
# [build]` guarantees the release already exists; `--clobber` makes a re-run
# idempotent. This can never create a second release.
# The Linux leg may repack + re-sign its AppImage (see the repair step in
# the build matrix); every tauri-action leg also re-uploads the SHARED
# latest.json, so patching the manifest inside any leg races the others.
# This job runs once after the whole matrix as the single final writer:
# it makes the manifest's linux signature agree with the .sig asset that
# actually shipped, and refuses to leave a mismatch behind.
repair-updater-manifest:
needs: [build, preview-gate]
runs-on: ubuntu-latest
timeout-minutes: 10
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Data, not shell source — same zizmor rule as the leg step.
TAG: ${{ (needs.preview-gate.outputs.is_preview == 'true') && 'preview' || github.ref_name }}
steps:
- name: Align latest.json's linux signature with the shipped .sig asset
shell: bash
run: |
set -euo pipefail
WORK="$(mktemp -d)"
HAS_MANIFEST=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '[.assets[].name]|contains(["latest.json"])')
if [ "$HAS_MANIFEST" != "true" ]; then
echo "no latest.json on the release — nothing to align"; exit 0
fi
gh release download "$TAG" --pattern latest.json --output "$WORK/latest.json" --repo "$GITHUB_REPOSITORY"
# Same fail-closed rule as the manifest: absence is checked against
# the asset LIST; an actual download failure must fail the job, or
# the manifest keeps a signature nobody shipped.
HAS_SIG=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '[.assets[].name|select(endswith(".AppImage.sig"))]|length > 0')
if [ "$HAS_SIG" != "true" ]; then
echo "no AppImage .sig asset on the release — nothing to align"; exit 0
fi
gh release download "$TAG" --pattern "*.AppImage.sig" --dir "$WORK" --repo "$GITHUB_REPOSITORY"
SIG_FILE=$(find "$WORK" -name "*.AppImage.sig" | head -1)
[ -n "$SIG_FILE" ] || { echo "sig asset listed but download produced nothing"; exit 1; }
NEW_SIG=$(cat "$SIG_FILE")
CHANGED=$(python3 -c 'import json,sys; p,sig=sys.argv[1],sys.argv[2]; d=json.load(open(p)); n=sum(1 for k,v in d.get("platforms",{}).items() if k.startswith("linux") and v.get("signature")!=sig and not v.update({"signature":sig})); json.dump(d,open(p,"w"),indent=2); print(n)' "$WORK/latest.json" "$NEW_SIG")
if [ "$CHANGED" -ge 1 ]; then
gh release upload "$TAG" "$WORK/latest.json" --clobber --repo "$GITHUB_REPOSITORY"
echo "aligned $CHANGED linux signature(s) with the shipped .sig"
else
echo "manifest already agrees with the shipped .sig — no write"
fi
uninstall-scripts:
needs: [build]
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
+42 -54
View File
@@ -10,53 +10,54 @@ the frozen-backend fallback mirror it for their toolchains.
**Highlights**
- The backend now answers within a second of launch and narrates its startup step by step
- Reporting a bug from an outdated build now offers the latest release first
- The backend is only announced ready once it can actually serve, and crash-loop restarts now pace themselves
### Changed
- The backend binds its port immediately and reports startup progress live — `/health` answers 503-with-step and a new `/startup/progress` endpoint lists every step while PyTorch, API routes, and database migrations load in the background, so "starting at step X" is never mistakable for "dead"; the desktop splash narrates each step (#1550)
### Added
- The bug reporter notices when you're on an outdated build and offers the latest release before filing — with a "File anyway" escape hatch — and stamps a `Build status` line into every report so up-to-date reports are tellable from stale ones (#1547)
- Docker/server mode now requires an API key for remote changes and side-effectful admin checks across workers, engines, media tools, MCP, pronunciation, diagnostics, and LLM providers. (#1525) — thanks @bultodepapas!
- The unified Support page no longer throws while opening a section in browsers or test environments without `scrollIntoView`. (#1525) — thanks @bultodepapas!
- A faster, cleaner Dub workspace for multilingual production (#1489)
- VoiceStudio now gives the app, desktop chrome, documentation, and package metadata one clear identity
- A local-first creative studio: voice cloning, design, dubbing, dictation, stories, audiobooks, and transcription without a subscription meter
- Reliability first: automatic cache repair, truthful hardware routing, safer sidecars, and actionable recovery instead of mystery failures
- Security boundaries now match the product: native file access stays native, untrusted network destinations fail closed, and public errors keep private diagnostics local
- RTX 40-series GPUs are used again instead of being sent to the CPU
- A warning before a slow generation, rather than after a five-minute wait
- The watermark can be turned off in Settings, as the docs always said
- Your other GPU can take the work now — send individual jobs to a second machine, opt-in
- More than one person can share one GPU machine, without shell access to it or taking turns
- A Model Catalogue workspace: every engine and model in one place, with the defaults set there
- Workspace tabs in the title bar, if you prefer them to the icon rail (#1412)
- macOS support now matches what the app actually delivers
- Linux AppImage: a blank white window on rolling distros (Mesa 26.1+) now starts normally
- Apple Silicon: transcription no longer needs a system ffmpeg, as the docs always said — thanks @gambletan! (#1436)
- A failed audiobook chapter says why, instead of turning red and saying nothing
### Fixed
- "Ready" now requires the deep health probe (a working database-backed route), not just the identity probe — a backend whose install broke underneath can no longer be announced up while every real request fails (#1548)
- Supervisor restarts after repeat crashes now back off (immediate, then 5s, then 15s) instead of respawning back-to-back, so a tight crash loop can't burn the whole restart budget in seconds (#1548)
## [0.5.0] — 2026-08-13
- The guard that keeps transcription on the degrading ASR loader now scans the whole backend, not just the routers — a service that transcribes on a request's behalf skipped `ensure_loaded()` just as thoroughly. (#1519) — thanks @ahov520!
- The Linux app icon is no longer blank. Every AppImage since v0.4.2 shipped `.DirIcon` as an absolute symlink into the machine that built it (`/home/runner/work/…`), so the link dangled on every user's computer and file managers, app menus and desktop integration all drew nothing. The release build now verifies the icon resolves inside the bundle before publishing. (#1518)
- The Linux desktop entry no longer ships an empty `Categories=`, which `desktop-file-validate` rejects and menu builders skip. (#1518)
**Highlights**
### Added
- The app is now **VoiceStudio** (previously OmniVoice-Studio) — one waveform-and-spark identity across the app, docs and installers. Your data folder, settings and Docker image paths stay put.
- **Model Catalogue** — engines and models in one workspace: every TTS, transcription and LLM engine with its device routing and install state, defaults picked there.
- Switch TTS, ASR and LLM engines from the status bar or any workspace — ready-only choices, memory status, environment-pin protection, `Ctrl/Cmd+E`. (#1530)
- Lend another machine's GPU with a join code and a QR scan — a Compute control in the status bar picks where jobs run, and several people can share one GPU box with revocable, certificate-pinned connections. (#1516, #1496)
- Server mode is locked down: admin actions require an API key (#1525), and the remote UI exchanges it for short-lived sessions that never sit in browser storage or WebSocket URLs (#1528) — thanks @bultodepapas!
- A faster, cleaner Dub workspace for multilingual production, with a production command bar and per-language cards. (#1489)
- The demo audio and video the app always advertised now actually ship, rendered by VoiceStudio's own engine. (#1517)
- Dictation works on Wayland now — the portal shortcut actually fires (#1490, #1526) — and the recording pill is back on every desktop.
- The Launchpad wears the project's signal-field waveform artwork over a quieter, borderless layout. (#1533)
- The catalogue reads as headroom, not breakage: available engines sort first, uninstalled ones say what they need (#1531), and the LLM row names the provider that actually answers (#1538).
- Gallery voices can be saved as local profiles — audio lands in your profile store with validated, content-addressed references. (#1542)
- The demo audio the app has always advertised now actually ships: previews for all seven voice-design presets, the three dictation replay clips, and the dubbing demo's source video plus four dubbed languages with subtitles. Every one of those was a dead link before — the tooling that renders them required macOS, so on Windows and Linux the files were never built. (#1517)
- Demo assets are rendered by VoiceStudio's own engine, so the tooling runs wherever the app does, and the demos are made by the thing they demonstrate. (#1517)
<img src="https://raw.githubusercontent.com/debpalash/VoiceStudio/main/docs/media/0.5.0/quick-switch.gif" alt="Switching TTS engines from the status bar" width="820" />
### Added
| The Model Catalogue | The Voice Gallery |
| --- | --- |
| <img src="https://raw.githubusercontent.com/debpalash/VoiceStudio/main/docs/media/0.5.0/catalogue.png" alt="Model Catalogue — engines pane" width="420" /> | <img src="https://raw.githubusercontent.com/debpalash/VoiceStudio/main/docs/media/0.5.0/gallery-save.png" alt="Voice Gallery — save a voice as a profile" width="420" /> |
- A machine can now join a control plane from the app: Settings → System → Remote workers → **Lend this machine's GPU**, paste the join code, done — no environment variables and no restart. The address travels with the code, so the machine reconnects on its own afterwards. (#1516)
- Join codes and connection strings are shown as a **QR code** alongside the text, with a live expiry countdown — scan it from the other machine instead of retyping forty characters. (#1516)
- A **Compute** control in the status bar: pick local or a remote machine, turn remote workers on or off, and mint a join code without opening Settings. It appears only once you have opted in or enrolled a machine. (#1516)
- A worker waiting for approval can be approved from its row. The panel labelled that state before but offered no way out of it. (#1516)
- The demo audio the app has always advertised now actually ships: previews for all seven voice-design presets, the three dictation replay clips, and the dubbing demo's source video plus four dubbed languages with subtitles. Every one of those was a dead link before — the tooling that renders them required macOS, so on Windows and Linux the files were never built. (#1517)
- Demo assets are rendered by VoiceStudio's own engine, so the tooling runs wherever the app does, and the demos are made by the thing they demonstrate. (#1517)
### Changed
- Gallery personas now preview through the local backend, retain their complete voice-design recipe, and open directly in Voice, Stories, or Audiobook. (#1542)
- Typing and large workspace edits no longer serialize and rewrite persisted documents on every input; writes are coalesced off the interaction path — thanks @bultodepapas! (#1541)
- Support amount choices now use every theme's shared card, accent and focus tokens. (#1530)
- Sponsoring, commercial licensing and getting in touch are one page now. They answered the same question between them and each used to live somewhere else, so they are three sections on a single scroll — the footer heart, the commercial-licence links and Contact all land on it, at the section you asked for. (#1522)
- Model Catalogue switches panes with tabs instead of a two-state toggle, and the Engine Compatibility Matrix's TTS / ASR / LLM switcher is now tabs too — arrow-key navigable, and each tab still shows the engine it would use. (#1522)
- Engines you can actually use sort to the top of the compatibility matrix, and an unavailable engine's name recedes instead of the whole row fading — the status badge and GPU chips that say *why* it is unavailable stay legible. (#1522)
- Remote workers reads as a device list: status dot, address, latency, a live task meter, resident models and last-seen per machine, with housekeeping actions revealed on hover and a three-step empty state. (#1516)
- The GPU picker and the new status-bar control paint their status dots and menu surfaces from themed tokens instead of fixed palette classes, so they stop showing Gruvbox colours on Midnight and Catppuccin. (#1516)
- Dictation shows the pill again: a capture puts a small always-on-top capsule near the bottom of the screen you are working on — listening, transcribing, the result, and any error — and takes it away when the session ends. It never takes focus, so the text still lands in the app you were typing into. On Wayland the compositor decides where it sits; everywhere else it is bottom-centred.
- Remote workers reads as a device list: status dot, address, latency, a live task meter, resident models and last-seen per machine, with housekeeping actions revealed on hover and a three-step empty state. (#1516)
- Engines and models moved out of Settings into a new Model Catalogue workspace, reachable from the icon rail (or the title-bar tabs); Settings → Engines and Settings → Models now point there, and Settings keeps the models directory and Hugging Face mirror.
- The Settings sidebar is keyboard-navigable: ⌘K / Ctrl+K jumps to the filter, ↑/↓ and Home/End move between categories, and Enter or ↓ from the filter drops into the list. Matching text in a filtered category name is highlighted, and group headers stay pinned while the list scrolls.
- The Launchpad has a quieter, more spacious look: borderless feature tiles that light up on hover or keyboard focus, plain-numeral counts, hairline section rules, and one shared page column for the hero, tiles, recent files and project lists.
@@ -76,13 +77,6 @@ the frozen-backend fallback mirror it for their toolchains.
### Added
- Gallery personas preview through the local backend, keep their full voice-design recipe, and open directly in Voice, Stories, or Audiobook — and can be saved as local profiles with validated audio references. (#1542)
- The demo audio the app has always advertised now actually ships: previews for all seven voice-design presets, the three dictation replay clips, and the dubbing demo's source video plus four dubbed languages with subtitles. Every one of those was a dead link before — the tooling that renders them required macOS, so on Windows and Linux the files were never built. (#1517)
- Demo assets are rendered by VoiceStudio's own engine, so the tooling runs wherever the app does, and the demos are made by the thing they demonstrate. (#1517)
- A machine can now join a control plane from the app: Settings → System → Remote workers → **Lend this machine's GPU**, paste the join code, done — no environment variables and no restart. The address travels with the code, so the machine reconnects on its own afterwards. (#1516)
- Join codes and connection strings are shown as a **QR code** alongside the text, with a live expiry countdown — scan it from the other machine instead of retyping forty characters. (#1516)
- A **Compute** control in the status bar: pick local or a remote machine, turn remote workers on or off, and mint a join code without opening Settings. It appears only once you have opted in or enrolled a machine. (#1516)
- A worker waiting for approval can be approved from its row. The panel labelled that state before but offered no way out of it. (#1516)
- **Model Catalogue** — a workspace of its own for engines and models: browse every TTS, transcription and LLM engine with its device routing and install state, pick the default for each, and install or remove model weights, all from one screen instead of two Settings categories.
- Remote GPU machines can now accept connections instead of dialling out, so several people can use the same box at once — each gets their own revocable connection string, with certificate-pinned TLS, a live list of who is connected, and a disconnect button. (#1496)
- Remote GPU model downloads now use the normal Models install flow and show per-worker progress. (#1478)
@@ -96,22 +90,14 @@ the frozen-backend fallback mirror it for their toolchains.
- Settings → Privacy now has an **Invisible watermark** toggle. On by default, available to everyone, and it only affects audio generated after the change. (#1308)
- A new opt-in crash-isolated TTS engine, so a native crash takes down the sidecar instead of the whole backend — thanks @paoloantinori! (#1292, #1298, #1304)
- **PocketTTS** (Kyutai), an opt-in CPU-only engine for fast, low-latency renders in six languages (en/fr/de/pt/it/es) with zero-shot cloning from a reference clip. Enable in Settings → Engines — thanks @paoloantinori! (#1306, #1328)
- A warning before a slow generation, rather than after a five-minute wait. (#1280)
### Docs
### CI
- Engine acceptance: new `docs/engine-acceptance.md` documents the job map, the bar a new engine must clear, and the out-of-tree path (#1306)
- macOS install notes and the README support table now state the real floor (#1268)
- Contact: the project X account is listed alongside Discord (#1313)
- `OMNIVOICE_ALLOWED_ORIGINS` is finally documented: a browser loading the UI from another machine's origin needs the backend's CORS allow-list, which neither server mode nor trusted networks touches — thanks @vanderlpp! (#1348)
- The stdio wire protocol every engine sidecar speaks is now tested once across all nine of them, instead of against a single engine — a bug in any one sidecar's copy gets caught — thanks @paoloantinori! (#1408)
### Fixed
- AMD/ROCm hosts no longer crash ASR with "CUDA driver version is insufficient": ROCm torch reports itself as CUDA, but whisperx/faster-whisper run on CTranslate2, which is NVIDIA-only — they now take the CPU path there, and auto-detect prefers pytorch-whisper, which genuinely uses the HIP GPU. (#1529)
- Crash reports now carry the crashed run's own stderr: the shared error log is append-only with per-run offsets, so a restart can no longer overwrite the dying process's final output with the replacement's healthy startup. (#1510)
- Wayland: a stale portal identity no longer kills the dictation shortcut for the whole session. The desktop entry the app writes for the GlobalShortcuts portal could point at a binary that has since moved (a `cargo clean`, a relocated AppImage) — GNOME then refuses the bind with "App info not found" and the hotkey silently dies. The entry is validated and rewritten at startup now. (#1526)
- The guard that keeps transcription on the degrading ASR loader now scans the whole backend, not just the routers — a service that transcribes on a request's behalf skipped `ensure_loaded()` just as thoroughly. (#1519) — thanks @ahov520!
- The Linux app icon is no longer blank. Every AppImage since v0.4.2 shipped `.DirIcon` as an absolute symlink into the machine that built it (`/home/runner/work/…`), so the link dangled on every user's computer and file managers, app menus and desktop integration all drew nothing. The release build now verifies the icon resolves inside the bundle before publishing. (#1518)
- The Linux app icon is no longer blank: the AppImage shipped `.DirIcon` as a symlink into the machine that built it, so file managers and app menus drew nothing. (#1518)
- The Linux desktop entry no longer ships an empty `Categories=`, which `desktop-file-validate` rejects and menu builders skip. (#1518)
- Wayland: the dictation shortcut now actually starts dictation. The desktop portal registered the key correctly — GNOME and KDE even showed it back — but every press was discarded while decoding the compositor's signal, so the hotkey did nothing on any Wayland session. (#1490)
- The first-run "Choose a comfortable UI size" screen no longer stutters while you sit there. Applying a scale resizes the window's own viewport, which the screen was reading back to re-pick a size — so it flipped between two sizes forever without anyone touching it. (#1514)
@@ -225,14 +211,16 @@ the frozen-backend fallback mirror it for their toolchains.
- Translation through LM Studio works. The built-in model name was the placeholder `local-model`, which LM Studio rejects because it serves whatever you have loaded — VoiceStudio now asks it, and a 404 from a local server names the models that ARE loaded instead of telling you to check a URL that was fine — thanks @biga73! (#1332)
- Generation that silently dropped the end of the input now says so. When an engine returns no audio for part of the text the result sounds clean and is simply short, so the only way to notice was to read along; the backend log now names the sentences that produced nothing. (#1330)
- Dubbing: a re-rendered line that quietly came back in a default voice instead of the cloned one now says why in the backend log — the clone clips are extracted per job and a saved dub outlives them, so regenerating after cleanup loses the reference with no error. (#1331)
- RTX 40-series GPUs are used again instead of being sent to the CPU. (#1289)
- Apple Silicon: transcription no longer needs a system ffmpeg, as the docs always said — thanks @gambletan! (#1436)
- A failed audiobook chapter says why, instead of turning red and saying nothing. (#1325)
### Docs
- Engine acceptance: new `docs/engine-acceptance.md` documents the job map, the bar a new engine must clear, and the out-of-tree path (#1306)
- macOS install notes and the README support table now state the real floor (#1268)
- Contact: the project X account is listed alongside Discord (#1313)
- `OMNIVOICE_ALLOWED_ORIGINS` is finally documented: a browser loading the UI from another machine's origin needs the backend's CORS allow-list, which neither server mode nor trusted networks touches — thanks @vanderlpp! (#1348)
### CI
- Windows CI falls back to a static ffmpeg build when the Chocolatey feed is down, instead of failing the run. (#1542)
- The stdio wire protocol every engine sidecar speaks is now tested once across all nine of them, instead of against a single engine — a bug in any one sidecar's copy gets caught — thanks @paoloantinori! (#1408)
- Windows smoke tests stopped silently passing a broken ffmpeg install, and every smoke leg is now budgeted for a cold dependency install. (#1290)
- Test suites no longer leak config paths or model-manager shutdown state into one another, which had been failing unrelated pull requests. (#1269)
- The nightly preview build stopped refusing to publish its own healthy updater manifest when the macOS legs finished a few minutes ahead of the slowest one — Preview-channel users were silently left without new builds.
+198 -93
View File
@@ -53,38 +53,6 @@
> [!WARNING]
> **Active beta.** Things may break between releases — for the newest fixes, run from source. Bug reports and PRs are very welcome: [open an issue](https://github.com/debpalash/VoiceStudio/issues) or [join Discord](https://discord.gg/bzQavDfVV9).
<a id="whats-new"></a>
## 🆕 What's new in 0.5.0
The rename release — full notes: [v0.5.0 release](https://github.com/debpalash/VoiceStudio/releases/tag/v0.5.0) · [CHANGELOG](CHANGELOG.md).
- 🏷️ **A new name** — VoiceStudio (previously OmniVoice-Studio): one waveform-and-spark identity across app, docs, and installers. Your data folder, settings, and Docker image paths stay put.
- 📚 **Model Catalogue** — engines and models in one workspace: every TTS, ASR, and LLM engine with its device routing and install state; pick defaults, install or remove weights.
- ⚡ **Engine quick-switch** — change TTS/ASR/LLM engines from the status bar or anywhere with <kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>E</kbd> — ready-only choices, memory status, environment-pin protection.
- 🖧 **Remote GPU workers** — lend another machine's GPU with a join code and a QR scan; a **Compute** control picks where jobs run, and several people can share one GPU box over revocable, certificate-pinned connections.
- 🔐 **Hardened server mode** — admin actions require an API key, exchanged for short-lived scoped sessions that never sit in browser storage or WebSocket URLs.
- 💾 **Gallery voices → local profiles** — save any gallery voice as a profile of your own and use it in every picker.
- 🎤 **Dictation on Wayland** — the portal shortcut actually fires now, and the recording pill is back on every desktop.
<div align="center">
<img src="docs/media/0.5.0/quick-switch.gif" alt="Switching engines from the status bar" width="640"/>
<br/><sub>Engine quick-switch from the status bar — <kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>E</kbd> from any workspace</sub>
</div>
<br/>
<table>
<tr>
<td width="50%"><img src="docs/media/0.5.0/catalogue.png" alt="Model Catalogue — engines pane" width="100%"/></td>
<td width="50%"><img src="docs/media/0.5.0/gallery-save.png" alt="Saving a gallery voice as a profile" width="100%"/></td>
</tr>
<tr>
<td align="center"><sub><b>Model Catalogue</b> — every engine, its routing and install state</sub></td>
<td align="center"><sub><b>Gallery → profile</b> — keep a gallery voice as your own</sub></td>
</tr>
</table>
<a id="features"></a>
## ✨ Features
@@ -115,21 +83,23 @@ Three flagships, five more headliners, and a dozen under the fold.
</table>
<details>
<summary><b>…and 12 more</b> — catalogue, remote GPUs, isolation, diarization, batch, watermarking, and friends</summary>
<summary><b>…and 12 more</b> — isolation, diarization, batch, watermarking, diagnostics, and friends</summary>
<br/>
- 📚 **Model Catalogue** — one workspace for every TTS/ASR/LLM engine and model: defaults, device routing, install or remove weights — and quick-switch engines from anywhere with <kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>E</kbd>.
- 🖧 **Remote GPU workers** — send jobs to GPUs on your other machines: join code + QR enrolment, Remote Model Downloads with per-worker live progress, chapter-by-chapter audiobook rendering with local fallback. Off by default; see [docs/remote-workers.md](docs/remote-workers.md).
- 🔊 **Vocal Isolation** — Demucs-powered: splits speech from music and keeps the background bed.
- 👥 **Speaker Diarization** — Pyannote + WhisperX auto-identify who said what.
- 📦 **Batch Queue** — drop 50 videos, walk away; per-job progress bars.
- 🛡️ **AI Watermark** — AudioSeal (Meta): invisible, survives compression.
- 🔬 **Diagnostics** — self-check suite, error journal, scrubbed diagnostic bundles.
- ⚡ **GPU Auto-Detect & Routing** — CUDA · MPS · ROCm (Linux, opt-in) · CPU; ≤8 GB VRAM auto-offloads; per-engine GPU preflight, no silent CPU fallback.
- ⚡ **GPU Auto-Detect** — CUDA · MPS · ROCm (Linux, opt-in) · CPU; ≤8 GB VRAM auto-offloads.
- 📥 **Remote Model Downloads** — install pinned model weights on the selected worker with live progress.
- 🧭 **Engine routing** — preflight GPU check per engine; no silent CPU fallback.
- 📚 **Model Catalogue** — one workspace listing every TTS/ASR/LLM engine and model: set the defaults, install or remove weights.
- 🧩 **Extensible** — subclass `TTSBackend`, add any engine in ~50 lines.
- 🎒 **Portable personas** — export voices as `.ovsvoice` bundles: identity + watermark.
- ♾️ **Unlimited TTS** — sentence-chunked generation, no length cap, streaming via WebSocket.
- 🌐 **Remote backend** — point the UI at a remote server; Tailscale-friendly, bearer auth.
- 🧠 **Dictation + LLM** — local-LLM cleanup of transcripts, optional echo cancellation.
</details>
@@ -181,15 +151,22 @@ Cloud voice tools are convenient, but they put your workflow behind an account,
| **Video Dubbing** | ✅ Cloud-only | ✅ Fully local |
| **Data Privacy** | Audio is processed remotely | Core workflow runs locally; online services are explicit opt-ins |
| **API Keys** | Account required | Not needed for the local workflow |
| **GPU Support** | N/A (cloud) | CUDA · Apple Silicon · ROCm (Linux) · CPU — plus your other machines' GPUs as [remote workers](docs/remote-workers.md) |
| **GPU Support** | N/A (cloud) | CUDA · Apple Silicon · ROCm (Linux) · CPU |
| **Desktop App** | ❌ | ✅ macOS · Windows · Linux |
| **TTS Engines** | 1 | **16** — [full matrix](#tts-engines) |
| **TTS Engines** | 1 | **14** — [full matrix](#tts-engines) |
| **ASR Engines** | 1 | **11** — [full lineup](#asr-engines) |
| **MCP Server** | ❌ | ✅ Use from Claude, Cursor, any MCP client |
| **Self-check** | ❌ | ✅ Diagnostics suite, error journal, scrubbed debug bundles |
| **Customizable** | ❌ Closed | ✅ Fork it, extend it, ship it |
Professional-grade voice AI, minus the subscription and the cloud. Convinced? [Come build with us.](https://discord.gg/bzQavDfVV9)
Professional-grade voice AI, minus the subscription and the cloud.
<div align="center">
<br/>
<b>Convinced? Come build with us.</b><br/>
<a href="https://discord.gg/bzQavDfVV9"><img src="https://img.shields.io/badge/Join_Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Join Discord" /></a>
<br/><br/>
</div>
---
@@ -211,10 +188,10 @@ Professional-grade voice AI, minus the subscription and the cloud. Convinced? [C
### 🗣️ TTS Engines
**16 engines, one picker.** VoiceStudio (default, 600+ languages) is always available; seven more are opt-in and auto-detected (CosyVoice 3, GPT-SoVITS, VoxCPM2, MOSS-TTS-Nano, KittenTTS, MLX-Audio, Sherpa-ONNX), plus eight lazy-installed opt-ins (IndexTTS 2.5, OmniVoice GGUF, OmniVoice subprocess, PocketTTS, Supertonic 3, MOSS-TTS-v1.5, dots.tts, Confucius4-TTS). Switch in **Model Catalogue → Engines** — or from anywhere with <kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>E</kbd>; the choice applies everywhere synthesis happens.
**14 engines, one picker.** VoiceStudio (default, 600+ languages) is always available; seven more are opt-in and auto-detected (CosyVoice 3, GPT-SoVITS, VoxCPM2, MOSS-TTS-Nano, KittenTTS, MLX-Audio, Sherpa-ONNX), plus six lazy-installed heavyweights (IndexTTS 2.5, OmniVoice GGUF, Supertonic 3, MOSS-TTS-v1.5, dots.tts, Confucius4-TTS). Switch in **Settings → TTS Engine**; the choice applies everywhere synthesis happens.
<details>
<summary><b>📊 The full matrix</b> — 16 engines × platform × clone/instruct × license</summary>
<summary><b>📊 The full matrix</b> — 14 engines × platform × clone/instruct × license</summary>
<br/>
@@ -230,8 +207,6 @@ Professional-grade voice AI, minus the subscription and the cloud. Convinced? [C
| **Sherpa-ONNX** | 20+ | — | — | ✅ CUDA/CPU | ✅ CPU | ✅ CUDA/CPU | Apache-2.0 |
| **IndexTTS 2.5** ⚡ | ZH · EN · JA · ES · AR | ✅ | — | ✅ CUDA | — | ✅ CUDA | Bilibili model license¹ |
| **OmniVoice GGUF** ⚡ | 600+ | ✅ | ✅ | ✅ CPU | ✅ CPU | ✅ CPU | Built-in |
| **OmniVoice (subprocess)** ⚡² | 600+ | ✅ | ✅ | ✅ CUDA/CPU | ✅ MPS | ✅ CUDA/CPU | Built-in |
| **PocketTTS** ⚡ (Kyutai) | EN · FR · DE · PT · IT · ES | ✅ | — | ✅ CPU | ✅ CPU | ✅ CPU | CC-BY-4.0 (gated)³ |
| **Supertonic 3** ⚡ | 31 | — | — | ✅ CPU | ✅ CPU | ✅ CPU | OpenRAIL-M |
| **MOSS-TTS-v1.5** ⚡ (8B) | 31 | ✅ | — | ✅ CUDA/CPU | ✅ CPU | ✅ CUDA/CPU | Apache-2.0 |
| **dots.tts** ⚡ (2B) | 24 | ✅ | — | ✅ CUDA/CPU | ✅ CPU | ❌ | Apache-2.0 |
@@ -242,21 +217,12 @@ monthly active users or RMB 1 billion in annual revenue. Review its
[model license](https://huggingface.co/IndexTeam/IndexTTS-2.5/blob/main/LICENSE)
before enabling the optional sidecar.
² **OmniVoice (subprocess)** is the same resident model as the default engine, run
in a crash-isolated child process: a wedged generation can be hard-killed and its
VRAM reclaimed. Opt-in for unattended synthesis and VRAM-tight MPS hosts —
[docs/engines/omnivoice-subprocess.md](docs/engines/omnivoice-subprocess.md).
³ **PocketTTS** (Kyutai) is a fast, low-latency CPU engine with zero-shot cloning;
its gated model access and CC-BY-4.0 conditions are shown for review in-app before
first use.
GPT-SoVITS connects to `http://127.0.0.1:9880` by default. To use a server on
another machine, set `OMNIVOICE_GPTSOVITS_URL` to its credential-free
`http://` or `https://` origin and add that machine's CIDR to
`OMNIVOICE_TRUSTED_NETWORKS`; redirects and untrusted destinations are rejected.
> **CUDA** = GPU-accelerated · **MPS** = Apple Silicon Metal · **CPU** = runs everywhere, slower for large models · KittenTTS, MOSS-TTS-Nano, and PocketTTS run realtime on CPU · MLX-Audio is Apple Silicon only · ⚡ = lazy-registered (installed on first use)
> **CUDA** = GPU-accelerated · **MPS** = Apple Silicon Metal · **CPU** = runs everywhere, slower for large models · KittenTTS and MOSS-TTS-Nano run realtime on CPU · MLX-Audio is Apple Silicon only · ⚡ = lazy-registered (installed on first use)
>
> **Clone** matters beyond single-clip generation: Video Dubbing (and any Batch job with a pinned voice) needs reference-audio cloning to preserve speaker identity, so picking a Clone-less engine (KittenTTS, Sherpa-ONNX, Supertonic 3) as the active engine fails those jobs up front with an actionable message instead of silently falling back to VoiceStudio.
>
@@ -283,15 +249,17 @@ another machine, set `OMNIVOICE_GPTSOVITS_URL` to its credential-free
| **MLX Whisper** | `mlx-whisper` | ~100 | Native Apple Silicon speed (Apple MLX / Metal) |
| **PyTorch Whisper** | `pytorch-whisper` | ~100 | CUDA / CPU fallback via 🤗 Transformers (no cuDNN 8 needed) |
| **Parakeet TDT** | `nemo-parakeet` | English + 25 EU | SOTA accuracy at ~10× realtime even on CPU, auto language detection (NVIDIA NeMo, CUDA/CPU) |
| **Parakeet TDT v3 (MLX)** | `parakeet-mlx` | 25 EU | The Parakeet tier for Apple Silicon — word timestamps, ~2 GB unified memory, dictation-grade speed via MLX. Dictation prefers it automatically for its 25 European languages; other languages keep multilingual Whisper. |
| **Parakeet TDT v3 (MLX)** | `parakeet-mlx` | 25 EU | The Parakeet tier for Apple Silicon — TDT word timestamps, ~2 GB unified memory, dictation-grade speed on the GPU via MLX. Install the model from **Model Catalogue → Models** and dictation prefers it automatically when your system language is one of its 25 (European) languages; other languages (CJK, Arabic, …) keep the multilingual Whisper engine so dictation coverage never regresses. |
| **Moonshine** | `moonshine` | English | Edge / low-latency, ONNX |
| **FunASR** | `funasr` | 50+ | All-in-one multilingual — built-in VAD + inline speaker diarization (SenseVoice) |
| **sherpa-onnx** (live dictation) | `sherpa-onnx-asr` | 25 EU + 90+ | Live, faster-than-real-time dictation — small streaming/offline ONNX models, CPU, identical on macOS / Windows / Linux. Picked per-model in **Settings → Voice**. |
| **OpenAI-compatible** ⚠️ remote | `openai-compat-asr` | Server-dependent | A path to **Qwen3-ASR** today (self-hosted server), any OpenAI-compatible transcription endpoint, or OpenAI's own API — configure + test in **Model Catalogue → Engines** (ASR tab). Audio leaves your machine to whatever server you point it at; see [docs/engines/openai-compatible-asr.md](docs/engines/openai-compatible-asr.md). |
| **sherpa-onnx** (live dictation) | `sherpa-onnx-asr` | 25 EU + 90+ | Live, faster-than-real-time dictation — small streaming/offline ONNX models (Parakeet TDT v3/v2, streaming Zipformer & Paraformer, Whisper Tiny), CPU, identical on macOS / Windows / Linux. Picked per-model in **Settings → Voice**. |
| **OpenAI-compatible** ⚠️ remote | `openai-compat-asr` | Server-dependent | A path to **Qwen3-ASR** today (self-hosted server, no transformers wait), any OpenAI-compatible transcription endpoint, or OpenAI's own API — no install, configure + test the connection in **Model Catalogue → Engines** (ASR tab). Audio leaves your machine to whatever server you point it at; see [docs/engines/openai-compatible-asr.md](docs/engines/openai-compatible-asr.md). |
> Whisper-family engines cover ~100 languages; **FunASR / SenseVoice** adds an all-in-one multilingual path with built-in voice-activity detection and inline speaker diarization. **sherpa-onnx** powers the live dictation model picker — you talk and text appears as you speak. Every engine runs on-device — no API keys, no cloud.
> If Dubbing needs an ASR model that is not installed yet, it offers the recommended download in place, shows its progress, and retries transcription on the same job when the model is ready.
>
> **GPU without efficient float16?** On older NVIDIA GPUs (Maxwell/Pascal, GTX 16xx) or after a CTranslate2/cuDNN mismatch, the CTranslate2 ASR engines (WhisperX, Faster-Whisper) can't run `float16` and VoiceStudio automatically retries on `int8` — no config needed. If transcription still fails, pin the compute type with `ASR_COMPUTE_TYPE=int8` (or `float32` for CPU) and restart the backend.
> **GPU without efficient float16?** On older NVIDIA GPUs (Maxwell/Pascal, GTX 16xx) or after a CTranslate2/cuDNN mismatch, the CTranslate2 ASR engines (WhisperX, Faster-Whisper) can't run `float16` and VoiceStudio automatically retries on `int8` — no config needed. If transcription still fails, pin the compute type with the `ASR_COMPUTE_TYPE` env var (escape hatch): `ASR_COMPUTE_TYPE=int8` (or `float32` for CPU). Set it to `int8` and restart the backend.
</details>
@@ -299,7 +267,7 @@ another machine, set `OMNIVOICE_GPTSOVITS_URL` to its credential-free
## 🏗️ Architecture
A **Tauri v2** desktop shell (Rust) wraps a **React** UI and a bundled **Python/FastAPI** backend that runs as a local sidecar on `localhost:3900`. Every layer runs on your machine by default; the only network paths are the ones you opt into (remote GPU workers, a remote backend, or an OpenAI-compatible ASR endpoint).
A **Tauri v2** desktop shell (Rust) wraps a **React** UI and a bundled **Python/FastAPI** backend that runs as a local sidecar on `localhost:3900`. Nothing external — every layer is on your machine.
```
┌────────────────────────────────────────────────────────────────────┐
@@ -309,24 +277,28 @@ A **Tauri v2** desktop shell (Rust) wraps a **React** UI and a bundled **Python/
│ first-run bootstrap (installs uv + Python venv) · blank guard │
├────────────────────────────────────────────────────────────────────┤
│ Frontend — React + Vite │
│ Studio · Dub · Stories · Audiobook · Gallery · Catalogue · │
Dictation · Batch · Diagnostics — Zustand store · WS bus
│ Studio · Dub · Stories · Audiobook · Gallery · Dictation · │
│ Batch · Diagnostics · MCP client — Zustand store · WS bus │
│ ▲ IPC / HTTP + WS │
├──────────────────────────┼─────────────────────────────────────────┤
│ Backend — FastAPI sidecar @ localhost:3900 │
│ 100+ REST endpoints · SSE + WebSocket streaming · │
│ SQLite + Alembic (omnivoice_data/) · OpenAI-compatible API │
├───────────┬───────────┬───────────┬───────────┬────────────────────┤
│ TTS ×16 │ ASR ×11 │ Demucs │ Pyannote │ AudioSeal │
│ TTS ×14 │ ASR ×11 │ Demucs │ Pyannote │ AudioSeal │
│ clone / │ WhisperX │ vocal │ speaker │ watermark │
│ design │ +10 more │ isolation│ diariz. │ embed / detect │
├───────────┴───────────┴───────────┴───────────┴────────────────────┤
│ Engine routing — per-engine GPU preflight, no silent CPU fallback │
│ Hardware: CUDA · MPS · ROCm (Linux) · CPU (auto-detected) │
│ + optional remote GPU workers on your other machines │
└────────────────────────────────────────────────────────────────────┘
```
- **Shell (Rust)** — native OS integration: the system-wide dictation hotkey, tray, signed auto-updater (stable + preview channels), single-instance lock, and the first-run bootstrap that installs `uv` and a Python 3.11 venv.
- **Frontend (React)** — every workspace tab over a Zustand store, with a WebSocket event bus that live-refreshes the UI when backend data changes.
- **Backend (FastAPI)** — the bundled Python sidecar: 100+ endpoints, SSE/WSS streaming, a SQLite DB migrated by Alembic, and the OpenAI-compatible API surface.
- **Engines** — 14 TTS + 11 ASR, plus Demucs (isolation), Pyannote (diarization), and AudioSeal (watermark), all behind routing that GPU-preflights each engine and refuses to silently fall back to CPU.
<a id="openai-api"></a>
## 🔌 OpenAI-compatible API
@@ -346,17 +318,28 @@ Your existing scripts, agents, and OpenAI/ElevenLabs SDK calls now run **locally
| Endpoint | What it does |
|---|---|
| `POST /v1/audio/speech` | TTS — text in; `mp3` / `opus` / `aac` / `flac` / `wav` / `pcm` out. `model`: `tts-1`/`tts-1-hd` (active engine) or a specific one (`voxcpm2`, `cosyvoice`, …). `voice`: a cloned profile ID, `default`, or an OpenAI name (`alloy`, …). `speed` supported. |
| `POST /v1/audio/speech` | TTS — text in; `mp3` / `opus` / `aac` / `flac` / `wav` / `pcm` out. `model`: `tts-1`/`tts-1-hd` (active engine) or a specific one (`voxcpm2`, `cosyvoice`, `kittentts`, …). `voice`: a cloned profile ID, `default`, or an OpenAI name (`alloy`, …). `speed` supported. |
| `POST /v1/audio/transcriptions` | STT — audio file in; `json` / `text` / `verbose_json` / `srt` / `vtt` out (`verbose_json` adds word-level timings). `whisper-1` maps to your active ASR engine. |
| `GET /v1/audio/voices` | VoiceStudio extension — lists every voice profile and engine, so clients can discover your clones. |
**Speak with your own cloned voice:**
**Speak with your own cloned voice** — list the IDs, then pass one as `voice`:
```sh
# 1 — find a cloned voice's profile ID
curl -s http://localhost:3900/v1/audio/voices | jq '.voices[] | select(.type=="profile") | {voice_id, name}'
# 2 — synthesize with it
curl http://localhost:3900/v1/audio/speech \
-H "Content-Type: application/json" \
-d '{"model":"tts-1","voice":"<profile-id>","input":"Made on my own hardware.","response_format":"wav"}' \
--output speech.wav
```
```python
from openai import OpenAI
client = OpenAI(base_url="http://localhost:3900/v1", api_key="none") # any string — nothing checks it
# Find your cloned voices: GET /v1/audio/voices lists profile IDs
# TTS with your cloned voice (or "alloy" / "default"; model= can pin a specific engine)
with client.audio.speech.with_streaming_response.create(
model="tts-1", voice="<profile-id>", input="Made on my own hardware.") as r:
r.stream_to_file("speech.wav")
@@ -367,13 +350,13 @@ print(client.audio.transcriptions.create(model="whisper-1", file=open("clip.wav"
Want the whole surface (100+ endpoints)? The full REST API reference is embedded in the app — **Settings → OpenAPI Reference** (Scalar-powered), or the `{}` button in the footer.
Calling the backend from **another machine** (LAN, Tailscale, behind a proxy)? It's loopback-only and unauthenticated by default; to reach it remotely you set a share PIN or an API key, and admin actions require the key — exchanged for short-lived scoped sessions. [docs/api-auth.md](docs/api-auth.md) covers the exact headers, query params, `401`/`403`/`429` meanings, and the `OMNIVOICE_TRUSTED_NETWORKS` exemption.
Calling the backend from **another machine** (LAN, Tailscale, behind a proxy)? It's loopback-only and unauthenticated by default; to reach it remotely you set a share PIN or an API key. [docs/api-auth.md](docs/api-auth.md) covers the exact headers, query params, `401`/`403`/`429` meanings, and the `OMNIVOICE_TRUSTED_NETWORKS` exemption.
### 📓 Run on Google Colab
[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/debpalash/VoiceStudio/blob/main/notebooks/OmniVoice_Studio_Colab.ipynb)
[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/debpalash/VoiceStudio/blob/main/notebooks/VoiceStudio_Studio_Colab.ipynb)
No local GPU? The [official notebook](notebooks/OmniVoice_Studio_Colab.ipynb) boots the full app — web UI included — on a free Colab T4, then walks the whole feature surface as a guided tour with inline playback. No tunnels, no API keys.
No local GPU? The [official notebook](notebooks/VoiceStudio_Studio_Colab.ipynb) boots the full app — web UI included — on a free Colab T4, then walks the whole feature surface (TTS, cloning, design, transcription, dubbing, audiobook, watermarking, the OpenAI-compatible API) as a guided tour with inline playback. No tunnels, no API keys.
### 🤝 Agent Skills
@@ -383,15 +366,50 @@ Teach your coding agent to speak and listen through your local VoiceStudio — o
npx skills add debpalash/omnivoice-studio
```
Ships two skills: **`omnivoice`** — generate speech (including your cloned voices) and transcribe audio from any agent, free and fully offline — and **`oss-maintainer`** — the maintainer methodology this project is run with.
Ships two [skills](https://skills.sh):
- **`omnivoice`** — generate speech (including your cloned voices) and transcribe audio from any agent, free and fully offline via your local install.
- **`oss-maintainer`** — the maintainer methodology this project is run with, for anyone running their own OSS project with an agent.
---
<a id="roadmap"></a>
## 🗺️ Roadmap
What's up next (lip-sync v2, hosted demo, plugin marketplace, real-time voice changer) and the full history of everything shipped so far live in **[docs/ROADMAP.md](docs/ROADMAP.md)**.
### 🔜 Up Next
- 🎬 **Lip-sync v2** — visual speech timing with wav2lip
- 🌐 **Hosted Demo** — try VoiceStudio without installing anything
- 🔌 **Plugin Marketplace** — community-contributed TTS engines and effects
- 🎵 **Real-time Voice Changer** — live microphone transformation during calls
<details>
<summary><b>✅ Everything shipped so far</b> — the receipts, by category</summary>
<br/>
| Category | Features |
|----------|----------|
| **Longform** | Audiobook editor (text/EPUB/PDF → chaptered .m4b) with multi-voice cast, expressive controls, live per-chapter progress + Stop, and a one-click sample; Stories multi-voice editor, two-pass loudnorm mastering, crash-resume for interrupted renders, pronunciation control + SSML-lite prosody |
| **Dubbing** | Full pipeline (transcribe→translate→synthesize→mux), scene-aware splitting, lip-sync scoring, streaming TTS, per-speaker voice assignment, Smart Fit timing + second-pass QC, paste-in translations from any external tool, dedicated Dub home |
| **Voice** | Zero-shot cloning, voice design, A/B comparison, voice preview widget, gallery with favorites/tags (its voices selectable in every picker — Studio, Audiobook, Stories, Dubbing), portable persona bundles (`.ovsvoice`), voice console workspace |
| **Audio** | Demucs vocal isolation, per-segment gain, selective track export, stem/SRT/VTT/MP3 export, unlimited-length TTS via sentence-chunked generation |
| **Multi-Lang** | Translate All preserves the primary language plus every extra language chip; Generate renders and exports one retained track per language with sequential GPU execution |
| **Diarization** | Pyannote ML diarization, auto speaker clone extraction, per-speaker voice assignment |
| **ASR** | 11 engines (WhisperX, Faster-Whisper, isolated Faster-Whisper, MLX Whisper, PyTorch Whisper, Parakeet TDT, Parakeet TDT v3 MLX, Moonshine, FunASR/SenseVoice, sherpa-onnx live dictation, OpenAI-compatible remote), crash-isolated subprocess backend |
| **TTS** | 14 engines (VoiceStudio, CosyVoice 3, GPT-SoVITS, VoxCPM2, MOSS-TTS-Nano, KittenTTS, MLX-Audio, Sherpa-ONNX, + lazy: IndexTTS 2.5, OmniVoice GGUF, Supertonic 3, MOSS-TTS-v1.5, dots.tts, Confucius4-TTS), engine routing with GPU preflight |
| **Infra** | Docker deployment, CUDA/MPS/ROCm auto-detect, cuDNN 8 compat, VRAM-aware model offloading, engine routing (no silent CPU fallback), diagnostics suite & error journal, restricted-network mirror support |
| **AI Provenance** | AudioSeal invisible watermarking (SynthID-like), video logo overlay, watermark detection API |
| **UX** | Undo/redo, keyboard shortcuts, drag-and-drop, session persistence, screen-sized first-run UI scaling, and native WebKitGTK scaling |
| **Real-time Events** | WebSocket event bus — instant sidebar refresh on data mutations, exponential backoff reconnect |
| **State Management** | Zustand store migration — `uiSlice`, `pillSlice`, `dubSlice`, `generateSlice`, `prefsSlice`, `glossarySlice` |
| **Desktop** | Cross-platform Tauri installers (macOS DMG — Apple Silicon; Intel unsupported for the local backend, #889 — Windows MSI, Linux deb/AppImage), auto-update infrastructure, single-instance enforcement, close-to-tray, macOS Gatekeeper fix |
| **Dictation** | Global system-wide hotkey (`⌘+⇧+Space`), frameless floating widget, streaming ASR via WebSocket, auto-paste, customizable hotkey, local-LLM transcript refinement |
| **Batch Pipeline** | Full batch TTS: extract → transcribe → translate → generate → mix → export, with live progress tracking |
| **MCP Server** | VoiceStudio as a local TTS/STT provider for Claude, Cursor, and any MCP client |
| **Remote Backend** | Point the desktop UI at a remote backend URL with bearer auth (Tailscale-documented) |
| **Reliability** | Stall watchdog on bootstrap splash, per-engine GPU compatibility matrix, actionable errors for non-executable engine binaries, setuptools auto-repair |
</details>
---
@@ -411,6 +429,10 @@ One developer, real AI-agent bills. If VoiceStudio is useful to you, chipping in
&nbsp;&nbsp;
<a href="https://paypal.me/palashCoder"><img src="https://img.shields.io/badge/PayPal-Donate-00457C?style=for-the-badge&logo=paypal&logoColor=white" alt="PayPal" /></a>
<br/><br/>
<sub>Also from the maker: <a href="https://github.com/debpalash/Opal"><b>Opal</b> 💠</a> · <a href="https://github.com/debpalash/memxt"><b>memxt</b> 🧠</a> — a ⭐ helps too.</sub>
</div>
<a id="sponsors"></a>
@@ -429,6 +451,8 @@ VoiceStudio is **free** and **AGPL-3.0** — no paid tier, no SaaS revenue. Spon
</div>
<sub>💡 GitHub also shows a **Sponsor** button at the top of this repo, wired to the same links via <a href=".github/FUNDING.yml"><code>.github/FUNDING.yml</code></a>.</sub>
---
## 💬 Community
@@ -437,21 +461,66 @@ VoiceStudio is **free** and **AGPL-3.0** — no paid tier, no SaaS revenue. Spon
<a href="https://discord.gg/bzQavDfVV9"><img src="https://img.shields.io/badge/💬_Discord-Join_Community-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Join Discord" /></a>
<a href="https://x.com/idebpalash"><img src="https://img.shields.io/badge/𝕏_Follow-for_updates-000000?style=for-the-badge&logo=x&logoColor=white" alt="Follow on X" /></a>
<br/>
<sub>Release news, setup help, GPU troubleshooting, feature votes, and showing off your dubs. We respond to setup questions within hours, not days.</sub>
<sub>We respond to setup questions within hours, not days.</sub>
</div>
<details>
<summary><b>What happens in there</b></summary>
<br/>
| Channel | What happens there |
|---------|--------------------|
| `#announcements` | Release news and the big moments — new versions land here first |
| `#releases` + `#changelog` | Every build and exactly what's inside it |
| `#issues` | Bug reports as forum posts — triaged straight into GitHub issues |
| `#ideas` | Feature requests, discussed and voted on |
| `#discuss-ideas` | Design talk before things get built |
| `#general` | Setup help, GPU troubleshooting, and showing off your dubs |
</details>
---
<a id="contributing"></a>
## 🤝 Contributing
Yes please — bug fixes, new TTS engine adapters, UI improvements, docs, translations. All of it. Start with the **[Contributing Guide](.github/CONTRIBUTING.md)** (setup, code style, PR workflow), browse [good first issues](https://github.com/debpalash/VoiceStudio/labels/good%20first%20issue), or ask in [Discord](https://discord.gg/bzQavDfVV9).
Yes please — bug fixes, new TTS engine adapters, UI improvements, docs, translations. All of it.
- 📖 Read the **[Contributing Guide](.github/CONTRIBUTING.md)** for setup, code style, and PR workflow
- 🐛 Browse [good first issues](https://github.com/debpalash/VoiceStudio/labels/good%20first%20issue)
- 💬 Join our [Discord](https://discord.gg/bzQavDfVV9) to discuss ideas or ask for help
- 𝕏 Follow [@idebpalash](https://x.com/idebpalash) for updates and what's being built next
---
## ❓ FAQ
<details>
<summary><b>Is this really as good as ElevenLabs?</b></summary>
<br/>
Honest answer: <b>it depends on what you're doing.</b>
<b>Where VoiceStudio is genuinely competitive:</b> voice cloning from a clean reference clip (state-of-the-art open diffusion TTS), language coverage (646 languages vs. their 32), and everything structural — no per-character billing, no usage caps, no audio leaving your machine, full pipeline customizability (14 TTS engines, 11 ASR engines, your choice of translation).
<b>Where ElevenLabs still wins:</b> out-of-the-box consistency and polish, especially for English TTS. Their one model is heavily tuned; our quality depends on which engine you pick, your hardware, and — for cloning — the reference audio (a dry, close-mic clip clones dramatically better than a noisy or echoey one).
<b>For dubbing specifically:</b> a dub is a chain — transcription → translation → cloning → synthesis — only as good as its weakest link on <i>your</i> source material. If parts come out incoherent, check the segment table's <i>original</i> text first: when the transcription is already wrong, switch the ASR engine or use cleaner source audio — that's usually the fix, not the voice.
Try it on your real material — it's free and takes one download. Many users replace ElevenLabs outright; some keep both. Both outcomes are fine with us.
</details>
<details>
<summary><b>Why doesn't a longer reference clip sound more like me?</b></summary>
<br/>
Because VoiceStudio's cloning is <b>zero-shot</b>: your clip is a <i>prompt</i> the model conditions on at generation time — it is never trained on. Feeding it 2 hours doesn't teach it your voice; past a short window the extra audio is simply not used. The dubbing pipeline's reference builder targets ~8 s and hard-caps at 15 s (<code>backend/services/speaker_clone.py</code>), and engines cap the prompt themselves (VoxCPM2 trims references to 30 s). This is different from ElevenLabs <i>Professional</i> Voice Cloning, which fine-tunes a model on hours of your audio — that's a training job, not a bigger prompt.
<b>What actually moves clone quality is the clip, not its length.</b> Zero-shot cloning mirrors the acoustics and delivery of the prompt, so: record 515 seconds (~8 s is the sweet spot) of continuous natural speech, close to the mic, in a quiet room with no reverb or music — an echoey clip clones echoey. One speaker only, and read in the tone and pace you want the output to have, because the clone copies your delivery, not just your timbre. Recording a few candidate clips and comparing results beats any amount of extra footage.
<b>Want audiobook-grade, trained-on-your-voice fidelity?</b> That path exists, but it's offline fine-tuning, not an in-app button: prepare a dataset of your recordings (<a href="docs/data_preparation.md">docs/data_preparation.md</a>) and fine-tune the bundled checkpoint via <code>init_from_checkpoint</code> (<a href="docs/training.md">docs/training.md</a>). Fair warning — it's a technical, command-line workflow that needs a capable GPU and hours of transcribed audio. In-app fine-tuning / long-reference "professional" cloning is on the <a href="docs/ROADMAP.md">roadmap</a> as research only; no promised date.
</details>
<details>
<summary><b>Does it work on Apple Silicon (M1/M2/M3/M4)?</b></summary>
<br/>
@@ -461,19 +530,7 @@ Yes. MPS acceleration is auto-detected. MLX-optimized Whisper models are availab
<details>
<summary><b>How much VRAM do I need?</b></summary>
<br/>
<b>4 GB minimum.</b> With ≤8 GB, the TTS model is automatically offloaded to CPU during transcription. With 8+ GB, everything runs on GPU simultaneously. No GPU at all? CPU mode works — just slower (~3× for TTS). You can also lend a GPU from another machine you own via <a href="docs/remote-workers.md">remote workers</a>.
</details>
<details>
<summary><b>What languages are supported?</b></summary>
<br/>
646 languages for TTS via the VoiceStudio model. Transcription (WhisperX) supports 99 languages. Translation coverage depends on the target language pair.
</details>
<details>
<summary><b>Why doesn't a longer reference clip sound more like me?</b></summary>
<br/>
Because VoiceStudio's cloning is <b>zero-shot</b>: your clip is a <i>prompt</i> the model conditions on — it is never trained on, and past a short window extra audio is simply unused (the dubbing pipeline targets ~8 s and hard-caps at 15 s). <b>What moves clone quality is the clip, not its length</b>: record 515 seconds of continuous natural speech, close to the mic, in a quiet room with no reverb or music, one speaker, delivered in the tone and pace you want — the clone copies your delivery, not just your timbre. Want trained-on-your-voice fidelity? That's offline fine-tuning, not an in-app button: <a href="docs/data_preparation.md">docs/data_preparation.md</a> + <a href="docs/training.md">docs/training.md</a>.
<b>4 GB minimum.</b> With ≤8 GB, the TTS model is automatically offloaded to CPU during transcription. With 8+ GB, everything runs on GPU simultaneously. No GPU at all? CPU mode works — just slower (~3× for TTS).
</details>
<details>
@@ -482,10 +539,16 @@ Because VoiceStudio's cloning is <b>zero-shot</b>: your clip is a <i>prompt</i>
<b>Yes — commercial use is free</b> under the <a href="https://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0</a>: run it, sell the audio you make, dub client videos, deploy it across your team. One obligation: if you <b>modify</b> VoiceStudio and offer the modified version to others over a network, you must share that modified source under the same terms. Embedding it in a closed-source product instead? A commercial license is available — see <a href="#license">License</a>.
</details>
<details>
<summary><b>What languages are supported?</b></summary>
<br/>
646 languages for TTS via the VoiceStudio model. Transcription (WhisperX) supports 99 languages. Translation coverage depends on the target language pair.
</details>
<details>
<summary><b>Can I add my own TTS engine?</b></summary>
<br/>
Yes. Subclass <code>TTSBackend</code> in <code>backend/services/tts_backend.py</code> and add it to the <code>_REGISTRY</code> dictionary — ~50 lines. The sixteen built-in engines all work this way; see <a href="#tts-engines">TTS Engines</a> and <a href="docs/engine-acceptance.md">docs/engine-acceptance.md</a>.
Yes. Subclass <code>TTSBackend</code> in <code>backend/services/tts_backend.py</code> and add it to the <code>_REGISTRY</code> dictionary — ~50 lines. The fourteen built-in engines all work this way; see <a href="#tts-engines">TTS Engines</a>.
</details>
<details>
@@ -493,7 +556,7 @@ Yes. Subclass <code>TTSBackend</code> in <code>backend/services/tts_backend.py</
<br/>
<b>Not unless you explicitly say yes.</b> On first run the app <i>asks</i> — one screen, two equal-weight buttons, no pre-ticked box — and until you answer yes, VoiceStudio sends nothing: no analytics, no telemetry, no accounts, no phone-home. Skipping the question means no. Your text, audio, voices, and projects never leave your machine either way.
If you do opt in (also togglable anytime under <b>Settings → Privacy → "Help improve VoiceStudio"</b>), what's sent is anonymous, content-free usage stats: generations (engine, language, generation time, character <i>count</i>, error <i>type</i>), plus app lifecycle — an install ping, updates (version-to-version), crashes (error class and a <i>bucketed</i> uptime, never logs), error <i>types</i> (capped, deduplicated), and a single uninstall ping if you remove it. Never your text, audio, file names, or anything identifying — enforced in code by a property allowlist (<code>backend/core/analytics.py</code>), not just a promise. Every build — installer, Docker, or built from source — asks the same first-run question and stays off unless you say yes. Your own numbers live in <b>Settings → Usage</b>, computed locally, sent nowhere.
If you do opt in (also togglable anytime under <b>Settings → Privacy → "Help improve VoiceStudio"</b>), what's sent is anonymous, content-free usage stats: generations (engine, language, generation time, character <i>count</i>, error <i>type</i>), plus app lifecycle — an install ping, updates (version-to-version), crashes (error class and a <i>bucketed</i> uptime, never logs), error <i>types</i> (capped, deduplicated), and a single uninstall ping if you remove it. Never your text, audio, file names, or anything identifying — enforced in code by a property allowlist (<code>backend/core/analytics.py</code>), not just a promise. Every build — installer, Docker, or built from source — asks the same first-run question and stays off unless you say yes (the destination is PostHog's publishable write-only client key; skipping the question means off). Your own numbers live in <b>Settings → Usage</b>, computed locally, sent nowhere.
</details>
<details>
@@ -520,13 +583,55 @@ The bundled `omnivoice/` TTS model by Han Zhu remains Apache-2.0 upstream. See [
## 🙏 Acknowledgments
VoiceStudio stands on exceptional open-source work: [OmniVoice (k2-fsa)](https://github.com/k2-fsa/OmniVoice) — the core zero-shot TTS model · [WhisperX](https://github.com/m-bain/whisperX) · [Demucs](https://github.com/facebookresearch/demucs) · [Pyannote](https://github.com/pyannote/pyannote-audio) · [CTranslate2](https://github.com/OpenNMT/CTranslate2) · [AudioSeal](https://github.com/facebookresearch/audioseal) · [Tauri](https://tauri.app) · [Supertonic](https://huggingface.co/Supertone/supertonic-3) · [Sherpa-ONNX](https://github.com/k2-fsa/sherpa-onnx) · [GPT-SoVITS](https://github.com/RVC-Boss/GPT-SoVITS) · [Kyutai PocketTTS](https://kyutai.org) — thank you.
VoiceStudio is built on the shoulders of exceptional open-source work:
| Project | Role |
|---------|------|
| [**VoiceStudio (k2-fsa)**](https://github.com/k2-fsa/OmniVoice) | Zero-shot diffusion TTS engine — the core voice synthesis model |
| [**WhisperX**](https://github.com/m-bain/whisperX) | Word-level speech recognition and alignment |
| [**Demucs (Meta)**](https://github.com/facebookresearch/demucs) | Music source separation for vocal isolation |
| [**Pyannote**](https://github.com/pyannote/pyannote-audio) | Speaker diarization — who said what |
| [**CTranslate2**](https://github.com/OpenNMT/CTranslate2) | Optimized Transformer inference on CPU and GPU |
| [**AudioSeal (Meta)**](https://github.com/facebookresearch/audioseal) | Invisible neural audio watermarking for AI provenance |
| [**Tauri**](https://tauri.app) | Native desktop app framework |
| [**Supertone / Supertonic 3**](https://huggingface.co/Supertone/supertonic-3) | ONNX TTS engine — 31 languages, CPU-efficient |
| [**Sherpa-ONNX**](https://github.com/k2-fsa/sherpa-onnx) | WASM-ready universal TTS/ASR runtime |
| [**GPT-SoVITS**](https://github.com/RVC-Boss/GPT-SoVITS) | Zero-shot TTS engine — 5 languages, RTF 0.014 |
---
<a id="more-from-the-maker"></a>
### 🧰 More local open-source from the maker
## 🧰 More local open-source from the maker
[**Opal** 💠](https://github.com/debpalash/Opal) — play everything: the media player for the AI era · [**memxt** 🧠](https://github.com/debpalash/memxt) — local long-term memory for coding agents. Same rule: **your data stays on your machine.**
Like the local-first philosophy? It runs in the family — same maker, same rule: **your data stays on your machine.**
<table>
<tr>
<td align="center" width="50%" valign="top">
<br/>
<a href="https://github.com/debpalash/Opal"><img src="https://raw.githubusercontent.com/debpalash/Opal/main/assets/opal_logo.png" width="96" alt="Opal logo"/></a>
<h3><a href="https://github.com/debpalash/Opal">Opal 💠</a></h3>
<p><b>Play everything.</b> The media player for the AI era.</p>
<p><sub>Video, anime, comics, torrents, Jellyfin & Plex — one player for all of it, with local AI memory and context built in. Written in Zig, runs on macOS & Windows.</sub></p>
<p>
<a href="https://github.com/debpalash/Opal/stargazers"><img src="https://img.shields.io/github/stars/debpalash/Opal?style=flat-square&color=f59e0b" alt="Opal stars"/></a>
<a href="https://palash.dev/opal"><img src="https://img.shields.io/badge/site-palash.dev%2Fopal-8b5cf6?style=flat-square" alt="Opal website"/></a>
</p>
</td>
<td align="center" width="50%" valign="top">
<br/>
<a href="https://github.com/debpalash/memxt"><img src="https://raw.githubusercontent.com/debpalash/memxt/main/assets/logo-mark.svg" width="96" alt="memxt logo"/></a>
<h3><a href="https://github.com/debpalash/memxt">memxt 🧠</a></h3>
<p><b>The fastest benchmarked open-source AI memory system.</b></p>
<p><sub>Local long-term memory for Claude Code and coding agents — an MCP server on SQLite + embeddings, 100% on your machine. Your agent finally remembers yesterday.</sub></p>
<p>
<a href="https://github.com/debpalash/memxt/stargazers"><img src="https://img.shields.io/github/stars/debpalash/memxt?style=flat-square&color=f59e0b" alt="memxt stars"/></a>
<a href="https://github.com/debpalash/memxt#readme"><img src="https://img.shields.io/badge/docs-README-10b981?style=flat-square" alt="memxt docs"/></a>
</p>
</td>
</tr>
</table>
---
+114 -46
View File
@@ -17,21 +17,67 @@ Currently exposed:
keep their own inline loopback guards.
"""
import ipaddress
import os
import secrets
from fastapi import HTTPException, Request
from core.auth import (
CredentialTransport,
PrincipalKind,
is_local_host,
is_loopback,
principal_for,
remote_api_key,
)
from core.csrf import SAFE_HTTP_METHODS, cookie_csrf_allowed
# IPv4 + IPv6 loopback literals + the conventional `localhost` hostname.
# `request.client.host` carries an address, not a hostname, so the literal
# "localhost" entry is defensive — some upstream wrappers (TestClient with
# a custom client tuple, certain reverse-proxy headers) may pass strings
# rather than parsed addresses. We accept the broader set without weakening
# the guard: nothing here matches a non-loopback origin.
_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"})
def _trusted_networks():
"""CIDR networks from OMNIVOICE_TRUSTED_NETWORKS (comma-separated) treated as
loopback-trusted e.g. a reverse proxy or self-hosted LAN, so the API-key /
PIN gates don't block LAN clients that can't present the credential (a proxy
that strips the Authorization header). Read at call time (matching
`_server_mode` / `remote_api_key`) so tests can monkeypatch the env; restart
to apply changes in production."""
nets = []
for cidr in os.environ.get("OMNIVOICE_TRUSTED_NETWORKS", "").split(","):
cidr = cidr.strip()
if cidr:
try:
nets.append(ipaddress.ip_network(cidr, strict=False))
except ValueError:
pass # malformed entry ignored — never wedge the auth gate
return nets
def is_loopback(host):
"""True loopback address only (127.0.0.1, ::1, localhost) — NOT a trusted
network. Admin gates (``require_admin`` ``/system/set-env``,
``/api/settings/*``) use this so a trusted-network CIDR exempts consumption
(TTS / dictation) but never the RCE-class admin surface."""
return host in _LOOPBACK_HOSTS
def is_local_host(host):
"""Loopback address, OR on a configured trusted network. The consumption
gates (PIN/API-key middleware, WS guard) call this so a trusted LAN/proxy is
exempted. Admin gates use :func:`is_loopback` NOT this to preserve the
two-tier privilege model: consumption trust admin trust."""
if is_loopback(host):
return True
try:
ip = ipaddress.ip_address(host)
except (ValueError, TypeError):
return False
# Unwrap IPv4-mapped IPv6 (::ffff:192.168.1.5) so it matches IPv4 CIDRs —
# dual-stack proxies (Caddy, Node.js) frequently pass the mapped form.
if getattr(ip, "ipv4_mapped", None):
ip = ip.ipv4_mapped
return any(ip in net for net in _trusted_networks())
_TRUTHY = frozenset({"1", "true", "yes", "on"})
_READ_ONLY_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
def _server_mode() -> bool:
@@ -54,6 +100,34 @@ def _server_mode() -> bool:
return os.environ.get("OMNIVOICE_SERVER_MODE", "").strip().lower() in _TRUTHY
def remote_api_key() -> str | None:
"""The normalized remote-backend bearer key, or None when remote mode is
off. Surrounding whitespace is configuration noise, never a valid secret.
Read at call time so tests can monkeypatch the environment."""
return os.environ.get("OMNIVOICE_API_KEY", "").strip() or None
def presented_api_key(connection) -> str:
"""Return the first non-empty normalized API key on an HTTP/WS connection.
Authorization wins over query, which wins over cookie. Each channel is
stripped before fallback so whitespace in a higher-priority channel cannot
shadow a valid lower-priority credential.
"""
headers = getattr(connection, "headers", None) or {}
query = getattr(connection, "query_params", None) or {}
cookies = getattr(connection, "cookies", None) or {}
auth = headers.get("authorization", "")
supplied = auth[7:].strip() if auth.lower().startswith("bearer ") else ""
if supplied:
return supplied
supplied = (query.get("api_key") or "").strip()
if supplied:
return supplied
return (cookies.get("ov_key") or "").strip()
def _configured_pin(request) -> str | None:
"""The active share PIN (``app.state.network_share.pin``) or None. Read via
getattr so a bare Request stub (or a request that hit before lifespan set
@@ -76,34 +150,25 @@ def _admin_credential_configured(request) -> bool:
return bool(_configured_pin(request))
def _request_presents_admin_credential(
request,
*,
side_effectful_get: bool = False,
) -> bool:
"""Whether the canonical principal carries remote admin capability.
def _request_presents_admin_credential(request) -> bool:
"""Whether the request carries a valid **API key** via the channels the
middleware accepts (``Authorization: Bearer`` / ``?api_key`` / ``ov_key``
cookie).
API-key and short-lived session principals may unlock server-mode admin.
PIN and trusted-network principals remain consumption-only.
"""
principal = principal_for(request)
if principal.kind not in {
PrincipalKind.API_KEY,
PrincipalKind.ADMIN_SESSION,
}:
Admin is RCE-class (``/system/set-env`` + ``/api/settings/*``), so only the
API key a long operator-chosen secret unlocks it. The 6-digit share PIN
is deliberately NOT accepted here: it is a *consumption* credential for LAN
playback and is short enough to brute-force (10^6, no lockout), so it must
never gate the admin surface (CodeRabbit #1213). A trusted-network CIDR
(``is_local_host`` also a consumption exemption) likewise never unlocks
admin. Net: remote admin in server mode requires the API key; a PIN-only
deployment keeps admin loopback-only. getattr-defensive so a minimal Request
stub never raises."""
api_key = remote_api_key() or ""
if not api_key:
return False
if principal.transport not in {
CredentialTransport.COOKIE,
CredentialTransport.LEGACY_COOKIE,
}:
return True
method = str(getattr(request, "method", "GET")).upper()
if side_effectful_get or method not in SAFE_HTTP_METHODS:
return cookie_csrf_allowed(
request,
side_effectful_get=side_effectful_get,
)
return True
supplied = presented_api_key(request)
return bool(supplied and secrets.compare_digest(supplied, api_key))
def require_loopback(request: Request) -> None:
@@ -144,7 +209,7 @@ def require_loopback(request: Request) -> None:
return
if _server_mode():
method = str(getattr(request, "method", "GET")).upper()
if method not in SAFE_HTTP_METHODS:
if method not in _READ_ONLY_METHODS:
# Defense in depth. Privileged routers should declare
# ``require_admin`` directly, but a missed migration must not turn
# into an unauthenticated Docker write primitive.
@@ -175,7 +240,7 @@ def require_admin(request: Request) -> None:
return
if _server_mode():
method = str(getattr(request, "method", "GET")).upper()
read_only = method in SAFE_HTTP_METHODS
read_only = method in _READ_ONLY_METHODS
if read_only and not _admin_credential_configured(request):
return
if _request_presents_admin_credential(request):
@@ -193,10 +258,7 @@ def require_admin_action(request: Request) -> None:
host = request.client.host if request.client else None
if is_loopback(host):
return
if _server_mode() and _request_presents_admin_credential(
request,
side_effectful_get=True,
):
if _server_mode() and _request_presents_admin_credential(request):
return
raise HTTPException(status_code=403, detail="loopback origin or admin API key required")
@@ -245,8 +307,14 @@ def require_native_access(request: Request) -> None:
def ws_remote_authorized(websocket) -> bool:
"""Whether the canonical WS principal has a remote admin credential."""
return principal_for(websocket).kind in {
PrincipalKind.API_KEY,
PrincipalKind.ADMIN_SESSION,
}
"""Whether a WebSocket handshake presents the remote API key.
Browser WebSockets cannot set an Authorization header, so the key may
arrive as ``?api_key=`` or via the ``ov_key`` cookie that the bearer
middleware sets on the first authenticated HTTP request. Returns False
when remote mode is off callers keep their loopback-only behavior.
"""
key = remote_api_key()
if not key:
return False
return secrets.compare_digest(presented_api_key(websocket), key)
+51 -253
View File
@@ -26,10 +26,8 @@ Design notes
from __future__ import annotations
import hashlib
import json
import logging
import os
import re
import time
import uuid
from pathlib import Path
@@ -39,7 +37,6 @@ from fastapi import APIRouter, Body, HTTPException, Query
from fastapi.responses import FileResponse
from core import archetypes
from core.audio_validation import is_playable_wav, resolve_regular_file
from core.config import OUTPUTS_DIR, VOICES_DIR
from services import gallery
@@ -72,153 +69,6 @@ def _preview_key(a: dict) -> str:
).hexdigest()[:16]
def _design_profile_values(a: dict) -> tuple[str, str]:
"""Canonical instruct + complete picker state for a designed archetype."""
return a["instruct"], json.dumps(a["attrs"], sort_keys=True)
def _profile_audio_path(ref_audio_path: object) -> Optional[Path]:
"""Resolve only a regular, non-symlinked file inside ``VOICES_DIR``."""
return resolve_regular_file(VOICES_DIR, ref_audio_path)
def _materialized_audio_is_current(row, a: dict) -> bool:
"""Whether an existing row still has the sample described by its metadata."""
expected_filename = _profile_audio_filename(row["id"])
path = _profile_audio_path(row["ref_audio_path"])
return bool(
row["ref_audio_path"] == expected_filename
and is_playable_wav(path)
and row["instruct"] == a["instruct"]
and row["language"] == a["language"]
and row["ref_text"] == a["sample_script"]
and row["seed"] == _PREVIEW_SEED
)
def _profile_audio_filename(profile_id: str) -> str:
safe_id = (
profile_id if re.fullmatch(r"[A-Za-z0-9_-]{1,64}", profile_id or "")
else hashlib.sha256(str(profile_id).encode("utf-8")).hexdigest()[:16]
)
return f"{safe_id}.wav"
def _archetype_personality(a: dict) -> str:
return f"archetype:{a['id']}"
def _legacy_archetype_profile(conn, a: dict):
"""Adopt only a row that an older archetype materializer could have made."""
row = conn.execute(
"SELECT * FROM voice_profiles WHERE personality=? LIMIT 1",
(a["id"],),
).fetchone()
if row is None:
return None
expected_audio = _profile_audio_filename(row["id"])
try:
states_match = (
not row["vd_states"] or json.loads(row["vd_states"]) == a["attrs"]
)
except (TypeError, ValueError):
states_match = False
if (
row["ref_audio_path"] == expected_audio
and row["instruct"] == a["instruct"]
and row["language"] == a["language"]
and row["ref_text"] == a["sample_script"]
and row["seed"] == _PREVIEW_SEED
and row["kind"] in (None, "", "clone", "design")
and not row["is_locked"]
and not row["verified_own_voice"]
and states_match
):
return row
return None
def _is_materialized_archetype_row(row, a: dict) -> bool:
"""Recognize rows owned by this materializer without trusting identity text alone."""
try:
states_match = json.loads(row["vd_states"]) == a["attrs"]
except (TypeError, ValueError):
return False
return bool(
row["personality"] == _archetype_personality(a)
and row["kind"] == "design"
and row["seed"] == _PREVIEW_SEED
and row["ref_audio_path"] == _profile_audio_filename(row["id"])
and row["instruct"] == a["instruct"]
and row["language"] == a["language"]
and row["ref_text"] == a["sample_script"]
and states_match
and not row["is_locked"]
and not row["verified_own_voice"]
)
def _existing_archetype_profile(conn, a: dict):
rows = conn.execute(
"SELECT * FROM voice_profiles WHERE personality=? ORDER BY created_at, id",
(_archetype_personality(a),),
).fetchall()
owned = next((row for row in rows if _is_materialized_archetype_row(row, a)), None)
return owned if owned is not None else _legacy_archetype_profile(conn, a)
async def _render_profile_audio(
a: dict, profile_id: str, *, publish: bool = True,
) -> tuple[str, Path]:
"""Render one validated sample, optionally staging it for a later CAS."""
audio_filename = _profile_audio_filename(profile_id)
safe_id = Path(audio_filename).stem
audio_path = Path(VOICES_DIR) / audio_filename
if publish:
await _render_wav_atomic(a, audio_path, prefix=f".{safe_id}-")
else:
audio_path.parent.mkdir(parents=True, exist_ok=True)
audio_path = audio_path.parent / f".{safe_id}-{uuid.uuid4().hex}.staged.wav"
try:
await _render_archetype_wav(a, audio_path)
if not is_playable_wav(audio_path):
raise RuntimeError("the voice engine produced an invalid WAV")
except BaseException:
with __import__("contextlib").suppress(OSError):
audio_path.unlink()
raise
return audio_filename, audio_path
async def _render_wav_atomic(a: dict, out_path: Path, *, prefix: str = ".render-") -> Path:
"""Render and validate a WAV before atomically replacing *out_path*."""
audio_path = Path(out_path)
audio_path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = audio_path.parent / f"{prefix}{uuid.uuid4().hex}.wav"
try:
await _render_archetype_wav(a, tmp_path)
if not is_playable_wav(tmp_path):
raise RuntimeError("the voice engine produced an invalid WAV")
os.replace(tmp_path, audio_path)
finally:
with __import__("contextlib").suppress(OSError):
tmp_path.unlink()
return audio_path
def _heal_materialized_profile(conn, row, a: dict, audio_filename: str) -> None:
"""Repair profiles created before archetype `/use` persisted design kind."""
instruct, vd_states = _design_profile_values(a)
conn.execute(
"UPDATE voice_profiles SET kind='design', instruct=?, vd_states=?, language=?, "
"ref_text=?, seed=?, ref_audio_path=?, personality=? WHERE id=?",
(
instruct, vd_states, a["language"], a["sample_script"], _PREVIEW_SEED,
audio_filename, _archetype_personality(a), row["id"],
),
)
# A non-empty script is always required — synthesizing empty text yields
# silence. Every archetype carries a use-case script, but guard the render path
# too so a malformed archetype can never drive a blank render.
@@ -405,7 +255,7 @@ def _preview_source(a: dict) -> tuple[str, str]:
"Pre-rendered preview from the voice gallery — a fixed reference "
"rendering, not a render from your current engine."
)
if is_playable_wav(_PREVIEW_DIR / f"{key}.wav"):
if (_PREVIEW_DIR / f"{key}.wav").exists():
return "cached", ""
if _no_voice_model_downloaded():
return "no_model", (
@@ -538,9 +388,9 @@ async def preview_archetype(
)
cache_path = _PREVIEW_DIR / f"{key}.wav"
if not is_playable_wav(cache_path):
if not cache_path.exists():
try:
await _render_wav_atomic(a, cache_path, prefix=".preview-")
await _render_archetype_wav(a, cache_path)
except Exception as e: # model missing / OOM / inference failure
logger.error("Archetype preview render failed", exc_info=True)
# Two different failures, two different answers. Without a model
@@ -592,122 +442,70 @@ async def use_archetype(archetype_id: str, name: Optional[str] = Query(None)):
# Idempotent (dedup): an archetype materializes to exactly ONE voice profile.
# Picking the same gallery voice again — from any picker (Gallery grid,
# VoiceSelector, …) — must reuse that one row instead of rendering + inserting
# a fresh duplicate every time. Use a namespaced personality identity so an
# imported persona cannot collide with and be rewritten by an archetype id.
# a fresh duplicate every time. The `personality` column already carries the
# source archetype id (stamped by the INSERT below), so it's the natural
# dedup key; the expensive render + INSERT only run on first use.
with db_conn() as conn:
existing = _existing_archetype_profile(conn, a)
profile_id = existing["id"] if existing is not None else str(uuid.uuid4())[:8]
audio_path: Optional[Path] = None
if existing is not None and _materialized_audio_is_current(existing, a):
audio_filename = existing["ref_audio_path"]
else:
try:
audio_filename, audio_path = await _render_profile_audio(
a, profile_id, publish=existing is None,
)
except Exception as e:
logger.error("Archetype 'use' render failed", exc_info=True)
# Same actionable/diagnostic split as /preview — minus the gallery
# suggestion, which cannot help here.
if _no_voice_model_downloaded():
detail = (
"Creating a voice needs the voice model — no voice model is "
"downloaded yet. Model Catalogue → Models → Download."
)
else:
detail = (
"Couldn't create a voice from this archetype — the voice engine "
f"reported: {e}"
)
raise HTTPException(status_code=503, detail=detail) from e
existing = conn.execute(
"SELECT id, name FROM voice_profiles WHERE personality = ? LIMIT 1",
(a["id"],),
).fetchone()
if existing is not None:
with db_conn() as conn:
conn.execute("BEGIN IMMEDIATE")
current = conn.execute(
"SELECT * FROM voice_profiles WHERE id=?", (existing["id"],),
).fetchone()
owned = _existing_archetype_profile(conn, a)
still_owned = current is not None and (
owned is not None and owned["id"] == current["id"]
)
if still_owned:
if audio_path is not None:
destination = Path(VOICES_DIR) / audio_filename
os.replace(audio_path, destination)
audio_path = None
_heal_materialized_profile(conn, current, a, audio_filename)
existing_result = {"profile_id": current["id"], "name": current["name"]}
else:
existing_result = None
if existing_result is not None:
event_bus.emit("profiles", {"action": "updated", "id": existing_result["profile_id"]})
return existing_result
# The row was edited/deleted while rendering. Preserve it and use the
# validated staged sample for a fresh canonical materialization.
profile_id = str(uuid.uuid4())[:8]
audio_filename = _profile_audio_filename(profile_id)
destination = Path(VOICES_DIR) / audio_filename
if audio_path is None:
try:
audio_filename, audio_path = await _render_profile_audio(a, profile_id)
except Exception as e:
raise HTTPException(
status_code=503, detail="Couldn't create a voice from this archetype.",
) from e
else:
os.replace(audio_path, destination)
audio_path = destination
return {"profile_id": existing["id"], "name": existing["name"]}
if audio_path is None: # defensive: a new profile always rendered above
raise RuntimeError("new archetype profile has no rendered audio")
profile_id = str(uuid.uuid4())[:8]
audio_filename = f"{profile_id}.wav"
audio_path = Path(VOICES_DIR) / audio_filename
try:
await _render_archetype_wav(a, audio_path)
except Exception as e:
logger.error("Archetype 'use' render failed", exc_info=True)
# Same actionable/diagnostic split as /preview — minus the gallery
# suggestion, which cannot help here.
if _no_voice_model_downloaded():
detail = (
"Creating a voice needs the voice model — no voice model is "
"downloaded yet. Model Catalogue → Models → Download."
)
else:
detail = (
"Couldn't create a voice from this archetype — the voice engine "
f"reported: {e}"
)
raise HTTPException(status_code=503, detail=detail)
profile_name = (name or a["name"]).strip() or a["name"]
try:
with db_conn() as conn:
conn.execute("BEGIN IMMEDIATE")
# Re-check under the write connection right before inserting: a
# concurrent /use for the same archetype may have inserted while we
# were rendering (the pre-render SELECT above raced). Reuse that row
# and drop our just-rendered sample instead of creating a duplicate.
# `personality` is not globally UNIQUE, so serialize and re-check.
dup = _existing_archetype_profile(conn, a)
# (personality is NOT globally unique — marketplace/persona imports
# reuse the column — so a UNIQUE index isn't an option; this closes
# the realistic window for the single-user desktop app.)
dup = conn.execute(
"SELECT id, name FROM voice_profiles WHERE personality = ? LIMIT 1",
(a["id"],),
).fetchone()
if dup is not None:
duplicate_audio = dup["ref_audio_path"]
if not _materialized_audio_is_current(dup, a):
duplicate_audio = _profile_audio_filename(dup["id"])
_duplicate_path = Path(VOICES_DIR) / duplicate_audio
_duplicate_path.parent.mkdir(parents=True, exist_ok=True)
os.replace(audio_path, _duplicate_path)
audio_path = None
_heal_materialized_profile(conn, dup, a, duplicate_audio)
with __import__("contextlib").suppress(OSError):
if audio_path is not None:
os.remove(audio_path)
duplicate_result = {"profile_id": dup["id"], "name": dup["name"]}
else:
duplicate_result = None
if duplicate_result is None:
instruct, vd_states = _design_profile_values(a)
conn.execute(
"INSERT INTO voice_profiles "
"(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, "
"created_at, kind, vd_states) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'design', ?)",
(
profile_id, profile_name, audio_filename, a["sample_script"],
instruct, a["language"], _PREVIEW_SEED,
_archetype_personality(a), time.time(), vd_states,
),
)
os.remove(audio_path)
return {"profile_id": dup["id"], "name": dup["name"]}
conn.execute(
"INSERT INTO voice_profiles "
"(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
profile_id, profile_name, audio_filename, a["sample_script"],
a["instruct"], a["language"], _PREVIEW_SEED, a["id"], time.time(),
),
)
except Exception:
with __import__("contextlib").suppress(OSError):
if audio_path is not None:
os.remove(audio_path)
os.remove(audio_path)
raise
if duplicate_result is not None:
event_bus.emit("profiles", {"action": "updated", "id": duplicate_result["profile_id"]})
return duplicate_result
event_bus.emit("profiles", {"action": "created", "id": profile_id})
return {"profile_id": profile_id, "name": profile_name}
-231
View File
@@ -1,231 +0,0 @@
"""Short-lived credentials for the first-party remote administration UI."""
from __future__ import annotations
import math
import threading
import time
from collections import OrderedDict, deque
from collections.abc import Callable
from datetime import UTC, datetime
from typing import Literal
from fastapi import APIRouter, HTTPException, Request, Response
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from core.auth import (
CredentialTransport,
PrincipalKind,
authorization_credential_present,
legacy_master_cookie_valid,
master_header_valid,
principal_for,
remote_api_key,
)
from core.csrf import cookie_csrf_allowed, effective_scheme
from services.admin_sessions import (
SESSION_TTL_SECONDS,
WS_TICKET_TTL_SECONDS,
admin_session_store,
)
router = APIRouter(prefix="/api/auth", tags=["auth"])
_FAILED_EXCHANGE_LIMIT = 10
_FAILED_EXCHANGE_WINDOW_SECONDS = 60
_MAX_TRACKED_CLIENTS = 1024
class _ExchangeAttemptLimiter:
"""Bounded per-client sliding window for failed pre-auth exchanges."""
def __init__(
self,
*,
monotonic: Callable[[], float] = time.monotonic,
limit: int = _FAILED_EXCHANGE_LIMIT,
window_seconds: int = _FAILED_EXCHANGE_WINDOW_SECONDS,
max_clients: int = _MAX_TRACKED_CLIENTS,
) -> None:
if limit <= 0 or window_seconds <= 0 or max_clients <= 0:
raise ValueError("rate-limit bounds must be positive")
self._monotonic = monotonic
self._limit = limit
self._window_seconds = window_seconds
self._max_clients = max_clients
self._attempts: OrderedDict[str, deque[float]] = OrderedDict()
self._lock = threading.Lock()
def register_failure(self, client_id: str) -> int | None:
now = self._monotonic()
cutoff = now - self._window_seconds
with self._lock:
failures = self._attempts.setdefault(client_id, deque())
while failures and failures[0] <= cutoff:
failures.popleft()
self._attempts.move_to_end(client_id)
while len(self._attempts) > self._max_clients:
self._attempts.popitem(last=False)
if len(failures) >= self._limit:
return max(
1,
math.ceil(self._window_seconds - (now - failures[0])),
)
failures.append(now)
return None
def clear(self, client_id: str) -> None:
with self._lock:
self._attempts.pop(client_id, None)
def reset(self) -> None:
with self._lock:
self._attempts.clear()
_exchange_attempt_limiter = _ExchangeAttemptLimiter()
class SessionRequest(BaseModel):
transport: Literal["cookie", "bearer"]
class WebSocketTicketRequest(BaseModel):
path: str
def _secure_cookie(request: Request) -> bool:
# Same effective-scheme logic as the exact-origin CSRF check: the resolved
# scope first (uvicorn's trusted-proxy rewrite), upgraded — never
# downgraded — by X-Forwarded-Proto for TLS-terminating proxies uvicorn
# doesn't trust (Tailscale Serve into Docker, etc.). Spoofing the header on
# a plain-http hop can only ADD the Secure flag, which fails safe: the
# browser drops such a cookie, so the spoofer only breaks their own
# session. See core.csrf.effective_scheme for the full analysis.
return effective_scheme(request) == "https"
def _set_session_cookie(response: Response, request: Request, token: str, expires_at: float) -> None:
response.set_cookie(
"ov_session",
token,
max_age=SESSION_TTL_SECONDS,
expires=datetime.fromtimestamp(expires_at, tz=UTC),
path="/",
secure=_secure_cookie(request),
httponly=True,
samesite="strict",
)
def _expire_cookie(response: Response, request: Request, name: str) -> None:
response.delete_cookie(
name,
path="/",
secure=_secure_cookie(request),
httponly=name == "ov_session",
samesite="strict",
)
def _client_id(request: Request) -> str:
host = request.client.host if request.client else "unknown"
return str(host).strip().lower()[:255] or "unknown"
def _reject_master_exchange(request: Request) -> None:
retry_after = _exchange_attempt_limiter.register_failure(_client_id(request))
if retry_after is not None:
raise HTTPException(
status_code=429,
detail="Too many authentication attempts",
headers={"Retry-After": str(retry_after)},
)
raise HTTPException(status_code=401, detail="API key required")
@router.post("/session")
def create_session(payload: SessionRequest, request: Request) -> Response:
configured = remote_api_key()
if not configured:
raise HTTPException(status_code=401, detail="API key required")
authorization_present = authorization_credential_present(request)
header_authorized = master_header_valid(request)
legacy_authorized = legacy_master_cookie_valid(request)
migrating_legacy = False
if authorization_present:
if not header_authorized:
_reject_master_exchange(request)
elif legacy_authorized:
if payload.transport != "cookie" or not cookie_csrf_allowed(request):
raise HTTPException(status_code=403, detail="browser origin rejected")
migrating_legacy = True
else:
_reject_master_exchange(request)
_exchange_attempt_limiter.clear(_client_id(request))
issued = admin_session_store.issue(configured)
if payload.transport == "bearer":
return JSONResponse(
{
"token": issued.token,
"expires_at": issued.expires_at,
"expires_in": SESSION_TTL_SECONDS,
},
status_code=201,
)
response = Response(status_code=204)
_set_session_cookie(response, request, issued.token, issued.expires_at)
if migrating_legacy or request.cookies.get("ov_key"):
_expire_cookie(response, request, "ov_key")
return response
@router.delete("/session", status_code=204)
def delete_session(request: Request) -> Response:
principal = principal_for(request)
if principal.kind is PrincipalKind.ADMIN_SESSION:
if (
principal.transport is CredentialTransport.COOKIE
and not cookie_csrf_allowed(request)
):
raise HTTPException(status_code=403, detail="browser origin rejected")
admin_session_store.revoke_by_credential(principal.credential_id)
response = Response(status_code=204)
_expire_cookie(response, request, "ov_session")
return response
@router.post("/ws-ticket")
def create_ws_ticket(payload: WebSocketTicketRequest, request: Request) -> JSONResponse:
principal = principal_for(request)
if principal.kind is not PrincipalKind.ADMIN_SESSION:
raise HTTPException(status_code=403, detail="admin session required")
if (
principal.transport is CredentialTransport.COOKIE
and not cookie_csrf_allowed(request)
):
raise HTTPException(status_code=403, detail="browser origin rejected")
try:
ticket = admin_session_store.issue_ws_ticket_for_credential(
principal.credential_id,
payload.path,
remote_api_key(),
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from None
except PermissionError:
raise HTTPException(status_code=401, detail="admin session required") from None
return JSONResponse(
{
"ticket": ticket.token,
"expires_at": ticket.expires_at,
"expires_in": WS_TICKET_TTL_SECONDS,
},
status_code=201,
)
+97 -726
View File
@@ -20,26 +20,18 @@ Design / safety
from __future__ import annotations
import asyncio
import contextlib
import hashlib
import json
import logging
import os
import re
import shutil
import tempfile
import time
import uuid
from pathlib import Path
from typing import Optional
from urllib.parse import urljoin, urlparse
from urllib.parse import urlparse
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import FileResponse
from core import archetypes
from core.audio_validation import is_playable_wav, resolve_regular_file
from core.config import DATA_DIR, VOICES_DIR
from core.config import DATA_DIR
logger = logging.getLogger("omnivoice.community")
router = APIRouter()
@@ -50,32 +42,9 @@ _ALLOWED_AUDIO_HOSTS = {
"cdn.jsdelivr.net", "github.com", "raw.githubusercontent.com",
"objects.githubusercontent.com", "release-assets.githubusercontent.com",
}
_ALLOWED_MANIFEST_HOSTS = {"cdn.jsdelivr.net"}
_VALID_TOKENS = set(archetypes._VD._INSTRUCT_ALL_VALID)
_USE_CASE_IDS = {c["id"] for c in archetypes.USE_CASES}
_SOURCE_RE = re.compile(
r"^[A-Za-z0-9._-]{1,100}/[A-Za-z0-9._-]{1,100}$",
) # owner/repo only
_ITEM_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,128}$")
_SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
# A gallery open may touch this loader several times (grid, preview, use). Keep
# a successful response for six hours, then revalidate it once. On a network
# failure the readable stale copy remains usable and its check time advances,
# preventing every offline gallery open from waiting through the same timeout.
_MANIFEST_MAX_AGE_S = 6 * 60 * 60
_MAX_MANIFEST_BYTES = 4 << 20
_MAX_SAMPLE_SCRIPT_CHARS = 2_000
_MAX_REF_TEXT_CHARS = 4_000
# Community voice submissions are documented as short clean WAV clips. The cap
# comfortably covers 15 s of uncompressed 96 kHz stereo PCM while preventing a
# remote manifest from turning Preview into an unbounded disk/memory download.
_MAX_VOICE_AUDIO_BYTES = 32 << 20
_ATTR_NAMES = (
"Gender", "Age", "Pitch", "Style", "EnglishAccent", "ChineseDialect",
)
_SOURCE_RE = re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$") # owner/repo only
# ── Config: which content repos to load ───────────────────────────────────────
@@ -83,18 +52,14 @@ def configured_sources() -> list[str]:
"""Gallery sources, in priority order. Env var > config file > default."""
env = os.environ.get("OMNIVOICE_GALLERY_SOURCES")
if env:
sources = [s.strip() for s in env.split(",")]
valid = [s for s in sources if _SOURCE_RE.fullmatch(s)]
return valid or list(_DEFAULT_SOURCES)
return [s.strip() for s in env.split(",") if s.strip()]
cfg = Path(DATA_DIR) / "gallery_sources.json"
if cfg.exists():
try:
data = json.loads(cfg.read_text(encoding="utf-8"))
srcs = data.get("sources")
if isinstance(srcs, list) and srcs:
valid = [s for s in srcs if isinstance(s, str) and _SOURCE_RE.fullmatch(s)]
if valid:
return valid
return [str(s) for s in srcs]
except Exception:
logger.warning("gallery_sources.json unreadable; using default")
return list(_DEFAULT_SOURCES)
@@ -116,51 +81,9 @@ def _safe_audio_url(url: str) -> bool:
return False
def _safe_manifest_url(url: str) -> bool:
try:
parsed = urlparse(url or "")
return parsed.scheme == "https" and parsed.hostname in _ALLOWED_MANIFEST_HOSTS
except Exception:
return False
def normalize_preset_instruct(instruct: str) -> Optional[tuple[str, dict]]:
"""Normalize one validator-safe tag per design category.
Membership in the vocabulary is not enough: ``male, female`` contains two
individually valid tokens but the engine rejects the pair as conflicting.
Build the frontend's full ``vd_states`` shape at this trust boundary too,
so Magic Wand never inherits stale sliders from the previous voice.
"""
attrs = {name: "Auto" for name in _ATTR_NAMES}
normalized: list[str] = []
seen_categories: set[int] = set()
for raw in re.split("[," + chr(0xFF0C) + "]", str(instruct or "")):
token = raw.strip().lower()
if not token or token not in _VALID_TOKENS:
return None
category = archetypes._VD._instruct_category_index(token)
if category < 0 or category in seen_categories:
return None
seen_categories.add(category)
# The picker represents the universal gender/age/pitch/style axes in
# English even for Chinese speech; dialect remains Chinese-only.
canonical = archetypes._VD._INSTRUCT_ZH_TO_EN.get(token, token)
attrs[_ATTR_NAMES[category]] = canonical
normalized.append(canonical)
if not normalized:
return None
# Accent and Chinese dialect are separate taxonomy buckets but the engine
# deliberately forbids mixing them in a single design.
if 4 in seen_categories and 5 in seen_categories:
return None
return ", ".join(normalized), attrs
def is_valid_instruct(instruct: str) -> bool:
return normalize_preset_instruct(instruct) is not None
toks = [t.strip() for t in (instruct or "").split(",") if t.strip()]
return bool(toks) and all(t in _VALID_TOKENS for t in toks)
def validate_item(raw: dict) -> Optional[dict]:
@@ -170,203 +93,62 @@ def validate_item(raw: dict) -> Optional[dict]:
it = dict(raw)
if it.get("type") not in ("preset", "voice"):
return None
if not isinstance(it.get("id"), str) or not _ITEM_ID_RE.fullmatch(it["id"]):
if not it.get("id") or not it.get("name"):
return None
if not isinstance(it.get("name"), str) or not it["name"].strip():
return None
it["name"] = it["name"].strip()[:80]
if it.get("use_case") not in _USE_CASE_IDS:
return None
raw_facets = it.get("facets")
if not isinstance(raw_facets, dict):
raw_facets = {}
language = it.get("language")
if not isinstance(language, str) or not language.strip():
language = raw_facets.get("lang", "English")
it["language"] = language.strip() if isinstance(language, str) and language.strip() else "English"
facets = dict(raw_facets)
if it["type"] == "preset":
normalized = normalize_preset_instruct(it.get("instruct", ""))
if normalized is None:
return None # unknown/conflicting tokens would crash synthesis
it["instruct"], it["attrs"] = normalized
attrs = it["attrs"]
facets.update({
"gender": None if attrs["Gender"] == "Auto" else attrs["Gender"],
"age": None if attrs["Age"] == "Auto" else attrs["Age"],
"pitch": None if attrs["Pitch"] == "Auto" else attrs["Pitch"],
"accent": None if attrs["EnglishAccent"] == "Auto" else attrs["EnglishAccent"],
"whisper": attrs["Style"] == "whisper",
"lang": it["language"],
})
sample_script = it.get("sample_script")
it["sample_script"] = (
sample_script.strip()[:_MAX_SAMPLE_SCRIPT_CHARS]
if isinstance(sample_script, str) else ""
)
else:
audio = it.get("audio")
if not isinstance(audio, dict) or not _safe_audio_url(audio.get("url", "")):
return None
expected = audio.get("sha256")
if expected is not None:
expected = str(expected).lower()
if not _SHA256_RE.fullmatch(expected):
return None
audio = {**audio, "sha256": expected}
ref_text = audio.get("ref_text")
audio = {
**audio,
"ref_text": (
ref_text.strip()[:_MAX_REF_TEXT_CHARS]
if isinstance(ref_text, str) else ""
),
}
it["audio"] = audio
facets.setdefault("gender", None)
facets.setdefault("age", None)
facets.setdefault("pitch", None)
facets.setdefault("accent", None)
facets.setdefault("whisper", False)
facets.setdefault("lang", it["language"])
it["facets"] = facets
if it["type"] == "preset" and not is_valid_instruct(it.get("instruct", "")):
return None # would crash synthesis — drop it
if it["type"] == "voice" and not _safe_audio_url((it.get("audio") or {}).get("url", "")):
return None
it.setdefault("facets", {})
it.setdefault("icon", archetypes._USE_ICON.get(it["use_case"], "Sparkles"))
it.setdefault("language", it.get("facets", {}).get("lang", "English"))
it["is_community"] = it.get("source") != "starter"
it["preview_url"] = f"/community/items/{it['id']}/preview"
return it
def _merge(manifests: list[tuple[str, Optional[dict]]]) -> tuple[list, list]:
items, packs, seen = [], [], set()
for src, m in manifests:
if not isinstance(m, dict):
if not m:
continue
raw_items = m.get("items")
for raw in raw_items if isinstance(raw_items, list) else []:
for raw in (m.get("items") or []):
v = validate_item(raw)
if v and v["id"] not in seen:
v["_source_repo"] = src
seen.add(v["id"])
items.append(v)
raw_packs = m.get("packs")
for p in raw_packs if isinstance(raw_packs, list) else []:
for p in (m.get("packs") or []):
if isinstance(p, dict):
packs.append({**p, "_source_repo": src})
return items, packs
def _read_manifest_cache(cache: Path) -> Optional[dict]:
try:
if cache.stat().st_size > _MAX_MANIFEST_BYTES:
return None
data = json.loads(cache.read_text(encoding="utf-8"))
return data if isinstance(data, dict) else None
except (OSError, ValueError, TypeError):
return None
def _write_bytes_atomic(path: Path, data: bytes) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=f".{path.name}-", suffix=".part")
try:
with os.fdopen(fd, "wb") as handle:
handle.write(data)
handle.flush()
os.fsync(handle.fileno())
os.replace(tmp, path)
except BaseException:
with contextlib.suppress(OSError):
os.unlink(tmp)
raise
def _fetch_remote_manifest(source: str, *, client=None) -> dict:
"""Fetch one bounded manifest, validating every redirect before request."""
import httpx
if not _SOURCE_RE.fullmatch(source or ""):
raise ValueError("invalid gallery source")
owned_client = client is None
http = client or httpx.Client(timeout=15.0, follow_redirects=False)
current_url = _manifest_url(source)
payload = bytearray()
try:
fetched = False
for _redirect in range(6):
if not _safe_manifest_url(current_url):
raise ValueError("gallery manifest URL is not from an allowed host")
with http.stream("GET", current_url, follow_redirects=False) as response:
if response.status_code in (301, 302, 303, 307, 308):
location = response.headers.get("location")
next_url = urljoin(current_url, location or "")
if not location or not _safe_manifest_url(next_url):
raise ValueError("gallery manifest redirected to a disallowed host")
current_url = next_url
continue
response.raise_for_status()
length = response.headers.get("content-length")
if length:
try:
declared_length = int(length)
except ValueError:
declared_length = None
if declared_length is not None and declared_length > _MAX_MANIFEST_BYTES:
raise ValueError("gallery manifest exceeded the size limit")
for chunk in response.iter_bytes():
if not chunk:
continue
if len(payload) + len(chunk) > _MAX_MANIFEST_BYTES:
raise ValueError("gallery manifest exceeded the size limit")
payload.extend(chunk)
fetched = True
break
if not fetched:
raise ValueError("gallery manifest followed too many redirects")
finally:
if owned_client:
http.close()
if not payload:
raise ValueError("gallery manifest was empty")
data = json.loads(payload)
if not isinstance(data, dict):
raise ValueError("gallery manifest is not a JSON object")
return data
def _fetch_manifest(
source: str, refresh: bool, *, now: Optional[float] = None,
) -> Optional[dict]:
"""Return a fresh manifest, with a throttled stale-cache offline fallback."""
def _fetch_manifest(source: str, refresh: bool) -> Optional[dict]:
"""Return a source's manifest from cache, or fetch + cache it. None if both fail."""
cache = _cache_path(source)
cached = _read_manifest_cache(cache)
checked_at = time.time() if now is None else float(now)
if not refresh and cached is not None:
if not refresh and cache.exists():
try:
if checked_at - cache.stat().st_mtime < _MANIFEST_MAX_AGE_S:
return cached
except OSError:
pass # treat a stat race as stale and try the source once
return json.loads(cache.read_text(encoding="utf-8"))
except Exception:
pass
try:
data = _fetch_remote_manifest(source)
encoded = json.dumps(
data, ensure_ascii=False, separators=(",", ":"),
).encode("utf-8")
if len(encoded) > _MAX_MANIFEST_BYTES:
raise ValueError("gallery manifest exceeded the cache size limit")
_write_bytes_atomic(cache, encoded)
# Tests inject their own clock; production's value equals wall time.
os.utime(cache, (checked_at, checked_at))
import httpx
with httpx.Client(timeout=15.0, follow_redirects=True) as client:
resp = client.get(_manifest_url(source))
resp.raise_for_status()
data = resp.json()
cache.parent.mkdir(parents=True, exist_ok=True)
cache.write_text(json.dumps(data), encoding="utf-8")
return data
except Exception as e: # offline / 404 / bad json
logger.warning("manifest fetch failed for %s: %s", source, e)
if cached is not None:
# This mtime is a last-*check* marker. Advancing it on failure keeps
# an offline app responsive while guaranteeing another check after
# the bounded freshness interval.
with contextlib.suppress(OSError):
os.utime(cache, (checked_at, checked_at))
return cached
if cache.exists():
try:
return json.loads(cache.read_text(encoding="utf-8"))
except Exception:
pass
return None
@@ -432,385 +214,6 @@ def community_submit_url(item_type: str = Query("preset", alias="type"), source:
return {"url": f"https://github.com/{src}/issues/new?template={template}"}
def _find_item(items: list[dict], item_id: str) -> dict:
if not _ITEM_ID_RE.fullmatch(item_id or ""):
raise HTTPException(status_code=404, detail="Item not found in the gallery.")
item = next((it for it in items if it["id"] == item_id), None)
if item is None:
raise HTTPException(status_code=404, detail="Item not found in the gallery.")
return item
def _canonical_archetype(item: dict) -> Optional[dict]:
"""The built-in archetype represented exactly by a marketplace preset."""
if item.get("type") != "preset":
return None
canonical = archetypes.get_archetype(item["id"])
if canonical is None:
return None
if (canonical.get("instruct") != item.get("instruct")
or canonical.get("language") != item.get("language")):
return None
remote_script = (item.get("sample_script") or "").strip()
if remote_script and remote_script != (canonical.get("sample_script") or "").strip():
return None
return canonical
def _preset_preview_path(item: dict) -> Path:
fingerprint = hashlib.sha256(
json.dumps({
"instruct": item.get("instruct"),
"language": item.get("language"),
"sample_script": item.get("sample_script"),
}, sort_keys=True).encode("utf-8")
).hexdigest()[:16]
return _CACHE_DIR / "previews" / f"{item['id']}-{fingerprint}.wav"
def _voice_audio_fingerprint(item: dict) -> str:
audio = item.get("audio") or {}
return hashlib.sha256(
f"{audio.get('url', '')}|{audio.get('sha256', '')}".encode("utf-8")
).hexdigest()[:16]
def _voice_audio_path(item: dict) -> Path:
return _CACHE_DIR / "audio" / f"{item['id']}-{_voice_audio_fingerprint(item)}.wav"
async def _render_preset_atomic(item: dict, out_path: Path) -> Path:
if is_playable_wav(out_path):
return out_path
from api.routers.archetypes import _render_archetype_wav
out_path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp_name = tempfile.mkstemp(dir=str(out_path.parent), prefix=".preview-", suffix=".wav")
os.close(fd)
tmp = Path(tmp_name)
try:
await _render_archetype_wav({
"instruct": item["instruct"],
"language": item.get("language", "English"),
"sample_script": (
(item.get("sample_script") or "").strip()
or "Hello — this is a preview of this voice."
),
}, tmp)
if not is_playable_wav(tmp):
raise RuntimeError("the voice engine produced an invalid preview WAV")
os.replace(tmp, out_path)
return out_path
finally:
with contextlib.suppress(OSError):
tmp.unlink()
def _download_voice_audio(item: dict, out_path: Path, *, client=None) -> None:
"""Stream one allow-listed voice clip into an atomic, size-bounded file."""
audio = item.get("audio") or {}
url = audio.get("url", "")
if not _safe_audio_url(url):
raise HTTPException(status_code=400, detail="Voice audio URL is not from an allowed host.")
import httpx
owned_client = client is None
http = client or httpx.Client(timeout=30.0, follow_redirects=False)
out_path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp_name = tempfile.mkstemp(dir=str(out_path.parent), prefix=".voice-", suffix=".part")
total = 0
digest = hashlib.sha256()
try:
with os.fdopen(fd, "wb") as handle:
current_url = url
downloaded = False
for _redirect in range(6):
with http.stream("GET", current_url, follow_redirects=False) as response:
if response.status_code in (301, 302, 303, 307, 308):
location = response.headers.get("location")
next_url = urljoin(current_url, location or "")
if not location or not _safe_audio_url(next_url):
raise HTTPException(
status_code=502,
detail="Community voice audio redirected to a disallowed host.",
)
current_url = next_url
continue
response.raise_for_status()
length = response.headers.get("content-length")
if length:
try:
if int(length) > _MAX_VOICE_AUDIO_BYTES:
raise HTTPException(
status_code=502,
detail="Community voice audio exceeded the download size limit.",
)
except ValueError:
# A non-numeric Content-Length header is the
# server's problem, not a reason to refuse the
# download — the streamed byte counter below
# still enforces the same cap on what actually
# arrives.
pass
for chunk in response.iter_bytes():
if not chunk:
continue
total += len(chunk)
if total > _MAX_VOICE_AUDIO_BYTES:
raise HTTPException(
status_code=502,
detail="Community voice audio exceeded the download size limit.",
)
digest.update(chunk)
handle.write(chunk)
downloaded = True
break
if not downloaded:
raise HTTPException(
status_code=502,
detail="Community voice audio followed too many redirects.",
)
if total == 0:
raise HTTPException(status_code=502, detail="Community voice audio was empty.")
expected = audio.get("sha256")
if expected and digest.hexdigest() != expected:
raise HTTPException(
status_code=502,
detail="Downloaded voice failed its integrity check.",
)
handle.flush()
os.fsync(handle.fileno())
if not is_playable_wav(Path(tmp_name)):
raise HTTPException(
status_code=502, detail="Community voice audio was not a valid WAV.",
)
os.replace(tmp_name, out_path)
except BaseException:
with contextlib.suppress(OSError):
os.unlink(tmp_name)
raise
finally:
if owned_client:
http.close()
def _cached_voice_audio(item: dict) -> Path:
path = _voice_audio_path(item)
if is_playable_wav(path):
return path
with contextlib.suppress(OSError):
path.unlink()
_download_voice_audio(item, path)
return path
def _copy_atomic(source: Path, destination: Path) -> None:
destination.parent.mkdir(parents=True, exist_ok=True)
fd, tmp_name = tempfile.mkstemp(
dir=str(destination.parent), prefix=f".{destination.name}-", suffix=".part",
)
try:
with os.fdopen(fd, "wb") as out, source.open("rb") as src:
shutil.copyfileobj(src, out)
out.flush()
os.fsync(out.fileno())
os.replace(tmp_name, destination)
except BaseException:
with contextlib.suppress(OSError):
os.unlink(tmp_name)
raise
@router.get("/community/items/{item_id}/preview")
async def community_preview(
item_id: str,
local: bool = Query(False, description="Bypass canonical gallery audio after decode failure"),
):
"""Serve every community preview through the authenticated same-origin API."""
_, items, _, _ = await asyncio.to_thread(_load, False)
item = _find_item(items, item_id)
canonical = _canonical_archetype(item)
if canonical is not None:
# Reuse the signed-gallery/local-render fallback and cache owned by the
# canonical endpoint rather than synthesizing the same preset twice.
# Delegate in-process: a root-relative HTTP redirect drops supported
# reverse-proxy path prefixes such as ``https://host/api``.
from api.routers.archetypes import preview_archetype
return await preview_archetype(canonical["id"], local=local)
try:
if item["type"] == "preset":
path = await _render_preset_atomic(item, _preset_preview_path(item))
else:
path = await asyncio.to_thread(_cached_voice_audio, item)
except HTTPException:
raise
except Exception as exc:
logger.warning("Community preview unavailable (%s)", type(exc).__name__)
raise HTTPException(
status_code=503, detail="This community voice preview is unavailable right now.",
) from exc
return FileResponse(
path, media_type="audio/wav",
headers={"Cache-Control": "no-cache", "X-OmniVoice-Preview-Source": "community"},
)
def _profile_fields(item: dict) -> tuple[str, str, Optional[str], Optional[int]]:
if item["type"] == "preset":
return "design", item["instruct"], json.dumps(item["attrs"]), 42
return "clone", "", None, None
def _community_profile_audio_filename(profile_id: str, item: dict) -> str:
safe_id = (
profile_id if re.fullmatch(r"[A-Za-z0-9_-]{1,64}", profile_id or "")
else hashlib.sha256(str(profile_id).encode("utf-8")).hexdigest()[:16]
)
if item["type"] == "voice":
# The manifest URL/checksum fingerprint makes a changed submission
# invalidate its already-materialized clone without a schema change.
return f"{safe_id}-community-{_voice_audio_fingerprint(item)}.wav"
return f"{safe_id}.wav"
def _stored_profile_audio(ref_audio_path: object) -> Optional[Path]:
return resolve_regular_file(VOICES_DIR, ref_audio_path)
def _community_audio_is_current(row, item: dict, ref_text: str) -> bool:
path = _stored_profile_audio(row["ref_audio_path"])
expected_filename = _community_profile_audio_filename(row["id"], item)
if row["ref_audio_path"] != expected_filename or not is_playable_wav(path):
return False
kind, instruct, _vd_states, seed = _profile_fields(item)
inputs_match = (
row["instruct"] == instruct
and row["language"] == item.get("language", "Auto")
and row["ref_text"] == ref_text
and row["seed"] == seed
)
if not inputs_match:
return False
return True
async def _materialize_item_audio(
item: dict, profile_id: str, *, publish: bool = True,
) -> tuple[str, Path]:
"""Copy the current manifest audio, optionally staging it for a later CAS."""
audio_filename = _community_profile_audio_filename(profile_id, item)
destination = Path(VOICES_DIR) / audio_filename
audio_path = destination
if not publish:
destination.parent.mkdir(parents=True, exist_ok=True)
audio_path = destination.parent / f".{Path(audio_filename).stem}-{uuid.uuid4().hex}.staged.wav"
if item["type"] == "preset":
cached = await _render_preset_atomic(item, _preset_preview_path(item))
else:
cached = await asyncio.to_thread(_cached_voice_audio, item)
await asyncio.to_thread(_copy_atomic, cached, audio_path)
return audio_filename, audio_path
def _community_personality(item: dict) -> str:
source = item.get("_source_repo")
if not isinstance(source, str) or not _SOURCE_RE.fullmatch(source):
source = _DEFAULT_SOURCES[0]
return f"community:{source}:{item['id']}"
def _is_materialized_community_row(row, item: dict) -> bool:
if (
row["personality"] != _community_personality(item)
or row["is_locked"] or row["verified_own_voice"]
):
return False
if item["type"] == "voice":
safe_id = Path(_community_profile_audio_filename(row["id"], item)).name.split(
"-community-", 1,
)[0]
return bool(
row["kind"] == "clone"
and row["seed"] is None
and not row["vd_states"]
and row["instruct"] == ""
and row["language"] == item.get("language", "Auto")
and row["ref_text"] == (item.get("audio") or {}).get("ref_text", "")
and re.fullmatch(
rf"{re.escape(safe_id)}-community-[0-9a-f]{{16}}\.wav",
row["ref_audio_path"] or "",
)
)
try:
states = json.loads(row["vd_states"])
except (TypeError, ValueError):
return False
return bool(
row["kind"] == "design"
and row["seed"] == 42
and row["ref_audio_path"] == _community_profile_audio_filename(row["id"], item)
and row["instruct"] == item["instruct"]
and row["language"] == item.get("language", "Auto")
and row["ref_text"] == (item.get("sample_script") or "")
and states == item["attrs"]
)
def _existing_community_profile(conn, item: dict, personality: str):
candidates = conn.execute(
"SELECT * FROM voice_profiles WHERE personality=? ORDER BY created_at, id",
(personality,),
).fetchall()
existing = next(
(row for row in candidates if _is_materialized_community_row(row, item)), None,
)
if existing is not None:
return existing
# Old builds stored the bare item id. Import formats preserve arbitrary
# personality text too, so adopt only the exact shape the old materializer
# wrote; otherwise a remote item id could rewrite a user's imported voice.
if archetypes.get_archetype(item["id"]) is None:
legacy = conn.execute(
"SELECT * FROM voice_profiles WHERE personality=? LIMIT 1",
(item["id"],),
).fetchone()
if legacy is not None:
kind, instruct, _vd_states, _seed = _profile_fields(item)
ref_text = item.get("sample_script") or (item.get("audio") or {}).get(
"ref_text", "",
)
if (
legacy["ref_audio_path"] == f"{legacy['id']}.wav"
and legacy["kind"] == kind
and legacy["instruct"] == instruct
and legacy["language"] == item.get("language", "Auto")
and legacy["ref_text"] == ref_text
and legacy["seed"] is None
and not legacy["vd_states"]
and not legacy["is_locked"]
and not legacy["verified_own_voice"]
):
return legacy
return None
def _heal_existing_profile(
conn, row, item: dict, ref_text: str, personality: str, audio_filename: str,
) -> None:
kind, instruct, vd_states, seed = _profile_fields(item)
conn.execute(
"UPDATE voice_profiles SET kind=?, instruct=?, vd_states=?, language=?, "
"ref_text=?, seed=?, personality=?, ref_audio_path=? WHERE id=?",
(
kind, instruct, vd_states, item.get("language", "Auto"), ref_text,
seed, personality, audio_filename, row["id"],
),
)
@router.post("/community/items/{item_id}/use")
async def community_use(item_id: str, name: Optional[str] = Query(None)):
"""Materialize a community item into a reusable voice profile.
@@ -820,108 +223,76 @@ async def community_use(item_id: str, name: Optional[str] = Query(None)):
``voice_profiles`` row usable everywhere voices are picked.
"""
_, items, _, _ = await asyncio.to_thread(_load, False)
item = _find_item(items, item_id)
canonical = _canonical_archetype(item)
if canonical is not None:
from api.routers.archetypes import use_archetype
return await use_archetype(canonical["id"], name)
item = next((it for it in items if it["id"] == item_id), None)
if item is None:
raise HTTPException(status_code=404, detail="Item not found in the gallery.")
import time
import uuid
from core import event_bus
from core.db import db_conn
from core.config import VOICES_DIR
ref_text = item.get("sample_script") or (item.get("audio") or {}).get("ref_text", "")
personality = _community_personality(item)
with db_conn() as conn:
existing = _existing_community_profile(conn, item, personality)
profile_id = existing["id"] if existing is not None else str(uuid.uuid4())[:8]
audio_path: Optional[Path] = None
if existing is not None and _community_audio_is_current(existing, item, ref_text):
audio_filename = existing["ref_audio_path"]
else:
try:
audio_filename, audio_path = await _materialize_item_audio(
item, profile_id, publish=existing is None,
)
except HTTPException:
raise
except Exception as e:
logger.error("Community 'use' failed", exc_info=True)
raise HTTPException(
status_code=503, detail="Couldn't add this voice right now.",
) from e
if existing is not None:
with db_conn() as conn:
conn.execute("BEGIN IMMEDIATE")
current = conn.execute(
"SELECT * FROM voice_profiles WHERE id=?", (existing["id"],),
).fetchone()
owned = _existing_community_profile(conn, item, personality)
still_owned = current is not None and (
_is_materialized_community_row(current, item)
or (owned is not None and owned["id"] == current["id"])
)
if still_owned:
if audio_path is not None:
destination = Path(VOICES_DIR) / audio_filename
os.replace(audio_path, destination)
audio_path = None
_heal_existing_profile(
conn, current, item, ref_text, personality, audio_filename,
)
existing_result = {"profile_id": current["id"], "name": current["name"]}
else:
existing_result = None
if existing_result is not None:
event_bus.emit("profiles", {"action": "updated", "id": existing_result["profile_id"]})
return existing_result
profile_id = str(uuid.uuid4())[:8]
audio_filename = _community_profile_audio_filename(profile_id, item)
destination = Path(VOICES_DIR) / audio_filename
if audio_path is None:
audio_filename, audio_path = await _materialize_item_audio(item, profile_id)
else:
os.replace(audio_path, destination)
audio_path = destination
if audio_path is None: # defensive: a new profile always materialized above
raise RuntimeError("new community profile has no materialized audio")
profile_id = str(uuid.uuid4())[:8]
audio_filename = f"{profile_id}.wav"
audio_path = Path(VOICES_DIR) / audio_filename
profile_name = (name or item["name"]).strip() or item["name"]
kind, instruct, vd_states, seed = _profile_fields(item)
instruct = item.get("instruct", "") if item["type"] == "preset" else ""
ref_text = item.get("sample_script") or (item.get("audio") or {}).get("ref_text", "")
try:
if item["type"] == "preset":
from api.routers.archetypes import _render_archetype_wav
pseudo = {
"instruct": instruct,
"language": item.get("language", "English"),
"sample_script": ref_text or "Hello — this is a preview of this voice.",
}
await _render_archetype_wav(pseudo, audio_path)
else: # voice — download the reference clip (off the event loop)
await asyncio.to_thread(_download_voice_audio, item, audio_path)
except HTTPException:
raise
except Exception as e:
logger.error("Community 'use' failed", exc_info=True)
raise HTTPException(status_code=503, detail=f"Couldn't add this voice right now. Error: {e}")
try:
# A community "preset" is a synthetic designed voice (rendered from an
# instruct string) → kind='design'; a "voice" carries a real reference
# clip → kind='clone'. Setting kind makes the persona-gallery
# synthetic-only gating work (§R3) instead of defaulting all imports to
# 'clone'.
kind = "design" if item["type"] == "preset" else "clone"
with db_conn() as conn:
conn.execute("BEGIN IMMEDIATE")
duplicate = _existing_community_profile(conn, item, personality)
if duplicate is not None:
duplicate_audio = duplicate["ref_audio_path"]
if not _community_audio_is_current(duplicate, item, ref_text):
duplicate_audio = _community_profile_audio_filename(duplicate["id"], item)
duplicate_path = Path(VOICES_DIR) / duplicate_audio
_copy_atomic(audio_path, duplicate_path)
_heal_existing_profile(
conn, duplicate, item, ref_text, personality, duplicate_audio,
)
with contextlib.suppress(OSError):
audio_path.unlink()
duplicate_result = {"profile_id": duplicate["id"], "name": duplicate["name"]}
else:
duplicate_result = None
if duplicate_result is None:
conn.execute(
"INSERT INTO voice_profiles "
"(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, "
"created_at, kind, vd_states) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(profile_id, profile_name, audio_filename, ref_text, instruct,
item.get("language", "Auto"), seed, personality, time.time(), kind, vd_states),
)
conn.execute(
"INSERT INTO voice_profiles "
"(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, created_at, kind) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(profile_id, profile_name, audio_filename, ref_text, instruct,
item.get("language", "Auto"), None, item["id"], time.time(), kind),
)
except Exception:
with contextlib.suppress(OSError):
audio_path.unlink()
with __import__("contextlib").suppress(OSError):
os.remove(audio_path)
raise
if duplicate_result is not None:
event_bus.emit("profiles", {"action": "updated", "id": duplicate_result["profile_id"]})
return duplicate_result
event_bus.emit("profiles", {"action": "created", "id": profile_id})
return {"profile_id": profile_id, "name": profile_name}
def _download_voice_audio(item: dict, out_path: Path) -> None:
import hashlib
audio = item.get("audio") or {}
url = audio.get("url", "")
if not _safe_audio_url(url):
raise HTTPException(status_code=400, detail="Voice audio URL is not from an allowed host.")
import httpx
with httpx.Client(timeout=30.0, follow_redirects=True) as client:
resp = client.get(url)
resp.raise_for_status()
data = resp.content
expected = audio.get("sha256")
if expected and hashlib.sha256(data).hexdigest() != expected:
raise HTTPException(status_code=502, detail="Downloaded voice failed its integrity check.")
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_bytes(data)
+15 -15
View File
@@ -41,15 +41,6 @@ _FAMILIES = {
"llm": (llm_backend, "llm_backend"),
}
def _family_payload(family: str, module):
"""Public inventory plus whether an environment pin owns this family."""
return {
"active": module.active_backend_id(),
"env_override": bool(os.environ.get(f"OMNIVOICE_{family.upper()}_BACKEND")),
"backends": public_backends(module.list_backends()),
}
def _is_hf_repo_id(value: str) -> bool:
"""Validate the route's ``owner/repo`` contract in bounded time."""
if not isinstance(value, str) or len(value) > 96 or value.count("/") != 1:
@@ -64,25 +55,34 @@ def _is_hf_repo_id(value: str) -> bool:
@router.get("/engines")
def list_all_engines():
return {
"tts": _family_payload("tts", tts_backend),
"asr": _family_payload("asr", asr_backend),
"llm": _family_payload("llm", llm_backend),
"tts": {
"active": tts_backend.active_backend_id(),
"backends": public_backends(tts_backend.list_backends()),
},
"asr": {
"active": asr_backend.active_backend_id(),
"backends": public_backends(asr_backend.list_backends()),
},
"llm": {
"active": llm_backend.active_backend_id(),
"backends": public_backends(llm_backend.list_backends()),
},
}
@router.get("/engines/tts")
def list_tts_backends():
return _family_payload("tts", tts_backend)
return {"active": tts_backend.active_backend_id(), "backends": public_backends(tts_backend.list_backends())}
@router.get("/engines/asr")
def list_asr_backends():
return _family_payload("asr", asr_backend)
return {"active": asr_backend.active_backend_id(), "backends": public_backends(asr_backend.list_backends())}
@router.get("/engines/llm")
def list_llm_backends():
return _family_payload("llm", llm_backend)
return {"active": llm_backend.active_backend_id(), "backends": public_backends(llm_backend.list_backends())}
@router.get("/engines/effects/presets", response_model=EffectPresetsResponse)
+86 -229
View File
@@ -1,24 +1,18 @@
import asyncio
import contextlib
import json
import logging
import os
import re
import shutil
import tempfile
import time
import json
import uuid
import time
import asyncio
import logging
from typing import Optional, List
from pathlib import Path
from typing import List, Optional
from fastapi import APIRouter, File, Form, UploadFile, HTTPException, Query
from fastapi.responses import FileResponse
from fastapi.responses import FileResponse, RedirectResponse
from pydantic import BaseModel
from core.db import db_conn
from core.config import VOICES_DIR, OUTPUTS_DIR
from core import event_bus
from core.audio_validation import resolve_regular_file
from core.file_cleanup import FileCleanupError, unlink_if_present
from services.ffmpeg_utils import spawn_subprocess
@@ -366,223 +360,46 @@ async def upload_voice_clip(
}
def _stage_profile_audio(source: Path, directory: Path) -> Path:
"""Copy an imported clip to a hidden temp file inside ``directory``.
The temp lives in the destination directory itself so a later
``os.replace`` to the final name is an atomic same-filesystem rename
cheap enough to run while holding a DB write lock, unlike the copy.
Callers own cleanup of the returned path if they never publish it.
"""
directory.mkdir(parents=True, exist_ok=True)
fd, tmp_name = tempfile.mkstemp(
dir=str(directory), prefix=".gallery-import-", suffix=".part",
)
os.close(fd)
try:
shutil.copy2(source, tmp_name)
except BaseException:
with contextlib.suppress(OSError):
os.unlink(tmp_name)
raise
return Path(tmp_name)
def _copy_profile_audio(source: Path, destination: Path) -> None:
"""Copy an imported clip without exposing a partial profile audio file."""
staged = _stage_profile_audio(source, destination.parent)
try:
os.replace(staged, destination)
except BaseException:
with contextlib.suppress(OSError):
os.unlink(staged)
raise
def _gallery_profile_audio_filename(profile_id: str, source: Path) -> str:
"""Return the canonical, portable filename for a My Imports profile."""
safe_id = (
profile_id if re.fullmatch(r"[A-Za-z0-9_-]{1,64}", profile_id or "")
else uuid.uuid5(uuid.NAMESPACE_URL, str(profile_id)).hex[:16]
)
suffix = source.suffix.lower()
if not re.fullmatch(r"\.[a-z0-9]{1,8}", suffix):
suffix = ".wav"
return f"{safe_id}_gallery{suffix}"
def _is_materialized_gallery_profile(row, voice: dict, audio_filename: str) -> bool:
"""Recognize only rows created by this materializer, not identity collisions."""
return bool(
row["personality"] == f"gallery:{voice['id']}"
and row["ref_audio_path"] == audio_filename
and row["ref_text"] == ""
and row["instruct"] == ""
and row["language"] == "Auto"
and row["seed"] is None
and row["kind"] == "clone"
and not row["vd_states"]
and row["description"] == (voice.get("description") or "")
and not row["is_locked"]
and not row["verified_own_voice"]
and not row["locked_audio_path"]
)
def _existing_gallery_profile(conn, voice: dict, source: Path):
personality = f"gallery:{voice['id']}"
rows = conn.execute(
"SELECT * FROM voice_profiles WHERE personality=? ORDER BY created_at, id",
(personality,),
).fetchall()
for row in rows:
expected = _gallery_profile_audio_filename(row["id"], source)
if _is_materialized_gallery_profile(row, voice, expected):
return row
return None
def _gallery_profile_audio_is_current(row, source: Path) -> bool:
"""Detect missing/replaced copies without re-hashing unchanged imports."""
destination = resolve_regular_file(VOICES_DIR, row["ref_audio_path"])
if destination is None:
return False
try:
source_stat = source.stat()
destination_stat = destination.stat()
# copy2 preserves mtime; size + nanosecond mtime catches ordinary edits
# and partial writes while keeping repeated Use clicks inexpensive.
return (
source_stat.st_size == destination_stat.st_size
and source_stat.st_mtime_ns == destination_stat.st_mtime_ns
)
except OSError:
return False
def _materialize_gallery_profile(
voice_id: str, requested_name: Optional[str] = None,
) -> dict:
"""Idempotently materialize/heal one My Imports clip as a clone profile."""
personality = f"gallery:{voice_id}"
copied_path: Optional[Path] = None
created = False
staged_path: Optional[Path] = None
staged_source: Optional[Path] = None
try:
# Stage the (potentially large) audio copy BEFORE taking SQLite's
# write lock: copying inside BEGIN IMMEDIATE would stall every other
# backend writer for the whole copy. The staged temp lives in
# VOICES_DIR itself, so publishing it inside the transaction is an
# atomic same-filesystem os.replace. This pre-read is advisory only —
# the locked transaction below re-reads and re-decides everything.
copy_needed = False
with db_conn() as conn:
pre_row = conn.execute(
"SELECT * FROM voice_gallery WHERE id = ?", (voice_id,),
).fetchone()
if pre_row is not None:
pre_source = Path(pre_row["audio_path"])
if pre_source.is_file():
pre_existing = _existing_gallery_profile(conn, dict(pre_row), pre_source)
copy_needed = pre_existing is None or not _gallery_profile_audio_is_current(
pre_existing, pre_source,
)
if copy_needed:
staged_path = _stage_profile_audio(pre_source, Path(VOICES_DIR))
staged_source = pre_source
with db_conn() as conn:
# The identity is not globally UNIQUE because personality is shared
# with other import mechanisms. Serialize this check+insert in
# SQLite so simultaneous Use clicks cannot both create a row.
conn.execute("BEGIN IMMEDIATE")
row = conn.execute(
"SELECT * FROM voice_gallery WHERE id = ?", (voice_id,),
).fetchone()
if row is None:
raise HTTPException(status_code=404, detail="Voice not found")
voice = dict(row)
source = Path(voice["audio_path"])
if not source.is_file():
raise HTTPException(status_code=404, detail="Audio file not found on disk")
def _install_audio(destination: Path) -> None:
"""Publish the staged copy under the lock via atomic rename."""
nonlocal staged_path
if staged_path is not None and staged_source == source:
os.replace(staged_path, destination)
staged_path = None
else:
# Rare race: the gallery row changed between the advisory
# pre-read and taking the lock, so any staged bytes may be
# from the wrong source. Fall back to the blocking copy
# rather than publish stale audio.
_copy_profile_audio(source, destination)
existing = _existing_gallery_profile(conn, voice, source)
if existing is not None:
ref_filename = _gallery_profile_audio_filename(existing["id"], source)
if not _gallery_profile_audio_is_current(existing, source):
ref_path = Path(VOICES_DIR) / ref_filename
_install_audio(ref_path)
copied_path = ref_path
conn.execute(
"UPDATE voice_profiles SET ref_audio_path=?, ref_text='', instruct='', "
"language='Auto', seed=NULL, description=?, kind='clone', vd_states=NULL, "
"personality=? WHERE id=?",
(
ref_filename, voice["description"] or "", personality,
existing["id"],
),
)
result = {"profile_id": existing["id"], "name": existing["name"]}
else:
profile_id = str(uuid.uuid4())[:8]
profile_name = (requested_name or voice["name"]).strip() or voice["name"]
ref_filename = _gallery_profile_audio_filename(profile_id, source)
copied_path = Path(VOICES_DIR) / ref_filename
_install_audio(copied_path)
conn.execute(
"""INSERT INTO voice_profiles
(id, name, ref_audio_path, ref_text, instruct, language, seed,
personality, is_locked, locked_audio_path, description, kind,
vd_states, created_at)
VALUES (?, ?, ?, '', '', 'Auto', NULL, ?, 0, '', ?, 'clone', NULL, ?)""",
(
profile_id, profile_name, ref_filename, personality,
voice["description"] or "", time.time(),
),
)
created = True
result = {"profile_id": profile_id, "name": profile_name}
except BaseException:
if copied_path is not None:
with contextlib.suppress(OSError):
copied_path.unlink()
raise
finally:
# Staged but never published (failure, or a concurrent request healed
# the profile first) — never leave .part droppings in VOICES_DIR.
if staged_path is not None:
with contextlib.suppress(OSError):
os.unlink(staged_path)
event_bus.emit(
"profiles", {"action": "created" if created else "updated", "id": result["profile_id"]},
)
return result
@router.post("/gallery/voices/{voice_id}/save-as-profile")
async def save_voice_as_profile(
voice_id: str,
profile_name: str = Query(..., description="Name for the voice profile"),
):
"""Save a gallery voice as a voice profile for cloning."""
result = await asyncio.to_thread(_materialize_gallery_profile, voice_id, profile_name)
return {"profile_id": result["profile_id"], "name": result["name"]}
with db_conn() as conn:
row = conn.execute(
"SELECT * FROM voice_gallery WHERE id = ?", (voice_id,)
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Voice not found")
profile_id = str(uuid.uuid4())[:8]
import shutil
ext = os.path.splitext(row["audio_path"])[1]
new_audio_path = os.path.join(VOICES_DIR, f"{profile_id}{ext}")
shutil.copy(row["audio_path"], new_audio_path)
conn.execute(
"""
INSERT INTO voice_profiles (id, name, ref_audio_path, ref_text, instruct, language, seed, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
profile_id,
profile_name,
f"{profile_id}{ext}",
row["description"] or "",
row["character"] or "",
"Auto",
None,
time.time(),
),
)
event_bus.emit("profiles", {"action": "created", "id": profile_id})
return {"profile_id": profile_id, "name": profile_name}
@router.get("/gallery/voices/{voice_id}/preview")
@@ -598,10 +415,22 @@ def preview_voice(voice_id: str):
audio_path = row["audio_path"]
if os.path.isabs(audio_path) and os.path.exists(audio_path):
# Serve the file from this API route so deployments mounted below a
# path prefix do not lose that prefix while following a redirect.
return FileResponse(audio_path)
# Debug logging
is_absolute = os.path.isabs(audio_path)
path_exists = os.path.exists(audio_path) if audio_path else False
# If absolute path, serve directly or redirect
if is_absolute and path_exists:
# Get just the relative path from outputs dir
outputs_path = str(OUTPUTS_DIR)
if audio_path.startswith(outputs_path):
# Remove outputs_dir prefix to get relative path within outputs
rel_path = os.path.relpath(audio_path, outputs_path)
# The audio_path is like: /Users/user4/.../outputs/voice_gallery/file.wav
# rel_path becomes: voice_gallery/file.wav
# We want to serve from /audio/ so: /audio/voice_gallery/file.wav
return RedirectResponse(f"/audio/{rel_path}")
return FileResponse(audio_path, media_type="audio/wav")
raise HTTPException(
status_code=404,
@@ -674,5 +503,33 @@ def batch_delete_voices(body: dict):
@router.post("/gallery/voices/{voice_id}/to-profile")
def voice_to_profile(voice_id: str):
"""Create a voice profile from a gallery clip."""
result = _materialize_gallery_profile(voice_id)
return {"success": True, "profile_id": result["profile_id"], "name": result["name"]}
with db_conn() as conn:
row = conn.execute("SELECT * FROM voice_gallery WHERE id = ?", (voice_id,)).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Voice not found")
voice = dict(row)
audio_path = voice["audio_path"]
if not os.path.exists(audio_path):
raise HTTPException(status_code=404, detail="Audio file not found on disk")
import shutil
import uuid
profile_id = str(uuid.uuid4())[:8]
# Copy audio to voices dir
dest_filename = f"{profile_id}_gallery.wav"
dest_path = os.path.join(VOICES_DIR, dest_filename)
shutil.copy2(audio_path, dest_path)
import time
now = time.time()
conn.execute(
"""INSERT INTO voice_profiles
(id, name, ref_audio_path, ref_text, instruct, seed, is_locked, locked_audio_path, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(profile_id, voice["name"], dest_filename, "", None, None, 0, None, now, now),
)
event_bus.emit("profiles", {"action": "created", "id": profile_id})
return {"success": True, "profile_id": profile_id, "name": voice["name"]}
-33
View File
@@ -1146,9 +1146,6 @@ async def generate_speech(
# classic flow, so streaming is purely a delivery channel — engine-agnostic
# (text-level chunking, no per-engine token streaming).
stream: bool = Form(False),
# Explicit opt-in. The absence of this field preserves the local-first
# /generate contract even when an administrator configured hosted values.
hosted: bool = Form(False),
):
# #502: NFC-normalize the input text so decomposed (NFD) diacritics — common
# in pasted Vietnamese and other Latin-with-marks text — are composed to the
@@ -1159,36 +1156,6 @@ async def generate_speech(
import unicodedata
text = unicodedata.normalize("NFC", text)
if hosted:
# Hosted execution accepts only a previously, explicitly synchronized
# consent-verified profile. Never silently sync a local recording from
# a synthesis request: that would make normal offline use an upload.
if not profile_id:
raise HTTPException(status_code=422, detail="Hosted synthesis requires a synchronized voice profile.")
from services.hosted_voice_api import HostedSettings, HostedVoiceClient, HostedVoiceError
try:
settings = HostedSettings.from_environment()
except HostedVoiceError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
if settings is None:
raise HTTPException(status_code=409, detail="Hosted synthesis is not configured on this device.")
with db_conn() as conn:
profile = conn.execute("SELECT hosted_voice_id, language FROM voice_profiles WHERE id=?", (profile_id,)).fetchone()
if not profile:
raise HTTPException(status_code=404, detail="Voice profile not found")
if not profile["hosted_voice_id"]:
raise HTTPException(status_code=422, detail="Sync this consent-verified profile to hosted before hosted synthesis.")
client = HostedVoiceClient(settings)
try:
audio = await client.synthesize(
text=text, profile_voice_id=profile["hosted_voice_id"], language=language or profile["language"],
)
except HostedVoiceError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
finally:
await client.aclose()
return StreamingResponse(io.BytesIO(audio), media_type="audio/wav", headers={"X-OmniVoice-Execution": "hosted"})
# ── Engine resolution (issue #312) ──────────────────────────────────────
# The request runs on the engine selected in Settings (POST /engines/select,
# env var OMNIVOICE_TTS_BACKEND wins), or an explicit per-request `engine`
-45
View File
@@ -14,7 +14,6 @@ from core import event_bus
from core.personalities import get_personalities
from omnivoice.utils.voice_design import heal_design_instruct, sanitize_instruct
from core.path_security import UnsafePath, resolve_within
from services.hosted_voice_api import HostedSettings, HostedVoiceClient, HostedVoiceError
router = APIRouter()
@@ -185,50 +184,6 @@ def get_profile(profile_id: str):
return dict(row)
@router.post("/profiles/{profile_id}/hosted-sync")
async def sync_profile_to_hosted(profile_id: str):
"""Explicitly copy a consent-verified local clone to the hosted library.
This is deliberately not part of local profile creation: merely creating a
profile must never upload biometric source audio. The hosted service records
the existing spoken-consent evidence as its versioned attestation; it does
not receive the consent recording itself.
"""
try:
settings = HostedSettings.from_environment()
except HostedVoiceError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
if settings is None:
raise HTTPException(status_code=409, detail="Hosted voice sync is not configured on this device.")
with db_conn() as conn:
row = conn.execute(
"SELECT id, name, description, ref_text, ref_audio_path, verified_own_voice, consent_text, hosted_voice_id "
"FROM voice_profiles WHERE id=?", (profile_id,)
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Profile not found")
if row["hosted_voice_id"]:
return {"profile_id": profile_id, "hosted_voice_id": row["hosted_voice_id"], "state": "already_synced"}
if not row["verified_own_voice"] or not row["consent_text"].strip():
raise HTTPException(status_code=422, detail="Record the voice-ownership consent statement before hosted sync.")
reference_path = _voices_path(row["ref_audio_path"] or "")
if not reference_path or not os.path.isfile(reference_path):
raise HTTPException(status_code=422, detail="This profile has no local reference recording to sync.")
client = HostedVoiceClient(settings)
try:
hosted_voice_id = await client.create_voice(
name=row["name"], description=row["description"] or row["ref_text"] or "", reference_path=reference_path,
)
except HostedVoiceError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
finally:
await client.aclose()
with db_conn() as conn:
conn.execute("UPDATE voice_profiles SET hosted_voice_id=? WHERE id=? AND hosted_voice_id=''", (hosted_voice_id, profile_id))
persisted = conn.execute("SELECT hosted_voice_id FROM voice_profiles WHERE id=?", (profile_id,)).fetchone()["hosted_voice_id"]
return {"profile_id": profile_id, "hosted_voice_id": persisted, "state": "synced"}
@router.put("/profiles/{profile_id}")
def update_profile(profile_id: str, patch: ProfileUpdate):
"""Partial update — only fields set on the payload are changed."""
-106
View File
@@ -1,106 +0,0 @@
"""Lightweight validation for persisted profile WAV references.
This module deliberately uses only the standard library. Gallery routers import
it during startup, so pulling in torch/torchaudio merely to validate a cached
file would make every Gallery open pay the model stack's import cost.
"""
from __future__ import annotations
import os
import wave
from pathlib import Path
from typing import Optional
from core.path_security import UnsafePath, resolve_within, safe_filename
_READ_CHUNK_BYTES = 1 << 20
_MAX_CHANNELS = 64
_MAX_SAMPLE_RATE = 768_000
_MAX_SAMPLE_WIDTH = 8
def resolve_regular_file(root: os.PathLike[str] | str, value: object) -> Optional[Path]:
"""Resolve a portable bare filename inside *root*, rejecting symlinks."""
try:
name = safe_filename(value)
unresolved = Path(root).resolve(strict=False) / name
if unresolved.is_symlink():
return None
return resolve_within(root, name)
except (OSError, UnsafePath):
return None
def is_playable_wav(path: Optional[Path]) -> bool:
"""Return true only for a regular, decodable WAV with audio frames."""
if path is None:
return False
try:
if not path.is_file() or path.is_symlink():
return False
file_size = path.stat().st_size
with wave.open(str(path), "rb") as wav:
channels = wav.getnchannels()
sample_rate = wav.getframerate()
sample_width = wav.getsampwidth()
frame_count = wav.getnframes()
if (
not 0 < channels <= _MAX_CHANNELS
or not 0 < sample_rate <= _MAX_SAMPLE_RATE
or not 0 < sample_width <= _MAX_SAMPLE_WIDTH
or frame_count <= 0
):
return False
# ``wave.getnframes`` trusts the header. Read through the declared
# payload so an interrupted write with a complete header but a
# truncated data chunk cannot masquerade as playable audio.
frame_size = channels * sample_width
expected_bytes = frame_count * frame_size
# A PCM payload cannot be larger than the containing file. Check
# before calling ``readframes`` so hostile header values cannot
# turn a tiny file into a multi-gigabyte allocation request.
if expected_bytes > file_size:
return False
read_bytes = 0
chunk_frames = max(1, min(frame_count, _READ_CHUNK_BYTES // frame_size))
while read_bytes < expected_bytes:
chunk = wav.readframes(chunk_frames)
if not chunk or len(chunk) % frame_size:
return False
read_bytes += len(chunk)
return read_bytes == expected_bytes
except (MemoryError, OSError, EOFError, OverflowError, wave.Error):
# Python 3.11's wave module rejects valid IEEE-float/WAVE_EXTENSIBLE
# files. SoundFile is already a runtime dependency and recognizes those
# containers; import it only on the uncommon fallback path.
try:
import soundfile as sf
with sf.SoundFile(str(path)) as audio:
if (
audio.format != "WAV"
or not 0 < audio.channels <= _MAX_CHANNELS
or not 0 < audio.samplerate <= _MAX_SAMPLE_RATE
or len(audio) <= 0
):
return False
remaining = len(audio)
# Decode through the declared payload in byte-bounded chunks;
# ``sf.info`` alone also trusts a truncated file's header.
chunk_frames = max(
1, _READ_CHUNK_BYTES // (audio.channels * 4),
)
while remaining:
frames = audio.read(
min(remaining, chunk_frames), dtype="float32", always_2d=True,
)
count = len(frames)
if count <= 0:
return False
remaining -= count
return True
except Exception:
return False
__all__ = ["is_playable_wav", "resolve_regular_file"]
-421
View File
@@ -1,421 +0,0 @@
"""Canonical authentication identity for HTTP and WebSocket connections.
Transport parsing belongs here; authorization remains in FastAPI dependencies.
Each ASGI scope receives exactly one secret-free :class:`AuthPrincipal` so
middleware and route guards cannot disagree about credential precedence.
"""
from __future__ import annotations
import ipaddress
import importlib
import os
import secrets
from collections.abc import Mapping
from dataclasses import dataclass, field
from enum import Enum
from services.admin_sessions import (
AdminSessionStore,
)
_AUTH_STATE_KEY = "auth_principal"
_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"})
CONSUME_CAPABILITIES = frozenset({"consume"})
ADMIN_CAPABILITIES = frozenset({"consume", "admin"})
LOOPBACK_CAPABILITIES = frozenset({"consume", "admin", "native"})
class PrincipalKind(str, Enum):
ANONYMOUS = "anonymous"
LOOPBACK = "loopback"
TRUSTED_NETWORK = "trusted_network"
PIN = "pin"
API_KEY = "api_key"
ADMIN_SESSION = "admin_session"
class CredentialTransport(str, Enum):
NONE = "none"
HEADER = "header"
QUERY = "query"
COOKIE = "cookie"
LEGACY_COOKIE = "legacy_cookie"
WS_TICKET = "ws_ticket"
@dataclass(frozen=True)
class AuthPrincipal:
kind: PrincipalKind
capabilities: frozenset[str]
credential_id: str | None = None
transport: CredentialTransport = CredentialTransport.NONE
def allows(self, capability: str) -> bool:
return capability in self.capabilities
@dataclass(frozen=True)
class _CredentialCandidate:
value: str = field(repr=False)
transport: CredentialTransport
allow_master: bool = False
allow_session: bool = False
allow_ticket: bool = False
def remote_api_key() -> str | None:
"""Normalized remote operator key, read dynamically for rotation support."""
return os.environ.get("OMNIVOICE_API_KEY", "").strip() or None
def credential_matches(supplied: str | None, configured: str | None) -> bool:
"""Constant-time credential comparison that accepts the full Unicode range."""
if not supplied or not configured:
return False
return secrets.compare_digest(
supplied.encode("utf-8", errors="surrogatepass"),
configured.encode("utf-8", errors="surrogatepass"),
)
def _active_admin_session_store() -> AdminSessionStore:
"""Resolve mutable process state at call time so app reloads cannot split it."""
module = importlib.import_module("services.admin_sessions")
return module.admin_session_store
def _trusted_networks() -> tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, ...]:
networks = []
for value in os.environ.get("OMNIVOICE_TRUSTED_NETWORKS", "").split(","):
value = value.strip()
if not value:
continue
try:
networks.append(ipaddress.ip_network(value, strict=False))
except ValueError:
# Invalid configuration never makes the gate fail open or wedge the
# backend. It simply contributes no trusted range.
continue
return tuple(networks)
def is_loopback(host: str | None) -> bool:
return host in _LOOPBACK_HOSTS
def is_local_host(host: str | None) -> bool:
if is_loopback(host):
return True
try:
address = ipaddress.ip_address(host)
except (TypeError, ValueError):
return False
if getattr(address, "ipv4_mapped", None):
address = address.ipv4_mapped
return any(address in network for network in _trusted_networks())
def _mapping_get(mapping: Mapping[str, str] | object, name: str) -> str:
if not mapping:
return ""
getter = getattr(mapping, "get", None)
if callable(getter):
value = getter(name, "")
if value:
return str(value)
# Real Starlette Headers are case-insensitive. This small fallback keeps
# minimal request stubs and non-Starlette callers correct too.
items = getattr(mapping, "items", None)
if callable(items):
for key, value in items():
if str(key).lower() == name.lower():
return str(value or "")
return ""
def _scope_type(connection) -> str:
scope = getattr(connection, "scope", None)
return str(scope.get("type", "http")) if isinstance(scope, dict) else "http"
def _path(connection) -> str:
scope = getattr(connection, "scope", None)
if isinstance(scope, dict):
return str(scope.get("path", ""))
return str(getattr(connection, "url", "") or "")
def _canonical_websocket_path(connection) -> str:
"""Remove only the ASGI-configured deployment prefix from a WS path."""
path = _path(connection)
scope = getattr(connection, "scope", None)
if not isinstance(scope, dict):
return path
root_path = str(scope.get("root_path", "") or "").rstrip("/")
if not root_path or root_path == "/":
return path
root_path = "/" + root_path.lstrip("/")
if path.startswith(root_path + "/"):
return path[len(root_path) :]
return path
def _client_host(connection) -> str | None:
client = getattr(connection, "client", None)
if client is not None:
return getattr(client, "host", None)
scope = getattr(connection, "scope", None)
if isinstance(scope, dict) and scope.get("client"):
return scope["client"][0]
return None
def _credential_candidate(connection) -> _CredentialCandidate | None:
query = getattr(connection, "query_params", None) or {}
cookies = getattr(connection, "cookies", None) or {}
raw_authorization = authorization_header(connection)
authorization = raw_authorization.strip()
if raw_authorization.lower().startswith("bearer "):
value = raw_authorization[7:].strip()
if value:
return _CredentialCandidate(
value=value,
transport=CredentialTransport.HEADER,
allow_master=True,
allow_session=True,
)
# Preserve the legacy normalization contract: ``Bearer`` followed
# only by whitespace is equivalent to an empty credential channel.
elif authorization:
# Any non-empty explicit Authorization value is authoritative, even
# when its scheme is unsupported or its Bearer payload is missing.
# It must never fall through to a stale ambient cookie.
return _CredentialCandidate(
value=authorization,
transport=CredentialTransport.HEADER,
)
if _scope_type(connection) == "websocket":
ticket = _mapping_get(query, "ws_ticket").strip()
if ticket:
return _CredentialCandidate(
value=ticket,
transport=CredentialTransport.WS_TICKET,
allow_ticket=True,
)
query_key = _mapping_get(query, "api_key").strip()
if query_key:
return _CredentialCandidate(
value=query_key,
transport=CredentialTransport.QUERY,
allow_master=True,
)
session = _mapping_get(cookies, "ov_session").strip()
if session:
return _CredentialCandidate(
value=session,
transport=CredentialTransport.COOKIE,
allow_session=True,
)
legacy_key = _mapping_get(cookies, "ov_key").strip()
if legacy_key:
return _CredentialCandidate(
value=legacy_key,
transport=CredentialTransport.LEGACY_COOKIE,
allow_master=True,
)
return None
def presented_api_key(connection) -> str:
"""Compatibility extractor for the durable API-key transports only."""
candidate = _credential_candidate(connection)
if candidate is None or not candidate.allow_master:
return ""
return candidate.value
def authorization_header(connection) -> str:
headers = getattr(connection, "headers", None) or {}
return _mapping_get(headers, "authorization")
def authorization_credential_present(connection) -> bool:
"""Whether Authorization contains an authoritative credential channel.
This deliberately mirrors :func:`_credential_candidate`: whitespace and
``Bearer`` followed only by spaces are empty channels that may fall back to
legacy migration state. Unsupported schemes and ``Bearer`` without the
required separating space remain explicit invalid credentials.
"""
authorization = authorization_header(connection)
if authorization.lower().startswith("bearer ") and not authorization[7:].strip():
return False
return bool(authorization.strip())
def bearer_header_value(connection) -> str:
authorization = authorization_header(connection)
if not authorization.lower().startswith("bearer "):
return ""
return authorization[7:].strip()
def legacy_master_cookie_valid(connection) -> bool:
configured = remote_api_key()
cookies = getattr(connection, "cookies", None) or {}
supplied = _mapping_get(cookies, "ov_key").strip()
return credential_matches(supplied, configured)
def master_header_valid(connection) -> bool:
configured = remote_api_key()
supplied = bearer_header_value(connection)
return credential_matches(supplied, configured)
def _configured_pin(connection) -> str | None:
app = getattr(connection, "app", None)
state = getattr(app, "state", None) if app is not None else None
network_share = getattr(state, "network_share", None) if state is not None else None
pin = getattr(network_share, "pin", None) if network_share is not None else None
return str(pin) if pin else None
def _valid_pin(connection) -> bool:
configured = _configured_pin(connection)
if not configured:
return False
headers = getattr(connection, "headers", None) or {}
query = getattr(connection, "query_params", None) or {}
cookies = getattr(connection, "cookies", None) or {}
supplied = (
_mapping_get(headers, "x-omnivoice-pin").strip()
or _mapping_get(query, "pin").strip()
or _mapping_get(cookies, "ov_pin").strip()
)
return credential_matches(supplied, configured)
def _attached_principal(connection) -> AuthPrincipal | None:
scope = getattr(connection, "scope", None)
if not isinstance(scope, dict):
return None
state = scope.get("state")
if isinstance(state, dict):
principal = state.get(_AUTH_STATE_KEY)
return principal if isinstance(principal, AuthPrincipal) else None
return None
def _attach_principal(connection, principal: AuthPrincipal) -> AuthPrincipal:
scope = getattr(connection, "scope", None)
if isinstance(scope, dict):
state = scope.setdefault("state", {})
if isinstance(state, dict):
state[_AUTH_STATE_KEY] = principal
return principal
def resolve_principal(
connection,
*,
store: AdminSessionStore | None = None,
) -> AuthPrincipal:
"""Resolve and attach the single authentication decision for one scope."""
attached = _attached_principal(connection)
if attached is not None:
return attached
if store is None:
store = _active_admin_session_store()
host = _client_host(connection)
if is_loopback(host):
return _attach_principal(
connection,
AuthPrincipal(PrincipalKind.LOOPBACK, LOOPBACK_CAPABILITIES),
)
candidate = _credential_candidate(connection)
configured_key = remote_api_key()
if candidate is not None:
principal: AuthPrincipal | None = None
if (
candidate.allow_master
and credential_matches(candidate.value, configured_key)
):
principal = AuthPrincipal(
PrincipalKind.API_KEY,
ADMIN_CAPABILITIES,
credential_id="api-key",
transport=candidate.transport,
)
elif candidate.allow_session:
session = store.resolve(candidate.value, configured_key)
if session is not None:
principal = AuthPrincipal(
PrincipalKind.ADMIN_SESSION,
session.capabilities,
credential_id=session.credential_id,
transport=candidate.transport,
)
elif candidate.allow_ticket:
session = store.consume_ws_ticket(
candidate.value,
_canonical_websocket_path(connection),
configured_key,
)
if session is not None:
principal = AuthPrincipal(
PrincipalKind.ADMIN_SESSION,
session.capabilities,
credential_id=session.credential_id,
transport=candidate.transport,
)
if principal is not None:
return _attach_principal(connection, principal)
# An explicit, non-empty credential is authoritative. Do not silently
# fall back to network or PIN trust after an invalid higher-priority
# credential was presented.
return _attach_principal(
connection,
AuthPrincipal(
PrincipalKind.ANONYMOUS,
frozenset(),
transport=candidate.transport,
),
)
if is_local_host(host):
return _attach_principal(
connection,
AuthPrincipal(PrincipalKind.TRUSTED_NETWORK, CONSUME_CAPABILITIES),
)
if _valid_pin(connection):
return _attach_principal(
connection,
AuthPrincipal(
PrincipalKind.PIN,
CONSUME_CAPABILITIES,
transport=CredentialTransport.HEADER,
),
)
return _attach_principal(
connection,
AuthPrincipal(PrincipalKind.ANONYMOUS, frozenset()),
)
def principal_for(
connection,
*,
store: AdminSessionStore | None = None,
) -> AuthPrincipal:
return _attached_principal(connection) or resolve_principal(connection, store=store)
-140
View File
@@ -1,140 +0,0 @@
"""Exact-origin CSRF checks for ambient browser authentication."""
from __future__ import annotations
import os
from urllib.parse import SplitResult, urlsplit
CSRF_HEADER = "x-voicestudio-csrf"
CSRF_VALUE = "1"
SAFE_HTTP_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
_FORWARDED_PROTO_HEADER = "x-forwarded-proto"
def effective_scheme(connection) -> str:
"""Scheme of the client-facing hop: the resolved scope, TLS-upgraded by proxy evidence.
Behind a TLS-terminating proxy (Tailscale Serve the flagship remote-GPU
deployment in docs/remote-gpu.md nginx, Caddy, ...) the browser talks
``https`` while the backend hop is plain ``http``. uvicorn's
ProxyHeadersMiddleware (on by default in both launch paths: ``uvicorn.run``
in backend/main.py and the Docker ``python -m uvicorn`` entrypoint) already
rewrites the ASGI scope from ``X-Forwarded-Proto``, but only when the peer
is in ``--forwarded-allow-ips`` (default: loopback). That covers Serve on
bare metal, and we prefer that signal the scope is consulted first but
it misses Docker (the proxy connects from the bridge gateway) and any other
non-loopback proxy topology, so the header is honored here as well.
Spoofing analysis why honoring it never weakens a check: the upgrade is
one-way. ``https``/``wss`` as the first forwarded value promotes ``http``
to ``https``; every other value is ignored, so a forged header can never
downgrade a genuine TLS hop. For the exact-origin comparison the host:port
half of the tuple is untouched, a browser cannot attach X-Forwarded-Proto
cross-site without a CORS preflight this API never grants, and a
non-browser client able to forge the header can already forge Origin
itself it gains nothing. For cookies the upgrade can only ADD the Secure
flag (a Secure cookie set over plain http is simply dropped by the
browser the spoofer only breaks their own session), never strip it.
"""
url = getattr(connection, "url", None)
scheme = getattr(url, "scheme", None)
if not scheme:
scope = getattr(connection, "scope", None)
scheme = scope.get("scheme", "http") if isinstance(scope, dict) else "http"
scheme = {"ws": "http", "wss": "https"}.get(scheme, scheme)
if scheme != "https":
headers = getattr(connection, "headers", None) or {}
forwarded = (
headers.get(_FORWARDED_PROTO_HEADER, "") if hasattr(headers, "get") else ""
)
if forwarded.split(",")[0].strip().lower() in {"https", "wss"}:
scheme = "https"
return scheme
def _origin_tuple(value: str | None) -> tuple[str, str, int | None] | None:
if not value or value == "null":
return None
try:
parsed: SplitResult = urlsplit(value)
port = parsed.port
except (TypeError, ValueError):
return None
if (
not parsed.scheme
or not parsed.hostname
or parsed.username is not None
or parsed.password is not None
or parsed.path not in ("", "/")
or parsed.query
or parsed.fragment
):
return None
scheme = parsed.scheme.lower()
if scheme not in {"http", "https", "tauri"}:
return None
if port is None:
if scheme == "http":
port = 80
elif scheme == "https":
port = 443
return scheme, parsed.hostname.lower(), port
def configured_allowed_origins() -> frozenset[tuple[str, str, int | None]]:
raw_port = os.environ.get("OMNIVOICE_UI_PORT", "3901")
try:
ui_port = int(raw_port)
except (TypeError, ValueError):
ui_port = 3901
values = os.environ.get(
"OMNIVOICE_ALLOWED_ORIGINS",
f"http://localhost:{ui_port},http://127.0.0.1:{ui_port},"
"tauri://localhost,http://tauri.localhost",
).split(",")
return frozenset(
origin
for value in values
if (origin := _origin_tuple(value.strip())) is not None
)
def _destination_origin(connection) -> tuple[str, str, int | None] | None:
scheme = effective_scheme(connection)
url = getattr(connection, "url", None)
netloc = getattr(url, "netloc", None)
if netloc:
return _origin_tuple(f"{scheme}://{netloc}")
scope = getattr(connection, "scope", None)
headers = getattr(connection, "headers", None) or {}
if not isinstance(scope, dict):
return None
host = headers.get("host", "") if hasattr(headers, "get") else ""
return _origin_tuple(f"{scheme}://{host}")
def origin_allowed(connection) -> bool:
headers = getattr(connection, "headers", None) or {}
origin_value = headers.get("origin", "") if hasattr(headers, "get") else ""
presented = _origin_tuple(origin_value)
if presented is None:
return False
return presented == _destination_origin(connection) or presented in configured_allowed_origins()
def cookie_csrf_allowed(connection, *, side_effectful_get: bool = False) -> bool:
headers = getattr(connection, "headers", None) or {}
marker = headers.get(CSRF_HEADER, "") if hasattr(headers, "get") else ""
if marker != CSRF_VALUE or not origin_allowed(connection):
return False
method = getattr(connection, "method", None)
if method is None:
scope = getattr(connection, "scope", None)
method = scope.get("method", "GET") if isinstance(scope, dict) else "GET"
method = str(method).upper()
if side_effectful_get or method in SAFE_HTTP_METHODS:
fetch_site = headers.get("sec-fetch-site", "") if hasattr(headers, "get") else ""
return fetch_site == "same-origin"
return True
-3
View File
@@ -57,9 +57,6 @@ _BASE_SCHEMA = """
consent_recorded_at REAL DEFAULT NULL,
kind TEXT DEFAULT 'clone',
vd_states TEXT DEFAULT NULL,
-- Hosted Voice ID is opt-in synchronization metadata. Local synthesis
-- never depends on it, so existing offline profiles remain useful.
hosted_voice_id TEXT DEFAULT '',
created_at REAL
);
CREATE TABLE IF NOT EXISTS generation_history (
-125
View File
@@ -1,125 +0,0 @@
"""Startup progress ledger — what the backend is doing before it can serve.
Why this exists: the project's #1 lifetime failure class is "can't reach the
local backend", and a large slice of it was never a dead backend at all —
just one that couldn't say "I'm starting, currently loading PyTorch" because
nothing listened until every heavy import and migration finished. main.py now
binds the socket early and defers the heavy work; this module is the shared
state the early `/health` + `/startup/progress` endpoints report from while
that work runs.
Thread-safety: the deferred init runs Phase A in an executor thread while the
event loop serves probes, so every mutation and snapshot takes the lock.
"""
from __future__ import annotations
import threading
import time
# Execution order matters only for display; the ledger records whatever order
# steps actually begin in. Keep ids stable — the desktop shell field-sniffs
# them and tests pin them.
STEPS: "dict[str, str]" = {
"env_prefs": "Restoring settings…",
"native_preload": "Preparing GPU libraries…",
"ml_imports": "Loading ML runtime (PyTorch)…",
"api_routes": "Loading API routes…",
"db_migrate": "Preparing database…",
"services_start": "Starting background services…",
}
_lock = threading.Lock()
_t0 = time.monotonic()
_current: "str | None" = None
_done: "list[tuple[str, float]]" = [] # (step_id, seconds it took)
_started_at: float = 0.0
_ready = False
_error: "dict | None" = None
def begin_step(step_id: str) -> None:
global _current, _started_at
with _lock:
_finish_current_locked()
_current = step_id
_started_at = time.monotonic()
def _finish_current_locked() -> None:
global _current
if _current is not None:
_done.append((_current, round(time.monotonic() - _started_at, 2)))
_current = None
def mark_ready() -> None:
global _ready
with _lock:
_finish_current_locked()
_ready = True
def fail(message: str) -> None:
"""Record a startup failure against the step that was running."""
global _error
with _lock:
_error = {"step": _current, "message": str(message)[:500]}
def is_ready() -> bool:
with _lock:
return _ready
def current_step() -> "tuple[str | None, str | None]":
"""(step_id, human label) of the active step, or (None, None)."""
with _lock:
if _current is None:
return None, None
return _current, STEPS.get(_current, _current)
def snapshot() -> dict:
"""The `/startup/progress` body. Always safe to call, never raises."""
with _lock:
if _error is not None:
status = "failed"
elif _ready:
status = "ready"
else:
status = "starting"
states = {sid: "pending" for sid in STEPS}
for sid, _t in _done:
states[sid] = "done"
if _current is not None:
states[_current] = "active"
if _error is not None and _error.get("step"):
states[_error["step"]] = "failed"
durations = dict(_done)
return {
"status": status,
"step": _current,
"label": STEPS.get(_current, _current) if _current else None,
"steps": [
{
"id": sid,
"label": label,
"state": states.get(sid, "pending"),
**({"t": durations[sid]} if sid in durations else {}),
}
for sid, label in STEPS.items()
],
"elapsed_s": round(time.monotonic() - _t0, 2),
"error": _error,
}
def _reset_for_tests() -> None:
global _current, _ready, _error, _started_at
with _lock:
_current = None
_done.clear()
_ready = False
_error = None
_started_at = 0.0
+1 -1
View File
@@ -24,7 +24,7 @@ from pathlib import Path
# tests/test_app_version.py::test_all_version_files_in_lockstep and bumped by
# release.yml's version-bump job, so it stays equal to
# pyproject/tauri.conf/Cargo/package.json.
_FALLBACK_VERSION = "0.5.0"
_FALLBACK_VERSION = "0.4.2"
def _fallback_version() -> str:
+445 -701
View File
File diff suppressed because it is too large Load Diff
@@ -1,30 +0,0 @@
"""Opt-in hosted Voice ID on local profiles.
Revision ID: 0011_hosted_voice_sync
Revises: 0010_remote_worker_schema
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0011_hosted_voice_sync"
down_revision: Union[str, None] = "0010_remote_worker_schema"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _has_column(table: str, column: str) -> bool:
rows = op.get_bind().execute(sa.text(f"PRAGMA table_info({table})")).fetchall()
return any(row[1] == column for row in rows)
def upgrade() -> None:
if not _has_column("voice_profiles", "hosted_voice_id"):
op.add_column("voice_profiles", sa.Column("hosted_voice_id", sa.Text(), nullable=True, server_default=""))
def downgrade() -> None:
if _has_column("voice_profiles", "hosted_voice_id"):
op.drop_column("voice_profiles", "hosted_voice_id")
@@ -1,54 +0,0 @@
"""Mark materialized gallery archetypes as voice-design profiles.
Revision ID: 0012_mark_archetype_profiles_design
Revises: 0011_hosted_voice_sync
Create Date: 2026-08-15 00:00:00.000000
``POST /archetypes/{id}/use`` stores the archetype id in ``personality`` and
also stores a locally rendered identity WAV. That WAV must not make the
profile a clone: the archetype's instruct recipe is authoritative. Older
rows relied on the ``kind='clone'`` default and therefore selected the clone
generation path. This data-only migration fixes every row whose personality
is a current archetype id, leaving unrelated persona and marketplace imports
untouched.
"""
from typing import Sequence, Union
from alembic import op
from sqlalchemy import inspect
revision: str = "0012_mark_archetype_profiles_design"
down_revision: Union[str, None] = "0011_hosted_voice_sync"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
if "voice_profiles" not in inspector.get_table_names():
return
columns = {column["name"] for column in inspector.get_columns("voice_profiles")}
if not {"kind", "personality"}.issubset(columns):
return
# The catalog is intentionally a value object, so checking an id against
# its current generated list is the precise provenance test. The
# parameterized update avoids treating any other personality string as an
# archetype.
from core import archetypes
archetype_ids = [item["id"] for item in archetypes.list_archetypes()]
for archetype_id in archetype_ids:
bind.exec_driver_sql(
"UPDATE voice_profiles SET kind = 'design' "
"WHERE personality = ? AND (kind IS NULL OR kind = '' OR kind = 'clone')",
(archetype_id,),
)
def downgrade() -> None:
# Do not silently convert voice-design profiles back to clones: that would
# reintroduce the generation mismatch for existing user data.
pass
-94
View File
@@ -1,94 +0,0 @@
# VoiceStudio runtime adapter
A local gRPC server implementing the vssaas GPU-node runtime contract
`voicestudio.runtime.v1.RuntimeAdapterService`, so a vssaas GPU Gateway can
drive this VoiceStudio backend as its inference runtime.
## Boundary (deliberate non-capabilities)
- Binds **only** a Unix-domain socket (default `/run/voicestudio/runtime.sock`,
override with `VOICE_STUDIO_RUNTIME_SOCKET`). No HTTP listener, no TCP.
- Never reaches PostgreSQL, customer credentials, or arbitrary network URLs.
`Execute` accepts **local file handles only** — absolute paths generated by
the Gateway; any URL-shaped or relative handle is rejected as invalid input.
- The Gateway owns leases, artifact transfer, retries, and billing. This
adapter owns approved model loading and inference only.
## Running
```sh
# serve (production socket):
VOICE_STUDIO_RUNTIME_SOCKET=/run/voicestudio/runtime.sock \
python -m backend.runtime_adapter
# self-check: starts the server on a private temp socket and validates the
# same expectations the Go preflight (cmd/runtime-adapter-preflight) enforces:
python -m backend.runtime_adapter --selfcheck
```
Environment:
| Variable | Default | Meaning |
| --- | --- | --- |
| `VOICE_STUDIO_RUNTIME_SOCKET` | `/run/voicestudio/runtime.sock` | Unix socket path (must be absolute; parent dir must exist and not be world-writable). |
| `VOICE_STUDIO_RUNTIME_SLOTS` | `1` | Concurrent execution slots per device. |
## Wire contract and generated stubs
`runtime_adapter.proto` is a **byte-identical vendored copy** of the vssaas
contract `api/proto/voicestudio/runtime/v1/runtime_adapter.proto`. Do not edit
it here; re-vendor from vssaas when the contract changes, then regenerate.
The `gen/` stubs are committed (same policy as `backend/worker/protocol/gen/`).
Regenerate with:
```sh
uv run python scripts/gen_runtime_adapter_protocol.py
```
`tests/test_runtime_adapter_gen.py` fails if the committed stubs drift from
the proto.
## Preflight expectations honoured
The Go preflight (`internal/gateway/preflight.go`) fails closed unless:
- the socket path is absolute, a real Unix socket (not a symlink), and its
parent directory is not world-writable — `server.prepare_socket` enforces
the same rules at bind time;
- `Health` returns `SERVING_STATE_READY` with nonempty runtime + adapter
versions, and `GetCapabilities` returns **identical** versions — both
handlers read the same constants, so they cannot disagree;
- at least one device with nonempty id/hardware class, nonzero VRAM and
slots, `free_slots <= total_slots`, unique ids;
- at least one model **explicitly READY** with `catalog_model_id`,
`model_version`, `model_digest`, and ≥1 precision. A loading, installed,
or failed model is reported with its true state and never as READY.
## Model identity
- `catalog_model_id` — the VoiceStudio TTS engine id (`omnivoice`,
`voxcpm2`, …) from `services.tts_backend`'s registry.
- `model_version` — the installed Hugging Face revision (40-char commit SHA)
recorded by `services.hf_revisions` (curated pin or the
`voicestudio-revision` marker).
- `model_digest``sha256:<hex>` computed over the installed snapshot files
(sorted relative path + per-file SHA-256), cached next to the repo cache
keyed by (revision, file list, sizes, mtimes) so multi-GB weights are
hashed once. See `digest.py`.
## Failure taxonomy
Stable codes (prefix `RTA_`) map onto the proto's `RuntimeFailureClass`:
invalid input (`RTA_INPUT_*`), model load (`RTA_MODEL_LOAD_FAILED`),
inference (`RTA_INFERENCE_*`), GPU resource (`RTA_GPU_*`), local storage
(`RTA_STORAGE_*`), cancellation (terminal `ExecutionCanceled`), and adapter
crash (`RTA_RUNTIME_CRASH`). See `codes.py`.
## Tests
```sh
uv run pytest backend/tests/test_runtime_adapter_capabilities.py \
backend/tests/test_runtime_adapter_execute.py \
tests/test_runtime_adapter_gen.py
```
-18
View File
@@ -1,18 +0,0 @@
"""VoiceStudio runtime adapter — the vssaas GPU-node runtime boundary.
Implements ``voicestudio.runtime.v1.RuntimeAdapterService`` over a private
Unix-domain socket so a vssaas GPU Gateway can drive VoiceStudio's TTS
engines as its inference runtime. No HTTP listener, no database access, no
outbound network: the adapter reads and writes only the local file handles
each ``Execute`` request carries. See ``README.md`` in this directory.
"""
from __future__ import annotations
#: Version of this adapter layer (the gRPC boundary), independent of the app
#: version, which is reported as ``runtime_version``. Bump on any behavioral
#: change to the adapter itself.
ADAPTER_VERSION = "0.1.0"
DEFAULT_SOCKET_PATH = "/run/voicestudio/runtime.sock"
SOCKET_ENV = "VOICE_STUDIO_RUNTIME_SOCKET"
SLOTS_ENV = "VOICE_STUDIO_RUNTIME_SLOTS"
-65
View File
@@ -1,65 +0,0 @@
"""Entry point: ``python -m backend.runtime_adapter``.
Serves the runtime adapter on a private Unix-domain socket (default
``/run/voicestudio/runtime.sock``, override ``VOICE_STUDIO_RUNTIME_SOCKET``
or ``--socket``). ``--selfcheck`` instead starts the server on a temp socket
and validates the GPU Gateway preflight expectations against it.
"""
from __future__ import annotations
import argparse
import sys
from ._paths import ensure_backend_on_path
def main(argv: list[str] | None = None) -> int:
ensure_backend_on_path()
parser = argparse.ArgumentParser(
prog="backend.runtime_adapter",
description="VoiceStudio runtime adapter (vssaas GPU-node gRPC server)",
)
parser.add_argument(
"--socket",
default=None,
help="absolute Unix socket path (default: $VOICE_STUDIO_RUNTIME_SOCKET "
"or /run/voicestudio/runtime.sock)",
)
parser.add_argument(
"--selfcheck",
action="store_true",
help="start on a temp socket and validate the preflight expectations",
)
parser.add_argument(
"--timeout",
type=float,
default=10.0,
help="selfcheck RPC timeout in seconds (default: 10)",
)
parser.add_argument(
"--no-prewarm",
action="store_true",
help="serve immediately without loading models first (the first "
"execution then pays weight loading and compilation)",
)
args = parser.parse_args(argv)
if args.selfcheck:
from .selfcheck import selfcheck # noqa: PLC0415
return selfcheck(timeout_s=args.timeout)
from .production import build_runtime_context, prewarm_engines # noqa: PLC0415
from .server import resolve_socket_path, serve # noqa: PLC0415
context = build_runtime_context()
if not args.no_prewarm:
# Deliberately before the socket exists: the Gateway's preflight and
# first offer should both find a runtime that can start inference at
# once, rather than one that spends an attempt lease compiling.
prewarm_engines(context)
return serve(context, resolve_socket_path(args.socket))
if __name__ == "__main__":
sys.exit(main())
-20
View File
@@ -1,20 +0,0 @@
"""Import-path bootstrap for running outside the FastAPI app.
The backend is laid out to run with ``--app-dir backend`` (imports like
``services.tts_backend`` resolve against the ``backend/`` directory). When
the adapter is launched as ``python -m backend.runtime_adapter`` from the
repo root, ``backend/`` is a namespace package but not on ``sys.path`` so
call :func:`ensure_backend_on_path` before any ``services.*`` / ``core.*``
import. Idempotent; mirrors ``backend/tests/conftest.py``.
"""
from __future__ import annotations
import os
import sys
def ensure_backend_on_path() -> str:
backend_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if backend_dir not in sys.path:
sys.path.insert(0, backend_dir)
return backend_dir
-163
View File
@@ -1,163 +0,0 @@
"""Stable failure codes and exception classification for Execute.
The vssaas API Gateway keys retry and customer-charge policy off these codes,
so they are a wire contract: never rename an existing code, only add. Every
code maps to exactly one proto ``RuntimeFailureClass``.
"""
from __future__ import annotations
import re
from .gen import runtime_adapter_pb2 as pb2
# ── invalid approved input ────────────────────────────────────────────────
INPUT_ATTEMPT_IDENTITY = "RTA_INPUT_ATTEMPT_IDENTITY"
INPUT_ATTEMPT_DUPLICATE = "RTA_INPUT_ATTEMPT_DUPLICATE"
INPUT_MODEL_UNKNOWN = "RTA_INPUT_MODEL_UNKNOWN"
INPUT_MODEL_NOT_READY = "RTA_INPUT_MODEL_NOT_READY"
INPUT_MODEL_DIGEST_MISMATCH = "RTA_INPUT_MODEL_DIGEST_MISMATCH"
INPUT_MODEL_PRECISION = "RTA_INPUT_MODEL_PRECISION_UNSUPPORTED"
INPUT_DEVICE_UNKNOWN = "RTA_INPUT_DEVICE_UNKNOWN"
INPUT_HANDLE_INVALID = "RTA_INPUT_HANDLE_INVALID"
INPUT_ARTIFACTS_INVALID = "RTA_INPUT_ARTIFACTS_INVALID"
INPUT_CHECKSUM_MISMATCH = "RTA_INPUT_CHECKSUM_MISMATCH"
INPUT_TEXT_EMPTY = "RTA_INPUT_TEXT_EMPTY"
INPUT_TEXT_TOO_LARGE = "RTA_INPUT_TEXT_TOO_LARGE"
INPUT_TEXT_ENCODING = "RTA_INPUT_TEXT_ENCODING"
INPUT_PARAMETER_UNKNOWN = "RTA_INPUT_PARAMETER_UNKNOWN"
INPUT_PARAMETER_TYPE = "RTA_INPUT_PARAMETER_TYPE"
INPUT_PARAMETER_RANGE = "RTA_INPUT_PARAMETER_RANGE"
INPUT_DEADLINE_INVALID = "RTA_INPUT_DEADLINE_INVALID"
INPUT_REJECTED = "RTA_INPUT_REJECTED" # engine-level TTSInputError
# ── model load / inference ────────────────────────────────────────────────
MODEL_LOAD_FAILED = "RTA_MODEL_LOAD_FAILED"
MODEL_LOAD_DEADLINE = "RTA_MODEL_LOAD_DEADLINE_EXCEEDED"
INFERENCE_FAILED = "RTA_INFERENCE_FAILED"
INFERENCE_BAD_OUTPUT = "RTA_INFERENCE_BAD_OUTPUT"
INFERENCE_DEADLINE = "RTA_INFERENCE_DEADLINE_EXCEEDED"
# ── GPU resource ──────────────────────────────────────────────────────────
GPU_OUT_OF_MEMORY = "RTA_GPU_OUT_OF_MEMORY"
GPU_SLOTS_EXHAUSTED = "RTA_GPU_SLOTS_EXHAUSTED"
# ── local storage ─────────────────────────────────────────────────────────
STORAGE_READ_FAILED = "RTA_STORAGE_READ_FAILED"
STORAGE_WRITE_FAILED = "RTA_STORAGE_WRITE_FAILED"
# ── adapter crash ─────────────────────────────────────────────────────────
RUNTIME_CRASH = "RTA_RUNTIME_CRASH"
_INPUT = pb2.RUNTIME_FAILURE_CLASS_INPUT
_MODEL_LOAD = pb2.RUNTIME_FAILURE_CLASS_MODEL_LOAD
_INFERENCE = pb2.RUNTIME_FAILURE_CLASS_INFERENCE
_GPU = pb2.RUNTIME_FAILURE_CLASS_GPU_RESOURCE
_STORAGE = pb2.RUNTIME_FAILURE_CLASS_LOCAL_STORAGE
_RUNTIME = pb2.RUNTIME_FAILURE_CLASS_RUNTIME
CODE_CLASS: dict[str, int] = {
INPUT_ATTEMPT_IDENTITY: _INPUT,
INPUT_ATTEMPT_DUPLICATE: _INPUT,
INPUT_MODEL_UNKNOWN: _INPUT,
INPUT_MODEL_NOT_READY: _INPUT,
INPUT_MODEL_DIGEST_MISMATCH: _INPUT,
INPUT_MODEL_PRECISION: _INPUT,
INPUT_DEVICE_UNKNOWN: _INPUT,
INPUT_HANDLE_INVALID: _INPUT,
INPUT_ARTIFACTS_INVALID: _INPUT,
INPUT_CHECKSUM_MISMATCH: _INPUT,
INPUT_TEXT_EMPTY: _INPUT,
INPUT_TEXT_TOO_LARGE: _INPUT,
INPUT_TEXT_ENCODING: _INPUT,
INPUT_PARAMETER_UNKNOWN: _INPUT,
INPUT_PARAMETER_TYPE: _INPUT,
INPUT_PARAMETER_RANGE: _INPUT,
INPUT_DEADLINE_INVALID: _INPUT,
INPUT_REJECTED: _INPUT,
MODEL_LOAD_FAILED: _MODEL_LOAD,
MODEL_LOAD_DEADLINE: _MODEL_LOAD,
INFERENCE_FAILED: _INFERENCE,
INFERENCE_BAD_OUTPUT: _INFERENCE,
INFERENCE_DEADLINE: _INFERENCE,
GPU_OUT_OF_MEMORY: _GPU,
GPU_SLOTS_EXHAUSTED: _GPU,
STORAGE_READ_FAILED: _STORAGE,
STORAGE_WRITE_FAILED: _STORAGE,
RUNTIME_CRASH: _RUNTIME,
}
class ExecutionFailure(Exception):
"""A classified, wire-safe execution failure."""
def __init__(self, stable_code: str, safe_detail: str = ""):
if stable_code not in CODE_CLASS: # programming error, not a wire case
raise ValueError(f"unknown stable code {stable_code!r}")
super().__init__(stable_code)
self.stable_code = stable_code
self.failure_class = CODE_CLASS[stable_code]
self.safe_detail = scrub_detail(safe_detail)
_PATHISH = re.compile(r"(?:[A-Za-z]:)?[/\\][^\s'\"]+")
_MAX_DETAIL = 240
def scrub_detail(detail: str) -> str:
"""Bound and de-path a detail string before it crosses the wire.
Local handles are server-generated, but engine exceptions routinely embed
checkpoint paths, cache dirs, and home directories. None of that belongs
in an event the Gateway relays upstream.
"""
scrubbed = _PATHISH.sub("<path>", detail or "").strip()
return scrubbed[:_MAX_DETAIL]
_OOM_MARKERS = (
"out of memory",
"cuda error: out of memory",
"mps backend out of memory",
"hip out of memory",
"cublas_status_alloc_failed",
)
def _is_oom(exc: BaseException) -> bool:
if type(exc).__name__ == "OutOfMemoryError": # torch.cuda.OutOfMemoryError
return True
message = str(exc).lower()
return any(marker in message for marker in _OOM_MARKERS)
def _is_engine_input_error(exc: BaseException) -> bool:
try:
from services.tts_backend import TTSInputError # noqa: PLC0415
except Exception:
return False
return isinstance(exc, TTSInputError)
def classify_engine_error(exc: BaseException, phase: str) -> ExecutionFailure:
"""Map an engine exception to a stable failure code.
``phase`` is ``"model_load"`` or ``"synthesis"`` the phase the engine
thread was in when it raised.
"""
if isinstance(exc, ExecutionFailure):
return exc
detail = f"{type(exc).__name__}: {exc}"
if _is_oom(exc):
return ExecutionFailure(GPU_OUT_OF_MEMORY, detail)
if _is_engine_input_error(exc):
return ExecutionFailure(INPUT_REJECTED, detail)
if isinstance(exc, OSError):
return ExecutionFailure(STORAGE_READ_FAILED, detail)
if phase == "model_load":
return ExecutionFailure(MODEL_LOAD_FAILED, detail)
return ExecutionFailure(INFERENCE_FAILED, detail)
def deadline_failure(phase: str) -> ExecutionFailure:
code = MODEL_LOAD_DEADLINE if phase == "model_load" else INFERENCE_DEADLINE
return ExecutionFailure(code, "attempt deadline exceeded")
-112
View File
@@ -1,112 +0,0 @@
"""Stable digests for locally installed model snapshots.
``model_digest`` in the wire contract pins the exact bytes a READY model will
execute with. Hugging Face snapshots are symlink farms into ``blobs/``, so the
digest is computed over the *resolved* file contents: SHA-256 of the sorted
sequence ``<posix relpath>\\n<file sha256>\\n``. That is stable across hosts,
cache locations, and symlink layout, and changes whenever any weight byte or
the file set changes.
Hashing multi-GB weights on every ``GetCapabilities`` call would be absurd, so
the result is cached in a JSON sidecar keyed by a cheap fingerprint of the
file list (relpath, size, mtime_ns). Any file change invalidates the cache and
forces a full re-hash.
"""
from __future__ import annotations
import hashlib
import json
import os
from pathlib import Path
DIGEST_PREFIX = "sha256:"
_CHUNK = 1024 * 1024
def file_sha256(path: str | os.PathLike[str]) -> str:
hasher = hashlib.sha256()
with open(path, "rb") as fh:
while True:
chunk = fh.read(_CHUNK)
if not chunk:
break
hasher.update(chunk)
return hasher.hexdigest()
def _manifest(root: Path) -> list[tuple[str, int, int]]:
"""Sorted (relpath, size, mtime_ns) for every regular file under root.
Follows symlinks (HF snapshot layout); a dangling symlink raises
``FileNotFoundError`` callers treat that as an incomplete install.
"""
entries: list[tuple[str, int, int]] = []
for current, dirs, files in os.walk(root, followlinks=True):
dirs.sort()
for name in sorted(files):
path = Path(current) / name
stat = path.stat() # resolves symlinks; raises if dangling
rel = path.relative_to(root).as_posix()
entries.append((rel, stat.st_size, stat.st_mtime_ns))
entries.sort()
return entries
def _fingerprint(entries: list[tuple[str, int, int]]) -> str:
return hashlib.sha256(
json.dumps(entries, separators=(",", ":")).encode("utf-8")
).hexdigest()
def snapshot_digest(root: str | os.PathLike[str], cache_path: str | os.PathLike[str] | None = None) -> str:
"""``sha256:<hex>`` digest of the snapshot at ``root``.
Raises ``FileNotFoundError`` for a missing/empty snapshot or dangling
symlink and ``OSError`` for unreadable files callers classify those as
not-READY rather than fabricating a digest.
"""
root = Path(root)
entries = _manifest(root)
if not entries:
raise FileNotFoundError(f"empty model snapshot: {root}")
fingerprint = _fingerprint(entries)
if cache_path is not None:
cached = _read_cache(cache_path)
if cached is not None and cached.get("fingerprint") == fingerprint:
digest = cached.get("digest", "")
if isinstance(digest, str) and digest.startswith(DIGEST_PREFIX):
return digest
hasher = hashlib.sha256()
for rel, _size, _mtime in entries:
hasher.update(rel.encode("utf-8"))
hasher.update(b"\n")
hasher.update(file_sha256(root / rel).encode("ascii"))
hasher.update(b"\n")
digest = DIGEST_PREFIX + hasher.hexdigest()
if cache_path is not None:
_write_cache(cache_path, fingerprint, digest)
return digest
def _read_cache(cache_path: str | os.PathLike[str]) -> dict | None:
try:
with open(cache_path, encoding="utf-8") as fh:
data = json.load(fh)
return data if isinstance(data, dict) else None
except (OSError, ValueError):
return None
def _write_cache(cache_path: str | os.PathLike[str], fingerprint: str, digest: str) -> None:
cache_path = Path(cache_path)
payload = json.dumps({"fingerprint": fingerprint, "digest": digest})
try:
cache_path.parent.mkdir(parents=True, exist_ok=True)
temporary = cache_path.with_suffix(f".tmp-{os.getpid()}")
temporary.write_text(payload, encoding="utf-8")
os.replace(temporary, cache_path)
except OSError:
pass # cache is an optimization; the digest itself is already computed
-639
View File
@@ -1,639 +0,0 @@
"""Execute/Cancel: attempt registry, validation, and the event stream.
One ``Execute`` call is one *attempt*. The generator emits::
started progress* exactly one of completed | failed | canceled
The engine call itself (``ensure_ready`` + ``generate``) runs on a daemon
worker thread; the streaming generator polls it, emitting bounded heartbeat
progress and enforcing the request deadline and cancellation. A blocking
engine cannot be interrupted mid-kernel, so on cancel/deadline the thread is
abandoned and its result discarded the terminal event is what the Gateway
acts on, and slot accounting is released only when the thread actually exits.
The adapter never turns a customer string into a filesystem path: it touches
exactly the local handles the request carries, after validation.
"""
from __future__ import annotations
import os
import threading
import time
from collections import OrderedDict
from dataclasses import dataclass, field
from . import codes
from ._paths import ensure_backend_on_path
from .digest import file_sha256
from .gen import runtime_adapter_pb2 as pb2
from .inventory import STATE_READY
_MAX_TEXT_BYTES = 512_000
_MAX_REF_AUDIO_BYTES = 100 * 1024 * 1024
_MAX_DEADLINE_S = 24 * 3600.0
_MAX_PROGRESS_EVENTS = 512
#: Typed, bounded Execute parameters → the engine ``generate()`` kwarg of the
#: same name. Kinds: ("string", max_len) / ("integer", lo, hi) /
#: ("number", lo, hi) / ("boolean",).
PARAMETER_SPECS: dict[str, tuple] = {
"language": ("string", 32),
"ref_text": ("string", 4096),
"instruct": ("string", 2048),
"description": ("string", 2048),
"speed": ("number", 0.25, 4.0),
"guidance_scale": ("number", 0.0, 16.0),
"num_step": ("integer", 1, 128),
}
# ── attempt registry ──────────────────────────────────────────────────────
@dataclass
class AttemptRecord:
job_id: str
attempt_id: str
cancel: threading.Event = field(default_factory=threading.Event)
terminal: str | None = None # "completed" | "failed" | "canceled"
class AttemptRegistry:
"""Attempt bookkeeping: admission, idempotent cancel, bounded history."""
def __init__(self, max_terminal: int = 4096):
self._lock = threading.Lock()
self._active: dict[str, AttemptRecord] = {}
self._terminal: OrderedDict[str, AttemptRecord] = OrderedDict()
self._max_terminal = max_terminal
def begin(self, job_id: str, attempt_id: str, slot_limit: int) -> AttemptRecord:
with self._lock:
if attempt_id in self._active or attempt_id in self._terminal:
raise codes.ExecutionFailure(
codes.INPUT_ATTEMPT_DUPLICATE, "attempt id already used"
)
if len(self._active) >= max(1, slot_limit):
raise codes.ExecutionFailure(
codes.GPU_SLOTS_EXHAUSTED, "no free execution slot"
)
record = AttemptRecord(job_id=job_id, attempt_id=attempt_id)
self._active[attempt_id] = record
return record
def finish(self, attempt_id: str, terminal: str) -> None:
with self._lock:
record = self._active.pop(attempt_id, None)
if record is None:
return
record.terminal = terminal
self._terminal[attempt_id] = record
while len(self._terminal) > self._max_terminal:
self._terminal.popitem(last=False)
def active_count(self) -> int:
with self._lock:
return len(self._active)
def cancel(self, job_id: str, attempt_id: str) -> int:
"""Idempotent by attempt id; returns a proto CancelDisposition."""
with self._lock:
record = self._active.get(attempt_id)
if record is not None:
if job_id and record.job_id and job_id != record.job_id:
return pb2.CANCEL_DISPOSITION_NOT_FOUND
record.cancel.set()
return pb2.CANCEL_DISPOSITION_ACCEPTED
record = self._terminal.get(attempt_id)
if record is not None:
if job_id and record.job_id and job_id != record.job_id:
return pb2.CANCEL_DISPOSITION_NOT_FOUND
return pb2.CANCEL_DISPOSITION_ALREADY_TERMINAL
return pb2.CANCEL_DISPOSITION_NOT_FOUND
# ── request validation ────────────────────────────────────────────────────
@dataclass
class ValidatedRequest:
text: str
output_handle: str
output_media_type: str
output_size_bound: int
engine_kwargs: dict
deadline_monotonic: float
catalog_model_id: str
def _validate_handle(handle: str, code: str = codes.INPUT_HANDLE_INVALID) -> str:
cleaned = (handle or "").strip()
if (
not cleaned
or "\x00" in cleaned
or "://" in cleaned
or not os.path.isabs(cleaned)
or os.path.normpath(cleaned) != cleaned
):
raise codes.ExecutionFailure(code, "local handle must be an absolute path")
return cleaned
def _read_input_file(artifact, max_bytes: int) -> bytes:
path = _validate_handle(artifact.local_handle)
try:
stat = os.lstat(path)
except OSError as exc:
raise codes.ExecutionFailure(
codes.STORAGE_READ_FAILED, f"input handle unreadable: {type(exc).__name__}"
)
import stat as stat_module # noqa: PLC0415
if not stat_module.S_ISREG(stat.st_mode):
raise codes.ExecutionFailure(
codes.INPUT_HANDLE_INVALID, "input handle must be a regular file"
)
bound = max_bytes
if 0 < artifact.expected_size_bytes <= max_bytes:
bound = artifact.expected_size_bytes
if stat.st_size > bound:
raise codes.ExecutionFailure(
codes.INPUT_TEXT_TOO_LARGE, "input exceeds its size bound"
)
try:
with open(path, "rb") as fh:
data = fh.read(bound + 1)
except OSError as exc:
raise codes.ExecutionFailure(
codes.STORAGE_READ_FAILED, f"input read failed: {type(exc).__name__}"
)
if len(data) > bound:
raise codes.ExecutionFailure(
codes.INPUT_TEXT_TOO_LARGE, "input exceeds its size bound"
)
expected = (artifact.expected_sha256 or "").strip().lower().removeprefix("sha256:")
if expected:
import hashlib # noqa: PLC0415
if hashlib.sha256(data).hexdigest() != expected:
raise codes.ExecutionFailure(
codes.INPUT_CHECKSUM_MISMATCH, "input checksum mismatch"
)
return data
def _typed_parameter(name: str, value) -> object:
spec = PARAMETER_SPECS.get(name)
if spec is None:
raise codes.ExecutionFailure(
codes.INPUT_PARAMETER_UNKNOWN, f"unknown parameter {name!r}"
)
kind = spec[0]
which = value.WhichOneof("value")
if kind == "string":
if which != "string_value":
raise codes.ExecutionFailure(
codes.INPUT_PARAMETER_TYPE, f"parameter {name!r} must be a string"
)
text = value.string_value
if len(text) > spec[1]:
raise codes.ExecutionFailure(
codes.INPUT_PARAMETER_RANGE, f"parameter {name!r} too long"
)
return text
if kind == "integer":
if which != "integer_value":
raise codes.ExecutionFailure(
codes.INPUT_PARAMETER_TYPE, f"parameter {name!r} must be an integer"
)
number = value.integer_value
if not spec[1] <= number <= spec[2]:
raise codes.ExecutionFailure(
codes.INPUT_PARAMETER_RANGE, f"parameter {name!r} out of range"
)
return int(number)
if kind == "number":
if which == "number_value":
number = value.number_value
elif which == "integer_value":
number = float(value.integer_value)
else:
raise codes.ExecutionFailure(
codes.INPUT_PARAMETER_TYPE, f"parameter {name!r} must be a number"
)
if not spec[1] <= number <= spec[2]:
raise codes.ExecutionFailure(
codes.INPUT_PARAMETER_RANGE, f"parameter {name!r} out of range"
)
return float(number)
if which != "boolean_value":
raise codes.ExecutionFailure(
codes.INPUT_PARAMETER_TYPE, f"parameter {name!r} must be a boolean"
)
return bool(value.boolean_value)
# ── the executor ──────────────────────────────────────────────────────────
class Executor:
"""Validates and runs attempts against an inventory + engine provider."""
def __init__(
self,
inventory,
engine_provider,
registry: AttemptRegistry,
*,
slot_limit: int = 1,
progress_interval: float = 0.5,
poll_interval: float = 0.02,
clock=time.monotonic,
):
self._inventory = inventory
self._engine_provider = engine_provider
self._registry = registry
self._slot_limit = max(1, slot_limit)
self._progress_interval = progress_interval
self._poll_interval = poll_interval
self._clock = clock
# -- validation ----------------------------------------------------
def _validate(self, request) -> ValidatedRequest:
now_ms = int(time.time() * 1000)
if request.deadline_unix_ms <= now_ms:
raise codes.ExecutionFailure(
codes.INPUT_DEADLINE_INVALID, "deadline is not in the future"
)
budget_s = min((request.deadline_unix_ms - now_ms) / 1000.0, _MAX_DEADLINE_S)
model = self._validate_model(request.model)
self._validate_device(request.device_id)
text_artifact, ref_artifact = self._split_inputs(request.inputs)
output = self._single_output(request.outputs)
output_handle = _validate_handle(output.local_handle)
parent = os.path.dirname(output_handle)
if not os.path.isdir(parent):
raise codes.ExecutionFailure(
codes.INPUT_HANDLE_INVALID, "output handle directory does not exist"
)
raw = _read_input_file(text_artifact, _MAX_TEXT_BYTES)
try:
text = raw.decode("utf-8").strip()
except UnicodeDecodeError:
raise codes.ExecutionFailure(
codes.INPUT_TEXT_ENCODING, "input text is not valid UTF-8"
)
if not text:
raise codes.ExecutionFailure(codes.INPUT_TEXT_EMPTY, "input text is empty")
engine_kwargs: dict = {}
for name in sorted(request.parameters):
engine_kwargs[name] = _typed_parameter(name, request.parameters[name])
if ref_artifact is not None:
_read_input_file(ref_artifact, _MAX_REF_AUDIO_BYTES) # existence/bounds/checksum
engine_kwargs["ref_audio"] = _validate_handle(ref_artifact.local_handle)
return ValidatedRequest(
text=text,
output_handle=output_handle,
output_media_type=output.media_type or "audio/wav",
output_size_bound=int(output.expected_size_bytes),
engine_kwargs=engine_kwargs,
deadline_monotonic=self._clock() + budget_s,
catalog_model_id=request.model.catalog_model_id,
)
def _validate_model(self, spec):
wanted = (spec.catalog_model_id or "").strip()
if not wanted:
raise codes.ExecutionFailure(
codes.INPUT_MODEL_UNKNOWN, "catalog model id is required"
)
matches = [
model
for model in self._inventory.models()
if model.catalog_model_id == wanted
]
if not matches:
raise codes.ExecutionFailure(codes.INPUT_MODEL_UNKNOWN, "model not present")
model = matches[0]
if model.state != STATE_READY:
raise codes.ExecutionFailure(
codes.INPUT_MODEL_NOT_READY, "model is not READY"
)
if spec.model_version and spec.model_version != model.model_version:
raise codes.ExecutionFailure(
codes.INPUT_MODEL_UNKNOWN, "model version mismatch"
)
if not spec.model_digest or spec.model_digest != model.model_digest:
raise codes.ExecutionFailure(
codes.INPUT_MODEL_DIGEST_MISMATCH, "approved model digest mismatch"
)
if spec.precision and spec.precision not in model.precisions:
raise codes.ExecutionFailure(
codes.INPUT_MODEL_PRECISION, "precision not offered by this model"
)
return model
def _validate_device(self, device_id: str) -> None:
wanted = (device_id or "").strip()
if not wanted:
raise codes.ExecutionFailure(
codes.INPUT_DEVICE_UNKNOWN, "device id is required"
)
known = {device.device_id for device in self._inventory.devices()}
if wanted not in known:
raise codes.ExecutionFailure(
codes.INPUT_DEVICE_UNKNOWN, "device id not in inventory"
)
@staticmethod
def _split_inputs(inputs):
text_artifacts, audio_artifacts = [], []
for artifact in inputs:
if artifact.operation != pb2.LOCAL_ARTIFACT_OPERATION_READ:
raise codes.ExecutionFailure(
codes.INPUT_ARTIFACTS_INVALID, "inputs must be READ artifacts"
)
media = artifact.media_type or ""
if media.startswith("audio/"):
audio_artifacts.append(artifact)
elif media == "" or media.startswith("text/"):
text_artifacts.append(artifact)
else:
raise codes.ExecutionFailure(
codes.INPUT_ARTIFACTS_INVALID, f"unsupported input media {media!r}"
)
if len(text_artifacts) != 1 or len(audio_artifacts) > 1:
raise codes.ExecutionFailure(
codes.INPUT_ARTIFACTS_INVALID,
"tts needs exactly one text input and at most one reference audio",
)
return text_artifacts[0], (audio_artifacts[0] if audio_artifacts else None)
@staticmethod
def _single_output(outputs):
if len(outputs) != 1:
raise codes.ExecutionFailure(
codes.INPUT_ARTIFACTS_INVALID, "tts needs exactly one output artifact"
)
output = outputs[0]
if output.operation != pb2.LOCAL_ARTIFACT_OPERATION_WRITE:
raise codes.ExecutionFailure(
codes.INPUT_ARTIFACTS_INVALID, "output must be a WRITE artifact"
)
media = output.media_type or ""
if media and not media.startswith("audio/"):
raise codes.ExecutionFailure(
codes.INPUT_ARTIFACTS_INVALID, f"unsupported output media {media!r}"
)
return output
# -- execution -----------------------------------------------------
def execute(self, request, grpc_context=None):
"""Generator of ``pb2.ExecuteResponse``. Never raises for a
classified failure failures become terminal events."""
session = _Session(self, request)
return session.run(grpc_context)
class _Session:
def __init__(self, executor: Executor, request):
self._x = executor
self.request = request
self.job_id = request.job_id
self.attempt_id = request.attempt_id
self.sequence = 0
self.phase = "model_load"
self.terminal_sent = False
self.chars = 0
self.gpu_ms = 0
self.cpu_ms = 0
self.output_audio_ms = 0
# event builders ---------------------------------------------------
def _event(self, **payload):
self.sequence += 1
return pb2.ExecuteResponse(
event=pb2.ExecutionEvent(
job_id=self.job_id,
attempt_id=self.attempt_id,
sequence=self.sequence,
observed_at_unix_ms=int(time.time() * 1000),
**payload,
)
)
def _measurements(self):
return pb2.RuntimeMeasurements(
normalized_input_characters=self.chars,
output_audio_ms=self.output_audio_ms,
gpu_execution_ms=self.gpu_ms,
cpu_execution_ms=self.cpu_ms,
)
def _failed(self, failure: codes.ExecutionFailure):
self.terminal_sent = True
return self._event(
failed=pb2.ExecutionFailed(
failure_class=failure.failure_class,
stable_code=failure.stable_code,
safe_detail=failure.safe_detail,
measurements=self._measurements(),
)
)
def _canceled(self):
self.terminal_sent = True
return self._event(
canceled=pb2.ExecutionCanceled(measurements=self._measurements())
)
# main flow --------------------------------------------------------
def run(self, grpc_context):
if not self.attempt_id.strip() or not self.job_id.strip():
yield self._failed(
codes.ExecutionFailure(
codes.INPUT_ATTEMPT_IDENTITY, "job and attempt ids are required"
)
)
return
registry = self._x._registry
try:
record = registry.begin(self.job_id, self.attempt_id, self._x._slot_limit)
except codes.ExecutionFailure as failure:
yield self._failed(failure)
return
try:
yield from self._run_admitted(record, grpc_context)
finally:
terminal = "canceled"
if self.terminal_sent:
terminal = self._terminal_kind or "failed"
registry.finish(self.attempt_id, terminal)
_terminal_kind: str | None = None
def _run_admitted(self, record, grpc_context):
try:
validated = self._x._validate(self.request)
except codes.ExecutionFailure as failure:
self._terminal_kind = "failed"
yield self._failed(failure)
return
except Exception as exc: # adapter bug — still a classified event
self._terminal_kind = "failed"
yield self._failed(
codes.ExecutionFailure(codes.RUNTIME_CRASH, f"{type(exc).__name__}")
)
return
self.chars = len(validated.text)
yield self._event(started=pb2.ExecutionStarted())
worker = _EngineWorker(self._x._engine_provider, validated, self)
worker.start()
clock = self._x._clock
next_progress = clock() + self._x._progress_interval
progress_events = 0
while not worker.done.wait(self._x._poll_interval):
if record.cancel.is_set() or (
grpc_context is not None and not grpc_context.is_active()
):
self._terminal_kind = "canceled"
yield self._canceled()
return
now = clock()
if now >= validated.deadline_monotonic:
self._terminal_kind = "failed"
yield self._failed(codes.deadline_failure(self.phase))
return
if now >= next_progress and progress_events < _MAX_PROGRESS_EVENTS:
progress_events += 1
next_progress = now + self._x._progress_interval
permille = 100 if self.phase == "model_load" else 550
yield self._event(
progress=pb2.ExecutionProgress(
progress_permille=permille, stage_code=self.phase
)
)
if record.cancel.is_set():
self._terminal_kind = "canceled"
yield self._canceled()
return
if worker.error is not None:
self._terminal_kind = "failed"
yield self._failed(codes.classify_engine_error(worker.error, worker.phase))
return
try:
manifest = self._write_output(worker, validated)
except codes.ExecutionFailure as failure:
self._terminal_kind = "failed"
yield self._failed(failure)
return
self._terminal_kind = "completed"
self.terminal_sent = True
yield self._event(
completed=pb2.ExecutionCompleted(
outputs=[manifest], measurements=self._measurements()
)
)
def _write_output(self, worker, validated: ValidatedRequest):
ensure_backend_on_path()
tensor = worker.result
sample_rate = worker.sample_rate
if tensor is None or not hasattr(tensor, "numel") or tensor.numel() == 0:
raise codes.ExecutionFailure(
codes.INFERENCE_BAD_OUTPUT, "engine returned no audio"
)
if not isinstance(sample_rate, int) or sample_rate <= 0:
raise codes.ExecutionFailure(
codes.INFERENCE_BAD_OUTPUT, "engine reported no sample rate"
)
try:
from services.audio_io import atomic_save_wav # noqa: PLC0415
atomic_save_wav(validated.output_handle, tensor.detach().cpu(), sample_rate)
except codes.ExecutionFailure:
raise
except Exception as exc:
raise codes.ExecutionFailure(
codes.STORAGE_WRITE_FAILED, f"{type(exc).__name__}: {exc}"
)
try:
size = os.stat(validated.output_handle).st_size
sha = file_sha256(validated.output_handle)
except OSError as exc:
raise codes.ExecutionFailure(
codes.STORAGE_WRITE_FAILED, f"{type(exc).__name__}"
)
if 0 < validated.output_size_bound < size:
raise codes.ExecutionFailure(
codes.STORAGE_WRITE_FAILED, "output exceeds its size bound"
)
samples = tensor.numel() if tensor.dim() == 1 else tensor.shape[-1]
self.output_audio_ms = int(samples * 1000 / sample_rate)
return pb2.LocalArtifactManifest(
artifact_id=self.request.outputs[0].artifact_id,
local_handle=validated.output_handle,
size_bytes=size,
sha256=sha,
media_type=validated.output_media_type,
duration_ms=self.output_audio_ms,
)
class _EngineWorker:
"""Runs the engine on a daemon thread, recording phase and timings."""
def __init__(self, engine_provider, validated: ValidatedRequest, session: _Session):
self._engine_provider = engine_provider
self._validated = validated
self._session = session
self.done = threading.Event()
self.error: BaseException | None = None
self.result = None
self.sample_rate: int | None = None
self.phase = "model_load"
def start(self) -> None:
thread = threading.Thread(
target=self._run,
name=f"runtime-adapter-attempt-{self._session.attempt_id}",
daemon=True,
)
thread.start()
def _run(self) -> None:
wall_start = time.monotonic()
cpu_start = time.process_time()
try:
engine = self._engine_provider(self._validated.catalog_model_id)
ensure_ready = getattr(engine, "ensure_ready", None)
if callable(ensure_ready):
ensure_ready()
self.phase = "synthesis"
self._session.phase = "synthesis"
synth_start = time.monotonic()
self.result = engine.generate(self._validated.text, **self._validated.engine_kwargs)
rate = getattr(engine, "sample_rate", None)
self.sample_rate = int(rate) if isinstance(rate, (int, float)) and rate else None
self._session.gpu_ms = int((time.monotonic() - synth_start) * 1000)
except BaseException as exc: # classified later, never lost
self.error = exc
finally:
self._session.cpu_ms = int((time.process_time() - cpu_start) * 1000)
if self._session.gpu_ms == 0 and self.error is None:
self._session.gpu_ms = int((time.monotonic() - wall_start) * 1000)
self.done.set()
-5
View File
@@ -1,5 +0,0 @@
"""Generated protocol stubs — DO NOT EDIT.
Regenerate with ``uv run python scripts/gen_runtime_adapter_protocol.py``
after any change to ``../runtime_adapter.proto``.
"""
File diff suppressed because one or more lines are too long
@@ -1,330 +0,0 @@
from google.protobuf.internal import containers as _containers
from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper
from google.protobuf import descriptor as _descriptor
from google.protobuf import message as _message
from collections.abc import Iterable as _Iterable, Mapping as _Mapping
from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union
DESCRIPTOR: _descriptor.FileDescriptor
class ServingState(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
__slots__ = ()
SERVING_STATE_UNSPECIFIED: _ClassVar[ServingState]
SERVING_STATE_READY: _ClassVar[ServingState]
SERVING_STATE_DEGRADED: _ClassVar[ServingState]
SERVING_STATE_UNHEALTHY: _ClassVar[ServingState]
class RuntimeModelState(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
__slots__ = ()
RUNTIME_MODEL_STATE_UNSPECIFIED: _ClassVar[RuntimeModelState]
RUNTIME_MODEL_STATE_INSTALLED: _ClassVar[RuntimeModelState]
RUNTIME_MODEL_STATE_LOADING: _ClassVar[RuntimeModelState]
RUNTIME_MODEL_STATE_READY: _ClassVar[RuntimeModelState]
RUNTIME_MODEL_STATE_FAILED: _ClassVar[RuntimeModelState]
class LocalArtifactOperation(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
__slots__ = ()
LOCAL_ARTIFACT_OPERATION_UNSPECIFIED: _ClassVar[LocalArtifactOperation]
LOCAL_ARTIFACT_OPERATION_READ: _ClassVar[LocalArtifactOperation]
LOCAL_ARTIFACT_OPERATION_WRITE: _ClassVar[LocalArtifactOperation]
class RuntimeFailureClass(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
__slots__ = ()
RUNTIME_FAILURE_CLASS_UNSPECIFIED: _ClassVar[RuntimeFailureClass]
RUNTIME_FAILURE_CLASS_INPUT: _ClassVar[RuntimeFailureClass]
RUNTIME_FAILURE_CLASS_MODEL_LOAD: _ClassVar[RuntimeFailureClass]
RUNTIME_FAILURE_CLASS_INFERENCE: _ClassVar[RuntimeFailureClass]
RUNTIME_FAILURE_CLASS_GPU_RESOURCE: _ClassVar[RuntimeFailureClass]
RUNTIME_FAILURE_CLASS_LOCAL_STORAGE: _ClassVar[RuntimeFailureClass]
RUNTIME_FAILURE_CLASS_RUNTIME: _ClassVar[RuntimeFailureClass]
RUNTIME_FAILURE_CLASS_CANCELED: _ClassVar[RuntimeFailureClass]
class CancelDisposition(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
__slots__ = ()
CANCEL_DISPOSITION_UNSPECIFIED: _ClassVar[CancelDisposition]
CANCEL_DISPOSITION_ACCEPTED: _ClassVar[CancelDisposition]
CANCEL_DISPOSITION_ALREADY_TERMINAL: _ClassVar[CancelDisposition]
CANCEL_DISPOSITION_NOT_FOUND: _ClassVar[CancelDisposition]
SERVING_STATE_UNSPECIFIED: ServingState
SERVING_STATE_READY: ServingState
SERVING_STATE_DEGRADED: ServingState
SERVING_STATE_UNHEALTHY: ServingState
RUNTIME_MODEL_STATE_UNSPECIFIED: RuntimeModelState
RUNTIME_MODEL_STATE_INSTALLED: RuntimeModelState
RUNTIME_MODEL_STATE_LOADING: RuntimeModelState
RUNTIME_MODEL_STATE_READY: RuntimeModelState
RUNTIME_MODEL_STATE_FAILED: RuntimeModelState
LOCAL_ARTIFACT_OPERATION_UNSPECIFIED: LocalArtifactOperation
LOCAL_ARTIFACT_OPERATION_READ: LocalArtifactOperation
LOCAL_ARTIFACT_OPERATION_WRITE: LocalArtifactOperation
RUNTIME_FAILURE_CLASS_UNSPECIFIED: RuntimeFailureClass
RUNTIME_FAILURE_CLASS_INPUT: RuntimeFailureClass
RUNTIME_FAILURE_CLASS_MODEL_LOAD: RuntimeFailureClass
RUNTIME_FAILURE_CLASS_INFERENCE: RuntimeFailureClass
RUNTIME_FAILURE_CLASS_GPU_RESOURCE: RuntimeFailureClass
RUNTIME_FAILURE_CLASS_LOCAL_STORAGE: RuntimeFailureClass
RUNTIME_FAILURE_CLASS_RUNTIME: RuntimeFailureClass
RUNTIME_FAILURE_CLASS_CANCELED: RuntimeFailureClass
CANCEL_DISPOSITION_UNSPECIFIED: CancelDisposition
CANCEL_DISPOSITION_ACCEPTED: CancelDisposition
CANCEL_DISPOSITION_ALREADY_TERMINAL: CancelDisposition
CANCEL_DISPOSITION_NOT_FOUND: CancelDisposition
class ExecuteResponse(_message.Message):
__slots__ = ("event",)
EVENT_FIELD_NUMBER: _ClassVar[int]
event: ExecutionEvent
def __init__(self, event: _Optional[_Union[ExecutionEvent, _Mapping]] = ...) -> None: ...
class HealthRequest(_message.Message):
__slots__ = ()
def __init__(self) -> None: ...
class HealthResponse(_message.Message):
__slots__ = ("state", "runtime_version", "adapter_version", "health_flags")
STATE_FIELD_NUMBER: _ClassVar[int]
RUNTIME_VERSION_FIELD_NUMBER: _ClassVar[int]
ADAPTER_VERSION_FIELD_NUMBER: _ClassVar[int]
HEALTH_FLAGS_FIELD_NUMBER: _ClassVar[int]
state: ServingState
runtime_version: str
adapter_version: str
health_flags: _containers.RepeatedScalarFieldContainer[str]
def __init__(self, state: _Optional[_Union[ServingState, str]] = ..., runtime_version: _Optional[str] = ..., adapter_version: _Optional[str] = ..., health_flags: _Optional[_Iterable[str]] = ...) -> None: ...
class GetCapabilitiesRequest(_message.Message):
__slots__ = ()
def __init__(self) -> None: ...
class GetCapabilitiesResponse(_message.Message):
__slots__ = ("runtime_version", "adapter_version", "devices", "models")
RUNTIME_VERSION_FIELD_NUMBER: _ClassVar[int]
ADAPTER_VERSION_FIELD_NUMBER: _ClassVar[int]
DEVICES_FIELD_NUMBER: _ClassVar[int]
MODELS_FIELD_NUMBER: _ClassVar[int]
runtime_version: str
adapter_version: str
devices: _containers.RepeatedCompositeFieldContainer[RuntimeDevice]
models: _containers.RepeatedCompositeFieldContainer[RuntimeModel]
def __init__(self, runtime_version: _Optional[str] = ..., adapter_version: _Optional[str] = ..., devices: _Optional[_Iterable[_Union[RuntimeDevice, _Mapping]]] = ..., models: _Optional[_Iterable[_Union[RuntimeModel, _Mapping]]] = ...) -> None: ...
class RuntimeDevice(_message.Message):
__slots__ = ("device_id", "hardware_class", "total_vram_bytes", "total_slots", "free_slots")
DEVICE_ID_FIELD_NUMBER: _ClassVar[int]
HARDWARE_CLASS_FIELD_NUMBER: _ClassVar[int]
TOTAL_VRAM_BYTES_FIELD_NUMBER: _ClassVar[int]
TOTAL_SLOTS_FIELD_NUMBER: _ClassVar[int]
FREE_SLOTS_FIELD_NUMBER: _ClassVar[int]
device_id: str
hardware_class: str
total_vram_bytes: int
total_slots: int
free_slots: int
def __init__(self, device_id: _Optional[str] = ..., hardware_class: _Optional[str] = ..., total_vram_bytes: _Optional[int] = ..., total_slots: _Optional[int] = ..., free_slots: _Optional[int] = ...) -> None: ...
class RuntimeModel(_message.Message):
__slots__ = ("catalog_model_id", "model_version", "model_digest", "precisions", "features", "state")
CATALOG_MODEL_ID_FIELD_NUMBER: _ClassVar[int]
MODEL_VERSION_FIELD_NUMBER: _ClassVar[int]
MODEL_DIGEST_FIELD_NUMBER: _ClassVar[int]
PRECISIONS_FIELD_NUMBER: _ClassVar[int]
FEATURES_FIELD_NUMBER: _ClassVar[int]
STATE_FIELD_NUMBER: _ClassVar[int]
catalog_model_id: str
model_version: str
model_digest: str
precisions: _containers.RepeatedScalarFieldContainer[str]
features: _containers.RepeatedScalarFieldContainer[str]
state: RuntimeModelState
def __init__(self, catalog_model_id: _Optional[str] = ..., model_version: _Optional[str] = ..., model_digest: _Optional[str] = ..., precisions: _Optional[_Iterable[str]] = ..., features: _Optional[_Iterable[str]] = ..., state: _Optional[_Union[RuntimeModelState, str]] = ...) -> None: ...
class ExecuteRequest(_message.Message):
__slots__ = ("job_id", "attempt_id", "device_id", "slot_id", "model", "parameters", "inputs", "outputs", "deadline_unix_ms", "maximum_preview_bytes")
class ParametersEntry(_message.Message):
__slots__ = ("key", "value")
KEY_FIELD_NUMBER: _ClassVar[int]
VALUE_FIELD_NUMBER: _ClassVar[int]
key: str
value: ParameterValue
def __init__(self, key: _Optional[str] = ..., value: _Optional[_Union[ParameterValue, _Mapping]] = ...) -> None: ...
JOB_ID_FIELD_NUMBER: _ClassVar[int]
ATTEMPT_ID_FIELD_NUMBER: _ClassVar[int]
DEVICE_ID_FIELD_NUMBER: _ClassVar[int]
SLOT_ID_FIELD_NUMBER: _ClassVar[int]
MODEL_FIELD_NUMBER: _ClassVar[int]
PARAMETERS_FIELD_NUMBER: _ClassVar[int]
INPUTS_FIELD_NUMBER: _ClassVar[int]
OUTPUTS_FIELD_NUMBER: _ClassVar[int]
DEADLINE_UNIX_MS_FIELD_NUMBER: _ClassVar[int]
MAXIMUM_PREVIEW_BYTES_FIELD_NUMBER: _ClassVar[int]
job_id: str
attempt_id: str
device_id: str
slot_id: str
model: ModelSpec
parameters: _containers.MessageMap[str, ParameterValue]
inputs: _containers.RepeatedCompositeFieldContainer[LocalArtifact]
outputs: _containers.RepeatedCompositeFieldContainer[LocalArtifact]
deadline_unix_ms: int
maximum_preview_bytes: int
def __init__(self, job_id: _Optional[str] = ..., attempt_id: _Optional[str] = ..., device_id: _Optional[str] = ..., slot_id: _Optional[str] = ..., model: _Optional[_Union[ModelSpec, _Mapping]] = ..., parameters: _Optional[_Mapping[str, ParameterValue]] = ..., inputs: _Optional[_Iterable[_Union[LocalArtifact, _Mapping]]] = ..., outputs: _Optional[_Iterable[_Union[LocalArtifact, _Mapping]]] = ..., deadline_unix_ms: _Optional[int] = ..., maximum_preview_bytes: _Optional[int] = ...) -> None: ...
class ModelSpec(_message.Message):
__slots__ = ("catalog_model_id", "model_version", "model_digest", "precision")
CATALOG_MODEL_ID_FIELD_NUMBER: _ClassVar[int]
MODEL_VERSION_FIELD_NUMBER: _ClassVar[int]
MODEL_DIGEST_FIELD_NUMBER: _ClassVar[int]
PRECISION_FIELD_NUMBER: _ClassVar[int]
catalog_model_id: str
model_version: str
model_digest: str
precision: str
def __init__(self, catalog_model_id: _Optional[str] = ..., model_version: _Optional[str] = ..., model_digest: _Optional[str] = ..., precision: _Optional[str] = ...) -> None: ...
class ParameterValue(_message.Message):
__slots__ = ("string_value", "integer_value", "number_value", "boolean_value")
STRING_VALUE_FIELD_NUMBER: _ClassVar[int]
INTEGER_VALUE_FIELD_NUMBER: _ClassVar[int]
NUMBER_VALUE_FIELD_NUMBER: _ClassVar[int]
BOOLEAN_VALUE_FIELD_NUMBER: _ClassVar[int]
string_value: str
integer_value: int
number_value: float
boolean_value: bool
def __init__(self, string_value: _Optional[str] = ..., integer_value: _Optional[int] = ..., number_value: _Optional[float] = ..., boolean_value: _Optional[bool] = ...) -> None: ...
class LocalArtifact(_message.Message):
__slots__ = ("artifact_id", "local_handle", "operation", "expected_size_bytes", "expected_sha256", "media_type")
ARTIFACT_ID_FIELD_NUMBER: _ClassVar[int]
LOCAL_HANDLE_FIELD_NUMBER: _ClassVar[int]
OPERATION_FIELD_NUMBER: _ClassVar[int]
EXPECTED_SIZE_BYTES_FIELD_NUMBER: _ClassVar[int]
EXPECTED_SHA256_FIELD_NUMBER: _ClassVar[int]
MEDIA_TYPE_FIELD_NUMBER: _ClassVar[int]
artifact_id: str
local_handle: str
operation: LocalArtifactOperation
expected_size_bytes: int
expected_sha256: str
media_type: str
def __init__(self, artifact_id: _Optional[str] = ..., local_handle: _Optional[str] = ..., operation: _Optional[_Union[LocalArtifactOperation, str]] = ..., expected_size_bytes: _Optional[int] = ..., expected_sha256: _Optional[str] = ..., media_type: _Optional[str] = ...) -> None: ...
class ExecutionEvent(_message.Message):
__slots__ = ("job_id", "attempt_id", "sequence", "observed_at_unix_ms", "started", "progress", "preview", "completed", "failed", "canceled")
JOB_ID_FIELD_NUMBER: _ClassVar[int]
ATTEMPT_ID_FIELD_NUMBER: _ClassVar[int]
SEQUENCE_FIELD_NUMBER: _ClassVar[int]
OBSERVED_AT_UNIX_MS_FIELD_NUMBER: _ClassVar[int]
STARTED_FIELD_NUMBER: _ClassVar[int]
PROGRESS_FIELD_NUMBER: _ClassVar[int]
PREVIEW_FIELD_NUMBER: _ClassVar[int]
COMPLETED_FIELD_NUMBER: _ClassVar[int]
FAILED_FIELD_NUMBER: _ClassVar[int]
CANCELED_FIELD_NUMBER: _ClassVar[int]
job_id: str
attempt_id: str
sequence: int
observed_at_unix_ms: int
started: ExecutionStarted
progress: ExecutionProgress
preview: PreviewChunk
completed: ExecutionCompleted
failed: ExecutionFailed
canceled: ExecutionCanceled
def __init__(self, job_id: _Optional[str] = ..., attempt_id: _Optional[str] = ..., sequence: _Optional[int] = ..., observed_at_unix_ms: _Optional[int] = ..., started: _Optional[_Union[ExecutionStarted, _Mapping]] = ..., progress: _Optional[_Union[ExecutionProgress, _Mapping]] = ..., preview: _Optional[_Union[PreviewChunk, _Mapping]] = ..., completed: _Optional[_Union[ExecutionCompleted, _Mapping]] = ..., failed: _Optional[_Union[ExecutionFailed, _Mapping]] = ..., canceled: _Optional[_Union[ExecutionCanceled, _Mapping]] = ...) -> None: ...
class ExecutionStarted(_message.Message):
__slots__ = ()
def __init__(self) -> None: ...
class ExecutionProgress(_message.Message):
__slots__ = ("progress_permille", "stage_code")
PROGRESS_PERMILLE_FIELD_NUMBER: _ClassVar[int]
STAGE_CODE_FIELD_NUMBER: _ClassVar[int]
progress_permille: int
stage_code: str
def __init__(self, progress_permille: _Optional[int] = ..., stage_code: _Optional[str] = ...) -> None: ...
class PreviewChunk(_message.Message):
__slots__ = ("sequence", "media_type", "data")
SEQUENCE_FIELD_NUMBER: _ClassVar[int]
MEDIA_TYPE_FIELD_NUMBER: _ClassVar[int]
DATA_FIELD_NUMBER: _ClassVar[int]
sequence: int
media_type: str
data: bytes
def __init__(self, sequence: _Optional[int] = ..., media_type: _Optional[str] = ..., data: _Optional[bytes] = ...) -> None: ...
class ExecutionCompleted(_message.Message):
__slots__ = ("outputs", "measurements")
OUTPUTS_FIELD_NUMBER: _ClassVar[int]
MEASUREMENTS_FIELD_NUMBER: _ClassVar[int]
outputs: _containers.RepeatedCompositeFieldContainer[LocalArtifactManifest]
measurements: RuntimeMeasurements
def __init__(self, outputs: _Optional[_Iterable[_Union[LocalArtifactManifest, _Mapping]]] = ..., measurements: _Optional[_Union[RuntimeMeasurements, _Mapping]] = ...) -> None: ...
class LocalArtifactManifest(_message.Message):
__slots__ = ("artifact_id", "local_handle", "size_bytes", "sha256", "media_type", "duration_ms")
ARTIFACT_ID_FIELD_NUMBER: _ClassVar[int]
LOCAL_HANDLE_FIELD_NUMBER: _ClassVar[int]
SIZE_BYTES_FIELD_NUMBER: _ClassVar[int]
SHA256_FIELD_NUMBER: _ClassVar[int]
MEDIA_TYPE_FIELD_NUMBER: _ClassVar[int]
DURATION_MS_FIELD_NUMBER: _ClassVar[int]
artifact_id: str
local_handle: str
size_bytes: int
sha256: str
media_type: str
duration_ms: int
def __init__(self, artifact_id: _Optional[str] = ..., local_handle: _Optional[str] = ..., size_bytes: _Optional[int] = ..., sha256: _Optional[str] = ..., media_type: _Optional[str] = ..., duration_ms: _Optional[int] = ...) -> None: ...
class ExecutionFailed(_message.Message):
__slots__ = ("failure_class", "stable_code", "safe_detail", "measurements")
FAILURE_CLASS_FIELD_NUMBER: _ClassVar[int]
STABLE_CODE_FIELD_NUMBER: _ClassVar[int]
SAFE_DETAIL_FIELD_NUMBER: _ClassVar[int]
MEASUREMENTS_FIELD_NUMBER: _ClassVar[int]
failure_class: RuntimeFailureClass
stable_code: str
safe_detail: str
measurements: RuntimeMeasurements
def __init__(self, failure_class: _Optional[_Union[RuntimeFailureClass, str]] = ..., stable_code: _Optional[str] = ..., safe_detail: _Optional[str] = ..., measurements: _Optional[_Union[RuntimeMeasurements, _Mapping]] = ...) -> None: ...
class ExecutionCanceled(_message.Message):
__slots__ = ("measurements",)
MEASUREMENTS_FIELD_NUMBER: _ClassVar[int]
measurements: RuntimeMeasurements
def __init__(self, measurements: _Optional[_Union[RuntimeMeasurements, _Mapping]] = ...) -> None: ...
class RuntimeMeasurements(_message.Message):
__slots__ = ("normalized_input_characters", "input_audio_ms", "output_audio_ms", "gpu_execution_ms", "cpu_execution_ms")
NORMALIZED_INPUT_CHARACTERS_FIELD_NUMBER: _ClassVar[int]
INPUT_AUDIO_MS_FIELD_NUMBER: _ClassVar[int]
OUTPUT_AUDIO_MS_FIELD_NUMBER: _ClassVar[int]
GPU_EXECUTION_MS_FIELD_NUMBER: _ClassVar[int]
CPU_EXECUTION_MS_FIELD_NUMBER: _ClassVar[int]
normalized_input_characters: int
input_audio_ms: int
output_audio_ms: int
gpu_execution_ms: int
cpu_execution_ms: int
def __init__(self, normalized_input_characters: _Optional[int] = ..., input_audio_ms: _Optional[int] = ..., output_audio_ms: _Optional[int] = ..., gpu_execution_ms: _Optional[int] = ..., cpu_execution_ms: _Optional[int] = ...) -> None: ...
class CancelRequest(_message.Message):
__slots__ = ("job_id", "attempt_id", "reason_code", "deadline_unix_ms")
JOB_ID_FIELD_NUMBER: _ClassVar[int]
ATTEMPT_ID_FIELD_NUMBER: _ClassVar[int]
REASON_CODE_FIELD_NUMBER: _ClassVar[int]
DEADLINE_UNIX_MS_FIELD_NUMBER: _ClassVar[int]
job_id: str
attempt_id: str
reason_code: str
deadline_unix_ms: int
def __init__(self, job_id: _Optional[str] = ..., attempt_id: _Optional[str] = ..., reason_code: _Optional[str] = ..., deadline_unix_ms: _Optional[int] = ...) -> None: ...
class CancelResponse(_message.Message):
__slots__ = ("disposition",)
DISPOSITION_FIELD_NUMBER: _ClassVar[int]
disposition: CancelDisposition
def __init__(self, disposition: _Optional[_Union[CancelDisposition, str]] = ...) -> None: ...
@@ -1,229 +0,0 @@
# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
"""Client and server classes corresponding to protobuf-defined services."""
import grpc
import warnings
from . import runtime_adapter_pb2 as runtime__adapter__pb2
GRPC_GENERATED_VERSION = '1.81.1'
GRPC_VERSION = grpc.__version__
_version_not_supported = False
try:
from grpc._utilities import first_version_is_lower
_version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION)
except ImportError:
_version_not_supported = True
if _version_not_supported:
raise RuntimeError(
f'The grpc package installed is at version {GRPC_VERSION},'
+ ' but the generated code in runtime_adapter_pb2_grpc.py depends on'
+ f' grpcio>={GRPC_GENERATED_VERSION}.'
+ f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}'
+ f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.'
)
class RuntimeAdapterServiceStub:
"""RuntimeAdapterService is local to a GPU Node and is never publicly exposed.
"""
def __init__(self, channel):
"""Constructor.
Args:
channel: A grpc.Channel.
"""
self.Health = channel.unary_unary(
'/voicestudio.runtime.v1.RuntimeAdapterService/Health',
request_serializer=runtime__adapter__pb2.HealthRequest.SerializeToString,
response_deserializer=runtime__adapter__pb2.HealthResponse.FromString,
_registered_method=True)
self.GetCapabilities = channel.unary_unary(
'/voicestudio.runtime.v1.RuntimeAdapterService/GetCapabilities',
request_serializer=runtime__adapter__pb2.GetCapabilitiesRequest.SerializeToString,
response_deserializer=runtime__adapter__pb2.GetCapabilitiesResponse.FromString,
_registered_method=True)
self.Execute = channel.unary_stream(
'/voicestudio.runtime.v1.RuntimeAdapterService/Execute',
request_serializer=runtime__adapter__pb2.ExecuteRequest.SerializeToString,
response_deserializer=runtime__adapter__pb2.ExecuteResponse.FromString,
_registered_method=True)
self.Cancel = channel.unary_unary(
'/voicestudio.runtime.v1.RuntimeAdapterService/Cancel',
request_serializer=runtime__adapter__pb2.CancelRequest.SerializeToString,
response_deserializer=runtime__adapter__pb2.CancelResponse.FromString,
_registered_method=True)
class RuntimeAdapterServiceServicer:
"""RuntimeAdapterService is local to a GPU Node and is never publicly exposed.
"""
def Health(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def GetCapabilities(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def Execute(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def Cancel(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def add_RuntimeAdapterServiceServicer_to_server(servicer, server):
rpc_method_handlers = {
'Health': grpc.unary_unary_rpc_method_handler(
servicer.Health,
request_deserializer=runtime__adapter__pb2.HealthRequest.FromString,
response_serializer=runtime__adapter__pb2.HealthResponse.SerializeToString,
),
'GetCapabilities': grpc.unary_unary_rpc_method_handler(
servicer.GetCapabilities,
request_deserializer=runtime__adapter__pb2.GetCapabilitiesRequest.FromString,
response_serializer=runtime__adapter__pb2.GetCapabilitiesResponse.SerializeToString,
),
'Execute': grpc.unary_stream_rpc_method_handler(
servicer.Execute,
request_deserializer=runtime__adapter__pb2.ExecuteRequest.FromString,
response_serializer=runtime__adapter__pb2.ExecuteResponse.SerializeToString,
),
'Cancel': grpc.unary_unary_rpc_method_handler(
servicer.Cancel,
request_deserializer=runtime__adapter__pb2.CancelRequest.FromString,
response_serializer=runtime__adapter__pb2.CancelResponse.SerializeToString,
),
}
generic_handler = grpc.method_handlers_generic_handler(
'voicestudio.runtime.v1.RuntimeAdapterService', rpc_method_handlers)
server.add_generic_rpc_handlers((generic_handler,))
server.add_registered_method_handlers('voicestudio.runtime.v1.RuntimeAdapterService', rpc_method_handlers)
# This class is part of an EXPERIMENTAL API.
class RuntimeAdapterService:
"""RuntimeAdapterService is local to a GPU Node and is never publicly exposed.
"""
@staticmethod
def Health(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/voicestudio.runtime.v1.RuntimeAdapterService/Health',
runtime__adapter__pb2.HealthRequest.SerializeToString,
runtime__adapter__pb2.HealthResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def GetCapabilities(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/voicestudio.runtime.v1.RuntimeAdapterService/GetCapabilities',
runtime__adapter__pb2.GetCapabilitiesRequest.SerializeToString,
runtime__adapter__pb2.GetCapabilitiesResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def Execute(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_stream(
request,
target,
'/voicestudio.runtime.v1.RuntimeAdapterService/Execute',
runtime__adapter__pb2.ExecuteRequest.SerializeToString,
runtime__adapter__pb2.ExecuteResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def Cancel(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/voicestudio.runtime.v1.RuntimeAdapterService/Cancel',
runtime__adapter__pb2.CancelRequest.SerializeToString,
runtime__adapter__pb2.CancelResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
-304
View File
@@ -1,304 +0,0 @@
"""Device and model inventory reported through Health/GetCapabilities.
The server is written against the small protocol at the top of this module so
tests can substitute fakes; :class:`ProductionInventory` is the real thing,
wired to ``services.tts_backend``'s engine registry, ``services.hf_revisions``
pinned revisions, and :mod:`runtime_adapter.digest`.
State rules (mirrors the Go preflight's expectations):
- READY is **explicit**: engine registered, availability probe passed, the
pinned snapshot fully present on disk, and a digest computed. Anything
less is INSTALLED / LOADING / FAILED never READY.
- A loading or failed model is still listed (with its true state) so the
Gateway can observe it; only READY models are schedulable.
"""
from __future__ import annotations
import os
import threading
import time
from dataclasses import dataclass, field
from . import SLOTS_ENV
from ._paths import ensure_backend_on_path
from .digest import snapshot_digest
STATE_INSTALLED = "installed"
STATE_LOADING = "loading"
STATE_READY = "ready"
STATE_FAILED = "failed"
@dataclass(frozen=True)
class DeviceInfo:
device_id: str
hardware_class: str
total_vram_bytes: int
total_slots: int
free_slots: int
@dataclass(frozen=True)
class ModelInfo:
catalog_model_id: str
model_version: str
model_digest: str
precisions: tuple[str, ...] = ()
features: tuple[str, ...] = ()
state: str = STATE_INSTALLED
#: Engines this adapter can attest as digest-pinned models: TTS engine id →
#: curated Hugging Face repo (must be pinned in ``services.hf_revisions``).
#: Engines without a single pinned weights repo (external API servers,
#: multi-model muxes) are deliberately absent — they cannot be digest-pinned.
ENGINE_MODEL_REPOS: dict[str, str] = {
"omnivoice": "k2-fsa/OmniVoice",
"voxcpm2": "openbmb/VoxCPM2",
"moss-tts-nano": "OpenMOSS-Team/MOSS-TTS-Nano-100M",
"kittentts": "KittenML/kitten-tts-mini-0.8",
"cosyvoice": "FunAudioLLM/Fun-CosyVoice3-0.5B-2512",
"moss-tts-v15": "OpenMOSS-Team/MOSS-TTS-v1.5",
}
def slots_per_device(default: int = 1) -> int:
raw = os.environ.get(SLOTS_ENV, "").strip()
try:
value = int(raw) if raw else default
except ValueError:
return default
return max(1, min(value, 64))
@dataclass
class ProductionInventory:
"""Real host inventory. All heavy imports happen inside methods.
``models()`` is memoized for ``model_ttl_s`` under a lock: the first call
hashes every installed snapshot (minutes for multi-GB weights, then cached
in the on-disk digest sidecar), and Health + GetCapabilities arrive
back-to-back. Call :meth:`warm` before serving so the first RPC never
pays the hashing cost inside its deadline.
"""
slots: int = field(default_factory=slots_per_device)
model_ttl_s: float = 15.0
def __post_init__(self):
self._model_lock = threading.Lock()
self._model_cache: list[ModelInfo] | None = None
self._model_cache_at = 0.0
def warm(self) -> None:
self.models()
def devices(self, busy_slots: int = 0) -> list[DeviceInfo]:
ensure_backend_on_path()
devices = self._accelerators() or [self._cpu_device()]
return [self._with_slots(device, busy_slots) for device in devices]
def _with_slots(self, device: DeviceInfo, busy_slots: int) -> DeviceInfo:
free = max(0, min(device.total_slots - busy_slots, device.total_slots))
return DeviceInfo(
device_id=device.device_id,
hardware_class=device.hardware_class,
total_vram_bytes=device.total_vram_bytes,
total_slots=device.total_slots,
free_slots=free,
)
def _accelerators(self) -> list[DeviceInfo]:
try:
import torch # noqa: PLC0415
except Exception:
return []
found: list[DeviceInfo] = []
try:
if torch.cuda.is_available():
for index in range(torch.cuda.device_count()):
props = torch.cuda.get_device_properties(index)
found.append(
DeviceInfo(
device_id=f"cuda:{index}",
hardware_class=torch.cuda.get_device_name(index),
total_vram_bytes=int(props.total_memory),
total_slots=self.slots,
free_slots=self.slots,
)
)
return found
except Exception:
pass
try:
if getattr(torch.backends, "mps", None) and torch.backends.mps.is_available():
vram = 0
recommended = getattr(torch.mps, "recommended_max_memory", None)
if callable(recommended):
try:
vram = int(recommended())
except Exception:
vram = 0
if vram <= 0:
vram = _system_memory_bytes()
return [
DeviceInfo(
device_id="mps:0",
hardware_class="apple-silicon-mps",
total_vram_bytes=vram,
total_slots=self.slots,
free_slots=self.slots,
)
]
except Exception:
pass
return []
def _cpu_device(self) -> DeviceInfo:
# A CPU-only node is a valid (slow) execution device. total_vram_bytes
# carries system memory so the Gateway's ">0" validity check reflects
# real capacity rather than a made-up constant.
import platform # noqa: PLC0415
return DeviceInfo(
device_id="cpu:0",
hardware_class=platform.processor() or platform.machine() or "cpu",
total_vram_bytes=_system_memory_bytes(),
total_slots=self.slots,
free_slots=self.slots,
)
def models(self) -> list[ModelInfo]:
with self._model_lock:
now = time.monotonic()
if (
self._model_cache is not None
and now - self._model_cache_at < self.model_ttl_s
):
return list(self._model_cache)
self._model_cache = self._scan_models()
self._model_cache_at = time.monotonic()
return list(self._model_cache)
def _scan_models(self) -> list[ModelInfo]:
ensure_backend_on_path()
from services.hf_cache_repair import repo_cache_dir # noqa: PLC0415
from services.hf_revisions import installed_revision # noqa: PLC0415
from services.tts_backend import get_backend_class # noqa: PLC0415
models: list[ModelInfo] = []
for engine_id, repo_id in sorted(ENGINE_MODEL_REPOS.items()):
try:
backend_cls = get_backend_class(engine_id)
except Exception:
continue # engine not registered in this build
repo_dir = repo_cache_dir(repo_id)
try:
revision = installed_revision(repo_id, os.path.dirname(repo_dir))
except ValueError:
continue # repo not in the curated catalog — cannot attest
snapshot = os.path.join(repo_dir, "snapshots", revision)
if not os.path.isdir(snapshot):
continue # weights not installed at the pinned revision
models.append(
self._model_state(engine_id, backend_cls, repo_dir, revision, snapshot)
)
return models
def _model_state(
self, engine_id: str, backend_cls, repo_dir: str, revision: str, snapshot: str
) -> ModelInfo:
base = ModelInfo(
catalog_model_id=engine_id,
model_version=revision,
model_digest="",
precisions=self._precisions(backend_cls),
features=self._features(backend_cls),
)
try:
ok, _message = backend_cls.is_available()
except Exception:
return _replace_state(base, STATE_FAILED)
if not ok:
return _replace_state(base, STATE_INSTALLED)
if _snapshot_incomplete(repo_dir, snapshot):
return _replace_state(base, STATE_LOADING)
try:
model_digest = snapshot_digest(
snapshot,
cache_path=os.path.join(repo_dir, f"voicestudio-digest-{revision}.json"),
)
except OSError:
return _replace_state(base, STATE_LOADING)
return ModelInfo(
catalog_model_id=base.catalog_model_id,
model_version=base.model_version,
model_digest=model_digest,
precisions=base.precisions,
features=base.features,
state=STATE_READY,
)
def _precisions(self, backend_cls) -> tuple[str, ...]:
# Advisory execution precisions. fp32 always works; fp16 is offered
# when the engine targets an accelerator this host actually has.
compat = tuple(getattr(backend_cls, "gpu_compat", ("cpu",)))
try:
from core.device_caps import detect_host_caps # noqa: PLC0415
family = detect_host_caps().family
except Exception:
family = "cpu"
if family != "cpu" and family in compat:
return ("fp16", "fp32")
return ("fp32",)
def _features(self, backend_cls) -> tuple[str, ...]:
features = ["tts"]
if getattr(backend_cls, "supports_cloning", False) is True:
features.append("voice_clone")
if getattr(backend_cls, "supports_voice_design", False):
features.append("voice_design")
if getattr(backend_cls, "supports_emotion", False):
features.append("emotion")
return tuple(features)
def _replace_state(model: ModelInfo, state: str) -> ModelInfo:
return ModelInfo(
catalog_model_id=model.catalog_model_id,
model_version=model.model_version,
model_digest=model.model_digest,
precisions=model.precisions,
features=model.features,
state=state,
)
def _snapshot_incomplete(repo_dir: str, snapshot: str) -> bool:
"""A download in flight leaves ``*.incomplete`` blobs or dangling links."""
blobs = os.path.join(repo_dir, "blobs")
try:
if any(name.endswith(".incomplete") for name in os.listdir(blobs)):
return True
except OSError:
pass
for current, _dirs, files in os.walk(snapshot):
for name in files:
path = os.path.join(current, name)
if not os.path.exists(path): # dangling symlink
return True
return False
def _system_memory_bytes() -> int:
try:
import psutil # noqa: PLC0415
return int(psutil.virtual_memory().total)
except Exception:
try:
return os.sysconf("SC_PAGE_SIZE") * os.sysconf("SC_PHYS_PAGES")
except (ValueError, OSError, AttributeError):
return 1 # still nonzero: the preflight requires > 0
-63
View File
@@ -1,63 +0,0 @@
"""Wires the adapter to the real VoiceStudio backend.
Kept separate from ``server.py`` so tests can build a
:class:`~runtime_adapter.server.RuntimeContext` from fakes without importing
torch or the engine registry.
"""
from __future__ import annotations
import sys
from . import ADAPTER_VERSION
from ._paths import ensure_backend_on_path
from .inventory import ProductionInventory, slots_per_device
from .server import RuntimeContext
def production_engine_provider(catalog_model_id: str):
"""Resolve a READY catalog model id to its cached engine instance."""
ensure_backend_on_path()
from services.tts_backend import get_engine_instance_for # noqa: PLC0415
return get_engine_instance_for(catalog_model_id)
def build_runtime_context() -> RuntimeContext:
ensure_backend_on_path()
from core.version import APP_VERSION # noqa: PLC0415
slots = slots_per_device()
return RuntimeContext(
runtime_version=APP_VERSION,
adapter_version=ADAPTER_VERSION,
inventory=ProductionInventory(slots=slots),
engine_provider=production_engine_provider,
slot_limit=slots,
)
def prewarm_engines(context: RuntimeContext) -> None:
"""Load and compile every READY model before the socket accepts work.
The GPU Gateway leases an attempt for a bounded window and renews it from
execution evidence. A cold engine produces no evidence: weight loading and
torch compilation can run for minutes emitting nothing, so the lease
expires mid-load, the attempt is fenced, the Job requeues, and the next
attempt pays the same cost a loop that never yields audio.
Paying that cost once at startup, before the adapter is reachable, means
the first real Execute begins inference immediately. Preflight already
refuses a runtime with no READY model, so a failure here is reported and
the model is dropped from the advertised set rather than being offered as
schedulable capacity the node cannot actually serve promptly.
"""
ensure_backend_on_path()
for model in context.inventory.models():
if model.state != "ready":
continue
try:
context.engine_provider(model.catalog_model_id)
except Exception as error: # noqa: BLE001 - reported, never fatal
print(
f"runtime adapter: prewarm of {model.catalog_model_id} failed: {error}",
file=sys.stderr,
)
@@ -1,199 +0,0 @@
syntax = "proto3";
package voicestudio.runtime.v1;
option go_package = "github.com/velixio/vssaas/api/gen/runtime/v1;runtimev1";
// RuntimeAdapterService is local to a GPU Node and is never publicly exposed.
service RuntimeAdapterService {
rpc Health(HealthRequest) returns (HealthResponse);
rpc GetCapabilities(GetCapabilitiesRequest) returns (GetCapabilitiesResponse);
rpc Execute(ExecuteRequest) returns (stream ExecuteResponse);
rpc Cancel(CancelRequest) returns (CancelResponse);
}
message ExecuteResponse { ExecutionEvent event = 1; }
message HealthRequest {}
message HealthResponse {
ServingState state = 1;
string runtime_version = 2;
string adapter_version = 3;
repeated string health_flags = 4;
}
enum ServingState {
SERVING_STATE_UNSPECIFIED = 0;
SERVING_STATE_READY = 1;
SERVING_STATE_DEGRADED = 2;
SERVING_STATE_UNHEALTHY = 3;
}
message GetCapabilitiesRequest {}
message GetCapabilitiesResponse {
string runtime_version = 1;
string adapter_version = 2;
repeated RuntimeDevice devices = 3;
repeated RuntimeModel models = 4;
}
message RuntimeDevice {
string device_id = 1;
string hardware_class = 2;
uint64 total_vram_bytes = 3;
uint32 total_slots = 4;
uint32 free_slots = 5;
}
message RuntimeModel {
string catalog_model_id = 1;
string model_version = 2;
string model_digest = 3;
repeated string precisions = 4;
repeated string features = 5;
RuntimeModelState state = 6;
}
enum RuntimeModelState {
RUNTIME_MODEL_STATE_UNSPECIFIED = 0;
RUNTIME_MODEL_STATE_INSTALLED = 1;
RUNTIME_MODEL_STATE_LOADING = 2;
RUNTIME_MODEL_STATE_READY = 3;
RUNTIME_MODEL_STATE_FAILED = 4;
}
message ExecuteRequest {
string job_id = 1;
string attempt_id = 2;
string device_id = 3;
string slot_id = 4;
ModelSpec model = 5;
map<string, ParameterValue> parameters = 6;
repeated LocalArtifact inputs = 7;
repeated LocalArtifact outputs = 8;
int64 deadline_unix_ms = 9;
uint32 maximum_preview_bytes = 10;
}
message ModelSpec {
string catalog_model_id = 1;
string model_version = 2;
string model_digest = 3;
string precision = 4;
}
message ParameterValue {
oneof value {
string string_value = 1;
int64 integer_value = 2;
double number_value = 3;
bool boolean_value = 4;
}
}
message LocalArtifact {
string artifact_id = 1;
string local_handle = 2;
LocalArtifactOperation operation = 3;
uint64 expected_size_bytes = 4;
string expected_sha256 = 5;
string media_type = 6;
}
enum LocalArtifactOperation {
LOCAL_ARTIFACT_OPERATION_UNSPECIFIED = 0;
LOCAL_ARTIFACT_OPERATION_READ = 1;
LOCAL_ARTIFACT_OPERATION_WRITE = 2;
}
message ExecutionEvent {
string job_id = 1;
string attempt_id = 2;
uint64 sequence = 3;
int64 observed_at_unix_ms = 4;
oneof payload {
ExecutionStarted started = 10;
ExecutionProgress progress = 11;
PreviewChunk preview = 12;
ExecutionCompleted completed = 13;
ExecutionFailed failed = 14;
ExecutionCanceled canceled = 15;
}
}
message ExecutionStarted {}
message ExecutionProgress {
uint32 progress_permille = 1;
string stage_code = 2;
}
message PreviewChunk {
uint64 sequence = 1;
string media_type = 2;
bytes data = 3;
}
message ExecutionCompleted {
repeated LocalArtifactManifest outputs = 1;
RuntimeMeasurements measurements = 2;
}
message LocalArtifactManifest {
string artifact_id = 1;
string local_handle = 2;
uint64 size_bytes = 3;
string sha256 = 4;
string media_type = 5;
uint64 duration_ms = 6;
}
message ExecutionFailed {
RuntimeFailureClass failure_class = 1;
string stable_code = 2;
string safe_detail = 3;
RuntimeMeasurements measurements = 4;
}
message ExecutionCanceled {
RuntimeMeasurements measurements = 1;
}
enum RuntimeFailureClass {
RUNTIME_FAILURE_CLASS_UNSPECIFIED = 0;
RUNTIME_FAILURE_CLASS_INPUT = 1;
RUNTIME_FAILURE_CLASS_MODEL_LOAD = 2;
RUNTIME_FAILURE_CLASS_INFERENCE = 3;
RUNTIME_FAILURE_CLASS_GPU_RESOURCE = 4;
RUNTIME_FAILURE_CLASS_LOCAL_STORAGE = 5;
RUNTIME_FAILURE_CLASS_RUNTIME = 6;
RUNTIME_FAILURE_CLASS_CANCELED = 7;
}
message RuntimeMeasurements {
uint64 normalized_input_characters = 1;
uint64 input_audio_ms = 2;
uint64 output_audio_ms = 3;
uint64 gpu_execution_ms = 4;
uint64 cpu_execution_ms = 5;
}
message CancelRequest {
string job_id = 1;
string attempt_id = 2;
string reason_code = 3;
int64 deadline_unix_ms = 4;
}
message CancelResponse {
CancelDisposition disposition = 1;
}
enum CancelDisposition {
CANCEL_DISPOSITION_UNSPECIFIED = 0;
CANCEL_DISPOSITION_ACCEPTED = 1;
CANCEL_DISPOSITION_ALREADY_TERMINAL = 2;
CANCEL_DISPOSITION_NOT_FOUND = 3;
}
-168
View File
@@ -1,168 +0,0 @@
"""``--selfcheck``: validate the Go preflight's expectations against ourselves.
Starts the server on a private temp socket, then runs a Python port of
``internal/gateway/preflight.go``'s checks over the wire: socket-path safety,
READY health with version evidence, identical versions across Health and
GetCapabilities, valid unique devices, and at least one explicitly READY,
digest-pinned model with a version and precisions. Prints only a bounded
readiness summary (never handles, paths, or credentials) and exits nonzero on
any failed expectation the same fail-closed behavior a node deployment gets
from ``cmd/runtime-adapter-preflight``.
"""
from __future__ import annotations
import os
import stat as stat_module
import tempfile
from dataclasses import dataclass
import grpc
from .gen import runtime_adapter_pb2 as pb2
from .gen import runtime_adapter_pb2_grpc as pb2_grpc
_MAX_UINT32 = 2**32 - 1
class PreflightError(Exception):
"""One failed preflight expectation, with a bounded message."""
@dataclass(frozen=True)
class PreflightSummary:
socket_path: str
runtime_version: str
adapter_version: str
device_count: int
ready_model_count: int
total_slots: int
free_slots: int
def render(self) -> str:
return (
f"runtime={self.runtime_version} adapter={self.adapter_version} "
f"devices={self.device_count} ready_models={self.ready_model_count} "
f"slots={self.free_slots}/{self.total_slots}"
)
def validate_socket_file(socket_path: str) -> None:
if not socket_path or not os.path.isabs(socket_path):
raise PreflightError("socket path must be absolute")
info = os.lstat(socket_path)
if stat_module.S_ISLNK(info.st_mode) or not stat_module.S_ISSOCK(info.st_mode):
raise PreflightError("endpoint must be a local Unix socket")
parent = os.stat(os.path.dirname(socket_path))
if not stat_module.S_ISDIR(parent.st_mode) or parent.st_mode & 0o002:
raise PreflightError("socket directory is unsafe")
def run_preflight(socket_path: str, timeout_s: float = 10.0) -> PreflightSummary:
"""Port of ``PreflightRuntime`` + ``validateRuntimeCapabilities``."""
validate_socket_file(socket_path)
with grpc.insecure_channel(f"unix:{socket_path}") as channel:
stub = pb2_grpc.RuntimeAdapterServiceStub(channel)
try:
health = stub.Health(pb2.HealthRequest(), timeout=timeout_s)
except grpc.RpcError as exc:
raise PreflightError(f"health call failed: {exc.code().name}")
if (
health.state != pb2.SERVING_STATE_READY
or not health.runtime_version.strip()
or not health.adapter_version.strip()
):
raise PreflightError("runtime is not ready with versioned adapter evidence")
try:
caps = stub.GetCapabilities(pb2.GetCapabilitiesRequest(), timeout=timeout_s)
except grpc.RpcError as exc:
raise PreflightError(f"capabilities call failed: {exc.code().name}")
return _validate_capabilities(socket_path, health, caps)
def _validate_capabilities(socket_path, health, caps) -> PreflightSummary:
if not caps.runtime_version.strip() or not caps.adapter_version.strip():
raise PreflightError("capabilities lack version evidence")
if (
caps.runtime_version != health.runtime_version
or caps.adapter_version != health.adapter_version
):
raise PreflightError("health and capabilities versions disagree")
if not caps.devices:
raise PreflightError("no execution devices reported")
total_slots = free_slots = 0
seen_devices: set[str] = set()
for device in caps.devices:
if (
not device.device_id.strip()
or not device.hardware_class.strip()
or device.total_vram_bytes == 0
or device.total_slots == 0
or device.free_slots > device.total_slots
):
raise PreflightError("invalid execution device reported")
if device.device_id in seen_devices:
raise PreflightError("duplicate execution device reported")
seen_devices.add(device.device_id)
if (
total_slots + device.total_slots > _MAX_UINT32
or free_slots + device.free_slots > _MAX_UINT32
):
raise PreflightError("slot total overflows protocol limit")
total_slots += device.total_slots
free_slots += device.free_slots
ready = 0
seen_models: set[tuple[str, str, str]] = set()
for model in caps.models:
if model.state != pb2.RUNTIME_MODEL_STATE_READY:
continue
if (
not model.catalog_model_id.strip()
or not model.model_version.strip()
or not model.model_digest.strip()
or not model.precisions
):
raise PreflightError("invalid ready model reported")
identity = (model.catalog_model_id, model.model_version, model.model_digest)
if identity in seen_models:
raise PreflightError("duplicate ready model reported")
seen_models.add(identity)
ready += 1
if ready == 0:
raise PreflightError("no ready model reported")
return PreflightSummary(
socket_path=socket_path,
runtime_version=health.runtime_version,
adapter_version=health.adapter_version,
device_count=len(caps.devices),
ready_model_count=ready,
total_slots=total_slots,
free_slots=free_slots,
)
def selfcheck(timeout_s: float = 10.0) -> int:
"""Start the production server on a temp socket and preflight it."""
from .production import build_runtime_context # noqa: PLC0415
from .server import create_server # noqa: PLC0415
context = build_runtime_context()
warm = getattr(context.inventory, "warm", None)
if callable(warm):
print("selfcheck: warming model inventory (first run hashes weights)…")
warm()
# Short prefix: macOS caps Unix-socket paths at 103 characters and the
# default macOS tempdir is already ~60 characters deep.
with tempfile.TemporaryDirectory(prefix="vs-rta-") as tmp:
os.chmod(tmp, 0o700)
socket_path = os.path.join(tmp, "runtime.sock")
server = create_server(context, socket_path)
server.start()
try:
summary = run_preflight(socket_path, timeout_s=timeout_s)
except PreflightError as failure:
print(f"selfcheck: FAIL: {failure}")
return 1
finally:
server.stop(grace=2).wait()
print(f"selfcheck: OK: {summary.render()}")
return 0
-208
View File
@@ -1,208 +0,0 @@
"""The gRPC server: Unix-domain socket only, no HTTP, no TCP.
``Health`` and ``GetCapabilities`` read the same version constants from one
:class:`RuntimeContext`, so the "identical versions" preflight expectation
holds by construction. Socket-path safety mirrors the Go preflight's checks
(absolute path, no symlink, parent directory not world-writable) at bind time
so an unsafe deployment fails closed on our side too.
"""
from __future__ import annotations
import os
import stat as stat_module
import threading
from concurrent import futures
from dataclasses import dataclass, field
import grpc
from . import ADAPTER_VERSION, DEFAULT_SOCKET_PATH, SOCKET_ENV
from .executor import AttemptRegistry, Executor
from .gen import runtime_adapter_pb2 as pb2
from .gen import runtime_adapter_pb2_grpc as pb2_grpc
from .inventory import (
STATE_FAILED,
STATE_INSTALLED,
STATE_LOADING,
STATE_READY,
)
_MODEL_STATE_TO_PB = {
STATE_INSTALLED: pb2.RUNTIME_MODEL_STATE_INSTALLED,
STATE_LOADING: pb2.RUNTIME_MODEL_STATE_LOADING,
STATE_READY: pb2.RUNTIME_MODEL_STATE_READY,
STATE_FAILED: pb2.RUNTIME_MODEL_STATE_FAILED,
}
@dataclass
class RuntimeContext:
"""Everything the servicer needs; tests build it from fakes."""
runtime_version: str
inventory: object
engine_provider: object
adapter_version: str = ADAPTER_VERSION
slot_limit: int = 1
progress_interval: float = 0.5
poll_interval: float = 0.02
registry: AttemptRegistry = field(default_factory=AttemptRegistry)
def executor(self) -> Executor:
return Executor(
self.inventory,
self.engine_provider,
self.registry,
slot_limit=self.slot_limit,
progress_interval=self.progress_interval,
poll_interval=self.poll_interval,
)
class RuntimeAdapterServicer(pb2_grpc.RuntimeAdapterServiceServicer):
def __init__(self, context: RuntimeContext):
self._context = context
self._executor = context.executor()
def Health(self, request, grpc_context):
flags: list[str] = []
state = pb2.SERVING_STATE_READY
try:
devices = self._context.inventory.devices(
busy_slots=self._context.registry.active_count()
)
models = self._context.inventory.models()
except Exception:
return pb2.HealthResponse(
state=pb2.SERVING_STATE_UNHEALTHY,
runtime_version=self._context.runtime_version,
adapter_version=self._context.adapter_version,
health_flags=["inventory-error"],
)
if not devices:
state = pb2.SERVING_STATE_UNHEALTHY
flags.append("no-device")
if not any(model.state == STATE_READY for model in models):
state = max(state, pb2.SERVING_STATE_DEGRADED)
flags.append("no-ready-model")
return pb2.HealthResponse(
state=state,
runtime_version=self._context.runtime_version,
adapter_version=self._context.adapter_version,
health_flags=flags,
)
def GetCapabilities(self, request, grpc_context):
busy = self._context.registry.active_count()
response = pb2.GetCapabilitiesResponse(
runtime_version=self._context.runtime_version,
adapter_version=self._context.adapter_version,
)
for device in self._context.inventory.devices(busy_slots=busy):
response.devices.append(
pb2.RuntimeDevice(
device_id=device.device_id,
hardware_class=device.hardware_class,
total_vram_bytes=device.total_vram_bytes,
total_slots=device.total_slots,
free_slots=device.free_slots,
)
)
for model in self._context.inventory.models():
response.models.append(
pb2.RuntimeModel(
catalog_model_id=model.catalog_model_id,
model_version=model.model_version,
model_digest=model.model_digest,
precisions=list(model.precisions),
features=list(model.features),
state=_MODEL_STATE_TO_PB.get(
model.state, pb2.RUNTIME_MODEL_STATE_UNSPECIFIED
),
)
)
return response
def Execute(self, request, grpc_context):
yield from self._executor.execute(request, grpc_context)
def Cancel(self, request, grpc_context):
disposition = self._context.registry.cancel(request.job_id, request.attempt_id)
return pb2.CancelResponse(disposition=disposition)
def resolve_socket_path(explicit: str | None = None) -> str:
return (
(explicit or "").strip()
or os.environ.get(SOCKET_ENV, "").strip()
or DEFAULT_SOCKET_PATH
)
def prepare_socket(socket_path: str) -> str:
"""Fail closed on any unsafe socket placement; remove only a stale socket."""
if not socket_path or not os.path.isabs(socket_path):
raise ValueError("runtime socket path must be absolute")
parent = os.path.dirname(socket_path)
try:
parent_stat = os.stat(parent)
except OSError as exc:
raise ValueError(f"runtime socket directory is missing: {exc}") from exc
if not stat_module.S_ISDIR(parent_stat.st_mode) or parent_stat.st_mode & 0o002:
raise ValueError("runtime socket directory is unsafe (world-writable?)")
try:
existing = os.lstat(socket_path)
except FileNotFoundError:
return socket_path
if stat_module.S_ISSOCK(existing.st_mode):
os.unlink(socket_path) # stale socket from a previous run
return socket_path
raise ValueError("runtime socket path exists and is not a socket")
def create_server(
context: RuntimeContext, socket_path: str, *, max_workers: int | None = None
) -> grpc.Server:
prepare_socket(socket_path)
workers = max_workers or max(8, context.slot_limit * 2 + 4)
server = grpc.server(
futures.ThreadPoolExecutor(
max_workers=workers, thread_name_prefix="runtime-adapter"
)
)
pb2_grpc.add_RuntimeAdapterServiceServicer_to_server(
RuntimeAdapterServicer(context), server
)
bound = server.add_insecure_port(f"unix:{socket_path}")
if bound == 0:
raise RuntimeError("failed to bind the runtime adapter socket")
return server
def serve(context: RuntimeContext, socket_path: str) -> int:
"""Run until SIGINT/SIGTERM. Returns a process exit code."""
import signal # noqa: PLC0415
warm = getattr(context.inventory, "warm", None)
if callable(warm):
warm() # hash installed snapshots before the socket exists
server = create_server(context, socket_path)
server.start()
try:
os.chmod(socket_path, 0o660) # gateway runs under the same service identity
except OSError:
pass
stop = threading.Event()
def _stop(_signum, _frame):
stop.set()
signal.signal(signal.SIGTERM, _stop)
signal.signal(signal.SIGINT, _stop)
stop.wait()
server.stop(grace=10).wait()
try:
os.unlink(socket_path)
except OSError:
pass
return 0
-402
View File
@@ -1,402 +0,0 @@
"""Process-bound credentials for the first-party remote administration UI.
The durable ``OMNIVOICE_API_KEY`` is an operator secret, not a browser session.
This module exchanges it for opaque, bounded-lifetime credentials without
depending on FastAPI or persisting a verifier to disk.
"""
from __future__ import annotations
import hmac
import re
import secrets
import sys
import threading
import time
from types import ModuleType
from base64 import urlsafe_b64encode
from collections import OrderedDict
from collections.abc import Callable
from dataclasses import dataclass, field
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
SESSION_TTL_SECONDS = 8 * 60 * 60
WS_TICKET_TTL_SECONDS = 30
MAX_ADMIN_SESSIONS = 256
MAX_WS_TICKETS = 512
ADMIN_SESSION_PREFIX = "ovs_admin_session_"
WS_TICKET_PREFIX = "ovs_ws_ticket_"
_TOKEN_BYTES = 32
_ENCODED_TOKEN_LENGTH = 43
_TOKEN_BODY_RE = re.compile(rf"^[A-Za-z0-9_-]{{{_ENCODED_TOKEN_LENGTH}}}$")
_ALLOWED_WS_PATHS = frozenset({"/ws/events", "/ws/transcribe"})
_ADMIN_CAPABILITIES = frozenset({"consume", "admin"})
_KEY_GENERATION_INFO = b"omnivoice-admin-key-generation-v1"
def _hash_token(token: str, pepper: bytes) -> str:
# These are 256-bit random values, not user-chosen passwords. A keyed,
# process-local index is the right primitive: there is no feasible password
# dictionary to slow down, and a copied record is unusable without the
# store's independently generated pepper.
return hmac.digest(pepper, token.encode("utf-8"), "sha256").hex()
def _encode_token(raw: bytes) -> str:
return urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
@dataclass(frozen=True)
class IssuedSession:
token: str = field(repr=False)
expires_at: float
@dataclass(frozen=True)
class IssuedTicket:
token: str = field(repr=False)
expires_at: float
@dataclass(frozen=True)
class SessionRecord:
credential_id: str
capabilities: frozenset[str]
issued_at: float
expires_at: float
@dataclass(frozen=True)
class _StoredSession:
credential_id: str
issued_monotonic: float
expires_monotonic: float
issued_at: float
expires_at: float
def public(self) -> SessionRecord:
return SessionRecord(
credential_id=self.credential_id,
capabilities=_ADMIN_CAPABILITIES,
issued_at=self.issued_at,
expires_at=self.expires_at,
)
@dataclass(frozen=True)
class _StoredTicket:
session_hash: str
path: str
issued_monotonic: float
expires_monotonic: float
class AdminSessionStore:
"""Thread-safe, process-local store for admin sessions and WS tickets."""
def __init__(
self,
*,
monotonic: Callable[[], float] = time.monotonic,
wall_time: Callable[[], float] = time.time,
token_bytes: Callable[[int], bytes] = secrets.token_bytes,
pepper: bytes | None = None,
session_ttl_seconds: int = SESSION_TTL_SECONDS,
ws_ticket_ttl_seconds: int = WS_TICKET_TTL_SECONDS,
max_sessions: int = MAX_ADMIN_SESSIONS,
max_tickets: int = MAX_WS_TICKETS,
) -> None:
if session_ttl_seconds <= 0 or ws_ticket_ttl_seconds <= 0:
raise ValueError("credential TTLs must be positive")
if max_sessions <= 0 or max_tickets <= 0:
raise ValueError("credential store capacities must be positive")
self._monotonic = monotonic
self._wall_time = wall_time
self._token_bytes = token_bytes
self._pepper = pepper if pepper is not None else secrets.token_bytes(32)
if len(self._pepper) < 32:
raise ValueError("session-store pepper must contain at least 256 bits")
self._session_ttl = session_ttl_seconds
self._ticket_ttl = ws_ticket_ttl_seconds
self._max_sessions = max_sessions
self._max_tickets = max_tickets
self._sessions: OrderedDict[str, _StoredSession] = OrderedDict()
self._tickets: OrderedDict[str, _StoredTicket] = OrderedDict()
self._ticket_hashes_by_session: dict[str, set[str]] = {}
self._key_generation: bytes | None = None
self._lock = threading.RLock()
def __repr__(self) -> str:
snapshot = self.debug_snapshot()
return (
"AdminSessionStore("
f"sessions={snapshot['sessions']}, ws_tickets={snapshot['ws_tickets']})"
)
@staticmethod
def _normalize_master(api_key: str | None) -> str:
return api_key.strip() if isinstance(api_key, str) else ""
def _generation(self, api_key: str) -> bytes:
return HKDF(
algorithm=hashes.SHA256(),
length=32,
salt=self._pepper,
info=_KEY_GENERATION_INFO,
).derive(api_key.encode("utf-8", errors="surrogatepass"))
def _sync_key_locked(self, api_key: str | None) -> bool:
normalized = self._normalize_master(api_key)
if not normalized:
self._clear_credentials_locked()
self._key_generation = None
return False
generation = self._generation(normalized)
if self._key_generation is None:
self._key_generation = generation
return True
if not hmac.compare_digest(self._key_generation, generation):
self._clear_credentials_locked()
self._key_generation = generation
return True
@staticmethod
def _valid_token(token: str | None, prefix: str) -> bool:
if not isinstance(token, str) or not token.startswith(prefix):
return False
return bool(_TOKEN_BODY_RE.fullmatch(token.removeprefix(prefix)))
def _new_token_locked(self, prefix: str, existing: object) -> tuple[str, str]:
for _attempt in range(8):
raw = self._token_bytes(_TOKEN_BYTES)
if not isinstance(raw, bytes) or len(raw) != _TOKEN_BYTES:
raise RuntimeError("token source must return exactly 32 bytes")
token = prefix + _encode_token(raw)
token_hash = _hash_token(token, self._pepper)
if token_hash not in existing:
return token, token_hash
raise RuntimeError("credential token source produced repeated collisions")
def _clear_credentials_locked(self) -> None:
self._sessions.clear()
self._tickets.clear()
self._ticket_hashes_by_session.clear()
def _remove_ticket_locked(self, ticket_hash: str) -> _StoredTicket | None:
ticket = self._tickets.pop(ticket_hash, None)
if ticket is None:
return None
session_tickets = self._ticket_hashes_by_session.get(ticket.session_hash)
if session_tickets is not None:
session_tickets.discard(ticket_hash)
if not session_tickets:
self._ticket_hashes_by_session.pop(ticket.session_hash, None)
return ticket
def _remove_session_locked(self, session_hash: str) -> _StoredSession | None:
record = self._sessions.pop(session_hash, None)
for ticket_hash in tuple(self._ticket_hashes_by_session.get(session_hash, ())):
self._remove_ticket_locked(ticket_hash)
# Defensive cleanup keeps a prior partial mutation from preserving a
# dangling reverse-index bucket even when the session was already gone.
self._ticket_hashes_by_session.pop(session_hash, None)
return record
def _purge_locked(self, now: float) -> None:
# TTLs are fixed per store and monotonic issue times never decrease, so
# insertion order is expiry order. Only the expired prefix can require
# work; the common request path examines at most one record per type.
while self._sessions:
session_hash = next(iter(self._sessions))
if now < self._sessions[session_hash].expires_monotonic:
break
self._remove_session_locked(session_hash)
while self._tickets:
ticket_hash = next(iter(self._tickets))
if now < self._tickets[ticket_hash].expires_monotonic:
break
self._remove_ticket_locked(ticket_hash)
def _evict_sessions_locked(self) -> None:
while len(self._sessions) >= self._max_sessions:
self._remove_session_locked(next(iter(self._sessions)))
def _evict_tickets_locked(self) -> None:
while len(self._tickets) >= self._max_tickets:
self._remove_ticket_locked(next(iter(self._tickets)))
def issue(self, api_key: str) -> IssuedSession:
normalized = self._normalize_master(api_key)
if not normalized:
raise ValueError("configured API key required")
with self._lock:
self._sync_key_locked(normalized)
now = self._monotonic()
wall_now = self._wall_time()
self._purge_locked(now)
self._evict_sessions_locked()
token, token_hash = self._new_token_locked(ADMIN_SESSION_PREFIX, self._sessions)
expires_monotonic = now + self._session_ttl
expires_at = wall_now + self._session_ttl
self._sessions[token_hash] = _StoredSession(
credential_id=token_hash,
issued_monotonic=now,
expires_monotonic=expires_monotonic,
issued_at=wall_now,
expires_at=expires_at,
)
return IssuedSession(token=token, expires_at=expires_at)
def resolve(self, token: str | None, api_key: str | None) -> SessionRecord | None:
if not self._valid_token(token, ADMIN_SESSION_PREFIX):
return None
assert isinstance(token, str)
with self._lock:
if not self._sync_key_locked(api_key):
return None
now = self._monotonic()
self._purge_locked(now)
record = self._sessions.get(_hash_token(token, self._pepper))
if record is None or now >= record.expires_monotonic:
return None
return record.public()
def revoke(self, token: str | None) -> bool:
if not self._valid_token(token, ADMIN_SESSION_PREFIX):
return False
assert isinstance(token, str)
token_hash = _hash_token(token, self._pepper)
with self._lock:
return self._remove_session_locked(token_hash) is not None
def revoke_by_credential(self, credential_id: str | None) -> bool:
if not isinstance(credential_id, str) or len(credential_id) != 64:
return False
with self._lock:
return self._remove_session_locked(credential_id) is not None
def issue_ws_ticket(
self,
session_token: str | None,
path: str,
api_key: str | None,
) -> IssuedTicket:
if path not in _ALLOWED_WS_PATHS:
raise ValueError("WebSocket path is not allowed")
if not self._valid_token(session_token, ADMIN_SESSION_PREFIX):
raise PermissionError("valid admin session required")
assert isinstance(session_token, str)
session_hash = _hash_token(session_token, self._pepper)
return self.issue_ws_ticket_for_credential(session_hash, path, api_key)
def issue_ws_ticket_for_credential(
self,
credential_id: str | None,
path: str,
api_key: str | None,
) -> IssuedTicket:
if path not in _ALLOWED_WS_PATHS:
raise ValueError("WebSocket path is not allowed")
with self._lock:
if not isinstance(credential_id, str) or len(credential_id) != 64:
raise PermissionError("valid admin session required")
if not self._sync_key_locked(api_key):
raise PermissionError("valid admin session required")
now = self._monotonic()
self._purge_locked(now)
session = self._sessions.get(credential_id)
if session is None or now >= session.expires_monotonic:
raise PermissionError("valid admin session required")
self._evict_tickets_locked()
token, token_hash = self._new_token_locked(WS_TICKET_PREFIX, self._tickets)
expires_at = self._wall_time() + self._ticket_ttl
self._tickets[token_hash] = _StoredTicket(
session_hash=credential_id,
path=path,
issued_monotonic=now,
expires_monotonic=now + self._ticket_ttl,
)
self._ticket_hashes_by_session.setdefault(credential_id, set()).add(
token_hash
)
return IssuedTicket(token=token, expires_at=expires_at)
def consume_ws_ticket(
self,
ticket_token: str | None,
path: str,
api_key: str | None,
) -> SessionRecord | None:
if not self._valid_token(ticket_token, WS_TICKET_PREFIX):
return None
assert isinstance(ticket_token, str)
with self._lock:
if not self._sync_key_locked(api_key):
return None
now = self._monotonic()
self._purge_locked(now)
ticket = self._remove_ticket_locked(
_hash_token(ticket_token, self._pepper)
)
if ticket is None or now >= ticket.expires_monotonic or ticket.path != path:
return None
session = self._sessions.get(ticket.session_hash)
if session is None or now >= session.expires_monotonic:
return None
return session.public()
def clear(self) -> None:
with self._lock:
self._clear_credentials_locked()
self._key_generation = None
@property
def active_session_count(self) -> int:
with self._lock:
self._purge_locked(self._monotonic())
return len(self._sessions)
def debug_snapshot(self) -> dict[str, int]:
with self._lock:
self._purge_locked(self._monotonic())
return {"sessions": len(self._sessions), "ws_tickets": len(self._tickets)}
#: Synthetic ``sys.modules`` key holding the one per-process store. A module
#: object in ``sys.modules`` is the only namespace that survives everything
#: test suites do to this package: ``importlib.reload`` re-executes module
#: code but never touches unrelated ``sys.modules`` entries, and the purges
#: that pop whole ``services.*`` / ``api.*`` trees match package prefixes this
#: underscore-prefixed top-level name is outside of.
_ANCHOR_MODULE_NAME = "_omnivoice_admin_session_store_anchor"
def _process_store() -> AdminSessionStore:
"""Return THE per-process store, however this module was (re)imported.
Auth is process-global state: the copy of this module that issues a
credential and the copy that later resolves it must always be looking at
the same store. A bare module-level ``AdminSessionStore()`` breaks that
the moment anything reloads or re-imports this module (fresh module dict
fresh store freshly issued sessions vanish for holders of the old
reference, and vice versa). Anchoring the instance outside the module's
own namespace makes every copy of this module share one store.
"""
anchor = sys.modules.get(_ANCHOR_MODULE_NAME)
if not isinstance(anchor, ModuleType):
anchor = ModuleType(_ANCHOR_MODULE_NAME)
anchor.__doc__ = "Process-global anchor for the VoiceStudio admin-session store."
sys.modules[_ANCHOR_MODULE_NAME] = anchor
store = getattr(anchor, "admin_session_store", None)
if store is None:
store = AdminSessionStore()
anchor.admin_session_store = store
return store
admin_session_store = _process_store()
+12 -55
View File
@@ -520,10 +520,12 @@ class WhisperXBackend(ASRBackend):
def _pick_device() -> tuple[str, str]:
# CUDA fp16 when available; otherwise CPU int8 (fastest CPU path,
# negligible WER regression vs fp32 for whisper-large-v3).
# _ctranslate2_cuda_ok, not torch.cuda.is_available: ROCm torch also
# answers True there, and CTranslate2 has no HIP backend (#1529).
if _ctranslate2_cuda_ok():
return "cuda", "float16"
try:
import torch
if torch.cuda.is_available():
return "cuda", "float16"
except Exception:
pass
return "cpu", "int8"
# Peak VRAM (GB) to load *and transcribe* whisper large-v3 per CTranslate2
@@ -979,10 +981,12 @@ class FasterWhisperBackend(ASRBackend):
# - Apple Silicon / CPU → CPU int8 (fastest on CPU, negligible
# WER regression vs fp32 for whisper-large-v3)
device, compute_type = "cpu", "int8"
# _ctranslate2_cuda_ok, not torch.cuda.is_available: ROCm torch also
# answers True there, and CTranslate2 has no HIP backend (#1529).
if _ctranslate2_cuda_ok():
device, compute_type = "cuda", "float16"
try:
import torch
if torch.cuda.is_available():
device, compute_type = "cuda", "float16"
except Exception:
pass
logger.info(
"faster-whisper loading %s on %s (%s)",
self._model_name, device, compute_type,
@@ -2460,45 +2464,6 @@ def _mps_available() -> bool:
return False
def _cuda_reported_available() -> bool:
"""``torch.cuda.is_available()`` verbatim — True on real CUDA *and* HIP."""
try:
import torch
return bool(torch.cuda.is_available())
except Exception: # noqa: BLE001 — no torch
return False
def _rocm_torch() -> bool:
"""True when torch is the ROCm (HIP) build.
ROCm torch masquerades as CUDA: ``torch.cuda.is_available()`` answers True
and tensors live on ``"cuda"`` devices, but the CUDA *runtime libraries*
other packages ship are still NVIDIA-only. ``torch.version.hip`` is the
one honest tell.
"""
try:
import torch
return getattr(torch.version, "hip", None) is not None
except Exception: # noqa: BLE001 — no torch
return False
def _ctranslate2_cuda_ok() -> bool:
"""Whether CTranslate2 (whisperx / faster-whisper) may use ``"cuda"``.
CTranslate2 has NO HIP backend. On a ROCm host torch says cuda is
available (HIP), the device string is handed to CTranslate2, and its
NVIDIA CUDA runtime dies with "CUDA driver version is insufficient for
CUDA runtime version" — the #1529 report, an AMD RX 7900 XTX in the
:rocm Docker image. Real CUDA only; ROCm hosts take the CPU path here
(auto-detect prefers pytorch-whisper there, which does use HIP).
"""
return _cuda_reported_available() and not _rocm_torch()
def _auto_detect() -> str:
"""Pick the best available ASR engine **for this hardware**.
@@ -2530,14 +2495,6 @@ def _auto_detect() -> str:
"""
if _mps_available() and _probe_available(MLXWhisperBackend):
return "mlx-whisper"
# Same class as the Apple case, on the ROCm axis (#1529): whisperx and
# faster-whisper are CTranslate2, which has no HIP backend — on a ROCm
# host they run on the CPU while the GPU sits idle (and before
# _ctranslate2_cuda_ok they died outright trying NVIDIA's runtime).
# pytorch-whisper is a pure transformers pipeline riding torch itself,
# so it genuinely uses the HIP GPU there.
if _rocm_torch() and _cuda_reported_available() and _probe_available(PyTorchWhisperBackend):
return "pytorch-whisper"
if _probe_available(WhisperXBackend):
return "whisperx"
if _probe_available(FasterWhisperBackend):
-145
View File
@@ -1,145 +0,0 @@
"""Opt-in adapter from OSS profiles/generation to the hosted v1 contract.
Local VoiceStudio never calls this module unless the caller explicitly requests
``hosted`` execution *and* all VSS_HOSTED_* settings are present. It stages
text/reference bytes as hosted Artifacts, creates a consent-backed Voice, and
uses durable Jobs; no local path, source recording URL, or plaintext text is
sent in a Job snapshot.
"""
from __future__ import annotations
import asyncio
import hashlib
import os
import time
import uuid
from dataclasses import dataclass
from pathlib import Path
import httpx
class HostedVoiceError(RuntimeError):
"""A safe, user-actionable hosted adapter failure."""
@dataclass(frozen=True)
class HostedSettings:
base_url: str
token: str
project_id: str
model_id: str
model_version: str
base_voice_id: str
consent_text_version: str
@classmethod
def from_environment(cls) -> "HostedSettings | None":
values = {
name: os.environ.get(name, "").strip()
for name in (
"VSS_HOSTED_API_BASE", "VSS_HOSTED_API_TOKEN",
"VSS_HOSTED_PROJECT_ID", "VSS_HOSTED_MODEL_ID",
"VSS_HOSTED_MODEL_VERSION", "VSS_HOSTED_BASE_VOICE_ID",
)
}
if not any(values.values()):
return None
missing = [name for name, value in values.items() if not value]
if missing:
raise HostedVoiceError("Hosted execution is incomplete; configure " + ", ".join(missing) + ".")
base_url = values["VSS_HOSTED_API_BASE"].rstrip("/")
if not base_url.startswith(("http://", "https://")):
raise HostedVoiceError("VSS_HOSTED_API_BASE must be an http(s) URL.")
return cls(
base_url=base_url, token=values["VSS_HOSTED_API_TOKEN"],
project_id=values["VSS_HOSTED_PROJECT_ID"], model_id=values["VSS_HOSTED_MODEL_ID"],
model_version=values["VSS_HOSTED_MODEL_VERSION"], base_voice_id=values["VSS_HOSTED_BASE_VOICE_ID"],
consent_text_version=os.environ.get("VSS_HOSTED_CONSENT_TEXT_VERSION", "oss-spoken-consent-v1").strip() or "oss-spoken-consent-v1",
)
class HostedVoiceClient:
def __init__(self, settings: HostedSettings, client: httpx.AsyncClient | None = None):
self.settings = settings
self.client = client or httpx.AsyncClient(base_url=settings.base_url, timeout=60)
self._owns_client = client is None
async def aclose(self) -> None:
if self._owns_client:
await self.client.aclose()
def _headers(self, *, idempotency: bool = False) -> dict[str, str]:
headers = {"Authorization": f"Bearer {self.settings.token}"}
if idempotency:
headers["Idempotency-Key"] = str(uuid.uuid4())
return headers
async def _request(self, method: str, path: str, *, json: dict | None = None, headers: dict | None = None) -> httpx.Response:
response = await self.client.request(method, path, json=json, headers=headers)
if response.is_error:
detail = "hosted service rejected the request"
try:
body = response.json()
detail = body.get("error", {}).get("message") or body.get("detail") or detail
except ValueError:
pass
raise HostedVoiceError(f"Hosted request failed ({response.status_code}): {detail}")
return response
async def upload_artifact(self, *, purpose: str, media_type: str, payload: bytes) -> str:
digest = hashlib.sha256(payload).hexdigest()
grant = (await self._request("POST", "/v1/artifacts/upload-authorizations", json={
"project_id": self.settings.project_id, "purpose": purpose, "media_type": media_type,
"size_bytes": len(payload), "sha256": digest,
}, headers=self._headers())).json()
put_headers = {k: v for k, v in (grant.get("required_headers") or {}).items() if k.lower() not in {"host", "content-length"}}
put_headers.setdefault("Content-Type", media_type)
response = await self.client.request(grant.get("method", "PUT"), grant["url"], content=payload, headers=put_headers)
if response.is_error:
raise HostedVoiceError(f"Hosted Artifact upload failed ({response.status_code}).")
await self._request("POST", f"/v1/artifacts/{grant['artifact_id']}/complete", json={"size_bytes": len(payload), "sha256": digest}, headers=self._headers())
return grant["artifact_id"]
async def create_voice(self, *, name: str, description: str, reference_path: str) -> str:
payload = Path(reference_path).read_bytes()
if not payload:
raise HostedVoiceError("The reference recording is empty.")
suffix = Path(reference_path).suffix.lower()
media_type = {".wav": "audio/wav", ".mp3": "audio/mpeg", ".flac": "audio/flac"}.get(suffix, "audio/wav")
reference_id = await self.upload_artifact(purpose="reference_audio", media_type=media_type, payload=payload)
voice = await self._request("POST", "/v1/voices", json={
"project_id": self.settings.project_id, "display_name": name, "description": description[:1024],
"reference_audio_artifact_id": reference_id,
"consent": {"attestation_text_version": self.settings.consent_text_version},
}, headers=self._headers(idempotency=True))
return voice.json()["id"]
async def synthesize(self, *, text: str, profile_voice_id: str, language: str | None = None) -> bytes:
text_artifact = await self.upload_artifact(purpose="input", media_type="text/plain", payload=text.encode("utf-8"))
configuration = {"voice_id": self.settings.base_voice_id, "voice_reference_id": profile_voice_id, "output_format": "wav"}
if language and language != "Auto":
configuration["language"] = language
job = await self._request("POST", "/v1/jobs", json={
"project_id": self.settings.project_id, "workflow": "tts",
"model": {"id": self.settings.model_id, "version": self.settings.model_version},
"input": {"text_artifact_id": text_artifact}, "configuration": configuration,
}, headers=self._headers(idempotency=True))
job_id = job.json()["job_id"]
deadline = time.monotonic() + 15 * 60
while time.monotonic() < deadline:
view = (await self._request("GET", f"/v1/jobs/{job_id}", headers=self._headers())).json()
if view.get("state") == "succeeded":
outputs = view.get("output_artifact_ids") or []
if not outputs:
raise HostedVoiceError("Hosted synthesis completed without audio output.")
grant = (await self._request("POST", f"/v1/artifacts/{outputs[0]}/download-authorization", headers=self._headers())).json()
audio = await self.client.request(grant.get("method", "GET"), grant["url"])
if audio.is_error:
raise HostedVoiceError("Hosted synthesis output could not be downloaded.")
return audio.content
if view.get("state") in {"failed", "canceled"}:
raise HostedVoiceError("Hosted synthesis did not complete successfully.")
await asyncio.sleep(0.5)
raise HostedVoiceError("Hosted synthesis timed out waiting for its durable Job.")
-23
View File
@@ -251,33 +251,10 @@ def list_backends() -> list[dict]:
"effective_device": "network",
"routing_status": "n/a",
"routing_reason": None,
# The openai-compat family entry and the LLM Providers panel are
# ONE system (this backend resolves through the active provider),
# but the UI presented them as unrelated. Naming the resolved
# provider + model here lets the catalogue row say which endpoint
# actually answers, instead of a generic family label.
"hint": _provider_hint(bid) if ok else None,
})
return out
def _provider_hint(bid: str) -> str | None:
"""``Provider · model`` for the openai-compat row, None for everything else."""
if bid != "openai-compat":
return None
try:
from services import llm_providers
p = llm_providers.active_provider()
if p is None:
return None
model = llm_providers.resolve_model(p)
return f"{p.display_name} · {model}" if model else p.display_name
except Exception:
# The hint is decoration; a provider-registry hiccup must not take
# down the whole engines listing.
return None
def active_backend_id() -> str:
explicit = os.environ.get("OMNIVOICE_LLM_BACKEND")
if explicit:
-205
View File
@@ -1,205 +0,0 @@
"""Shared fakes and harness for the runtime-adapter tests.
Not a test module (no ``test_`` prefix): imported by
``test_runtime_adapter_capabilities.py`` and
``test_runtime_adapter_execute.py``.
"""
from __future__ import annotations
import contextlib
import hashlib
import os
import shutil
import tempfile
import threading
import time
import grpc
from runtime_adapter.gen import runtime_adapter_pb2 as pb2
from runtime_adapter.gen import runtime_adapter_pb2_grpc as pb2_grpc
from runtime_adapter.inventory import (
STATE_READY,
DeviceInfo,
ModelInfo,
)
from runtime_adapter.server import RuntimeContext, create_server
READY_MODEL = ModelInfo(
catalog_model_id="fake-tts",
model_version="a" * 40,
model_digest="sha256:" + "b" * 64,
precisions=("fp32",),
features=("tts",),
state=STATE_READY,
)
DEVICE = DeviceInfo(
device_id="cpu:0",
hardware_class="test-cpu",
total_vram_bytes=8 * 1024**3,
total_slots=1,
free_slots=1,
)
class FakeInventory:
def __init__(self, models=None, devices=None):
self._models = list(models) if models is not None else [READY_MODEL]
self._devices = list(devices) if devices is not None else [DEVICE]
def devices(self, busy_slots: int = 0):
return [
DeviceInfo(
device_id=d.device_id,
hardware_class=d.hardware_class,
total_vram_bytes=d.total_vram_bytes,
total_slots=d.total_slots,
free_slots=max(0, d.total_slots - busy_slots),
)
for d in self._devices
]
def models(self):
return list(self._models)
class FakeEngine:
"""Half a second of silence at 24 kHz, instantly."""
sample_rate = 24000
def __init__(self):
self.generate_calls = []
def ensure_ready(self):
pass
def generate(self, text, **kw):
import torch
self.generate_calls.append((text, kw))
return torch.zeros(1, 12000)
class SlowEngine(FakeEngine):
"""Sleeps through generate in small slices so tests stay responsive."""
def __init__(self, seconds: float = 10.0):
super().__init__()
self.seconds = seconds
self.started = threading.Event()
def generate(self, text, **kw):
self.started.set()
deadline = time.monotonic() + self.seconds
while time.monotonic() < deadline:
time.sleep(0.01)
return super().generate(text, **kw)
class FailingEngine(FakeEngine):
def __init__(self, exc: BaseException, phase: str = "synthesis"):
super().__init__()
self._exc = exc
self._phase = phase
def ensure_ready(self):
if self._phase == "model_load":
raise self._exc
def generate(self, text, **kw):
raise self._exc
def make_context(engine=None, inventory=None, **kw) -> RuntimeContext:
engine = engine if engine is not None else FakeEngine()
engines = {READY_MODEL.catalog_model_id: engine}
kw.setdefault("progress_interval", 0.05)
kw.setdefault("poll_interval", 0.005)
return RuntimeContext(
runtime_version="1.2.3-test",
inventory=inventory if inventory is not None else FakeInventory(),
engine_provider=lambda model_id: engines[model_id],
**kw,
)
@contextlib.contextmanager
def serve_over_socket(context: RuntimeContext, tmp_path=None):
# A pytest tmp_path routinely exceeds the 103-character Unix-socket path
# limit on macOS, so the socket gets its own short private tempdir.
socket_dir = tempfile.mkdtemp(prefix="vs-rta-")
socket_path = os.path.join(socket_dir, "runtime.sock")
server = create_server(context, socket_path)
server.start()
channel = grpc.insecure_channel(f"unix:{socket_path}")
try:
yield pb2_grpc.RuntimeAdapterServiceStub(channel), socket_path
finally:
channel.close()
server.stop(grace=0).wait()
shutil.rmtree(socket_dir, ignore_errors=True)
def make_execute_request(
tmp_path,
text: str = "hello runtime",
*,
attempt_id: str = "attempt-1",
job_id: str = "job-1",
model: ModelInfo = READY_MODEL,
device_id: str = "cpu:0",
deadline_in_s: float = 30.0,
parameters: dict | None = None,
input_sha256: str | None = None,
input_handle: str | None = None,
output_handle: str | None = None,
) -> pb2.ExecuteRequest:
if input_handle is None:
input_path = tmp_path / "input.txt"
input_path.write_text(text, encoding="utf-8")
input_handle = str(input_path)
if input_sha256 is None and text is not None:
input_sha256 = hashlib.sha256(text.encode("utf-8")).hexdigest()
if output_handle is None:
output_handle = str(tmp_path / "output.wav")
return pb2.ExecuteRequest(
job_id=job_id,
attempt_id=attempt_id,
device_id=device_id,
slot_id="slot-0",
model=pb2.ModelSpec(
catalog_model_id=model.catalog_model_id,
model_version=model.model_version,
model_digest=model.model_digest,
precision="fp32",
),
parameters=parameters or {},
inputs=[
pb2.LocalArtifact(
artifact_id="in-1",
local_handle=input_handle,
operation=pb2.LOCAL_ARTIFACT_OPERATION_READ,
expected_sha256=input_sha256 or "",
media_type="text/plain",
)
],
outputs=[
pb2.LocalArtifact(
artifact_id="out-1",
local_handle=output_handle,
operation=pb2.LOCAL_ARTIFACT_OPERATION_WRITE,
media_type="audio/wav",
)
],
deadline_unix_ms=int((time.time() + deadline_in_s) * 1000),
maximum_preview_bytes=0,
)
def terminal_of(events):
last = events[-1].event
kind = last.WhichOneof("payload")
assert kind in ("completed", "failed", "canceled"), kind
return kind, last
-29
View File
@@ -49,38 +49,9 @@ if not os.environ.get("OMNIVOICE_ENV_FILE"):
os.environ["OMNIVOICE_MODEL"] = "test"
import functools
import shutil
import pytest
@functools.lru_cache(maxsize=1)
def supports_symlinks() -> bool:
"""True when this process may create symlinks. On Windows,
``os.symlink`` raises OSError without Developer Mode or admin rights, so
symlink-dependent assertions must be skipped there rather than fail."""
probe_dir = tempfile.mkdtemp(prefix="omnivoice-symlink-probe-")
try:
target = os.path.join(probe_dir, "target")
with open(target, "w", encoding="utf-8"):
pass
try:
os.symlink(target, os.path.join(probe_dir, "link"))
except (OSError, NotImplementedError):
return False
return True
finally:
shutil.rmtree(probe_dir, ignore_errors=True)
@pytest.fixture(scope="session")
def symlinks_supported() -> bool:
"""Bool fixture over :func:`supports_symlinks` for guarding the
symlink-only assertions of a test while its other assertions still run."""
return supports_symlinks()
@pytest.fixture
def asr_model_installed(monkeypatch, request):
"""Neutralize the no-ASR-installed preflight (asr_model_missing_error →
+6 -271
View File
@@ -9,10 +9,7 @@ generation.py's proven ``_run_inference`` rather than re-implementing it.
"""
from __future__ import annotations
import io
import json
from pathlib import Path
import wave
import pytest
@@ -26,23 +23,6 @@ from core import archetypes # noqa: E402
from api.routers import archetypes as arch_router # noqa: E402
def _wav_bytes() -> bytes:
buf = io.BytesIO()
with wave.open(buf, "wb") as wav:
wav.setnchannels(1)
wav.setsampwidth(2)
wav.setframerate(24_000)
wav.writeframes(b"\x00\x01" * 64)
return buf.getvalue()
def _write_wav(path: Path) -> bytes:
data = _wav_bytes()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
return data
@pytest.fixture(scope="module")
def client():
app = FastAPI()
@@ -153,7 +133,8 @@ def test_preview_serves_cached_wav_without_model(client):
key = arch_router._preview_key(sample)
cache_dir = Path(arch_router._PREVIEW_DIR)
cache_dir.mkdir(parents=True, exist_ok=True)
dummy = _write_wav(cache_dir / f"{key}.wav")
dummy = b"RIFF\x24\x00\x00\x00WAVEfmt cached-archetype-preview"
(cache_dir / f"{key}.wav").write_bytes(dummy)
r = client.get(f"/archetypes/{sample['id']}/preview")
assert r.status_code == 200
@@ -162,7 +143,7 @@ def test_preview_serves_cached_wav_without_model(client):
# ── Materialize-on-use idempotency (dedup, no re-render) ───────────────────────
def test_use_is_idempotent_dedup(client, tmp_path, monkeypatch, symlinks_supported):
def test_use_is_idempotent_dedup(client, monkeypatch):
"""The 2nd `/use` of the same archetype reuses its one materialized profile
and does NOT render again the guarantee that materialize-on-select in any
voice picker can't spawn duplicate rows on repeated picks.
@@ -170,7 +151,6 @@ def test_use_is_idempotent_dedup(client, tmp_path, monkeypatch, symlinks_support
The render boundary (``_render_archetype_wav``) is mocked so no model/GPU is
needed: it just drops a stub WAV where the row expects one.
"""
from core import event_bus
from core.db import init_db
init_db() # ensure the voice_profiles table exists in the hermetic tmp DB
@@ -179,13 +159,10 @@ def test_use_is_idempotent_dedup(client, tmp_path, monkeypatch, symlinks_support
async def _fake_render(a, out_path):
render_calls["n"] += 1
_write_wav(Path(out_path))
Path(out_path).parent.mkdir(parents=True, exist_ok=True)
Path(out_path).write_bytes(b"RIFF\x24\x00\x00\x00WAVEfmt stub")
monkeypatch.setattr(arch_router, "_render_archetype_wav", _fake_render)
emitted = []
monkeypatch.setattr(
event_bus, "emit", lambda topic, payload: emitted.append((topic, payload)),
)
sample = archetypes.list_archetypes(featured=True)[0]
@@ -204,248 +181,6 @@ def test_use_is_idempotent_dedup(client, tmp_path, monkeypatch, symlinks_support
from core.db import db_conn
with db_conn() as conn:
rows = conn.execute(
"SELECT * FROM voice_profiles WHERE personality = ?",
(arch_router._archetype_personality(sample),),
"SELECT id FROM voice_profiles WHERE personality = ?", (sample["id"],)
).fetchall()
assert len(rows) == 1
assert rows[0]["kind"] == "design"
assert json.loads(rows[0]["vd_states"]) == sample["attrs"]
with db_conn() as conn:
row = conn.execute("SELECT * FROM voice_profiles WHERE id=?", (pid,)).fetchone()
assert row["kind"] == "design"
assert row["instruct"] == sample["instruct"]
assert json.loads(row["vd_states"]) == sample["attrs"]
# A missing sample or synthesis-input drift must be repaired before the
# existing profile is returned; Preview and Use must describe one voice.
audio_path = arch_router._profile_audio_path(row["ref_audio_path"])
assert audio_path is not None
audio_path.unlink()
repaired = client.post(f"/archetypes/{sample['id']}/use")
assert repaired.status_code == 200 and repaired.json()["profile_id"] == pid
assert render_calls["n"] == 2
assert audio_path.read_bytes().startswith(b"RIFF")
with db_conn() as conn:
conn.execute("UPDATE voice_profiles SET instruct='male' WHERE id=?", (pid,))
refreshed = client.post(f"/archetypes/{sample['id']}/use")
assert refreshed.status_code == 200
assert refreshed.json()["profile_id"] != pid
assert render_calls["n"] == 3
with db_conn() as conn:
edited = conn.execute("SELECT instruct FROM voice_profiles WHERE id=?", (pid,)).fetchone()
assert edited["instruct"] == "male"
# Continue corruption checks against the new canonical materialization.
pid = refreshed.json()["profile_id"]
with db_conn() as conn:
row = conn.execute("SELECT * FROM voice_profiles WHERE id=?", (pid,)).fetchone()
audio_path = arch_router._profile_audio_path(row["ref_audio_path"])
assert audio_path is not None
audio_path.write_bytes(b"not a WAV")
repaired_corrupt = client.post(f"/archetypes/{sample['id']}/use")
assert repaired_corrupt.status_code == 200
assert render_calls["n"] == 4
if symlinks_supported: # Windows needs Developer Mode to create symlinks
outside = tmp_path / "outside.wav"
outside_bytes = _write_wav(outside)
audio_path.unlink()
audio_path.symlink_to(outside)
repaired_symlink = client.post(f"/archetypes/{sample['id']}/use")
assert repaired_symlink.status_code == 200
assert render_calls["n"] == 5
assert not audio_path.is_symlink()
assert outside.read_bytes() == outside_bytes
# A valid header with a missing payload is not playable and must self-heal.
renders_before = render_calls["n"]
truncated = _wav_bytes()[:44]
audio_path.write_bytes(truncated)
repaired_truncated = client.post(f"/archetypes/{sample['id']}/use")
assert repaired_truncated.status_code == 200
assert render_calls["n"] == renders_before + 1
assert audio_path.read_bytes() != truncated
def test_archetype_staged_repair_preserves_concurrently_edited_profile(
client, monkeypatch,
):
"""A repair may publish only if the row still belongs to the archetype."""
from core.config import VOICES_DIR
from core.db import db_conn, init_db
init_db()
sample = archetypes.list_archetypes(featured=True)[3]
personality = arch_router._archetype_personality(sample)
edited_personality = f"user-edited:{sample['id']}"
with db_conn() as conn:
conn.execute(
"DELETE FROM voice_profiles WHERE personality IN (?, ?, ?)",
(sample["id"], personality, edited_personality),
)
original_id = {"value": None}
mutation_seen = {"value": False}
async def racing_render(_item, path):
destination = Path(path)
if destination.name.endswith(".staged.wav"):
assert original_id["value"] is not None
with db_conn() as conn:
conn.execute(
"UPDATE voice_profiles SET name='User edit', personality=? WHERE id=?",
(edited_personality, original_id["value"]),
)
mutation_seen["value"] = True
_write_wav(destination)
monkeypatch.setattr(arch_router, "_render_archetype_wav", racing_render)
first = client.post(f"/archetypes/{sample['id']}/use")
assert first.status_code == 200
original_id["value"] = first.json()["profile_id"]
with db_conn() as conn:
original = conn.execute(
"SELECT ref_audio_path FROM voice_profiles WHERE id=?",
(original_id["value"],),
).fetchone()
original_audio = arch_router._profile_audio_path(original["ref_audio_path"])
assert original_audio is not None
corrupt_bytes = b"corrupt user-owned sample"
original_audio.write_bytes(corrupt_bytes)
repaired = client.post(f"/archetypes/{sample['id']}/use")
assert repaired.status_code == 200
repaired_id = repaired.json()["profile_id"]
assert mutation_seen["value"]
assert repaired_id != original_id["value"]
with db_conn() as conn:
edited = conn.execute(
"SELECT * FROM voice_profiles WHERE id=?", (original_id["value"],),
).fetchone()
canonical = conn.execute(
"SELECT * FROM voice_profiles WHERE id=?", (repaired_id,),
).fetchone()
canonical_count = conn.execute(
"SELECT count(*) FROM voice_profiles WHERE personality=?", (personality,),
).fetchone()[0]
assert edited["name"] == "User edit"
assert edited["personality"] == edited_personality
assert edited["instruct"] == sample["instruct"]
assert original_audio.read_bytes() == corrupt_bytes
assert canonical["personality"] == personality
assert canonical["ref_audio_path"] == arch_router._profile_audio_filename(repaired_id)
assert canonical_count == 1
assert (Path(VOICES_DIR) / canonical["ref_audio_path"]).read_bytes() == _wav_bytes()
assert not list(Path(VOICES_DIR).glob(f".{original_id['value']}-*.staged.wav"))
def test_archetype_use_adopts_only_a_compatible_legacy_row(client, monkeypatch):
from core.config import VOICES_DIR
from core.db import db_conn, init_db
init_db()
sample = archetypes.list_archetypes(featured=True)[1]
legacy_id = "legacyarch"
legacy_audio = Path(VOICES_DIR) / f"{legacy_id}.wav"
_write_wav(legacy_audio)
with db_conn() as conn:
conn.execute(
"DELETE FROM voice_profiles WHERE personality IN (?, ?)",
(sample["id"], arch_router._archetype_personality(sample)),
)
conn.execute(
"INSERT INTO voice_profiles "
"(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, "
"kind, vd_states, created_at) VALUES (?, 'Legacy archetype', ?, ?, ?, ?, 42, ?, "
"'clone', NULL, 1)",
(
legacy_id, legacy_audio.name, sample["sample_script"], sample["instruct"],
sample["language"], sample["id"],
),
)
async def unexpected_render(*_args):
raise AssertionError("a valid legacy archetype sample must be reused")
monkeypatch.setattr(arch_router, "_render_archetype_wav", unexpected_render)
response = client.post(f"/archetypes/{sample['id']}/use")
assert response.status_code == 200
assert response.json()["profile_id"] == legacy_id
with db_conn() as conn:
row = conn.execute("SELECT * FROM voice_profiles WHERE id=?", (legacy_id,)).fetchone()
assert row["personality"] == arch_router._archetype_personality(sample)
assert row["kind"] == "design"
assert json.loads(row["vd_states"]) == sample["attrs"]
def test_archetype_use_does_not_rewrite_an_imported_personality_collision(
client, monkeypatch,
):
from core.config import VOICES_DIR
from core.db import db_conn, init_db
init_db()
sample = archetypes.list_archetypes(featured=True)[2]
imported_id = "importedarch"
imported_ns_id = "importedarchns"
imported_audio = Path(VOICES_DIR) / f"{imported_id}.wav"
imported_ns_audio = Path(VOICES_DIR) / f"{imported_ns_id}.wav"
original_audio = _write_wav(imported_audio)
original_ns_audio = _write_wav(imported_ns_audio)
with db_conn() as conn:
conn.execute(
"DELETE FROM voice_profiles WHERE personality IN (?, ?)",
(sample["id"], arch_router._archetype_personality(sample)),
)
conn.execute(
"INSERT INTO voice_profiles "
"(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, "
"kind, is_locked, verified_own_voice, created_at) VALUES "
"(?, 'Imported collision', ?, 'user transcript', 'male', 'Auto', NULL, ?, "
"'clone', 1, 1, 1)",
(imported_id, imported_audio.name, sample["id"]),
)
conn.execute(
"INSERT INTO voice_profiles "
"(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, "
"kind, vd_states, is_locked, verified_own_voice, created_at) VALUES "
"(?, 'Imported namespaced collision', ?, ?, ?, ?, 42, ?, "
"'design', NULL, 0, 0, 2)",
(
imported_ns_id, imported_ns_audio.name, sample["sample_script"],
sample["instruct"], sample["language"],
arch_router._archetype_personality(sample),
),
)
async def render(_item, path):
_write_wav(Path(path))
monkeypatch.setattr(arch_router, "_render_archetype_wav", render)
response = client.post(f"/archetypes/{sample['id']}/use")
assert response.status_code == 200
assert response.json()["profile_id"] != imported_id
with db_conn() as conn:
imported = conn.execute(
"SELECT * FROM voice_profiles WHERE id=?", (imported_id,),
).fetchone()
imported_ns = conn.execute(
"SELECT * FROM voice_profiles WHERE id=?", (imported_ns_id,),
).fetchone()
created = conn.execute(
"SELECT * FROM voice_profiles WHERE id=?", (response.json()["profile_id"],),
).fetchone()
assert imported["personality"] == sample["id"]
assert imported["instruct"] == "male"
assert imported["ref_text"] == "user transcript"
assert imported_audio.read_bytes() == original_audio
assert imported_ns["instruct"] == sample["instruct"]
assert imported_ns["ref_text"] == sample["sample_script"]
assert imported_ns["vd_states"] is None
assert imported_ns_audio.read_bytes() == original_ns_audio
assert created["personality"] == arch_router._archetype_personality(sample)
-44
View File
@@ -1,44 +0,0 @@
"""Regression tests for the lightweight persisted-WAV trust boundary."""
from __future__ import annotations
import struct
from core.audio_validation import is_playable_wav, resolve_regular_file
def test_oversized_declared_wav_payload_is_not_treated_as_playable(tmp_path):
"""A hostile frame count must be bounded and backed by real payload bytes."""
path = tmp_path / "oversized.wav"
declared_size = 0xFFFF_FFF0
header = struct.pack(
"<4sI4s4sIHHIIHH4sI",
b"RIFF",
0xFFFF_FFFF,
b"WAVE",
b"fmt ",
16,
1,
1,
24_000,
48_000,
2,
16,
b"data",
declared_size,
)
path.write_bytes(header + b"\x00\x01")
assert not is_playable_wav(path)
def test_profile_wav_resolution_rejects_escape_and_symlink(tmp_path, symlinks_supported):
root = tmp_path / "voices"
root.mkdir()
outside = tmp_path / "outside.wav"
outside.write_bytes(b"outside")
assert resolve_regular_file(root, "../outside.wav") is None
assert resolve_regular_file(root, str(outside)) is None
if symlinks_supported: # Windows needs Developer Mode to create symlinks
(root / "linked.wav").symlink_to(outside)
assert resolve_regular_file(root, "linked.wav") is None
+6 -680
View File
@@ -1,43 +1,25 @@
"""Tests for the community gallery (marketplace) loader.
Covers strict item validation, manifest/cache boundaries, same-origin preview,
and idempotent profile materialization without a model or network dependency.
Covers the no-network surface: strict item validation (invalid presets and
unsafe audio URLs are dropped so they can never crash synthesis or fetch from
an arbitrary host), manifest merge/dedup, offline cache reads, filtering, and
the prefilled submit URL. The render/download paths need the model/network and
are exercised at runtime.
"""
from __future__ import annotations
import io
import json
import os
from pathlib import Path
import wave
import pytest
# conftest.py puts `backend/` on sys.path and points OMNIVOICE_DATA_DIR at a
# throwaway tmpdir before this module imports the REAL core.config (the old
# sys.modules stub leaked at collection time and broke mixed runs).
from fastapi import FastAPI, HTTPException, Response # noqa: E402
from fastapi import FastAPI # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
from api.routers import community # noqa: E402
def _wav_bytes() -> bytes:
buf = io.BytesIO()
with wave.open(buf, "wb") as wav:
wav.setnchannels(1)
wav.setsampwidth(2)
wav.setframerate(24_000)
wav.writeframes(b"\x00\x01" * 64)
return buf.getvalue()
def _write_wav(path: Path) -> bytes:
data = _wav_bytes()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
return data
_FIXTURE = {
"schema_version": 1,
"items": [
@@ -93,51 +75,12 @@ def test_unknown_use_case_dropped():
assert community.validate_item(_FIXTURE["items"][4]) is None
def test_malformed_manifest_entries_do_not_break_other_sources():
valid = _FIXTURE["items"][0]
items, packs = community._merge([
("bad/repo", {"items": 42, "packs": "not-a-list"}),
("good/repo", {"items": [None, "not-an-item", valid], "packs": [None]}),
])
assert [item["id"] for item in items] == [valid["id"]]
assert packs == []
def test_is_valid_instruct():
assert community.is_valid_instruct("male, elderly, very low pitch")
assert not community.is_valid_instruct("male, sultry")
assert not community.is_valid_instruct("male, female")
assert not community.is_valid_instruct("british accent, 四川话")
assert not community.is_valid_instruct("")
def test_preset_attrs_are_normalized_and_complete():
item = community.validate_item(_FIXTURE["items"][0])
assert item["instruct"] == "female, middle-aged, low pitch"
assert item["attrs"] == {
"Gender": "female", "Age": "middle-aged", "Pitch": "low pitch",
"Style": "Auto", "EnglishAccent": "Auto", "ChineseDialect": "Auto",
}
assert item["preview_url"] == "/community/items/p1/preview"
def test_remote_transcript_fields_are_bounded():
preset = community.validate_item({
**_FIXTURE["items"][0],
"sample_script": " x " * (community._MAX_SAMPLE_SCRIPT_CHARS + 10),
})
voice = community.validate_item({
**_FIXTURE["items"][3],
"audio": {
**_FIXTURE["items"][3]["audio"],
"ref_text": " y " * (community._MAX_REF_TEXT_CHARS + 10),
},
})
assert len(preset["sample_script"]) == community._MAX_SAMPLE_SCRIPT_CHARS
assert len(voice["audio"]["ref_text"]) == community._MAX_REF_TEXT_CHARS
# ── merge keeps only valid items ──────────────────────────────────────────────
def test_merge_drops_invalid_and_dedups():
items, packs = community._merge([("debpalash/omnivoice-gallery", _FIXTURE)])
@@ -173,620 +116,3 @@ def test_submit_url(client):
voice = client.get("/community/submit-url", params={"type": "voice"}).json()["url"]
assert "preset-submission.yml" in preset and "omnivoice-gallery" in preset
assert "voice-submission.yml" in voice
# ── bounded cache freshness + stale offline fallback ─────────────────────────
def test_stale_manifest_refreshes_then_stays_fresh(tmp_path, monkeypatch):
monkeypatch.setattr(community, "_CACHE_DIR", tmp_path)
source = "debpalash/omnivoice-gallery"
cache = community._cache_path(source)
cache.parent.mkdir(parents=True)
cache.write_text(json.dumps(_FIXTURE), encoding="utf-8")
os.utime(cache, (100.0, 100.0))
fresh = {**_FIXTURE, "updated_at": "new"}
calls = []
monkeypatch.setattr(
community, "_fetch_remote_manifest",
lambda src: calls.append(src) or fresh,
)
now = 100.0 + community._MANIFEST_MAX_AGE_S + 1
assert community._fetch_manifest(source, False, now=now)["updated_at"] == "new"
assert community._fetch_manifest(source, False, now=now + 1)["updated_at"] == "new"
assert calls == [source]
def test_stale_manifest_falls_back_and_throttles_offline_retry(tmp_path, monkeypatch):
monkeypatch.setattr(community, "_CACHE_DIR", tmp_path)
source = "debpalash/omnivoice-gallery"
cache = community._cache_path(source)
cache.parent.mkdir(parents=True)
cache.write_text(json.dumps(_FIXTURE), encoding="utf-8")
os.utime(cache, (100.0, 100.0))
calls = []
def offline(src):
calls.append(src)
raise OSError("offline")
monkeypatch.setattr(community, "_fetch_remote_manifest", offline)
now = 100.0 + community._MANIFEST_MAX_AGE_S + 1
assert community._fetch_manifest(source, False, now=now) == _FIXTURE
assert community._fetch_manifest(source, False, now=now + 1) == _FIXTURE
assert calls == [source]
def test_manifest_fetch_is_bounded(monkeypatch):
monkeypatch.setattr(community, "_MAX_MANIFEST_BYTES", 8)
class Response:
status_code = 200
headers = {}
def __enter__(self): return self
def __exit__(self, *_args): return False
def raise_for_status(self): return None
def iter_bytes(self): yield b'{"items":[]}'
class Client:
def stream(self, method, url, **kwargs):
assert method == "GET"
assert url.startswith("https://cdn.jsdelivr.net/")
assert kwargs == {"follow_redirects": False}
return Response()
with pytest.raises(ValueError, match="size limit"):
community._fetch_remote_manifest("test/source", client=Client())
def test_manifest_fetch_rejects_redirect_before_external_request():
requested = []
class Response:
status_code = 302
headers = {"location": "https://evil.example/manifest.json"}
def __enter__(self): return self
def __exit__(self, *_args): return False
class Client:
def stream(self, _method, url, **_kwargs):
requested.append(url)
return Response()
with pytest.raises(ValueError, match="disallowed host"):
community._fetch_remote_manifest("test/source", client=Client())
assert requested == [community._manifest_url("test/source")]
# ── Preview proxy ─────────────────────────────────────────────────────────────
def test_canonical_preset_preview_delegates_same_origin(client, monkeypatch):
from core import archetypes
from api.routers import archetypes as arch_router
canonical = archetypes.list_archetypes(featured=True)[0]
item = community.validate_item({
**canonical, "type": "preset", "source": "starter",
})
monkeypatch.setattr(
community, "_load", lambda _refresh: (["test/source"], [item], [], False),
)
delegated = []
async def preview(archetype_id, local=False):
delegated.append((archetype_id, local))
return Response(_wav_bytes(), media_type="audio/wav")
monkeypatch.setattr(arch_router, "preview_archetype", preview)
response = client.get(f"/community/items/{item['id']}/preview")
local = client.get(f"/community/items/{item['id']}/preview?local=true")
assert response.status_code == local.status_code == 200
assert "location" not in response.headers
assert delegated == [(item["id"], False), (item["id"], True)]
def test_noncanonical_preset_preview_renders_once(client, tmp_path, monkeypatch):
item = community.validate_item(_FIXTURE["items"][0])
monkeypatch.setattr(community, "_CACHE_DIR", tmp_path)
monkeypatch.setattr(
community, "_load", lambda _refresh: (["test/source"], [item], [], False),
)
from api.routers import archetypes as arch_router
calls = []
async def render(_item, path):
calls.append(path)
_write_wav(Path(path))
monkeypatch.setattr(arch_router, "_render_archetype_wav", render)
first = client.get("/community/items/p1/preview")
second = client.get("/community/items/p1/preview")
assert first.status_code == second.status_code == 200
assert first.content == _wav_bytes()
assert first.headers["x-omnivoice-preview-source"] == "community"
assert len(calls) == 1
community._preset_preview_path(item).write_bytes(b"not audio")
repaired = client.get("/community/items/p1/preview")
assert repaired.status_code == 200
assert repaired.content == _wav_bytes()
assert len(calls) == 2
def test_recorded_preview_is_served_from_same_origin(client, tmp_path, monkeypatch):
item = community.validate_item(_FIXTURE["items"][3])
clip = tmp_path / "voice.wav"
expected = _write_wav(clip)
monkeypatch.setattr(
community, "_load", lambda _refresh: (["test/source"], [item], [], False),
)
monkeypatch.setattr(community, "_cached_voice_audio", lambda _item: clip)
response = client.get("/community/items/v1/preview")
assert response.status_code == 200
assert response.content == expected
def test_recorded_download_cap_is_atomic(tmp_path, monkeypatch):
item = community.validate_item(_FIXTURE["items"][3])
destination = tmp_path / "voice.wav"
destination.write_bytes(b"existing-good-audio")
monkeypatch.setattr(community, "_MAX_VOICE_AUDIO_BYTES", 8)
class Response:
status_code = 200
headers = {}
def __enter__(self): return self
def __exit__(self, *_args): return False
def raise_for_status(self): return None
def iter_bytes(self): yield b"123456789"
class Client:
def stream(self, method, url, **kwargs):
assert method == "GET" and url.startswith("https://github.com/")
assert kwargs == {"follow_redirects": False}
return Response()
with pytest.raises(HTTPException) as exc:
community._download_voice_audio(item, destination, client=Client())
assert getattr(exc.value, "status_code", None) == 502
assert destination.read_bytes() == b"existing-good-audio"
assert not list(tmp_path.glob(".*.part"))
def test_recorded_download_rejects_redirect_before_external_request(tmp_path):
item = community.validate_item(_FIXTURE["items"][3])
requested = []
class Response:
status_code = 302
headers = {"location": "https://evil.example/private.wav"}
def __enter__(self): return self
def __exit__(self, *_args): return False
class Client:
def stream(self, _method, url, **_kwargs):
requested.append(url)
return Response()
with pytest.raises(HTTPException) as exc:
community._download_voice_audio(item, tmp_path / "voice.wav", client=Client())
assert getattr(exc.value, "status_code", None) == 502
assert requested == [item["audio"]["url"]]
def test_recorded_download_follows_allowlisted_redirect(tmp_path):
item = community.validate_item(_FIXTURE["items"][3])
destination = tmp_path / "voice.wav"
requested = []
expected = _wav_bytes()
class Response:
def __init__(self, status, headers, body=b""):
self.status_code, self.headers, self.body = status, headers, body
def __enter__(self): return self
def __exit__(self, *_args): return False
def raise_for_status(self): return None
def iter_bytes(self): yield self.body
class Client:
def stream(self, _method, url, **_kwargs):
requested.append(url)
if len(requested) == 1:
return Response(302, {"location": "https://objects.githubusercontent.com/v1.wav"})
return Response(200, {}, expected)
community._download_voice_audio(item, destination, client=Client())
assert destination.read_bytes() == expected
assert requested == [item["audio"]["url"], "https://objects.githubusercontent.com/v1.wav"]
def test_recorded_download_rejects_non_audio_bytes(tmp_path):
item = community.validate_item(_FIXTURE["items"][3])
destination = tmp_path / "voice.wav"
class Response:
status_code = 200
headers = {}
def __enter__(self): return self
def __exit__(self, *_args): return False
def raise_for_status(self): return None
def iter_bytes(self): yield b"this is not audio"
class Client:
def stream(self, _method, _url, **_kwargs): return Response()
with pytest.raises(HTTPException, match="valid WAV"):
community._download_voice_audio(item, destination, client=Client())
assert not destination.exists()
assert not list(tmp_path.glob(".*.part"))
# ── Materialization ───────────────────────────────────────────────────────────
def test_community_use_is_idempotent_design_profile(
client, tmp_path, monkeypatch, symlinks_supported,
):
from core import event_bus
from core.db import db_conn, init_db
from api.routers import archetypes as arch_router
init_db()
item = community.validate_item(_FIXTURE["items"][0])
item["_source_repo"] = "test/source"
personality = community._community_personality(item)
monkeypatch.setattr(
community, "_load", lambda _refresh: (["test/source"], [item], [], False),
)
calls = []
emitted = []
async def render(_item, path):
calls.append(path)
_write_wav(Path(path))
monkeypatch.setattr(arch_router, "_render_archetype_wav", render)
monkeypatch.setattr(
event_bus, "emit", lambda topic, payload: emitted.append((topic, payload)),
)
with db_conn() as conn:
conn.execute(
"DELETE FROM voice_profiles WHERE personality IN (?, ?)",
(item["id"], personality),
)
first = client.post("/community/items/p1/use")
second = client.post("/community/items/p1/use")
assert first.status_code == second.status_code == 200
assert second.json()["profile_id"] == first.json()["profile_id"]
assert len(calls) == 1
with db_conn() as conn:
row = conn.execute(
"SELECT * FROM voice_profiles WHERE id=?", (first.json()["profile_id"],),
).fetchone()
assert row["kind"] == "design"
assert row["personality"] == personality
assert json.loads(row["vd_states"])["Gender"] == "female"
assert row["instruct"] == item["instruct"]
assert emitted[-1] == (
"profiles", {"action": "updated", "id": first.json()["profile_id"]},
)
profile_audio = community._stored_profile_audio(row["ref_audio_path"])
assert profile_audio is not None
profile_audio.unlink()
repaired = client.post("/community/items/p1/use")
assert repaired.status_code == 200
assert repaired.json()["profile_id"] == first.json()["profile_id"]
assert profile_audio.read_bytes() == _wav_bytes()
# The current preset preview cache repairs the profile without another
# model render.
assert len(calls) == 1
profile_audio.write_bytes(b"not a WAV")
repaired_corrupt = client.post("/community/items/p1/use")
assert repaired_corrupt.status_code == 200
assert profile_audio.read_bytes() == _wav_bytes()
if symlinks_supported: # Windows needs Developer Mode to create symlinks
outside = tmp_path / "outside.wav"
outside_bytes = _write_wav(outside)
profile_audio.unlink()
profile_audio.symlink_to(outside)
repaired_symlink = client.post("/community/items/p1/use")
assert repaired_symlink.status_code == 200
assert not profile_audio.is_symlink()
assert outside.read_bytes() == outside_bytes
def test_community_staged_repair_preserves_concurrently_edited_profile(
client, monkeypatch,
):
"""A staged community repair must not reclaim a row edited mid-copy."""
from core.config import VOICES_DIR
from core.db import db_conn, init_db
init_db()
item = community.validate_item(_FIXTURE["items"][0])
item["_source_repo"] = "test/source"
personality = community._community_personality(item)
edited_personality = f"user-edited:{personality}"
monkeypatch.setattr(
community, "_load", lambda _refresh: (["test/source"], [item], [], False),
)
with db_conn() as conn:
conn.execute(
"DELETE FROM voice_profiles WHERE personality IN (?, ?, ?)",
(item["id"], personality, edited_personality),
)
_write_wav(community._preset_preview_path(item))
original_id = {"value": None}
mutation_seen = {"value": False}
real_copy_atomic = community._copy_atomic
def racing_copy(source, destination):
destination = Path(destination)
if destination.name.endswith(".staged.wav"):
assert original_id["value"] is not None
with db_conn() as conn:
conn.execute(
"UPDATE voice_profiles SET name='User edit', personality=? WHERE id=?",
(edited_personality, original_id["value"]),
)
mutation_seen["value"] = True
real_copy_atomic(Path(source), destination)
monkeypatch.setattr(community, "_copy_atomic", racing_copy)
first = client.post(f"/community/items/{item['id']}/use")
assert first.status_code == 200
original_id["value"] = first.json()["profile_id"]
with db_conn() as conn:
original = conn.execute(
"SELECT ref_audio_path FROM voice_profiles WHERE id=?",
(original_id["value"],),
).fetchone()
original_audio = community._stored_profile_audio(original["ref_audio_path"])
assert original_audio is not None
corrupt_bytes = b"corrupt user-owned sample"
original_audio.write_bytes(corrupt_bytes)
repaired = client.post(f"/community/items/{item['id']}/use")
assert repaired.status_code == 200
repaired_id = repaired.json()["profile_id"]
assert mutation_seen["value"]
assert repaired_id != original_id["value"]
with db_conn() as conn:
edited = conn.execute(
"SELECT * FROM voice_profiles WHERE id=?", (original_id["value"],),
).fetchone()
canonical = conn.execute(
"SELECT * FROM voice_profiles WHERE id=?", (repaired_id,),
).fetchone()
canonical_count = conn.execute(
"SELECT count(*) FROM voice_profiles WHERE personality=?", (personality,),
).fetchone()[0]
assert edited["name"] == "User edit"
assert edited["personality"] == edited_personality
assert edited["instruct"] == item["instruct"]
assert original_audio.read_bytes() == corrupt_bytes
assert canonical["personality"] == personality
assert canonical["ref_audio_path"] == community._community_profile_audio_filename(
repaired_id, item,
)
assert canonical_count == 1
assert (Path(VOICES_DIR) / canonical["ref_audio_path"]).read_bytes() == _wav_bytes()
assert not list(Path(VOICES_DIR).glob(f".{original_id['value']}-*.staged.wav"))
def test_recorded_community_use_is_idempotent_clone_profile(client, tmp_path, monkeypatch):
from core.db import db_conn, init_db
init_db()
item = community.validate_item(_FIXTURE["items"][3])
item["_source_repo"] = "test/source"
personality = community._community_personality(item)
clip = tmp_path / "recorded.wav"
_write_wav(clip)
cache_calls = []
monkeypatch.setattr(
community, "_load", lambda _refresh: (["test/source"], [item], [], False),
)
monkeypatch.setattr(
community, "_cached_voice_audio", lambda _item: cache_calls.append(_item["id"]) or clip,
)
with db_conn() as conn:
conn.execute(
"DELETE FROM voice_profiles WHERE personality IN (?, ?)",
(item["id"], personality),
)
first = client.post("/community/items/v1/use")
second = client.post("/community/items/v1/use")
assert first.status_code == second.status_code == 200
assert second.json()["profile_id"] == first.json()["profile_id"]
assert cache_calls == ["v1"]
with db_conn() as conn:
row = conn.execute(
"SELECT * FROM voice_profiles WHERE id=?", (first.json()["profile_id"],),
).fetchone()
assert row["kind"] == "clone"
assert row["personality"] == personality
assert row["vd_states"] is None and row["instruct"] == ""
assert row["ref_text"] == ""
old_audio_filename = row["ref_audio_path"]
item["audio"]["url"] = "https://raw.githubusercontent.com/test/source/main/v2.wav"
refreshed = client.post("/community/items/v1/use")
assert refreshed.status_code == 200
assert refreshed.json()["profile_id"] == first.json()["profile_id"]
assert cache_calls == ["v1", "v1"]
with db_conn() as conn:
refreshed_row = conn.execute(
"SELECT ref_audio_path FROM voice_profiles WHERE id=?",
(first.json()["profile_id"],),
).fetchone()
assert refreshed_row["ref_audio_path"] != old_audio_filename
def test_noncanonical_builtin_id_cannot_heal_archetype_profile(client, monkeypatch):
from core import archetypes
from core.db import db_conn, init_db
from api.routers import archetypes as arch_router
init_db()
canonical = archetypes.list_archetypes(featured=True)[0]
changed_instruct = "female" if canonical["instruct"] != "female" else "male"
item = community.validate_item({
**canonical,
"type": "preset",
"source": "community",
"instruct": changed_instruct,
})
item["_source_repo"] = "test/source"
personality = community._community_personality(item)
builtin_profile_id = f"b{os.urandom(4).hex()[:7]}"
with db_conn() as conn:
conn.execute(
"DELETE FROM voice_profiles WHERE personality IN (?, ?)",
(canonical["id"], personality),
)
conn.execute(
"INSERT INTO voice_profiles (id, name, personality, instruct, kind, created_at) "
"VALUES (?, 'Built-in profile', ?, 'sentinel', 'design', 1)",
(builtin_profile_id, canonical["id"]),
)
monkeypatch.setattr(
community, "_load", lambda _refresh: (["test/source"], [item], [], False),
)
async def render(_item, path):
_write_wav(Path(path))
monkeypatch.setattr(arch_router, "_render_archetype_wav", render)
response = client.post(f"/community/items/{canonical['id']}/use")
assert response.status_code == 200
assert response.json()["profile_id"] != builtin_profile_id
with db_conn() as conn:
builtin = conn.execute(
"SELECT instruct FROM voice_profiles WHERE id=?", (builtin_profile_id,),
).fetchone()
community_row = conn.execute(
"SELECT personality FROM voice_profiles WHERE id=?",
(response.json()["profile_id"],),
).fetchone()
conn.execute(
"DELETE FROM voice_profiles WHERE id IN (?, ?)",
(builtin_profile_id, response.json()["profile_id"]),
)
assert builtin["instruct"] == "sentinel"
assert community_row["personality"] == personality
def test_community_use_does_not_rewrite_an_imported_bare_id_collision(
client, monkeypatch,
):
from core.config import VOICES_DIR
from core.db import db_conn, init_db
from api.routers import archetypes as arch_router
init_db()
item = community.validate_item(_FIXTURE["items"][0])
item["_source_repo"] = "test/source"
personality = community._community_personality(item)
imported_id = "importedcomm"
imported_ns_id = "importedcommns"
imported_audio = Path(VOICES_DIR) / f"{imported_id}.wav"
imported_ns_audio = Path(VOICES_DIR) / f"{imported_ns_id}.wav"
original_audio = _write_wav(imported_audio)
original_ns_audio = _write_wav(imported_ns_audio)
with db_conn() as conn:
conn.execute(
"DELETE FROM voice_profiles WHERE personality IN (?, ?)",
(item["id"], personality),
)
conn.execute(
"INSERT INTO voice_profiles "
"(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, "
"kind, is_locked, verified_own_voice, created_at) VALUES "
"(?, 'Imported collision', ?, 'user transcript', 'male', 'Auto', NULL, ?, "
"'clone', 1, 1, 1)",
(imported_id, imported_audio.name, item["id"]),
)
conn.execute(
"INSERT INTO voice_profiles "
"(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, "
"kind, vd_states, is_locked, verified_own_voice, created_at) VALUES "
"(?, 'Imported namespaced collision', ?, ?, ?, ?, 42, ?, "
"'design', NULL, 0, 0, 2)",
(
imported_ns_id, imported_ns_audio.name, item["sample_script"],
item["instruct"], item["language"], personality,
),
)
monkeypatch.setattr(
community, "_load", lambda _refresh: (["test/source"], [item], [], False),
)
async def render(_item, path):
_write_wav(Path(path))
monkeypatch.setattr(arch_router, "_render_archetype_wav", render)
response = client.post(f"/community/items/{item['id']}/use")
assert response.status_code == 200
assert response.json()["profile_id"] != imported_id
with db_conn() as conn:
imported = conn.execute(
"SELECT * FROM voice_profiles WHERE id=?", (imported_id,),
).fetchone()
imported_ns = conn.execute(
"SELECT * FROM voice_profiles WHERE id=?", (imported_ns_id,),
).fetchone()
created = conn.execute(
"SELECT * FROM voice_profiles WHERE id=?", (response.json()["profile_id"],),
).fetchone()
assert imported["personality"] == item["id"]
assert imported["instruct"] == "male"
assert imported["ref_text"] == "user transcript"
assert imported_audio.read_bytes() == original_audio
assert imported_ns["instruct"] == item["instruct"]
assert imported_ns["ref_text"] == item["sample_script"]
assert imported_ns["vd_states"] is None
assert imported_ns_audio.read_bytes() == original_ns_audio
assert created["personality"] == personality
def test_noncolliding_legacy_community_profile_is_adopted(client, monkeypatch):
from core.config import VOICES_DIR
from core.db import db_conn, init_db
from api.routers import archetypes as arch_router
init_db()
item = community.validate_item(_FIXTURE["items"][0])
item["_source_repo"] = "test/source"
personality = community._community_personality(item)
legacy_id = f"l{os.urandom(4).hex()[:7]}"
with db_conn() as conn:
conn.execute(
"DELETE FROM voice_profiles WHERE personality IN (?, ?)",
(item["id"], personality),
)
conn.execute(
"INSERT INTO voice_profiles "
"(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, "
"kind, vd_states, created_at) VALUES "
"(?, 'Legacy community profile', ?, '', ?, ?, NULL, ?, 'design', NULL, 1)",
(legacy_id, f"{legacy_id}.wav", item["instruct"], item["language"], item["id"]),
)
_write_wav(Path(VOICES_DIR) / f"{legacy_id}.wav")
monkeypatch.setattr(
community, "_load", lambda _refresh: (["test/source"], [item], [], False),
)
community._preset_preview_path(item).unlink(missing_ok=True)
rendered = []
async def render(_item, path):
rendered.append(path)
_write_wav(Path(path))
monkeypatch.setattr(arch_router, "_render_archetype_wav", render)
response = client.post(f"/community/items/{item['id']}/use")
assert response.status_code == 200
assert response.json()["profile_id"] == legacy_id
assert len(rendered) == 1
with db_conn() as conn:
adopted = conn.execute(
"SELECT personality, kind, ref_audio_path FROM voice_profiles WHERE id=?",
(legacy_id,),
).fetchone()
assert adopted["personality"] == personality
assert adopted["kind"] == "design"
adopted_audio = community._stored_profile_audio(adopted["ref_audio_path"])
assert adopted_audio is not None and adopted_audio.is_file()
-250
View File
@@ -1,250 +0,0 @@
"""Gallery-import profile materialization contracts."""
from __future__ import annotations
import shutil
import sqlite3
import time
import uuid
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from api.routers import gallery
from core.db import db_conn, init_db
@pytest.fixture(scope="module")
def client():
init_db()
gallery._init_gallery_db()
app = FastAPI()
app.include_router(gallery.router)
return TestClient(app)
def _gallery_voice(
suffix: str = ".wav", content: bytes = b"RIFF imported voice",
) -> tuple[str, Path]:
voice_id = f"g{uuid.uuid4().hex[:7]}"
path = gallery.VOICE_GALLERY_DIR / f"{voice_id}{suffix}"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(content)
with db_conn() as conn:
conn.execute(
"""INSERT INTO voice_gallery
(id, name, character, category, source_type, source_url, audio_path,
duration, description, tags, created_at)
VALUES (?, ?, ?, 'import', 'youtube', ?, ?, 5.0, ?, '[]', ?)""",
(
voice_id, "Imported narrator", "Video title is not an instruct",
"https://example.invalid/source", str(path),
"Source URL/notes are not a spoken transcript", time.time(),
),
)
return voice_id, path
def test_save_as_profile_keeps_import_metadata_out_of_tts_fields(client):
voice_id, _ = _gallery_voice()
response = client.post(
f"/gallery/voices/{voice_id}/save-as-profile",
params={"profile_name": "Reusable import"},
)
assert response.status_code == 200
with db_conn() as conn:
row = conn.execute(
"SELECT * FROM voice_profiles WHERE id=?", (response.json()["profile_id"],),
).fetchone()
assert row["kind"] == "clone"
assert row["personality"] == f"gallery:{voice_id}"
assert row["ref_text"] == ""
assert row["instruct"] == ""
assert row["description"] == "Source URL/notes are not a spoken transcript"
def test_to_profile_uses_live_schema_and_clone_metadata(client):
voice_id, _ = _gallery_voice()
response = client.post(f"/gallery/voices/{voice_id}/to-profile")
assert response.status_code == 200
with db_conn() as conn:
row = conn.execute(
"SELECT * FROM voice_profiles WHERE id=?", (response.json()["profile_id"],),
).fetchone()
assert row["kind"] == "clone"
assert row["personality"] == f"gallery:{voice_id}"
assert row["ref_text"] == row["instruct"] == ""
assert row["description"] == "Source URL/notes are not a spoken transcript"
def test_both_import_routes_share_one_idempotent_profile(client, monkeypatch):
emitted = []
monkeypatch.setattr(
gallery.event_bus, "emit", lambda topic, payload: emitted.append((topic, payload)),
)
voice_id, _ = _gallery_voice()
first = client.post(
f"/gallery/voices/{voice_id}/save-as-profile",
params={"profile_name": "One reusable profile"},
)
repeated = client.post(
f"/gallery/voices/{voice_id}/save-as-profile",
params={"profile_name": "Ignored duplicate name"},
)
alternate = client.post(f"/gallery/voices/{voice_id}/to-profile")
assert first.status_code == repeated.status_code == alternate.status_code == 200
assert {
first.json()["profile_id"],
repeated.json()["profile_id"],
alternate.json()["profile_id"],
} == {first.json()["profile_id"]}
with db_conn() as conn:
rows = conn.execute(
"SELECT * FROM voice_profiles WHERE personality=?",
(f"gallery:{voice_id}",),
).fetchall()
assert len(rows) == 1
assert rows[0]["name"] == "One reusable profile"
assert rows[0]["kind"] == "clone" and rows[0]["vd_states"] is None
assert emitted[-1] == (
"profiles", {"action": "updated", "id": first.json()["profile_id"]},
)
def test_gallery_profile_repairs_a_missing_copy_without_duplication(client):
voice_id, source = _gallery_voice()
first = client.post(f"/gallery/voices/{voice_id}/to-profile")
assert first.status_code == 200
with db_conn() as conn:
row = conn.execute(
"SELECT * FROM voice_profiles WHERE id=?", (first.json()["profile_id"],),
).fetchone()
copied = Path(gallery.VOICES_DIR) / row["ref_audio_path"]
copied.unlink()
repaired = client.post(f"/gallery/voices/{voice_id}/to-profile")
assert repaired.status_code == 200
assert repaired.json()["profile_id"] == first.json()["profile_id"]
assert copied.read_bytes() == source.read_bytes()
def test_gallery_profile_does_not_rewrite_a_namespaced_import_collision(client):
voice_id, source = _gallery_voice()
collision_id = f"c{uuid.uuid4().hex[:7]}"
personality = f"gallery:{voice_id}"
collision_name = gallery._gallery_profile_audio_filename(collision_id, source)
collision_audio = Path(gallery.VOICES_DIR) / collision_name
collision_audio.parent.mkdir(parents=True, exist_ok=True)
collision_audio.write_bytes(b"user-owned audio")
with db_conn() as conn:
conn.execute(
"INSERT INTO voice_profiles "
"(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, "
"description, kind, vd_states, is_locked, verified_own_voice, created_at) "
"VALUES (?, 'User profile', ?, '', '', 'Auto', NULL, ?, "
"'user-owned metadata', 'clone', NULL, 0, 0, ?)",
(collision_id, collision_name, personality, time.time()),
)
response = client.post(f"/gallery/voices/{voice_id}/to-profile")
assert response.status_code == 200
assert response.json()["profile_id"] != collision_id
with db_conn() as conn:
collision = conn.execute(
"SELECT * FROM voice_profiles WHERE id=?", (collision_id,),
).fetchone()
created = conn.execute(
"SELECT * FROM voice_profiles WHERE id=?", (response.json()["profile_id"],),
).fetchone()
assert collision["description"] == "user-owned metadata"
assert collision_audio.read_bytes() == b"user-owned audio"
assert created["personality"] == personality
def _part_files() -> set[Path]:
return set(Path(gallery.VOICES_DIR).glob("*.part")) | set(
Path(gallery.VOICES_DIR).glob(".*.part")
)
def test_audio_copy_never_holds_the_db_write_lock(client, monkeypatch):
"""The bulk file copy must happen BEFORE the BEGIN IMMEDIATE transaction.
While the copy runs, another backend writer takes (and releases) SQLite's
write lock. If materialization copied inside its own write transaction,
this concurrent writer would hit `database is locked` and the test fails.
"""
from core.config import DB_PATH
voice_id, _ = _gallery_voice()
real_copy2 = shutil.copy2
concurrent_writes = []
def copy_and_probe(src, dst, **kwargs):
probe = sqlite3.connect(DB_PATH, timeout=0.5)
try:
probe.execute("BEGIN IMMEDIATE")
probe.execute(
"UPDATE voice_gallery SET category = category WHERE id = ?",
(voice_id,),
)
probe.commit()
concurrent_writes.append(True)
finally:
probe.close()
return real_copy2(src, dst, **kwargs)
monkeypatch.setattr(gallery.shutil, "copy2", copy_and_probe)
response = client.post(f"/gallery/voices/{voice_id}/to-profile")
assert response.status_code == 200
assert concurrent_writes == [True]
assert _part_files() == set()
def test_failed_copy_leaves_no_temp_droppings_or_profile_row(client, monkeypatch):
"""A copy that dies mid-write must not leave .part files or a DB row."""
voice_id, _ = _gallery_voice()
def exploding_copy(src, dst, **kwargs):
Path(dst).write_bytes(b"partial bytes")
raise OSError("disk full mid-copy")
monkeypatch.setattr(gallery.shutil, "copy2", exploding_copy)
with pytest.raises(OSError, match="disk full mid-copy"):
client.post(f"/gallery/voices/{voice_id}/to-profile")
assert _part_files() == set()
with db_conn() as conn:
rows = conn.execute(
"SELECT * FROM voice_profiles WHERE personality = ?",
(f"gallery:{voice_id}",),
).fetchall()
assert rows == []
def test_gallery_preview_serves_outputs_file_without_root_relative_redirect(client):
voice_id, source = _gallery_voice()
response = client.get(
f"/gallery/voices/{voice_id}/preview", follow_redirects=False,
)
assert response.status_code == 200
assert "location" not in response.headers
assert response.content == source.read_bytes()
def test_gallery_preview_preserves_non_wav_content_type(client):
voice_id, _ = _gallery_voice(".mp3", b"ID3 imported voice")
response = client.get(f"/gallery/voices/{voice_id}/preview")
assert response.status_code == 200
assert response.headers["content-type"] == "audio/mpeg"
-71
View File
@@ -1,71 +0,0 @@
import asyncio
import httpx
import pytest
from services.hosted_voice_api import HostedSettings, HostedVoiceClient, HostedVoiceError
_NAMES = (
"VSS_HOSTED_API_BASE", "VSS_HOSTED_API_TOKEN", "VSS_HOSTED_PROJECT_ID",
"VSS_HOSTED_MODEL_ID", "VSS_HOSTED_MODEL_VERSION", "VSS_HOSTED_BASE_VOICE_ID",
)
def test_hosted_adapter_is_disabled_without_configuration(monkeypatch):
for name in _NAMES:
monkeypatch.delenv(name, raising=False)
assert HostedSettings.from_environment() is None
def test_hosted_adapter_refuses_partial_configuration(monkeypatch):
for name in _NAMES:
monkeypatch.delenv(name, raising=False)
monkeypatch.setenv("VSS_HOSTED_API_BASE", "http://127.0.0.1:8080")
with pytest.raises(HostedVoiceError, match="VSS_HOSTED_API_TOKEN"):
HostedSettings.from_environment()
def test_hosted_adapter_requires_http_endpoint(monkeypatch):
values = {
"VSS_HOSTED_API_BASE": "not-a-url",
"VSS_HOSTED_API_TOKEN": "token",
"VSS_HOSTED_PROJECT_ID": "project",
"VSS_HOSTED_MODEL_ID": "model",
"VSS_HOSTED_MODEL_VERSION": "v1",
"VSS_HOSTED_BASE_VOICE_ID": "base",
}
for name, value in values.items():
monkeypatch.setenv(name, value)
with pytest.raises(HostedVoiceError, match="http"):
HostedSettings.from_environment()
def test_create_voice_uses_artifact_grants_then_canonical_voice_resource(tmp_path):
reference = tmp_path / "reference.wav"
reference.write_bytes(b"reference-audio")
settings = HostedSettings("https://api.test", "token", "project", "model", "v1", "base", "oss-spoken-consent-v1")
requests = []
def handler(request):
requests.append(request)
if request.url.path == "/v1/artifacts/upload-authorizations":
return httpx.Response(200, json={"artifact_id": "artifact-ref", "method": "PUT", "url": "https://objects.test/ref", "required_headers": {}})
if request.url.host == "objects.test":
return httpx.Response(200)
if request.url.path == "/v1/artifacts/artifact-ref/complete":
return httpx.Response(200, json={})
if request.url.path == "/v1/voices":
return httpx.Response(201, json={"id": "hosted-voice"})
return httpx.Response(404)
async def create():
client = HostedVoiceClient(settings, httpx.AsyncClient(base_url=settings.base_url, transport=httpx.MockTransport(handler)))
return await client.create_voice(name="Local profile", description="description", reference_path=str(reference))
assert asyncio.run(create()) == "hosted-voice"
voice_request = next(request for request in requests if request.url.path == "/v1/voices")
body = __import__("json").loads(voice_request.content)
assert body["project_id"] == "project"
assert body["reference_audio_artifact_id"] == "artifact-ref"
assert body["consent"]["attestation_text_version"] == "oss-spoken-consent-v1"
@@ -1,161 +0,0 @@
"""Health/GetCapabilities shape, preflight parity, digest stability.
Mirrors what ``internal/gateway/preflight.go`` in vssaas enforces: READY
health with version evidence, identical versions across both calls, valid
unique devices, and only explicitly-READY models counting as schedulable.
"""
from __future__ import annotations
import os
import sys
import pytest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _runtime_adapter_helpers import ( # noqa: E402
DEVICE,
READY_MODEL,
FakeInventory,
make_context,
serve_over_socket,
)
from runtime_adapter.digest import file_sha256, snapshot_digest
from runtime_adapter.gen import runtime_adapter_pb2 as pb2
from runtime_adapter.inventory import (
STATE_FAILED,
STATE_INSTALLED,
STATE_LOADING,
ModelInfo,
)
from runtime_adapter.selfcheck import PreflightError, run_preflight
from runtime_adapter.server import prepare_socket
def _model(state, model_id="other-model", digest="sha256:" + "c" * 64):
return ModelInfo(
catalog_model_id=model_id,
model_version="d" * 40,
model_digest=digest,
precisions=("fp32",),
features=("tts",),
state=state,
)
def test_health_and_capabilities_versions_are_identical_and_ready(tmp_path):
with serve_over_socket(make_context(), tmp_path) as (stub, _):
health = stub.Health(pb2.HealthRequest(), timeout=5)
caps = stub.GetCapabilities(pb2.GetCapabilitiesRequest(), timeout=5)
assert health.state == pb2.SERVING_STATE_READY
assert health.runtime_version == "1.2.3-test"
assert health.adapter_version.strip()
assert caps.runtime_version == health.runtime_version
assert caps.adapter_version == health.adapter_version
def test_capabilities_report_device_and_ready_model_evidence(tmp_path):
inventory = FakeInventory(
models=[
READY_MODEL,
_model(STATE_LOADING, "loading-model"),
_model(STATE_FAILED, "failed-model"),
_model(STATE_INSTALLED, "installed-model"),
]
)
with serve_over_socket(make_context(inventory=inventory), tmp_path) as (stub, _):
caps = stub.GetCapabilities(pb2.GetCapabilitiesRequest(), timeout=5)
[device] = caps.devices
assert device.device_id == DEVICE.device_id
assert device.hardware_class == DEVICE.hardware_class
assert device.total_vram_bytes > 0
assert 0 < device.free_slots <= device.total_slots
by_id = {model.catalog_model_id: model for model in caps.models}
ready = by_id[READY_MODEL.catalog_model_id]
assert ready.state == pb2.RUNTIME_MODEL_STATE_READY
assert len(ready.model_version) == 40
assert ready.model_digest.startswith("sha256:")
assert list(ready.precisions)
# A loading/failed/installed model is reported truthfully, never READY.
assert by_id["loading-model"].state == pb2.RUNTIME_MODEL_STATE_LOADING
assert by_id["failed-model"].state == pb2.RUNTIME_MODEL_STATE_FAILED
assert by_id["installed-model"].state == pb2.RUNTIME_MODEL_STATE_INSTALLED
def test_preflight_port_passes_against_a_ready_server(tmp_path):
inventory = FakeInventory(models=[READY_MODEL, _model(STATE_LOADING)])
with serve_over_socket(make_context(inventory=inventory), tmp_path) as (
stub,
socket_path,
):
summary = run_preflight(socket_path, timeout_s=5)
assert summary.ready_model_count == 1 # the loading model must not count
assert summary.device_count == 1
assert summary.runtime_version == "1.2.3-test"
assert summary.total_slots == 1
def test_preflight_fails_closed_without_a_ready_model(tmp_path):
inventory = FakeInventory(models=[_model(STATE_LOADING)])
with serve_over_socket(make_context(inventory=inventory), tmp_path) as (
stub,
socket_path,
):
health = stub.Health(pb2.HealthRequest(), timeout=5)
assert health.state == pb2.SERVING_STATE_DEGRADED
assert "no-ready-model" in health.health_flags
with pytest.raises(PreflightError):
run_preflight(socket_path, timeout_s=5)
def test_prepare_socket_rejects_unsafe_paths(tmp_path):
with pytest.raises(ValueError):
prepare_socket("relative/socket.sock")
regular = tmp_path / "not-a-socket"
regular.write_text("x")
with pytest.raises(ValueError):
prepare_socket(str(regular))
missing_parent = tmp_path / "nope" / "runtime.sock"
with pytest.raises(ValueError):
prepare_socket(str(missing_parent))
def test_snapshot_digest_is_stable_and_content_sensitive(tmp_path):
snapshot = tmp_path / "snapshots" / "rev"
snapshot.mkdir(parents=True)
(snapshot / "weights.bin").write_bytes(b"\x01\x02\x03")
(snapshot / "config.json").write_text("{}")
cache = tmp_path / "digest-cache.json"
first = snapshot_digest(snapshot, cache_path=cache)
second = snapshot_digest(snapshot, cache_path=cache) # served from cache
assert first == second
assert first.startswith("sha256:")
assert cache.exists()
# Any byte change must change the digest (cache invalidated by mtime/size).
(snapshot / "weights.bin").write_bytes(b"\x01\x02\x04")
assert snapshot_digest(snapshot, cache_path=cache) != first
with pytest.raises(FileNotFoundError):
snapshot_digest(tmp_path / "empty-none")
def test_file_sha256_matches_hashlib(tmp_path):
import hashlib
payload = b"runtime adapter"
path = tmp_path / "f.bin"
path.write_bytes(payload)
assert file_sha256(path) == hashlib.sha256(payload).hexdigest()
def test_socket_file_is_private_to_the_node(tmp_path):
import stat
with serve_over_socket(make_context(), tmp_path) as (_stub, socket_path):
mode = os.lstat(socket_path).st_mode
assert stat.S_ISSOCK(mode)
@@ -1,331 +0,0 @@
"""Execute/Cancel: happy path, deadline, cancel race, failure taxonomy."""
from __future__ import annotations
import hashlib
import os
import sys
import threading
import time
import pytest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _runtime_adapter_helpers import ( # noqa: E402
READY_MODEL,
FailingEngine,
FakeInventory,
SlowEngine,
make_context,
make_execute_request,
serve_over_socket,
terminal_of,
)
from runtime_adapter import codes
from runtime_adapter.gen import runtime_adapter_pb2 as pb2
from runtime_adapter.inventory import STATE_INSTALLED, ModelInfo
def _run_direct(context, request):
"""Drive the executor without a live gRPC server (fast path for taxonomy)."""
return list(context.executor().execute(request, None))
def _failure(events):
kind, last = terminal_of(events)
assert kind == "failed", f"expected failed terminal, got {kind}"
return last.failed
# ── happy path ────────────────────────────────────────────────────────────
def test_execute_happy_path_streams_and_writes_the_manifest(tmp_path):
context = make_context()
request = make_execute_request(tmp_path, text="hello runtime")
with serve_over_socket(context, tmp_path) as (stub, _):
events = list(stub.Execute(request, timeout=30))
payloads = [event.event.WhichOneof("payload") for event in events]
assert payloads[0] == "started"
assert payloads[-1] == "completed"
assert all(kind == "progress" for kind in payloads[1:-1])
sequences = [event.event.sequence for event in events]
assert sequences == sorted(sequences)
assert all(event.event.attempt_id == "attempt-1" for event in events)
completed = events[-1].event.completed
[manifest] = completed.outputs
output_path = tmp_path / "output.wav"
assert manifest.local_handle == str(output_path)
assert output_path.stat().st_size == manifest.size_bytes > 0
assert manifest.sha256 == hashlib.sha256(output_path.read_bytes()).hexdigest()
assert manifest.media_type == "audio/wav"
assert manifest.duration_ms == 500 # 12000 samples at 24 kHz
measurements = completed.measurements
assert measurements.normalized_input_characters == len("hello runtime")
assert measurements.output_audio_ms == 500
def test_execute_passes_typed_parameters_to_the_engine(tmp_path):
from _runtime_adapter_helpers import FakeEngine
engine = FakeEngine()
context = make_context(engine=engine)
request = make_execute_request(
tmp_path,
parameters={
"speed": pb2.ParameterValue(number_value=1.5),
"language": pb2.ParameterValue(string_value="en"),
"num_step": pb2.ParameterValue(integer_value=8),
},
)
events = _run_direct(context, request)
assert terminal_of(events)[0] == "completed"
[(text, kwargs)] = engine.generate_calls
assert text == "hello runtime"
assert kwargs == {"speed": 1.5, "language": "en", "num_step": 8}
# ── deadline ──────────────────────────────────────────────────────────────
def test_deadline_is_enforced_with_a_stable_code(tmp_path):
context = make_context(engine=SlowEngine(seconds=30))
request = make_execute_request(tmp_path, deadline_in_s=0.4)
start = time.monotonic()
events = _run_direct(context, request)
elapsed = time.monotonic() - start
failed = _failure(events)
assert failed.stable_code in (codes.INFERENCE_DEADLINE, codes.MODEL_LOAD_DEADLINE)
assert failed.failure_class in (
pb2.RUNTIME_FAILURE_CLASS_INFERENCE,
pb2.RUNTIME_FAILURE_CLASS_MODEL_LOAD,
)
assert elapsed < 5, "terminal event must arrive promptly after the deadline"
def test_deadline_in_the_past_is_invalid_input(tmp_path):
context = make_context()
request = make_execute_request(tmp_path)
request.deadline_unix_ms = int(time.time() * 1000) - 1000
failed = _failure(_run_direct(context, request))
assert failed.stable_code == codes.INPUT_DEADLINE_INVALID
assert failed.failure_class == pb2.RUNTIME_FAILURE_CLASS_INPUT
# ── cancel ────────────────────────────────────────────────────────────────
def test_cancel_race_yields_canceled_terminal_and_idempotent_dispositions(tmp_path):
engine = SlowEngine(seconds=30)
context = make_context(engine=engine)
request = make_execute_request(tmp_path)
with serve_over_socket(context, tmp_path) as (stub, _):
stream = stub.Execute(request, timeout=30)
first = next(stream)
assert first.event.WhichOneof("payload") == "started"
assert engine.started.wait(5), "engine must be mid-generate for the race"
cancel = pb2.CancelRequest(job_id="job-1", attempt_id="attempt-1")
assert stub.Cancel(cancel, timeout=5).disposition == (
pb2.CANCEL_DISPOSITION_ACCEPTED
)
# Idempotent while still running.
assert stub.Cancel(cancel, timeout=5).disposition == (
pb2.CANCEL_DISPOSITION_ACCEPTED
)
events = [first, *stream]
kind, last = terminal_of(events)
assert kind == "canceled"
assert last.canceled.HasField("measurements")
# After the terminal event the same cancel is ALREADY_TERMINAL …
assert stub.Cancel(cancel, timeout=5).disposition == (
pb2.CANCEL_DISPOSITION_ALREADY_TERMINAL
)
# … and an unknown attempt is NOT_FOUND.
unknown = pb2.CancelRequest(job_id="job-1", attempt_id="nope")
assert stub.Cancel(unknown, timeout=5).disposition == (
pb2.CANCEL_DISPOSITION_NOT_FOUND
)
def test_cancel_before_any_execute_is_not_found(tmp_path):
with serve_over_socket(make_context(), tmp_path) as (stub, _):
response = stub.Cancel(
pb2.CancelRequest(job_id="j", attempt_id="never-ran"), timeout=5
)
assert response.disposition == pb2.CANCEL_DISPOSITION_NOT_FOUND
# ── failure classification ────────────────────────────────────────────────
def test_model_load_failure_is_classified(tmp_path):
engine = FailingEngine(RuntimeError("weights corrupted"), phase="model_load")
failed = _failure(
_run_direct(make_context(engine=engine), make_execute_request(tmp_path))
)
assert failed.stable_code == codes.MODEL_LOAD_FAILED
assert failed.failure_class == pb2.RUNTIME_FAILURE_CLASS_MODEL_LOAD
def test_inference_failure_is_classified(tmp_path):
engine = FailingEngine(ValueError("synthesis exploded"))
failed = _failure(
_run_direct(make_context(engine=engine), make_execute_request(tmp_path))
)
assert failed.stable_code == codes.INFERENCE_FAILED
assert failed.failure_class == pb2.RUNTIME_FAILURE_CLASS_INFERENCE
def test_gpu_oom_is_classified_as_gpu_resource(tmp_path):
engine = FailingEngine(RuntimeError("CUDA out of memory. Tried to allocate…"))
failed = _failure(
_run_direct(make_context(engine=engine), make_execute_request(tmp_path))
)
assert failed.stable_code == codes.GPU_OUT_OF_MEMORY
assert failed.failure_class == pb2.RUNTIME_FAILURE_CLASS_GPU_RESOURCE
def test_engine_input_rejection_is_invalid_input(tmp_path):
from services.tts_backend import TTSInputError
engine = FailingEngine(TTSInputError("text too long for this engine"))
failed = _failure(
_run_direct(make_context(engine=engine), make_execute_request(tmp_path))
)
assert failed.stable_code == codes.INPUT_REJECTED
assert failed.failure_class == pb2.RUNTIME_FAILURE_CLASS_INPUT
def test_url_handles_are_rejected_never_fetched(tmp_path):
request = make_execute_request(
tmp_path, input_handle="https://evil.example/input.txt", input_sha256=""
)
failed = _failure(_run_direct(make_context(), request))
assert failed.stable_code == codes.INPUT_HANDLE_INVALID
assert failed.failure_class == pb2.RUNTIME_FAILURE_CLASS_INPUT
def test_relative_output_handle_is_rejected(tmp_path):
request = make_execute_request(tmp_path, output_handle="relative/out.wav")
failed = _failure(_run_direct(make_context(), request))
assert failed.stable_code == codes.INPUT_HANDLE_INVALID
def test_model_digest_mismatch_is_rejected(tmp_path):
request = make_execute_request(tmp_path)
request.model.model_digest = "sha256:" + "f" * 64
failed = _failure(_run_direct(make_context(), request))
assert failed.stable_code == codes.INPUT_MODEL_DIGEST_MISMATCH
def test_non_ready_model_is_rejected(tmp_path):
installed = ModelInfo(
catalog_model_id=READY_MODEL.catalog_model_id,
model_version=READY_MODEL.model_version,
model_digest=READY_MODEL.model_digest,
precisions=READY_MODEL.precisions,
features=READY_MODEL.features,
state=STATE_INSTALLED,
)
context = make_context(inventory=FakeInventory(models=[installed]))
failed = _failure(_run_direct(context, make_execute_request(tmp_path)))
assert failed.stable_code == codes.INPUT_MODEL_NOT_READY
def test_unknown_model_is_rejected(tmp_path):
request = make_execute_request(tmp_path)
request.model.catalog_model_id = "who-dis"
failed = _failure(_run_direct(make_context(), request))
assert failed.stable_code == codes.INPUT_MODEL_UNKNOWN
def test_unknown_and_out_of_range_parameters_are_rejected(tmp_path):
unknown = make_execute_request(
tmp_path,
parameters={"exfiltrate": pb2.ParameterValue(string_value="x")},
)
assert _failure(_run_direct(make_context(), unknown)).stable_code == (
codes.INPUT_PARAMETER_UNKNOWN
)
out_of_range = make_execute_request(
tmp_path,
attempt_id="attempt-2",
parameters={"speed": pb2.ParameterValue(number_value=99.0)},
)
assert _failure(_run_direct(make_context(), out_of_range)).stable_code == (
codes.INPUT_PARAMETER_RANGE
)
def test_input_checksum_mismatch_is_rejected(tmp_path):
request = make_execute_request(tmp_path, input_sha256="0" * 64)
failed = _failure(_run_direct(make_context(), request))
assert failed.stable_code == codes.INPUT_CHECKSUM_MISMATCH
def test_empty_text_is_rejected(tmp_path):
request = make_execute_request(tmp_path, text=" ")
failed = _failure(_run_direct(make_context(), request))
assert failed.stable_code == codes.INPUT_TEXT_EMPTY
def test_unwritable_output_directory_is_local_storage(tmp_path):
locked = tmp_path / "locked"
locked.mkdir()
request = make_execute_request(tmp_path, output_handle=str(locked / "out.wav"))
locked.chmod(0o500)
try:
failed = _failure(_run_direct(make_context(), request))
finally:
locked.chmod(0o700)
assert failed.stable_code == codes.STORAGE_WRITE_FAILED
assert failed.failure_class == pb2.RUNTIME_FAILURE_CLASS_LOCAL_STORAGE
def test_duplicate_attempt_id_is_rejected(tmp_path):
context = make_context()
executor = context.executor()
first = make_execute_request(tmp_path)
assert terminal_of(list(executor.execute(first, None)))[0] == "completed"
duplicate = make_execute_request(tmp_path)
events = list(executor.execute(duplicate, None))
failed = _failure(events)
assert failed.stable_code == codes.INPUT_ATTEMPT_DUPLICATE
def test_slot_exhaustion_is_gpu_resource(tmp_path):
engine = SlowEngine(seconds=30)
context = make_context(engine=engine, slot_limit=1)
executor = context.executor()
hog = make_execute_request(tmp_path, attempt_id="hog")
hog_events = []
hog_thread = threading.Thread(
target=lambda: hog_events.extend(executor.execute(hog, None)), daemon=True
)
hog_thread.start()
assert engine.started.wait(5)
try:
crowded = make_execute_request(tmp_path, attempt_id="crowded")
failed = _failure(list(executor.execute(crowded, None)))
assert failed.stable_code == codes.GPU_SLOTS_EXHAUSTED
assert failed.failure_class == pb2.RUNTIME_FAILURE_CLASS_GPU_RESOURCE
finally:
context.registry.cancel("job-1", "hog")
hog_thread.join(timeout=10)
assert terminal_of(hog_events)[0] == "canceled"
def test_safe_detail_never_carries_local_paths(tmp_path):
engine = FailingEngine(RuntimeError(f"failed loading {tmp_path}/weights.bin"))
failed = _failure(
_run_direct(make_context(engine=engine), make_execute_request(tmp_path))
)
assert str(tmp_path) not in failed.safe_detail
assert "<path>" in failed.safe_detail
+15 -40
View File
@@ -460,55 +460,30 @@ class TaskExecutor:
@staticmethod
def _synthesize(backend, text: str, params: dict):
"""Render through the same seeded pipeline as local ``/generate``.
"""Call the engine through the same serial GPU gate local jobs use.
Held against the idle sweep for the duration: a long generation touches
the instance cache once, at the start, so on elapsed time alone it is
indistinguishable from a model nobody wants any more.
Do not reduce this to ``backend.generate()``. The control plane sends
a complete render contract (pinned gallery seed, synthetic reference,
quality controls, chunking, effects); calling the adapter directly
silently turns a selected gallery voice into a fresh random take.
"""
from services import tts_backend # noqa: PLC0415
from api.routers.generation import _run_backend_inference, _run_inference # noqa: PLC0415
language = params.get("language")
ref_audio = params.get("ref_audio")
ref_text = params.get("ref_text")
instruct = params.get("instruct")
duration = params.get("duration")
num_step = params.get("num_step", 16)
guidance_scale = params.get("guidance_scale", 2.0)
speed = params.get("speed", 1.0)
denoise = params.get("denoise", True)
postprocess_output = params.get("postprocess_output", True)
used_seed = params.get("seed")
effect_preset = params.get("effect_preset", "broadcast")
max_chunk_chars = params.get("max_chunk_chars")
crossfade_ms = params.get("crossfade_ms")
kwargs = {
key: params[key]
for key in (
"ref_audio",
"ref_text",
"instruct",
"language",
"duration",
"description",
"speed",
)
if params.get(key) is not None
}
try:
with tts_backend.engine_in_use(backend):
if isinstance(backend, tts_backend.OmniVoiceBackend):
# The OSS default engine has an extended native surface;
# preserving it is required for a gallery preview and a
# GPU-worker take to share the same voice identity.
return _run_inference(
backend._model, text, language, ref_audio, ref_text,
instruct, duration, num_step, guidance_scale, speed,
params.get("t_shift"), denoise, postprocess_output,
params.get("layer_penalty_factor"),
params.get("position_temperature"),
params.get("class_temperature"), used_seed,
effect_preset, max_chunk_chars, crossfade_ms,
)
return _run_backend_inference(
backend, text, language, ref_audio, ref_text, instruct,
duration, num_step, guidance_scale, speed, denoise,
postprocess_output, used_seed, effect_preset,
max_chunk_chars, crossfade_ms,
)
return backend.generate(text, **kwargs)
except Exception as exc:
from worker import errors as worker_errors # noqa: PLC0415
+1 -18
View File
@@ -657,25 +657,8 @@ class WorkerClient:
)
)
return
# Reserve the slot BEFORE the accept-send await: awaiting yields to
# the event loop, and a concurrently delivered assignment would read
# the un-reserved counter and over-accept past capacity (#1536 — a
# capacity-1 worker accepted a second task on a slow runner). Message
# order on the stream survives the swap: _send enqueues synchronously
# (put_nowait before any suspension), so ACCEPTED is in the outbox
# before this handler ever yields to the just-created _run task.
await self._send(pb.WorkerMessage(accepted=pb.TaskAccepted(ref=assignment.ref)))
self._running[key] = asyncio.create_task(self._run(assignment))
try:
await self._send(pb.WorkerMessage(accepted=pb.TaskAccepted(ref=assignment.ref)))
except BaseException:
# BaseException, not Exception: a handler CANCELLED mid-send must
# release the slot too, or the reserved task keeps running work
# the scheduler never saw accepted — and double-executes after
# reassignment. The stream-death case lands here as well.
task = self._running.pop(key, None)
if task is not None:
task.cancel()
raise
async def _run(self, assignment: pb.TaskAssignment) -> None:
key = self._key(assignment.ref)
+1 -1
View File
@@ -15,7 +15,7 @@
},
"frontend": {
"name": "omnivoice-studio",
"version": "0.5.0",
"version": "0.4.2",
"dependencies": {
"@fontsource-variable/inter": "^5.2.8",
"@fontsource-variable/source-serif-4": "^5.2.9",
+21 -90
View File
@@ -12,7 +12,7 @@ env var that exempts trusted callers:
| Gate | Turn on with | Guards | Applies to |
|---|---|---|---|
| **Share PIN** | the in-app Network share toggle | casual LAN-share guests, one session | non-loopback **HTTP** |
| **API key** | `OMNIVOICE_API_KEY` env var on the backend | direct clients and first-party session bootstrap | non-loopback **HTTP + WebSocket** |
| **API key** | `OMNIVOICE_API_KEY` env var on the backend | a durable remote credential | non-loopback **HTTP + WebSocket** |
| **Trusted networks** | `OMNIVOICE_TRUSTED_NETWORKS` env var | *exempts* the two gates above | non-loopback **consumption** routes only |
Loopback traffic (`127.0.0.1`, `::1`, `localhost`) is **never** gated — local
@@ -25,9 +25,7 @@ tools keep working unchanged whichever gate is set.
> desktop-only even with a key (see [Admin routes](#admin-routes-and-server-mode)).
> Both gates can be active at once. The PIN and the API key are independent; when
> both are set, each is checked on the paths it covers. Session exchange validates
> the master key before the PIN gate so the UI can bootstrap safely; ordinary HTTP
> requests still require the PIN afterward, and the UI prompts for it next.
> both are set, each is checked on the paths it covers.
---
@@ -44,14 +42,14 @@ present it. Supply it any one of three ways:
| Where | How |
|---|---|
| Header | `X-OmniVoice-Pin: <pin>` |
| Header | `X-VoiceStudio-Pin: <pin>` |
| Query param | `?pin=<pin>` |
| Cookie | `ov_pin=<pin>` — the backend sets this automatically after the first valid PIN, so browser sessions only prove it once |
```bash
# From another device on the LAN — with the PIN
curl http://<host>:3900/v1/audio/voices \
-H "X-OmniVoice-Pin: 123456"
-H "X-VoiceStudio-Pin: 123456"
```
A missing or wrong PIN returns:
@@ -77,11 +75,9 @@ Notes on the PIN gate (`NetworkAccessMiddleware`, `backend/main.py`):
## API key
The API key is the backend's durable root credential for a GPU box, Docker
container, or reverse-proxied host. Direct API clients may send it on each
request. The first-party browser/Tauri UI instead exchanges it once for a
short-lived administrator session and never stores the master. Set it on the
**backend** process:
The API key is the durable credential for running the backend somewhere and
driving it remotely — a GPU box on your tailnet, a Docker container, a
reverse-proxied host. Set it on the **backend** process:
```bash
# Generate a strong key and start the backend with it
@@ -91,16 +87,14 @@ uv run uvicorn backend.main:app --host 0.0.0.0 --port 3900
```
While `OMNIVOICE_API_KEY` is set, every **non-loopback HTTP and WebSocket**
request must present an accepted credential. SPA shell paths remain public;
`POST /api/auth/session` passes through the middleware only so its route can
validate the master and perform the one-time exchange. Direct-client
compatibility accepts:
request must present it (the SPA shell paths below are the only HTTP exception).
Supply it any one of three ways:
| Where | How |
|---|---|
| Header | `Authorization: Bearer <key>`**preferred** for scripts and SDKs |
| Legacy cookie | `ov_key=<key>`accepted only for compatibility and migrated by the first-party UI; the backend no longer creates it |
| Legacy query param | `?api_key=<key>`compatibility only. **A key in a URL leaks into proxy/access logs and browser history** |
| Header | `Authorization: Bearer <key>`**preferred**; the one place a key isn't at risk of landing in a log |
| Cookie | `ov_key=<key>`set automatically after the first authenticated HTTP request; the safer fallback for browser WebSockets |
| Query param | `?api_key=<key>`last resort (browser WebSockets can't set headers). **A key in a URL leaks into proxy/access logs and browser history** — prefer the header or cookie |
```bash
# Prefer an encrypted transport (Tailscale Serve / TLS) for a real key; plain
@@ -139,8 +133,6 @@ code **1008** (policy violation) instead of a JSON body.
Notes on the API-key gate (`BearerKeyMiddleware`, `backend/main.py`):
- The key is compared in **constant time** and is **never logged**.
- The backend never copies the master into a response cookie. Browser clients
receive only `ov_session`, an opaque, HttpOnly, SameSite=Strict credential.
- The SPA shell paths bypass the gate on **HTTP** so a remote UI can load and
show what's wrong; WebSockets have no such exemption.
- **Plain HTTP is sniffable** — a Bearer key over `http://` on a hostile
@@ -148,40 +140,6 @@ Notes on the API-key gate (`BearerKeyMiddleware`, `backend/main.py`):
anything beyond a fully trusted LAN. See
[docs/remote-gpu.md](remote-gpu.md) for the full remote-backend setup.
### First-party administrator sessions
The bundled UI uses a narrower protocol:
1. `POST /api/auth/session` receives the master in an `Authorization` header
exactly once and selects `{"transport":"cookie"}` for exact same-origin
browsers or `{"transport":"bearer"}` for Tauri/cross-origin clients.
2. Cookie transport returns `204` and sets `ov_session` as HttpOnly,
SameSite=Strict, path `/`, with an eight-hour maximum lifetime. Bearer
transport returns an opaque `ovs_admin_session_…` value which the UI keeps
in **sessionStorage only**, bound to the exact backend base URL. Bearer JSON
responses include both `expires_at` and a bounded `expires_in`; the UI uses
the relative lifetime when available so clock skew between a remote GPU host
and the browser cannot reject a valid session. `expires_at` remains for
backward compatibility with older clients and servers.
3. `DELETE /api/auth/session` revokes the session. Removing or rotating
`OMNIVOICE_API_KEY`, backend restart, explicit logout, and the eight-hour
deadline also invalidate it.
The master is never written to localStorage/sessionStorage, never returned by
the backend, and never placed in a WebSocket URL. Legacy `ov_api_key` browser
storage is deleted before migration waits on the network. All auth responses,
including errors, carry `Cache-Control: no-store`.
Failed session exchanges are limited per client to ten attempts in a rolling
60-second window and then return `429` with `Retry-After`. A correct master key
is always evaluated and clears the failure window, so an attacker cannot lock
an operator out by deliberately exhausting the limit.
Cookie-authenticated mutations require both an exact allowed `Origin` and
`X-VoiceStudio-CSRF: 1`. Side-effectful GET actions additionally require the
browser's `Sec-Fetch-Site: same-origin`. Bearer/header clients are not subject
to the ambient-cookie CSRF check.
---
## Dictation WebSocket
@@ -191,22 +149,13 @@ own inline guard (`backend/api/routers/capture_ws.py`) *in addition to* the
API-key middleware. A non-loopback client reaches it only if it is **either**:
- on a [trusted network](#trusted-networks) (`is_local_host` passes), **or**
- presenting a direct-client **API key** in `Authorization`, or through a
legacy `ov_key`/`?api_key=` transport.
- presenting the **API key** — as `Authorization: Bearer <key>`, the `ov_key`
cookie, or `?api_key=<key>` (URL keys leak into logs — prefer the cookie).
```
ws://gpu-box:3900/ws/transcribe?api_key=<key>
```
That URL form is retained for non-browser compatibility only. The first-party
UI never constructs it. A bearer administrator session first calls
`POST /api/auth/ws-ticket` and puts only the returned `ws_ticket` in the URL.
Tickets are scoped to `/ws/transcribe` or `/ws/events`, expire after 30 seconds,
return the same bounded `expires_in`/`expires_at` pair, and are consumed
atomically at most once. Same-origin UI WebSockets use the
HttpOnly session cookie and must pass exact `Origin` validation; `null`, missing,
and lookalike origins are rejected.
The **share PIN does not authorize dictation** — the PIN gate is HTTP-only, and
the dictation guard checks only the API key (or trusted-network membership). A
LAN guest who has only entered a PIN can use the HTTP API but **not** live
@@ -266,12 +215,11 @@ requirement is dropped (issue #261, else the operator is 403'd out of their own
a model, and LLM provider discovery makes a request with the saved provider
credential. Set `OMNIVOICE_API_KEY` before changing settings or triggering
those actions remotely.
- **An API key is configured** → admin requires that **API key** (direct-client
`Authorization` / legacy query or cookie), a valid short-lived administrator
session, or genuine loopback. The **6-digit share PIN does not gate admin**
(it is brute-forceable), and trusted-network membership never does either. A
**PIN-only** server-mode deployment therefore keeps admin routes loopback-only;
remote admin starts from the long API key.
- **An API key is configured** → admin requires that **API key** (`Authorization:
Bearer` / `?api_key` / `ov_key` cookie), or genuine loopback. The **6-digit
share PIN does not gate admin** (it is brute-forceable), and trusted-network
membership never does either. A **PIN-only** server-mode deployment therefore
keeps admin routes loopback-only; remote admin requires the long API key.
Managed sidecar installation remains true-loopback-only even with an API key.
Its installer fetches mutable source and creates an editable environment, so it
@@ -320,30 +268,13 @@ the default list, so restate the loopback/Tauri origins alongside your own. (The
same origin.) If you only moved the Vite dev server's port, set
`OMNIVOICE_UI_PORT` instead and the default list follows it.
CORS wraps both authentication gates: credentialless browser preflights are
answered before PIN/API-key enforcement, and gate-generated `401` responses
retain CORS headers so the UI can read the actual failure and prompt for the
right credential.
TLS-terminating proxies must establish the effective scheme at the ASGI server
boundary. Uvicorn's proxy-header handling trusts loopback by default, which
covers Tailscale Serve; a custom proxy on another address must be listed with
`--forwarded-allow-ips=<proxy-ip>` (and proxy headers must remain enabled).
VoiceStudio deliberately does not trust a raw `X-Forwarded-Proto` header inside
the application: once Uvicorn accepts a trusted proxy, the resolved ASGI scheme
drives exact-Origin checks and the session cookie's `Secure` attribute.
For a public path prefix such as `/studio`, either strip that prefix before
forwarding or configure the ASGI `root_path` to the same value. WebSocket ticket
validation removes only that trusted, configured prefix; it never accepts an
arbitrary path merely because it ends in `/ws/events` or `/ws/transcribe`.
## Status codes
| Code | Meaning | What to do |
|---|---|---|
| **401** | Consumption auth failed — `{"detail": "PIN required"}` or `{"detail": "API key required"}`. | Supply the PIN / key (header, cookie, or query param above). A WebSocket surfaces this as close code **1008**. |
| **403** | Authorization failed: loopback/native access was required, cookie Origin/CSRF validation failed, a server-mode mutation lacked an admin credential, or a native path capability was invalid/expired. | A PIN cannot grant admin or filesystem access. Re-authenticate the UI; scripts should use the API-key header; run native operations from the desktop app. |
| **429** | A failed administrator-session exchange exceeded its per-client limit, the GPU pool is saturated, or a model download is rate-limited. Ships with `Retry-After`; workload throttles also carry `X-VoiceStudio-Retryable: true`. | Back off for `Retry-After` seconds. For authentication, verify the master before retrying; a correct master is never locked out. |
| **403** | Authorization failed: loopback/native access was required, a server-mode mutation lacked the API key, or a native path capability was invalid, expired, or for a different operation. | A PIN cannot grant admin or filesystem access. Run native operations from the desktop app; configure and present the API key for remote server-mode mutations; reopen the native picker if a one-shot capability expired. |
| **429** | **Not an auth failure.** The GPU pool is saturated (admission control) or a model download is rate-limited. Ships with `Retry-After` and `X-VoiceStudio-Retryable: true`. | Back off for `Retry-After` seconds and retry the identical request. |
---
-29
View File
@@ -1,29 +0,0 @@
# Hosted Voice integration
VoiceStudio remains local-first. `/profiles` and `/generate` keep their local
SQLite and on-device synthesis behaviour unless a caller explicitly asks for a
hosted operation. No profile or generation is uploaded merely because hosted
configuration exists.
To enable the optional adapter, configure the backend environment:
```text
VSS_HOSTED_API_BASE=http://127.0.0.1:8080
VSS_HOSTED_API_TOKEN=<scoped API credential>
VSS_HOSTED_PROJECT_ID=<hosted project id>
VSS_HOSTED_MODEL_ID=<approved TTS model id>
VSS_HOSTED_MODEL_VERSION=<approved model version>
VSS_HOSTED_BASE_VOICE_ID=<model-approved base voice>
VSS_HOSTED_CONSENT_TEXT_VERSION=oss-spoken-consent-v1
```
First record ownership consent in the local profile UI, then explicitly call
`POST /profiles/{profile_id}/hosted-sync`. The adapter uploads the reference
recording through hosted Artifact grants and creates a consent-backed
`/v1/voices` record; it never sends a local path or a consent recording. The
returned hosted ID is stored only as local synchronization metadata.
Call `POST /generate` with `hosted=true` and that synchronized `profile_id` to
use the hosted durable `/v1/jobs` path. The adapter stages text as an Artifact,
polls the durable Job, and downloads the result only through a temporary grant.
Without `hosted=true`, `/generate` stays entirely on-device.
-7
View File
@@ -214,13 +214,6 @@ Two paths are worth persisting across container restarts:
The running version is now shown in **Settings → About → Version** (read live
from the backend), so the web UI no longer displays a dash in Docker.
- **Checking which version is running:** `docker exec <container> python3 -c "import importlib.metadata; print(importlib.metadata.version('omnivoice'))"`, or hit the `/health` endpoint — it returns `{"status": "ok", "device": ..., "version": "0.3.x"}`. Use the container name listed by `docker compose ps` (or `omnivoice` for the `docker run` examples).
- **Watching startup:** the port answers within about a second of container
start, but heavy initialization (PyTorch, API routes, database migration)
continues in the background. During that window `/health` returns **503**
with the current step, and `GET /startup/progress` returns the full
step-by-step ledger (`status`, current `step`/`label`, per-step states) —
useful when a start seems slow and you want to see where it actually is.
The Docker `HEALTHCHECK` flips healthy only once `/health` is 200.
- **"Loopback origin required" errors (and a blank version):** the desktop
build restricts the `/system/*` and `/api/settings/*` routes to a loopback
origin, but Docker's NAT makes every request look non-loopback, so the gate
Binary file not shown.

Before

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

+17 -28
View File
@@ -24,14 +24,13 @@ loopback-only exactly as before.
┌──────────────┐ tailnet (WireGuard) ┌─────────────────────┐
│ laptop │ ws/https to MagicDNS URL │ gpu-box │
│ VoiceStudio UI │ ──────────────────────────▶ │ VoiceStudio backend │
│ (thin client) │ short-lived session/ticket │ OMNIVOICE_API_KEY set │
│ (thin client) │ Authorization: Bearer … │ OMNIVOICE_API_KEY set │
└──────────────┘ └─────────────────────┘
```
The desktop app *is* the thin client — there is no separate binary. You enter a
**Backend URL** and an **API key** in Settings. The key is exchanged once for a
short-lived session; ordinary HTTP requests use that session and WebSockets use
path-bound, single-use tickets. The master is never stored or put in a URL.
The desktop app *is* the thin client — there is no separate binary. You set a
**Backend URL** and an **API key** in Settings, and every request (including
the dictation and TTS WebSockets) is sent to the remote with the key attached.
## 1. On the GPU box: run the backend with a key
@@ -51,11 +50,11 @@ the backend's CORS allow-list to include that origin — see
[Browsers from another origin (CORS)](api-auth.md#browsers-from-another-origin-cors);
neither server mode nor trusted networks covers CORS.
When `OMNIVOICE_API_KEY` is set, every non-loopback request needs an accepted
credential. Scripts should use `Authorization: Bearer <key>`. Legacy
`?api_key=` and `ov_key` transports remain accepted for compatibility, but the
backend no longer creates a master-key cookie and the bundled UI uses only
short-lived sessions. Loopback traffic on the box itself remains ungated.
When `OMNIVOICE_API_KEY` is set, **every non-loopback HTTP and WebSocket
request must present it**, as `Authorization: Bearer <key>`, `?api_key=<key>`
(browser WebSockets can't set headers), or the `ov_key` cookie the backend
sets after the first authenticated request. Loopback traffic on the box
itself is never gated, so local tools keep working.
## 2. Reach it over Tailscale
@@ -80,11 +79,6 @@ Serve terminates on the node and forwards from `127.0.0.1`, so to the backend
the request looks like loopback — which is why the **API key is still
required** in that path (the bearer gate doesn't rely on the source address
for non-local exposure; set the key and it always applies to keyed clients).
Uvicorn trusts proxy headers from loopback by default, so Serve's forwarded
HTTPS scheme becomes the authoritative ASGI scheme and browser session cookies
receive `Secure`. For a non-loopback reverse proxy, explicitly configure
Uvicorn's `--forwarded-allow-ips=<proxy-ip>`; the application never trusts an
arbitrary `X-Forwarded-Proto` header itself.
> **Do not use `tailscale funnel`** (public-internet exposure) for this. Even
> with a key, a voice-cloning backend should not be on the open internet.
@@ -96,11 +90,10 @@ Settings → Sharing → **Remote backend**:
- **Backend URL**: the MagicDNS URL from step 2 (with `:3900` if you didn't
use Serve, or no port if you did).
- **API key**: the value of `OMNIVOICE_API_KEY` from step 1.
- **Test connection** hits the auth-exempt `{url}/health` with no credential,
then exchanges the entered key for a session if health succeeds.
- **Save & reload** stores only the URL and restarts the UI against the remote.
The key input is cleared after its single exchange. The URL must be a full
`http://` or `https://` URL
- **Test connection** hits `{url}/health` and shows the remote's version and
device.
- **Save & reload** stores both in this browser/app and restarts the UI
against the remote. The URL must be a full `http://` or `https://` URL
(`gpu-box:3900` alone is rejected), and saving a URL that hasn't passed
**Test connection** asks for confirmation first — a wrong base would leave
the app unable to reach any backend until you change it back here.
@@ -118,13 +111,12 @@ https://gpu-box.your-tailnet.ts.net/#api_key=<key>
Use the fragment (`#`, not `?`) deliberately: fragments are never sent to the
server, so the key stays out of the GPU box's and any reverse proxy's request
logs. The fragment is scrubbed synchronously, then the key is exchanged once
for an eight-hour maximum session; the master is not stored. If
logs. The key is stored for that browser and the fragment is scrubbed from the
address bar (so it doesn't linger in history or get re-applied on a reload). If
your key contains `+`, `&`, `#`, or `=`, URL-encode it (e.g. `#api_key=a%2Bb`);
keys from `secrets.token_urlsafe` (above) need no encoding.
Thereafter the UI loads normally with the short-lived session. Cross-origin
bearer sessions are tab-scoped; closing the tab requires re-entry. If a request
401s again (expired/wrong/rotated key), you're prompted to re-enter it. The
Thereafter the UI loads normally with the key attached to every request. If a
request ever 401s again (wrong/rotated key), you're prompted to re-enter it. The
same gate shows a LAN-share **PIN** prompt instead when network sharing — not a
remote key — is what's gating access.
@@ -133,9 +125,6 @@ remote key — is what's gating access.
- **Plain HTTP is sniffable.** A bearer key over `http://` on a hostile
network can be read off the wire. Use Tailscale (WireGuard-encrypted) or
Tailscale Serve (TLS) for anything beyond a fully trusted LAN.
- The first-party UI never persists `OMNIVOICE_API_KEY`, never creates a URL
containing it, and never puts its administrator session in a WebSocket URL.
WebSocket tickets expire after 30 seconds and work once for one path.
- The API key and the LAN-share **PIN** are independent: the PIN guards a
casual share session, the key is the durable remote credential. Either can
be active; both are checked when set.
+3 -12
View File
@@ -1,6 +1,6 @@
# Remote GPU workers
Run VoiceStudio on this machine, but hand individual jobs to GPUs on your other
Run OmniVoice on this machine, but hand individual jobs to GPUs on your other
machines. Results come back here.
This is **opt-in and off by default**. Until you turn it on and approve a
@@ -23,7 +23,7 @@ administration.
## What you need
* VoiceStudio on both machines, on versions no more than two releases apart.
* OmniVoice on both machines, on versions no more than two releases apart.
* The worker machine must be able to **reach** this one over the network. Same
LAN is enough at home; across networks, a VPN such as
[Tailscale](https://tailscale.com/) is the reliable answer. The worker dials
@@ -153,15 +153,6 @@ fallback is reported once. ASR, diarization and translation also remain local. D
runs here, deliberately and permanently, because there latency *is* the
feature. The remaining operations are being ported one at a time.
### Voice identity parity
For TTS, the worker receives the complete local rendering contract: the voice
profile's reference audio and transcript, its pinned seed, model quality
controls, text chunking/crossfade settings, and output effect preset. The
worker runs the same native or generic rendering pipeline as local
`/generate`; selecting a gallery voice therefore does not turn it into a new
random voice merely because it was rendered on another GPU.
The picker knows this. It resolves against the surface you are on, so a chosen
worker reads **Local** on a tab whose work has no remote path yet and names the
reason, instead of showing a green dot next to a GPU that receives nothing. The
@@ -213,7 +204,7 @@ The row tells you what happened in words — "Paused after 3 failures … retryi
in 45s" — and **Resume** clears it immediately when you've fixed the machine.
**You quit the app mid-task.** Remote work keeps running on the worker. On next
launch VoiceStudio recovers those tasks and reconciles with each worker about
launch OmniVoice recovers those tasks and reconciles with each worker about
what is genuinely still in flight.
**Version or feature mismatch.** The protocol keeps a two-release compatibility
Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

After

Width:  |  Height:  |  Size: 268 KiB

File diff suppressed because it is too large Load Diff
@@ -1,7 +1,5 @@
# Spec — TASK #26: Gallery "Use in Stories" / "Set as Audiobook default" + create-voice handoff
> **Implemented (2026-08-13).** Gallery and Community persona cards now materialize once and hand the returned profile directly to Studio, the current Stories cast, or the current Audiobook default. The implementation uses the unified `longformSlice` that superseded the store additions proposed below; the remainder of this document preserves the original design record.
## TL;DR
Today the Gallery's "Use voice" action materializes an archetype/community voice into a profile and hard-codes a handoff into the **Studio** synthesis view (`frontend/src/pages/VoiceGallery.jsx:199-210` for archetypes; `frontend/src/App.jsx:254-268` for the studio-side pickup). There is no path from the Gallery into the **Stories** cast or the **Audiobook** default narrator. This task adds two quick-actions to gallery + community cards — "Use in Stories" and "Set as Audiobook default" — that (a) materialize the voice into a real profile (same backend call as today) and (b) land it in the right destination: appended to the Stories cast as a new character, or set as the persisted Audiobook default voice. The Audiobook default currently has no store binding at all, so this task also promotes it from local `useState` to a persisted store field.
@@ -1,479 +0,0 @@
# Frontend Responsiveness: Persistence Write-Amplification Remediation Plan
| Field | Decision |
| --- | --- |
| Status | Implemented in draft PR #1541; CI and review pending |
| Target | One focused frontend PR |
| Priority | P1 responsiveness and data-safety hardening |
| Risk | Medium: persistence timing changes, persisted formats do not |
| Dependencies | None |
| Rollback | Revert the PR; the existing keys and schemas remain readable |
## Executive decision
The first optimization PR should remove synchronous JSON serialization and `localStorage` writes from high-frequency interaction paths. It should preserve the existing `omnivoice.app` and `omni_ui` contracts, coalesce each burst to the latest value, flush within a bounded window, and prevent deferred writes from undoing Factory Reset.
This is the best first change because it addresses a measured, cross-workspace bottleneck without combining it with a storage migration, backend change, or `App.jsx` rewrite. Incremental-dub scheduling, transactional undo, and workspace decomposition remain separate follow-ups with their own evidence and rollback boundaries.
## Evidence and diagnosis
### Static path
Two independent persistence paths run on the browser main thread:
1. Every Zustand `set` invokes the persist middleware. The middleware runs `partialize`, serializes the complete persisted projection, and calls synchronous `localStorage.setItem('omnivoice.app', ...)`, even when the mutation only changes transient state.
2. `useAppData` has a broad effect that serializes and writes `omni_ui` whenever text, dub segments, transcript, tracks, history, or a related preference changes.
The resulting hot path is:
`input -> store update -> render/effects -> full projection -> JSON.stringify -> localStorage.setItem`
The cost scales with document size rather than with the small field the user changed. `localStorage` is synchronous, so both serialization and the physical write compete with the next frame.
### Local runtime baseline
The following measurements are diagnostic baselines from commit `3e3189d04d2d6dba69b4dd07fefc8725b9c94af6`, not portable CI thresholds. Each scenario performs 20 UI-scale interactions; raw storage timing excludes `JSON.stringify`, so it is a lower bound. Two unrelated contact-key writes were excluded from the target-key counts but included in the aggregate raw timing.
| Fixture | Writes to target keys | Input-to-next-frame | Raw `setItem` time |
| --- | ---: | ---: | ---: |
| Small local state | 40 `omnivoice.app` + 20 `omni_ui` | 13.9 ms average, 18.9 ms max | 1.9 ms |
| 1,800 dub segments + 400 story tracks | 40 + 20 | 23.6 ms average, 39.0 ms max | 50.7 ms |
| 3,000 dub segments + 3,000 story tracks | 40 + 20 | Repeated 56-114 ms long tasks | 809 ms |
Representative serialized sizes were approximately 156 KB for `omnivoice.app` and 1.5 MB for `omni_ui`. A direct text-edit probe also produced one write to each key for each change.
### Baseline verification
- Baseline commit: `3e3189d04d2d6dba69b4dd07fefc8725b9c94af6`.
- `bun run test -- src/utils/prefKeys.test.js src/test/omniUiSchema.test.js src/test/dubStepRestoreClamp.test.js src/store/uiScaleMigration.test.ts src/test/dubPerLangTranslations.test.jsx src/test/dubVoiceMatchRequest.test.jsx` passes: 6 files, 35 tests.
- The production build passes. The main application chunk is approximately 381.82 KB minified / 116.27 KB gzip.
- `backend/api/routers/mcp_bindings.py` is not implicated: its list handler is a thin delegation, and the bindings panel already loads bindings and profiles concurrently.
- The large Settings/OpenAPI chunk is lazy and is not the interaction-time bottleneck targeted here.
## Goal
For a rapid sequence of edits, perform no JSON serialization or physical storage write in the originating interaction task and persist only the newest value after the burst, while retaining synchronous hydration and the current recovery formats.
## Scope
### In scope
- One shared, typed, coalescing JSON writer for browser `localStorage`.
- A Zustand-compatible structured storage adapter that defers serialization itself.
- Deferred `omni_ui` persistence with its exact current field set.
- Trailing flush, maximum-wait flush, and page-lifecycle flush.
- Single-writer protection for the standalone Tauri capture widget.
- Factory Reset cancellation so pending values cannot recreate deleted keys.
- Deterministic unit/integration tests, a before/after browser trace, and an Unreleased changelog entry.
### Explicitly out of scope
- IndexedDB, workers, new storage keys, schema changes, or a Zustand version bump.
- Removing duplicated fields from `omni_ui` or changing restore precedence.
- Backend/API/database changes, including MCP bindings.
- Debouncing `/tools/incremental` in this PR.
- Changing undo/redo semantics or snapshot representation.
- Splitting stores, decomposing `App.jsx`, or moving workspace imports.
- New dependencies, user-visible strings, locale files, or an app version bump.
- Hardware-sensitive timing assertions in CI.
## Compatibility and safety invariants
The implementation must preserve all of the following:
| Contract | Required invariant |
| --- | --- |
| Zustand key | `omnivoice.app` |
| Zustand envelope | `{ state, version: 7 }`, serialized with normal `JSON.stringify` semantics |
| Zustand projection | Existing `partialize` fields and transient-field stripping remain semantically unchanged |
| Zustand migration | Existing v1-v7 migration behavior remains unchanged |
| Legacy recovery key | `omni_ui` |
| Legacy recovery shape | Exact current field names, omission behavior, and `sanitizeOmniUi` restore path |
| Hydration | Synchronous; no loading gate or async race is introduced |
| Durability | When serialization/storage succeeds and the browser runs timers, a dirty key is attempted within 1,000 ms of its first unflushed change |
| Lifecycle | `pagehide` and hidden-document events attempt pending values; both events together cause at most one physical write per unchanged generation |
| Reset | A removed preference key cannot be recreated by old or newly queued work before the reset reload |
| Desktop windows | Persistence starts in an unknown/read-only role; the resolved main webview is activated as the only writer and the standalone widget stays read-only |
| Privacy | Logs may contain a key and error name, never persisted user content |
| Platform parity | Same default behavior on macOS, Windows, Linux, browser, and Docker |
Direct consumers such as `utils/donationMoments.js`, E2E state seeding, long-form recovery, and the preference-key registry must continue to parse the existing envelope without changes. The donation opt-out's primary `omnivoice.donate.optOut` flag remains an immediate, separate write; add a compatibility assertion that its immediate behavior and the flushed legacy-envelope fallback both remain valid.
Concurrent browser/Docker tabs are explicitly not promoted to a coordinated multi-writer system in this PR. They retain unsupported last-physical-writer-wins behavior. The PR description must state that boundary; adding cross-tab revisions or `BroadcastChannel` arbitration would be a separate data-consistency design.
## Proposed design
### 1. Shared coalescing writer
Create `frontend/src/utils/coalescedJsonStorage.ts` with an injectable core and one application singleton. The public contract should be small:
| API | Contract |
| --- | --- |
| `queueJsonWrite(key, readLatestValue)` | Mark `key` dirty and replace its lazy provider; return a generation-bound disposer that can cancel only this registration |
| `createZustandJsonStorage()` | Return a `PersistStorage` adapter whose `getItem` is synchronous and whose `setItem` queues the structured `StorageValue` |
| `flushPendingWrites()` | Synchronously serialize and attempt every pending write; return a summary for tests/diagnostics |
| `discardPendingWrites(predicate?)` | Cancel timers and pending values matching a key predicate |
| `suspendJsonWrites(predicate)` | Discard matching work and reject later matching queues until the returned resume callback is used |
| `configurePersistenceRole(role)` | Resolve the singleton from initial `unknown` to `main` or `readonly`; activate staged main work or discard all staged widget work |
| Adapter `removeItem(key)` | Cancel/stage-remove that key before raw removal; propagate main-window removal errors; remain inert in a read-only widget |
| `installPersistenceLifecycleFlush()` | Install the singleton listener pair once for the main bootstrap owner; cleanup is idempotent and reserved for tests/HMR teardown |
Required scheduling semantics:
- Quiet delay: 250 ms after the latest value for a key.
- Hard maximum: 1,000 ms from the first unflushed value for that key; continuous input must not starve persistence.
- Last scheduled value wins.
- The queued provider is evaluated on the JavaScript thread only at flush, so the value serialized is the latest application value at flush time rather than a deep-cloned event-time object.
- The quiet timer resets on replacement; the maximum timer does not.
- A successful maximum flush starts a new window for later updates.
- Use standard timers. Do not make `requestIdleCallback` part of the correctness path; availability differs across the supported webviews.
- Do not wrap `createJSONStorage`. It stringifies before calling the adapter and would leave the main cost inside the interaction path.
Flush behavior:
1. Read the latest provider and serialize only at flush time.
2. Compare the serialized value with the currently durable raw value and skip an identical physical write.
3. Call `setItem` once at most for each dirty key in that flush.
4. Mark the entry clean only after a successful write or confirmed identical value.
5. Ensure an old timer cannot commit after a newer value, cancellation, or removal.
`getItem` must evaluate and return the latest pending structured value when one exists; otherwise it must synchronously parse the durable raw value. This keeps explicit Zustand `rehydrate()` calls internally consistent without changing cold-start hydration.
The lazy-provider contract avoids copying a 1.5 MB document on every input. Task 0 must audit every persisted nested container for in-place mutation. React/Zustand setters are expected to publish replacements; any isolated violation must be fixed or explicitly converted to a safe value provider before wiring this scheduler. If the audit reveals a broad mutable-data convention, stop and redesign this PR rather than hiding a state-model refactor inside it. A deterministic test must pin current-at-flush semantics: mutate/replace the provider's source without serializing, then flush and verify the current value is written.
### 2. Failure semantics
- `JSON.stringify` or storage failures must not escape through a Zustand setter, React effect, or lifecycle event.
- A serialization failure discards that invalid value after a warning; a later valid update can proceed.
- Every flush attempt clears both timers first.
- A quota/security/write failure leaves the previous durable blob untouched and keeps the newest value dirty, but disarms automatic retry. A later queue starts a fresh 250/1,000 ms window; an explicit/lifecycle flush attempts it once. Advancing timers alone must not create a retry loop.
- A multi-key flush is isolated per key: successful keys become clean; a failed key remains dirty; retrying the failed key must not rewrite successful siblings.
- Warn once per key/operation/error class to avoid console floods.
- Never log the value, text, segment data, or serialized payload.
- Adapter `removeItem` and Factory Reset remain truthful: cancel pending work first, then allow a main-window raw removal failure to reach the caller.
- The 1,000 ms durability statement applies only when the browser schedules the timer and storage succeeds. Timer throttling, quota denial, a crashed process, or a failed lifecycle write cannot be promised durable; these cases are observable and non-crashing.
### 3. Main-window ownership
The Tauri widget imports the same Zustand store in a separate webview and calls setters for runtime dictation state. Today those transient setters can persist an older projection over the main window's current preferences.
Do not duplicate widget detection inside the storage utility. `detectIsWidget()` already resolves the initialization marker, Tauri `getCurrentWindow().label`, and legacy development URL. `bootstrapApp()` must pass that exact resolved result to `configurePersistenceRole()` before React renders.
The singleton begins in `unknown`: hydration reads work, but writes/removals can only be staged and no timer, serialization, or raw mutation may run. Resolving `main` replays only the latest staged operation per key and starts its 250/1,000 ms clocks at activation; time spent awaiting role detection does not count against a window in which writing was forbidden. Resolving `readonly` discards staged work and makes both `setItem` and `removeItem` inert. This is necessary because the store is statically imported before asynchronous window detection completes. The in-page browser capture pill shares the main document and remains writable.
Tests that import the store without `bootstrapApp()` must use an isolated writer or explicitly configure `main` in setup and reset role, staged work, suspensions, timers, and listeners in teardown. Existing migration tests must clear scheduler state before seeding raw fixtures; otherwise a staged pending value can mask the fixture during `persist.rehydrate()`.
### 4. Lifecycle ownership
After `detectIsWidget()` resolves, `bootstrapApp()` should configure the role and install lifecycle flushing before rendering only for the main window. Bootstrap is the sole production owner; an isolated writer instance or explicit teardown resets listeners in tests.
- Flush on `pagehide`.
- Flush on `visibilitychange` only when `document.visibilityState === 'hidden'`.
- Do not add `beforeunload`; it is unnecessary and can interfere with back/forward caching.
- Lifecycle flush uses the same generation/cancellation checks as timer flushes. If hidden visibility and `pagehide` both fire, the second invocation observes a clean generation and performs no second serialization/write.
### 5. Zustand integration
In `frontend/src/store/index.ts`:
- Replace `createJSONStorage(() => localStorage)` with the structured coalescing adapter.
- Preserve `name`, `partialize`, `version: 7`, and `migrate` semantically unchanged.
- Keep the long-form projection and removal of `generating`/`audioUrl` intact.
- Do not add `text`, dub segments, or other legacy recovery fields to this key.
This PR deliberately leaves `partialize` synchronous. If post-change profiling shows its `storyTracks.map(...)` is still material, optimize projection scheduling in a separate change rather than replacing hydration and migration machinery here.
### 6. `omni_ui` integration
In `frontend/src/hooks/useAppData.js`:
- Build the same recovery object with the same property order and values.
- Replace direct `JSON.stringify` + `localStorage.setItem` with a lazy `queueJsonWrite('omni_ui', readLatestOmniUi)` provider.
- Keep synchronous parsing, `sanitizeOmniUi`, legacy `clone`/`design` handling, and dub-step clamping unchanged.
- Add an explicit `omniUiRestoreComplete` readiness state. The initial persistence effect must queue nothing; the restore effect sets all recovered values and flips readiness in the same batch, and the subsequent render supplies the first writable value.
- Prove an immediate lifecycle event between the initial effects and the restored render cannot persist defaults.
- Feed a lazy latest-value provider to the writer and invoke its generation-bound disposer in effect cleanup. An obsolete StrictMode/unmounted effect may cancel only its own registration, never a newer mount's provider. Do not deep-clone at queue time; the immutability audit and current-at-flush contract above define ownership.
### 7. Factory Reset integration
In `clearLocalPreferences`:
1. Suspend and discard every pending key for which `isPrefKey(key)` is true.
2. Enumerate and remove durable preference keys exactly as today.
3. Preserve connection credentials and user-data keys exactly as today.
The suspension lasts for the remainder of the successful reset session, because background store activity can occur during the 400 ms before reload. Wrap the entire enumerate-and-remove transaction, including `length`, `key()`, and key filtering/access, so any failure resumes writes before rethrowing. This prevents the existing reset error path from leaving persistence silently disabled. This ordering is mandatory: a stale timer, a new post-reset store update, or the later `pagehide` could otherwise resurrect `omnivoice.app` or `omni_ui` after deletion. Tests that simulate a successful reset without a real reload must explicitly reset the isolated writer afterward.
## File-level change budget
| File | Change |
| --- | --- |
| `frontend/src/utils/coalescedJsonStorage.ts` | New lazy scheduler, Zustand adapter, role configuration, suspension, and lifecycle ownership |
| `frontend/src/utils/coalescedJsonStorage.test.ts` | New deterministic scheduler/failure/lifecycle/widget tests |
| `frontend/src/store/index.ts` | Swap storage adapter only; preserve projection and migrations |
| `frontend/src/store/persistenceScheduling.test.ts` | New Zustand envelope, coalescing, hydration, and long-form projection tests |
| `frontend/src/hooks/useAppData.js` | Gate restore readiness and queue the existing `omni_ui` value provider |
| `frontend/src/hooks/useAppData.persistence.test.jsx` | New restore and burst-write integration tests |
| `frontend/src/main-app.jsx` | Configure the resolved window role, then install main-only lifecycle flushing |
| `frontend/src/main-app.test.jsx` | Extend label/marker/URL role-order coverage |
| `frontend/src/utils/prefKeys.js` | Suspend pending and future preference writes across successful reset |
| `frontend/src/utils/prefKeys.test.js` | Add no-resurrection coverage |
| `frontend/src/utils/donationMoments.test.js` | Preserve immediate primary opt-out and flushed legacy fallback behavior |
| `frontend/e2e-perf/responsiveness.spec.ts` | Add opt-in production-bundle fixture, route mocks, instrumentation, and JSON artifact; no wall-clock CI assertions |
| `frontend/playwright.perf.config.ts` | Add cross-platform production-preview benchmark config derived from the existing prod smoke config |
| `CHANGELOG.md` | One Unreleased performance/fix line once the PR number exists |
No backend, locale, package manifest, lockfile, or persisted-schema file should change.
## Implementation sequence
### Task 0: Freeze the current contracts
- [ ] Record the parent commit SHA and rerun the browser baseline with identical fixtures.
- [ ] Add characterization assertions for the exact Zustand envelope, version, legacy snapshot keys, direct readers, and reset key registry; these must pass before production changes.
- [ ] Add integration assertions for burst write counts and initial-default overwrite behavior; these must fail on the current immediate writer for the expected reason.
- [ ] Confirm existing direct readers (`donationMoments`, E2E helpers) against the frozen fixture.
- [ ] Audit the persisted Zustand projection and every `omni_ui` nested value for in-place mutation. Record the search paths in the PR; resolve any hit before adopting lazy providers.
- [ ] Keep the current 35 targeted tests green while adding fail-before cases.
Exit condition: characterization tests pass; behavioral integration tests fail only because writes are immediate/repeated or startup persistence is ungated. Scheduler-specific unit tests are introduced with the new utility rather than pretending to fail before their seam exists.
### Task 1: Implement the storage primitive
- [ ] Implement per-key quiet and maximum timers with injected clock/storage/serializer dependencies.
- [ ] Make value materialization and serialization lazy and deduplicate against the durable raw string.
- [ ] Implement synchronous pending/durable reads.
- [ ] Implement generation-bound provider disposers plus flush, discard, and removal guards.
- [ ] Implement predicate-based suspension for destructive reset windows.
- [ ] Define failed attempts as timer-disarmed; a later queue starts a new maximum window.
- [ ] Isolate partial failures across multiple dirty keys.
- [ ] Recover from throwing providers, durable reads, malformed JSON, and raw writes without poisoning later valid operations.
- [ ] Deduplicate warnings and prove no value, serialized payload, or error message containing user content is logged.
- [ ] Contain and deduplicate errors without logging payloads.
- [ ] Add `unknown -> main|readonly` role configuration; unknown work cannot reach raw storage.
- [ ] Make adapter removal obey cancellation, role, and error-propagation contracts.
- [ ] Add single-owner lifecycle installation and idempotent teardown.
- [ ] Add a full isolated-writer reset hook for tests: role, staged operations, suspensions, timers, listeners, and warning registry.
Exit condition: all utility tests pass without importing React or the application store.
### Task 2: Wire Zustand without changing its contract
- [ ] Replace `createJSONStorage` with the structured adapter.
- [ ] Keep `partialize`, `version`, and `migrate` unchanged except for any mechanical key constant extraction needed by tests.
- [ ] Prove that 100 rapid transient updates cause zero synchronous serializations/writes and at most one trailing write.
- [ ] Prove the final JSON contains the latest persisted update and `{ version: 7 }`.
- [ ] Prove `persist.clearStorage()` cannot be undone by timers/lifecycle and is inert in the widget role.
- [ ] Update raw-seeded migration tests to reset pending/staged writer state before `rehydrate()`.
- [ ] Prove long-form fields round-trip while `generating` and `audioUrl` remain excluded.
- [ ] Prove v6-to-v7 and older accepted fixtures still hydrate synchronously.
Exit condition: existing store migration tests plus the new scheduling suite pass.
### Task 3: Wire `omni_ui`
- [ ] Extract snapshot construction only if needed for a precise shape test; do not redesign ownership.
- [ ] Add the restore-complete state gate, then queue a latest-value provider rather than serializing in the effect.
- [ ] Add a seeded-restore test proving the initial defaults never become the durable winner.
- [ ] Dispatch lifecycle flush before the post-restore render and prove it writes no defaults.
- [ ] Add a burst test proving the latest text and dub segment data win after one write.
- [ ] Cover StrictMode double effects plus unmount/remount before the quiet timer; no obsolete provider may win.
- [ ] Re-run schema, legacy-mode, and restored-dub-step tests unchanged.
Exit condition: a reload after explicit flush restores a deep-equal latest snapshot through `sanitizeOmniUi`.
### Task 4: Close lifecycle and reset races
- [ ] Configure the exact `detectIsWidget()` result before render, then install main-window lifecycle flushing.
- [ ] Prove marker, Tauri-label-only, and legacy-URL detection; pre-role setters cannot leak from a widget.
- [ ] Prove unknown-role set→remove ends removed, remove→set activates the set, and the 1-second clock starts at main-role activation.
- [ ] Prove duplicate installation does not duplicate listeners and teardown removes the exact callbacks.
- [ ] Prove hidden visibility plus `pagehide` produce at most one serialization/write for an unchanged pending generation.
- [ ] Suspend pending and future preference values before Factory Reset removal.
- [ ] Queue another store update, advance every fake timer, and dispatch lifecycle events after reset; both target keys must remain absent.
- [ ] Prove raw removal and enumeration/access failures resume normal persistence before propagating the error.
- [ ] Prove preserved connection/data keys remain untouched.
- [ ] Prove standalone-widget setters and `persist.clearStorage()` cannot mutate durable state, while main-window operations still work.
Exit condition: neither stale timers, lifecycle events, StrictMode, nor the widget can overwrite newer or deliberately removed durable state.
### Task 5: Verify and document
- [ ] Run targeted tests during iteration.
- [ ] Run frontend typecheck, lint, format check, full Vitest, build, and production-bundle smoke.
- [ ] Run the repository's backend suites offline before landing, despite no backend diff, because they are merge gates.
- [ ] Check in the opt-in Playwright benchmark with deterministic fixture generation and JSON output.
- [ ] Run an alternating parent/implementation/parent (A/B/A) benchmark sequence with five repeats per leg; repeat if same-commit variance exceeds 5%.
- [ ] Attach counts, payload sizes, p50/p95/max interaction latency, and long-task evidence to the PR.
- [ ] Open the draft PR to obtain its number, then add/amend the Unreleased changelog line before requesting review.
Exit condition: deterministic acceptance criteria pass; build/merge gates are green; browser timing is attached as reproducible decision evidence rather than a hardware-sensitive CI gate.
## Required deterministic tests
| Scenario | Required result |
| --- | --- |
| 100 replacements in one burst | 0 synchronous provider/serializer/write calls; 1 trailing write with value 100 |
| Lazy provider source changes before flush | Current-at-flush value is written; no deep clone or serialization occurred while queueing |
| Obsolete provider disposer | Cancels only its generation; it cannot cancel a newer provider for the same key |
| Continuous updates beyond 1 second | A maximum-wait flush occurs; later updates start a new window |
| Identical durable value | Serialization may occur at flush; physical `setItem` is skipped |
| Explicit `getItem` before flush | Latest pending structured value is returned synchronously |
| Hidden document followed by `pagehide` | At most one serialization/write for the unchanged pending generation |
| Cancel/remove followed by all timers | Deleted key stays absent |
| Zustand `persist.clearStorage()` | Pending/staged key is cancelled; timer/lifecycle cannot resurrect it |
| Successful reset followed by a new store update | Matching writes remain suspended and deleted keys stay absent until reload |
| Failed reset removal | Error propagates and write suspension is released |
| Reset enumeration/access failure | Error propagates and write suspension is released |
| Serialization error | Caller does not throw; invalid entry does not poison a later valid update |
| Provider throws | Caller/lifecycle does not crash; invalid entry is discarded and a later valid provider succeeds |
| Durable `getItem` throws | Hydration falls back to defaults without crashing; a later valid queue can persist |
| Malformed durable JSON | Hydration follows the current safe fallback/migration behavior and later persistence repairs it |
| Quota/security error | Caller does not throw; old durable value remains; timers do not retry; one later queue starts one new window |
| Two-key partial failure | Successful key stays clean; failed key alone retries later |
| Unknown staged set→remove / remove→set | Only the final operation activates on `main`; its clocks start at activation |
| Unknown/standalone-widget update and removal | Reads work; no raw mutation before role resolution or after read-only resolution |
| Duplicate lifecycle installation/teardown | One listener set; exact callbacks are removed once |
| Zustand transient burst | At most one `omnivoice.app` write and unchanged v7 envelope |
| Legacy recovery burst | At most one `omni_ui` write with latest text/segments |
| Seeded initial recovery | Defaults never overwrite restored state, including immediate lifecycle and StrictMode/unmount races |
| Factory Reset race | Both pending target keys remain absent after timers and lifecycle events |
| Donation opt-out compatibility | Primary opt-out remains immediately visible; flushed v7 legacy fallback remains readable |
| Repeated warning | One warning per key/operation/error class; no value, serialized payload, or content-bearing error message appears |
Do not use elapsed milliseconds as Vitest pass/fail assertions. Use fake timers and call counts for CI; use browser traces for performance evidence.
## Verification commands
Run targeted tests while iterating:
```powershell
cd frontend
bun run test -- src/utils/coalescedJsonStorage.test.ts src/store/persistenceScheduling.test.ts src/hooks/useAppData.persistence.test.jsx src/main-app.test.jsx src/utils/prefKeys.test.js src/utils/donationMoments.test.js src/test/omniUiSchema.test.js src/test/dubStepRestoreClamp.test.js src/store/uiScaleMigration.test.ts
```
Run the frontend landing gate:
```powershell
cd frontend
bun run typecheck:ci
bun run lint
bun run format:check
bun run test
bun run test:prod-bundle
bun run test:legacy
```
`test:prod-bundle` already performs the production build before its smoke test, so a separate `bun run build` would only duplicate work. Run it separately only when build output is needed during iteration.
Run the backend CI-equivalent suites from the repository root with a genuinely empty Hugging Face cache:
```powershell
$previousOffline = $env:HF_HUB_OFFLINE
$previousCache = $env:HF_HUB_CACHE
$tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath())
$emptyHfCache = [IO.Path]::GetFullPath((Join-Path $tempRoot ("omnivoice-hf-empty-" + [guid]::NewGuid())))
if (-not $emptyHfCache.StartsWith($tempRoot, [StringComparison]::OrdinalIgnoreCase)) { throw 'Unsafe cache path' }
New-Item -ItemType Directory -Path $emptyHfCache | Out-Null
try {
if (@(Get-ChildItem -LiteralPath $emptyHfCache -Force).Count -ne 0) { throw 'HF cache is not empty' }
$env:HF_HUB_OFFLINE = '1'
$env:HF_HUB_CACHE = $emptyHfCache
uv run --no-sync pytest tests/ -q --tb=short
if ($LASTEXITCODE -ne 0) { throw "tests/ failed with exit code $LASTEXITCODE" }
uv run --no-sync pytest backend/tests/ -q --tb=short
if ($LASTEXITCODE -ne 0) { throw "backend/tests/ failed with exit code $LASTEXITCODE" }
} finally {
$env:HF_HUB_OFFLINE = $previousOffline
$env:HF_HUB_CACHE = $previousCache
Remove-Item -LiteralPath $emptyHfCache -Recurse -Force
}
```
These are repository landing gates, not evidence that the frontend optimization itself works. The unique cache and restored environment prevent a populated developer cache or leaked shell state from masking failures.
## Browser validation protocol
Check in `frontend/e2e-perf/responsiveness.spec.ts` and `frontend/playwright.perf.config.ts` as a non-CI production-bundle benchmark harness. Keeping it outside `e2e-prod/` ensures the existing production-smoke CI command cannot discover this manual benchmark. The config must mirror `playwright.prod.config.ts`: build the real `dist/`, serve it with `vite preview` on a dedicated strict port, honor `PLAYWRIGHT_CHROMIUM`, use `/usr/bin/chromium` only when it exists, and otherwise fall back to Playwright's bundled browser. It must not use the dev-server E2E config.
The spec must generate fixtures from fixed seeds, install all required API/WebSocket route mocks or a deterministic bootstrap bypass before navigation, and make no assumption that a backend is running on port 3900. It must use `page.addInitScript` before application code to wrap target-key storage writes and `PerformanceObserver`, drive selectors rather than arbitrary sleeps, and emit machine-readable JSON under Playwright's `test-results` directory. It asserts final state and observable deterministic write counts, but it does not assert elapsed milliseconds or claim to observe serializer task identity. The injected Vitest scheduler tests own the stronger “no provider/serializer execution in the originating task” assertion.
Run it with:
```powershell
cd frontend
node ./node_modules/@playwright/test/cli.js test --config=playwright.perf.config.ts responsiveness.spec.ts --repeat-each=5 --reporter=line
```
Run `bun install --frozen-lockfile` first. The command above is verified from `frontend/` to resolve the installed Playwright 1.61.0 CLI by exact package path; do not replace it with `bun x playwright` or a global `bun run` shim, which can select another Playwright version, fetch a package, or even resolve a stale Windows shim. If `PLAYWRIGHT_CHROMIUM` is unset and no supported system Chromium exists, install the pinned browser once with `node ./node_modules/@playwright/test/cli.js install chromium`. This adds no project dependency, and the dedicated config provides the cross-platform executable fallback. The config owns port 4174 and never reuses an existing listener, so a stale preview fails loudly and every successful run tears down the exact server it started.
Use the same browser version, build mode, machine power state, and fixture on both commits.
1. Instrument target-key `setItem` count, serialized byte length, and call duration before the app loads.
2. Observe long tasks and event-to-next-`requestAnimationFrame` latency.
3. Seed 1,800 dub segments and 400 story tracks using the current v7/legacy formats.
4. Run 20 UI-scale updates 25 ms apart, keeping the complete burst below the hard maximum.
5. Run 20 Studio text updates under the same cadence.
6. End each burst, wait 1,250 ms, and verify the durable latest values by parsing both keys.
7. Run A/B/A (parent, implementation, parent), five repeats per leg; compare median p95 and retain every JSON artifact.
8. Run the 3,000/3,000 fixture once as a diagnostic stress case, not as a product limit.
Deterministic merge gates:
- A sub-1-second 20-event burst produces no more than one physical write per target key after the burst: at least a 96% reduction from the measured 60 target writes.
- Injected utility/integration tests prove no target-key provider, serialization, or write executes in the originating input task; the browser harness independently verifies observable physical writes.
- Both parsed durable values contain the final interaction's state.
Manual decision thresholds, not CI merge gates:
- Target at least 20% lower median p95 input-to-frame latency on the representative fixture.
- Target no more than 5% median-p95 regression on the small fixture.
- Expect no greater-than-50-ms task during the interaction burst with persistence work in its trace stack.
- If either target is missed or same-commit A/A variance exceeds 5%, treat the timing as inconclusive, attach the raw artifacts, and re-profile. Do not widen this PR merely to manufacture a favorable number.
## Acceptance criteria
The PR is ready for review only when all are true:
- [ ] Existing keys, field sets, JSON envelope, version, migrations, and restore behavior are unchanged.
- [ ] One burst yields at most one trailing write per dirty key and the newest value wins.
- [ ] Normal continuous input schedules an attempt within 1 second; failure and timer-throttling limits are documented accurately.
- [ ] With healthy storage, orderly hide/navigation flushes synchronously and a hard process termination can lose at most the scheduled unflushed window; failure/throttling exceptions are documented.
- [ ] Factory Reset cannot be undone by pending work.
- [ ] Unknown-role work cannot reach raw storage, and the standalone widget cannot write or remove main-window preferences.
- [ ] Storage failures cannot crash input handling and never leak user content to logs.
- [ ] Deterministic tests meet merge gates; the checked-in A/B/A benchmark and raw timing artifacts are attached as non-CI decision evidence.
- [ ] Frontend and backend merge gates pass.
- [ ] No dependency, lockfile, locale, backend, persisted-version, or package-version change is present.
- [ ] The PR remains reviewable as one persistence concern; no opportunistic refactor is included.
## Risks and mitigations
| Risk | Mitigation |
| --- | --- |
| Up to the scheduled window of edits lost on a hard process kill | 250 ms quiet flush, 1,000 ms maximum attempt, hidden/pagehide flush; disclose timer/storage limitations |
| Pending or newly queued write recreates reset data | Suspend by `isPrefKey` before raw removal; post-reset update + timer + lifecycle regression test |
| Widget flushes stale main-window state | Resolve the existing detector before render; unknown cannot write; widget set/remove operations stay read-only |
| Older timer overwrites a newer value | Per-key generation token and last-value-wins tests |
| Mutable data changes before deferred serialization | Lazy current-value provider plus a documented mutation audit; never claim event-time snapshot semantics |
| Quota or disabled storage breaks the UI | Contain write errors, preserve the previous durable blob, disarm timers, retry only on later activity/explicit flush |
| Concurrent browser tabs overwrite each other | Keep the unsupported last-physical-writer boundary explicit; do not add an incomplete conflict protocol here |
| Trailing flush is still expensive for pathological documents | Measure it; do not hide it. Escalate to document storage/worker design in a separate PR if representative flush exceeds the budget |
| Middleware contract accidentally changes | Exact envelope/fixture tests plus existing migration and direct-reader suites |
| Lifecycle listeners duplicate in development/tests | One production owner, isolated test instances, idempotent teardown, and duplicate-install test |
| Timing benchmark flakes in CI | Keep wall-clock evidence informational/manual; gate deterministic operation counts |
## Rollback plan
No data rollback or migration is required. Reverting the adapter wiring restores immediate writes, and both old and new builds read the same `omnivoice.app` v7 envelope and `omni_ui` object. If a release-only issue appears, revert the PR rather than introducing a second persistence mode or format.
## Follow-up queue
These are intentionally not part of the first PR:
1. **Incremental dub scheduling.** Add a 300 ms debounce, pass `AbortController.signal` through `apiPost`, use a monotonic request revision, cancel outside Dub, and prove one request per burst plus stale-response rejection.
2. **Transactional dub undo.** Profile `pushUndo`, which currently stringifies the complete segment array per edit and retains up to 50 snapshots. If material, group edits by segment/field and focus or idle boundary while preserving one-step undo behavior.
3. **Workspace isolation.** Profile React commits after persistence remediation; then extract one workspace at a time, moving heavy hooks/imports behind lazy boundaries. Source length and selector count alone are not success metrics.
4. **Document storage migration.** Consider IndexedDB or a worker only if representative post-PR flushes remain over budget. That work requires an independent migration, downgrade, reset, quota, and async-hydration design.
Each follow-up must begin from a fresh trace. None should be pulled into this PR merely because it is nearby.
@@ -131,9 +131,7 @@ Gallery tab has two zones (top toggle):
- `GET /archetypes/{id}/preview` — serve pre-rendered WAV if present; else render
via the voice-design engine and cache to disk keyed by instruct hash.
- `POST /archetypes/{id}/use` — render a sample → create a `voice_profile`
with `kind='design'` (the rendered WAV is an identity sample; the
archetype `instruct`/`language` and deterministic render seed remain
authoritative) → return profile id.
(rendered WAV as `ref_audio`, archetype `instruct`/`language`) → return profile id.
- Register in `backend/main.py` alongside the other routers.
- **Preview cache**`OUTPUTS_DIR/archetype_previews/<hash>.wav`, served via a new
static mount or `FileResponse`. Pre-rendered featured WAVs live under
-529
View File
@@ -1,529 +0,0 @@
import { expect, test, type Page, type TestInfo } from '@playwright/test';
import { writeFile } from 'node:fs/promises';
const APP_STORE_KEY = 'omnivoice.app';
const OMNI_UI_KEY = 'omni_ui';
const TARGET_KEYS = [APP_STORE_KEY, OMNI_UI_KEY] as const;
const UPDATE_COUNT = 20;
const UPDATE_INTERVAL_MS = 25;
const TRAILING_FLUSH_SETTLE_MS = 1_250;
type TargetKey = (typeof TARGET_KEYS)[number];
interface PhysicalWrite {
phase: string;
key: TargetKey;
atMs: number;
bytes: number;
durationMs: number;
}
interface LongTaskSample {
phase: string;
atMs: number;
durationMs: number;
}
interface InputFrameSample {
phase: string;
target: 'ui-scale' | 'studio-text';
atMs: number;
durationMs: number;
}
interface BrowserMetrics {
phase: string;
writes: PhysicalWrite[];
longTasks: LongTaskSample[];
inputToNextRaf: InputFrameSample[];
}
declare global {
interface Window {
__OV_WINDOW__?: string;
__OMNIVOICE_API_BASE__?: string;
__ovResponsivenessMetrics?: BrowserMetrics;
__ovSetResponsivenessPhase?: (phase: string) => void;
}
}
function makeStoryTracks() {
return Array.from({ length: 400 }, (_, index) => ({
id: index + 1,
character: index % 2 === 0 ? 'narrator' : 'guest',
text: `Story track ${index.toString().padStart(3, '0')} ${'narration '.repeat(8)}`,
profileId: null,
emotion: index % 3 === 0 ? 'warm' : null,
speed: 1,
}));
}
function makeDubSegments() {
return Array.from({ length: 1_800 }, (_, index) => ({
id: `segment-${index.toString().padStart(4, '0')}`,
start: index * 2.5,
end: index * 2.5 + 2.25,
speaker: index % 2 === 0 ? 'SPEAKER_00' : 'SPEAKER_01',
text_original: `Original line ${index} ${'source '.repeat(7)}`,
text: `Translated line ${index} ${'target '.repeat(7)}`,
profile_id: null,
direction: '',
}));
}
function persistedFixtures() {
return {
app: {
state: {
mode: 'settings',
defineMethod: 'audio',
uiScale: 1,
uiScaleConfigured: true,
navStyle: 'rail',
locale: 'en',
localeChosen: true,
langPromptSeen: true,
storyTracks: makeStoryTracks(),
},
version: 7,
},
omniUi: {
uiScale: 1,
text: 'Seeded studio text',
mode: 'settings',
defineMethod: 'audio',
vdStates: {
Gender: 'Auto',
Age: 'Auto',
Pitch: 'Auto',
Style: 'Auto',
EnglishAccent: 'Auto',
ChineseDialect: 'Auto',
},
language: 'Auto',
isSidebarCollapsed: false,
sidebarTab: 'projects',
dubJobId: 'responsiveness-fixture',
dubFilename: 'responsiveness-fixture.mp4',
dubDuration: 4_500,
dubSegments: makeDubSegments(),
dubLang: 'English',
dubLangCode: 'en',
dubTracks: [],
dubStep: 'editing',
dubTranscript: '',
exportTracks: {},
preserveBg: true,
defaultTrack: 'dialogue',
exportHistory: [],
speed: 1,
steps: 16,
cfg: 2,
denoise: true,
showOverrides: false,
},
};
}
async function installDeterministicBrowserState(page: Page): Promise<Set<string>> {
const fixtures = persistedFixtures();
const unexpectedRequests = new Set<string>();
await page.addInitScript(
({ appKey, omniUiKey, app, omniUi }) => {
// Fix window identity and API routing before any application module runs.
window.__OV_WINDOW__ = 'main';
window.__OMNIVOICE_API_BASE__ = window.location.origin;
// Seed through the native method so fixture setup is not counted as an
// application write. Both payloads intentionally match production schema.
const nativeSetItem = Storage.prototype.setItem;
nativeSetItem.call(localStorage, appKey, JSON.stringify(app));
nativeSetItem.call(localStorage, omniUiKey, JSON.stringify(omniUi));
nativeSetItem.call(localStorage, 'omnivoice.settings.category', 'appearance');
const targetKeys = new Set([appKey, omniUiKey]);
const metrics: BrowserMetrics = {
phase: 'startup',
writes: [],
longTasks: [],
inputToNextRaf: [],
};
window.__ovResponsivenessMetrics = metrics;
window.__ovSetResponsivenessPhase = (phase) => {
metrics.phase = phase;
};
Storage.prototype.setItem = function setItem(key: string, value: string): void {
const startedAt = performance.now();
try {
nativeSetItem.call(this, key, value);
} finally {
if (targetKeys.has(key)) {
const durationMs = performance.now() - startedAt;
metrics.writes.push({
phase: metrics.phase,
key: key as TargetKey,
atMs: startedAt,
// Encode after the native call so byte accounting is excluded
// from the measured physical-storage duration.
bytes: new TextEncoder().encode(value).byteLength,
durationMs,
});
}
}
};
document.addEventListener(
'input',
(event) => {
const target = event.target;
if (!(target instanceof HTMLElement)) return;
const sampleTarget = target.matches('.appearance-panel input[type="range"]')
? 'ui-scale'
: target.matches('textarea.studio-script-input')
? 'studio-text'
: null;
if (!sampleTarget) return;
const startedAt = performance.now();
requestAnimationFrame(() => {
metrics.inputToNextRaf.push({
phase: metrics.phase,
target: sampleTarget,
atMs: startedAt,
durationMs: performance.now() - startedAt,
});
});
},
true,
);
if (
'PerformanceObserver' in window &&
PerformanceObserver.supportedEntryTypes?.includes('longtask')
) {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
metrics.longTasks.push({
phase: metrics.phase,
atMs: entry.startTime,
durationMs: entry.duration,
});
}
});
observer.observe({ type: 'longtask', buffered: true });
}
// Keep the realtime hook deterministic and fully local while preserving
// the handler and EventTarget surfaces used by capture/realtime clients.
class DeterministicWebSocket extends EventTarget {
static readonly CONNECTING = 0;
static readonly OPEN = 1;
static readonly CLOSING = 2;
static readonly CLOSED = 3;
readonly url: string;
readyState = DeterministicWebSocket.CONNECTING;
onopen: ((event: Event) => void) | null = null;
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: ((event: Event) => void) | null = null;
onclose: ((event: CloseEvent) => void) | null = null;
constructor(url: string | URL) {
super();
this.url = String(url);
queueMicrotask(() => {
if (this.readyState !== DeterministicWebSocket.CONNECTING) return;
this.readyState = DeterministicWebSocket.OPEN;
const event = new Event('open');
this.dispatchEvent(event);
this.onopen?.(event);
});
}
send(): void {}
close(): void {
if (this.readyState === DeterministicWebSocket.CLOSED) return;
this.readyState = DeterministicWebSocket.CLOSED;
const event = new CloseEvent('close', { code: 1000, wasClean: true });
this.dispatchEvent(event);
this.onclose?.(event);
}
}
Object.defineProperty(window, 'WebSocket', {
configurable: true,
writable: true,
value: DeterministicWebSocket,
});
},
{
appKey: APP_STORE_KEY,
omniUiKey: OMNI_UI_KEY,
app: fixtures.app,
omniUi: fixtures.omniUi,
},
);
// Production resolves API calls to the preview origin. Fulfil every
// fetch/XHR deterministically, while allowing HTML, chunks, fonts and CSS to
// come from the real production bundle under test.
await page.route('**/*', async (route) => {
const request = route.request();
if (!['fetch', 'xhr'].includes(request.resourceType())) {
await route.continue();
return;
}
const path = new URL(request.url()).pathname;
const responseByPath: Record<string, unknown> = {
'/health': { status: 'ok' },
'/setup/status': {
models_ready: true,
missing: [],
hf_cache_dir: '/deterministic/models',
disk_free_gb: 100,
min_free_gb: 1,
enough_disk: true,
},
'/model/status': { status: 'idle', sub_stage: null, detail: '', error: null, progress: null },
'/profiles': [],
'/personalities': [],
'/history': [],
'/dub/history': [],
'/projects': [],
'/export/history': [],
'/engines': {
tts: { active: null, backends: [] },
asr: { active: null, backends: [] },
llm: { active: null, backends: [] },
},
'/sysinfo': { cpu: 0, ram: 0, total_ram: 32, vram: 0, gpu_active: false },
'/system/info': { platform: 'benchmark', device: 'deterministic' },
'/system/notifications': { notifications: [] },
'/system/last-run-crash': { record: null, acknowledged: false },
'/system/logs': { path: '', exists: false, lines: [] },
'/system/logs/tauri': { path: '', exists: false, lines: [] },
'/system/network/state': { enabled: false },
'/dictation/prefs': {
enabled: false,
mode: 'toggle',
model_id: 'sherpa-parakeet-tdt-v3',
},
'/workers': { enabled: false, running: false, workers: [] },
'/workers/target': {
target: 'local',
active: { remote: false },
targets: [
{ id: 'local', label: 'Local', is_local: true, status: 'ready', available: true },
],
},
'/api/settings/analytics': { available: false, prompted: true, opted_in: false },
'/donation_progress.json': {
raised: 10,
goal: 200,
currency: 'USD',
sponsorCount: 1,
updated: '2026-06-17',
},
};
const responseBody = responseByPath[path];
if (responseBody === undefined) {
unexpectedRequests.add(`${request.method()} ${path}`);
await route.fulfill({
status: 501,
contentType: 'application/json',
headers: { 'x-omnivoice-backend': '1' },
body: JSON.stringify({ detail: 'Unhandled deterministic benchmark route' }),
});
return;
}
await route.fulfill({
status: 200,
contentType: 'application/json',
headers: { 'x-omnivoice-backend': '1' },
body: JSON.stringify(responseBody),
});
});
return unexpectedRequests;
}
async function setPhase(page: Page, phase: string): Promise<void> {
await page.evaluate((nextPhase) => window.__ovSetResponsivenessPhase?.(nextPhase), phase);
}
async function driveNativeInputBurst(
page: Page,
selector: string,
values: string[],
): Promise<void> {
await page.locator(selector).evaluate(
async (node, burst) => {
const element = node as HTMLInputElement | HTMLTextAreaElement;
const prototype =
element instanceof HTMLTextAreaElement
? HTMLTextAreaElement.prototype
: HTMLInputElement.prototype;
const nativeValueSetter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set;
if (!nativeValueSetter) throw new Error(`No native value setter for ${element.tagName}`);
await new Promise<void>((resolve) => {
// Schedule against one common origin. Measuring UI work must not add
// another 25 ms after every handler and accidentally turn a 475 ms
// burst into a >1 s stream that rightfully crosses the max-flush gate.
burst.values.forEach((value, index) => {
setTimeout(() => {
nativeValueSetter.call(element, value);
element.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
if (index === burst.values.length - 1) resolve();
}, index * burst.intervalMs);
});
});
},
{ values, intervalMs: UPDATE_INTERVAL_MS },
);
}
async function readDurableValues(page: Page) {
return page.evaluate(
({ appKey, omniUiKey }) => ({
app: JSON.parse(localStorage.getItem(appKey) || 'null'),
omniUi: JSON.parse(localStorage.getItem(omniUiKey) || 'null'),
}),
{ appKey: APP_STORE_KEY, omniUiKey: OMNI_UI_KEY },
);
}
function writesFor(metrics: BrowserMetrics, phase: string, key: TargetKey): PhysicalWrite[] {
return metrics.writes.filter((write) => write.phase === phase && write.key === key);
}
async function writeReport(testInfo: TestInfo, report: unknown): Promise<void> {
const artifactPath = testInfo.outputPath('responsiveness.json');
await writeFile(artifactPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
await testInfo.attach('responsiveness.json', {
path: artifactPath,
contentType: 'application/json',
});
}
test('coalesces large-state persistence during rapid UI input', async ({ page }, testInfo) => {
const unexpectedRequests = await installDeterministicBrowserState(page);
await page.goto('/', { waitUntil: 'domcontentloaded' });
const listenerProbe = await page.evaluate(async () => {
const socket = new WebSocket('ws://benchmark.invalid');
let onceCalls = 0;
let removedCalls = 0;
const removedListener = () => {
removedCalls += 1;
};
socket.addEventListener(
'open',
() => {
onceCalls += 1;
},
{ once: true },
);
socket.addEventListener('open', removedListener);
socket.removeEventListener('open', removedListener);
await Promise.resolve();
socket.dispatchEvent(new Event('open'));
socket.close();
return { onceCalls, removedCalls };
});
expect(listenerProbe).toEqual({ onceCalls: 1, removedCalls: 0 });
const scaleSelector = '.appearance-panel input[type="range"]';
await expect(page.locator(scaleSelector)).toBeVisible();
// Let startup restoration and its trailing persistence window fully settle;
// subsequent records are phase-labelled and attributable to one burst.
await page.waitForTimeout(TRAILING_FLUSH_SETTLE_MS);
const scaleValues = Array.from({ length: UPDATE_COUNT }, (_, index) =>
(0.65 + index * 0.05).toFixed(2),
);
const finalScale = Number(scaleValues.at(-1));
await setPhase(page, 'ui-scale');
await driveNativeInputBurst(page, scaleSelector, scaleValues);
await page.waitForTimeout(TRAILING_FLUSH_SETTLE_MS);
const afterScale = await readDurableValues(page);
expect(afterScale.app?.state?.uiScale).toBe(finalScale);
expect(afterScale.omniUi?.uiScale).toBe(finalScale);
// Navigate through the real production UI. Waiting before phase assignment
// prevents the navigation write from being counted as a text-input write.
await setPhase(page, 'navigation');
await page.locator('.nav-rail button[aria-label="Voice"]').click();
const textSelector = 'textarea.studio-script-input';
await expect(page.locator(textSelector)).toBeVisible();
await page.waitForTimeout(TRAILING_FLUSH_SETTLE_MS);
const textValues = Array.from(
{ length: UPDATE_COUNT },
(_, index) => `responsiveness-${index.toString().padStart(2, '0')}-${'voice '.repeat(8)}`,
);
const finalText = textValues.at(-1);
await setPhase(page, 'studio-text');
await driveNativeInputBurst(page, textSelector, textValues);
await page.waitForTimeout(TRAILING_FLUSH_SETTLE_MS);
const durable = await readDurableValues(page);
const metrics = await page.evaluate(() => window.__ovResponsivenessMetrics as BrowserMetrics);
const report = {
schemaVersion: 1,
fixture: { appStoreVersion: 7, storyTracks: 400, dubSegments: 1_800 },
burst: { updates: UPDATE_COUNT, requestedIntervalMs: UPDATE_INTERVAL_MS },
durable: {
appUiScale: durable.app?.state?.uiScale,
omniUiScale: durable.omniUi?.uiScale,
omniUiText: durable.omniUi?.text,
},
phases: {
uiScale: {
writes: Object.fromEntries(
TARGET_KEYS.map((key) => [key, writesFor(metrics, 'ui-scale', key)]),
),
inputToNextRaf: metrics.inputToNextRaf.filter((sample) => sample.phase === 'ui-scale'),
longTasks: metrics.longTasks.filter((sample) => sample.phase === 'ui-scale'),
},
studioText: {
writes: Object.fromEntries(
TARGET_KEYS.map((key) => [key, writesFor(metrics, 'studio-text', key)]),
),
inputToNextRaf: metrics.inputToNextRaf.filter((sample) => sample.phase === 'studio-text'),
longTasks: metrics.longTasks.filter((sample) => sample.phase === 'studio-text'),
},
},
startup: {
writes: metrics.writes.filter((write) => write.phase === 'startup'),
longTasks: metrics.longTasks.filter((sample) => sample.phase === 'startup'),
},
network: { unexpectedRequests: [...unexpectedRequests].sort() },
};
await writeReport(testInfo, report);
expect(durable.omniUi?.text).toBe(finalText);
expect([...unexpectedRequests].sort(), 'every fetch/XHR must have an explicit fixture').toEqual(
[],
);
expect(metrics.inputToNextRaf.filter((sample) => sample.phase === 'ui-scale')).toHaveLength(
UPDATE_COUNT,
);
expect(metrics.inputToNextRaf.filter((sample) => sample.phase === 'studio-text')).toHaveLength(
UPDATE_COUNT,
);
for (const phase of ['ui-scale', 'studio-text']) {
for (const key of TARGET_KEYS) {
expect(
writesFor(metrics, phase, key).length,
`${phase} should physically write ${key} no more than once`,
).toBeLessThanOrEqual(1);
}
}
});
@@ -1,130 +0,0 @@
import { expect, test, type Page, type Request, type Route } from '@playwright/test';
const MASTER = 'root-master-never-retained';
const SESSION = `ovs_admin_session_${'S'.repeat(43)}`;
async function browserCredentialSnapshot(page: Page) {
return page.evaluate(() => ({
href: location.href,
legacyMaster: localStorage.getItem('ov_api_key'),
storedSession: sessionStorage.getItem('ov_admin_session'),
localValues: Object.values(localStorage),
sessionValues: Object.values(sessionStorage),
}));
}
test('same-origin production bootstrap exchanges once into an HttpOnly cookie', async ({
context,
page,
}) => {
const seen: Request[] = [];
page.on('request', (request) => seen.push(request));
await page.addInitScript((master) => localStorage.setItem('ov_api_key', master), MASTER);
let exchange: Request | undefined;
await page.route('**/api/auth/session', async (route) => {
exchange = route.request();
await route.fulfill({
status: 204,
headers: {
'cache-control': 'no-store',
'set-cookie': `ov_session=${SESSION}; HttpOnly; SameSite=Strict; Path=/; Max-Age=28800`,
},
});
});
await page.goto(`/#api_key=${MASTER}&tab=voices`, { waitUntil: 'domcontentloaded' });
await expect.poll(() => exchange?.headers().authorization).toBe(`Bearer ${MASTER}`);
expect(exchange?.postDataJSON()).toEqual({ transport: 'cookie' });
const snapshot = await browserCredentialSnapshot(page);
expect(snapshot.href).toMatch(/#tab=voices$/);
expect(snapshot.href).not.toContain(MASTER);
expect(snapshot.legacyMaster).toBeNull();
expect(snapshot.storedSession).toBeNull();
expect([...snapshot.localValues, ...snapshot.sessionValues].join('\n')).not.toContain(MASTER);
expect(seen.map((request) => request.url()).join('\n')).not.toContain(MASTER);
const cookies = await context.cookies();
const cookie = cookies.find(({ name }) => name === 'ov_session');
expect(cookie).toMatchObject({ value: SESSION, httpOnly: true, sameSite: 'Strict', path: '/' });
expect(cookies.some(({ name }) => name === 'ov_key')).toBe(false);
expect(cookies.map(({ value }) => value).join('\n')).not.toContain(MASTER);
});
test('cross-origin production bootstrap stores only a backend-bound tab session', async ({
page,
}) => {
const remote = 'http://gpu.test:3900';
const seen: Request[] = [];
page.on('request', (request) => seen.push(request));
await page.addInitScript(
({ backend, master }) => {
localStorage.setItem('ov_backend_url', backend);
localStorage.setItem('ov_api_key', master);
},
{ backend: remote, master: MASTER },
);
let exchange: Request | undefined;
await page.route(`${remote}/**`, async (route: Route) => {
const request = route.request();
const corsHeaders = {
'access-control-allow-credentials': 'true',
'access-control-allow-headers': 'authorization,content-type,x-voicestudio-csrf',
'access-control-allow-methods': 'GET,POST,DELETE,OPTIONS',
'access-control-allow-origin': request.headers().origin ?? 'http://localhost:4173',
'access-control-expose-headers': 'x-omnivoice-backend',
'x-omnivoice-backend': 'e2e',
};
if (request.method() === 'OPTIONS') {
await route.fulfill({ status: 204, headers: corsHeaders });
return;
}
if (new URL(request.url()).pathname === '/api/auth/session') {
exchange = request;
await route.fulfill({
status: 201,
headers: {
...corsHeaders,
'cache-control': 'no-store',
'content-type': 'application/json',
},
body: JSON.stringify({ token: SESSION, expires_at: 1, expires_in: 3600 }),
});
return;
}
if (new URL(request.url()).pathname === '/health') {
await route.fulfill({
status: 200,
headers: { ...corsHeaders, 'content-type': 'application/json' },
body: JSON.stringify({ status: 'ok', version: 'e2e', device: 'cpu' }),
});
return;
}
await route.fulfill({
status: 200,
headers: { ...corsHeaders, 'content-type': 'application/json' },
body: '{}',
});
});
await page.goto(`/#api_key=${MASTER}`, { waitUntil: 'domcontentloaded' });
await expect.poll(() => exchange?.headers().authorization).toBe(`Bearer ${MASTER}`);
expect(exchange?.postDataJSON()).toEqual({ transport: 'bearer' });
const snapshot = await browserCredentialSnapshot(page);
expect(snapshot.href).not.toContain(MASTER);
expect(snapshot.legacyMaster).toBeNull();
expect(snapshot.storedSession).not.toBeNull();
expect(JSON.parse(snapshot.storedSession ?? '{}')).toMatchObject({
token: SESSION,
apiBase: remote,
});
expect(snapshot.localValues.join('\n')).not.toContain(MASTER);
expect(snapshot.sessionValues.join('\n')).not.toContain(MASTER);
expect(seen.map((request) => request.url()).join('\n')).not.toContain(MASTER);
expect(
seen.filter((request) => request.headers().authorization === `Bearer ${MASTER}`),
).toHaveLength(1);
});
+2 -2
View File
@@ -27,7 +27,7 @@ test.describe('LogsFooter never covers page content @ 900x600', () => {
test('gallery: bottom-most voice card stays above the collapsed footer', async ({ page }) => {
await gotoMode(page, 'gallery');
const cards = page.getByTestId('gallery-persona-card');
const cards = page.locator('.archetype-card');
await expect(cards.first()).toBeVisible({ timeout: 20_000 });
const top = await footerTop(page);
@@ -45,7 +45,7 @@ test.describe('LogsFooter never covers page content @ 900x600', () => {
page,
}) => {
await gotoMode(page, 'gallery');
const cards = page.getByTestId('gallery-persona-card');
const cards = page.locator('.archetype-card');
await expect(cards.first()).toBeVisible({ timeout: 20_000 });
// Expand the logs panel (chevron toggle in the collapsed bar).
+2 -4
View File
@@ -9,9 +9,7 @@ test.describe('VoiceStudio Gallery', () => {
test('facet dropdowns use the dark theme, not the OS-default light surface', async ({ page }) => {
await gotoMode(page, 'gallery');
// Scope by testid, not the translated 'Archetypes' label — the accessible
// name follows the app locale and breaks under non-English navigators.
const select = page.getByTestId('archetypes-zone').getByRole('combobox').first();
const select = page.locator('select.facet-select').first();
await expect(select).toBeVisible();
// Regression guard for the undefined-var fallback: the fixed style resolves
// --chrome-hover-bg → rgba(255,255,255,0.04), NOT an opaque UA light surface
@@ -27,7 +25,7 @@ test.describe('VoiceStudio Gallery', () => {
await gotoMode(page, 'gallery');
// Cards load from the backend; wait for the first one.
const designerBtn = page.getByRole('button', { name: /Open in Designer/i }).first();
const designerBtn = page.locator('.archetype-card .designer-btn').first();
await expect(designerBtn).toBeVisible({ timeout: 20_000 });
await designerBtn.click();
-32
View File
@@ -1,32 +0,0 @@
import { test, expect } from '@playwright/test';
import { gotoMode } from './_helpers';
const MIN_WINDOW = { width: 900, height: 600 };
test.describe('Support page stays on one screen @ 900x600', () => {
test.use({ viewport: MIN_WINDOW });
test('support, commercial licence and contact panels do not overflow', async ({ page }) => {
await gotoMode(page, 'donate');
await expect(page.getByRole('heading', { name: 'Support VoiceStudio' })).toBeVisible({
timeout: 20_000,
});
for (const tabName of ['Support', 'Commercial License', 'Contact']) {
const tab = page.getByRole('tab', { name: tabName });
if ((await tab.getAttribute('aria-selected')) !== 'true') await tab.click({ force: true });
await expect(tab).toHaveAttribute('aria-selected', 'true');
const panel = page.getByRole('tabpanel');
await expect(panel).toBeVisible();
const { clientHeight, scrollHeight } = await panel.evaluate((element) => ({
clientHeight: element.clientHeight,
scrollHeight: element.scrollHeight,
}));
expect(
scrollHeight,
`${tabName} panel should not need vertical scrolling`,
).toBeLessThanOrEqual(clientHeight + 1);
}
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "omnivoice-studio",
"version": "0.5.0",
"version": "0.4.2",
"private": true,
"license": "AGPL-3.0-only",
"type": "module",
-47
View File
@@ -1,47 +0,0 @@
import { defineConfig, devices } from '@playwright/test';
import { existsSync } from 'node:fs';
// Opt-in production-bundle responsiveness benchmark. Keep it separate from
// playwright.prod.config.ts: the smoke suite is a CI correctness gate, while
// this harness records machine-dependent timing diagnostics for local review.
const PORT = Number(process.env.E2E_PERF_PORT || 4174);
// An explicit browser wins; Linux CI/dev containers commonly provide a system
// Chromium; contributors on Windows/macOS fall back to Playwright's bundle.
const SYSTEM_CHROMIUM = '/usr/bin/chromium';
const browserPath =
process.env.PLAYWRIGHT_CHROMIUM || (existsSync(SYSTEM_CHROMIUM) ? SYSTEM_CHROMIUM : undefined);
export default defineConfig({
testDir: './e2e-perf',
testMatch: 'responsiveness.spec.ts',
timeout: 120_000,
expect: { timeout: 15_000 },
fullyParallel: false,
// `--repeat-each=5` is a variance sample, not five independent load tests.
// Keep repeats serial so they do not contend with each other or distort the
// input/long-task evidence on high-core development machines.
workers: 1,
retries: 0,
reporter: [['list']],
outputDir: 'test-results/responsiveness',
use: {
baseURL: `http://localhost:${PORT}`,
headless: true,
trace: 'retain-on-failure',
...(browserPath ? { launchOptions: { executablePath: browserPath } } : {}),
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
webServer: {
// Playwright launches through the platform shell. Invoke the repo-pinned
// Vite binary directly so Windows does not depend on whichever global Bun
// shim happens to precede the checked-in toolchain on PATH.
command: `node ./node_modules/vite/bin/vite.js build && node ./node_modules/vite/bin/vite.js preview --port ${PORT} --strictPort`,
url: `http://localhost:${PORT}`,
// Always own the production preview used for a measurement. Reusing an
// arbitrary listener can benchmark stale dist bytes and leaves teardown
// ownership ambiguous; a stale 4174 listener should fail loudly instead.
reuseExistingServer: false,
timeout: 180_000,
},
});
+1 -1
View File
@@ -2941,7 +2941,7 @@ dependencies = [
[[package]]
name = "omnivoice-studio"
version = "0.5.0"
version = "0.4.2"
dependencies = [
"arboard",
"dirs-next",
+1 -4
View File
@@ -4,7 +4,7 @@
# launcher's pkill matches `omnivoice-studio` and must never match a user's
# installed app. Renaming it would collapse that distinction.
name = "omnivoice-studio"
version = "0.5.0"
version = "0.4.2"
description = "VoiceStudio AI voice cloning & dubbing desktop app"
authors = ["Debpalash"]
license = "AGPL-3.0-only"
@@ -100,6 +100,3 @@ zbus = "5.16"
# Scoped-reset tests build real directory trees to prove the delete guard only
# ever removes paths inside a validated OmniVoice root.
tempfile = "3"
# MockRuntime app for the backend-lifecycle fault-injection harness
# (tests/backend_lifecycle.rs) — feature-unifies onto the main dep.
tauri = { version = "2.11.0", features = ["test"] }
-16
View File
@@ -44,21 +44,5 @@ fn main() {
ensure_sidecar_placeholder("uv");
ensure_sidecar_placeholder("ffmpeg");
ensure_sidecar_placeholder("ffprobe");
// Windows test binaries need the Common-Controls v6 manifest that
// tauri-build embeds into the app binary but cargo gives tests none of:
// without it the loader resolves comctl32 v5 (no TaskDialogIndirect —
// imported by tauri's dialog/tray stack) and every integration-test
// binary dies at load with STATUS_ENTRYPOINT_NOT_FOUND (0xc0000139).
// See tests/windows-test.manifest.
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") {
let manifest = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".into()))
.join("tests")
.join("windows-test.manifest");
println!("cargo:rerun-if-changed={}", manifest.display());
println!("cargo:rustc-link-arg-tests=/MANIFEST:EMBED");
println!("cargo:rustc-link-arg-tests=/MANIFESTINPUT:{}", manifest.display());
}
tauri_build::build();
}
+39 -522
View File
@@ -100,52 +100,6 @@ pub fn backend_deep_healthy(port: u16) -> bool {
}
}
/// Readiness = identity AND capability. The shallow probe proves the
/// responder is OUR backend; the deep probe proves it can actually serve a
/// DB-backed route. Declaring Ready on the shallow probe alone announced a
/// backend whose install/DB was broken underneath as up — the UI looked
/// alive while every real request 500'd or dead-ended on "can't reach the
/// backend". Both Ready transitions (startup poll, supervisor respawn wait)
/// gate on this; the supervisor's DEATH detection stays process-exit-only
/// (`try_wait`), so a busy-but-alive backend is still never killed.
pub fn backend_ready(port: u16) -> bool {
backend_healthy(port) && backend_deep_healthy(port)
}
/// Startup progress from the backend's early-bind `/startup/progress`
/// endpoint: `(status, step, label)`, e.g. `("starting", "ml_imports",
/// "Loading ML runtime (PyTorch)…")`. `None` when nothing answers, when the
/// responder lacks the `x-omnivoice-backend` marker header (a foreign
/// process on our port must not narrate our splash), or on an old backend
/// without the endpoint — callers fall back to the legacy probes.
pub fn startup_progress(port: u16) -> Option<(String, String, String)> {
let url = format!("http://127.0.0.1:{}/startup/progress", port);
let resp = raw_http_get(&url, Duration::from_millis(800)).ok()?;
if parse_http_status(&resp) != Some(200) {
return None;
}
let head_end = resp.find("\r\n\r\n").unwrap_or(resp.len());
if !resp[..head_end].to_ascii_lowercase().contains("x-omnivoice-backend") {
return None;
}
let body = &resp[resp.find("\r\n\r\n").map(|i| i + 4).unwrap_or(0)..];
let status = parse_json_string_field(body, "status")?;
let step = parse_json_string_field(body, "step").unwrap_or_default();
let label = parse_json_string_field(body, "label").unwrap_or_default();
Some((status, step, label))
}
/// First `"key": "value"` string field in a JSON body — same dependency-free
/// sniffing style as `parse_app_version`. `None` for absent or non-string
/// (e.g. `null`) values.
fn parse_json_string_field(body: &str, key: &str) -> Option<String> {
let needle = format!("\"{key}\"");
let rest = &body[body.find(&needle)? + needle.len()..];
let rest = rest[rest.find(':')? + 1..].trim_start();
let rest = rest.strip_prefix('"')?;
Some(rest[..rest.find('"')?].to_string())
}
/// Status code from a raw HTTP response ("HTTP/1.1 200 OK" → 200).
fn parse_http_status(response: &str) -> Option<u16> {
let line = response.lines().next()?;
@@ -289,16 +243,6 @@ pub fn kill_orphan_on_port(port: u16) {
// ── Log paths ─────────────────────────────────────────────────────────────
pub fn backend_log_path() -> PathBuf {
// Support/test override: point logs (and the crash-marker store, which
// derives from this path) somewhere explicit. The fault-injection
// harness gives every scenario its own tempdir through this.
if let Ok(dir) = std::env::var("OMNIVOICE_LOG_DIR") {
if !dir.trim().is_empty() {
let log_dir = PathBuf::from(dir);
let _ = fs::create_dir_all(&log_dir);
return log_dir.join("backend.log");
}
}
let log_dir = if cfg!(target_os = "macos") {
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
PathBuf::from(home).join("Library/Logs/OmniVoice")
@@ -318,117 +262,18 @@ pub fn backend_log_path() -> PathBuf {
}
/// Read the last N lines from backend_err.log for diagnostic messages.
///
/// Whole-file view — bootstrap phases (uv sync et al.) that predate any
/// backend run use this. Anything reporting on a specific backend process
/// (crash markers, death diagnostics) must use [`read_error_log_tail_for_run`]
/// instead: the file outlives runs, so an unbounded tail can attribute one
/// run's output to another (#1510).
pub fn read_error_log_tail(max_lines: usize) -> String {
let err_path = backend_log_path().with_file_name("backend_err.log");
read_error_log_tail_at(&err_path, 0, max_lines)
}
// ── Per-run crash evidence (#1510) ────────────────────────────────────────
//
// backend_err.log is one file shared by every backend run in an app session,
// and it used to be TRUNCATED on each spawn. Both properties destroyed crash
// evidence: a respawn wiped the dead process's final words, and any tail read
// after the replacement started could attach the new run's healthy startup to
// the old run's crash marker — exactly the undiagnosable report in #1510.
// The file is append-only now, each spawn records where its run begins, and
// death paths read only their own run's slice.
/// Byte offset in backend_err.log where the CURRENT run's output begins.
/// Set by `spawn_backend` before the child starts writing.
static ERR_LOG_RUN_START: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
/// Rotate once the shared file gets this big (append-only would otherwise
/// grow across runs forever). Generous: evidence beats disk here.
const ERR_LOG_ROTATE_BYTES: u64 = 1024 * 1024;
/// Where the current backend run's slice of backend_err.log begins.
pub fn err_log_run_start() -> u64 {
ERR_LOG_RUN_START.load(std::sync::atomic::Ordering::SeqCst)
}
/// Last N lines of the CURRENT run's slice of backend_err.log.
///
/// This is the reader every death path must use: it cannot see another run's
/// output, so a crash marker carries the dying process's words or nothing.
pub fn read_error_log_tail_for_run(max_lines: usize) -> String {
let err_path = backend_log_path().with_file_name("backend_err.log");
read_error_log_tail_at(&err_path, err_log_run_start(), max_lines)
}
/// Tail of `path` starting at byte `start` (whole file when `start` is 0 or
/// no longer valid — an externally replaced/shrunk file must degrade to the
/// old whole-file behaviour, never to a silent empty capture).
fn read_error_log_tail_at(path: &Path, start: u64, max_lines: usize) -> String {
let content = match fs::read_to_string(path) {
Ok(c) => c,
Err(_) => return String::new(),
};
let start = usize::try_from(start).unwrap_or(0);
let slice = if start > 0 && start <= content.len() && content.is_char_boundary(start) {
&content[start..]
} else {
&content[..]
};
let lines: Vec<&str> = slice.lines().collect();
let from = lines.len().saturating_sub(max_lines);
lines[from..].join("\n")
}
/// The previous run's stderr-drainer thread. Joined (bounded) before a new
/// spawn records its offset, so a dying run's still-buffered stderr cannot be
/// appended AFTER the new run's start offset and get attributed to the new
/// run. (Full per-child offset binding isn't needed: spawns are serialized by
/// the #1223 spawn-once flow, so the only race left was this buffered tail.)
static ERR_LOG_DRAINER: Mutex<Option<std::thread::JoinHandle<()>>> = Mutex::new(None);
/// Wait briefly for the previous run's stderr drainer to flush. A wedged
/// drainer (pipe held open by an orphaned grandchild) must not block a
/// respawn forever — after the bound we proceed; the offset then simply
/// includes whatever the old run still manages to write, which degrades to
/// attributing too MUCH to the new run, never to destroying evidence.
fn join_previous_err_drainer(bound: Duration) {
let handle = ERR_LOG_DRAINER.lock().ok().and_then(|mut g| g.take());
if let Some(handle) = handle {
let deadline = std::time::Instant::now() + bound;
while !handle.is_finished() && std::time::Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(20));
}
if handle.is_finished() {
let _ = handle.join();
match fs::read_to_string(&err_path) {
Ok(content) => {
let lines: Vec<&str> = content.lines().collect();
let start = lines.len().saturating_sub(max_lines);
lines[start..].join("\n")
}
Err(_) => String::new(),
}
}
/// Open backend_err.log for a new run: append-only (a respawn must not
/// destroy the previous run's evidence), rotated when oversized, with the
/// run's start offset returned for `ERR_LOG_RUN_START`.
fn open_err_log_for_run(err_path: &Path) -> (Option<fs::File>, u64) {
let len = fs::metadata(err_path).map(|m| m.len()).unwrap_or(0);
if len > ERR_LOG_ROTATE_BYTES {
let rotated = err_path.with_file_name("backend_err.log.1");
// Rename preferred (keeps the old evidence in .1); on failure —
// e.g. the file is still held open on Windows — fall back to
// truncating, which is exactly the pre-#1510 behaviour.
if fs::rename(err_path, &rotated).is_err() {
let file = fs::File::create(err_path).ok();
return (file, 0);
}
}
let file = fs::OpenOptions::new()
.create(true)
.append(true)
.open(err_path)
.ok();
let start = fs::metadata(err_path).map(|m| m.len()).unwrap_or(0);
(file, start)
}
/// Human-readable diagnostic for a failed `Command::spawn()` of the backend.
///
/// #144 / #127: when the bundled venv Python can't exec (the common Linux/
@@ -436,21 +281,6 @@ fn open_err_log_for_run(err_path: &Path) -> (Option<fs::File>, u64) {
/// process "never started" and we previously surfaced "no error output
/// captured". Writing this to backend_err.log lets read_error_log_tail show the
/// real OS error + an actionable hint instead.
/// Replace the user's home-directory prefix with `~`. This diagnostic is
/// retained in backend_err.log across runs and lands verbatim in bug
/// reports, so the username must not travel with it.
fn redact_home(text: &str) -> String {
for var in ["HOME", "USERPROFILE"] {
if let Ok(home) = std::env::var(var) {
let home = home.trim_end_matches(['/', '\\']);
if home.len() > 1 && text.starts_with(home) {
return format!("~{}", &text[home.len()..]);
}
}
}
text.to_string()
}
fn spawn_failure_diagnostic(python: &Path, err: &std::io::Error) -> String {
// Platform-specific tail (cfg! resolves to this build's target OS, i.e. the
// OS it runs on) — don't show AppImage/loader wording to macOS/Windows users.
@@ -475,7 +305,7 @@ fn spawn_failure_diagnostic(python: &Path, err: &std::io::Error) -> String {
Interpreter present on disk: {}\n\
OS error: {}\n\n\
{} Use \"Clean & Retry\" to rebuild the environment.",
redact_home(&python.display().to_string()),
python.display(),
python.exists(),
err,
os_hint,
@@ -527,29 +357,6 @@ fn analytics_env(baked_token: Option<&str>, baked_host: Option<&str>) -> Vec<(St
out
}
/// Parse the `OMNIVOICE_BACKEND_CMD` override: a JSON array (`["prog","a"]`)
/// when it starts with `[` — the form the harness uses, so paths with spaces
/// survive — else whitespace-split. `None` for unset/empty/unparseable.
pub fn parse_backend_cmd_override(raw: &str) -> Option<Vec<String>> {
let raw = raw.trim();
if raw.is_empty() {
return None;
}
let argv: Vec<String> = if raw.starts_with('[') {
serde_json::from_str(raw).ok()?
} else {
raw.split_whitespace().map(str::to_string).collect()
};
if argv.is_empty() || argv[0].trim().is_empty() {
return None;
}
Some(argv)
}
fn backend_cmd_override() -> Option<Vec<String>> {
parse_backend_cmd_override(&std::env::var("OMNIVOICE_BACKEND_CMD").ok()?)
}
pub fn spawn_backend<R: tauri::Runtime>(app: &tauri::AppHandle<R>, progress: Option<&Arc<Mutex<BootstrapStage>>>) -> Option<Child> {
let log_path = backend_log_path();
let err_path = log_path.with_file_name("backend_err.log");
@@ -559,22 +366,12 @@ pub fn spawn_backend<R: tauri::Runtime>(app: &tauri::AppHandle<R>, progress: Opt
err_path.display(),
);
// Fault-injection / QA seam: OMNIVOICE_BACKEND_CMD runs the given argv
// as "the backend". Venv bootstrap and ffmpeg resolution are skipped
// (they can install toolchains or touch the network); everything else —
// the err-log run offset, the drainer threads, env pinning, real OS
// pipes, the spawn-failure diagnostic — stays exactly real, which is
// the point: the lifecycle harness exercises genuine process deaths.
let cmd_override = backend_cmd_override();
let (python, backend_dir) = match cmd_override {
Some(ref argv) => (PathBuf::from(&argv[0]), PathBuf::new()),
None => match ensure_venv_ready(app, progress) {
Some(x) => x,
None => {
log::error!("Venv bootstrap failed — backend not started");
return None;
}
},
let (python, backend_dir) = match ensure_venv_ready(app, progress) {
Some(x) => x,
None => {
log::error!("Venv bootstrap failed — backend not started");
return None;
}
};
if let Some(p) = progress {
@@ -582,24 +379,7 @@ pub fn spawn_backend<R: tauri::Runtime>(app: &tauri::AppHandle<R>, progress: Opt
}
let stdout_file = fs::File::create(&log_path).ok();
// Append + per-run offset, never truncate: the previous run's stderr is
// crash evidence until someone reads it (#1510). Flush the previous
// drainer first so old buffered lines land BEFORE this run's offset.
join_previous_err_drainer(Duration::from_secs(2));
let (err_log_file, err_log_start) = open_err_log_for_run(&err_path);
ERR_LOG_RUN_START.store(err_log_start, std::sync::atomic::Ordering::SeqCst);
if let Some(ref f) = err_log_file {
use std::io::Write;
let mut f = f;
let _ = writeln!(
f,
"──── backend run starting (unix {}s) ────",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
);
}
let err_log_file = fs::File::create(&err_path).ok();
let mut env: Vec<(String, String)> = vec![("PYTHONUNBUFFERED".into(), "1".into())];
// Pin the child's OMNIVOICE_PORT to the value Rust resolved so Python's
@@ -653,20 +433,18 @@ pub fn spawn_backend<R: tauri::Runtime>(app: &tauri::AppHandle<R>, progress: Opt
}
// Analytics destination (#1123) — see analytics_env() below for why.
env.extend(analytics_env(option_env!("VITE_POSTHOG_KEY"), option_env!("VITE_POSTHOG_HOST")));
if cmd_override.is_none() {
let app_data = app.path().app_local_data_dir().unwrap_or_default();
if let Some(ffmpeg_path) = resolve_ffmpeg(app, &app_data) {
env.push(("FFMPEG_PATH".into(), ffmpeg_path.to_string_lossy().into()));
}
if let Some(ffprobe_path) = resolve_ffprobe(app, &app_data) {
let ffprobe_str: String = ffprobe_path.to_string_lossy().into();
env.push(("FFPROBE_PATH".into(), ffprobe_str.clone()));
// Issue #76: OMNIVOICE_FFPROBE_PATH is the canonical name going
// forward — explicit, namespaced, and unambiguously the path of a
// file (not a PATH-style command name). FFPROBE_PATH stays for
// backward compat with prior backend releases.
env.push(("OMNIVOICE_FFPROBE_PATH".into(), ffprobe_str));
}
let app_data = app.path().app_local_data_dir().unwrap_or_default();
if let Some(ffmpeg_path) = resolve_ffmpeg(app, &app_data) {
env.push(("FFMPEG_PATH".into(), ffmpeg_path.to_string_lossy().into()));
}
if let Some(ffprobe_path) = resolve_ffprobe(app, &app_data) {
let ffprobe_str: String = ffprobe_path.to_string_lossy().into();
env.push(("FFPROBE_PATH".into(), ffprobe_str.clone()));
// Issue #76: OMNIVOICE_FFPROBE_PATH is the canonical name going
// forward — explicit, namespaced, and unambiguously the path of a
// file (not a PATH-style command name). FFPROBE_PATH stays for
// backward compat with prior backend releases.
env.push(("OMNIVOICE_FFPROBE_PATH".into(), ffprobe_str));
}
let mut cmd = Command::new(&python);
cmd.env_remove("PYTHONHOME").env_remove("PYTHONPATH").env_remove("LD_LIBRARY_PATH");
@@ -685,25 +463,18 @@ pub fn spawn_backend<R: tauri::Runtime>(app: &tauri::AppHandle<R>, progress: Opt
// nvidia-smi probe already uses (setup.rs).
cmd.creation_flags(0x0800_0000 | 0x0000_0200);
}
match cmd_override {
Some(ref argv) => {
cmd.args(&argv[1..]);
}
None => {
cmd.args([
"-m",
"uvicorn",
"main:app",
"--app-dir",
backend_dir.to_string_lossy().as_ref(),
"--host",
"127.0.0.1",
"--port",
&backend_port().to_string(),
]);
}
}
let mut child = match cmd
.args([
"-m",
"uvicorn",
"main:app",
"--app-dir",
backend_dir.to_string_lossy().as_ref(),
"--host",
"127.0.0.1",
"--port",
&backend_port().to_string(),
])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
@@ -722,16 +493,7 @@ pub fn spawn_backend<R: tauri::Runtime>(app: &tauri::AppHandle<R>, progress: Opt
// real exec error instead of "no error output captured".
let diag = spawn_failure_diagnostic(&python, &e);
log::error!("{}", diag);
// Append (not overwrite): the run header above already marks this
// run's slice, and earlier runs' evidence stays intact.
if let Ok(mut f) = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&err_path)
{
use std::io::Write;
let _ = writeln!(f, "{}", diag);
}
let _ = fs::write(&err_path, &diag);
return None;
}
};
@@ -754,9 +516,7 @@ pub fn spawn_backend<R: tauri::Runtime>(app: &tauri::AppHandle<R>, progress: Opt
if let Some(stderr_pipe) = child.stderr.take() {
let app_clone = app.clone();
// Tracked (not detached): the next spawn joins this handle so this
// run's buffered tail flushes before the next run's offset is taken.
let drainer = std::thread::spawn(move || {
std::thread::spawn(move || {
use std::io::Write;
let reader = BufReader::new(stderr_pipe);
let mut log_file = err_log_file;
@@ -768,9 +528,6 @@ pub fn spawn_backend<R: tauri::Runtime>(app: &tauri::AppHandle<R>, progress: Opt
}
}
});
if let Ok(mut guard) = ERR_LOG_DRAINER.lock() {
*guard = Some(drainer);
}
}
Some(child)
@@ -840,119 +597,6 @@ mod tests {
std::env::remove_var("OMNIVOICE_INSTALL_CHANNEL");
}
/// Loopback responder for the /startup/progress probe tests.
fn spawn_progress_stub(with_marker: bool, body: &'static str) -> u16 {
use std::io::{Read, Write};
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
let port = listener.local_addr().unwrap().port();
std::thread::spawn(move || {
for stream in listener.incoming() {
let Ok(mut stream) = stream else { break };
let mut buf = [0u8; 512];
let _ = stream.read(&mut buf);
let marker = if with_marker {
"x-omnivoice-backend: 0.0.0\r\n"
} else {
""
};
let resp = format!(
"HTTP/1.1 200 OK\r\n{marker}Content-Length: {}\r\n\r\n{body}",
body.len()
);
let _ = stream.write_all(resp.as_bytes());
}
});
port
}
/// Loopback HTTP responder for the probe tests: answers `/system/info`
/// with a genuine-looking backend body and `/profiles` with the given
/// status — the exact shape of a zombie whose install/DB broke while
/// `/system/info` kept answering from memory.
fn spawn_probe_stub(profiles_status: u16) -> u16 {
use std::io::{Read, Write};
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
let port = listener.local_addr().unwrap().port();
std::thread::spawn(move || {
for stream in listener.incoming() {
let Ok(mut stream) = stream else { break };
let mut buf = [0u8; 512];
let n = stream.read(&mut buf).unwrap_or(0);
let req = String::from_utf8_lossy(&buf[..n]);
let resp = if req.starts_with("GET /system/info") {
"HTTP/1.1 200 OK\r\nContent-Length: 19\r\n\r\n{\"data_dir\": \"/x\"}\n".to_string()
} else {
format!("HTTP/1.1 {profiles_status} X\r\nContent-Length: 2\r\n\r\n[]")
};
let _ = stream.write_all(resp.as_bytes());
}
});
port
}
#[test]
fn backend_cmd_override_parses_json_and_whitespace_forms() {
// JSON form (the harness's): paths with spaces survive.
assert_eq!(
parse_backend_cmd_override(r#"["/tmp/my dir/prog", "arg1"]"#),
Some(vec!["/tmp/my dir/prog".into(), "arg1".into()])
);
// Whitespace form (manual QA): OMNIVOICE_BACKEND_CMD="/bin/false x".
assert_eq!(
parse_backend_cmd_override("/bin/false x"),
Some(vec!["/bin/false".into(), "x".into()])
);
// Unset/empty/garbage never activates the seam — production behavior
// is byte-identical without the env var.
assert_eq!(parse_backend_cmd_override(""), None);
assert_eq!(parse_backend_cmd_override(" "), None);
assert_eq!(parse_backend_cmd_override("[not json"), None);
assert_eq!(parse_backend_cmd_override("[]"), None);
assert_eq!(parse_backend_cmd_override(r#"[""]"#), None);
}
#[test]
fn startup_progress_parses_fields_and_requires_the_marker() {
const BODY: &str =
r#"{"status": "starting", "step": "ml_imports", "label": "Loading ML runtime (PyTorch)…", "error": null}"#;
// Marker present → the tuple the poll loops narrate from.
let port = spawn_progress_stub(true, BODY);
assert_eq!(
startup_progress(port),
Some((
"starting".into(),
"ml_imports".into(),
"Loading ML runtime (PyTorch)…".into()
))
);
// No marker header → a foreign responder must not narrate our splash.
let foreign = spawn_progress_stub(false, BODY);
assert_eq!(startup_progress(foreign), None);
// Ready body with null step/label → status still parses, step empty.
let ready = spawn_progress_stub(true, r#"{"status": "ready", "step": null, "label": null}"#);
assert_eq!(startup_progress(ready), Some(("ready".into(), String::new(), String::new())));
// Nothing listening → None (old backend / dead port fall back).
assert_eq!(startup_progress(1), None);
}
#[test]
fn ready_requires_the_deep_probe_not_just_identity() {
// Regression for the shallow-Ready class: a backend that identifies
// itself on /system/info but 500s a DB-backed route must NOT be
// announced Ready — that zombie looked alive while every real
// request dead-ended on "can't reach the backend".
let broken = spawn_probe_stub(500);
assert!(backend_healthy(broken), "identity probe should pass");
assert!(!backend_deep_healthy(broken), "deep probe must fail on 500");
assert!(!backend_ready(broken), "Ready must gate on the deep probe");
let ok = spawn_probe_stub(200);
assert!(backend_ready(ok), "identity + working DB route is Ready");
// Nothing listening at all: no probe passes.
assert!(!backend_ready(1)); // port 1 — never bindable by us
}
#[test]
fn spawn_failure_diagnostic_surfaces_path_error_and_hint() {
let err = io::Error::new(io::ErrorKind::NotFound, "No such file or directory");
@@ -1003,131 +647,4 @@ mod tests {
// unversioned (pre-app_version backend) is stale by definition
assert!(!same_app_version(""));
}
// ── Per-run crash evidence (#1510) ───────────────────────────────────
// The reported failure shape: a crash marker whose stderr tail was the
// REPLACEMENT process's healthy startup, because the shared err log was
// truncated on respawn and read unbounded afterwards.
#[test]
fn a_respawn_preserves_the_previous_runs_evidence() {
use std::io::Write;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("backend_err.log");
let (file, start) = open_err_log_for_run(&path);
assert_eq!(start, 0);
writeln!(file.unwrap(), "run1: fatal abort, last words").unwrap();
// Respawn: pre-#1510 this truncated the file (File::create), turning
// the dead run's final output into nothing.
let (file2, start2) = open_err_log_for_run(&path);
let content = fs::read_to_string(&path).unwrap();
assert!(
content.contains("run1: fatal abort"),
"respawn destroyed the previous run's evidence: {content:?}"
);
assert_eq!(
start2 as usize,
content.len(),
"run2 must begin at the old EOF"
);
drop(file2);
}
#[test]
fn a_run_bounded_tail_cannot_show_another_runs_output() {
use std::io::Write;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("backend_err.log");
let (file, _) = open_err_log_for_run(&path);
writeln!(file.unwrap(), "run1: Traceback — the actual crash").unwrap();
let (file2, start2) = open_err_log_for_run(&path);
writeln!(file2.unwrap(), "run2: OmniVoice model loaded successfully.").unwrap();
// The dead run's slice: only its own words.
let run1 = read_error_log_tail_at(&path, 0, 10);
assert!(run1.contains("the actual crash"));
// The replacement's slice: its startup, and NEVER run1's crash —
// and, symmetrically, a marker bounded to run1's slice could never
// have contained run2's healthy startup (the #1510 report).
let run2 = read_error_log_tail_at(&path, start2, 10);
assert!(run2.contains("model loaded successfully"));
assert!(
!run2.contains("the actual crash"),
"run-bounded tail leaked another run's output: {run2:?}"
);
}
#[test]
fn an_invalid_offset_degrades_to_the_whole_file_not_to_silence() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("backend_err.log");
fs::write(&path, "only line\n").unwrap();
// Offset beyond EOF (file replaced/shrunk externally): evidence
// beats precision — degrade to the whole file, never to "".
assert_eq!(read_error_log_tail_at(&path, 10_000, 10), "only line");
}
#[test]
fn a_dying_runs_buffered_stderr_flushes_before_the_next_offset() {
use std::io::Write;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("backend_err.log");
fs::write(&path, "run1: early line\n").unwrap();
// A drainer still flushing the dead run's buffered tail…
let p = path.clone();
let late = std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(120));
let mut f = fs::OpenOptions::new().append(true).open(&p).unwrap();
writeln!(f, "run1: buffered last words").unwrap();
});
*ERR_LOG_DRAINER.lock().unwrap() = Some(late);
// …must land BEFORE the next run records where its output begins.
join_previous_err_drainer(Duration::from_secs(2));
let (_file, start) = open_err_log_for_run(&path);
let run2 = read_error_log_tail_at(&path, start, 10);
assert!(
!run2.contains("buffered last words"),
"old run's buffered stderr was attributed to the new run: {run2:?}"
);
assert!(fs::read_to_string(&path)
.unwrap()
.contains("buffered last words"));
}
#[test]
fn the_spawn_diagnostic_never_carries_the_users_home_path() {
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let saved = std::env::var("HOME").ok();
std::env::set_var("HOME", "/home/realname");
let diag = spawn_failure_diagnostic(
Path::new("/home/realname/.local/share/app/venv/bin/python"),
&io::Error::new(io::ErrorKind::NotFound, "nope"),
);
match saved {
Some(v) => std::env::set_var("HOME", v),
None => std::env::remove_var("HOME"),
}
assert!(!diag.contains("/home/realname"), "home path leaked: {diag}");
assert!(diag.contains("~/.local/share/app/venv/bin/python"));
}
#[test]
fn an_oversized_log_rotates_instead_of_growing_forever() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("backend_err.log");
fs::write(&path, "x".repeat((ERR_LOG_ROTATE_BYTES + 1) as usize)).unwrap();
let (_file, start) = open_err_log_for_run(&path);
assert_eq!(start, 0, "a rotated log starts the new run at offset 0");
let rotated = path.with_file_name("backend_err.log.1");
assert!(
rotated.exists(),
"old evidence must survive rotation in the sibling file"
);
}
}
+17 -190
View File
@@ -293,20 +293,14 @@ pub fn respawn_backend(
/// the venv — is removed and the bootstrap re-runs once, recreating it through
/// the normal `CreatingVenv` / `InstallingDeps` setup path instead of
/// surfacing the same dead-end failure on every retry.
pub fn spawn_backend_and_wait<R: tauri::Runtime>(app: &tauri::AppHandle<R>, stage_handle: &Arc<Mutex<BootstrapStage>>) {
pub fn spawn_backend_and_wait(app: &tauri::AppHandle, stage_handle: &Arc<Mutex<BootstrapStage>>) {
let mut venv_heal_attempted = false;
'bootstrap: loop {
let child = crate::backend::spawn_backend(app, Some(stage_handle));
track_backend_child(app, child);
let start = std::time::Instant::now();
// Early-bind narration: the backend answers /startup/progress within
// ~1s of spawn, long before it is Ready — surface each step change
// as a log line so the splash shows "Loading ML runtime (PyTorch)…"
// instead of a silent 300s wait. An old backend (no endpoint) yields
// None and the wait looks exactly as it did before.
let mut last_step = String::new();
while start.elapsed() < startup_budget() {
if crate::backend::backend_ready(backend_port()) {
while start.elapsed() < Duration::from_secs(300) {
if crate::backend::backend_healthy(backend_port()) {
set_stage(stage_handle, BootstrapStage::Ready);
// #567/#570/#571: once Ready, keep watching the backend child
// and respawn it if it dies mid-session, so a crash self-heals
@@ -344,7 +338,7 @@ pub fn spawn_backend_and_wait<R: tauri::Runtime>(app: &tauri::AppHandle<R>, stag
None
};
if let Some((exit_info, real_exit)) = process_dead {
let err_tail = crate::backend::read_error_log_tail_for_run(30);
let err_tail = crate::backend::read_error_log_tail(30);
// #941: persist the forensics for every true process death —
// startup crashes included — unless the app is shutting down
// or a retry flow deliberately killed the child.
@@ -353,7 +347,7 @@ pub fn spawn_backend_and_wait<R: tauri::Runtime>(app: &tauri::AppHandle<R>, stag
crate::crash::record_crash(crate::crash::marker_now(
exit,
backend_uptime_s(app),
crate::backend::read_error_log_tail_for_run(CRASH_STDERR_TAIL_LINES),
crate::backend::read_error_log_tail(CRASH_STDERR_TAIL_LINES),
));
}
}
@@ -447,25 +441,13 @@ pub fn spawn_backend_and_wait<R: tauri::Runtime>(app: &tauri::AppHandle<R>, stag
set_stage(stage_handle, BootstrapStage::Failed { message: msg });
return;
}
if let Some((status, step, label)) =
crate::backend::startup_progress(backend_port())
{
if status == "starting" && !step.is_empty() && step != last_step {
last_step = step;
emit_log(app, "starting_backend", &format!("Startup: {label}"));
}
}
std::thread::sleep(Duration::from_millis(500));
}
let err_tail = crate::backend::read_error_log_tail_for_run(20);
let err_tail = crate::backend::read_error_log_tail(20);
let msg = if err_tail.is_empty() {
format!("Backend did not respond within {} s", startup_budget().as_secs())
"Backend did not respond within 300 s".to_string()
} else {
format!(
"Backend did not respond within {} s. Last stderr output:\n{}",
startup_budget().as_secs(),
err_tail
)
format!("Backend did not respond within 300 s. Last stderr output:\n{}", err_tail)
};
set_stage(stage_handle, BootstrapStage::Failed { message: msg });
return;
@@ -494,15 +476,6 @@ static SUPERVISOR_ACTIVE: AtomicBool = AtomicBool::new(false);
/// moment a fresh child is spawned and tracked (`track_backend_child`).
static BACKEND_KILL_INTENDED: AtomicBool = AtomicBool::new(false);
/// Bumped every time `track_backend_child` installs a new child. The
/// supervisor snapshots it when it observes a death; a change during its
/// backoff pause means ANOTHER flow (Retry / Clean & Retry) spawned and
/// tracked a replacement — ownership has transferred, whether or not that
/// replacement is still alive when sampled (the flag and a liveness check
/// can both be missed inside one 500ms window; the generation cannot).
static BACKEND_SPAWN_GENERATION: std::sync::atomic::AtomicU64 =
std::sync::atomic::AtomicU64::new(0);
pub fn set_backend_kill_intended(value: bool) {
BACKEND_KILL_INTENDED.store(value, Ordering::SeqCst);
}
@@ -526,7 +499,7 @@ const CRASH_STDERR_TAIL_LINES: usize = 40;
const MAX_RESTARTS: usize = 3;
const RESTART_WINDOW: Duration = Duration::from_secs(600);
fn app_is_quitting<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> bool {
fn app_is_quitting(app: &tauri::AppHandle) -> bool {
app.try_state::<AppFlags>()
.map(|f| f.quitting.load(Ordering::SeqCst))
.unwrap_or(false)
@@ -535,7 +508,7 @@ fn app_is_quitting<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> bool {
/// Store the freshly spawned backend child (and its spawn time, for the crash
/// marker's `uptime_s`), and re-arm the death watchers: any deliberate-kill
/// window ends the moment a new child is tracked.
fn track_backend_child<R: tauri::Runtime>(app: &tauri::AppHandle<R>, child: Option<std::process::Child>) {
fn track_backend_child(app: &tauri::AppHandle, child: Option<std::process::Child>) {
let state = app.state::<BackendState>();
if let Ok(mut guard) = state.process.lock() {
*guard = child;
@@ -543,12 +516,11 @@ fn track_backend_child<R: tauri::Runtime>(app: &tauri::AppHandle<R>, child: Opti
if let Ok(mut spawned) = state.spawned_at.lock() {
*spawned = Some(Instant::now());
}
BACKEND_SPAWN_GENERATION.fetch_add(1, Ordering::SeqCst);
set_backend_kill_intended(false);
}
/// Seconds since the tracked backend child was spawned (0 when unknown).
fn backend_uptime_s<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> u64 {
fn backend_uptime_s(app: &tauri::AppHandle) -> u64 {
app.try_state::<BackendState>()
.and_then(|s| s.spawned_at.lock().ok().and_then(|g| *g))
.map(|t| t.elapsed().as_secs())
@@ -558,7 +530,7 @@ fn backend_uptime_s<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> u64 {
/// Returns `Some(BackendExit)` if the tracked backend child has exited,
/// `None` if it is still running (or none is tracked — which we never treat as
/// a death to respawn, to avoid fighting a deliberate teardown).
fn backend_child_exit<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> Option<BackendExit> {
fn backend_child_exit(app: &tauri::AppHandle) -> Option<BackendExit> {
let state = app.try_state::<BackendState>()?;
let mut guard = state.process.lock().ok()?;
match guard.as_mut() {
@@ -571,30 +543,6 @@ fn backend_child_exit<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> Option<Ba
}
}
/// How long the launch poll waits for the backend to become Ready before
/// declaring Failed. 300s in production; `OMNIVOICE_STARTUP_BUDGET_S`
/// exists for the fault-injection harness (a slow-start scenario must not
/// sleep five minutes in CI) and for support triage on pathological disks.
fn startup_budget() -> Duration {
std::env::var("OMNIVOICE_STARTUP_BUDGET_S")
.ok()
.and_then(|v| v.trim().parse::<u64>().ok())
.filter(|&s| s > 0)
.map(Duration::from_secs)
.unwrap_or(Duration::from_secs(300))
}
/// The supervisor's death-detection poll interval. 2s in production;
/// `OMNIVOICE_SUPERVISOR_POLL_MS` shrinks it for the harness only.
fn supervisor_poll() -> Duration {
std::env::var("OMNIVOICE_SUPERVISOR_POLL_MS")
.ok()
.and_then(|v| v.trim().parse::<u64>().ok())
.filter(|&ms| ms > 0)
.map(Duration::from_millis)
.unwrap_or(Duration::from_secs(2))
}
/// Drop restart timestamps older than `RESTART_WINDOW` and report whether the
/// remaining count has hit the cap. Pure so the backoff policy is unit-tested
/// without spawning real processes.
@@ -603,40 +551,19 @@ fn restart_budget_exhausted(times: &mut Vec<Instant>, now: Instant) -> bool {
times.len() >= MAX_RESTARTS
}
/// Escalating pause before a respawn, keyed on how many restarts already
/// happened inside `RESTART_WINDOW`. The FIRST respawn stays immediate (a
/// one-off crash should self-heal fast); repeat deaths get breathing room so
/// a tight crash loop doesn't burn the whole 3-in-600s budget in seconds —
/// back-to-back torch-import storms are exactly what pushes a
/// memory-pressured machine over the edge again. Pure for unit testing.
fn restart_backoff_delay(recent_restarts: usize) -> Duration {
match recent_restarts {
0 => Duration::ZERO,
1 => Duration::from_secs(5),
_ => Duration::from_secs(15),
}
}
/// After the backend is Ready, watch its process and respawn it on an
/// unexpected exit. Runs on the (otherwise-returning) bootstrap thread and
/// stops the instant the app is quitting so it never resurrects the backend
/// during shutdown. Death is detected only via a *confirmed process exit*
/// (`try_wait`), never a slow health probe, so a busy-but-alive backend is
/// never killed.
fn supervise_backend<R: tauri::Runtime>(app: &tauri::AppHandle<R>, stage_handle: &Arc<Mutex<BootstrapStage>>) {
fn supervise_backend(app: &tauri::AppHandle, stage_handle: &Arc<Mutex<BootstrapStage>>) {
let mut restart_times: Vec<Instant> = Vec::new();
loop {
std::thread::sleep(supervisor_poll());
std::thread::sleep(Duration::from_secs(2));
if app_is_quitting(app) {
return;
}
// Snapshot the spawn generation BEFORE observing the exit: sampled
// after, a replacement tracked in the gap between `try_wait` and the
// load would be baked into the snapshot and the transfer missed
// (third-pass review find). Sampled before, any tracking that
// happens from here on — even one whose child we are about to see
// exit — reads as a generation change and yields.
let observed_generation = BACKEND_SPAWN_GENERATION.load(Ordering::SeqCst);
let exit = match backend_child_exit(app) {
Some(exit) => exit,
None => continue, // still running
@@ -660,10 +587,10 @@ fn supervise_backend<R: tauri::Runtime>(app: &tauri::AppHandle<R>, stage_handle:
crate::crash::record_crash(crate::crash::marker_now(
&exit,
uptime_s,
crate::backend::read_error_log_tail_for_run(CRASH_STDERR_TAIL_LINES),
crate::backend::read_error_log_tail(CRASH_STDERR_TAIL_LINES),
));
if restart_budget_exhausted(&mut restart_times, Instant::now()) {
let tail = crate::backend::read_error_log_tail_for_run(30);
let tail = crate::backend::read_error_log_tail(30);
let msg = format!(
"The backend kept crashing ({} times in {} min; last death: {}) and couldn't \
be kept running. Use Clean & Retry, or check Settings Logs Backend.{}",
@@ -677,10 +604,6 @@ fn supervise_backend<R: tauri::Runtime>(app: &tauri::AppHandle<R>, stage_handle:
set_stage(stage_handle, BootstrapStage::Failed { message: msg });
return;
}
// Backoff BEFORE this restart is recorded: `restart_times` was just
// pruned to the window, so its length is the number of recent
// respawns already attempted.
let backoff = restart_backoff_delay(restart_times.len());
restart_times.push(Instant::now());
log::warn!("Backend process exited unexpectedly ({exit_info}) — restarting it (#567)");
emit_log(app, "starting_backend", "Backend stopped unexpectedly — restarting it automatically");
@@ -688,51 +611,6 @@ fn supervise_backend<R: tauri::Runtime>(app: &tauri::AppHandle<R>, stage_handle:
// poll has already stopped post-Ready, so the stage alone won't show).
let _ = app.emit("backend-restarting", exit_info.clone());
set_stage(stage_handle, BootstrapStage::StartingBackend);
// The banner is already up, so the pause reads as "reconnecting", not
// as a hang. Chunked so quitting (or a deliberate retry-flow kill,
// which owns the respawn) is honored within 500 ms.
if !backoff.is_zero() {
log::info!(
"Backend died {} time(s) in the last {} min — waiting {}s before respawning",
restart_times.len(),
RESTART_WINDOW.as_secs() / 60,
backoff.as_secs()
);
let waited = Instant::now();
while waited.elapsed() < backoff {
if app_is_quitting(app) {
return;
}
if backend_kill_intended() {
log::info!("Deliberate replace during restart backoff — supervisor yielding");
return;
}
// A completed Retry/Clean&Retry sets the deliberate-kill flag
// and then `track_backend_child` CLEARS it — possibly both
// between two of these samples, so the flag alone can be
// missed. The durable tell is the spawn GENERATION: it bumps
// when a replacement is tracked and never un-bumps, so it is
// observed even if the replacement has itself already exited
// by the time we sample. Yield promptly (not at backoff end)
// so the retry's own spawn_backend_and_wait can claim the
// supervisor slot at Ready — and so we never free_port() a
// replacement out from under the flow that owns it.
if BACKEND_SPAWN_GENERATION.load(Ordering::SeqCst) != observed_generation {
log::info!(
"A replacement backend was tracked during restart backoff — supervisor yielding"
);
return;
}
std::thread::sleep(Duration::from_millis(500));
}
}
// Last look before touching the port — covers the zero-backoff first
// respawn (which never enters the pause loop) and the tail of the
// pause itself. After this point we own the respawn.
if BACKEND_SPAWN_GENERATION.load(Ordering::SeqCst) != observed_generation {
log::info!("A replacement backend was tracked — supervisor yielding to its flow");
return;
}
// Clear any orphan still holding the port before the respawn. #1223:
// if it can't be cleared, respawning just reproduces the bind failure
// — stop and say so rather than burning a restart attempt.
@@ -764,12 +642,11 @@ fn supervise_backend<R: tauri::Runtime>(app: &tauri::AppHandle<R>, stage_handle:
// Wait (bounded) for the respawn to become healthy. If it dies again
// immediately, bail early so the next loop counts it toward the cap.
let start = Instant::now();
let mut last_step = String::new();
while start.elapsed() < Duration::from_secs(120) {
if app_is_quitting(app) {
return;
}
if crate::backend::backend_ready(backend_port()) {
if crate::backend::backend_healthy(backend_port()) {
set_stage(stage_handle, BootstrapStage::Ready);
let _ = app.emit("backend-restored", ());
log::info!("Backend restarted and healthy again");
@@ -778,16 +655,6 @@ fn supervise_backend<R: tauri::Runtime>(app: &tauri::AppHandle<R>, stage_handle:
if backend_child_exit(app).is_some() {
break;
}
// Same early-bind narration as the launch poll: name the startup
// step in the reconnecting window instead of a silent wait.
if let Some((status, step, label)) =
crate::backend::startup_progress(backend_port())
{
if status == "starting" && !step.is_empty() && step != last_step {
last_step = step;
emit_log(app, "starting_backend", &format!("Startup: {label}"));
}
}
std::thread::sleep(Duration::from_millis(500));
}
}
@@ -2157,46 +2024,6 @@ mod tests {
assert!(aged.is_empty(), "stale timestamps should have been dropped");
}
/// Env-mutating tests in THIS module serialize on their own lock (cargo
/// runs tests in threads; the harness binary has its own).
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[test]
fn timing_overrides_default_to_production_values() {
// The env overrides exist for the fault-injection harness only —
// production timing must not drift when they are unset.
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
std::env::remove_var("OMNIVOICE_STARTUP_BUDGET_S");
std::env::remove_var("OMNIVOICE_SUPERVISOR_POLL_MS");
assert_eq!(startup_budget(), Duration::from_secs(300));
assert_eq!(supervisor_poll(), Duration::from_secs(2));
// Zero/garbage never yields a degenerate loop.
std::env::set_var("OMNIVOICE_STARTUP_BUDGET_S", "0");
std::env::set_var("OMNIVOICE_SUPERVISOR_POLL_MS", "abc");
assert_eq!(startup_budget(), Duration::from_secs(300));
assert_eq!(supervisor_poll(), Duration::from_secs(2));
std::env::set_var("OMNIVOICE_STARTUP_BUDGET_S", "6");
assert_eq!(startup_budget(), Duration::from_secs(6));
std::env::remove_var("OMNIVOICE_STARTUP_BUDGET_S");
std::env::remove_var("OMNIVOICE_SUPERVISOR_POLL_MS");
}
#[test]
fn restart_backoff_escalates_but_first_respawn_is_immediate() {
// A one-off crash self-heals with zero added latency; repeat deaths
// inside the window get an escalating pause so a tight crash loop
// can't burn the whole 3-in-600s budget in seconds.
assert_eq!(restart_backoff_delay(0), Duration::ZERO);
assert_eq!(restart_backoff_delay(1), Duration::from_secs(5));
assert_eq!(restart_backoff_delay(2), Duration::from_secs(15));
// Monotonic, and capped rather than unbounded — the budget check is
// what ends a hopeless loop, not an ever-growing sleep.
assert_eq!(restart_backoff_delay(50), Duration::from_secs(15));
for n in 0..10 {
assert!(restart_backoff_delay(n) <= restart_backoff_delay(n + 1));
}
}
#[test]
fn torch_download_failure_is_detected_for_targeted_help() {
// #569: the cu128 torch wheel host (and a torch-named download/fetch
+14 -116
View File
@@ -98,16 +98,14 @@ impl PortalShortcutState {
const DESKTOP_ID: &str = "com.debpalash.omnivoice-studio";
fn user_entry_path() -> Option<std::path::PathBuf> {
dirs_next::data_dir().map(|dir| {
dir.join("applications")
.join(format!("{DESKTOP_ID}.desktop"))
})
}
/// A packaged (system-dir) entry — deb installs manage their own; never touch.
fn system_entry_exists() -> bool {
fn desktop_entry_exists() -> bool {
let filename = format!("{DESKTOP_ID}.desktop");
let user_entry = dirs_next::data_dir()
.map(|dir| dir.join("applications").join(&filename))
.is_some_and(|path| path.is_file());
if user_entry {
return true;
}
std::env::var_os("XDG_DATA_DIRS")
.map(|dirs| {
std::env::split_paths(&dirs)
@@ -123,54 +121,6 @@ fn system_entry_exists() -> bool {
})
}
/// The `[Desktop Entry]` group's Exec target, unquoted. `None` when the main
/// group has no usable Exec line — which GLib treats the same as a missing
/// program. Scoped to the main group deliberately: a `[Desktop Action …]`
/// group carries its own `Exec=`, and accepting it would retain an entry GLib
/// still cannot resolve (CodeRabbit, #1526).
fn entry_exec_target(content: &str) -> Option<std::path::PathBuf> {
let mut in_main_group = false;
let mut exec = None;
for line in content.lines() {
let line = line.trim_start();
if line.starts_with('[') {
in_main_group = line == "[Desktop Entry]";
continue;
}
if in_main_group {
if let Some(value) = line.strip_prefix("Exec=") {
exec = Some(value);
break;
}
}
}
let raw = exec?.trim();
let unquoted = raw
.strip_prefix('"')
.and_then(|rest| rest.split('"').next())
.unwrap_or_else(|| raw.split_whitespace().next().unwrap_or(raw));
if unquoted.is_empty() {
return None;
}
Some(std::path::PathBuf::from(unquoted))
}
/// Whether a user-local identity entry must be rewritten before the portal
/// will accept it.
///
/// GLib refuses to resolve a desktop entry whose Exec program does not exist
/// (`GDesktopAppInfo` returns NULL), and the portal then rejects the bind with
/// "App info not found" — the shortcut silently dies for the whole session.
/// A dev entry pointing at a `target/debug` binary goes stale exactly this
/// way: a `cargo clean`, a moved checkout, or anything that relocates the
/// binary breaks system-wide dictation with only a log line to show for it.
fn entry_needs_rewrite(content: &str, exec_exists: impl Fn(&std::path::Path) -> bool) -> bool {
match entry_exec_target(content) {
Some(target) => !exec_exists(&target),
None => true,
}
}
fn desktop_exec_path() -> Result<std::path::PathBuf, String> {
// AppImage's current_exe() points inside its transient mount. APPIMAGE is
// the stable launcher path the desktop entry must retain.
@@ -194,31 +144,19 @@ fn desktop_exec_value(path: &std::path::Path) -> String {
/// Deb packages already install one; dev builds and standalone AppImages may
/// not. Add an invisible identity entry only when none exists.
fn ensure_desktop_identity() -> Result<(), String> {
if system_entry_exists() {
if desktop_entry_exists() {
return Ok(());
}
let path = user_entry_path().ok_or("could not locate the user data directory")?;
if let Ok(existing) = std::fs::read_to_string(&path) {
if !entry_needs_rewrite(&existing, |target| target.exists()) {
return Ok(());
}
// Stale: GLib returns NULL for an entry whose Exec is gone, and the
// portal then refuses the bind ("App info not found"). Rewrite with
// where the app actually is NOW. The user dir with our app id is ours
// to manage — packaged entries live in the system dirs handled above.
log::info!(
"Wayland portal identity at {} points at a missing program — rewriting",
path.display()
);
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|error| format!("could not create applications directory: {error}"))?;
}
let applications = dirs_next::data_dir()
.ok_or("could not locate the user data directory")?
.join("applications");
std::fs::create_dir_all(&applications)
.map_err(|error| format!("could not create applications directory: {error}"))?;
let entry = format!(
"[Desktop Entry]\nType=Application\nName=VoiceStudio\nExec={}\nTerminal=false\nNoDisplay=true\nStartupWMClass=VoiceStudio\nX-VoiceStudio-Generated=true\n",
desktop_exec_value(&desktop_exec_path()?)
);
let path = applications.join(format!("{DESKTOP_ID}.desktop"));
std::fs::write(&path, entry)
.map_err(|error| format!("could not create {}: {error}", path.display()))?;
log::info!("Installed Wayland portal identity at {}", path.display());
@@ -698,46 +636,6 @@ mod tests {
assert_eq!(portal_trigger("Ctrl+K+L"), None);
}
#[test]
fn a_stale_identity_entry_is_rewritten() {
// The class from 2026-08-13: the entry's Exec pointed at a binary that
// had been moved. GLib then resolves the entry to NULL and the portal
// refuses the bind with "App info not found" — system-wide dictation
// silently dead for the whole session.
let stale = "[Desktop Entry]\nType=Application\nExec=/gone/omnivoice-studio\n";
assert!(super::entry_needs_rewrite(stale, |_| false));
let healthy = "[Desktop Entry]\nType=Application\nExec=\"/opt/VoiceStudio.AppImage\"\n";
assert!(!super::entry_needs_rewrite(healthy, |path| {
path == std::path::Path::new("/opt/VoiceStudio.AppImage")
}));
}
#[test]
fn exec_targets_parse_quoted_legacy_and_missing_lines() {
use super::entry_exec_target;
// Current writer: quoted.
assert_eq!(
entry_exec_target("[Desktop Entry]\nExec=\"/tmp/Voice Studio/app\"\n").as_deref(),
Some(std::path::Path::new("/tmp/Voice Studio/app"))
);
// Pre-quoting entries from older builds still parse.
assert_eq!(
entry_exec_target("[Desktop Entry]\nExec=/home/u/target/debug/omnivoice-studio\n")
.as_deref(),
Some(std::path::Path::new("/home/u/target/debug/omnivoice-studio"))
);
// No Exec at all resolves to NULL in GLib — treat as needing rewrite.
assert_eq!(entry_exec_target("[Desktop Entry]\nType=Application\n"), None);
assert!(super::entry_needs_rewrite("[Desktop Entry]\n", |_| true));
// An action group's Exec is NOT the entry's Exec: GLib still resolves
// the entry to NULL without a main-group Exec, so accepting this would
// keep exactly the stale entry the rewrite exists to replace.
let action_only = "[Desktop Entry]\nType=Application\n[Desktop Action new]\nExec=/bin/true\n";
assert_eq!(entry_exec_target(action_only), None);
assert!(super::entry_needs_rewrite(action_only, |_| true));
}
#[test]
fn desktop_exec_paths_are_quoted_and_escaped() {
assert_eq!(
@@ -1,549 +0,0 @@
//! Backend-lifecycle fault-injection harness.
//!
//! Runs `spawn_backend_and_wait` / `supervise_backend` against REAL dying
//! child processes (via the `OMNIVOICE_BACKEND_CMD` seam) and asserts the
//! user receives the CORRECT NAMED DIAGNOSIS — not merely that recovery
//! happened. Diagnosis quality is the bar: 61% of the historical "can't
//! reach the backend" class was closed undiagnosed.
//!
//! The scenario "backend" is this test binary re-invoking itself
//! (`scenario_child`), so exit codes, Unix signals, and pipe-close ordering
//! are the genuine OS articles on all three platforms — no system python,
//! no mocks of the behaviors under test.
//!
//! Every test mutates process-global state (env vars, the crash store, the
//! kill-intended flag), so they hold one mutex AND CI runs this binary with
//! `--test-threads=1`.
use std::io::{Read, Write};
use std::sync::atomic::AtomicBool;
use std::sync::{Arc, Mutex, MutexGuard};
use std::time::{Duration, Instant};
use tauri::Listener;
use tauri::Manager;
use app_lib::bootstrap::{
spawn_backend_and_wait, BootstrapStage, BootstrapState, LogPayload,
set_backend_kill_intended,
};
use app_lib::{AppFlags, BackendState, CaptureDispatchState};
static HARNESS: Mutex<()> = Mutex::new(());
// ── Scenario child ────────────────────────────────────────────────────────
/// Not a real test: when `OMNIVOICE_SCENARIO` is set, this plays the backend
/// — optionally serving minimal HTTP on `OMNIVOICE_PORT`, printing a stderr
/// script, then dying the scripted death. A no-op in a normal test pass.
#[test]
fn scenario_child() {
// The gate value is the PID of the process that ARMED the scenario (the
// parent harness). The parent's own libtest also runs this test — in a
// parallel local `cargo test` it could observe the armed env and start
// fault-injecting itself (binding the port, idling 600s). Only a
// DIFFERENT process — the spawned child — may play the backend.
match std::env::var("OMNIVOICE_SCENARIO") {
Ok(v) if v.parse::<u32>() == Ok(std::process::id()) => return, // the parent itself
Ok(_) => {}
Err(_) => return,
}
let get = |k: &str| std::env::var(k).unwrap_or_default();
let get_ms = |k: &str| get(k).parse::<u64>().ok();
if let Some(delay) = get_ms("OMNIVOICE_SCENARIO_START_DELAY_MS") {
std::thread::sleep(Duration::from_millis(delay));
}
// Serve /system/info + /profiles (the two probes behind backend_ready)
// and /startup/progress (marker-stamped) for the given window; 0 = serve
// forever.
if let Some(serve_ms) = get_ms("OMNIVOICE_SCENARIO_SERVE_MS") {
let port: u16 = get("OMNIVOICE_PORT").parse().expect("OMNIVOICE_PORT");
let progress_only = get("OMNIVOICE_SCENARIO_PROGRESS_ONLY") == "1";
let listener = std::net::TcpListener::bind(("127.0.0.1", port)).expect("bind scenario port");
listener.set_nonblocking(true).unwrap();
let deadline = if serve_ms == 0 {
None
} else {
Some(Instant::now() + Duration::from_millis(serve_ms))
};
loop {
if let Some(d) = deadline {
if Instant::now() >= d {
break;
}
}
match listener.accept() {
Ok((mut stream, _)) => {
let mut buf = [0u8; 512];
let _ = stream.set_read_timeout(Some(Duration::from_millis(200)));
let n = stream.read(&mut buf).unwrap_or(0);
let req = String::from_utf8_lossy(&buf[..n]);
let resp = if req.starts_with("GET /startup/progress") {
let body = r#"{"status": "starting", "step": "ml_imports", "label": "Loading ML runtime (PyTorch)_"}"#;
format!(
"HTTP/1.1 200 OK\r\nx-omnivoice-backend: 0.0.0\r\nContent-Length: {}\r\n\r\n{}",
body.len(), body
)
} else if progress_only {
"HTTP/1.1 503 X\r\nContent-Length: 0\r\n\r\n".to_string()
} else if req.starts_with("GET /system/info") {
let body = r#"{"data_dir": "/x", "app_version": "0.0.0"}"#;
format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{}", body.len(), body)
} else {
"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n[]".to_string()
};
let _ = stream.write_all(resp.as_bytes());
}
Err(_) => std::thread::sleep(Duration::from_millis(20)),
}
}
}
let stderr_script = get("OMNIVOICE_SCENARIO_STDERR");
if !stderr_script.is_empty() {
// \n-encoded so a multi-line traceback fits in one env var.
eprintln!("{}", stderr_script.replace("\\n", "\n"));
let _ = std::io::stderr().flush();
// Let the shell's drainer thread pull the pipe before death.
std::thread::sleep(Duration::from_millis(150));
}
#[cfg(unix)]
if get("OMNIVOICE_SCENARIO_SIGNAL") == "9" {
unsafe { libc::raise(libc::SIGKILL) };
}
if let Some(code) = get_ms("OMNIVOICE_SCENARIO_EXIT") {
std::process::exit(code as i32);
}
// Scripted to serve forever / be killed externally: idle out.
std::thread::sleep(Duration::from_secs(600));
}
// ── Harness plumbing ──────────────────────────────────────────────────────
struct Scenario<'a> {
stderr: &'a str,
exit: Option<i32>,
signal9: bool,
serve_ms: Option<u64>,
progress_only: bool,
}
impl Default for Scenario<'_> {
fn default() -> Self {
Scenario { stderr: "", exit: None, signal9: false, serve_ms: None, progress_only: false }
}
}
const SCENARIO_ENV: &[&str] = &[
"OMNIVOICE_SCENARIO",
"OMNIVOICE_SCENARIO_STDERR",
"OMNIVOICE_SCENARIO_EXIT",
"OMNIVOICE_SCENARIO_SIGNAL",
"OMNIVOICE_SCENARIO_SERVE_MS",
"OMNIVOICE_SCENARIO_PROGRESS_ONLY",
"OMNIVOICE_SCENARIO_START_DELAY_MS",
"OMNIVOICE_BACKEND_CMD",
"OMNIVOICE_LOG_DIR",
"OMNIVOICE_PORT",
"OMNIVOICE_STARTUP_BUDGET_S",
"OMNIVOICE_SUPERVISOR_POLL_MS",
];
struct TestApp {
app: tauri::App<tauri::test::MockRuntime>,
stage: Arc<Mutex<BootstrapStage>>,
logs: Arc<Mutex<Vec<LogPayload>>>,
_logdir: tempfile::TempDir,
_guard: MutexGuard<'static, ()>,
}
impl TestApp {
fn new(scenario: &Scenario) -> Self {
let guard = HARNESS.lock().unwrap_or_else(|e| e.into_inner());
for k in SCENARIO_ENV {
std::env::remove_var(k);
}
// Reset the retry-flow flag a previous scenario may have left set.
set_backend_kill_intended(false);
let logdir = tempfile::tempdir().expect("logdir");
std::env::set_var("OMNIVOICE_LOG_DIR", logdir.path());
// Fresh ephemeral port per scenario.
let port = {
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
l.local_addr().unwrap().port()
};
std::env::set_var("OMNIVOICE_PORT", port.to_string());
std::env::set_var("OMNIVOICE_STARTUP_BUDGET_S", "6");
std::env::set_var("OMNIVOICE_SUPERVISOR_POLL_MS", "100");
let exe = std::env::current_exe().expect("current_exe");
std::env::set_var(
"OMNIVOICE_BACKEND_CMD",
serde_json::to_string(&[
exe.to_string_lossy().as_ref(),
"scenario_child",
"--exact",
"--nocapture",
])
.unwrap(),
);
// Armed with OUR pid: the in-process scenario_child test sees its own
// pid and stays inert; only the spawned child (a different pid) runs.
std::env::set_var("OMNIVOICE_SCENARIO", std::process::id().to_string());
if !scenario.stderr.is_empty() {
std::env::set_var("OMNIVOICE_SCENARIO_STDERR", scenario.stderr);
}
if let Some(code) = scenario.exit {
std::env::set_var("OMNIVOICE_SCENARIO_EXIT", code.to_string());
}
if scenario.signal9 {
std::env::set_var("OMNIVOICE_SCENARIO_SIGNAL", "9");
}
if let Some(ms) = scenario.serve_ms {
std::env::set_var("OMNIVOICE_SCENARIO_SERVE_MS", ms.to_string());
}
if scenario.progress_only {
std::env::set_var("OMNIVOICE_SCENARIO_PROGRESS_ONLY", "1");
}
let app = tauri::test::mock_builder()
.build(tauri::test::mock_context(tauri::test::noop_assets()))
.expect("mock app");
app.manage(BackendState { process: Mutex::new(None), spawned_at: Mutex::new(None) });
app.manage(AppFlags {
quitting: AtomicBool::new(false),
dictating: AtomicBool::new(false),
capture: Mutex::new(CaptureDispatchState { ready: false, pending: None }),
});
let stage = Arc::new(Mutex::new(BootstrapStage::Checking));
let logs: Arc<Mutex<Vec<LogPayload>>> = Arc::new(Mutex::new(Vec::new()));
app.manage(BootstrapState { stage: stage.clone(), logs: logs.clone() });
TestApp { app, stage, logs, _logdir: logdir, _guard: guard }
}
fn handle(&self) -> tauri::AppHandle<tauri::test::MockRuntime> {
self.app.handle().clone()
}
/// Run the bootstrap on a thread; the returned closure joins it with a
/// hard timeout so a wiring regression fails red instead of hanging CI.
fn run_bootstrap(&self) -> std::thread::JoinHandle<()> {
let handle = self.handle();
let stage = self.stage.clone();
std::thread::spawn(move || spawn_backend_and_wait(&handle, &stage))
}
fn stage_snapshot(&self) -> BootstrapStage {
self.stage.lock().unwrap_or_else(|e| e.into_inner()).clone()
}
fn failed_message(&self) -> Option<String> {
match self.stage_snapshot() {
BootstrapStage::Failed { message } => Some(message),
_ => None,
}
}
fn markers(&self) -> app_lib::crash::CrashStore {
app_lib::crash::load_store_from(&app_lib::crash::markers_path())
}
fn record_events(&self, name: &'static str) -> Arc<Mutex<Vec<String>>> {
let seen: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let seen2 = seen.clone();
self.app.handle().listen(name, move |_ev| {
seen2.lock().unwrap_or_else(|e| e.into_inner()).push(name.to_string());
});
seen
}
fn kill_tracked_child(&self) {
let state = self.app.state::<BackendState>();
let guard = state.process.lock();
if let Ok(mut guard) = guard {
if let Some(child) = guard.as_mut() {
let _ = child.kill();
let _ = child.wait();
}
}
}
fn quit(&self) {
self.app
.state::<AppFlags>()
.quitting
.store(true, std::sync::atomic::Ordering::SeqCst);
}
}
impl Drop for TestApp {
fn drop(&mut self) {
self.quit(); // stop any still-running supervisor loop promptly
self.kill_tracked_child();
for k in SCENARIO_ENV {
std::env::remove_var(k);
}
set_backend_kill_intended(false);
}
}
fn wait_until(timeout: Duration, mut pred: impl FnMut() -> bool) -> bool {
let start = Instant::now();
while start.elapsed() < timeout {
if pred() {
return true;
}
std::thread::sleep(Duration::from_millis(100));
}
false
}
fn join_with_timeout(h: std::thread::JoinHandle<()>, timeout: Duration, what: &str) {
let start = Instant::now();
while !h.is_finished() {
assert!(
start.elapsed() < timeout,
"{what}: bootstrap thread still running after {timeout:?} — a lifecycle \
regression is hanging instead of diagnosing"
);
std::thread::sleep(Duration::from_millis(100));
}
let _ = h.join();
}
// ── Scenarios ─────────────────────────────────────────────────────────────
/// S1 — the backend exits EXIT_PORT_IN_USE: the user must read a port
/// conflict (in the exact phrasing BootstrapSplash.detectHints localizes),
/// not a traceback whose one meaningful line is an OS-translated errno.
#[test]
fn port_conflict_is_named_as_a_port_conflict() {
let t = TestApp::new(&Scenario {
stderr: "FATAL: port is already in use",
exit: Some(app_lib::backend::EXIT_PORT_IN_USE),
..Default::default()
});
let h = t.run_bootstrap();
join_with_timeout(h, Duration::from_secs(30), "port conflict");
let msg = t.failed_message().expect("stage must be Failed");
assert!(
msg.contains("is already in use, so the backend could not"),
"diagnosis must carry the detectHints-matchable port phrasing, got: {msg}"
);
let store = t.markers();
assert_eq!(store.markers.len(), 1, "one real death → one marker");
assert_eq!(store.markers.last().unwrap().exit_code, Some(app_lib::backend::EXIT_PORT_IN_USE));
}
/// S3 — generic startup traceback: the Failed message must carry the stderr
/// tail INCLUDING the chained-traceback root cause, and the marker must
/// record the death's shape.
#[test]
fn generic_traceback_surfaces_the_root_cause() {
let t = TestApp::new(&Scenario {
stderr: "Traceback (most recent call last):\\n File \"main.py\", line 1\\nImportError: libcublas.so.12: cannot open shared object file\\n\\nThe above exception was the direct cause of the following exception:\\n\\nTraceback (most recent call last):\\n File \"wrapper.py\", line 9\\nRuntimeError: failed to initialize CUDA backend",
exit: Some(1),
..Default::default()
});
let h = t.run_bootstrap();
join_with_timeout(h, Duration::from_secs(30), "generic traceback");
let msg = t.failed_message().expect("stage must be Failed");
assert!(msg.contains("Backend process exited"), "got: {msg}");
assert!(
msg.contains("libcublas.so.12"),
"the root-cause line must survive into the diagnosis, got: {msg}"
);
let store = t.markers();
assert_eq!(store.markers.len(), 1);
let m = store.markers.last().unwrap();
assert_eq!(m.exit_code, Some(1));
assert!(m.last_stderr.contains("Traceback"), "marker carries the evidence");
assert!(m.last_stderr.contains("libcublas.so.12"));
}
/// S4 — spawn failure (the program does not exist): the spawn diagnostic
/// must reach the user, and NO crash marker is written — nothing ever ran.
#[test]
fn spawn_failure_diagnoses_and_writes_no_bogus_marker() {
let t = TestApp::new(&Scenario::default());
// Point the seam at a program that cannot exist.
let missing = t._logdir.path().join("no-such-backend");
std::env::set_var(
"OMNIVOICE_BACKEND_CMD",
serde_json::to_string(&[missing.to_string_lossy().as_ref()]).unwrap(),
);
let h = t.run_bootstrap();
join_with_timeout(h, Duration::from_secs(30), "spawn failure");
let msg = t.failed_message().expect("stage must be Failed");
assert!(
msg.contains("Failed to launch the backend process"),
"spawn_failure_diagnostic must reach the user, got: {msg}"
);
assert_eq!(
t.markers().markers.len(),
0,
"never-started is not a crash — no marker may be written"
);
}
/// S5 — slow start past the budget: the timeout diagnosis must name the
/// budget and carry the last stderr, and no death marker exists (the
/// process is alive, just slow).
#[test]
fn slow_start_times_out_with_the_last_stderr() {
let t = TestApp::new(&Scenario {
stderr: "Loading checkpoint shards_ 10%",
serve_ms: None,
..Default::default()
});
// The child prints, then idles far past the 6s budget without serving.
let h = t.run_bootstrap();
join_with_timeout(h, Duration::from_secs(60), "slow start");
let msg = t.failed_message().expect("stage must be Failed");
assert!(msg.contains("did not respond within 6 s"), "got: {msg}");
assert!(
msg.contains("Loading checkpoint shards"),
"the last stderr must ride along so triage sees WHERE it was, got: {msg}"
);
assert_eq!(t.markers().markers.len(), 0, "no death → no marker");
}
/// S6 — post-Ready crash loop: markers are recorded BEFORE each restart,
/// restarts are announced, and budget exhaustion lands on a Failed message
/// naming the pattern and the last exit.
#[test]
fn crash_loop_exhausts_the_budget_with_a_named_diagnosis() {
let t = TestApp::new(&Scenario {
stderr: "RuntimeError: CUDA error: out of memory",
exit: Some(1),
serve_ms: Some(1500),
..Default::default()
});
let restarts = t.record_events("backend-restarting");
let gave_up = t.record_events("backend-restart-failed");
let h = t.run_bootstrap();
assert!(
wait_until(Duration::from_secs(20), || matches!(
t.stage_snapshot(),
BootstrapStage::Ready | BootstrapStage::StartingBackend | BootstrapStage::Failed { .. }
)),
"backend never reached Ready"
);
join_with_timeout(h, Duration::from_secs(120), "crash loop");
let msg = t.failed_message().expect("budget exhaustion must land on Failed");
assert!(msg.contains("kept crashing"), "got: {msg}");
assert!(msg.contains("exit code 1"), "the last death must be named, got: {msg}");
assert_eq!(restarts.lock().unwrap().len(), 3, "3 respawns before giving up");
assert_eq!(gave_up.lock().unwrap().len(), 1);
let store = t.markers();
assert!(
!store.markers.is_empty(),
"every real death records forensics BEFORE the restart decision"
);
assert!(
store.markers.iter().all(|m| m.exit_code == Some(1)),
"markers carry the actual exit"
);
assert!(
store.markers.last().unwrap().last_stderr.contains("out of memory"),
"the OOM evidence must be in the marker"
);
}
/// S7 (unix) — SIGKILL (the OS OOM killer's signature): the death must be
/// named as signal 9, not exit-code noise.
#[cfg(unix)]
#[test]
fn sigkill_is_named_as_signal_nine() {
let t = TestApp::new(&Scenario {
signal9: true,
serve_ms: Some(1500),
..Default::default()
});
let h = t.run_bootstrap();
join_with_timeout(h, Duration::from_secs(120), "sigkill loop");
let msg = t.failed_message().expect("stage must be Failed");
assert!(msg.contains("signal 9"), "signal deaths must be named, got: {msg}");
let store = t.markers();
let m = store.markers.last().expect("marker written");
assert_eq!(m.exit_code, None);
assert_eq!(m.signal, Some(9));
}
/// S8 — a deliberate kill (Retry/Clean&Retry owns the respawn): the
/// supervisor must yield silently — no crash marker, no restart, the stage
/// never Failed.
#[test]
fn deliberate_kill_yields_without_a_crash_marker() {
let t = TestApp::new(&Scenario {
serve_ms: Some(0), // serve forever
..Default::default()
});
let restarts = t.record_events("backend-restarting");
let h = t.run_bootstrap();
assert!(
wait_until(Duration::from_secs(20), || matches!(
t.stage_snapshot(),
BootstrapStage::Ready
)),
"backend never reached Ready"
);
let before = t.markers().markers.len();
set_backend_kill_intended(true);
t.kill_tracked_child();
join_with_timeout(h, Duration::from_secs(30), "deliberate kill");
assert_eq!(t.markers().markers.len(), before, "no marker for an intentional kill");
assert_eq!(restarts.lock().unwrap().len(), 0, "no respawn — the retry flow owns it");
assert!(
matches!(t.stage_snapshot(), BootstrapStage::Ready),
"the stage must never flip to Failed for a deliberate replace"
);
}
/// S9 — early-bind narration + a deferred-startup FATAL: the splash log
/// narrates the step the backend reported, and when it dies the named step
/// reaches both the user-facing diagnosis and the crash forensics.
#[test]
fn deferred_startup_failure_names_the_step() {
let t = TestApp::new(&Scenario {
stderr: "Traceback (most recent call last):\\n File \"main.py\"\\nImportError: torch\\nFATAL: backend startup failed during 'ml_imports': ImportError: torch",
exit: Some(1),
serve_ms: Some(1500),
progress_only: true, // /startup/progress answers; health probes do not
..Default::default()
});
let h = t.run_bootstrap();
join_with_timeout(h, Duration::from_secs(60), "deferred FATAL");
let msg = t.failed_message().expect("stage must be Failed");
assert!(
msg.contains("FATAL: backend startup failed during 'ml_imports'"),
"the named step must reach the user, got: {msg}"
);
let store = t.markers();
assert!(store
.markers
.last()
.expect("marker written")
.last_stderr
.contains("failed during 'ml_imports'"));
let logs = t.logs.lock().unwrap_or_else(|e| e.into_inner());
assert!(
logs.iter().any(|l| l.line.contains("Startup: Loading ML runtime")),
"the launch poll must narrate the step the backend reported; logs: {:?}",
logs.iter().map(|l| &l.line).collect::<Vec<_>>()
);
}
@@ -1,23 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<!-- Manifest embedded into TEST binaries on Windows (build.rs,
rustc-link-arg-tests). tauri-build embeds the app's manifest into the
application binary, but cargo test binaries get none — so the loader
resolves comctl32 v5, which lacks the TaskDialogIndirect entry point
tauri's dialog/tray stack imports, and every integration-test binary
dies at load with STATUS_ENTRYPOINT_NOT_FOUND (0xc0000139) before a
single test runs. Declaring the Common-Controls v6 dependency here is
the documented remedy. -->
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
<dependency>
<dependentAssembly>
<assemblyIdentity
type="win32"
name="Microsoft.Windows.Common-Controls"
version="6.0.0.0"
processorArchitecture="*"
publicKeyToken="6595b64144ccf1df"
language="*"
/>
</dependentAssembly>
</dependency>
</assembly>
+54 -38
View File
@@ -9,7 +9,6 @@ import React, {
} from 'react';
import './index.css';
import { useAppStore, FONT_STACKS } from './store';
import { NAV_ITEMS } from './components/navItems';
import SearchableSelect from './components/SearchableSelect';
import DirectionDialog from './components/DirectionDialog';
@@ -109,7 +108,6 @@ import { clearDubHistory as apiClearDubHistory } from './api/dub';
import { isTauri, doubleClickMaximize, fileToMediaUrl, playBlobAudio } from './utils/media';
import { browserDownload } from './utils/download';
import { downloadMedia } from './utils/mediaDownload';
import { installDesktopInteractionGuards } from './utils/desktopInteractions';
import { checkForUpdate, fetchAppVersion } from './utils/updater';
import { syncChannel } from './utils/channelControl';
import i18n from './i18n';
@@ -398,7 +396,6 @@ function App() {
handleLockProfile,
handleUnlockProfile,
} = useProfiles({ loadHistory, loadProfiles });
const clearSelectedProfile = useCallback(() => setSelectedProfile(null), [setSelectedProfile]);
const {
refAudio,
@@ -412,8 +409,6 @@ function App() {
insertTag,
applyPreset,
handleGenerate,
cancelGeneration,
cancelAllPendingJobs,
} = useTTS({ selectedProfile, setSelectedProfile, loadHistory, profiles });
const handleSaveProfile = () => _handleSaveProfile(refAudio, refText, instruct, language);
@@ -661,7 +656,7 @@ function App() {
let cancelled = false;
(async () => {
if (remoteBackend) {
const result = await probeRemoteBackend(remoteBackend.url);
const result = await probeRemoteBackend(remoteBackend.url, remoteBackend.key);
if (cancelled) return;
setRemoteFailure(result.ok ? null : result);
setSetupNeeded(false);
@@ -751,40 +746,63 @@ function App() {
// DESKTOP NATIVE INTEGRATION
useEffect(() => {
if (typeof window === 'undefined') return;
return installDesktopInteractionGuards({
onDrop: (file) => {
const isVideo = file.name.match(/\.(mp4|mov|mkv|webm|avi)$/i);
const isAudio = file.name.match(/\.(mp3|wav|flac|m4a|ogg)$/i);
if (isVideo || isAudio) {
setMode('dub');
setDubVideoFile(file);
fileToMediaUrl(file, null).then((urls) => setDubLocalBlobUrl(urls));
setDubFilename(file.name);
setDubStep('idle');
}
},
});
// 1. Prevent default right-click to hide web nature
const handleContextMenu = (e) => {
// allow on inputs/textareas for copy/paste
if (['INPUT', 'TEXTAREA'].includes(e.target.tagName)) return;
e.preventDefault();
};
// 2. Prevent keyboard quicks (reload, zoom, print)
const handleKeyDown = (e) => {
if (!e.metaKey && !e.ctrlKey) return;
if (['r', 'p', '=', '-', '+'].includes(e.key.toLowerCase())) {
e.preventDefault();
}
};
// 3. Prevent pinch-to-zoom
const handleWheel = (e) => {
if (e.ctrlKey) e.preventDefault();
};
// 4. Global Drag and drop for seamless native feeling
const handleDrop = (e) => {
e.preventDefault();
const file = e.dataTransfer?.files[0];
if (!file) return;
const isVideo = file.name.match(/\.(mp4|mov|mkv|webm|avi)$/i);
const isAudio = file.name.match(/\.(mp3|wav|flac|m4a|ogg)$/i);
if (isVideo || isAudio) {
setMode('dub');
setDubVideoFile(file);
fileToMediaUrl(file, null).then((urls) => setDubLocalBlobUrl(urls));
setDubFilename(file.name);
setDubStep('idle');
}
};
const handleDragOver = (e) => e.preventDefault();
window.addEventListener('contextmenu', handleContextMenu);
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('wheel', handleWheel, { passive: false });
window.addEventListener('drop', handleDrop);
window.addEventListener('dragover', handleDragOver);
return () => {
window.removeEventListener('contextmenu', handleContextMenu);
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('wheel', handleWheel);
window.removeEventListener('drop', handleDrop);
window.removeEventListener('dragover', handleDragOver);
};
}, []);
// KEYBOARD SHORTCUTS
useEffect(() => {
const handler = (e) => {
// In-webview navigation only: using DOM keydown keeps this identical in
// browser, macOS, Windows and Linux builds (unlike OS-level hotkeys).
if ((e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey) {
const key = e.key.toLowerCase();
if (key === 'e') {
e.preventDefault();
window.dispatchEvent(new Event('engine-quick-switch'));
return;
}
const index = Number(key);
if (index >= 1 && index <= NAV_ITEMS.length) {
e.preventDefault();
setMode(NAV_ITEMS[index - 1].id);
return;
}
}
// +Enter or Ctrl+Enter Generate
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault();
@@ -1478,7 +1496,7 @@ function App() {
) : mode === 'gallery' ? (
<ErrorBoundary name="gallery">
<Suspense fallback={<LazyFallback />}>
<VoiceGallery clearSelectedProfile={clearSelectedProfile} />
<VoiceGallery />
</Suspense>
</ErrorBoundary>
) : mode === 'transcriptions' ? (
@@ -1719,8 +1737,6 @@ function App() {
handleSaveProfile={handleSaveProfile}
handleSaveDesignProfile={handleSaveDesignProfile}
handleGenerate={handleGenerate}
cancelGeneration={cancelGeneration}
cancelAllPendingJobs={cancelAllPendingJobs}
startRecording={startRecording}
stopRecording={stopRecording}
ingestRefAudio={ingestRefAudio}
@@ -1,94 +0,0 @@
import { describe, expect, it } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const SRC = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const SOURCE_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx']);
function* productionFiles(directory) {
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const absolute = path.join(directory, entry.name);
if (entry.isDirectory()) {
if (entry.name === 'test') continue;
yield* productionFiles(absolute);
continue;
}
if (SOURCE_EXTENSIONS.has(path.extname(entry.name)) && !/\.test\.[jt]sx?$/.test(entry.name)) {
yield absolute;
}
}
}
const sources = () =>
[...productionFiles(SRC)].map((file) => ({
file: path.relative(SRC, file).replaceAll('\\', '/'),
source: fs.readFileSync(file, 'utf8'),
}));
// Any storage receiver counts: `sessionStorage.setItem('ov_api_key', …)` is the
// same credential-persistence class as localStorage, and production code passes
// injected stores under other names (sessionStore, localStore, legacyStorage,
// storage). Matching `.setItem(<master key>` — whatever the receiver, whatever
// the quote style, optional chaining included — closes the whole class instead
// of one spelling. getItem/removeItem (the migration/removal call sites) and
// setItem of other keys stay legal.
const PERSISTED_MASTER_RE =
/\.setItem(?:\?\.)?\(\s*(?:LS_API_KEY\b|LEGACY_API_KEY_STORAGE_KEY\b|[`'"]ov_api_key[`'"])/;
describe('administrator credential hygiene static guard', () => {
it('has no production path that writes the legacy master key to any Web Storage', () => {
const violations = sources()
.filter(({ source }) => PERSISTED_MASTER_RE.test(source))
.map(({ file }) => file);
expect(violations, 'OMNIVOICE_API_KEY must never enter localStorage or sessionStorage').toEqual(
[],
);
});
it('catches realistic storage receivers, aliases, and quote styles', () => {
const caught = [
"localStorage.setItem('ov_api_key', key)",
"localStorage.setItem?.('ov_api_key', key)",
'sessionStorage.setItem("ov_api_key", key)',
'window.localStorage.setItem(`ov_api_key`, key)',
'sessionStore?.setItem(LS_API_KEY, key)',
'localStore.setItem( LEGACY_API_KEY_STORAGE_KEY, key)',
'legacyStorage?.setItem(LS_API_KEY, master)',
];
const allowed = [
"localStorage.removeItem('ov_api_key')",
'localStore?.getItem(LS_API_KEY)',
'storage.setItem(ADMIN_SESSION_STORAGE_KEY, JSON.stringify(record))',
"sessionStore?.setItem('ov_pin', pin)",
'localStorage.setItem(LS_BACKEND_URL, normalized)',
];
for (const line of caught) expect(PERSISTED_MASTER_RE.test(line), line).toBe(true);
for (const line of allowed) expect(PERSISTED_MASTER_RE.test(line), line).toBe(false);
});
it('has no production WebSocket query builder for a master API key', () => {
const forbidden = [
/searchParams\.set\(\s*['"]api_key['"]/,
/[?&]api_key=\$\{/,
/[?&]api_key=['"]\s*\+/,
];
const violations = sources()
.filter(({ source }) => forbidden.some((pattern) => pattern.test(source)))
.map(({ file }) => file);
expect(violations, 'WebSocket URLs may contain ws_ticket, never a master key').toEqual([]);
});
it('keeps both WebSocket consumers behind the authenticated URL boundary', () => {
const constructors = sources()
.filter(({ source }) => source.includes('new WebSocket('))
.map(({ file, source }) => ({ file, authenticated: source.includes('authenticatedWsUrl') }));
expect(constructors).toEqual([
{ file: 'components/CaptureWidget.jsx', authenticated: true },
{ file: 'hooks/useRealtimeEvents.js', authenticated: true },
]);
});
});
-548
View File
@@ -1,548 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
ADMIN_SESSION_STORAGE_KEY,
AuthSessionError,
LEGACY_API_KEY_STORAGE_KEY,
authenticatedWsUrl,
clearAdminSession,
exchangeApiKey,
getAdminSession,
isSameOriginApi,
requestWebSocketTicket,
revokeAdminSession,
} from './authSession';
const SESSION = `ovs_admin_session_${'A'.repeat(43)}`;
const TICKET = `ovs_ws_ticket_${'B'.repeat(43)}`;
const MASTER = 'master-must-never-persist';
const NOW_SECONDS = 1_800_000_000;
const response = (body: unknown, status = 201) =>
new Response(body === null ? null : JSON.stringify(body), {
status,
headers: body === null ? undefined : { 'content-type': 'application/json' },
});
const sameOriginWindow = {
location: { origin: 'https://voice.test' },
dispatchEvent: vi.fn(),
};
const crossOriginWindow = {
location: { origin: 'tauri://localhost' },
__TAURI_INTERNALS__: {},
dispatchEvent: vi.fn(),
};
describe('short-lived admin session client', () => {
beforeEach(() => {
localStorage.clear();
sessionStorage.clear();
vi.clearAllMocks();
});
afterEach(() => {
vi.useRealTimers();
localStorage.clear();
sessionStorage.clear();
});
it('selects cookie transport only for an exact same-origin HTTP API', () => {
expect(isSameOriginApi('https://voice.test', sameOriginWindow)).toBe(true);
expect(isSameOriginApi('https://voice.test:444', sameOriginWindow)).toBe(false);
expect(isSameOriginApi('http://voice.test', sameOriginWindow)).toBe(false);
expect(isSameOriginApi('https://voice.test.evil.test', sameOriginWindow)).toBe(false);
expect(isSameOriginApi('http://127.0.0.1:3900', crossOriginWindow)).toBe(false);
});
it('exchanges a same-origin master for an HttpOnly cookie without persisting any token', async () => {
localStorage.setItem(LEGACY_API_KEY_STORAGE_KEY, MASTER);
const fetchImpl = vi.fn().mockResolvedValue(response(null, 204));
await expect(
exchangeApiKey(MASTER, {
apiBase: 'https://voice.test/',
fetchImpl,
windowLike: sameOriginWindow,
now: () => NOW_SECONDS * 1000,
}),
).resolves.toEqual({ transport: 'cookie' });
expect(fetchImpl).toHaveBeenCalledTimes(1);
expect(fetchImpl).toHaveBeenCalledWith(
'https://voice.test/api/auth/session',
expect.objectContaining({
method: 'POST',
credentials: 'include',
cache: 'no-store',
referrerPolicy: 'no-referrer',
headers: expect.objectContaining({ Authorization: `Bearer ${MASTER}` }),
body: JSON.stringify({ transport: 'cookie' }),
}),
);
expect(localStorage.getItem(LEGACY_API_KEY_STORAGE_KEY)).toBeNull();
expect(sessionStorage.length).toBe(0);
});
it('stores only a backend-bound short-lived bearer session for cross-origin clients', async () => {
localStorage.setItem(LEGACY_API_KEY_STORAGE_KEY, MASTER);
const fetchImpl = vi
.fn()
.mockResolvedValue(response({ token: SESSION, expires_at: NOW_SECONDS + 3600 }));
await expect(
exchangeApiKey(MASTER, {
apiBase: 'https://gpu.test:3900/',
fetchImpl,
windowLike: crossOriginWindow,
now: () => NOW_SECONDS * 1000,
}),
).resolves.toEqual({ transport: 'bearer', expiresAt: NOW_SECONDS + 3600 });
const persisted = sessionStorage.getItem(ADMIN_SESSION_STORAGE_KEY) ?? '';
expect(persisted).toContain(SESSION);
expect(persisted).toContain('https://gpu.test:3900');
expect(persisted).not.toContain(MASTER);
expect(localStorage.getItem(LEGACY_API_KEY_STORAGE_KEY)).toBeNull();
expect(getAdminSession('https://gpu.test:3900', { now: () => NOW_SECONDS * 1000 })).toEqual({
token: SESSION,
expiresAt: NOW_SECONDS + 3600,
apiBase: 'https://gpu.test:3900',
});
});
it('uses relative lifetime when remote and browser clocks are not synchronized', async () => {
const fetchImpl = vi.fn().mockResolvedValue(
response({
token: SESSION,
expires_at: 1,
expires_in: 3600,
}),
);
await expect(
exchangeApiKey(MASTER, {
apiBase: 'https://gpu.test:3900',
fetchImpl,
windowLike: crossOriginWindow,
now: () => NOW_SECONDS * 1000,
}),
).resolves.toEqual({ transport: 'bearer', expiresAt: NOW_SECONDS + 3600 });
expect(getAdminSession('https://gpu.test:3900', { now: () => NOW_SECONDS * 1000 })).toEqual({
token: SESSION,
expiresAt: NOW_SECONDS + 3600,
apiBase: 'https://gpu.test:3900',
});
});
it('retains the legacy master while the exchange is pending and removes it on success', async () => {
localStorage.setItem(LEGACY_API_KEY_STORAGE_KEY, MASTER);
let resolveFetch: (value: Response) => void = () => {};
const fetchImpl = vi.fn(
() =>
new Promise<Response>((resolve) => {
resolveFetch = resolve;
}),
);
const pending = exchangeApiKey(MASTER, {
apiBase: 'https://gpu.test:3900',
fetchImpl,
windowLike: crossOriginWindow,
now: () => NOW_SECONDS * 1000,
});
// Not yet: only a session that actually exists may consume the stored key.
expect(localStorage.getItem(LEGACY_API_KEY_STORAGE_KEY)).toBe(MASTER);
resolveFetch(response({ token: SESSION, expires_at: NOW_SECONDS + 3600 }));
await pending;
expect(localStorage.getItem(LEGACY_API_KEY_STORAGE_KEY)).toBeNull();
});
it('never retries a failed exchange and exposes no master or response body in its error', async () => {
localStorage.setItem(LEGACY_API_KEY_STORAGE_KEY, MASTER);
const reflected = `invalid credential: ${MASTER}`;
const fetchImpl = vi.fn().mockResolvedValue(response({ detail: reflected }, 401));
const error = await exchangeApiKey(MASTER, {
apiBase: 'https://gpu.test:3900',
fetchImpl,
windowLike: crossOriginWindow,
now: () => NOW_SECONDS * 1000,
}).catch((value) => value);
expect(error).toBeInstanceOf(AuthSessionError);
expect(error.status).toBe(401);
expect(String(error)).not.toContain(MASTER);
expect(String(error)).not.toContain(reflected);
expect(fetchImpl).toHaveBeenCalledTimes(1);
expect(sessionStorage.length).toBe(0);
// A failed exchange leaves the durable key for the next launch's retry.
expect(localStorage.getItem(LEGACY_API_KEY_STORAGE_KEY)).toBe(MASTER);
});
it('bounds a hung exchange and retains the durable master for the next migration attempt', async () => {
vi.useFakeTimers();
localStorage.setItem(LEGACY_API_KEY_STORAGE_KEY, MASTER);
const fetchImpl = vi.fn(
(_url, init) =>
new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener('abort', () =>
reject(new DOMException('aborted', 'AbortError')),
);
}),
);
const pending = exchangeApiKey(MASTER, {
apiBase: 'https://gpu.test:3900',
fetchImpl: fetchImpl as typeof fetch,
windowLike: crossOriginWindow,
timeoutMs: 25,
});
const observed = pending.catch((error) => error);
await vi.advanceTimersByTimeAsync(25);
expect(await observed).toBeInstanceOf(AuthSessionError);
expect(fetchImpl).toHaveBeenCalledOnce();
// Unreachable/hung backend: the stored copy is the user's only copy.
expect(localStorage.getItem(LEGACY_API_KEY_STORAGE_KEY)).toBe(MASTER);
expect(sessionStorage.length).toBe(0);
vi.useRealTimers();
});
it.each([
[{ token: MASTER, expires_at: NOW_SECONDS + 3600 }, 'master-shaped token'],
[{ token: SESSION, expires_at: NOW_SECONDS - 1 }, 'expired session'],
[{ token: SESSION, expires_at: NOW_SECONDS + 40_000 }, 'implausible expiry'],
[{ token: SESSION }, 'missing expiry'],
[null, 'missing body'],
])('rejects and does not persist a malformed bearer response: %s (%s)', async (body, _label) => {
const fetchImpl = vi.fn().mockResolvedValue(response(body));
await expect(
exchangeApiKey(MASTER, {
apiBase: 'https://gpu.test:3900',
fetchImpl,
windowLike: crossOriginWindow,
now: () => NOW_SECONDS * 1000,
}),
).rejects.toBeInstanceOf(AuthSessionError);
expect(sessionStorage.length).toBe(0);
});
it.each([0, -1, Number.NaN, Number.POSITIVE_INFINITY, 40_000, '3600'])(
'rejects an invalid relative session lifetime: %s',
async (expiresIn) => {
const fetchImpl = vi.fn().mockResolvedValue(
response({
token: SESSION,
expires_at: NOW_SECONDS + 3600,
expires_in: expiresIn,
}),
);
await expect(
exchangeApiKey(MASTER, {
apiBase: 'https://gpu.test:3900',
fetchImpl,
windowLike: crossOriginWindow,
now: () => NOW_SECONDS * 1000,
}),
).rejects.toBeInstanceOf(AuthSessionError);
expect(sessionStorage.length).toBe(0);
},
);
it('rejects oversized bearer responses before parsing them', async () => {
const fetchImpl = vi.fn().mockResolvedValue(
new Response('x'.repeat(20_000), {
status: 201,
headers: { 'content-length': '20000' },
}),
);
await expect(
exchangeApiKey(MASTER, {
apiBase: 'https://gpu.test:3900',
fetchImpl,
windowLike: crossOriginWindow,
now: () => NOW_SECONDS * 1000,
}),
).rejects.toBeInstanceOf(AuthSessionError);
expect(sessionStorage.length).toBe(0);
});
it('drops malformed, expired, or wrong-backend session storage', () => {
sessionStorage.setItem(ADMIN_SESSION_STORAGE_KEY, '{bad json');
expect(getAdminSession('https://gpu.test', { now: () => NOW_SECONDS * 1000 })).toBeNull();
sessionStorage.setItem(
ADMIN_SESSION_STORAGE_KEY,
JSON.stringify({ token: SESSION, expiresAt: NOW_SECONDS - 1, apiBase: 'https://gpu.test' }),
);
expect(getAdminSession('https://gpu.test', { now: () => NOW_SECONDS * 1000 })).toBeNull();
sessionStorage.setItem(
ADMIN_SESSION_STORAGE_KEY,
JSON.stringify({
token: SESSION,
expiresAt: NOW_SECONDS + 10,
apiBase: 'https://other.test',
}),
);
expect(getAdminSession('https://gpu.test', { now: () => NOW_SECONDS * 1000 })).toBeNull();
sessionStorage.setItem(
ADMIN_SESSION_STORAGE_KEY,
JSON.stringify({
token: SESSION,
expiresAt: NOW_SECONDS + 40_000,
apiBase: 'https://gpu.test',
}),
);
expect(getAdminSession('https://gpu.test', { now: () => NOW_SECONDS * 1000 })).toBeNull();
expect(sessionStorage.getItem(ADMIN_SESSION_STORAGE_KEY)).toBeNull();
});
it('mints a path-bound WebSocket ticket with the session only in an HTTP header', async () => {
sessionStorage.setItem(
ADMIN_SESSION_STORAGE_KEY,
JSON.stringify({
token: SESSION,
expiresAt: NOW_SECONDS + 3600,
apiBase: 'https://gpu.test:3900',
}),
);
const fetchImpl = vi
.fn()
.mockResolvedValue(response({ ticket: TICKET, expires_at: NOW_SECONDS + 30 }));
await expect(
requestWebSocketTicket('/ws/transcribe?model=live', {
apiBase: 'https://gpu.test:3900',
fetchImpl,
now: () => NOW_SECONDS * 1000,
}),
).resolves.toBe(TICKET);
expect(fetchImpl).toHaveBeenCalledWith(
'https://gpu.test:3900/api/auth/ws-ticket',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({ Authorization: `Bearer ${SESSION}` }),
body: JSON.stringify({ path: '/ws/transcribe' }),
}),
);
expect(JSON.stringify(fetchImpl.mock.calls[0][0])).not.toContain(SESSION);
});
it('accepts a ticket lifetime independent of server wall-clock skew', async () => {
sessionStorage.setItem(
ADMIN_SESSION_STORAGE_KEY,
JSON.stringify({
token: SESSION,
expiresAt: NOW_SECONDS + 3600,
apiBase: 'https://gpu.test:3900',
}),
);
const fetchImpl = vi
.fn()
.mockResolvedValue(response({ ticket: TICKET, expires_at: 1, expires_in: 30 }));
await expect(
requestWebSocketTicket('/ws/events', {
apiBase: 'https://gpu.test:3900',
fetchImpl,
now: () => NOW_SECONDS * 1000,
}),
).resolves.toBe(TICKET);
});
it('places only the one-use ticket in a bearer-authenticated WebSocket URL', async () => {
sessionStorage.setItem(
ADMIN_SESSION_STORAGE_KEY,
JSON.stringify({
token: SESSION,
expiresAt: NOW_SECONDS + 3600,
apiBase: 'https://gpu.test:3900',
}),
);
const fetchImpl = vi
.fn()
.mockResolvedValue(response({ ticket: TICKET, expires_at: NOW_SECONDS + 30 }));
const url = await authenticatedWsUrl('/ws/transcribe?model=live&api_key=legacy', {
apiBase: 'https://gpu.test:3900',
fetchImpl,
now: () => NOW_SECONDS * 1000,
});
expect(url).toBe(`wss://gpu.test:3900/ws/transcribe?model=live&ws_ticket=${TICKET}`);
expect(url).not.toContain(SESSION);
expect(url).not.toContain(MASTER);
expect(url).not.toContain('api_key');
});
it('preserves a reverse-proxy base path while binding the ticket to the logical WS route', async () => {
sessionStorage.setItem(
ADMIN_SESSION_STORAGE_KEY,
JSON.stringify({
token: SESSION,
expiresAt: NOW_SECONDS + 3600,
apiBase: 'https://gpu.test/studio',
}),
);
const fetchImpl = vi
.fn()
.mockResolvedValue(response({ ticket: TICKET, expires_in: 30, expires_at: 1 }));
await expect(
authenticatedWsUrl('/ws/events?view=active', {
apiBase: 'https://gpu.test/studio',
fetchImpl,
now: () => NOW_SECONDS * 1000,
}),
).resolves.toBe(`wss://gpu.test/studio/ws/events?view=active&ws_ticket=${TICKET}`);
expect(fetchImpl).toHaveBeenCalledWith(
'https://gpu.test/studio/api/auth/ws-ticket',
expect.objectContaining({ body: JSON.stringify({ path: '/ws/events' }) }),
);
});
it.each(['/ws/events/../admin', '//evil.test/ws/events', 'https://gpu.test/ws/events'])(
'rejects a non-canonical WebSocket target: %s',
async (path) => {
await expect(
authenticatedWsUrl(path, { apiBase: 'https://gpu.test', fetchImpl: vi.fn() }),
).rejects.toBeInstanceOf(AuthSessionError);
},
);
it('requests a fresh ticket for every WebSocket connection attempt', async () => {
sessionStorage.setItem(
ADMIN_SESSION_STORAGE_KEY,
JSON.stringify({
token: SESSION,
expiresAt: NOW_SECONDS + 3600,
apiBase: 'https://gpu.test:3900',
}),
);
const secondTicket = `ovs_ws_ticket_${'C'.repeat(43)}`;
const fetchImpl = vi
.fn()
.mockResolvedValueOnce(response({ ticket: TICKET, expires_at: NOW_SECONDS + 30 }))
.mockResolvedValueOnce(response({ ticket: secondTicket, expires_at: NOW_SECONDS + 30 }));
const first = await authenticatedWsUrl('/ws/events', {
apiBase: 'https://gpu.test:3900',
fetchImpl,
now: () => NOW_SECONDS * 1000,
});
const second = await authenticatedWsUrl('/ws/events', {
apiBase: 'https://gpu.test:3900',
fetchImpl,
now: () => NOW_SECONDS * 1000,
});
expect(first).toContain(TICKET);
expect(second).toContain(secondTicket);
expect(fetchImpl).toHaveBeenCalledTimes(2);
});
it('uses a credential-free WebSocket URL when no bearer session exists', async () => {
const fetchImpl = vi.fn();
await expect(
authenticatedWsUrl('/ws/events?api_key=must-be-removed', {
apiBase: 'http://127.0.0.1:3900',
fetchImpl,
}),
).resolves.toBe('ws://127.0.0.1:3900/ws/events');
expect(fetchImpl).not.toHaveBeenCalled();
});
it('clears an invalid session and raises the auth gate when ticket issuance is rejected', async () => {
sessionStorage.setItem(
ADMIN_SESSION_STORAGE_KEY,
JSON.stringify({
token: SESSION,
expiresAt: NOW_SECONDS + 3600,
apiBase: 'https://gpu.test:3900',
}),
);
const windowLike = { ...crossOriginWindow, dispatchEvent: vi.fn() };
await expect(
requestWebSocketTicket('/ws/events', {
apiBase: 'https://gpu.test:3900',
fetchImpl: vi.fn().mockResolvedValue(response({ detail: 'expired' }, 401)),
windowLike,
now: () => NOW_SECONDS * 1000,
}),
).rejects.toBeInstanceOf(AuthSessionError);
expect(getAdminSession('https://gpu.test:3900', { now: () => NOW_SECONDS * 1000 })).toBeNull();
expect(windowLike.dispatchEvent).toHaveBeenCalledWith(
expect.objectContaining({ type: 'ov:auth-required' }),
);
});
it('clears session state idempotently without touching unrelated storage', () => {
sessionStorage.setItem(ADMIN_SESSION_STORAGE_KEY, 'value');
sessionStorage.setItem('unrelated', 'keep');
clearAdminSession();
clearAdminSession();
expect(sessionStorage.getItem(ADMIN_SESSION_STORAGE_KEY)).toBeNull();
expect(sessionStorage.getItem('unrelated')).toBe('keep');
});
it('revokes a bearer session while clearing local state before the request settles', async () => {
sessionStorage.setItem(
ADMIN_SESSION_STORAGE_KEY,
JSON.stringify({
token: SESSION,
expiresAt: NOW_SECONDS + 3600,
apiBase: 'https://gpu.test:3900',
}),
);
let resolveFetch: (response: Response) => void = () => {};
const fetchImpl = vi.fn(() => new Promise<Response>((resolve) => (resolveFetch = resolve)));
const pending = revokeAdminSession('https://gpu.test:3900', {
fetchImpl,
now: () => NOW_SECONDS * 1000,
});
expect(sessionStorage.getItem(ADMIN_SESSION_STORAGE_KEY)).toBeNull();
expect(fetchImpl).toHaveBeenCalledWith(
'https://gpu.test:3900/api/auth/session',
expect.objectContaining({
method: 'DELETE',
headers: expect.objectContaining({ Authorization: `Bearer ${SESSION}` }),
credentials: 'include',
}),
);
resolveFetch(response(null, 204));
await expect(pending).resolves.toBe(true);
});
it('revokes a same-origin cookie session with the CSRF marker', async () => {
const fetchImpl = vi.fn().mockResolvedValue(response(null, 204));
await expect(
revokeAdminSession('https://voice.test', {
fetchImpl,
windowLike: sameOriginWindow,
}),
).resolves.toBe(true);
expect(fetchImpl).toHaveBeenCalledWith(
'https://voice.test/api/auth/session',
expect.objectContaining({
headers: { 'X-VoiceStudio-CSRF': '1' },
credentials: 'include',
}),
);
});
});
-483
View File
@@ -1,483 +0,0 @@
/**
* Browser-side boundary for the remote administrator credential.
*
* The configured master key is accepted only as an input to `exchangeApiKey`.
* It is never written to storage and never placed in a WebSocket URL. Browser
* clients retain only a backend-bound, short-lived session in sessionStorage;
* same-origin clients use an HttpOnly cookie that JavaScript cannot read.
*/
export const LEGACY_API_KEY_STORAGE_KEY = 'ov_api_key';
export const ADMIN_SESSION_STORAGE_KEY = 'ov_admin_session';
export const CSRF_HEADER_NAME = 'X-VoiceStudio-CSRF';
const ADMIN_SESSION_RE = /^ovs_admin_session_[A-Za-z0-9_-]{43}$/;
const WS_TICKET_RE = /^ovs_ws_ticket_[A-Za-z0-9_-]{43}$/;
const MAX_AUTH_RESPONSE_BYTES = 16 * 1024;
const MAX_SESSION_LIFETIME_SECONDS = 9 * 60 * 60;
const MAX_TICKET_LIFETIME_SECONDS = 60;
type StorageLike = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>;
type AuthWindow = {
location?: { origin?: string };
dispatchEvent?: (event: Event) => boolean;
__TAURI__?: unknown;
__TAURI_INTERNALS__?: unknown;
};
type CommonOptions = {
apiBase: string;
fetchImpl?: typeof fetch;
storage?: StorageLike | null;
windowLike?: AuthWindow;
now?: () => number;
timeoutMs?: number;
};
export type StoredAdminSession = {
token: string;
expiresAt: number;
apiBase: string;
};
export class AuthSessionError extends Error {
status?: number;
constructor(status?: number) {
super('Remote administrator authentication failed.');
this.name = 'AuthSessionError';
this.status = status;
}
}
function defaultWindow(): AuthWindow | undefined {
return typeof window === 'undefined' ? undefined : window;
}
function defaultSessionStorage(): StorageLike | null {
try {
return typeof sessionStorage === 'undefined' ? null : sessionStorage;
} catch {
return null;
}
}
function defaultLocalStorage(): StorageLike | null {
try {
return typeof localStorage === 'undefined' ? null : localStorage;
} catch {
return null;
}
}
function normalizedApiBase(raw: string): string {
const candidate = raw.trim();
let url: URL;
try {
url = new URL(candidate);
} catch {
throw new AuthSessionError();
}
if (
(url.protocol !== 'http:' && url.protocol !== 'https:') ||
url.username ||
url.password ||
url.search ||
url.hash
) {
throw new AuthSessionError();
}
return url.toString().replace(/\/+$/, '');
}
export function isSameOriginApi(
apiBase: string,
windowLike: AuthWindow | undefined = defaultWindow(),
): boolean {
try {
const apiOrigin = new URL(normalizedApiBase(apiBase)).origin;
const pageOrigin = windowLike?.location?.origin;
return Boolean(pageOrigin && pageOrigin !== 'null' && apiOrigin === pageOrigin);
} catch {
return false;
}
}
function removeLegacyMaster(storage: StorageLike | null = defaultLocalStorage()): void {
try {
storage?.removeItem(LEGACY_API_KEY_STORAGE_KEY);
} catch {
// A blocked storage API is already equivalent to the key not persisting.
}
}
export function clearAdminSession({
storage = defaultSessionStorage(),
}: { storage?: StorageLike | null } = {}): void {
try {
storage?.removeItem(ADMIN_SESSION_STORAGE_KEY);
} catch {
// Best effort; callers still stop using the in-memory value immediately.
}
}
export function getAdminSession(
apiBase: string,
{
storage = defaultSessionStorage(),
now = Date.now,
}: { storage?: StorageLike | null; now?: () => number } = {},
): StoredAdminSession | null {
let normalized: string;
try {
normalized = normalizedApiBase(apiBase);
} catch {
clearAdminSession({ storage });
return null;
}
let raw: string | null = null;
try {
raw = storage?.getItem(ADMIN_SESSION_STORAGE_KEY) ?? null;
} catch {
return null;
}
if (!raw || raw.length > 4096) {
if (raw) clearAdminSession({ storage });
return null;
}
try {
const parsed = JSON.parse(raw) as Partial<StoredAdminSession>;
const nowSeconds = now() / 1000;
if (
!ADMIN_SESSION_RE.test(String(parsed.token ?? '')) ||
typeof parsed.expiresAt !== 'number' ||
!Number.isFinite(parsed.expiresAt) ||
parsed.expiresAt <= nowSeconds ||
parsed.expiresAt > nowSeconds + MAX_SESSION_LIFETIME_SECONDS ||
parsed.apiBase !== normalized
) {
clearAdminSession({ storage });
return null;
}
return {
token: parsed.token as string,
expiresAt: parsed.expiresAt,
apiBase: normalized,
};
} catch {
clearAdminSession({ storage });
return null;
}
}
async function readBoundedText(response: Response): Promise<string> {
const advertisedBytes = Number(response.headers?.get?.('content-length'));
if (Number.isFinite(advertisedBytes) && advertisedBytes > MAX_AUTH_RESPONSE_BYTES) {
throw new AuthSessionError(response.status);
}
const reader = response.body?.getReader();
if (!reader) return '';
const decoder = new TextDecoder();
const parts: string[] = [];
let bytes = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
bytes += value.byteLength;
if (bytes > MAX_AUTH_RESPONSE_BYTES) {
await reader.cancel();
throw new AuthSessionError(response.status);
}
parts.push(decoder.decode(value, { stream: true }));
}
parts.push(decoder.decode());
return parts.join('');
} finally {
reader.releaseLock();
}
}
async function readBoundedObject(response: Response): Promise<Record<string, unknown>> {
const text = await readBoundedText(response);
try {
const value = JSON.parse(text);
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError();
return value as Record<string, unknown>;
} catch (error) {
if (error instanceof AuthSessionError) throw error;
throw new AuthSessionError(response.status);
}
}
function plausibleExpiry(
value: unknown,
nowMs: number,
maxLifetimeSeconds: number,
): value is number {
if (typeof value !== 'number' || !Number.isFinite(value)) return false;
const nowSeconds = nowMs / 1000;
return value > nowSeconds && value <= nowSeconds + maxLifetimeSeconds;
}
function responseExpiry(
payload: Record<string, unknown>,
nowMs: number,
maxLifetimeSeconds: number,
): number | null {
const relative = payload.expires_in;
if (relative !== undefined) {
if (
typeof relative !== 'number' ||
!Number.isFinite(relative) ||
relative <= 0 ||
relative > maxLifetimeSeconds
) {
return null;
}
return nowMs / 1000 + relative;
}
return plausibleExpiry(payload.expires_at, nowMs, maxLifetimeSeconds) ? payload.expires_at : null;
}
function dispatchAuthRequired(windowLike: AuthWindow | undefined): void {
try {
windowLike?.dispatchEvent?.(
new CustomEvent('ov:auth-required', { detail: { mode: 'apikey' } }),
);
} catch {
// Non-browser callers can still handle the typed error.
}
}
export async function exchangeApiKey(
apiKey: string,
{
apiBase,
fetchImpl = fetch,
storage = defaultSessionStorage(),
windowLike = defaultWindow(),
now = Date.now,
legacyStorage = defaultLocalStorage(),
timeoutMs = 10_000,
}: CommonOptions & { legacyStorage?: StorageLike | null },
): Promise<{ transport: 'cookie' } | { transport: 'bearer'; expiresAt: number }> {
// A stale session must not outlive a new exchange attempt, but the
// historical durable master is deleted only after the backend ACCEPTS the
// exchange. Deleting it up front stranded remote-backend users whose box was
// unreachable at first launch after upgrade: the failed exchange consumed
// their only stored copy of OMNIVOICE_API_KEY. Keeping it on failure lets
// the next launch retry the migration; every success path below removes it,
// so the key never coexists with a live session.
clearAdminSession({ storage });
const master = apiKey.trim();
if (!master || master.length > 8192) throw new AuthSessionError();
const base = normalizedApiBase(apiBase);
const transport = isSameOriginApi(base, windowLike) ? 'cookie' : 'bearer';
let response: Response;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), Math.max(1, Math.min(timeoutMs, 60_000)));
try {
response = await fetchImpl(`${base}/api/auth/session`, {
method: 'POST',
headers: {
Authorization: `Bearer ${master}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ transport }),
credentials: 'include',
cache: 'no-store',
redirect: 'error',
referrerPolicy: 'no-referrer',
signal: controller.signal,
});
} catch {
throw new AuthSessionError();
} finally {
clearTimeout(timer);
}
if (transport === 'cookie') {
if (response.status !== 204) throw new AuthSessionError(response.status);
removeLegacyMaster(legacyStorage);
return { transport };
}
if (response.status !== 201) throw new AuthSessionError(response.status);
const payload = await readBoundedObject(response);
const token = payload.token;
const expiresAt = responseExpiry(payload, now(), MAX_SESSION_LIFETIME_SECONDS);
if (typeof token !== 'string' || !ADMIN_SESSION_RE.test(token) || expiresAt === null) {
throw new AuthSessionError(response.status);
}
const record: StoredAdminSession = { token, expiresAt, apiBase: base };
try {
if (!storage) throw new TypeError();
storage.setItem(ADMIN_SESSION_STORAGE_KEY, JSON.stringify(record));
} catch {
clearAdminSession({ storage });
throw new AuthSessionError();
}
removeLegacyMaster(legacyStorage);
return { transport, expiresAt };
}
/** Best-effort server revocation used when switching away from a backend.
* Local state is cleared before the network await, so a hung or unreachable
* backend cannot prolong the browser's ability to use the session. */
export async function revokeAdminSession(
apiBase: string,
{
fetchImpl = fetch,
storage = defaultSessionStorage(),
windowLike = defaultWindow(),
now = Date.now,
timeoutMs = 1500,
}: Omit<CommonOptions, 'apiBase'> = {},
): Promise<boolean> {
let base: string;
try {
base = normalizedApiBase(apiBase);
} catch {
clearAdminSession({ storage });
return false;
}
const session = getAdminSession(base, { storage, now });
const sameOrigin = isSameOriginApi(base, windowLike);
clearAdminSession({ storage });
// Cross-origin cookie auth cannot work (the cookie is SameSite=Strict), and
// without a bearer token there is nothing meaningful to revoke remotely.
if (!session && !sameOrigin) return true;
const headers: Record<string, string> = {};
if (session) headers.Authorization = `Bearer ${session.token}`;
if (sameOrigin) headers[CSRF_HEADER_NAME] = '1';
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), Math.max(1, Math.min(timeoutMs, 10_000)));
try {
const response = await fetchImpl(`${base}/api/auth/session`, {
method: 'DELETE',
headers,
credentials: 'include',
cache: 'no-store',
redirect: 'error',
referrerPolicy: 'no-referrer',
signal: controller.signal,
});
return response.status === 204;
} catch {
return false;
} finally {
clearTimeout(timer);
}
}
const ALLOWED_WS_PATHS = new Set(['/ws/events', '/ws/transcribe']);
const LOGICAL_WS_ORIGIN = 'http://omnivoice.invalid';
function websocketTarget(path: string, apiBase: string): { url: URL; logicalPath: string } {
const base = normalizedApiBase(apiBase);
const baseUrl = new URL(base);
let logical: URL;
try {
if (!path.startsWith('/') || path.startsWith('//')) throw new TypeError();
logical = new URL(path, `${LOGICAL_WS_ORIGIN}/`);
} catch {
throw new AuthSessionError();
}
if (logical.origin !== LOGICAL_WS_ORIGIN || !ALLOWED_WS_PATHS.has(logical.pathname)) {
throw new AuthSessionError();
}
// Resolve relative to `${base}/`, not the origin root. Reverse proxies may
// publish the backend under a path prefix (for example `/studio`). The
// server still receives the logical route after the proxy strips its prefix,
// so ticket binding uses `logical.pathname` below.
const url = new URL(path.slice(1), `${base}/`);
if (url.origin !== baseUrl.origin) throw new AuthSessionError();
url.username = '';
url.password = '';
url.hash = '';
url.searchParams.delete('api_key');
url.searchParams.delete('ws_ticket');
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
return { url, logicalPath: logical.pathname };
}
export async function requestWebSocketTicket(
path: string,
{
apiBase,
fetchImpl = fetch,
storage = defaultSessionStorage(),
windowLike = defaultWindow(),
now = Date.now,
timeoutMs = 5000,
}: CommonOptions,
): Promise<string> {
const base = normalizedApiBase(apiBase);
const { logicalPath } = websocketTarget(path, base);
const session = getAdminSession(base, { storage, now });
if (!session) throw new AuthSessionError(401);
let response: Response;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), Math.max(1, Math.min(timeoutMs, 30_000)));
try {
response = await fetchImpl(`${base}/api/auth/ws-ticket`, {
method: 'POST',
headers: {
Authorization: `Bearer ${session.token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ path: logicalPath }),
credentials: 'include',
cache: 'no-store',
redirect: 'error',
referrerPolicy: 'no-referrer',
signal: controller.signal,
});
} catch {
throw new AuthSessionError();
} finally {
clearTimeout(timer);
}
if (response.status !== 201) {
if (response.status === 401 || response.status === 403) {
clearAdminSession({ storage });
dispatchAuthRequired(windowLike);
}
throw new AuthSessionError(response.status);
}
const payload = await readBoundedObject(response);
const expiresAt = responseExpiry(payload, now(), MAX_TICKET_LIFETIME_SECONDS);
if (
typeof payload.ticket !== 'string' ||
!WS_TICKET_RE.test(payload.ticket) ||
expiresAt === null
) {
throw new AuthSessionError(response.status);
}
return payload.ticket;
}
export async function authenticatedWsUrl(path: string, options: CommonOptions): Promise<string> {
const { url } = websocketTarget(path, options.apiBase);
const session = getAdminSession(options.apiBase, {
storage: options.storage,
now: options.now,
});
if (!session) return url.toString();
const ticket = await requestWebSocketTicket(path, options);
url.searchParams.set('ws_ticket', ticket);
return url.toString();
}
-9
View File
@@ -33,15 +33,6 @@ describe('_resolveApiBase', () => {
expect(_resolveApiBase({ VITE_API_PORT: '4000' }, win)).toBe('http://127.0.0.1:4000');
});
it('does not send a development UI back to itself when VITE_API_PORT matches its port', () => {
const win = {
location: { origin: 'http://127.0.0.1:3000', hostname: '127.0.0.1', port: '3000' },
};
expect(_resolveApiBase({ DEV: true, VITE_API_PORT: '3000' }, win)).toBe(
'http://127.0.0.1:3900',
);
});
it('runtime window.__OMNIVOICE_API_BASE__ wins over everything (Docker prebuilt-image override)', () => {
const win = {
__TAURI__: {},
+3 -243
View File
@@ -1,12 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
API,
_bootstrapBrowserCredentials,
_isApiTarget,
_parseDeepLinkCredentials,
wsUrl,
} from './client';
import { ADMIN_SESSION_STORAGE_KEY, CSRF_HEADER_NAME } from './authSession';
import { _parseDeepLinkCredentials } from './client';
describe('apiFetch PIN header', () => {
let realFetch: typeof globalThis.fetch;
@@ -28,8 +21,7 @@ describe('apiFetch PIN header', () => {
}) as any;
const { apiFetch } = await import('./client');
await apiFetch('/system/info');
expect(new Headers(seen.headers).get('X-OmniVoice-Pin')).toBe('424242');
expect(seen.credentials).toBe('include');
expect((seen.headers || {})['X-OmniVoice-Pin']).toBe('424242');
});
it('omits the header when no pin', async () => {
@@ -40,27 +32,7 @@ describe('apiFetch PIN header', () => {
}) as any;
const { apiFetch } = await import('./client');
await apiFetch('/system/info');
expect(new Headers(seen.headers).get('X-OmniVoice-Pin')).toBeNull();
});
it('keeps cookie and loopback requests usable when Web Storage is blocked', async () => {
const getItem = vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new DOMException('blocked', 'SecurityError');
});
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
globalThis.fetch = fetchMock as any;
try {
const { apiFetch } = await import('./client');
await expect(apiFetch('/system/info')).resolves.toMatchObject({ ok: true });
} finally {
getItem.mockRestore();
}
const headers = new Headers(fetchMock.mock.calls[0][1]?.headers);
expect(headers.get('X-OmniVoice-Pin')).toBeNull();
expect(headers.get('Authorization')).toBeNull();
expect(fetchMock.mock.calls[0][1]?.credentials).toBe('include');
expect((seen.headers || {})['X-OmniVoice-Pin']).toBeUndefined();
});
it('turns a thrown fetch into an actionable ApiError (backend unreachable)', async () => {
@@ -83,85 +55,6 @@ describe('apiFetch PIN header', () => {
});
});
describe('apiFetch short-lived admin authentication', () => {
let realFetch: typeof globalThis.fetch;
beforeEach(() => {
realFetch = globalThis.fetch;
sessionStorage.clear();
localStorage.clear();
});
afterEach(() => {
globalThis.fetch = realFetch;
sessionStorage.clear();
localStorage.clear();
});
it('attaches only the backend-bound short-lived session, never the persisted master', async () => {
const session = `ovs_admin_session_${'S'.repeat(43)}`;
localStorage.setItem('ov_api_key', 'legacy-master');
sessionStorage.setItem(
ADMIN_SESSION_STORAGE_KEY,
JSON.stringify({ token: session, expiresAt: Date.now() / 1000 + 3600, apiBase: API }),
);
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
globalThis.fetch = fetchMock as any;
const { apiFetch } = await import('./client');
await apiFetch('/system/info');
const headers = new Headers(fetchMock.mock.calls[0][1]?.headers);
expect(headers.get('Authorization')).toBe(`Bearer ${session}`);
expect(headers.get('Authorization')).not.toContain('legacy-master');
});
it('builds credential-free legacy WebSocket URLs even if old storage is populated', () => {
localStorage.setItem('ov_api_key', 'legacy-master');
const url = wsUrl('/ws/events?view=active');
expect(url).toContain('/ws/events?view=active');
expect(url).not.toContain('api_key');
expect(url).not.toContain('legacy-master');
});
it('never sends backend credentials to an absolute foreign URL', async () => {
const session = `ovs_admin_session_${'S'.repeat(43)}`;
sessionStorage.setItem('ov_pin', '424242');
sessionStorage.setItem(
ADMIN_SESSION_STORAGE_KEY,
JSON.stringify({ token: session, expiresAt: Date.now() / 1000 + 3600, apiBase: API }),
);
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
globalThis.fetch = fetchMock as any;
const { apiFetch } = await import('./client');
await apiFetch('https://voice.example.evil.test/public.wav', {
credentials: 'omit',
headers: { 'X-Public-Media': '1' },
});
const [target, init] = fetchMock.mock.calls[0];
const headers = new Headers(init?.headers);
expect(target).toBe('https://voice.example.evil.test/public.wav');
expect(headers.get('Authorization')).toBeNull();
expect(headers.get('X-OmniVoice-Pin')).toBeNull();
expect(headers.get(CSRF_HEADER_NAME)).toBeNull();
expect(headers.get('X-Public-Media')).toBe('1');
expect(init?.credentials).toBe('omit');
});
it('binds credentials to the exact configured API path prefix', () => {
expect(_isApiTarget('https://voice.test/studio/v1/audio', 'https://voice.test/studio')).toBe(
true,
);
expect(_isApiTarget('https://voice.test/studio-evil/v1', 'https://voice.test/studio')).toBe(
false,
);
expect(_isApiTarget('https://voice.test/other', 'https://voice.test/studio')).toBe(false);
expect(_isApiTarget('https://voice.test.evil/v1', 'https://voice.test')).toBe(false);
});
});
describe('apiFetch 401 routing', () => {
// The backend has two 401-returning middlewares distinguished only by their
// `detail` body: "API key required" (BearerKeyMiddleware) vs "PIN required"
@@ -196,14 +89,6 @@ describe('apiFetch 401 routing', () => {
dispatch.mock.calls.map((c) => c[0]).find((e) => (e as Event).type === 'ov:auth-required');
it('dispatches ov:auth-required {mode:"apikey"} on an "API key required" 401', async () => {
sessionStorage.setItem(
ADMIN_SESSION_STORAGE_KEY,
JSON.stringify({
token: `ovs_admin_session_${'S'.repeat(43)}`,
expiresAt: Date.now() / 1000 + 3600,
apiBase: API,
}),
);
globalThis.fetch = stub401('API key required');
const { apiFetch } = await import('./client');
try {
@@ -213,7 +98,6 @@ describe('apiFetch 401 routing', () => {
}
expect(authEvent()).toBeTruthy();
expect((authEvent() as any).detail.mode).toBe('apikey');
expect(sessionStorage.getItem(ADMIN_SESSION_STORAGE_KEY)).toBeNull();
});
it('dispatches ov:auth-required {mode:"pin"} on a "PIN required" 401', async () => {
@@ -347,127 +231,3 @@ describe('_parseDeepLinkCredentials', () => {
expect(r.cleanUrl).toBe('/path?page=2#top');
});
});
describe('_bootstrapBrowserCredentials', () => {
beforeEach(() => {
localStorage.clear();
sessionStorage.clear();
});
it('scrubs the fragment before exchanging exactly once, deleting legacy storage on success', async () => {
const order: string[] = [];
localStorage.setItem('ov_api_key', 'older-master');
const win = {
location: { href: 'https://voice.test/app?pin=1234#api_key=fragment-master&tab=voices' },
history: {
replaceState: (_data: unknown, _unused: string, url?: string | URL | null) => {
order.push(`scrub:${String(url)}`);
},
},
};
const exchange = vi.fn(async (master) => {
order.push('exchange');
expect(master).toBe('fragment-master');
// The durable key survives until the backend accepts the exchange — a
// failure at this point must leave it for the next launch to retry.
expect(localStorage.getItem('ov_api_key')).toBe('older-master');
});
await _bootstrapBrowserCredentials(win, {
apiBase: 'https://voice.test',
exchange: exchange as any,
});
expect(sessionStorage.getItem('ov_pin')).toBe('1234');
expect(order).toEqual(['scrub:/app#tab=voices', 'exchange']);
expect(exchange).toHaveBeenCalledOnce();
expect(localStorage.getItem('ov_api_key')).toBeNull();
});
it('retains the stored master when the backend is unreachable (no stranding)', async () => {
// The upgrade-day disaster this guards against: a remote-backend user's
// only copy of OMNIVOICE_API_KEY lives in localStorage, and the backend is
// down at first launch. The failed exchange must NOT consume the key.
localStorage.setItem('ov_api_key', 'legacy-master');
const exchange = vi.fn().mockRejectedValue(new TypeError('Failed to fetch'));
await expect(
_bootstrapBrowserCredentials(
{ location: { href: 'https://voice.test/' }, history: { replaceState: vi.fn() } },
{ apiBase: 'https://voice.test', exchange },
),
).rejects.toThrow();
expect(exchange).toHaveBeenCalledWith('legacy-master', { apiBase: 'https://voice.test' });
expect(localStorage.getItem('ov_api_key')).toBe('legacy-master');
});
it('re-runs the migration on the next launch and consumes the key once it succeeds', async () => {
localStorage.setItem('ov_api_key', 'legacy-master');
const exchange = vi
.fn()
.mockRejectedValueOnce(new TypeError('Failed to fetch'))
.mockResolvedValueOnce({ transport: 'bearer', expiresAt: Date.now() / 1000 + 60 });
const launch = () =>
_bootstrapBrowserCredentials(
{ location: { href: 'https://voice.test/' }, history: { replaceState: vi.fn() } },
{ apiBase: 'https://voice.test', exchange },
);
// Launch 1: backend unreachable — key survives.
await expect(launch()).rejects.toThrow();
expect(localStorage.getItem('ov_api_key')).toBe('legacy-master');
// Launch 2: backend back — the retained key is retried and then removed.
await launch();
expect(exchange).toHaveBeenNthCalledWith(2, 'legacy-master', {
apiBase: 'https://voice.test',
});
expect(localStorage.getItem('ov_api_key')).toBeNull();
});
it('consumes a legacy stored master without writing it anywhere else', async () => {
localStorage.setItem('ov_api_key', 'legacy-master');
const exchange = vi.fn().mockResolvedValue({ transport: 'bearer' });
await _bootstrapBrowserCredentials(
{
location: { href: 'https://voice.test/' },
history: { replaceState: vi.fn() },
},
{ apiBase: 'https://voice.test', exchange },
);
expect(exchange).toHaveBeenCalledWith('legacy-master', { apiBase: 'https://voice.test' });
expect(localStorage.getItem('ov_api_key')).toBeNull();
expect(sessionStorage.getItem('ov_api_key')).toBeNull();
});
it('still deletes and exchanges the master when PIN session storage is blocked', async () => {
localStorage.setItem('ov_api_key', 'legacy-master');
const replaceState = vi.fn();
const exchange = vi.fn().mockResolvedValue({ transport: 'bearer' });
await _bootstrapBrowserCredentials(
{
location: { href: 'https://voice.test/?pin=1234#api_key=fragment-master' },
history: { replaceState },
},
{
apiBase: 'https://voice.test',
sessionStore: {
setItem: vi.fn(() => {
throw new DOMException('blocked');
}),
},
exchange,
},
);
expect(replaceState).toHaveBeenCalledWith(null, '', '/');
expect(localStorage.getItem('ov_api_key')).toBeNull();
expect(exchange).toHaveBeenCalledWith('fragment-master', {
apiBase: 'https://voice.test',
});
});
});
+51 -158
View File
@@ -26,36 +26,17 @@ import {
recordBackendContact,
unreachableBackendMessage,
} from '../utils/backendContact.ts';
import {
CSRF_HEADER_NAME,
LEGACY_API_KEY_STORAGE_KEY,
clearAdminSession,
exchangeApiKey,
getAdminSession,
isSameOriginApi,
} from './authSession.ts';
const viteEnv = import.meta.env ?? {};
// Remote-backend settings (Wave 2.3): user-configured in Settings → Sharing.
// localStorage so the choice survives restarts; read once at module load —
// the Settings panel reloads the app on save.
export const LS_BACKEND_URL = 'ov_backend_url';
// Compatibility name used only to delete data written by older releases.
// New code must never persist the configured master credential.
export const LS_API_KEY = LEGACY_API_KEY_STORAGE_KEY;
export const LS_API_KEY = 'ov_api_key';
// Pure + exported for unit testing — takes env + window so tests don't need to
// re-import the module or stub import.meta.env.
export function _resolveApiBase(env: any, win: any): string {
const defaultPort = '3900';
// A port override is useful for a deliberately moved backend, but pointing
// it at Vite itself can only return the SPA's 404 page. This commonly
// happens when a developer moves the UI to :3000 and copies that value into
// both variables. Preserve explicit API URLs (which may name a real proxy),
// while making the port-only configuration recover to the local backend.
const requestedPort = String(env?.VITE_API_PORT || defaultPort);
const port = env?.DEV && requestedPort === String(win?.location?.port || '')
? defaultPort
: requestedPort;
const port = env?.VITE_API_PORT || '3900';
// Explicit override, in precedence order:
// 1. localStorage ov_backend_url — the user's explicit "Remote backend"
// setting (Wave 2.3). Beats everything: it's the one override a
@@ -83,25 +64,40 @@ export function _resolveApiBase(env: any, win: any): string {
}
export const API = _resolveApiBase(viteEnv, typeof window !== 'undefined' ? window : undefined);
function sessionPin(): string | null {
function _apiKey(): string | null {
try {
return typeof sessionStorage === 'undefined' ? null : sessionStorage.getItem('ov_pin');
return typeof localStorage !== 'undefined' ? localStorage.getItem(LS_API_KEY) : null;
} catch {
// Cookie-authenticated and loopback requests must still work when a
// privacy policy blocks Web Storage.
return null;
}
}
/** Persist the durable remote API key (trimmed). localStorage so it survives
* reloads; read back by `_apiKey()` on every request. Returns false (without
* writing) when the value is empty-after-trim or storage is unavailable, so the
* caller can avoid reloading into a loop. */
export function saveApiKey(v: string): boolean {
const t = v.trim();
if (!t) return false;
try {
localStorage.setItem(LS_API_KEY, t);
return true;
} catch {
return false;
}
}
/** Build a ws:// or wss:// URL for a backend WebSocket endpoint.
*
* Scheme derives from the API base itself (NOT window.location a Tauri
* webview pointing at an https remote must still get wss). Credentials are
* intentionally excluded; authenticated callers obtain a one-use ticket via
* `authenticatedWsUrl` in authSession.ts. */
* webview pointing at an https remote must still get wss), and the remote
* API key rides as ?api_key= because browser WebSockets can't set headers. */
export function wsUrl(path: string): string {
const base = API.replace(/^http/, 'ws').replace(/\/+$/, '');
return `${base}${path.startsWith('/') ? '' : '/'}${path}`;
const url = `${base}${path.startsWith('/') ? '' : '/'}${path}`;
const key = _apiKey();
if (!key) return url;
return `${url}${url.includes('?') ? '&' : '?'}api_key=${encodeURIComponent(key)}`;
}
/**
@@ -110,9 +106,9 @@ export function wsUrl(path: string): string {
* the effects.
* ?pin=<pin> (query) LAN-share QR. Returned as `pin` (session).
* #api_key=<key> (fragment) remote-backend deep link. Returned as `apiKey`
* for one immediate exchange. Read from the FRAGMENT because fragments
* aren't sent to the server, so the root secret stays out of request logs;
* the PIN stays in the query since the QR flow needs the server to see it.
* (durable). Read from the FRAGMENT because fragments aren't sent to the
* server, so the durable secret stays out of request logs; the PIN stays in
* the query since the QR flow needs the server to see it.
* A stray legacy ?api_key= in the query is scrubbed from `cleanUrl` but NOT
* returned reading it would resend the secret to the server on reload, the
* very leak the fragment avoids. `scrubbed` is true when any credential param
@@ -147,95 +143,15 @@ export function _parseDeepLinkCredentials(href: string): {
};
}
type BootstrapWindow = {
location: { href: string };
history: { replaceState: (data: unknown, unused: string, url?: string | URL | null) => void };
};
/** One-shot migration seam kept injectable so ordering is regression-tested:
* scrub the URL synchronously, read (never re-write) the durable master, then
* perform the only request that may carry it. The durable copy is deleted only
* after that exchange SUCCEEDS: deleting it first stranded remote-backend
* users whose backend was unreachable at first launch after upgrade the
* failed exchange destroyed their only copy of the admin key. On failure the
* key stays put so the next launch retries this migration. */
export async function _bootstrapBrowserCredentials(
win: BootstrapWindow,
{
apiBase = API,
sessionStore,
localStore,
exchange = exchangeApiKey,
}: {
apiBase?: string;
sessionStore?: Pick<Storage, 'setItem'> | null;
localStore?: Pick<Storage, 'getItem' | 'removeItem'> | null;
exchange?: typeof exchangeApiKey;
} = {},
): Promise<void> {
if (sessionStore === undefined) {
try {
sessionStore = sessionStorage;
} catch {
sessionStore = null;
}
}
if (localStore === undefined) {
try {
localStore = localStorage;
} catch {
localStore = null;
}
}
const { pin, apiKey, cleanUrl, scrubbed } = _parseDeepLinkCredentials(win.location.href);
if (scrubbed) {
try {
win.history.replaceState(null, '', cleanUrl);
} catch {
/* keep deleting retained credentials even if history is unavailable */
}
}
let master = apiKey;
try {
const legacy = localStore?.getItem(LS_API_KEY) ?? null;
if (!master) master = legacy;
} catch {
/* a fragment exchange can still proceed */
}
if (pin) {
try {
sessionStore?.setItem('ov_pin', pin);
} catch {
/* blocked PIN storage must not prevent the exchange */
}
}
if (master) {
// A rejected/unreachable exchange throws past this point, leaving the
// durable key in place for the next launch's retry (the module-load catch
// below still raises the auth gate). Only a session that actually exists
// may consume the stored master.
await exchange(master, { apiBase });
try {
localStore?.removeItem(LS_API_KEY);
} catch {
/* storage unavailable; exchangeApiKey performed the same best-effort deletion */
}
}
}
// On load, capture deep-link credentials, scrub the address bar synchronously,
// and exchange a master key exactly once. Historical durable master storage is
// read before the first await and deleted only once the exchange succeeds, so
// an unreachable backend leaves it for the next launch to retry. apiFetch
// waits for this one-shot migration so no request races ahead with an
// unauthenticated first call.
let authBootstrapPromise: Promise<void> = Promise.resolve();
// On load, capture deep-link credentials (?pin= from the QR query, #api_key=
// from a remote-backend fragment) so apiFetch attaches them automatically, then
// scrub them from the address bar (one-shot — see _parseDeepLinkCredentials).
if (typeof window !== 'undefined') {
try {
authBootstrapPromise = _bootstrapBrowserCredentials(window).catch(() => {
window.dispatchEvent(new CustomEvent('ov:auth-required', { detail: { mode: 'apikey' } }));
});
const { pin, apiKey, cleanUrl, scrubbed } = _parseDeepLinkCredentials(window.location.href);
if (pin) sessionStorage.setItem('ov_pin', pin);
if (apiKey) saveApiKey(apiKey);
if (scrubbed) window.history.replaceState(null, '', cleanUrl);
} catch {
/* noop */
}
@@ -257,21 +173,6 @@ export function apiUrl(path?: string): string {
return path.startsWith('http') ? path : `${API}${path.startsWith('/') ? '' : '/'}${path}`;
}
/** Whether an already-resolved request URL stays inside the configured API
* origin and path prefix. Absolute URLs remain supported for public media, but
* they must never inherit backend credentials by accident. */
export function _isApiTarget(target: string, apiBase: string = API): boolean {
try {
const base = new URL(apiBase.replace(/\/+$/, '') + '/');
const url = new URL(target);
if (url.origin !== base.origin) return false;
const prefix = base.pathname.replace(/\/+$/, '');
return !prefix || url.pathname === prefix || url.pathname.startsWith(`${prefix}/`);
} catch {
return false;
}
}
// Stamped on EVERY response by the backend's BackendMarkerMiddleware and
// exposed cross-origin, so its presence is AUTHORITATIVE: this really is an
// VoiceStudio backend answering, whatever the body looks like (#1385).
@@ -342,25 +243,18 @@ const RECONCILE_INTERVAL_MS = 1000;
export type ApiFetchOptions = RequestInit & { retryTransport?: boolean };
export async function apiFetch(path: string, opts: ApiFetchOptions = {}): Promise<Response> {
await authBootstrapPromise;
const requestUrl = apiUrl(path);
const backendTarget = _isApiTarget(requestUrl);
const pin = backendTarget ? sessionPin() : null;
const session = backendTarget ? getAdminSession(API) : null;
const pin = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('ov_pin') : null;
const key = _apiKey();
// Only modify the request when a PIN/API key is set, so the default call
// shape (e.g. FormData posts with no headers / no Content-Type override)
// is preserved exactly.
const extra: Record<string, string> = {};
if (pin) extra['X-OmniVoice-Pin'] = pin;
if (key) extra['Authorization'] = `Bearer ${key}`;
const { retryTransport = true, ...requestOpts } = opts;
const headers = new Headers(requestOpts.headers);
if (pin) headers.set('X-OmniVoice-Pin', pin);
if (session) headers.set('Authorization', `Bearer ${session.token}`);
// Same-origin browser clients authenticate through an HttpOnly cookie. The
// marker makes ambient-cookie mutations fail closed under the backend's
// exact-Origin CSRF policy; browser-managed Sec-Fetch-Site supplies the
// additional guard for side-effectful GET routes.
if (backendTarget && isSameOriginApi(API)) headers.set(CSRF_HEADER_NAME, '1');
const finalOpts: RequestInit = {
...requestOpts,
headers,
...(backendTarget ? { credentials: 'include' as RequestCredentials } : {}),
};
const finalOpts: RequestInit = Object.keys(extra).length
? { ...requestOpts, headers: { ...(requestOpts.headers as Record<string, string>), ...extra } }
: requestOpts;
const signal = finalOpts.signal as AbortSignal | null | undefined;
let lastDetail = '';
// The shell's last word on the backend. When it still says `ready` after we've
@@ -376,12 +270,12 @@ export async function apiFetch(path: string, opts: ApiFetchOptions = {}): Promis
if (signal?.aborted) throw new DOMException('Aborted', 'AbortError');
let res: Response;
try {
res = await fetch(requestUrl, finalOpts);
res = await fetch(apiUrl(path), finalOpts);
// Any response — success or HTTP error alike — proves the backend
// process is alive and answering. Recording it lets a LATER transport
// failure say "it was answering Xs ago and stopped" instead of the
// one-size "can't reach" (#1164).
if (backendTarget) recordBackendContact();
recordBackendContact();
} catch (e) {
// A thrown fetch (TypeError "Failed to fetch" / "NetworkError") means the
// request never reached the backend — it's still starting up, crashed, or
@@ -527,8 +421,8 @@ export async function apiFetch(path: string, opts: ApiFetchOptions = {}): Promis
// or a reverse proxy with no route for this path. Echoing that page
// ("NOT_FOUND bom1::…") sends the user chasing a page that never
// existed; name the actual problem instead: where requests are going.
if (backendTarget && res.status === 404 && !backendShaped) {
throw new ApiError(misroutedBackendMessage(requestUrl), {
if (res.status === 404 && !backendShaped) {
throw new ApiError(misroutedBackendMessage(apiUrl(path)), {
status: res.status,
detail,
});
@@ -537,14 +431,13 @@ export async function apiFetch(path: string, opts: ApiFetchOptions = {}): Promis
// "API key required" (BearerKeyMiddleware, OMNIVOICE_API_KEY) vs anything
// else, i.e. "PIN required" (NetworkAccessMiddleware). Both are 401; the
// detail is the only discriminator (only two 401 sites exist backend-side).
if (backendTarget && res.status === 401 && typeof window !== 'undefined') {
if (res.status === 401 && typeof window !== 'undefined') {
// readError's declared `string` return isn't guaranteed at runtime —
// `j.detail` can be a structured object/array on a future 401. Match only
// real strings (avoids both a `.toLowerCase()` crash and `String()` itself
// throwing on a malformed object); anything else falls back to PIN.
const mode =
typeof detail === 'string' && detail.toLowerCase().includes('api key') ? 'apikey' : 'pin';
if (mode === 'apikey') clearAdminSession();
window.dispatchEvent(new CustomEvent('ov:auth-required', { detail: { mode } }));
}
// Structured details (e.g. the typed asr_model_missing 409) carry a
-6
View File
@@ -19,9 +19,7 @@ interface CommunityItem {
author?: string;
license?: string;
source?: string;
_source_repo?: string;
is_community?: boolean;
attrs?: Record<string, string>;
}
export interface CommunityPage {
@@ -61,7 +59,3 @@ export const addCommunityItem = (
const q = name ? `?name=${encodeURIComponent(name)}` : '';
return apiJson(`/community/items/${encodeURIComponent(id)}/use${q}`, { method: 'POST' });
};
/** Same-origin preview path for both designed presets and recorded voices. */
export const communityPreviewUrl = (id: string, local = false): string =>
`/community/items/${encodeURIComponent(id)}/preview${local ? '?local=true' : ''}`;
-13
View File
@@ -55,19 +55,6 @@ export async function generateSpeech(
}
}
// Hosted builds replace this module and provide tenant-scoped durable Job
// cancellation. Local VoiceStudio has no durable hosted Job queue, so its
// equivalent is intentionally a no-op rather than a cloud dependency.
export async function cancelPendingHostedJobs(): Promise<number> {
return 0;
}
// The local backend has no durable hosted Job to cancel. Returning false lets
// the caller stop its local request directly.
export async function cancelActiveHostedJob(_signal: AbortSignal): Promise<boolean> {
return false;
}
export async function listHistory(): Promise<unknown> {
return apiJson('/history');
}
-40
View File
@@ -14,8 +14,6 @@ import * as archetypesApi from './archetypes';
import type { ArchetypeFilters } from './archetypes';
import * as communityApi from './community';
import type { CommunityFilters } from './community';
import * as enginesApi from './engines';
import type { AllEnginesResponse, EngineFamily } from './types';
// ── Keys (prevents typos, enables targeted invalidation) ─────────────────
export const queryKeys = {
@@ -28,7 +26,6 @@ export const queryKeys = {
models: ['models'] as const,
recommendations: ['recommendations'] as const,
preflight: ['preflight'] as const,
engines: ['engines'] as const,
setupStatus: ['setup-status'] as const,
galleryVoices: (params?: any) => ['gallery-voices', params] as const,
galleryCategories: ['gallery-categories'] as const,
@@ -174,39 +171,6 @@ export function usePreflight() {
});
}
/**
* The app-wide engine inventory. Engine selection affects more than the
* catalogue (for example Audiobook's expressive controls), so every consumer
* must share this cache rather than take its own one-off snapshot.
*
* `queryFn` is injectable for the compatibility-matrix test seam.
*/
export function useEngines(queryFn: () => Promise<AllEnginesResponse> = enginesApi.listEngines) {
return useQuery({
queryKey: queryKeys.engines,
queryFn,
staleTime: 30_000,
retry: 1,
});
}
/** Select an engine and invalidate every view derived from `/engines`. */
export function useSelectEngine() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
family,
backendId,
modelId,
}: {
family: EngineFamily;
backendId: string;
modelId?: string;
}) => enginesApi.selectEngine(family, backendId, modelId),
onSuccess: () => queryClient.invalidateQueries({ queryKey: queryKeys.engines }),
});
}
export function useSetupStatus() {
return useQuery({
queryKey: queryKeys.setupStatus,
@@ -266,9 +230,6 @@ export function useInstallModel() {
qc.invalidateQueries({ queryKey: queryKeys.models });
qc.invalidateQueries({ queryKey: queryKeys.setupStatus });
qc.invalidateQueries({ queryKey: queryKeys.recommendations });
// Some model installs make an engine selectable; refresh every engine
// indicator rather than leaving a stale unavailable snapshot behind.
qc.invalidateQueries({ queryKey: queryKeys.engines });
},
});
}
@@ -281,7 +242,6 @@ export function useDeleteModel() {
qc.invalidateQueries({ queryKey: queryKeys.models });
qc.invalidateQueries({ queryKey: queryKeys.setupStatus });
qc.invalidateQueries({ queryKey: queryKeys.recommendations });
qc.invalidateQueries({ queryKey: queryKeys.engines });
},
});
}
+2 -9
View File
@@ -24,7 +24,7 @@ type EffectiveDevice = GPUTarget | 'network';
// `n/a` is LLM-only; resolve_routing only ever returns the first four.
type RoutingStatus = 'accelerated' | 'cpu_fallback' | 'cpu_only' | 'unavailable' | 'n/a';
export interface EngineBackend {
interface EngineBackend {
id: string;
display_name: string;
available: boolean;
@@ -76,10 +76,8 @@ export interface CuratedModel {
repo_id: string;
}
export interface EngineFamilyResponse {
interface EngineFamilyResponse {
active: string;
/** A process environment pin wins over a saved UI selection. */
env_override?: boolean;
backends: EngineBackend[];
}
@@ -177,14 +175,9 @@ export interface Profile {
id: string;
name: string;
kind: ProfileKind;
language?: string;
language_code?: string;
ref_audio?: string;
ref_text?: string;
instruct?: string;
/** Deterministic identity seed for a designed profile. */
seed?: number | null;
vd_states?: string | null;
description?: string;
created_at?: string;
is_locked?: boolean;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

@@ -11,7 +11,8 @@ import {
hasCrashEvidence,
isSentinelMarker,
} from '../utils/backendCrash';
import { openBugReport } from '../utils/bugReport';
import { openExternal } from '../api/external';
import { buildBugReportUrl } from '../utils/bugReport';
/**
* BackendCrashNotice the honest half of #941.
@@ -128,11 +129,13 @@ export default function BackendCrashNotice() {
// A sentinel report must not claim a crash in its title
// the marker's whole point is that it cannot know
// (CodeRabbit on #1380). The evidence still rides along.
await openBugReport({
title: sentinel
? '[Crash] Backend ended uncleanly (previous run)'
: `[Crash] Backend died (${exit})`,
});
await openExternal(
await buildBugReportUrl({
title: sentinel
? '[Crash] Backend ended uncleanly (previous run)'
: `[Crash] Backend died (${exit})`,
}),
);
} catch (e) {
// Same class as BackendStartFailureNotice (#1177): a Report
// click that silently does nothing reads as a broken button.
@@ -15,7 +15,7 @@ vi.mock('../utils/backendCrash', async (importOriginal) => {
};
});
vi.mock('../utils/bugReport', () => ({
openBugReport: vi.fn().mockResolvedValue(undefined),
buildBugReportUrl: vi.fn().mockResolvedValue('https://example.test/issues/new'),
}));
vi.mock('../api/external', () => ({
openExternal: vi.fn().mockResolvedValue(undefined),
@@ -138,9 +138,9 @@ describe('BackendCrashNotice — sentinel evidence gate (#1375)', () => {
// The report's TITLE must not claim a death the sentinel cannot attest to
// "Backend died (process ended uncleanly )" states as fact what the
// marker only suspects.
const { openBugReport } = await import('../utils/bugReport');
await waitFor(() => expect(openBugReport).toHaveBeenCalled());
const { title } = openBugReport.mock.calls[0][0];
const { buildBugReportUrl } = await import('../utils/bugReport');
await waitFor(() => expect(buildBugReportUrl).toHaveBeenCalled());
const { title } = buildBugReportUrl.mock.calls[0][0];
expect(title).toMatch(/ended uncleanly/);
expect(title).not.toMatch(/died/);
});
@@ -3,7 +3,8 @@ import { useTranslation } from 'react-i18next';
import { AlertTriangle, X } from 'lucide-react';
import toast from 'react-hot-toast';
import { Button, Dialog } from '../ui';
import { openBugReport } from '../utils/bugReport';
import { openExternal } from '../api/external';
import { buildBugReportUrl } from '../utils/bugReport';
import { detectHints, isUnrecoverableFailure } from './BootstrapSplash';
/**
@@ -100,10 +101,12 @@ export default function BackendStartFailureNotice() {
// buildBugReportUrl scrubs the Error text again and attaches
// the environment block, so the report arrives WITH the
// evidence and WITHOUT the user's home path.
await openBugReport({
title: '[Backend] Backend failed to start',
error: new Error(message),
});
await openExternal(
await buildBugReportUrl({
title: '[Backend] Backend failed to start',
error: new Error(message),
}),
);
} catch (e) {
// Never fail silently: the user clicked Report and must be
// told it didn't open, plus the fallback that still works
@@ -1,14 +1,15 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import BackendStartFailureNotice from './BackendStartFailureNotice';
import { openBugReport } from '../utils/bugReport';
import { buildBugReportUrl } from '../utils/bugReport';
import { openExternal } from '../api/external';
import toast from 'react-hot-toast';
// #1177: the shell's `Failed { message }` diagnosis must reach the user AFTER
// the bootstrap splash is gone the window in which a start failure used to
// collapse into the evidence-free "Can't reach the local VoiceStudio backend".
vi.mock('../utils/bugReport', () => ({
openBugReport: vi.fn().mockResolvedValue(undefined),
buildBugReportUrl: vi.fn().mockResolvedValue('https://example.test/issues/new'),
}));
vi.mock('../api/external', () => ({
openExternal: vi.fn().mockResolvedValue(undefined),
@@ -69,15 +70,16 @@ describe('BackendStartFailureNotice', () => {
fireEvent.click(await screen.findByRole('button', { name: /see why/i }));
fireEvent.click(await screen.findByRole('button', { name: /report/i }));
await waitFor(() => expect(openBugReport).toHaveBeenCalled());
await waitFor(() => expect(buildBugReportUrl).toHaveBeenCalled());
// The evidence rides along on the report, not just on screen.
expect(openBugReport.mock.calls[0][0].error.message).toContain('ModuleNotFoundError');
expect(buildBugReportUrl.mock.calls[0][0].error.message).toContain('ModuleNotFoundError');
expect(openExternal).toHaveBeenCalledWith('https://example.test/issues/new');
});
// A Report click that silently does nothing reads as a broken button the
// user is left with no idea whether anything was sent.
it('tells the user when the report cannot be opened', async () => {
openBugReport.mockRejectedValueOnce(new Error('no browser'));
buildBugReportUrl.mockRejectedValueOnce(new Error('no browser'));
render(<BackendStartFailureNotice />);
emit(DIAGNOSIS);
fireEvent.click(await screen.findByRole('button', { name: /see why/i }));
+7 -11
View File
@@ -5,8 +5,7 @@ import { toast } from 'react-hot-toast';
import { useAppStore } from '../store';
import { useTranslation } from 'react-i18next';
import { API, apiFetch } from '../api/client';
import { authenticatedWsUrl } from '../api/authSession';
import { wsUrl as buildWsUrl, apiFetch } from '../api/client';
import { addTranscription } from '../pages/Transcriptions';
import { describeMicError, detectPlatform, micErrorMessage, micHintKey } from '../utils/micError';
import { checkMicrophone, openMicrophoneSettings } from '../utils/permissions';
@@ -899,10 +898,8 @@ export default function CaptureWidget({ onDismiss }) {
// Open WebSocket BEFORE starting capture.
try {
// Scheme + host derive from the API base (window.location lies inside
// the Tauri webview). A remote bearer session is converted to a fresh,
// path-bound WebSocket ticket; neither the master nor session token is
// ever placed in this URL.
// Scheme + host + remote api key all derive from the API base
// (Wave 2.3) window.location lies inside the Tauri webview.
// sherpa ?model=<id>&sr=16000 (raw int16 PCM, live partials)
// AEC ?aec=1&sr=16000 (tagged raw PCM, NLMS canceller)
// both ?model=<id>&aec=1&sr=16000
@@ -914,8 +911,7 @@ export default function CaptureWidget({ onDismiss }) {
if (pcmFallback) params.push('pcm=1');
if (pcmMode) params.push('sr=16000');
const wsPath = params.length ? `/ws/transcribe?${params.join('&')}` : '/ws/transcribe';
const endpoint = await authenticatedWsUrl(wsPath, { apiBase: API });
const ws = new WebSocket(endpoint);
const ws = new WebSocket(buildWsUrl(wsPath));
ws.binaryType = 'arraybuffer';
const failRawPcmSession = () => {
if (
@@ -1117,7 +1113,7 @@ export default function CaptureWidget({ onDismiss }) {
}
};
wsRef.current = ws;
} catch {
} catch (err) {
wsRef.current = null;
if (pcmMode) {
// Raw-PCM has no POST fallback a socket that can't even be
@@ -1125,13 +1121,13 @@ export default function CaptureWidget({ onDismiss }) {
// recording into the void.
stream.getTracks().forEach((tr) => tr.stop());
streamRef.current = null;
setErrorInfo({ kind: 'server', message: '' });
setErrorInfo({ kind: 'server', message: String(err?.message || err) });
setState('error');
return;
}
// Legacy path continues below: the recorder still buffers chunks and
// the POST /transcribe fallback delivers the result on stop.
console.warn('ws open failed — will fall back to POST /transcribe');
console.warn('ws open failed — will fall back to POST /transcribe:', err);
}
if (pcmMode) {
@@ -34,7 +34,6 @@ const mocks = vi.hoisted(() => {
return {
state,
holder,
authenticatedWsUrl: vi.fn(async (path) => `ws://test${path}&ws_ticket=one-use`),
invoke: async (cmd, args) => {
holder.calls.push([cmd, args]);
if (cmd === 'check_accessibility') return holder.a11y;
@@ -48,11 +47,9 @@ vi.mock('../store', () => ({
useAppStore: Object.assign((sel) => sel(mocks.state), { getState: () => mocks.state }),
}));
vi.mock('../api/client', () => ({
API: 'http://test',
wsUrl: (p) => `ws://test${p}`,
apiFetch: vi.fn(async () => ({ json: async () => ({}) })),
}));
vi.mock('../api/authSession', () => ({ authenticatedWsUrl: mocks.authenticatedWsUrl }));
vi.mock('../pages/Transcriptions', () => ({ addTranscription: vi.fn() }));
vi.mock('../utils/copyText', () => ({ copyText: vi.fn(async () => {}) }));
vi.mock('react-hot-toast', () => ({ toast: { error: vi.fn() } }));
@@ -135,10 +132,6 @@ describe('CaptureWidget', () => {
mocks.holder.paste = async () => undefined;
mocks.holder.calls = [];
mocks.holder.onFrame = null;
mocks.authenticatedWsUrl.mockClear();
mocks.authenticatedWsUrl.mockImplementation(
async (path) => `ws://test${path}${path.includes('?') ? '&' : '?'}ws_ticket=one-use`,
);
mocks.state.dictationMode = 'toggle';
mocks.state.dictationModelId = 'sherpa-parakeet-tdt-v3';
FakeWebSocket.instances = [];
@@ -193,10 +186,6 @@ describe('CaptureWidget', () => {
const ws = await startSession();
expect(ws.url).toContain('/ws/transcribe?pcm=1&sr=16000');
expect(mocks.authenticatedWsUrl).toHaveBeenCalledWith('/ws/transcribe?pcm=1&sr=16000', {
apiBase: 'http://test',
});
expect(ws.url).toContain('ws_ticket=one-use');
expect(mocks.holder.onFrame).toBeTypeOf('function');
act(() => mocks.holder.onFrame(new Float32Array([0.25, -0.25])));
@@ -23,7 +23,6 @@ import {
getSidecarInstallStatus,
} from '../api/engines';
import { listLoadedModels, unloadLoadedModel } from '../api/system';
import { useAppStore } from '../store';
import { copyText } from '../utils/copyText';
import { ChevronRight } from 'lucide-react';
import { Badge, Button, Select, Table, Tabs } from '../ui';
@@ -227,10 +226,6 @@ export default function EngineCompatibilityMatrix({
showFamilyTabs = true,
onFamilyChange = null,
reloadToken = 0,
// The catalogue passes its app-wide query here. Keeping the standalone
// fallback preserves the matrix's injectable API seam for isolated hosts
// and its extensive focused test suite.
sharedEngines = null,
// Injectable API layer lets the RTL suite mock it without module-level
// vi.mock incantations, and keeps the "one GET /engines per Settings open"
// contract overridable by hosts.
@@ -247,15 +242,9 @@ export default function EngineCompatibilityMatrix({
apiInstallStatus = getSidecarInstallStatus,
}) {
const { t } = useTranslation();
const [localData, setLocalData] = useState(null);
const [localLoading, setLocalLoading] = useState(true);
const [localError, setLocalError] = useState(null);
const sharedRefetch = sharedEngines?.refetch;
const isShared = Boolean(sharedEngines);
const sharedReloadToken = useRef(reloadToken);
const data = sharedEngines?.data ?? localData;
const loading = isShared ? sharedEngines.isLoading : localLoading;
const error = sharedEngines?.error ?? localError;
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [activeFamily, setActiveFamily] = useState(family);
// Phase 3 Plan 03-01 / TTS-05: which engine has its license dialog
// currently open, or null. Only one dialog is ever open at a time.
@@ -299,42 +288,26 @@ export default function EngineCompatibilityMatrix({
}, [apiListLoadedModels]);
const reload = useCallback(async () => {
if (sharedRefetch) {
const result = await sharedRefetch();
if (result.error) {
const message = result.error?.message || String(result.error);
toastErrorWithReport(t('engines.loadFailed', { message }), result.error);
}
} else {
setLocalLoading(true);
setLocalError(null);
try {
setLocalData(await apiListEngines());
} catch (requestError) {
const message = requestError?.message || String(requestError);
setLocalError(requestError);
toastErrorWithReport(t('engines.loadFailed', { message }), requestError);
} finally {
setLocalLoading(false);
}
setLoading(true);
setError(null);
try {
const fresh = await apiListEngines();
setData(fresh);
} catch (e) {
const msg = e?.message || String(e);
setError(msg);
toastErrorWithReport(t('engines.loadFailed', { message: msg }), e);
} finally {
setLoading(false);
}
refreshResidency();
}, [apiListEngines, refreshResidency, sharedRefetch, t]);
}, [apiListEngines, refreshResidency, t]);
useEffect(() => {
if (isShared) {
if (sharedReloadToken.current !== reloadToken) {
sharedReloadToken.current = reloadToken;
void reload();
return;
}
refreshResidency();
return;
}
void reload();
reload();
// reloadToken: an external bump (e.g. the ASR config panel just saved a
// server URL) refetches so availability + "Use" reflect the new config.
}, [reload, reloadToken, refreshResidency, isShared]);
}, [reload, reloadToken]);
// Unload a resident engine's model/sidecar by its /model/loaded id. Safe by
// contract: the model reloads lazily on the next generation.
@@ -625,8 +598,7 @@ export default function EngineCompatibilityMatrix({
className="engine-matrix engine-matrix--error flex flex-col gap-[8px] items-center p-[16px]"
role="alert"
>
<AlertTriangle size={14} />{' '}
{t('engines.couldNotLoad', { message: error.message || String(error) })}
<AlertTriangle size={14} /> {t('engines.couldNotLoad', { message: error })}
<Button size="sm" variant="subtle" onClick={reload} leading={<RefreshCw size={11} />}>
{t('engines.retry')}
</Button>
@@ -754,7 +726,7 @@ export default function EngineCompatibilityMatrix({
data-testid="engine-list-scroll"
aria-label={t('engines.engineCompatLabel', { family: activeFamily })}
>
{backends.map((b, index) => {
{backends.map((b) => {
const isActive = b.id === activeBackendId;
const health = healthByEngine[b.id];
const selfTest = selfTestByEngine[b.id];
@@ -809,20 +781,6 @@ export default function EngineCompatibilityMatrix({
) : null;
return (
<React.Fragment key={b.id}>
{(index === 0 || (backends[index - 1]?.available && !b.available)) && (
<div
className={cn(
'px-[var(--space-2)] pt-[4px] font-mono text-[10px] font-semibold uppercase tracking-[0.08em]',
MUTED,
)}
>
{/* Section framing, not status: "ready to use" vs "add
more" frames the grey majority as headroom to unlock
rather than a mostly-broken app (13 of 16 rows read as
failures under a plain "Not installed" caption). */}
{b.available ? t('engines.sectionReady') : t('engines.sectionMore')}
</div>
)}
<div
role="row"
data-engine-id={b.id}
@@ -1276,22 +1234,6 @@ export default function EngineCompatibilityMatrix({
{t('engines.use')}
</Button>
)}
{/* The openai-compat family entry and the LLM Providers
panel are one system (the backend resolves through the
active provider); this is the door between the two, so
picking the family and configuring the endpoint stop
being separate discoveries. */}
{activeFamily === 'llm' && b.id === 'openai-compat' && (
<Button
size="sm"
variant="subtle"
onClick={() => useAppStore.getState().openSettingsTab?.('llm-providers')}
aria-label={t('engines.configureProviders')}
data-testid="configure-llm-providers"
>
{t('engines.configureProviders')}
</Button>
)}
{/* TTS-05: license-acceptance entry point. Surfaced when
the backend says the user hasn't accepted the
engine's license yet AND we have a dialog

Some files were not shown because too many files have changed in this diff Show More