Compare commits
184
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6f4c766f5 | ||
|
|
c4ea6a14b0 | ||
|
|
751f04078d | ||
|
|
2926ce615a | ||
|
|
7e64d13739 | ||
|
|
5151243ee4 | ||
|
|
eaee379dd5 | ||
|
|
0d81123954 | ||
|
|
ee35d2389e | ||
|
|
1fda5bdf96 | ||
|
|
2477dde688 | ||
|
|
df2da4bb4d | ||
|
|
37c8be6bfe | ||
|
|
48c9a3b1f8 | ||
|
|
030d5ea01f | ||
|
|
b79ba9bd3b | ||
|
|
c818d235fb | ||
|
|
09ba4feb1c | ||
|
|
08791175f9 | ||
|
|
4fa1b31eef | ||
|
|
8654bb0225 | ||
|
|
3d0c9605df | ||
|
|
bb813ff676 | ||
|
|
bc6acec5a3 | ||
|
|
94ba362ef2 | ||
|
|
aabe5783f3 | ||
|
|
854b4852ed | ||
|
|
6111b8e4ae | ||
|
|
420bc73e78 | ||
|
|
fb46fa4788 | ||
|
|
f83b7371c3 | ||
|
|
9184d7d625 | ||
|
|
0ee62b2261 | ||
|
|
77ae194f9c | ||
|
|
b1f322dde2 | ||
|
|
d3822c4976 | ||
|
|
19ae20111a | ||
|
|
b982192011 | ||
|
|
81b146f53b | ||
|
|
72cabb3daf | ||
|
|
4a228a00b4 | ||
|
|
1a9a70509e | ||
|
|
e877572c1a | ||
|
|
f81ace68d1 | ||
|
|
9832fbd693 | ||
|
|
1f03f5632c | ||
|
|
4335c8c1ea | ||
|
|
dcd8683f3a | ||
|
|
b2f94d2bf8 | ||
|
|
cd54113173 | ||
|
|
6948399e61 | ||
|
|
92b1ee5d1b | ||
|
|
9760f0c501 | ||
|
|
41a8a7b644 | ||
|
|
41722afe3b | ||
|
|
41c098e009 | ||
|
|
a310141114 | ||
|
|
4fc07c21fd | ||
|
|
8fdc5292e4 | ||
|
|
a4fd62f85f | ||
|
|
6eaeb23403 | ||
|
|
fa8fddc456 | ||
|
|
7a7ce47b64 | ||
|
|
7810581beb | ||
|
|
a089359b02 | ||
|
|
4227cbd07d | ||
|
|
4b86692653 | ||
|
|
b99eadfafc | ||
|
|
3f32f97e09 | ||
|
|
45eda7db01 | ||
|
|
fce5075e77 | ||
|
|
0fb9b38bfa | ||
|
|
64973d1829 | ||
|
|
57e435d3c0 | ||
|
|
20d9cb5f1b | ||
|
|
d9a939dff8 | ||
|
|
6f1e26a950 | ||
|
|
06ca67650e | ||
|
|
2e8a08973d | ||
|
|
f47cec164f | ||
|
|
6c58294d56 | ||
|
|
b548a7ab9d | ||
|
|
b74703d506 | ||
|
|
76d8c11553 | ||
|
|
5818e19137 | ||
|
|
80eca8cc6f | ||
|
|
037a5689de | ||
|
|
5b806124c2 | ||
|
|
50dd851bf8 | ||
|
|
2165339a36 | ||
|
|
b0a6fdfdc2 | ||
|
|
ffc6fade50 | ||
|
|
5bb50f7832 | ||
|
|
642513d205 | ||
|
|
090cc37144 | ||
|
|
0f69b4d3ca | ||
|
|
232784cb5a | ||
|
|
32bd5cfedb | ||
|
|
6ba2e2a914 | ||
|
|
d5a496a8ad | ||
|
|
6a6f3fbc29 | ||
|
|
8a8059b9c6 | ||
|
|
5ebf21166d | ||
|
|
b8fb5a14c2 | ||
|
|
17a364c476 | ||
|
|
ce5f051252 | ||
|
|
1ac3dcf3fe | ||
|
|
6535ed3284 | ||
|
|
5ef5d9b73a | ||
|
|
735cdd6b6f | ||
|
|
6c679bfd75 | ||
|
|
16a1ee63ee | ||
|
|
2b0cd04599 | ||
|
|
f0382e0290 | ||
|
|
99e865600b | ||
|
|
680fa8fdef | ||
|
|
40569d0657 | ||
|
|
cf316b18bc | ||
|
|
5832a81bb6 | ||
|
|
9ef0f4a61b | ||
|
|
8e5a023058 | ||
|
|
b718b2be46 | ||
|
|
8d8765315f | ||
|
|
dc5c9cf43e | ||
|
|
fd7f06d62e | ||
|
|
33714b2fe0 | ||
|
|
008c8a70a6 | ||
|
|
4f2dea97b8 | ||
|
|
3dbae35feb | ||
|
|
569517e5d8 | ||
|
|
eaad1017df | ||
|
|
e121e69d0f | ||
|
|
0988a48caa | ||
|
|
9930a0b41a | ||
|
|
2b6f49c596 | ||
|
|
29ccf8ee52 | ||
|
|
53cb316854 | ||
|
|
e6096a4eab | ||
|
|
eb97a54105 | ||
|
|
9687611e6f | ||
|
|
ef671de36e | ||
|
|
1fc0b89778 | ||
|
|
a95afc28ea | ||
|
|
13c342c238 | ||
|
|
19e352560e | ||
|
|
606d02a3ea | ||
|
|
b28d0f5f08 | ||
|
|
539a8bb571 | ||
|
|
e369179efb | ||
|
|
918fca3ead | ||
|
|
465b08cb9c | ||
|
|
b52165d2a7 | ||
|
|
441099bf68 | ||
|
|
c960dcb7e2 | ||
|
|
ed48861008 | ||
|
|
673e544812 | ||
|
|
aa1d739843 | ||
|
|
7a3367be53 | ||
|
|
cedd038e3e | ||
|
|
b7caa494eb | ||
|
|
bda169c900 | ||
|
|
fe467d15e7 | ||
|
|
b54cd28403 | ||
|
|
c88955fb37 | ||
|
|
69a6867e5c | ||
|
|
935deba22b | ||
|
|
084c1d1ebb | ||
|
|
137c76abb9 | ||
|
|
7eec1d7d11 | ||
|
|
aa14c9ade3 | ||
|
|
29232f4591 | ||
|
|
5ae23f37d1 | ||
|
|
94af4c9f3a | ||
|
|
aaa5bfa0c6 | ||
|
|
e1964dfbb3 | ||
|
|
27d76955da | ||
|
|
c643706d07 | ||
|
|
7924b35f8d | ||
|
|
9eb1ec7591 | ||
|
|
02ec8e3675 | ||
|
|
b8f44e089d | ||
|
|
4f4d9c6e3e | ||
|
|
e5bd11bf85 | ||
|
|
43de1c794c |
@@ -23,6 +23,12 @@ jobs:
|
||||
test:
|
||||
name: Tests (backend + frontend)
|
||||
runs-on: ubuntu-22.04
|
||||
env:
|
||||
# Same restricted-network resilience the smoke matrix already sets. This
|
||||
# job resolves the same direct-URL dependency and had none of it, which
|
||||
# is why it was the one that kept dying (see scripts/uv-sync-retry.sh).
|
||||
UV_HTTP_TIMEOUT: "120"
|
||||
UV_HTTP_RETRIES: "5"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -61,7 +67,14 @@ jobs:
|
||||
# so their tests can exercise the real import path, not the
|
||||
# "package not installed" fallback. Smoke job below stays on bare
|
||||
# `uv sync` because smoke only hits /health + fixture profiles.
|
||||
run: uv sync --all-extras
|
||||
#
|
||||
# Retried because one dependency — en-core-web-sm — resolves to a
|
||||
# direct GitHub release URL, and github.com intermittently answers
|
||||
# `http2 error: refused stream before processing any application
|
||||
# logic`. uv's own 3 retries all land inside the same few seconds and
|
||||
# fail together, which has cost otherwise-green runs (#1517, #1518).
|
||||
# Backing off between whole attempts is what actually clears it.
|
||||
run: bash scripts/uv-sync-retry.sh --all-extras
|
||||
|
||||
# HF_HUB_OFFLINE=1 is a recurrence guard, not an optimization: a test
|
||||
# that reaches huggingface.co fails fast and loud instead of silently
|
||||
@@ -71,7 +84,7 @@ jobs:
|
||||
# interactions in tests are stubbed; anything that trips this is a
|
||||
# test-isolation bug.
|
||||
- name: Run pytest
|
||||
run: uv run pytest tests/ -q --tb=short
|
||||
run: uv run --no-sync pytest tests/ -q --tb=short
|
||||
env:
|
||||
HF_HUB_OFFLINE: "1"
|
||||
|
||||
@@ -92,13 +105,14 @@ jobs:
|
||||
run: |
|
||||
bash frontend/src-tauri/appimage/AppRun.test.sh
|
||||
bash scripts/inject-apprun.test.sh
|
||||
bash scripts/verify-apprun-bundle.test.sh
|
||||
|
||||
# `backend/tests/` mounts routers on bare FastAPI apps (no heavy main
|
||||
# import chain) with a hermetic data dir from its conftest.py. It no
|
||||
# longer stubs sys.modules, so mixed sessions with tests/ are safe;
|
||||
# the separate session is kept for cheaper, clearer CI output.
|
||||
- name: Run pytest (backend/tests, isolated)
|
||||
run: uv run pytest backend/tests/ -q --tb=short
|
||||
run: uv run --no-sync pytest backend/tests/ -q --tb=short
|
||||
env:
|
||||
HF_HUB_OFFLINE: "1" # same no-silent-downloads guard as tests/
|
||||
|
||||
@@ -257,6 +271,17 @@ 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
|
||||
@@ -351,6 +376,19 @@ 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)
|
||||
@@ -366,7 +404,7 @@ jobs:
|
||||
# backend host. The Intel-Mac leg separately pins the documented
|
||||
# unsupported contract: its UI is a remote-backend client only (#889).
|
||||
if: matrix.backend_supported
|
||||
run: uv sync --extra pockettts
|
||||
run: bash scripts/uv-sync-retry.sh --extra pockettts
|
||||
|
||||
- name: Verify the documented Intel Mac contract
|
||||
if: ${{ !matrix.backend_supported }}
|
||||
@@ -391,7 +429,16 @@ jobs:
|
||||
|
||||
- name: Run smoke tests
|
||||
if: matrix.backend_supported
|
||||
run: uv run pytest tests/smoke/ -q --tb=short
|
||||
run: uv run --no-sync pytest tests/smoke/ -q --tb=short
|
||||
env:
|
||||
HF_HUB_OFFLINE: "1" # same no-silent-downloads guard as the main pytest job
|
||||
HF_HUB_CACHE: ${{ runner.temp }}/pockettts-empty-hf-cache
|
||||
|
||||
# Artifact commits depend on native Windows rename/replace semantics;
|
||||
# Linux emulation cannot exercise sharing rules or path parsing.
|
||||
- name: Remote-worker artifact paths (Windows)
|
||||
if: runner.os == 'Windows' && matrix.backend_supported
|
||||
run: uv run --no-sync pytest tests/test_worker_upload_server.py tests/test_worker_server_integrity.py -q --tb=short
|
||||
env:
|
||||
HF_HUB_OFFLINE: "1"
|
||||
HF_HUB_CACHE: ${{ runner.temp }}/worker-artifact-empty-hf-cache
|
||||
|
||||
@@ -38,14 +38,14 @@ jobs:
|
||||
cache-dependency-glob: "uv.lock"
|
||||
|
||||
- name: Install deps
|
||||
run: uv sync
|
||||
run: bash scripts/uv-sync-retry.sh
|
||||
|
||||
- name: Run eval suites (non-gating)
|
||||
continue-on-error: true
|
||||
env:
|
||||
TRANSLATE_BASE_URL: ${{ secrets.EVALS_LLM_BASE_URL }}
|
||||
TRANSLATE_API_KEY: ${{ secrets.EVALS_LLM_API_KEY }}
|
||||
run: uv run python tests/evals/run_evals.py --output eval-report.json
|
||||
run: uv run --no-sync python tests/evals/run_evals.py --output eval-report.json
|
||||
|
||||
- name: Upload report artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
+109
-13
@@ -109,10 +109,10 @@ jobs:
|
||||
version: 1.0
|
||||
|
||||
- name: Install Python deps
|
||||
run: uv sync
|
||||
run: bash scripts/uv-sync-retry.sh
|
||||
|
||||
- name: Run pytest
|
||||
run: uv run pytest tests/ -q --tb=short
|
||||
run: uv run --no-sync pytest tests/ -q --tb=short
|
||||
|
||||
- name: Cache bun deps
|
||||
uses: actions/cache@v4
|
||||
@@ -731,6 +731,62 @@ 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
|
||||
@@ -751,17 +807,13 @@ jobs:
|
||||
"$APPIMAGE" --appimage-extract >/dev/null
|
||||
ROOT="$EXTRACT_DIR/squashfs-root"
|
||||
fail() { echo "FAIL — $1"; find "$ROOT" -maxdepth 5 -type f 2>/dev/null | head -40; exit 1; }
|
||||
# Regression gate: beforeBundleCommand runs before Tauri creates the
|
||||
# AppDir. The v0.4.2 artifact therefore silently shipped Tauri's
|
||||
# stock AppRun and bypassed every WebKit/Mesa compatibility fix.
|
||||
cmp -s "$ROOT/AppRun" "$GITHUB_WORKSPACE/frontend/src-tauri/appimage/AppRun" \
|
||||
|| fail "custom AppRun missing from final AppImage"
|
||||
[ -s "$ROOT/usr/lib/.bundled-webkitgtk-version" ] \
|
||||
|| fail "bundled WebKitGTK version marker missing"
|
||||
cmp -s \
|
||||
"$ROOT/usr/lib/.bundled-webkitgtk-version" \
|
||||
"$GITHUB_WORKSPACE/frontend/src-tauri/target/.tauri/bundled-webkitgtk-version" \
|
||||
|| fail "bundled WebKitGTK version marker is stale or mismatched"
|
||||
# linuxdeploy's GTK/GStreamer hooks wrap the seeded launcher as
|
||||
# AppRun.wrapped. Verify the complete launcher chain, not only the
|
||||
# small hook runner installed at the AppImage root.
|
||||
bash "$GITHUB_WORKSPACE/scripts/verify-apprun-bundle.sh" \
|
||||
"$ROOT" \
|
||||
"$GITHUB_WORKSPACE/frontend/src-tauri/appimage/AppRun" \
|
||||
"$GITHUB_WORKSPACE/frontend/src-tauri/target/.tauri/bundled-webkitgtk-version"
|
||||
# Thin uv-venv installer: verify the AppImage carries the shell binary,
|
||||
# the bundled uv sidecar, and the backend source resources.
|
||||
{ [ -f "$ROOT/AppRun" ] || find "$ROOT" -type f \( -name "VoiceStudio" -o -name "omnivoice-studio" \) | grep -q .; } || fail "shell binary / AppRun missing"
|
||||
@@ -848,6 +900,50 @@ 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')
|
||||
|
||||
@@ -174,7 +174,7 @@ jobs:
|
||||
- name: pip-audit (Python)
|
||||
continue-on-error: true
|
||||
run: |
|
||||
uv sync
|
||||
bash scripts/uv-sync-retry.sh
|
||||
uv run --with pip-audit pip-audit
|
||||
|
||||
# Pin a floor: `bun audit` was added in bun 1.2.x, so guarantee it exists.
|
||||
|
||||
@@ -154,3 +154,12 @@ playwright-report/
|
||||
|
||||
# probe — generated HTML reports
|
||||
tests/probe/reports/
|
||||
|
||||
# Local architecture/planning scratch (goal docs, review briefs, council
|
||||
# reports). Working notes for whoever is driving a change, not a repo artifact.
|
||||
/remote/
|
||||
|
||||
# Dubbing-demo intermediates. The .mp4/.srt/manifest.json in this directory ARE
|
||||
# committed (they ship with the app); the per-language source WAVs are just the
|
||||
# inputs scripts/render_dub_demo_audio.py hands to scripts/build_dub_demo.sh.
|
||||
backend/assets/samples/demo/dubbing/*.src.wav
|
||||
|
||||
+110
-21
@@ -10,21 +10,68 @@ the frozen-backend fallback mirror it for their toolchains.
|
||||
|
||||
**Highlights**
|
||||
|
||||
- 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
|
||||
- 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
|
||||
- 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)
|
||||
- Settings → Performance & Device gains a compute-device override (Auto / CUDA / ROCm / XPU / MPS / CPU, or `OMNIVOICE_DEVICE`) — pin the device when auto-detect picks wrong; only devices your machine actually has are offered (#1557)
|
||||
|
||||
### Docs
|
||||
- The READMEs now lead with download buttons and a three-step first-clone walkthrough, and a new benchmarks page anchors measured per-engine/per-device numbers on the in-repo harness (#1555)
|
||||
- Every engine now has its own guide — 21 new pages under docs/engines plus an index covering all 16 TTS and 11 ASR engines, linked from both READMEs (#1556)
|
||||
|
||||
### Fixed
|
||||
- The crash-isolated ASR sidecar and its download preflight now agree on which model to load — setting the shared faster-whisper model variable applies to both variants instead of the sidecar quietly using a different one (#1556)
|
||||
- "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
|
||||
|
||||
**Highlights**
|
||||
|
||||
- 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)
|
||||
|
||||
<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" />
|
||||
|
||||
| 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" /> |
|
||||
|
||||
### 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.
|
||||
- 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.
|
||||
- Linux release smoke now validates linuxdeploy's wrapped custom launcher instead of rejecting a healthy AppImage. (#1506)
|
||||
- Remote GPU workers render audiobooks chapter by chapter, with automatic per-chapter local fallback and one combined notice if the worker drops out. (#1478)
|
||||
- Remote GPU workers can now run a job to completion: long renders no longer die at two minutes, a worker that drops and reconnects mid-render keeps its work, and a timed-out job no longer takes the worker offline for good. Placing a job still needs the development-only `POST /workers/tasks`; wiring the app's own Synthesize button to it comes next.
|
||||
- Voice, Stories, Audiobook, Gallery, Settings, profiles, and Launchpad now use compact, responsive layouts with accessible controls. (#1491)
|
||||
- Dubbing's Generate Dub, Verify, and Export actions now use a compact hierarchy with visible labels, responsive reflow, and motion-safe feedback. (#1493)
|
||||
- The Dub workspace now has a compact production command bar, responsive flag-based language cards, media previews in Dub History, and a narrower Projects rail. (#1489)
|
||||
- VoiceStudio now uses one waveform-and-spark mark across the title bar, About screen, README, browser favicon, and every desktop/platform icon. (#1487)
|
||||
- PocketTTS now asks you to review its code license, model license and gated-access conditions before first use, and explains how to unlock the model instead of showing a raw download failure — thanks @paoloantinori! (#1442)
|
||||
- The repository moved to github.com/debpalash/VoiceStudio. Every link in the app, docs and scripts now points there; GitHub redirects the old URLs, and the Docker image paths, the app bundle identifier and your data folder are all deliberately unchanged. (#1394)
|
||||
@@ -35,6 +82,19 @@ 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)
|
||||
- Settings → System → **Remote workers** sends individual jobs to GPUs on your other machines while everything else stays here. Off by default; each machine is added with a single-use token and approved before any audio reaches it. See [docs/remote-workers.md](docs/remote-workers.md).
|
||||
- First-run setup now recommends a screen-aware interface scale, with compact controls available throughout setup. (#1502)
|
||||
- OrcaRouter is now available as a named OpenAI-compatible LLM provider — thanks @Marc-oss-hub! (#1499)
|
||||
- IndexTTS 2.5 is available as a pinned one-click sidecar with five-language dubbing, expressive cloning, and backward-compatible IndexTTS-2 support. (#1482) — thanks @marwanlhabti5-coder!
|
||||
- Voice recording now offers microphone and channel selection with a live input-level meter on every desktop platform. (#1481)
|
||||
- Settings → Appearance → **Navigation style** switches the workspace switcher between the icon rail down the window edge and browser-style tabs across the title bar. Both offer the same workspaces; the choice sticks across launches, and the rail stays the default. Tab labels fold down to icons when the title bar runs out of room — the workspace you're in keeps its name. (#1412)
|
||||
@@ -42,16 +102,47 @@ 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)
|
||||
|
||||
### CI
|
||||
### Docs
|
||||
|
||||
- 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)
|
||||
- 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)
|
||||
|
||||
### 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 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)
|
||||
- Transcription now moves to the next working engine when the auto-picked one passes its availability check but breaks on first real use, instead of returning an internal error — the recovery dubbing already had. Affected accurate-mode transcription, the OpenAI-compatible API, batch, dub verify, and voice-clone reference text. (#1512)
|
||||
- A malformed request now gets a clear 422 instead of an internal error, and uploading a file to an endpoint that expects JSON no longer copies the whole upload into the app log — a 145 KB clip wrote roughly 500 KB of log, recording your audio in the file people paste into bug reports. (#1513)
|
||||
- The Simplified Chinese (zh-CN) translation no longer mistranslates brand names and technical terms — Discord, Tailscale, Hugging Face, IPA, and LLM (Cinematic) were rendered as nonsensical literal translations, and ~250 more awkward machine-translation strings are now natural Chinese. (#1508) — thanks @anyingiit!
|
||||
- Worker restart coverage now waits for the registration response to persist its identity instead of racing the client callback in CI. (#1505)
|
||||
- Dub language and export selections now restore without false schema warnings, and remote-worker port 7443 is identified instead of reported as a generic timeout. (#1504)
|
||||
- A configured remote backend now bypasses local first-run setup, verifies itself before app requests begin, and shows recovery instead of leaving the desktop stuck on Setup. (#1503)
|
||||
- An idle voice model now actually hands its memory back. The unload emptied the GPU cache a moment before releasing the model, so it freed nothing while reporting success — a GPU machine lending its card sat on 3.6 GB indefinitely. (#1495)
|
||||
- Unloading a model on an NVIDIA GPU now returns the last ~770 MB too. A single 8.5 MB cuBLAS workspace sat inside the model's memory block and kept the whole block reserved, so an idle machine held 1.2 GB instead of 470 MB no matter how often you pressed Flush Memory. (#1495)
|
||||
- Flush Memory reports reserved GPU memory alongside allocated. Allocated alone reads near zero right after an unload while the GPU still shows gigabytes, which is exactly the case people were reporting. (#1495)
|
||||
- The AudioSeal watermark models are released after the same idle period as everything else, instead of staying in memory for the life of the app once anything was watermarked. (#1495)
|
||||
- Remote GPU workers now synthesize a dub's fresh segments as one coarse job with live progress and cancellation; fitting, assembly and RVC remain local. (#1478)
|
||||
- Gallery voice previews now fall back to a local render when a downloaded clip cannot be decoded, instead of failing silently. (#1478)
|
||||
- A second VoiceStudio instance can no longer silently share the remote-worker port; it keeps running locally and explains how to resolve the conflict. (#1478)
|
||||
- Remote GPU jobs stay pinned to the selected worker across retries and restarts, stop when their caller leaves, and cannot return from cancellation as completed. (#1478)
|
||||
- Remote GPU model labels now survive registration, legacy blank model IDs share one capacity slot, long jobs retain bounded leases, and idle cleanup cannot evict a live local render. (#1478)
|
||||
- Remote GPU jobs now stop before dispatch when that worker lacks the model, offer the download there, and refresh scheduling as soon as it finishes. (#1478)
|
||||
- Leaving a screen while its waveform is still loading no longer opens a bug-report prompt for a normal cancelled request. (#1498)
|
||||
- An unreachable remote backend now opens a retryable recovery screen instead of sending the app into local model setup, with clear TLS, CORS, network, HTTP, and wrong-port guidance — thanks @debpalash! (#1501)
|
||||
- Linux production test launches now stop their own extracted AppImage before resetting SQLite and logs. (#1494)
|
||||
- Restored the pre-release version to 0.4.2 while the next release remains in preparation. (#1488)
|
||||
- Large multi-language dubbing batches now use compact searchable language and track managers instead of overflowing the editor. (#1492)
|
||||
- Dictation shortcuts now register and rebind through the desktop portal on Wayland, honor custom keys in focused app views, and show the effective platform keys. (#1490)
|
||||
- Multi-language dubbing now translates, edits, generates, retains, and exports every selected language, and its language picker stays visible at viewport edges. (#1486)
|
||||
- Dubbing's **From video** cast now uses available source-audio samples for every speaker and short line, including jobs without a pooled diarization clone. (#1484)
|
||||
- Basic Dubbing translation remains available without an LLM; Cinematic and Autofit now degrade through the existing Fast translation path instead of blocking the quality choice. (#1481)
|
||||
@@ -140,16 +231,14 @@ 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)
|
||||
|
||||
### 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)
|
||||
- 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)
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -1,654 +1,385 @@
|
||||
<div align="center">
|
||||
<img src="docs/logo.png" alt="VoiceStudio Logo" width="120" height="120" />
|
||||
<img src="docs/logo.png" alt="VoiceStudio logo" width="120" height="120" />
|
||||
<h1>VoiceStudio</h1>
|
||||
<p><sub><em>previously OmniVoice-Studio</em></sub></p>
|
||||
<h3>Make voices. Tell stories. Keep the files. ♡</h3>
|
||||
<p>Clone, design, dub, dictate, and build audiobooks in one open-source desktop studio.<br/><b>Local-first by default.</b> No subscription or usage meter. Optional online services stay opt-in.</p>
|
||||
<p><sub>Previously OmniVoice-Studio</sub></p>
|
||||
<h3>Local voice cloning, dubbing, dictation, and long-form audio.</h3>
|
||||
<p>16 TTS engines · 11 ASR engines · 646-language catalogue · macOS, Windows, and Linux</p>
|
||||
<p><strong>Local-first.</strong> No account, API key, subscription, or usage meter for the core workflow.</p>
|
||||
|
||||
<p>
|
||||
<a href="#quickstart">Quickstart</a> ·
|
||||
<a href="#install">Install</a> ·
|
||||
<a href="#features">Features</a> ·
|
||||
<a href="#why-voicestudio">Why VoiceStudio</a> ·
|
||||
<a href="#tts-engines">Engines</a> ·
|
||||
<a href="#openai-api">API</a> ·
|
||||
<a href="#sponsor--donate">Donate</a> ·
|
||||
<a href="#contributing">Contributing</a> ·
|
||||
<a href="https://voicestudio.sh">Website</a> ·
|
||||
<a href="https://voicestudio.sh/docs">Docs</a> ·
|
||||
<a href="https://status.voicestudio.sh">Status</a> ·
|
||||
<a href="https://discord.gg/bzQavDfVV9">Discord</a> ·
|
||||
<a href="https://x.com/idebpalash">X</a> ·
|
||||
<a href="#comparison">Compare</a> ·
|
||||
<a href="#requirements">Requirements</a> ·
|
||||
<a href="#engines">Engines</a> ·
|
||||
<a href="#architecture">Architecture</a> ·
|
||||
<a href="#api">API</a> ·
|
||||
<a href="#documentation">Docs</a> ·
|
||||
<a href="README_CN.md"><strong>简体中文</strong></a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a href="https://github.com/debpalash/VoiceStudio/stargazers"><img src="https://img.shields.io/github/stars/debpalash/VoiceStudio?style=flat-square&color=f59e0b" alt="Stars" /></a>
|
||||
<a href="https://github.com/debpalash/VoiceStudio/stargazers"><img src="https://img.shields.io/github/stars/debpalash/VoiceStudio?style=flat-square&color=f59e0b" alt="GitHub stars" /></a>
|
||||
<a href="https://github.com/debpalash/VoiceStudio/releases"><img src="https://img.shields.io/github/downloads/debpalash/VoiceStudio/total?style=flat-square&color=8b5cf6&label=downloads" alt="Total downloads" /></a>
|
||||
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/github/v/release/debpalash/VoiceStudio?style=flat-square&color=10b981" alt="Release" /></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-AGPL--3.0-blue?style=flat-square" alt="License" /></a>
|
||||
<a href="https://github.com/debpalash/VoiceStudio/issues"><img src="https://img.shields.io/github/issues/debpalash/VoiceStudio?style=flat-square&color=ef4444" alt="Issues" /></a>
|
||||
<a href="https://discord.gg/bzQavDfVV9"><img src="https://img.shields.io/badge/Discord-Join_Community-5865F2?style=flat-square&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
<a href="https://x.com/idebpalash"><img src="https://img.shields.io/badge/X-Follow_for_updates-000000?style=flat-square&logo=x&logoColor=white" alt="Follow on X" /></a>
|
||||
<a href="https://ko-fi.com/debpalash"><img src="https://img.shields.io/badge/Ko--fi-Support_Us-FF5E5B?style=flat-square&logo=ko-fi&logoColor=white" alt="Ko-fi" /></a>
|
||||
<a href="https://paypal.me/palashCoder"><img src="https://img.shields.io/badge/PayPal-Donate-00457C?style=flat-square&logo=paypal&logoColor=white" alt="PayPal" /></a>
|
||||
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/github/v/release/debpalash/VoiceStudio?style=flat-square&color=10b981" alt="Latest release" /></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-AGPL--3.0-blue?style=flat-square" alt="AGPL-3.0 license" /></a>
|
||||
<a href="https://discord.gg/bzQavDfVV9"><img src="https://img.shields.io/badge/Discord-Community-5865F2?style=flat-square&logo=discord&logoColor=white" alt="Discord community" /></a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/badge/⬇_Download-macOS_·_Windows_·_Linux-10b981?style=for-the-badge" alt="Download the latest release" /></a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a href="https://trendshift.io/repositories/28176?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-28176" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/28176/daily?language=Python" alt="debpalash%2FVoiceStudio | Trendshift" width="250" height="55"/></a>
|
||||
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/badge/Download-macOS_·_Windows_·_Linux-10b981?style=for-the-badge" alt="Download VoiceStudio" /></a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
|
||||
<div align="center">
|
||||
<img src="docs/screenshot-launchpad.png" alt="VoiceStudio — Launchpad" width="100%"/>
|
||||
<img src="docs/media/0.5.0/quick-switch.gif" alt="Switching TTS engines from the VoiceStudio status bar" width="100%" />
|
||||
</div>
|
||||
|
||||
> **Your voice is personal. Your studio should feel personal too.** VoiceStudio keeps its core workflow on your hardware: clone, design, dub, dictate, and publish in 646 languages without a subscription or usage meter. Network-backed engines and services are optional, visible choices—not hidden requirements.
|
||||
|
||||
> [!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).
|
||||
> **Active beta.** Use the [latest release](https://github.com/debpalash/VoiceStudio/releases/latest) for stable work or `main` for current fixes. Report problems through [GitHub Issues](https://github.com/debpalash/VoiceStudio/issues).
|
||||
|
||||
## At a glance
|
||||
|
||||
| | VoiceStudio |
|
||||
|---|---|
|
||||
| **Workflows** | Voice cloning and design, video dubbing, dictation, stories, audiobooks, batch generation |
|
||||
| **Language catalogue** | 646 TTS languages; actual coverage and quality depend on the selected engine |
|
||||
| **Engines** | 16 TTS · 11 ASR · switch in Model Catalogue or with <kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>E</kbd> |
|
||||
| **Platforms** | macOS 13.3+ on Apple Silicon · Windows 10/11 x64 · Linux x86_64 with glibc 2.39+ |
|
||||
| **Compute** | CUDA · Apple Silicon MPS/MLX · ROCm on Linux · CPU · optional remote workers |
|
||||
| **Interfaces** | Desktop app · local REST/SSE/WebSocket API · OpenAI-compatible audio API · MCP Server |
|
||||
| **Storage** | Voices, projects, settings, and outputs stay on the machine by default |
|
||||
| **License** | AGPL-3.0; optional engines keep their own model licenses |
|
||||
|
||||
<a id="install"></a>
|
||||
|
||||
## Install
|
||||
|
||||
| Platform | Package | Guide |
|
||||
|---|---|---|
|
||||
| macOS 13.3+ | DMG, Apple Silicon | [Install on macOS](docs/install/macos.md) |
|
||||
| Windows 10/11 | MSI, x64 | [Install on Windows](docs/install/windows.md) |
|
||||
| Linux | AppImage, x86_64 with glibc 2.39+ | [Install on Linux](docs/install/linux.md) |
|
||||
| Docker | CUDA, ROCm, or CPU | [Run with Docker](docs/install/docker.md) |
|
||||
|
||||
Download packages from the [latest release](https://github.com/debpalash/VoiceStudio/releases/latest). First launch creates a managed Python environment and downloads the default model. Later launches reuse both.
|
||||
|
||||
> [!NOTE]
|
||||
> On macOS, first launch needs a one-time right-click → **Open** approval. Intel Macs cannot run the local Python backend; use a [remote backend](docs/install/macos.md) instead.
|
||||
|
||||
### First voice
|
||||
|
||||
1. Launch VoiceStudio and open **Voice Cloning**.
|
||||
2. Add a clean voice sample. Three seconds works; 5–15 seconds usually gives a better prompt.
|
||||
3. Enter text, choose a language, then select **Generate**.
|
||||
|
||||
### Run from source
|
||||
|
||||
Install the [development prerequisites](.github/CONTRIBUTING.md#development-setup), then:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/debpalash/VoiceStudio.git
|
||||
cd VoiceStudio
|
||||
bun install
|
||||
bun run desktop
|
||||
```
|
||||
|
||||
Use `bun run dev` for the browser UI. See [Contributing](.github/CONTRIBUTING.md) for services, tests, and platform packages.
|
||||
|
||||
### If setup fails
|
||||
|
||||
- Run **Settings → About → Run self-check** or `uv run python backend/main.py --diagnose --deep`.
|
||||
- Check [install troubleshooting](docs/install/troubleshooting.md).
|
||||
- Save a scrubbed diagnostic bundle from the app when opening an issue.
|
||||
- For slow generation, compare [measured benchmarks](docs/benchmarks.md) and [performance settings](docs/performance.md).
|
||||
|
||||
<a id="features"></a>
|
||||
|
||||
## ✨ Features
|
||||
## Features
|
||||
|
||||
Three flagships, five more headliners, and a dozen under the fold.
|
||||
| Area | Included |
|
||||
|---|---|
|
||||
| **Voice Cloning** | Zero-shot synthesis from a short reference clip |
|
||||
| **Voice Design** | Create a voice from age, accent, pitch, style, and delivery instructions |
|
||||
| **Video Dubbing** | Transcribe, translate, preserve speakers, synthesize, and export video |
|
||||
| **Stories and audiobooks** | Multi-voice scripts · EPUB/PDF import · chapter rendering · `.m4b` export |
|
||||
| **Dictation Widget** | System-wide shortcut, live transcription, optional local-LLM cleanup |
|
||||
| **Vocal Isolation** | Demucs speech/background separation |
|
||||
| **Speaker Diarization** | Pyannote and WhisperX speaker assignment |
|
||||
| **Batch Queue** | Queue large sets of audio and video jobs with per-job progress |
|
||||
| **Model Catalogue** | Install, remove, select, and route TTS, ASR, and LLM models |
|
||||
| **Remote Model Downloads** | Install models on enrolled remote workers with live progress |
|
||||
| **GPU Auto-Detect** | CUDA, MPS, ROCm, and CPU routing with per-engine checks |
|
||||
| **AI Watermark** | AudioSeal embedding and detection |
|
||||
| **MCP Server** | Synthesis and transcription tools for MCP clients |
|
||||
| **Diagnostics** | Self-checks, error journal, logs, and scrubbed support bundles |
|
||||
| **Local-first** | Core creation stays local; network-backed features are explicit opt-ins |
|
||||
| **Extensible** | Registry-based TTS, ASR, and plugin interfaces |
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="33%"><img src="docs/features/clone.png" alt="Voice Cloning" width="100%"/></td>
|
||||
<td width="33%"><img src="docs/features/design.png" alt="Voice Design" width="100%"/></td>
|
||||
<td width="33%"><img src="docs/features/dub.png" alt="Video Dubbing" width="100%"/></td>
|
||||
<td width="50%"><img src="docs/media/0.5.0/catalogue.png" alt="VoiceStudio Model Catalogue" width="100%" /></td>
|
||||
<td width="50%"><img src="docs/media/0.5.0/gallery-save.png" alt="Saving a gallery voice as a local profile" width="100%" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">🎙️ <b>Voice Cloning</b><br/><sub>3-sec clip → any voice · 646 languages · zero-shot</sub></td>
|
||||
<td align="center">🎨 <b>Voice Design</b><br/><sub>Describe it — gender, age, accent, emotion</sub></td>
|
||||
<td align="center">🎬 <b>Video Dubbing</b><br/><sub>Transcribe → translate → re-voice → MP4</sub></td>
|
||||
<td align="center"><sub>Model Catalogue: engine, device, and install state</sub></td>
|
||||
<td align="center"><sub>Gallery: save a shared voice as a local profile</sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="20%">📖<br/><b>Audiobook</b><br/><sub>EPUB/PDF → .m4b, multi-voice cast</sub></td>
|
||||
<td align="center" width="20%">🎭<br/><b>Stories</b><br/><sub>Multi-voice script editor</sub></td>
|
||||
<td align="center" width="20%">⌨️<br/><b>Dictation Widget</b><br/><sub><kbd>⌘⇧Space</kbd> in any app</sub></td>
|
||||
<td align="center" width="20%">🔐<br/><b>Local-first</b><br/><sub>Core creation stays on your machine</sub></td>
|
||||
<td align="center" width="20%">🤖<br/><b>MCP Server</b><br/><sub>Use from Claude, Cursor, …</sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
<a id="comparison"></a>
|
||||
|
||||
<details>
|
||||
<summary><b>…and 12 more</b> — isolation, diarization, batch, watermarking, diagnostics, and friends</summary>
|
||||
## Comparison
|
||||
|
||||
<br/>
|
||||
VoiceStudio trades managed cloud compute for local control. This is the practical difference:
|
||||
|
||||
- 🔊 **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** — CUDA · MPS · ROCm (Linux, opt-in) · CPU; ≤8 GB VRAM auto-offloads.
|
||||
- 🧭 **Engine routing** — preflight GPU check per engine; no silent CPU fallback.
|
||||
- 🧩 **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>
|
||||
|
||||
---
|
||||
|
||||
<a id="quickstart"></a>
|
||||
|
||||
## ⚡ Quickstart
|
||||
|
||||
<div align="center">
|
||||
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/badge/macOS-DMG_(Apple_Silicon)-000?style=for-the-badge&logo=apple&logoColor=white" alt="Download macOS DMG" /></a>
|
||||
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/badge/Windows-MSI_(x64)-0078D4?style=for-the-badge&logo=windows&logoColor=white" alt="Download Windows MSI" /></a>
|
||||
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/badge/Linux-AppImage_(x64)-FCC624?style=for-the-badge&logo=linux&logoColor=black" alt="Download Linux AppImage" /></a>
|
||||
<br/>
|
||||
<sub><b>macOS:</b> first launch needs a one-time approval — right-click → <b>Open</b> (or System Settings → Privacy & Security → <b>"Open Anyway"</b> on macOS 15). No Terminal needed. <a href="docs/install/macos.md#gatekeeper-quarantine">Why?</a> · <b>Intel Macs:</b> local backend unsupported (<a href="https://github.com/debpalash/VoiceStudio/issues/889">#889</a>) — <a href="docs/install/macos.md">details</a>.</sub>
|
||||
</div>
|
||||
|
||||
**Install guide:** [🍎 macOS](docs/install/macos.md) · [🪟 Windows](docs/install/windows.md) · [🐧 Linux](docs/install/linux.md) · [🐳 Docker](docs/install/docker.md)
|
||||
|
||||
<details>
|
||||
<summary><b>🧰 Troubleshooting · slow generation · HF tokens · restricted networks</b></summary>
|
||||
|
||||
<br/>
|
||||
|
||||
- **Something broke?** Run the self-check — **Settings → About → "Run self-check"** (or `uv run python backend/main.py --diagnose --deep`) — then the [top 10 install errors](docs/install/troubleshooting.md). **"Save diagnostic bundle"** packages scrubbed logs for a bug report.
|
||||
- **Feels slow?** [docs/performance.md](docs/performance.md) — where the time goes and how to tune it.
|
||||
- **Want breaths, laughter, emotion?** [docs/expressive-speech.md](docs/expressive-speech.md) — what each engine can do today.
|
||||
- **HF tokens · diarization · download speed / mirrors:** [tokens](docs/setup/huggingface-token.md) · [diarization](docs/features/diarization.md) · [downloads](docs/downloading-models.md).
|
||||
- **Coming from [Real-Time-Voice-Cloning](https://github.com/CorentinJ/Real-Time-Voice-Cloning)?** [Migration guide](docs/migration/real-time-voice-cloning.md).
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<a id="why-voicestudio"></a>
|
||||
|
||||
## ⚖️ Why VoiceStudio
|
||||
|
||||
Cloud voice tools are convenient, but they put your workflow behind an account, a meter, and somebody else's infrastructure. VoiceStudio gives you a capable studio that runs on your hardware, with optional integrations when you choose them.
|
||||
|
||||
| | **ElevenLabs** | **VoiceStudio** |
|
||||
| | **VoiceStudio** | **Typical hosted voice service** |
|
||||
|---|---|---|
|
||||
| **Pricing** | Subscription and usage limits | Free & open-source (AGPL-3.0) · [Commercial license](#license) for proprietary use |
|
||||
| **Voice Cloning** | ✅ 3s clip | ✅ 3s clip, zero-shot |
|
||||
| **Voice Design** | ✅ Gender, age | ✅ Gender, age, accent, pitch, style, dialect |
|
||||
| **Audiobook / Stories** | ❌ | ✅ Full audiobook editor + multi-voice stories (EPUB/PDF import, .m4b export) |
|
||||
| **Languages** | Plan/model dependent | **646** |
|
||||
| **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 |
|
||||
| **Desktop App** | ❌ | ✅ macOS · Windows · Linux |
|
||||
| **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 |
|
||||
| **Best fit** | Private, offline, self-hosted, or high-volume work | Fast setup without local model management |
|
||||
| **Data path** | Local by default; remote features are opt-in | Audio and text are processed by the provider |
|
||||
| **Cost model** | Free software; you supply the hardware | Subscription, credits, or metered API use |
|
||||
| **Setup** | Install the app and model weights | Create an account and use the web app or API |
|
||||
| **Performance** | Depends on your engine and hardware | Provider manages compute and scaling |
|
||||
| **Offline use** | Yes, after required models are installed | Usually requires a network connection |
|
||||
| **Customization** | Source, engines, models, API, and routing are open | Limited to provider options |
|
||||
| **Maintenance** | You manage updates, disk, and compute | Provider manages infrastructure |
|
||||
|
||||
Professional-grade voice AI, minus the subscription and the cloud.
|
||||
<a id="requirements"></a>
|
||||
|
||||
<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>
|
||||
## Requirements
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ System Requirements
|
||||
Requirements vary by engine. These values cover the default local workflow.
|
||||
|
||||
| | **Minimum** | **Recommended** |
|
||||
|---|---|---|
|
||||
| **OS** | Windows 10, macOS 13.3+ (Apple Silicon), Ubuntu 24.04+ (glibc 2.39+) | Any modern 64-bit OS |
|
||||
| **OS** | Windows 10 x64 · macOS 13.3 Apple Silicon · Linux x86_64 with glibc 2.39+ | Current supported OS release |
|
||||
| **RAM** | 8 GB | 16 GB+ |
|
||||
| **VRAM (GPU)** | 4 GB (auto-offloads TTS to CPU) | 8 GB+ (NVIDIA RTX 3060+) |
|
||||
| **Disk** | 10 GB free (models + cache) | 20 GB+ SSD |
|
||||
| **Python** | 3.10+ (managed by `uv`) | 3.11–3.12 |
|
||||
| **GPU** | Optional — CPU works | NVIDIA CUDA · Apple Silicon MPS · AMD ROCm (Linux only) |
|
||||
| **Disk** | 10 GB free | 20 GB+ SSD |
|
||||
| **GPU** | Optional; CPU mode is supported | NVIDIA CUDA or Apple Silicon |
|
||||
| **VRAM** | 4 GB when using a GPU | 8 GB+; large optional engines need more |
|
||||
| **Python from source** | 3.11+ | 3.11–3.12 |
|
||||
|
||||
> [!NOTE]
|
||||
> **A GPU is optional** — the whole pipeline runs on CPU (just slower), and on ≤8 GB VRAM, TTS auto-offloads to CPU. Caveats: **AMD ROCm** is Linux-only + opt-in ([Linux](docs/install/linux.md#amd-gpu-rocm)) — Windows AMD/Ryzen AI is CPU-only ([Windows](docs/install/windows.md#gpu-support)); **macOS Intel** can't run the local backend, so point it at a remote one ([#889](https://github.com/debpalash/VoiceStudio/issues/889) · [macOS](docs/install/macos.md)).
|
||||
ROCm is Linux-only and opt-in. Windows AMD/Ryzen AI uses CPU. Systems with limited VRAM offload work to CPU when required. See [performance](docs/performance.md), [benchmarks](docs/benchmarks.md), and [engine disk usage](docs/engines/disk-usage.md).
|
||||
|
||||
<a id="engines"></a>
|
||||
|
||||
## Engines
|
||||
|
||||
Engine support is capability-specific. Check cloning, language, platform, memory, and license before choosing one. Full setup guides: [docs/engines](docs/engines/README.md).
|
||||
|
||||
<a id="tts-engines"></a>
|
||||
|
||||
### 🗣️ TTS Engines
|
||||
|
||||
**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> — 14 engines × platform × clone/instruct × license</summary>
|
||||
|
||||
<br/>
|
||||
### Text to speech
|
||||
|
||||
| Engine | Languages | Clone | Instruct | Linux | macOS ARM | Windows | License |
|
||||
|--------|:---------:|:-----:|:--------:|:-----:|:---------:|:-------:|:-------:|
|
||||
| **VoiceStudio** (default, powered by k2-fsa/OmniVoice) | 600+ | ✅ | ✅ | ✅ CUDA/CPU | ✅ MPS | ✅ CUDA/CPU | Built-in |
|
||||
| **CosyVoice 3** | 9 + 18 dialects | ✅ | ✅ | ✅ CUDA/CPU | ✅ MPS | ✅ CUDA/CPU | Apache-2.0 |
|
||||
| **GPT-SoVITS** | 5 | ✅ | — | ✅ CUDA/CPU | — | ✅ CUDA/CPU | MIT |
|
||||
| **VoxCPM2** | 30 | ✅ | ✅ | ✅ CUDA/CPU | ✅ MPS | ✅ CUDA/CPU | Apache-2.0 |
|
||||
| **MOSS-TTS-Nano** | 20 | ✅ | — | ✅ CUDA/CPU | ✅ CPU | ✅ CUDA/CPU | Apache-2.0 |
|
||||
| **KittenTTS** | English | — | — | ✅ CPU | ✅ CPU | ✅ CPU | MIT |
|
||||
| **MLX-Audio** (Kokoro, Qwen3-TTS, CSM, Dia, …) | Multi | Varies | Varies | ❌ | ✅ Native | ❌ | Varies |
|
||||
| **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 |
|
||||
| **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 |
|
||||
| **Confucius4-TTS** ⚡ | 14 | ✅ | — | ✅ CUDA/CPU | ✅ CPU | ✅ CUDA/CPU | Apache-2.0 |
|
||||
|---|:---:|:---:|:---:|:---:|:---:|:---:|---|
|
||||
| **VoiceStudio** (default, powered by k2-fsa/OmniVoice) | 600+ | Yes | Yes | CUDA/CPU | MPS | CUDA/CPU | [AGPL-3.0](LICENSE) app · [Apache-2.0](LICENSE-NOTICE.md) model |
|
||||
| **CosyVoice 3** | 9 + 18 dialects | Yes | Yes | CUDA/CPU | CPU | CUDA/CPU | Apache-2.0 |
|
||||
| **GPT-SoVITS** | 5 | Yes | — | CUDA/CPU | — | CUDA/CPU | MIT |
|
||||
| **VoxCPM2** | 30 | Yes | Yes | CUDA/CPU | MPS | CUDA/CPU | Apache-2.0 |
|
||||
| **MOSS-TTS-Nano** | 20 | Yes | — | CUDA/CPU | CPU | CUDA/CPU | Apache-2.0 |
|
||||
| **KittenTTS** | English | — | — | CPU | CPU | CPU | MIT |
|
||||
| **MLX-Audio** | Model-dependent | Varies | Varies | — | MLX | — | Varies |
|
||||
| **Sherpa-ONNX** | 20+ | — | — | CUDA/CPU | CPU | CUDA/CPU | Apache-2.0 |
|
||||
| **IndexTTS 2.5** ⚡ | ZH · EN · JA · ES · AR | Yes | — | CUDA/CPU | CPU | CUDA/CPU | Bilibili model license¹ |
|
||||
| **OmniVoice GGUF** ⚡ | 600+ | Yes | Yes | CUDA/CPU | MPS/CPU | CUDA/CPU | [AGPL-3.0](LICENSE) app · [Apache-2.0](LICENSE-NOTICE.md) model |
|
||||
| **OmniVoice (subprocess)** ⚡ | 600+ | Yes | Yes | CUDA/CPU | MPS | CUDA/CPU | [AGPL-3.0](LICENSE) app · [Apache-2.0](LICENSE-NOTICE.md) model |
|
||||
| **PocketTTS** ⚡ | EN · FR · DE · PT · IT · ES | Yes | — | CPU | CPU | CPU | CC-BY-4.0, gated² |
|
||||
| **Supertonic 3** ⚡ | 31 | — | — | CPU | CPU | CPU | OpenRAIL-M |
|
||||
| **MOSS-TTS-v1.5** ⚡ | 31 | Yes | — | CUDA/CPU | CPU | CUDA/CPU | Apache-2.0 |
|
||||
| **dots.tts** ⚡ | 24 | Yes | — | CUDA/CPU | CPU | — | Apache-2.0 |
|
||||
| **Confucius4-TTS** ⚡ | 14 | Yes | — | CUDA/CPU | CPU | CUDA/CPU | Apache-2.0 |
|
||||
|
||||
¹ IndexTTS 2.5 requires a separate written Bilibili license above 100 million
|
||||
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.
|
||||
⚡ Installed or registered on demand.
|
||||
|
||||
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.
|
||||
¹ IndexTTS 2.5 requires a separate written Bilibili license above 100 million monthly active users or RMB 1 billion annual revenue. Review the [model license](https://huggingface.co/IndexTeam/IndexTTS-2.5/blob/main/LICENSE).
|
||||
|
||||
> **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.
|
||||
>
|
||||
> **MOSS-TTS-v1.5** (8B, ~16 GB), **dots.tts** (2B, ~9 GB), and **Confucius4-TTS** are heavyweight opt-ins that run in their own isolated venv from a local clone. None claims Apple-Silicon MPS (CPU on Macs); dots.tts has no Windows path; Confucius4 wants CUDA (CPU works, ~17× realtime). Details: [MOSS-TTS-v1.5](docs/engines/moss-tts-v15.md) · [dots.tts](docs/engines/dots-tts.md) · [Confucius4-TTS](docs/engines/confucius4-tts.md).
|
||||
² PocketTTS shows its gated-access and CC-BY-4.0 terms before first use.
|
||||
|
||||
</details>
|
||||
Clone-less engines cannot preserve a reference speaker in dubbing or pinned-voice batch jobs. VoiceStudio rejects those jobs instead of silently changing engines. Heavy engines have separate memory and platform limits; check their engine guide first.
|
||||
|
||||
<a id="asr-engines"></a>
|
||||
|
||||
### 🎧 ASR Engines
|
||||
### Speech to text
|
||||
|
||||
**11 engines** — they power dictation, video dubbing, and subtitles. **WhisperX** is the cross-platform default (~100 languages, word-level timing); the rest are opt-in and auto-detected. Switch in **Settings → Engines**. Ten run fully on-device; the eleventh (OpenAI-compatible) is an optional remote client for Qwen3-ASR or any compatible server.
|
||||
| Engine | ID | Languages | Best fit |
|
||||
|---|---|:---:|---|
|
||||
| **WhisperX** (default) | `whisperx` | ~100 | Dubbing, subtitles, word-level timing |
|
||||
| **Faster-Whisper** | `faster-whisper` | ~100 | General cross-platform transcription |
|
||||
| **Faster-Whisper (isolated)** | `faster-whisper-isolated` | ~100 | Crash-isolated batch transcription |
|
||||
| **MLX Whisper** | `mlx-whisper` | ~100 | Apple Silicon |
|
||||
| **PyTorch Whisper** | `pytorch-whisper` | ~100 | CUDA, MPS, and CPU fallback |
|
||||
| **Parakeet TDT** | `nemo-parakeet` | English + 25 EU | Fast CPU/CUDA transcription |
|
||||
| **Parakeet TDT v3 (MLX)** | `parakeet-mlx` | 25 EU | Apple Silicon dictation and word timestamps |
|
||||
| **Moonshine** | `moonshine` | English | Low-power, low-latency ONNX |
|
||||
| **FunASR** | `funasr` | 50+ | VAD and inline diarization |
|
||||
| **sherpa-onnx** (live dictation) | `sherpa-onnx-asr` | Model-dependent | Streaming CPU dictation |
|
||||
| **OpenAI-compatible** ⚠️ remote | `openai-compat-asr` | Server-dependent | Qwen3-ASR or another compatible endpoint; audio leaves the machine |
|
||||
|
||||
<details>
|
||||
<summary><b>📊 The full lineup</b> — 11 engines, what each is best at, and compute-type notes</summary>
|
||||
WhisperX and Faster-Whisper retry with `int8` when efficient `float16` is unavailable. Pin `ASR_COMPUTE_TYPE=int8` or `float32` only if automatic selection still fails.
|
||||
|
||||
<br/>
|
||||
<a id="architecture"></a>
|
||||
|
||||
| Engine | `OMNIVOICE_ASR_BACKEND` | Languages | Best for |
|
||||
|--------|-------------------------|:---------:|----------|
|
||||
| **WhisperX** (default) | `whisperx` | ~100 | Dubbing & subtitles — word-level timing via wav2vec2 forced alignment |
|
||||
| **Faster-Whisper** | `faster-whisper` | ~100 | Fast transcription on Linux / macOS / Windows (CTranslate2) |
|
||||
| **Faster-Whisper (isolated)** | `faster-whisper-isolated` | ~100 | Same as Faster-Whisper but crash-isolated in a subprocess — an ASR crash won't take down the app |
|
||||
| **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 — TDT word timestamps, ~2 GB unified memory, dictation-grade speed on the GPU via MLX. Install the model from **Settings → 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 (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 **Settings → 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). |
|
||||
## Architecture
|
||||
|
||||
> 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 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>
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ 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`. Nothing external — every layer is on your machine.
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────┐
|
||||
│ Tauri v2 shell — Rust │
|
||||
│ window state · global dictation hotkey · system tray · │
|
||||
│ signed auto-updater (stable/preview) · single-instance · │
|
||||
│ first-run bootstrap (installs uv + Python venv) · blank guard │
|
||||
├────────────────────────────────────────────────────────────────────┤
|
||||
│ Frontend — React + Vite │
|
||||
│ 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 ×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) │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
```text
|
||||
Tauri v2 desktop shell (Rust)
|
||||
│ IPC
|
||||
React + Vite UI
|
||||
│ HTTP · SSE · WebSocket on localhost:3900
|
||||
FastAPI backend
|
||||
├── TTS / ASR engine registries
|
||||
├── dubbing / audio / long-form pipelines
|
||||
├── OpenAI-compatible API and MCP server
|
||||
└── SQLite + Alembic → omnivoice_data/
|
||||
```
|
||||
|
||||
- **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.
|
||||
| Layer | Path | Responsibility |
|
||||
|---|---|---|
|
||||
| Desktop shell | `frontend/src-tauri/` | Window lifecycle, tray, shortcuts, updater, sidecar bootstrap |
|
||||
| Frontend | `frontend/src/` | React UI, Zustand state, API and event clients, i18n |
|
||||
| API | `backend/api/` | REST routes, schemas, auth boundaries, streaming |
|
||||
| Core services | `backend/services/` | Generation, dubbing, audio processing, persistence |
|
||||
| Engines | `backend/engines/` | Isolated and optional engine adapters |
|
||||
| Worker system | `backend/worker/` | Authenticated remote compute and job transport |
|
||||
| Data | `omnivoice_data/` | Projects, voices, settings, logs, and SQLite state |
|
||||
| Delivery | `scripts/`, `deploy/`, `.github/workflows/` | Development, packaging, containers, releases, CI |
|
||||
|
||||
<a id="openai-api"></a>
|
||||
### Network boundary
|
||||
|
||||
## 🔌 OpenAI-compatible API
|
||||
- The desktop talks to a loopback-only backend on `localhost:3900`.
|
||||
- Loopback API calls need no server key. Remote access requires a share PIN or API key.
|
||||
- Remote workers and OpenAI-compatible ASR are opt-in. The UI identifies when audio leaves the machine.
|
||||
- Analytics is off until consent. If enabled, it sends allowlisted, content-free usage metadata—not text, audio, file names, or projects.
|
||||
|
||||
<div align="center">
|
||||
<a id="api"></a>
|
||||
|
||||
**Drop-in replacement for OpenAI / ElevenLabs audio.** One line — no key, no code changes:
|
||||
## OpenAI-compatible API
|
||||
|
||||
Point an OpenAI-compatible audio client at the local backend:
|
||||
|
||||
```diff
|
||||
- base_url="https://api.openai.com/v1"
|
||||
+ base_url="http://localhost:3900/v1"
|
||||
```
|
||||
|
||||
</div>
|
||||
|
||||
Your existing scripts, agents, and OpenAI/ElevenLabs SDK calls now run **locally** on whatever engine you have active. What the cloud can't do: `voice` takes **your own cloned-voice profile IDs**, and `model` can pin a **specific engine** per request.
|
||||
|
||||
| Endpoint | What it does |
|
||||
| Endpoint | Purpose |
|
||||
|---|---|
|
||||
| `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** — 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
|
||||
```
|
||||
| `POST /v1/audio/speech` | TTS to `mp3`, `opus`, `aac`, `flac`, `wav`, or `pcm`; select a profile with `voice` and an engine with `model` |
|
||||
| `POST /v1/audio/transcriptions` | STT to `json`, `text`, `verbose_json`, `srt`, or `vtt` |
|
||||
| `GET /v1/audio/voices` | List local voice profiles and engines |
|
||||
|
||||
```python
|
||||
from openai import OpenAI
|
||||
client = OpenAI(base_url="http://localhost:3900/v1", api_key="none") # any string — nothing checks it
|
||||
|
||||
# TTS with your cloned voice (or "alloy" / "default"; model= can pin a specific engine)
|
||||
client = OpenAI(base_url="http://localhost:3900/v1", api_key="local")
|
||||
|
||||
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")
|
||||
|
||||
# STT
|
||||
print(client.audio.transcriptions.create(model="whisper-1", file=open("clip.wav", "rb")).text)
|
||||
model="tts-1",
|
||||
voice="<profile-id>",
|
||||
input="Made on my own hardware.",
|
||||
response_format="wav",
|
||||
) as response:
|
||||
response.stream_to_file("speech.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.
|
||||
The full API reference is in **Settings → OpenAPI Reference**. For LAN, Tailscale, or proxy access, read [API authentication](docs/api-auth.md) before exposing the backend.
|
||||
|
||||
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.
|
||||
### Agent skills
|
||||
|
||||
### 📓 Run on Google Colab
|
||||
Install the VoiceStudio skills for Claude Code, Codex, Cursor, and other [skills.sh](https://skills.sh)-compatible agents:
|
||||
|
||||
[](https://colab.research.google.com/github/debpalash/VoiceStudio/blob/main/notebooks/VoiceStudio_Studio_Colab.ipynb)
|
||||
|
||||
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
|
||||
|
||||
Teach your coding agent to speak and listen through your local VoiceStudio — one command, works with **Claude Code, Codex, Cursor, Grok, Kimi, opencode**, and any [skills.sh](https://skills.sh)-compatible agent:
|
||||
|
||||
```sh
|
||||
```bash
|
||||
npx skills add debpalash/omnivoice-studio
|
||||
```
|
||||
|
||||
Ships two [skills](https://skills.sh):
|
||||
- `omnivoice`: synthesize speech and transcribe audio through local VoiceStudio.
|
||||
- `oss-maintainer`: the repository's open-source maintenance workflow.
|
||||
|
||||
- **`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.
|
||||
### Google Colab
|
||||
|
||||
---
|
||||
[](https://colab.research.google.com/github/debpalash/VoiceStudio/blob/main/notebooks/OmniVoice_Studio_Colab.ipynb)
|
||||
|
||||
## 🗺️ Roadmap
|
||||
The [notebook](notebooks/OmniVoice_Studio_Colab.ipynb) runs the app and web UI on a Colab GPU. Colab is remote compute, so uploaded audio and project data do not remain local to your machine.
|
||||
|
||||
### 🔜 Up Next
|
||||
<a id="documentation"></a>
|
||||
|
||||
- 🎬 **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
|
||||
## Documentation
|
||||
|
||||
| Need | Read |
|
||||
|---|---|
|
||||
| Install | [macOS](docs/install/macos.md) · [Windows](docs/install/windows.md) · [Linux](docs/install/linux.md) · [Docker](docs/install/docker.md) |
|
||||
| Fix setup | [Troubleshooting](docs/install/troubleshooting.md) · [model downloads](docs/downloading-models.md) · [Hugging Face token](docs/setup/huggingface-token.md) |
|
||||
| Choose an engine | [Engine guides](docs/engines/README.md) · [benchmarks](docs/benchmarks.md) · [expressive speech](docs/expressive-speech.md) |
|
||||
| Tune hardware | [Performance](docs/performance.md) · [remote workers](docs/remote-workers.md) |
|
||||
| Build integrations | [API auth](docs/api-auth.md) · [MCP](docs/mcp.md) · [examples](examples/README.md) |
|
||||
| Build VoiceStudio | [Contributing](.github/CONTRIBUTING.md) · [engine acceptance](docs/engine-acceptance.md) |
|
||||
| Track changes | [Changelog](CHANGELOG.md) · [roadmap](docs/ROADMAP.md) · [latest release](https://github.com/debpalash/VoiceStudio/releases/latest) |
|
||||
| Remove everything | [Uninstall guide](docs/install/uninstall.md) |
|
||||
|
||||
## FAQ
|
||||
|
||||
<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, glassmorphism design system, UI scale fix for Linux/WebKitGTK |
|
||||
| **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 |
|
||||
<summary><strong>Does it work on Apple Silicon and Intel Macs?</strong></summary>
|
||||
|
||||
Apple Silicon is supported with MPS and MLX options. Intel Macs cannot run the local backend because current PyTorch wheels are unavailable; they can connect to a remote backend. See [macOS installation](docs/install/macos.md).
|
||||
</details>
|
||||
|
||||
---
|
||||
<details>
|
||||
<summary><strong>How much VRAM do I need?</strong></summary>
|
||||
|
||||
<a id="sponsor--donate"></a>
|
||||
A GPU is optional. Use 4 GB VRAM as the minimum for accelerated work and 8 GB+ for the default multi-stage workflow. Large optional engines can require 12–16 GB or more. Check the [benchmarks](docs/benchmarks.md) and engine guide.
|
||||
</details>
|
||||
|
||||
## 💜 Sponsor / Donate
|
||||
<details>
|
||||
<summary><strong>Why does a longer reference clip not always improve the clone?</strong></summary>
|
||||
|
||||
One developer, real AI-agent bills. If VoiceStudio is useful to you, chipping in keeps development full-time — every dollar goes straight to the bills.
|
||||
Cloning is zero-shot: the clip is a prompt, not training data. Use 5–15 seconds of one speaker, close to the microphone, without music, noise, or reverb. Match the tone and pace you want in the output. For training, see [data preparation](docs/data_preparation.md) and [training](docs/training.md).
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Can I use generated audio commercially?</strong></summary>
|
||||
|
||||
Yes under VoiceStudio's AGPL-3.0 terms. Optional engines and model weights may use different licenses; review the selected engine's license before commercial use.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Does VoiceStudio collect data?</strong></summary>
|
||||
|
||||
Not unless you opt in. Analytics is off by default and skipping consent keeps it off. When enabled, the app sends allowlisted, content-free usage metadata. Text, audio, file names, voices, and projects are excluded. Change this at **Settings → Privacy**.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>How do I remove VoiceStudio and its data?</strong></summary>
|
||||
|
||||
Use `scripts/uninstall.sh` on macOS/Linux or `scripts\uninstall.ps1` on Windows. Both show a dry run before deletion. See the [uninstall guide](docs/install/uninstall.md) for every path.
|
||||
</details>
|
||||
|
||||
## Community and contributing
|
||||
|
||||
- [GitHub Issues](https://github.com/debpalash/VoiceStudio/issues) for reproducible bugs and feature requests.
|
||||
- [Discord](https://discord.gg/bzQavDfVV9) for setup help and project discussion.
|
||||
- [Good first issues](https://github.com/debpalash/VoiceStudio/labels/good%20first%20issue) for a scoped starting point.
|
||||
- [Contributing guide](.github/CONTRIBUTING.md) for setup, tests, and pull requests.
|
||||
|
||||
## Support development
|
||||
|
||||
VoiceStudio is free and has no paid tier. Donations fund development and infrastructure.
|
||||
|
||||
[Ko-fi](https://ko-fi.com/debpalash) · [PayPal](https://paypal.me/palashCoder) · [Sponsorship details](SPONSORS.md)
|
||||
|
||||
## License
|
||||
|
||||
VoiceStudio is licensed under [AGPL-3.0](LICENSE). You may run it, modify it, use it internally, and sell generated audio. If you modify VoiceStudio and provide that modified version as a network service, AGPL requires you to offer the corresponding source under the same license. A commercial license is available for proprietary embedding; contact **VoiceStudio@palash.dev**. See [LICENSE-NOTICE.md](LICENSE-NOTICE.md) for the plain-language scope.
|
||||
|
||||
Optional engines and downloaded models retain their own licenses. The bundled `omnivoice/` model remains Apache-2.0 upstream.
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
VoiceStudio builds on [OmniVoice](https://github.com/k2-fsa/OmniVoice), [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), and [PocketTTS](https://kyutai.org).
|
||||
|
||||
<div align="center">
|
||||
|
||||
<img src="https://img.shields.io/badge/raised_%2410_of_%24200-5%25-EAB308?style=for-the-badge" alt="This month's agent-bill fund: $10 / $200" />
|
||||
|
||||
<br/><br/>
|
||||
|
||||
<a href="https://ko-fi.com/debpalash"><img src="https://img.shields.io/badge/Ko--fi-Support_❤️-FF5E5B?style=for-the-badge&logo=ko-fi&logoColor=white" alt="Ko-fi" /></a>
|
||||
|
||||
<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>
|
||||
|
||||
### 🌟 Sponsors
|
||||
|
||||
VoiceStudio is **free** and **AGPL-3.0** — no paid tier, no SaaS revenue. Sponsors keep development going, and in return get a logo slot here, in the app, and (for top tiers) on the project website. It's a thank-you, never a paywall. **[See tiers & become a sponsor →](SPONSORS.md)**
|
||||
|
||||
<div align="center">
|
||||
|
||||
<!-- SPONSORS:START — logo slots are filled here as sponsors come aboard; see SPONSORS.md -->
|
||||
|
||||
**Your logo here** — [become a sponsor](SPONSORS.md)
|
||||
|
||||
<!-- SPONSORS:END -->
|
||||
|
||||
</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
|
||||
|
||||
<div align="center">
|
||||
<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>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.
|
||||
|
||||
- 📖 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 5–15 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/>
|
||||
Yes. MPS acceleration is auto-detected. MLX-optimized Whisper models are available for faster transcription on Apple hardware. <b>Intel Macs are not supported</b>: the app UI installs, but the local Python backend cannot run because PyTorch no longer ships Intel-Mac wheels (<a href="https://github.com/debpalash/VoiceStudio/issues/889">#889</a>) — an Intel Mac can only be used with a remote backend.
|
||||
</details>
|
||||
|
||||
<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).
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Can I use this commercially?</b></summary>
|
||||
<br/>
|
||||
<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 fourteen built-in engines all work this way; see <a href="#tts-engines">TTS Engines</a>.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Does VoiceStudio collect any data about me?</b></summary>
|
||||
<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 (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>
|
||||
<summary><b>How do I uninstall it / remove all its data?</b></summary>
|
||||
<br/>
|
||||
VoiceStudio is fully local — uninstalling is just deleting the app plus the folders it wrote (model cache, Python env, your voices/projects, config). Run <code>scripts/uninstall.sh</code> (macOS/Linux) or <code>scripts\uninstall.ps1</code> (Windows) — it prints every folder with its size as a dry-run first, then deletes on <code>--yes</code>. The full per-platform path list and app-removal steps are in <a href="docs/install/uninstall.md"><b>docs/install/uninstall.md</b></a>.
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<a id="license"></a>
|
||||
|
||||
## 📜 License
|
||||
|
||||
VoiceStudio is free and open-source software under the [**GNU Affero General Public License v3.0 (AGPL-3.0)**](https://www.gnu.org/licenses/agpl-3.0.html).
|
||||
|
||||
**Free for any use — including commercial and internal business use.** Run it, sell the audio you produce with it, dub your own or clients' videos, roll it out across your team — all free, no license needed. As a **network copyleft** license, AGPL adds one obligation: if you **modify** VoiceStudio and offer that modified version to others over a network, you must make the complete corresponding source of your modified version available to them under the same AGPL-3.0 terms.
|
||||
|
||||
A **commercial license** is available for organizations that want to embed VoiceStudio in a **closed-source or proprietary** product or service without the AGPL-3.0 copyleft obligations. **Pricing tiers coming soon.** Inquiries: **VoiceStudio@palash.dev**.
|
||||
|
||||
The bundled `omnivoice/` TTS model by Han Zhu remains Apache-2.0 upstream. See [`LICENSE`](LICENSE) for the full, binding terms, and [`LICENSE-NOTICE.md`](LICENSE-NOTICE.md) for the plain-language summary and scope.
|
||||
|
||||
---
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
|
||||
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
|
||||
|
||||
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>
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
<br/>
|
||||
|
||||
If you read this far, you're our kind of person.<br/>
|
||||
**[⭐ Star this repo](https://github.com/debpalash/VoiceStudio)** so others can find it too.<br/>
|
||||
**[💬 Join the Discord](https://discord.gg/bzQavDfVV9)** to share what you build.<br/>
|
||||
**[❤️ Support development](https://ko-fi.com/debpalash)** — fund the AI agent bills that keep VoiceStudio shipping.
|
||||
|
||||
<br/>
|
||||
|
||||
<a href="https://star-history.com/#debpalash/VoiceStudio&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=debpalash/VoiceStudio&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=debpalash/VoiceStudio&type=Date" />
|
||||
<img alt="Star History" src="https://api.star-history.com/svg?repos=debpalash/VoiceStudio&type=Date&theme=dark" width="600" />
|
||||
</picture>
|
||||
</a>
|
||||
<strong><a href="https://github.com/debpalash/VoiceStudio/releases/latest">Download VoiceStudio</a></strong> ·
|
||||
<a href="https://github.com/debpalash/VoiceStudio">Star the project</a> ·
|
||||
<a href="https://discord.gg/bzQavDfVV9">Join Discord</a>
|
||||
</div>
|
||||
|
||||
+60
-53
@@ -37,7 +37,7 @@
|
||||
<br/>
|
||||
|
||||
<div align="center">
|
||||
<img src="docs/screenshot-launchpad.png" alt="VoiceStudio — 启动台" width="100%"/>
|
||||
<img src="docs/media/0.5.0/quick-switch.gif" alt="VoiceStudio — 从状态栏快速切换 TTS 引擎" width="100%"/>
|
||||
</div>
|
||||
|
||||
> **声音很私人,创作空间也应该真正属于你。** VoiceStudio 的核心流程运行在你的硬件上:克隆、设计、配音、听写,并以 646 种语言创作,不需要订阅,也没有用量计费。联网引擎和服务始终是清晰可见的可选项,而不是隐藏依赖。
|
||||
@@ -45,6 +45,56 @@
|
||||
> [!WARNING]
|
||||
> **活跃 Beta 阶段。** 各版本之间可能出现故障——如需最新修复,请从源码运行。非常欢迎 Bug 报告和 PR:[提交 Issue](https://github.com/debpalash/VoiceStudio/issues) 或 [加入 Discord](https://discord.gg/bzQavDfVV9)。
|
||||
|
||||
<a id="quickstart"></a>
|
||||
|
||||
## ⚡ 快速开始
|
||||
|
||||
<div align="center">
|
||||
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/badge/macOS-DMG_(Apple_Silicon)-000?style=for-the-badge&logo=apple&logoColor=white" alt="下载 macOS DMG" /></a>
|
||||
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/badge/Windows-MSI_(x64)-0078D4?style=for-the-badge&logo=windows&logoColor=white" alt="下载 Windows MSI" /></a>
|
||||
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/badge/Linux-AppImage_(x64)-FCC624?style=for-the-badge&logo=linux&logoColor=black" alt="下载 Linux AppImage" /></a>
|
||||
<br/>
|
||||
<sub>三个按钮都会打开最新发布页——在资源列表中下载对应你系统的安装包。</sub><br/>
|
||||
<sub><b>macOS:</b>首次启动需要一次性批准——右键点击 → <b>打开</b>(macOS 15 上为 系统设置 → 隐私与安全性 → <b>“仍要打开”</b>)。无需终端。<a href="docs/install/macos.md#gatekeeper-quarantine">为什么?</a> · <b>Intel Mac:</b>不支持本地后端(<a href="https://github.com/debpalash/VoiceStudio/issues/889">#889</a>)——<a href="docs/install/macos.md">详情</a>。</sub>
|
||||
</div>
|
||||
|
||||
选择你的操作系统,按指南从头到尾操作:
|
||||
|
||||
- 🍎 **macOS** — [docs/install/macos.md](docs/install/macos.md)
|
||||
- 🪟 **Windows** — [docs/install/windows.md](docs/install/windows.md)
|
||||
- 🐧 **Linux** — [docs/install/linux.md](docs/install/linux.md)
|
||||
- 🐳 **Docker** — [docs/install/docker.md](docs/install/docker.md) · [Docker Hub: `palashdeb/omnivoice-studio`](https://hub.docker.com/r/palashdeb/omnivoice-studio)
|
||||
|
||||
**三步克隆出你的第一个声音:**
|
||||
|
||||
1. **安装并启动。** 首次启动会自动搭建 Python 运行环境并下载模型权重——启动画面会逐步显示进度(仅首次,需要几分钟;之后即开即用)。
|
||||
2. 从启动台打开**语音克隆**,拖入任意声音的 **3 秒音频**。
|
||||
3. **输入一句话,点击生成。** 音频完全属于你——在你的设备上生成和保存,支持 646 种语言。
|
||||
|
||||
觉得慢?[docs/performance.md](docs/performance.md) 讲清了生成时间到底花在哪里、有哪些调优开关,以及“它变慢了”的三个经典原因。各引擎/设备的实测数据见 [docs/benchmarks.md](docs/benchmarks.md)。
|
||||
|
||||
> 正在从 **[CorentinJ/Real-Time-Voice-Cloning](https://github.com/CorentinJ/Real-Time-Voice-Cloning)**(现已归档)迁移过来?我们有专门的迁移指南:[docs/migration/real-time-voice-cloning.md](docs/migration/real-time-voice-cloning.md)。
|
||||
|
||||
<details>
|
||||
<summary><b>🧰 卡住了?自检、Token 与受限网络</b></summary>
|
||||
|
||||
<br/>
|
||||
|
||||
先运行内置自检——在应用中打开 **设置 → 关于 → “运行自检”**,或在源码检出目录中执行
|
||||
`uv run python backend/main.py --diagnose`(加 `--deep` 还会实际加载当前引擎进行测试)。然后查看
|
||||
[docs/install/troubleshooting.md](docs/install/troubleshooting.md) 中排名前
|
||||
10 的安装错误。运行时出错时,应用内的错误界面会直接深链到对应条目;**设置 → 关于 →
|
||||
“保存诊断包”** 会把脱敏日志与自检报告打包,方便附在 Bug 报告里。
|
||||
|
||||
Hugging Face Token 的配置见
|
||||
[docs/setup/huggingface-token.md](docs/setup/huggingface-token.md)。说话人分离相关的模型访问门槛见
|
||||
[docs/features/diarization.md](docs/features/diarization.md)。下载速度、⚡ 快速下载(Xet)状态,以及受限网络 / 镜像选项见
|
||||
[docs/downloading-models.md](docs/downloading-models.md)。
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<a id="features"></a>
|
||||
|
||||
## ✨ 功能
|
||||
@@ -112,49 +162,6 @@
|
||||
|
||||
---
|
||||
|
||||
<a id="quickstart"></a>
|
||||
|
||||
## ⚡ 快速开始
|
||||
|
||||
<div align="center">
|
||||
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/badge/macOS-DMG_(Apple_Silicon)-000?style=for-the-badge&logo=apple&logoColor=white" alt="下载 macOS DMG" /></a>
|
||||
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/badge/Windows-MSI_(x64)-0078D4?style=for-the-badge&logo=windows&logoColor=white" alt="下载 Windows MSI" /></a>
|
||||
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/badge/Linux-AppImage_(x64)-FCC624?style=for-the-badge&logo=linux&logoColor=black" alt="下载 Linux AppImage" /></a>
|
||||
<br/>
|
||||
<sub><b>macOS:</b>首次启动需要一次性批准——右键点击 → <b>打开</b>(macOS 15 上为 系统设置 → 隐私与安全性 → <b>“仍要打开”</b>)。无需终端。<a href="docs/install/macos.md#gatekeeper-quarantine">为什么?</a> · <b>Intel Mac:</b>不支持本地后端(<a href="https://github.com/debpalash/VoiceStudio/issues/889">#889</a>)——<a href="docs/install/macos.md">详情</a>。</sub>
|
||||
</div>
|
||||
|
||||
选择你的操作系统,按指南从头到尾操作:
|
||||
|
||||
- 🍎 **macOS** — [docs/install/macos.md](docs/install/macos.md)
|
||||
- 🪟 **Windows** — [docs/install/windows.md](docs/install/windows.md)
|
||||
- 🐧 **Linux** — [docs/install/linux.md](docs/install/linux.md)
|
||||
- 🐳 **Docker** — [docs/install/docker.md](docs/install/docker.md) · [Docker Hub: `palashdeb/omnivoice-studio`](https://hub.docker.com/r/palashdeb/omnivoice-studio)
|
||||
|
||||
觉得慢?[docs/performance.md](docs/performance.md) 讲清了生成时间到底花在哪里、有哪些调优开关,以及“它变慢了”的三个经典原因。
|
||||
|
||||
> 正在从 **[CorentinJ/Real-Time-Voice-Cloning](https://github.com/CorentinJ/Real-Time-Voice-Cloning)**(现已归档)迁移过来?我们有专门的迁移指南:[docs/migration/real-time-voice-cloning.md](docs/migration/real-time-voice-cloning.md)。
|
||||
|
||||
<details>
|
||||
<summary><b>🧰 卡住了?自检、Token 与受限网络</b></summary>
|
||||
|
||||
<br/>
|
||||
|
||||
先运行内置自检——在应用中打开 **设置 → 关于 → “运行自检”**,或在源码检出目录中执行
|
||||
`uv run python backend/main.py --diagnose`(加 `--deep` 还会实际加载当前引擎进行测试)。然后查看
|
||||
[docs/install/troubleshooting.md](docs/install/troubleshooting.md) 中排名前
|
||||
10 的安装错误。运行时出错时,应用内的错误界面会直接深链到对应条目;**设置 → 关于 →
|
||||
“保存诊断包”** 会把脱敏日志与自检报告打包,方便附在 Bug 报告里。
|
||||
|
||||
Hugging Face Token 的配置见
|
||||
[docs/setup/huggingface-token.md](docs/setup/huggingface-token.md)。说话人分离相关的模型访问门槛见
|
||||
[docs/features/diarization.md](docs/features/diarization.md)。下载速度、⚡ 快速下载(Xet)状态,以及受限网络 / 镜像选项见
|
||||
[docs/downloading-models.md](docs/downloading-models.md)。
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<a id="why-voicestudio"></a>
|
||||
|
||||
## 💡 为什么选择 VoiceStudio?
|
||||
@@ -173,8 +180,8 @@ Hugging Face Token 的配置见
|
||||
| **API 密钥** | 需要账号 | 本地流程不需要 |
|
||||
| **GPU 支持** | 不适用(云端) | CUDA · Apple Silicon · ROCm(Linux)· CPU |
|
||||
| **桌面应用** | ❌ | ✅ macOS · Windows · Linux |
|
||||
| **TTS 引擎** | 1 | **14** — [完整矩阵](#tts-engines) |
|
||||
| **ASR 引擎** | 1 | **10** — [完整阵容](#asr-engines) |
|
||||
| **TTS 引擎** | 1 | **16** — [完整矩阵](#tts-engines) |
|
||||
| **ASR 引擎** | 1 | **11** — [完整阵容](#asr-engines) |
|
||||
| **MCP 服务器** | ❌ | ✅ 可从 Claude、Cursor 及任何 MCP 客户端使用 |
|
||||
| **自检** | ❌ | ✅ 诊断套件、错误日志、脱敏调试包 |
|
||||
| **可定制** | ❌ 闭源 | ✅ 随你 Fork、扩展、发布 |
|
||||
@@ -214,10 +221,10 @@ Hugging Face Token 的配置见
|
||||
|
||||
### 🗣️ TTS 引擎
|
||||
|
||||
**14 个引擎,一个选择器。** VoiceStudio(默认,支持 600+ 语言)始终可用;另有七个引擎可选装并自动检测(CosyVoice 3、GPT-SoVITS、VoxCPM2、MOSS-TTS-Nano、KittenTTS、MLX-Audio、Sherpa-ONNX),外加六个按需延迟安装的重量级引擎(IndexTTS 2.5、OmniVoice GGUF、Supertonic 3、MOSS-TTS-v1.5、dots.tts、Confucius4-TTS)。在 **设置 → TTS 引擎** 中切换;所选引擎将应用于所有语音合成场景。
|
||||
**16 个引擎,一个选择器。** VoiceStudio(默认,支持 600+ 语言)始终可用;另有七个引擎可选装并自动检测(CosyVoice 3、GPT-SoVITS、VoxCPM2、MOSS-TTS-Nano、KittenTTS、MLX-Audio、Sherpa-ONNX),外加八个按需延迟安装的引擎(IndexTTS 2.5、OmniVoice GGUF、OmniVoice 子进程版、PocketTTS、Supertonic 3、MOSS-TTS-v1.5、dots.tts、Confucius4-TTS)。在 **设置 → TTS 引擎** 中切换;所选引擎将应用于所有语音合成场景。**每个引擎都有独立指南:[docs/engines](docs/engines/README.md)(英文)。**
|
||||
|
||||
<details>
|
||||
<summary><b>📊 完整矩阵</b>——14 个引擎 × 平台 × 克隆/指令 × 许可证</summary>
|
||||
<summary><b>📊 完整矩阵</b>——16 个引擎 × 平台 × 克隆/指令 × 许可证</summary>
|
||||
|
||||
<br/>
|
||||
|
||||
@@ -254,10 +261,10 @@ Hugging Face Token 的配置见
|
||||
|
||||
### 🎧 ASR 引擎
|
||||
|
||||
**10 个引擎**——它们驱动听写、视频配音和字幕。**WhisperX** 是跨平台的默认引擎(约 100 种语言,词级时间对齐);其余引擎均为可选装并自动检测。在 **设置 → 引擎** 中切换。九个完全在本地设备上运行;第十个(OpenAI 兼容)是可选的远程客户端,可用于 Qwen3-ASR 或任何兼容的服务器。
|
||||
**11 个引擎**——它们驱动听写、视频配音和字幕。**WhisperX** 是跨平台的默认引擎(约 100 种语言,词级时间对齐);其余引擎均为可选装并自动检测。在 **设置 → 引擎** 中切换。十个完全在本地设备上运行;第十一个(OpenAI 兼容)是可选的远程客户端,可用于 Qwen3-ASR 或任何兼容的服务器。
|
||||
|
||||
<details>
|
||||
<summary><b>📊 完整阵容</b>——10 个引擎、各自的强项与计算类型说明</summary>
|
||||
<summary><b>📊 完整阵容</b>——11 个引擎、各自的强项与计算类型说明</summary>
|
||||
|
||||
<br/>
|
||||
|
||||
@@ -274,7 +281,7 @@ Hugging Face Token 的配置见
|
||||
| **sherpa-onnx**(实时听写) | `sherpa-onnx-asr` | 25 种欧洲语言 + 90+ | 实时、快于实时的听写——小体积流式/离线 ONNX 模型(Parakeet TDT v3/v2、流式 Zipformer 与 Paraformer、Whisper Tiny),CPU 运行,macOS / Windows / Linux 表现完全一致。在 **设置 → 语音** 中按模型选择。 |
|
||||
| **OpenAI 兼容** ⚠️ 远程 | `openai-compat-asr` | 取决于服务器 | 当下通往 **Qwen3-ASR** 的路径(自托管服务器,无需等 transformers 支持)、任何 OpenAI 兼容的转录端点,或 OpenAI 官方 API——无需安装,在 **设置 → 引擎**(ASR 标签页)中配置并测试连接。音频会离开你的设备,发送到你指定的任何服务器;参见 [docs/engines/openai-compatible-asr.md](docs/engines/openai-compatible-asr.md)。 |
|
||||
|
||||
> Whisper 系列引擎覆盖约 100 种语言;**FunASR / SenseVoice** 额外提供一条多语言一体化路径,内置语音活动检测与行内说话人分离。**sherpa-onnx** 驱动实时听写的模型选择器——你边说,文字边出现。每个引擎都在本地设备上运行——无需 API 密钥,无需云端。
|
||||
> Whisper 系列引擎覆盖约 100 种语言;**FunASR / SenseVoice** 额外提供一条多语言一体化路径,内置语音活动检测与行内说话人分离。**sherpa-onnx** 驱动实时听写的模型选择器——你边说,文字边出现。除可选的 OpenAI 兼容远程客户端外,所有引擎都在本地设备上运行——无需 API 密钥,无需云端。
|
||||
|
||||
> **GPU 不支持高效 float16?** 在较老的 NVIDIA GPU(Maxwell/Pascal、GTX 16xx)上,或在 CTranslate2/cuDNN 版本不匹配之后,CTranslate2 系 ASR 引擎(WhisperX、Faster-Whisper)无法运行 `float16`,VoiceStudio 会自动改用 `int8` 重试——无需配置。如果转录仍然失败,可用 `ASR_COMPUTE_TYPE` 环境变量固定计算类型(逃生舱口):`ASR_COMPUTE_TYPE=int8`(CPU 用 `float32`)。将其设为 `int8` 并重启后端。
|
||||
|
||||
@@ -373,7 +380,7 @@ npx skills add debpalash/omnivoice-studio
|
||||
| **TTS** | 14 个引擎(VoiceStudio、CosyVoice 3、GPT-SoVITS、VoxCPM2、MOSS-TTS-Nano、KittenTTS、MLX-Audio、Sherpa-ONNX,+ 延迟安装:IndexTTS 2.5、OmniVoice GGUF、Supertonic 3、MOSS-TTS-v1.5、dots.tts、Confucius4-TTS)、带 GPU 预检的引擎路由 |
|
||||
| **基础设施** | Docker 部署、CUDA/MPS/ROCm 自动检测、cuDNN 8 兼容、显存感知模型卸载、引擎路由(绝不静默回退 CPU)、诊断套件与错误日志、受限网络镜像支持 |
|
||||
| **AI 溯源** | AudioSeal 不可见水印(类似 SynthID)、视频徽标叠加、水印检测 API |
|
||||
| **用户体验** | 撤销/重做、键盘快捷键、拖放、会话持久化、毛玻璃设计系统、Linux/WebKitGTK 的 UI 缩放修复 |
|
||||
| **用户体验** | 撤销/重做、键盘快捷键、拖放、会话持久化、首次启动按屏幕推荐界面缩放,以及原生 WebKitGTK 缩放 |
|
||||
| **实时事件** | WebSocket 事件总线——数据变更时即时刷新侧边栏、指数退避重连 |
|
||||
| **状态管理** | Zustand 状态迁移——`uiSlice`、`pillSlice`、`dubSlice`、`generateSlice`、`prefsSlice`、`glossarySlice` |
|
||||
| **桌面** | 跨平台 Tauri 安装程序(macOS DMG——Apple Silicon;Intel 不支持本地后端,#889——Windows MSI、Linux deb/AppImage)、自动更新基础设施、单实例约束、关闭最小化到托盘、macOS Gatekeeper 修复 |
|
||||
@@ -574,7 +581,7 @@ VoiceStudio 站在这些杰出开源工作的肩膀上:
|
||||
|
||||
## 🧰 来自同一作者的更多本地开源项目
|
||||
|
||||
喜欢这种本地优先的理念?它是一脉相承的——同一位作者,同一条准则:**你的数据只留在你的设备上。**
|
||||
喜欢这种本地优先的理念?它是一脉相承的——同一位作者,同一条准则:**你的数据只留在你的设备上。** 全部项目见 [palash.dev](https://palash.dev)。
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
|
||||
@@ -41,6 +41,16 @@ hiddenimports = [
|
||||
# even though pyproject.toml ships the package. Guarded by
|
||||
# tests/test_socks_proxy.py.
|
||||
'socksio',
|
||||
# Remote GPU workers (backend/worker/). The feature is opt-in, so every
|
||||
# import of it is deliberately deferred to the moment it is switched on —
|
||||
# inside `lifespan` and inside `ControlPlane.start()`. That keeps the cost
|
||||
# off users who never enable it, but it also means a frozen build has no
|
||||
# static import chain to follow, so the modules must be named here or the
|
||||
# feature raises ModuleNotFoundError only in the installers.
|
||||
'grpc', 'grpc.aio',
|
||||
'worker.service', 'worker.agent',
|
||||
'worker.transport.server', 'worker.transport.client',
|
||||
'worker.protocol.gen.worker_v1_pb2', 'worker.protocol.gen.worker_v1_pb2_grpc',
|
||||
|
||||
# Core
|
||||
'uuid', 'asyncio',
|
||||
|
||||
+86
-118
@@ -6,7 +6,10 @@ composed at the route or router level without surprises.
|
||||
|
||||
Currently exposed:
|
||||
- `require_loopback`: 403 unless the request came from a loopback origin
|
||||
(bypassed in explicit server mode — see `_server_mode`).
|
||||
(read-only bootstrap is allowed in explicit server mode; mutations still
|
||||
require the admin API key — see `_server_mode`).
|
||||
- `require_admin`: method-aware admin gate for privileged routers.
|
||||
- `require_admin_action`: strict admin gate for side-effectful GET actions.
|
||||
- `require_native_access`: true-loopback-only access to the host filesystem;
|
||||
unlike `require_loopback`, it is never bypassed by server mode.
|
||||
- `ws_remote_authorized`: whether a WebSocket handshake from a non-loopback
|
||||
@@ -14,64 +17,19 @@ Currently exposed:
|
||||
keep their own inline loopback guards.
|
||||
"""
|
||||
|
||||
import ipaddress
|
||||
import os
|
||||
import secrets
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
|
||||
# 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_loopback`` → ``/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())
|
||||
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
|
||||
|
||||
_TRUTHY = frozenset({"1", "true", "yes", "on"})
|
||||
|
||||
@@ -107,40 +65,45 @@ def _configured_pin(request) -> str | None:
|
||||
|
||||
|
||||
def _admin_credential_configured(request) -> bool:
|
||||
"""Whether the operator has set ANY credential gate — the remote API key or
|
||||
a share PIN. When neither is set, server mode leaves admin open (the Docker
|
||||
issue #261 flow the image depends on)."""
|
||||
if os.environ.get("OMNIVOICE_API_KEY"):
|
||||
"""Whether an API key or share PIN is configured.
|
||||
|
||||
The PIN cannot authorize admin access, but its presence means the operator
|
||||
opted out of bare-server discovery. Remote admin then remains closed until
|
||||
they configure and present the long API key.
|
||||
"""
|
||||
if remote_api_key():
|
||||
return True
|
||||
return bool(_configured_pin(request))
|
||||
|
||||
|
||||
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).
|
||||
def _request_presents_admin_credential(
|
||||
request,
|
||||
*,
|
||||
side_effectful_get: bool = False,
|
||||
) -> bool:
|
||||
"""Whether the canonical principal carries remote admin capability.
|
||||
|
||||
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 = os.environ.get("OMNIVOICE_API_KEY") or ""
|
||||
if not api_key:
|
||||
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,
|
||||
}:
|
||||
return False
|
||||
headers = getattr(request, "headers", None) or {}
|
||||
query = getattr(request, "query_params", None) or {}
|
||||
cookies = getattr(request, "cookies", None) or {}
|
||||
|
||||
auth = headers.get("authorization", "")
|
||||
supplied = auth[7:].strip() if auth.lower().startswith("bearer ") else ""
|
||||
if not supplied:
|
||||
supplied = query.get("api_key") or cookies.get("ov_key") or ""
|
||||
return bool(supplied and secrets.compare_digest(supplied, api_key))
|
||||
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
|
||||
|
||||
|
||||
def require_loopback(request: Request) -> None:
|
||||
@@ -163,9 +126,9 @@ def require_loopback(request: Request) -> None:
|
||||
unenforceable, so the gate can't require true loopback. It then applies the
|
||||
admin-credential rule instead:
|
||||
|
||||
- No credential configured (no API key, no PIN) → open, matching the #261
|
||||
Docker flow where the operator reaches ``/system/*`` off the bridge
|
||||
gateway with nothing set.
|
||||
- No credential configured (no API key, no PIN) → read-only requests are
|
||||
open, matching the #261 Docker bootstrap flow. State-changing requests
|
||||
fail closed even if a route accidentally kept this legacy dependency.
|
||||
- A credential IS configured → the request must present the **API key**.
|
||||
This keeps the two-tier privilege model intact under server mode:
|
||||
``OMNIVOICE_TRUSTED_NETWORKS`` is a *consumption* exemption
|
||||
@@ -173,14 +136,20 @@ def require_loopback(request: Request) -> None:
|
||||
NEVER by itself unlock the admin surface (``/system/set-env`` — RCE-class —
|
||||
and ``/api/settings/*``). The 6-digit share PIN is a consumption credential
|
||||
too and does not gate admin, so a PIN-only deployment keeps admin
|
||||
loopback-only; remote admin requires the (long) API key. A LAN client in a
|
||||
trusted CIDR — or one holding only the PIN — gets 403 here even though it
|
||||
sails through the consumption gates. See docs/api-auth.md (#1213).
|
||||
loopback-only; remote admin requires the long API key. See
|
||||
docs/api-auth.md (#1213).
|
||||
"""
|
||||
host = request.client.host if request.client else None
|
||||
if is_loopback(host):
|
||||
return
|
||||
if _server_mode():
|
||||
method = str(getattr(request, "method", "GET")).upper()
|
||||
if method not in SAFE_HTTP_METHODS:
|
||||
# Defense in depth. Privileged routers should declare
|
||||
# ``require_admin`` directly, but a missed migration must not turn
|
||||
# into an unauthenticated Docker write primitive.
|
||||
require_admin(request)
|
||||
return
|
||||
if not _admin_credential_configured(request):
|
||||
return
|
||||
if _request_presents_admin_credential(request):
|
||||
@@ -206,14 +175,32 @@ def require_admin(request: Request) -> None:
|
||||
return
|
||||
if _server_mode():
|
||||
method = str(getattr(request, "method", "GET")).upper()
|
||||
read_only = method in {"GET", "HEAD", "OPTIONS"}
|
||||
if read_only and not os.environ.get("OMNIVOICE_API_KEY", "").strip():
|
||||
read_only = method in SAFE_HTTP_METHODS
|
||||
if read_only and not _admin_credential_configured(request):
|
||||
return
|
||||
if _request_presents_admin_credential(request):
|
||||
return
|
||||
raise HTTPException(status_code=403, detail="loopback origin or admin API key required")
|
||||
|
||||
|
||||
def require_admin_action(request: Request) -> None:
|
||||
"""Gate an administrative action even when its HTTP method is read-only.
|
||||
|
||||
A small number of legacy GET endpoints have real side effects. For example,
|
||||
an engine health check may spawn a sidecar process. Such routes cannot use
|
||||
:func:`require_admin`'s bare-server discovery exception.
|
||||
"""
|
||||
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,
|
||||
):
|
||||
return
|
||||
raise HTTPException(status_code=403, detail="loopback origin or admin API key required")
|
||||
|
||||
|
||||
def require_desktop(request: Request) -> None:
|
||||
"""Gate capabilities that may select or execute host filesystem paths.
|
||||
|
||||
@@ -232,9 +219,10 @@ def require_local(request: Request) -> None:
|
||||
trusted network. The consumption-tier companion to :func:`require_loopback`:
|
||||
use on routes a trusted-network client (LAN/proxy) should reach without a PIN
|
||||
or API key — e.g. the dictation model/prefs endpoints that pair with the
|
||||
dictation WebSocket. Admin routes stay on :func:`require_loopback`.
|
||||
dictation WebSocket. Admin routes stay on :func:`require_admin`.
|
||||
|
||||
In server mode the gate is a no-op (same as :func:`require_loopback`)."""
|
||||
In server mode this consumption gate is a no-op. Admin dependencies remain
|
||||
method-aware and independent from this exemption."""
|
||||
host = request.client.host if request.client else None
|
||||
if is_local_host(host):
|
||||
return
|
||||
@@ -256,29 +244,9 @@ def require_native_access(request: Request) -> None:
|
||||
raise HTTPException(status_code=403, detail="native filesystem access requires loopback origin")
|
||||
|
||||
|
||||
def remote_api_key() -> str | None:
|
||||
"""The remote-backend bearer key (Wave 2.3), or None when remote mode is
|
||||
off. Read at call time so tests can monkeypatch the env."""
|
||||
return os.environ.get("OMNIVOICE_API_KEY") or None
|
||||
|
||||
|
||||
def ws_remote_authorized(websocket) -> bool:
|
||||
"""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
|
||||
auth = websocket.headers.get("authorization", "")
|
||||
supplied = auth[7:].strip() if auth.lower().startswith("bearer ") else ""
|
||||
if not supplied:
|
||||
supplied = (
|
||||
websocket.query_params.get("api_key")
|
||||
or websocket.cookies.get("ov_key")
|
||||
or ""
|
||||
)
|
||||
return secrets.compare_digest(supplied, key)
|
||||
"""Whether the canonical WS principal has a remote admin credential."""
|
||||
return principal_for(websocket).kind in {
|
||||
PrincipalKind.API_KEY,
|
||||
PrincipalKind.ADMIN_SESSION,
|
||||
}
|
||||
|
||||
@@ -15,22 +15,33 @@ Design notes
|
||||
* Previews are cached on disk keyed by a hash of (instruct, language), so two
|
||||
archetypes that resolve to the same voice share a cache file and the cold
|
||||
render only happens once per distinct voice.
|
||||
* That same key names the pre-rendered clips in the opt-in voice gallery
|
||||
(``services.gallery``), which is consulted BEFORE the engine so a fresh
|
||||
install can hear voices before the 2.4 GB checkpoint finishes downloading.
|
||||
Gallery files win over a local render of the same key — but only for
|
||||
``/preview``. ``/use`` always renders locally: the WAV it keeps in
|
||||
``VOICES_DIR`` is the reference audio a cloned voice is built from, and a
|
||||
downloaded MP3 must never become that.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
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
|
||||
|
||||
logger = logging.getLogger("omnivoice.archetypes")
|
||||
|
||||
@@ -61,6 +72,153 @@ 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.
|
||||
@@ -214,6 +372,49 @@ async def _render_archetype_wav(a: dict, out_path: Path) -> None:
|
||||
_safe_torchaudio_save(str(out_path), audio_tensor, model.sampling_rate)
|
||||
|
||||
|
||||
def _no_voice_model_downloaded() -> bool:
|
||||
"""True only on a *positive* "no TTS weights on this machine" answer.
|
||||
|
||||
Fails open on purpose: the cache probes are best-effort (a user-managed
|
||||
clone outside the HF layout is invisible to them), and telling someone with
|
||||
a working engine to go download a model is worse than saying nothing. Only
|
||||
a catalog we could read, with not one TTS repo cached, earns the offline
|
||||
message.
|
||||
"""
|
||||
try:
|
||||
from api.routers.setup.models import get_model_catalog, is_cached
|
||||
|
||||
tts = [m for m in get_model_catalog().all if m.get("role") == "TTS"]
|
||||
return bool(tts) and not any(is_cached(m["repo_id"]) for m in tts)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _preview_source(a: dict) -> tuple[str, str]:
|
||||
"""Which path ``/preview`` will take for *a*, and what to tell the user.
|
||||
|
||||
Replaces the old "see Settings → Logs → Backend" advice, which asked a user
|
||||
who wanted to hear a voice to go read a log file. The three states that
|
||||
actually differ are: we already have the audio (gallery), we can make it
|
||||
(render — say so, it takes a moment), and we can neither fetch nor make it
|
||||
(no model — the one state with an action attached).
|
||||
"""
|
||||
key = _preview_key(a)
|
||||
if gallery.cached_preview(key) is not None:
|
||||
return "gallery", (
|
||||
"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"):
|
||||
return "cached", ""
|
||||
if _no_voice_model_downloaded():
|
||||
return "no_model", (
|
||||
"You're offline and no voice model is downloaded yet — "
|
||||
"Model Catalogue → Models → Download."
|
||||
)
|
||||
return "rendering", "Rendering this preview on your machine — it may take a moment."
|
||||
|
||||
|
||||
# ── Read endpoints (no model) ─────────────────────────────────────────────────
|
||||
# NOTE: declare the literal `/archetypes/categories` before `/archetypes/{id}`
|
||||
# so it isn't swallowed by the path-parameter route.
|
||||
@@ -223,6 +424,37 @@ def list_categories():
|
||||
return archetypes.categories()
|
||||
|
||||
|
||||
# ── Voice-gallery (pre-rendered previews) ─────────────────────────────────────
|
||||
# Declared above `/archetypes/{archetype_id}` for the same reason as
|
||||
# `/categories`: keep literal paths out of the path-parameter route's reach.
|
||||
@router.get("/archetypes/previews/status")
|
||||
def preview_gallery_status():
|
||||
"""Consent state, coverage and freshness for the Settings line."""
|
||||
return gallery.status()
|
||||
|
||||
|
||||
@router.put("/archetypes/previews")
|
||||
async def set_preview_gallery(enabled: bool = Body(..., embed=True)):
|
||||
"""Turn pre-rendered previews on or off.
|
||||
|
||||
Turning it ON is the user's explicit yes to an outbound call, and is the
|
||||
only thing that ever starts one — there is no on-install background fetch.
|
||||
The featured set is pulled right here so the yes has a visible effect;
|
||||
failures are silent by design (``fetch_featured`` swallows them) and leave
|
||||
previews rendering locally.
|
||||
"""
|
||||
state = gallery.set_enabled(enabled)
|
||||
if enabled:
|
||||
state = await gallery.fetch_featured()
|
||||
return state
|
||||
|
||||
|
||||
@router.post("/archetypes/previews/check")
|
||||
async def check_preview_gallery():
|
||||
"""Manual "check now" — bypasses the 24 h throttle, never the signature."""
|
||||
return await gallery.check_for_updates(force=True)
|
||||
|
||||
|
||||
@router.get("/archetypes")
|
||||
def list_archetypes_endpoint(
|
||||
q: Optional[str] = None,
|
||||
@@ -262,33 +494,77 @@ def get_archetype_endpoint(archetype_id: str):
|
||||
|
||||
|
||||
# ── Render endpoints (model-gated) ────────────────────────────────────────────
|
||||
@router.get("/archetypes/{archetype_id}/preview/state")
|
||||
def preview_archetype_state(archetype_id: str):
|
||||
"""Where the next ``/preview`` for this archetype would come from.
|
||||
|
||||
Touches neither the model nor the network, so a picker can label a voice
|
||||
("may take a moment", "download a model first") *before* it commits to a
|
||||
request that may take 40 seconds or fail.
|
||||
"""
|
||||
a = archetypes.get_archetype(archetype_id)
|
||||
if a is None:
|
||||
raise HTTPException(status_code=404, detail="Archetype not found")
|
||||
source, message = _preview_source(a)
|
||||
return {"source": source, "message": message}
|
||||
|
||||
|
||||
@router.get("/archetypes/{archetype_id}/preview")
|
||||
async def preview_archetype(archetype_id: str):
|
||||
"""Serve a short preview clip — pre-rendered if cached, else render once."""
|
||||
async def preview_archetype(
|
||||
archetype_id: str,
|
||||
local: bool = Query(False, description="Bypass gallery audio after a client decode failure"),
|
||||
):
|
||||
"""Serve a short preview clip — from the gallery, the cache, or the engine."""
|
||||
a = archetypes.get_archetype(archetype_id)
|
||||
if a is None:
|
||||
raise HTTPException(status_code=404, detail="Archetype not found")
|
||||
|
||||
cache_path = _PREVIEW_DIR / f"{_preview_key(a)}.wav"
|
||||
if not cache_path.exists():
|
||||
key = _preview_key(a)
|
||||
# Gallery first, and only for /preview: these bytes are audio we can prove
|
||||
# the provenance of, so they beat a local render of the same key. A miss
|
||||
# (offline, disabled, key not published) is silent — we just render.
|
||||
gallery_path = None if local else gallery.cached_preview(key)
|
||||
if gallery_path is None and not local:
|
||||
gallery_path = await gallery.fetch_preview(key)
|
||||
if gallery_path is not None:
|
||||
# Nothing else in the app polls, so the daily refresh hangs off the
|
||||
# request that proves previews are being used. Fire-and-forget.
|
||||
gallery.maybe_refresh_in_background()
|
||||
return FileResponse(
|
||||
str(gallery_path),
|
||||
media_type="audio/mpeg",
|
||||
headers={"Cache-Control": "no-cache",
|
||||
"X-OmniVoice-Preview-Source": "gallery"},
|
||||
)
|
||||
|
||||
cache_path = _PREVIEW_DIR / f"{key}.wav"
|
||||
if not is_playable_wav(cache_path):
|
||||
try:
|
||||
await _render_archetype_wav(a, cache_path)
|
||||
await _render_wav_atomic(a, cache_path, prefix=".preview-")
|
||||
except Exception as e: # model missing / OOM / inference failure
|
||||
logger.error("Archetype preview render failed", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=(
|
||||
"Couldn't render a preview right now — the voice engine is "
|
||||
f"unavailable. See Settings → Logs → Backend. Error: {e}"
|
||||
),
|
||||
)
|
||||
# Two different failures, two different answers. Without a model
|
||||
# there is nothing to read in a log — there is something to do.
|
||||
if _no_voice_model_downloaded():
|
||||
detail = (
|
||||
"You're offline and no voice model is downloaded yet — "
|
||||
"Model Catalogue → Models → Download. (Or turn on pre-rendered "
|
||||
"voice previews in Model Catalogue → Models.)"
|
||||
)
|
||||
else:
|
||||
detail = (
|
||||
"Couldn't render a preview right now — the voice engine "
|
||||
f"reported: {e}"
|
||||
)
|
||||
raise HTTPException(status_code=503, detail=detail)
|
||||
# no-cache (not no-store): the URL is stable but its bytes change when an
|
||||
# archetype's preview is re-rendered, so force the client to revalidate
|
||||
# against the ETag instead of serving a stale cached clip indefinitely.
|
||||
return FileResponse(
|
||||
str(cache_path),
|
||||
media_type="audio/wav",
|
||||
headers={"Cache-Control": "no-cache"},
|
||||
headers={"Cache-Control": "no-cache",
|
||||
"X-OmniVoice-Preview-Source": "local"},
|
||||
)
|
||||
|
||||
|
||||
@@ -300,6 +576,11 @@ async def use_archetype(archetype_id: str, name: Optional[str] = Query(None)):
|
||||
preview) and inserts a ``voice_profiles`` row carrying the archetype's
|
||||
instruct + language. The profile then shows up everywhere voices are
|
||||
picked (Dub / Generate / Clone).
|
||||
|
||||
Never sourced from the voice gallery, no matter how cheap that would be:
|
||||
this WAV lands in ``VOICES_DIR`` as the profile's reference audio, so a
|
||||
downloaded, lossily-encoded MP3 would silently become the sample every
|
||||
future clone of this voice is built from. It renders locally or it fails.
|
||||
"""
|
||||
a = archetypes.get_archetype(archetype_id)
|
||||
if a is None:
|
||||
@@ -311,64 +592,122 @@ 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. 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.
|
||||
# a fresh duplicate every time. Use a namespaced personality identity so an
|
||||
# imported persona cannot collide with and be rewritten by an archetype id.
|
||||
with db_conn() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT id, name FROM voice_profiles WHERE personality = ? LIMIT 1",
|
||||
(a["id"],),
|
||||
).fetchone()
|
||||
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
|
||||
|
||||
if existing is not None:
|
||||
return {"profile_id": existing["id"], "name": existing["name"]}
|
||||
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
|
||||
|
||||
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)
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=(
|
||||
"Couldn't create a voice from this archetype — the voice engine "
|
||||
f"is unavailable. See Settings → Logs → Backend. Error: {e}"
|
||||
),
|
||||
)
|
||||
if audio_path is None: # defensive: a new profile always rendered above
|
||||
raise RuntimeError("new archetype profile has no rendered audio")
|
||||
|
||||
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 — 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()
|
||||
# `personality` is not globally UNIQUE, so serialize and re-check.
|
||||
dup = _existing_archetype_profile(conn, a)
|
||||
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):
|
||||
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(),
|
||||
),
|
||||
)
|
||||
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,
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
with __import__("contextlib").suppress(OSError):
|
||||
os.remove(audio_path)
|
||||
if audio_path is not None:
|
||||
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}
|
||||
|
||||
@@ -25,6 +25,7 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import uuid
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
@@ -681,6 +682,87 @@ def _render_chapter_cached(chapter, synth, sr, engine_id, resolve, cache_dir, le
|
||||
"cached": seg_cache.hits}
|
||||
|
||||
|
||||
def _remote_chapter_call(chapter, *, engine_id, default_voice, voice_map,
|
||||
language, lexicon, opts, cache_dir):
|
||||
"""Build one opaque remote chapter task without loading a local TTS model."""
|
||||
import hashlib
|
||||
|
||||
from services import gpu_gateway
|
||||
from services.text_normalization import normalize_for_tts
|
||||
from services.watermark import is_enabled as watermark_enabled
|
||||
|
||||
rows, voices, refs = [], [], []
|
||||
for span in chapter.spans:
|
||||
profile_id = _map_span_voice(span.voice_id, default_voice, voice_map)
|
||||
voice = _resolve_voice(profile_id)
|
||||
rows.append({
|
||||
"text": normalize_for_tts(span.text, language),
|
||||
"pause_ms_after": span.pause_ms_after,
|
||||
"speed": getattr(span, "speed", None),
|
||||
})
|
||||
refs.append(voice.get("ref_audio"))
|
||||
voices.append({
|
||||
"ref_text": voice.get("ref_text"), "instruct": voice.get("instruct"),
|
||||
"seed": voice.get("seed"),
|
||||
})
|
||||
params = {
|
||||
"spans": rows, "voices": voices, "ref_audio": refs,
|
||||
"language": language, "lexicon": lexicon,
|
||||
"expressive": opts.to_manifest(), "watermark": bool(watermark_enabled()),
|
||||
}
|
||||
signature = hashlib.sha256(json.dumps(params, sort_keys=True, default=str).encode()).hexdigest()
|
||||
wav_path = os.path.join(cache_dir, f"remote-{signature}.wav")
|
||||
|
||||
def decode(result):
|
||||
import soundfile as sf
|
||||
if not os.path.exists(wav_path):
|
||||
partial = f"{wav_path}.part"
|
||||
shutil.copyfile(result.path, partial)
|
||||
os.replace(partial, wav_path)
|
||||
info = sf.info(wav_path)
|
||||
return wav_path, float(info.duration), False, None
|
||||
|
||||
return gpu_gateway.RemoteCall(
|
||||
engine=engine_id, operation="audiobook", params=params,
|
||||
idempotency_key=f"audiobook:{signature}", decode=decode,
|
||||
), wav_path
|
||||
|
||||
|
||||
async def _run_chapter(chapter, *, operation="audiobook", decision, job, default_voice, language, opts,
|
||||
voice_map, lexicon, cache_dir):
|
||||
"""Run one chapter through the gateway; local preparation stays lazy."""
|
||||
from services import gpu_gateway
|
||||
from services.tts_backend import active_backend_id
|
||||
|
||||
engine_id = active_backend_id()
|
||||
remote, remote_cache = _remote_chapter_call(
|
||||
chapter, engine_id=engine_id, default_voice=default_voice,
|
||||
voice_map=voice_map, language=language, lexicon=lexicon,
|
||||
opts=opts, cache_dir=cache_dir,
|
||||
)
|
||||
if decision.remote and os.path.exists(remote_cache):
|
||||
import soundfile as sf
|
||||
info = sf.info(remote_cache)
|
||||
return remote_cache, float(info.duration), True, None
|
||||
|
||||
async def prepare_local():
|
||||
synth, sr, resolve, local_engine = await _prepare_synth(
|
||||
default_voice, language=language, opts=opts, voice_map=voice_map
|
||||
)
|
||||
return gpu_gateway.LocalCall(
|
||||
fn=lambda: _render_chapter_cached(
|
||||
chapter, synth, sr, local_engine, resolve, cache_dir, lexicon,
|
||||
language, opts, voice_map,
|
||||
),
|
||||
what="Audiobook chapter",
|
||||
)
|
||||
|
||||
return await gpu_gateway.run(
|
||||
operation, local=gpu_gateway.LocalCall(prepare=prepare_local),
|
||||
remote=remote, decision=decision, job=job,
|
||||
)
|
||||
|
||||
|
||||
class AudiobookPreviewRequest(ExpressiveMixin):
|
||||
text: str
|
||||
chapter_index: int = 0
|
||||
@@ -700,7 +782,7 @@ async def audiobook_preview(req: AudiobookPreviewRequest) -> dict:
|
||||
cache (the later full render reuses it) and a re-preview is instant.
|
||||
"""
|
||||
from core.config import OUTPUTS_DIR
|
||||
from services.model_manager import _gpu_pool
|
||||
from services import gpu_gateway
|
||||
|
||||
plan = parse_audiobook_script(req.text, default_voice=req.default_voice)
|
||||
if not plan.chapters:
|
||||
@@ -714,16 +796,11 @@ async def audiobook_preview(req: AudiobookPreviewRequest) -> dict:
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
resolved_lang = _resolve_default_language(req.language, req.default_voice)
|
||||
opts = _expressive_opts(req)
|
||||
synth, sr, resolve, engine_id = await _prepare_synth(
|
||||
req.default_voice,
|
||||
language=resolved_lang,
|
||||
opts=opts,
|
||||
voice_map=req.voice_map,
|
||||
)
|
||||
loop = asyncio.get_running_loop()
|
||||
wav_path, dur, was_cached, _seg_stats = await loop.run_in_executor(
|
||||
_gpu_pool, _render_chapter_cached, chapter, synth, sr, engine_id, resolve, cache_dir,
|
||||
req.lexicon, resolved_lang, opts, req.voice_map,
|
||||
decision = gpu_gateway.decide("audiobook")
|
||||
wav_path, dur, was_cached, _seg_stats = await _run_chapter(
|
||||
chapter, decision=decision, job=None, default_voice=req.default_voice,
|
||||
language=resolved_lang, opts=opts, voice_map=req.voice_map,
|
||||
lexicon=req.lexicon, cache_dir=cache_dir,
|
||||
)
|
||||
return {
|
||||
"output": os.path.relpath(wav_path, OUTPUTS_DIR), # served via /audio
|
||||
@@ -762,7 +839,7 @@ async def _render_longform_sse(
|
||||
from core.config import OUTPUTS_DIR
|
||||
from core.failure import build_failure, build_failure_event
|
||||
from services.ffmpeg_utils import find_ffmpeg, run_ffmpeg
|
||||
from services.model_manager import _gpu_pool
|
||||
from services import gpu_gateway
|
||||
|
||||
opts = opts or ExpressiveOptions()
|
||||
|
||||
@@ -837,13 +914,11 @@ async def _render_longform_sse(
|
||||
cache_dir = os.path.join(OUTPUTS_DIR, "longform_cache")
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
prune_cache_dir(cache_dir) # bound disk before this job adds its chapters
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
try:
|
||||
resolved_lang = _resolve_default_language(language, default_voice)
|
||||
synth, sr, resolve, engine_id = await _prepare_synth(
|
||||
default_voice, language=resolved_lang, opts=opts, voice_map=voice_map
|
||||
)
|
||||
operation = "audiobook" if job_type == "audiobook" else "longform"
|
||||
decision = gpu_gateway.decide(operation)
|
||||
chapter_run = gpu_gateway.JobRun(operation)
|
||||
|
||||
total = len(plan.chapters)
|
||||
chapter_files: list[str] = []
|
||||
@@ -877,10 +952,11 @@ async def _render_longform_sse(
|
||||
interrupted = True
|
||||
break
|
||||
try:
|
||||
wav_path, dur, was_cached, seg_stats = await loop.run_in_executor(
|
||||
_gpu_pool, _render_chapter_cached,
|
||||
chapter, synth, sr, engine_id, resolve, cache_dir, lexicon,
|
||||
resolved_lang, opts, voice_map,
|
||||
wav_path, dur, was_cached, seg_stats = await _run_chapter(
|
||||
chapter, operation=operation, decision=decision, job=chapter_run,
|
||||
default_voice=default_voice, language=resolved_lang,
|
||||
opts=opts, voice_map=voice_map, lexicon=lexicon,
|
||||
cache_dir=cache_dir,
|
||||
)
|
||||
except Exception as e: # isolate a bad chapter — keep going
|
||||
logger.warning("[%s] chapter %d (%s) failed to render",
|
||||
@@ -918,6 +994,11 @@ async def _render_longform_sse(
|
||||
ev["cached_segments"] = seg_stats["cached"]
|
||||
yield _emit(ev)
|
||||
|
||||
route_notice = chapter_run.notice()
|
||||
if route_notice is not None:
|
||||
yield _emit({"type": "routing_notice", "status": route_notice[0],
|
||||
"reason": route_notice[1]})
|
||||
|
||||
if interrupted:
|
||||
logger.info("[%s] client disconnected — stopped after %d/%d chapters",
|
||||
job_id, len(chapter_files), total)
|
||||
@@ -1033,6 +1114,25 @@ async def _render_longform_sse(
|
||||
yield _emit({"type": "error", "error": "render failed (see backend log)"})
|
||||
|
||||
|
||||
async def _public_longform_stream(plan, **render_kwargs):
|
||||
"""Keep generator diagnostics local if setup fails before its own guard."""
|
||||
try:
|
||||
async for event in _render_longform_sse(plan, **render_kwargs):
|
||||
yield event
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
from core.public_errors import public_failure
|
||||
|
||||
error = public_failure(
|
||||
logger,
|
||||
"Longform response stream failed",
|
||||
exc,
|
||||
response="Render failed; check the backend log for details.",
|
||||
)
|
||||
yield f"data: {json.dumps({'type': 'error', 'error': error})}\n\n"
|
||||
|
||||
|
||||
@router.post("/audiobook")
|
||||
async def audiobook_synthesize(req: AudiobookRequest, request: Request = None):
|
||||
"""Synthesize a chapterized audiobook from a script, streaming SSE progress."""
|
||||
@@ -1041,7 +1141,7 @@ async def audiobook_synthesize(req: AudiobookRequest, request: Request = None):
|
||||
# to a direct in-process call, e.g. a unit test); its disconnect poll is what
|
||||
# lets Stop cancel the render mid-book (#1216).
|
||||
return StreamingResponse(
|
||||
_render_longform_sse(
|
||||
_public_longform_stream(
|
||||
plan, default_voice=req.default_voice, language=req.language,
|
||||
fmt=req.format, bitrate=req.bitrate,
|
||||
loudness=req.loudness, cover_path=req.cover_path, metadata=req.metadata,
|
||||
@@ -1102,7 +1202,7 @@ async def longform_render(req: LongformRenderRequest, request: Request = None):
|
||||
chapters.append(Chapter(title=c.title or f"Chapter {i + 1}", spans=spans))
|
||||
plan = AudiobookPlan(chapters=chapters)
|
||||
return StreamingResponse(
|
||||
_render_longform_sse(
|
||||
_public_longform_stream(
|
||||
plan, default_voice=req.default_voice, language=req.language,
|
||||
fmt=req.format, bitrate=req.bitrate,
|
||||
loudness=req.loudness, cover_path=req.cover_path, metadata=req.metadata,
|
||||
@@ -1195,7 +1295,7 @@ async def resume_longform(job_id: str, request: Request = None):
|
||||
# unrendered ones synthesize. Using a fresh id means the request's job_id
|
||||
# never names a work dir / output file (defence-in-depth path-injection).
|
||||
return StreamingResponse(
|
||||
_render_longform_sse(
|
||||
_public_longform_stream(
|
||||
plan, default_voice=p.get("default_voice"), language=p.get("language"),
|
||||
fmt=p.get("fmt", "m4b"), bitrate=p.get("bitrate", "128k"),
|
||||
loudness=p.get("loudness"), cover_path=p.get("cover_path"),
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
"""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,
|
||||
)
|
||||
@@ -151,14 +151,17 @@ async def _run_batch_pipeline(job_id: str, job: dict):
|
||||
# ── 2. Transcribe ─────────────────────────────────────────────────
|
||||
_set_progress(job, "transcribe", 0)
|
||||
|
||||
from services.asr_backend import get_active_asr_backend
|
||||
from services.asr_backend import load_active_asr_backend
|
||||
from services.model_manager import _gpu_pool, _cpu_pool, run_on_gpu_pool_guarded
|
||||
from services.segmentation import (
|
||||
segment_transcript, assign_speakers_heuristic,
|
||||
)
|
||||
|
||||
def _transcribe():
|
||||
backend = get_active_asr_backend()
|
||||
# `load_*`, not `get_*`: the plain selector returns engines whose
|
||||
# shallow probe passed but whose deep import chain is broken, failing
|
||||
# the whole batch job at `.transcribe()` instead of degrading (#1185).
|
||||
backend = load_active_asr_backend()
|
||||
result = backend.transcribe(audio_path, word_timestamps=True)
|
||||
detected_lang = result.get("language", "en")
|
||||
segments = segment_transcript(result, duration=duration)
|
||||
@@ -191,7 +194,7 @@ async def _run_batch_pipeline(job_id: str, job: dict):
|
||||
|
||||
# ── Engine resolution (issue #312 class) ────────────────────────────
|
||||
# Batch used to hardcode VoiceStudio via get_model() regardless of the
|
||||
# engine selected in Settings → Engines. require_cloning only when a
|
||||
# engine selected in Model Catalogue → Engines. require_cloning only when a
|
||||
# specific voice is pinned (job["voice_id"]) — an unpinned job is fine on
|
||||
# any active engine. Resolved ONCE for the whole job (every language
|
||||
# below shares the same active engine); an uncaught ValueError here
|
||||
|
||||
@@ -8,8 +8,11 @@ raw audio bytes and get back transcribed text immediately. Used by:
|
||||
• The MCP server's future `transcribe_audio` tool
|
||||
• CLI consumers that just want speech-to-text
|
||||
|
||||
The ASR engine is whatever `get_active_asr_backend()` returns — WhisperX
|
||||
by default, or MLX Whisper on Apple Silicon when configured.
|
||||
The ASR engine is whatever `load_active_asr_backend()` returns — WhisperX
|
||||
by default, or MLX Whisper on Apple Silicon when configured. The *loader*,
|
||||
not the bare selector: it also runs `ensure_loaded()` and falls through to
|
||||
the next healthy engine when the selected one has a broken deep import chain
|
||||
(#1185), which the shallow `is_available()` probe cannot see.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -97,8 +100,12 @@ async def transcribe_audio(
|
||||
if use_accurate:
|
||||
# Accurate mode: full WhisperX with forced alignment —
|
||||
# for when the user explicitly wants word-level timing.
|
||||
from services.asr_backend import get_active_asr_backend
|
||||
backend = get_active_asr_backend()
|
||||
# `load_*`, not `get_*`: the selector alone hands back an
|
||||
# engine whose shallow probe passed but whose deep import
|
||||
# chain is broken, which then 500s at `.transcribe()`. The
|
||||
# loader degrades to the next healthy engine (#1185).
|
||||
from services.asr_backend import load_active_asr_backend
|
||||
backend = load_active_asr_backend()
|
||||
result = backend.transcribe(tmp.name, word_timestamps=True)
|
||||
else:
|
||||
# Fast mode (default): use the fastest available engine
|
||||
@@ -110,7 +117,11 @@ async def transcribe_audio(
|
||||
return result, backend.id
|
||||
|
||||
from services.model_manager import _gpu_pool
|
||||
from services.asr_backend import ASRTimeoutError, run_transcribe_guarded
|
||||
from services.asr_backend import (
|
||||
ASRModelMissingError,
|
||||
ASRTimeoutError,
|
||||
run_transcribe_guarded,
|
||||
)
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
result, engine_id = await run_transcribe_guarded(
|
||||
@@ -121,6 +132,14 @@ async def transcribe_audio(
|
||||
# silent hang the UI reads as "can't reach the local backend".
|
||||
logger.warning("Capture transcription timed out: %s", e)
|
||||
raise HTTPException(status_code=504, detail=str(e))
|
||||
except ASRModelMissingError as e:
|
||||
# Degraded past the broken engine onto one with no weights on
|
||||
# disk — same typed 409 (+ download CTA) as the preflight above,
|
||||
# never a 500 and never a silent multi-GB auto-download.
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={**e.payload, "message": asr_model_missing_detail(e.payload)},
|
||||
)
|
||||
elapsed = round(time.perf_counter() - t0, 2)
|
||||
|
||||
# Normalize result shape
|
||||
|
||||
@@ -153,8 +153,8 @@ def _select_sherpa_spec(websocket: WebSocket):
|
||||
async def ws_transcribe(websocket: WebSocket):
|
||||
"""Stream audio in, get partial + final transcription out."""
|
||||
# Loopback origin guard — refuse anything not from 127.0.0.1, ::1, or
|
||||
# localhost. HTTP routers use Depends(require_loopback) at router level;
|
||||
# WebSocket dependency injection differs across FastAPI versions, so we
|
||||
# localhost. Privileged HTTP routers use Depends(require_admin) at router
|
||||
# level; WebSocket dependency injection differs across FastAPI versions, so we
|
||||
# inline the check before accept(). Without it, any local process could
|
||||
# stream the user's microphone over this endpoint.
|
||||
# Wave 2.3 (remote backend): a non-loopback client that presents the
|
||||
|
||||
@@ -20,18 +20,26 @@ 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 urlparse
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from core import archetypes
|
||||
from core.config import DATA_DIR
|
||||
from core.audio_validation import is_playable_wav, resolve_regular_file
|
||||
from core.config import DATA_DIR, VOICES_DIR
|
||||
|
||||
logger = logging.getLogger("omnivoice.community")
|
||||
router = APIRouter()
|
||||
@@ -42,9 +50,32 @@ _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._-]+/[A-Za-z0-9._-]+$") # owner/repo only
|
||||
_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",
|
||||
)
|
||||
|
||||
|
||||
# ── Config: which content repos to load ───────────────────────────────────────
|
||||
@@ -52,14 +83,18 @@ def configured_sources() -> list[str]:
|
||||
"""Gallery sources, in priority order. Env var > config file > default."""
|
||||
env = os.environ.get("OMNIVOICE_GALLERY_SOURCES")
|
||||
if env:
|
||||
return [s.strip() for s in env.split(",") if s.strip()]
|
||||
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)
|
||||
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:
|
||||
return [str(s) for s in srcs]
|
||||
valid = [s for s in srcs if isinstance(s, str) and _SOURCE_RE.fullmatch(s)]
|
||||
if valid:
|
||||
return valid
|
||||
except Exception:
|
||||
logger.warning("gallery_sources.json unreadable; using default")
|
||||
return list(_DEFAULT_SOURCES)
|
||||
@@ -81,9 +116,51 @@ 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:
|
||||
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)
|
||||
return normalize_preset_instruct(instruct) is not None
|
||||
|
||||
|
||||
def validate_item(raw: dict) -> Optional[dict]:
|
||||
@@ -93,62 +170,203 @@ def validate_item(raw: dict) -> Optional[dict]:
|
||||
it = dict(raw)
|
||||
if it.get("type") not in ("preset", "voice"):
|
||||
return None
|
||||
if not it.get("id") or not it.get("name"):
|
||||
if not isinstance(it.get("id"), str) or not _ITEM_ID_RE.fullmatch(it["id"]):
|
||||
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
|
||||
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", {})
|
||||
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
|
||||
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 m:
|
||||
if not isinstance(m, dict):
|
||||
continue
|
||||
for raw in (m.get("items") or []):
|
||||
raw_items = m.get("items")
|
||||
for raw in raw_items if isinstance(raw_items, list) else []:
|
||||
v = validate_item(raw)
|
||||
if v and v["id"] not in seen:
|
||||
v["_source_repo"] = src
|
||||
seen.add(v["id"])
|
||||
items.append(v)
|
||||
for p in (m.get("packs") or []):
|
||||
raw_packs = m.get("packs")
|
||||
for p in raw_packs if isinstance(raw_packs, list) else []:
|
||||
if isinstance(p, dict):
|
||||
packs.append({**p, "_source_repo": src})
|
||||
return items, packs
|
||||
|
||||
|
||||
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)
|
||||
if not refresh and cache.exists():
|
||||
try:
|
||||
return json.loads(cache.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
pass
|
||||
def _read_manifest_cache(cache: Path) -> Optional[dict]:
|
||||
try:
|
||||
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")
|
||||
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."""
|
||||
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:
|
||||
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
|
||||
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))
|
||||
return data
|
||||
except Exception as e: # offline / 404 / bad json
|
||||
logger.warning("manifest fetch failed for %s: %s", source, e)
|
||||
if cache.exists():
|
||||
try:
|
||||
return json.loads(cache.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
pass
|
||||
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
|
||||
return None
|
||||
|
||||
|
||||
@@ -214,6 +432,385 @@ 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.
|
||||
@@ -223,76 +820,108 @@ 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 = 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.")
|
||||
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)
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from core import event_bus
|
||||
from core.db import db_conn
|
||||
from core.config import VOICES_DIR
|
||||
|
||||
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"]
|
||||
instruct = item.get("instruct", "") if item["type"] == "preset" else ""
|
||||
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)
|
||||
|
||||
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(
|
||||
"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),
|
||||
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_name = (name or item["name"]).strip() or item["name"]
|
||||
kind, instruct, vd_states, seed = _profile_fields(item)
|
||||
try:
|
||||
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),
|
||||
)
|
||||
except Exception:
|
||||
with __import__("contextlib").suppress(OSError):
|
||||
os.remove(audio_path)
|
||||
with contextlib.suppress(OSError):
|
||||
audio_path.unlink()
|
||||
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)
|
||||
|
||||
@@ -557,7 +557,7 @@ _prep_event_helper = dub_pipeline.prep_event # alias; we keep the module-local
|
||||
#: into one reference, which is how "made up" clone voices happen).
|
||||
CLONE_SKIP_HEURISTIC_MSG = (
|
||||
"auto voice cloning skipped: speaker labels are gap-based estimates — "
|
||||
"set up diarization (Settings → Models → pyannote) for per-speaker clones"
|
||||
"set up diarization (Model Catalogue → Models → pyannote) for per-speaker clones"
|
||||
)
|
||||
|
||||
|
||||
@@ -1203,7 +1203,7 @@ async def dub_transcribe_stream(
|
||||
f"unavailable, so the ASR engine's built-in speaker "
|
||||
f"turns were used and the detected count may differ "
|
||||
f"from the {num_speakers} you set. Set up diarization "
|
||||
f"(Settings → Models → pyannote) to enforce an exact "
|
||||
f"(Model Catalogue → Models → pyannote) to enforce an exact "
|
||||
f"speaker count."
|
||||
)
|
||||
return resplit, {
|
||||
@@ -1687,8 +1687,11 @@ async def dub_transcribe(job_id: str, num_speakers: Optional[int] = None):
|
||||
# / mlx / pytorch based on what's installed + user preference. Works
|
||||
# identically on all platforms; the older mlx-vs-pytorch branching
|
||||
# here duplicated the logic in asr_backend.py and skipped WhisperX.
|
||||
from services.asr_backend import get_active_asr_backend
|
||||
_asr = get_active_asr_backend(asr_pipe=getattr(_model, "_asr_pipe", None))
|
||||
# `load_*`, not `get_*`: the plain selector hands back engines whose
|
||||
# shallow probe passed but whose deep import chain is broken, which
|
||||
# then dies at `.transcribe()`. The loader degrades (#1185).
|
||||
from services.asr_backend import load_active_asr_backend
|
||||
_asr = load_active_asr_backend(asr_pipe=getattr(_model, "_asr_pipe", None))
|
||||
try:
|
||||
try:
|
||||
logger.info("Transcribing full audio via %s ...", _asr.id)
|
||||
|
||||
@@ -1434,13 +1434,20 @@ async def dub_qc_pass(job_id: str, lang: str = Query(None), drift_threshold: flo
|
||||
)
|
||||
|
||||
def _recognize():
|
||||
from services.asr_backend import get_active_asr_backend
|
||||
backend = get_active_asr_backend()
|
||||
# `load_*`, not `get_*`: the plain selector returns engines whose
|
||||
# shallow probe passed but whose deep import chain is broken, which
|
||||
# then 500s at `.transcribe()`. The loader degrades (#1185).
|
||||
from services.asr_backend import load_active_asr_backend
|
||||
backend = load_active_asr_backend()
|
||||
result = backend.transcribe(wav_path, word_timestamps=False)
|
||||
return result.get("segments", []), backend.id
|
||||
|
||||
try:
|
||||
from services.asr_backend import ASRTimeoutError, run_transcribe_guarded
|
||||
from services.asr_backend import (
|
||||
ASRModelMissingError,
|
||||
ASRTimeoutError,
|
||||
run_transcribe_guarded,
|
||||
)
|
||||
from services.model_manager import _get_gpu_pool
|
||||
recognized, engine_id = await run_transcribe_guarded(
|
||||
_get_gpu_pool(), _recognize, what="QC",
|
||||
@@ -1449,6 +1456,13 @@ async def dub_qc_pass(job_id: str, lang: str = Query(None), drift_threshold: flo
|
||||
# Backend is alive; ASR just couldn't finish in time. 504, not 500/connection.
|
||||
logger.warning("dub QC ASR pass timed out")
|
||||
raise HTTPException(status_code=504, detail=str(e))
|
||||
except ASRModelMissingError as e:
|
||||
# Degraded onto an engine with no weights on disk — typed 409 with the
|
||||
# download CTA, matching the preflight above.
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={**e.payload, "message": asr_model_missing_detail(e.payload)},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("dub QC ASR pass failed")
|
||||
raise HTTPException(status_code=500, detail=f"QC transcription failed: {e}")
|
||||
|
||||
@@ -4,6 +4,8 @@ import json
|
||||
import logging
|
||||
import time
|
||||
import asyncio
|
||||
import shutil
|
||||
import zipfile
|
||||
import torch
|
||||
import torchaudio
|
||||
from fastapi import APIRouter, HTTPException
|
||||
@@ -13,7 +15,8 @@ from core.config import DUB_DIR, VOICES_DIR, dub_seg_path
|
||||
from core.tasks import task_manager
|
||||
from schemas.requests import DubRequest
|
||||
from services.model_manager import _gpu_pool, run_on_gpu_pool_guarded
|
||||
from services.tts_backend import resolve_generation_backend
|
||||
from services.tts_backend import resolve_generation_backend, active_backend_id
|
||||
from services import gpu_gateway
|
||||
from services.audio_dsp import apply_mastering, normalize_audio, apply_effects_chain, get_effect_chain
|
||||
from services.audio_io import atomic_save_wav, _safe_torchaudio_save
|
||||
from services.ffmpeg_utils import (
|
||||
@@ -45,6 +48,38 @@ logger = logging.getLogger("omnivoice.dub")
|
||||
MAX_STRETCH_RATIO = 1.8
|
||||
|
||||
|
||||
def _prepare_oom_retry(error: Exception, *, execution_target: str) -> bool:
|
||||
"""Prepare one *local* low-step retry after a genuine device OOM.
|
||||
|
||||
The cache being flushed must belong to the device that raised the error.
|
||||
A remote worker owns its own recovery policy; flushing this process's CUDA
|
||||
cache after a remote failure both stalls the wrong GPU and can evict an
|
||||
unrelated local job. Keep this guard at the retry chokepoint so a future
|
||||
``dub_segments`` producer cannot accidentally inherit the old behaviour.
|
||||
|
||||
Returns ``False`` for non-OOM errors. Remote OOMs are deliberately raised
|
||||
unchanged: the worker may classify/retry them, but this process must not.
|
||||
"""
|
||||
is_oom = (
|
||||
isinstance(error, torch.cuda.OutOfMemoryError)
|
||||
or "out of memory" in str(error).lower()
|
||||
or "CUDA error" in str(error)
|
||||
)
|
||||
if not is_oom:
|
||||
return False
|
||||
if execution_target != "local":
|
||||
raise error
|
||||
|
||||
import gc
|
||||
|
||||
gc.collect()
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
||||
torch.mps.empty_cache()
|
||||
return True
|
||||
|
||||
|
||||
def _underrun_min_rate() -> float:
|
||||
"""Floor for the underrun fill (audio slowed toward its slot, never below
|
||||
this rate). Default 0.85 stays natural-sounding; OMNIVOICE_UNDERRUN_MIN_RATE=1.0
|
||||
@@ -317,6 +352,75 @@ def resolve_consistent_ref(job: dict, speaker_key: str, memo: dict | None = None
|
||||
return ref
|
||||
|
||||
|
||||
def _remote_voice(job: dict, profile_id: str | None, seg_id, voice_match: str,
|
||||
memo: dict) -> tuple[str | None, str | None, bool, str | None, int | None]:
|
||||
"""Resolve a dub binding without touching the TTS model."""
|
||||
ref_audio = ref_text = instruct = None
|
||||
seed = None
|
||||
single_use = False
|
||||
if profile_id and profile_id.startswith("auto-seg:"):
|
||||
sid = profile_id[len("auto-seg:"):]
|
||||
info = (job.get("segment_clones") or {}).get(sid)
|
||||
shared = False
|
||||
if voice_match == "consistent" and sid == str(seg_id):
|
||||
key = _speaker_key_for_segment(job, sid)
|
||||
alternate = resolve_consistent_ref(job, key, memo) if key else None
|
||||
if alternate:
|
||||
info = alternate
|
||||
shared = True
|
||||
if info:
|
||||
ref_audio, ref_text = info.get("ref_audio"), info.get("ref_text")
|
||||
single_use = not shared
|
||||
elif profile_id and profile_id.startswith("auto:"):
|
||||
key = profile_id[len("auto:"):]
|
||||
if voice_match == "consistent":
|
||||
info = resolve_consistent_ref(job, key, memo)
|
||||
else:
|
||||
info = ((job.get("segment_clones") or {}).get(str(seg_id))
|
||||
or _find_speaker_clone(job.get("speaker_clones") or {}, key))
|
||||
single_use = str(seg_id) in (job.get("segment_clones") or {})
|
||||
if info:
|
||||
ref_audio, ref_text = info.get("ref_audio"), info.get("ref_text")
|
||||
elif profile_id:
|
||||
with db_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM voice_profiles WHERE id=?", (profile_id,)).fetchone()
|
||||
if row:
|
||||
seed = row["seed"]
|
||||
if row["is_locked"] and row["locked_audio_path"]:
|
||||
ref_audio = os.path.join(VOICES_DIR, row["locked_audio_path"])
|
||||
ref_text = row["ref_text"]
|
||||
elif row["instruct"] and not row["is_locked"]:
|
||||
try:
|
||||
vd_states = row["vd_states"]
|
||||
except (KeyError, IndexError):
|
||||
vd_states = None
|
||||
instruct = heal_design_instruct(row["instruct"], vd_states)
|
||||
else:
|
||||
ref_audio = os.path.join(VOICES_DIR, row["ref_audio_path"])
|
||||
ref_text = row["ref_text"]
|
||||
return ref_audio, ref_text, single_use, instruct, seed
|
||||
|
||||
|
||||
def _decode_remote_dub(result: gpu_gateway.RemoteResult) -> dict[int, str]:
|
||||
"""Extract the worker bundle into a task-scoped directory, path-safely."""
|
||||
target = os.path.join(DUB_DIR, ".remote", result.task_id)
|
||||
os.makedirs(target, exist_ok=True)
|
||||
paths: dict[int, str] = {}
|
||||
with zipfile.ZipFile(result.path) as archive:
|
||||
for member in archive.infolist():
|
||||
match = re.fullmatch(r"segments/(\d+)\.wav", member.filename)
|
||||
if not match:
|
||||
raise ValueError(f"unexpected dub artifact member: {member.filename}")
|
||||
index = int(match.group(1))
|
||||
destination = os.path.join(target, f"{index}.wav")
|
||||
partial = f"{destination}.part"
|
||||
with archive.open(member) as source, open(partial, "wb") as output:
|
||||
shutil.copyfileobj(source, output)
|
||||
os.replace(partial, destination)
|
||||
paths[index] = destination
|
||||
return paths
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/dub/generate/{job_id}")
|
||||
@@ -331,7 +435,7 @@ async def dub_generate(job_id: str, req: DubRequest):
|
||||
|
||||
# ── Engine resolution (issue #312 class) ────────────────────────────────
|
||||
# Dub used to hardcode VoiceStudio via get_model() regardless of the engine
|
||||
# selected in Settings → Engines — a SILENT fallback. Every real dub
|
||||
# selected in Model Catalogue → Engines — a SILENT fallback. Every real dub
|
||||
# segment's ref_audio resolves to either an auto:<speaker>/auto-seg:<id>
|
||||
# clone cut from the source video or a saved voice-profile row (see
|
||||
# `_gen` below), so require_cloning=True: an engine that can't clone
|
||||
@@ -488,6 +592,7 @@ async def dub_generate(job_id: str, req: DubRequest):
|
||||
# every segment of that speaker for the whole run.
|
||||
voice_match = (req.voice_match or "per_line").lower()
|
||||
_consistent_ref_memo: dict = {}
|
||||
remote_audio: dict[int, str] = {}
|
||||
# Strategy-transition guard: smart_fit re-mixes the *natural-rate*
|
||||
# per-segment WAVs from disk. If the previous run used strict_slot,
|
||||
# the on-disk WAVs are slot-squeezed ("slotted") — reusing them would
|
||||
@@ -528,6 +633,86 @@ async def dub_generate(job_id: str, req: DubRequest):
|
||||
_t_cache = 0.0
|
||||
_t_tts = 0.0
|
||||
|
||||
# One coarse remote lease for every segment that actually needs fresh
|
||||
# synthesis. Assembly, fitting and the separately-pooled RVC pass stay
|
||||
# here; the worker returns a single verified bundle of segment WAVs.
|
||||
decision = gpu_gateway.decide("dub_segments")
|
||||
if decision.remote:
|
||||
remote_rows: list[dict] = []
|
||||
remote_refs: list[str | None] = []
|
||||
for i, seg in enumerate(req.segments):
|
||||
seg_id = seg_ids[i] if i < len(seg_ids) else f"seg_{i}"
|
||||
if (regen_only is not None and seg_id not in regen_only) or not seg.text.strip():
|
||||
continue
|
||||
ref_audio, ref_text, ref_single_use, profile_instruct, seed = _remote_voice(
|
||||
job, seg.profile_id or None, seg_id, voice_match, _consistent_ref_memo
|
||||
)
|
||||
ref_audio = warn_if_ref_missing(
|
||||
ref_audio, job_id=job_id, seg_id=seg_id, where="remote dub render"
|
||||
)
|
||||
seg_instruct = seg.instruct or req.instruct or profile_instruct
|
||||
seg_speed = seg.speed if seg.speed is not None else req.speed
|
||||
if seg.direction and seg.direction.strip():
|
||||
try:
|
||||
from services.director import parse as _parse_direction
|
||||
direction = _parse_direction(seg.direction)
|
||||
extra = direction.instruct_prompt()
|
||||
if extra:
|
||||
seg_instruct = f"{seg_instruct}, {extra}" if seg_instruct else extra
|
||||
bias = direction.rate_bias()
|
||||
if bias and abs(bias - 1.0) > 0.01 and strategy == "strict_slot":
|
||||
seg_speed = (seg_speed or 1.0) * bias
|
||||
except Exception:
|
||||
logger.debug("direction parse skipped for remote segment %s", seg_id,
|
||||
exc_info=True)
|
||||
remote_rows.append({
|
||||
"index": i, "text": seg.text,
|
||||
"language": seg.target_lang or req.language,
|
||||
"ref_text": ref_text, "ref_single_use": ref_single_use,
|
||||
"instruct": seg_instruct,
|
||||
"duration": (seg.end - seg.start) if strategy == "strict_slot" else None,
|
||||
"num_step": 8 if req.preview else req.num_step,
|
||||
"guidance_scale": req.guidance_scale, "speed": seg_speed,
|
||||
"effect_preset": seg.effect_preset or "broadcast",
|
||||
"seed": seed,
|
||||
# RVC changes the waveform locally after TTS, so that path
|
||||
# is marked at the existing post-RVC chokepoint below.
|
||||
"watermark": not rvc_is_enabled(),
|
||||
})
|
||||
remote_refs.append(ref_audio)
|
||||
if remote_rows:
|
||||
states: asyncio.Queue = asyncio.Queue()
|
||||
call = gpu_gateway.RemoteCall(
|
||||
engine=active_backend_id(), operation="dub_segments",
|
||||
params={"segments": remote_rows, "ref_audio": remote_refs},
|
||||
decode=_decode_remote_dub,
|
||||
)
|
||||
dub_run = gpu_gateway.JobRun("dub_segments")
|
||||
run = asyncio.create_task(gpu_gateway.run(
|
||||
"dub_segments", local=gpu_gateway.LocalCall(fn=lambda: {}),
|
||||
remote=call, decision=decision, job=dub_run,
|
||||
on_state=states.put_nowait,
|
||||
))
|
||||
while not run.done():
|
||||
if task_manager.is_cancelled(task_id):
|
||||
run.cancel()
|
||||
try:
|
||||
await run
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
yield f"data: {json.dumps({'type': 'cancelled', 'segments_processed': 0})}\n\n"
|
||||
return
|
||||
try:
|
||||
state = await asyncio.wait_for(states.get(), timeout=0.25)
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
fraction = float(state.get("progress") or 0.0)
|
||||
yield f"data: {json.dumps({'type': 'progress', 'current': round(fraction * total, 2), 'total': total, 'text': state.get('stage') or state.get('phase')})}\n\n"
|
||||
remote_audio = await run
|
||||
notice = dub_run.notice()
|
||||
if notice is not None:
|
||||
yield f"data: {json.dumps({'type': 'routing_notice', 'status': notice[0], 'reason': notice[1]})}\n\n"
|
||||
|
||||
for i, seg in enumerate(req.segments):
|
||||
seg_id = seg_ids[i] if i < len(seg_ids) else f"seg_{i}"
|
||||
|
||||
@@ -536,7 +721,8 @@ async def dub_generate(job_id: str, req: DubRequest):
|
||||
yield f"data: {json.dumps({'type': 'cancelled', 'segments_processed': i})}\n\n"
|
||||
return
|
||||
|
||||
yield f"data: {json.dumps({'type': 'progress', 'current': i, 'total': total, 'text': seg.text[:50]})}\n\n"
|
||||
if not remote_audio:
|
||||
yield f"data: {json.dumps({'type': 'progress', 'current': i, 'total': total, 'text': seg.text[:50]})}\n\n"
|
||||
|
||||
seg_duration = seg.end - seg.start
|
||||
if seg_duration <= 0.05 or not seg.text.strip():
|
||||
@@ -612,7 +798,8 @@ async def dub_generate(job_id: str, req: DubRequest):
|
||||
sync_scores.append(1.0)
|
||||
continue
|
||||
|
||||
def _gen(text, lang, instruct_str, dur_s, nstep, cfg, spd, profile_id, effect_preset):
|
||||
def _gen(text, lang, instruct_str, dur_s, nstep, cfg, spd, profile_id, effect_preset,
|
||||
*, execution_target="local"):
|
||||
# Normalize once at the segment's text→engine choke point
|
||||
# (covers the OOM-retry generate below too, which reuses this
|
||||
# closure's `text`). Pref-gated, idempotent, never raises.
|
||||
@@ -782,19 +969,7 @@ async def dub_generate(job_id: str, req: DubRequest):
|
||||
)
|
||||
return normalize_audio(mastered_audio, target_dBFS=-2.0)
|
||||
except Exception as e:
|
||||
is_oom = (
|
||||
isinstance(e, torch.cuda.OutOfMemoryError)
|
||||
or "out of memory" in str(e).lower()
|
||||
or "CUDA error" in str(e)
|
||||
)
|
||||
import gc
|
||||
gc.collect()
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
||||
torch.mps.empty_cache()
|
||||
|
||||
if not is_oom:
|
||||
if not _prepare_oom_retry(e, execution_target=execution_target):
|
||||
raise
|
||||
|
||||
retry_steps = min(nstep, 8)
|
||||
@@ -900,14 +1075,24 @@ async def dub_generate(job_id: str, req: DubRequest):
|
||||
# Budget from the shared length-scaled helper (#1190): a long
|
||||
# dub segment used to die on the flat 300s even after v0.3.22.
|
||||
from services.model_manager import generate_timeout_s
|
||||
audio_tensor = await run_on_gpu_pool_guarded(
|
||||
lambda: _gen(
|
||||
seg.text, seg_lang, seg_instruct, _dur_for_tts,
|
||||
_num_step, req.guidance_scale, seg_speed, seg_profile, seg_effect_preset,
|
||||
),
|
||||
what="Dub generate",
|
||||
timeout=generate_timeout_s(seg.text),
|
||||
)
|
||||
if i in remote_audio:
|
||||
audio_tensor, remote_sr = torchaudio.load(remote_audio[i])
|
||||
try:
|
||||
os.unlink(remote_audio[i])
|
||||
except OSError:
|
||||
pass
|
||||
if remote_sr != backend.sample_rate:
|
||||
import torchaudio.functional as AF
|
||||
audio_tensor = AF.resample(audio_tensor, remote_sr, backend.sample_rate)
|
||||
else:
|
||||
audio_tensor = await run_on_gpu_pool_guarded(
|
||||
lambda: _gen(
|
||||
seg.text, seg_lang, seg_instruct, _dur_for_tts,
|
||||
_num_step, req.guidance_scale, seg_speed, seg_profile, seg_effect_preset,
|
||||
),
|
||||
what="Dub generate",
|
||||
timeout=generate_timeout_s(seg.text),
|
||||
)
|
||||
_t_tts += time.perf_counter() - _t_tts_0
|
||||
|
||||
# Check abort immediately after GPU work completes
|
||||
@@ -996,8 +1181,9 @@ async def dub_generate(job_id: str, req: DubRequest):
|
||||
# no double-mark. Cached-reuse audio is already marked;
|
||||
# silence/zero slots carry no speech to mark, so neither is
|
||||
# re-watermarked.
|
||||
audio_tensor = mark_synthetic(audio_tensor, backend.sample_rate,
|
||||
context="dub_generate.segment")
|
||||
if i not in remote_audio or rvc_is_enabled():
|
||||
audio_tensor = mark_synthetic(audio_tensor, backend.sample_rate,
|
||||
context="dub_generate.segment")
|
||||
|
||||
seg_wav_path = _seg_lang_path(seg_id)
|
||||
try:
|
||||
|
||||
@@ -25,7 +25,7 @@ from huggingface_hub import utils as hf_utils
|
||||
from huggingface_hub.errors import HFValidationError
|
||||
from pydantic import BaseModel
|
||||
|
||||
from api.dependencies import require_loopback
|
||||
from api.dependencies import require_admin, require_admin_action, require_desktop
|
||||
from core import prefs
|
||||
from services import tts_backend, asr_backend, llm_backend, translation_engines
|
||||
from services.audio_dsp import list_effect_presets
|
||||
@@ -41,6 +41,15 @@ _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:
|
||||
@@ -55,34 +64,25 @@ def _is_hf_repo_id(value: str) -> bool:
|
||||
@router.get("/engines")
|
||||
def list_all_engines():
|
||||
return {
|
||||
"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()),
|
||||
},
|
||||
"tts": _family_payload("tts", tts_backend),
|
||||
"asr": _family_payload("asr", asr_backend),
|
||||
"llm": _family_payload("llm", llm_backend),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/engines/tts")
|
||||
def list_tts_backends():
|
||||
return {"active": tts_backend.active_backend_id(), "backends": public_backends(tts_backend.list_backends())}
|
||||
return _family_payload("tts", tts_backend)
|
||||
|
||||
|
||||
@router.get("/engines/asr")
|
||||
def list_asr_backends():
|
||||
return {"active": asr_backend.active_backend_id(), "backends": public_backends(asr_backend.list_backends())}
|
||||
return _family_payload("asr", asr_backend)
|
||||
|
||||
|
||||
@router.get("/engines/llm")
|
||||
def list_llm_backends():
|
||||
return {"active": llm_backend.active_backend_id(), "backends": public_backends(llm_backend.list_backends())}
|
||||
return _family_payload("llm", llm_backend)
|
||||
|
||||
|
||||
@router.get("/engines/effects/presets", response_model=EffectPresetsResponse)
|
||||
@@ -113,7 +113,10 @@ def list_translation_engines():
|
||||
}
|
||||
|
||||
|
||||
@router.post("/engines/translation/{engine_id}/install")
|
||||
@router.post(
|
||||
"/engines/translation/{engine_id}/install",
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
async def install_translation_engine(engine_id: str):
|
||||
entry = translation_engines.get_engine(engine_id)
|
||||
if not entry:
|
||||
@@ -149,7 +152,10 @@ async def install_translation_engine(engine_id: str):
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/engines/translation/{engine_id}")
|
||||
@router.delete(
|
||||
"/engines/translation/{engine_id}",
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
async def uninstall_translation_engine(engine_id: str):
|
||||
entry = translation_engines.get_engine(engine_id)
|
||||
if not entry:
|
||||
@@ -178,7 +184,7 @@ async def uninstall_translation_engine(engine_id: str):
|
||||
# Sidecar engines (dedicated venv + source checkout + weights, isolated from
|
||||
# the parent's transformers>=5.3) used to require four manual terminal steps.
|
||||
# These routes drive services.sidecar_install: POST starts a resumable
|
||||
# background job, GET polls its step-by-step status (the Settings → Engines
|
||||
# background job, GET polls its step-by-step status (the Model Catalogue → Engines
|
||||
# Install button polls this), DELETE removes an app-managed install.
|
||||
#
|
||||
# Path namespace: /engines/sidecar/{engine_id}/… — NOT /engines/{engine_id}/…
|
||||
@@ -188,15 +194,16 @@ async def uninstall_translation_engine(engine_id: str):
|
||||
# POST /engines/sonitranslate/install). Mirrors the
|
||||
# /engines/translation/{engine_id}/install namespace pattern.
|
||||
#
|
||||
# Loopback-gated: installing spawns subprocesses (git/uv) and writes to the
|
||||
# data directory — only the local desktop frontend may trigger it. The job
|
||||
# runs fine in packaged builds: the venv lives under the user data dir, not
|
||||
# inside the signed app bundle, and uv resolves via OMNIVOICE_BUNDLED_UV/PATH.
|
||||
# Desktop-only: installing spawns git/uv against mutable source and writes an
|
||||
# editable environment. An API key does not make that supply-chain path safe to
|
||||
# trigger remotely. The job runs fine in packaged builds: the venv lives under
|
||||
# the user data dir, not inside the signed app bundle, and uv resolves via
|
||||
# OMNIVOICE_BUNDLED_UV/PATH.
|
||||
|
||||
|
||||
@router.post(
|
||||
"/engines/sidecar/{engine_id}/install",
|
||||
dependencies=[Depends(require_loopback)],
|
||||
dependencies=[Depends(require_admin), Depends(require_desktop)],
|
||||
)
|
||||
def install_sidecar_engine(engine_id: str):
|
||||
"""Start (or report) the one-click install for a sidecar engine.
|
||||
@@ -222,7 +229,7 @@ def install_sidecar_engine(engine_id: str):
|
||||
|
||||
@router.get(
|
||||
"/engines/sidecar/{engine_id}/install/status",
|
||||
dependencies=[Depends(require_loopback)],
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
def sidecar_install_status(engine_id: str):
|
||||
"""Step-by-step status of the sidecar install job (poll while running).
|
||||
@@ -243,7 +250,7 @@ def sidecar_install_status(engine_id: str):
|
||||
|
||||
@router.delete(
|
||||
"/engines/sidecar/{engine_id}/install",
|
||||
dependencies=[Depends(require_loopback)],
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
def uninstall_sidecar_engine(engine_id: str):
|
||||
"""Remove an app-managed sidecar install (checkout + venv + weights) and
|
||||
@@ -274,29 +281,22 @@ def uninstall_sidecar_engine(engine_id: str):
|
||||
# frame. Result includes wall-clock latency so the UI can render
|
||||
# "1234 ms — pong" inline next to the button.
|
||||
#
|
||||
# Loopback-gated (T-02-13): only the local desktop frontend may trigger
|
||||
# a sidecar spawn through this endpoint.
|
||||
# Admin-gated (T-02-13): only the local desktop frontend or an authenticated
|
||||
# server-mode administrator may trigger a sidecar spawn through this endpoint.
|
||||
|
||||
# Engine instances cached for the lifetime of the FastAPI process so that
|
||||
# repeated health checks don't spawn a new SubprocessBackend (each spawn
|
||||
# allocates a sidecar venv probe + atexit hook). The cache is keyed by
|
||||
# class to survive registry-sandbox tests that rebind ids transiently.
|
||||
_ENGINE_INSTANCES: dict[type, object] = {}
|
||||
#
|
||||
# It now lives in services.tts_backend — the worker executor needs the same
|
||||
# warm instances and cannot import an API router without inverting the
|
||||
# layering. This name is the SAME dict object, kept so the existing consumers
|
||||
# (engine_memory eviction, model_lifecycle inventory/unload) go on working
|
||||
# unchanged; rebinding it here would fork the cache in two.
|
||||
_ENGINE_INSTANCES: dict[type, object] = tts_backend._ENGINE_INSTANCES
|
||||
|
||||
|
||||
def _get_engine_instance(cls):
|
||||
"""Return a cached singleton instance of ``cls``.
|
||||
|
||||
SubprocessBackend's ``__init__`` registers an atexit shutdown hook,
|
||||
so re-instantiating per request would leak handler entries (and on
|
||||
real engines, additional sidecar processes the first time the lock
|
||||
is acquired). One instance per process is the right move.
|
||||
"""
|
||||
inst = _ENGINE_INSTANCES.get(cls)
|
||||
if inst is None:
|
||||
inst = cls()
|
||||
_ENGINE_INSTANCES[cls] = inst
|
||||
return inst
|
||||
_get_engine_instance = tts_backend.get_engine_instance
|
||||
|
||||
|
||||
def _resolve_engine_class(engine_id: str):
|
||||
@@ -318,7 +318,7 @@ def _resolve_engine_class(engine_id: str):
|
||||
|
||||
@router.get(
|
||||
"/engines/{engine_id}/health",
|
||||
dependencies=[Depends(require_loopback)],
|
||||
dependencies=[Depends(require_admin_action)],
|
||||
)
|
||||
def engine_health(engine_id: str):
|
||||
"""Spawn-and-ping a SubprocessBackend; ``is_available()`` for the rest.
|
||||
@@ -392,7 +392,7 @@ def engine_health(engine_id: str):
|
||||
# hanging the Settings panel. The orphaned worker is best-effort daemon.
|
||||
# * A process-wide lock serialises self-tests so a click-storm can't stack
|
||||
# concurrent model loads.
|
||||
# * Only ever on user click (POST) — never on Settings load. Loopback-gated.
|
||||
# * Only ever on user click (POST) — never on Settings load. Admin-gated.
|
||||
|
||||
# Deliberately short + ASCII so the synth stays CPU-cheap and the phrase never
|
||||
# trips the no-hardcoded-CJK guard.
|
||||
@@ -459,7 +459,7 @@ class SelfTestResponse(BaseModel):
|
||||
@router.post(
|
||||
"/engines/{engine_id}/selftest",
|
||||
response_model=SelfTestResponse,
|
||||
dependencies=[Depends(require_loopback)],
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
def engine_selftest(engine_id: str):
|
||||
"""Run a bounded, real synthesis on an available in-process TTS engine.
|
||||
@@ -558,7 +558,11 @@ class SelectEngineResponse(BaseModel):
|
||||
routing_reason: str | None = None
|
||||
|
||||
|
||||
@router.post("/engines/select", response_model=SelectEngineResponse)
|
||||
@router.post(
|
||||
"/engines/select",
|
||||
response_model=SelectEngineResponse,
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
def select_engine(req: SelectEngineRequest):
|
||||
"""Persist a family's engine pick to prefs.json. Refuses unknown backends,
|
||||
backends whose deps aren't installed, AND backends that cannot run on THIS
|
||||
@@ -589,7 +593,7 @@ def select_engine(req: SelectEngineRequest):
|
||||
# #981: mlx-audio multiplexes 7+ curated models behind one backend id —
|
||||
# persist the model pick alongside the backend id so the UI can actually
|
||||
# select which curated model gets loaded (previously it always defaulted
|
||||
# to Kokoro no matter what the user downloaded in Settings → Models).
|
||||
# to Kokoro no matter what the user downloaded in Model Catalogue → Models).
|
||||
if req.family == "tts" and req.backend_id == "mlx-audio" and req.model_id is not None:
|
||||
known_keys = tts_backend.MLXAudioBackend.CURATED_MODELS
|
||||
# Accept a curated key OR a raw HF repo id ("owner/name") — the same
|
||||
|
||||
+229
-86
@@ -1,18 +1,24 @@
|
||||
import os
|
||||
import json
|
||||
import uuid
|
||||
import time
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional, List
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, File, Form, UploadFile, HTTPException, Query
|
||||
from fastapi.responses import FileResponse, RedirectResponse
|
||||
from fastapi.responses import FileResponse
|
||||
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
|
||||
|
||||
@@ -360,46 +366,223 @@ 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."""
|
||||
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}
|
||||
result = await asyncio.to_thread(_materialize_gallery_profile, voice_id, profile_name)
|
||||
return {"profile_id": result["profile_id"], "name": result["name"]}
|
||||
|
||||
|
||||
@router.get("/gallery/voices/{voice_id}/preview")
|
||||
@@ -415,22 +598,10 @@ def preview_voice(voice_id: str):
|
||||
|
||||
audio_path = row["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")
|
||||
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)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
@@ -503,33 +674,5 @@ 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."""
|
||||
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"]}
|
||||
result = _materialize_gallery_profile(voice_id)
|
||||
return {"success": True, "profile_id": result["profile_id"], "name": result["name"]}
|
||||
|
||||
+546
-119
@@ -528,7 +528,7 @@ def _oom_friendly_reraise(e):
|
||||
# the OOM catch-all, telling a user with 63 GB of RAM to press Flush. Point
|
||||
# at the real fix — set the variable — and never mention memory or Flush.
|
||||
# The underlying error already names the exact variable + what to point it
|
||||
# at (and Settings → Engines shows a copy-paste setup line), so keep it
|
||||
# at (and Model Catalogue → Engines shows a copy-paste setup line), so keep it
|
||||
# front-and-center. Checked before the OOM branch so a config error can
|
||||
# never be mislabeled as memory.
|
||||
if _is_config_failure(e):
|
||||
@@ -537,7 +537,7 @@ def _oom_friendly_reraise(e):
|
||||
f"environment variable that isn't configured, so nothing was "
|
||||
f"generated. Set it as the underlying error describes (it names the "
|
||||
f"exact variable and what to point it at), then restart VoiceStudio — "
|
||||
f"or pick a ready engine in Settings → Engines. This is a setup "
|
||||
f"or pick a ready engine in Model Catalogue → Engines. This is a setup "
|
||||
f"problem, not a memory one. Underlying error: {e}"
|
||||
) from e
|
||||
# #880 (the class bug): the OOM hint used to be the catch-all fallback,
|
||||
@@ -817,7 +817,7 @@ def _language_rejection_or(e: BaseException, backend, language):
|
||||
f"The {engine} engine can't speak{requested}. VoiceStudio offers every "
|
||||
f"language its default engine supports, but each engine covers a "
|
||||
f"different set — pick one this engine supports, or switch engine in "
|
||||
f"Settings → Engines (the VoiceStudio engine has the widest coverage) "
|
||||
f"Model Catalogue → Engines (the VoiceStudio engine has the widest coverage) "
|
||||
f"and generate again. Engine's own message: {e}"
|
||||
)
|
||||
|
||||
@@ -851,6 +851,7 @@ def _persist_profile_ref_text(profile_id: str, ref_text: str) -> None:
|
||||
async def _finalize_generation(
|
||||
audio_tensor, sample_rate, *, text, history_mode, ref_audio_path,
|
||||
language, instruct, resolved_profile_id, used_seed, start_time,
|
||||
already_marked=False,
|
||||
):
|
||||
"""Shared tail of a successful generation: watermark → save WAV →
|
||||
history row (self-healing) → retention prune → event emit.
|
||||
@@ -860,6 +861,12 @@ async def _finalize_generation(
|
||||
watermark, filename, history row, retention behavior — is identical
|
||||
regardless of how the audio was delivered to the client.
|
||||
|
||||
``already_marked`` is for audio that arrives provenance-marked: a remote
|
||||
worker marks at the tensor stage before it encodes (with ``force=True``,
|
||||
so the *requesting* user's preference governs, not the GPU owner's), and
|
||||
embedding a second AudioSeal payload over the first degrades detection of
|
||||
both. The take users keep carries exactly one whole-take mark either way.
|
||||
|
||||
Returns ``(watermarked_tensor, meta)`` where ``meta`` carries
|
||||
``id`` / ``filename`` / ``duration`` / ``gen_time``.
|
||||
"""
|
||||
@@ -874,13 +881,14 @@ async def _finalize_generation(
|
||||
# Dispatched to the dedicated watermark pool, not the GPU pool (#1190):
|
||||
# AudioSeal embedding is CPU work that holds no VRAM, so occupying a GPU
|
||||
# worker with it only delays the next generate on 1-worker hosts.
|
||||
from services.watermark import mark_synthetic
|
||||
from services.model_manager import get_watermark_pool
|
||||
audio_tensor = await loop.run_in_executor(
|
||||
get_watermark_pool(),
|
||||
functools.partial(mark_synthetic, audio_tensor, sample_rate,
|
||||
context="generate.finalize"),
|
||||
)
|
||||
if not already_marked:
|
||||
from services.watermark import mark_synthetic
|
||||
from services.model_manager import get_watermark_pool
|
||||
audio_tensor = await loop.run_in_executor(
|
||||
get_watermark_pool(),
|
||||
functools.partial(mark_synthetic, audio_tensor, sample_rate,
|
||||
context="generate.finalize"),
|
||||
)
|
||||
gen_time = round(time.time() - start_time, 2)
|
||||
|
||||
audio_id = str(uuid.uuid4())[:8]
|
||||
@@ -962,6 +970,144 @@ def _pcm16_b64(wav_tensor) -> str:
|
||||
return base64.b64encode(pcm.cpu().numpy().tobytes()).decode("ascii")
|
||||
|
||||
|
||||
# ── Remote GPU: this route is the producer the scheduler never had ─────────
|
||||
#
|
||||
# Picking a remote worker used to change a badge and nothing else — every
|
||||
# render still ran on this machine, which is the whole reported bug. The
|
||||
# decision is taken ONCE per request, through `services/gpu_gateway.py`, and
|
||||
# BEFORE anything local is loaded — for two reasons that are not
|
||||
# interchangeable: a render bound for the user's 4090 must not first pull a
|
||||
# multi-GB model into this machine's RAM, and it must not be refused by a gate
|
||||
# that asked whether THIS host has the accelerator the engine needs (a
|
||||
# CUDA-only engine on a Mac control plane is exactly the case remote workers
|
||||
# exist for).
|
||||
|
||||
_REMOTE_OP = "tts"
|
||||
|
||||
# The gateway's coarse phase → the sentence a user reads while someone else's
|
||||
# GPU works. A five-minute remote render otherwise shows the same bare spinner
|
||||
# as a local one, with no way to tell "queued behind another task" from
|
||||
# "downloading 5 GB of weights" from "actually generating".
|
||||
_REMOTE_PHASE_LABELS = {
|
||||
"queued": "queued on {target}",
|
||||
"loading": "loading model on {target}",
|
||||
"running": "generating on {target}",
|
||||
"uploading": "receiving audio from {target}",
|
||||
}
|
||||
|
||||
|
||||
class _LocalDecision:
|
||||
"""Stand-in for ``worker.routing.Decision`` meaning "run here".
|
||||
|
||||
Used only when the gateway cannot be imported at all, so a build without
|
||||
it still renders instead of 500-ing.
|
||||
"""
|
||||
|
||||
remote = False
|
||||
worker_id = None
|
||||
label = "Local"
|
||||
reason = ""
|
||||
|
||||
|
||||
_LOCAL_DECISION = _LocalDecision()
|
||||
|
||||
|
||||
def _routing_decision():
|
||||
"""Local or remote for this request — resolved once, never re-asked.
|
||||
|
||||
Asked once because the target is user-settable at any moment: a decision
|
||||
that flipped between prewarm and dispatch would either warm an engine
|
||||
nothing will use or dispatch remotely after paying a local cold load.
|
||||
"""
|
||||
try:
|
||||
from services import gpu_gateway
|
||||
|
||||
return gpu_gateway.decide(_REMOTE_OP)
|
||||
except Exception: # noqa: BLE001 — routing is advisory; local always works
|
||||
logger.debug("remote routing unavailable; running locally", exc_info=True)
|
||||
return _LOCAL_DECISION
|
||||
|
||||
|
||||
def _remote_only_local_call(target_label, reason=""):
|
||||
"""The local branch of a render whose local half was deliberately skipped.
|
||||
|
||||
``gpu_gateway.run`` always takes a local callable — it is where rule 1
|
||||
(pre-dispatch unavailability) lands. But this route skips every local
|
||||
preparation step once the decision is remote, precisely so a job bound for
|
||||
the 4090 does not first load gigabytes here, so there is no local render
|
||||
left to fall back to.
|
||||
|
||||
The causes rule 1 actually covers — worker offline, disabled, not
|
||||
approved, breaker open, remote workers switched off — are already answered
|
||||
by ``decide()`` BEFORE that skip, and come back as a local decision with a
|
||||
named reason. What is left is the narrow window where dispatch itself is
|
||||
refused (a full queue, a task dropped between submit and wait). Saying so
|
||||
and offering the local re-run is honest; silently returning nothing is not.
|
||||
"""
|
||||
from services.gpu_gateway import RemoteJobFailed
|
||||
|
||||
def _refuse():
|
||||
raise RemoteJobFailed(
|
||||
reason or f"{target_label} could not take this render",
|
||||
worker_label=target_label,
|
||||
code="REMOTE_NOT_DISPATCHED",
|
||||
hint="Run it on this machine instead, or pick another GPU.",
|
||||
)
|
||||
|
||||
return _refuse
|
||||
|
||||
|
||||
def _remote_progress_frame(state, target):
|
||||
"""One gateway ``on_state`` payload → the NDJSON event the UI renders."""
|
||||
phase = str((state or {}).get("phase") or "running")
|
||||
try:
|
||||
pct = max(0, min(100, round(float((state or {}).get("progress") or 0.0) * 100)))
|
||||
except (TypeError, ValueError):
|
||||
pct = 0
|
||||
detail = _REMOTE_PHASE_LABELS.get(phase, _REMOTE_PHASE_LABELS["running"])
|
||||
detail = detail.format(target=target)
|
||||
if phase == "running" and pct:
|
||||
detail = f"{detail} ({pct}%)"
|
||||
return {
|
||||
"type": "progress", "stage": phase, "percent": pct,
|
||||
"target": target, "detail": detail,
|
||||
}
|
||||
|
||||
|
||||
def _apply_routing_headers(headers, engine_notice, decision):
|
||||
"""Say where this render ran, on the notice channel that already exists.
|
||||
|
||||
``X-OmniVoice-Routing`` / ``-Routing-Reason`` are already set for the #21
|
||||
engine routing gate and already consumed as a de-duped one-time toast, so
|
||||
"this ran on gpu2" and "your 4090 was asleep, this ran here" travel the
|
||||
same wire rather than inventing a second one.
|
||||
|
||||
The engine notice wins on a local render: "the engine fell back to CPU"
|
||||
explains the slowness the user is looking at, while the worker notice for
|
||||
a local render is the quieter of the two. A remote render has no engine
|
||||
notice at all — that gate answers for THIS host, and this host did nothing.
|
||||
"""
|
||||
from services.engine_routing import header_safe_reason
|
||||
|
||||
notice = engine_notice
|
||||
if decision is not None:
|
||||
try:
|
||||
from services.gpu_gateway import notice_for
|
||||
|
||||
worker_notice = notice_for(decision)
|
||||
except Exception: # noqa: BLE001 — a notice must never fail a render
|
||||
worker_notice = None
|
||||
if worker_notice and (getattr(decision, "remote", False) or not notice):
|
||||
notice = worker_notice
|
||||
if not notice:
|
||||
return headers
|
||||
headers["X-OmniVoice-Routing"] = notice[0]
|
||||
safe = header_safe_reason(notice[1]) if notice[1] else ""
|
||||
if safe:
|
||||
headers["X-OmniVoice-Routing-Reason"] = safe
|
||||
return headers
|
||||
|
||||
|
||||
@router.post("/generate")
|
||||
async def generate_speech(
|
||||
text: str = Form(...),
|
||||
@@ -1000,6 +1146,9 @@ 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
|
||||
@@ -1010,6 +1159,36 @@ 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`
|
||||
@@ -1038,70 +1217,85 @@ async def generate_speech(
|
||||
from core.run_sentinel import touch_activity
|
||||
touch_activity("generate", engine_id)
|
||||
|
||||
# Single-active-engine memory discipline: hand back any OTHER resident TTS
|
||||
# engine's model before loading this one, so switching engines (or a
|
||||
# per-request engine= override, which bypasses /engines/select entirely)
|
||||
# doesn't stack two multi-GB models in memory — the accumulation behind the
|
||||
# 16 GB-Mac OOM deaths. No-op when nothing else is resident, so steady-state
|
||||
# single-engine use pays nothing. Opt out: OMNIVOICE_SINGLE_ENGINE_RESIDENT=0.
|
||||
from services.engine_memory import evict_other_tts_engines
|
||||
await evict_other_tts_engines(engine_id)
|
||||
|
||||
# Non-blocking breadcrumb: if free memory is already low before this load,
|
||||
# log it. A later OOM kill (the 16 GB-Mac class) then has a trail pointing
|
||||
# at the load that tipped it, instead of a silent process death. Never
|
||||
# blocks — the OS can reclaim cache, and a hard refuse would brick
|
||||
# legitimate loads.
|
||||
try:
|
||||
from services.memory_budget import log_if_low
|
||||
|
||||
log_if_low(f"TTS load ({engine_id})")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# VRAM eviction runs in get_model()'s warm-return path now, so every native
|
||||
# TTS generate (this route, WS TTS, dub, batch, audiobook) is covered.
|
||||
# ── Where does this render run? Asked once, here, because every line
|
||||
# between this point and the dispatch below is preparation of THIS
|
||||
# machine's GPU — model eviction, a multi-GB load, a host-capability gate.
|
||||
# None of it applies to a render that belongs on the user's other box, and
|
||||
# running it anyway is how "I selected gpu2" ended up meaning "the Mac did
|
||||
# the work after loading the model twice".
|
||||
_decision = _routing_decision()
|
||||
_remote = bool(getattr(_decision, "remote", False))
|
||||
_target_label = getattr(_decision, "label", "") or "the chosen worker"
|
||||
|
||||
_model = None
|
||||
_backend = None
|
||||
if backend_cls is OmniVoiceBackend:
|
||||
# VoiceStudio keeps its native path: it carries the full advanced
|
||||
# parameter surface (t_shift, layer/position/class controls) that the
|
||||
# generic adapter protocol doesn't. Byte-identical to the old behavior.
|
||||
_model = await get_model()
|
||||
else:
|
||||
try:
|
||||
ok, msg = backend_cls.is_available()
|
||||
except Exception as exc:
|
||||
ok, msg = False, f"{type(exc).__name__}: {exc}"
|
||||
if not ok:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"TTS engine '{engine_id}' is not available: {_mask_hf_tokens(msg)}",
|
||||
)
|
||||
# Reuse the per-process instance cache shared with the engine
|
||||
# health-check route so weights load once, not per request.
|
||||
from api.routers.engines import _get_engine_instance
|
||||
_backend = _get_engine_instance(backend_cls)
|
||||
|
||||
# ── Routing gate (#21 — no silent CPU fallback). Computed ONCE per request
|
||||
# (host caps are constant; the per-request engine= override bypasses the
|
||||
# /engines/select gate, so this is the only place it's enforced for synth).
|
||||
from core.device_caps import detect_host_caps
|
||||
from services.engine_routing import resolve_routing, routing_notice
|
||||
# The engine's declared VRAM floor (#1226) — used by the routing gate and,
|
||||
# below, to let a generate TIMEOUT name the same shortfall. Resolved once:
|
||||
# every other job on this GPU pool (reference transcribe, assemble) leaves
|
||||
# it at 0, so only TTS generates can get the under-provisioned wording.
|
||||
_engine_min_vram_gb = getattr(backend_cls, "min_vram_gb", 0.0)
|
||||
_routing = resolve_routing(
|
||||
getattr(backend_cls, "gpu_compat", ("cpu",)), detect_host_caps(),
|
||||
_engine_min_vram_gb,
|
||||
)
|
||||
if _routing["routing_status"] == "unavailable":
|
||||
# The engine needs an accelerator this host lacks and has no CPU path.
|
||||
raise HTTPException(status_code=400, detail=_routing["routing_reason"])
|
||||
_routing_notice = routing_notice(_routing) # (status, reason) or None
|
||||
_routing_notice = None
|
||||
|
||||
if not _remote:
|
||||
# Single-active-engine memory discipline: hand back any OTHER resident
|
||||
# TTS engine's model before loading this one, so switching engines (or
|
||||
# a per-request engine= override, which bypasses /engines/select
|
||||
# entirely) doesn't stack two multi-GB models in memory — the
|
||||
# accumulation behind the 16 GB-Mac OOM deaths. No-op when nothing else
|
||||
# is resident, so steady-state single-engine use pays nothing. Opt out:
|
||||
# OMNIVOICE_SINGLE_ENGINE_RESIDENT=0.
|
||||
from services.engine_memory import evict_other_tts_engines
|
||||
await evict_other_tts_engines(engine_id)
|
||||
|
||||
# Non-blocking breadcrumb: if free memory is already low before this
|
||||
# load, log it. A later OOM kill (the 16 GB-Mac class) then has a trail
|
||||
# pointing at the load that tipped it, instead of a silent process
|
||||
# death. Never blocks — the OS can reclaim cache, and a hard refuse
|
||||
# would brick legitimate loads.
|
||||
try:
|
||||
from services.memory_budget import log_if_low
|
||||
|
||||
log_if_low(f"TTS load ({engine_id})")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# VRAM eviction runs in get_model()'s warm-return path now, so every
|
||||
# native TTS generate (this route, WS TTS, dub, batch, audiobook) is
|
||||
# covered.
|
||||
if backend_cls is OmniVoiceBackend:
|
||||
# VoiceStudio keeps its native path: it carries the full advanced
|
||||
# parameter surface (t_shift, layer/position/class controls) that
|
||||
# the generic adapter protocol doesn't. Byte-identical behavior.
|
||||
_model = await get_model()
|
||||
else:
|
||||
try:
|
||||
ok, msg = backend_cls.is_available()
|
||||
except Exception as exc:
|
||||
ok, msg = False, f"{type(exc).__name__}: {exc}"
|
||||
if not ok:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"TTS engine '{engine_id}' is not available: {_mask_hf_tokens(msg)}",
|
||||
)
|
||||
# Reuse the per-process instance cache shared with the engine
|
||||
# health-check route so weights load once, not per request.
|
||||
from api.routers.engines import _get_engine_instance
|
||||
_backend = _get_engine_instance(backend_cls)
|
||||
|
||||
# ── Routing gate (#21 — no silent CPU fallback). Computed ONCE per
|
||||
# request (host caps are constant; the per-request engine= override
|
||||
# bypasses the /engines/select gate, so this is the only place it's
|
||||
# enforced for synth). Local only, and deliberately: it asks what THIS
|
||||
# host can accelerate, and a remote render is precisely the case where
|
||||
# that answer is none of the question — a CUDA-only engine sent to a
|
||||
# 4090 from a Mac control plane would be refused by a gate describing
|
||||
# a machine that is about to do nothing.
|
||||
from core.device_caps import detect_host_caps
|
||||
from services.engine_routing import resolve_routing, routing_notice
|
||||
_routing = resolve_routing(
|
||||
getattr(backend_cls, "gpu_compat", ("cpu",)), detect_host_caps(),
|
||||
_engine_min_vram_gb,
|
||||
)
|
||||
if _routing["routing_status"] == "unavailable":
|
||||
# The engine needs an accelerator this host lacks and has no CPU path.
|
||||
raise HTTPException(status_code=400, detail=_routing["routing_reason"])
|
||||
_routing_notice = routing_notice(_routing) # (status, reason) or None
|
||||
|
||||
# ── #1033/#1037: warm the engine under the LOAD budget, not the generate
|
||||
# budget. A cold adapter lazily loads (and possibly downloads multi-GB
|
||||
@@ -1111,25 +1305,24 @@ async def generate_speech(
|
||||
# measured it: 0% GPU util for the full 300s). Model loading gets its own,
|
||||
# larger budget (OMNIVOICE_MODEL_LOAD_TIMEOUT, default 1200s) — the same
|
||||
# split get_model() already has for the native engine. Once warm, this is
|
||||
# a no-op per request.
|
||||
# a no-op per request. A remote render gets the same two-phase split from
|
||||
# the worker, under the assignment's own model-load deadline.
|
||||
if _backend is not None:
|
||||
from services.model_manager import _model_load_timeout
|
||||
from services import gpu_gateway
|
||||
try:
|
||||
await run_on_gpu_pool_guarded(
|
||||
_backend.ensure_ready,
|
||||
what=f"TTS engine '{engine_id}' model load",
|
||||
timeout=_model_load_timeout(),
|
||||
await gpu_gateway.prewarm(
|
||||
_REMOTE_OP, backend=_backend, engine=engine_id, decision=_decision,
|
||||
)
|
||||
# Builtin TimeoutError base, not GpuJobTimeoutError — reload-proof
|
||||
# class identity (see the twin catch in openai_compat.py).
|
||||
except TimeoutError as exc:
|
||||
except (TimeoutError, gpu_gateway.ModelLoadTimeout) as exc:
|
||||
logger.warning("engine load exceeded the model-load budget: %s", exc)
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=(
|
||||
f"TTS engine '{engine_id}' did not finish loading within its "
|
||||
f"model-load budget — on a first run this usually means the "
|
||||
f"weight download is slow or stalled (check Settings → Models "
|
||||
f"weight download is slow or stalled (check Model Catalogue → Models "
|
||||
f"for progress), not that generation failed. Retry once the "
|
||||
f"model shows as installed."
|
||||
),
|
||||
@@ -1308,6 +1501,74 @@ async def generate_speech(
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
# ── The remote assignment ───────────────────────────────────────────────
|
||||
# Built even for a local render (it costs a dict) so the gateway owns the
|
||||
# branch rather than this route owning two of them.
|
||||
#
|
||||
# The worker runs the ENTIRE render as one op — sentence split, per-chunk
|
||||
# generate at ``seed + i``, crossfaded concat, effect chain, provenance
|
||||
# mark — because dispatching a chunk at a time would pay a round trip, a
|
||||
# progress lease and a slot per sentence against a worker whose
|
||||
# concurrency defaults to 1. So every knob that shapes the local render has
|
||||
# to be on the wire: a missing one is not an error, it is remote audio that
|
||||
# quietly differs from local audio (no sentence splitting, no per-chunk
|
||||
# seed variation, no crossfade).
|
||||
from services import gpu_gateway
|
||||
from services.watermark import is_enabled as _watermark_enabled
|
||||
|
||||
_remote_params = {
|
||||
"text": text,
|
||||
"language": None if (language and language.lower() == "auto") else language,
|
||||
"ref_audio": ref_audio_path,
|
||||
"ref_text": ref_text,
|
||||
"instruct": instruct,
|
||||
"duration": duration,
|
||||
"speed": speed,
|
||||
"num_step": num_step,
|
||||
"guidance_scale": guidance_scale,
|
||||
"denoise": denoise,
|
||||
"postprocess_output": postprocess_output,
|
||||
"t_shift": t_shift,
|
||||
"layer_penalty_factor": layer_penalty_factor,
|
||||
"position_temperature": position_temperature,
|
||||
"class_temperature": class_temperature,
|
||||
"seed": used_seed,
|
||||
"max_chunk_chars": max_chunk_chars,
|
||||
"crossfade_ms": crossfade_ms,
|
||||
"effect_preset": effect_preset,
|
||||
# The requesting user's provenance preference, not the GPU owner's.
|
||||
"watermark": bool(_watermark_enabled()),
|
||||
}
|
||||
_remote_call = gpu_gateway.RemoteCall(
|
||||
engine=engine_id, operation=_REMOTE_OP, params=_remote_params,
|
||||
)
|
||||
|
||||
async def _render_on_worker(on_state=None):
|
||||
"""One whole render on the chosen worker → ``(tensor, sample_rate)``.
|
||||
|
||||
The audio comes back already effect-chained and provenance-marked: the
|
||||
worker mirrors the local order (split → generate → concat → effects →
|
||||
mark) so a remote take and a local take of the same request differ
|
||||
only in which GPU produced them.
|
||||
"""
|
||||
waveform, sample_rate = await gpu_gateway.run(
|
||||
_REMOTE_OP,
|
||||
local=gpu_gateway.LocalCall(
|
||||
_remote_only_local_call(_target_label),
|
||||
what="TTS generate",
|
||||
timeout=_generate_timeout_s(text),
|
||||
min_vram_gb=_engine_min_vram_gb,
|
||||
),
|
||||
remote=_remote_call,
|
||||
decision=_decision,
|
||||
on_state=on_state,
|
||||
)
|
||||
if getattr(waveform, "ndim", 2) == 1:
|
||||
# `_safe_torchaudio_save` and the local paths deal in
|
||||
# (channels, samples); a mono artifact reads back flat.
|
||||
waveform = waveform.unsqueeze(0)
|
||||
return waveform, sample_rate
|
||||
|
||||
# ── Streaming preview (feat: streaming-tts-preview) ─────────────────────
|
||||
# Long scripts used to mean staring at a spinner until the ENTIRE render
|
||||
# finished. With stream=true the existing text chunks (the Wave 1.2
|
||||
@@ -1322,6 +1583,138 @@ async def generate_speech(
|
||||
# seed / normalization) already ran, so per-chunk jobs spend the generate
|
||||
# budget on generation only — and each chunk gets its own budget, so a
|
||||
# long script can't time out merely for being long.
|
||||
if stream and _remote:
|
||||
# ── Remote: the streaming PREVIEW is off, the render still streams ──
|
||||
# Progressive playback needs per-chunk dispatch, and per-chunk dispatch
|
||||
# to a worker means a round trip, a progress lease and a slot for every
|
||||
# sentence, serialised by a default concurrency of 1. So the render
|
||||
# goes as ONE op and there is no first chunk to play early.
|
||||
#
|
||||
# The NDJSON channel stays open anyway, because the desktop UI asks for
|
||||
# it whenever auto-play is on — which is the default. Answering with
|
||||
# the classic WAV shape here would make the client fall back to a
|
||||
# LOCAL re-render, i.e. exactly the bug this phase exists to fix: the
|
||||
# user picks gpu2, clicks Synthesize, and their laptop does the work.
|
||||
# What flows down it instead is coarse progress from the worker, then
|
||||
# the finished take as a single chunk.
|
||||
_remote_headers = _apply_routing_headers(
|
||||
{"X-Seed": str(used_seed) if used_seed is not None else "",
|
||||
"Cache-Control": "no-cache"},
|
||||
None, _decision,
|
||||
)
|
||||
|
||||
_progress_q: asyncio.Queue = asyncio.Queue()
|
||||
|
||||
def _push_progress(event):
|
||||
# Called from the control plane's own loop; never let a progress
|
||||
# frame break a render that is otherwise going fine.
|
||||
try:
|
||||
_progress_q.put_nowait(dict(event or {}))
|
||||
except Exception: # noqa: BLE001
|
||||
logger.debug("dropped a remote progress frame", exc_info=True)
|
||||
|
||||
async def _remote_stream_events():
|
||||
import json
|
||||
|
||||
def _line(obj) -> bytes:
|
||||
return (json.dumps(obj, separators=(",", ":")) + "\n").encode("utf-8")
|
||||
|
||||
render = asyncio.ensure_future(_render_on_worker(_push_progress))
|
||||
try:
|
||||
# Relay progress until the render settles, then flush whatever
|
||||
# arrived in the gap so the last "generating (98%)" is not lost.
|
||||
while not render.done():
|
||||
getter = asyncio.ensure_future(_progress_q.get())
|
||||
done, _pending = await asyncio.wait(
|
||||
{render, getter}, return_when=asyncio.FIRST_COMPLETED
|
||||
)
|
||||
if getter in done:
|
||||
yield _line(_remote_progress_frame(getter.result(), _target_label))
|
||||
continue
|
||||
getter.cancel()
|
||||
while not _progress_q.empty():
|
||||
yield _line(_remote_progress_frame(_progress_q.get_nowait(),
|
||||
_target_label))
|
||||
audio_tensor, sample_rate = await render
|
||||
|
||||
yield _line({
|
||||
"type": "start", "sample_rate": sample_rate, "channels": 1,
|
||||
"format": "pcm16", "total_chunks": 1, "crossfade_ms": 0,
|
||||
"seed": used_seed,
|
||||
})
|
||||
# No second provenance mark: the worker marked at the tensor
|
||||
# stage before encoding, with the requesting user's preference
|
||||
# forced, and stacking a second AudioSeal payload over the
|
||||
# first degrades detection of both.
|
||||
yield _line({"type": "chunk", "seq": 0, "pcm": _pcm16_b64(audio_tensor)})
|
||||
|
||||
_, meta = await _finalize_generation(
|
||||
audio_tensor, sample_rate, text=text, history_mode=history_mode,
|
||||
ref_audio_path=ref_audio_path, language=language,
|
||||
instruct=instruct, resolved_profile_id=resolved_profile_id,
|
||||
used_seed=used_seed, start_time=start_time, already_marked=True,
|
||||
)
|
||||
# #1330's dropped-chunk warning has no remote carrier yet: the
|
||||
# gateway hands back audio, not the worker's render metadata.
|
||||
# Reported as a cross-stream gap rather than faked as zero.
|
||||
yield _line({
|
||||
"type": "done", "id": meta["id"], "audio_path": meta["filename"],
|
||||
"duration": meta["duration"], "gen_time": meta["gen_time"],
|
||||
"seed": used_seed, "sample_rate": sample_rate,
|
||||
})
|
||||
except (asyncio.CancelledError, GeneratorExit):
|
||||
# The user hit stop, or the request was abandoned. Cancelling
|
||||
# the render is what tells the worker to release its slot —
|
||||
# otherwise the 4090 keeps rendering audio nobody will hear,
|
||||
# holding what is often its only slot until the lease lapses.
|
||||
render.cancel()
|
||||
raise
|
||||
except ValueError:
|
||||
logger.error("Remote generation request rejected")
|
||||
from core.public_errors import stream_failure
|
||||
yield _line({"type": "error", **stream_failure("invalid_request")})
|
||||
except gpu_gateway.ModelNotDownloaded as e:
|
||||
logger.warning("Remote model missing on %s", _target_label)
|
||||
from core.public_errors import stream_failure
|
||||
yield _line({
|
||||
"type": "error",
|
||||
**stream_failure("model_not_downloaded"),
|
||||
"engine": e.engine,
|
||||
"repo_ids": e.repo_ids,
|
||||
"target": e.target,
|
||||
"target_label": e.target_label,
|
||||
"downloadable": e.downloadable,
|
||||
})
|
||||
except gpu_gateway.RemoteJobFailed as e:
|
||||
logger.error("Remote generate failed on %s", _target_label)
|
||||
from core.public_errors import stream_failure
|
||||
yield _line({
|
||||
"type": "error",
|
||||
**stream_failure("generation_failed"),
|
||||
"retryable": True,
|
||||
"target_label": e.worker_label or _target_label,
|
||||
"hint": e.hint,
|
||||
})
|
||||
except Exception:
|
||||
# Mid-job remote failure is NOT quietly redone here: the client
|
||||
# treats a retryable error as "surface it", so the user decides
|
||||
# whether to spend the same minutes again on this machine.
|
||||
logger.error("Remote generation failed", exc_info=True)
|
||||
from core.public_errors import stream_failure
|
||||
yield _line({"type": "error", **stream_failure("generation_failed")})
|
||||
finally:
|
||||
if not render.done():
|
||||
render.cancel()
|
||||
if cleanup_ref and ref_audio_path:
|
||||
with contextlib.suppress(OSError):
|
||||
os.remove(ref_audio_path)
|
||||
|
||||
return StreamingResponse(
|
||||
_remote_stream_events(),
|
||||
media_type="application/x-ndjson",
|
||||
headers=_remote_headers,
|
||||
)
|
||||
|
||||
if stream:
|
||||
from omnivoice.utils.text import parse_pause_markers
|
||||
from services.chunked_tts import split_text_into_chunks
|
||||
@@ -1425,7 +1818,7 @@ async def generate_speech(
|
||||
_backend, text, language, ref_audio_path, ref_text,
|
||||
instruct, duration, num_step, guidance_scale, speed,
|
||||
denoise, postprocess_output, used_seed, effect_preset,
|
||||
max_chunk_chars, crossfade_ms,
|
||||
max_chunk_chars, crossfade_ms, dropped_sink=_dropped_sink,
|
||||
),
|
||||
what="TTS generate",
|
||||
min_vram_gb=_engine_min_vram_gb,
|
||||
@@ -1441,7 +1834,7 @@ async def generate_speech(
|
||||
t_shift, denoise, postprocess_output,
|
||||
layer_penalty_factor, position_temperature,
|
||||
class_temperature, used_seed, effect_preset,
|
||||
max_chunk_chars, crossfade_ms,
|
||||
max_chunk_chars, crossfade_ms, dropped_sink=_dropped_sink,
|
||||
),
|
||||
what="TTS generate",
|
||||
min_vram_gb=_engine_min_vram_gb,
|
||||
@@ -1562,18 +1955,13 @@ async def generate_speech(
|
||||
with contextlib.suppress(OSError):
|
||||
os.remove(ref_audio_path)
|
||||
|
||||
_stream_headers = {
|
||||
# Routing notice (#21): known before the stream starts, so it rides the
|
||||
# same headers the classic path uses — and now also carries "your
|
||||
# chosen worker was unavailable, this ran here".
|
||||
_stream_headers = _apply_routing_headers({
|
||||
"X-Seed": str(used_seed) if used_seed is not None else "",
|
||||
"Cache-Control": "no-cache",
|
||||
}
|
||||
# Routing notice (#21): known before the stream starts, so it rides the
|
||||
# same headers the classic path uses.
|
||||
if _routing_notice:
|
||||
from services.engine_routing import header_safe_reason
|
||||
_stream_headers["X-OmniVoice-Routing"] = _routing_notice[0]
|
||||
_hr = header_safe_reason(_routing_notice[1])
|
||||
if _hr:
|
||||
_stream_headers["X-OmniVoice-Routing-Reason"] = _hr
|
||||
}, _routing_notice, _decision)
|
||||
return StreamingResponse(
|
||||
_stream_events(),
|
||||
media_type="application/x-ndjson",
|
||||
@@ -1586,47 +1974,54 @@ async def generate_speech(
|
||||
# so. A warning in a log the user never opens is a record of the bug, not a
|
||||
# fix for it.
|
||||
_dropped_text: list = []
|
||||
_already_marked = False
|
||||
try:
|
||||
if _backend is not None:
|
||||
# Bounded + pool-reset on hang so a wedged generate can't starve the
|
||||
# GPU pool and brick the backend ("can't reach backend", #730 class).
|
||||
audio_tensor = await run_on_gpu_pool_guarded(
|
||||
functools.partial(
|
||||
if _remote:
|
||||
# One op, one worker, the whole render — including the chunk loop.
|
||||
audio_tensor, sample_rate = await _render_on_worker()
|
||||
_already_marked = True
|
||||
else:
|
||||
# The gateway owns the dispatch on both branches. Locally it still
|
||||
# lands in run_on_gpu_pool_guarded, so the #730 bound + pool reset
|
||||
# that keeps a wedged generate from bricking the backend is
|
||||
# unchanged.
|
||||
if _backend is not None:
|
||||
_local_render = functools.partial(
|
||||
_run_backend_inference,
|
||||
_backend, text, language, ref_audio_path, ref_text, instruct,
|
||||
duration, num_step, guidance_scale, speed, denoise,
|
||||
postprocess_output, used_seed, effect_preset,
|
||||
max_chunk_chars, crossfade_ms, dropped_sink=_dropped_text,
|
||||
),
|
||||
what="TTS generate",
|
||||
min_vram_gb=_engine_min_vram_gb,
|
||||
timeout=_generate_timeout_s(text),
|
||||
)
|
||||
# Read after generation: engines with lazy model loading report
|
||||
# their real rate only once weights are up.
|
||||
sample_rate = _backend.sample_rate
|
||||
else:
|
||||
audio_tensor = await run_on_gpu_pool_guarded(
|
||||
functools.partial(
|
||||
)
|
||||
else:
|
||||
_local_render = functools.partial(
|
||||
_run_inference,
|
||||
_model, text, language, ref_audio_path, ref_text, instruct, duration,
|
||||
num_step, guidance_scale, speed, t_shift, denoise,
|
||||
postprocess_output, layer_penalty_factor, position_temperature,
|
||||
class_temperature, used_seed, effect_preset,
|
||||
max_chunk_chars, crossfade_ms, dropped_sink=_dropped_text,
|
||||
)
|
||||
audio_tensor = await gpu_gateway.run(
|
||||
_REMOTE_OP,
|
||||
local=gpu_gateway.LocalCall(
|
||||
_local_render, what="TTS generate",
|
||||
timeout=_generate_timeout_s(text),
|
||||
min_vram_gb=_engine_min_vram_gb,
|
||||
),
|
||||
what="TTS generate",
|
||||
min_vram_gb=_engine_min_vram_gb,
|
||||
timeout=_generate_timeout_s(text),
|
||||
decision=_decision,
|
||||
)
|
||||
sample_rate = _model.sampling_rate
|
||||
# Read after generation: engines with lazy model loading report
|
||||
# their real rate only once weights are up.
|
||||
sample_rate = (_backend.sample_rate if _backend is not None
|
||||
else _model.sampling_rate)
|
||||
# Watermark → save → history → prune → emit, shared with the streaming
|
||||
# path (see _finalize_generation) so both flows produce identical takes.
|
||||
audio_tensor, _meta = await _finalize_generation(
|
||||
audio_tensor, sample_rate, text=text, history_mode=history_mode,
|
||||
ref_audio_path=ref_audio_path, language=language, instruct=instruct,
|
||||
resolved_profile_id=resolved_profile_id, used_seed=used_seed,
|
||||
start_time=start_time,
|
||||
start_time=start_time, already_marked=_already_marked,
|
||||
)
|
||||
audio_id = _meta["id"]
|
||||
audio_filename = _meta["filename"]
|
||||
@@ -1660,14 +2055,10 @@ async def generate_speech(
|
||||
_lost = header_safe_reason(" | ".join(t for t in _dropped_text if t))
|
||||
if _lost:
|
||||
_resp_headers["X-OmniVoice-Dropped-Text"] = _lost
|
||||
# Routing notice (#21): cpu_fallback or accelerated-with-caveat only;
|
||||
# the WAV body is binary so the header channel is the carrier.
|
||||
if _routing_notice:
|
||||
from services.engine_routing import header_safe_reason
|
||||
_resp_headers["X-OmniVoice-Routing"] = _routing_notice[0]
|
||||
_hr = header_safe_reason(_routing_notice[1])
|
||||
if _hr:
|
||||
_resp_headers["X-OmniVoice-Routing-Reason"] = _hr
|
||||
# Routing notice (#21): cpu_fallback, accelerated-with-caveat, or the
|
||||
# machine this render ran on. The WAV body is binary so the header
|
||||
# channel is the carrier.
|
||||
_apply_routing_headers(_resp_headers, _routing_notice, _decision)
|
||||
return StreamingResponse(
|
||||
_stream_wav(),
|
||||
media_type="audio/wav",
|
||||
@@ -1675,6 +2066,42 @@ async def generate_speech(
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except gpu_gateway.ModelNotDownloaded as e:
|
||||
size_bytes = None
|
||||
try:
|
||||
from api.routers.setup.models import KNOWN_MODELS
|
||||
|
||||
sizes = [m.get("size_gb") for m in KNOWN_MODELS if m.get("repo_id") in e.repo_ids]
|
||||
if sizes and all(size is not None for size in sizes):
|
||||
size_bytes = int(sum(float(size) for size in sizes) * 1024**3)
|
||||
except Exception:
|
||||
pass
|
||||
raise HTTPException(status_code=409, detail={
|
||||
"error": "model_not_downloaded",
|
||||
"message": str(e),
|
||||
"engine": e.engine,
|
||||
"repo_ids": e.repo_ids,
|
||||
"size_bytes": size_bytes,
|
||||
"target": e.target,
|
||||
"target_label": e.target_label,
|
||||
"downloadable": e.downloadable,
|
||||
}) from e
|
||||
except gpu_gateway.RemoteJobFailed as e:
|
||||
# Rule 2 of the fallback policy: a single-shot interactive render that
|
||||
# failed ON the worker is reported, not silently redone here. Minutes
|
||||
# already went somewhere else, the user is watching, and quietly
|
||||
# re-rendering on the slower machine turns a 20-second wait into a
|
||||
# four-minute one with no explanation. The header names the target so
|
||||
# the client can offer "run it on this machine instead" — a resubmit
|
||||
# the user chose, with a wait they were told about.
|
||||
logger.error("Remote generate failed on %s: %s", _target_label, e)
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=f"{e} {e.hint or 'Run it on this machine instead, or pick another GPU.'}",
|
||||
headers={"X-OmniVoice-Retryable": "true",
|
||||
"X-OmniVoice-Routing": "remote_failed",
|
||||
"Retry-After": "10"},
|
||||
) from e
|
||||
except GpuPoolBusyError as e:
|
||||
# Saturation, not failure (#1190): the job never started, so the caller
|
||||
# can retry the identical request. Retry-After + the retryable marker
|
||||
|
||||
@@ -9,13 +9,13 @@ from __future__ import annotations
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from api.dependencies import require_loopback
|
||||
from api.dependencies import require_admin
|
||||
from services import mcp_bindings
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/mcp",
|
||||
tags=["mcp"],
|
||||
dependencies=[Depends(require_loopback)],
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -12,10 +12,10 @@ import logging
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from api.dependencies import require_loopback
|
||||
from api.dependencies import require_admin
|
||||
|
||||
logger = logging.getLogger("omnivoice.api")
|
||||
router = APIRouter(dependencies=[Depends(require_loopback)])
|
||||
router = APIRouter(dependencies=[Depends(require_admin)])
|
||||
|
||||
|
||||
class CustomPathRequest(BaseModel):
|
||||
|
||||
@@ -415,7 +415,7 @@ async def create_speech(req: SpeechRequest):
|
||||
detail=(
|
||||
f"TTS engine '{backend.id}' did not finish loading within its "
|
||||
f"model-load budget — on a first run this usually means the weight "
|
||||
f"download is slow or stalled (check Settings → Models for "
|
||||
f"download is slow or stalled (check Model Catalogue → Models for "
|
||||
f"progress), not that generation failed. Retry once the model "
|
||||
f"shows as installed."
|
||||
),
|
||||
@@ -517,9 +517,10 @@ async def create_transcription(
|
||||
):
|
||||
"""Transcribe audio to text. Compatible with OpenAI's POST /v1/audio/transcriptions."""
|
||||
from services.asr_backend import (
|
||||
ASRModelMissingError,
|
||||
asr_model_missing_detail,
|
||||
asr_model_missing_error,
|
||||
get_active_asr_backend,
|
||||
load_active_asr_backend,
|
||||
)
|
||||
|
||||
# TTS-only install: no ASR model on disk → actionable 409, BEFORE any
|
||||
@@ -546,18 +547,25 @@ async def create_transcription(
|
||||
raise HTTPException(status_code=400, detail=f"Could not read audio file: {e}")
|
||||
|
||||
try:
|
||||
backend = get_active_asr_backend()
|
||||
|
||||
# Run transcription in the thread pool to avoid blocking the event loop,
|
||||
# bounded so a stuck/starved ASR returns a 504 with guidance instead of
|
||||
# hanging the request forever (see run_transcribe_guarded).
|
||||
from services.asr_backend import run_transcribe_guarded
|
||||
word_ts = response_format == "verbose_json"
|
||||
result = await run_transcribe_guarded(
|
||||
_gpu_pool,
|
||||
lambda: backend.transcribe(tmp_path, word_timestamps=word_ts),
|
||||
what="OpenAI",
|
||||
)
|
||||
|
||||
# `load_active_asr_backend`, not `get_active_asr_backend`: the latter is
|
||||
# a pure selector, so a backend whose shallow `is_available()` probe
|
||||
# passes but whose deep import chain is broken (whisperx →
|
||||
# ctranslate2 failing to dlopen on a hardened kernel) reached
|
||||
# `.transcribe()` and 500'd, even with a healthy engine next in line.
|
||||
# The loader does select + ensure_loaded + degrade (#1185). It loads
|
||||
# weights, so it belongs inside the pool with the transcribe call —
|
||||
# never on the event loop.
|
||||
def _run():
|
||||
backend = load_active_asr_backend()
|
||||
return backend.transcribe(tmp_path, word_timestamps=word_ts)
|
||||
|
||||
result = await run_transcribe_guarded(_gpu_pool, _run, what="OpenAI")
|
||||
|
||||
# Extract the full text from segments
|
||||
segments = result.get("segments", [])
|
||||
@@ -625,6 +633,14 @@ async def create_transcription(
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except ASRModelMissingError as e:
|
||||
# A degraded-to candidate has no weights on disk. Same typed 409 the
|
||||
# preflight above raises — never a 500, and never a silent multi-GB
|
||||
# auto-download.
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={**e.payload, "message": asr_model_missing_detail(e.payload)},
|
||||
)
|
||||
except TimeoutError as e:
|
||||
# ASRTimeoutError (subclass): backend alive, ASR too heavy for compute.
|
||||
logger.warning("OpenAI transcription timed out: %s", e)
|
||||
|
||||
@@ -14,6 +14,7 @@ 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()
|
||||
|
||||
@@ -184,6 +185,50 @@ 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."""
|
||||
|
||||
@@ -7,7 +7,7 @@ CRUD for the DB-backed, per-language pronunciation dictionary the
|
||||
before synthesis (see ``services/pronunciation.apply_pronunciation`` and the
|
||||
generate path), so a saved entry actually changes the audio on every engine.
|
||||
|
||||
Endpoints (loopback-only, like the dictation router):
|
||||
Endpoints (admin-gated; loopback or authenticated server mode):
|
||||
GET /pronunciation → list every entry
|
||||
POST /pronunciation → create one entry
|
||||
PUT /pronunciation/{entry_id} → update an entry (partial)
|
||||
@@ -30,12 +30,12 @@ from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from api.dependencies import require_loopback
|
||||
from api.dependencies import require_admin
|
||||
from core.db import db_conn
|
||||
from services.pronunciation import apply_pronunciation, entries_for_language
|
||||
|
||||
logger = logging.getLogger("omnivoice.pronunciation")
|
||||
router = APIRouter()
|
||||
router = APIRouter(dependencies=[Depends(require_admin)])
|
||||
|
||||
_VALID_TYPES = ("respelling", "ipa", "cmu")
|
||||
_ALL_LANG = "*"
|
||||
@@ -133,7 +133,7 @@ class PronImportRequest(BaseModel):
|
||||
# ── CRUD ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/pronunciation", dependencies=[Depends(require_loopback)])
|
||||
@router.get("/pronunciation")
|
||||
def list_entries():
|
||||
with db_conn() as conn:
|
||||
rows = conn.execute(
|
||||
@@ -143,7 +143,7 @@ def list_entries():
|
||||
return [_row_to_dict(r) for r in rows]
|
||||
|
||||
|
||||
@router.post("/pronunciation", dependencies=[Depends(require_loopback)])
|
||||
@router.post("/pronunciation")
|
||||
def create_entry(entry: PronEntry):
|
||||
term = entry.term.strip()
|
||||
if not term:
|
||||
@@ -171,7 +171,7 @@ def create_entry(entry: PronEntry):
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
@router.put("/pronunciation/{entry_id}", dependencies=[Depends(require_loopback)])
|
||||
@router.put("/pronunciation/{entry_id}")
|
||||
def update_entry(entry_id: str, patch: PronEntryUpdate):
|
||||
with db_conn() as conn:
|
||||
existing = conn.execute(
|
||||
@@ -226,7 +226,7 @@ def update_entry(entry_id: str, patch: PronEntryUpdate):
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
@router.delete("/pronunciation/{entry_id}", dependencies=[Depends(require_loopback)])
|
||||
@router.delete("/pronunciation/{entry_id}")
|
||||
def delete_entry(entry_id: str):
|
||||
with db_conn() as conn:
|
||||
cur = conn.execute("DELETE FROM pronunciation_entries WHERE id = ?", (entry_id,))
|
||||
@@ -236,7 +236,7 @@ def delete_entry(entry_id: str):
|
||||
# ── Dry-run + import/export ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/pronunciation/test", dependencies=[Depends(require_loopback)])
|
||||
@router.post("/pronunciation/test")
|
||||
def test_substitution(req: PronTestRequest):
|
||||
"""Show the post-substitution text for ``req.text`` — no model call.
|
||||
|
||||
@@ -258,7 +258,7 @@ def test_substitution(req: PronTestRequest):
|
||||
}
|
||||
|
||||
|
||||
@router.get("/pronunciation/export", dependencies=[Depends(require_loopback)])
|
||||
@router.get("/pronunciation/export")
|
||||
def export_entries():
|
||||
"""Every entry as a JSON-serializable list (round-trips ``/import``)."""
|
||||
with db_conn() as conn:
|
||||
@@ -273,7 +273,7 @@ def export_entries():
|
||||
]}
|
||||
|
||||
|
||||
@router.post("/pronunciation/import", dependencies=[Depends(require_loopback)])
|
||||
@router.post("/pronunciation/import")
|
||||
def import_entries(req: PronImportRequest):
|
||||
"""Bulk-add entries. ``replace=true`` clears the table first.
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from core.logging_utils import log_safe
|
||||
from api.dependencies import require_admin
|
||||
from api.dependencies import require_admin, require_admin_action
|
||||
|
||||
logger = logging.getLogger("omnivoice.api.settings")
|
||||
|
||||
@@ -92,8 +92,8 @@ def get_hf_token_state(fresh: bool = Query(False)):
|
||||
|
||||
|
||||
# ── Performance settings (INST-12) ────────────────────────────────────────
|
||||
# Threat T-02-04: same loopback guard as the hf-token endpoints via the
|
||||
# router-level `require_loopback` dep.
|
||||
# Threat T-02-04: same admin guard as the hf-token endpoints via the
|
||||
# router-level `require_admin` dep.
|
||||
|
||||
|
||||
_TORCH_COMPILE_KEY = "perf.torch_compile_disabled"
|
||||
@@ -133,6 +133,83 @@ def set_torch_compile_disabled(body: _TorchCompileBody):
|
||||
return _torch_compile_state()
|
||||
|
||||
|
||||
# ── Compute-device override (Settings → Performance) ──────────────────────
|
||||
|
||||
|
||||
class _ComputeDeviceBody(BaseModel):
|
||||
value: str = Field(..., description="auto | cuda | rocm | xpu | mps | cpu")
|
||||
|
||||
|
||||
def _compute_device_state() -> dict:
|
||||
"""Everything the Performance panel needs to render the device control:
|
||||
the resolved pick (env > prefs > auto), what this process actually applied
|
||||
at probe time (differs after a change until restart — caps are immutable
|
||||
per process), what auto would pick, and which families exist here."""
|
||||
from core import device_caps
|
||||
|
||||
caps = device_caps.detect_host_caps()
|
||||
env_pin = (os.environ.get("OMNIVOICE_DEVICE") or "").strip().lower()
|
||||
auto_family = next(
|
||||
(f for f in ("cuda", "rocm", "xpu", "mps") if f in caps.available_families),
|
||||
"cpu",
|
||||
)
|
||||
value = device_caps.requested_device_override()
|
||||
return {
|
||||
"value": value,
|
||||
"applied": caps.requested_family,
|
||||
"restart_required": value != caps.requested_family,
|
||||
# The running process asked for a family it doesn't have (env pin on
|
||||
# the wrong machine, hardware removed): auto is in effect, and a
|
||||
# restart would not change that — the panel says so instead of
|
||||
# pretending the pick took.
|
||||
"override_ignored": (
|
||||
caps.requested_family not in ("auto", caps.family)
|
||||
),
|
||||
"effective_family": caps.family,
|
||||
"auto_family": auto_family,
|
||||
"available_families": list(caps.available_families),
|
||||
"env_pinned": env_pin in device_caps.DEVICE_OVERRIDE_CHOICES and env_pin != "",
|
||||
"choices": list(device_caps.DEVICE_OVERRIDE_CHOICES),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/compute-device")
|
||||
def get_compute_device():
|
||||
"""Current compute-device override state (Settings → Performance)."""
|
||||
return _compute_device_state()
|
||||
|
||||
|
||||
@router.put("/compute-device")
|
||||
def set_compute_device(body: _ComputeDeviceBody):
|
||||
"""Persist the compute-device pick. Applied by the capability probe at
|
||||
the next backend start (host caps are immutable per process — same
|
||||
restart contract as the rest of the Performance tab). ``OMNIVOICE_DEVICE``
|
||||
always wins over this pick; the UI shows the pin instead of pretending."""
|
||||
from core import device_caps, prefs
|
||||
|
||||
value = (body.value or "").strip().lower()
|
||||
if value not in device_caps.DEVICE_OVERRIDE_CHOICES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unknown device '{value}'. Valid: {', '.join(device_caps.DEVICE_OVERRIDE_CHOICES)}",
|
||||
)
|
||||
caps = device_caps.detect_host_caps()
|
||||
if value not in ("auto", "cpu") and value not in caps.available_families:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
f"'{value}' is not available on this host "
|
||||
f"(have: {', '.join(caps.available_families)})"
|
||||
),
|
||||
)
|
||||
try:
|
||||
prefs.set_("compute_device", value)
|
||||
except Exception:
|
||||
logger.exception("set_compute_device failed")
|
||||
raise HTTPException(status_code=500, detail="Failed to persist setting")
|
||||
return _compute_device_state()
|
||||
|
||||
|
||||
# ── Generation-history retention (Studio takes rail) ──────────────────────
|
||||
|
||||
|
||||
@@ -481,7 +558,10 @@ def _local_models(base_url: str, api_key: str):
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/llm-providers/{provider_id}/models")
|
||||
@router.get(
|
||||
"/llm-providers/{provider_id}/models",
|
||||
dependencies=[Depends(require_admin_action)],
|
||||
)
|
||||
def list_llm_provider_models(provider_id: str):
|
||||
"""List model ids the provider's key can access (OpenAI-compat /models).
|
||||
|
||||
|
||||
@@ -296,17 +296,19 @@ def _validate_snapshot_has_weights(repo_id: str, snapshot_path: str) -> None:
|
||||
f"{repo_id}: download finished but no model weights were found in the "
|
||||
"snapshot (largest file "
|
||||
f"{biggest} bytes). The download was likely interrupted — delete the "
|
||||
"model in Settings → Models and install it again."
|
||||
"model in Model Catalogue → Models and install it again."
|
||||
)
|
||||
|
||||
|
||||
@router.get("/setup/download-stream")
|
||||
async def setup_download_stream():
|
||||
async def setup_download_stream(target: str | None = None):
|
||||
"""SSE: forward every HuggingFace download tqdm update as a JSON event."""
|
||||
queue: asyncio.Queue = asyncio.Queue(maxsize=512)
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
def listener(event):
|
||||
if target and event.get("target", "local") != target:
|
||||
return
|
||||
try:
|
||||
loop.call_soon_threadsafe(_safe_put, queue, event)
|
||||
except RuntimeError:
|
||||
@@ -340,6 +342,7 @@ async def setup_download_stream():
|
||||
|
||||
class InstallModelRequest(BaseModel):
|
||||
repo_id: str
|
||||
target: str | None = None
|
||||
|
||||
|
||||
|
||||
@@ -389,6 +392,21 @@ async def install_model(req: InstallModelRequest):
|
||||
+ ", ".join(m["repo_id"] for m in KNOWN_MODELS)
|
||||
),
|
||||
)
|
||||
target = (req.target or "").strip()
|
||||
if target != "local":
|
||||
from services import gpu_gateway # noqa: PLC0415
|
||||
from worker import routing # noqa: PLC0415
|
||||
|
||||
decision = routing.decide()
|
||||
if target and target != "local" and (
|
||||
not decision.remote or decision.worker_id != target
|
||||
):
|
||||
raise HTTPException(status_code=409, detail="The selected GPU target changed; try again.")
|
||||
if decision.remote:
|
||||
try:
|
||||
return await gpu_gateway.download(req.repo_id, decision=decision)
|
||||
except gpu_gateway.GatewayError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
# Cooldown guard — don't retry if the same model just failed.
|
||||
import time as _time_check
|
||||
_sweep_cooldowns(_time_check.time()) # bound the dict (MM2-06)
|
||||
@@ -410,6 +428,7 @@ async def install_model(req: InstallModelRequest):
|
||||
|
||||
def _do():
|
||||
token = hf_progress.current_repo_id.set(req.repo_id)
|
||||
target_token = hf_progress.current_target.set("local")
|
||||
_cancelled.discard(req.repo_id) # clear any stale cancel from a prior run
|
||||
hf_progress.emit({
|
||||
"repo_id": req.repo_id,
|
||||
@@ -505,6 +524,7 @@ async def install_model(req: InstallModelRequest):
|
||||
return
|
||||
download_aggregator.start(
|
||||
req.repo_id,
|
||||
target=target or "local",
|
||||
total_bytes=_summary["to_download_bytes"],
|
||||
files_total=max(0, _summary["n_files"] - _summary["n_cached"]),
|
||||
)
|
||||
@@ -518,7 +538,7 @@ async def install_model(req: InstallModelRequest):
|
||||
# No preflight (older/gated repo, mirror without dry-run, etc.):
|
||||
# fall back to today's fill-in-as-files-appear behaviour.
|
||||
logger.info("model install %s: preflight unavailable (%s)", req.repo_id, _pf_err)
|
||||
download_aggregator.start(req.repo_id)
|
||||
download_aggregator.start(req.repo_id, target=target or "local")
|
||||
hf_progress.emit({
|
||||
"repo_id": req.repo_id,
|
||||
"filename": req.repo_id,
|
||||
@@ -618,7 +638,7 @@ async def install_model(req: InstallModelRequest):
|
||||
# Flush the overall bar to 100% with the true byte total (FDL-06):
|
||||
# under Xet the per-file byte bars don't surface completion, so the
|
||||
# aggregator can sit below 100% even though every file landed.
|
||||
download_aggregator.complete(req.repo_id)
|
||||
download_aggregator.complete(req.repo_id, target=target or "local")
|
||||
logger.info("model install done: %s", req.repo_id)
|
||||
hf_progress.emit({
|
||||
"repo_id": req.repo_id,
|
||||
@@ -662,8 +682,9 @@ async def install_model(req: InstallModelRequest):
|
||||
})
|
||||
finally:
|
||||
_cancelled.discard(req.repo_id)
|
||||
download_aggregator.finish(req.repo_id)
|
||||
download_aggregator.finish(req.repo_id, target=target or "local")
|
||||
hf_progress.current_repo_id.reset(token)
|
||||
hf_progress.current_target.reset(target_token)
|
||||
with _active_installs_lock:
|
||||
_active_installs.discard(req.repo_id)
|
||||
|
||||
|
||||
@@ -90,6 +90,36 @@ def get_model_catalog() -> ModelCatalog:
|
||||
|
||||
# ── Platform Detection ─────────────────────────────────────────────────────
|
||||
|
||||
def _target_worker():
|
||||
"""Selected live remote worker, or None when the catalog targets local."""
|
||||
try:
|
||||
from worker import routing, service # noqa: PLC0415
|
||||
|
||||
decision = routing.decide()
|
||||
plane = service.control_plane
|
||||
return plane.pool.get(decision.worker_id) if decision.remote and plane.pool else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _target_host() -> dict | None:
|
||||
"""Selected remote worker host, or None when the catalog targets local."""
|
||||
live = _target_worker()
|
||||
return dict(live.record.host or {}) if live is not None else None
|
||||
|
||||
|
||||
def _target_repo_inventory() -> tuple[str, set[str]] | None:
|
||||
"""Selected worker id and the catalog repositories it reports on disk."""
|
||||
live = _target_worker()
|
||||
if live is None:
|
||||
return None
|
||||
downloaded: set[str] = set()
|
||||
for capability in live.record.capabilities or []:
|
||||
if capability.get("downloaded"):
|
||||
downloaded.update(str(repo) for repo in capability.get("repo_ids") or [])
|
||||
return live.id, downloaded
|
||||
|
||||
|
||||
def _current_platform_tags() -> list[str]:
|
||||
"""Return platform tags that the current host supports.
|
||||
|
||||
@@ -100,6 +130,25 @@ def _current_platform_tags() -> list[str]:
|
||||
``rocm`` (AMD HIP builds), and ``cpu`` (no GPU acceleration at all —
|
||||
Apple Silicon is NOT tagged cpu; it curates via ``darwin-arm64``).
|
||||
"""
|
||||
target = _target_host()
|
||||
if target is not None:
|
||||
target_os = {"windows": "win32", "darwin": "darwin"}.get(
|
||||
str(target.get("os") or "").lower(), "linux"
|
||||
)
|
||||
arch = str(target.get("arch") or "").lower()
|
||||
arch = {"amd64": "x86_64", "aarch64": "arm64"}.get(arch, arch)
|
||||
tags = [target_os, f"{target_os}-{arch}"]
|
||||
backend = ""
|
||||
if target.get("gpus"):
|
||||
backend = str(target["gpus"][0].get("backend") or "").lower()
|
||||
if backend:
|
||||
tags.append(backend)
|
||||
if backend == "rocm":
|
||||
tags.append("cuda")
|
||||
if not backend and not (target_os == "darwin" and arch == "arm64"):
|
||||
tags.append("cpu")
|
||||
return tags
|
||||
|
||||
tags = [sys.platform]
|
||||
arch = _platform.machine()
|
||||
tags.append(f"{sys.platform}-{arch}")
|
||||
@@ -455,35 +504,52 @@ def list_models():
|
||||
Uses a 10 s response cache to avoid repeated ``scan_cache_dir()`` disk
|
||||
walks when the frontend polls.
|
||||
"""
|
||||
cached_response = _cached("models")
|
||||
platform_tags = _current_platform_tags()
|
||||
remote_inventory = _target_repo_inventory()
|
||||
target_key = remote_inventory[0] if remote_inventory else "local"
|
||||
cache_key = "models:" + target_key + ":" + ",".join(sorted(platform_tags))
|
||||
cached_response = _cached(cache_key)
|
||||
if cached_response is not None:
|
||||
return cached_response
|
||||
|
||||
cached_by_repo: dict[str, dict] = {}
|
||||
try:
|
||||
from huggingface_hub import scan_cache_dir
|
||||
info = scan_cache_dir()
|
||||
for entry in info.repos:
|
||||
cached_by_repo[entry.repo_id] = {
|
||||
"size_on_disk": entry.size_on_disk,
|
||||
"last_accessed": entry.last_accessed,
|
||||
"nb_files": entry.nb_files,
|
||||
}
|
||||
except Exception as e:
|
||||
# WinError-448 fallback (#117/#118): use a direct disk scan so installed
|
||||
# models still show as installed instead of offering a re-download.
|
||||
logger.warning("scan_cache_dir failed (%s); using disk fallback", e)
|
||||
cached_by_repo = _scan_cache_on_disk()
|
||||
if remote_inventory is not None:
|
||||
for model in KNOWN_MODELS:
|
||||
if model["repo_id"] in remote_inventory[1]:
|
||||
cached_by_repo[model["repo_id"]] = {
|
||||
"size_on_disk": int(float(model.get("size_gb") or 0) * _GIB),
|
||||
"last_accessed": None,
|
||||
"nb_files": 0,
|
||||
}
|
||||
else:
|
||||
try:
|
||||
from huggingface_hub import scan_cache_dir
|
||||
info = scan_cache_dir()
|
||||
for entry in info.repos:
|
||||
cached_by_repo[entry.repo_id] = {
|
||||
"size_on_disk": entry.size_on_disk,
|
||||
"last_accessed": entry.last_accessed,
|
||||
"nb_files": entry.nb_files,
|
||||
}
|
||||
except Exception as e:
|
||||
# WinError-448 fallback (#117/#118): use a direct disk scan so installed
|
||||
# models still show as installed instead of offering a re-download.
|
||||
logger.warning("scan_cache_dir failed (%s); using disk fallback", e)
|
||||
cached_by_repo = _scan_cache_on_disk()
|
||||
|
||||
out = []
|
||||
host_tags = set(_current_platform_tags())
|
||||
host_tags = set(platform_tags)
|
||||
for m in KNOWN_MODELS:
|
||||
cached = cached_by_repo.get(m["repo_id"])
|
||||
on_disk = cached is not None and cached["size_on_disk"] > 0
|
||||
on_disk = (
|
||||
m["repo_id"] in remote_inventory[1]
|
||||
if remote_inventory is not None
|
||||
else cached is not None and cached["size_on_disk"] > 0
|
||||
)
|
||||
# A size-positive cache can still be a truncated download (config landed,
|
||||
# weight shard didn't). Treat that as not-installed + incomplete so the
|
||||
# wizard re-offers the download instead of stranding the user (#622).
|
||||
incomplete = on_disk and not cache_is_complete(m)
|
||||
incomplete = on_disk and remote_inventory is None and not cache_is_complete(m)
|
||||
out.append({
|
||||
**m,
|
||||
"installed": on_disk and not incomplete,
|
||||
@@ -498,14 +564,14 @@ def list_models():
|
||||
response = {
|
||||
"models": out,
|
||||
"total_installed_bytes": sum(m["size_on_disk_bytes"] for m in out),
|
||||
"hf_cache_dir": hf_cache_dir(),
|
||||
"hf_cache_dir": "" if remote_inventory is not None else hf_cache_dir(),
|
||||
# Free space on the cache volume, so the Model Store header can warn
|
||||
# BEFORE an "Install all" overruns the disk (pairs with the per-install
|
||||
# disk_space_error guard in setup/download.py).
|
||||
"disk_free_gb": round(disk_free_bytes() / _GIB, 1),
|
||||
"platform_tags": _current_platform_tags(),
|
||||
"disk_free_gb": None if remote_inventory is not None else round(disk_free_bytes() / _GIB, 1),
|
||||
"platform_tags": platform_tags,
|
||||
}
|
||||
_set_cache("models", response)
|
||||
_set_cache(cache_key, response)
|
||||
return response
|
||||
|
||||
|
||||
@@ -518,18 +584,19 @@ def recommendations():
|
||||
TTS model is required; the ASR picks here are the optional "best for your
|
||||
system" set the wizard and Settings surface for on-demand install.
|
||||
"""
|
||||
is_mac_arm = sys.platform == "darwin" and _platform.machine() == "arm64"
|
||||
is_mac_intel = sys.platform == "darwin" and _platform.machine() == "x86_64"
|
||||
is_linux = sys.platform.startswith("linux")
|
||||
is_windows = sys.platform == "win32"
|
||||
|
||||
tags = set(_current_platform_tags())
|
||||
target_os = "darwin" if "darwin" in tags else "win32" if "win32" in tags else "linux"
|
||||
target_arch = next((tag.split("-", 1)[1] for tag in tags if tag.startswith(target_os + "-")), _platform.machine())
|
||||
is_mac_arm = target_os == "darwin" and target_arch == "arm64"
|
||||
is_mac_intel = target_os == "darwin" and target_arch == "x86_64"
|
||||
is_linux = target_os == "linux"
|
||||
is_windows = target_os == "win32"
|
||||
has_cuda = "cuda" in tags and "rocm" not in tags
|
||||
has_rocm = "rocm" in tags
|
||||
|
||||
# Device label — used as the card title.
|
||||
if is_mac_arm:
|
||||
device_label = f"Apple Silicon ({_platform.machine()})"
|
||||
device_label = f"Apple Silicon ({target_arch})"
|
||||
elif is_mac_intel:
|
||||
device_label = "macOS Intel (x86_64)"
|
||||
elif is_windows:
|
||||
@@ -537,7 +604,7 @@ def recommendations():
|
||||
elif is_linux:
|
||||
device_label = "Linux x64" + (" + CUDA" if has_cuda else " + ROCm" if has_rocm else "")
|
||||
else:
|
||||
device_label = f"{sys.platform} / {_platform.machine()}"
|
||||
device_label = f"{target_os} / {target_arch}"
|
||||
|
||||
# Curated preset for this host, in catalog order (required entries lead).
|
||||
curated = [
|
||||
@@ -574,24 +641,30 @@ def recommendations():
|
||||
"instant English TTS."
|
||||
)
|
||||
|
||||
remote_inventory = _target_repo_inventory()
|
||||
cached_ids: set[str] = set()
|
||||
try:
|
||||
from huggingface_hub import scan_cache_dir
|
||||
info = scan_cache_dir()
|
||||
cached_ids = {
|
||||
entry.repo_id for entry in info.repos if entry.size_on_disk > 0
|
||||
}
|
||||
except Exception as e:
|
||||
# WinError-448 fallback (#117/#118): recommend based on the disk scan.
|
||||
logger.debug("scan_cache_dir failed (%s); using disk fallback", e)
|
||||
cached_ids = set(_scan_cache_on_disk().keys())
|
||||
if remote_inventory is not None:
|
||||
cached_ids = remote_inventory[1]
|
||||
else:
|
||||
try:
|
||||
from huggingface_hub import scan_cache_dir
|
||||
info = scan_cache_dir()
|
||||
cached_ids = {
|
||||
entry.repo_id for entry in info.repos if entry.size_on_disk > 0
|
||||
}
|
||||
except Exception as e:
|
||||
# WinError-448 fallback (#117/#118): recommend based on the disk scan.
|
||||
logger.debug("scan_cache_dir failed (%s); using disk fallback", e)
|
||||
cached_ids = set(_scan_cache_on_disk().keys())
|
||||
|
||||
entries = []
|
||||
for meta in curated:
|
||||
rid = meta["repo_id"]
|
||||
# Mirror /models: a truncated cache (weights missing) is not installed, so
|
||||
# the wizard counts it toward the remaining download instead of "all set".
|
||||
installed = rid in cached_ids and cache_is_complete(meta)
|
||||
installed = rid in cached_ids and (
|
||||
remote_inventory is not None or cache_is_complete(meta)
|
||||
)
|
||||
entries.append({
|
||||
"repo_id": rid,
|
||||
"label": meta.get("label", rid),
|
||||
@@ -607,8 +680,8 @@ def recommendations():
|
||||
|
||||
return {
|
||||
"device": {
|
||||
"os": sys.platform,
|
||||
"arch": _platform.machine(),
|
||||
"os": target_os,
|
||||
"arch": target_arch,
|
||||
"is_mac_arm": is_mac_arm,
|
||||
"is_mac_intel": is_mac_intel,
|
||||
"is_linux": is_linux,
|
||||
|
||||
@@ -183,7 +183,7 @@ def _hf_endpoint_host() -> tuple[str, int]:
|
||||
"""Host/port of the Hugging Face endpoint actually in effect.
|
||||
|
||||
Mirror-aware: restricted-network users (e.g. behind the Great Firewall)
|
||||
point HF_ENDPOINT at a mirror via Settings → Models → Hugging Face
|
||||
point HF_ENDPOINT at a mirror via Model Catalogue → Models → Hugging Face
|
||||
mirror. Probing hardcoded huggingface.co would fail them even when their
|
||||
configured mirror works fine.
|
||||
"""
|
||||
@@ -281,7 +281,7 @@ def _network_check() -> dict:
|
||||
"id": "network", "label": "Network (configured endpoint)",
|
||||
"status": "warn",
|
||||
"detail": "The configured Hugging Face endpoint could not be validated.",
|
||||
"fix": "Review the endpoint in Settings → Models, then re-check.",
|
||||
"fix": "Review the endpoint in Model Catalogue → Models, then re-check.",
|
||||
"mirror_reachable": False,
|
||||
}
|
||||
net_ok = _probe_network(net_host, net_port)
|
||||
@@ -496,10 +496,10 @@ def preflight():
|
||||
elif _rs == "unavailable":
|
||||
r_status, r_detail, r_fix = "fail", (
|
||||
f"{_eng} can't run on this host: {_why or 'needs a GPU this machine lacks'}"), (
|
||||
"Select an engine with a CPU path in Settings → Engines.")
|
||||
"Select an engine with a CPU path in Model Catalogue → Engines.")
|
||||
else: # "none" / unknown
|
||||
r_status, r_detail, r_fix = "warn", "No active TTS engine resolved for routing.", (
|
||||
"Pick an engine in Settings → Engines.")
|
||||
"Pick an engine in Model Catalogue → Engines.")
|
||||
checks.append({
|
||||
"id": "gpu_routing", "label": "Active engine routing",
|
||||
"status": r_status, "detail": r_detail, "fix": r_fix,
|
||||
|
||||
@@ -11,7 +11,7 @@ from core.prefs import set_ as prefs_set, delete as prefs_delete
|
||||
from services import network_share
|
||||
from services import tailscale as _tailscale
|
||||
from api.schemas import SysinfoResponse, SystemInfoResponse, ModelStatusResponse
|
||||
from api.dependencies import is_loopback, require_admin
|
||||
from api.dependencies import is_loopback, require_admin, require_admin_action
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
import torch
|
||||
import shutil
|
||||
@@ -544,9 +544,10 @@ async def flush_memory(unload_model: bool = False):
|
||||
if unload_model:
|
||||
import services.model_manager as mm
|
||||
async with mm._model_lock:
|
||||
if mm.model is not None:
|
||||
mm.model = None
|
||||
freed_model = True
|
||||
# Also drops the clone-prompt side cache, which this path used to
|
||||
# leave resident — an "unload" that kept the encoded reference
|
||||
# tensors belonging to the model it just released (#1495).
|
||||
freed_model = mm.unload_shared_model()
|
||||
|
||||
# Multi-pass GC to break reference cycles
|
||||
gc.collect(generation=2)
|
||||
@@ -555,15 +556,25 @@ async def flush_memory(unload_model: bool = False):
|
||||
|
||||
free_vram()
|
||||
|
||||
# Snapshot after flush
|
||||
# Snapshot after flush. Two numbers, because one of them is a lie by
|
||||
# omission: `memory_allocated` counts live tensors only, so it reads ~0
|
||||
# after an unload while nvidia-smi still shows gigabytes — which is exactly
|
||||
# the report we keep getting ("flush says it worked, the GPU says it
|
||||
# didn't"). `memory_reserved` is what the caching allocator holds from the
|
||||
# driver, and the gap between reserved and the driver's own figure is the
|
||||
# CUDA context plus kernel workspaces, which no in-process call can return.
|
||||
vram_after = 0.0
|
||||
vram_reserved = 0.0
|
||||
try:
|
||||
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
||||
driver = getattr(torch.mps, "driver_allocated_memory", None)
|
||||
if driver:
|
||||
vram_after = driver() / (1024**3)
|
||||
current = getattr(torch.mps, "current_allocated_memory", None)
|
||||
vram_reserved = (current() / (1024**3)) if current else vram_after
|
||||
elif torch.cuda.is_available():
|
||||
vram_after = torch.cuda.memory_allocated() / (1024**3)
|
||||
vram_reserved = torch.cuda.memory_reserved() / (1024**3)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -574,6 +585,7 @@ async def flush_memory(unload_model: bool = False):
|
||||
"unloaded_model": freed_model,
|
||||
"ram_after": round(ram_after, 2),
|
||||
"vram_after": round(vram_after, 2),
|
||||
"vram_reserved": round(vram_reserved, 2),
|
||||
}
|
||||
|
||||
|
||||
@@ -1077,7 +1089,10 @@ async def diagnostic_bundle(network: bool = Query(False, description="Include th
|
||||
# ── Self-check diagnostics ────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/system/diagnose")
|
||||
@router.get(
|
||||
"/system/diagnose",
|
||||
dependencies=[Depends(require_admin_action)],
|
||||
)
|
||||
async def system_diagnose(
|
||||
network: bool = Query(True, description="Include the HuggingFace hub reachability probe"),
|
||||
deep: bool = Query(False, description="Also load the active engine and synthesize a short utterance (may cold-load the model — minutes on first run)"),
|
||||
|
||||
@@ -62,6 +62,11 @@ async def ws_tts(websocket: WebSocket):
|
||||
await websocket.accept()
|
||||
logger.info("TTS streaming WebSocket connected")
|
||||
|
||||
# Said once per socket, not once per utterance: a conversational client
|
||||
# sends many requests down one connection and a repeated notice would be
|
||||
# noise. See `_announce_local_only`.
|
||||
announced_local_only = False
|
||||
|
||||
try:
|
||||
while True:
|
||||
# Wait for a text request from the client
|
||||
@@ -83,6 +88,43 @@ async def ws_tts(websocket: WebSocket):
|
||||
t0 = time.perf_counter()
|
||||
text = data["text"]
|
||||
|
||||
# Remote GPU: this socket stays on this machine, and says so.
|
||||
#
|
||||
# /generate's port trades progressive playback for the remote
|
||||
# render — the classic path was always a single wait, so spending
|
||||
# it on a faster GPU is a straight win. This route is the opposite
|
||||
# shape: it exists to put audio in the user's ear before the
|
||||
# sentence has finished synthesizing, and sending each utterance to
|
||||
# a worker would pay queue admission, a round trip and cold-load
|
||||
# risk per utterance, for the one surface where latency IS the
|
||||
# feature.
|
||||
#
|
||||
# Silence would be worse than the limitation: the header badge
|
||||
# would read "gpu2" while this machine does 100% of the work, the
|
||||
# same class of lie the op-aware picker exists to stop. Said once
|
||||
# per socket — a conversational client sends many requests down one
|
||||
# connection — and BEFORE engine resolution, so an engine that
|
||||
# cannot load still tells the user where it would have run.
|
||||
if not announced_local_only:
|
||||
announced_local_only = True
|
||||
try:
|
||||
from worker import routing as worker_routing
|
||||
|
||||
target = worker_routing.decide(op="tts")
|
||||
except Exception: # noqa: BLE001 — advisory; never break audio
|
||||
target = None
|
||||
if target is not None and target.remote:
|
||||
from core.scrub import scrub_text as _scrub
|
||||
|
||||
await websocket.send_json({
|
||||
"type": "routing",
|
||||
"status": "local_stream",
|
||||
"reason": _scrub(
|
||||
f"{target.label} is your GPU target, but live "
|
||||
f"streaming runs on this machine"
|
||||
),
|
||||
})
|
||||
|
||||
try:
|
||||
# Resolve engine
|
||||
from services.tts_backend import (
|
||||
|
||||
@@ -0,0 +1,608 @@
|
||||
"""Remote worker management API.
|
||||
|
||||
Deliberately small. The council's warning about the original design was that
|
||||
seven strategies times three execution modes times priorities times weights
|
||||
times per-model concurrency is a configuration surface nobody can test and
|
||||
every knob is a compatibility promise forever. So this exposes what a user
|
||||
actually needs to run their other GPU: see workers, add one, name it, prefer
|
||||
one, pause one, remove one.
|
||||
|
||||
Two things here are not conveniences and must not be softened:
|
||||
|
||||
* **Consent is explicit and per worker.** Audio, reference voices, and text
|
||||
leave the machine for a worker, so each one is approved individually. There
|
||||
is no global "trust all workers".
|
||||
* **A token is shown exactly once.** Only its hash is stored, so it cannot be
|
||||
re-displayed — which is the point.
|
||||
|
||||
One endpoint here is not part of that surface: `POST /workers/tasks` submits a
|
||||
single task and waits for it, and exists only because the scheduler otherwise
|
||||
has no caller at all outside the tests. It is marked dev-only everywhere it
|
||||
appears and is replaced by the GPU gateway.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from api.dependencies import require_admin
|
||||
from worker import registry, routing, service
|
||||
|
||||
logger = logging.getLogger("omnivoice.worker")
|
||||
|
||||
# How often an awaiting request checks whether its caller is still there.
|
||||
# Starlette does not cancel a handler when the client hangs up, so polling is
|
||||
# the only way the "cancel what nobody is waiting for" rule can fire before
|
||||
# the task's own deadline does.
|
||||
_DISCONNECT_POLL_SECONDS = 1.0
|
||||
|
||||
# Management is admin-gated: these endpoints mint join tokens and revoke
|
||||
# machines, so Docker writes require the API key while desktop stays loopback.
|
||||
router = APIRouter(prefix="/workers", tags=["workers"], dependencies=[Depends(require_admin)])
|
||||
|
||||
|
||||
class EnableRequest(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
class EnrollRequest(BaseModel):
|
||||
label: str = Field("", max_length=120)
|
||||
endpoint: str = Field("", max_length=256)
|
||||
ttl_seconds: int = Field(900, ge=60, le=24 * 3600)
|
||||
|
||||
|
||||
class JoinRequest(BaseModel):
|
||||
"""A join code, as pasted (or scanned) from the control plane."""
|
||||
|
||||
token: str = Field(..., max_length=4096)
|
||||
|
||||
|
||||
class TargetRequest(BaseModel):
|
||||
"""`local`, or the id of an enrolled worker."""
|
||||
|
||||
target: str = Field(..., max_length=64)
|
||||
|
||||
|
||||
class WorkerUpdate(BaseModel):
|
||||
name: str | None = Field(None, max_length=120)
|
||||
enabled: bool | None = None
|
||||
priority: int | None = Field(None, ge=0, le=100)
|
||||
|
||||
|
||||
class SubmitTaskRequest(BaseModel):
|
||||
"""One unit of work for a remote worker. **Dev only** — see `submit_task`."""
|
||||
|
||||
engine: str = Field(..., max_length=64)
|
||||
operation: str = Field("tts", max_length=32)
|
||||
model_id: str = Field("", max_length=128)
|
||||
params: dict = Field(default_factory=dict)
|
||||
# Mandatory, and deliberately without a default: the sweeper fails a task
|
||||
# on its deadline only while it is QUEUED, so one submitted without a
|
||||
# deadline while no worker is online waits forever with nothing left in
|
||||
# the system that would ever time it out.
|
||||
deadline_seconds: float = Field(..., gt=0, le=6 * 3600)
|
||||
idempotency_key: str | None = Field(None, max_length=128)
|
||||
|
||||
|
||||
class _ClientGone(Exception):
|
||||
"""The caller hung up while its task was still running."""
|
||||
|
||||
|
||||
class _WaitExpired(Exception):
|
||||
"""The task did not reach a terminal state inside its deadline."""
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_workers() -> dict:
|
||||
"""Everything the workers panel renders, in one call."""
|
||||
return service.control_plane.snapshot()
|
||||
|
||||
|
||||
@router.get("/target")
|
||||
def get_target(op: str = "") -> dict:
|
||||
"""What the GPU picker shows: the choice, the resolved answer, the options.
|
||||
|
||||
`active` is the same answer the generation path uses, so the badge cannot
|
||||
claim work goes somewhere the router will not send it. Pass `op` for the
|
||||
surface being rendered — omitting it answers for the target as a whole,
|
||||
which is what the picker's own menu asks.
|
||||
"""
|
||||
return routing.status(op=op.strip() or None)
|
||||
|
||||
|
||||
@router.post("/target")
|
||||
def set_target(request: TargetRequest) -> dict:
|
||||
"""Choose where work runs. Exactly one target is active at a time."""
|
||||
chosen = request.target.strip() or routing.LOCAL
|
||||
if chosen != routing.LOCAL:
|
||||
worker = registry.get(chosen)
|
||||
if worker is None or worker.revoked:
|
||||
raise HTTPException(status_code=404, detail="No such worker.")
|
||||
routing.set_target_id(chosen)
|
||||
return routing.status()
|
||||
|
||||
|
||||
@router.post("/enabled")
|
||||
async def set_enabled(request: EnableRequest) -> dict:
|
||||
"""Turn the feature on or off.
|
||||
|
||||
Off means off: the control plane stops, the listening socket closes, and
|
||||
the app is exactly what it was before the toggle existed.
|
||||
"""
|
||||
service.set_remote_workers_enabled(request.enabled)
|
||||
if request.enabled:
|
||||
try:
|
||||
await service.control_plane.start()
|
||||
except Exception as exc:
|
||||
service.control_plane.startup_error = str(exc)
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
else:
|
||||
await service.control_plane.stop()
|
||||
return service.control_plane.snapshot()
|
||||
|
||||
|
||||
@router.get("/agent")
|
||||
def agent_status() -> dict:
|
||||
"""The other side of the same feature: is THIS machine lending its GPU?
|
||||
|
||||
Separate from `GET /workers`, which answers for the control plane. A
|
||||
machine can legitimately be both — a desktop that borrows a laptop's GPU
|
||||
and lends its own to a colleague — so neither status can stand in for the
|
||||
other.
|
||||
"""
|
||||
from worker import agent as worker_agent # noqa: PLC0415
|
||||
|
||||
return worker_agent.agent.status()
|
||||
|
||||
|
||||
def _refuse_when_env_pinned(worker_agent) -> None:
|
||||
"""OMNIVOICE_WORKER_MODE wins over the setting everywhere else.
|
||||
|
||||
`worker_mode_enabled()` reads the variable first and `status()` reports the
|
||||
machine as env-pinned, so a route that changed worker mode anyway would
|
||||
contradict both: it writes a setting nothing consults, and the next restart
|
||||
undoes whatever the user just saw happen.
|
||||
"""
|
||||
if worker_agent.agent.status()["env_pinned"]:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
"OMNIVOICE_WORKER_MODE controls this machine's worker mode. Unset it "
|
||||
"and restart VoiceStudio to manage it from here."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/agent/join")
|
||||
async def join_control_plane(request: JoinRequest) -> dict:
|
||||
"""Redeem a join code and start working for that control plane.
|
||||
|
||||
This is the endpoint that makes the feature reachable. Joining used to mean
|
||||
setting OMNIVOICE_WORKER_MODE and OMNIVOICE_WORKER_TOKEN in the environment
|
||||
and relaunching the app — a step most users will never take, on the machine
|
||||
that is usually the least convenient to configure by hand.
|
||||
|
||||
The code is single-use and short-lived, so a failure here is nearly always
|
||||
"expired" or "wrong address"; it is returned verbatim rather than as a bare
|
||||
409, because the user's next action depends on which one it was.
|
||||
"""
|
||||
from worker import agent as worker_agent # noqa: PLC0415
|
||||
|
||||
token = request.token.strip()
|
||||
if not token:
|
||||
raise HTTPException(status_code=422, detail="Paste the join code first.")
|
||||
# Same rule as the toggle below: joining ENABLES worker mode, so under
|
||||
# OMNIVOICE_WORKER_MODE it would write a setting the rest of the app
|
||||
# ignores — and with the variable set to 0, hand the user a machine that
|
||||
# says it joined and never lends anything (CodeRabbit).
|
||||
_refuse_when_env_pinned(worker_agent)
|
||||
async with worker_agent.agent.lifecycle:
|
||||
# A rejoin replaces a working enrollment. Keep enough to put it back:
|
||||
# pinning the new certificate overwrites the old one on disk, so a
|
||||
# failed rejoin would otherwise leave the machine unable to reconnect
|
||||
# to the control plane it was already serving.
|
||||
previous = worker_agent.snapshot_enrollment()
|
||||
await worker_agent.agent.stop()
|
||||
try:
|
||||
await worker_agent.agent.start(token_text=token)
|
||||
# Success is the control plane ACCEPTING this worker, not the
|
||||
# connection being scheduled — see wait_until_registered.
|
||||
await worker_agent.agent.wait_until_registered()
|
||||
except Exception as exc:
|
||||
worker_agent.agent.last_error = str(exc)
|
||||
await worker_agent.agent.stop()
|
||||
await worker_agent.restore_enrollment(previous)
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
worker_agent.agent.last_error = ""
|
||||
# Persisted only after the join actually worked: a machine that failed
|
||||
# to enrol must not come back up trying again forever.
|
||||
worker_agent.set_worker_mode_enabled(True)
|
||||
return worker_agent.agent.status()
|
||||
|
||||
|
||||
@router.post("/agent/enabled")
|
||||
async def set_agent_enabled(request: EnableRequest) -> dict:
|
||||
"""Start or stop lending this machine, without forgetting the enrollment.
|
||||
|
||||
Off stops the agent and clears the setting, so nothing dials out; the
|
||||
pinned certificate stays, which is what lets "on" resume without asking for
|
||||
another code.
|
||||
"""
|
||||
from worker import agent as worker_agent # noqa: PLC0415
|
||||
|
||||
_refuse_when_env_pinned(worker_agent)
|
||||
async with worker_agent.agent.lifecycle:
|
||||
if request.enabled:
|
||||
try:
|
||||
await worker_agent.agent.start()
|
||||
await worker_agent.agent.wait_until_registered()
|
||||
except Exception as exc:
|
||||
worker_agent.agent.last_error = str(exc)
|
||||
await worker_agent.agent.stop()
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
worker_agent.agent.last_error = ""
|
||||
worker_agent.set_worker_mode_enabled(True)
|
||||
else:
|
||||
await worker_agent.agent.stop()
|
||||
worker_agent.set_worker_mode_enabled(False)
|
||||
return worker_agent.agent.status()
|
||||
|
||||
|
||||
@router.post("/enrollments")
|
||||
def create_enrollment(request: EnrollRequest) -> dict:
|
||||
"""Mint a single-use join token.
|
||||
|
||||
The plaintext is returned once and never stored — the response is the only
|
||||
time it exists outside the worker that redeems it.
|
||||
"""
|
||||
if not service.control_plane.running:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Remote workers are turned off. Enable them in Settings → System → Remote workers first.",
|
||||
)
|
||||
token = service.control_plane.create_enrollment(
|
||||
endpoint=request.endpoint, label=request.label, ttl_seconds=request.ttl_seconds
|
||||
)
|
||||
return {
|
||||
"token": token.encode(),
|
||||
"endpoint": token.endpoint,
|
||||
"fingerprint": token.cert_fingerprint,
|
||||
"expires_at": token.expires_at,
|
||||
"shown_once": True,
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/{worker_id}")
|
||||
def update_worker(worker_id: str, request: WorkerUpdate) -> dict:
|
||||
worker = registry.get(worker_id)
|
||||
if worker is None:
|
||||
raise HTTPException(status_code=404, detail="No such worker.")
|
||||
if request.name is not None:
|
||||
registry.rename(worker_id, request.name)
|
||||
if request.enabled is not None:
|
||||
registry.set_enabled(worker_id, request.enabled)
|
||||
if request.priority is not None:
|
||||
registry.set_priority(worker_id, request.priority)
|
||||
updated = registry.get(worker_id)
|
||||
# Keep the live copy in step, so the scheduler and its logs do not go on
|
||||
# using the name or priority this worker had when it connected.
|
||||
if updated is not None and service.control_plane.running:
|
||||
service.control_plane.pool.refresh_record(updated)
|
||||
return updated.to_dict() if updated else {}
|
||||
|
||||
|
||||
@router.post("/{worker_id}/consent")
|
||||
def grant_consent(worker_id: str) -> dict:
|
||||
"""Record the user's explicit yes to sending their audio to this machine."""
|
||||
if registry.get(worker_id) is None:
|
||||
raise HTTPException(status_code=404, detail="No such worker.")
|
||||
registry.grant_consent(worker_id)
|
||||
worker = registry.get(worker_id)
|
||||
return worker.to_dict() if worker else {}
|
||||
|
||||
|
||||
@router.post("/{worker_id}/resume")
|
||||
def clear_breaker(worker_id: str) -> dict:
|
||||
"""Clear a paused worker's circuit breakers.
|
||||
|
||||
The user fixed the machine and knows it — a breaker with no manual clear is
|
||||
the quarantine trap the reputation system had.
|
||||
"""
|
||||
if not service.control_plane.running:
|
||||
raise HTTPException(status_code=409, detail="Remote workers are turned off.")
|
||||
breakers = service.control_plane.pool.breakers
|
||||
for breaker in breakers.open_breakers(worker_id):
|
||||
breaker.force_close()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.delete("/{worker_id}")
|
||||
def revoke_worker(worker_id: str) -> dict:
|
||||
"""Remove a worker — which means revoke its key, not hide the row.
|
||||
|
||||
Its in-flight work is released so it can be retried elsewhere rather than
|
||||
waiting out a lease on a machine that will never answer again.
|
||||
"""
|
||||
if registry.get(worker_id) is None:
|
||||
raise HTTPException(status_code=404, detail="No such worker.")
|
||||
registry.revoke(worker_id)
|
||||
if service.control_plane.running:
|
||||
service.control_plane.scheduler.on_disconnected(worker_id)
|
||||
service.control_plane.pool.breakers.forget_worker(worker_id)
|
||||
return {"ok": True, "revoked": worker_id}
|
||||
|
||||
|
||||
@router.get("/tasks")
|
||||
def list_tasks(limit: int = 50) -> dict:
|
||||
"""Recent remote tasks, for the queue view."""
|
||||
if not service.control_plane.running:
|
||||
return {"tasks": [], "queue_depth": 0}
|
||||
from worker import task_store # noqa: PLC0415
|
||||
|
||||
return {
|
||||
"queue_depth": service.control_plane.scheduler.queue_depth,
|
||||
"tasks": [t.to_dict() for t in task_store.list_tasks(limit=min(200, max(1, limit)))],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/tasks")
|
||||
async def submit_task(request: Request, body: SubmitTaskRequest) -> dict:
|
||||
"""Run one task on a remote worker and wait for it. **DEV ONLY.**
|
||||
|
||||
This is the producer the remote pipeline never had: until it existed the
|
||||
scheduler had no caller outside the test suite, so picking a remote GPU
|
||||
changed the badge and nothing else — every job still ran locally. It is
|
||||
the smallest thing that makes remote execution observable end to end, not
|
||||
the shipping surface: the GPU gateway takes over routing real generation
|
||||
and this endpoint goes with it.
|
||||
|
||||
Loopback-only and behind the same opt-in as the rest of the feature, so a
|
||||
user who never enabled remote workers cannot reach it at all.
|
||||
"""
|
||||
from worker.lifecycle import TaskState # noqa: PLC0415
|
||||
from worker.scheduler import QueueFull, SchedulerStopped # noqa: PLC0415
|
||||
|
||||
if not service.remote_workers_enabled() or not service.control_plane.running:
|
||||
raise HTTPException(status_code=409, detail="Remote workers are turned off.")
|
||||
if not routing.supports_operation(body.operation):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"'{body.operation}' does not run on a remote worker yet.",
|
||||
)
|
||||
|
||||
scheduler = service.control_plane.scheduler
|
||||
try:
|
||||
task = scheduler.submit(
|
||||
operation=body.operation,
|
||||
engine=body.engine,
|
||||
model_id=body.model_id,
|
||||
params=body.params,
|
||||
idempotency_key=body.idempotency_key or None,
|
||||
deadline_seconds=body.deadline_seconds,
|
||||
pinned_worker_id=routing.decide().worker_id or None,
|
||||
)
|
||||
except QueueFull as exc:
|
||||
raise HTTPException(status_code=429, detail=str(exc)) from exc
|
||||
|
||||
settled = None
|
||||
reason = "the request was interrupted"
|
||||
try:
|
||||
settled = await _await_terminal(
|
||||
request, scheduler, task.task_id, timeout=body.deadline_seconds
|
||||
)
|
||||
except _ClientGone:
|
||||
reason = "the client disconnected"
|
||||
raise HTTPException(status_code=499, detail="The client stopped waiting.") from None
|
||||
except _WaitExpired:
|
||||
reason = "the task passed its deadline"
|
||||
raise HTTPException(
|
||||
status_code=504,
|
||||
detail=f"The task did not finish within {body.deadline_seconds:g}s.",
|
||||
) from None
|
||||
except SchedulerStopped as exc:
|
||||
# Deliberately no cancel: the worker was never told to stop and may
|
||||
# still be rendering, so claiming the task is cancelled would be a
|
||||
# statement about someone else's GPU that we cannot make.
|
||||
reason = None
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from None
|
||||
finally:
|
||||
# Nothing else will stop it: a worker holds its slot — often its only
|
||||
# one — until the control plane says otherwise, and the sweeper only
|
||||
# enforces deadlines on tasks that are still queued. Swallowed because
|
||||
# a failure here would replace the caller's real error with a 500.
|
||||
if settled is None and reason is not None:
|
||||
try:
|
||||
await service.control_plane.cancel(task.task_id, reason=reason)
|
||||
except Exception:
|
||||
logger.exception("Could not cancel abandoned remote task %s", task.task_id)
|
||||
|
||||
payload = settled.to_dict()
|
||||
if settled.state is TaskState.COMPLETED:
|
||||
return payload
|
||||
# A failure that answered 200 would be indistinguishable from success to
|
||||
# anything that does not read `state` — which is the whole point of this
|
||||
# endpoint existing before the gateway does.
|
||||
raise HTTPException(
|
||||
status_code=409 if settled.state is TaskState.CANCELLED else 502, detail=payload
|
||||
)
|
||||
|
||||
|
||||
async def _await_terminal(request: Request, scheduler, task_id: str, *, timeout: float):
|
||||
"""Wait for a terminal task, giving up if the caller does first."""
|
||||
waiter = asyncio.ensure_future(scheduler.wait(task_id, timeout=timeout))
|
||||
while True:
|
||||
done, _pending = await asyncio.wait({waiter}, timeout=_DISCONNECT_POLL_SECONDS)
|
||||
if done:
|
||||
try:
|
||||
settled = waiter.result()
|
||||
except (asyncio.TimeoutError, TimeoutError) as exc:
|
||||
raise _WaitExpired() from exc
|
||||
if settled is None or not settled.state.terminal:
|
||||
raise _WaitExpired()
|
||||
return settled
|
||||
if await request.is_disconnected():
|
||||
waiter.cancel()
|
||||
raise _ClientGone()
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/cancel")
|
||||
async def cancel_task(task_id: str) -> dict:
|
||||
if not service.control_plane.running:
|
||||
raise HTTPException(status_code=409, detail="Remote workers are turned off.")
|
||||
cancelled = await service.control_plane.cancel(task_id, reason="cancelled by user")
|
||||
if not cancelled:
|
||||
raise HTTPException(status_code=404, detail="No such active task.")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── Inbound mode ───────────────────────────────────────────────────────────
|
||||
#
|
||||
# The other direction: this machine accepts connections from panels, or dials
|
||||
# out to nodes that do. Outbound enrollment above is unchanged and remains the
|
||||
# default — see docs/adr/inbound-node-mode.md for why this exists alongside it
|
||||
# rather than replacing it.
|
||||
|
||||
|
||||
class InboundEnableRequest(BaseModel):
|
||||
enabled: bool
|
||||
# Widening the bind is a separate decision from turning the feature on,
|
||||
# so it is a separate field with a safe default rather than a flag that
|
||||
# rides along with `enabled`.
|
||||
bind: str = ""
|
||||
port: int = 0
|
||||
|
||||
|
||||
class IssueKeyRequest(BaseModel):
|
||||
label: str = Field(default="", max_length=64)
|
||||
|
||||
|
||||
class ConnectRequest(BaseModel):
|
||||
connection_string: str = Field(min_length=1, max_length=512)
|
||||
|
||||
|
||||
@router.get("/inbound")
|
||||
def inbound_status() -> dict:
|
||||
from worker.inbound import service as inbound_service # noqa: PLC0415
|
||||
|
||||
return {
|
||||
**inbound_service.node.snapshot(),
|
||||
"connections": inbound_service.outbound.snapshot(),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/inbound/enabled")
|
||||
async def set_inbound_enabled(request: InboundEnableRequest) -> dict:
|
||||
from worker.inbound import service as inbound_service # noqa: PLC0415
|
||||
|
||||
if inbound_service.enabled_override() is not None:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
"Accept connections is controlled by OMNIVOICE_INBOUND_NODE on this "
|
||||
"machine. Change that environment setting and restart VoiceStudio."
|
||||
),
|
||||
)
|
||||
if request.bind:
|
||||
inbound_service.set_bind_host(request.bind)
|
||||
if request.port:
|
||||
inbound_service.set_bind_port(request.port)
|
||||
inbound_service.set_enabled(request.enabled)
|
||||
|
||||
if inbound_service.enabled():
|
||||
await inbound_service.node.start()
|
||||
if inbound_service.node.startup_error:
|
||||
logger.error("Inbound worker listener failed to start; details withheld.")
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
"The inbound worker listener could not start; "
|
||||
"check the backend log for details."
|
||||
),
|
||||
)
|
||||
else:
|
||||
await inbound_service.node.stop()
|
||||
return inbound_service.node.snapshot()
|
||||
|
||||
|
||||
@router.post("/inbound/keys")
|
||||
def issue_inbound_key(request: IssueKeyRequest) -> dict:
|
||||
"""Mint one panel's key and return the string it pastes.
|
||||
|
||||
The secret is in this response and nowhere else afterwards — only its hash
|
||||
is stored, so it cannot be shown again, only replaced.
|
||||
"""
|
||||
from worker.inbound import service as inbound_service # noqa: PLC0415
|
||||
|
||||
if not inbound_service.node.running:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
"This machine is not accepting connections yet. Turn on "
|
||||
"Settings → System → Remote workers → Accept connections first."
|
||||
),
|
||||
)
|
||||
issued = inbound_service.node.keys.issue(request.label)
|
||||
return {
|
||||
"key_id": issued.key.key_id,
|
||||
"label": issued.key.label,
|
||||
"connection_string": inbound_service.node.connection_string(issued.secret),
|
||||
"exposed": inbound_service.is_exposed(),
|
||||
"shown_once": True,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/inbound/keys/{key_id}")
|
||||
def revoke_inbound_key(key_id: str) -> dict:
|
||||
"""Revoke one panel. Everyone else stays connected — the whole reason keys
|
||||
are per panel rather than one shared node key."""
|
||||
from worker.inbound import service as inbound_service # noqa: PLC0415
|
||||
|
||||
if not inbound_service.node.keys.revoke(key_id):
|
||||
raise HTTPException(status_code=404, detail="No such key.")
|
||||
return inbound_service.node.snapshot()
|
||||
|
||||
|
||||
@router.post("/inbound/sessions/{session_id}/disconnect")
|
||||
def disconnect_inbound_session(session_id: str) -> dict:
|
||||
from worker.inbound import service as inbound_service # noqa: PLC0415
|
||||
|
||||
if not inbound_service.node.log.kick(session_id):
|
||||
raise HTTPException(status_code=404, detail="That connection has already ended.")
|
||||
return inbound_service.node.snapshot()
|
||||
|
||||
|
||||
@router.post("/inbound/connections")
|
||||
async def add_inbound_connection(request: ConnectRequest) -> dict:
|
||||
"""Paste a connection string from a GPU machine and dial it."""
|
||||
from worker.inbound import service as inbound_service # noqa: PLC0415
|
||||
from worker.inbound.connection_string import InvalidConnectionString # noqa: PLC0415
|
||||
|
||||
if not service.control_plane.running:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
"Remote workers are turned off. Enable them in "
|
||||
"Settings → System → Remote workers first."
|
||||
),
|
||||
)
|
||||
try:
|
||||
connection = await inbound_service.outbound.add(
|
||||
request.connection_string, service.control_plane.servicer
|
||||
)
|
||||
except InvalidConnectionString as exc:
|
||||
# 400 with the parser's own words: every one of these otherwise
|
||||
# surfaces as "cannot connect", which is what a firewall, a wrong port
|
||||
# and a dead node all say too.
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return {"endpoint": connection.endpoint, "connections": inbound_service.outbound.snapshot()}
|
||||
|
||||
|
||||
@router.delete("/inbound/connections/{endpoint}")
|
||||
async def remove_inbound_connection(endpoint: str) -> dict:
|
||||
from worker.inbound import service as inbound_service # noqa: PLC0415
|
||||
|
||||
await inbound_service.outbound.remove(endpoint)
|
||||
return {"connections": inbound_service.outbound.snapshot()}
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,3 @@
|
||||
1
|
||||
00:00:00,000 --> 00:00:13,720
|
||||
VoiceStudio es una aplicación de escritorio para clonación de voz, doblaje de vídeo y diseño de voz. Funciona completamente en tu máquina. Sin cuentas, sin nube, sin claves de API. Solo abre la aplicación y comienza a crear.
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,3 @@
|
||||
1
|
||||
00:00:00,000 --> 00:00:15,000
|
||||
VoiceStudio est une application de bureau pour le clonage de voix, le doublage vidéo et la conception vocale. Elle fonctionne entièrement sur votre machine. Pas de compte, pas de cloud, pas de clé d'API. Ouvrez l'application et commencez à créer.
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,3 @@
|
||||
1
|
||||
00:00:00,000 --> 00:00:16,560
|
||||
VoiceStudioは、ボイスクローン、ビデオ吹き替え、ボイスデザインのためのデスクトップアプリです。すべてお使いのコンピュータ上で動作します。アカウント、クラウド、APIキーは不要です。アプリを開けば、すぐに制作を始められます。
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,3 @@
|
||||
1
|
||||
00:00:00,000 --> 00:00:13,200
|
||||
VoiceStudio 是一款桌面应用,用于语音克隆、视频配音和声音设计。它完全在你的电脑上运行。无需账户,无需云端,无需 API 密钥。打开应用即可开始创作。
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"version": "0.3.0",
|
||||
"rendered_by": "omnivoice engine + ffmpeg showwaves",
|
||||
"rendered_at": "2026-08-12T19:47:29Z",
|
||||
"license": "MIT (synthetic, no third-party IP)",
|
||||
"source": {
|
||||
"code": "en",
|
||||
"label": "English",
|
||||
"video": "source.mp4",
|
||||
"srt": "source.srt",
|
||||
"script": "VoiceStudio is a desktop app for voice cloning, video dubbing, and voice design. It runs entirely on your machine. No accounts, no cloud, no API keys. Just open the app and start creating."
|
||||
},
|
||||
"dubbed": [
|
||||
{
|
||||
"code": "es",
|
||||
"label": "Español",
|
||||
"video": "dubbed_es.mp4",
|
||||
"srt": "dubbed_es.srt",
|
||||
"dir": "ltr",
|
||||
"script": "VoiceStudio es una aplicación de escritorio para clonación de voz, doblaje de vídeo y diseño de voz. Funciona completamente en tu máquina. Sin cuentas, sin nube, sin claves de API. Solo abre la aplicación y comienza a crear."
|
||||
},
|
||||
{
|
||||
"code": "fr",
|
||||
"label": "Français",
|
||||
"video": "dubbed_fr.mp4",
|
||||
"srt": "dubbed_fr.srt",
|
||||
"dir": "ltr",
|
||||
"script": "VoiceStudio est une application de bureau pour le clonage de voix, le doublage vidéo et la conception vocale. Elle fonctionne entièrement sur votre machine. Pas de compte, pas de cloud, pas de clé d'API. Ouvrez l'application et commencez à créer."
|
||||
},
|
||||
{
|
||||
"code": "zh",
|
||||
"label": "中文",
|
||||
"video": "dubbed_zh.mp4",
|
||||
"srt": "dubbed_zh.srt",
|
||||
"dir": "ltr",
|
||||
"script": "VoiceStudio 是一款桌面应用,用于语音克隆、视频配音和声音设计。它完全在你的电脑上运行。无需账户,无需云端,无需 API 密钥。打开应用即可开始创作。"
|
||||
},
|
||||
{
|
||||
"code": "ja",
|
||||
"label": "日本語",
|
||||
"video": "dubbed_ja.mp4",
|
||||
"srt": "dubbed_ja.srt",
|
||||
"dir": "ltr",
|
||||
"script": "VoiceStudioは、ボイスクローン、ビデオ吹き替え、ボイスデザインのためのデスクトップアプリです。すべてお使いのコンピュータ上で動作します。アカウント、クラウド、APIキーは不要です。アプリを開けば、すぐに制作を始められます。"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,3 @@
|
||||
1
|
||||
00:00:00,000 --> 00:00:11,400
|
||||
VoiceStudio is a desktop app for voice cloning, video dubbing, and voice design. It runs entirely on your machine. No accounts, no cloud, no API keys. Just open the app and start creating.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -242,6 +242,24 @@ models:
|
||||
size_gb: 0.08
|
||||
curated_on: [all]
|
||||
|
||||
- repo_id: "openbmb/VoxCPM2"
|
||||
label: "VoxCPM2 (30 languages, voice cloning and design)"
|
||||
role: TTS
|
||||
size_gb: 5.0
|
||||
curated_on: [cuda]
|
||||
|
||||
- repo_id: "FunAudioLLM/Fun-CosyVoice3-0.5B-2512"
|
||||
label: "CosyVoice 3 0.5B (multilingual zero-shot)"
|
||||
role: TTS
|
||||
size_gb: 9.8
|
||||
curated_on: [cuda]
|
||||
|
||||
- repo_id: "lj1995/GPT-SoVITS"
|
||||
label: "GPT-SoVITS pretrained weights"
|
||||
role: TTS
|
||||
size_gb: 2.0
|
||||
curated_on: [cuda]
|
||||
|
||||
# ── mlx-audio engines (Apple Silicon only) ────────────────────────────
|
||||
|
||||
- repo_id: "mlx-community/Kokoro-82M-bf16"
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"""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"]
|
||||
@@ -0,0 +1,421 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,140 @@
|
||||
"""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
|
||||
@@ -57,6 +57,9 @@ _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 (
|
||||
@@ -176,6 +179,108 @@ _BASE_SCHEMA = """
|
||||
created_at REAL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_pron_lang ON pronunciation_entries(language);
|
||||
|
||||
-- Remote GPU workers (docs/remote-workers.md). Opt-in: an install with no
|
||||
-- remote workers never writes a row here and behaves exactly as before.
|
||||
--
|
||||
-- `public_key` is the worker's identity — a server-assigned id is a name,
|
||||
-- not proof, so every reconnect is verified against this key. Revocation
|
||||
-- is a persisted fact (not in-memory state) precisely so a restart of the
|
||||
-- control plane cannot silently readmit a worker the user removed.
|
||||
CREATE TABLE IF NOT EXISTS remote_workers (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
key_id TEXT NOT NULL,
|
||||
public_key BLOB NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
revoked INTEGER NOT NULL DEFAULT 0,
|
||||
revoked_at REAL,
|
||||
priority INTEGER NOT NULL DEFAULT 50,
|
||||
endpoint TEXT NOT NULL DEFAULT '',
|
||||
host_json TEXT NOT NULL DEFAULT '{}',
|
||||
capabilities_json TEXT NOT NULL DEFAULT '[]',
|
||||
max_concurrent_tasks INTEGER NOT NULL DEFAULT 1,
|
||||
-- Bumped on every successful (re)connect. Messages stamped with an
|
||||
-- older epoch are from a session we have already replaced.
|
||||
session_epoch INTEGER NOT NULL DEFAULT 0,
|
||||
consent_granted_at REAL,
|
||||
created_at REAL NOT NULL,
|
||||
last_seen_at REAL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_remote_workers_key ON remote_workers(key_id);
|
||||
|
||||
-- Single-use join tokens. Only the hash is stored: the plaintext exists
|
||||
-- once, in the dialog that shows it.
|
||||
CREATE TABLE IF NOT EXISTS remote_worker_enrollments (
|
||||
token_id TEXT PRIMARY KEY,
|
||||
secret_hash TEXT NOT NULL,
|
||||
endpoint TEXT NOT NULL DEFAULT '',
|
||||
cert_fingerprint TEXT NOT NULL DEFAULT '',
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
created_at REAL NOT NULL,
|
||||
expires_at REAL NOT NULL,
|
||||
used_at REAL,
|
||||
used_by_worker TEXT
|
||||
);
|
||||
|
||||
-- Tasks dispatched to remote workers. Unlike the local `jobs` table (whose
|
||||
-- startup sweep marks anything in-flight as failed), these must SURVIVE a
|
||||
-- control-plane restart: the desktop app quits while a remote GPU keeps
|
||||
-- rendering, and the worker is the source of truth for what is still
|
||||
-- running. Reconciliation on reconnect rebuilds live state from here.
|
||||
CREATE TABLE IF NOT EXISTS remote_tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
-- Client-supplied; deduplicates client retries before the worker
|
||||
-- protocol is involved at all.
|
||||
idempotency_key TEXT,
|
||||
operation TEXT NOT NULL,
|
||||
engine TEXT NOT NULL DEFAULT '',
|
||||
model_id TEXT NOT NULL DEFAULT '',
|
||||
params_json TEXT NOT NULL DEFAULT '{}',
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
state TEXT NOT NULL DEFAULT 'queued',
|
||||
max_attempts INTEGER NOT NULL DEFAULT 3,
|
||||
excluded_json TEXT NOT NULL DEFAULT '[]',
|
||||
error_json TEXT,
|
||||
-- Written BEFORE RESULT_ACK is sent. If the server dies between
|
||||
-- receiving a result and acknowledging it, the worker redelivers and
|
||||
-- this row is what makes the second delivery a no-op instead of a
|
||||
-- silently lost multi-minute render.
|
||||
result_ref TEXT,
|
||||
result_json TEXT,
|
||||
project_id TEXT,
|
||||
created_at REAL NOT NULL,
|
||||
updated_at REAL NOT NULL,
|
||||
deadline_at REAL,
|
||||
-- Deliberate additive-reconcile exception to the alembic rule: remote
|
||||
-- task recovery must work in bundled installs where alembic may be
|
||||
-- unavailable, and this nullable affinity column is additive-only.
|
||||
pinned_worker_id TEXT,
|
||||
finished_at REAL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_remote_tasks_state ON remote_tasks(state, priority, created_at);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_remote_tasks_idem ON remote_tasks(idempotency_key)
|
||||
WHERE idempotency_key IS NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS remote_task_attempts (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL,
|
||||
worker_id TEXT NOT NULL,
|
||||
session_epoch INTEGER NOT NULL DEFAULT 0,
|
||||
attempt_number INTEGER NOT NULL DEFAULT 1,
|
||||
state TEXT NOT NULL DEFAULT 'assigned',
|
||||
progress REAL NOT NULL DEFAULT 0,
|
||||
stage TEXT NOT NULL DEFAULT '',
|
||||
error_json TEXT,
|
||||
created_at REAL NOT NULL,
|
||||
accepted_at REAL,
|
||||
started_at REAL,
|
||||
finished_at REAL,
|
||||
lease_expires_at REAL,
|
||||
grace_expires_at REAL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_remote_attempts_task ON remote_task_attempts(task_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_remote_attempts_worker ON remote_task_attempts(worker_id, state);
|
||||
"""
|
||||
|
||||
# Only tables/columns this module is allowed to ALTER. Prevents SQL injection via
|
||||
|
||||
@@ -374,6 +374,33 @@ class HostCaps:
|
||||
probe_ok: bool = True
|
||||
"""``False`` only when torch could not be imported (degraded CPU-only)."""
|
||||
|
||||
requested_family: str = "auto"
|
||||
"""The user's compute-device override as requested — ``"auto"`` when none.
|
||||
``family`` reflects what was actually honored: an override that names a
|
||||
family this host doesn't have is noted and ignored, never obeyed blindly."""
|
||||
|
||||
|
||||
#: Every value the compute-device override accepts. "auto" = today's
|
||||
#: priority pick; "cpu" is always honorable (invariant: cpu is always
|
||||
#: available); accelerator names are honored only when detected.
|
||||
DEVICE_OVERRIDE_CHOICES: tuple[str, ...] = ("auto", "cuda", "rocm", "xpu", "mps", "cpu")
|
||||
|
||||
|
||||
def requested_device_override() -> str:
|
||||
"""The user's compute-device pick: ``OMNIVOICE_DEVICE`` env > the Settings
|
||||
choice (``compute_device`` in prefs.json) > ``"auto"``. Env wins so
|
||||
power-users can pin a device without the UI silently undoing it (same
|
||||
resolution order as engine selection, #981). Unknown values normalize to
|
||||
``"auto"`` — the probe must never raise."""
|
||||
try:
|
||||
from core import prefs
|
||||
|
||||
raw = prefs.resolve("compute_device", env="OMNIVOICE_DEVICE", default="auto")
|
||||
except Exception:
|
||||
raw = os.environ.get("OMNIVOICE_DEVICE", "auto")
|
||||
val = str(raw or "auto").strip().lower()
|
||||
return val if val in DEVICE_OVERRIDE_CHOICES else "auto"
|
||||
|
||||
|
||||
def _probe() -> HostCaps:
|
||||
"""Run the probe once. Enumerates every failure branch from the spec's
|
||||
@@ -386,6 +413,7 @@ def _probe() -> HostCaps:
|
||||
available_families=("cpu",),
|
||||
notes=("torch not importable; treating host as CPU-only",),
|
||||
probe_ok=False,
|
||||
requested_family=requested_device_override(),
|
||||
)
|
||||
|
||||
notes: list[str] = []
|
||||
@@ -507,6 +535,26 @@ def _probe() -> HostCaps:
|
||||
# available_families: every detected accelerator + cpu, deduped, cpu last.
|
||||
available: tuple[DeviceFamily, ...] = tuple(dict.fromkeys([*detected, "cpu"]))
|
||||
|
||||
# User override (Settings → Performance, or OMNIVOICE_DEVICE): honored
|
||||
# only when the named family actually exists on this host — an override
|
||||
# can steer, it cannot invent hardware. Applied here, at the single
|
||||
# choke point, so routing, model loads (get_best_device delegates its
|
||||
# family decision here), and every badge inherit it for free.
|
||||
requested = requested_device_override()
|
||||
if requested != "auto":
|
||||
if requested in available:
|
||||
if requested != family:
|
||||
notes.append(
|
||||
f"compute device pinned to '{requested}' by user override "
|
||||
f"(auto would pick '{family}')"
|
||||
)
|
||||
family = requested # type: ignore[assignment]
|
||||
else:
|
||||
notes.append(
|
||||
f"requested compute device '{requested}' is not available on "
|
||||
f"this host (have: {', '.join(available)}) — using '{family}'"
|
||||
)
|
||||
|
||||
return HostCaps(
|
||||
family=family,
|
||||
available_families=available,
|
||||
@@ -515,6 +563,7 @@ def _probe() -> HostCaps:
|
||||
driver=driver,
|
||||
notes=tuple(notes),
|
||||
probe_ok=True,
|
||||
requested_family=requested,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -200,12 +200,12 @@ def _check_engines() -> dict:
|
||||
return _check(
|
||||
"engines", "TTS engines", FAIL,
|
||||
f"{detail} - active engine '{active}' is unavailable: {reason}",
|
||||
active_row.get("install_hint") or "Pick a different engine in Settings > Engines.",
|
||||
active_row.get("install_hint") or "Pick a different engine in Model Catalogue > Engines.",
|
||||
)
|
||||
if not available:
|
||||
return _check(
|
||||
"engines", "TTS engines", FAIL, detail,
|
||||
"No usable TTS engine. Install one from Settings > Engines.",
|
||||
"No usable TTS engine. Install one from Model Catalogue > Engines.",
|
||||
)
|
||||
return _check("engines", "TTS engines", OK, detail)
|
||||
|
||||
|
||||
+30
-2
@@ -52,6 +52,34 @@ _REDACTED_VALUE = "***REDACTED***"
|
||||
# One-line "what to do" per docs-taxonomy key. Keys mirror error_docs_map's
|
||||
# taxonomy; the docs URL itself stays owned by error_docs_map.
|
||||
_HINTS: dict[str, str] = {
|
||||
"WORKER_AT_CAPACITY": "Wait for a running job on that worker to finish, or choose another available worker and retry.",
|
||||
"MODEL_NOT_INSTALLED": "Install or enable this engine on the worker machine, then refresh its capabilities and retry.",
|
||||
"MODEL_NOT_DOWNLOADED": "Open Models, install this model on the selected worker, then retry when the download completes.",
|
||||
"INSUFFICIENT_MEMORY": "Choose a worker with more free GPU memory, unload another model there, or use a smaller model and retry.",
|
||||
"OPERATION_UNSUPPORTED": "Choose a worker whose capability list includes this operation, or run the job locally.",
|
||||
"ACCEPT_TIMEOUT": "Check that the worker is responsive and not overloaded, then reconnect it and retry.",
|
||||
"MODEL_LOAD_TIMEOUT": "Check the worker's model download and load status, then retry after the model is ready.",
|
||||
"EXECUTION_TIMEOUT": "Check the worker for a stalled engine or GPU error, restart that engine if needed, then retry.",
|
||||
"PROGRESS_LEASE_EXPIRED": "Check the worker connection and engine log, reconnect or restart the worker, then retry the job.",
|
||||
"RESULT_DELIVERY_TIMEOUT": "Check the connection and free disk space on both machines, then reconnect the worker and retry.",
|
||||
"INPUT_FETCH_TIMEOUT": "Check the connection to the worker and retry; keep both machines awake until the reference file finishes transferring.",
|
||||
"INPUT_FETCH_FAILED": "Check that the source file still exists and both machines are connected, then submit the job again.",
|
||||
"RESULT_UPLOAD_FAILED": "Check the worker connection and free disk space on this machine, reconnect the worker, then retry.",
|
||||
"WORKER_FAILED": "Open the selected worker's log for the underlying error, fix it there, then reconnect and retry.",
|
||||
"SESSION_EXPIRED": "Reconnect the worker; if it cannot renew its session, remove it and enroll it again.",
|
||||
"STALE_EPOCH": "Reconnect the worker so it receives the current session, then retry the job.",
|
||||
"STALE_ATTEMPT": "Refresh the job state and retry only if the current attempt has not already completed elsewhere.",
|
||||
"UPGRADE_REQUIRED": "Update VoiceStudio on the machine named in the error, then reconnect the worker.",
|
||||
"WORKER_REVOKED": "Add the worker again from Settings → System → Remote workers to create a new trusted enrollment.",
|
||||
"AUTH_FAILED": "Remove this worker, generate a new enrollment token, and add it again.",
|
||||
"INVALID_TASK_PARAMS": "Review the job inputs, correct the invalid or missing value named in the error, and submit it again.",
|
||||
"MODEL_REF_REJECTED": "Select a model from VoiceStudio's catalog on that worker instead of a path or custom model reference.",
|
||||
"RESULT_TOO_LARGE": "Shorten or split the job so each result is smaller, then render the parts separately.",
|
||||
"ARTIFACT_TOO_LARGE": "Shorten or split the job so each uploaded artifact is smaller, then render the parts separately.",
|
||||
"OFFSET_MISMATCH": "Reconnect the worker and retry the upload from the byte count reported by the control plane.",
|
||||
"SIZE_MISMATCH": "Reconnect the worker and retry the result upload; if it repeats, restart the worker before rerendering.",
|
||||
"DIGEST_MISMATCH": "Retry the result upload; if it repeats, check the worker's disk and network for corruption, then rerender.",
|
||||
"UPLOAD_INCOMPLETE": "Reconnect the worker and resume the result upload from the byte count reported by the control plane.",
|
||||
"PKG_RESOURCES_MISSING": "Run `uv pip install --reinstall 'setuptools>=75,<80'` in the backend venv (a plain install is skipped when setuptools' metadata is present but its pkg_resources files were removed by antivirus). Restart after.",
|
||||
"GATEKEEPER_QUARANTINE": "Clear the macOS quarantine flag (xattr -cr the app), then reopen.",
|
||||
"APPIMAGE_WEBKIT_WHITESCREEN": "Launch with WEBKIT_DISABLE_DMABUF_RENDERER=1 set.",
|
||||
@@ -65,7 +93,7 @@ _HINTS: dict[str, str] = {
|
||||
# fail with "file not found" for exactly the users most likely to need it
|
||||
# (greptile on #1377). tests/test_failure_classify.py pins these literals
|
||||
# to the constraint file so they cannot drift when the pins bump.
|
||||
"TRANSFORMERS_IMPORT": "Your transformers install is incomplete, or a package it loads models through (torchaudio, torchvision) is missing or mismatched with your torch — a torch/torchvision version mismatch fails with exactly this wording. Reinstall them together at the pinned versions (`uv pip install --python .venv --reinstall torch==2.8.0 torchaudio==2.8.0 torchvision==0.23.0 transformers` in the project folder), then restart the backend. If only transcription is affected, switching ASR to faster-whisper (Settings → Models) also works around it.",
|
||||
"TRANSFORMERS_IMPORT": "Your transformers install is incomplete, or a package it loads models through (torchaudio, torchvision) is missing or mismatched with your torch — a torch/torchvision version mismatch fails with exactly this wording. Reinstall them together at the pinned versions (`uv pip install --python .venv --reinstall torch==2.8.0 torchaudio==2.8.0 torchvision==0.23.0 transformers` in the project folder), then restart the backend. If only transcription is affected, switching ASR to faster-whisper (Model Catalogue → Models) also works around it.",
|
||||
"WINDOWS_APP_CONTROL_BLOCKED": "Windows refused to load a file VoiceStudio needs — an Application Control policy (Smart App Control, WDAC, or AppLocker) blocked it. On a personal PC: Windows Security → App & browser control → Smart App Control → Off (Windows only lets you turn it off once — re-enabling requires a Windows reset), then restart VoiceStudio. On a managed/work PC, ask IT to allow the VoiceStudio install folder.",
|
||||
"WINDOWS_PAGING_FILE_TOO_SMALL": "Windows ran out of virtual memory while mapping the model into memory — its paging file is smaller than the model needs. This is not the same as your RAM being full, and closing other apps usually won't fix it: Windows has to be allowed to back the mapping. Set a bigger paging file — Settings → System → About → Advanced system settings → Performance → Settings → Advanced → Virtual memory → Change: untick \"Automatically manage\", pick your system drive, choose \"Custom size\" and set both Initial and Maximum to at least 32768 MB (more than the model's size), then OK and restart Windows. A smaller/quantized engine (OmniVoice GGUF, Supertonic-3) also avoids the large mapping entirely.",
|
||||
"MEDIA_TOOL_MISSING": "VoiceStudio's media engine (ffmpeg/ffprobe) wasn't on the system path when a component went looking for it. Open Settings → Audio tools and use Download/Repair to fetch the bundled copy, then retry — a restart picks it up for everything. If you'd rather use a system install, install ffmpeg (macOS: `brew install ffmpeg`; Windows: `winget install Gyan.FFmpeg`; Linux: your package manager) and restart VoiceStudio, or point FFMPEG_PATH / OMNIVOICE_FFPROBE_PATH at the binaries in Settings.",
|
||||
@@ -89,7 +117,7 @@ _HINTS: dict[str, str] = {
|
||||
# told the reporter to reinstall transformers — advice that cannot work,
|
||||
# because nothing is wrong with their install. Checked first so the cause
|
||||
# wins over the symptom.
|
||||
"MODEL_DOWNLOAD_INTERRUPTED": "A model download was cut off mid-request, and the component it was fetching then failed to load. Nothing is wrong with your install — reinstalling won't help, and the partial download is resumed rather than restarted. Just retry. If it keeps happening, check your connection (and any VPN, proxy or HF mirror setting); if only transcription is affected, switching ASR to faster-whisper in Settings → Models avoids the pipeline that downloads this component.",
|
||||
"MODEL_DOWNLOAD_INTERRUPTED": "A model download was cut off mid-request, and the component it was fetching then failed to load. Nothing is wrong with your install — reinstalling won't help, and the partial download is resumed rather than restarted. Just retry. If it keeps happening, check your connection (and any VPN, proxy or HF mirror setting); if only transcription is affected, switching ASR to faster-whisper in Model Catalogue → Models avoids the pipeline that downloads this component.",
|
||||
"BROKEN_VENV": "The Python backend environment was moved or damaged. VoiceStudio rebuilds it automatically on the next launch; if it keeps failing, use Clean & Retry on the setup screen.",
|
||||
"MODEL_CACHE_CORRUPT": "A model file is missing or damaged — a download that stopped part-way, a broken link to downloaded data, or a file changed on disk after it arrived (interrupted renames and antivirus interference both cause this). VoiceStudio repairs it automatically and retries the load once, re-downloading the damaged file where a resume would not have replaced it. If the error persists, quit VoiceStudio, delete the model's models--<org>--<name> folder inside the Hugging Face cache, and restart — the model re-downloads automatically.",
|
||||
# HF_MIRROR_UNREACHABLE has a DYNAMIC hint (it names the configured mirror)
|
||||
|
||||
@@ -3,10 +3,11 @@ isn't empty on initial launch. Runs once; skips silently if any
|
||||
profiles already exist.
|
||||
"""
|
||||
|
||||
import filecmp
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
import logging
|
||||
|
||||
from core.db import get_db
|
||||
from core.config import VOICES_DIR
|
||||
@@ -23,16 +24,15 @@ DEMO_PROFILE_NAME = "VoiceStudio Demo Voice"
|
||||
# Must match the actual spoken content of backend/assets/samples/demo_voice.wav.
|
||||
# Regenerated by scripts/build_demos.sh — update both files in lockstep.
|
||||
DEMO_REF_TEXT = (
|
||||
"Hi, I'm the VoiceStudio demo voice. Everything you hear me say from now on "
|
||||
"was synthesized on your own machine. No cloud, no account, just you and "
|
||||
"the model."
|
||||
"Hey. I'm the VoiceStudio demo voice. I was made right here, on your "
|
||||
"machine: private, local, and ready whenever you are."
|
||||
)
|
||||
|
||||
|
||||
_DEMO_DESCRIPTION = (
|
||||
"A neutral reference voice bundled with VoiceStudio. Clone it to hear how "
|
||||
"the engine sounds on your machine, then replace it with your own "
|
||||
"recording when you're ready."
|
||||
"An original warm, low cinematic voice bundled with VoiceStudio. Clone it "
|
||||
"to hear how the engine sounds on your machine, then replace it with your "
|
||||
"own recording when you're ready."
|
||||
)
|
||||
|
||||
|
||||
@@ -43,8 +43,14 @@ def _backfill_demo_metadata(conn):
|
||||
try:
|
||||
conn.execute(
|
||||
"UPDATE voice_profiles SET description=?, is_demo=1, ref_text=? "
|
||||
"WHERE id=? AND (is_demo=0 OR description='' OR ref_text!=?)",
|
||||
(_DEMO_DESCRIPTION, DEMO_REF_TEXT, DEMO_PROFILE_ID, DEMO_REF_TEXT),
|
||||
"WHERE id=? AND (is_demo=0 OR description!=? OR ref_text!=?)",
|
||||
(
|
||||
_DEMO_DESCRIPTION,
|
||||
DEMO_REF_TEXT,
|
||||
DEMO_PROFILE_ID,
|
||||
_DEMO_DESCRIPTION,
|
||||
DEMO_REF_TEXT,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
@@ -52,11 +58,34 @@ def _backfill_demo_metadata(conn):
|
||||
logger.debug("Demo backfill skipped: %s", e)
|
||||
|
||||
|
||||
def _refresh_demo_audio(conn):
|
||||
"""Keep the canonical demo profile in sync with the bundled render."""
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM voice_profiles WHERE id=? AND is_demo=1",
|
||||
(DEMO_PROFILE_ID,),
|
||||
).fetchone()
|
||||
if not row or not os.path.isfile(_DEMO_AUDIO):
|
||||
return
|
||||
os.makedirs(VOICES_DIR, exist_ok=True)
|
||||
dest = os.path.join(VOICES_DIR, f"{DEMO_PROFILE_ID}.wav")
|
||||
if not os.path.isfile(dest) or not filecmp.cmp(
|
||||
_DEMO_AUDIO, dest, shallow=False
|
||||
):
|
||||
shutil.copy2(_DEMO_AUDIO, dest)
|
||||
logger.info("Refreshed bundled demo voice audio")
|
||||
except Exception as e:
|
||||
# The demo must never make startup fail; a fresh seed below can still
|
||||
# repair it once the schema and data directory are available.
|
||||
logger.debug("Demo audio refresh skipped: %s", e)
|
||||
|
||||
|
||||
def seed_sample_project():
|
||||
"""Create the demo voice profile if no profiles exist yet."""
|
||||
conn = get_db()
|
||||
try:
|
||||
_backfill_demo_metadata(conn)
|
||||
_refresh_demo_audio(conn)
|
||||
count = conn.execute("SELECT COUNT(*) FROM voice_profiles").fetchone()[0]
|
||||
if count > 0:
|
||||
return # Not first run — skip
|
||||
|
||||
@@ -10,8 +10,13 @@ from __future__ import annotations
|
||||
|
||||
import ntpath
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
_WINDOWS_RESERVED_NAMES = frozenset({"CON", "PRN", "AUX", "NUL"}) | frozenset(
|
||||
f"{prefix}{number}" for prefix in ("COM", "LPT") for number in range(1, 10)
|
||||
)
|
||||
|
||||
|
||||
class UnsafePath(ValueError):
|
||||
"""Raised when a path crosses its allowed filesystem boundary."""
|
||||
@@ -28,6 +33,10 @@ def safe_filename(value: object) -> str:
|
||||
or os.path.isabs(name)
|
||||
or ntpath.isabs(name)
|
||||
or ntpath.basename(name) != name
|
||||
or name.endswith((" ", "."))
|
||||
or re.search(r"[\x00-\x1f]", name)
|
||||
or name.split(".", 1)[0].upper() in _WINDOWS_RESERVED_NAMES
|
||||
or len(name.encode("utf-8")) > 240
|
||||
):
|
||||
raise UnsafePath("expected a bare filename")
|
||||
return name
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"""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
|
||||
@@ -144,6 +144,6 @@ def _drop_invalid_path_keys() -> None:
|
||||
logger.warning(
|
||||
"%s from the saved env file points at an unusable path (%s) — "
|
||||
"ignoring it for this run and falling back to the default "
|
||||
"location. Fix or clear it in Settings → Models.", key, val,
|
||||
"location. Fix or clear it in Model Catalogue → Models.", key, val,
|
||||
)
|
||||
os.environ.pop(key, None)
|
||||
|
||||
@@ -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.4.2"
|
||||
_FALLBACK_VERSION = "0.5.0"
|
||||
|
||||
|
||||
def _fallback_version() -> str:
|
||||
|
||||
@@ -85,11 +85,27 @@ def _get_model():
|
||||
global _model
|
||||
if _model is None:
|
||||
from faster_whisper import WhisperModel
|
||||
name = os.environ.get("ASR_MODEL_FW", "large-v3")
|
||||
# Same weights as in-process faster-whisper: ASR_MODEL_FASTER selects
|
||||
# for BOTH variants, ASR_MODEL_FW stays as a sidecar-only override.
|
||||
# Before this, the sidecar read only ASR_MODEL_FW while the download
|
||||
# preflight read ASR_MODEL_FASTER — set one and the other variant (or
|
||||
# the preflight) quietly used a different model.
|
||||
name = (
|
||||
os.environ.get("ASR_MODEL_FW")
|
||||
or os.environ.get("ASR_MODEL_FASTER")
|
||||
or "large-v3"
|
||||
)
|
||||
try:
|
||||
import torch
|
||||
device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
# The probe honors the user compute-device override and the
|
||||
# ROCm/CT2 incompatibility (#1529) — the child must agree with
|
||||
# the parent's device decision, not re-derive its own.
|
||||
from core.device_caps import detect_host_caps
|
||||
device = "cuda" if detect_host_caps().family == "cuda" else "cpu"
|
||||
except Exception:
|
||||
# Fail SAFE: guessing "cuda" from torch here would bypass a cpu
|
||||
# override and hand CTranslate2 HIP-flavoured cuda on ROCm
|
||||
# (#1529). CPU always works; say why in the sidecar log.
|
||||
print("asr-sidecar: device probe failed — using cpu", file=sys.stderr, flush=True)
|
||||
device = "cpu"
|
||||
# Degrade fp16 → int8 rather than crash on GPUs without efficient fp16
|
||||
# (older Maxwell/Pascal, GTX 16xx, CTranslate2/cuDNN mismatch) (#551).
|
||||
|
||||
@@ -173,7 +173,7 @@ def _binary_repair_hint() -> str:
|
||||
f"the bundled GGUF runtime is not usable on this machine — build it "
|
||||
f"with `scripts/build-omnivoice-tts.sh --platform {_platform_slug()}`, "
|
||||
f"reinstall VoiceStudio, or switch to the default in-process "
|
||||
f"OmniVoice engine (Settings → Engines)"
|
||||
f"OmniVoice engine (Model Catalogue → Engines)"
|
||||
)
|
||||
|
||||
|
||||
@@ -353,6 +353,9 @@ def _make_backend_class():
|
||||
display_name = "OmniVoice (GGUF, hardware-adaptive)"
|
||||
gpu_compat = ("cuda", "mps", "cpu")
|
||||
supports_voice_design = False
|
||||
# Every generate() spawns the external binary — allocations live in
|
||||
# that process, invisible to parent-side accelerator counters.
|
||||
runs_out_of_process = True
|
||||
|
||||
# 24 kHz mono Higgs Audio v2 — same as the in-process OmniVoice.
|
||||
_SAMPLE_RATE = 24_000
|
||||
@@ -406,7 +409,7 @@ def _make_backend_class():
|
||||
f"built — run `scripts/build-omnivoice-tts.sh "
|
||||
f"--platform {_platform_slug()}`, reinstall "
|
||||
f"VoiceStudio, or use the default in-process "
|
||||
f"OmniVoice engine (Settings → Engines)."
|
||||
f"OmniVoice engine (Model Catalogue → Engines)."
|
||||
)
|
||||
# Manifest-based SHA-256 verification (T-04-01).
|
||||
manifest = _load_checksum_manifest()
|
||||
@@ -855,7 +858,7 @@ def select_default_engine() -> str:
|
||||
Returns ``"omnivoice"`` (the existing in-process default) on any
|
||||
failure. The fallback is deliberately silent — a user who hits this
|
||||
code path still gets a working cloning engine; the failure surfaces
|
||||
in the Settings → Engines Compatibility Matrix (Plan 02-04) so the
|
||||
in the Model Catalogue → Engines Compatibility Matrix (Plan 02-04) so the
|
||||
user can investigate if they care to.
|
||||
"""
|
||||
cls = _make_backend_class()
|
||||
|
||||
@@ -94,7 +94,7 @@ class PocketTTSBackend(SubprocessBackend):
|
||||
raise RuntimeError(platform_error)
|
||||
if not self._license_accepted():
|
||||
raise RuntimeError(
|
||||
"PocketTTS license not accepted. Review it in Settings → Engines."
|
||||
"PocketTTS license not accepted. Review it in Model Catalogue → Engines."
|
||||
)
|
||||
super().__init__()
|
||||
|
||||
@@ -104,7 +104,7 @@ class PocketTTSBackend(SubprocessBackend):
|
||||
# relying on every caller to evict its cached instance.
|
||||
if not self._license_accepted():
|
||||
raise RuntimeError(
|
||||
"PocketTTS license not accepted. Review it in Settings → Engines."
|
||||
"PocketTTS license not accepted. Review it in Model Catalogue → Engines."
|
||||
)
|
||||
return super().generate(*args, **kwargs)
|
||||
|
||||
@@ -114,7 +114,7 @@ class PocketTTSBackend(SubprocessBackend):
|
||||
# so revocation while waiting cannot reach the sidecar or return audio.
|
||||
if not self._license_accepted():
|
||||
raise RuntimeError(
|
||||
"PocketTTS license not accepted. Review it in Settings → Engines."
|
||||
"PocketTTS license not accepted. Review it in Model Catalogue → Engines."
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@@ -137,7 +137,7 @@ class PocketTTSBackend(SubprocessBackend):
|
||||
# use behind an explicit local acknowledgement, matching the dialog.
|
||||
if not cls._license_accepted():
|
||||
return False, (
|
||||
"PocketTTS license not accepted. Open Settings → Engines → "
|
||||
"PocketTTS license not accepted. Open Model Catalogue → Engines → "
|
||||
"PocketTTS and review the MIT code license, CC-BY-4.0 model "
|
||||
"license, and gated-access conditions before enabling it."
|
||||
)
|
||||
|
||||
@@ -100,8 +100,8 @@ class Supertonic3Backend(SubprocessBackend):
|
||||
import supertonic # type: ignore[import-not-found] # noqa: F401
|
||||
except ImportError:
|
||||
return False, (
|
||||
"supertonic package not installed. Enable in Settings → "
|
||||
"Engines (installs `supertonic` via `uv add --optional "
|
||||
"supertonic package not installed. Enable in "
|
||||
"Model Catalogue → Engines (installs `supertonic` via `uv add --optional "
|
||||
"supertonic supertonic==1.3.1`)."
|
||||
)
|
||||
|
||||
@@ -120,7 +120,7 @@ class Supertonic3Backend(SubprocessBackend):
|
||||
accepted = False
|
||||
if not accepted:
|
||||
return False, (
|
||||
"Supertonic-3 license not accepted. Open Settings → Engines → "
|
||||
"Supertonic-3 license not accepted. Open Model Catalogue → Engines → "
|
||||
"Supertonic-3 and click Accept to enable. "
|
||||
"(MIT code license + OpenRAIL-M model license.)"
|
||||
)
|
||||
|
||||
+780
-429
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,106 @@
|
||||
"""Remote worker control-plane schema.
|
||||
|
||||
Revision ID: 0010_remote_worker_schema
|
||||
Revises: 0009_generation_history_starred
|
||||
|
||||
Mirrors ``core.db::_BASE_SCHEMA`` while that startup schema remains the
|
||||
fallback for bundled installs where Alembic is unavailable.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision: str = "0010_remote_worker_schema"
|
||||
down_revision: Union[str, None] = "0009_generation_history_starred"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def _has_table(name: str) -> bool:
|
||||
row = op.get_bind().execute(
|
||||
sa.text("SELECT name FROM sqlite_master WHERE type='table' AND name=:name"),
|
||||
{"name": name},
|
||||
).fetchone()
|
||||
return row is not 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:
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS remote_workers (
|
||||
id TEXT PRIMARY KEY, name TEXT NOT NULL DEFAULT '', key_id TEXT NOT NULL,
|
||||
public_key BLOB NOT NULL, enabled INTEGER NOT NULL DEFAULT 1,
|
||||
revoked INTEGER NOT NULL DEFAULT 0, revoked_at REAL,
|
||||
priority INTEGER NOT NULL DEFAULT 50, endpoint TEXT NOT NULL DEFAULT '',
|
||||
host_json TEXT NOT NULL DEFAULT '{}', capabilities_json TEXT NOT NULL DEFAULT '[]',
|
||||
max_concurrent_tasks INTEGER NOT NULL DEFAULT 1,
|
||||
session_epoch INTEGER NOT NULL DEFAULT 0, consent_granted_at REAL,
|
||||
created_at REAL NOT NULL, last_seen_at REAL
|
||||
)
|
||||
""")
|
||||
op.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_remote_workers_key ON remote_workers(key_id)"
|
||||
)
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS remote_worker_enrollments (
|
||||
token_id TEXT PRIMARY KEY, secret_hash TEXT NOT NULL,
|
||||
endpoint TEXT NOT NULL DEFAULT '', cert_fingerprint TEXT NOT NULL DEFAULT '',
|
||||
label TEXT NOT NULL DEFAULT '', created_at REAL NOT NULL,
|
||||
expires_at REAL NOT NULL, used_at REAL, used_by_worker TEXT
|
||||
)
|
||||
""")
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS remote_tasks (
|
||||
id TEXT PRIMARY KEY, idempotency_key TEXT, operation TEXT NOT NULL,
|
||||
engine TEXT NOT NULL DEFAULT '', model_id TEXT NOT NULL DEFAULT '',
|
||||
params_json TEXT NOT NULL DEFAULT '{}', priority INTEGER NOT NULL DEFAULT 0,
|
||||
state TEXT NOT NULL DEFAULT 'queued', max_attempts INTEGER NOT NULL DEFAULT 3,
|
||||
excluded_json TEXT NOT NULL DEFAULT '[]', error_json TEXT, result_ref TEXT,
|
||||
result_json TEXT, project_id TEXT, created_at REAL NOT NULL,
|
||||
updated_at REAL NOT NULL, deadline_at REAL, pinned_worker_id TEXT, finished_at REAL
|
||||
)
|
||||
""")
|
||||
if not _has_column("remote_tasks", "pinned_worker_id"):
|
||||
op.add_column("remote_tasks", sa.Column("pinned_worker_id", sa.Text(), nullable=True))
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_remote_tasks_state "
|
||||
"ON remote_tasks(state, priority, created_at)"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_remote_tasks_idem "
|
||||
"ON remote_tasks(idempotency_key) WHERE idempotency_key IS NOT NULL"
|
||||
)
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS remote_task_attempts (
|
||||
id TEXT PRIMARY KEY, task_id TEXT NOT NULL, worker_id TEXT NOT NULL,
|
||||
session_epoch INTEGER NOT NULL DEFAULT 0, attempt_number INTEGER NOT NULL DEFAULT 1,
|
||||
state TEXT NOT NULL DEFAULT 'assigned', progress REAL NOT NULL DEFAULT 0,
|
||||
stage TEXT NOT NULL DEFAULT '', error_json TEXT, created_at REAL NOT NULL,
|
||||
accepted_at REAL, started_at REAL, finished_at REAL,
|
||||
lease_expires_at REAL, grace_expires_at REAL
|
||||
)
|
||||
""")
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_remote_attempts_task ON remote_task_attempts(task_id)"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_remote_attempts_worker "
|
||||
"ON remote_task_attempts(worker_id, state)"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for table in (
|
||||
"remote_task_attempts",
|
||||
"remote_tasks",
|
||||
"remote_worker_enrollments",
|
||||
"remote_workers",
|
||||
):
|
||||
if _has_table(table):
|
||||
op.drop_table(table)
|
||||
@@ -0,0 +1,30 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,54 @@
|
||||
"""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
|
||||
@@ -0,0 +1,95 @@
|
||||
# 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` — an immutable catalog version comprising the installed
|
||||
Hugging Face revision (40-char commit SHA) and the first 16 hex characters
|
||||
of the attested snapshot digest. This creates a new catalog identity when
|
||||
snapshot bytes change; it never rewrites an identity retained by a Job.
|
||||
- `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
|
||||
```
|
||||
@@ -0,0 +1,18 @@
|
||||
"""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"
|
||||
@@ -0,0 +1,65 @@
|
||||
"""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())
|
||||
@@ -0,0 +1,20 @@
|
||||
"""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
|
||||
@@ -0,0 +1,163 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,112 @@
|
||||
"""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
|
||||
@@ -0,0 +1,664 @@
|
||||
"""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),
|
||||
# Gallery reference voices persist their OSS design seed. Accept it at
|
||||
# the hosted runtime boundary so a selected voice produces the same take.
|
||||
"seed": ("integer", 0, 4_294_967_295),
|
||||
}
|
||||
|
||||
|
||||
# ── 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()
|
||||
|
||||
@staticmethod
|
||||
def _synthesize(engine, text: str, params: dict):
|
||||
"""Use the same seeded native path as OSS Gallery and ovnode workers."""
|
||||
from services import tts_backend # noqa: PLC0415
|
||||
|
||||
if isinstance(engine, tts_backend.OmniVoiceBackend):
|
||||
from api.routers.generation import _run_inference # noqa: PLC0415
|
||||
|
||||
with tts_backend.engine_in_use(engine):
|
||||
return _run_inference(
|
||||
engine._model, text, params.get("language"),
|
||||
params.get("ref_audio"), params.get("ref_text"),
|
||||
params.get("instruct"), params.get("duration"),
|
||||
params.get("num_step", 16), params.get("guidance_scale", 2.0),
|
||||
params.get("speed", 1.0), params.get("t_shift"),
|
||||
params.get("denoise", True), params.get("postprocess_output", True),
|
||||
params.get("layer_penalty_factor"),
|
||||
params.get("position_temperature"),
|
||||
params.get("class_temperature"), params.get("seed"),
|
||||
)
|
||||
return engine.generate(text, **params)
|
||||
|
||||
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 = self._synthesize(engine, 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()
|
||||
@@ -0,0 +1,5 @@
|
||||
"""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
@@ -0,0 +1,330 @@
|
||||
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: ...
|
||||
@@ -0,0 +1,229 @@
|
||||
# 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)
|
||||
@@ -0,0 +1,318 @@
|
||||
"""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 catalog_model_version(revision: str, model_digest: str) -> str:
|
||||
"""Return the immutable catalog version for an attested model snapshot.
|
||||
|
||||
A Hugging Face revision names source history, not necessarily the exact
|
||||
snapshot bytes installed on a node. The catalog version therefore carries
|
||||
a short, deterministic digest suffix. A changed snapshot becomes a new
|
||||
catalog identity instead of mutating an identity retained by Jobs.
|
||||
"""
|
||||
digest = model_digest.removeprefix("sha256:")
|
||||
if len(revision) != 40 or len(digest) != 64:
|
||||
raise ValueError("model identity requires a SHA revision and SHA-256 digest")
|
||||
return f"{revision}+sha256-{digest[:16]}"
|
||||
|
||||
|
||||
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=catalog_model_version(base.model_version, model_digest),
|
||||
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
|
||||
@@ -0,0 +1,63 @@
|
||||
"""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,
|
||||
)
|
||||
@@ -0,0 +1,199 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
"""``--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
|
||||
@@ -0,0 +1,208 @@
|
||||
"""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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user