Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60a4743a63 | ||
|
|
d5b9f17c1a | ||
|
|
cb581f38df | ||
|
|
efa750bc88 | ||
|
|
d04611cbc5 | ||
|
|
ce1f7c3b83 | ||
|
|
ca14764083 | ||
|
|
dffb02e953 |
@@ -365,19 +365,6 @@ jobs:
|
||||
echo "choco attempt $i did not produce ffmpeg — retrying in $((i * 30))s"
|
||||
sleep $((i * 30))
|
||||
done
|
||||
# Chocolatey is one distribution channel, not the dependency. When
|
||||
# its feed is down across every retry (2026-08-13: three attempts,
|
||||
# three 'installed 0/1'), fall back to the static gyan.dev release
|
||||
# build GitHub mirror — the same binary, no feed in the path.
|
||||
if ! command -v ffmpeg >/dev/null 2>&1; then
|
||||
echo "::warning::choco feed down — falling back to static ffmpeg build"
|
||||
curl -fsSL --retry 3 -o /tmp/ffmpeg.zip \
|
||||
https://github.com/GyanD/codexffmpeg/releases/download/7.1/ffmpeg-7.1-essentials_build.zip
|
||||
unzip -q /tmp/ffmpeg.zip -d /tmp/ffmpeg
|
||||
bindir=$(dirname "$(find /tmp/ffmpeg -name ffmpeg.exe | head -1)")
|
||||
echo "$bindir" >> "$GITHUB_PATH"
|
||||
export PATH="$bindir:$PATH"
|
||||
fi
|
||||
ffmpeg -version
|
||||
|
||||
- name: System deps (Linux)
|
||||
|
||||
@@ -731,62 +731,6 @@ jobs:
|
||||
find "$INSTALL" -type f -path '*backend*main.py' | grep -q . || fail "backend source main.py missing"
|
||||
echo "OK — MSI installed shell + uv + backend resources"
|
||||
|
||||
# linuxdeploy re-links .DirIcon as an ABSOLUTE symlink into the build
|
||||
# machine AFTER tauri's files-map has placed the real icon bytes — the
|
||||
# exact bug #1518 guarded against, resurfacing on the first real tag
|
||||
# build (v0.5.0). The seam tauri-action leaves us is post-upload: repack
|
||||
# the AppImage with the icon as a REGULAR FILE, re-sign it (the updater
|
||||
# signature covered the old bytes), and clobber the draft release's
|
||||
# asset + the linux signature inside latest.json. The smoke below then
|
||||
# validates the repaired artifact, not the broken one.
|
||||
- name: Repair AppImage .DirIcon, re-sign, re-upload
|
||||
if: runner.os == 'Linux'
|
||||
timeout-minutes: 10
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
# Data, not shell source (zizmor template-injection): a crafted ref
|
||||
# must never expand inside a script that holds the signing key.
|
||||
TAG: ${{ (needs.preview-gate.outputs.is_preview == 'true') && 'preview' || github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
APPIMAGE=$(find frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle/appimage -name "*.AppImage" | head -1)
|
||||
APPIMAGE=$(realpath "$APPIMAGE")
|
||||
WORK="$(mktemp -d)"; cd "$WORK"
|
||||
"$APPIMAGE" --appimage-extract >/dev/null
|
||||
ROOT="$WORK/squashfs-root"
|
||||
ICON=$(readlink -f "$ROOT/.DirIcon" 2>/dev/null || true)
|
||||
if [ -n "$ICON" ] && [ -f "$ICON" ] && case "$ICON" in "$ROOT"/*) true;; *) false;; esac; then
|
||||
echo ".DirIcon already resolves inside the bundle — no repair needed"
|
||||
exit 0
|
||||
fi
|
||||
# The real bytes are at the AppDir root (linuxdeploy put them there
|
||||
# before mislinking). Ship a regular file: nothing left to dangle.
|
||||
SRC=$(find "$ROOT" -maxdepth 1 -name "*.png" | head -1)
|
||||
[ -n "$SRC" ] || SRC=$(find "$ROOT/usr/share/icons" -name "*.png" | head -1)
|
||||
[ -n "$SRC" ] || { echo "no icon bytes found in bundle"; exit 1; }
|
||||
rm -f "$ROOT/.DirIcon"
|
||||
cp "$SRC" "$ROOT/.DirIcon"
|
||||
# Pinned immutable release + checksum: this binary runs with the
|
||||
# updater signing key and a release-write token in its environment,
|
||||
# so a mutable 'continuous' asset is not acceptable supply chain.
|
||||
AIT_URL="https://github.com/AppImage/appimagetool/releases/download/1.9.1/appimagetool-x86_64.AppImage"
|
||||
AIT_SHA256="ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0"
|
||||
curl -fsSL --retry 3 -o "$WORK/appimagetool" "$AIT_URL"
|
||||
echo "$AIT_SHA256 $WORK/appimagetool" | sha256sum -c - || { echo "appimagetool checksum mismatch"; exit 1; }
|
||||
chmod +x "$WORK/appimagetool"
|
||||
# Same FUSE-less trick the build itself uses.
|
||||
APPIMAGE_EXTRACT_AND_RUN=1 ARCH=x86_64 "$WORK/appimagetool" --no-appstream "$ROOT" "$APPIMAGE"
|
||||
cd "$GITHUB_WORKSPACE/frontend"
|
||||
bunx tauri signer sign "$APPIMAGE"
|
||||
gh release upload "$TAG" "$APPIMAGE" "$APPIMAGE.sig" --clobber --repo "$GITHUB_REPOSITORY"
|
||||
# latest.json is NOT patched here: every tauri-action leg re-uploads
|
||||
# the shared manifest, so an in-leg patch races the other platforms —
|
||||
# the repair-updater-manifest job below is the single final writer.
|
||||
echo "repacked, re-signed, re-uploaded"
|
||||
|
||||
- name: Installer smoke (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
timeout-minutes: 5
|
||||
@@ -900,50 +844,6 @@ jobs:
|
||||
# the tag (v0.3.20 shipped with only the Linux AppImage that way). `needs:
|
||||
# [build]` guarantees the release already exists; `--clobber` makes a re-run
|
||||
# idempotent. This can never create a second release.
|
||||
# The Linux leg may repack + re-sign its AppImage (see the repair step in
|
||||
# the build matrix); every tauri-action leg also re-uploads the SHARED
|
||||
# latest.json, so patching the manifest inside any leg races the others.
|
||||
# This job runs once after the whole matrix as the single final writer:
|
||||
# it makes the manifest's linux signature agree with the .sig asset that
|
||||
# actually shipped, and refuses to leave a mismatch behind.
|
||||
repair-updater-manifest:
|
||||
needs: [build, preview-gate]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Data, not shell source — same zizmor rule as the leg step.
|
||||
TAG: ${{ (needs.preview-gate.outputs.is_preview == 'true') && 'preview' || github.ref_name }}
|
||||
steps:
|
||||
- name: Align latest.json's linux signature with the shipped .sig asset
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
WORK="$(mktemp -d)"
|
||||
HAS_MANIFEST=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '[.assets[].name]|contains(["latest.json"])')
|
||||
if [ "$HAS_MANIFEST" != "true" ]; then
|
||||
echo "no latest.json on the release — nothing to align"; exit 0
|
||||
fi
|
||||
gh release download "$TAG" --pattern latest.json --output "$WORK/latest.json" --repo "$GITHUB_REPOSITORY"
|
||||
# Same fail-closed rule as the manifest: absence is checked against
|
||||
# the asset LIST; an actual download failure must fail the job, or
|
||||
# the manifest keeps a signature nobody shipped.
|
||||
HAS_SIG=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '[.assets[].name|select(endswith(".AppImage.sig"))]|length > 0')
|
||||
if [ "$HAS_SIG" != "true" ]; then
|
||||
echo "no AppImage .sig asset on the release — nothing to align"; exit 0
|
||||
fi
|
||||
gh release download "$TAG" --pattern "*.AppImage.sig" --dir "$WORK" --repo "$GITHUB_REPOSITORY"
|
||||
SIG_FILE=$(find "$WORK" -name "*.AppImage.sig" | head -1)
|
||||
[ -n "$SIG_FILE" ] || { echo "sig asset listed but download produced nothing"; exit 1; }
|
||||
NEW_SIG=$(cat "$SIG_FILE")
|
||||
CHANGED=$(python3 -c 'import json,sys; p,sig=sys.argv[1],sys.argv[2]; d=json.load(open(p)); n=sum(1 for k,v in d.get("platforms",{}).items() if k.startswith("linux") and v.get("signature")!=sig and not v.update({"signature":sig})); json.dump(d,open(p,"w"),indent=2); print(n)' "$WORK/latest.json" "$NEW_SIG")
|
||||
if [ "$CHANGED" -ge 1 ]; then
|
||||
gh release upload "$TAG" "$WORK/latest.json" --clobber --repo "$GITHUB_REPOSITORY"
|
||||
echo "aligned $CHANGED linux signature(s) with the shipped .sig"
|
||||
else
|
||||
echo "manifest already agrees with the shipped .sig — no write"
|
||||
fi
|
||||
|
||||
uninstall-scripts:
|
||||
needs: [build]
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
|
||||
|
||||
+49
-42
@@ -6,38 +6,58 @@ The format is loosely based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
`frontend/package.json` is the app-version source of truth; Cargo, Python, and
|
||||
the frozen-backend fallback mirror it for their toolchains.
|
||||
|
||||
## [0.5.0] — 2026-08-13
|
||||
## [Unreleased]
|
||||
|
||||
**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)
|
||||
- Docker/server mode now requires an API key for remote changes and side-effectful admin checks across workers, engines, media tools, MCP, pronunciation, diagnostics, and LLM providers. (#1525) — thanks @bultodepapas!
|
||||
- The unified Support page no longer throws while opening a section in browsers or test environments without `scrollIntoView`. (#1525) — thanks @bultodepapas!
|
||||
- A faster, cleaner Dub workspace for multilingual production (#1489)
|
||||
- VoiceStudio now gives the app, desktop chrome, documentation, and package metadata one clear identity
|
||||
- A local-first creative studio: voice cloning, design, dubbing, dictation, stories, audiobooks, and transcription without a subscription meter
|
||||
- Reliability first: automatic cache repair, truthful hardware routing, safer sidecars, and actionable recovery instead of mystery failures
|
||||
- Security boundaries now match the product: native file access stays native, untrusted network destinations fail closed, and public errors keep private diagnostics local
|
||||
- RTX 40-series GPUs are used again instead of being sent to the CPU
|
||||
- A warning before a slow generation, rather than after a five-minute wait
|
||||
- The watermark can be turned off in Settings, as the docs always said
|
||||
- Your other GPU can take the work now — send individual jobs to a second machine, opt-in
|
||||
- More than one person can share one GPU machine, without shell access to it or taking turns
|
||||
- A Model Catalogue workspace: every engine and model in one place, with the defaults set there
|
||||
- Workspace tabs in the title bar, if you prefer them to the icon rail (#1412)
|
||||
- macOS support now matches what the app actually delivers
|
||||
- Linux AppImage: a blank white window on rolling distros (Mesa 26.1+) now starts normally
|
||||
- Apple Silicon: transcription no longer needs a system ffmpeg, as the docs always said — thanks @gambletan! (#1436)
|
||||
- A failed audiobook chapter says why, instead of turning red and saying nothing
|
||||
|
||||
<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" />
|
||||
### Fixed
|
||||
|
||||
| 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" /> |
|
||||
- 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)
|
||||
|
||||
### Added
|
||||
|
||||
- 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)
|
||||
|
||||
### Added
|
||||
|
||||
- A machine can now join a control plane from the app: Settings → System → Remote workers → **Lend this machine's GPU**, paste the join code, done — no environment variables and no restart. The address travels with the code, so the machine reconnects on its own afterwards. (#1516)
|
||||
- Join codes and connection strings are shown as a **QR code** alongside the text, with a live expiry countdown — scan it from the other machine instead of retyping forty characters. (#1516)
|
||||
- A **Compute** control in the status bar: pick local or a remote machine, turn remote workers on or off, and mint a join code without opening Settings. It appears only once you have opted in or enrolled a machine. (#1516)
|
||||
- A worker waiting for approval can be approved from its row. The panel labelled that state before but offered no way out of it. (#1516)
|
||||
- The demo audio the app has always advertised now actually ships: previews for all seven voice-design presets, the three dictation replay clips, and the dubbing demo's source video plus four dubbed languages with subtitles. Every one of those was a dead link before — the tooling that renders them required macOS, so on Windows and Linux the files were never built. (#1517)
|
||||
- Demo assets are rendered by VoiceStudio's own engine, so the tooling runs wherever the app does, and the demos are made by the thing they demonstrate. (#1517)
|
||||
|
||||
### Changed
|
||||
|
||||
- Gallery personas now preview through the local backend, retain their complete voice-design recipe, and open directly in Voice, Stories, or Audiobook. (#1542)
|
||||
- Support amount choices now use every theme's shared card, accent and focus tokens. (#1530)
|
||||
- Sponsoring, commercial licensing and getting in touch are one page now. They answered the same question between them and each used to live somewhere else, so they are three sections on a single scroll — the footer heart, the commercial-licence links and Contact all land on it, at the section you asked for. (#1522)
|
||||
- Model Catalogue switches panes with tabs instead of a two-state toggle, and the Engine Compatibility Matrix's TTS / ASR / LLM switcher is now tabs too — arrow-key navigable, and each tab still shows the engine it would use. (#1522)
|
||||
- Engines you can actually use sort to the top of the compatibility matrix, and an unavailable engine's name recedes instead of the whole row fading — the status badge and GPU chips that say *why* it is unavailable stay legible. (#1522)
|
||||
- Remote workers reads as a device list: status dot, address, latency, a live task meter, resident models and last-seen per machine, with housekeeping actions revealed on hover and a three-step empty state. (#1516)
|
||||
- The GPU picker and the new status-bar control paint their status dots and menu surfaces from themed tokens instead of fixed palette classes, so they stop showing Gruvbox colours on Midnight and Catppuccin. (#1516)
|
||||
- Dictation shows the pill again: a capture puts a small always-on-top capsule near the bottom of the screen you are working on — listening, transcribing, the result, and any error — and takes it away when the session ends. It never takes focus, so the text still lands in the app you were typing into. On Wayland the compositor decides where it sits; everywhere else it is bottom-centred.
|
||||
- Remote workers reads as a device list: status dot, address, latency, a live task meter, resident models and last-seen per machine, with housekeeping actions revealed on hover and a three-step empty state. (#1516)
|
||||
- Engines and models moved out of Settings into a new Model Catalogue workspace, reachable from the icon rail (or the title-bar tabs); Settings → Engines and Settings → Models now point there, and Settings keeps the models directory and Hugging Face mirror.
|
||||
- The Settings sidebar is keyboard-navigable: ⌘K / Ctrl+K jumps to the filter, ↑/↓ and Home/End move between categories, and Enter or ↓ from the filter drops into the list. Matching text in a filtered category name is highlighted, and group headers stay pinned while the list scrolls.
|
||||
- The Launchpad has a quieter, more spacious look: borderless feature tiles that light up on hover or keyboard focus, plain-numeral counts, hairline section rules, and one shared page column for the hero, tiles, recent files and project lists.
|
||||
@@ -57,13 +77,6 @@ the frozen-backend fallback mirror it for their toolchains.
|
||||
|
||||
### Added
|
||||
|
||||
- Gallery personas preview through the local backend, keep their full voice-design recipe, and open directly in Voice, Stories, or Audiobook — and can be saved as local profiles with validated audio references. (#1542)
|
||||
- The demo audio the app has always advertised now actually ships: previews for all seven voice-design presets, the three dictation replay clips, and the dubbing demo's source video plus four dubbed languages with subtitles. Every one of those was a dead link before — the tooling that renders them required macOS, so on Windows and Linux the files were never built. (#1517)
|
||||
- Demo assets are rendered by VoiceStudio's own engine, so the tooling runs wherever the app does, and the demos are made by the thing they demonstrate. (#1517)
|
||||
- A machine can now join a control plane from the app: Settings → System → Remote workers → **Lend this machine's GPU**, paste the join code, done — no environment variables and no restart. The address travels with the code, so the machine reconnects on its own afterwards. (#1516)
|
||||
- Join codes and connection strings are shown as a **QR code** alongside the text, with a live expiry countdown — scan it from the other machine instead of retyping forty characters. (#1516)
|
||||
- A **Compute** control in the status bar: pick local or a remote machine, turn remote workers on or off, and mint a join code without opening Settings. It appears only once you have opted in or enrolled a machine. (#1516)
|
||||
- A worker waiting for approval can be approved from its row. The panel labelled that state before but offered no way out of it. (#1516)
|
||||
- **Model Catalogue** — a workspace of its own for engines and models: browse every TTS, transcription and LLM engine with its device routing and install state, pick the default for each, and install or remove model weights, all from one screen instead of two Settings categories.
|
||||
- Remote GPU machines can now accept connections instead of dialling out, so several people can use the same box at once — each gets their own revocable connection string, with certificate-pinned TLS, a live list of who is connected, and a disconnect button. (#1496)
|
||||
- Remote GPU model downloads now use the normal Models install flow and show per-worker progress. (#1478)
|
||||
@@ -77,22 +90,14 @@ the frozen-backend fallback mirror it for their toolchains.
|
||||
- Settings → Privacy now has an **Invisible watermark** toggle. On by default, available to everyone, and it only affects audio generated after the change. (#1308)
|
||||
- A new opt-in crash-isolated TTS engine, so a native crash takes down the sidecar instead of the whole backend — thanks @paoloantinori! (#1292, #1298, #1304)
|
||||
- **PocketTTS** (Kyutai), an opt-in CPU-only engine for fast, low-latency renders in six languages (en/fr/de/pt/it/es) with zero-shot cloning from a reference clip. Enable in Settings → Engines — thanks @paoloantinori! (#1306, #1328)
|
||||
- A warning before a slow generation, rather than after a five-minute wait. (#1280)
|
||||
|
||||
### Docs
|
||||
### CI
|
||||
|
||||
- Engine acceptance: new `docs/engine-acceptance.md` documents the job map, the bar a new engine must clear, and the out-of-tree path (#1306)
|
||||
- macOS install notes and the README support table now state the real floor (#1268)
|
||||
- Contact: the project X account is listed alongside Discord (#1313)
|
||||
- `OMNIVOICE_ALLOWED_ORIGINS` is finally documented: a browser loading the UI from another machine's origin needs the backend's CORS allow-list, which neither server mode nor trusted networks touches — thanks @vanderlpp! (#1348)
|
||||
- The stdio wire protocol every engine sidecar speaks is now tested once across all nine of them, instead of against a single engine — a bug in any one sidecar's copy gets caught — thanks @paoloantinori! (#1408)
|
||||
|
||||
### Fixed
|
||||
|
||||
- AMD/ROCm hosts no longer crash ASR with "CUDA driver version is insufficient": ROCm torch reports itself as CUDA, but whisperx/faster-whisper run on CTranslate2, which is NVIDIA-only — they now take the CPU path there, and auto-detect prefers pytorch-whisper, which genuinely uses the HIP GPU. (#1529)
|
||||
- Crash reports now carry the crashed run's own stderr: the shared error log is append-only with per-run offsets, so a restart can no longer overwrite the dying process's final output with the replacement's healthy startup. (#1510)
|
||||
- Wayland: a stale portal identity no longer kills the dictation shortcut for the whole session. The desktop entry the app writes for the GlobalShortcuts portal could point at a binary that has since moved (a `cargo clean`, a relocated AppImage) — GNOME then refuses the bind with "App info not found" and the hotkey silently dies. The entry is validated and rewritten at startup now. (#1526)
|
||||
- The guard that keeps transcription on the degrading ASR loader now scans the whole backend, not just the routers — a service that transcribes on a request's behalf skipped `ensure_loaded()` just as thoroughly. (#1519) — thanks @ahov520!
|
||||
- The Linux app icon is no longer blank. Every AppImage since v0.4.2 shipped `.DirIcon` as an absolute symlink into the machine that built it (`/home/runner/work/…`), so the link dangled on every user's computer and file managers, app menus and desktop integration all drew nothing. The release build now verifies the icon resolves inside the bundle before publishing. (#1518)
|
||||
- The Linux app icon is no longer blank: the AppImage shipped `.DirIcon` as a symlink into the machine that built it, so file managers and app menus drew nothing. (#1518)
|
||||
- The Linux desktop entry no longer ships an empty `Categories=`, which `desktop-file-validate` rejects and menu builders skip. (#1518)
|
||||
- Wayland: the dictation shortcut now actually starts dictation. The desktop portal registered the key correctly — GNOME and KDE even showed it back — but every press was discarded while decoding the compositor's signal, so the hotkey did nothing on any Wayland session. (#1490)
|
||||
- The first-run "Choose a comfortable UI size" screen no longer stutters while you sit there. Applying a scale resizes the window's own viewport, which the screen was reading back to re-pick a size — so it flipped between two sizes forever without anyone touching it. (#1514)
|
||||
@@ -206,17 +211,19 @@ the frozen-backend fallback mirror it for their toolchains.
|
||||
- Translation through LM Studio works. The built-in model name was the placeholder `local-model`, which LM Studio rejects because it serves whatever you have loaded — VoiceStudio now asks it, and a 404 from a local server names the models that ARE loaded instead of telling you to check a URL that was fine — thanks @biga73! (#1332)
|
||||
- Generation that silently dropped the end of the input now says so. When an engine returns no audio for part of the text the result sounds clean and is simply short, so the only way to notice was to read along; the backend log now names the sentences that produced nothing. (#1330)
|
||||
- Dubbing: a re-rendered line that quietly came back in a default voice instead of the cloned one now says why in the backend log — the clone clips are extracted per job and a saved dub outlives them, so regenerating after cleanup loses the reference with no error. (#1331)
|
||||
- RTX 40-series GPUs are used again instead of being sent to the CPU. (#1289)
|
||||
- Apple Silicon: transcription no longer needs a system ffmpeg, as the docs always said — thanks @gambletan! (#1436)
|
||||
- A failed audiobook chapter says why, instead of turning red and saying nothing. (#1325)
|
||||
|
||||
### Docs
|
||||
|
||||
- Engine acceptance: new `docs/engine-acceptance.md` documents the job map, the bar a new engine must clear, and the out-of-tree path (#1306)
|
||||
- macOS install notes and the README support table now state the real floor (#1268)
|
||||
- Contact: the project X account is listed alongside Discord (#1313)
|
||||
- `OMNIVOICE_ALLOWED_ORIGINS` is finally documented: a browser loading the UI from another machine's origin needs the backend's CORS allow-list, which neither server mode nor trusted networks touches — thanks @vanderlpp! (#1348)
|
||||
|
||||
### CI
|
||||
|
||||
- Windows CI falls back to a static ffmpeg build when the Chocolatey feed is down, instead of failing the run. (#1542)
|
||||
- The stdio wire protocol every engine sidecar speaks is now tested once across all nine of them, instead of against a single engine — a bug in any one sidecar's copy gets caught — thanks @paoloantinori! (#1408)
|
||||
- Windows smoke tests stopped silently passing a broken ffmpeg install, and every smoke leg is now budgeted for a cold dependency install. (#1290)
|
||||
- Test suites no longer leak config paths or model-manager shutdown state into one another, which had been failing unrelated pull requests. (#1269)
|
||||
- The nightly preview build stopped refusing to publish its own healthy updater manifest when the macOS legs finished a few minutes ahead of the slowest one — Preview-channel users were silently left without new builds.
|
||||
- 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.
|
||||
|
||||
## [0.4.2] — 2026-07-28
|
||||
|
||||
|
||||
@@ -53,38 +53,6 @@
|
||||
> [!WARNING]
|
||||
> **Active beta.** Things may break between releases — for the newest fixes, run from source. Bug reports and PRs are very welcome: [open an issue](https://github.com/debpalash/VoiceStudio/issues) or [join Discord](https://discord.gg/bzQavDfVV9).
|
||||
|
||||
<a id="whats-new"></a>
|
||||
|
||||
## 🆕 What's new in 0.5.0
|
||||
|
||||
The rename release — full notes: [v0.5.0 release](https://github.com/debpalash/VoiceStudio/releases/tag/v0.5.0) · [CHANGELOG](CHANGELOG.md).
|
||||
|
||||
- 🏷️ **A new name** — VoiceStudio (previously OmniVoice-Studio): one waveform-and-spark identity across app, docs, and installers. Your data folder, settings, and Docker image paths stay put.
|
||||
- 📚 **Model Catalogue** — engines and models in one workspace: every TTS, ASR, and LLM engine with its device routing and install state; pick defaults, install or remove weights.
|
||||
- ⚡ **Engine quick-switch** — change TTS/ASR/LLM engines from the status bar or anywhere with <kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>E</kbd> — ready-only choices, memory status, environment-pin protection.
|
||||
- 🖧 **Remote GPU workers** — lend another machine's GPU with a join code and a QR scan; a **Compute** control picks where jobs run, and several people can share one GPU box over revocable, certificate-pinned connections.
|
||||
- 🔐 **Hardened server mode** — admin actions require an API key, exchanged for short-lived scoped sessions that never sit in browser storage or WebSocket URLs.
|
||||
- 💾 **Gallery voices → local profiles** — save any gallery voice as a profile of your own and use it in every picker.
|
||||
- 🎤 **Dictation on Wayland** — the portal shortcut actually fires now, and the recording pill is back on every desktop.
|
||||
|
||||
<div align="center">
|
||||
<img src="docs/media/0.5.0/quick-switch.gif" alt="Switching engines from the status bar" width="640"/>
|
||||
<br/><sub>Engine quick-switch from the status bar — <kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>E</kbd> from any workspace</sub>
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%"><img src="docs/media/0.5.0/catalogue.png" alt="Model Catalogue — engines pane" width="100%"/></td>
|
||||
<td width="50%"><img src="docs/media/0.5.0/gallery-save.png" alt="Saving a gallery voice as a profile" width="100%"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><sub><b>Model Catalogue</b> — every engine, its routing and install state</sub></td>
|
||||
<td align="center"><sub><b>Gallery → profile</b> — keep a gallery voice as your own</sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<a id="features"></a>
|
||||
|
||||
## ✨ Features
|
||||
@@ -115,21 +83,23 @@ Three flagships, five more headliners, and a dozen under the fold.
|
||||
</table>
|
||||
|
||||
<details>
|
||||
<summary><b>…and 12 more</b> — catalogue, remote GPUs, isolation, diarization, batch, watermarking, and friends</summary>
|
||||
<summary><b>…and 12 more</b> — isolation, diarization, batch, watermarking, diagnostics, and friends</summary>
|
||||
|
||||
<br/>
|
||||
|
||||
- 📚 **Model Catalogue** — one workspace for every TTS/ASR/LLM engine and model: defaults, device routing, install or remove weights — and quick-switch engines from anywhere with <kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>E</kbd>.
|
||||
- 🖧 **Remote GPU workers** — send jobs to GPUs on your other machines: join code + QR enrolment, Remote Model Downloads with per-worker live progress, chapter-by-chapter audiobook rendering with local fallback. Off by default; see [docs/remote-workers.md](docs/remote-workers.md).
|
||||
- 🔊 **Vocal Isolation** — Demucs-powered: splits speech from music and keeps the background bed.
|
||||
- 👥 **Speaker Diarization** — Pyannote + WhisperX auto-identify who said what.
|
||||
- 📦 **Batch Queue** — drop 50 videos, walk away; per-job progress bars.
|
||||
- 🛡️ **AI Watermark** — AudioSeal (Meta): invisible, survives compression.
|
||||
- 🔬 **Diagnostics** — self-check suite, error journal, scrubbed diagnostic bundles.
|
||||
- ⚡ **GPU Auto-Detect & Routing** — CUDA · MPS · ROCm (Linux, opt-in) · CPU; ≤8 GB VRAM auto-offloads; per-engine GPU preflight, no silent CPU fallback.
|
||||
- ⚡ **GPU Auto-Detect** — CUDA · MPS · ROCm (Linux, opt-in) · CPU; ≤8 GB VRAM auto-offloads.
|
||||
- 📥 **Remote Model Downloads** — install pinned model weights on the selected worker with live progress.
|
||||
- 🧭 **Engine routing** — preflight GPU check per engine; no silent CPU fallback.
|
||||
- 📚 **Model Catalogue** — one workspace listing every TTS/ASR/LLM engine and model: set the defaults, install or remove weights.
|
||||
- 🧩 **Extensible** — subclass `TTSBackend`, add any engine in ~50 lines.
|
||||
- 🎒 **Portable personas** — export voices as `.ovsvoice` bundles: identity + watermark.
|
||||
- ♾️ **Unlimited TTS** — sentence-chunked generation, no length cap, streaming via WebSocket.
|
||||
- 🌐 **Remote backend** — point the UI at a remote server; Tailscale-friendly, bearer auth.
|
||||
- 🧠 **Dictation + LLM** — local-LLM cleanup of transcripts, optional echo cancellation.
|
||||
|
||||
</details>
|
||||
@@ -181,15 +151,22 @@ Cloud voice tools are convenient, but they put your workflow behind an account,
|
||||
| **Video Dubbing** | ✅ Cloud-only | ✅ Fully local |
|
||||
| **Data Privacy** | Audio is processed remotely | Core workflow runs locally; online services are explicit opt-ins |
|
||||
| **API Keys** | Account required | Not needed for the local workflow |
|
||||
| **GPU Support** | N/A (cloud) | CUDA · Apple Silicon · ROCm (Linux) · CPU — plus your other machines' GPUs as [remote workers](docs/remote-workers.md) |
|
||||
| **GPU Support** | N/A (cloud) | CUDA · Apple Silicon · ROCm (Linux) · CPU |
|
||||
| **Desktop App** | ❌ | ✅ macOS · Windows · Linux |
|
||||
| **TTS Engines** | 1 | **16** — [full matrix](#tts-engines) |
|
||||
| **TTS Engines** | 1 | **14** — [full matrix](#tts-engines) |
|
||||
| **ASR Engines** | 1 | **11** — [full lineup](#asr-engines) |
|
||||
| **MCP Server** | ❌ | ✅ Use from Claude, Cursor, any MCP client |
|
||||
| **Self-check** | ❌ | ✅ Diagnostics suite, error journal, scrubbed debug bundles |
|
||||
| **Customizable** | ❌ Closed | ✅ Fork it, extend it, ship it |
|
||||
|
||||
Professional-grade voice AI, minus the subscription and the cloud. Convinced? [Come build with us.](https://discord.gg/bzQavDfVV9)
|
||||
Professional-grade voice AI, minus the subscription and the cloud.
|
||||
|
||||
<div align="center">
|
||||
<br/>
|
||||
<b>Convinced? Come build with us.</b><br/>
|
||||
<a href="https://discord.gg/bzQavDfVV9"><img src="https://img.shields.io/badge/Join_Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Join Discord" /></a>
|
||||
<br/><br/>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
@@ -211,10 +188,10 @@ Professional-grade voice AI, minus the subscription and the cloud. Convinced? [C
|
||||
|
||||
### 🗣️ TTS Engines
|
||||
|
||||
**16 engines, one picker.** VoiceStudio (default, 600+ languages) is always available; seven more are opt-in and auto-detected (CosyVoice 3, GPT-SoVITS, VoxCPM2, MOSS-TTS-Nano, KittenTTS, MLX-Audio, Sherpa-ONNX), plus eight lazy-installed opt-ins (IndexTTS 2.5, OmniVoice GGUF, OmniVoice subprocess, PocketTTS, Supertonic 3, MOSS-TTS-v1.5, dots.tts, Confucius4-TTS). Switch in **Model Catalogue → Engines** — or from anywhere with <kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>E</kbd>; the choice applies everywhere synthesis happens.
|
||||
**14 engines, one picker.** VoiceStudio (default, 600+ languages) is always available; seven more are opt-in and auto-detected (CosyVoice 3, GPT-SoVITS, VoxCPM2, MOSS-TTS-Nano, KittenTTS, MLX-Audio, Sherpa-ONNX), plus six lazy-installed heavyweights (IndexTTS 2.5, OmniVoice GGUF, Supertonic 3, MOSS-TTS-v1.5, dots.tts, Confucius4-TTS). Switch in **Settings → TTS Engine**; the choice applies everywhere synthesis happens.
|
||||
|
||||
<details>
|
||||
<summary><b>📊 The full matrix</b> — 16 engines × platform × clone/instruct × license</summary>
|
||||
<summary><b>📊 The full matrix</b> — 14 engines × platform × clone/instruct × license</summary>
|
||||
|
||||
<br/>
|
||||
|
||||
@@ -230,8 +207,6 @@ Professional-grade voice AI, minus the subscription and the cloud. Convinced? [C
|
||||
| **Sherpa-ONNX** | 20+ | — | — | ✅ CUDA/CPU | ✅ CPU | ✅ CUDA/CPU | Apache-2.0 |
|
||||
| **IndexTTS 2.5** ⚡ | ZH · EN · JA · ES · AR | ✅ | — | ✅ CUDA | — | ✅ CUDA | Bilibili model license¹ |
|
||||
| **OmniVoice GGUF** ⚡ | 600+ | ✅ | ✅ | ✅ CPU | ✅ CPU | ✅ CPU | Built-in |
|
||||
| **OmniVoice (subprocess)** ⚡² | 600+ | ✅ | ✅ | ✅ CUDA/CPU | ✅ MPS | ✅ CUDA/CPU | Built-in |
|
||||
| **PocketTTS** ⚡ (Kyutai) | EN · FR · DE · PT · IT · ES | ✅ | — | ✅ CPU | ✅ CPU | ✅ CPU | CC-BY-4.0 (gated)³ |
|
||||
| **Supertonic 3** ⚡ | 31 | — | — | ✅ CPU | ✅ CPU | ✅ CPU | OpenRAIL-M |
|
||||
| **MOSS-TTS-v1.5** ⚡ (8B) | 31 | ✅ | — | ✅ CUDA/CPU | ✅ CPU | ✅ CUDA/CPU | Apache-2.0 |
|
||||
| **dots.tts** ⚡ (2B) | 24 | ✅ | — | ✅ CUDA/CPU | ✅ CPU | ❌ | Apache-2.0 |
|
||||
@@ -242,21 +217,12 @@ monthly active users or RMB 1 billion in annual revenue. Review its
|
||||
[model license](https://huggingface.co/IndexTeam/IndexTTS-2.5/blob/main/LICENSE)
|
||||
before enabling the optional sidecar.
|
||||
|
||||
² **OmniVoice (subprocess)** is the same resident model as the default engine, run
|
||||
in a crash-isolated child process: a wedged generation can be hard-killed and its
|
||||
VRAM reclaimed. Opt-in for unattended synthesis and VRAM-tight MPS hosts —
|
||||
[docs/engines/omnivoice-subprocess.md](docs/engines/omnivoice-subprocess.md).
|
||||
|
||||
³ **PocketTTS** (Kyutai) is a fast, low-latency CPU engine with zero-shot cloning;
|
||||
its gated model access and CC-BY-4.0 conditions are shown for review in-app before
|
||||
first use.
|
||||
|
||||
GPT-SoVITS connects to `http://127.0.0.1:9880` by default. To use a server on
|
||||
another machine, set `OMNIVOICE_GPTSOVITS_URL` to its credential-free
|
||||
`http://` or `https://` origin and add that machine's CIDR to
|
||||
`OMNIVOICE_TRUSTED_NETWORKS`; redirects and untrusted destinations are rejected.
|
||||
|
||||
> **CUDA** = GPU-accelerated · **MPS** = Apple Silicon Metal · **CPU** = runs everywhere, slower for large models · KittenTTS, MOSS-TTS-Nano, and PocketTTS run realtime on CPU · MLX-Audio is Apple Silicon only · ⚡ = lazy-registered (installed on first use)
|
||||
> **CUDA** = GPU-accelerated · **MPS** = Apple Silicon Metal · **CPU** = runs everywhere, slower for large models · KittenTTS and MOSS-TTS-Nano run realtime on CPU · MLX-Audio is Apple Silicon only · ⚡ = lazy-registered (installed on first use)
|
||||
>
|
||||
> **Clone** matters beyond single-clip generation: Video Dubbing (and any Batch job with a pinned voice) needs reference-audio cloning to preserve speaker identity, so picking a Clone-less engine (KittenTTS, Sherpa-ONNX, Supertonic 3) as the active engine fails those jobs up front with an actionable message instead of silently falling back to VoiceStudio.
|
||||
>
|
||||
@@ -283,15 +249,17 @@ another machine, set `OMNIVOICE_GPTSOVITS_URL` to its credential-free
|
||||
| **MLX Whisper** | `mlx-whisper` | ~100 | Native Apple Silicon speed (Apple MLX / Metal) |
|
||||
| **PyTorch Whisper** | `pytorch-whisper` | ~100 | CUDA / CPU fallback via 🤗 Transformers (no cuDNN 8 needed) |
|
||||
| **Parakeet TDT** | `nemo-parakeet` | English + 25 EU | SOTA accuracy at ~10× realtime even on CPU, auto language detection (NVIDIA NeMo, CUDA/CPU) |
|
||||
| **Parakeet TDT v3 (MLX)** | `parakeet-mlx` | 25 EU | The Parakeet tier for Apple Silicon — word timestamps, ~2 GB unified memory, dictation-grade speed via MLX. Dictation prefers it automatically for its 25 European languages; other languages keep multilingual Whisper. |
|
||||
| **Parakeet TDT v3 (MLX)** | `parakeet-mlx` | 25 EU | The Parakeet tier for Apple Silicon — TDT word timestamps, ~2 GB unified memory, dictation-grade speed on the GPU via MLX. Install the model from **Model Catalogue → Models** and dictation prefers it automatically when your system language is one of its 25 (European) languages; other languages (CJK, Arabic, …) keep the multilingual Whisper engine so dictation coverage never regresses. |
|
||||
| **Moonshine** | `moonshine` | English | Edge / low-latency, ONNX |
|
||||
| **FunASR** | `funasr` | 50+ | All-in-one multilingual — built-in VAD + inline speaker diarization (SenseVoice) |
|
||||
| **sherpa-onnx** (live dictation) | `sherpa-onnx-asr` | 25 EU + 90+ | Live, faster-than-real-time dictation — small streaming/offline ONNX models, CPU, identical on macOS / Windows / Linux. Picked per-model in **Settings → Voice**. |
|
||||
| **OpenAI-compatible** ⚠️ remote | `openai-compat-asr` | Server-dependent | A path to **Qwen3-ASR** today (self-hosted server), any OpenAI-compatible transcription endpoint, or OpenAI's own API — configure + test in **Model Catalogue → Engines** (ASR tab). Audio leaves your machine to whatever server you point it at; see [docs/engines/openai-compatible-asr.md](docs/engines/openai-compatible-asr.md). |
|
||||
| **sherpa-onnx** (live dictation) | `sherpa-onnx-asr` | 25 EU + 90+ | Live, faster-than-real-time dictation — small streaming/offline ONNX models (Parakeet TDT v3/v2, streaming Zipformer & Paraformer, Whisper Tiny), CPU, identical on macOS / Windows / Linux. Picked per-model in **Settings → Voice**. |
|
||||
| **OpenAI-compatible** ⚠️ remote | `openai-compat-asr` | Server-dependent | A path to **Qwen3-ASR** today (self-hosted server, no transformers wait), any OpenAI-compatible transcription endpoint, or OpenAI's own API — no install, configure + test the connection in **Model Catalogue → Engines** (ASR tab). Audio leaves your machine to whatever server you point it at; see [docs/engines/openai-compatible-asr.md](docs/engines/openai-compatible-asr.md). |
|
||||
|
||||
> Whisper-family engines cover ~100 languages; **FunASR / SenseVoice** adds an all-in-one multilingual path with built-in voice-activity detection and inline speaker diarization. **sherpa-onnx** powers the live dictation model picker — you talk and text appears as you speak. Every engine runs on-device — no API keys, no cloud.
|
||||
|
||||
> If Dubbing needs an ASR model that is not installed yet, it offers the recommended download in place, shows its progress, and retries transcription on the same job when the model is ready.
|
||||
>
|
||||
> **GPU without efficient float16?** On older NVIDIA GPUs (Maxwell/Pascal, GTX 16xx) or after a CTranslate2/cuDNN mismatch, the CTranslate2 ASR engines (WhisperX, Faster-Whisper) can't run `float16` and VoiceStudio automatically retries on `int8` — no config needed. If transcription still fails, pin the compute type with `ASR_COMPUTE_TYPE=int8` (or `float32` for CPU) and restart the backend.
|
||||
|
||||
> **GPU without efficient float16?** On older NVIDIA GPUs (Maxwell/Pascal, GTX 16xx) or after a CTranslate2/cuDNN mismatch, the CTranslate2 ASR engines (WhisperX, Faster-Whisper) can't run `float16` and VoiceStudio automatically retries on `int8` — no config needed. If transcription still fails, pin the compute type with the `ASR_COMPUTE_TYPE` env var (escape hatch): `ASR_COMPUTE_TYPE=int8` (or `float32` for CPU). Set it to `int8` and restart the backend.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -299,7 +267,7 @@ another machine, set `OMNIVOICE_GPTSOVITS_URL` to its credential-free
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
A **Tauri v2** desktop shell (Rust) wraps a **React** UI and a bundled **Python/FastAPI** backend that runs as a local sidecar on `localhost:3900`. Every layer runs on your machine by default; the only network paths are the ones you opt into (remote GPU workers, a remote backend, or an OpenAI-compatible ASR endpoint).
|
||||
A **Tauri v2** desktop shell (Rust) wraps a **React** UI and a bundled **Python/FastAPI** backend that runs as a local sidecar on `localhost:3900`. Nothing external — every layer is on your machine.
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────┐
|
||||
@@ -309,24 +277,28 @@ A **Tauri v2** desktop shell (Rust) wraps a **React** UI and a bundled **Python/
|
||||
│ first-run bootstrap (installs uv + Python venv) · blank guard │
|
||||
├────────────────────────────────────────────────────────────────────┤
|
||||
│ Frontend — React + Vite │
|
||||
│ Studio · Dub · Stories · Audiobook · Gallery · Catalogue · │
|
||||
│ Dictation · Batch · Diagnostics — Zustand store · WS bus │
|
||||
│ Studio · Dub · Stories · Audiobook · Gallery · Dictation · │
|
||||
│ Batch · Diagnostics · MCP client — Zustand store · WS bus │
|
||||
│ ▲ IPC / HTTP + WS │
|
||||
├──────────────────────────┼─────────────────────────────────────────┤
|
||||
│ Backend — FastAPI sidecar @ localhost:3900 │
|
||||
│ 100+ REST endpoints · SSE + WebSocket streaming · │
|
||||
│ SQLite + Alembic (omnivoice_data/) · OpenAI-compatible API │
|
||||
├───────────┬───────────┬───────────┬───────────┬────────────────────┤
|
||||
│ TTS ×16 │ ASR ×11 │ Demucs │ Pyannote │ AudioSeal │
|
||||
│ TTS ×14 │ ASR ×11 │ Demucs │ Pyannote │ AudioSeal │
|
||||
│ clone / │ WhisperX │ vocal │ speaker │ watermark │
|
||||
│ design │ +10 more │ isolation│ diariz. │ embed / detect │
|
||||
├───────────┴───────────┴───────────┴───────────┴────────────────────┤
|
||||
│ Engine routing — per-engine GPU preflight, no silent CPU fallback │
|
||||
│ Hardware: CUDA · MPS · ROCm (Linux) · CPU (auto-detected) │
|
||||
│ + optional remote GPU workers on your other machines │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Shell (Rust)** — native OS integration: the system-wide dictation hotkey, tray, signed auto-updater (stable + preview channels), single-instance lock, and the first-run bootstrap that installs `uv` and a Python 3.11 venv.
|
||||
- **Frontend (React)** — every workspace tab over a Zustand store, with a WebSocket event bus that live-refreshes the UI when backend data changes.
|
||||
- **Backend (FastAPI)** — the bundled Python sidecar: 100+ endpoints, SSE/WSS streaming, a SQLite DB migrated by Alembic, and the OpenAI-compatible API surface.
|
||||
- **Engines** — 14 TTS + 11 ASR, plus Demucs (isolation), Pyannote (diarization), and AudioSeal (watermark), all behind routing that GPU-preflights each engine and refuses to silently fall back to CPU.
|
||||
|
||||
<a id="openai-api"></a>
|
||||
|
||||
## 🔌 OpenAI-compatible API
|
||||
@@ -346,17 +318,28 @@ Your existing scripts, agents, and OpenAI/ElevenLabs SDK calls now run **locally
|
||||
|
||||
| Endpoint | What it does |
|
||||
|---|---|
|
||||
| `POST /v1/audio/speech` | TTS — text in; `mp3` / `opus` / `aac` / `flac` / `wav` / `pcm` out. `model`: `tts-1`/`tts-1-hd` (active engine) or a specific one (`voxcpm2`, `cosyvoice`, …). `voice`: a cloned profile ID, `default`, or an OpenAI name (`alloy`, …). `speed` supported. |
|
||||
| `POST /v1/audio/speech` | TTS — text in; `mp3` / `opus` / `aac` / `flac` / `wav` / `pcm` out. `model`: `tts-1`/`tts-1-hd` (active engine) or a specific one (`voxcpm2`, `cosyvoice`, `kittentts`, …). `voice`: a cloned profile ID, `default`, or an OpenAI name (`alloy`, …). `speed` supported. |
|
||||
| `POST /v1/audio/transcriptions` | STT — audio file in; `json` / `text` / `verbose_json` / `srt` / `vtt` out (`verbose_json` adds word-level timings). `whisper-1` maps to your active ASR engine. |
|
||||
| `GET /v1/audio/voices` | VoiceStudio extension — lists every voice profile and engine, so clients can discover your clones. |
|
||||
|
||||
**Speak with your own cloned voice:**
|
||||
**Speak with your own cloned voice** — list the IDs, then pass one as `voice`:
|
||||
|
||||
```sh
|
||||
# 1 — find a cloned voice's profile ID
|
||||
curl -s http://localhost:3900/v1/audio/voices | jq '.voices[] | select(.type=="profile") | {voice_id, name}'
|
||||
|
||||
# 2 — synthesize with it
|
||||
curl http://localhost:3900/v1/audio/speech \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model":"tts-1","voice":"<profile-id>","input":"Made on my own hardware.","response_format":"wav"}' \
|
||||
--output speech.wav
|
||||
```
|
||||
|
||||
```python
|
||||
from openai import OpenAI
|
||||
client = OpenAI(base_url="http://localhost:3900/v1", api_key="none") # any string — nothing checks it
|
||||
|
||||
# Find your cloned voices: GET /v1/audio/voices lists profile IDs
|
||||
# TTS with your cloned voice (or "alloy" / "default"; model= can pin a specific engine)
|
||||
with client.audio.speech.with_streaming_response.create(
|
||||
model="tts-1", voice="<profile-id>", input="Made on my own hardware.") as r:
|
||||
r.stream_to_file("speech.wav")
|
||||
@@ -367,13 +350,13 @@ print(client.audio.transcriptions.create(model="whisper-1", file=open("clip.wav"
|
||||
|
||||
Want the whole surface (100+ endpoints)? The full REST API reference is embedded in the app — **Settings → OpenAPI Reference** (Scalar-powered), or the `{}` button in the footer.
|
||||
|
||||
Calling the backend from **another machine** (LAN, Tailscale, behind a proxy)? It's loopback-only and unauthenticated by default; to reach it remotely you set a share PIN or an API key, and admin actions require the key — exchanged for short-lived scoped sessions. [docs/api-auth.md](docs/api-auth.md) covers the exact headers, query params, `401`/`403`/`429` meanings, and the `OMNIVOICE_TRUSTED_NETWORKS` exemption.
|
||||
Calling the backend from **another machine** (LAN, Tailscale, behind a proxy)? It's loopback-only and unauthenticated by default; to reach it remotely you set a share PIN or an API key. [docs/api-auth.md](docs/api-auth.md) covers the exact headers, query params, `401`/`403`/`429` meanings, and the `OMNIVOICE_TRUSTED_NETWORKS` exemption.
|
||||
|
||||
### 📓 Run on Google Colab
|
||||
|
||||
[](https://colab.research.google.com/github/debpalash/VoiceStudio/blob/main/notebooks/OmniVoice_Studio_Colab.ipynb)
|
||||
[](https://colab.research.google.com/github/debpalash/VoiceStudio/blob/main/notebooks/VoiceStudio_Studio_Colab.ipynb)
|
||||
|
||||
No local GPU? The [official notebook](notebooks/OmniVoice_Studio_Colab.ipynb) boots the full app — web UI included — on a free Colab T4, then walks the whole feature surface as a guided tour with inline playback. No tunnels, no API keys.
|
||||
No local GPU? The [official notebook](notebooks/VoiceStudio_Studio_Colab.ipynb) boots the full app — web UI included — on a free Colab T4, then walks the whole feature surface (TTS, cloning, design, transcription, dubbing, audiobook, watermarking, the OpenAI-compatible API) as a guided tour with inline playback. No tunnels, no API keys.
|
||||
|
||||
### 🤝 Agent Skills
|
||||
|
||||
@@ -383,15 +366,50 @@ Teach your coding agent to speak and listen through your local VoiceStudio — o
|
||||
npx skills add debpalash/omnivoice-studio
|
||||
```
|
||||
|
||||
Ships two skills: **`omnivoice`** — generate speech (including your cloned voices) and transcribe audio from any agent, free and fully offline — and **`oss-maintainer`** — the maintainer methodology this project is run with.
|
||||
Ships two [skills](https://skills.sh):
|
||||
|
||||
- **`omnivoice`** — generate speech (including your cloned voices) and transcribe audio from any agent, free and fully offline via your local install.
|
||||
- **`oss-maintainer`** — the maintainer methodology this project is run with, for anyone running their own OSS project with an agent.
|
||||
|
||||
---
|
||||
|
||||
<a id="roadmap"></a>
|
||||
|
||||
## 🗺️ Roadmap
|
||||
|
||||
What's up next (lip-sync v2, hosted demo, plugin marketplace, real-time voice changer) and the full history of everything shipped so far live in **[docs/ROADMAP.md](docs/ROADMAP.md)**.
|
||||
### 🔜 Up Next
|
||||
|
||||
- 🎬 **Lip-sync v2** — visual speech timing with wav2lip
|
||||
- 🌐 **Hosted Demo** — try VoiceStudio without installing anything
|
||||
- 🔌 **Plugin Marketplace** — community-contributed TTS engines and effects
|
||||
- 🎵 **Real-time Voice Changer** — live microphone transformation during calls
|
||||
|
||||
<details>
|
||||
<summary><b>✅ Everything shipped so far</b> — the receipts, by category</summary>
|
||||
|
||||
<br/>
|
||||
|
||||
| Category | Features |
|
||||
|----------|----------|
|
||||
| **Longform** | Audiobook editor (text/EPUB/PDF → chaptered .m4b) with multi-voice cast, expressive controls, live per-chapter progress + Stop, and a one-click sample; Stories multi-voice editor, two-pass loudnorm mastering, crash-resume for interrupted renders, pronunciation control + SSML-lite prosody |
|
||||
| **Dubbing** | Full pipeline (transcribe→translate→synthesize→mux), scene-aware splitting, lip-sync scoring, streaming TTS, per-speaker voice assignment, Smart Fit timing + second-pass QC, paste-in translations from any external tool, dedicated Dub home |
|
||||
| **Voice** | Zero-shot cloning, voice design, A/B comparison, voice preview widget, gallery with favorites/tags (its voices selectable in every picker — Studio, Audiobook, Stories, Dubbing), portable persona bundles (`.ovsvoice`), voice console workspace |
|
||||
| **Audio** | Demucs vocal isolation, per-segment gain, selective track export, stem/SRT/VTT/MP3 export, unlimited-length TTS via sentence-chunked generation |
|
||||
| **Multi-Lang** | Translate All preserves the primary language plus every extra language chip; Generate renders and exports one retained track per language with sequential GPU execution |
|
||||
| **Diarization** | Pyannote ML diarization, auto speaker clone extraction, per-speaker voice assignment |
|
||||
| **ASR** | 11 engines (WhisperX, Faster-Whisper, isolated Faster-Whisper, MLX Whisper, PyTorch Whisper, Parakeet TDT, Parakeet TDT v3 MLX, Moonshine, FunASR/SenseVoice, sherpa-onnx live dictation, OpenAI-compatible remote), crash-isolated subprocess backend |
|
||||
| **TTS** | 14 engines (VoiceStudio, CosyVoice 3, GPT-SoVITS, VoxCPM2, MOSS-TTS-Nano, KittenTTS, MLX-Audio, Sherpa-ONNX, + lazy: IndexTTS 2.5, OmniVoice GGUF, Supertonic 3, MOSS-TTS-v1.5, dots.tts, Confucius4-TTS), engine routing with GPU preflight |
|
||||
| **Infra** | Docker deployment, CUDA/MPS/ROCm auto-detect, cuDNN 8 compat, VRAM-aware model offloading, engine routing (no silent CPU fallback), diagnostics suite & error journal, restricted-network mirror support |
|
||||
| **AI Provenance** | AudioSeal invisible watermarking (SynthID-like), video logo overlay, watermark detection API |
|
||||
| **UX** | Undo/redo, keyboard shortcuts, drag-and-drop, session persistence, screen-sized first-run UI scaling, and native WebKitGTK scaling |
|
||||
| **Real-time Events** | WebSocket event bus — instant sidebar refresh on data mutations, exponential backoff reconnect |
|
||||
| **State Management** | Zustand store migration — `uiSlice`, `pillSlice`, `dubSlice`, `generateSlice`, `prefsSlice`, `glossarySlice` |
|
||||
| **Desktop** | Cross-platform Tauri installers (macOS DMG — Apple Silicon; Intel unsupported for the local backend, #889 — Windows MSI, Linux deb/AppImage), auto-update infrastructure, single-instance enforcement, close-to-tray, macOS Gatekeeper fix |
|
||||
| **Dictation** | Global system-wide hotkey (`⌘+⇧+Space`), frameless floating widget, streaming ASR via WebSocket, auto-paste, customizable hotkey, local-LLM transcript refinement |
|
||||
| **Batch Pipeline** | Full batch TTS: extract → transcribe → translate → generate → mix → export, with live progress tracking |
|
||||
| **MCP Server** | VoiceStudio as a local TTS/STT provider for Claude, Cursor, and any MCP client |
|
||||
| **Remote Backend** | Point the desktop UI at a remote backend URL with bearer auth (Tailscale-documented) |
|
||||
| **Reliability** | Stall watchdog on bootstrap splash, per-engine GPU compatibility matrix, actionable errors for non-executable engine binaries, setuptools auto-repair |
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
@@ -411,6 +429,10 @@ One developer, real AI-agent bills. If VoiceStudio is useful to you, chipping in
|
||||
|
||||
<a href="https://paypal.me/palashCoder"><img src="https://img.shields.io/badge/PayPal-Donate-00457C?style=for-the-badge&logo=paypal&logoColor=white" alt="PayPal" /></a>
|
||||
|
||||
<br/><br/>
|
||||
|
||||
<sub>Also from the maker: <a href="https://github.com/debpalash/Opal"><b>Opal</b> 💠</a> · <a href="https://github.com/debpalash/memxt"><b>memxt</b> 🧠</a> — a ⭐ helps too.</sub>
|
||||
|
||||
</div>
|
||||
|
||||
<a id="sponsors"></a>
|
||||
@@ -429,6 +451,8 @@ VoiceStudio is **free** and **AGPL-3.0** — no paid tier, no SaaS revenue. Spon
|
||||
|
||||
</div>
|
||||
|
||||
<sub>💡 GitHub also shows a **Sponsor** button at the top of this repo, wired to the same links via <a href=".github/FUNDING.yml"><code>.github/FUNDING.yml</code></a>.</sub>
|
||||
|
||||
---
|
||||
|
||||
## 💬 Community
|
||||
@@ -437,21 +461,66 @@ VoiceStudio is **free** and **AGPL-3.0** — no paid tier, no SaaS revenue. Spon
|
||||
<a href="https://discord.gg/bzQavDfVV9"><img src="https://img.shields.io/badge/💬_Discord-Join_Community-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Join Discord" /></a>
|
||||
<a href="https://x.com/idebpalash"><img src="https://img.shields.io/badge/𝕏_Follow-for_updates-000000?style=for-the-badge&logo=x&logoColor=white" alt="Follow on X" /></a>
|
||||
<br/>
|
||||
<sub>Release news, setup help, GPU troubleshooting, feature votes, and showing off your dubs. We respond to setup questions within hours, not days.</sub>
|
||||
<sub>We respond to setup questions within hours, not days.</sub>
|
||||
</div>
|
||||
|
||||
<details>
|
||||
<summary><b>What happens in there</b></summary>
|
||||
|
||||
<br/>
|
||||
|
||||
| Channel | What happens there |
|
||||
|---------|--------------------|
|
||||
| `#announcements` | Release news and the big moments — new versions land here first |
|
||||
| `#releases` + `#changelog` | Every build and exactly what's inside it |
|
||||
| `#issues` | Bug reports as forum posts — triaged straight into GitHub issues |
|
||||
| `#ideas` | Feature requests, discussed and voted on |
|
||||
| `#discuss-ideas` | Design talk before things get built |
|
||||
| `#general` | Setup help, GPU troubleshooting, and showing off your dubs |
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<a id="contributing"></a>
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Yes please — bug fixes, new TTS engine adapters, UI improvements, docs, translations. All of it. Start with the **[Contributing Guide](.github/CONTRIBUTING.md)** (setup, code style, PR workflow), browse [good first issues](https://github.com/debpalash/VoiceStudio/labels/good%20first%20issue), or ask in [Discord](https://discord.gg/bzQavDfVV9).
|
||||
Yes please — bug fixes, new TTS engine adapters, UI improvements, docs, translations. All of it.
|
||||
|
||||
- 📖 Read the **[Contributing Guide](.github/CONTRIBUTING.md)** for setup, code style, and PR workflow
|
||||
- 🐛 Browse [good first issues](https://github.com/debpalash/VoiceStudio/labels/good%20first%20issue)
|
||||
- 💬 Join our [Discord](https://discord.gg/bzQavDfVV9) to discuss ideas or ask for help
|
||||
- 𝕏 Follow [@idebpalash](https://x.com/idebpalash) for updates and what's being built next
|
||||
|
||||
---
|
||||
|
||||
## ❓ FAQ
|
||||
|
||||
<details>
|
||||
<summary><b>Is this really as good as ElevenLabs?</b></summary>
|
||||
<br/>
|
||||
Honest answer: <b>it depends on what you're doing.</b>
|
||||
|
||||
<b>Where VoiceStudio is genuinely competitive:</b> voice cloning from a clean reference clip (state-of-the-art open diffusion TTS), language coverage (646 languages vs. their 32), and everything structural — no per-character billing, no usage caps, no audio leaving your machine, full pipeline customizability (14 TTS engines, 11 ASR engines, your choice of translation).
|
||||
|
||||
<b>Where ElevenLabs still wins:</b> out-of-the-box consistency and polish, especially for English TTS. Their one model is heavily tuned; our quality depends on which engine you pick, your hardware, and — for cloning — the reference audio (a dry, close-mic clip clones dramatically better than a noisy or echoey one).
|
||||
|
||||
<b>For dubbing specifically:</b> a dub is a chain — transcription → translation → cloning → synthesis — only as good as its weakest link on <i>your</i> source material. If parts come out incoherent, check the segment table's <i>original</i> text first: when the transcription is already wrong, switch the ASR engine or use cleaner source audio — that's usually the fix, not the voice.
|
||||
|
||||
Try it on your real material — it's free and takes one download. Many users replace ElevenLabs outright; some keep both. Both outcomes are fine with us.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Why doesn't a longer reference clip sound more like me?</b></summary>
|
||||
<br/>
|
||||
Because VoiceStudio's cloning is <b>zero-shot</b>: your clip is a <i>prompt</i> the model conditions on at generation time — it is never trained on. Feeding it 2 hours doesn't teach it your voice; past a short window the extra audio is simply not used. The dubbing pipeline's reference builder targets ~8 s and hard-caps at 15 s (<code>backend/services/speaker_clone.py</code>), and engines cap the prompt themselves (VoxCPM2 trims references to 30 s). This is different from ElevenLabs <i>Professional</i> Voice Cloning, which fine-tunes a model on hours of your audio — that's a training job, not a bigger prompt.
|
||||
|
||||
<b>What actually moves clone quality is the clip, not its length.</b> Zero-shot cloning mirrors the acoustics and delivery of the prompt, so: record 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/>
|
||||
@@ -461,19 +530,7 @@ Yes. MPS acceleration is auto-detected. MLX-optimized Whisper models are availab
|
||||
<details>
|
||||
<summary><b>How much VRAM do I need?</b></summary>
|
||||
<br/>
|
||||
<b>4 GB minimum.</b> With ≤8 GB, the TTS model is automatically offloaded to CPU during transcription. With 8+ GB, everything runs on GPU simultaneously. No GPU at all? CPU mode works — just slower (~3× for TTS). You can also lend a GPU from another machine you own via <a href="docs/remote-workers.md">remote workers</a>.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>What languages are supported?</b></summary>
|
||||
<br/>
|
||||
646 languages for TTS via the VoiceStudio model. Transcription (WhisperX) supports 99 languages. Translation coverage depends on the target language pair.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Why doesn't a longer reference clip sound more like me?</b></summary>
|
||||
<br/>
|
||||
Because VoiceStudio's cloning is <b>zero-shot</b>: your clip is a <i>prompt</i> the model conditions on — it is never trained on, and past a short window extra audio is simply unused (the dubbing pipeline targets ~8 s and hard-caps at 15 s). <b>What moves clone quality is the clip, not its length</b>: record 5–15 seconds of continuous natural speech, close to the mic, in a quiet room with no reverb or music, one speaker, delivered in the tone and pace you want — the clone copies your delivery, not just your timbre. Want trained-on-your-voice fidelity? That's offline fine-tuning, not an in-app button: <a href="docs/data_preparation.md">docs/data_preparation.md</a> + <a href="docs/training.md">docs/training.md</a>.
|
||||
<b>4 GB minimum.</b> With ≤8 GB, the TTS model is automatically offloaded to CPU during transcription. With 8+ GB, everything runs on GPU simultaneously. No GPU at all? CPU mode works — just slower (~3× for TTS).
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -482,10 +539,16 @@ Because VoiceStudio's cloning is <b>zero-shot</b>: your clip is a <i>prompt</i>
|
||||
<b>Yes — commercial use is free</b> under the <a href="https://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0</a>: run it, sell the audio you make, dub client videos, deploy it across your team. One obligation: if you <b>modify</b> VoiceStudio and offer the modified version to others over a network, you must share that modified source under the same terms. Embedding it in a closed-source product instead? A commercial license is available — see <a href="#license">License</a>.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>What languages are supported?</b></summary>
|
||||
<br/>
|
||||
646 languages for TTS via the VoiceStudio model. Transcription (WhisperX) supports 99 languages. Translation coverage depends on the target language pair.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Can I add my own TTS engine?</b></summary>
|
||||
<br/>
|
||||
Yes. Subclass <code>TTSBackend</code> in <code>backend/services/tts_backend.py</code> and add it to the <code>_REGISTRY</code> dictionary — ~50 lines. The sixteen built-in engines all work this way; see <a href="#tts-engines">TTS Engines</a> and <a href="docs/engine-acceptance.md">docs/engine-acceptance.md</a>.
|
||||
Yes. Subclass <code>TTSBackend</code> in <code>backend/services/tts_backend.py</code> and add it to the <code>_REGISTRY</code> dictionary — ~50 lines. The fourteen built-in engines all work this way; see <a href="#tts-engines">TTS Engines</a>.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -493,7 +556,7 @@ Yes. Subclass <code>TTSBackend</code> in <code>backend/services/tts_backend.py</
|
||||
<br/>
|
||||
<b>Not unless you explicitly say yes.</b> On first run the app <i>asks</i> — one screen, two equal-weight buttons, no pre-ticked box — and until you answer yes, VoiceStudio sends nothing: no analytics, no telemetry, no accounts, no phone-home. Skipping the question means no. Your text, audio, voices, and projects never leave your machine either way.
|
||||
|
||||
If you do opt in (also togglable anytime under <b>Settings → Privacy → "Help improve VoiceStudio"</b>), what's sent is anonymous, content-free usage stats: generations (engine, language, generation time, character <i>count</i>, error <i>type</i>), plus app lifecycle — an install ping, updates (version-to-version), crashes (error class and a <i>bucketed</i> uptime, never logs), error <i>types</i> (capped, deduplicated), and a single uninstall ping if you remove it. Never your text, audio, file names, or anything identifying — enforced in code by a property allowlist (<code>backend/core/analytics.py</code>), not just a promise. Every build — installer, Docker, or built from source — asks the same first-run question and stays off unless you say yes. Your own numbers live in <b>Settings → Usage</b>, computed locally, sent nowhere.
|
||||
If you do opt in (also togglable anytime under <b>Settings → Privacy → "Help improve VoiceStudio"</b>), what's sent is anonymous, content-free usage stats: generations (engine, language, generation time, character <i>count</i>, error <i>type</i>), plus app lifecycle — an install ping, updates (version-to-version), crashes (error class and a <i>bucketed</i> uptime, never logs), error <i>types</i> (capped, deduplicated), and a single uninstall ping if you remove it. Never your text, audio, file names, or anything identifying — enforced in code by a property allowlist (<code>backend/core/analytics.py</code>), not just a promise. Every build — installer, Docker, or built from source — asks the same first-run question and stays off unless you say yes (the destination is PostHog's publishable write-only client key; skipping the question means off). Your own numbers live in <b>Settings → Usage</b>, computed locally, sent nowhere.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -520,13 +583,55 @@ The bundled `omnivoice/` TTS model by Han Zhu remains Apache-2.0 upstream. See [
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
|
||||
VoiceStudio stands on exceptional open-source work: [OmniVoice (k2-fsa)](https://github.com/k2-fsa/OmniVoice) — the core zero-shot TTS model · [WhisperX](https://github.com/m-bain/whisperX) · [Demucs](https://github.com/facebookresearch/demucs) · [Pyannote](https://github.com/pyannote/pyannote-audio) · [CTranslate2](https://github.com/OpenNMT/CTranslate2) · [AudioSeal](https://github.com/facebookresearch/audioseal) · [Tauri](https://tauri.app) · [Supertonic](https://huggingface.co/Supertone/supertonic-3) · [Sherpa-ONNX](https://github.com/k2-fsa/sherpa-onnx) · [GPT-SoVITS](https://github.com/RVC-Boss/GPT-SoVITS) · [Kyutai PocketTTS](https://kyutai.org) — thank you.
|
||||
VoiceStudio is built on the shoulders of exceptional open-source work:
|
||||
|
||||
| Project | Role |
|
||||
|---------|------|
|
||||
| [**VoiceStudio (k2-fsa)**](https://github.com/k2-fsa/OmniVoice) | Zero-shot diffusion TTS engine — the core voice synthesis model |
|
||||
| [**WhisperX**](https://github.com/m-bain/whisperX) | Word-level speech recognition and alignment |
|
||||
| [**Demucs (Meta)**](https://github.com/facebookresearch/demucs) | Music source separation for vocal isolation |
|
||||
| [**Pyannote**](https://github.com/pyannote/pyannote-audio) | Speaker diarization — who said what |
|
||||
| [**CTranslate2**](https://github.com/OpenNMT/CTranslate2) | Optimized Transformer inference on CPU and GPU |
|
||||
| [**AudioSeal (Meta)**](https://github.com/facebookresearch/audioseal) | Invisible neural audio watermarking for AI provenance |
|
||||
| [**Tauri**](https://tauri.app) | Native desktop app framework |
|
||||
| [**Supertone / Supertonic 3**](https://huggingface.co/Supertone/supertonic-3) | ONNX TTS engine — 31 languages, CPU-efficient |
|
||||
| [**Sherpa-ONNX**](https://github.com/k2-fsa/sherpa-onnx) | WASM-ready universal TTS/ASR runtime |
|
||||
| [**GPT-SoVITS**](https://github.com/RVC-Boss/GPT-SoVITS) | Zero-shot TTS engine — 5 languages, RTF 0.014 |
|
||||
|
||||
---
|
||||
|
||||
<a id="more-from-the-maker"></a>
|
||||
|
||||
### 🧰 More local open-source from the maker
|
||||
## 🧰 More local open-source from the maker
|
||||
|
||||
[**Opal** 💠](https://github.com/debpalash/Opal) — play everything: the media player for the AI era · [**memxt** 🧠](https://github.com/debpalash/memxt) — local long-term memory for coding agents. Same rule: **your data stays on your machine.**
|
||||
Like the local-first philosophy? It runs in the family — same maker, same rule: **your data stays on your machine.**
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="50%" valign="top">
|
||||
<br/>
|
||||
<a href="https://github.com/debpalash/Opal"><img src="https://raw.githubusercontent.com/debpalash/Opal/main/assets/opal_logo.png" width="96" alt="Opal logo"/></a>
|
||||
<h3><a href="https://github.com/debpalash/Opal">Opal 💠</a></h3>
|
||||
<p><b>Play everything.</b> The media player for the AI era.</p>
|
||||
<p><sub>Video, anime, comics, torrents, Jellyfin & Plex — one player for all of it, with local AI memory and context built in. Written in Zig, runs on macOS & Windows.</sub></p>
|
||||
<p>
|
||||
<a href="https://github.com/debpalash/Opal/stargazers"><img src="https://img.shields.io/github/stars/debpalash/Opal?style=flat-square&color=f59e0b" alt="Opal stars"/></a>
|
||||
<a href="https://palash.dev/opal"><img src="https://img.shields.io/badge/site-palash.dev%2Fopal-8b5cf6?style=flat-square" alt="Opal website"/></a>
|
||||
</p>
|
||||
</td>
|
||||
<td align="center" width="50%" valign="top">
|
||||
<br/>
|
||||
<a href="https://github.com/debpalash/memxt"><img src="https://raw.githubusercontent.com/debpalash/memxt/main/assets/logo-mark.svg" width="96" alt="memxt logo"/></a>
|
||||
<h3><a href="https://github.com/debpalash/memxt">memxt 🧠</a></h3>
|
||||
<p><b>The fastest benchmarked open-source AI memory system.</b></p>
|
||||
<p><sub>Local long-term memory for Claude Code and coding agents — an MCP server on SQLite + embeddings, 100% on your machine. Your agent finally remembers yesterday.</sub></p>
|
||||
<p>
|
||||
<a href="https://github.com/debpalash/memxt/stargazers"><img src="https://img.shields.io/github/stars/debpalash/memxt?style=flat-square&color=f59e0b" alt="memxt stars"/></a>
|
||||
<a href="https://github.com/debpalash/memxt#readme"><img src="https://img.shields.io/badge/docs-README-10b981?style=flat-square" alt="memxt docs"/></a>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
---
|
||||
|
||||
|
||||
+114
-46
@@ -17,21 +17,67 @@ Currently exposed:
|
||||
keep their own inline loopback guards.
|
||||
"""
|
||||
|
||||
import ipaddress
|
||||
import os
|
||||
import secrets
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from core.auth import (
|
||||
CredentialTransport,
|
||||
PrincipalKind,
|
||||
is_local_host,
|
||||
is_loopback,
|
||||
principal_for,
|
||||
remote_api_key,
|
||||
)
|
||||
from core.csrf import SAFE_HTTP_METHODS, cookie_csrf_allowed
|
||||
|
||||
# IPv4 + IPv6 loopback literals + the conventional `localhost` hostname.
|
||||
# `request.client.host` carries an address, not a hostname, so the literal
|
||||
# "localhost" entry is defensive — some upstream wrappers (TestClient with
|
||||
# a custom client tuple, certain reverse-proxy headers) may pass strings
|
||||
# rather than parsed addresses. We accept the broader set without weakening
|
||||
# the guard: nothing here matches a non-loopback origin.
|
||||
_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"})
|
||||
|
||||
|
||||
def _trusted_networks():
|
||||
"""CIDR networks from OMNIVOICE_TRUSTED_NETWORKS (comma-separated) treated as
|
||||
loopback-trusted — e.g. a reverse proxy or self-hosted LAN, so the API-key /
|
||||
PIN gates don't block LAN clients that can't present the credential (a proxy
|
||||
that strips the Authorization header). Read at call time (matching
|
||||
`_server_mode` / `remote_api_key`) so tests can monkeypatch the env; restart
|
||||
to apply changes in production."""
|
||||
nets = []
|
||||
for cidr in os.environ.get("OMNIVOICE_TRUSTED_NETWORKS", "").split(","):
|
||||
cidr = cidr.strip()
|
||||
if cidr:
|
||||
try:
|
||||
nets.append(ipaddress.ip_network(cidr, strict=False))
|
||||
except ValueError:
|
||||
pass # malformed entry ignored — never wedge the auth gate
|
||||
return nets
|
||||
|
||||
|
||||
def is_loopback(host):
|
||||
"""True loopback address only (127.0.0.1, ::1, localhost) — NOT a trusted
|
||||
network. Admin gates (``require_admin`` → ``/system/set-env``,
|
||||
``/api/settings/*``) use this so a trusted-network CIDR exempts consumption
|
||||
(TTS / dictation) but never the RCE-class admin surface."""
|
||||
return host in _LOOPBACK_HOSTS
|
||||
|
||||
|
||||
def is_local_host(host):
|
||||
"""Loopback address, OR on a configured trusted network. The consumption
|
||||
gates (PIN/API-key middleware, WS guard) call this so a trusted LAN/proxy is
|
||||
exempted. Admin gates use :func:`is_loopback` — NOT this — to preserve the
|
||||
two-tier privilege model: consumption trust ≠ admin trust."""
|
||||
if is_loopback(host):
|
||||
return True
|
||||
try:
|
||||
ip = ipaddress.ip_address(host)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
# Unwrap IPv4-mapped IPv6 (::ffff:192.168.1.5) so it matches IPv4 CIDRs —
|
||||
# dual-stack proxies (Caddy, Node.js) frequently pass the mapped form.
|
||||
if getattr(ip, "ipv4_mapped", None):
|
||||
ip = ip.ipv4_mapped
|
||||
return any(ip in net for net in _trusted_networks())
|
||||
|
||||
_TRUTHY = frozenset({"1", "true", "yes", "on"})
|
||||
_READ_ONLY_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
|
||||
|
||||
|
||||
def _server_mode() -> bool:
|
||||
@@ -54,6 +100,34 @@ def _server_mode() -> bool:
|
||||
return os.environ.get("OMNIVOICE_SERVER_MODE", "").strip().lower() in _TRUTHY
|
||||
|
||||
|
||||
def remote_api_key() -> str | None:
|
||||
"""The normalized remote-backend bearer key, or None when remote mode is
|
||||
off. Surrounding whitespace is configuration noise, never a valid secret.
|
||||
Read at call time so tests can monkeypatch the environment."""
|
||||
return os.environ.get("OMNIVOICE_API_KEY", "").strip() or None
|
||||
|
||||
|
||||
def presented_api_key(connection) -> str:
|
||||
"""Return the first non-empty normalized API key on an HTTP/WS connection.
|
||||
|
||||
Authorization wins over query, which wins over cookie. Each channel is
|
||||
stripped before fallback so whitespace in a higher-priority channel cannot
|
||||
shadow a valid lower-priority credential.
|
||||
"""
|
||||
headers = getattr(connection, "headers", None) or {}
|
||||
query = getattr(connection, "query_params", None) or {}
|
||||
cookies = getattr(connection, "cookies", None) or {}
|
||||
|
||||
auth = headers.get("authorization", "")
|
||||
supplied = auth[7:].strip() if auth.lower().startswith("bearer ") else ""
|
||||
if supplied:
|
||||
return supplied
|
||||
supplied = (query.get("api_key") or "").strip()
|
||||
if supplied:
|
||||
return supplied
|
||||
return (cookies.get("ov_key") or "").strip()
|
||||
|
||||
|
||||
def _configured_pin(request) -> str | None:
|
||||
"""The active share PIN (``app.state.network_share.pin``) or None. Read via
|
||||
getattr so a bare Request stub (or a request that hit before lifespan set
|
||||
@@ -76,34 +150,25 @@ def _admin_credential_configured(request) -> bool:
|
||||
return bool(_configured_pin(request))
|
||||
|
||||
|
||||
def _request_presents_admin_credential(
|
||||
request,
|
||||
*,
|
||||
side_effectful_get: bool = False,
|
||||
) -> bool:
|
||||
"""Whether the canonical principal carries remote admin capability.
|
||||
def _request_presents_admin_credential(request) -> bool:
|
||||
"""Whether the request carries a valid **API key** via the channels the
|
||||
middleware accepts (``Authorization: Bearer`` / ``?api_key`` / ``ov_key``
|
||||
cookie).
|
||||
|
||||
API-key and short-lived session principals may unlock server-mode admin.
|
||||
PIN and trusted-network principals remain consumption-only.
|
||||
"""
|
||||
principal = principal_for(request)
|
||||
if principal.kind not in {
|
||||
PrincipalKind.API_KEY,
|
||||
PrincipalKind.ADMIN_SESSION,
|
||||
}:
|
||||
Admin is RCE-class (``/system/set-env`` + ``/api/settings/*``), so only the
|
||||
API key — a long operator-chosen secret — unlocks it. The 6-digit share PIN
|
||||
is deliberately NOT accepted here: it is a *consumption* credential for LAN
|
||||
playback and is short enough to brute-force (10^6, no lockout), so it must
|
||||
never gate the admin surface (CodeRabbit #1213). A trusted-network CIDR
|
||||
(``is_local_host`` — also a consumption exemption) likewise never unlocks
|
||||
admin. Net: remote admin in server mode requires the API key; a PIN-only
|
||||
deployment keeps admin loopback-only. getattr-defensive so a minimal Request
|
||||
stub never raises."""
|
||||
api_key = remote_api_key() or ""
|
||||
if not api_key:
|
||||
return False
|
||||
if principal.transport not in {
|
||||
CredentialTransport.COOKIE,
|
||||
CredentialTransport.LEGACY_COOKIE,
|
||||
}:
|
||||
return True
|
||||
method = str(getattr(request, "method", "GET")).upper()
|
||||
if side_effectful_get or method not in SAFE_HTTP_METHODS:
|
||||
return cookie_csrf_allowed(
|
||||
request,
|
||||
side_effectful_get=side_effectful_get,
|
||||
)
|
||||
return True
|
||||
supplied = presented_api_key(request)
|
||||
return bool(supplied and secrets.compare_digest(supplied, api_key))
|
||||
|
||||
|
||||
def require_loopback(request: Request) -> None:
|
||||
@@ -144,7 +209,7 @@ def require_loopback(request: Request) -> None:
|
||||
return
|
||||
if _server_mode():
|
||||
method = str(getattr(request, "method", "GET")).upper()
|
||||
if method not in SAFE_HTTP_METHODS:
|
||||
if method not in _READ_ONLY_METHODS:
|
||||
# Defense in depth. Privileged routers should declare
|
||||
# ``require_admin`` directly, but a missed migration must not turn
|
||||
# into an unauthenticated Docker write primitive.
|
||||
@@ -175,7 +240,7 @@ def require_admin(request: Request) -> None:
|
||||
return
|
||||
if _server_mode():
|
||||
method = str(getattr(request, "method", "GET")).upper()
|
||||
read_only = method in SAFE_HTTP_METHODS
|
||||
read_only = method in _READ_ONLY_METHODS
|
||||
if read_only and not _admin_credential_configured(request):
|
||||
return
|
||||
if _request_presents_admin_credential(request):
|
||||
@@ -193,10 +258,7 @@ def require_admin_action(request: Request) -> None:
|
||||
host = request.client.host if request.client else None
|
||||
if is_loopback(host):
|
||||
return
|
||||
if _server_mode() and _request_presents_admin_credential(
|
||||
request,
|
||||
side_effectful_get=True,
|
||||
):
|
||||
if _server_mode() and _request_presents_admin_credential(request):
|
||||
return
|
||||
raise HTTPException(status_code=403, detail="loopback origin or admin API key required")
|
||||
|
||||
@@ -245,8 +307,14 @@ def require_native_access(request: Request) -> None:
|
||||
|
||||
|
||||
def ws_remote_authorized(websocket) -> bool:
|
||||
"""Whether the canonical WS principal has a remote admin credential."""
|
||||
return principal_for(websocket).kind in {
|
||||
PrincipalKind.API_KEY,
|
||||
PrincipalKind.ADMIN_SESSION,
|
||||
}
|
||||
"""Whether a WebSocket handshake presents the remote API key.
|
||||
|
||||
Browser WebSockets cannot set an Authorization header, so the key may
|
||||
arrive as ``?api_key=`` or via the ``ov_key`` cookie that the bearer
|
||||
middleware sets on the first authenticated HTTP request. Returns False
|
||||
when remote mode is off — callers keep their loopback-only behavior.
|
||||
"""
|
||||
key = remote_api_key()
|
||||
if not key:
|
||||
return False
|
||||
return secrets.compare_digest(presented_api_key(websocket), key)
|
||||
|
||||
@@ -26,10 +26,8 @@ Design notes
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
@@ -39,7 +37,6 @@ from fastapi import APIRouter, Body, HTTPException, Query
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from core import archetypes
|
||||
from core.audio_validation import is_playable_wav, resolve_regular_file
|
||||
from core.config import OUTPUTS_DIR, VOICES_DIR
|
||||
from services import gallery
|
||||
|
||||
@@ -72,153 +69,6 @@ def _preview_key(a: dict) -> str:
|
||||
).hexdigest()[:16]
|
||||
|
||||
|
||||
def _design_profile_values(a: dict) -> tuple[str, str]:
|
||||
"""Canonical instruct + complete picker state for a designed archetype."""
|
||||
return a["instruct"], json.dumps(a["attrs"], sort_keys=True)
|
||||
|
||||
|
||||
def _profile_audio_path(ref_audio_path: object) -> Optional[Path]:
|
||||
"""Resolve only a regular, non-symlinked file inside ``VOICES_DIR``."""
|
||||
return resolve_regular_file(VOICES_DIR, ref_audio_path)
|
||||
|
||||
|
||||
def _materialized_audio_is_current(row, a: dict) -> bool:
|
||||
"""Whether an existing row still has the sample described by its metadata."""
|
||||
expected_filename = _profile_audio_filename(row["id"])
|
||||
path = _profile_audio_path(row["ref_audio_path"])
|
||||
return bool(
|
||||
row["ref_audio_path"] == expected_filename
|
||||
and is_playable_wav(path)
|
||||
and row["instruct"] == a["instruct"]
|
||||
and row["language"] == a["language"]
|
||||
and row["ref_text"] == a["sample_script"]
|
||||
and row["seed"] == _PREVIEW_SEED
|
||||
)
|
||||
|
||||
|
||||
def _profile_audio_filename(profile_id: str) -> str:
|
||||
safe_id = (
|
||||
profile_id if re.fullmatch(r"[A-Za-z0-9_-]{1,64}", profile_id or "")
|
||||
else hashlib.sha256(str(profile_id).encode("utf-8")).hexdigest()[:16]
|
||||
)
|
||||
return f"{safe_id}.wav"
|
||||
|
||||
|
||||
def _archetype_personality(a: dict) -> str:
|
||||
return f"archetype:{a['id']}"
|
||||
|
||||
|
||||
def _legacy_archetype_profile(conn, a: dict):
|
||||
"""Adopt only a row that an older archetype materializer could have made."""
|
||||
row = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE personality=? LIMIT 1",
|
||||
(a["id"],),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
expected_audio = _profile_audio_filename(row["id"])
|
||||
try:
|
||||
states_match = (
|
||||
not row["vd_states"] or json.loads(row["vd_states"]) == a["attrs"]
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
states_match = False
|
||||
if (
|
||||
row["ref_audio_path"] == expected_audio
|
||||
and row["instruct"] == a["instruct"]
|
||||
and row["language"] == a["language"]
|
||||
and row["ref_text"] == a["sample_script"]
|
||||
and row["seed"] == _PREVIEW_SEED
|
||||
and row["kind"] in (None, "", "clone", "design")
|
||||
and not row["is_locked"]
|
||||
and not row["verified_own_voice"]
|
||||
and states_match
|
||||
):
|
||||
return row
|
||||
return None
|
||||
|
||||
|
||||
def _is_materialized_archetype_row(row, a: dict) -> bool:
|
||||
"""Recognize rows owned by this materializer without trusting identity text alone."""
|
||||
try:
|
||||
states_match = json.loads(row["vd_states"]) == a["attrs"]
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return bool(
|
||||
row["personality"] == _archetype_personality(a)
|
||||
and row["kind"] == "design"
|
||||
and row["seed"] == _PREVIEW_SEED
|
||||
and row["ref_audio_path"] == _profile_audio_filename(row["id"])
|
||||
and row["instruct"] == a["instruct"]
|
||||
and row["language"] == a["language"]
|
||||
and row["ref_text"] == a["sample_script"]
|
||||
and states_match
|
||||
and not row["is_locked"]
|
||||
and not row["verified_own_voice"]
|
||||
)
|
||||
|
||||
|
||||
def _existing_archetype_profile(conn, a: dict):
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE personality=? ORDER BY created_at, id",
|
||||
(_archetype_personality(a),),
|
||||
).fetchall()
|
||||
owned = next((row for row in rows if _is_materialized_archetype_row(row, a)), None)
|
||||
return owned if owned is not None else _legacy_archetype_profile(conn, a)
|
||||
|
||||
|
||||
async def _render_profile_audio(
|
||||
a: dict, profile_id: str, *, publish: bool = True,
|
||||
) -> tuple[str, Path]:
|
||||
"""Render one validated sample, optionally staging it for a later CAS."""
|
||||
audio_filename = _profile_audio_filename(profile_id)
|
||||
safe_id = Path(audio_filename).stem
|
||||
audio_path = Path(VOICES_DIR) / audio_filename
|
||||
if publish:
|
||||
await _render_wav_atomic(a, audio_path, prefix=f".{safe_id}-")
|
||||
else:
|
||||
audio_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
audio_path = audio_path.parent / f".{safe_id}-{uuid.uuid4().hex}.staged.wav"
|
||||
try:
|
||||
await _render_archetype_wav(a, audio_path)
|
||||
if not is_playable_wav(audio_path):
|
||||
raise RuntimeError("the voice engine produced an invalid WAV")
|
||||
except BaseException:
|
||||
with __import__("contextlib").suppress(OSError):
|
||||
audio_path.unlink()
|
||||
raise
|
||||
return audio_filename, audio_path
|
||||
|
||||
|
||||
async def _render_wav_atomic(a: dict, out_path: Path, *, prefix: str = ".render-") -> Path:
|
||||
"""Render and validate a WAV before atomically replacing *out_path*."""
|
||||
audio_path = Path(out_path)
|
||||
audio_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp_path = audio_path.parent / f"{prefix}{uuid.uuid4().hex}.wav"
|
||||
try:
|
||||
await _render_archetype_wav(a, tmp_path)
|
||||
if not is_playable_wav(tmp_path):
|
||||
raise RuntimeError("the voice engine produced an invalid WAV")
|
||||
os.replace(tmp_path, audio_path)
|
||||
finally:
|
||||
with __import__("contextlib").suppress(OSError):
|
||||
tmp_path.unlink()
|
||||
return audio_path
|
||||
|
||||
|
||||
def _heal_materialized_profile(conn, row, a: dict, audio_filename: str) -> None:
|
||||
"""Repair profiles created before archetype `/use` persisted design kind."""
|
||||
instruct, vd_states = _design_profile_values(a)
|
||||
conn.execute(
|
||||
"UPDATE voice_profiles SET kind='design', instruct=?, vd_states=?, language=?, "
|
||||
"ref_text=?, seed=?, ref_audio_path=?, personality=? WHERE id=?",
|
||||
(
|
||||
instruct, vd_states, a["language"], a["sample_script"], _PREVIEW_SEED,
|
||||
audio_filename, _archetype_personality(a), row["id"],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# A non-empty script is always required — synthesizing empty text yields
|
||||
# silence. Every archetype carries a use-case script, but guard the render path
|
||||
# too so a malformed archetype can never drive a blank render.
|
||||
@@ -405,7 +255,7 @@ def _preview_source(a: dict) -> tuple[str, str]:
|
||||
"Pre-rendered preview from the voice gallery — a fixed reference "
|
||||
"rendering, not a render from your current engine."
|
||||
)
|
||||
if is_playable_wav(_PREVIEW_DIR / f"{key}.wav"):
|
||||
if (_PREVIEW_DIR / f"{key}.wav").exists():
|
||||
return "cached", ""
|
||||
if _no_voice_model_downloaded():
|
||||
return "no_model", (
|
||||
@@ -538,9 +388,9 @@ async def preview_archetype(
|
||||
)
|
||||
|
||||
cache_path = _PREVIEW_DIR / f"{key}.wav"
|
||||
if not is_playable_wav(cache_path):
|
||||
if not cache_path.exists():
|
||||
try:
|
||||
await _render_wav_atomic(a, cache_path, prefix=".preview-")
|
||||
await _render_archetype_wav(a, cache_path)
|
||||
except Exception as e: # model missing / OOM / inference failure
|
||||
logger.error("Archetype preview render failed", exc_info=True)
|
||||
# Two different failures, two different answers. Without a model
|
||||
@@ -592,122 +442,70 @@ async def use_archetype(archetype_id: str, name: Optional[str] = Query(None)):
|
||||
# Idempotent (dedup): an archetype materializes to exactly ONE voice profile.
|
||||
# Picking the same gallery voice again — from any picker (Gallery grid,
|
||||
# VoiceSelector, …) — must reuse that one row instead of rendering + inserting
|
||||
# a fresh duplicate every time. Use a namespaced personality identity so an
|
||||
# imported persona cannot collide with and be rewritten by an archetype id.
|
||||
# a fresh duplicate every time. The `personality` column already carries the
|
||||
# source archetype id (stamped by the INSERT below), so it's the natural
|
||||
# dedup key; the expensive render + INSERT only run on first use.
|
||||
with db_conn() as conn:
|
||||
existing = _existing_archetype_profile(conn, a)
|
||||
|
||||
profile_id = existing["id"] if existing is not None else str(uuid.uuid4())[:8]
|
||||
audio_path: Optional[Path] = None
|
||||
if existing is not None and _materialized_audio_is_current(existing, a):
|
||||
audio_filename = existing["ref_audio_path"]
|
||||
else:
|
||||
try:
|
||||
audio_filename, audio_path = await _render_profile_audio(
|
||||
a, profile_id, publish=existing is None,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Archetype 'use' render failed", exc_info=True)
|
||||
# Same actionable/diagnostic split as /preview — minus the gallery
|
||||
# suggestion, which cannot help here.
|
||||
if _no_voice_model_downloaded():
|
||||
detail = (
|
||||
"Creating a voice needs the voice model — no voice model is "
|
||||
"downloaded yet. Model Catalogue → Models → Download."
|
||||
)
|
||||
else:
|
||||
detail = (
|
||||
"Couldn't create a voice from this archetype — the voice engine "
|
||||
f"reported: {e}"
|
||||
)
|
||||
raise HTTPException(status_code=503, detail=detail) from e
|
||||
|
||||
existing = conn.execute(
|
||||
"SELECT id, name FROM voice_profiles WHERE personality = ? LIMIT 1",
|
||||
(a["id"],),
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
with db_conn() as conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
current = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id=?", (existing["id"],),
|
||||
).fetchone()
|
||||
owned = _existing_archetype_profile(conn, a)
|
||||
still_owned = current is not None and (
|
||||
owned is not None and owned["id"] == current["id"]
|
||||
)
|
||||
if still_owned:
|
||||
if audio_path is not None:
|
||||
destination = Path(VOICES_DIR) / audio_filename
|
||||
os.replace(audio_path, destination)
|
||||
audio_path = None
|
||||
_heal_materialized_profile(conn, current, a, audio_filename)
|
||||
existing_result = {"profile_id": current["id"], "name": current["name"]}
|
||||
else:
|
||||
existing_result = None
|
||||
if existing_result is not None:
|
||||
event_bus.emit("profiles", {"action": "updated", "id": existing_result["profile_id"]})
|
||||
return existing_result
|
||||
# The row was edited/deleted while rendering. Preserve it and use the
|
||||
# validated staged sample for a fresh canonical materialization.
|
||||
profile_id = str(uuid.uuid4())[:8]
|
||||
audio_filename = _profile_audio_filename(profile_id)
|
||||
destination = Path(VOICES_DIR) / audio_filename
|
||||
if audio_path is None:
|
||||
try:
|
||||
audio_filename, audio_path = await _render_profile_audio(a, profile_id)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Couldn't create a voice from this archetype.",
|
||||
) from e
|
||||
else:
|
||||
os.replace(audio_path, destination)
|
||||
audio_path = destination
|
||||
return {"profile_id": existing["id"], "name": existing["name"]}
|
||||
|
||||
if audio_path is None: # defensive: a new profile always rendered above
|
||||
raise RuntimeError("new archetype profile has no rendered audio")
|
||||
profile_id = str(uuid.uuid4())[:8]
|
||||
audio_filename = f"{profile_id}.wav"
|
||||
audio_path = Path(VOICES_DIR) / audio_filename
|
||||
|
||||
try:
|
||||
await _render_archetype_wav(a, audio_path)
|
||||
except Exception as e:
|
||||
logger.error("Archetype 'use' render failed", exc_info=True)
|
||||
# Same actionable/diagnostic split as /preview — minus the gallery
|
||||
# suggestion, which cannot help here.
|
||||
if _no_voice_model_downloaded():
|
||||
detail = (
|
||||
"Creating a voice needs the voice model — no voice model is "
|
||||
"downloaded yet. Model Catalogue → Models → Download."
|
||||
)
|
||||
else:
|
||||
detail = (
|
||||
"Couldn't create a voice from this archetype — the voice engine "
|
||||
f"reported: {e}"
|
||||
)
|
||||
raise HTTPException(status_code=503, detail=detail)
|
||||
|
||||
profile_name = (name or a["name"]).strip() or a["name"]
|
||||
try:
|
||||
with db_conn() as conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
# Re-check under the write connection right before inserting: a
|
||||
# concurrent /use for the same archetype may have inserted while we
|
||||
# were rendering (the pre-render SELECT above raced). Reuse that row
|
||||
# and drop our just-rendered sample instead of creating a duplicate.
|
||||
# `personality` is not globally UNIQUE, so serialize and re-check.
|
||||
dup = _existing_archetype_profile(conn, a)
|
||||
# (personality is NOT globally unique — marketplace/persona imports
|
||||
# reuse the column — so a UNIQUE index isn't an option; this closes
|
||||
# the realistic window for the single-user desktop app.)
|
||||
dup = conn.execute(
|
||||
"SELECT id, name FROM voice_profiles WHERE personality = ? LIMIT 1",
|
||||
(a["id"],),
|
||||
).fetchone()
|
||||
if dup is not None:
|
||||
duplicate_audio = dup["ref_audio_path"]
|
||||
if not _materialized_audio_is_current(dup, a):
|
||||
duplicate_audio = _profile_audio_filename(dup["id"])
|
||||
_duplicate_path = Path(VOICES_DIR) / duplicate_audio
|
||||
_duplicate_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
os.replace(audio_path, _duplicate_path)
|
||||
audio_path = None
|
||||
_heal_materialized_profile(conn, dup, a, duplicate_audio)
|
||||
with __import__("contextlib").suppress(OSError):
|
||||
if audio_path is not None:
|
||||
os.remove(audio_path)
|
||||
duplicate_result = {"profile_id": dup["id"], "name": dup["name"]}
|
||||
else:
|
||||
duplicate_result = None
|
||||
if duplicate_result is None:
|
||||
instruct, vd_states = _design_profile_values(a)
|
||||
conn.execute(
|
||||
"INSERT INTO voice_profiles "
|
||||
"(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, "
|
||||
"created_at, kind, vd_states) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'design', ?)",
|
||||
(
|
||||
profile_id, profile_name, audio_filename, a["sample_script"],
|
||||
instruct, a["language"], _PREVIEW_SEED,
|
||||
_archetype_personality(a), time.time(), vd_states,
|
||||
),
|
||||
)
|
||||
os.remove(audio_path)
|
||||
return {"profile_id": dup["id"], "name": dup["name"]}
|
||||
conn.execute(
|
||||
"INSERT INTO voice_profiles "
|
||||
"(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, created_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
profile_id, profile_name, audio_filename, a["sample_script"],
|
||||
a["instruct"], a["language"], _PREVIEW_SEED, a["id"], time.time(),
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
with __import__("contextlib").suppress(OSError):
|
||||
if audio_path is not None:
|
||||
os.remove(audio_path)
|
||||
os.remove(audio_path)
|
||||
raise
|
||||
|
||||
if duplicate_result is not None:
|
||||
event_bus.emit("profiles", {"action": "updated", "id": duplicate_result["profile_id"]})
|
||||
return duplicate_result
|
||||
event_bus.emit("profiles", {"action": "created", "id": profile_id})
|
||||
return {"profile_id": profile_id, "name": profile_name}
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
"""Short-lived credentials for the first-party remote administration UI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import threading
|
||||
import time
|
||||
from collections import OrderedDict, deque
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from core.auth import (
|
||||
CredentialTransport,
|
||||
PrincipalKind,
|
||||
authorization_credential_present,
|
||||
legacy_master_cookie_valid,
|
||||
master_header_valid,
|
||||
principal_for,
|
||||
remote_api_key,
|
||||
)
|
||||
from core.csrf import cookie_csrf_allowed, effective_scheme
|
||||
from services.admin_sessions import (
|
||||
SESSION_TTL_SECONDS,
|
||||
WS_TICKET_TTL_SECONDS,
|
||||
admin_session_store,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
_FAILED_EXCHANGE_LIMIT = 10
|
||||
_FAILED_EXCHANGE_WINDOW_SECONDS = 60
|
||||
_MAX_TRACKED_CLIENTS = 1024
|
||||
|
||||
|
||||
class _ExchangeAttemptLimiter:
|
||||
"""Bounded per-client sliding window for failed pre-auth exchanges."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
monotonic: Callable[[], float] = time.monotonic,
|
||||
limit: int = _FAILED_EXCHANGE_LIMIT,
|
||||
window_seconds: int = _FAILED_EXCHANGE_WINDOW_SECONDS,
|
||||
max_clients: int = _MAX_TRACKED_CLIENTS,
|
||||
) -> None:
|
||||
if limit <= 0 or window_seconds <= 0 or max_clients <= 0:
|
||||
raise ValueError("rate-limit bounds must be positive")
|
||||
self._monotonic = monotonic
|
||||
self._limit = limit
|
||||
self._window_seconds = window_seconds
|
||||
self._max_clients = max_clients
|
||||
self._attempts: OrderedDict[str, deque[float]] = OrderedDict()
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def register_failure(self, client_id: str) -> int | None:
|
||||
now = self._monotonic()
|
||||
cutoff = now - self._window_seconds
|
||||
with self._lock:
|
||||
failures = self._attempts.setdefault(client_id, deque())
|
||||
while failures and failures[0] <= cutoff:
|
||||
failures.popleft()
|
||||
self._attempts.move_to_end(client_id)
|
||||
while len(self._attempts) > self._max_clients:
|
||||
self._attempts.popitem(last=False)
|
||||
if len(failures) >= self._limit:
|
||||
return max(
|
||||
1,
|
||||
math.ceil(self._window_seconds - (now - failures[0])),
|
||||
)
|
||||
failures.append(now)
|
||||
return None
|
||||
|
||||
def clear(self, client_id: str) -> None:
|
||||
with self._lock:
|
||||
self._attempts.pop(client_id, None)
|
||||
|
||||
def reset(self) -> None:
|
||||
with self._lock:
|
||||
self._attempts.clear()
|
||||
|
||||
|
||||
_exchange_attempt_limiter = _ExchangeAttemptLimiter()
|
||||
|
||||
|
||||
class SessionRequest(BaseModel):
|
||||
transport: Literal["cookie", "bearer"]
|
||||
|
||||
|
||||
class WebSocketTicketRequest(BaseModel):
|
||||
path: str
|
||||
|
||||
|
||||
def _secure_cookie(request: Request) -> bool:
|
||||
# Same effective-scheme logic as the exact-origin CSRF check: the resolved
|
||||
# scope first (uvicorn's trusted-proxy rewrite), upgraded — never
|
||||
# downgraded — by X-Forwarded-Proto for TLS-terminating proxies uvicorn
|
||||
# doesn't trust (Tailscale Serve into Docker, etc.). Spoofing the header on
|
||||
# a plain-http hop can only ADD the Secure flag, which fails safe: the
|
||||
# browser drops such a cookie, so the spoofer only breaks their own
|
||||
# session. See core.csrf.effective_scheme for the full analysis.
|
||||
return effective_scheme(request) == "https"
|
||||
|
||||
|
||||
def _set_session_cookie(response: Response, request: Request, token: str, expires_at: float) -> None:
|
||||
response.set_cookie(
|
||||
"ov_session",
|
||||
token,
|
||||
max_age=SESSION_TTL_SECONDS,
|
||||
expires=datetime.fromtimestamp(expires_at, tz=UTC),
|
||||
path="/",
|
||||
secure=_secure_cookie(request),
|
||||
httponly=True,
|
||||
samesite="strict",
|
||||
)
|
||||
|
||||
|
||||
def _expire_cookie(response: Response, request: Request, name: str) -> None:
|
||||
response.delete_cookie(
|
||||
name,
|
||||
path="/",
|
||||
secure=_secure_cookie(request),
|
||||
httponly=name == "ov_session",
|
||||
samesite="strict",
|
||||
)
|
||||
|
||||
|
||||
def _client_id(request: Request) -> str:
|
||||
host = request.client.host if request.client else "unknown"
|
||||
return str(host).strip().lower()[:255] or "unknown"
|
||||
|
||||
|
||||
def _reject_master_exchange(request: Request) -> None:
|
||||
retry_after = _exchange_attempt_limiter.register_failure(_client_id(request))
|
||||
if retry_after is not None:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Too many authentication attempts",
|
||||
headers={"Retry-After": str(retry_after)},
|
||||
)
|
||||
raise HTTPException(status_code=401, detail="API key required")
|
||||
|
||||
|
||||
@router.post("/session")
|
||||
def create_session(payload: SessionRequest, request: Request) -> Response:
|
||||
configured = remote_api_key()
|
||||
if not configured:
|
||||
raise HTTPException(status_code=401, detail="API key required")
|
||||
|
||||
authorization_present = authorization_credential_present(request)
|
||||
header_authorized = master_header_valid(request)
|
||||
legacy_authorized = legacy_master_cookie_valid(request)
|
||||
migrating_legacy = False
|
||||
|
||||
if authorization_present:
|
||||
if not header_authorized:
|
||||
_reject_master_exchange(request)
|
||||
elif legacy_authorized:
|
||||
if payload.transport != "cookie" or not cookie_csrf_allowed(request):
|
||||
raise HTTPException(status_code=403, detail="browser origin rejected")
|
||||
migrating_legacy = True
|
||||
else:
|
||||
_reject_master_exchange(request)
|
||||
|
||||
_exchange_attempt_limiter.clear(_client_id(request))
|
||||
issued = admin_session_store.issue(configured)
|
||||
if payload.transport == "bearer":
|
||||
return JSONResponse(
|
||||
{
|
||||
"token": issued.token,
|
||||
"expires_at": issued.expires_at,
|
||||
"expires_in": SESSION_TTL_SECONDS,
|
||||
},
|
||||
status_code=201,
|
||||
)
|
||||
|
||||
response = Response(status_code=204)
|
||||
_set_session_cookie(response, request, issued.token, issued.expires_at)
|
||||
if migrating_legacy or request.cookies.get("ov_key"):
|
||||
_expire_cookie(response, request, "ov_key")
|
||||
return response
|
||||
|
||||
|
||||
@router.delete("/session", status_code=204)
|
||||
def delete_session(request: Request) -> Response:
|
||||
principal = principal_for(request)
|
||||
if principal.kind is PrincipalKind.ADMIN_SESSION:
|
||||
if (
|
||||
principal.transport is CredentialTransport.COOKIE
|
||||
and not cookie_csrf_allowed(request)
|
||||
):
|
||||
raise HTTPException(status_code=403, detail="browser origin rejected")
|
||||
admin_session_store.revoke_by_credential(principal.credential_id)
|
||||
response = Response(status_code=204)
|
||||
_expire_cookie(response, request, "ov_session")
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/ws-ticket")
|
||||
def create_ws_ticket(payload: WebSocketTicketRequest, request: Request) -> JSONResponse:
|
||||
principal = principal_for(request)
|
||||
if principal.kind is not PrincipalKind.ADMIN_SESSION:
|
||||
raise HTTPException(status_code=403, detail="admin session required")
|
||||
if (
|
||||
principal.transport is CredentialTransport.COOKIE
|
||||
and not cookie_csrf_allowed(request)
|
||||
):
|
||||
raise HTTPException(status_code=403, detail="browser origin rejected")
|
||||
try:
|
||||
ticket = admin_session_store.issue_ws_ticket_for_credential(
|
||||
principal.credential_id,
|
||||
payload.path,
|
||||
remote_api_key(),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from None
|
||||
except PermissionError:
|
||||
raise HTTPException(status_code=401, detail="admin session required") from None
|
||||
return JSONResponse(
|
||||
{
|
||||
"ticket": ticket.token,
|
||||
"expires_at": ticket.expires_at,
|
||||
"expires_in": WS_TICKET_TTL_SECONDS,
|
||||
},
|
||||
status_code=201,
|
||||
)
|
||||
@@ -20,26 +20,18 @@ Design / safety
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from urllib.parse import urljoin, urlparse
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from core import archetypes
|
||||
from core.audio_validation import is_playable_wav, resolve_regular_file
|
||||
from core.config import DATA_DIR, VOICES_DIR
|
||||
from core.config import DATA_DIR
|
||||
|
||||
logger = logging.getLogger("omnivoice.community")
|
||||
router = APIRouter()
|
||||
@@ -50,32 +42,9 @@ _ALLOWED_AUDIO_HOSTS = {
|
||||
"cdn.jsdelivr.net", "github.com", "raw.githubusercontent.com",
|
||||
"objects.githubusercontent.com", "release-assets.githubusercontent.com",
|
||||
}
|
||||
_ALLOWED_MANIFEST_HOSTS = {"cdn.jsdelivr.net"}
|
||||
_VALID_TOKENS = set(archetypes._VD._INSTRUCT_ALL_VALID)
|
||||
_USE_CASE_IDS = {c["id"] for c in archetypes.USE_CASES}
|
||||
_SOURCE_RE = re.compile(
|
||||
r"^[A-Za-z0-9._-]{1,100}/[A-Za-z0-9._-]{1,100}$",
|
||||
) # owner/repo only
|
||||
_ITEM_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,128}$")
|
||||
_SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||
|
||||
# A gallery open may touch this loader several times (grid, preview, use). Keep
|
||||
# a successful response for six hours, then revalidate it once. On a network
|
||||
# failure the readable stale copy remains usable and its check time advances,
|
||||
# preventing every offline gallery open from waiting through the same timeout.
|
||||
_MANIFEST_MAX_AGE_S = 6 * 60 * 60
|
||||
_MAX_MANIFEST_BYTES = 4 << 20
|
||||
_MAX_SAMPLE_SCRIPT_CHARS = 2_000
|
||||
_MAX_REF_TEXT_CHARS = 4_000
|
||||
|
||||
# Community voice submissions are documented as short clean WAV clips. The cap
|
||||
# comfortably covers 15 s of uncompressed 96 kHz stereo PCM while preventing a
|
||||
# remote manifest from turning Preview into an unbounded disk/memory download.
|
||||
_MAX_VOICE_AUDIO_BYTES = 32 << 20
|
||||
|
||||
_ATTR_NAMES = (
|
||||
"Gender", "Age", "Pitch", "Style", "EnglishAccent", "ChineseDialect",
|
||||
)
|
||||
_SOURCE_RE = re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$") # owner/repo only
|
||||
|
||||
|
||||
# ── Config: which content repos to load ───────────────────────────────────────
|
||||
@@ -83,18 +52,14 @@ def configured_sources() -> list[str]:
|
||||
"""Gallery sources, in priority order. Env var > config file > default."""
|
||||
env = os.environ.get("OMNIVOICE_GALLERY_SOURCES")
|
||||
if env:
|
||||
sources = [s.strip() for s in env.split(",")]
|
||||
valid = [s for s in sources if _SOURCE_RE.fullmatch(s)]
|
||||
return valid or list(_DEFAULT_SOURCES)
|
||||
return [s.strip() for s in env.split(",") if s.strip()]
|
||||
cfg = Path(DATA_DIR) / "gallery_sources.json"
|
||||
if cfg.exists():
|
||||
try:
|
||||
data = json.loads(cfg.read_text(encoding="utf-8"))
|
||||
srcs = data.get("sources")
|
||||
if isinstance(srcs, list) and srcs:
|
||||
valid = [s for s in srcs if isinstance(s, str) and _SOURCE_RE.fullmatch(s)]
|
||||
if valid:
|
||||
return valid
|
||||
return [str(s) for s in srcs]
|
||||
except Exception:
|
||||
logger.warning("gallery_sources.json unreadable; using default")
|
||||
return list(_DEFAULT_SOURCES)
|
||||
@@ -116,51 +81,9 @@ def _safe_audio_url(url: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _safe_manifest_url(url: str) -> bool:
|
||||
try:
|
||||
parsed = urlparse(url or "")
|
||||
return parsed.scheme == "https" and parsed.hostname in _ALLOWED_MANIFEST_HOSTS
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def normalize_preset_instruct(instruct: str) -> Optional[tuple[str, dict]]:
|
||||
"""Normalize one validator-safe tag per design category.
|
||||
|
||||
Membership in the vocabulary is not enough: ``male, female`` contains two
|
||||
individually valid tokens but the engine rejects the pair as conflicting.
|
||||
Build the frontend's full ``vd_states`` shape at this trust boundary too,
|
||||
so Magic Wand never inherits stale sliders from the previous voice.
|
||||
"""
|
||||
attrs = {name: "Auto" for name in _ATTR_NAMES}
|
||||
normalized: list[str] = []
|
||||
seen_categories: set[int] = set()
|
||||
for raw in re.split("[," + chr(0xFF0C) + "]", str(instruct or "")):
|
||||
token = raw.strip().lower()
|
||||
if not token or token not in _VALID_TOKENS:
|
||||
return None
|
||||
category = archetypes._VD._instruct_category_index(token)
|
||||
if category < 0 or category in seen_categories:
|
||||
return None
|
||||
seen_categories.add(category)
|
||||
|
||||
# The picker represents the universal gender/age/pitch/style axes in
|
||||
# English even for Chinese speech; dialect remains Chinese-only.
|
||||
canonical = archetypes._VD._INSTRUCT_ZH_TO_EN.get(token, token)
|
||||
attrs[_ATTR_NAMES[category]] = canonical
|
||||
normalized.append(canonical)
|
||||
|
||||
if not normalized:
|
||||
return None
|
||||
# Accent and Chinese dialect are separate taxonomy buckets but the engine
|
||||
# deliberately forbids mixing them in a single design.
|
||||
if 4 in seen_categories and 5 in seen_categories:
|
||||
return None
|
||||
return ", ".join(normalized), attrs
|
||||
|
||||
|
||||
def is_valid_instruct(instruct: str) -> bool:
|
||||
return normalize_preset_instruct(instruct) is not None
|
||||
toks = [t.strip() for t in (instruct or "").split(",") if t.strip()]
|
||||
return bool(toks) and all(t in _VALID_TOKENS for t in toks)
|
||||
|
||||
|
||||
def validate_item(raw: dict) -> Optional[dict]:
|
||||
@@ -170,203 +93,62 @@ def validate_item(raw: dict) -> Optional[dict]:
|
||||
it = dict(raw)
|
||||
if it.get("type") not in ("preset", "voice"):
|
||||
return None
|
||||
if not isinstance(it.get("id"), str) or not _ITEM_ID_RE.fullmatch(it["id"]):
|
||||
if not it.get("id") or not it.get("name"):
|
||||
return None
|
||||
if not isinstance(it.get("name"), str) or not it["name"].strip():
|
||||
return None
|
||||
it["name"] = it["name"].strip()[:80]
|
||||
if it.get("use_case") not in _USE_CASE_IDS:
|
||||
return None
|
||||
raw_facets = it.get("facets")
|
||||
if not isinstance(raw_facets, dict):
|
||||
raw_facets = {}
|
||||
language = it.get("language")
|
||||
if not isinstance(language, str) or not language.strip():
|
||||
language = raw_facets.get("lang", "English")
|
||||
it["language"] = language.strip() if isinstance(language, str) and language.strip() else "English"
|
||||
|
||||
facets = dict(raw_facets)
|
||||
if it["type"] == "preset":
|
||||
normalized = normalize_preset_instruct(it.get("instruct", ""))
|
||||
if normalized is None:
|
||||
return None # unknown/conflicting tokens would crash synthesis
|
||||
it["instruct"], it["attrs"] = normalized
|
||||
attrs = it["attrs"]
|
||||
facets.update({
|
||||
"gender": None if attrs["Gender"] == "Auto" else attrs["Gender"],
|
||||
"age": None if attrs["Age"] == "Auto" else attrs["Age"],
|
||||
"pitch": None if attrs["Pitch"] == "Auto" else attrs["Pitch"],
|
||||
"accent": None if attrs["EnglishAccent"] == "Auto" else attrs["EnglishAccent"],
|
||||
"whisper": attrs["Style"] == "whisper",
|
||||
"lang": it["language"],
|
||||
})
|
||||
sample_script = it.get("sample_script")
|
||||
it["sample_script"] = (
|
||||
sample_script.strip()[:_MAX_SAMPLE_SCRIPT_CHARS]
|
||||
if isinstance(sample_script, str) else ""
|
||||
)
|
||||
else:
|
||||
audio = it.get("audio")
|
||||
if not isinstance(audio, dict) or not _safe_audio_url(audio.get("url", "")):
|
||||
return None
|
||||
expected = audio.get("sha256")
|
||||
if expected is not None:
|
||||
expected = str(expected).lower()
|
||||
if not _SHA256_RE.fullmatch(expected):
|
||||
return None
|
||||
audio = {**audio, "sha256": expected}
|
||||
ref_text = audio.get("ref_text")
|
||||
audio = {
|
||||
**audio,
|
||||
"ref_text": (
|
||||
ref_text.strip()[:_MAX_REF_TEXT_CHARS]
|
||||
if isinstance(ref_text, str) else ""
|
||||
),
|
||||
}
|
||||
it["audio"] = audio
|
||||
facets.setdefault("gender", None)
|
||||
facets.setdefault("age", None)
|
||||
facets.setdefault("pitch", None)
|
||||
facets.setdefault("accent", None)
|
||||
facets.setdefault("whisper", False)
|
||||
facets.setdefault("lang", it["language"])
|
||||
it["facets"] = facets
|
||||
if it["type"] == "preset" and not is_valid_instruct(it.get("instruct", "")):
|
||||
return None # would crash synthesis — drop it
|
||||
if it["type"] == "voice" and not _safe_audio_url((it.get("audio") or {}).get("url", "")):
|
||||
return None
|
||||
it.setdefault("facets", {})
|
||||
it.setdefault("icon", archetypes._USE_ICON.get(it["use_case"], "Sparkles"))
|
||||
it.setdefault("language", it.get("facets", {}).get("lang", "English"))
|
||||
it["is_community"] = it.get("source") != "starter"
|
||||
it["preview_url"] = f"/community/items/{it['id']}/preview"
|
||||
return it
|
||||
|
||||
|
||||
def _merge(manifests: list[tuple[str, Optional[dict]]]) -> tuple[list, list]:
|
||||
items, packs, seen = [], [], set()
|
||||
for src, m in manifests:
|
||||
if not isinstance(m, dict):
|
||||
if not m:
|
||||
continue
|
||||
raw_items = m.get("items")
|
||||
for raw in raw_items if isinstance(raw_items, list) else []:
|
||||
for raw in (m.get("items") or []):
|
||||
v = validate_item(raw)
|
||||
if v and v["id"] not in seen:
|
||||
v["_source_repo"] = src
|
||||
seen.add(v["id"])
|
||||
items.append(v)
|
||||
raw_packs = m.get("packs")
|
||||
for p in raw_packs if isinstance(raw_packs, list) else []:
|
||||
for p in (m.get("packs") or []):
|
||||
if isinstance(p, dict):
|
||||
packs.append({**p, "_source_repo": src})
|
||||
return items, packs
|
||||
|
||||
|
||||
def _read_manifest_cache(cache: Path) -> Optional[dict]:
|
||||
try:
|
||||
if cache.stat().st_size > _MAX_MANIFEST_BYTES:
|
||||
return None
|
||||
data = json.loads(cache.read_text(encoding="utf-8"))
|
||||
return data if isinstance(data, dict) else None
|
||||
except (OSError, ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _write_bytes_atomic(path: Path, data: bytes) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=f".{path.name}-", suffix=".part")
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as handle:
|
||||
handle.write(data)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(tmp, path)
|
||||
except BaseException:
|
||||
with contextlib.suppress(OSError):
|
||||
os.unlink(tmp)
|
||||
raise
|
||||
|
||||
|
||||
def _fetch_remote_manifest(source: str, *, client=None) -> dict:
|
||||
"""Fetch one bounded manifest, validating every redirect before request."""
|
||||
import httpx
|
||||
|
||||
if not _SOURCE_RE.fullmatch(source or ""):
|
||||
raise ValueError("invalid gallery source")
|
||||
owned_client = client is None
|
||||
http = client or httpx.Client(timeout=15.0, follow_redirects=False)
|
||||
current_url = _manifest_url(source)
|
||||
payload = bytearray()
|
||||
try:
|
||||
fetched = False
|
||||
for _redirect in range(6):
|
||||
if not _safe_manifest_url(current_url):
|
||||
raise ValueError("gallery manifest URL is not from an allowed host")
|
||||
with http.stream("GET", current_url, follow_redirects=False) as response:
|
||||
if response.status_code in (301, 302, 303, 307, 308):
|
||||
location = response.headers.get("location")
|
||||
next_url = urljoin(current_url, location or "")
|
||||
if not location or not _safe_manifest_url(next_url):
|
||||
raise ValueError("gallery manifest redirected to a disallowed host")
|
||||
current_url = next_url
|
||||
continue
|
||||
response.raise_for_status()
|
||||
length = response.headers.get("content-length")
|
||||
if length:
|
||||
try:
|
||||
declared_length = int(length)
|
||||
except ValueError:
|
||||
declared_length = None
|
||||
if declared_length is not None and declared_length > _MAX_MANIFEST_BYTES:
|
||||
raise ValueError("gallery manifest exceeded the size limit")
|
||||
for chunk in response.iter_bytes():
|
||||
if not chunk:
|
||||
continue
|
||||
if len(payload) + len(chunk) > _MAX_MANIFEST_BYTES:
|
||||
raise ValueError("gallery manifest exceeded the size limit")
|
||||
payload.extend(chunk)
|
||||
fetched = True
|
||||
break
|
||||
if not fetched:
|
||||
raise ValueError("gallery manifest followed too many redirects")
|
||||
finally:
|
||||
if owned_client:
|
||||
http.close()
|
||||
if not payload:
|
||||
raise ValueError("gallery manifest was empty")
|
||||
data = json.loads(payload)
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("gallery manifest is not a JSON object")
|
||||
return data
|
||||
|
||||
|
||||
def _fetch_manifest(
|
||||
source: str, refresh: bool, *, now: Optional[float] = None,
|
||||
) -> Optional[dict]:
|
||||
"""Return a fresh manifest, with a throttled stale-cache offline fallback."""
|
||||
def _fetch_manifest(source: str, refresh: bool) -> Optional[dict]:
|
||||
"""Return a source's manifest from cache, or fetch + cache it. None if both fail."""
|
||||
cache = _cache_path(source)
|
||||
cached = _read_manifest_cache(cache)
|
||||
checked_at = time.time() if now is None else float(now)
|
||||
if not refresh and cached is not None:
|
||||
if not refresh and cache.exists():
|
||||
try:
|
||||
if checked_at - cache.stat().st_mtime < _MANIFEST_MAX_AGE_S:
|
||||
return cached
|
||||
except OSError:
|
||||
pass # treat a stat race as stale and try the source once
|
||||
return json.loads(cache.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
data = _fetch_remote_manifest(source)
|
||||
encoded = json.dumps(
|
||||
data, ensure_ascii=False, separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
if len(encoded) > _MAX_MANIFEST_BYTES:
|
||||
raise ValueError("gallery manifest exceeded the cache size limit")
|
||||
_write_bytes_atomic(cache, encoded)
|
||||
# Tests inject their own clock; production's value equals wall time.
|
||||
os.utime(cache, (checked_at, checked_at))
|
||||
import httpx
|
||||
with httpx.Client(timeout=15.0, follow_redirects=True) as client:
|
||||
resp = client.get(_manifest_url(source))
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
cache.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache.write_text(json.dumps(data), encoding="utf-8")
|
||||
return data
|
||||
except Exception as e: # offline / 404 / bad json
|
||||
logger.warning("manifest fetch failed for %s: %s", source, e)
|
||||
if cached is not None:
|
||||
# This mtime is a last-*check* marker. Advancing it on failure keeps
|
||||
# an offline app responsive while guaranteeing another check after
|
||||
# the bounded freshness interval.
|
||||
with contextlib.suppress(OSError):
|
||||
os.utime(cache, (checked_at, checked_at))
|
||||
return cached
|
||||
if cache.exists():
|
||||
try:
|
||||
return json.loads(cache.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
@@ -432,385 +214,6 @@ def community_submit_url(item_type: str = Query("preset", alias="type"), source:
|
||||
return {"url": f"https://github.com/{src}/issues/new?template={template}"}
|
||||
|
||||
|
||||
def _find_item(items: list[dict], item_id: str) -> dict:
|
||||
if not _ITEM_ID_RE.fullmatch(item_id or ""):
|
||||
raise HTTPException(status_code=404, detail="Item not found in the gallery.")
|
||||
item = next((it for it in items if it["id"] == item_id), None)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail="Item not found in the gallery.")
|
||||
return item
|
||||
|
||||
|
||||
def _canonical_archetype(item: dict) -> Optional[dict]:
|
||||
"""The built-in archetype represented exactly by a marketplace preset."""
|
||||
if item.get("type") != "preset":
|
||||
return None
|
||||
canonical = archetypes.get_archetype(item["id"])
|
||||
if canonical is None:
|
||||
return None
|
||||
if (canonical.get("instruct") != item.get("instruct")
|
||||
or canonical.get("language") != item.get("language")):
|
||||
return None
|
||||
remote_script = (item.get("sample_script") or "").strip()
|
||||
if remote_script and remote_script != (canonical.get("sample_script") or "").strip():
|
||||
return None
|
||||
return canonical
|
||||
|
||||
|
||||
def _preset_preview_path(item: dict) -> Path:
|
||||
fingerprint = hashlib.sha256(
|
||||
json.dumps({
|
||||
"instruct": item.get("instruct"),
|
||||
"language": item.get("language"),
|
||||
"sample_script": item.get("sample_script"),
|
||||
}, sort_keys=True).encode("utf-8")
|
||||
).hexdigest()[:16]
|
||||
return _CACHE_DIR / "previews" / f"{item['id']}-{fingerprint}.wav"
|
||||
|
||||
|
||||
def _voice_audio_fingerprint(item: dict) -> str:
|
||||
audio = item.get("audio") or {}
|
||||
return hashlib.sha256(
|
||||
f"{audio.get('url', '')}|{audio.get('sha256', '')}".encode("utf-8")
|
||||
).hexdigest()[:16]
|
||||
|
||||
|
||||
def _voice_audio_path(item: dict) -> Path:
|
||||
return _CACHE_DIR / "audio" / f"{item['id']}-{_voice_audio_fingerprint(item)}.wav"
|
||||
|
||||
|
||||
async def _render_preset_atomic(item: dict, out_path: Path) -> Path:
|
||||
if is_playable_wav(out_path):
|
||||
return out_path
|
||||
from api.routers.archetypes import _render_archetype_wav
|
||||
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp_name = tempfile.mkstemp(dir=str(out_path.parent), prefix=".preview-", suffix=".wav")
|
||||
os.close(fd)
|
||||
tmp = Path(tmp_name)
|
||||
try:
|
||||
await _render_archetype_wav({
|
||||
"instruct": item["instruct"],
|
||||
"language": item.get("language", "English"),
|
||||
"sample_script": (
|
||||
(item.get("sample_script") or "").strip()
|
||||
or "Hello — this is a preview of this voice."
|
||||
),
|
||||
}, tmp)
|
||||
if not is_playable_wav(tmp):
|
||||
raise RuntimeError("the voice engine produced an invalid preview WAV")
|
||||
os.replace(tmp, out_path)
|
||||
return out_path
|
||||
finally:
|
||||
with contextlib.suppress(OSError):
|
||||
tmp.unlink()
|
||||
|
||||
|
||||
def _download_voice_audio(item: dict, out_path: Path, *, client=None) -> None:
|
||||
"""Stream one allow-listed voice clip into an atomic, size-bounded file."""
|
||||
audio = item.get("audio") or {}
|
||||
url = audio.get("url", "")
|
||||
if not _safe_audio_url(url):
|
||||
raise HTTPException(status_code=400, detail="Voice audio URL is not from an allowed host.")
|
||||
|
||||
import httpx
|
||||
|
||||
owned_client = client is None
|
||||
http = client or httpx.Client(timeout=30.0, follow_redirects=False)
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp_name = tempfile.mkstemp(dir=str(out_path.parent), prefix=".voice-", suffix=".part")
|
||||
total = 0
|
||||
digest = hashlib.sha256()
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as handle:
|
||||
current_url = url
|
||||
downloaded = False
|
||||
for _redirect in range(6):
|
||||
with http.stream("GET", current_url, follow_redirects=False) as response:
|
||||
if response.status_code in (301, 302, 303, 307, 308):
|
||||
location = response.headers.get("location")
|
||||
next_url = urljoin(current_url, location or "")
|
||||
if not location or not _safe_audio_url(next_url):
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="Community voice audio redirected to a disallowed host.",
|
||||
)
|
||||
current_url = next_url
|
||||
continue
|
||||
response.raise_for_status()
|
||||
length = response.headers.get("content-length")
|
||||
if length:
|
||||
try:
|
||||
if int(length) > _MAX_VOICE_AUDIO_BYTES:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="Community voice audio exceeded the download size limit.",
|
||||
)
|
||||
except ValueError:
|
||||
# A non-numeric Content-Length header is the
|
||||
# server's problem, not a reason to refuse the
|
||||
# download — the streamed byte counter below
|
||||
# still enforces the same cap on what actually
|
||||
# arrives.
|
||||
pass
|
||||
for chunk in response.iter_bytes():
|
||||
if not chunk:
|
||||
continue
|
||||
total += len(chunk)
|
||||
if total > _MAX_VOICE_AUDIO_BYTES:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="Community voice audio exceeded the download size limit.",
|
||||
)
|
||||
digest.update(chunk)
|
||||
handle.write(chunk)
|
||||
downloaded = True
|
||||
break
|
||||
if not downloaded:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="Community voice audio followed too many redirects.",
|
||||
)
|
||||
if total == 0:
|
||||
raise HTTPException(status_code=502, detail="Community voice audio was empty.")
|
||||
expected = audio.get("sha256")
|
||||
if expected and digest.hexdigest() != expected:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="Downloaded voice failed its integrity check.",
|
||||
)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
if not is_playable_wav(Path(tmp_name)):
|
||||
raise HTTPException(
|
||||
status_code=502, detail="Community voice audio was not a valid WAV.",
|
||||
)
|
||||
os.replace(tmp_name, out_path)
|
||||
except BaseException:
|
||||
with contextlib.suppress(OSError):
|
||||
os.unlink(tmp_name)
|
||||
raise
|
||||
finally:
|
||||
if owned_client:
|
||||
http.close()
|
||||
|
||||
|
||||
def _cached_voice_audio(item: dict) -> Path:
|
||||
path = _voice_audio_path(item)
|
||||
if is_playable_wav(path):
|
||||
return path
|
||||
with contextlib.suppress(OSError):
|
||||
path.unlink()
|
||||
_download_voice_audio(item, path)
|
||||
return path
|
||||
|
||||
|
||||
def _copy_atomic(source: Path, destination: Path) -> None:
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp_name = tempfile.mkstemp(
|
||||
dir=str(destination.parent), prefix=f".{destination.name}-", suffix=".part",
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as out, source.open("rb") as src:
|
||||
shutil.copyfileobj(src, out)
|
||||
out.flush()
|
||||
os.fsync(out.fileno())
|
||||
os.replace(tmp_name, destination)
|
||||
except BaseException:
|
||||
with contextlib.suppress(OSError):
|
||||
os.unlink(tmp_name)
|
||||
raise
|
||||
|
||||
|
||||
@router.get("/community/items/{item_id}/preview")
|
||||
async def community_preview(
|
||||
item_id: str,
|
||||
local: bool = Query(False, description="Bypass canonical gallery audio after decode failure"),
|
||||
):
|
||||
"""Serve every community preview through the authenticated same-origin API."""
|
||||
_, items, _, _ = await asyncio.to_thread(_load, False)
|
||||
item = _find_item(items, item_id)
|
||||
|
||||
canonical = _canonical_archetype(item)
|
||||
if canonical is not None:
|
||||
# Reuse the signed-gallery/local-render fallback and cache owned by the
|
||||
# canonical endpoint rather than synthesizing the same preset twice.
|
||||
# Delegate in-process: a root-relative HTTP redirect drops supported
|
||||
# reverse-proxy path prefixes such as ``https://host/api``.
|
||||
from api.routers.archetypes import preview_archetype
|
||||
return await preview_archetype(canonical["id"], local=local)
|
||||
|
||||
try:
|
||||
if item["type"] == "preset":
|
||||
path = await _render_preset_atomic(item, _preset_preview_path(item))
|
||||
else:
|
||||
path = await asyncio.to_thread(_cached_voice_audio, item)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.warning("Community preview unavailable (%s)", type(exc).__name__)
|
||||
raise HTTPException(
|
||||
status_code=503, detail="This community voice preview is unavailable right now.",
|
||||
) from exc
|
||||
return FileResponse(
|
||||
path, media_type="audio/wav",
|
||||
headers={"Cache-Control": "no-cache", "X-OmniVoice-Preview-Source": "community"},
|
||||
)
|
||||
|
||||
|
||||
def _profile_fields(item: dict) -> tuple[str, str, Optional[str], Optional[int]]:
|
||||
if item["type"] == "preset":
|
||||
return "design", item["instruct"], json.dumps(item["attrs"]), 42
|
||||
return "clone", "", None, None
|
||||
|
||||
|
||||
def _community_profile_audio_filename(profile_id: str, item: dict) -> str:
|
||||
safe_id = (
|
||||
profile_id if re.fullmatch(r"[A-Za-z0-9_-]{1,64}", profile_id or "")
|
||||
else hashlib.sha256(str(profile_id).encode("utf-8")).hexdigest()[:16]
|
||||
)
|
||||
if item["type"] == "voice":
|
||||
# The manifest URL/checksum fingerprint makes a changed submission
|
||||
# invalidate its already-materialized clone without a schema change.
|
||||
return f"{safe_id}-community-{_voice_audio_fingerprint(item)}.wav"
|
||||
return f"{safe_id}.wav"
|
||||
|
||||
|
||||
def _stored_profile_audio(ref_audio_path: object) -> Optional[Path]:
|
||||
return resolve_regular_file(VOICES_DIR, ref_audio_path)
|
||||
|
||||
|
||||
def _community_audio_is_current(row, item: dict, ref_text: str) -> bool:
|
||||
path = _stored_profile_audio(row["ref_audio_path"])
|
||||
expected_filename = _community_profile_audio_filename(row["id"], item)
|
||||
if row["ref_audio_path"] != expected_filename or not is_playable_wav(path):
|
||||
return False
|
||||
kind, instruct, _vd_states, seed = _profile_fields(item)
|
||||
inputs_match = (
|
||||
row["instruct"] == instruct
|
||||
and row["language"] == item.get("language", "Auto")
|
||||
and row["ref_text"] == ref_text
|
||||
and row["seed"] == seed
|
||||
)
|
||||
if not inputs_match:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
async def _materialize_item_audio(
|
||||
item: dict, profile_id: str, *, publish: bool = True,
|
||||
) -> tuple[str, Path]:
|
||||
"""Copy the current manifest audio, optionally staging it for a later CAS."""
|
||||
audio_filename = _community_profile_audio_filename(profile_id, item)
|
||||
destination = Path(VOICES_DIR) / audio_filename
|
||||
audio_path = destination
|
||||
if not publish:
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
audio_path = destination.parent / f".{Path(audio_filename).stem}-{uuid.uuid4().hex}.staged.wav"
|
||||
if item["type"] == "preset":
|
||||
cached = await _render_preset_atomic(item, _preset_preview_path(item))
|
||||
else:
|
||||
cached = await asyncio.to_thread(_cached_voice_audio, item)
|
||||
await asyncio.to_thread(_copy_atomic, cached, audio_path)
|
||||
return audio_filename, audio_path
|
||||
|
||||
|
||||
def _community_personality(item: dict) -> str:
|
||||
source = item.get("_source_repo")
|
||||
if not isinstance(source, str) or not _SOURCE_RE.fullmatch(source):
|
||||
source = _DEFAULT_SOURCES[0]
|
||||
return f"community:{source}:{item['id']}"
|
||||
|
||||
|
||||
def _is_materialized_community_row(row, item: dict) -> bool:
|
||||
if (
|
||||
row["personality"] != _community_personality(item)
|
||||
or row["is_locked"] or row["verified_own_voice"]
|
||||
):
|
||||
return False
|
||||
if item["type"] == "voice":
|
||||
safe_id = Path(_community_profile_audio_filename(row["id"], item)).name.split(
|
||||
"-community-", 1,
|
||||
)[0]
|
||||
return bool(
|
||||
row["kind"] == "clone"
|
||||
and row["seed"] is None
|
||||
and not row["vd_states"]
|
||||
and row["instruct"] == ""
|
||||
and row["language"] == item.get("language", "Auto")
|
||||
and row["ref_text"] == (item.get("audio") or {}).get("ref_text", "")
|
||||
and re.fullmatch(
|
||||
rf"{re.escape(safe_id)}-community-[0-9a-f]{{16}}\.wav",
|
||||
row["ref_audio_path"] or "",
|
||||
)
|
||||
)
|
||||
try:
|
||||
states = json.loads(row["vd_states"])
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return bool(
|
||||
row["kind"] == "design"
|
||||
and row["seed"] == 42
|
||||
and row["ref_audio_path"] == _community_profile_audio_filename(row["id"], item)
|
||||
and row["instruct"] == item["instruct"]
|
||||
and row["language"] == item.get("language", "Auto")
|
||||
and row["ref_text"] == (item.get("sample_script") or "")
|
||||
and states == item["attrs"]
|
||||
)
|
||||
|
||||
|
||||
def _existing_community_profile(conn, item: dict, personality: str):
|
||||
candidates = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE personality=? ORDER BY created_at, id",
|
||||
(personality,),
|
||||
).fetchall()
|
||||
existing = next(
|
||||
(row for row in candidates if _is_materialized_community_row(row, item)), None,
|
||||
)
|
||||
if existing is not None:
|
||||
return existing
|
||||
# Old builds stored the bare item id. Import formats preserve arbitrary
|
||||
# personality text too, so adopt only the exact shape the old materializer
|
||||
# wrote; otherwise a remote item id could rewrite a user's imported voice.
|
||||
if archetypes.get_archetype(item["id"]) is None:
|
||||
legacy = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE personality=? LIMIT 1",
|
||||
(item["id"],),
|
||||
).fetchone()
|
||||
if legacy is not None:
|
||||
kind, instruct, _vd_states, _seed = _profile_fields(item)
|
||||
ref_text = item.get("sample_script") or (item.get("audio") or {}).get(
|
||||
"ref_text", "",
|
||||
)
|
||||
if (
|
||||
legacy["ref_audio_path"] == f"{legacy['id']}.wav"
|
||||
and legacy["kind"] == kind
|
||||
and legacy["instruct"] == instruct
|
||||
and legacy["language"] == item.get("language", "Auto")
|
||||
and legacy["ref_text"] == ref_text
|
||||
and legacy["seed"] is None
|
||||
and not legacy["vd_states"]
|
||||
and not legacy["is_locked"]
|
||||
and not legacy["verified_own_voice"]
|
||||
):
|
||||
return legacy
|
||||
return None
|
||||
|
||||
|
||||
def _heal_existing_profile(
|
||||
conn, row, item: dict, ref_text: str, personality: str, audio_filename: str,
|
||||
) -> None:
|
||||
kind, instruct, vd_states, seed = _profile_fields(item)
|
||||
conn.execute(
|
||||
"UPDATE voice_profiles SET kind=?, instruct=?, vd_states=?, language=?, "
|
||||
"ref_text=?, seed=?, personality=?, ref_audio_path=? WHERE id=?",
|
||||
(
|
||||
kind, instruct, vd_states, item.get("language", "Auto"), ref_text,
|
||||
seed, personality, audio_filename, row["id"],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/community/items/{item_id}/use")
|
||||
async def community_use(item_id: str, name: Optional[str] = Query(None)):
|
||||
"""Materialize a community item into a reusable voice profile.
|
||||
@@ -820,108 +223,76 @@ async def community_use(item_id: str, name: Optional[str] = Query(None)):
|
||||
``voice_profiles`` row usable everywhere voices are picked.
|
||||
"""
|
||||
_, items, _, _ = await asyncio.to_thread(_load, False)
|
||||
item = _find_item(items, item_id)
|
||||
|
||||
canonical = _canonical_archetype(item)
|
||||
if canonical is not None:
|
||||
from api.routers.archetypes import use_archetype
|
||||
return await use_archetype(canonical["id"], name)
|
||||
item = next((it for it in items if it["id"] == item_id), None)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail="Item not found in the gallery.")
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from core import event_bus
|
||||
from core.db import db_conn
|
||||
from core.config import VOICES_DIR
|
||||
|
||||
ref_text = item.get("sample_script") or (item.get("audio") or {}).get("ref_text", "")
|
||||
personality = _community_personality(item)
|
||||
with db_conn() as conn:
|
||||
existing = _existing_community_profile(conn, item, personality)
|
||||
|
||||
profile_id = existing["id"] if existing is not None else str(uuid.uuid4())[:8]
|
||||
audio_path: Optional[Path] = None
|
||||
if existing is not None and _community_audio_is_current(existing, item, ref_text):
|
||||
audio_filename = existing["ref_audio_path"]
|
||||
else:
|
||||
try:
|
||||
audio_filename, audio_path = await _materialize_item_audio(
|
||||
item, profile_id, publish=existing is None,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Community 'use' failed", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Couldn't add this voice right now.",
|
||||
) from e
|
||||
|
||||
if existing is not None:
|
||||
with db_conn() as conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
current = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id=?", (existing["id"],),
|
||||
).fetchone()
|
||||
owned = _existing_community_profile(conn, item, personality)
|
||||
still_owned = current is not None and (
|
||||
_is_materialized_community_row(current, item)
|
||||
or (owned is not None and owned["id"] == current["id"])
|
||||
)
|
||||
if still_owned:
|
||||
if audio_path is not None:
|
||||
destination = Path(VOICES_DIR) / audio_filename
|
||||
os.replace(audio_path, destination)
|
||||
audio_path = None
|
||||
_heal_existing_profile(
|
||||
conn, current, item, ref_text, personality, audio_filename,
|
||||
)
|
||||
existing_result = {"profile_id": current["id"], "name": current["name"]}
|
||||
else:
|
||||
existing_result = None
|
||||
if existing_result is not None:
|
||||
event_bus.emit("profiles", {"action": "updated", "id": existing_result["profile_id"]})
|
||||
return existing_result
|
||||
profile_id = str(uuid.uuid4())[:8]
|
||||
audio_filename = _community_profile_audio_filename(profile_id, item)
|
||||
destination = Path(VOICES_DIR) / audio_filename
|
||||
if audio_path is None:
|
||||
audio_filename, audio_path = await _materialize_item_audio(item, profile_id)
|
||||
else:
|
||||
os.replace(audio_path, destination)
|
||||
audio_path = destination
|
||||
|
||||
if audio_path is None: # defensive: a new profile always materialized above
|
||||
raise RuntimeError("new community profile has no materialized audio")
|
||||
profile_id = str(uuid.uuid4())[:8]
|
||||
audio_filename = f"{profile_id}.wav"
|
||||
audio_path = Path(VOICES_DIR) / audio_filename
|
||||
profile_name = (name or item["name"]).strip() or item["name"]
|
||||
kind, instruct, vd_states, seed = _profile_fields(item)
|
||||
instruct = item.get("instruct", "") if item["type"] == "preset" else ""
|
||||
ref_text = item.get("sample_script") or (item.get("audio") or {}).get("ref_text", "")
|
||||
|
||||
try:
|
||||
if item["type"] == "preset":
|
||||
from api.routers.archetypes import _render_archetype_wav
|
||||
pseudo = {
|
||||
"instruct": instruct,
|
||||
"language": item.get("language", "English"),
|
||||
"sample_script": ref_text or "Hello — this is a preview of this voice.",
|
||||
}
|
||||
await _render_archetype_wav(pseudo, audio_path)
|
||||
else: # voice — download the reference clip (off the event loop)
|
||||
await asyncio.to_thread(_download_voice_audio, item, audio_path)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Community 'use' failed", exc_info=True)
|
||||
raise HTTPException(status_code=503, detail=f"Couldn't add this voice right now. Error: {e}")
|
||||
|
||||
try:
|
||||
# A community "preset" is a synthetic designed voice (rendered from an
|
||||
# instruct string) → kind='design'; a "voice" carries a real reference
|
||||
# clip → kind='clone'. Setting kind makes the persona-gallery
|
||||
# synthetic-only gating work (§R3) instead of defaulting all imports to
|
||||
# 'clone'.
|
||||
kind = "design" if item["type"] == "preset" else "clone"
|
||||
with db_conn() as conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
duplicate = _existing_community_profile(conn, item, personality)
|
||||
if duplicate is not None:
|
||||
duplicate_audio = duplicate["ref_audio_path"]
|
||||
if not _community_audio_is_current(duplicate, item, ref_text):
|
||||
duplicate_audio = _community_profile_audio_filename(duplicate["id"], item)
|
||||
duplicate_path = Path(VOICES_DIR) / duplicate_audio
|
||||
_copy_atomic(audio_path, duplicate_path)
|
||||
_heal_existing_profile(
|
||||
conn, duplicate, item, ref_text, personality, duplicate_audio,
|
||||
)
|
||||
with contextlib.suppress(OSError):
|
||||
audio_path.unlink()
|
||||
duplicate_result = {"profile_id": duplicate["id"], "name": duplicate["name"]}
|
||||
else:
|
||||
duplicate_result = None
|
||||
if duplicate_result is None:
|
||||
conn.execute(
|
||||
"INSERT INTO voice_profiles "
|
||||
"(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, "
|
||||
"created_at, kind, vd_states) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(profile_id, profile_name, audio_filename, ref_text, instruct,
|
||||
item.get("language", "Auto"), seed, personality, time.time(), kind, vd_states),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO voice_profiles "
|
||||
"(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, created_at, kind) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(profile_id, profile_name, audio_filename, ref_text, instruct,
|
||||
item.get("language", "Auto"), None, item["id"], time.time(), kind),
|
||||
)
|
||||
except Exception:
|
||||
with contextlib.suppress(OSError):
|
||||
audio_path.unlink()
|
||||
with __import__("contextlib").suppress(OSError):
|
||||
os.remove(audio_path)
|
||||
raise
|
||||
if duplicate_result is not None:
|
||||
event_bus.emit("profiles", {"action": "updated", "id": duplicate_result["profile_id"]})
|
||||
return duplicate_result
|
||||
event_bus.emit("profiles", {"action": "created", "id": profile_id})
|
||||
return {"profile_id": profile_id, "name": profile_name}
|
||||
|
||||
|
||||
def _download_voice_audio(item: dict, out_path: Path) -> None:
|
||||
import hashlib
|
||||
audio = item.get("audio") or {}
|
||||
url = audio.get("url", "")
|
||||
if not _safe_audio_url(url):
|
||||
raise HTTPException(status_code=400, detail="Voice audio URL is not from an allowed host.")
|
||||
import httpx
|
||||
with httpx.Client(timeout=30.0, follow_redirects=True) as client:
|
||||
resp = client.get(url)
|
||||
resp.raise_for_status()
|
||||
data = resp.content
|
||||
expected = audio.get("sha256")
|
||||
if expected and hashlib.sha256(data).hexdigest() != expected:
|
||||
raise HTTPException(status_code=502, detail="Downloaded voice failed its integrity check.")
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_path.write_bytes(data)
|
||||
|
||||
@@ -41,15 +41,6 @@ _FAMILIES = {
|
||||
"llm": (llm_backend, "llm_backend"),
|
||||
}
|
||||
|
||||
|
||||
def _family_payload(family: str, module):
|
||||
"""Public inventory plus whether an environment pin owns this family."""
|
||||
return {
|
||||
"active": module.active_backend_id(),
|
||||
"env_override": bool(os.environ.get(f"OMNIVOICE_{family.upper()}_BACKEND")),
|
||||
"backends": public_backends(module.list_backends()),
|
||||
}
|
||||
|
||||
def _is_hf_repo_id(value: str) -> bool:
|
||||
"""Validate the route's ``owner/repo`` contract in bounded time."""
|
||||
if not isinstance(value, str) or len(value) > 96 or value.count("/") != 1:
|
||||
@@ -64,25 +55,34 @@ def _is_hf_repo_id(value: str) -> bool:
|
||||
@router.get("/engines")
|
||||
def list_all_engines():
|
||||
return {
|
||||
"tts": _family_payload("tts", tts_backend),
|
||||
"asr": _family_payload("asr", asr_backend),
|
||||
"llm": _family_payload("llm", llm_backend),
|
||||
"tts": {
|
||||
"active": tts_backend.active_backend_id(),
|
||||
"backends": public_backends(tts_backend.list_backends()),
|
||||
},
|
||||
"asr": {
|
||||
"active": asr_backend.active_backend_id(),
|
||||
"backends": public_backends(asr_backend.list_backends()),
|
||||
},
|
||||
"llm": {
|
||||
"active": llm_backend.active_backend_id(),
|
||||
"backends": public_backends(llm_backend.list_backends()),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/engines/tts")
|
||||
def list_tts_backends():
|
||||
return _family_payload("tts", tts_backend)
|
||||
return {"active": tts_backend.active_backend_id(), "backends": public_backends(tts_backend.list_backends())}
|
||||
|
||||
|
||||
@router.get("/engines/asr")
|
||||
def list_asr_backends():
|
||||
return _family_payload("asr", asr_backend)
|
||||
return {"active": asr_backend.active_backend_id(), "backends": public_backends(asr_backend.list_backends())}
|
||||
|
||||
|
||||
@router.get("/engines/llm")
|
||||
def list_llm_backends():
|
||||
return _family_payload("llm", llm_backend)
|
||||
return {"active": llm_backend.active_backend_id(), "backends": public_backends(llm_backend.list_backends())}
|
||||
|
||||
|
||||
@router.get("/engines/effects/presets", response_model=EffectPresetsResponse)
|
||||
|
||||
+86
-229
@@ -1,24 +1,18 @@
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
import json
|
||||
import uuid
|
||||
import time
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional, List
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, File, Form, UploadFile, HTTPException, Query
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.responses import FileResponse, RedirectResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from core.db import db_conn
|
||||
from core.config import VOICES_DIR, OUTPUTS_DIR
|
||||
from core import event_bus
|
||||
from core.audio_validation import resolve_regular_file
|
||||
from core.file_cleanup import FileCleanupError, unlink_if_present
|
||||
from services.ffmpeg_utils import spawn_subprocess
|
||||
|
||||
@@ -366,223 +360,46 @@ async def upload_voice_clip(
|
||||
}
|
||||
|
||||
|
||||
def _stage_profile_audio(source: Path, directory: Path) -> Path:
|
||||
"""Copy an imported clip to a hidden temp file inside ``directory``.
|
||||
|
||||
The temp lives in the destination directory itself so a later
|
||||
``os.replace`` to the final name is an atomic same-filesystem rename —
|
||||
cheap enough to run while holding a DB write lock, unlike the copy.
|
||||
Callers own cleanup of the returned path if they never publish it.
|
||||
"""
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp_name = tempfile.mkstemp(
|
||||
dir=str(directory), prefix=".gallery-import-", suffix=".part",
|
||||
)
|
||||
os.close(fd)
|
||||
try:
|
||||
shutil.copy2(source, tmp_name)
|
||||
except BaseException:
|
||||
with contextlib.suppress(OSError):
|
||||
os.unlink(tmp_name)
|
||||
raise
|
||||
return Path(tmp_name)
|
||||
|
||||
|
||||
def _copy_profile_audio(source: Path, destination: Path) -> None:
|
||||
"""Copy an imported clip without exposing a partial profile audio file."""
|
||||
staged = _stage_profile_audio(source, destination.parent)
|
||||
try:
|
||||
os.replace(staged, destination)
|
||||
except BaseException:
|
||||
with contextlib.suppress(OSError):
|
||||
os.unlink(staged)
|
||||
raise
|
||||
|
||||
|
||||
def _gallery_profile_audio_filename(profile_id: str, source: Path) -> str:
|
||||
"""Return the canonical, portable filename for a My Imports profile."""
|
||||
safe_id = (
|
||||
profile_id if re.fullmatch(r"[A-Za-z0-9_-]{1,64}", profile_id or "")
|
||||
else uuid.uuid5(uuid.NAMESPACE_URL, str(profile_id)).hex[:16]
|
||||
)
|
||||
suffix = source.suffix.lower()
|
||||
if not re.fullmatch(r"\.[a-z0-9]{1,8}", suffix):
|
||||
suffix = ".wav"
|
||||
return f"{safe_id}_gallery{suffix}"
|
||||
|
||||
|
||||
def _is_materialized_gallery_profile(row, voice: dict, audio_filename: str) -> bool:
|
||||
"""Recognize only rows created by this materializer, not identity collisions."""
|
||||
return bool(
|
||||
row["personality"] == f"gallery:{voice['id']}"
|
||||
and row["ref_audio_path"] == audio_filename
|
||||
and row["ref_text"] == ""
|
||||
and row["instruct"] == ""
|
||||
and row["language"] == "Auto"
|
||||
and row["seed"] is None
|
||||
and row["kind"] == "clone"
|
||||
and not row["vd_states"]
|
||||
and row["description"] == (voice.get("description") or "")
|
||||
and not row["is_locked"]
|
||||
and not row["verified_own_voice"]
|
||||
and not row["locked_audio_path"]
|
||||
)
|
||||
|
||||
|
||||
def _existing_gallery_profile(conn, voice: dict, source: Path):
|
||||
personality = f"gallery:{voice['id']}"
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE personality=? ORDER BY created_at, id",
|
||||
(personality,),
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
expected = _gallery_profile_audio_filename(row["id"], source)
|
||||
if _is_materialized_gallery_profile(row, voice, expected):
|
||||
return row
|
||||
return None
|
||||
|
||||
|
||||
def _gallery_profile_audio_is_current(row, source: Path) -> bool:
|
||||
"""Detect missing/replaced copies without re-hashing unchanged imports."""
|
||||
destination = resolve_regular_file(VOICES_DIR, row["ref_audio_path"])
|
||||
if destination is None:
|
||||
return False
|
||||
try:
|
||||
source_stat = source.stat()
|
||||
destination_stat = destination.stat()
|
||||
# copy2 preserves mtime; size + nanosecond mtime catches ordinary edits
|
||||
# and partial writes while keeping repeated Use clicks inexpensive.
|
||||
return (
|
||||
source_stat.st_size == destination_stat.st_size
|
||||
and source_stat.st_mtime_ns == destination_stat.st_mtime_ns
|
||||
)
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _materialize_gallery_profile(
|
||||
voice_id: str, requested_name: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""Idempotently materialize/heal one My Imports clip as a clone profile."""
|
||||
personality = f"gallery:{voice_id}"
|
||||
copied_path: Optional[Path] = None
|
||||
created = False
|
||||
staged_path: Optional[Path] = None
|
||||
staged_source: Optional[Path] = None
|
||||
try:
|
||||
# Stage the (potentially large) audio copy BEFORE taking SQLite's
|
||||
# write lock: copying inside BEGIN IMMEDIATE would stall every other
|
||||
# backend writer for the whole copy. The staged temp lives in
|
||||
# VOICES_DIR itself, so publishing it inside the transaction is an
|
||||
# atomic same-filesystem os.replace. This pre-read is advisory only —
|
||||
# the locked transaction below re-reads and re-decides everything.
|
||||
copy_needed = False
|
||||
with db_conn() as conn:
|
||||
pre_row = conn.execute(
|
||||
"SELECT * FROM voice_gallery WHERE id = ?", (voice_id,),
|
||||
).fetchone()
|
||||
if pre_row is not None:
|
||||
pre_source = Path(pre_row["audio_path"])
|
||||
if pre_source.is_file():
|
||||
pre_existing = _existing_gallery_profile(conn, dict(pre_row), pre_source)
|
||||
copy_needed = pre_existing is None or not _gallery_profile_audio_is_current(
|
||||
pre_existing, pre_source,
|
||||
)
|
||||
if copy_needed:
|
||||
staged_path = _stage_profile_audio(pre_source, Path(VOICES_DIR))
|
||||
staged_source = pre_source
|
||||
|
||||
with db_conn() as conn:
|
||||
# The identity is not globally UNIQUE because personality is shared
|
||||
# with other import mechanisms. Serialize this check+insert in
|
||||
# SQLite so simultaneous Use clicks cannot both create a row.
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
row = conn.execute(
|
||||
"SELECT * FROM voice_gallery WHERE id = ?", (voice_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Voice not found")
|
||||
|
||||
voice = dict(row)
|
||||
source = Path(voice["audio_path"])
|
||||
if not source.is_file():
|
||||
raise HTTPException(status_code=404, detail="Audio file not found on disk")
|
||||
|
||||
def _install_audio(destination: Path) -> None:
|
||||
"""Publish the staged copy under the lock via atomic rename."""
|
||||
nonlocal staged_path
|
||||
if staged_path is not None and staged_source == source:
|
||||
os.replace(staged_path, destination)
|
||||
staged_path = None
|
||||
else:
|
||||
# Rare race: the gallery row changed between the advisory
|
||||
# pre-read and taking the lock, so any staged bytes may be
|
||||
# from the wrong source. Fall back to the blocking copy
|
||||
# rather than publish stale audio.
|
||||
_copy_profile_audio(source, destination)
|
||||
|
||||
existing = _existing_gallery_profile(conn, voice, source)
|
||||
if existing is not None:
|
||||
ref_filename = _gallery_profile_audio_filename(existing["id"], source)
|
||||
if not _gallery_profile_audio_is_current(existing, source):
|
||||
ref_path = Path(VOICES_DIR) / ref_filename
|
||||
_install_audio(ref_path)
|
||||
copied_path = ref_path
|
||||
conn.execute(
|
||||
"UPDATE voice_profiles SET ref_audio_path=?, ref_text='', instruct='', "
|
||||
"language='Auto', seed=NULL, description=?, kind='clone', vd_states=NULL, "
|
||||
"personality=? WHERE id=?",
|
||||
(
|
||||
ref_filename, voice["description"] or "", personality,
|
||||
existing["id"],
|
||||
),
|
||||
)
|
||||
result = {"profile_id": existing["id"], "name": existing["name"]}
|
||||
else:
|
||||
profile_id = str(uuid.uuid4())[:8]
|
||||
profile_name = (requested_name or voice["name"]).strip() or voice["name"]
|
||||
ref_filename = _gallery_profile_audio_filename(profile_id, source)
|
||||
copied_path = Path(VOICES_DIR) / ref_filename
|
||||
_install_audio(copied_path)
|
||||
conn.execute(
|
||||
"""INSERT INTO voice_profiles
|
||||
(id, name, ref_audio_path, ref_text, instruct, language, seed,
|
||||
personality, is_locked, locked_audio_path, description, kind,
|
||||
vd_states, created_at)
|
||||
VALUES (?, ?, ?, '', '', 'Auto', NULL, ?, 0, '', ?, 'clone', NULL, ?)""",
|
||||
(
|
||||
profile_id, profile_name, ref_filename, personality,
|
||||
voice["description"] or "", time.time(),
|
||||
),
|
||||
)
|
||||
created = True
|
||||
result = {"profile_id": profile_id, "name": profile_name}
|
||||
except BaseException:
|
||||
if copied_path is not None:
|
||||
with contextlib.suppress(OSError):
|
||||
copied_path.unlink()
|
||||
raise
|
||||
finally:
|
||||
# Staged but never published (failure, or a concurrent request healed
|
||||
# the profile first) — never leave .part droppings in VOICES_DIR.
|
||||
if staged_path is not None:
|
||||
with contextlib.suppress(OSError):
|
||||
os.unlink(staged_path)
|
||||
|
||||
event_bus.emit(
|
||||
"profiles", {"action": "created" if created else "updated", "id": result["profile_id"]},
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/gallery/voices/{voice_id}/save-as-profile")
|
||||
async def save_voice_as_profile(
|
||||
voice_id: str,
|
||||
profile_name: str = Query(..., description="Name for the voice profile"),
|
||||
):
|
||||
"""Save a gallery voice as a voice profile for cloning."""
|
||||
result = await asyncio.to_thread(_materialize_gallery_profile, voice_id, profile_name)
|
||||
return {"profile_id": result["profile_id"], "name": result["name"]}
|
||||
with db_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM voice_gallery WHERE id = ?", (voice_id,)
|
||||
).fetchone()
|
||||
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Voice not found")
|
||||
|
||||
profile_id = str(uuid.uuid4())[:8]
|
||||
import shutil
|
||||
|
||||
ext = os.path.splitext(row["audio_path"])[1]
|
||||
new_audio_path = os.path.join(VOICES_DIR, f"{profile_id}{ext}")
|
||||
shutil.copy(row["audio_path"], new_audio_path)
|
||||
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO voice_profiles (id, name, ref_audio_path, ref_text, instruct, language, seed, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
profile_id,
|
||||
profile_name,
|
||||
f"{profile_id}{ext}",
|
||||
row["description"] or "",
|
||||
row["character"] or "",
|
||||
"Auto",
|
||||
None,
|
||||
time.time(),
|
||||
),
|
||||
)
|
||||
event_bus.emit("profiles", {"action": "created", "id": profile_id})
|
||||
|
||||
return {"profile_id": profile_id, "name": profile_name}
|
||||
|
||||
|
||||
@router.get("/gallery/voices/{voice_id}/preview")
|
||||
@@ -598,10 +415,22 @@ def preview_voice(voice_id: str):
|
||||
|
||||
audio_path = row["audio_path"]
|
||||
|
||||
if os.path.isabs(audio_path) and os.path.exists(audio_path):
|
||||
# Serve the file from this API route so deployments mounted below a
|
||||
# path prefix do not lose that prefix while following a redirect.
|
||||
return FileResponse(audio_path)
|
||||
# Debug logging
|
||||
is_absolute = os.path.isabs(audio_path)
|
||||
path_exists = os.path.exists(audio_path) if audio_path else False
|
||||
|
||||
# If absolute path, serve directly or redirect
|
||||
if is_absolute and path_exists:
|
||||
# Get just the relative path from outputs dir
|
||||
outputs_path = str(OUTPUTS_DIR)
|
||||
if audio_path.startswith(outputs_path):
|
||||
# Remove outputs_dir prefix to get relative path within outputs
|
||||
rel_path = os.path.relpath(audio_path, outputs_path)
|
||||
# The audio_path is like: /Users/user4/.../outputs/voice_gallery/file.wav
|
||||
# rel_path becomes: voice_gallery/file.wav
|
||||
# We want to serve from /audio/ so: /audio/voice_gallery/file.wav
|
||||
return RedirectResponse(f"/audio/{rel_path}")
|
||||
return FileResponse(audio_path, media_type="audio/wav")
|
||||
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
@@ -674,5 +503,33 @@ def batch_delete_voices(body: dict):
|
||||
@router.post("/gallery/voices/{voice_id}/to-profile")
|
||||
def voice_to_profile(voice_id: str):
|
||||
"""Create a voice profile from a gallery clip."""
|
||||
result = _materialize_gallery_profile(voice_id)
|
||||
return {"success": True, "profile_id": result["profile_id"], "name": result["name"]}
|
||||
with db_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM voice_gallery WHERE id = ?", (voice_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Voice not found")
|
||||
|
||||
voice = dict(row)
|
||||
audio_path = voice["audio_path"]
|
||||
if not os.path.exists(audio_path):
|
||||
raise HTTPException(status_code=404, detail="Audio file not found on disk")
|
||||
|
||||
import shutil
|
||||
import uuid
|
||||
|
||||
profile_id = str(uuid.uuid4())[:8]
|
||||
# Copy audio to voices dir
|
||||
dest_filename = f"{profile_id}_gallery.wav"
|
||||
dest_path = os.path.join(VOICES_DIR, dest_filename)
|
||||
shutil.copy2(audio_path, dest_path)
|
||||
|
||||
import time
|
||||
now = time.time()
|
||||
conn.execute(
|
||||
"""INSERT INTO voice_profiles
|
||||
(id, name, ref_audio_path, ref_text, instruct, seed, is_locked, locked_audio_path, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(profile_id, voice["name"], dest_filename, "", None, None, 0, None, now, now),
|
||||
)
|
||||
event_bus.emit("profiles", {"action": "created", "id": profile_id})
|
||||
|
||||
return {"success": True, "profile_id": profile_id, "name": voice["name"]}
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
"""Lightweight validation for persisted profile WAV references.
|
||||
|
||||
This module deliberately uses only the standard library. Gallery routers import
|
||||
it during startup, so pulling in torch/torchaudio merely to validate a cached
|
||||
file would make every Gallery open pay the model stack's import cost.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import wave
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from core.path_security import UnsafePath, resolve_within, safe_filename
|
||||
|
||||
_READ_CHUNK_BYTES = 1 << 20
|
||||
_MAX_CHANNELS = 64
|
||||
_MAX_SAMPLE_RATE = 768_000
|
||||
_MAX_SAMPLE_WIDTH = 8
|
||||
|
||||
|
||||
def resolve_regular_file(root: os.PathLike[str] | str, value: object) -> Optional[Path]:
|
||||
"""Resolve a portable bare filename inside *root*, rejecting symlinks."""
|
||||
try:
|
||||
name = safe_filename(value)
|
||||
unresolved = Path(root).resolve(strict=False) / name
|
||||
if unresolved.is_symlink():
|
||||
return None
|
||||
return resolve_within(root, name)
|
||||
except (OSError, UnsafePath):
|
||||
return None
|
||||
|
||||
|
||||
def is_playable_wav(path: Optional[Path]) -> bool:
|
||||
"""Return true only for a regular, decodable WAV with audio frames."""
|
||||
if path is None:
|
||||
return False
|
||||
try:
|
||||
if not path.is_file() or path.is_symlink():
|
||||
return False
|
||||
file_size = path.stat().st_size
|
||||
with wave.open(str(path), "rb") as wav:
|
||||
channels = wav.getnchannels()
|
||||
sample_rate = wav.getframerate()
|
||||
sample_width = wav.getsampwidth()
|
||||
frame_count = wav.getnframes()
|
||||
if (
|
||||
not 0 < channels <= _MAX_CHANNELS
|
||||
or not 0 < sample_rate <= _MAX_SAMPLE_RATE
|
||||
or not 0 < sample_width <= _MAX_SAMPLE_WIDTH
|
||||
or frame_count <= 0
|
||||
):
|
||||
return False
|
||||
# ``wave.getnframes`` trusts the header. Read through the declared
|
||||
# payload so an interrupted write with a complete header but a
|
||||
# truncated data chunk cannot masquerade as playable audio.
|
||||
frame_size = channels * sample_width
|
||||
expected_bytes = frame_count * frame_size
|
||||
# A PCM payload cannot be larger than the containing file. Check
|
||||
# before calling ``readframes`` so hostile header values cannot
|
||||
# turn a tiny file into a multi-gigabyte allocation request.
|
||||
if expected_bytes > file_size:
|
||||
return False
|
||||
read_bytes = 0
|
||||
chunk_frames = max(1, min(frame_count, _READ_CHUNK_BYTES // frame_size))
|
||||
while read_bytes < expected_bytes:
|
||||
chunk = wav.readframes(chunk_frames)
|
||||
if not chunk or len(chunk) % frame_size:
|
||||
return False
|
||||
read_bytes += len(chunk)
|
||||
return read_bytes == expected_bytes
|
||||
except (MemoryError, OSError, EOFError, OverflowError, wave.Error):
|
||||
# Python 3.11's wave module rejects valid IEEE-float/WAVE_EXTENSIBLE
|
||||
# files. SoundFile is already a runtime dependency and recognizes those
|
||||
# containers; import it only on the uncommon fallback path.
|
||||
try:
|
||||
import soundfile as sf
|
||||
|
||||
with sf.SoundFile(str(path)) as audio:
|
||||
if (
|
||||
audio.format != "WAV"
|
||||
or not 0 < audio.channels <= _MAX_CHANNELS
|
||||
or not 0 < audio.samplerate <= _MAX_SAMPLE_RATE
|
||||
or len(audio) <= 0
|
||||
):
|
||||
return False
|
||||
remaining = len(audio)
|
||||
# Decode through the declared payload in byte-bounded chunks;
|
||||
# ``sf.info`` alone also trusts a truncated file's header.
|
||||
chunk_frames = max(
|
||||
1, _READ_CHUNK_BYTES // (audio.channels * 4),
|
||||
)
|
||||
while remaining:
|
||||
frames = audio.read(
|
||||
min(remaining, chunk_frames), dtype="float32", always_2d=True,
|
||||
)
|
||||
count = len(frames)
|
||||
if count <= 0:
|
||||
return False
|
||||
remaining -= count
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
__all__ = ["is_playable_wav", "resolve_regular_file"]
|
||||
@@ -1,421 +0,0 @@
|
||||
"""Canonical authentication identity for HTTP and WebSocket connections.
|
||||
|
||||
Transport parsing belongs here; authorization remains in FastAPI dependencies.
|
||||
Each ASGI scope receives exactly one secret-free :class:`AuthPrincipal` so
|
||||
middleware and route guards cannot disagree about credential precedence.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import importlib
|
||||
import os
|
||||
import secrets
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
|
||||
from services.admin_sessions import (
|
||||
AdminSessionStore,
|
||||
)
|
||||
|
||||
|
||||
_AUTH_STATE_KEY = "auth_principal"
|
||||
_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"})
|
||||
|
||||
CONSUME_CAPABILITIES = frozenset({"consume"})
|
||||
ADMIN_CAPABILITIES = frozenset({"consume", "admin"})
|
||||
LOOPBACK_CAPABILITIES = frozenset({"consume", "admin", "native"})
|
||||
|
||||
|
||||
class PrincipalKind(str, Enum):
|
||||
ANONYMOUS = "anonymous"
|
||||
LOOPBACK = "loopback"
|
||||
TRUSTED_NETWORK = "trusted_network"
|
||||
PIN = "pin"
|
||||
API_KEY = "api_key"
|
||||
ADMIN_SESSION = "admin_session"
|
||||
|
||||
|
||||
class CredentialTransport(str, Enum):
|
||||
NONE = "none"
|
||||
HEADER = "header"
|
||||
QUERY = "query"
|
||||
COOKIE = "cookie"
|
||||
LEGACY_COOKIE = "legacy_cookie"
|
||||
WS_TICKET = "ws_ticket"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthPrincipal:
|
||||
kind: PrincipalKind
|
||||
capabilities: frozenset[str]
|
||||
credential_id: str | None = None
|
||||
transport: CredentialTransport = CredentialTransport.NONE
|
||||
|
||||
def allows(self, capability: str) -> bool:
|
||||
return capability in self.capabilities
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _CredentialCandidate:
|
||||
value: str = field(repr=False)
|
||||
transport: CredentialTransport
|
||||
allow_master: bool = False
|
||||
allow_session: bool = False
|
||||
allow_ticket: bool = False
|
||||
|
||||
|
||||
def remote_api_key() -> str | None:
|
||||
"""Normalized remote operator key, read dynamically for rotation support."""
|
||||
return os.environ.get("OMNIVOICE_API_KEY", "").strip() or None
|
||||
|
||||
|
||||
def credential_matches(supplied: str | None, configured: str | None) -> bool:
|
||||
"""Constant-time credential comparison that accepts the full Unicode range."""
|
||||
if not supplied or not configured:
|
||||
return False
|
||||
return secrets.compare_digest(
|
||||
supplied.encode("utf-8", errors="surrogatepass"),
|
||||
configured.encode("utf-8", errors="surrogatepass"),
|
||||
)
|
||||
|
||||
|
||||
def _active_admin_session_store() -> AdminSessionStore:
|
||||
"""Resolve mutable process state at call time so app reloads cannot split it."""
|
||||
module = importlib.import_module("services.admin_sessions")
|
||||
return module.admin_session_store
|
||||
|
||||
|
||||
def _trusted_networks() -> tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, ...]:
|
||||
networks = []
|
||||
for value in os.environ.get("OMNIVOICE_TRUSTED_NETWORKS", "").split(","):
|
||||
value = value.strip()
|
||||
if not value:
|
||||
continue
|
||||
try:
|
||||
networks.append(ipaddress.ip_network(value, strict=False))
|
||||
except ValueError:
|
||||
# Invalid configuration never makes the gate fail open or wedge the
|
||||
# backend. It simply contributes no trusted range.
|
||||
continue
|
||||
return tuple(networks)
|
||||
|
||||
|
||||
def is_loopback(host: str | None) -> bool:
|
||||
return host in _LOOPBACK_HOSTS
|
||||
|
||||
|
||||
def is_local_host(host: str | None) -> bool:
|
||||
if is_loopback(host):
|
||||
return True
|
||||
try:
|
||||
address = ipaddress.ip_address(host)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
if getattr(address, "ipv4_mapped", None):
|
||||
address = address.ipv4_mapped
|
||||
return any(address in network for network in _trusted_networks())
|
||||
|
||||
|
||||
def _mapping_get(mapping: Mapping[str, str] | object, name: str) -> str:
|
||||
if not mapping:
|
||||
return ""
|
||||
getter = getattr(mapping, "get", None)
|
||||
if callable(getter):
|
||||
value = getter(name, "")
|
||||
if value:
|
||||
return str(value)
|
||||
# Real Starlette Headers are case-insensitive. This small fallback keeps
|
||||
# minimal request stubs and non-Starlette callers correct too.
|
||||
items = getattr(mapping, "items", None)
|
||||
if callable(items):
|
||||
for key, value in items():
|
||||
if str(key).lower() == name.lower():
|
||||
return str(value or "")
|
||||
return ""
|
||||
|
||||
|
||||
def _scope_type(connection) -> str:
|
||||
scope = getattr(connection, "scope", None)
|
||||
return str(scope.get("type", "http")) if isinstance(scope, dict) else "http"
|
||||
|
||||
|
||||
def _path(connection) -> str:
|
||||
scope = getattr(connection, "scope", None)
|
||||
if isinstance(scope, dict):
|
||||
return str(scope.get("path", ""))
|
||||
return str(getattr(connection, "url", "") or "")
|
||||
|
||||
|
||||
def _canonical_websocket_path(connection) -> str:
|
||||
"""Remove only the ASGI-configured deployment prefix from a WS path."""
|
||||
path = _path(connection)
|
||||
scope = getattr(connection, "scope", None)
|
||||
if not isinstance(scope, dict):
|
||||
return path
|
||||
root_path = str(scope.get("root_path", "") or "").rstrip("/")
|
||||
if not root_path or root_path == "/":
|
||||
return path
|
||||
root_path = "/" + root_path.lstrip("/")
|
||||
if path.startswith(root_path + "/"):
|
||||
return path[len(root_path) :]
|
||||
return path
|
||||
|
||||
|
||||
def _client_host(connection) -> str | None:
|
||||
client = getattr(connection, "client", None)
|
||||
if client is not None:
|
||||
return getattr(client, "host", None)
|
||||
scope = getattr(connection, "scope", None)
|
||||
if isinstance(scope, dict) and scope.get("client"):
|
||||
return scope["client"][0]
|
||||
return None
|
||||
|
||||
|
||||
def _credential_candidate(connection) -> _CredentialCandidate | None:
|
||||
query = getattr(connection, "query_params", None) or {}
|
||||
cookies = getattr(connection, "cookies", None) or {}
|
||||
|
||||
raw_authorization = authorization_header(connection)
|
||||
authorization = raw_authorization.strip()
|
||||
if raw_authorization.lower().startswith("bearer "):
|
||||
value = raw_authorization[7:].strip()
|
||||
if value:
|
||||
return _CredentialCandidate(
|
||||
value=value,
|
||||
transport=CredentialTransport.HEADER,
|
||||
allow_master=True,
|
||||
allow_session=True,
|
||||
)
|
||||
# Preserve the legacy normalization contract: ``Bearer`` followed
|
||||
# only by whitespace is equivalent to an empty credential channel.
|
||||
elif authorization:
|
||||
# Any non-empty explicit Authorization value is authoritative, even
|
||||
# when its scheme is unsupported or its Bearer payload is missing.
|
||||
# It must never fall through to a stale ambient cookie.
|
||||
return _CredentialCandidate(
|
||||
value=authorization,
|
||||
transport=CredentialTransport.HEADER,
|
||||
)
|
||||
|
||||
if _scope_type(connection) == "websocket":
|
||||
ticket = _mapping_get(query, "ws_ticket").strip()
|
||||
if ticket:
|
||||
return _CredentialCandidate(
|
||||
value=ticket,
|
||||
transport=CredentialTransport.WS_TICKET,
|
||||
allow_ticket=True,
|
||||
)
|
||||
|
||||
query_key = _mapping_get(query, "api_key").strip()
|
||||
if query_key:
|
||||
return _CredentialCandidate(
|
||||
value=query_key,
|
||||
transport=CredentialTransport.QUERY,
|
||||
allow_master=True,
|
||||
)
|
||||
|
||||
session = _mapping_get(cookies, "ov_session").strip()
|
||||
if session:
|
||||
return _CredentialCandidate(
|
||||
value=session,
|
||||
transport=CredentialTransport.COOKIE,
|
||||
allow_session=True,
|
||||
)
|
||||
|
||||
legacy_key = _mapping_get(cookies, "ov_key").strip()
|
||||
if legacy_key:
|
||||
return _CredentialCandidate(
|
||||
value=legacy_key,
|
||||
transport=CredentialTransport.LEGACY_COOKIE,
|
||||
allow_master=True,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def presented_api_key(connection) -> str:
|
||||
"""Compatibility extractor for the durable API-key transports only."""
|
||||
candidate = _credential_candidate(connection)
|
||||
if candidate is None or not candidate.allow_master:
|
||||
return ""
|
||||
return candidate.value
|
||||
|
||||
|
||||
def authorization_header(connection) -> str:
|
||||
headers = getattr(connection, "headers", None) or {}
|
||||
return _mapping_get(headers, "authorization")
|
||||
|
||||
|
||||
def authorization_credential_present(connection) -> bool:
|
||||
"""Whether Authorization contains an authoritative credential channel.
|
||||
|
||||
This deliberately mirrors :func:`_credential_candidate`: whitespace and
|
||||
``Bearer`` followed only by spaces are empty channels that may fall back to
|
||||
legacy migration state. Unsupported schemes and ``Bearer`` without the
|
||||
required separating space remain explicit invalid credentials.
|
||||
"""
|
||||
authorization = authorization_header(connection)
|
||||
if authorization.lower().startswith("bearer ") and not authorization[7:].strip():
|
||||
return False
|
||||
return bool(authorization.strip())
|
||||
|
||||
|
||||
def bearer_header_value(connection) -> str:
|
||||
authorization = authorization_header(connection)
|
||||
if not authorization.lower().startswith("bearer "):
|
||||
return ""
|
||||
return authorization[7:].strip()
|
||||
|
||||
|
||||
def legacy_master_cookie_valid(connection) -> bool:
|
||||
configured = remote_api_key()
|
||||
cookies = getattr(connection, "cookies", None) or {}
|
||||
supplied = _mapping_get(cookies, "ov_key").strip()
|
||||
return credential_matches(supplied, configured)
|
||||
|
||||
|
||||
def master_header_valid(connection) -> bool:
|
||||
configured = remote_api_key()
|
||||
supplied = bearer_header_value(connection)
|
||||
return credential_matches(supplied, configured)
|
||||
|
||||
|
||||
def _configured_pin(connection) -> str | None:
|
||||
app = getattr(connection, "app", None)
|
||||
state = getattr(app, "state", None) if app is not None else None
|
||||
network_share = getattr(state, "network_share", None) if state is not None else None
|
||||
pin = getattr(network_share, "pin", None) if network_share is not None else None
|
||||
return str(pin) if pin else None
|
||||
|
||||
|
||||
def _valid_pin(connection) -> bool:
|
||||
configured = _configured_pin(connection)
|
||||
if not configured:
|
||||
return False
|
||||
headers = getattr(connection, "headers", None) or {}
|
||||
query = getattr(connection, "query_params", None) or {}
|
||||
cookies = getattr(connection, "cookies", None) or {}
|
||||
supplied = (
|
||||
_mapping_get(headers, "x-omnivoice-pin").strip()
|
||||
or _mapping_get(query, "pin").strip()
|
||||
or _mapping_get(cookies, "ov_pin").strip()
|
||||
)
|
||||
return credential_matches(supplied, configured)
|
||||
|
||||
|
||||
def _attached_principal(connection) -> AuthPrincipal | None:
|
||||
scope = getattr(connection, "scope", None)
|
||||
if not isinstance(scope, dict):
|
||||
return None
|
||||
state = scope.get("state")
|
||||
if isinstance(state, dict):
|
||||
principal = state.get(_AUTH_STATE_KEY)
|
||||
return principal if isinstance(principal, AuthPrincipal) else None
|
||||
return None
|
||||
|
||||
|
||||
def _attach_principal(connection, principal: AuthPrincipal) -> AuthPrincipal:
|
||||
scope = getattr(connection, "scope", None)
|
||||
if isinstance(scope, dict):
|
||||
state = scope.setdefault("state", {})
|
||||
if isinstance(state, dict):
|
||||
state[_AUTH_STATE_KEY] = principal
|
||||
return principal
|
||||
|
||||
|
||||
def resolve_principal(
|
||||
connection,
|
||||
*,
|
||||
store: AdminSessionStore | None = None,
|
||||
) -> AuthPrincipal:
|
||||
"""Resolve and attach the single authentication decision for one scope."""
|
||||
attached = _attached_principal(connection)
|
||||
if attached is not None:
|
||||
return attached
|
||||
if store is None:
|
||||
store = _active_admin_session_store()
|
||||
|
||||
host = _client_host(connection)
|
||||
if is_loopback(host):
|
||||
return _attach_principal(
|
||||
connection,
|
||||
AuthPrincipal(PrincipalKind.LOOPBACK, LOOPBACK_CAPABILITIES),
|
||||
)
|
||||
|
||||
candidate = _credential_candidate(connection)
|
||||
configured_key = remote_api_key()
|
||||
if candidate is not None:
|
||||
principal: AuthPrincipal | None = None
|
||||
if (
|
||||
candidate.allow_master
|
||||
and credential_matches(candidate.value, configured_key)
|
||||
):
|
||||
principal = AuthPrincipal(
|
||||
PrincipalKind.API_KEY,
|
||||
ADMIN_CAPABILITIES,
|
||||
credential_id="api-key",
|
||||
transport=candidate.transport,
|
||||
)
|
||||
elif candidate.allow_session:
|
||||
session = store.resolve(candidate.value, configured_key)
|
||||
if session is not None:
|
||||
principal = AuthPrincipal(
|
||||
PrincipalKind.ADMIN_SESSION,
|
||||
session.capabilities,
|
||||
credential_id=session.credential_id,
|
||||
transport=candidate.transport,
|
||||
)
|
||||
elif candidate.allow_ticket:
|
||||
session = store.consume_ws_ticket(
|
||||
candidate.value,
|
||||
_canonical_websocket_path(connection),
|
||||
configured_key,
|
||||
)
|
||||
if session is not None:
|
||||
principal = AuthPrincipal(
|
||||
PrincipalKind.ADMIN_SESSION,
|
||||
session.capabilities,
|
||||
credential_id=session.credential_id,
|
||||
transport=candidate.transport,
|
||||
)
|
||||
if principal is not None:
|
||||
return _attach_principal(connection, principal)
|
||||
# An explicit, non-empty credential is authoritative. Do not silently
|
||||
# fall back to network or PIN trust after an invalid higher-priority
|
||||
# credential was presented.
|
||||
return _attach_principal(
|
||||
connection,
|
||||
AuthPrincipal(
|
||||
PrincipalKind.ANONYMOUS,
|
||||
frozenset(),
|
||||
transport=candidate.transport,
|
||||
),
|
||||
)
|
||||
|
||||
if is_local_host(host):
|
||||
return _attach_principal(
|
||||
connection,
|
||||
AuthPrincipal(PrincipalKind.TRUSTED_NETWORK, CONSUME_CAPABILITIES),
|
||||
)
|
||||
if _valid_pin(connection):
|
||||
return _attach_principal(
|
||||
connection,
|
||||
AuthPrincipal(
|
||||
PrincipalKind.PIN,
|
||||
CONSUME_CAPABILITIES,
|
||||
transport=CredentialTransport.HEADER,
|
||||
),
|
||||
)
|
||||
return _attach_principal(
|
||||
connection,
|
||||
AuthPrincipal(PrincipalKind.ANONYMOUS, frozenset()),
|
||||
)
|
||||
|
||||
|
||||
def principal_for(
|
||||
connection,
|
||||
*,
|
||||
store: AdminSessionStore | None = None,
|
||||
) -> AuthPrincipal:
|
||||
return _attached_principal(connection) or resolve_principal(connection, store=store)
|
||||
@@ -1,140 +0,0 @@
|
||||
"""Exact-origin CSRF checks for ambient browser authentication."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from urllib.parse import SplitResult, urlsplit
|
||||
|
||||
|
||||
CSRF_HEADER = "x-voicestudio-csrf"
|
||||
CSRF_VALUE = "1"
|
||||
SAFE_HTTP_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
|
||||
|
||||
_FORWARDED_PROTO_HEADER = "x-forwarded-proto"
|
||||
|
||||
|
||||
def effective_scheme(connection) -> str:
|
||||
"""Scheme of the client-facing hop: the resolved scope, TLS-upgraded by proxy evidence.
|
||||
|
||||
Behind a TLS-terminating proxy (Tailscale Serve — the flagship remote-GPU
|
||||
deployment in docs/remote-gpu.md — nginx, Caddy, ...) the browser talks
|
||||
``https`` while the backend hop is plain ``http``. uvicorn's
|
||||
ProxyHeadersMiddleware (on by default in both launch paths: ``uvicorn.run``
|
||||
in backend/main.py and the Docker ``python -m uvicorn`` entrypoint) already
|
||||
rewrites the ASGI scope from ``X-Forwarded-Proto``, but only when the peer
|
||||
is in ``--forwarded-allow-ips`` (default: loopback). That covers Serve on
|
||||
bare metal, and we prefer that signal — the scope is consulted first — but
|
||||
it misses Docker (the proxy connects from the bridge gateway) and any other
|
||||
non-loopback proxy topology, so the header is honored here as well.
|
||||
|
||||
Spoofing analysis — why honoring it never weakens a check: the upgrade is
|
||||
one-way. ``https``/``wss`` as the first forwarded value promotes ``http``
|
||||
to ``https``; every other value is ignored, so a forged header can never
|
||||
downgrade a genuine TLS hop. For the exact-origin comparison the host:port
|
||||
half of the tuple is untouched, a browser cannot attach X-Forwarded-Proto
|
||||
cross-site without a CORS preflight this API never grants, and a
|
||||
non-browser client able to forge the header can already forge Origin
|
||||
itself — it gains nothing. For cookies the upgrade can only ADD the Secure
|
||||
flag (a Secure cookie set over plain http is simply dropped by the
|
||||
browser — the spoofer only breaks their own session), never strip it.
|
||||
"""
|
||||
url = getattr(connection, "url", None)
|
||||
scheme = getattr(url, "scheme", None)
|
||||
if not scheme:
|
||||
scope = getattr(connection, "scope", None)
|
||||
scheme = scope.get("scheme", "http") if isinstance(scope, dict) else "http"
|
||||
scheme = {"ws": "http", "wss": "https"}.get(scheme, scheme)
|
||||
if scheme != "https":
|
||||
headers = getattr(connection, "headers", None) or {}
|
||||
forwarded = (
|
||||
headers.get(_FORWARDED_PROTO_HEADER, "") if hasattr(headers, "get") else ""
|
||||
)
|
||||
if forwarded.split(",")[0].strip().lower() in {"https", "wss"}:
|
||||
scheme = "https"
|
||||
return scheme
|
||||
|
||||
|
||||
def _origin_tuple(value: str | None) -> tuple[str, str, int | None] | None:
|
||||
if not value or value == "null":
|
||||
return None
|
||||
try:
|
||||
parsed: SplitResult = urlsplit(value)
|
||||
port = parsed.port
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if (
|
||||
not parsed.scheme
|
||||
or not parsed.hostname
|
||||
or parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or parsed.path not in ("", "/")
|
||||
or parsed.query
|
||||
or parsed.fragment
|
||||
):
|
||||
return None
|
||||
scheme = parsed.scheme.lower()
|
||||
if scheme not in {"http", "https", "tauri"}:
|
||||
return None
|
||||
if port is None:
|
||||
if scheme == "http":
|
||||
port = 80
|
||||
elif scheme == "https":
|
||||
port = 443
|
||||
return scheme, parsed.hostname.lower(), port
|
||||
|
||||
|
||||
def configured_allowed_origins() -> frozenset[tuple[str, str, int | None]]:
|
||||
raw_port = os.environ.get("OMNIVOICE_UI_PORT", "3901")
|
||||
try:
|
||||
ui_port = int(raw_port)
|
||||
except (TypeError, ValueError):
|
||||
ui_port = 3901
|
||||
values = os.environ.get(
|
||||
"OMNIVOICE_ALLOWED_ORIGINS",
|
||||
f"http://localhost:{ui_port},http://127.0.0.1:{ui_port},"
|
||||
"tauri://localhost,http://tauri.localhost",
|
||||
).split(",")
|
||||
return frozenset(
|
||||
origin
|
||||
for value in values
|
||||
if (origin := _origin_tuple(value.strip())) is not None
|
||||
)
|
||||
|
||||
|
||||
def _destination_origin(connection) -> tuple[str, str, int | None] | None:
|
||||
scheme = effective_scheme(connection)
|
||||
url = getattr(connection, "url", None)
|
||||
netloc = getattr(url, "netloc", None)
|
||||
if netloc:
|
||||
return _origin_tuple(f"{scheme}://{netloc}")
|
||||
scope = getattr(connection, "scope", None)
|
||||
headers = getattr(connection, "headers", None) or {}
|
||||
if not isinstance(scope, dict):
|
||||
return None
|
||||
host = headers.get("host", "") if hasattr(headers, "get") else ""
|
||||
return _origin_tuple(f"{scheme}://{host}")
|
||||
|
||||
|
||||
def origin_allowed(connection) -> bool:
|
||||
headers = getattr(connection, "headers", None) or {}
|
||||
origin_value = headers.get("origin", "") if hasattr(headers, "get") else ""
|
||||
presented = _origin_tuple(origin_value)
|
||||
if presented is None:
|
||||
return False
|
||||
return presented == _destination_origin(connection) or presented in configured_allowed_origins()
|
||||
|
||||
|
||||
def cookie_csrf_allowed(connection, *, side_effectful_get: bool = False) -> bool:
|
||||
headers = getattr(connection, "headers", None) or {}
|
||||
marker = headers.get(CSRF_HEADER, "") if hasattr(headers, "get") else ""
|
||||
if marker != CSRF_VALUE or not origin_allowed(connection):
|
||||
return False
|
||||
method = getattr(connection, "method", None)
|
||||
if method is None:
|
||||
scope = getattr(connection, "scope", None)
|
||||
method = scope.get("method", "GET") if isinstance(scope, dict) else "GET"
|
||||
method = str(method).upper()
|
||||
if side_effectful_get or method in SAFE_HTTP_METHODS:
|
||||
fetch_site = headers.get("sec-fetch-site", "") if hasattr(headers, "get") else ""
|
||||
return fetch_site == "same-origin"
|
||||
return True
|
||||
@@ -24,7 +24,7 @@ from pathlib import Path
|
||||
# tests/test_app_version.py::test_all_version_files_in_lockstep and bumped by
|
||||
# release.yml's version-bump job, so it stays equal to
|
||||
# pyproject/tauri.conf/Cargo/package.json.
|
||||
_FALLBACK_VERSION = "0.5.0"
|
||||
_FALLBACK_VERSION = "0.4.2"
|
||||
|
||||
|
||||
def _fallback_version() -> str:
|
||||
|
||||
+47
-78
@@ -341,6 +341,7 @@ if not os.environ.get("OMNIVOICE_DISABLE_FILE_LOG"):
|
||||
logger = logging.getLogger("omnivoice.api")
|
||||
|
||||
import asyncio
|
||||
import secrets
|
||||
import time
|
||||
import threading
|
||||
from contextlib import asynccontextmanager
|
||||
@@ -374,15 +375,11 @@ from services.model_manager import (
|
||||
)
|
||||
from services import network_share
|
||||
|
||||
from core.auth import (
|
||||
CredentialTransport,
|
||||
PrincipalKind,
|
||||
credential_matches,
|
||||
from api.dependencies import ( # loopback + OMNIVOICE_TRUSTED_NETWORKS
|
||||
is_local_host,
|
||||
principal_for,
|
||||
presented_api_key,
|
||||
remote_api_key,
|
||||
)
|
||||
from core.csrf import SAFE_HTTP_METHODS, cookie_csrf_allowed, origin_allowed
|
||||
|
||||
from api.routers import (
|
||||
system,
|
||||
@@ -419,7 +416,6 @@ from api.routers import (
|
||||
pronunciation, # Expressive-TTS Spec 01: user pronunciation dictionary
|
||||
settings as settings_router, # Phase 1 AUTH-03: HF token save/clear/state
|
||||
media_tools as media_tools_router, # Audio tools: ffmpeg/ffprobe/yt-dlp management
|
||||
auth as auth_router,
|
||||
)
|
||||
from utils import hf_progress
|
||||
|
||||
@@ -1115,12 +1111,7 @@ class NetworkAccessMiddleware:
|
||||
if is_local_host(client):
|
||||
return await self.app(scope, receive, send)
|
||||
path = scope["path"]
|
||||
if (
|
||||
path in _SHELL_PATHS
|
||||
or path.startswith("/assets/")
|
||||
or path.startswith("/favicon")
|
||||
or path == "/api/auth/session"
|
||||
):
|
||||
if path in _SHELL_PATHS or path.startswith("/assets/") or path.startswith("/favicon"):
|
||||
return await self.app(scope, receive, send)
|
||||
supplied = (
|
||||
request.headers.get("x-omnivoice-pin")
|
||||
@@ -1128,7 +1119,7 @@ class NetworkAccessMiddleware:
|
||||
or request.cookies.get("ov_pin")
|
||||
or ""
|
||||
)
|
||||
if not credential_matches(supplied, pin):
|
||||
if not secrets.compare_digest(supplied, pin):
|
||||
resp = JSONResponse({"detail": "PIN required"}, status_code=401)
|
||||
return await resp(scope, receive, send)
|
||||
# Valid PIN. Set the cookie by wrapping send to inject Set-Cookie on the
|
||||
@@ -1170,10 +1161,9 @@ class BackendMarkerMiddleware:
|
||||
|
||||
async def send_with_marker(message):
|
||||
if message["type"] == "http.response.start":
|
||||
headers = MutableHeaders(scope=message)
|
||||
headers.setdefault(BACKEND_MARKER_HEADER, _backend_marker_value())
|
||||
if str(scope.get("path", "")).startswith("/api/auth/"):
|
||||
headers["cache-control"] = "no-store"
|
||||
MutableHeaders(scope=message).setdefault(
|
||||
BACKEND_MARKER_HEADER, _backend_marker_value()
|
||||
)
|
||||
await send(message)
|
||||
|
||||
return await self.app(scope, receive, send_with_marker)
|
||||
@@ -1193,12 +1183,11 @@ def _backend_marker_value() -> str:
|
||||
|
||||
class BearerKeyMiddleware:
|
||||
"""When OMNIVOICE_API_KEY is set, non-loopback clients must present it on
|
||||
every HTTP + WebSocket request. Durable API-key transports remain compatible;
|
||||
the first-party UI may instead present a short-lived admin session. The
|
||||
middleware never reflects a presented master key into browser state.
|
||||
|
||||
Loopback always bypasses — the desktop default is unchanged — and the SPA
|
||||
shell paths stay reachable so a remote UI can load and show what's wrong.
|
||||
every HTTP + WebSocket request: ``Authorization: Bearer <key>``,
|
||||
``?api_key=<key>`` (browser WebSockets cannot set headers), or the
|
||||
``ov_key`` cookie (set on the first successful HTTP auth). Loopback
|
||||
always bypasses — the desktop default is unchanged — and the SPA shell
|
||||
paths stay reachable so a remote UI can load and show what's wrong.
|
||||
|
||||
Inert when the env var is unset (the default). Pure ASGI for the same
|
||||
no-buffering reason as NetworkAccessMiddleware above. Plain-HTTP caveat
|
||||
@@ -1215,25 +1204,21 @@ class BearerKeyMiddleware:
|
||||
key = remote_api_key() or ""
|
||||
if not key:
|
||||
return await self.app(scope, receive, send)
|
||||
client = scope["client"][0] if scope.get("client") else None
|
||||
if is_local_host(client):
|
||||
return await self.app(scope, receive, send)
|
||||
path = scope.get("path", "")
|
||||
if scope["type"] == "http" and (
|
||||
path in _SHELL_PATHS
|
||||
or path.startswith("/assets/")
|
||||
or path.startswith("/favicon")
|
||||
or path == "/api/auth/session"
|
||||
path in _SHELL_PATHS or path.startswith("/assets/") or path.startswith("/favicon")
|
||||
):
|
||||
return await self.app(scope, receive, send)
|
||||
|
||||
from starlette.requests import HTTPConnection
|
||||
|
||||
conn = HTTPConnection(scope)
|
||||
principal = principal_for(conn)
|
||||
if principal.kind not in {
|
||||
PrincipalKind.LOOPBACK,
|
||||
PrincipalKind.TRUSTED_NETWORK,
|
||||
PrincipalKind.API_KEY,
|
||||
PrincipalKind.ADMIN_SESSION,
|
||||
}:
|
||||
supplied = presented_api_key(conn)
|
||||
|
||||
if not secrets.compare_digest(supplied, key):
|
||||
if scope["type"] == "websocket":
|
||||
# Reject the handshake; 1008 = policy violation.
|
||||
await receive() # consume websocket.connect
|
||||
@@ -1241,31 +1226,17 @@ class BearerKeyMiddleware:
|
||||
return
|
||||
resp = JSONResponse({"detail": "API key required"}, status_code=401)
|
||||
return await resp(scope, receive, send)
|
||||
if (
|
||||
scope["type"] == "http"
|
||||
and str(scope.get("method", "GET")).upper() not in SAFE_HTTP_METHODS
|
||||
and principal.transport
|
||||
in {CredentialTransport.COOKIE, CredentialTransport.LEGACY_COOKIE}
|
||||
and not cookie_csrf_allowed(conn)
|
||||
):
|
||||
resp = JSONResponse(
|
||||
{"detail": "browser origin rejected"},
|
||||
status_code=403,
|
||||
)
|
||||
return await resp(scope, receive, send)
|
||||
if (
|
||||
scope["type"] == "websocket"
|
||||
and principal.transport
|
||||
in {
|
||||
CredentialTransport.COOKIE,
|
||||
CredentialTransport.LEGACY_COOKIE,
|
||||
CredentialTransport.WS_TICKET,
|
||||
}
|
||||
and not origin_allowed(conn)
|
||||
):
|
||||
await receive()
|
||||
await send({"type": "websocket.close", "code": 1008})
|
||||
return
|
||||
|
||||
if scope["type"] == "http" and conn.cookies.get("ov_key") != key:
|
||||
async def send_with_cookie(message):
|
||||
if message["type"] == "http.response.start":
|
||||
headers = MutableHeaders(scope=message)
|
||||
headers.append(
|
||||
"set-cookie", f"ov_key={key}; Path=/; SameSite=Lax"
|
||||
)
|
||||
await send(message)
|
||||
|
||||
return await self.app(scope, receive, send_with_cookie)
|
||||
return await self.app(scope, receive, send)
|
||||
|
||||
|
||||
@@ -1287,20 +1258,6 @@ _allowed = os.environ.get(
|
||||
f"http://localhost:{_ui},http://127.0.0.1:{_ui},tauri://localhost,http://tauri.localhost",
|
||||
).split(",")
|
||||
|
||||
# Inert unless a PIN is set. CORS is registered after both auth gates below so
|
||||
# Starlette places it outside them: browser preflights carry no credentials and
|
||||
# must reach CORS before either gate can reject the request.
|
||||
app.add_middleware(NetworkAccessMiddleware)
|
||||
|
||||
# Remote-backend bearer gate (parity program Wave 2.3 / §R2). Inert unless
|
||||
# OMNIVOICE_API_KEY is set. Distinct from the PIN gate above: the PIN guards
|
||||
# casual LAN-share guests for one session; the API key is the durable
|
||||
# credential for running this backend remotely (Tailscale / Docker GPU box).
|
||||
# Covers WebSockets too — the PIN gate never did, because every WS endpoint
|
||||
# carried its own loopback guard; remote mode is exactly the case where a
|
||||
# keyed non-loopback client must reach them.
|
||||
app.add_middleware(BearerKeyMiddleware)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[o.strip() for o in _allowed if o.strip()],
|
||||
@@ -1313,10 +1270,23 @@ app.add_middleware(
|
||||
expose_headers=["Content-Disposition", BACKEND_MARKER_HEADER],
|
||||
)
|
||||
|
||||
# Registered AFTER CORS so CORS remains the outermost layer (CORS headers are
|
||||
# applied even to the 401 PIN-required responses). Inert unless a PIN is set.
|
||||
app.add_middleware(NetworkAccessMiddleware)
|
||||
|
||||
# Remote-backend bearer gate (parity program Wave 2.3 / §R2). Inert unless
|
||||
# OMNIVOICE_API_KEY is set. Distinct from the PIN gate above: the PIN guards
|
||||
# casual LAN-share guests for one session; the API key is the durable
|
||||
# credential for running this backend remotely (Tailscale / Docker GPU box).
|
||||
# Covers WebSockets too — the PIN gate never did, because every WS endpoint
|
||||
# carried its own loopback guard; remote mode is exactly the case where a
|
||||
# keyed non-loopback client must reach them.
|
||||
app.add_middleware(BearerKeyMiddleware)
|
||||
|
||||
# Registered LAST, which in Starlette means OUTERMOST — so the marker lands on
|
||||
# every response. CORS is immediately inside it and outside both auth gates, so
|
||||
# preflights and gate-generated 401s retain the browser contract. The marker's
|
||||
# absence lets a client conclude that the responder is not VoiceStudio (#1385).
|
||||
# every response, including the two gates' 401s above and StaticFiles' bare
|
||||
# "Not Found". Its absence is what lets a client conclude "whatever answered
|
||||
# me is not a VoiceStudio backend" (#1385).
|
||||
app.add_middleware(BackendMarkerMiddleware)
|
||||
|
||||
# Register canonical audio MIME types before any StaticFiles mount.
|
||||
@@ -1393,7 +1363,6 @@ app.include_router(longform_jobs.router)
|
||||
app.include_router(pronunciation.router) # Expressive-TTS Spec 01: pronunciation dictionary
|
||||
app.include_router(settings_router.router) # Phase 1 AUTH-03 endpoints
|
||||
app.include_router(media_tools_router.router) # Settings → Audio tools + wizard media-engine self-heal
|
||||
app.include_router(auth_router.router) # short-lived first-party remote admin sessions
|
||||
from api.routers import mcp_bindings as _mcp_bindings_router # noqa: E402
|
||||
from api.routers import workers as workers_router # noqa: E402
|
||||
app.include_router(_mcp_bindings_router.router) # Wave 2.2 per-agent voice bindings
|
||||
|
||||
@@ -1,402 +0,0 @@
|
||||
"""Process-bound credentials for the first-party remote administration UI.
|
||||
|
||||
The durable ``OMNIVOICE_API_KEY`` is an operator secret, not a browser session.
|
||||
This module exchanges it for opaque, bounded-lifetime credentials without
|
||||
depending on FastAPI or persisting a verifier to disk.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import re
|
||||
import secrets
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from types import ModuleType
|
||||
from base64 import urlsafe_b64encode
|
||||
from collections import OrderedDict
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||
|
||||
|
||||
SESSION_TTL_SECONDS = 8 * 60 * 60
|
||||
WS_TICKET_TTL_SECONDS = 30
|
||||
MAX_ADMIN_SESSIONS = 256
|
||||
MAX_WS_TICKETS = 512
|
||||
|
||||
ADMIN_SESSION_PREFIX = "ovs_admin_session_"
|
||||
WS_TICKET_PREFIX = "ovs_ws_ticket_"
|
||||
_TOKEN_BYTES = 32
|
||||
_ENCODED_TOKEN_LENGTH = 43
|
||||
_TOKEN_BODY_RE = re.compile(rf"^[A-Za-z0-9_-]{{{_ENCODED_TOKEN_LENGTH}}}$")
|
||||
_ALLOWED_WS_PATHS = frozenset({"/ws/events", "/ws/transcribe"})
|
||||
_ADMIN_CAPABILITIES = frozenset({"consume", "admin"})
|
||||
_KEY_GENERATION_INFO = b"omnivoice-admin-key-generation-v1"
|
||||
|
||||
|
||||
def _hash_token(token: str, pepper: bytes) -> str:
|
||||
# These are 256-bit random values, not user-chosen passwords. A keyed,
|
||||
# process-local index is the right primitive: there is no feasible password
|
||||
# dictionary to slow down, and a copied record is unusable without the
|
||||
# store's independently generated pepper.
|
||||
return hmac.digest(pepper, token.encode("utf-8"), "sha256").hex()
|
||||
|
||||
|
||||
def _encode_token(raw: bytes) -> str:
|
||||
return urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IssuedSession:
|
||||
token: str = field(repr=False)
|
||||
expires_at: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IssuedTicket:
|
||||
token: str = field(repr=False)
|
||||
expires_at: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SessionRecord:
|
||||
credential_id: str
|
||||
capabilities: frozenset[str]
|
||||
issued_at: float
|
||||
expires_at: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _StoredSession:
|
||||
credential_id: str
|
||||
issued_monotonic: float
|
||||
expires_monotonic: float
|
||||
issued_at: float
|
||||
expires_at: float
|
||||
|
||||
def public(self) -> SessionRecord:
|
||||
return SessionRecord(
|
||||
credential_id=self.credential_id,
|
||||
capabilities=_ADMIN_CAPABILITIES,
|
||||
issued_at=self.issued_at,
|
||||
expires_at=self.expires_at,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _StoredTicket:
|
||||
session_hash: str
|
||||
path: str
|
||||
issued_monotonic: float
|
||||
expires_monotonic: float
|
||||
|
||||
|
||||
class AdminSessionStore:
|
||||
"""Thread-safe, process-local store for admin sessions and WS tickets."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
monotonic: Callable[[], float] = time.monotonic,
|
||||
wall_time: Callable[[], float] = time.time,
|
||||
token_bytes: Callable[[int], bytes] = secrets.token_bytes,
|
||||
pepper: bytes | None = None,
|
||||
session_ttl_seconds: int = SESSION_TTL_SECONDS,
|
||||
ws_ticket_ttl_seconds: int = WS_TICKET_TTL_SECONDS,
|
||||
max_sessions: int = MAX_ADMIN_SESSIONS,
|
||||
max_tickets: int = MAX_WS_TICKETS,
|
||||
) -> None:
|
||||
if session_ttl_seconds <= 0 or ws_ticket_ttl_seconds <= 0:
|
||||
raise ValueError("credential TTLs must be positive")
|
||||
if max_sessions <= 0 or max_tickets <= 0:
|
||||
raise ValueError("credential store capacities must be positive")
|
||||
self._monotonic = monotonic
|
||||
self._wall_time = wall_time
|
||||
self._token_bytes = token_bytes
|
||||
self._pepper = pepper if pepper is not None else secrets.token_bytes(32)
|
||||
if len(self._pepper) < 32:
|
||||
raise ValueError("session-store pepper must contain at least 256 bits")
|
||||
self._session_ttl = session_ttl_seconds
|
||||
self._ticket_ttl = ws_ticket_ttl_seconds
|
||||
self._max_sessions = max_sessions
|
||||
self._max_tickets = max_tickets
|
||||
self._sessions: OrderedDict[str, _StoredSession] = OrderedDict()
|
||||
self._tickets: OrderedDict[str, _StoredTicket] = OrderedDict()
|
||||
self._ticket_hashes_by_session: dict[str, set[str]] = {}
|
||||
self._key_generation: bytes | None = None
|
||||
self._lock = threading.RLock()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
snapshot = self.debug_snapshot()
|
||||
return (
|
||||
"AdminSessionStore("
|
||||
f"sessions={snapshot['sessions']}, ws_tickets={snapshot['ws_tickets']})"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_master(api_key: str | None) -> str:
|
||||
return api_key.strip() if isinstance(api_key, str) else ""
|
||||
|
||||
def _generation(self, api_key: str) -> bytes:
|
||||
return HKDF(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=32,
|
||||
salt=self._pepper,
|
||||
info=_KEY_GENERATION_INFO,
|
||||
).derive(api_key.encode("utf-8", errors="surrogatepass"))
|
||||
|
||||
def _sync_key_locked(self, api_key: str | None) -> bool:
|
||||
normalized = self._normalize_master(api_key)
|
||||
if not normalized:
|
||||
self._clear_credentials_locked()
|
||||
self._key_generation = None
|
||||
return False
|
||||
generation = self._generation(normalized)
|
||||
if self._key_generation is None:
|
||||
self._key_generation = generation
|
||||
return True
|
||||
if not hmac.compare_digest(self._key_generation, generation):
|
||||
self._clear_credentials_locked()
|
||||
self._key_generation = generation
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _valid_token(token: str | None, prefix: str) -> bool:
|
||||
if not isinstance(token, str) or not token.startswith(prefix):
|
||||
return False
|
||||
return bool(_TOKEN_BODY_RE.fullmatch(token.removeprefix(prefix)))
|
||||
|
||||
def _new_token_locked(self, prefix: str, existing: object) -> tuple[str, str]:
|
||||
for _attempt in range(8):
|
||||
raw = self._token_bytes(_TOKEN_BYTES)
|
||||
if not isinstance(raw, bytes) or len(raw) != _TOKEN_BYTES:
|
||||
raise RuntimeError("token source must return exactly 32 bytes")
|
||||
token = prefix + _encode_token(raw)
|
||||
token_hash = _hash_token(token, self._pepper)
|
||||
if token_hash not in existing:
|
||||
return token, token_hash
|
||||
raise RuntimeError("credential token source produced repeated collisions")
|
||||
|
||||
def _clear_credentials_locked(self) -> None:
|
||||
self._sessions.clear()
|
||||
self._tickets.clear()
|
||||
self._ticket_hashes_by_session.clear()
|
||||
|
||||
def _remove_ticket_locked(self, ticket_hash: str) -> _StoredTicket | None:
|
||||
ticket = self._tickets.pop(ticket_hash, None)
|
||||
if ticket is None:
|
||||
return None
|
||||
session_tickets = self._ticket_hashes_by_session.get(ticket.session_hash)
|
||||
if session_tickets is not None:
|
||||
session_tickets.discard(ticket_hash)
|
||||
if not session_tickets:
|
||||
self._ticket_hashes_by_session.pop(ticket.session_hash, None)
|
||||
return ticket
|
||||
|
||||
def _remove_session_locked(self, session_hash: str) -> _StoredSession | None:
|
||||
record = self._sessions.pop(session_hash, None)
|
||||
for ticket_hash in tuple(self._ticket_hashes_by_session.get(session_hash, ())):
|
||||
self._remove_ticket_locked(ticket_hash)
|
||||
# Defensive cleanup keeps a prior partial mutation from preserving a
|
||||
# dangling reverse-index bucket even when the session was already gone.
|
||||
self._ticket_hashes_by_session.pop(session_hash, None)
|
||||
return record
|
||||
|
||||
def _purge_locked(self, now: float) -> None:
|
||||
# TTLs are fixed per store and monotonic issue times never decrease, so
|
||||
# insertion order is expiry order. Only the expired prefix can require
|
||||
# work; the common request path examines at most one record per type.
|
||||
while self._sessions:
|
||||
session_hash = next(iter(self._sessions))
|
||||
if now < self._sessions[session_hash].expires_monotonic:
|
||||
break
|
||||
self._remove_session_locked(session_hash)
|
||||
|
||||
while self._tickets:
|
||||
ticket_hash = next(iter(self._tickets))
|
||||
if now < self._tickets[ticket_hash].expires_monotonic:
|
||||
break
|
||||
self._remove_ticket_locked(ticket_hash)
|
||||
|
||||
def _evict_sessions_locked(self) -> None:
|
||||
while len(self._sessions) >= self._max_sessions:
|
||||
self._remove_session_locked(next(iter(self._sessions)))
|
||||
|
||||
def _evict_tickets_locked(self) -> None:
|
||||
while len(self._tickets) >= self._max_tickets:
|
||||
self._remove_ticket_locked(next(iter(self._tickets)))
|
||||
|
||||
def issue(self, api_key: str) -> IssuedSession:
|
||||
normalized = self._normalize_master(api_key)
|
||||
if not normalized:
|
||||
raise ValueError("configured API key required")
|
||||
with self._lock:
|
||||
self._sync_key_locked(normalized)
|
||||
now = self._monotonic()
|
||||
wall_now = self._wall_time()
|
||||
self._purge_locked(now)
|
||||
self._evict_sessions_locked()
|
||||
token, token_hash = self._new_token_locked(ADMIN_SESSION_PREFIX, self._sessions)
|
||||
expires_monotonic = now + self._session_ttl
|
||||
expires_at = wall_now + self._session_ttl
|
||||
self._sessions[token_hash] = _StoredSession(
|
||||
credential_id=token_hash,
|
||||
issued_monotonic=now,
|
||||
expires_monotonic=expires_monotonic,
|
||||
issued_at=wall_now,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
return IssuedSession(token=token, expires_at=expires_at)
|
||||
|
||||
def resolve(self, token: str | None, api_key: str | None) -> SessionRecord | None:
|
||||
if not self._valid_token(token, ADMIN_SESSION_PREFIX):
|
||||
return None
|
||||
assert isinstance(token, str)
|
||||
with self._lock:
|
||||
if not self._sync_key_locked(api_key):
|
||||
return None
|
||||
now = self._monotonic()
|
||||
self._purge_locked(now)
|
||||
record = self._sessions.get(_hash_token(token, self._pepper))
|
||||
if record is None or now >= record.expires_monotonic:
|
||||
return None
|
||||
return record.public()
|
||||
|
||||
def revoke(self, token: str | None) -> bool:
|
||||
if not self._valid_token(token, ADMIN_SESSION_PREFIX):
|
||||
return False
|
||||
assert isinstance(token, str)
|
||||
token_hash = _hash_token(token, self._pepper)
|
||||
with self._lock:
|
||||
return self._remove_session_locked(token_hash) is not None
|
||||
|
||||
def revoke_by_credential(self, credential_id: str | None) -> bool:
|
||||
if not isinstance(credential_id, str) or len(credential_id) != 64:
|
||||
return False
|
||||
with self._lock:
|
||||
return self._remove_session_locked(credential_id) is not None
|
||||
|
||||
def issue_ws_ticket(
|
||||
self,
|
||||
session_token: str | None,
|
||||
path: str,
|
||||
api_key: str | None,
|
||||
) -> IssuedTicket:
|
||||
if path not in _ALLOWED_WS_PATHS:
|
||||
raise ValueError("WebSocket path is not allowed")
|
||||
if not self._valid_token(session_token, ADMIN_SESSION_PREFIX):
|
||||
raise PermissionError("valid admin session required")
|
||||
assert isinstance(session_token, str)
|
||||
session_hash = _hash_token(session_token, self._pepper)
|
||||
return self.issue_ws_ticket_for_credential(session_hash, path, api_key)
|
||||
|
||||
def issue_ws_ticket_for_credential(
|
||||
self,
|
||||
credential_id: str | None,
|
||||
path: str,
|
||||
api_key: str | None,
|
||||
) -> IssuedTicket:
|
||||
if path not in _ALLOWED_WS_PATHS:
|
||||
raise ValueError("WebSocket path is not allowed")
|
||||
with self._lock:
|
||||
if not isinstance(credential_id, str) or len(credential_id) != 64:
|
||||
raise PermissionError("valid admin session required")
|
||||
if not self._sync_key_locked(api_key):
|
||||
raise PermissionError("valid admin session required")
|
||||
now = self._monotonic()
|
||||
self._purge_locked(now)
|
||||
session = self._sessions.get(credential_id)
|
||||
if session is None or now >= session.expires_monotonic:
|
||||
raise PermissionError("valid admin session required")
|
||||
self._evict_tickets_locked()
|
||||
token, token_hash = self._new_token_locked(WS_TICKET_PREFIX, self._tickets)
|
||||
expires_at = self._wall_time() + self._ticket_ttl
|
||||
self._tickets[token_hash] = _StoredTicket(
|
||||
session_hash=credential_id,
|
||||
path=path,
|
||||
issued_monotonic=now,
|
||||
expires_monotonic=now + self._ticket_ttl,
|
||||
)
|
||||
self._ticket_hashes_by_session.setdefault(credential_id, set()).add(
|
||||
token_hash
|
||||
)
|
||||
return IssuedTicket(token=token, expires_at=expires_at)
|
||||
|
||||
def consume_ws_ticket(
|
||||
self,
|
||||
ticket_token: str | None,
|
||||
path: str,
|
||||
api_key: str | None,
|
||||
) -> SessionRecord | None:
|
||||
if not self._valid_token(ticket_token, WS_TICKET_PREFIX):
|
||||
return None
|
||||
assert isinstance(ticket_token, str)
|
||||
with self._lock:
|
||||
if not self._sync_key_locked(api_key):
|
||||
return None
|
||||
now = self._monotonic()
|
||||
self._purge_locked(now)
|
||||
ticket = self._remove_ticket_locked(
|
||||
_hash_token(ticket_token, self._pepper)
|
||||
)
|
||||
if ticket is None or now >= ticket.expires_monotonic or ticket.path != path:
|
||||
return None
|
||||
session = self._sessions.get(ticket.session_hash)
|
||||
if session is None or now >= session.expires_monotonic:
|
||||
return None
|
||||
return session.public()
|
||||
|
||||
def clear(self) -> None:
|
||||
with self._lock:
|
||||
self._clear_credentials_locked()
|
||||
self._key_generation = None
|
||||
|
||||
@property
|
||||
def active_session_count(self) -> int:
|
||||
with self._lock:
|
||||
self._purge_locked(self._monotonic())
|
||||
return len(self._sessions)
|
||||
|
||||
def debug_snapshot(self) -> dict[str, int]:
|
||||
with self._lock:
|
||||
self._purge_locked(self._monotonic())
|
||||
return {"sessions": len(self._sessions), "ws_tickets": len(self._tickets)}
|
||||
|
||||
|
||||
#: Synthetic ``sys.modules`` key holding the one per-process store. A module
|
||||
#: object in ``sys.modules`` is the only namespace that survives everything
|
||||
#: test suites do to this package: ``importlib.reload`` re-executes module
|
||||
#: code but never touches unrelated ``sys.modules`` entries, and the purges
|
||||
#: that pop whole ``services.*`` / ``api.*`` trees match package prefixes this
|
||||
#: underscore-prefixed top-level name is outside of.
|
||||
_ANCHOR_MODULE_NAME = "_omnivoice_admin_session_store_anchor"
|
||||
|
||||
|
||||
def _process_store() -> AdminSessionStore:
|
||||
"""Return THE per-process store, however this module was (re)imported.
|
||||
|
||||
Auth is process-global state: the copy of this module that issues a
|
||||
credential and the copy that later resolves it must always be looking at
|
||||
the same store. A bare module-level ``AdminSessionStore()`` breaks that
|
||||
the moment anything reloads or re-imports this module (fresh module dict →
|
||||
fresh store → freshly issued sessions vanish for holders of the old
|
||||
reference, and vice versa). Anchoring the instance outside the module's
|
||||
own namespace makes every copy of this module share one store.
|
||||
"""
|
||||
anchor = sys.modules.get(_ANCHOR_MODULE_NAME)
|
||||
if not isinstance(anchor, ModuleType):
|
||||
anchor = ModuleType(_ANCHOR_MODULE_NAME)
|
||||
anchor.__doc__ = "Process-global anchor for the VoiceStudio admin-session store."
|
||||
sys.modules[_ANCHOR_MODULE_NAME] = anchor
|
||||
store = getattr(anchor, "admin_session_store", None)
|
||||
if store is None:
|
||||
store = AdminSessionStore()
|
||||
anchor.admin_session_store = store
|
||||
return store
|
||||
|
||||
|
||||
admin_session_store = _process_store()
|
||||
@@ -520,10 +520,12 @@ class WhisperXBackend(ASRBackend):
|
||||
def _pick_device() -> tuple[str, str]:
|
||||
# CUDA fp16 when available; otherwise CPU int8 (fastest CPU path,
|
||||
# negligible WER regression vs fp32 for whisper-large-v3).
|
||||
# _ctranslate2_cuda_ok, not torch.cuda.is_available: ROCm torch also
|
||||
# answers True there, and CTranslate2 has no HIP backend (#1529).
|
||||
if _ctranslate2_cuda_ok():
|
||||
return "cuda", "float16"
|
||||
try:
|
||||
import torch
|
||||
if torch.cuda.is_available():
|
||||
return "cuda", "float16"
|
||||
except Exception:
|
||||
pass
|
||||
return "cpu", "int8"
|
||||
|
||||
# Peak VRAM (GB) to load *and transcribe* whisper large-v3 per CTranslate2
|
||||
@@ -979,10 +981,12 @@ class FasterWhisperBackend(ASRBackend):
|
||||
# - Apple Silicon / CPU → CPU int8 (fastest on CPU, negligible
|
||||
# WER regression vs fp32 for whisper-large-v3)
|
||||
device, compute_type = "cpu", "int8"
|
||||
# _ctranslate2_cuda_ok, not torch.cuda.is_available: ROCm torch also
|
||||
# answers True there, and CTranslate2 has no HIP backend (#1529).
|
||||
if _ctranslate2_cuda_ok():
|
||||
device, compute_type = "cuda", "float16"
|
||||
try:
|
||||
import torch
|
||||
if torch.cuda.is_available():
|
||||
device, compute_type = "cuda", "float16"
|
||||
except Exception:
|
||||
pass
|
||||
logger.info(
|
||||
"faster-whisper loading %s on %s (%s)",
|
||||
self._model_name, device, compute_type,
|
||||
@@ -2460,45 +2464,6 @@ def _mps_available() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _cuda_reported_available() -> bool:
|
||||
"""``torch.cuda.is_available()`` verbatim — True on real CUDA *and* HIP."""
|
||||
try:
|
||||
import torch
|
||||
|
||||
return bool(torch.cuda.is_available())
|
||||
except Exception: # noqa: BLE001 — no torch
|
||||
return False
|
||||
|
||||
|
||||
def _rocm_torch() -> bool:
|
||||
"""True when torch is the ROCm (HIP) build.
|
||||
|
||||
ROCm torch masquerades as CUDA: ``torch.cuda.is_available()`` answers True
|
||||
and tensors live on ``"cuda"`` devices, but the CUDA *runtime libraries*
|
||||
other packages ship are still NVIDIA-only. ``torch.version.hip`` is the
|
||||
one honest tell.
|
||||
"""
|
||||
try:
|
||||
import torch
|
||||
|
||||
return getattr(torch.version, "hip", None) is not None
|
||||
except Exception: # noqa: BLE001 — no torch
|
||||
return False
|
||||
|
||||
|
||||
def _ctranslate2_cuda_ok() -> bool:
|
||||
"""Whether CTranslate2 (whisperx / faster-whisper) may use ``"cuda"``.
|
||||
|
||||
CTranslate2 has NO HIP backend. On a ROCm host torch says cuda is
|
||||
available (HIP), the device string is handed to CTranslate2, and its
|
||||
NVIDIA CUDA runtime dies with "CUDA driver version is insufficient for
|
||||
CUDA runtime version" — the #1529 report, an AMD RX 7900 XTX in the
|
||||
:rocm Docker image. Real CUDA only; ROCm hosts take the CPU path here
|
||||
(auto-detect prefers pytorch-whisper there, which does use HIP).
|
||||
"""
|
||||
return _cuda_reported_available() and not _rocm_torch()
|
||||
|
||||
|
||||
def _auto_detect() -> str:
|
||||
"""Pick the best available ASR engine **for this hardware**.
|
||||
|
||||
@@ -2530,14 +2495,6 @@ def _auto_detect() -> str:
|
||||
"""
|
||||
if _mps_available() and _probe_available(MLXWhisperBackend):
|
||||
return "mlx-whisper"
|
||||
# Same class as the Apple case, on the ROCm axis (#1529): whisperx and
|
||||
# faster-whisper are CTranslate2, which has no HIP backend — on a ROCm
|
||||
# host they run on the CPU while the GPU sits idle (and before
|
||||
# _ctranslate2_cuda_ok they died outright trying NVIDIA's runtime).
|
||||
# pytorch-whisper is a pure transformers pipeline riding torch itself,
|
||||
# so it genuinely uses the HIP GPU there.
|
||||
if _rocm_torch() and _cuda_reported_available() and _probe_available(PyTorchWhisperBackend):
|
||||
return "pytorch-whisper"
|
||||
if _probe_available(WhisperXBackend):
|
||||
return "whisperx"
|
||||
if _probe_available(FasterWhisperBackend):
|
||||
|
||||
@@ -251,33 +251,10 @@ def list_backends() -> list[dict]:
|
||||
"effective_device": "network",
|
||||
"routing_status": "n/a",
|
||||
"routing_reason": None,
|
||||
# The openai-compat family entry and the LLM Providers panel are
|
||||
# ONE system (this backend resolves through the active provider),
|
||||
# but the UI presented them as unrelated. Naming the resolved
|
||||
# provider + model here lets the catalogue row say which endpoint
|
||||
# actually answers, instead of a generic family label.
|
||||
"hint": _provider_hint(bid) if ok else None,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _provider_hint(bid: str) -> str | None:
|
||||
"""``Provider · model`` for the openai-compat row, None for everything else."""
|
||||
if bid != "openai-compat":
|
||||
return None
|
||||
try:
|
||||
from services import llm_providers
|
||||
p = llm_providers.active_provider()
|
||||
if p is None:
|
||||
return None
|
||||
model = llm_providers.resolve_model(p)
|
||||
return f"{p.display_name} · {model}" if model else p.display_name
|
||||
except Exception:
|
||||
# The hint is decoration; a provider-registry hiccup must not take
|
||||
# down the whole engines listing.
|
||||
return None
|
||||
|
||||
|
||||
def active_backend_id() -> str:
|
||||
explicit = os.environ.get("OMNIVOICE_LLM_BACKEND")
|
||||
if explicit:
|
||||
|
||||
@@ -49,38 +49,9 @@ if not os.environ.get("OMNIVOICE_ENV_FILE"):
|
||||
os.environ["OMNIVOICE_MODEL"] = "test"
|
||||
|
||||
|
||||
import functools
|
||||
import shutil
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def supports_symlinks() -> bool:
|
||||
"""True when this process may create symlinks. On Windows,
|
||||
``os.symlink`` raises OSError without Developer Mode or admin rights, so
|
||||
symlink-dependent assertions must be skipped there rather than fail."""
|
||||
probe_dir = tempfile.mkdtemp(prefix="omnivoice-symlink-probe-")
|
||||
try:
|
||||
target = os.path.join(probe_dir, "target")
|
||||
with open(target, "w", encoding="utf-8"):
|
||||
pass
|
||||
try:
|
||||
os.symlink(target, os.path.join(probe_dir, "link"))
|
||||
except (OSError, NotImplementedError):
|
||||
return False
|
||||
return True
|
||||
finally:
|
||||
shutil.rmtree(probe_dir, ignore_errors=True)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def symlinks_supported() -> bool:
|
||||
"""Bool fixture over :func:`supports_symlinks` for guarding the
|
||||
symlink-only assertions of a test while its other assertions still run."""
|
||||
return supports_symlinks()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def asr_model_installed(monkeypatch, request):
|
||||
"""Neutralize the no-ASR-installed preflight (asr_model_missing_error →
|
||||
|
||||
@@ -9,10 +9,7 @@ generation.py's proven ``_run_inference`` rather than re-implementing it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import wave
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -26,23 +23,6 @@ from core import archetypes # noqa: E402
|
||||
from api.routers import archetypes as arch_router # noqa: E402
|
||||
|
||||
|
||||
def _wav_bytes() -> bytes:
|
||||
buf = io.BytesIO()
|
||||
with wave.open(buf, "wb") as wav:
|
||||
wav.setnchannels(1)
|
||||
wav.setsampwidth(2)
|
||||
wav.setframerate(24_000)
|
||||
wav.writeframes(b"\x00\x01" * 64)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _write_wav(path: Path) -> bytes:
|
||||
data = _wav_bytes()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(data)
|
||||
return data
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def client():
|
||||
app = FastAPI()
|
||||
@@ -153,7 +133,8 @@ def test_preview_serves_cached_wav_without_model(client):
|
||||
key = arch_router._preview_key(sample)
|
||||
cache_dir = Path(arch_router._PREVIEW_DIR)
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
dummy = _write_wav(cache_dir / f"{key}.wav")
|
||||
dummy = b"RIFF\x24\x00\x00\x00WAVEfmt cached-archetype-preview"
|
||||
(cache_dir / f"{key}.wav").write_bytes(dummy)
|
||||
|
||||
r = client.get(f"/archetypes/{sample['id']}/preview")
|
||||
assert r.status_code == 200
|
||||
@@ -162,7 +143,7 @@ def test_preview_serves_cached_wav_without_model(client):
|
||||
|
||||
|
||||
# ── Materialize-on-use idempotency (dedup, no re-render) ───────────────────────
|
||||
def test_use_is_idempotent_dedup(client, tmp_path, monkeypatch, symlinks_supported):
|
||||
def test_use_is_idempotent_dedup(client, monkeypatch):
|
||||
"""The 2nd `/use` of the same archetype reuses its one materialized profile
|
||||
and does NOT render again — the guarantee that materialize-on-select in any
|
||||
voice picker can't spawn duplicate rows on repeated picks.
|
||||
@@ -170,7 +151,6 @@ def test_use_is_idempotent_dedup(client, tmp_path, monkeypatch, symlinks_support
|
||||
The render boundary (``_render_archetype_wav``) is mocked so no model/GPU is
|
||||
needed: it just drops a stub WAV where the row expects one.
|
||||
"""
|
||||
from core import event_bus
|
||||
from core.db import init_db
|
||||
|
||||
init_db() # ensure the voice_profiles table exists in the hermetic tmp DB
|
||||
@@ -179,13 +159,10 @@ def test_use_is_idempotent_dedup(client, tmp_path, monkeypatch, symlinks_support
|
||||
|
||||
async def _fake_render(a, out_path):
|
||||
render_calls["n"] += 1
|
||||
_write_wav(Path(out_path))
|
||||
Path(out_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
Path(out_path).write_bytes(b"RIFF\x24\x00\x00\x00WAVEfmt stub")
|
||||
|
||||
monkeypatch.setattr(arch_router, "_render_archetype_wav", _fake_render)
|
||||
emitted = []
|
||||
monkeypatch.setattr(
|
||||
event_bus, "emit", lambda topic, payload: emitted.append((topic, payload)),
|
||||
)
|
||||
|
||||
sample = archetypes.list_archetypes(featured=True)[0]
|
||||
|
||||
@@ -204,248 +181,6 @@ def test_use_is_idempotent_dedup(client, tmp_path, monkeypatch, symlinks_support
|
||||
from core.db import db_conn
|
||||
with db_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE personality = ?",
|
||||
(arch_router._archetype_personality(sample),),
|
||||
"SELECT id FROM voice_profiles WHERE personality = ?", (sample["id"],)
|
||||
).fetchall()
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["kind"] == "design"
|
||||
assert json.loads(rows[0]["vd_states"]) == sample["attrs"]
|
||||
|
||||
with db_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM voice_profiles WHERE id=?", (pid,)).fetchone()
|
||||
assert row["kind"] == "design"
|
||||
assert row["instruct"] == sample["instruct"]
|
||||
assert json.loads(row["vd_states"]) == sample["attrs"]
|
||||
|
||||
# A missing sample or synthesis-input drift must be repaired before the
|
||||
# existing profile is returned; Preview and Use must describe one voice.
|
||||
audio_path = arch_router._profile_audio_path(row["ref_audio_path"])
|
||||
assert audio_path is not None
|
||||
audio_path.unlink()
|
||||
repaired = client.post(f"/archetypes/{sample['id']}/use")
|
||||
assert repaired.status_code == 200 and repaired.json()["profile_id"] == pid
|
||||
assert render_calls["n"] == 2
|
||||
assert audio_path.read_bytes().startswith(b"RIFF")
|
||||
|
||||
with db_conn() as conn:
|
||||
conn.execute("UPDATE voice_profiles SET instruct='male' WHERE id=?", (pid,))
|
||||
refreshed = client.post(f"/archetypes/{sample['id']}/use")
|
||||
assert refreshed.status_code == 200
|
||||
assert refreshed.json()["profile_id"] != pid
|
||||
assert render_calls["n"] == 3
|
||||
with db_conn() as conn:
|
||||
edited = conn.execute("SELECT instruct FROM voice_profiles WHERE id=?", (pid,)).fetchone()
|
||||
assert edited["instruct"] == "male"
|
||||
|
||||
# Continue corruption checks against the new canonical materialization.
|
||||
pid = refreshed.json()["profile_id"]
|
||||
with db_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM voice_profiles WHERE id=?", (pid,)).fetchone()
|
||||
audio_path = arch_router._profile_audio_path(row["ref_audio_path"])
|
||||
assert audio_path is not None
|
||||
|
||||
audio_path.write_bytes(b"not a WAV")
|
||||
repaired_corrupt = client.post(f"/archetypes/{sample['id']}/use")
|
||||
assert repaired_corrupt.status_code == 200
|
||||
assert render_calls["n"] == 4
|
||||
|
||||
if symlinks_supported: # Windows needs Developer Mode to create symlinks
|
||||
outside = tmp_path / "outside.wav"
|
||||
outside_bytes = _write_wav(outside)
|
||||
audio_path.unlink()
|
||||
audio_path.symlink_to(outside)
|
||||
repaired_symlink = client.post(f"/archetypes/{sample['id']}/use")
|
||||
assert repaired_symlink.status_code == 200
|
||||
assert render_calls["n"] == 5
|
||||
assert not audio_path.is_symlink()
|
||||
assert outside.read_bytes() == outside_bytes
|
||||
|
||||
# A valid header with a missing payload is not playable and must self-heal.
|
||||
renders_before = render_calls["n"]
|
||||
truncated = _wav_bytes()[:44]
|
||||
audio_path.write_bytes(truncated)
|
||||
repaired_truncated = client.post(f"/archetypes/{sample['id']}/use")
|
||||
assert repaired_truncated.status_code == 200
|
||||
assert render_calls["n"] == renders_before + 1
|
||||
assert audio_path.read_bytes() != truncated
|
||||
|
||||
|
||||
def test_archetype_staged_repair_preserves_concurrently_edited_profile(
|
||||
client, monkeypatch,
|
||||
):
|
||||
"""A repair may publish only if the row still belongs to the archetype."""
|
||||
from core.config import VOICES_DIR
|
||||
from core.db import db_conn, init_db
|
||||
|
||||
init_db()
|
||||
sample = archetypes.list_archetypes(featured=True)[3]
|
||||
personality = arch_router._archetype_personality(sample)
|
||||
edited_personality = f"user-edited:{sample['id']}"
|
||||
with db_conn() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM voice_profiles WHERE personality IN (?, ?, ?)",
|
||||
(sample["id"], personality, edited_personality),
|
||||
)
|
||||
|
||||
original_id = {"value": None}
|
||||
mutation_seen = {"value": False}
|
||||
|
||||
async def racing_render(_item, path):
|
||||
destination = Path(path)
|
||||
if destination.name.endswith(".staged.wav"):
|
||||
assert original_id["value"] is not None
|
||||
with db_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE voice_profiles SET name='User edit', personality=? WHERE id=?",
|
||||
(edited_personality, original_id["value"]),
|
||||
)
|
||||
mutation_seen["value"] = True
|
||||
_write_wav(destination)
|
||||
|
||||
monkeypatch.setattr(arch_router, "_render_archetype_wav", racing_render)
|
||||
first = client.post(f"/archetypes/{sample['id']}/use")
|
||||
assert first.status_code == 200
|
||||
original_id["value"] = first.json()["profile_id"]
|
||||
|
||||
with db_conn() as conn:
|
||||
original = conn.execute(
|
||||
"SELECT ref_audio_path FROM voice_profiles WHERE id=?",
|
||||
(original_id["value"],),
|
||||
).fetchone()
|
||||
original_audio = arch_router._profile_audio_path(original["ref_audio_path"])
|
||||
assert original_audio is not None
|
||||
corrupt_bytes = b"corrupt user-owned sample"
|
||||
original_audio.write_bytes(corrupt_bytes)
|
||||
|
||||
repaired = client.post(f"/archetypes/{sample['id']}/use")
|
||||
assert repaired.status_code == 200
|
||||
repaired_id = repaired.json()["profile_id"]
|
||||
assert mutation_seen["value"]
|
||||
assert repaired_id != original_id["value"]
|
||||
|
||||
with db_conn() as conn:
|
||||
edited = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id=?", (original_id["value"],),
|
||||
).fetchone()
|
||||
canonical = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id=?", (repaired_id,),
|
||||
).fetchone()
|
||||
canonical_count = conn.execute(
|
||||
"SELECT count(*) FROM voice_profiles WHERE personality=?", (personality,),
|
||||
).fetchone()[0]
|
||||
assert edited["name"] == "User edit"
|
||||
assert edited["personality"] == edited_personality
|
||||
assert edited["instruct"] == sample["instruct"]
|
||||
assert original_audio.read_bytes() == corrupt_bytes
|
||||
assert canonical["personality"] == personality
|
||||
assert canonical["ref_audio_path"] == arch_router._profile_audio_filename(repaired_id)
|
||||
assert canonical_count == 1
|
||||
assert (Path(VOICES_DIR) / canonical["ref_audio_path"]).read_bytes() == _wav_bytes()
|
||||
assert not list(Path(VOICES_DIR).glob(f".{original_id['value']}-*.staged.wav"))
|
||||
|
||||
|
||||
def test_archetype_use_adopts_only_a_compatible_legacy_row(client, monkeypatch):
|
||||
from core.config import VOICES_DIR
|
||||
from core.db import db_conn, init_db
|
||||
|
||||
init_db()
|
||||
sample = archetypes.list_archetypes(featured=True)[1]
|
||||
legacy_id = "legacyarch"
|
||||
legacy_audio = Path(VOICES_DIR) / f"{legacy_id}.wav"
|
||||
_write_wav(legacy_audio)
|
||||
with db_conn() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM voice_profiles WHERE personality IN (?, ?)",
|
||||
(sample["id"], arch_router._archetype_personality(sample)),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO voice_profiles "
|
||||
"(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, "
|
||||
"kind, vd_states, created_at) VALUES (?, 'Legacy archetype', ?, ?, ?, ?, 42, ?, "
|
||||
"'clone', NULL, 1)",
|
||||
(
|
||||
legacy_id, legacy_audio.name, sample["sample_script"], sample["instruct"],
|
||||
sample["language"], sample["id"],
|
||||
),
|
||||
)
|
||||
|
||||
async def unexpected_render(*_args):
|
||||
raise AssertionError("a valid legacy archetype sample must be reused")
|
||||
|
||||
monkeypatch.setattr(arch_router, "_render_archetype_wav", unexpected_render)
|
||||
response = client.post(f"/archetypes/{sample['id']}/use")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["profile_id"] == legacy_id
|
||||
with db_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM voice_profiles WHERE id=?", (legacy_id,)).fetchone()
|
||||
assert row["personality"] == arch_router._archetype_personality(sample)
|
||||
assert row["kind"] == "design"
|
||||
assert json.loads(row["vd_states"]) == sample["attrs"]
|
||||
|
||||
|
||||
def test_archetype_use_does_not_rewrite_an_imported_personality_collision(
|
||||
client, monkeypatch,
|
||||
):
|
||||
from core.config import VOICES_DIR
|
||||
from core.db import db_conn, init_db
|
||||
|
||||
init_db()
|
||||
sample = archetypes.list_archetypes(featured=True)[2]
|
||||
imported_id = "importedarch"
|
||||
imported_ns_id = "importedarchns"
|
||||
imported_audio = Path(VOICES_DIR) / f"{imported_id}.wav"
|
||||
imported_ns_audio = Path(VOICES_DIR) / f"{imported_ns_id}.wav"
|
||||
original_audio = _write_wav(imported_audio)
|
||||
original_ns_audio = _write_wav(imported_ns_audio)
|
||||
with db_conn() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM voice_profiles WHERE personality IN (?, ?)",
|
||||
(sample["id"], arch_router._archetype_personality(sample)),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO voice_profiles "
|
||||
"(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, "
|
||||
"kind, is_locked, verified_own_voice, created_at) VALUES "
|
||||
"(?, 'Imported collision', ?, 'user transcript', 'male', 'Auto', NULL, ?, "
|
||||
"'clone', 1, 1, 1)",
|
||||
(imported_id, imported_audio.name, sample["id"]),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO voice_profiles "
|
||||
"(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, "
|
||||
"kind, vd_states, is_locked, verified_own_voice, created_at) VALUES "
|
||||
"(?, 'Imported namespaced collision', ?, ?, ?, ?, 42, ?, "
|
||||
"'design', NULL, 0, 0, 2)",
|
||||
(
|
||||
imported_ns_id, imported_ns_audio.name, sample["sample_script"],
|
||||
sample["instruct"], sample["language"],
|
||||
arch_router._archetype_personality(sample),
|
||||
),
|
||||
)
|
||||
|
||||
async def render(_item, path):
|
||||
_write_wav(Path(path))
|
||||
|
||||
monkeypatch.setattr(arch_router, "_render_archetype_wav", render)
|
||||
response = client.post(f"/archetypes/{sample['id']}/use")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["profile_id"] != imported_id
|
||||
with db_conn() as conn:
|
||||
imported = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id=?", (imported_id,),
|
||||
).fetchone()
|
||||
imported_ns = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id=?", (imported_ns_id,),
|
||||
).fetchone()
|
||||
created = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id=?", (response.json()["profile_id"],),
|
||||
).fetchone()
|
||||
assert imported["personality"] == sample["id"]
|
||||
assert imported["instruct"] == "male"
|
||||
assert imported["ref_text"] == "user transcript"
|
||||
assert imported_audio.read_bytes() == original_audio
|
||||
assert imported_ns["instruct"] == sample["instruct"]
|
||||
assert imported_ns["ref_text"] == sample["sample_script"]
|
||||
assert imported_ns["vd_states"] is None
|
||||
assert imported_ns_audio.read_bytes() == original_ns_audio
|
||||
assert created["personality"] == arch_router._archetype_personality(sample)
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
"""Regression tests for the lightweight persisted-WAV trust boundary."""
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
|
||||
from core.audio_validation import is_playable_wav, resolve_regular_file
|
||||
|
||||
|
||||
def test_oversized_declared_wav_payload_is_not_treated_as_playable(tmp_path):
|
||||
"""A hostile frame count must be bounded and backed by real payload bytes."""
|
||||
path = tmp_path / "oversized.wav"
|
||||
declared_size = 0xFFFF_FFF0
|
||||
header = struct.pack(
|
||||
"<4sI4s4sIHHIIHH4sI",
|
||||
b"RIFF",
|
||||
0xFFFF_FFFF,
|
||||
b"WAVE",
|
||||
b"fmt ",
|
||||
16,
|
||||
1,
|
||||
1,
|
||||
24_000,
|
||||
48_000,
|
||||
2,
|
||||
16,
|
||||
b"data",
|
||||
declared_size,
|
||||
)
|
||||
path.write_bytes(header + b"\x00\x01")
|
||||
|
||||
assert not is_playable_wav(path)
|
||||
|
||||
|
||||
def test_profile_wav_resolution_rejects_escape_and_symlink(tmp_path, symlinks_supported):
|
||||
root = tmp_path / "voices"
|
||||
root.mkdir()
|
||||
outside = tmp_path / "outside.wav"
|
||||
outside.write_bytes(b"outside")
|
||||
|
||||
assert resolve_regular_file(root, "../outside.wav") is None
|
||||
assert resolve_regular_file(root, str(outside)) is None
|
||||
if symlinks_supported: # Windows needs Developer Mode to create symlinks
|
||||
(root / "linked.wav").symlink_to(outside)
|
||||
assert resolve_regular_file(root, "linked.wav") is None
|
||||
@@ -1,43 +1,25 @@
|
||||
"""Tests for the community gallery (marketplace) loader.
|
||||
|
||||
Covers strict item validation, manifest/cache boundaries, same-origin preview,
|
||||
and idempotent profile materialization without a model or network dependency.
|
||||
Covers the no-network surface: strict item validation (invalid presets and
|
||||
unsafe audio URLs are dropped so they can never crash synthesis or fetch from
|
||||
an arbitrary host), manifest merge/dedup, offline cache reads, filtering, and
|
||||
the prefilled submit URL. The render/download paths need the model/network and
|
||||
are exercised at runtime.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import wave
|
||||
|
||||
import pytest
|
||||
|
||||
# conftest.py puts `backend/` on sys.path and points OMNIVOICE_DATA_DIR at a
|
||||
# throwaway tmpdir before this module imports the REAL core.config (the old
|
||||
# sys.modules stub leaked at collection time and broke mixed runs).
|
||||
from fastapi import FastAPI, HTTPException, Response # noqa: E402
|
||||
from fastapi import FastAPI # noqa: E402
|
||||
from fastapi.testclient import TestClient # noqa: E402
|
||||
|
||||
from api.routers import community # noqa: E402
|
||||
|
||||
|
||||
def _wav_bytes() -> bytes:
|
||||
buf = io.BytesIO()
|
||||
with wave.open(buf, "wb") as wav:
|
||||
wav.setnchannels(1)
|
||||
wav.setsampwidth(2)
|
||||
wav.setframerate(24_000)
|
||||
wav.writeframes(b"\x00\x01" * 64)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _write_wav(path: Path) -> bytes:
|
||||
data = _wav_bytes()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(data)
|
||||
return data
|
||||
|
||||
_FIXTURE = {
|
||||
"schema_version": 1,
|
||||
"items": [
|
||||
@@ -93,51 +75,12 @@ def test_unknown_use_case_dropped():
|
||||
assert community.validate_item(_FIXTURE["items"][4]) is None
|
||||
|
||||
|
||||
def test_malformed_manifest_entries_do_not_break_other_sources():
|
||||
valid = _FIXTURE["items"][0]
|
||||
items, packs = community._merge([
|
||||
("bad/repo", {"items": 42, "packs": "not-a-list"}),
|
||||
("good/repo", {"items": [None, "not-an-item", valid], "packs": [None]}),
|
||||
])
|
||||
|
||||
assert [item["id"] for item in items] == [valid["id"]]
|
||||
assert packs == []
|
||||
|
||||
|
||||
def test_is_valid_instruct():
|
||||
assert community.is_valid_instruct("male, elderly, very low pitch")
|
||||
assert not community.is_valid_instruct("male, sultry")
|
||||
assert not community.is_valid_instruct("male, female")
|
||||
assert not community.is_valid_instruct("british accent, 四川话")
|
||||
assert not community.is_valid_instruct("")
|
||||
|
||||
|
||||
def test_preset_attrs_are_normalized_and_complete():
|
||||
item = community.validate_item(_FIXTURE["items"][0])
|
||||
assert item["instruct"] == "female, middle-aged, low pitch"
|
||||
assert item["attrs"] == {
|
||||
"Gender": "female", "Age": "middle-aged", "Pitch": "low pitch",
|
||||
"Style": "Auto", "EnglishAccent": "Auto", "ChineseDialect": "Auto",
|
||||
}
|
||||
assert item["preview_url"] == "/community/items/p1/preview"
|
||||
|
||||
|
||||
def test_remote_transcript_fields_are_bounded():
|
||||
preset = community.validate_item({
|
||||
**_FIXTURE["items"][0],
|
||||
"sample_script": " x " * (community._MAX_SAMPLE_SCRIPT_CHARS + 10),
|
||||
})
|
||||
voice = community.validate_item({
|
||||
**_FIXTURE["items"][3],
|
||||
"audio": {
|
||||
**_FIXTURE["items"][3]["audio"],
|
||||
"ref_text": " y " * (community._MAX_REF_TEXT_CHARS + 10),
|
||||
},
|
||||
})
|
||||
assert len(preset["sample_script"]) == community._MAX_SAMPLE_SCRIPT_CHARS
|
||||
assert len(voice["audio"]["ref_text"]) == community._MAX_REF_TEXT_CHARS
|
||||
|
||||
|
||||
# ── merge keeps only valid items ──────────────────────────────────────────────
|
||||
def test_merge_drops_invalid_and_dedups():
|
||||
items, packs = community._merge([("debpalash/omnivoice-gallery", _FIXTURE)])
|
||||
@@ -173,620 +116,3 @@ def test_submit_url(client):
|
||||
voice = client.get("/community/submit-url", params={"type": "voice"}).json()["url"]
|
||||
assert "preset-submission.yml" in preset and "omnivoice-gallery" in preset
|
||||
assert "voice-submission.yml" in voice
|
||||
|
||||
|
||||
# ── bounded cache freshness + stale offline fallback ─────────────────────────
|
||||
def test_stale_manifest_refreshes_then_stays_fresh(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(community, "_CACHE_DIR", tmp_path)
|
||||
source = "debpalash/omnivoice-gallery"
|
||||
cache = community._cache_path(source)
|
||||
cache.parent.mkdir(parents=True)
|
||||
cache.write_text(json.dumps(_FIXTURE), encoding="utf-8")
|
||||
os.utime(cache, (100.0, 100.0))
|
||||
|
||||
fresh = {**_FIXTURE, "updated_at": "new"}
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
community, "_fetch_remote_manifest",
|
||||
lambda src: calls.append(src) or fresh,
|
||||
)
|
||||
now = 100.0 + community._MANIFEST_MAX_AGE_S + 1
|
||||
assert community._fetch_manifest(source, False, now=now)["updated_at"] == "new"
|
||||
assert community._fetch_manifest(source, False, now=now + 1)["updated_at"] == "new"
|
||||
assert calls == [source]
|
||||
|
||||
|
||||
def test_stale_manifest_falls_back_and_throttles_offline_retry(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(community, "_CACHE_DIR", tmp_path)
|
||||
source = "debpalash/omnivoice-gallery"
|
||||
cache = community._cache_path(source)
|
||||
cache.parent.mkdir(parents=True)
|
||||
cache.write_text(json.dumps(_FIXTURE), encoding="utf-8")
|
||||
os.utime(cache, (100.0, 100.0))
|
||||
|
||||
calls = []
|
||||
def offline(src):
|
||||
calls.append(src)
|
||||
raise OSError("offline")
|
||||
monkeypatch.setattr(community, "_fetch_remote_manifest", offline)
|
||||
now = 100.0 + community._MANIFEST_MAX_AGE_S + 1
|
||||
assert community._fetch_manifest(source, False, now=now) == _FIXTURE
|
||||
assert community._fetch_manifest(source, False, now=now + 1) == _FIXTURE
|
||||
assert calls == [source]
|
||||
|
||||
|
||||
def test_manifest_fetch_is_bounded(monkeypatch):
|
||||
monkeypatch.setattr(community, "_MAX_MANIFEST_BYTES", 8)
|
||||
|
||||
class Response:
|
||||
status_code = 200
|
||||
headers = {}
|
||||
def __enter__(self): return self
|
||||
def __exit__(self, *_args): return False
|
||||
def raise_for_status(self): return None
|
||||
def iter_bytes(self): yield b'{"items":[]}'
|
||||
class Client:
|
||||
def stream(self, method, url, **kwargs):
|
||||
assert method == "GET"
|
||||
assert url.startswith("https://cdn.jsdelivr.net/")
|
||||
assert kwargs == {"follow_redirects": False}
|
||||
return Response()
|
||||
|
||||
with pytest.raises(ValueError, match="size limit"):
|
||||
community._fetch_remote_manifest("test/source", client=Client())
|
||||
|
||||
|
||||
def test_manifest_fetch_rejects_redirect_before_external_request():
|
||||
requested = []
|
||||
|
||||
class Response:
|
||||
status_code = 302
|
||||
headers = {"location": "https://evil.example/manifest.json"}
|
||||
def __enter__(self): return self
|
||||
def __exit__(self, *_args): return False
|
||||
class Client:
|
||||
def stream(self, _method, url, **_kwargs):
|
||||
requested.append(url)
|
||||
return Response()
|
||||
|
||||
with pytest.raises(ValueError, match="disallowed host"):
|
||||
community._fetch_remote_manifest("test/source", client=Client())
|
||||
assert requested == [community._manifest_url("test/source")]
|
||||
|
||||
|
||||
# ── Preview proxy ─────────────────────────────────────────────────────────────
|
||||
def test_canonical_preset_preview_delegates_same_origin(client, monkeypatch):
|
||||
from core import archetypes
|
||||
from api.routers import archetypes as arch_router
|
||||
|
||||
canonical = archetypes.list_archetypes(featured=True)[0]
|
||||
item = community.validate_item({
|
||||
**canonical, "type": "preset", "source": "starter",
|
||||
})
|
||||
monkeypatch.setattr(
|
||||
community, "_load", lambda _refresh: (["test/source"], [item], [], False),
|
||||
)
|
||||
delegated = []
|
||||
|
||||
async def preview(archetype_id, local=False):
|
||||
delegated.append((archetype_id, local))
|
||||
return Response(_wav_bytes(), media_type="audio/wav")
|
||||
|
||||
monkeypatch.setattr(arch_router, "preview_archetype", preview)
|
||||
response = client.get(f"/community/items/{item['id']}/preview")
|
||||
local = client.get(f"/community/items/{item['id']}/preview?local=true")
|
||||
|
||||
assert response.status_code == local.status_code == 200
|
||||
assert "location" not in response.headers
|
||||
assert delegated == [(item["id"], False), (item["id"], True)]
|
||||
|
||||
|
||||
def test_noncanonical_preset_preview_renders_once(client, tmp_path, monkeypatch):
|
||||
item = community.validate_item(_FIXTURE["items"][0])
|
||||
monkeypatch.setattr(community, "_CACHE_DIR", tmp_path)
|
||||
monkeypatch.setattr(
|
||||
community, "_load", lambda _refresh: (["test/source"], [item], [], False),
|
||||
)
|
||||
from api.routers import archetypes as arch_router
|
||||
calls = []
|
||||
async def render(_item, path):
|
||||
calls.append(path)
|
||||
_write_wav(Path(path))
|
||||
monkeypatch.setattr(arch_router, "_render_archetype_wav", render)
|
||||
|
||||
first = client.get("/community/items/p1/preview")
|
||||
second = client.get("/community/items/p1/preview")
|
||||
assert first.status_code == second.status_code == 200
|
||||
assert first.content == _wav_bytes()
|
||||
assert first.headers["x-omnivoice-preview-source"] == "community"
|
||||
assert len(calls) == 1
|
||||
|
||||
community._preset_preview_path(item).write_bytes(b"not audio")
|
||||
repaired = client.get("/community/items/p1/preview")
|
||||
assert repaired.status_code == 200
|
||||
assert repaired.content == _wav_bytes()
|
||||
assert len(calls) == 2
|
||||
|
||||
|
||||
def test_recorded_preview_is_served_from_same_origin(client, tmp_path, monkeypatch):
|
||||
item = community.validate_item(_FIXTURE["items"][3])
|
||||
clip = tmp_path / "voice.wav"
|
||||
expected = _write_wav(clip)
|
||||
monkeypatch.setattr(
|
||||
community, "_load", lambda _refresh: (["test/source"], [item], [], False),
|
||||
)
|
||||
monkeypatch.setattr(community, "_cached_voice_audio", lambda _item: clip)
|
||||
response = client.get("/community/items/v1/preview")
|
||||
assert response.status_code == 200
|
||||
assert response.content == expected
|
||||
|
||||
|
||||
def test_recorded_download_cap_is_atomic(tmp_path, monkeypatch):
|
||||
item = community.validate_item(_FIXTURE["items"][3])
|
||||
destination = tmp_path / "voice.wav"
|
||||
destination.write_bytes(b"existing-good-audio")
|
||||
monkeypatch.setattr(community, "_MAX_VOICE_AUDIO_BYTES", 8)
|
||||
|
||||
class Response:
|
||||
status_code = 200
|
||||
headers = {}
|
||||
def __enter__(self): return self
|
||||
def __exit__(self, *_args): return False
|
||||
def raise_for_status(self): return None
|
||||
def iter_bytes(self): yield b"123456789"
|
||||
class Client:
|
||||
def stream(self, method, url, **kwargs):
|
||||
assert method == "GET" and url.startswith("https://github.com/")
|
||||
assert kwargs == {"follow_redirects": False}
|
||||
return Response()
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
community._download_voice_audio(item, destination, client=Client())
|
||||
assert getattr(exc.value, "status_code", None) == 502
|
||||
assert destination.read_bytes() == b"existing-good-audio"
|
||||
assert not list(tmp_path.glob(".*.part"))
|
||||
|
||||
|
||||
def test_recorded_download_rejects_redirect_before_external_request(tmp_path):
|
||||
item = community.validate_item(_FIXTURE["items"][3])
|
||||
requested = []
|
||||
|
||||
class Response:
|
||||
status_code = 302
|
||||
headers = {"location": "https://evil.example/private.wav"}
|
||||
def __enter__(self): return self
|
||||
def __exit__(self, *_args): return False
|
||||
class Client:
|
||||
def stream(self, _method, url, **_kwargs):
|
||||
requested.append(url)
|
||||
return Response()
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
community._download_voice_audio(item, tmp_path / "voice.wav", client=Client())
|
||||
assert getattr(exc.value, "status_code", None) == 502
|
||||
assert requested == [item["audio"]["url"]]
|
||||
|
||||
|
||||
def test_recorded_download_follows_allowlisted_redirect(tmp_path):
|
||||
item = community.validate_item(_FIXTURE["items"][3])
|
||||
destination = tmp_path / "voice.wav"
|
||||
requested = []
|
||||
expected = _wav_bytes()
|
||||
|
||||
class Response:
|
||||
def __init__(self, status, headers, body=b""):
|
||||
self.status_code, self.headers, self.body = status, headers, body
|
||||
def __enter__(self): return self
|
||||
def __exit__(self, *_args): return False
|
||||
def raise_for_status(self): return None
|
||||
def iter_bytes(self): yield self.body
|
||||
class Client:
|
||||
def stream(self, _method, url, **_kwargs):
|
||||
requested.append(url)
|
||||
if len(requested) == 1:
|
||||
return Response(302, {"location": "https://objects.githubusercontent.com/v1.wav"})
|
||||
return Response(200, {}, expected)
|
||||
|
||||
community._download_voice_audio(item, destination, client=Client())
|
||||
assert destination.read_bytes() == expected
|
||||
assert requested == [item["audio"]["url"], "https://objects.githubusercontent.com/v1.wav"]
|
||||
|
||||
|
||||
def test_recorded_download_rejects_non_audio_bytes(tmp_path):
|
||||
item = community.validate_item(_FIXTURE["items"][3])
|
||||
destination = tmp_path / "voice.wav"
|
||||
|
||||
class Response:
|
||||
status_code = 200
|
||||
headers = {}
|
||||
def __enter__(self): return self
|
||||
def __exit__(self, *_args): return False
|
||||
def raise_for_status(self): return None
|
||||
def iter_bytes(self): yield b"this is not audio"
|
||||
class Client:
|
||||
def stream(self, _method, _url, **_kwargs): return Response()
|
||||
|
||||
with pytest.raises(HTTPException, match="valid WAV"):
|
||||
community._download_voice_audio(item, destination, client=Client())
|
||||
assert not destination.exists()
|
||||
assert not list(tmp_path.glob(".*.part"))
|
||||
|
||||
|
||||
# ── Materialization ───────────────────────────────────────────────────────────
|
||||
def test_community_use_is_idempotent_design_profile(
|
||||
client, tmp_path, monkeypatch, symlinks_supported,
|
||||
):
|
||||
from core import event_bus
|
||||
from core.db import db_conn, init_db
|
||||
from api.routers import archetypes as arch_router
|
||||
|
||||
init_db()
|
||||
item = community.validate_item(_FIXTURE["items"][0])
|
||||
item["_source_repo"] = "test/source"
|
||||
personality = community._community_personality(item)
|
||||
monkeypatch.setattr(
|
||||
community, "_load", lambda _refresh: (["test/source"], [item], [], False),
|
||||
)
|
||||
calls = []
|
||||
emitted = []
|
||||
async def render(_item, path):
|
||||
calls.append(path)
|
||||
_write_wav(Path(path))
|
||||
monkeypatch.setattr(arch_router, "_render_archetype_wav", render)
|
||||
monkeypatch.setattr(
|
||||
event_bus, "emit", lambda topic, payload: emitted.append((topic, payload)),
|
||||
)
|
||||
with db_conn() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM voice_profiles WHERE personality IN (?, ?)",
|
||||
(item["id"], personality),
|
||||
)
|
||||
|
||||
first = client.post("/community/items/p1/use")
|
||||
second = client.post("/community/items/p1/use")
|
||||
assert first.status_code == second.status_code == 200
|
||||
assert second.json()["profile_id"] == first.json()["profile_id"]
|
||||
assert len(calls) == 1
|
||||
with db_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id=?", (first.json()["profile_id"],),
|
||||
).fetchone()
|
||||
assert row["kind"] == "design"
|
||||
assert row["personality"] == personality
|
||||
assert json.loads(row["vd_states"])["Gender"] == "female"
|
||||
assert row["instruct"] == item["instruct"]
|
||||
assert emitted[-1] == (
|
||||
"profiles", {"action": "updated", "id": first.json()["profile_id"]},
|
||||
)
|
||||
|
||||
profile_audio = community._stored_profile_audio(row["ref_audio_path"])
|
||||
assert profile_audio is not None
|
||||
profile_audio.unlink()
|
||||
repaired = client.post("/community/items/p1/use")
|
||||
assert repaired.status_code == 200
|
||||
assert repaired.json()["profile_id"] == first.json()["profile_id"]
|
||||
assert profile_audio.read_bytes() == _wav_bytes()
|
||||
# The current preset preview cache repairs the profile without another
|
||||
# model render.
|
||||
assert len(calls) == 1
|
||||
|
||||
profile_audio.write_bytes(b"not a WAV")
|
||||
repaired_corrupt = client.post("/community/items/p1/use")
|
||||
assert repaired_corrupt.status_code == 200
|
||||
assert profile_audio.read_bytes() == _wav_bytes()
|
||||
|
||||
if symlinks_supported: # Windows needs Developer Mode to create symlinks
|
||||
outside = tmp_path / "outside.wav"
|
||||
outside_bytes = _write_wav(outside)
|
||||
profile_audio.unlink()
|
||||
profile_audio.symlink_to(outside)
|
||||
repaired_symlink = client.post("/community/items/p1/use")
|
||||
assert repaired_symlink.status_code == 200
|
||||
assert not profile_audio.is_symlink()
|
||||
assert outside.read_bytes() == outside_bytes
|
||||
|
||||
|
||||
def test_community_staged_repair_preserves_concurrently_edited_profile(
|
||||
client, monkeypatch,
|
||||
):
|
||||
"""A staged community repair must not reclaim a row edited mid-copy."""
|
||||
from core.config import VOICES_DIR
|
||||
from core.db import db_conn, init_db
|
||||
|
||||
init_db()
|
||||
item = community.validate_item(_FIXTURE["items"][0])
|
||||
item["_source_repo"] = "test/source"
|
||||
personality = community._community_personality(item)
|
||||
edited_personality = f"user-edited:{personality}"
|
||||
monkeypatch.setattr(
|
||||
community, "_load", lambda _refresh: (["test/source"], [item], [], False),
|
||||
)
|
||||
with db_conn() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM voice_profiles WHERE personality IN (?, ?, ?)",
|
||||
(item["id"], personality, edited_personality),
|
||||
)
|
||||
_write_wav(community._preset_preview_path(item))
|
||||
|
||||
original_id = {"value": None}
|
||||
mutation_seen = {"value": False}
|
||||
real_copy_atomic = community._copy_atomic
|
||||
|
||||
def racing_copy(source, destination):
|
||||
destination = Path(destination)
|
||||
if destination.name.endswith(".staged.wav"):
|
||||
assert original_id["value"] is not None
|
||||
with db_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE voice_profiles SET name='User edit', personality=? WHERE id=?",
|
||||
(edited_personality, original_id["value"]),
|
||||
)
|
||||
mutation_seen["value"] = True
|
||||
real_copy_atomic(Path(source), destination)
|
||||
|
||||
monkeypatch.setattr(community, "_copy_atomic", racing_copy)
|
||||
first = client.post(f"/community/items/{item['id']}/use")
|
||||
assert first.status_code == 200
|
||||
original_id["value"] = first.json()["profile_id"]
|
||||
|
||||
with db_conn() as conn:
|
||||
original = conn.execute(
|
||||
"SELECT ref_audio_path FROM voice_profiles WHERE id=?",
|
||||
(original_id["value"],),
|
||||
).fetchone()
|
||||
original_audio = community._stored_profile_audio(original["ref_audio_path"])
|
||||
assert original_audio is not None
|
||||
corrupt_bytes = b"corrupt user-owned sample"
|
||||
original_audio.write_bytes(corrupt_bytes)
|
||||
|
||||
repaired = client.post(f"/community/items/{item['id']}/use")
|
||||
assert repaired.status_code == 200
|
||||
repaired_id = repaired.json()["profile_id"]
|
||||
assert mutation_seen["value"]
|
||||
assert repaired_id != original_id["value"]
|
||||
|
||||
with db_conn() as conn:
|
||||
edited = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id=?", (original_id["value"],),
|
||||
).fetchone()
|
||||
canonical = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id=?", (repaired_id,),
|
||||
).fetchone()
|
||||
canonical_count = conn.execute(
|
||||
"SELECT count(*) FROM voice_profiles WHERE personality=?", (personality,),
|
||||
).fetchone()[0]
|
||||
assert edited["name"] == "User edit"
|
||||
assert edited["personality"] == edited_personality
|
||||
assert edited["instruct"] == item["instruct"]
|
||||
assert original_audio.read_bytes() == corrupt_bytes
|
||||
assert canonical["personality"] == personality
|
||||
assert canonical["ref_audio_path"] == community._community_profile_audio_filename(
|
||||
repaired_id, item,
|
||||
)
|
||||
assert canonical_count == 1
|
||||
assert (Path(VOICES_DIR) / canonical["ref_audio_path"]).read_bytes() == _wav_bytes()
|
||||
assert not list(Path(VOICES_DIR).glob(f".{original_id['value']}-*.staged.wav"))
|
||||
|
||||
|
||||
def test_recorded_community_use_is_idempotent_clone_profile(client, tmp_path, monkeypatch):
|
||||
from core.db import db_conn, init_db
|
||||
|
||||
init_db()
|
||||
item = community.validate_item(_FIXTURE["items"][3])
|
||||
item["_source_repo"] = "test/source"
|
||||
personality = community._community_personality(item)
|
||||
clip = tmp_path / "recorded.wav"
|
||||
_write_wav(clip)
|
||||
cache_calls = []
|
||||
monkeypatch.setattr(
|
||||
community, "_load", lambda _refresh: (["test/source"], [item], [], False),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
community, "_cached_voice_audio", lambda _item: cache_calls.append(_item["id"]) or clip,
|
||||
)
|
||||
with db_conn() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM voice_profiles WHERE personality IN (?, ?)",
|
||||
(item["id"], personality),
|
||||
)
|
||||
|
||||
first = client.post("/community/items/v1/use")
|
||||
second = client.post("/community/items/v1/use")
|
||||
assert first.status_code == second.status_code == 200
|
||||
assert second.json()["profile_id"] == first.json()["profile_id"]
|
||||
assert cache_calls == ["v1"]
|
||||
with db_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id=?", (first.json()["profile_id"],),
|
||||
).fetchone()
|
||||
assert row["kind"] == "clone"
|
||||
assert row["personality"] == personality
|
||||
assert row["vd_states"] is None and row["instruct"] == ""
|
||||
assert row["ref_text"] == ""
|
||||
|
||||
old_audio_filename = row["ref_audio_path"]
|
||||
item["audio"]["url"] = "https://raw.githubusercontent.com/test/source/main/v2.wav"
|
||||
refreshed = client.post("/community/items/v1/use")
|
||||
assert refreshed.status_code == 200
|
||||
assert refreshed.json()["profile_id"] == first.json()["profile_id"]
|
||||
assert cache_calls == ["v1", "v1"]
|
||||
with db_conn() as conn:
|
||||
refreshed_row = conn.execute(
|
||||
"SELECT ref_audio_path FROM voice_profiles WHERE id=?",
|
||||
(first.json()["profile_id"],),
|
||||
).fetchone()
|
||||
assert refreshed_row["ref_audio_path"] != old_audio_filename
|
||||
|
||||
|
||||
def test_noncanonical_builtin_id_cannot_heal_archetype_profile(client, monkeypatch):
|
||||
from core import archetypes
|
||||
from core.db import db_conn, init_db
|
||||
from api.routers import archetypes as arch_router
|
||||
|
||||
init_db()
|
||||
canonical = archetypes.list_archetypes(featured=True)[0]
|
||||
changed_instruct = "female" if canonical["instruct"] != "female" else "male"
|
||||
item = community.validate_item({
|
||||
**canonical,
|
||||
"type": "preset",
|
||||
"source": "community",
|
||||
"instruct": changed_instruct,
|
||||
})
|
||||
item["_source_repo"] = "test/source"
|
||||
personality = community._community_personality(item)
|
||||
builtin_profile_id = f"b{os.urandom(4).hex()[:7]}"
|
||||
with db_conn() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM voice_profiles WHERE personality IN (?, ?)",
|
||||
(canonical["id"], personality),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO voice_profiles (id, name, personality, instruct, kind, created_at) "
|
||||
"VALUES (?, 'Built-in profile', ?, 'sentinel', 'design', 1)",
|
||||
(builtin_profile_id, canonical["id"]),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
community, "_load", lambda _refresh: (["test/source"], [item], [], False),
|
||||
)
|
||||
async def render(_item, path):
|
||||
_write_wav(Path(path))
|
||||
monkeypatch.setattr(arch_router, "_render_archetype_wav", render)
|
||||
|
||||
response = client.post(f"/community/items/{canonical['id']}/use")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["profile_id"] != builtin_profile_id
|
||||
with db_conn() as conn:
|
||||
builtin = conn.execute(
|
||||
"SELECT instruct FROM voice_profiles WHERE id=?", (builtin_profile_id,),
|
||||
).fetchone()
|
||||
community_row = conn.execute(
|
||||
"SELECT personality FROM voice_profiles WHERE id=?",
|
||||
(response.json()["profile_id"],),
|
||||
).fetchone()
|
||||
conn.execute(
|
||||
"DELETE FROM voice_profiles WHERE id IN (?, ?)",
|
||||
(builtin_profile_id, response.json()["profile_id"]),
|
||||
)
|
||||
assert builtin["instruct"] == "sentinel"
|
||||
assert community_row["personality"] == personality
|
||||
|
||||
|
||||
def test_community_use_does_not_rewrite_an_imported_bare_id_collision(
|
||||
client, monkeypatch,
|
||||
):
|
||||
from core.config import VOICES_DIR
|
||||
from core.db import db_conn, init_db
|
||||
from api.routers import archetypes as arch_router
|
||||
|
||||
init_db()
|
||||
item = community.validate_item(_FIXTURE["items"][0])
|
||||
item["_source_repo"] = "test/source"
|
||||
personality = community._community_personality(item)
|
||||
imported_id = "importedcomm"
|
||||
imported_ns_id = "importedcommns"
|
||||
imported_audio = Path(VOICES_DIR) / f"{imported_id}.wav"
|
||||
imported_ns_audio = Path(VOICES_DIR) / f"{imported_ns_id}.wav"
|
||||
original_audio = _write_wav(imported_audio)
|
||||
original_ns_audio = _write_wav(imported_ns_audio)
|
||||
with db_conn() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM voice_profiles WHERE personality IN (?, ?)",
|
||||
(item["id"], personality),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO voice_profiles "
|
||||
"(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, "
|
||||
"kind, is_locked, verified_own_voice, created_at) VALUES "
|
||||
"(?, 'Imported collision', ?, 'user transcript', 'male', 'Auto', NULL, ?, "
|
||||
"'clone', 1, 1, 1)",
|
||||
(imported_id, imported_audio.name, item["id"]),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO voice_profiles "
|
||||
"(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, "
|
||||
"kind, vd_states, is_locked, verified_own_voice, created_at) VALUES "
|
||||
"(?, 'Imported namespaced collision', ?, ?, ?, ?, 42, ?, "
|
||||
"'design', NULL, 0, 0, 2)",
|
||||
(
|
||||
imported_ns_id, imported_ns_audio.name, item["sample_script"],
|
||||
item["instruct"], item["language"], personality,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
community, "_load", lambda _refresh: (["test/source"], [item], [], False),
|
||||
)
|
||||
|
||||
async def render(_item, path):
|
||||
_write_wav(Path(path))
|
||||
|
||||
monkeypatch.setattr(arch_router, "_render_archetype_wav", render)
|
||||
response = client.post(f"/community/items/{item['id']}/use")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["profile_id"] != imported_id
|
||||
with db_conn() as conn:
|
||||
imported = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id=?", (imported_id,),
|
||||
).fetchone()
|
||||
imported_ns = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id=?", (imported_ns_id,),
|
||||
).fetchone()
|
||||
created = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id=?", (response.json()["profile_id"],),
|
||||
).fetchone()
|
||||
assert imported["personality"] == item["id"]
|
||||
assert imported["instruct"] == "male"
|
||||
assert imported["ref_text"] == "user transcript"
|
||||
assert imported_audio.read_bytes() == original_audio
|
||||
assert imported_ns["instruct"] == item["instruct"]
|
||||
assert imported_ns["ref_text"] == item["sample_script"]
|
||||
assert imported_ns["vd_states"] is None
|
||||
assert imported_ns_audio.read_bytes() == original_ns_audio
|
||||
assert created["personality"] == personality
|
||||
|
||||
|
||||
def test_noncolliding_legacy_community_profile_is_adopted(client, monkeypatch):
|
||||
from core.config import VOICES_DIR
|
||||
from core.db import db_conn, init_db
|
||||
from api.routers import archetypes as arch_router
|
||||
|
||||
init_db()
|
||||
item = community.validate_item(_FIXTURE["items"][0])
|
||||
item["_source_repo"] = "test/source"
|
||||
personality = community._community_personality(item)
|
||||
legacy_id = f"l{os.urandom(4).hex()[:7]}"
|
||||
with db_conn() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM voice_profiles WHERE personality IN (?, ?)",
|
||||
(item["id"], personality),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO voice_profiles "
|
||||
"(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, "
|
||||
"kind, vd_states, created_at) VALUES "
|
||||
"(?, 'Legacy community profile', ?, '', ?, ?, NULL, ?, 'design', NULL, 1)",
|
||||
(legacy_id, f"{legacy_id}.wav", item["instruct"], item["language"], item["id"]),
|
||||
)
|
||||
_write_wav(Path(VOICES_DIR) / f"{legacy_id}.wav")
|
||||
monkeypatch.setattr(
|
||||
community, "_load", lambda _refresh: (["test/source"], [item], [], False),
|
||||
)
|
||||
community._preset_preview_path(item).unlink(missing_ok=True)
|
||||
rendered = []
|
||||
async def render(_item, path):
|
||||
rendered.append(path)
|
||||
_write_wav(Path(path))
|
||||
monkeypatch.setattr(arch_router, "_render_archetype_wav", render)
|
||||
|
||||
response = client.post(f"/community/items/{item['id']}/use")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["profile_id"] == legacy_id
|
||||
assert len(rendered) == 1
|
||||
with db_conn() as conn:
|
||||
adopted = conn.execute(
|
||||
"SELECT personality, kind, ref_audio_path FROM voice_profiles WHERE id=?",
|
||||
(legacy_id,),
|
||||
).fetchone()
|
||||
assert adopted["personality"] == personality
|
||||
assert adopted["kind"] == "design"
|
||||
adopted_audio = community._stored_profile_audio(adopted["ref_audio_path"])
|
||||
assert adopted_audio is not None and adopted_audio.is_file()
|
||||
|
||||
@@ -1,250 +0,0 @@
|
||||
"""Gallery-import profile materialization contracts."""
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import sqlite3
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from api.routers import gallery
|
||||
from core.db import db_conn, init_db
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def client():
|
||||
init_db()
|
||||
gallery._init_gallery_db()
|
||||
app = FastAPI()
|
||||
app.include_router(gallery.router)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _gallery_voice(
|
||||
suffix: str = ".wav", content: bytes = b"RIFF imported voice",
|
||||
) -> tuple[str, Path]:
|
||||
voice_id = f"g{uuid.uuid4().hex[:7]}"
|
||||
path = gallery.VOICE_GALLERY_DIR / f"{voice_id}{suffix}"
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(content)
|
||||
with db_conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO voice_gallery
|
||||
(id, name, character, category, source_type, source_url, audio_path,
|
||||
duration, description, tags, created_at)
|
||||
VALUES (?, ?, ?, 'import', 'youtube', ?, ?, 5.0, ?, '[]', ?)""",
|
||||
(
|
||||
voice_id, "Imported narrator", "Video title is not an instruct",
|
||||
"https://example.invalid/source", str(path),
|
||||
"Source URL/notes are not a spoken transcript", time.time(),
|
||||
),
|
||||
)
|
||||
return voice_id, path
|
||||
|
||||
|
||||
def test_save_as_profile_keeps_import_metadata_out_of_tts_fields(client):
|
||||
voice_id, _ = _gallery_voice()
|
||||
response = client.post(
|
||||
f"/gallery/voices/{voice_id}/save-as-profile",
|
||||
params={"profile_name": "Reusable import"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
with db_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id=?", (response.json()["profile_id"],),
|
||||
).fetchone()
|
||||
assert row["kind"] == "clone"
|
||||
assert row["personality"] == f"gallery:{voice_id}"
|
||||
assert row["ref_text"] == ""
|
||||
assert row["instruct"] == ""
|
||||
assert row["description"] == "Source URL/notes are not a spoken transcript"
|
||||
|
||||
|
||||
def test_to_profile_uses_live_schema_and_clone_metadata(client):
|
||||
voice_id, _ = _gallery_voice()
|
||||
response = client.post(f"/gallery/voices/{voice_id}/to-profile")
|
||||
assert response.status_code == 200
|
||||
with db_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id=?", (response.json()["profile_id"],),
|
||||
).fetchone()
|
||||
assert row["kind"] == "clone"
|
||||
assert row["personality"] == f"gallery:{voice_id}"
|
||||
assert row["ref_text"] == row["instruct"] == ""
|
||||
assert row["description"] == "Source URL/notes are not a spoken transcript"
|
||||
|
||||
|
||||
def test_both_import_routes_share_one_idempotent_profile(client, monkeypatch):
|
||||
emitted = []
|
||||
monkeypatch.setattr(
|
||||
gallery.event_bus, "emit", lambda topic, payload: emitted.append((topic, payload)),
|
||||
)
|
||||
voice_id, _ = _gallery_voice()
|
||||
first = client.post(
|
||||
f"/gallery/voices/{voice_id}/save-as-profile",
|
||||
params={"profile_name": "One reusable profile"},
|
||||
)
|
||||
repeated = client.post(
|
||||
f"/gallery/voices/{voice_id}/save-as-profile",
|
||||
params={"profile_name": "Ignored duplicate name"},
|
||||
)
|
||||
alternate = client.post(f"/gallery/voices/{voice_id}/to-profile")
|
||||
|
||||
assert first.status_code == repeated.status_code == alternate.status_code == 200
|
||||
assert {
|
||||
first.json()["profile_id"],
|
||||
repeated.json()["profile_id"],
|
||||
alternate.json()["profile_id"],
|
||||
} == {first.json()["profile_id"]}
|
||||
with db_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE personality=?",
|
||||
(f"gallery:{voice_id}",),
|
||||
).fetchall()
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["name"] == "One reusable profile"
|
||||
assert rows[0]["kind"] == "clone" and rows[0]["vd_states"] is None
|
||||
assert emitted[-1] == (
|
||||
"profiles", {"action": "updated", "id": first.json()["profile_id"]},
|
||||
)
|
||||
|
||||
|
||||
def test_gallery_profile_repairs_a_missing_copy_without_duplication(client):
|
||||
voice_id, source = _gallery_voice()
|
||||
first = client.post(f"/gallery/voices/{voice_id}/to-profile")
|
||||
assert first.status_code == 200
|
||||
with db_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id=?", (first.json()["profile_id"],),
|
||||
).fetchone()
|
||||
copied = Path(gallery.VOICES_DIR) / row["ref_audio_path"]
|
||||
copied.unlink()
|
||||
|
||||
repaired = client.post(f"/gallery/voices/{voice_id}/to-profile")
|
||||
|
||||
assert repaired.status_code == 200
|
||||
assert repaired.json()["profile_id"] == first.json()["profile_id"]
|
||||
assert copied.read_bytes() == source.read_bytes()
|
||||
|
||||
|
||||
def test_gallery_profile_does_not_rewrite_a_namespaced_import_collision(client):
|
||||
voice_id, source = _gallery_voice()
|
||||
collision_id = f"c{uuid.uuid4().hex[:7]}"
|
||||
personality = f"gallery:{voice_id}"
|
||||
collision_name = gallery._gallery_profile_audio_filename(collision_id, source)
|
||||
collision_audio = Path(gallery.VOICES_DIR) / collision_name
|
||||
collision_audio.parent.mkdir(parents=True, exist_ok=True)
|
||||
collision_audio.write_bytes(b"user-owned audio")
|
||||
with db_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO voice_profiles "
|
||||
"(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, "
|
||||
"description, kind, vd_states, is_locked, verified_own_voice, created_at) "
|
||||
"VALUES (?, 'User profile', ?, '', '', 'Auto', NULL, ?, "
|
||||
"'user-owned metadata', 'clone', NULL, 0, 0, ?)",
|
||||
(collision_id, collision_name, personality, time.time()),
|
||||
)
|
||||
|
||||
response = client.post(f"/gallery/voices/{voice_id}/to-profile")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["profile_id"] != collision_id
|
||||
with db_conn() as conn:
|
||||
collision = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id=?", (collision_id,),
|
||||
).fetchone()
|
||||
created = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id=?", (response.json()["profile_id"],),
|
||||
).fetchone()
|
||||
assert collision["description"] == "user-owned metadata"
|
||||
assert collision_audio.read_bytes() == b"user-owned audio"
|
||||
assert created["personality"] == personality
|
||||
|
||||
|
||||
def _part_files() -> set[Path]:
|
||||
return set(Path(gallery.VOICES_DIR).glob("*.part")) | set(
|
||||
Path(gallery.VOICES_DIR).glob(".*.part")
|
||||
)
|
||||
|
||||
|
||||
def test_audio_copy_never_holds_the_db_write_lock(client, monkeypatch):
|
||||
"""The bulk file copy must happen BEFORE the BEGIN IMMEDIATE transaction.
|
||||
|
||||
While the copy runs, another backend writer takes (and releases) SQLite's
|
||||
write lock. If materialization copied inside its own write transaction,
|
||||
this concurrent writer would hit `database is locked` and the test fails.
|
||||
"""
|
||||
from core.config import DB_PATH
|
||||
|
||||
voice_id, _ = _gallery_voice()
|
||||
real_copy2 = shutil.copy2
|
||||
concurrent_writes = []
|
||||
|
||||
def copy_and_probe(src, dst, **kwargs):
|
||||
probe = sqlite3.connect(DB_PATH, timeout=0.5)
|
||||
try:
|
||||
probe.execute("BEGIN IMMEDIATE")
|
||||
probe.execute(
|
||||
"UPDATE voice_gallery SET category = category WHERE id = ?",
|
||||
(voice_id,),
|
||||
)
|
||||
probe.commit()
|
||||
concurrent_writes.append(True)
|
||||
finally:
|
||||
probe.close()
|
||||
return real_copy2(src, dst, **kwargs)
|
||||
|
||||
monkeypatch.setattr(gallery.shutil, "copy2", copy_and_probe)
|
||||
|
||||
response = client.post(f"/gallery/voices/{voice_id}/to-profile")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert concurrent_writes == [True]
|
||||
assert _part_files() == set()
|
||||
|
||||
|
||||
def test_failed_copy_leaves_no_temp_droppings_or_profile_row(client, monkeypatch):
|
||||
"""A copy that dies mid-write must not leave .part files or a DB row."""
|
||||
voice_id, _ = _gallery_voice()
|
||||
|
||||
def exploding_copy(src, dst, **kwargs):
|
||||
Path(dst).write_bytes(b"partial bytes")
|
||||
raise OSError("disk full mid-copy")
|
||||
|
||||
monkeypatch.setattr(gallery.shutil, "copy2", exploding_copy)
|
||||
|
||||
with pytest.raises(OSError, match="disk full mid-copy"):
|
||||
client.post(f"/gallery/voices/{voice_id}/to-profile")
|
||||
|
||||
assert _part_files() == set()
|
||||
with db_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE personality = ?",
|
||||
(f"gallery:{voice_id}",),
|
||||
).fetchall()
|
||||
assert rows == []
|
||||
|
||||
|
||||
def test_gallery_preview_serves_outputs_file_without_root_relative_redirect(client):
|
||||
voice_id, source = _gallery_voice()
|
||||
|
||||
response = client.get(
|
||||
f"/gallery/voices/{voice_id}/preview", follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "location" not in response.headers
|
||||
assert response.content == source.read_bytes()
|
||||
|
||||
|
||||
def test_gallery_preview_preserves_non_wav_content_type(client):
|
||||
voice_id, _ = _gallery_voice(".mp3", b"ID3 imported voice")
|
||||
|
||||
response = client.get(f"/gallery/voices/{voice_id}/preview")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "audio/mpeg"
|
||||
@@ -657,25 +657,8 @@ class WorkerClient:
|
||||
)
|
||||
)
|
||||
return
|
||||
# Reserve the slot BEFORE the accept-send await: awaiting yields to
|
||||
# the event loop, and a concurrently delivered assignment would read
|
||||
# the un-reserved counter and over-accept past capacity (#1536 — a
|
||||
# capacity-1 worker accepted a second task on a slow runner). Message
|
||||
# order on the stream survives the swap: _send enqueues synchronously
|
||||
# (put_nowait before any suspension), so ACCEPTED is in the outbox
|
||||
# before this handler ever yields to the just-created _run task.
|
||||
await self._send(pb.WorkerMessage(accepted=pb.TaskAccepted(ref=assignment.ref)))
|
||||
self._running[key] = asyncio.create_task(self._run(assignment))
|
||||
try:
|
||||
await self._send(pb.WorkerMessage(accepted=pb.TaskAccepted(ref=assignment.ref)))
|
||||
except BaseException:
|
||||
# BaseException, not Exception: a handler CANCELLED mid-send must
|
||||
# release the slot too, or the reserved task keeps running work
|
||||
# the scheduler never saw accepted — and double-executes after
|
||||
# reassignment. The stream-death case lands here as well.
|
||||
task = self._running.pop(key, None)
|
||||
if task is not None:
|
||||
task.cancel()
|
||||
raise
|
||||
|
||||
async def _run(self, assignment: pb.TaskAssignment) -> None:
|
||||
key = self._key(assignment.ref)
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
},
|
||||
"frontend": {
|
||||
"name": "omnivoice-studio",
|
||||
"version": "0.5.0",
|
||||
"version": "0.4.2",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@fontsource-variable/source-serif-4": "^5.2.9",
|
||||
|
||||
+21
-90
@@ -12,7 +12,7 @@ env var that exempts trusted callers:
|
||||
| Gate | Turn on with | Guards | Applies to |
|
||||
|---|---|---|---|
|
||||
| **Share PIN** | the in-app Network share toggle | casual LAN-share guests, one session | non-loopback **HTTP** |
|
||||
| **API key** | `OMNIVOICE_API_KEY` env var on the backend | direct clients and first-party session bootstrap | non-loopback **HTTP + WebSocket** |
|
||||
| **API key** | `OMNIVOICE_API_KEY` env var on the backend | a durable remote credential | non-loopback **HTTP + WebSocket** |
|
||||
| **Trusted networks** | `OMNIVOICE_TRUSTED_NETWORKS` env var | *exempts* the two gates above | non-loopback **consumption** routes only |
|
||||
|
||||
Loopback traffic (`127.0.0.1`, `::1`, `localhost`) is **never** gated — local
|
||||
@@ -25,9 +25,7 @@ tools keep working unchanged whichever gate is set.
|
||||
> desktop-only even with a key (see [Admin routes](#admin-routes-and-server-mode)).
|
||||
|
||||
> Both gates can be active at once. The PIN and the API key are independent; when
|
||||
> both are set, each is checked on the paths it covers. Session exchange validates
|
||||
> the master key before the PIN gate so the UI can bootstrap safely; ordinary HTTP
|
||||
> requests still require the PIN afterward, and the UI prompts for it next.
|
||||
> both are set, each is checked on the paths it covers.
|
||||
|
||||
---
|
||||
|
||||
@@ -44,14 +42,14 @@ present it. Supply it any one of three ways:
|
||||
|
||||
| Where | How |
|
||||
|---|---|
|
||||
| Header | `X-OmniVoice-Pin: <pin>` |
|
||||
| Header | `X-VoiceStudio-Pin: <pin>` |
|
||||
| Query param | `?pin=<pin>` |
|
||||
| Cookie | `ov_pin=<pin>` — the backend sets this automatically after the first valid PIN, so browser sessions only prove it once |
|
||||
|
||||
```bash
|
||||
# From another device on the LAN — with the PIN
|
||||
curl http://<host>:3900/v1/audio/voices \
|
||||
-H "X-OmniVoice-Pin: 123456"
|
||||
-H "X-VoiceStudio-Pin: 123456"
|
||||
```
|
||||
|
||||
A missing or wrong PIN returns:
|
||||
@@ -77,11 +75,9 @@ Notes on the PIN gate (`NetworkAccessMiddleware`, `backend/main.py`):
|
||||
|
||||
## API key
|
||||
|
||||
The API key is the backend's durable root credential for a GPU box, Docker
|
||||
container, or reverse-proxied host. Direct API clients may send it on each
|
||||
request. The first-party browser/Tauri UI instead exchanges it once for a
|
||||
short-lived administrator session and never stores the master. Set it on the
|
||||
**backend** process:
|
||||
The API key is the durable credential for running the backend somewhere and
|
||||
driving it remotely — a GPU box on your tailnet, a Docker container, a
|
||||
reverse-proxied host. Set it on the **backend** process:
|
||||
|
||||
```bash
|
||||
# Generate a strong key and start the backend with it
|
||||
@@ -91,16 +87,14 @@ uv run uvicorn backend.main:app --host 0.0.0.0 --port 3900
|
||||
```
|
||||
|
||||
While `OMNIVOICE_API_KEY` is set, every **non-loopback HTTP and WebSocket**
|
||||
request must present an accepted credential. SPA shell paths remain public;
|
||||
`POST /api/auth/session` passes through the middleware only so its route can
|
||||
validate the master and perform the one-time exchange. Direct-client
|
||||
compatibility accepts:
|
||||
request must present it (the SPA shell paths below are the only HTTP exception).
|
||||
Supply it any one of three ways:
|
||||
|
||||
| Where | How |
|
||||
|---|---|
|
||||
| Header | `Authorization: Bearer <key>` — **preferred** for scripts and SDKs |
|
||||
| Legacy cookie | `ov_key=<key>` — accepted only for compatibility and migrated by the first-party UI; the backend no longer creates it |
|
||||
| Legacy query param | `?api_key=<key>` — compatibility only. **A key in a URL leaks into proxy/access logs and browser history** |
|
||||
| Header | `Authorization: Bearer <key>` — **preferred**; the one place a key isn't at risk of landing in a log |
|
||||
| Cookie | `ov_key=<key>` — set automatically after the first authenticated HTTP request; the safer fallback for browser WebSockets |
|
||||
| Query param | `?api_key=<key>` — last resort (browser WebSockets can't set headers). **A key in a URL leaks into proxy/access logs and browser history** — prefer the header or cookie |
|
||||
|
||||
```bash
|
||||
# Prefer an encrypted transport (Tailscale Serve / TLS) for a real key; plain
|
||||
@@ -139,8 +133,6 @@ code **1008** (policy violation) instead of a JSON body.
|
||||
Notes on the API-key gate (`BearerKeyMiddleware`, `backend/main.py`):
|
||||
|
||||
- The key is compared in **constant time** and is **never logged**.
|
||||
- The backend never copies the master into a response cookie. Browser clients
|
||||
receive only `ov_session`, an opaque, HttpOnly, SameSite=Strict credential.
|
||||
- The SPA shell paths bypass the gate on **HTTP** so a remote UI can load and
|
||||
show what's wrong; WebSockets have no such exemption.
|
||||
- **Plain HTTP is sniffable** — a Bearer key over `http://` on a hostile
|
||||
@@ -148,40 +140,6 @@ Notes on the API-key gate (`BearerKeyMiddleware`, `backend/main.py`):
|
||||
anything beyond a fully trusted LAN. See
|
||||
[docs/remote-gpu.md](remote-gpu.md) for the full remote-backend setup.
|
||||
|
||||
### First-party administrator sessions
|
||||
|
||||
The bundled UI uses a narrower protocol:
|
||||
|
||||
1. `POST /api/auth/session` receives the master in an `Authorization` header
|
||||
exactly once and selects `{"transport":"cookie"}` for exact same-origin
|
||||
browsers or `{"transport":"bearer"}` for Tauri/cross-origin clients.
|
||||
2. Cookie transport returns `204` and sets `ov_session` as HttpOnly,
|
||||
SameSite=Strict, path `/`, with an eight-hour maximum lifetime. Bearer
|
||||
transport returns an opaque `ovs_admin_session_…` value which the UI keeps
|
||||
in **sessionStorage only**, bound to the exact backend base URL. Bearer JSON
|
||||
responses include both `expires_at` and a bounded `expires_in`; the UI uses
|
||||
the relative lifetime when available so clock skew between a remote GPU host
|
||||
and the browser cannot reject a valid session. `expires_at` remains for
|
||||
backward compatibility with older clients and servers.
|
||||
3. `DELETE /api/auth/session` revokes the session. Removing or rotating
|
||||
`OMNIVOICE_API_KEY`, backend restart, explicit logout, and the eight-hour
|
||||
deadline also invalidate it.
|
||||
|
||||
The master is never written to localStorage/sessionStorage, never returned by
|
||||
the backend, and never placed in a WebSocket URL. Legacy `ov_api_key` browser
|
||||
storage is deleted before migration waits on the network. All auth responses,
|
||||
including errors, carry `Cache-Control: no-store`.
|
||||
|
||||
Failed session exchanges are limited per client to ten attempts in a rolling
|
||||
60-second window and then return `429` with `Retry-After`. A correct master key
|
||||
is always evaluated and clears the failure window, so an attacker cannot lock
|
||||
an operator out by deliberately exhausting the limit.
|
||||
|
||||
Cookie-authenticated mutations require both an exact allowed `Origin` and
|
||||
`X-VoiceStudio-CSRF: 1`. Side-effectful GET actions additionally require the
|
||||
browser's `Sec-Fetch-Site: same-origin`. Bearer/header clients are not subject
|
||||
to the ambient-cookie CSRF check.
|
||||
|
||||
---
|
||||
|
||||
## Dictation WebSocket
|
||||
@@ -191,22 +149,13 @@ own inline guard (`backend/api/routers/capture_ws.py`) *in addition to* the
|
||||
API-key middleware. A non-loopback client reaches it only if it is **either**:
|
||||
|
||||
- on a [trusted network](#trusted-networks) (`is_local_host` passes), **or**
|
||||
- presenting a direct-client **API key** in `Authorization`, or through a
|
||||
legacy `ov_key`/`?api_key=` transport.
|
||||
- presenting the **API key** — as `Authorization: Bearer <key>`, the `ov_key`
|
||||
cookie, or `?api_key=<key>` (URL keys leak into logs — prefer the cookie).
|
||||
|
||||
```
|
||||
ws://gpu-box:3900/ws/transcribe?api_key=<key>
|
||||
```
|
||||
|
||||
That URL form is retained for non-browser compatibility only. The first-party
|
||||
UI never constructs it. A bearer administrator session first calls
|
||||
`POST /api/auth/ws-ticket` and puts only the returned `ws_ticket` in the URL.
|
||||
Tickets are scoped to `/ws/transcribe` or `/ws/events`, expire after 30 seconds,
|
||||
return the same bounded `expires_in`/`expires_at` pair, and are consumed
|
||||
atomically at most once. Same-origin UI WebSockets use the
|
||||
HttpOnly session cookie and must pass exact `Origin` validation; `null`, missing,
|
||||
and lookalike origins are rejected.
|
||||
|
||||
The **share PIN does not authorize dictation** — the PIN gate is HTTP-only, and
|
||||
the dictation guard checks only the API key (or trusted-network membership). A
|
||||
LAN guest who has only entered a PIN can use the HTTP API but **not** live
|
||||
@@ -266,12 +215,11 @@ requirement is dropped (issue #261, else the operator is 403'd out of their own
|
||||
a model, and LLM provider discovery makes a request with the saved provider
|
||||
credential. Set `OMNIVOICE_API_KEY` before changing settings or triggering
|
||||
those actions remotely.
|
||||
- **An API key is configured** → admin requires that **API key** (direct-client
|
||||
`Authorization` / legacy query or cookie), a valid short-lived administrator
|
||||
session, or genuine loopback. The **6-digit share PIN does not gate admin**
|
||||
(it is brute-forceable), and trusted-network membership never does either. A
|
||||
**PIN-only** server-mode deployment therefore keeps admin routes loopback-only;
|
||||
remote admin starts from the long API key.
|
||||
- **An API key is configured** → admin requires that **API key** (`Authorization:
|
||||
Bearer` / `?api_key` / `ov_key` cookie), or genuine loopback. The **6-digit
|
||||
share PIN does not gate admin** (it is brute-forceable), and trusted-network
|
||||
membership never does either. A **PIN-only** server-mode deployment therefore
|
||||
keeps admin routes loopback-only; remote admin requires the long API key.
|
||||
|
||||
Managed sidecar installation remains true-loopback-only even with an API key.
|
||||
Its installer fetches mutable source and creates an editable environment, so it
|
||||
@@ -320,30 +268,13 @@ the default list, so restate the loopback/Tauri origins alongside your own. (The
|
||||
same origin.) If you only moved the Vite dev server's port, set
|
||||
`OMNIVOICE_UI_PORT` instead and the default list follows it.
|
||||
|
||||
CORS wraps both authentication gates: credentialless browser preflights are
|
||||
answered before PIN/API-key enforcement, and gate-generated `401` responses
|
||||
retain CORS headers so the UI can read the actual failure and prompt for the
|
||||
right credential.
|
||||
|
||||
TLS-terminating proxies must establish the effective scheme at the ASGI server
|
||||
boundary. Uvicorn's proxy-header handling trusts loopback by default, which
|
||||
covers Tailscale Serve; a custom proxy on another address must be listed with
|
||||
`--forwarded-allow-ips=<proxy-ip>` (and proxy headers must remain enabled).
|
||||
VoiceStudio deliberately does not trust a raw `X-Forwarded-Proto` header inside
|
||||
the application: once Uvicorn accepts a trusted proxy, the resolved ASGI scheme
|
||||
drives exact-Origin checks and the session cookie's `Secure` attribute.
|
||||
For a public path prefix such as `/studio`, either strip that prefix before
|
||||
forwarding or configure the ASGI `root_path` to the same value. WebSocket ticket
|
||||
validation removes only that trusted, configured prefix; it never accepts an
|
||||
arbitrary path merely because it ends in `/ws/events` or `/ws/transcribe`.
|
||||
|
||||
## Status codes
|
||||
|
||||
| Code | Meaning | What to do |
|
||||
|---|---|---|
|
||||
| **401** | Consumption auth failed — `{"detail": "PIN required"}` or `{"detail": "API key required"}`. | Supply the PIN / key (header, cookie, or query param above). A WebSocket surfaces this as close code **1008**. |
|
||||
| **403** | Authorization failed: loopback/native access was required, cookie Origin/CSRF validation failed, a server-mode mutation lacked an admin credential, or a native path capability was invalid/expired. | A PIN cannot grant admin or filesystem access. Re-authenticate the UI; scripts should use the API-key header; run native operations from the desktop app. |
|
||||
| **429** | A failed administrator-session exchange exceeded its per-client limit, the GPU pool is saturated, or a model download is rate-limited. Ships with `Retry-After`; workload throttles also carry `X-VoiceStudio-Retryable: true`. | Back off for `Retry-After` seconds. For authentication, verify the master before retrying; a correct master is never locked out. |
|
||||
| **403** | Authorization failed: loopback/native access was required, a server-mode mutation lacked the API key, or a native path capability was invalid, expired, or for a different operation. | A PIN cannot grant admin or filesystem access. Run native operations from the desktop app; configure and present the API key for remote server-mode mutations; reopen the native picker if a one-shot capability expired. |
|
||||
| **429** | **Not an auth failure.** The GPU pool is saturated (admission control) or a model download is rate-limited. Ships with `Retry-After` and `X-VoiceStudio-Retryable: true`. | Back off for `Retry-After` seconds and retry the identical request. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 218 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 137 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.2 MiB |
+17
-28
@@ -24,14 +24,13 @@ loopback-only exactly as before.
|
||||
┌──────────────┐ tailnet (WireGuard) ┌─────────────────────┐
|
||||
│ laptop │ ws/https to MagicDNS URL │ gpu-box │
|
||||
│ VoiceStudio UI │ ──────────────────────────▶ │ VoiceStudio backend │
|
||||
│ (thin client) │ short-lived session/ticket │ OMNIVOICE_API_KEY set │
|
||||
│ (thin client) │ Authorization: Bearer … │ OMNIVOICE_API_KEY set │
|
||||
└──────────────┘ └─────────────────────┘
|
||||
```
|
||||
|
||||
The desktop app *is* the thin client — there is no separate binary. You enter a
|
||||
**Backend URL** and an **API key** in Settings. The key is exchanged once for a
|
||||
short-lived session; ordinary HTTP requests use that session and WebSockets use
|
||||
path-bound, single-use tickets. The master is never stored or put in a URL.
|
||||
The desktop app *is* the thin client — there is no separate binary. You set a
|
||||
**Backend URL** and an **API key** in Settings, and every request (including
|
||||
the dictation and TTS WebSockets) is sent to the remote with the key attached.
|
||||
|
||||
## 1. On the GPU box: run the backend with a key
|
||||
|
||||
@@ -51,11 +50,11 @@ the backend's CORS allow-list to include that origin — see
|
||||
[Browsers from another origin (CORS)](api-auth.md#browsers-from-another-origin-cors);
|
||||
neither server mode nor trusted networks covers CORS.
|
||||
|
||||
When `OMNIVOICE_API_KEY` is set, every non-loopback request needs an accepted
|
||||
credential. Scripts should use `Authorization: Bearer <key>`. Legacy
|
||||
`?api_key=` and `ov_key` transports remain accepted for compatibility, but the
|
||||
backend no longer creates a master-key cookie and the bundled UI uses only
|
||||
short-lived sessions. Loopback traffic on the box itself remains ungated.
|
||||
When `OMNIVOICE_API_KEY` is set, **every non-loopback HTTP and WebSocket
|
||||
request must present it**, as `Authorization: Bearer <key>`, `?api_key=<key>`
|
||||
(browser WebSockets can't set headers), or the `ov_key` cookie the backend
|
||||
sets after the first authenticated request. Loopback traffic on the box
|
||||
itself is never gated, so local tools keep working.
|
||||
|
||||
## 2. Reach it over Tailscale
|
||||
|
||||
@@ -80,11 +79,6 @@ Serve terminates on the node and forwards from `127.0.0.1`, so to the backend
|
||||
the request looks like loopback — which is why the **API key is still
|
||||
required** in that path (the bearer gate doesn't rely on the source address
|
||||
for non-local exposure; set the key and it always applies to keyed clients).
|
||||
Uvicorn trusts proxy headers from loopback by default, so Serve's forwarded
|
||||
HTTPS scheme becomes the authoritative ASGI scheme and browser session cookies
|
||||
receive `Secure`. For a non-loopback reverse proxy, explicitly configure
|
||||
Uvicorn's `--forwarded-allow-ips=<proxy-ip>`; the application never trusts an
|
||||
arbitrary `X-Forwarded-Proto` header itself.
|
||||
|
||||
> **Do not use `tailscale funnel`** (public-internet exposure) for this. Even
|
||||
> with a key, a voice-cloning backend should not be on the open internet.
|
||||
@@ -96,11 +90,10 @@ Settings → Sharing → **Remote backend**:
|
||||
- **Backend URL**: the MagicDNS URL from step 2 (with `:3900` if you didn't
|
||||
use Serve, or no port if you did).
|
||||
- **API key**: the value of `OMNIVOICE_API_KEY` from step 1.
|
||||
- **Test connection** hits the auth-exempt `{url}/health` with no credential,
|
||||
then exchanges the entered key for a session if health succeeds.
|
||||
- **Save & reload** stores only the URL and restarts the UI against the remote.
|
||||
The key input is cleared after its single exchange. The URL must be a full
|
||||
`http://` or `https://` URL
|
||||
- **Test connection** hits `{url}/health` and shows the remote's version and
|
||||
device.
|
||||
- **Save & reload** stores both in this browser/app and restarts the UI
|
||||
against the remote. The URL must be a full `http://` or `https://` URL
|
||||
(`gpu-box:3900` alone is rejected), and saving a URL that hasn't passed
|
||||
**Test connection** asks for confirmation first — a wrong base would leave
|
||||
the app unable to reach any backend until you change it back here.
|
||||
@@ -118,13 +111,12 @@ https://gpu-box.your-tailnet.ts.net/#api_key=<key>
|
||||
|
||||
Use the fragment (`#`, not `?`) deliberately: fragments are never sent to the
|
||||
server, so the key stays out of the GPU box's and any reverse proxy's request
|
||||
logs. The fragment is scrubbed synchronously, then the key is exchanged once
|
||||
for an eight-hour maximum session; the master is not stored. If
|
||||
logs. The key is stored for that browser and the fragment is scrubbed from the
|
||||
address bar (so it doesn't linger in history or get re-applied on a reload). If
|
||||
your key contains `+`, `&`, `#`, or `=`, URL-encode it (e.g. `#api_key=a%2Bb`);
|
||||
keys from `secrets.token_urlsafe` (above) need no encoding.
|
||||
Thereafter the UI loads normally with the short-lived session. Cross-origin
|
||||
bearer sessions are tab-scoped; closing the tab requires re-entry. If a request
|
||||
401s again (expired/wrong/rotated key), you're prompted to re-enter it. The
|
||||
Thereafter the UI loads normally with the key attached to every request. If a
|
||||
request ever 401s again (wrong/rotated key), you're prompted to re-enter it. The
|
||||
same gate shows a LAN-share **PIN** prompt instead when network sharing — not a
|
||||
remote key — is what's gating access.
|
||||
|
||||
@@ -133,9 +125,6 @@ remote key — is what's gating access.
|
||||
- **Plain HTTP is sniffable.** A bearer key over `http://` on a hostile
|
||||
network can be read off the wire. Use Tailscale (WireGuard-encrypted) or
|
||||
Tailscale Serve (TLS) for anything beyond a fully trusted LAN.
|
||||
- The first-party UI never persists `OMNIVOICE_API_KEY`, never creates a URL
|
||||
containing it, and never puts its administrator session in a WebSocket URL.
|
||||
WebSocket tickets expire after 30 seconds and work once for one path.
|
||||
- The API key and the LAN-share **PIN** are independent: the PIN guards a
|
||||
casual share session, the key is the durable remote credential. Either can
|
||||
be active; both are checked when set.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Remote GPU workers
|
||||
|
||||
Run VoiceStudio on this machine, but hand individual jobs to GPUs on your other
|
||||
Run OmniVoice on this machine, but hand individual jobs to GPUs on your other
|
||||
machines. Results come back here.
|
||||
|
||||
This is **opt-in and off by default**. Until you turn it on and approve a
|
||||
@@ -23,7 +23,7 @@ administration.
|
||||
|
||||
## What you need
|
||||
|
||||
* VoiceStudio on both machines, on versions no more than two releases apart.
|
||||
* OmniVoice on both machines, on versions no more than two releases apart.
|
||||
* The worker machine must be able to **reach** this one over the network. Same
|
||||
LAN is enough at home; across networks, a VPN such as
|
||||
[Tailscale](https://tailscale.com/) is the reliable answer. The worker dials
|
||||
@@ -204,7 +204,7 @@ The row tells you what happened in words — "Paused after 3 failures … retryi
|
||||
in 45s" — and **Resume** clears it immediately when you've fixed the machine.
|
||||
|
||||
**You quit the app mid-task.** Remote work keeps running on the worker. On next
|
||||
launch VoiceStudio recovers those tasks and reconciles with each worker about
|
||||
launch OmniVoice recovers those tasks and reconciles with each worker about
|
||||
what is genuinely still in flight.
|
||||
|
||||
**Version or feature mismatch.** The protocol keeps a two-release compatibility
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 185 KiB After Width: | Height: | Size: 268 KiB |
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,5 @@
|
||||
# Spec — TASK #26: Gallery "Use in Stories" / "Set as Audiobook default" + create-voice handoff
|
||||
|
||||
> **Implemented (2026-08-13).** Gallery and Community persona cards now materialize once and hand the returned profile directly to Studio, the current Stories cast, or the current Audiobook default. The implementation uses the unified `longformSlice` that superseded the store additions proposed below; the remainder of this document preserves the original design record.
|
||||
|
||||
## TL;DR
|
||||
|
||||
Today the Gallery's "Use voice" action materializes an archetype/community voice into a profile and hard-codes a handoff into the **Studio** synthesis view (`frontend/src/pages/VoiceGallery.jsx:199-210` for archetypes; `frontend/src/App.jsx:254-268` for the studio-side pickup). There is no path from the Gallery into the **Stories** cast or the **Audiobook** default narrator. This task adds two quick-actions to gallery + community cards — "Use in Stories" and "Set as Audiobook default" — that (a) materialize the voice into a real profile (same backend call as today) and (b) land it in the right destination: appended to the Stories cast as a new character, or set as the persisted Audiobook default voice. The Audiobook default currently has no store binding at all, so this task also promotes it from local `useState` to a persisted store field.
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
import { expect, test, type Page, type Request, type Route } from '@playwright/test';
|
||||
|
||||
const MASTER = 'root-master-never-retained';
|
||||
const SESSION = `ovs_admin_session_${'S'.repeat(43)}`;
|
||||
|
||||
async function browserCredentialSnapshot(page: Page) {
|
||||
return page.evaluate(() => ({
|
||||
href: location.href,
|
||||
legacyMaster: localStorage.getItem('ov_api_key'),
|
||||
storedSession: sessionStorage.getItem('ov_admin_session'),
|
||||
localValues: Object.values(localStorage),
|
||||
sessionValues: Object.values(sessionStorage),
|
||||
}));
|
||||
}
|
||||
|
||||
test('same-origin production bootstrap exchanges once into an HttpOnly cookie', async ({
|
||||
context,
|
||||
page,
|
||||
}) => {
|
||||
const seen: Request[] = [];
|
||||
page.on('request', (request) => seen.push(request));
|
||||
await page.addInitScript((master) => localStorage.setItem('ov_api_key', master), MASTER);
|
||||
|
||||
let exchange: Request | undefined;
|
||||
await page.route('**/api/auth/session', async (route) => {
|
||||
exchange = route.request();
|
||||
await route.fulfill({
|
||||
status: 204,
|
||||
headers: {
|
||||
'cache-control': 'no-store',
|
||||
'set-cookie': `ov_session=${SESSION}; HttpOnly; SameSite=Strict; Path=/; Max-Age=28800`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(`/#api_key=${MASTER}&tab=voices`, { waitUntil: 'domcontentloaded' });
|
||||
await expect.poll(() => exchange?.headers().authorization).toBe(`Bearer ${MASTER}`);
|
||||
|
||||
expect(exchange?.postDataJSON()).toEqual({ transport: 'cookie' });
|
||||
const snapshot = await browserCredentialSnapshot(page);
|
||||
expect(snapshot.href).toMatch(/#tab=voices$/);
|
||||
expect(snapshot.href).not.toContain(MASTER);
|
||||
expect(snapshot.legacyMaster).toBeNull();
|
||||
expect(snapshot.storedSession).toBeNull();
|
||||
expect([...snapshot.localValues, ...snapshot.sessionValues].join('\n')).not.toContain(MASTER);
|
||||
expect(seen.map((request) => request.url()).join('\n')).not.toContain(MASTER);
|
||||
|
||||
const cookies = await context.cookies();
|
||||
const cookie = cookies.find(({ name }) => name === 'ov_session');
|
||||
expect(cookie).toMatchObject({ value: SESSION, httpOnly: true, sameSite: 'Strict', path: '/' });
|
||||
expect(cookies.some(({ name }) => name === 'ov_key')).toBe(false);
|
||||
expect(cookies.map(({ value }) => value).join('\n')).not.toContain(MASTER);
|
||||
});
|
||||
|
||||
test('cross-origin production bootstrap stores only a backend-bound tab session', async ({
|
||||
page,
|
||||
}) => {
|
||||
const remote = 'http://gpu.test:3900';
|
||||
const seen: Request[] = [];
|
||||
page.on('request', (request) => seen.push(request));
|
||||
await page.addInitScript(
|
||||
({ backend, master }) => {
|
||||
localStorage.setItem('ov_backend_url', backend);
|
||||
localStorage.setItem('ov_api_key', master);
|
||||
},
|
||||
{ backend: remote, master: MASTER },
|
||||
);
|
||||
|
||||
let exchange: Request | undefined;
|
||||
await page.route(`${remote}/**`, async (route: Route) => {
|
||||
const request = route.request();
|
||||
const corsHeaders = {
|
||||
'access-control-allow-credentials': 'true',
|
||||
'access-control-allow-headers': 'authorization,content-type,x-voicestudio-csrf',
|
||||
'access-control-allow-methods': 'GET,POST,DELETE,OPTIONS',
|
||||
'access-control-allow-origin': request.headers().origin ?? 'http://localhost:4173',
|
||||
'access-control-expose-headers': 'x-omnivoice-backend',
|
||||
'x-omnivoice-backend': 'e2e',
|
||||
};
|
||||
if (request.method() === 'OPTIONS') {
|
||||
await route.fulfill({ status: 204, headers: corsHeaders });
|
||||
return;
|
||||
}
|
||||
if (new URL(request.url()).pathname === '/api/auth/session') {
|
||||
exchange = request;
|
||||
await route.fulfill({
|
||||
status: 201,
|
||||
headers: {
|
||||
...corsHeaders,
|
||||
'cache-control': 'no-store',
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ token: SESSION, expires_at: 1, expires_in: 3600 }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (new URL(request.url()).pathname === '/health') {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { ...corsHeaders, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ status: 'ok', version: 'e2e', device: 'cpu' }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { ...corsHeaders, 'content-type': 'application/json' },
|
||||
body: '{}',
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(`/#api_key=${MASTER}`, { waitUntil: 'domcontentloaded' });
|
||||
await expect.poll(() => exchange?.headers().authorization).toBe(`Bearer ${MASTER}`);
|
||||
|
||||
expect(exchange?.postDataJSON()).toEqual({ transport: 'bearer' });
|
||||
const snapshot = await browserCredentialSnapshot(page);
|
||||
expect(snapshot.href).not.toContain(MASTER);
|
||||
expect(snapshot.legacyMaster).toBeNull();
|
||||
expect(snapshot.storedSession).not.toBeNull();
|
||||
expect(JSON.parse(snapshot.storedSession ?? '{}')).toMatchObject({
|
||||
token: SESSION,
|
||||
apiBase: remote,
|
||||
});
|
||||
expect(snapshot.localValues.join('\n')).not.toContain(MASTER);
|
||||
expect(snapshot.sessionValues.join('\n')).not.toContain(MASTER);
|
||||
expect(seen.map((request) => request.url()).join('\n')).not.toContain(MASTER);
|
||||
expect(
|
||||
seen.filter((request) => request.headers().authorization === `Bearer ${MASTER}`),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
@@ -27,7 +27,7 @@ test.describe('LogsFooter never covers page content @ 900x600', () => {
|
||||
|
||||
test('gallery: bottom-most voice card stays above the collapsed footer', async ({ page }) => {
|
||||
await gotoMode(page, 'gallery');
|
||||
const cards = page.getByTestId('gallery-persona-card');
|
||||
const cards = page.locator('.archetype-card');
|
||||
await expect(cards.first()).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
const top = await footerTop(page);
|
||||
@@ -45,7 +45,7 @@ test.describe('LogsFooter never covers page content @ 900x600', () => {
|
||||
page,
|
||||
}) => {
|
||||
await gotoMode(page, 'gallery');
|
||||
const cards = page.getByTestId('gallery-persona-card');
|
||||
const cards = page.locator('.archetype-card');
|
||||
await expect(cards.first()).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// Expand the logs panel (chevron toggle in the collapsed bar).
|
||||
|
||||
@@ -9,9 +9,7 @@ test.describe('VoiceStudio Gallery', () => {
|
||||
|
||||
test('facet dropdowns use the dark theme, not the OS-default light surface', async ({ page }) => {
|
||||
await gotoMode(page, 'gallery');
|
||||
// Scope by testid, not the translated 'Archetypes' label — the accessible
|
||||
// name follows the app locale and breaks under non-English navigators.
|
||||
const select = page.getByTestId('archetypes-zone').getByRole('combobox').first();
|
||||
const select = page.locator('select.facet-select').first();
|
||||
await expect(select).toBeVisible();
|
||||
// Regression guard for the undefined-var fallback: the fixed style resolves
|
||||
// --chrome-hover-bg → rgba(255,255,255,0.04), NOT an opaque UA light surface
|
||||
@@ -27,7 +25,7 @@ test.describe('VoiceStudio Gallery', () => {
|
||||
await gotoMode(page, 'gallery');
|
||||
|
||||
// Cards load from the backend; wait for the first one.
|
||||
const designerBtn = page.getByRole('button', { name: /Open in Designer/i }).first();
|
||||
const designerBtn = page.locator('.archetype-card .designer-btn').first();
|
||||
await expect(designerBtn).toBeVisible({ timeout: 20_000 });
|
||||
await designerBtn.click();
|
||||
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { gotoMode } from './_helpers';
|
||||
|
||||
const MIN_WINDOW = { width: 900, height: 600 };
|
||||
|
||||
test.describe('Support page stays on one screen @ 900x600', () => {
|
||||
test.use({ viewport: MIN_WINDOW });
|
||||
|
||||
test('support, commercial licence and contact panels do not overflow', async ({ page }) => {
|
||||
await gotoMode(page, 'donate');
|
||||
await expect(page.getByRole('heading', { name: 'Support VoiceStudio' })).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
for (const tabName of ['Support', 'Commercial License', 'Contact']) {
|
||||
const tab = page.getByRole('tab', { name: tabName });
|
||||
if ((await tab.getAttribute('aria-selected')) !== 'true') await tab.click({ force: true });
|
||||
|
||||
await expect(tab).toHaveAttribute('aria-selected', 'true');
|
||||
const panel = page.getByRole('tabpanel');
|
||||
await expect(panel).toBeVisible();
|
||||
const { clientHeight, scrollHeight } = await panel.evaluate((element) => ({
|
||||
clientHeight: element.clientHeight,
|
||||
scrollHeight: element.scrollHeight,
|
||||
}));
|
||||
expect(
|
||||
scrollHeight,
|
||||
`${tabName} panel should not need vertical scrolling`,
|
||||
).toBeLessThanOrEqual(clientHeight + 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "omnivoice-studio",
|
||||
"version": "0.5.0",
|
||||
"version": "0.4.2",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-only",
|
||||
"type": "module",
|
||||
|
||||
Generated
+1
-1
@@ -2941,7 +2941,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "omnivoice-studio"
|
||||
version = "0.5.0"
|
||||
version = "0.4.2"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"dirs-next",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# launcher's pkill matches `omnivoice-studio` and must never match a user's
|
||||
# installed app. Renaming it would collapse that distinction.
|
||||
name = "omnivoice-studio"
|
||||
version = "0.5.0"
|
||||
version = "0.4.2"
|
||||
description = "VoiceStudio – AI voice cloning & dubbing desktop app"
|
||||
authors = ["Debpalash"]
|
||||
license = "AGPL-3.0-only"
|
||||
|
||||
@@ -262,117 +262,18 @@ pub fn backend_log_path() -> PathBuf {
|
||||
}
|
||||
|
||||
/// Read the last N lines from backend_err.log for diagnostic messages.
|
||||
///
|
||||
/// Whole-file view — bootstrap phases (uv sync et al.) that predate any
|
||||
/// backend run use this. Anything reporting on a specific backend process
|
||||
/// (crash markers, death diagnostics) must use [`read_error_log_tail_for_run`]
|
||||
/// instead: the file outlives runs, so an unbounded tail can attribute one
|
||||
/// run's output to another (#1510).
|
||||
pub fn read_error_log_tail(max_lines: usize) -> String {
|
||||
let err_path = backend_log_path().with_file_name("backend_err.log");
|
||||
read_error_log_tail_at(&err_path, 0, max_lines)
|
||||
}
|
||||
|
||||
// ── Per-run crash evidence (#1510) ────────────────────────────────────────
|
||||
//
|
||||
// backend_err.log is one file shared by every backend run in an app session,
|
||||
// and it used to be TRUNCATED on each spawn. Both properties destroyed crash
|
||||
// evidence: a respawn wiped the dead process's final words, and any tail read
|
||||
// after the replacement started could attach the new run's healthy startup to
|
||||
// the old run's crash marker — exactly the undiagnosable report in #1510.
|
||||
// The file is append-only now, each spawn records where its run begins, and
|
||||
// death paths read only their own run's slice.
|
||||
|
||||
/// Byte offset in backend_err.log where the CURRENT run's output begins.
|
||||
/// Set by `spawn_backend` before the child starts writing.
|
||||
static ERR_LOG_RUN_START: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
|
||||
/// Rotate once the shared file gets this big (append-only would otherwise
|
||||
/// grow across runs forever). Generous: evidence beats disk here.
|
||||
const ERR_LOG_ROTATE_BYTES: u64 = 1024 * 1024;
|
||||
|
||||
/// Where the current backend run's slice of backend_err.log begins.
|
||||
pub fn err_log_run_start() -> u64 {
|
||||
ERR_LOG_RUN_START.load(std::sync::atomic::Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// Last N lines of the CURRENT run's slice of backend_err.log.
|
||||
///
|
||||
/// This is the reader every death path must use: it cannot see another run's
|
||||
/// output, so a crash marker carries the dying process's words or nothing.
|
||||
pub fn read_error_log_tail_for_run(max_lines: usize) -> String {
|
||||
let err_path = backend_log_path().with_file_name("backend_err.log");
|
||||
read_error_log_tail_at(&err_path, err_log_run_start(), max_lines)
|
||||
}
|
||||
|
||||
/// Tail of `path` starting at byte `start` (whole file when `start` is 0 or
|
||||
/// no longer valid — an externally replaced/shrunk file must degrade to the
|
||||
/// old whole-file behaviour, never to a silent empty capture).
|
||||
fn read_error_log_tail_at(path: &Path, start: u64, max_lines: usize) -> String {
|
||||
let content = match fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return String::new(),
|
||||
};
|
||||
let start = usize::try_from(start).unwrap_or(0);
|
||||
let slice = if start > 0 && start <= content.len() && content.is_char_boundary(start) {
|
||||
&content[start..]
|
||||
} else {
|
||||
&content[..]
|
||||
};
|
||||
let lines: Vec<&str> = slice.lines().collect();
|
||||
let from = lines.len().saturating_sub(max_lines);
|
||||
lines[from..].join("\n")
|
||||
}
|
||||
|
||||
/// The previous run's stderr-drainer thread. Joined (bounded) before a new
|
||||
/// spawn records its offset, so a dying run's still-buffered stderr cannot be
|
||||
/// appended AFTER the new run's start offset and get attributed to the new
|
||||
/// run. (Full per-child offset binding isn't needed: spawns are serialized by
|
||||
/// the #1223 spawn-once flow, so the only race left was this buffered tail.)
|
||||
static ERR_LOG_DRAINER: Mutex<Option<std::thread::JoinHandle<()>>> = Mutex::new(None);
|
||||
|
||||
/// Wait briefly for the previous run's stderr drainer to flush. A wedged
|
||||
/// drainer (pipe held open by an orphaned grandchild) must not block a
|
||||
/// respawn forever — after the bound we proceed; the offset then simply
|
||||
/// includes whatever the old run still manages to write, which degrades to
|
||||
/// attributing too MUCH to the new run, never to destroying evidence.
|
||||
fn join_previous_err_drainer(bound: Duration) {
|
||||
let handle = ERR_LOG_DRAINER.lock().ok().and_then(|mut g| g.take());
|
||||
if let Some(handle) = handle {
|
||||
let deadline = std::time::Instant::now() + bound;
|
||||
while !handle.is_finished() && std::time::Instant::now() < deadline {
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
if handle.is_finished() {
|
||||
let _ = handle.join();
|
||||
match fs::read_to_string(&err_path) {
|
||||
Ok(content) => {
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
let start = lines.len().saturating_sub(max_lines);
|
||||
lines[start..].join("\n")
|
||||
}
|
||||
Err(_) => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Open backend_err.log for a new run: append-only (a respawn must not
|
||||
/// destroy the previous run's evidence), rotated when oversized, with the
|
||||
/// run's start offset returned for `ERR_LOG_RUN_START`.
|
||||
fn open_err_log_for_run(err_path: &Path) -> (Option<fs::File>, u64) {
|
||||
let len = fs::metadata(err_path).map(|m| m.len()).unwrap_or(0);
|
||||
if len > ERR_LOG_ROTATE_BYTES {
|
||||
let rotated = err_path.with_file_name("backend_err.log.1");
|
||||
// Rename preferred (keeps the old evidence in .1); on failure —
|
||||
// e.g. the file is still held open on Windows — fall back to
|
||||
// truncating, which is exactly the pre-#1510 behaviour.
|
||||
if fs::rename(err_path, &rotated).is_err() {
|
||||
let file = fs::File::create(err_path).ok();
|
||||
return (file, 0);
|
||||
}
|
||||
}
|
||||
let file = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(err_path)
|
||||
.ok();
|
||||
let start = fs::metadata(err_path).map(|m| m.len()).unwrap_or(0);
|
||||
(file, start)
|
||||
}
|
||||
|
||||
/// Human-readable diagnostic for a failed `Command::spawn()` of the backend.
|
||||
///
|
||||
/// #144 / #127: when the bundled venv Python can't exec (the common Linux/
|
||||
@@ -380,21 +281,6 @@ fn open_err_log_for_run(err_path: &Path) -> (Option<fs::File>, u64) {
|
||||
/// process "never started" and we previously surfaced "no error output
|
||||
/// captured". Writing this to backend_err.log lets read_error_log_tail show the
|
||||
/// real OS error + an actionable hint instead.
|
||||
/// Replace the user's home-directory prefix with `~`. This diagnostic is
|
||||
/// retained in backend_err.log across runs and lands verbatim in bug
|
||||
/// reports, so the username must not travel with it.
|
||||
fn redact_home(text: &str) -> String {
|
||||
for var in ["HOME", "USERPROFILE"] {
|
||||
if let Ok(home) = std::env::var(var) {
|
||||
let home = home.trim_end_matches(['/', '\\']);
|
||||
if home.len() > 1 && text.starts_with(home) {
|
||||
return format!("~{}", &text[home.len()..]);
|
||||
}
|
||||
}
|
||||
}
|
||||
text.to_string()
|
||||
}
|
||||
|
||||
fn spawn_failure_diagnostic(python: &Path, err: &std::io::Error) -> String {
|
||||
// Platform-specific tail (cfg! resolves to this build's target OS, i.e. the
|
||||
// OS it runs on) — don't show AppImage/loader wording to macOS/Windows users.
|
||||
@@ -419,7 +305,7 @@ fn spawn_failure_diagnostic(python: &Path, err: &std::io::Error) -> String {
|
||||
Interpreter present on disk: {}\n\
|
||||
OS error: {}\n\n\
|
||||
{} Use \"Clean & Retry\" to rebuild the environment.",
|
||||
redact_home(&python.display().to_string()),
|
||||
python.display(),
|
||||
python.exists(),
|
||||
err,
|
||||
os_hint,
|
||||
@@ -493,24 +379,7 @@ pub fn spawn_backend<R: tauri::Runtime>(app: &tauri::AppHandle<R>, progress: Opt
|
||||
}
|
||||
|
||||
let stdout_file = fs::File::create(&log_path).ok();
|
||||
// Append + per-run offset, never truncate: the previous run's stderr is
|
||||
// crash evidence until someone reads it (#1510). Flush the previous
|
||||
// drainer first so old buffered lines land BEFORE this run's offset.
|
||||
join_previous_err_drainer(Duration::from_secs(2));
|
||||
let (err_log_file, err_log_start) = open_err_log_for_run(&err_path);
|
||||
ERR_LOG_RUN_START.store(err_log_start, std::sync::atomic::Ordering::SeqCst);
|
||||
if let Some(ref f) = err_log_file {
|
||||
use std::io::Write;
|
||||
let mut f = f;
|
||||
let _ = writeln!(
|
||||
f,
|
||||
"──── backend run starting (unix {}s) ────",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
);
|
||||
}
|
||||
let err_log_file = fs::File::create(&err_path).ok();
|
||||
|
||||
let mut env: Vec<(String, String)> = vec![("PYTHONUNBUFFERED".into(), "1".into())];
|
||||
// Pin the child's OMNIVOICE_PORT to the value Rust resolved so Python's
|
||||
@@ -624,16 +493,7 @@ pub fn spawn_backend<R: tauri::Runtime>(app: &tauri::AppHandle<R>, progress: Opt
|
||||
// real exec error instead of "no error output captured".
|
||||
let diag = spawn_failure_diagnostic(&python, &e);
|
||||
log::error!("{}", diag);
|
||||
// Append (not overwrite): the run header above already marks this
|
||||
// run's slice, and earlier runs' evidence stays intact.
|
||||
if let Ok(mut f) = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&err_path)
|
||||
{
|
||||
use std::io::Write;
|
||||
let _ = writeln!(f, "{}", diag);
|
||||
}
|
||||
let _ = fs::write(&err_path, &diag);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
@@ -656,9 +516,7 @@ pub fn spawn_backend<R: tauri::Runtime>(app: &tauri::AppHandle<R>, progress: Opt
|
||||
|
||||
if let Some(stderr_pipe) = child.stderr.take() {
|
||||
let app_clone = app.clone();
|
||||
// Tracked (not detached): the next spawn joins this handle so this
|
||||
// run's buffered tail flushes before the next run's offset is taken.
|
||||
let drainer = std::thread::spawn(move || {
|
||||
std::thread::spawn(move || {
|
||||
use std::io::Write;
|
||||
let reader = BufReader::new(stderr_pipe);
|
||||
let mut log_file = err_log_file;
|
||||
@@ -670,9 +528,6 @@ pub fn spawn_backend<R: tauri::Runtime>(app: &tauri::AppHandle<R>, progress: Opt
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Ok(mut guard) = ERR_LOG_DRAINER.lock() {
|
||||
*guard = Some(drainer);
|
||||
}
|
||||
}
|
||||
|
||||
Some(child)
|
||||
@@ -792,131 +647,4 @@ mod tests {
|
||||
// unversioned (pre-app_version backend) is stale by definition
|
||||
assert!(!same_app_version(""));
|
||||
}
|
||||
|
||||
// ── Per-run crash evidence (#1510) ───────────────────────────────────
|
||||
// The reported failure shape: a crash marker whose stderr tail was the
|
||||
// REPLACEMENT process's healthy startup, because the shared err log was
|
||||
// truncated on respawn and read unbounded afterwards.
|
||||
|
||||
#[test]
|
||||
fn a_respawn_preserves_the_previous_runs_evidence() {
|
||||
use std::io::Write;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("backend_err.log");
|
||||
|
||||
let (file, start) = open_err_log_for_run(&path);
|
||||
assert_eq!(start, 0);
|
||||
writeln!(file.unwrap(), "run1: fatal abort, last words").unwrap();
|
||||
|
||||
// Respawn: pre-#1510 this truncated the file (File::create), turning
|
||||
// the dead run's final output into nothing.
|
||||
let (file2, start2) = open_err_log_for_run(&path);
|
||||
let content = fs::read_to_string(&path).unwrap();
|
||||
assert!(
|
||||
content.contains("run1: fatal abort"),
|
||||
"respawn destroyed the previous run's evidence: {content:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
start2 as usize,
|
||||
content.len(),
|
||||
"run2 must begin at the old EOF"
|
||||
);
|
||||
drop(file2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_run_bounded_tail_cannot_show_another_runs_output() {
|
||||
use std::io::Write;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("backend_err.log");
|
||||
|
||||
let (file, _) = open_err_log_for_run(&path);
|
||||
writeln!(file.unwrap(), "run1: Traceback — the actual crash").unwrap();
|
||||
let (file2, start2) = open_err_log_for_run(&path);
|
||||
writeln!(file2.unwrap(), "run2: OmniVoice model loaded successfully.").unwrap();
|
||||
|
||||
// The dead run's slice: only its own words.
|
||||
let run1 = read_error_log_tail_at(&path, 0, 10);
|
||||
assert!(run1.contains("the actual crash"));
|
||||
// The replacement's slice: its startup, and NEVER run1's crash —
|
||||
// and, symmetrically, a marker bounded to run1's slice could never
|
||||
// have contained run2's healthy startup (the #1510 report).
|
||||
let run2 = read_error_log_tail_at(&path, start2, 10);
|
||||
assert!(run2.contains("model loaded successfully"));
|
||||
assert!(
|
||||
!run2.contains("the actual crash"),
|
||||
"run-bounded tail leaked another run's output: {run2:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_invalid_offset_degrades_to_the_whole_file_not_to_silence() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("backend_err.log");
|
||||
fs::write(&path, "only line\n").unwrap();
|
||||
// Offset beyond EOF (file replaced/shrunk externally): evidence
|
||||
// beats precision — degrade to the whole file, never to "".
|
||||
assert_eq!(read_error_log_tail_at(&path, 10_000, 10), "only line");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dying_runs_buffered_stderr_flushes_before_the_next_offset() {
|
||||
use std::io::Write;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("backend_err.log");
|
||||
fs::write(&path, "run1: early line\n").unwrap();
|
||||
|
||||
// A drainer still flushing the dead run's buffered tail…
|
||||
let p = path.clone();
|
||||
let late = std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_millis(120));
|
||||
let mut f = fs::OpenOptions::new().append(true).open(&p).unwrap();
|
||||
writeln!(f, "run1: buffered last words").unwrap();
|
||||
});
|
||||
*ERR_LOG_DRAINER.lock().unwrap() = Some(late);
|
||||
|
||||
// …must land BEFORE the next run records where its output begins.
|
||||
join_previous_err_drainer(Duration::from_secs(2));
|
||||
let (_file, start) = open_err_log_for_run(&path);
|
||||
let run2 = read_error_log_tail_at(&path, start, 10);
|
||||
assert!(
|
||||
!run2.contains("buffered last words"),
|
||||
"old run's buffered stderr was attributed to the new run: {run2:?}"
|
||||
);
|
||||
assert!(fs::read_to_string(&path)
|
||||
.unwrap()
|
||||
.contains("buffered last words"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_spawn_diagnostic_never_carries_the_users_home_path() {
|
||||
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let saved = std::env::var("HOME").ok();
|
||||
std::env::set_var("HOME", "/home/realname");
|
||||
let diag = spawn_failure_diagnostic(
|
||||
Path::new("/home/realname/.local/share/app/venv/bin/python"),
|
||||
&io::Error::new(io::ErrorKind::NotFound, "nope"),
|
||||
);
|
||||
match saved {
|
||||
Some(v) => std::env::set_var("HOME", v),
|
||||
None => std::env::remove_var("HOME"),
|
||||
}
|
||||
assert!(!diag.contains("/home/realname"), "home path leaked: {diag}");
|
||||
assert!(diag.contains("~/.local/share/app/venv/bin/python"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_oversized_log_rotates_instead_of_growing_forever() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("backend_err.log");
|
||||
fs::write(&path, "x".repeat((ERR_LOG_ROTATE_BYTES + 1) as usize)).unwrap();
|
||||
|
||||
let (_file, start) = open_err_log_for_run(&path);
|
||||
assert_eq!(start, 0, "a rotated log starts the new run at offset 0");
|
||||
let rotated = path.with_file_name("backend_err.log.1");
|
||||
assert!(
|
||||
rotated.exists(),
|
||||
"old evidence must survive rotation in the sibling file"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -338,7 +338,7 @@ pub fn spawn_backend_and_wait(app: &tauri::AppHandle, stage_handle: &Arc<Mutex<B
|
||||
None
|
||||
};
|
||||
if let Some((exit_info, real_exit)) = process_dead {
|
||||
let err_tail = crate::backend::read_error_log_tail_for_run(30);
|
||||
let err_tail = crate::backend::read_error_log_tail(30);
|
||||
// #941: persist the forensics for every true process death —
|
||||
// startup crashes included — unless the app is shutting down
|
||||
// or a retry flow deliberately killed the child.
|
||||
@@ -347,7 +347,7 @@ pub fn spawn_backend_and_wait(app: &tauri::AppHandle, stage_handle: &Arc<Mutex<B
|
||||
crate::crash::record_crash(crate::crash::marker_now(
|
||||
exit,
|
||||
backend_uptime_s(app),
|
||||
crate::backend::read_error_log_tail_for_run(CRASH_STDERR_TAIL_LINES),
|
||||
crate::backend::read_error_log_tail(CRASH_STDERR_TAIL_LINES),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -443,7 +443,7 @@ pub fn spawn_backend_and_wait(app: &tauri::AppHandle, stage_handle: &Arc<Mutex<B
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
let err_tail = crate::backend::read_error_log_tail_for_run(20);
|
||||
let err_tail = crate::backend::read_error_log_tail(20);
|
||||
let msg = if err_tail.is_empty() {
|
||||
"Backend did not respond within 300 s".to_string()
|
||||
} else {
|
||||
@@ -587,10 +587,10 @@ fn supervise_backend(app: &tauri::AppHandle, stage_handle: &Arc<Mutex<BootstrapS
|
||||
crate::crash::record_crash(crate::crash::marker_now(
|
||||
&exit,
|
||||
uptime_s,
|
||||
crate::backend::read_error_log_tail_for_run(CRASH_STDERR_TAIL_LINES),
|
||||
crate::backend::read_error_log_tail(CRASH_STDERR_TAIL_LINES),
|
||||
));
|
||||
if restart_budget_exhausted(&mut restart_times, Instant::now()) {
|
||||
let tail = crate::backend::read_error_log_tail_for_run(30);
|
||||
let tail = crate::backend::read_error_log_tail(30);
|
||||
let msg = format!(
|
||||
"The backend kept crashing ({} times in {} min; last death: {}) and couldn't \
|
||||
be kept running. Use Clean & Retry, or check Settings → Logs → Backend.{}",
|
||||
|
||||
@@ -98,16 +98,14 @@ impl PortalShortcutState {
|
||||
|
||||
const DESKTOP_ID: &str = "com.debpalash.omnivoice-studio";
|
||||
|
||||
fn user_entry_path() -> Option<std::path::PathBuf> {
|
||||
dirs_next::data_dir().map(|dir| {
|
||||
dir.join("applications")
|
||||
.join(format!("{DESKTOP_ID}.desktop"))
|
||||
})
|
||||
}
|
||||
|
||||
/// A packaged (system-dir) entry — deb installs manage their own; never touch.
|
||||
fn system_entry_exists() -> bool {
|
||||
fn desktop_entry_exists() -> bool {
|
||||
let filename = format!("{DESKTOP_ID}.desktop");
|
||||
let user_entry = dirs_next::data_dir()
|
||||
.map(|dir| dir.join("applications").join(&filename))
|
||||
.is_some_and(|path| path.is_file());
|
||||
if user_entry {
|
||||
return true;
|
||||
}
|
||||
std::env::var_os("XDG_DATA_DIRS")
|
||||
.map(|dirs| {
|
||||
std::env::split_paths(&dirs)
|
||||
@@ -123,54 +121,6 @@ fn system_entry_exists() -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// The `[Desktop Entry]` group's Exec target, unquoted. `None` when the main
|
||||
/// group has no usable Exec line — which GLib treats the same as a missing
|
||||
/// program. Scoped to the main group deliberately: a `[Desktop Action …]`
|
||||
/// group carries its own `Exec=`, and accepting it would retain an entry GLib
|
||||
/// still cannot resolve (CodeRabbit, #1526).
|
||||
fn entry_exec_target(content: &str) -> Option<std::path::PathBuf> {
|
||||
let mut in_main_group = false;
|
||||
let mut exec = None;
|
||||
for line in content.lines() {
|
||||
let line = line.trim_start();
|
||||
if line.starts_with('[') {
|
||||
in_main_group = line == "[Desktop Entry]";
|
||||
continue;
|
||||
}
|
||||
if in_main_group {
|
||||
if let Some(value) = line.strip_prefix("Exec=") {
|
||||
exec = Some(value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let raw = exec?.trim();
|
||||
let unquoted = raw
|
||||
.strip_prefix('"')
|
||||
.and_then(|rest| rest.split('"').next())
|
||||
.unwrap_or_else(|| raw.split_whitespace().next().unwrap_or(raw));
|
||||
if unquoted.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(std::path::PathBuf::from(unquoted))
|
||||
}
|
||||
|
||||
/// Whether a user-local identity entry must be rewritten before the portal
|
||||
/// will accept it.
|
||||
///
|
||||
/// GLib refuses to resolve a desktop entry whose Exec program does not exist
|
||||
/// (`GDesktopAppInfo` returns NULL), and the portal then rejects the bind with
|
||||
/// "App info not found" — the shortcut silently dies for the whole session.
|
||||
/// A dev entry pointing at a `target/debug` binary goes stale exactly this
|
||||
/// way: a `cargo clean`, a moved checkout, or anything that relocates the
|
||||
/// binary breaks system-wide dictation with only a log line to show for it.
|
||||
fn entry_needs_rewrite(content: &str, exec_exists: impl Fn(&std::path::Path) -> bool) -> bool {
|
||||
match entry_exec_target(content) {
|
||||
Some(target) => !exec_exists(&target),
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn desktop_exec_path() -> Result<std::path::PathBuf, String> {
|
||||
// AppImage's current_exe() points inside its transient mount. APPIMAGE is
|
||||
// the stable launcher path the desktop entry must retain.
|
||||
@@ -194,31 +144,19 @@ fn desktop_exec_value(path: &std::path::Path) -> String {
|
||||
/// Deb packages already install one; dev builds and standalone AppImages may
|
||||
/// not. Add an invisible identity entry only when none exists.
|
||||
fn ensure_desktop_identity() -> Result<(), String> {
|
||||
if system_entry_exists() {
|
||||
if desktop_entry_exists() {
|
||||
return Ok(());
|
||||
}
|
||||
let path = user_entry_path().ok_or("could not locate the user data directory")?;
|
||||
if let Ok(existing) = std::fs::read_to_string(&path) {
|
||||
if !entry_needs_rewrite(&existing, |target| target.exists()) {
|
||||
return Ok(());
|
||||
}
|
||||
// Stale: GLib returns NULL for an entry whose Exec is gone, and the
|
||||
// portal then refuses the bind ("App info not found"). Rewrite with
|
||||
// where the app actually is NOW. The user dir with our app id is ours
|
||||
// to manage — packaged entries live in the system dirs handled above.
|
||||
log::info!(
|
||||
"Wayland portal identity at {} points at a missing program — rewriting",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|error| format!("could not create applications directory: {error}"))?;
|
||||
}
|
||||
let applications = dirs_next::data_dir()
|
||||
.ok_or("could not locate the user data directory")?
|
||||
.join("applications");
|
||||
std::fs::create_dir_all(&applications)
|
||||
.map_err(|error| format!("could not create applications directory: {error}"))?;
|
||||
let entry = format!(
|
||||
"[Desktop Entry]\nType=Application\nName=VoiceStudio\nExec={}\nTerminal=false\nNoDisplay=true\nStartupWMClass=VoiceStudio\nX-VoiceStudio-Generated=true\n",
|
||||
desktop_exec_value(&desktop_exec_path()?)
|
||||
);
|
||||
let path = applications.join(format!("{DESKTOP_ID}.desktop"));
|
||||
std::fs::write(&path, entry)
|
||||
.map_err(|error| format!("could not create {}: {error}", path.display()))?;
|
||||
log::info!("Installed Wayland portal identity at {}", path.display());
|
||||
@@ -698,46 +636,6 @@ mod tests {
|
||||
assert_eq!(portal_trigger("Ctrl+K+L"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stale_identity_entry_is_rewritten() {
|
||||
// The class from 2026-08-13: the entry's Exec pointed at a binary that
|
||||
// had been moved. GLib then resolves the entry to NULL and the portal
|
||||
// refuses the bind with "App info not found" — system-wide dictation
|
||||
// silently dead for the whole session.
|
||||
let stale = "[Desktop Entry]\nType=Application\nExec=/gone/omnivoice-studio\n";
|
||||
assert!(super::entry_needs_rewrite(stale, |_| false));
|
||||
|
||||
let healthy = "[Desktop Entry]\nType=Application\nExec=\"/opt/VoiceStudio.AppImage\"\n";
|
||||
assert!(!super::entry_needs_rewrite(healthy, |path| {
|
||||
path == std::path::Path::new("/opt/VoiceStudio.AppImage")
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_targets_parse_quoted_legacy_and_missing_lines() {
|
||||
use super::entry_exec_target;
|
||||
// Current writer: quoted.
|
||||
assert_eq!(
|
||||
entry_exec_target("[Desktop Entry]\nExec=\"/tmp/Voice Studio/app\"\n").as_deref(),
|
||||
Some(std::path::Path::new("/tmp/Voice Studio/app"))
|
||||
);
|
||||
// Pre-quoting entries from older builds still parse.
|
||||
assert_eq!(
|
||||
entry_exec_target("[Desktop Entry]\nExec=/home/u/target/debug/omnivoice-studio\n")
|
||||
.as_deref(),
|
||||
Some(std::path::Path::new("/home/u/target/debug/omnivoice-studio"))
|
||||
);
|
||||
// No Exec at all resolves to NULL in GLib — treat as needing rewrite.
|
||||
assert_eq!(entry_exec_target("[Desktop Entry]\nType=Application\n"), None);
|
||||
assert!(super::entry_needs_rewrite("[Desktop Entry]\n", |_| true));
|
||||
// An action group's Exec is NOT the entry's Exec: GLib still resolves
|
||||
// the entry to NULL without a main-group Exec, so accepting this would
|
||||
// keep exactly the stale entry the rewrite exists to replace.
|
||||
let action_only = "[Desktop Entry]\nType=Application\n[Desktop Action new]\nExec=/bin/true\n";
|
||||
assert_eq!(entry_exec_target(action_only), None);
|
||||
assert!(super::entry_needs_rewrite(action_only, |_| true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_exec_paths_are_quoted_and_escaped() {
|
||||
assert_eq!(
|
||||
|
||||
+2
-20
@@ -9,7 +9,6 @@ import React, {
|
||||
} from 'react';
|
||||
import './index.css';
|
||||
import { useAppStore, FONT_STACKS } from './store';
|
||||
import { NAV_ITEMS } from './components/navItems';
|
||||
import SearchableSelect from './components/SearchableSelect';
|
||||
import DirectionDialog from './components/DirectionDialog';
|
||||
|
||||
@@ -397,7 +396,6 @@ function App() {
|
||||
handleLockProfile,
|
||||
handleUnlockProfile,
|
||||
} = useProfiles({ loadHistory, loadProfiles });
|
||||
const clearSelectedProfile = useCallback(() => setSelectedProfile(null), [setSelectedProfile]);
|
||||
|
||||
const {
|
||||
refAudio,
|
||||
@@ -658,7 +656,7 @@ function App() {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
if (remoteBackend) {
|
||||
const result = await probeRemoteBackend(remoteBackend.url);
|
||||
const result = await probeRemoteBackend(remoteBackend.url, remoteBackend.key);
|
||||
if (cancelled) return;
|
||||
setRemoteFailure(result.ok ? null : result);
|
||||
setSetupNeeded(false);
|
||||
@@ -805,22 +803,6 @@ function App() {
|
||||
// ── KEYBOARD SHORTCUTS ──
|
||||
useEffect(() => {
|
||||
const handler = (e) => {
|
||||
// In-webview navigation only: using DOM keydown keeps this identical in
|
||||
// browser, macOS, Windows and Linux builds (unlike OS-level hotkeys).
|
||||
if ((e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey) {
|
||||
const key = e.key.toLowerCase();
|
||||
if (key === 'e') {
|
||||
e.preventDefault();
|
||||
window.dispatchEvent(new Event('engine-quick-switch'));
|
||||
return;
|
||||
}
|
||||
const index = Number(key);
|
||||
if (index >= 1 && index <= NAV_ITEMS.length) {
|
||||
e.preventDefault();
|
||||
setMode(NAV_ITEMS[index - 1].id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// ⌘+Enter or Ctrl+Enter → Generate
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
@@ -1514,7 +1496,7 @@ function App() {
|
||||
) : mode === 'gallery' ? (
|
||||
<ErrorBoundary name="gallery">
|
||||
<Suspense fallback={<LazyFallback />}>
|
||||
<VoiceGallery clearSelectedProfile={clearSelectedProfile} />
|
||||
<VoiceGallery />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
) : mode === 'transcriptions' ? (
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const SRC = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const SOURCE_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx']);
|
||||
|
||||
function* productionFiles(directory) {
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
const absolute = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === 'test') continue;
|
||||
yield* productionFiles(absolute);
|
||||
continue;
|
||||
}
|
||||
if (SOURCE_EXTENSIONS.has(path.extname(entry.name)) && !/\.test\.[jt]sx?$/.test(entry.name)) {
|
||||
yield absolute;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sources = () =>
|
||||
[...productionFiles(SRC)].map((file) => ({
|
||||
file: path.relative(SRC, file).replaceAll('\\', '/'),
|
||||
source: fs.readFileSync(file, 'utf8'),
|
||||
}));
|
||||
|
||||
// Any storage receiver counts: `sessionStorage.setItem('ov_api_key', …)` is the
|
||||
// same credential-persistence class as localStorage, and production code passes
|
||||
// injected stores under other names (sessionStore, localStore, legacyStorage,
|
||||
// storage). Matching `.setItem(<master key>` — whatever the receiver, whatever
|
||||
// the quote style, optional chaining included — closes the whole class instead
|
||||
// of one spelling. getItem/removeItem (the migration/removal call sites) and
|
||||
// setItem of other keys stay legal.
|
||||
const PERSISTED_MASTER_RE =
|
||||
/\.setItem(?:\?\.)?\(\s*(?:LS_API_KEY\b|LEGACY_API_KEY_STORAGE_KEY\b|[`'"]ov_api_key[`'"])/;
|
||||
|
||||
describe('administrator credential hygiene static guard', () => {
|
||||
it('has no production path that writes the legacy master key to any Web Storage', () => {
|
||||
const violations = sources()
|
||||
.filter(({ source }) => PERSISTED_MASTER_RE.test(source))
|
||||
.map(({ file }) => file);
|
||||
|
||||
expect(violations, 'OMNIVOICE_API_KEY must never enter localStorage or sessionStorage').toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it('catches realistic storage receivers, aliases, and quote styles', () => {
|
||||
const caught = [
|
||||
"localStorage.setItem('ov_api_key', key)",
|
||||
"localStorage.setItem?.('ov_api_key', key)",
|
||||
'sessionStorage.setItem("ov_api_key", key)',
|
||||
'window.localStorage.setItem(`ov_api_key`, key)',
|
||||
'sessionStore?.setItem(LS_API_KEY, key)',
|
||||
'localStore.setItem( LEGACY_API_KEY_STORAGE_KEY, key)',
|
||||
'legacyStorage?.setItem(LS_API_KEY, master)',
|
||||
];
|
||||
const allowed = [
|
||||
"localStorage.removeItem('ov_api_key')",
|
||||
'localStore?.getItem(LS_API_KEY)',
|
||||
'storage.setItem(ADMIN_SESSION_STORAGE_KEY, JSON.stringify(record))',
|
||||
"sessionStore?.setItem('ov_pin', pin)",
|
||||
'localStorage.setItem(LS_BACKEND_URL, normalized)',
|
||||
];
|
||||
for (const line of caught) expect(PERSISTED_MASTER_RE.test(line), line).toBe(true);
|
||||
for (const line of allowed) expect(PERSISTED_MASTER_RE.test(line), line).toBe(false);
|
||||
});
|
||||
|
||||
it('has no production WebSocket query builder for a master API key', () => {
|
||||
const forbidden = [
|
||||
/searchParams\.set\(\s*['"]api_key['"]/,
|
||||
/[?&]api_key=\$\{/,
|
||||
/[?&]api_key=['"]\s*\+/,
|
||||
];
|
||||
const violations = sources()
|
||||
.filter(({ source }) => forbidden.some((pattern) => pattern.test(source)))
|
||||
.map(({ file }) => file);
|
||||
|
||||
expect(violations, 'WebSocket URLs may contain ws_ticket, never a master key').toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps both WebSocket consumers behind the authenticated URL boundary', () => {
|
||||
const constructors = sources()
|
||||
.filter(({ source }) => source.includes('new WebSocket('))
|
||||
.map(({ file, source }) => ({ file, authenticated: source.includes('authenticatedWsUrl') }));
|
||||
|
||||
expect(constructors).toEqual([
|
||||
{ file: 'components/CaptureWidget.jsx', authenticated: true },
|
||||
{ file: 'hooks/useRealtimeEvents.js', authenticated: true },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,548 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
ADMIN_SESSION_STORAGE_KEY,
|
||||
AuthSessionError,
|
||||
LEGACY_API_KEY_STORAGE_KEY,
|
||||
authenticatedWsUrl,
|
||||
clearAdminSession,
|
||||
exchangeApiKey,
|
||||
getAdminSession,
|
||||
isSameOriginApi,
|
||||
requestWebSocketTicket,
|
||||
revokeAdminSession,
|
||||
} from './authSession';
|
||||
|
||||
const SESSION = `ovs_admin_session_${'A'.repeat(43)}`;
|
||||
const TICKET = `ovs_ws_ticket_${'B'.repeat(43)}`;
|
||||
const MASTER = 'master-must-never-persist';
|
||||
const NOW_SECONDS = 1_800_000_000;
|
||||
|
||||
const response = (body: unknown, status = 201) =>
|
||||
new Response(body === null ? null : JSON.stringify(body), {
|
||||
status,
|
||||
headers: body === null ? undefined : { 'content-type': 'application/json' },
|
||||
});
|
||||
|
||||
const sameOriginWindow = {
|
||||
location: { origin: 'https://voice.test' },
|
||||
dispatchEvent: vi.fn(),
|
||||
};
|
||||
|
||||
const crossOriginWindow = {
|
||||
location: { origin: 'tauri://localhost' },
|
||||
__TAURI_INTERNALS__: {},
|
||||
dispatchEvent: vi.fn(),
|
||||
};
|
||||
|
||||
describe('short-lived admin session client', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
it('selects cookie transport only for an exact same-origin HTTP API', () => {
|
||||
expect(isSameOriginApi('https://voice.test', sameOriginWindow)).toBe(true);
|
||||
expect(isSameOriginApi('https://voice.test:444', sameOriginWindow)).toBe(false);
|
||||
expect(isSameOriginApi('http://voice.test', sameOriginWindow)).toBe(false);
|
||||
expect(isSameOriginApi('https://voice.test.evil.test', sameOriginWindow)).toBe(false);
|
||||
expect(isSameOriginApi('http://127.0.0.1:3900', crossOriginWindow)).toBe(false);
|
||||
});
|
||||
|
||||
it('exchanges a same-origin master for an HttpOnly cookie without persisting any token', async () => {
|
||||
localStorage.setItem(LEGACY_API_KEY_STORAGE_KEY, MASTER);
|
||||
const fetchImpl = vi.fn().mockResolvedValue(response(null, 204));
|
||||
|
||||
await expect(
|
||||
exchangeApiKey(MASTER, {
|
||||
apiBase: 'https://voice.test/',
|
||||
fetchImpl,
|
||||
windowLike: sameOriginWindow,
|
||||
now: () => NOW_SECONDS * 1000,
|
||||
}),
|
||||
).resolves.toEqual({ transport: 'cookie' });
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
'https://voice.test/api/auth/session',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
referrerPolicy: 'no-referrer',
|
||||
headers: expect.objectContaining({ Authorization: `Bearer ${MASTER}` }),
|
||||
body: JSON.stringify({ transport: 'cookie' }),
|
||||
}),
|
||||
);
|
||||
expect(localStorage.getItem(LEGACY_API_KEY_STORAGE_KEY)).toBeNull();
|
||||
expect(sessionStorage.length).toBe(0);
|
||||
});
|
||||
|
||||
it('stores only a backend-bound short-lived bearer session for cross-origin clients', async () => {
|
||||
localStorage.setItem(LEGACY_API_KEY_STORAGE_KEY, MASTER);
|
||||
const fetchImpl = vi
|
||||
.fn()
|
||||
.mockResolvedValue(response({ token: SESSION, expires_at: NOW_SECONDS + 3600 }));
|
||||
|
||||
await expect(
|
||||
exchangeApiKey(MASTER, {
|
||||
apiBase: 'https://gpu.test:3900/',
|
||||
fetchImpl,
|
||||
windowLike: crossOriginWindow,
|
||||
now: () => NOW_SECONDS * 1000,
|
||||
}),
|
||||
).resolves.toEqual({ transport: 'bearer', expiresAt: NOW_SECONDS + 3600 });
|
||||
|
||||
const persisted = sessionStorage.getItem(ADMIN_SESSION_STORAGE_KEY) ?? '';
|
||||
expect(persisted).toContain(SESSION);
|
||||
expect(persisted).toContain('https://gpu.test:3900');
|
||||
expect(persisted).not.toContain(MASTER);
|
||||
expect(localStorage.getItem(LEGACY_API_KEY_STORAGE_KEY)).toBeNull();
|
||||
expect(getAdminSession('https://gpu.test:3900', { now: () => NOW_SECONDS * 1000 })).toEqual({
|
||||
token: SESSION,
|
||||
expiresAt: NOW_SECONDS + 3600,
|
||||
apiBase: 'https://gpu.test:3900',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses relative lifetime when remote and browser clocks are not synchronized', async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValue(
|
||||
response({
|
||||
token: SESSION,
|
||||
expires_at: 1,
|
||||
expires_in: 3600,
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
exchangeApiKey(MASTER, {
|
||||
apiBase: 'https://gpu.test:3900',
|
||||
fetchImpl,
|
||||
windowLike: crossOriginWindow,
|
||||
now: () => NOW_SECONDS * 1000,
|
||||
}),
|
||||
).resolves.toEqual({ transport: 'bearer', expiresAt: NOW_SECONDS + 3600 });
|
||||
|
||||
expect(getAdminSession('https://gpu.test:3900', { now: () => NOW_SECONDS * 1000 })).toEqual({
|
||||
token: SESSION,
|
||||
expiresAt: NOW_SECONDS + 3600,
|
||||
apiBase: 'https://gpu.test:3900',
|
||||
});
|
||||
});
|
||||
|
||||
it('retains the legacy master while the exchange is pending and removes it on success', async () => {
|
||||
localStorage.setItem(LEGACY_API_KEY_STORAGE_KEY, MASTER);
|
||||
let resolveFetch: (value: Response) => void = () => {};
|
||||
const fetchImpl = vi.fn(
|
||||
() =>
|
||||
new Promise<Response>((resolve) => {
|
||||
resolveFetch = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const pending = exchangeApiKey(MASTER, {
|
||||
apiBase: 'https://gpu.test:3900',
|
||||
fetchImpl,
|
||||
windowLike: crossOriginWindow,
|
||||
now: () => NOW_SECONDS * 1000,
|
||||
});
|
||||
|
||||
// Not yet: only a session that actually exists may consume the stored key.
|
||||
expect(localStorage.getItem(LEGACY_API_KEY_STORAGE_KEY)).toBe(MASTER);
|
||||
resolveFetch(response({ token: SESSION, expires_at: NOW_SECONDS + 3600 }));
|
||||
await pending;
|
||||
expect(localStorage.getItem(LEGACY_API_KEY_STORAGE_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('never retries a failed exchange and exposes no master or response body in its error', async () => {
|
||||
localStorage.setItem(LEGACY_API_KEY_STORAGE_KEY, MASTER);
|
||||
const reflected = `invalid credential: ${MASTER}`;
|
||||
const fetchImpl = vi.fn().mockResolvedValue(response({ detail: reflected }, 401));
|
||||
|
||||
const error = await exchangeApiKey(MASTER, {
|
||||
apiBase: 'https://gpu.test:3900',
|
||||
fetchImpl,
|
||||
windowLike: crossOriginWindow,
|
||||
now: () => NOW_SECONDS * 1000,
|
||||
}).catch((value) => value);
|
||||
|
||||
expect(error).toBeInstanceOf(AuthSessionError);
|
||||
expect(error.status).toBe(401);
|
||||
expect(String(error)).not.toContain(MASTER);
|
||||
expect(String(error)).not.toContain(reflected);
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
expect(sessionStorage.length).toBe(0);
|
||||
// A failed exchange leaves the durable key for the next launch's retry.
|
||||
expect(localStorage.getItem(LEGACY_API_KEY_STORAGE_KEY)).toBe(MASTER);
|
||||
});
|
||||
|
||||
it('bounds a hung exchange and retains the durable master for the next migration attempt', async () => {
|
||||
vi.useFakeTimers();
|
||||
localStorage.setItem(LEGACY_API_KEY_STORAGE_KEY, MASTER);
|
||||
const fetchImpl = vi.fn(
|
||||
(_url, init) =>
|
||||
new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener('abort', () =>
|
||||
reject(new DOMException('aborted', 'AbortError')),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const pending = exchangeApiKey(MASTER, {
|
||||
apiBase: 'https://gpu.test:3900',
|
||||
fetchImpl: fetchImpl as typeof fetch,
|
||||
windowLike: crossOriginWindow,
|
||||
timeoutMs: 25,
|
||||
});
|
||||
const observed = pending.catch((error) => error);
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
|
||||
expect(await observed).toBeInstanceOf(AuthSessionError);
|
||||
expect(fetchImpl).toHaveBeenCalledOnce();
|
||||
// Unreachable/hung backend: the stored copy is the user's only copy.
|
||||
expect(localStorage.getItem(LEGACY_API_KEY_STORAGE_KEY)).toBe(MASTER);
|
||||
expect(sessionStorage.length).toBe(0);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ token: MASTER, expires_at: NOW_SECONDS + 3600 }, 'master-shaped token'],
|
||||
[{ token: SESSION, expires_at: NOW_SECONDS - 1 }, 'expired session'],
|
||||
[{ token: SESSION, expires_at: NOW_SECONDS + 40_000 }, 'implausible expiry'],
|
||||
[{ token: SESSION }, 'missing expiry'],
|
||||
[null, 'missing body'],
|
||||
])('rejects and does not persist a malformed bearer response: %s (%s)', async (body, _label) => {
|
||||
const fetchImpl = vi.fn().mockResolvedValue(response(body));
|
||||
|
||||
await expect(
|
||||
exchangeApiKey(MASTER, {
|
||||
apiBase: 'https://gpu.test:3900',
|
||||
fetchImpl,
|
||||
windowLike: crossOriginWindow,
|
||||
now: () => NOW_SECONDS * 1000,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(AuthSessionError);
|
||||
expect(sessionStorage.length).toBe(0);
|
||||
});
|
||||
|
||||
it.each([0, -1, Number.NaN, Number.POSITIVE_INFINITY, 40_000, '3600'])(
|
||||
'rejects an invalid relative session lifetime: %s',
|
||||
async (expiresIn) => {
|
||||
const fetchImpl = vi.fn().mockResolvedValue(
|
||||
response({
|
||||
token: SESSION,
|
||||
expires_at: NOW_SECONDS + 3600,
|
||||
expires_in: expiresIn,
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
exchangeApiKey(MASTER, {
|
||||
apiBase: 'https://gpu.test:3900',
|
||||
fetchImpl,
|
||||
windowLike: crossOriginWindow,
|
||||
now: () => NOW_SECONDS * 1000,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(AuthSessionError);
|
||||
expect(sessionStorage.length).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects oversized bearer responses before parsing them', async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValue(
|
||||
new Response('x'.repeat(20_000), {
|
||||
status: 201,
|
||||
headers: { 'content-length': '20000' },
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
exchangeApiKey(MASTER, {
|
||||
apiBase: 'https://gpu.test:3900',
|
||||
fetchImpl,
|
||||
windowLike: crossOriginWindow,
|
||||
now: () => NOW_SECONDS * 1000,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(AuthSessionError);
|
||||
expect(sessionStorage.length).toBe(0);
|
||||
});
|
||||
|
||||
it('drops malformed, expired, or wrong-backend session storage', () => {
|
||||
sessionStorage.setItem(ADMIN_SESSION_STORAGE_KEY, '{bad json');
|
||||
expect(getAdminSession('https://gpu.test', { now: () => NOW_SECONDS * 1000 })).toBeNull();
|
||||
|
||||
sessionStorage.setItem(
|
||||
ADMIN_SESSION_STORAGE_KEY,
|
||||
JSON.stringify({ token: SESSION, expiresAt: NOW_SECONDS - 1, apiBase: 'https://gpu.test' }),
|
||||
);
|
||||
expect(getAdminSession('https://gpu.test', { now: () => NOW_SECONDS * 1000 })).toBeNull();
|
||||
|
||||
sessionStorage.setItem(
|
||||
ADMIN_SESSION_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
token: SESSION,
|
||||
expiresAt: NOW_SECONDS + 10,
|
||||
apiBase: 'https://other.test',
|
||||
}),
|
||||
);
|
||||
expect(getAdminSession('https://gpu.test', { now: () => NOW_SECONDS * 1000 })).toBeNull();
|
||||
|
||||
sessionStorage.setItem(
|
||||
ADMIN_SESSION_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
token: SESSION,
|
||||
expiresAt: NOW_SECONDS + 40_000,
|
||||
apiBase: 'https://gpu.test',
|
||||
}),
|
||||
);
|
||||
expect(getAdminSession('https://gpu.test', { now: () => NOW_SECONDS * 1000 })).toBeNull();
|
||||
expect(sessionStorage.getItem(ADMIN_SESSION_STORAGE_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('mints a path-bound WebSocket ticket with the session only in an HTTP header', async () => {
|
||||
sessionStorage.setItem(
|
||||
ADMIN_SESSION_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
token: SESSION,
|
||||
expiresAt: NOW_SECONDS + 3600,
|
||||
apiBase: 'https://gpu.test:3900',
|
||||
}),
|
||||
);
|
||||
const fetchImpl = vi
|
||||
.fn()
|
||||
.mockResolvedValue(response({ ticket: TICKET, expires_at: NOW_SECONDS + 30 }));
|
||||
|
||||
await expect(
|
||||
requestWebSocketTicket('/ws/transcribe?model=live', {
|
||||
apiBase: 'https://gpu.test:3900',
|
||||
fetchImpl,
|
||||
now: () => NOW_SECONDS * 1000,
|
||||
}),
|
||||
).resolves.toBe(TICKET);
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
'https://gpu.test:3900/api/auth/ws-ticket',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: expect.objectContaining({ Authorization: `Bearer ${SESSION}` }),
|
||||
body: JSON.stringify({ path: '/ws/transcribe' }),
|
||||
}),
|
||||
);
|
||||
expect(JSON.stringify(fetchImpl.mock.calls[0][0])).not.toContain(SESSION);
|
||||
});
|
||||
|
||||
it('accepts a ticket lifetime independent of server wall-clock skew', async () => {
|
||||
sessionStorage.setItem(
|
||||
ADMIN_SESSION_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
token: SESSION,
|
||||
expiresAt: NOW_SECONDS + 3600,
|
||||
apiBase: 'https://gpu.test:3900',
|
||||
}),
|
||||
);
|
||||
const fetchImpl = vi
|
||||
.fn()
|
||||
.mockResolvedValue(response({ ticket: TICKET, expires_at: 1, expires_in: 30 }));
|
||||
|
||||
await expect(
|
||||
requestWebSocketTicket('/ws/events', {
|
||||
apiBase: 'https://gpu.test:3900',
|
||||
fetchImpl,
|
||||
now: () => NOW_SECONDS * 1000,
|
||||
}),
|
||||
).resolves.toBe(TICKET);
|
||||
});
|
||||
|
||||
it('places only the one-use ticket in a bearer-authenticated WebSocket URL', async () => {
|
||||
sessionStorage.setItem(
|
||||
ADMIN_SESSION_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
token: SESSION,
|
||||
expiresAt: NOW_SECONDS + 3600,
|
||||
apiBase: 'https://gpu.test:3900',
|
||||
}),
|
||||
);
|
||||
const fetchImpl = vi
|
||||
.fn()
|
||||
.mockResolvedValue(response({ ticket: TICKET, expires_at: NOW_SECONDS + 30 }));
|
||||
|
||||
const url = await authenticatedWsUrl('/ws/transcribe?model=live&api_key=legacy', {
|
||||
apiBase: 'https://gpu.test:3900',
|
||||
fetchImpl,
|
||||
now: () => NOW_SECONDS * 1000,
|
||||
});
|
||||
|
||||
expect(url).toBe(`wss://gpu.test:3900/ws/transcribe?model=live&ws_ticket=${TICKET}`);
|
||||
expect(url).not.toContain(SESSION);
|
||||
expect(url).not.toContain(MASTER);
|
||||
expect(url).not.toContain('api_key');
|
||||
});
|
||||
|
||||
it('preserves a reverse-proxy base path while binding the ticket to the logical WS route', async () => {
|
||||
sessionStorage.setItem(
|
||||
ADMIN_SESSION_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
token: SESSION,
|
||||
expiresAt: NOW_SECONDS + 3600,
|
||||
apiBase: 'https://gpu.test/studio',
|
||||
}),
|
||||
);
|
||||
const fetchImpl = vi
|
||||
.fn()
|
||||
.mockResolvedValue(response({ ticket: TICKET, expires_in: 30, expires_at: 1 }));
|
||||
|
||||
await expect(
|
||||
authenticatedWsUrl('/ws/events?view=active', {
|
||||
apiBase: 'https://gpu.test/studio',
|
||||
fetchImpl,
|
||||
now: () => NOW_SECONDS * 1000,
|
||||
}),
|
||||
).resolves.toBe(`wss://gpu.test/studio/ws/events?view=active&ws_ticket=${TICKET}`);
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
'https://gpu.test/studio/api/auth/ws-ticket',
|
||||
expect.objectContaining({ body: JSON.stringify({ path: '/ws/events' }) }),
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['/ws/events/../admin', '//evil.test/ws/events', 'https://gpu.test/ws/events'])(
|
||||
'rejects a non-canonical WebSocket target: %s',
|
||||
async (path) => {
|
||||
await expect(
|
||||
authenticatedWsUrl(path, { apiBase: 'https://gpu.test', fetchImpl: vi.fn() }),
|
||||
).rejects.toBeInstanceOf(AuthSessionError);
|
||||
},
|
||||
);
|
||||
|
||||
it('requests a fresh ticket for every WebSocket connection attempt', async () => {
|
||||
sessionStorage.setItem(
|
||||
ADMIN_SESSION_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
token: SESSION,
|
||||
expiresAt: NOW_SECONDS + 3600,
|
||||
apiBase: 'https://gpu.test:3900',
|
||||
}),
|
||||
);
|
||||
const secondTicket = `ovs_ws_ticket_${'C'.repeat(43)}`;
|
||||
const fetchImpl = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(response({ ticket: TICKET, expires_at: NOW_SECONDS + 30 }))
|
||||
.mockResolvedValueOnce(response({ ticket: secondTicket, expires_at: NOW_SECONDS + 30 }));
|
||||
|
||||
const first = await authenticatedWsUrl('/ws/events', {
|
||||
apiBase: 'https://gpu.test:3900',
|
||||
fetchImpl,
|
||||
now: () => NOW_SECONDS * 1000,
|
||||
});
|
||||
const second = await authenticatedWsUrl('/ws/events', {
|
||||
apiBase: 'https://gpu.test:3900',
|
||||
fetchImpl,
|
||||
now: () => NOW_SECONDS * 1000,
|
||||
});
|
||||
|
||||
expect(first).toContain(TICKET);
|
||||
expect(second).toContain(secondTicket);
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('uses a credential-free WebSocket URL when no bearer session exists', async () => {
|
||||
const fetchImpl = vi.fn();
|
||||
await expect(
|
||||
authenticatedWsUrl('/ws/events?api_key=must-be-removed', {
|
||||
apiBase: 'http://127.0.0.1:3900',
|
||||
fetchImpl,
|
||||
}),
|
||||
).resolves.toBe('ws://127.0.0.1:3900/ws/events');
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears an invalid session and raises the auth gate when ticket issuance is rejected', async () => {
|
||||
sessionStorage.setItem(
|
||||
ADMIN_SESSION_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
token: SESSION,
|
||||
expiresAt: NOW_SECONDS + 3600,
|
||||
apiBase: 'https://gpu.test:3900',
|
||||
}),
|
||||
);
|
||||
const windowLike = { ...crossOriginWindow, dispatchEvent: vi.fn() };
|
||||
|
||||
await expect(
|
||||
requestWebSocketTicket('/ws/events', {
|
||||
apiBase: 'https://gpu.test:3900',
|
||||
fetchImpl: vi.fn().mockResolvedValue(response({ detail: 'expired' }, 401)),
|
||||
windowLike,
|
||||
now: () => NOW_SECONDS * 1000,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(AuthSessionError);
|
||||
|
||||
expect(getAdminSession('https://gpu.test:3900', { now: () => NOW_SECONDS * 1000 })).toBeNull();
|
||||
expect(windowLike.dispatchEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'ov:auth-required' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('clears session state idempotently without touching unrelated storage', () => {
|
||||
sessionStorage.setItem(ADMIN_SESSION_STORAGE_KEY, 'value');
|
||||
sessionStorage.setItem('unrelated', 'keep');
|
||||
clearAdminSession();
|
||||
clearAdminSession();
|
||||
expect(sessionStorage.getItem(ADMIN_SESSION_STORAGE_KEY)).toBeNull();
|
||||
expect(sessionStorage.getItem('unrelated')).toBe('keep');
|
||||
});
|
||||
|
||||
it('revokes a bearer session while clearing local state before the request settles', async () => {
|
||||
sessionStorage.setItem(
|
||||
ADMIN_SESSION_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
token: SESSION,
|
||||
expiresAt: NOW_SECONDS + 3600,
|
||||
apiBase: 'https://gpu.test:3900',
|
||||
}),
|
||||
);
|
||||
let resolveFetch: (response: Response) => void = () => {};
|
||||
const fetchImpl = vi.fn(() => new Promise<Response>((resolve) => (resolveFetch = resolve)));
|
||||
|
||||
const pending = revokeAdminSession('https://gpu.test:3900', {
|
||||
fetchImpl,
|
||||
now: () => NOW_SECONDS * 1000,
|
||||
});
|
||||
|
||||
expect(sessionStorage.getItem(ADMIN_SESSION_STORAGE_KEY)).toBeNull();
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
'https://gpu.test:3900/api/auth/session',
|
||||
expect.objectContaining({
|
||||
method: 'DELETE',
|
||||
headers: expect.objectContaining({ Authorization: `Bearer ${SESSION}` }),
|
||||
credentials: 'include',
|
||||
}),
|
||||
);
|
||||
resolveFetch(response(null, 204));
|
||||
await expect(pending).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('revokes a same-origin cookie session with the CSRF marker', async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValue(response(null, 204));
|
||||
|
||||
await expect(
|
||||
revokeAdminSession('https://voice.test', {
|
||||
fetchImpl,
|
||||
windowLike: sameOriginWindow,
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
'https://voice.test/api/auth/session',
|
||||
expect.objectContaining({
|
||||
headers: { 'X-VoiceStudio-CSRF': '1' },
|
||||
credentials: 'include',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,483 +0,0 @@
|
||||
/**
|
||||
* Browser-side boundary for the remote administrator credential.
|
||||
*
|
||||
* The configured master key is accepted only as an input to `exchangeApiKey`.
|
||||
* It is never written to storage and never placed in a WebSocket URL. Browser
|
||||
* clients retain only a backend-bound, short-lived session in sessionStorage;
|
||||
* same-origin clients use an HttpOnly cookie that JavaScript cannot read.
|
||||
*/
|
||||
|
||||
export const LEGACY_API_KEY_STORAGE_KEY = 'ov_api_key';
|
||||
export const ADMIN_SESSION_STORAGE_KEY = 'ov_admin_session';
|
||||
export const CSRF_HEADER_NAME = 'X-VoiceStudio-CSRF';
|
||||
|
||||
const ADMIN_SESSION_RE = /^ovs_admin_session_[A-Za-z0-9_-]{43}$/;
|
||||
const WS_TICKET_RE = /^ovs_ws_ticket_[A-Za-z0-9_-]{43}$/;
|
||||
const MAX_AUTH_RESPONSE_BYTES = 16 * 1024;
|
||||
const MAX_SESSION_LIFETIME_SECONDS = 9 * 60 * 60;
|
||||
const MAX_TICKET_LIFETIME_SECONDS = 60;
|
||||
|
||||
type StorageLike = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>;
|
||||
|
||||
type AuthWindow = {
|
||||
location?: { origin?: string };
|
||||
dispatchEvent?: (event: Event) => boolean;
|
||||
__TAURI__?: unknown;
|
||||
__TAURI_INTERNALS__?: unknown;
|
||||
};
|
||||
|
||||
type CommonOptions = {
|
||||
apiBase: string;
|
||||
fetchImpl?: typeof fetch;
|
||||
storage?: StorageLike | null;
|
||||
windowLike?: AuthWindow;
|
||||
now?: () => number;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export type StoredAdminSession = {
|
||||
token: string;
|
||||
expiresAt: number;
|
||||
apiBase: string;
|
||||
};
|
||||
|
||||
export class AuthSessionError extends Error {
|
||||
status?: number;
|
||||
|
||||
constructor(status?: number) {
|
||||
super('Remote administrator authentication failed.');
|
||||
this.name = 'AuthSessionError';
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
function defaultWindow(): AuthWindow | undefined {
|
||||
return typeof window === 'undefined' ? undefined : window;
|
||||
}
|
||||
|
||||
function defaultSessionStorage(): StorageLike | null {
|
||||
try {
|
||||
return typeof sessionStorage === 'undefined' ? null : sessionStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function defaultLocalStorage(): StorageLike | null {
|
||||
try {
|
||||
return typeof localStorage === 'undefined' ? null : localStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizedApiBase(raw: string): string {
|
||||
const candidate = raw.trim();
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(candidate);
|
||||
} catch {
|
||||
throw new AuthSessionError();
|
||||
}
|
||||
if (
|
||||
(url.protocol !== 'http:' && url.protocol !== 'https:') ||
|
||||
url.username ||
|
||||
url.password ||
|
||||
url.search ||
|
||||
url.hash
|
||||
) {
|
||||
throw new AuthSessionError();
|
||||
}
|
||||
return url.toString().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
export function isSameOriginApi(
|
||||
apiBase: string,
|
||||
windowLike: AuthWindow | undefined = defaultWindow(),
|
||||
): boolean {
|
||||
try {
|
||||
const apiOrigin = new URL(normalizedApiBase(apiBase)).origin;
|
||||
const pageOrigin = windowLike?.location?.origin;
|
||||
return Boolean(pageOrigin && pageOrigin !== 'null' && apiOrigin === pageOrigin);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function removeLegacyMaster(storage: StorageLike | null = defaultLocalStorage()): void {
|
||||
try {
|
||||
storage?.removeItem(LEGACY_API_KEY_STORAGE_KEY);
|
||||
} catch {
|
||||
// A blocked storage API is already equivalent to the key not persisting.
|
||||
}
|
||||
}
|
||||
|
||||
export function clearAdminSession({
|
||||
storage = defaultSessionStorage(),
|
||||
}: { storage?: StorageLike | null } = {}): void {
|
||||
try {
|
||||
storage?.removeItem(ADMIN_SESSION_STORAGE_KEY);
|
||||
} catch {
|
||||
// Best effort; callers still stop using the in-memory value immediately.
|
||||
}
|
||||
}
|
||||
|
||||
export function getAdminSession(
|
||||
apiBase: string,
|
||||
{
|
||||
storage = defaultSessionStorage(),
|
||||
now = Date.now,
|
||||
}: { storage?: StorageLike | null; now?: () => number } = {},
|
||||
): StoredAdminSession | null {
|
||||
let normalized: string;
|
||||
try {
|
||||
normalized = normalizedApiBase(apiBase);
|
||||
} catch {
|
||||
clearAdminSession({ storage });
|
||||
return null;
|
||||
}
|
||||
|
||||
let raw: string | null = null;
|
||||
try {
|
||||
raw = storage?.getItem(ADMIN_SESSION_STORAGE_KEY) ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!raw || raw.length > 4096) {
|
||||
if (raw) clearAdminSession({ storage });
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<StoredAdminSession>;
|
||||
const nowSeconds = now() / 1000;
|
||||
if (
|
||||
!ADMIN_SESSION_RE.test(String(parsed.token ?? '')) ||
|
||||
typeof parsed.expiresAt !== 'number' ||
|
||||
!Number.isFinite(parsed.expiresAt) ||
|
||||
parsed.expiresAt <= nowSeconds ||
|
||||
parsed.expiresAt > nowSeconds + MAX_SESSION_LIFETIME_SECONDS ||
|
||||
parsed.apiBase !== normalized
|
||||
) {
|
||||
clearAdminSession({ storage });
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
token: parsed.token as string,
|
||||
expiresAt: parsed.expiresAt,
|
||||
apiBase: normalized,
|
||||
};
|
||||
} catch {
|
||||
clearAdminSession({ storage });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function readBoundedText(response: Response): Promise<string> {
|
||||
const advertisedBytes = Number(response.headers?.get?.('content-length'));
|
||||
if (Number.isFinite(advertisedBytes) && advertisedBytes > MAX_AUTH_RESPONSE_BYTES) {
|
||||
throw new AuthSessionError(response.status);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) return '';
|
||||
const decoder = new TextDecoder();
|
||||
const parts: string[] = [];
|
||||
let bytes = 0;
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
bytes += value.byteLength;
|
||||
if (bytes > MAX_AUTH_RESPONSE_BYTES) {
|
||||
await reader.cancel();
|
||||
throw new AuthSessionError(response.status);
|
||||
}
|
||||
parts.push(decoder.decode(value, { stream: true }));
|
||||
}
|
||||
parts.push(decoder.decode());
|
||||
return parts.join('');
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
async function readBoundedObject(response: Response): Promise<Record<string, unknown>> {
|
||||
const text = await readBoundedText(response);
|
||||
try {
|
||||
const value = JSON.parse(text);
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError();
|
||||
return value as Record<string, unknown>;
|
||||
} catch (error) {
|
||||
if (error instanceof AuthSessionError) throw error;
|
||||
throw new AuthSessionError(response.status);
|
||||
}
|
||||
}
|
||||
|
||||
function plausibleExpiry(
|
||||
value: unknown,
|
||||
nowMs: number,
|
||||
maxLifetimeSeconds: number,
|
||||
): value is number {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return false;
|
||||
const nowSeconds = nowMs / 1000;
|
||||
return value > nowSeconds && value <= nowSeconds + maxLifetimeSeconds;
|
||||
}
|
||||
|
||||
function responseExpiry(
|
||||
payload: Record<string, unknown>,
|
||||
nowMs: number,
|
||||
maxLifetimeSeconds: number,
|
||||
): number | null {
|
||||
const relative = payload.expires_in;
|
||||
if (relative !== undefined) {
|
||||
if (
|
||||
typeof relative !== 'number' ||
|
||||
!Number.isFinite(relative) ||
|
||||
relative <= 0 ||
|
||||
relative > maxLifetimeSeconds
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return nowMs / 1000 + relative;
|
||||
}
|
||||
return plausibleExpiry(payload.expires_at, nowMs, maxLifetimeSeconds) ? payload.expires_at : null;
|
||||
}
|
||||
|
||||
function dispatchAuthRequired(windowLike: AuthWindow | undefined): void {
|
||||
try {
|
||||
windowLike?.dispatchEvent?.(
|
||||
new CustomEvent('ov:auth-required', { detail: { mode: 'apikey' } }),
|
||||
);
|
||||
} catch {
|
||||
// Non-browser callers can still handle the typed error.
|
||||
}
|
||||
}
|
||||
|
||||
export async function exchangeApiKey(
|
||||
apiKey: string,
|
||||
{
|
||||
apiBase,
|
||||
fetchImpl = fetch,
|
||||
storage = defaultSessionStorage(),
|
||||
windowLike = defaultWindow(),
|
||||
now = Date.now,
|
||||
legacyStorage = defaultLocalStorage(),
|
||||
timeoutMs = 10_000,
|
||||
}: CommonOptions & { legacyStorage?: StorageLike | null },
|
||||
): Promise<{ transport: 'cookie' } | { transport: 'bearer'; expiresAt: number }> {
|
||||
// A stale session must not outlive a new exchange attempt, but the
|
||||
// historical durable master is deleted only after the backend ACCEPTS the
|
||||
// exchange. Deleting it up front stranded remote-backend users whose box was
|
||||
// unreachable at first launch after upgrade: the failed exchange consumed
|
||||
// their only stored copy of OMNIVOICE_API_KEY. Keeping it on failure lets
|
||||
// the next launch retry the migration; every success path below removes it,
|
||||
// so the key never coexists with a live session.
|
||||
clearAdminSession({ storage });
|
||||
|
||||
const master = apiKey.trim();
|
||||
if (!master || master.length > 8192) throw new AuthSessionError();
|
||||
const base = normalizedApiBase(apiBase);
|
||||
const transport = isSameOriginApi(base, windowLike) ? 'cookie' : 'bearer';
|
||||
|
||||
let response: Response;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), Math.max(1, Math.min(timeoutMs, 60_000)));
|
||||
try {
|
||||
response = await fetchImpl(`${base}/api/auth/session`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${master}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ transport }),
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
redirect: 'error',
|
||||
referrerPolicy: 'no-referrer',
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch {
|
||||
throw new AuthSessionError();
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
if (transport === 'cookie') {
|
||||
if (response.status !== 204) throw new AuthSessionError(response.status);
|
||||
removeLegacyMaster(legacyStorage);
|
||||
return { transport };
|
||||
}
|
||||
if (response.status !== 201) throw new AuthSessionError(response.status);
|
||||
|
||||
const payload = await readBoundedObject(response);
|
||||
const token = payload.token;
|
||||
const expiresAt = responseExpiry(payload, now(), MAX_SESSION_LIFETIME_SECONDS);
|
||||
if (typeof token !== 'string' || !ADMIN_SESSION_RE.test(token) || expiresAt === null) {
|
||||
throw new AuthSessionError(response.status);
|
||||
}
|
||||
|
||||
const record: StoredAdminSession = { token, expiresAt, apiBase: base };
|
||||
try {
|
||||
if (!storage) throw new TypeError();
|
||||
storage.setItem(ADMIN_SESSION_STORAGE_KEY, JSON.stringify(record));
|
||||
} catch {
|
||||
clearAdminSession({ storage });
|
||||
throw new AuthSessionError();
|
||||
}
|
||||
removeLegacyMaster(legacyStorage);
|
||||
return { transport, expiresAt };
|
||||
}
|
||||
|
||||
/** Best-effort server revocation used when switching away from a backend.
|
||||
* Local state is cleared before the network await, so a hung or unreachable
|
||||
* backend cannot prolong the browser's ability to use the session. */
|
||||
export async function revokeAdminSession(
|
||||
apiBase: string,
|
||||
{
|
||||
fetchImpl = fetch,
|
||||
storage = defaultSessionStorage(),
|
||||
windowLike = defaultWindow(),
|
||||
now = Date.now,
|
||||
timeoutMs = 1500,
|
||||
}: Omit<CommonOptions, 'apiBase'> = {},
|
||||
): Promise<boolean> {
|
||||
let base: string;
|
||||
try {
|
||||
base = normalizedApiBase(apiBase);
|
||||
} catch {
|
||||
clearAdminSession({ storage });
|
||||
return false;
|
||||
}
|
||||
const session = getAdminSession(base, { storage, now });
|
||||
const sameOrigin = isSameOriginApi(base, windowLike);
|
||||
clearAdminSession({ storage });
|
||||
// Cross-origin cookie auth cannot work (the cookie is SameSite=Strict), and
|
||||
// without a bearer token there is nothing meaningful to revoke remotely.
|
||||
if (!session && !sameOrigin) return true;
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (session) headers.Authorization = `Bearer ${session.token}`;
|
||||
if (sameOrigin) headers[CSRF_HEADER_NAME] = '1';
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), Math.max(1, Math.min(timeoutMs, 10_000)));
|
||||
try {
|
||||
const response = await fetchImpl(`${base}/api/auth/session`, {
|
||||
method: 'DELETE',
|
||||
headers,
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
redirect: 'error',
|
||||
referrerPolicy: 'no-referrer',
|
||||
signal: controller.signal,
|
||||
});
|
||||
return response.status === 204;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
const ALLOWED_WS_PATHS = new Set(['/ws/events', '/ws/transcribe']);
|
||||
const LOGICAL_WS_ORIGIN = 'http://omnivoice.invalid';
|
||||
|
||||
function websocketTarget(path: string, apiBase: string): { url: URL; logicalPath: string } {
|
||||
const base = normalizedApiBase(apiBase);
|
||||
const baseUrl = new URL(base);
|
||||
let logical: URL;
|
||||
try {
|
||||
if (!path.startsWith('/') || path.startsWith('//')) throw new TypeError();
|
||||
logical = new URL(path, `${LOGICAL_WS_ORIGIN}/`);
|
||||
} catch {
|
||||
throw new AuthSessionError();
|
||||
}
|
||||
if (logical.origin !== LOGICAL_WS_ORIGIN || !ALLOWED_WS_PATHS.has(logical.pathname)) {
|
||||
throw new AuthSessionError();
|
||||
}
|
||||
|
||||
// Resolve relative to `${base}/`, not the origin root. Reverse proxies may
|
||||
// publish the backend under a path prefix (for example `/studio`). The
|
||||
// server still receives the logical route after the proxy strips its prefix,
|
||||
// so ticket binding uses `logical.pathname` below.
|
||||
const url = new URL(path.slice(1), `${base}/`);
|
||||
if (url.origin !== baseUrl.origin) throw new AuthSessionError();
|
||||
url.username = '';
|
||||
url.password = '';
|
||||
url.hash = '';
|
||||
url.searchParams.delete('api_key');
|
||||
url.searchParams.delete('ws_ticket');
|
||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
return { url, logicalPath: logical.pathname };
|
||||
}
|
||||
|
||||
export async function requestWebSocketTicket(
|
||||
path: string,
|
||||
{
|
||||
apiBase,
|
||||
fetchImpl = fetch,
|
||||
storage = defaultSessionStorage(),
|
||||
windowLike = defaultWindow(),
|
||||
now = Date.now,
|
||||
timeoutMs = 5000,
|
||||
}: CommonOptions,
|
||||
): Promise<string> {
|
||||
const base = normalizedApiBase(apiBase);
|
||||
const { logicalPath } = websocketTarget(path, base);
|
||||
const session = getAdminSession(base, { storage, now });
|
||||
if (!session) throw new AuthSessionError(401);
|
||||
|
||||
let response: Response;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), Math.max(1, Math.min(timeoutMs, 30_000)));
|
||||
try {
|
||||
response = await fetchImpl(`${base}/api/auth/ws-ticket`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ path: logicalPath }),
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
redirect: 'error',
|
||||
referrerPolicy: 'no-referrer',
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch {
|
||||
throw new AuthSessionError();
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
if (response.status !== 201) {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
clearAdminSession({ storage });
|
||||
dispatchAuthRequired(windowLike);
|
||||
}
|
||||
throw new AuthSessionError(response.status);
|
||||
}
|
||||
const payload = await readBoundedObject(response);
|
||||
const expiresAt = responseExpiry(payload, now(), MAX_TICKET_LIFETIME_SECONDS);
|
||||
if (
|
||||
typeof payload.ticket !== 'string' ||
|
||||
!WS_TICKET_RE.test(payload.ticket) ||
|
||||
expiresAt === null
|
||||
) {
|
||||
throw new AuthSessionError(response.status);
|
||||
}
|
||||
return payload.ticket;
|
||||
}
|
||||
|
||||
export async function authenticatedWsUrl(path: string, options: CommonOptions): Promise<string> {
|
||||
const { url } = websocketTarget(path, options.apiBase);
|
||||
const session = getAdminSession(options.apiBase, {
|
||||
storage: options.storage,
|
||||
now: options.now,
|
||||
});
|
||||
if (!session) return url.toString();
|
||||
|
||||
const ticket = await requestWebSocketTicket(path, options);
|
||||
url.searchParams.set('ws_ticket', ticket);
|
||||
return url.toString();
|
||||
}
|
||||
@@ -1,12 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
API,
|
||||
_bootstrapBrowserCredentials,
|
||||
_isApiTarget,
|
||||
_parseDeepLinkCredentials,
|
||||
wsUrl,
|
||||
} from './client';
|
||||
import { ADMIN_SESSION_STORAGE_KEY, CSRF_HEADER_NAME } from './authSession';
|
||||
import { _parseDeepLinkCredentials } from './client';
|
||||
|
||||
describe('apiFetch PIN header', () => {
|
||||
let realFetch: typeof globalThis.fetch;
|
||||
@@ -28,8 +21,7 @@ describe('apiFetch PIN header', () => {
|
||||
}) as any;
|
||||
const { apiFetch } = await import('./client');
|
||||
await apiFetch('/system/info');
|
||||
expect(new Headers(seen.headers).get('X-OmniVoice-Pin')).toBe('424242');
|
||||
expect(seen.credentials).toBe('include');
|
||||
expect((seen.headers || {})['X-OmniVoice-Pin']).toBe('424242');
|
||||
});
|
||||
|
||||
it('omits the header when no pin', async () => {
|
||||
@@ -40,27 +32,7 @@ describe('apiFetch PIN header', () => {
|
||||
}) as any;
|
||||
const { apiFetch } = await import('./client');
|
||||
await apiFetch('/system/info');
|
||||
expect(new Headers(seen.headers).get('X-OmniVoice-Pin')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps cookie and loopback requests usable when Web Storage is blocked', async () => {
|
||||
const getItem = vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
||||
throw new DOMException('blocked', 'SecurityError');
|
||||
});
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
||||
globalThis.fetch = fetchMock as any;
|
||||
|
||||
try {
|
||||
const { apiFetch } = await import('./client');
|
||||
await expect(apiFetch('/system/info')).resolves.toMatchObject({ ok: true });
|
||||
} finally {
|
||||
getItem.mockRestore();
|
||||
}
|
||||
|
||||
const headers = new Headers(fetchMock.mock.calls[0][1]?.headers);
|
||||
expect(headers.get('X-OmniVoice-Pin')).toBeNull();
|
||||
expect(headers.get('Authorization')).toBeNull();
|
||||
expect(fetchMock.mock.calls[0][1]?.credentials).toBe('include');
|
||||
expect((seen.headers || {})['X-OmniVoice-Pin']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('turns a thrown fetch into an actionable ApiError (backend unreachable)', async () => {
|
||||
@@ -83,85 +55,6 @@ describe('apiFetch PIN header', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('apiFetch short-lived admin authentication', () => {
|
||||
let realFetch: typeof globalThis.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
realFetch = globalThis.fetch;
|
||||
sessionStorage.clear();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = realFetch;
|
||||
sessionStorage.clear();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('attaches only the backend-bound short-lived session, never the persisted master', async () => {
|
||||
const session = `ovs_admin_session_${'S'.repeat(43)}`;
|
||||
localStorage.setItem('ov_api_key', 'legacy-master');
|
||||
sessionStorage.setItem(
|
||||
ADMIN_SESSION_STORAGE_KEY,
|
||||
JSON.stringify({ token: session, expiresAt: Date.now() / 1000 + 3600, apiBase: API }),
|
||||
);
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
||||
globalThis.fetch = fetchMock as any;
|
||||
|
||||
const { apiFetch } = await import('./client');
|
||||
await apiFetch('/system/info');
|
||||
|
||||
const headers = new Headers(fetchMock.mock.calls[0][1]?.headers);
|
||||
expect(headers.get('Authorization')).toBe(`Bearer ${session}`);
|
||||
expect(headers.get('Authorization')).not.toContain('legacy-master');
|
||||
});
|
||||
|
||||
it('builds credential-free legacy WebSocket URLs even if old storage is populated', () => {
|
||||
localStorage.setItem('ov_api_key', 'legacy-master');
|
||||
const url = wsUrl('/ws/events?view=active');
|
||||
expect(url).toContain('/ws/events?view=active');
|
||||
expect(url).not.toContain('api_key');
|
||||
expect(url).not.toContain('legacy-master');
|
||||
});
|
||||
|
||||
it('never sends backend credentials to an absolute foreign URL', async () => {
|
||||
const session = `ovs_admin_session_${'S'.repeat(43)}`;
|
||||
sessionStorage.setItem('ov_pin', '424242');
|
||||
sessionStorage.setItem(
|
||||
ADMIN_SESSION_STORAGE_KEY,
|
||||
JSON.stringify({ token: session, expiresAt: Date.now() / 1000 + 3600, apiBase: API }),
|
||||
);
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
||||
globalThis.fetch = fetchMock as any;
|
||||
|
||||
const { apiFetch } = await import('./client');
|
||||
await apiFetch('https://voice.example.evil.test/public.wav', {
|
||||
credentials: 'omit',
|
||||
headers: { 'X-Public-Media': '1' },
|
||||
});
|
||||
|
||||
const [target, init] = fetchMock.mock.calls[0];
|
||||
const headers = new Headers(init?.headers);
|
||||
expect(target).toBe('https://voice.example.evil.test/public.wav');
|
||||
expect(headers.get('Authorization')).toBeNull();
|
||||
expect(headers.get('X-OmniVoice-Pin')).toBeNull();
|
||||
expect(headers.get(CSRF_HEADER_NAME)).toBeNull();
|
||||
expect(headers.get('X-Public-Media')).toBe('1');
|
||||
expect(init?.credentials).toBe('omit');
|
||||
});
|
||||
|
||||
it('binds credentials to the exact configured API path prefix', () => {
|
||||
expect(_isApiTarget('https://voice.test/studio/v1/audio', 'https://voice.test/studio')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(_isApiTarget('https://voice.test/studio-evil/v1', 'https://voice.test/studio')).toBe(
|
||||
false,
|
||||
);
|
||||
expect(_isApiTarget('https://voice.test/other', 'https://voice.test/studio')).toBe(false);
|
||||
expect(_isApiTarget('https://voice.test.evil/v1', 'https://voice.test')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('apiFetch 401 routing', () => {
|
||||
// The backend has two 401-returning middlewares distinguished only by their
|
||||
// `detail` body: "API key required" (BearerKeyMiddleware) vs "PIN required"
|
||||
@@ -196,14 +89,6 @@ describe('apiFetch 401 routing', () => {
|
||||
dispatch.mock.calls.map((c) => c[0]).find((e) => (e as Event).type === 'ov:auth-required');
|
||||
|
||||
it('dispatches ov:auth-required {mode:"apikey"} on an "API key required" 401', async () => {
|
||||
sessionStorage.setItem(
|
||||
ADMIN_SESSION_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
token: `ovs_admin_session_${'S'.repeat(43)}`,
|
||||
expiresAt: Date.now() / 1000 + 3600,
|
||||
apiBase: API,
|
||||
}),
|
||||
);
|
||||
globalThis.fetch = stub401('API key required');
|
||||
const { apiFetch } = await import('./client');
|
||||
try {
|
||||
@@ -213,7 +98,6 @@ describe('apiFetch 401 routing', () => {
|
||||
}
|
||||
expect(authEvent()).toBeTruthy();
|
||||
expect((authEvent() as any).detail.mode).toBe('apikey');
|
||||
expect(sessionStorage.getItem(ADMIN_SESSION_STORAGE_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('dispatches ov:auth-required {mode:"pin"} on a "PIN required" 401', async () => {
|
||||
@@ -347,127 +231,3 @@ describe('_parseDeepLinkCredentials', () => {
|
||||
expect(r.cleanUrl).toBe('/path?page=2#top');
|
||||
});
|
||||
});
|
||||
|
||||
describe('_bootstrapBrowserCredentials', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
it('scrubs the fragment before exchanging exactly once, deleting legacy storage on success', async () => {
|
||||
const order: string[] = [];
|
||||
localStorage.setItem('ov_api_key', 'older-master');
|
||||
const win = {
|
||||
location: { href: 'https://voice.test/app?pin=1234#api_key=fragment-master&tab=voices' },
|
||||
history: {
|
||||
replaceState: (_data: unknown, _unused: string, url?: string | URL | null) => {
|
||||
order.push(`scrub:${String(url)}`);
|
||||
},
|
||||
},
|
||||
};
|
||||
const exchange = vi.fn(async (master) => {
|
||||
order.push('exchange');
|
||||
expect(master).toBe('fragment-master');
|
||||
// The durable key survives until the backend accepts the exchange — a
|
||||
// failure at this point must leave it for the next launch to retry.
|
||||
expect(localStorage.getItem('ov_api_key')).toBe('older-master');
|
||||
});
|
||||
|
||||
await _bootstrapBrowserCredentials(win, {
|
||||
apiBase: 'https://voice.test',
|
||||
exchange: exchange as any,
|
||||
});
|
||||
|
||||
expect(sessionStorage.getItem('ov_pin')).toBe('1234');
|
||||
expect(order).toEqual(['scrub:/app#tab=voices', 'exchange']);
|
||||
expect(exchange).toHaveBeenCalledOnce();
|
||||
expect(localStorage.getItem('ov_api_key')).toBeNull();
|
||||
});
|
||||
|
||||
it('retains the stored master when the backend is unreachable (no stranding)', async () => {
|
||||
// The upgrade-day disaster this guards against: a remote-backend user's
|
||||
// only copy of OMNIVOICE_API_KEY lives in localStorage, and the backend is
|
||||
// down at first launch. The failed exchange must NOT consume the key.
|
||||
localStorage.setItem('ov_api_key', 'legacy-master');
|
||||
const exchange = vi.fn().mockRejectedValue(new TypeError('Failed to fetch'));
|
||||
|
||||
await expect(
|
||||
_bootstrapBrowserCredentials(
|
||||
{ location: { href: 'https://voice.test/' }, history: { replaceState: vi.fn() } },
|
||||
{ apiBase: 'https://voice.test', exchange },
|
||||
),
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(exchange).toHaveBeenCalledWith('legacy-master', { apiBase: 'https://voice.test' });
|
||||
expect(localStorage.getItem('ov_api_key')).toBe('legacy-master');
|
||||
});
|
||||
|
||||
it('re-runs the migration on the next launch and consumes the key once it succeeds', async () => {
|
||||
localStorage.setItem('ov_api_key', 'legacy-master');
|
||||
const exchange = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new TypeError('Failed to fetch'))
|
||||
.mockResolvedValueOnce({ transport: 'bearer', expiresAt: Date.now() / 1000 + 60 });
|
||||
const launch = () =>
|
||||
_bootstrapBrowserCredentials(
|
||||
{ location: { href: 'https://voice.test/' }, history: { replaceState: vi.fn() } },
|
||||
{ apiBase: 'https://voice.test', exchange },
|
||||
);
|
||||
|
||||
// Launch 1: backend unreachable — key survives.
|
||||
await expect(launch()).rejects.toThrow();
|
||||
expect(localStorage.getItem('ov_api_key')).toBe('legacy-master');
|
||||
|
||||
// Launch 2: backend back — the retained key is retried and then removed.
|
||||
await launch();
|
||||
expect(exchange).toHaveBeenNthCalledWith(2, 'legacy-master', {
|
||||
apiBase: 'https://voice.test',
|
||||
});
|
||||
expect(localStorage.getItem('ov_api_key')).toBeNull();
|
||||
});
|
||||
|
||||
it('consumes a legacy stored master without writing it anywhere else', async () => {
|
||||
localStorage.setItem('ov_api_key', 'legacy-master');
|
||||
const exchange = vi.fn().mockResolvedValue({ transport: 'bearer' });
|
||||
|
||||
await _bootstrapBrowserCredentials(
|
||||
{
|
||||
location: { href: 'https://voice.test/' },
|
||||
history: { replaceState: vi.fn() },
|
||||
},
|
||||
{ apiBase: 'https://voice.test', exchange },
|
||||
);
|
||||
|
||||
expect(exchange).toHaveBeenCalledWith('legacy-master', { apiBase: 'https://voice.test' });
|
||||
expect(localStorage.getItem('ov_api_key')).toBeNull();
|
||||
expect(sessionStorage.getItem('ov_api_key')).toBeNull();
|
||||
});
|
||||
|
||||
it('still deletes and exchanges the master when PIN session storage is blocked', async () => {
|
||||
localStorage.setItem('ov_api_key', 'legacy-master');
|
||||
const replaceState = vi.fn();
|
||||
const exchange = vi.fn().mockResolvedValue({ transport: 'bearer' });
|
||||
|
||||
await _bootstrapBrowserCredentials(
|
||||
{
|
||||
location: { href: 'https://voice.test/?pin=1234#api_key=fragment-master' },
|
||||
history: { replaceState },
|
||||
},
|
||||
{
|
||||
apiBase: 'https://voice.test',
|
||||
sessionStore: {
|
||||
setItem: vi.fn(() => {
|
||||
throw new DOMException('blocked');
|
||||
}),
|
||||
},
|
||||
exchange,
|
||||
},
|
||||
);
|
||||
|
||||
expect(replaceState).toHaveBeenCalledWith(null, '', '/');
|
||||
expect(localStorage.getItem('ov_api_key')).toBeNull();
|
||||
expect(exchange).toHaveBeenCalledWith('fragment-master', {
|
||||
apiBase: 'https://voice.test',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+50
-148
@@ -26,23 +26,13 @@ import {
|
||||
recordBackendContact,
|
||||
unreachableBackendMessage,
|
||||
} from '../utils/backendContact.ts';
|
||||
import {
|
||||
CSRF_HEADER_NAME,
|
||||
LEGACY_API_KEY_STORAGE_KEY,
|
||||
clearAdminSession,
|
||||
exchangeApiKey,
|
||||
getAdminSession,
|
||||
isSameOriginApi,
|
||||
} from './authSession.ts';
|
||||
|
||||
const viteEnv = import.meta.env ?? {};
|
||||
// Remote-backend settings (Wave 2.3): user-configured in Settings → Sharing.
|
||||
// localStorage so the choice survives restarts; read once at module load —
|
||||
// the Settings panel reloads the app on save.
|
||||
export const LS_BACKEND_URL = 'ov_backend_url';
|
||||
// Compatibility name used only to delete data written by older releases.
|
||||
// New code must never persist the configured master credential.
|
||||
export const LS_API_KEY = LEGACY_API_KEY_STORAGE_KEY;
|
||||
export const LS_API_KEY = 'ov_api_key';
|
||||
// Pure + exported for unit testing — takes env + window so tests don't need to
|
||||
// re-import the module or stub import.meta.env.
|
||||
export function _resolveApiBase(env: any, win: any): string {
|
||||
@@ -74,25 +64,40 @@ export function _resolveApiBase(env: any, win: any): string {
|
||||
}
|
||||
export const API = _resolveApiBase(viteEnv, typeof window !== 'undefined' ? window : undefined);
|
||||
|
||||
function sessionPin(): string | null {
|
||||
function _apiKey(): string | null {
|
||||
try {
|
||||
return typeof sessionStorage === 'undefined' ? null : sessionStorage.getItem('ov_pin');
|
||||
return typeof localStorage !== 'undefined' ? localStorage.getItem(LS_API_KEY) : null;
|
||||
} catch {
|
||||
// Cookie-authenticated and loopback requests must still work when a
|
||||
// privacy policy blocks Web Storage.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist the durable remote API key (trimmed). localStorage so it survives
|
||||
* reloads; read back by `_apiKey()` on every request. Returns false (without
|
||||
* writing) when the value is empty-after-trim or storage is unavailable, so the
|
||||
* caller can avoid reloading into a loop. */
|
||||
export function saveApiKey(v: string): boolean {
|
||||
const t = v.trim();
|
||||
if (!t) return false;
|
||||
try {
|
||||
localStorage.setItem(LS_API_KEY, t);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a ws:// or wss:// URL for a backend WebSocket endpoint.
|
||||
*
|
||||
* Scheme derives from the API base itself (NOT window.location — a Tauri
|
||||
* webview pointing at an https remote must still get wss). Credentials are
|
||||
* intentionally excluded; authenticated callers obtain a one-use ticket via
|
||||
* `authenticatedWsUrl` in authSession.ts. */
|
||||
* webview pointing at an https remote must still get wss), and the remote
|
||||
* API key rides as ?api_key= because browser WebSockets can't set headers. */
|
||||
export function wsUrl(path: string): string {
|
||||
const base = API.replace(/^http/, 'ws').replace(/\/+$/, '');
|
||||
return `${base}${path.startsWith('/') ? '' : '/'}${path}`;
|
||||
const url = `${base}${path.startsWith('/') ? '' : '/'}${path}`;
|
||||
const key = _apiKey();
|
||||
if (!key) return url;
|
||||
return `${url}${url.includes('?') ? '&' : '?'}api_key=${encodeURIComponent(key)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -101,9 +106,9 @@ export function wsUrl(path: string): string {
|
||||
* the effects.
|
||||
* • ?pin=<pin> (query) — LAN-share QR. Returned as `pin` (session).
|
||||
* • #api_key=<key> (fragment) — remote-backend deep link. Returned as `apiKey`
|
||||
* for one immediate exchange. Read from the FRAGMENT because fragments
|
||||
* aren't sent to the server, so the root secret stays out of request logs;
|
||||
* the PIN stays in the query since the QR flow needs the server to see it.
|
||||
* (durable). Read from the FRAGMENT because fragments aren't sent to the
|
||||
* server, so the durable secret stays out of request logs; the PIN stays in
|
||||
* the query since the QR flow needs the server to see it.
|
||||
* A stray legacy ?api_key= in the query is scrubbed from `cleanUrl` but NOT
|
||||
* returned — reading it would resend the secret to the server on reload, the
|
||||
* very leak the fragment avoids. `scrubbed` is true when any credential param
|
||||
@@ -138,95 +143,15 @@ export function _parseDeepLinkCredentials(href: string): {
|
||||
};
|
||||
}
|
||||
|
||||
type BootstrapWindow = {
|
||||
location: { href: string };
|
||||
history: { replaceState: (data: unknown, unused: string, url?: string | URL | null) => void };
|
||||
};
|
||||
|
||||
/** One-shot migration seam kept injectable so ordering is regression-tested:
|
||||
* scrub the URL synchronously, read (never re-write) the durable master, then
|
||||
* perform the only request that may carry it. The durable copy is deleted only
|
||||
* after that exchange SUCCEEDS: deleting it first stranded remote-backend
|
||||
* users whose backend was unreachable at first launch after upgrade — the
|
||||
* failed exchange destroyed their only copy of the admin key. On failure the
|
||||
* key stays put so the next launch retries this migration. */
|
||||
export async function _bootstrapBrowserCredentials(
|
||||
win: BootstrapWindow,
|
||||
{
|
||||
apiBase = API,
|
||||
sessionStore,
|
||||
localStore,
|
||||
exchange = exchangeApiKey,
|
||||
}: {
|
||||
apiBase?: string;
|
||||
sessionStore?: Pick<Storage, 'setItem'> | null;
|
||||
localStore?: Pick<Storage, 'getItem' | 'removeItem'> | null;
|
||||
exchange?: typeof exchangeApiKey;
|
||||
} = {},
|
||||
): Promise<void> {
|
||||
if (sessionStore === undefined) {
|
||||
try {
|
||||
sessionStore = sessionStorage;
|
||||
} catch {
|
||||
sessionStore = null;
|
||||
}
|
||||
}
|
||||
if (localStore === undefined) {
|
||||
try {
|
||||
localStore = localStorage;
|
||||
} catch {
|
||||
localStore = null;
|
||||
}
|
||||
}
|
||||
const { pin, apiKey, cleanUrl, scrubbed } = _parseDeepLinkCredentials(win.location.href);
|
||||
if (scrubbed) {
|
||||
try {
|
||||
win.history.replaceState(null, '', cleanUrl);
|
||||
} catch {
|
||||
/* keep deleting retained credentials even if history is unavailable */
|
||||
}
|
||||
}
|
||||
|
||||
let master = apiKey;
|
||||
try {
|
||||
const legacy = localStore?.getItem(LS_API_KEY) ?? null;
|
||||
if (!master) master = legacy;
|
||||
} catch {
|
||||
/* a fragment exchange can still proceed */
|
||||
}
|
||||
if (pin) {
|
||||
try {
|
||||
sessionStore?.setItem('ov_pin', pin);
|
||||
} catch {
|
||||
/* blocked PIN storage must not prevent the exchange */
|
||||
}
|
||||
}
|
||||
if (master) {
|
||||
// A rejected/unreachable exchange throws past this point, leaving the
|
||||
// durable key in place for the next launch's retry (the module-load catch
|
||||
// below still raises the auth gate). Only a session that actually exists
|
||||
// may consume the stored master.
|
||||
await exchange(master, { apiBase });
|
||||
try {
|
||||
localStore?.removeItem(LS_API_KEY);
|
||||
} catch {
|
||||
/* storage unavailable; exchangeApiKey performed the same best-effort deletion */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// On load, capture deep-link credentials, scrub the address bar synchronously,
|
||||
// and exchange a master key exactly once. Historical durable master storage is
|
||||
// read before the first await and deleted only once the exchange succeeds, so
|
||||
// an unreachable backend leaves it for the next launch to retry. apiFetch
|
||||
// waits for this one-shot migration so no request races ahead with an
|
||||
// unauthenticated first call.
|
||||
let authBootstrapPromise: Promise<void> = Promise.resolve();
|
||||
// On load, capture deep-link credentials (?pin= from the QR query, #api_key=
|
||||
// from a remote-backend fragment) so apiFetch attaches them automatically, then
|
||||
// scrub them from the address bar (one-shot — see _parseDeepLinkCredentials).
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
authBootstrapPromise = _bootstrapBrowserCredentials(window).catch(() => {
|
||||
window.dispatchEvent(new CustomEvent('ov:auth-required', { detail: { mode: 'apikey' } }));
|
||||
});
|
||||
const { pin, apiKey, cleanUrl, scrubbed } = _parseDeepLinkCredentials(window.location.href);
|
||||
if (pin) sessionStorage.setItem('ov_pin', pin);
|
||||
if (apiKey) saveApiKey(apiKey);
|
||||
if (scrubbed) window.history.replaceState(null, '', cleanUrl);
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
@@ -248,21 +173,6 @@ export function apiUrl(path?: string): string {
|
||||
return path.startsWith('http') ? path : `${API}${path.startsWith('/') ? '' : '/'}${path}`;
|
||||
}
|
||||
|
||||
/** Whether an already-resolved request URL stays inside the configured API
|
||||
* origin and path prefix. Absolute URLs remain supported for public media, but
|
||||
* they must never inherit backend credentials by accident. */
|
||||
export function _isApiTarget(target: string, apiBase: string = API): boolean {
|
||||
try {
|
||||
const base = new URL(apiBase.replace(/\/+$/, '') + '/');
|
||||
const url = new URL(target);
|
||||
if (url.origin !== base.origin) return false;
|
||||
const prefix = base.pathname.replace(/\/+$/, '');
|
||||
return !prefix || url.pathname === prefix || url.pathname.startsWith(`${prefix}/`);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Stamped on EVERY response by the backend's BackendMarkerMiddleware and
|
||||
// exposed cross-origin, so its presence is AUTHORITATIVE: this really is an
|
||||
// VoiceStudio backend answering, whatever the body looks like (#1385).
|
||||
@@ -333,25 +243,18 @@ const RECONCILE_INTERVAL_MS = 1000;
|
||||
export type ApiFetchOptions = RequestInit & { retryTransport?: boolean };
|
||||
|
||||
export async function apiFetch(path: string, opts: ApiFetchOptions = {}): Promise<Response> {
|
||||
await authBootstrapPromise;
|
||||
const requestUrl = apiUrl(path);
|
||||
const backendTarget = _isApiTarget(requestUrl);
|
||||
const pin = backendTarget ? sessionPin() : null;
|
||||
const session = backendTarget ? getAdminSession(API) : null;
|
||||
const pin = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('ov_pin') : null;
|
||||
const key = _apiKey();
|
||||
// Only modify the request when a PIN/API key is set, so the default call
|
||||
// shape (e.g. FormData posts with no headers / no Content-Type override)
|
||||
// is preserved exactly.
|
||||
const extra: Record<string, string> = {};
|
||||
if (pin) extra['X-OmniVoice-Pin'] = pin;
|
||||
if (key) extra['Authorization'] = `Bearer ${key}`;
|
||||
const { retryTransport = true, ...requestOpts } = opts;
|
||||
const headers = new Headers(requestOpts.headers);
|
||||
if (pin) headers.set('X-OmniVoice-Pin', pin);
|
||||
if (session) headers.set('Authorization', `Bearer ${session.token}`);
|
||||
// Same-origin browser clients authenticate through an HttpOnly cookie. The
|
||||
// marker makes ambient-cookie mutations fail closed under the backend's
|
||||
// exact-Origin CSRF policy; browser-managed Sec-Fetch-Site supplies the
|
||||
// additional guard for side-effectful GET routes.
|
||||
if (backendTarget && isSameOriginApi(API)) headers.set(CSRF_HEADER_NAME, '1');
|
||||
const finalOpts: RequestInit = {
|
||||
...requestOpts,
|
||||
headers,
|
||||
...(backendTarget ? { credentials: 'include' as RequestCredentials } : {}),
|
||||
};
|
||||
const finalOpts: RequestInit = Object.keys(extra).length
|
||||
? { ...requestOpts, headers: { ...(requestOpts.headers as Record<string, string>), ...extra } }
|
||||
: requestOpts;
|
||||
const signal = finalOpts.signal as AbortSignal | null | undefined;
|
||||
let lastDetail = '';
|
||||
// The shell's last word on the backend. When it still says `ready` after we've
|
||||
@@ -367,12 +270,12 @@ export async function apiFetch(path: string, opts: ApiFetchOptions = {}): Promis
|
||||
if (signal?.aborted) throw new DOMException('Aborted', 'AbortError');
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(requestUrl, finalOpts);
|
||||
res = await fetch(apiUrl(path), finalOpts);
|
||||
// Any response — success or HTTP error alike — proves the backend
|
||||
// process is alive and answering. Recording it lets a LATER transport
|
||||
// failure say "it was answering Xs ago and stopped" instead of the
|
||||
// one-size "can't reach" (#1164).
|
||||
if (backendTarget) recordBackendContact();
|
||||
recordBackendContact();
|
||||
} catch (e) {
|
||||
// A thrown fetch (TypeError "Failed to fetch" / "NetworkError") means the
|
||||
// request never reached the backend — it's still starting up, crashed, or
|
||||
@@ -518,8 +421,8 @@ export async function apiFetch(path: string, opts: ApiFetchOptions = {}): Promis
|
||||
// or a reverse proxy with no route for this path. Echoing that page
|
||||
// ("NOT_FOUND bom1::…") sends the user chasing a page that never
|
||||
// existed; name the actual problem instead: where requests are going.
|
||||
if (backendTarget && res.status === 404 && !backendShaped) {
|
||||
throw new ApiError(misroutedBackendMessage(requestUrl), {
|
||||
if (res.status === 404 && !backendShaped) {
|
||||
throw new ApiError(misroutedBackendMessage(apiUrl(path)), {
|
||||
status: res.status,
|
||||
detail,
|
||||
});
|
||||
@@ -528,14 +431,13 @@ export async function apiFetch(path: string, opts: ApiFetchOptions = {}): Promis
|
||||
// "API key required" (BearerKeyMiddleware, OMNIVOICE_API_KEY) vs anything
|
||||
// else, i.e. "PIN required" (NetworkAccessMiddleware). Both are 401; the
|
||||
// detail is the only discriminator (only two 401 sites exist backend-side).
|
||||
if (backendTarget && res.status === 401 && typeof window !== 'undefined') {
|
||||
if (res.status === 401 && typeof window !== 'undefined') {
|
||||
// readError's declared `string` return isn't guaranteed at runtime —
|
||||
// `j.detail` can be a structured object/array on a future 401. Match only
|
||||
// real strings (avoids both a `.toLowerCase()` crash and `String()` itself
|
||||
// throwing on a malformed object); anything else falls back to PIN.
|
||||
const mode =
|
||||
typeof detail === 'string' && detail.toLowerCase().includes('api key') ? 'apikey' : 'pin';
|
||||
if (mode === 'apikey') clearAdminSession();
|
||||
window.dispatchEvent(new CustomEvent('ov:auth-required', { detail: { mode } }));
|
||||
}
|
||||
// Structured details (e.g. the typed asr_model_missing 409) carry a
|
||||
|
||||
@@ -19,9 +19,7 @@ interface CommunityItem {
|
||||
author?: string;
|
||||
license?: string;
|
||||
source?: string;
|
||||
_source_repo?: string;
|
||||
is_community?: boolean;
|
||||
attrs?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface CommunityPage {
|
||||
@@ -61,7 +59,3 @@ export const addCommunityItem = (
|
||||
const q = name ? `?name=${encodeURIComponent(name)}` : '';
|
||||
return apiJson(`/community/items/${encodeURIComponent(id)}/use${q}`, { method: 'POST' });
|
||||
};
|
||||
|
||||
/** Same-origin preview path for both designed presets and recorded voices. */
|
||||
export const communityPreviewUrl = (id: string, local = false): string =>
|
||||
`/community/items/${encodeURIComponent(id)}/preview${local ? '?local=true' : ''}`;
|
||||
|
||||
@@ -14,8 +14,6 @@ import * as archetypesApi from './archetypes';
|
||||
import type { ArchetypeFilters } from './archetypes';
|
||||
import * as communityApi from './community';
|
||||
import type { CommunityFilters } from './community';
|
||||
import * as enginesApi from './engines';
|
||||
import type { AllEnginesResponse, EngineFamily } from './types';
|
||||
|
||||
// ── Keys (prevents typos, enables targeted invalidation) ─────────────────
|
||||
export const queryKeys = {
|
||||
@@ -28,7 +26,6 @@ export const queryKeys = {
|
||||
models: ['models'] as const,
|
||||
recommendations: ['recommendations'] as const,
|
||||
preflight: ['preflight'] as const,
|
||||
engines: ['engines'] as const,
|
||||
setupStatus: ['setup-status'] as const,
|
||||
galleryVoices: (params?: any) => ['gallery-voices', params] as const,
|
||||
galleryCategories: ['gallery-categories'] as const,
|
||||
@@ -174,39 +171,6 @@ export function usePreflight() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The app-wide engine inventory. Engine selection affects more than the
|
||||
* catalogue (for example Audiobook's expressive controls), so every consumer
|
||||
* must share this cache rather than take its own one-off snapshot.
|
||||
*
|
||||
* `queryFn` is injectable for the compatibility-matrix test seam.
|
||||
*/
|
||||
export function useEngines(queryFn: () => Promise<AllEnginesResponse> = enginesApi.listEngines) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.engines,
|
||||
queryFn,
|
||||
staleTime: 30_000,
|
||||
retry: 1,
|
||||
});
|
||||
}
|
||||
|
||||
/** Select an engine and invalidate every view derived from `/engines`. */
|
||||
export function useSelectEngine() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
family,
|
||||
backendId,
|
||||
modelId,
|
||||
}: {
|
||||
family: EngineFamily;
|
||||
backendId: string;
|
||||
modelId?: string;
|
||||
}) => enginesApi.selectEngine(family, backendId, modelId),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: queryKeys.engines }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetupStatus() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.setupStatus,
|
||||
@@ -266,9 +230,6 @@ export function useInstallModel() {
|
||||
qc.invalidateQueries({ queryKey: queryKeys.models });
|
||||
qc.invalidateQueries({ queryKey: queryKeys.setupStatus });
|
||||
qc.invalidateQueries({ queryKey: queryKeys.recommendations });
|
||||
// Some model installs make an engine selectable; refresh every engine
|
||||
// indicator rather than leaving a stale unavailable snapshot behind.
|
||||
qc.invalidateQueries({ queryKey: queryKeys.engines });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -281,7 +242,6 @@ export function useDeleteModel() {
|
||||
qc.invalidateQueries({ queryKey: queryKeys.models });
|
||||
qc.invalidateQueries({ queryKey: queryKeys.setupStatus });
|
||||
qc.invalidateQueries({ queryKey: queryKeys.recommendations });
|
||||
qc.invalidateQueries({ queryKey: queryKeys.engines });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ type EffectiveDevice = GPUTarget | 'network';
|
||||
// `n/a` is LLM-only; resolve_routing only ever returns the first four.
|
||||
type RoutingStatus = 'accelerated' | 'cpu_fallback' | 'cpu_only' | 'unavailable' | 'n/a';
|
||||
|
||||
export interface EngineBackend {
|
||||
interface EngineBackend {
|
||||
id: string;
|
||||
display_name: string;
|
||||
available: boolean;
|
||||
@@ -76,10 +76,8 @@ export interface CuratedModel {
|
||||
repo_id: string;
|
||||
}
|
||||
|
||||
export interface EngineFamilyResponse {
|
||||
interface EngineFamilyResponse {
|
||||
active: string;
|
||||
/** A process environment pin wins over a saved UI selection. */
|
||||
env_override?: boolean;
|
||||
backends: EngineBackend[];
|
||||
}
|
||||
|
||||
@@ -177,12 +175,9 @@ export interface Profile {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: ProfileKind;
|
||||
language?: string;
|
||||
language_code?: string;
|
||||
ref_audio?: string;
|
||||
ref_text?: string;
|
||||
instruct?: string;
|
||||
vd_states?: string | null;
|
||||
description?: string;
|
||||
created_at?: string;
|
||||
is_locked?: boolean;
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 22 KiB |
@@ -5,8 +5,7 @@ import { toast } from 'react-hot-toast';
|
||||
import { useAppStore } from '../store';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { API, apiFetch } from '../api/client';
|
||||
import { authenticatedWsUrl } from '../api/authSession';
|
||||
import { wsUrl as buildWsUrl, apiFetch } from '../api/client';
|
||||
import { addTranscription } from '../pages/Transcriptions';
|
||||
import { describeMicError, detectPlatform, micErrorMessage, micHintKey } from '../utils/micError';
|
||||
import { checkMicrophone, openMicrophoneSettings } from '../utils/permissions';
|
||||
@@ -899,10 +898,8 @@ export default function CaptureWidget({ onDismiss }) {
|
||||
|
||||
// Open WebSocket BEFORE starting capture.
|
||||
try {
|
||||
// Scheme + host derive from the API base (window.location lies inside
|
||||
// the Tauri webview). A remote bearer session is converted to a fresh,
|
||||
// path-bound WebSocket ticket; neither the master nor session token is
|
||||
// ever placed in this URL.
|
||||
// Scheme + host + remote api key all derive from the API base
|
||||
// (Wave 2.3) — window.location lies inside the Tauri webview.
|
||||
// • sherpa → ?model=<id>&sr=16000 (raw int16 PCM, live partials)
|
||||
// • AEC → ?aec=1&sr=16000 (tagged raw PCM, NLMS canceller)
|
||||
// • both → ?model=<id>&aec=1&sr=16000
|
||||
@@ -914,8 +911,7 @@ export default function CaptureWidget({ onDismiss }) {
|
||||
if (pcmFallback) params.push('pcm=1');
|
||||
if (pcmMode) params.push('sr=16000');
|
||||
const wsPath = params.length ? `/ws/transcribe?${params.join('&')}` : '/ws/transcribe';
|
||||
const endpoint = await authenticatedWsUrl(wsPath, { apiBase: API });
|
||||
const ws = new WebSocket(endpoint);
|
||||
const ws = new WebSocket(buildWsUrl(wsPath));
|
||||
ws.binaryType = 'arraybuffer';
|
||||
const failRawPcmSession = () => {
|
||||
if (
|
||||
@@ -1117,7 +1113,7 @@ export default function CaptureWidget({ onDismiss }) {
|
||||
}
|
||||
};
|
||||
wsRef.current = ws;
|
||||
} catch {
|
||||
} catch (err) {
|
||||
wsRef.current = null;
|
||||
if (pcmMode) {
|
||||
// Raw-PCM has no POST fallback — a socket that can't even be
|
||||
@@ -1125,13 +1121,13 @@ export default function CaptureWidget({ onDismiss }) {
|
||||
// recording into the void.
|
||||
stream.getTracks().forEach((tr) => tr.stop());
|
||||
streamRef.current = null;
|
||||
setErrorInfo({ kind: 'server', message: '' });
|
||||
setErrorInfo({ kind: 'server', message: String(err?.message || err) });
|
||||
setState('error');
|
||||
return;
|
||||
}
|
||||
// Legacy path continues below: the recorder still buffers chunks and
|
||||
// the POST /transcribe fallback delivers the result on stop.
|
||||
console.warn('ws open failed — will fall back to POST /transcribe');
|
||||
console.warn('ws open failed — will fall back to POST /transcribe:', err);
|
||||
}
|
||||
|
||||
if (pcmMode) {
|
||||
|
||||
@@ -34,7 +34,6 @@ const mocks = vi.hoisted(() => {
|
||||
return {
|
||||
state,
|
||||
holder,
|
||||
authenticatedWsUrl: vi.fn(async (path) => `ws://test${path}&ws_ticket=one-use`),
|
||||
invoke: async (cmd, args) => {
|
||||
holder.calls.push([cmd, args]);
|
||||
if (cmd === 'check_accessibility') return holder.a11y;
|
||||
@@ -48,11 +47,9 @@ vi.mock('../store', () => ({
|
||||
useAppStore: Object.assign((sel) => sel(mocks.state), { getState: () => mocks.state }),
|
||||
}));
|
||||
vi.mock('../api/client', () => ({
|
||||
API: 'http://test',
|
||||
wsUrl: (p) => `ws://test${p}`,
|
||||
apiFetch: vi.fn(async () => ({ json: async () => ({}) })),
|
||||
}));
|
||||
vi.mock('../api/authSession', () => ({ authenticatedWsUrl: mocks.authenticatedWsUrl }));
|
||||
vi.mock('../pages/Transcriptions', () => ({ addTranscription: vi.fn() }));
|
||||
vi.mock('../utils/copyText', () => ({ copyText: vi.fn(async () => {}) }));
|
||||
vi.mock('react-hot-toast', () => ({ toast: { error: vi.fn() } }));
|
||||
@@ -135,10 +132,6 @@ describe('CaptureWidget', () => {
|
||||
mocks.holder.paste = async () => undefined;
|
||||
mocks.holder.calls = [];
|
||||
mocks.holder.onFrame = null;
|
||||
mocks.authenticatedWsUrl.mockClear();
|
||||
mocks.authenticatedWsUrl.mockImplementation(
|
||||
async (path) => `ws://test${path}${path.includes('?') ? '&' : '?'}ws_ticket=one-use`,
|
||||
);
|
||||
mocks.state.dictationMode = 'toggle';
|
||||
mocks.state.dictationModelId = 'sherpa-parakeet-tdt-v3';
|
||||
FakeWebSocket.instances = [];
|
||||
@@ -193,10 +186,6 @@ describe('CaptureWidget', () => {
|
||||
|
||||
const ws = await startSession();
|
||||
expect(ws.url).toContain('/ws/transcribe?pcm=1&sr=16000');
|
||||
expect(mocks.authenticatedWsUrl).toHaveBeenCalledWith('/ws/transcribe?pcm=1&sr=16000', {
|
||||
apiBase: 'http://test',
|
||||
});
|
||||
expect(ws.url).toContain('ws_ticket=one-use');
|
||||
expect(mocks.holder.onFrame).toBeTypeOf('function');
|
||||
|
||||
act(() => mocks.holder.onFrame(new Float32Array([0.25, -0.25])));
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
getSidecarInstallStatus,
|
||||
} from '../api/engines';
|
||||
import { listLoadedModels, unloadLoadedModel } from '../api/system';
|
||||
import { useAppStore } from '../store';
|
||||
import { copyText } from '../utils/copyText';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { Badge, Button, Select, Table, Tabs } from '../ui';
|
||||
@@ -227,10 +226,6 @@ export default function EngineCompatibilityMatrix({
|
||||
showFamilyTabs = true,
|
||||
onFamilyChange = null,
|
||||
reloadToken = 0,
|
||||
// The catalogue passes its app-wide query here. Keeping the standalone
|
||||
// fallback preserves the matrix's injectable API seam for isolated hosts
|
||||
// and its extensive focused test suite.
|
||||
sharedEngines = null,
|
||||
// Injectable API layer — lets the RTL suite mock it without module-level
|
||||
// vi.mock incantations, and keeps the "one GET /engines per Settings open"
|
||||
// contract overridable by hosts.
|
||||
@@ -247,15 +242,9 @@ export default function EngineCompatibilityMatrix({
|
||||
apiInstallStatus = getSidecarInstallStatus,
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [localData, setLocalData] = useState(null);
|
||||
const [localLoading, setLocalLoading] = useState(true);
|
||||
const [localError, setLocalError] = useState(null);
|
||||
const sharedRefetch = sharedEngines?.refetch;
|
||||
const isShared = Boolean(sharedEngines);
|
||||
const sharedReloadToken = useRef(reloadToken);
|
||||
const data = sharedEngines?.data ?? localData;
|
||||
const loading = isShared ? sharedEngines.isLoading : localLoading;
|
||||
const error = sharedEngines?.error ?? localError;
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [activeFamily, setActiveFamily] = useState(family);
|
||||
// Phase 3 Plan 03-01 / TTS-05: which engine has its license dialog
|
||||
// currently open, or null. Only one dialog is ever open at a time.
|
||||
@@ -299,42 +288,26 @@ export default function EngineCompatibilityMatrix({
|
||||
}, [apiListLoadedModels]);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
if (sharedRefetch) {
|
||||
const result = await sharedRefetch();
|
||||
if (result.error) {
|
||||
const message = result.error?.message || String(result.error);
|
||||
toastErrorWithReport(t('engines.loadFailed', { message }), result.error);
|
||||
}
|
||||
} else {
|
||||
setLocalLoading(true);
|
||||
setLocalError(null);
|
||||
try {
|
||||
setLocalData(await apiListEngines());
|
||||
} catch (requestError) {
|
||||
const message = requestError?.message || String(requestError);
|
||||
setLocalError(requestError);
|
||||
toastErrorWithReport(t('engines.loadFailed', { message }), requestError);
|
||||
} finally {
|
||||
setLocalLoading(false);
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const fresh = await apiListEngines();
|
||||
setData(fresh);
|
||||
} catch (e) {
|
||||
const msg = e?.message || String(e);
|
||||
setError(msg);
|
||||
toastErrorWithReport(t('engines.loadFailed', { message: msg }), e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
refreshResidency();
|
||||
}, [apiListEngines, refreshResidency, sharedRefetch, t]);
|
||||
}, [apiListEngines, refreshResidency, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isShared) {
|
||||
if (sharedReloadToken.current !== reloadToken) {
|
||||
sharedReloadToken.current = reloadToken;
|
||||
void reload();
|
||||
return;
|
||||
}
|
||||
refreshResidency();
|
||||
return;
|
||||
}
|
||||
void reload();
|
||||
reload();
|
||||
// reloadToken: an external bump (e.g. the ASR config panel just saved a
|
||||
// server URL) refetches so availability + "Use" reflect the new config.
|
||||
}, [reload, reloadToken, refreshResidency, isShared]);
|
||||
}, [reload, reloadToken]);
|
||||
|
||||
// Unload a resident engine's model/sidecar by its /model/loaded id. Safe by
|
||||
// contract: the model reloads lazily on the next generation.
|
||||
@@ -625,8 +598,7 @@ export default function EngineCompatibilityMatrix({
|
||||
className="engine-matrix engine-matrix--error flex flex-col gap-[8px] items-center p-[16px]"
|
||||
role="alert"
|
||||
>
|
||||
<AlertTriangle size={14} />{' '}
|
||||
{t('engines.couldNotLoad', { message: error.message || String(error) })}
|
||||
<AlertTriangle size={14} /> {t('engines.couldNotLoad', { message: error })}
|
||||
<Button size="sm" variant="subtle" onClick={reload} leading={<RefreshCw size={11} />}>
|
||||
{t('engines.retry')}
|
||||
</Button>
|
||||
@@ -754,7 +726,7 @@ export default function EngineCompatibilityMatrix({
|
||||
data-testid="engine-list-scroll"
|
||||
aria-label={t('engines.engineCompatLabel', { family: activeFamily })}
|
||||
>
|
||||
{backends.map((b, index) => {
|
||||
{backends.map((b) => {
|
||||
const isActive = b.id === activeBackendId;
|
||||
const health = healthByEngine[b.id];
|
||||
const selfTest = selfTestByEngine[b.id];
|
||||
@@ -809,20 +781,6 @@ export default function EngineCompatibilityMatrix({
|
||||
) : null;
|
||||
return (
|
||||
<React.Fragment key={b.id}>
|
||||
{(index === 0 || (backends[index - 1]?.available && !b.available)) && (
|
||||
<div
|
||||
className={cn(
|
||||
'px-[var(--space-2)] pt-[4px] font-mono text-[10px] font-semibold uppercase tracking-[0.08em]',
|
||||
MUTED,
|
||||
)}
|
||||
>
|
||||
{/* Section framing, not status: "ready to use" vs "add
|
||||
more" frames the grey majority as headroom to unlock
|
||||
rather than a mostly-broken app (13 of 16 rows read as
|
||||
failures under a plain "Not installed" caption). */}
|
||||
{b.available ? t('engines.sectionReady') : t('engines.sectionMore')}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
role="row"
|
||||
data-engine-id={b.id}
|
||||
@@ -1276,22 +1234,6 @@ export default function EngineCompatibilityMatrix({
|
||||
{t('engines.use')}
|
||||
</Button>
|
||||
)}
|
||||
{/* The openai-compat family entry and the LLM Providers
|
||||
panel are one system (the backend resolves through the
|
||||
active provider); this is the door between the two, so
|
||||
picking the family and configuring the endpoint stop
|
||||
being separate discoveries. */}
|
||||
{activeFamily === 'llm' && b.id === 'openai-compat' && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
onClick={() => useAppStore.getState().openSettingsTab?.('llm-providers')}
|
||||
aria-label={t('engines.configureProviders')}
|
||||
data-testid="configure-llm-providers"
|
||||
>
|
||||
{t('engines.configureProviders')}
|
||||
</Button>
|
||||
)}
|
||||
{/* TTS-05: license-acceptance entry point. Surfaced when
|
||||
the backend says the user hasn't accepted the
|
||||
engine's license yet AND we have a dialog
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Check, ChevronRight, Cpu } from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { listLoadedModels } from '../api/system';
|
||||
import { useEngines, useSelectEngine } from '../api/hooks';
|
||||
import { notifyEngineSelected } from '../utils/engineSelectToast';
|
||||
import { useAppStore } from '../store';
|
||||
import { MENU_SURFACE } from './computeTarget';
|
||||
|
||||
/**
|
||||
* A compact TTS/ASR/LLM picker for chrome that needs to expose the active
|
||||
* engine without growing a second engine-management surface. The matrix stays
|
||||
* the detailed view; this lists only engines that are ready to be used.
|
||||
*/
|
||||
export default function EngineQuickSwitch({
|
||||
family = 'tts',
|
||||
className = '',
|
||||
shortcutTarget = false,
|
||||
// The footer chip opens upward (nothing below the last row on screen);
|
||||
// workspace-header chips must open downward or the popover clips off the
|
||||
// top of the viewport.
|
||||
dropUp = false,
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const rootRef = useRef(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [switchError, setSwitchError] = useState('');
|
||||
const { data: engines } = useEngines();
|
||||
const selectMutation = useSelectEngine();
|
||||
const { data: residency } = useQuery({
|
||||
queryKey: ['loaded-models'],
|
||||
queryFn: listLoadedModels,
|
||||
staleTime: 10_000,
|
||||
retry: false,
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const familyData = engines?.[family];
|
||||
const active = familyData?.backends?.find((engine) => engine.id === familyData.active);
|
||||
const available = useMemo(
|
||||
() => (familyData?.backends || []).filter((engine) => engine.available),
|
||||
[familyData],
|
||||
);
|
||||
const residentIds = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
(residency?.models || []).flatMap((model) => (model.engine_id ? [model.engine_id] : [])),
|
||||
),
|
||||
[residency],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined;
|
||||
const close = (event) => {
|
||||
if (rootRef.current && !rootRef.current.contains(event.target)) setOpen(false);
|
||||
};
|
||||
const escape = (event) => {
|
||||
if (event.key === 'Escape') setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', close);
|
||||
document.addEventListener('keydown', escape);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', close);
|
||||
document.removeEventListener('keydown', escape);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
// In-webview shortcut bridge. This stays a DOM event (not a Tauri global
|
||||
// shortcut), so every desktop and browser build behaves the same way.
|
||||
useEffect(() => {
|
||||
if (!shortcutTarget) return undefined;
|
||||
const show = () => {
|
||||
setSwitchError('');
|
||||
setOpen(true);
|
||||
};
|
||||
window.addEventListener('engine-quick-switch', show);
|
||||
return () => window.removeEventListener('engine-quick-switch', show);
|
||||
}, [shortcutTarget]);
|
||||
|
||||
if (!active || available.length === 0) return null;
|
||||
|
||||
const locked = Boolean(familyData.env_override);
|
||||
const choose = async (backendId) => {
|
||||
if (backendId === familyData.active || locked) return;
|
||||
setSwitchError('');
|
||||
try {
|
||||
const result = await selectMutation.mutateAsync({ family, backendId });
|
||||
if (result.env_override) {
|
||||
setSwitchError(t('settings.llmp_env_override'));
|
||||
return;
|
||||
}
|
||||
notifyEngineSelected(result, t, family);
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
setSwitchError(error?.message || t('engines.switch_failed'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`relative inline-flex shrink-0 items-center ${className}`} ref={rootRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSwitchError('');
|
||||
setOpen((value) => !value);
|
||||
}}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
title={t('engines.activeEngine', {
|
||||
family: family.toUpperCase(),
|
||||
engine: active.display_name,
|
||||
})}
|
||||
aria-label={t('engines.activeEngine', {
|
||||
family: family.toUpperCase(),
|
||||
engine: active.display_name,
|
||||
})}
|
||||
className="inline-flex h-[20px] items-center gap-[5px] rounded-sm border-0 bg-transparent px-[7px] text-[11px] font-medium text-[color:var(--chrome-fg-muted)] transition-[background,color] hover:bg-[var(--chrome-hover-bg)] hover:text-[color:var(--chrome-fg)]"
|
||||
>
|
||||
<Cpu size={13} aria-hidden="true" />
|
||||
<span className="max-w-[124px] truncate">{active.display_name}</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label={t('engines.engineCompatLabel', { family: family.toUpperCase() })}
|
||||
className={`absolute right-0 z-[60] flex w-[272px] flex-col gap-[4px] p-[8px] ${
|
||||
dropUp ? 'bottom-[calc(100%+8px)]' : 'top-[calc(100%+8px)]'
|
||||
} ${MENU_SURFACE}`}
|
||||
>
|
||||
{locked && (
|
||||
<p className="m-[4px] text-[11px] leading-[1.4] text-[color:var(--chrome-fg-muted)]">
|
||||
{t('settings.llmp_env_override')}
|
||||
</p>
|
||||
)}
|
||||
{available.map((engine) => {
|
||||
const isActive = engine.id === familyData.active;
|
||||
const warm = residentIds.has(engine.id);
|
||||
return (
|
||||
<button
|
||||
key={engine.id}
|
||||
type="button"
|
||||
disabled={isActive || locked || selectMutation.isPending}
|
||||
onClick={() => choose(engine.id)}
|
||||
className="flex w-full items-center gap-[8px] rounded-[5px] border-0 bg-transparent px-[7px] py-[6px] text-left text-[11px] text-[color:var(--chrome-fg)] hover:bg-[var(--chrome-hover-bg)] disabled:cursor-default disabled:opacity-60"
|
||||
>
|
||||
<span className="w-[12px] shrink-0">
|
||||
{isActive && <Check size={12} aria-label={t('engines.active')} />}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">{engine.display_name}</span>
|
||||
<span className="shrink-0 text-[10px] text-[color:var(--chrome-fg-muted)]">
|
||||
{warm ? t('engines.inMemory') : t('engines.available')}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{switchError && (
|
||||
<p
|
||||
role="alert"
|
||||
className="m-[4px] text-[11px] leading-[1.4] text-[color:var(--chrome-severity-err)]"
|
||||
>
|
||||
{switchError}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
useAppStore.getState().openCatalogue({ pane: 'engines', family });
|
||||
}}
|
||||
className="mt-[3px] flex items-center gap-[3px] border-0 bg-transparent px-[7px] py-[5px] text-left text-[11px] text-[color:var(--chrome-fg-muted)] hover:text-[color:var(--chrome-fg)]"
|
||||
>
|
||||
{t('settings.engines')} <ChevronRight size={12} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import React from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
vi.mock('react-hot-toast', () => ({ toast: { success: vi.fn() } }));
|
||||
|
||||
const { listEngines, selectEngine, listLoadedModels } = vi.hoisted(() => ({
|
||||
listEngines: vi.fn(),
|
||||
selectEngine: vi.fn(),
|
||||
listLoadedModels: vi.fn(),
|
||||
}));
|
||||
vi.mock('../api/engines', () => ({ listEngines, selectEngine }));
|
||||
vi.mock('../api/system', () => ({ listLoadedModels }));
|
||||
|
||||
import EngineQuickSwitch from './EngineQuickSwitch';
|
||||
|
||||
const inventory = (env_override = false) => ({
|
||||
tts: {
|
||||
active: 'omnivoice',
|
||||
env_override,
|
||||
backends: [
|
||||
{ id: 'omnivoice', display_name: 'OmniVoice', available: true },
|
||||
{ id: 'indextts2', display_name: 'IndexTTS 2', available: true },
|
||||
{ id: 'offline', display_name: 'Offline', available: false },
|
||||
],
|
||||
},
|
||||
asr: { active: 'whisper', backends: [] },
|
||||
llm: { active: 'off', backends: [] },
|
||||
});
|
||||
|
||||
function renderPicker(props = {}) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<EngineQuickSwitch {...props} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('EngineQuickSwitch', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
listEngines.mockResolvedValue(inventory());
|
||||
listLoadedModels.mockResolvedValue({ models: [{ engine_id: 'omnivoice' }] });
|
||||
});
|
||||
|
||||
it('lists only available engines and annotates residency', async () => {
|
||||
renderPicker();
|
||||
fireEvent.click(await screen.findByRole('button', { name: /active tts: omnivoice/i }));
|
||||
|
||||
expect(screen.getByText('IndexTTS 2')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Offline')).not.toBeInTheDocument();
|
||||
expect(await screen.findByText('In memory')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('selects through the shared mutation', async () => {
|
||||
selectEngine.mockResolvedValue({ family: 'tts', active: 'indextts2', env_override: false });
|
||||
renderPicker();
|
||||
fireEvent.click(await screen.findByRole('button', { name: /active tts: omnivoice/i }));
|
||||
fireEvent.click(screen.getByText('IndexTTS 2'));
|
||||
|
||||
await waitFor(() => expect(selectEngine).toHaveBeenCalledWith('tts', 'indextts2', undefined));
|
||||
});
|
||||
|
||||
it('locks a family owned by an environment variable', async () => {
|
||||
listEngines.mockResolvedValue(inventory(true));
|
||||
renderPicker();
|
||||
fireEvent.click(await screen.findByRole('button', { name: /active tts: omnivoice/i }));
|
||||
|
||||
expect(screen.getByText(/set via an environment variable/i)).toBeInTheDocument();
|
||||
expect(screen.getByText('IndexTTS 2').closest('button')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('opens only the designated picker from the global shortcut bridge', async () => {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={client}>
|
||||
<EngineQuickSwitch />
|
||||
<EngineQuickSwitch shortcutTarget />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await screen.findAllByRole('button', { name: /active tts: omnivoice/i });
|
||||
|
||||
fireEvent(window, new Event('engine-quick-switch'));
|
||||
|
||||
expect(await screen.findByRole('dialog')).toBeInTheDocument();
|
||||
expect(screen.getAllByRole('dialog')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -19,8 +19,6 @@ export default function KeyboardCheatsheet({ open, onClose }) {
|
||||
title: t('keyboard.nav'),
|
||||
items: [
|
||||
['?', t('keyboard.nav_cheatsheet')],
|
||||
[t('keyboard.nav_enginePickerKey'), t('engines.matrixTitle')],
|
||||
[t('keyboard.nav_workspacesKey'), t('keyboard.nav')],
|
||||
['Esc', t('keyboard.nav_closeModal')],
|
||||
['Cmd/Ctrl+S', t('keyboard.nav_save')],
|
||||
],
|
||||
|
||||
@@ -32,7 +32,6 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useAppStore } from '../store';
|
||||
import NetworkToggle from './NetworkToggle';
|
||||
import ComputeQuickSettings from './ComputeQuickSettings';
|
||||
import EngineQuickSwitch from './EngineQuickSwitch';
|
||||
import { APP_VERSION, whatsNewPending } from '../utils/appVersion';
|
||||
import DonateMomentPopover, { DONATE_POPOVER_AUTO_DISMISS_MS } from './DonateMomentPopover';
|
||||
import { DONATION_MOMENT_EVENT, optOutOfDonationMoments } from '../utils/donationMoments';
|
||||
@@ -640,7 +639,6 @@ export default function LogsFooter() {
|
||||
)}
|
||||
</button>
|
||||
<ComputeQuickSettings />
|
||||
<EngineQuickSwitch shortcutTarget dropUp />
|
||||
<NetworkToggle />
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,29 +1,27 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { API } from '../api/client';
|
||||
import { exchangeApiKey } from '../api/authSession';
|
||||
import { saveApiKey } from '../api/client';
|
||||
|
||||
// On a remote device the backend can demand EITHER a LAN-share PIN
|
||||
// (NetworkAccessMiddleware → "PIN required") OR an API key (BearerKeyMiddleware
|
||||
// → "API key required") — both 401. client.ts reads the detail, decides which,
|
||||
// and dispatches a single `ov:auth-required` CustomEvent carrying the mode; this
|
||||
// gate listens for it and swaps the app tree for the matching entry form.
|
||||
// `forceGate` / `forceMode` are test-only. PINs remain tab-scoped; an API master
|
||||
// is immediately exchanged for a short-lived session and never persisted.
|
||||
// `forceGate` / `forceMode` are test-only. Submitting stores the credential
|
||||
// (sessionStorage for the session PIN, localStorage for the durable API key) and
|
||||
// reloads so the gated requests retry with the header attached. If both gates
|
||||
// are active the reload cycle re-shows this gate in whichever mode the next 401
|
||||
// dictates.
|
||||
export default function RemoteAuthGate({ children, forceGate = false, forceMode = 'pin' }) {
|
||||
const { t } = useTranslation();
|
||||
const [gated, setGated] = useState(forceGate);
|
||||
const [mode, setMode] = useState(forceMode);
|
||||
const [value, setValue] = useState('');
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onRequired = (e) => {
|
||||
setMode(e.detail?.mode === 'apikey' ? 'apikey' : 'pin');
|
||||
setGated(true);
|
||||
setError(null);
|
||||
setValue('');
|
||||
};
|
||||
window.addEventListener('ov:auth-required', onRequired);
|
||||
return () => window.removeEventListener('ov:auth-required', onRequired);
|
||||
@@ -33,36 +31,21 @@ export default function RemoteAuthGate({ children, forceGate = false, forceMode
|
||||
|
||||
const i18nKey = mode === 'apikey' ? 'remote_apikey_gate' : 'remote_gate';
|
||||
|
||||
const submit = async (e) => {
|
||||
const submit = (e) => {
|
||||
e.preventDefault();
|
||||
if (pending) return;
|
||||
const v = value.trim();
|
||||
if (!v) return;
|
||||
setError(null);
|
||||
if (mode === 'pin') {
|
||||
try {
|
||||
sessionStorage.setItem('ov_pin', v);
|
||||
window.location.reload();
|
||||
} catch {
|
||||
setError({ status: undefined });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove the secret from controlled UI state before awaiting the network.
|
||||
// On success, exchangeApiKey also deletes any durable value left by an
|
||||
// older release; a failed exchange leaves it for the bootstrap migration
|
||||
// to retry on the next launch.
|
||||
setValue('');
|
||||
setPending(true);
|
||||
// Persist, then reload so the gated requests retry with the credential
|
||||
// attached. A failed write (privacy-mode storage, etc.) must NOT reload into
|
||||
// a loop — leave the form up so the user isn't silently re-prompted forever.
|
||||
let ok = true;
|
||||
try {
|
||||
await exchangeApiKey(v, { apiBase: API });
|
||||
window.location.reload();
|
||||
} catch (exchangeError) {
|
||||
setError({ status: exchangeError?.status });
|
||||
} finally {
|
||||
setPending(false);
|
||||
if (mode === 'apikey') ok = saveApiKey(v);
|
||||
else sessionStorage.setItem('ov_pin', v);
|
||||
} catch {
|
||||
ok = false;
|
||||
}
|
||||
if (ok) window.location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -77,20 +60,9 @@ export default function RemoteAuthGate({ children, forceGate = false, forceMode
|
||||
inputMode={mode === 'apikey' ? undefined : 'numeric'}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
autoComplete="off"
|
||||
disabled={pending}
|
||||
autoFocus
|
||||
/>
|
||||
{error && (
|
||||
<p role="alert">
|
||||
{error.status
|
||||
? t('settings.remote_backend_error_http', { status: error.status })
|
||||
: t('settings.remote_backend_error_network')}
|
||||
</p>
|
||||
)}
|
||||
<button type="submit" disabled={pending}>
|
||||
{t(`${i18nKey}.connect`)}
|
||||
</button>
|
||||
<button type="submit">{t(`${i18nKey}.connect`)}</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
|
||||
const { exchangeApiKey } = vi.hoisted(() => ({ exchangeApiKey: vi.fn() }));
|
||||
vi.mock('../api/authSession', async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
exchangeApiKey,
|
||||
}));
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import RemoteAuthGate from './RemoteAuthGate';
|
||||
|
||||
describe('RemoteAuthGate', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
exchangeApiKey.mockResolvedValue({ transport: 'bearer', expiresAt: Date.now() / 1000 + 60 });
|
||||
sessionStorage.clear();
|
||||
localStorage.clear();
|
||||
});
|
||||
@@ -41,7 +32,7 @@ describe('RemoteAuthGate', () => {
|
||||
expect(sessionStorage.getItem('ov_pin')).toBe('999111');
|
||||
});
|
||||
|
||||
it('exchanges the entered API key without persisting the master (apikey mode)', async () => {
|
||||
it('stores the entered API key (apikey mode)', () => {
|
||||
render(
|
||||
<RemoteAuthGate forceGate forceMode="apikey">
|
||||
<div>app-content</div>
|
||||
@@ -49,51 +40,6 @@ describe('RemoteAuthGate', () => {
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText(/api key/i), { target: { value: 'secret123' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /connect/i }));
|
||||
|
||||
await waitFor(() => expect(exchangeApiKey).toHaveBeenCalledOnce());
|
||||
expect(exchangeApiKey).toHaveBeenCalledWith(
|
||||
'secret123',
|
||||
expect.objectContaining({ apiBase: expect.any(String) }),
|
||||
);
|
||||
expect(localStorage.getItem('ov_api_key')).toBeNull();
|
||||
expect(sessionStorage.getItem('ov_api_key')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not retain or reflect the master when exchange fails', async () => {
|
||||
exchangeApiKey.mockRejectedValueOnce(Object.assign(new Error('generic'), { status: 401 }));
|
||||
render(
|
||||
<RemoteAuthGate forceGate forceMode="apikey">
|
||||
<div>app-content</div>
|
||||
</RemoteAuthGate>,
|
||||
);
|
||||
const input = screen.getByLabelText(/api key/i);
|
||||
fireEvent.change(input, { target: { value: 'do-not-reflect' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /connect/i }));
|
||||
|
||||
await screen.findByRole('alert');
|
||||
expect(input).toHaveValue('');
|
||||
expect(screen.queryByText(/do-not-reflect/)).not.toBeInTheDocument();
|
||||
expect(localStorage.getItem('ov_api_key')).toBeNull();
|
||||
});
|
||||
|
||||
it('coalesces repeated submissions while an exchange is pending', async () => {
|
||||
let resolveExchange;
|
||||
exchangeApiKey.mockImplementationOnce(
|
||||
() => new Promise((resolve) => (resolveExchange = resolve)),
|
||||
);
|
||||
render(
|
||||
<RemoteAuthGate forceGate forceMode="apikey">
|
||||
<div>app-content</div>
|
||||
</RemoteAuthGate>,
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText(/api key/i), { target: { value: 'secret123' } });
|
||||
const button = screen.getByRole('button', { name: /connect/i });
|
||||
fireEvent.click(button);
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(exchangeApiKey).toHaveBeenCalledOnce();
|
||||
expect(button).toBeDisabled();
|
||||
resolveExchange({ transport: 'bearer', expiresAt: Date.now() / 1000 + 60 });
|
||||
await waitFor(() => expect(button).not.toBeDisabled());
|
||||
expect(localStorage.getItem('ov_api_key')).toBe('secret123');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,7 +31,7 @@ export default function RemoteBackendRecovery({
|
||||
<Button variant="primary" onClick={onRetry}>
|
||||
{t('bootstrap.retry')}
|
||||
</Button>
|
||||
<Button variant="subtle" onClick={() => void disableRemoteBackend(reload)}>
|
||||
<Button variant="subtle" onClick={() => disableRemoteBackend(reload)}>
|
||||
{t('settings.remote_backend_use_local')}
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={onOpenSettings}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import RemoteBackendRecovery from './RemoteBackendRecovery';
|
||||
|
||||
describe('RemoteBackendRecovery', () => {
|
||||
@@ -25,10 +25,9 @@ describe('RemoteBackendRecovery', () => {
|
||||
expect(onOpenSettings).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('clears the remote URL and API key before reloading locally', async () => {
|
||||
it('clears the remote URL and API key before reloading locally', () => {
|
||||
localStorage.setItem('ov_backend_url', 'https://gpu-box:3900');
|
||||
localStorage.setItem('ov_api_key', 'secret');
|
||||
sessionStorage.setItem('ov_admin_session', 'session');
|
||||
const reload = vi.fn();
|
||||
render(
|
||||
<RemoteBackendRecovery
|
||||
@@ -39,9 +38,8 @@ describe('RemoteBackendRecovery', () => {
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Use local backend' }));
|
||||
await waitFor(() => expect(reload).toHaveBeenCalledOnce());
|
||||
expect(localStorage.getItem('ov_backend_url')).toBeNull();
|
||||
expect(localStorage.getItem('ov_api_key')).toBeNull();
|
||||
expect(sessionStorage.getItem('ov_admin_session')).toBeNull();
|
||||
expect(reload).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,8 +22,9 @@ import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { Check, ChevronRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useEngines, useModels, useInstallModel, useSelectEngine } from '../api/hooks';
|
||||
import { useModels, useInstallModel } from '../api/hooks';
|
||||
import { setupDownloadStreamUrl } from '../api/setup';
|
||||
import { listEngines, selectEngine } from '../api/engines';
|
||||
import { notifyEngineSelected } from '../utils/engineSelectToast';
|
||||
import MirrorRescue from './MirrorRescue';
|
||||
import { Badge, Button } from '../ui';
|
||||
@@ -236,9 +237,7 @@ export default function WizardLibrary() {
|
||||
const { t } = useTranslation();
|
||||
const modelsQuery = useModels();
|
||||
const installMutation = useInstallModel();
|
||||
const { data: engineInventory } = useEngines();
|
||||
const selectMutation = useSelectEngine();
|
||||
const engines = engineInventory?.tts ?? null;
|
||||
const [engines, setEngines] = useState(null);
|
||||
const [progress, setProgress] = useState({}); // { repo_id: { phase, files } }
|
||||
const [showTail, setShowTail] = useState(false);
|
||||
const [switching, setSwitching] = useState(null);
|
||||
@@ -256,6 +255,22 @@ export default function WizardLibrary() {
|
||||
return Array.isArray(d) ? [] : (d?.platform_tags ?? []);
|
||||
}, [modelsQuery.data]);
|
||||
|
||||
// Engines: TTS family only on first run — the family the studio speaks with.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const all = await listEngines();
|
||||
if (!cancelled) setEngines(all?.tts ?? null);
|
||||
} catch {
|
||||
/* backend mid-boot — the wizard polls models anyway */
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// One SSE stream for all rows (same channel the Settings store uses).
|
||||
useEffect(() => {
|
||||
const es = new EventSource(setupDownloadStreamUrl());
|
||||
@@ -297,7 +312,8 @@ export default function WizardLibrary() {
|
||||
const useEngine = async (id) => {
|
||||
setSwitching(id);
|
||||
try {
|
||||
const r = await selectMutation.mutateAsync({ family: 'tts', backendId: id });
|
||||
const r = await selectEngine('tts', id);
|
||||
setEngines((e) => (e ? { ...e, active: r.active } : e));
|
||||
// Consume the routing echo: warn when the pick lands on a CPU fallback
|
||||
// on this host, otherwise confirm the switch. See notifyEngineSelected.
|
||||
notifyEngineSelected(r, t, 'tts');
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useRef } from 'react';
|
||||
import { BookMarked, BookOpen, FileUp, ListTree, Loader, Sparkles, Square } from 'lucide-react';
|
||||
|
||||
import { Button } from '../../ui';
|
||||
import EngineQuickSwitch from '../EngineQuickSwitch';
|
||||
|
||||
/** The inviting front door for the long-form workflow: context, path, and actions. */
|
||||
export default function AudiobookHero({
|
||||
@@ -33,7 +32,6 @@ export default function AudiobookHero({
|
||||
<h2 className="m-0 [font-family:var(--font-serif)] text-[var(--text-lg)] font-semibold text-fg">
|
||||
{t('audiobook.title')}
|
||||
</h2>
|
||||
<EngineQuickSwitch />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-[4px]">
|
||||
|
||||
@@ -12,7 +12,6 @@ import { Button } from '../../ui';
|
||||
import FooterBtn from './FooterBtn';
|
||||
import DubPipelineStepper from './DubPipelineStepper';
|
||||
import { formatTime } from '../../utils/format';
|
||||
import EngineQuickSwitch from '../EngineQuickSwitch';
|
||||
|
||||
export default function DubHeader({
|
||||
t,
|
||||
@@ -90,7 +89,6 @@ export default function DubHeader({
|
||||
|
||||
<div className="dub-command-bar__actions [.shell-mini_&]:col-[1/-1] [.shell-mini_&]:row-start-3 [.shell-mini_&]:w-full [.shell-mini_&]:flex-wrap">
|
||||
<div className="dub-command-bar__utilities">
|
||||
<EngineQuickSwitch />
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import i18n from '../../i18n';
|
||||
import DubHeader from './DubHeader';
|
||||
|
||||
// DubHeader mounts EngineQuickSwitch, whose useEngines query needs a client.
|
||||
// One client at module scope: an inline `new QueryClient()` would be a fresh
|
||||
// client on every wrapper render, so a rerender() drops the query cache.
|
||||
const queryClient = new QueryClient();
|
||||
const wrapper = ({ children }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
|
||||
describe('DubHeader command bar', () => {
|
||||
it('keeps project identity, compact pipeline, and batch action in one production bar', () => {
|
||||
render(
|
||||
@@ -41,7 +32,6 @@ describe('DubHeader command bar', () => {
|
||||
onPipelineStep={vi.fn()}
|
||||
/>
|
||||
</I18nextProvider>,
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
const bar = screen.getByTestId('dub-command-bar');
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react';
|
||||
import { BookOpen, Ellipsis, Headphones, Loader, Play, Star, UserPlus, Wand2 } from 'lucide-react';
|
||||
import { Menu } from '../../ui';
|
||||
import { Play, Loader, Star, Wand2, UserPlus } from 'lucide-react';
|
||||
import {
|
||||
ArchetypeAvatar,
|
||||
AccentFlag,
|
||||
@@ -20,12 +19,6 @@ export default function ArchetypeCard({
|
||||
onUse,
|
||||
onDesign,
|
||||
onToggleFavorite,
|
||||
onUseInStories,
|
||||
onUseAsAudiobookDefault,
|
||||
favoriteId = a.id,
|
||||
previewLocked = false,
|
||||
isMaterializing = false,
|
||||
materializationLocked = false,
|
||||
}) {
|
||||
const color = USE_CASE_COLOR[a.use_case] || '#83a598';
|
||||
const sub = [a.facets.gender, a.facets.age, a.facets.pitch]
|
||||
@@ -51,11 +44,7 @@ export default function ArchetypeCard({
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="gallery-persona-card"
|
||||
className={`${cardBase} ${cardState}`}
|
||||
style={{ '--card-accent': color }}
|
||||
>
|
||||
<div className={`${cardBase} ${cardState}`} style={{ '--card-accent': color }}>
|
||||
{/* Header — the name is the focal point; metadata recedes (smaller, muted). */}
|
||||
<div className="flex items-start gap-[10px]">
|
||||
<ArchetypeAvatar item={a} size={40} />
|
||||
@@ -76,7 +65,7 @@ export default function ArchetypeCard({
|
||||
? 'text-[#fabd2f]'
|
||||
: 'text-[var(--color-fg-subtle)] opacity-70 group-hover:opacity-100 hover:text-[#fabd2f]'
|
||||
}`}
|
||||
onClick={() => onToggleFavorite(favoriteId)}
|
||||
onClick={() => onToggleFavorite(a.id)}
|
||||
title={t('gallery.favorite', { defaultValue: 'Favorite' })}
|
||||
aria-label={t('gallery.favorite', { defaultValue: 'Favorite' })}
|
||||
aria-pressed={isFavorite}
|
||||
@@ -109,10 +98,8 @@ export default function ArchetypeCard({
|
||||
<div className="mt-auto flex items-center gap-[6px] pt-[9px]">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-[6px] px-[9px] py-[6px] rounded-[6px] bg-transparent text-[var(--color-fg-muted)] text-[0.68rem] cursor-pointer transition-colors hover:bg-[var(--chrome-hover-bg)] hover:text-[var(--color-fg)] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="inline-flex items-center gap-[6px] px-[9px] py-[6px] rounded-[6px] bg-transparent text-[var(--color-fg-muted)] text-[0.68rem] cursor-pointer transition-colors hover:bg-[var(--chrome-hover-bg)] hover:text-[var(--color-fg)]"
|
||||
onClick={() => onPreview(a)}
|
||||
disabled={previewLocked}
|
||||
aria-busy={isLoadingPreview}
|
||||
title={t('gallery.preview', { defaultValue: 'Preview' })}
|
||||
>
|
||||
{isLoadingPreview ? (
|
||||
@@ -126,65 +113,21 @@ export default function ArchetypeCard({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 inline-flex items-center justify-center gap-[6px] px-[10px] py-[6px] rounded-[6px] bg-[color-mix(in_srgb,var(--card-accent)_13%,transparent)] text-[var(--card-accent)] text-[0.7rem] font-semibold cursor-pointer transition-colors hover:bg-[var(--card-accent)] hover:text-[var(--color-fg-inverse)] focus-visible:bg-[var(--card-accent)] focus-visible:text-[var(--color-fg-inverse)] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="flex-1 inline-flex items-center justify-center gap-[6px] px-[10px] py-[6px] rounded-[6px] bg-[color-mix(in_srgb,var(--card-accent)_13%,transparent)] text-[var(--card-accent)] text-[0.7rem] font-semibold cursor-pointer transition-colors hover:bg-[var(--card-accent)] hover:text-[var(--color-fg-inverse)] focus-visible:bg-[var(--card-accent)] focus-visible:text-[var(--color-fg-inverse)]"
|
||||
onClick={() => onUse(a)}
|
||||
disabled={materializationLocked}
|
||||
aria-busy={isMaterializing}
|
||||
>
|
||||
{isMaterializing ? (
|
||||
<Loader className="spin" size={14} aria-hidden="true" />
|
||||
) : (
|
||||
<UserPlus size={14} aria-hidden="true" />
|
||||
)}{' '}
|
||||
<UserPlus size={14} aria-hidden="true" />{' '}
|
||||
{t('gallery.use_voice', { defaultValue: 'Use voice' })}
|
||||
</button>
|
||||
{onDesign ? (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center w-[30px] h-[30px] flex-shrink-0 rounded-[8px] bg-transparent text-[var(--color-fg-muted)] cursor-pointer opacity-50 transition-[opacity,color,background-color] duration-150 group-hover:opacity-100 focus-visible:opacity-100 hover:bg-[var(--chrome-hover-bg)] hover:text-[var(--card-accent)]"
|
||||
onClick={() => onDesign(a)}
|
||||
disabled={materializationLocked}
|
||||
title={t('gallery.open_designer', { defaultValue: 'Open in Designer' })}
|
||||
aria-label={t('gallery.open_designer', { defaultValue: 'Open in Designer' })}
|
||||
>
|
||||
<Wand2 size={14} aria-hidden="true" />
|
||||
</button>
|
||||
) : null}
|
||||
{onUseInStories || onUseAsAudiobookDefault ? (
|
||||
<Menu
|
||||
placement="bottom-end"
|
||||
disabled={materializationLocked}
|
||||
items={[
|
||||
onUseInStories
|
||||
? {
|
||||
id: 'stories',
|
||||
icon: BookOpen,
|
||||
label: t('gallery.use_in_stories', { defaultValue: 'Use in Stories' }),
|
||||
onSelect: () => onUseInStories(a),
|
||||
}
|
||||
: null,
|
||||
onUseAsAudiobookDefault
|
||||
? {
|
||||
id: 'audiobook',
|
||||
icon: Headphones,
|
||||
label: t('gallery.set_audiobook_default', {
|
||||
defaultValue: 'Set as Audiobook default',
|
||||
}),
|
||||
onSelect: () => onUseAsAudiobookDefault(a),
|
||||
}
|
||||
: null,
|
||||
].filter(Boolean)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center w-[30px] h-[30px] flex-shrink-0 rounded-[8px] bg-transparent text-[var(--color-fg-muted)] cursor-pointer opacity-50 transition-[opacity,color,background-color] duration-150 group-hover:opacity-100 focus-visible:opacity-100 hover:bg-[var(--chrome-hover-bg)] hover:text-[var(--card-accent)] disabled:cursor-not-allowed disabled:opacity-30"
|
||||
aria-label={t('gallery.more_actions', { defaultValue: 'More actions' })}
|
||||
title={t('gallery.more_actions', { defaultValue: 'More actions' })}
|
||||
>
|
||||
<Ellipsis size={15} aria-hidden="true" />
|
||||
</button>
|
||||
</Menu>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center w-[30px] h-[30px] flex-shrink-0 rounded-[8px] bg-transparent text-[var(--color-fg-muted)] cursor-pointer opacity-50 transition-[opacity,color,background-color] duration-150 group-hover:opacity-100 focus-visible:opacity-100 hover:bg-[var(--chrome-hover-bg)] hover:text-[var(--card-accent)]"
|
||||
onClick={() => onDesign(a)}
|
||||
title={t('gallery.open_designer', { defaultValue: 'Open in Designer' })}
|
||||
aria-label={t('gallery.open_designer', { defaultValue: 'Open in Designer' })}
|
||||
>
|
||||
<Wand2 size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -59,9 +59,6 @@ export default function ArchetypesZone({
|
||||
onPreview,
|
||||
onUse,
|
||||
onDesign,
|
||||
onUseInStories,
|
||||
onUseAsAudiobookDefault,
|
||||
materializingId,
|
||||
}) {
|
||||
const [favOnly, setFavOnly] = useState(false);
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
@@ -114,15 +111,10 @@ export default function ArchetypesZone({
|
||||
isFavorite: favSet.has(a.id),
|
||||
isPlaying: playingId === a.id,
|
||||
isLoadingPreview: loadingPreviewId === a.id,
|
||||
previewLocked: Boolean(loadingPreviewId),
|
||||
onPreview,
|
||||
onUse,
|
||||
onDesign,
|
||||
onUseInStories,
|
||||
onUseAsAudiobookDefault,
|
||||
onToggleFavorite: toggleFavorite,
|
||||
isMaterializing: materializingId === a.id,
|
||||
materializationLocked: Boolean(materializingId),
|
||||
});
|
||||
|
||||
const facetToggle =
|
||||
@@ -133,9 +125,7 @@ export default function ArchetypesZone({
|
||||
: 'flex flex-col gap-[6px]';
|
||||
|
||||
return (
|
||||
// data-testid: stable e2e hook — locale-independent, unlike the translated
|
||||
// aria-labels/headings inside (see e2e/gallery.spec.ts).
|
||||
<div data-testid="archetypes-zone" className="flex-1 min-h-0 flex flex-col overflow-y-auto">
|
||||
<div className="flex-1 min-h-0 flex flex-col overflow-y-auto">
|
||||
<div className="shrink-0 mb-[8px] pb-[8px] border-b border-transparent">
|
||||
<div className="flex items-center gap-[6px] min-w-0">
|
||||
<Select
|
||||
|
||||
@@ -91,46 +91,4 @@ describe('ArchetypeCard accessibility', () => {
|
||||
);
|
||||
expect(screen.getByRole('button', { name: 'Open in Designer' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('exposes the Stories and Audiobook handoffs from More actions', async () => {
|
||||
const onUseInStories = vi.fn();
|
||||
const onUseAsAudiobookDefault = vi.fn();
|
||||
const archetype = {
|
||||
id: 'narrator',
|
||||
name: 'Narrator',
|
||||
language: 'English',
|
||||
use_case: 'narration',
|
||||
facets: { gender: 'female', age: 'adult', pitch: 'moderate pitch' },
|
||||
attrs: {},
|
||||
};
|
||||
render(
|
||||
<ArchetypeCard
|
||||
a={archetype}
|
||||
t={t}
|
||||
isFavorite={false}
|
||||
isPlaying={false}
|
||||
isLoadingPreview={false}
|
||||
onPreview={vi.fn()}
|
||||
onUse={vi.fn()}
|
||||
onDesign={vi.fn()}
|
||||
onToggleFavorite={vi.fn()}
|
||||
onUseInStories={onUseInStories}
|
||||
onUseAsAudiobookDefault={onUseAsAudiobookDefault}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.pointerDown(screen.getByRole('button', { name: 'More actions' }), {
|
||||
button: 0,
|
||||
ctrlKey: false,
|
||||
});
|
||||
fireEvent.click(await screen.findByRole('menuitem', { name: 'Use in Stories' }));
|
||||
expect(onUseInStories).toHaveBeenCalledWith(archetype);
|
||||
|
||||
fireEvent.pointerDown(screen.getByRole('button', { name: 'More actions' }), {
|
||||
button: 0,
|
||||
ctrlKey: false,
|
||||
});
|
||||
fireEvent.click(await screen.findByRole('menuitem', { name: 'Set as Audiobook default' }));
|
||||
expect(onUseAsAudiobookDefault).toHaveBeenCalledWith(archetype);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Loader, Send } from 'lucide-react';
|
||||
import { useCommunityItems } from '../../api/hooks';
|
||||
import { communitySubmitUrl } from '../../api/community';
|
||||
import { addCommunityItem, communitySubmitUrl } from '../../api/community';
|
||||
import { openExternal } from '../../api/external';
|
||||
import ArchetypeCard from './ArchetypeCard';
|
||||
|
||||
const itemKey = (item) => `community:${item._source_repo || item.source || 'default'}:${item.id}`;
|
||||
|
||||
// ── Community zone (marketplace) ─────────────────────────────────────────────
|
||||
export default function CommunityZone({
|
||||
t,
|
||||
@@ -14,13 +12,9 @@ export default function CommunityZone({
|
||||
loadingPreviewId,
|
||||
favorites,
|
||||
toggleFavorite,
|
||||
onPreview,
|
||||
onPlayAudio,
|
||||
flash,
|
||||
onDesign,
|
||||
onUse,
|
||||
onUseInStories,
|
||||
onUseAsAudiobookDefault,
|
||||
materializingId,
|
||||
}) {
|
||||
const itemsQ = useCommunityItems({ limit: 100 });
|
||||
const items = itemsQ.data?.items || [];
|
||||
@@ -72,22 +66,49 @@ export default function CommunityZone({
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(248px,1fr))] gap-[10px]">
|
||||
{items.map((it) => (
|
||||
<ArchetypeCard
|
||||
key={itemKey(it)}
|
||||
key={it.id}
|
||||
a={it}
|
||||
t={t}
|
||||
favoriteId={itemKey(it)}
|
||||
isFavorite={favSet.has(itemKey(it))}
|
||||
isPlaying={playingId === itemKey(it)}
|
||||
isLoadingPreview={loadingPreviewId === itemKey(it)}
|
||||
previewLocked={Boolean(loadingPreviewId)}
|
||||
isFavorite={favSet.has(it.id)}
|
||||
isPlaying={playingId === it.id}
|
||||
isLoadingPreview={loadingPreviewId === it.id}
|
||||
onToggleFavorite={toggleFavorite}
|
||||
onPreview={onPreview}
|
||||
onUse={onUse}
|
||||
onDesign={it.type === 'preset' && it.instruct ? onDesign : null}
|
||||
onUseInStories={onUseInStories}
|
||||
onUseAsAudiobookDefault={onUseAsAudiobookDefault}
|
||||
isMaterializing={materializingId === it.id}
|
||||
materializationLocked={Boolean(materializingId)}
|
||||
onPreview={(item) =>
|
||||
item.audio?.url
|
||||
? onPlayAudio(item.audio.url, item.id, item.name)
|
||||
: flash(
|
||||
t('gallery.no_preview', {
|
||||
defaultValue: 'No preview — add it with "Use voice" to hear it.',
|
||||
}),
|
||||
)
|
||||
}
|
||||
onUse={async (item) => {
|
||||
try {
|
||||
const r = await addCommunityItem(item.id, item.name);
|
||||
flash(
|
||||
t('gallery.saved_as_profile', {
|
||||
defaultValue: 'Added "{{name}}" to your voices.',
|
||||
name: r.name,
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
flash(
|
||||
t('gallery.use_failed', {
|
||||
message: e?.message || String(e),
|
||||
defaultValue: 'Could not create that voice: {{message}}',
|
||||
}),
|
||||
);
|
||||
}
|
||||
}}
|
||||
onDesign={(item) =>
|
||||
item.instruct
|
||||
? onDesign(item.instruct)
|
||||
: flash(
|
||||
t('gallery.no_designer', {
|
||||
defaultValue: 'Recorded voice — use "Use voice" instead.',
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const items = [
|
||||
{
|
||||
id: 'p1',
|
||||
type: 'preset',
|
||||
name: 'Shared Designer',
|
||||
_source_repo: 'repo/test',
|
||||
use_case: 'narration',
|
||||
language: 'English',
|
||||
instruct: 'female, high pitch',
|
||||
attrs: { Gender: 'female', Pitch: 'high pitch' },
|
||||
facets: { gender: 'female', age: null, pitch: 'high pitch', whisper: false },
|
||||
},
|
||||
{
|
||||
id: 'v1',
|
||||
type: 'voice',
|
||||
name: 'Shared Recording',
|
||||
_source_repo: 'repo/test',
|
||||
use_case: 'narration',
|
||||
language: 'English',
|
||||
facets: { gender: null, age: null, pitch: null, whisper: false },
|
||||
},
|
||||
];
|
||||
|
||||
vi.mock('../../api/hooks', () => ({
|
||||
useCommunityItems: () => ({ data: { items }, isLoading: false }),
|
||||
}));
|
||||
vi.mock('../../api/community', () => ({ communitySubmitUrl: vi.fn() }));
|
||||
vi.mock('../../api/external', () => ({ openExternal: vi.fn() }));
|
||||
|
||||
import CommunityZone from './CommunityZone';
|
||||
|
||||
const t = (_key, options = {}) => options.defaultValue || _key;
|
||||
|
||||
describe('CommunityZone persona actions', () => {
|
||||
it('wires both item types while offering the wand only for designed presets', () => {
|
||||
const onPreview = vi.fn();
|
||||
const onUse = vi.fn();
|
||||
const onDesign = vi.fn();
|
||||
const toggleFavorite = vi.fn();
|
||||
render(
|
||||
<CommunityZone
|
||||
t={t}
|
||||
playingId={null}
|
||||
loadingPreviewId={null}
|
||||
favorites={[]}
|
||||
toggleFavorite={toggleFavorite}
|
||||
onPreview={onPreview}
|
||||
onDesign={onDesign}
|
||||
onUse={onUse}
|
||||
onUseInStories={vi.fn()}
|
||||
onUseAsAudiobookDefault={vi.fn()}
|
||||
materializingId={null}
|
||||
flash={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const previews = screen.getAllByRole('button', { name: 'Preview' });
|
||||
const uses = screen.getAllByRole('button', { name: 'Use voice' });
|
||||
fireEvent.click(previews[0]);
|
||||
fireEvent.click(previews[1]);
|
||||
fireEvent.click(uses[0]);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open in Designer' }));
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Favorite' })[0]);
|
||||
|
||||
expect(onPreview).toHaveBeenNthCalledWith(1, items[0]);
|
||||
expect(onPreview).toHaveBeenNthCalledWith(2, items[1]);
|
||||
expect(onUse).toHaveBeenCalledWith(items[0]);
|
||||
expect(onDesign).toHaveBeenCalledWith(items[0]);
|
||||
expect(toggleFavorite).toHaveBeenCalledWith('community:repo/test:p1');
|
||||
expect(screen.getAllByRole('button', { name: 'Open in Designer' })).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,7 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import {
|
||||
BookOpen,
|
||||
Search,
|
||||
Download,
|
||||
Ellipsis,
|
||||
Headphones,
|
||||
Play,
|
||||
Pause,
|
||||
Trash2,
|
||||
@@ -15,7 +12,7 @@ import {
|
||||
Scissors,
|
||||
Package,
|
||||
} from 'lucide-react';
|
||||
import { Button, Input, Menu } from '../../ui';
|
||||
import { Button, Input } from '../../ui';
|
||||
import { useGalleryVoices } from '../../api/hooks';
|
||||
import { importPersona } from '../../api/profiles';
|
||||
import {
|
||||
@@ -31,23 +28,14 @@ import { apiFetch } from '../../api/client';
|
||||
import { askConfirm } from '../../utils/dialog';
|
||||
|
||||
// ── My Imports zone (neutral importer) ───────────────────────────────────────
|
||||
export default function ImportsZone({
|
||||
t,
|
||||
playingId,
|
||||
loadingPreviewId,
|
||||
onPlayGallery,
|
||||
onUseProfile,
|
||||
flash,
|
||||
}) {
|
||||
export default function ImportsZone({ t, playingId, loadingPreviewId, onPlayGallery, flash }) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const [trimming, setTrimming] = useState(null); // { voice, file }
|
||||
const [savingProfileId, setSavingProfileId] = useState(null);
|
||||
const fileRef = useRef(null);
|
||||
const personaRef = useRef(null);
|
||||
const savingProfileRef = useRef(false);
|
||||
const [importingPersona, setImportingPersona] = useState(false);
|
||||
|
||||
const voicesQ = useGalleryVoices();
|
||||
@@ -177,21 +165,15 @@ export default function ImportsZone({
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveProfile = async (v, target = 'studio') => {
|
||||
// Close the double-click window before React can paint `disabled`.
|
||||
if (savingProfileRef.current) return;
|
||||
savingProfileRef.current = true;
|
||||
setSavingProfileId(v.id);
|
||||
const handleSaveProfile = async (v) => {
|
||||
try {
|
||||
const profile = await saveVoiceAsProfile(v.id, v.name);
|
||||
if (onUseProfile) onUseProfile(profile, target);
|
||||
else
|
||||
flash(
|
||||
t('gallery.saved_as_profile', {
|
||||
defaultValue: 'Added "{{name}}" to your voices.',
|
||||
name: profile.name,
|
||||
}),
|
||||
);
|
||||
await saveVoiceAsProfile(v.id, v.name);
|
||||
flash(
|
||||
t('gallery.saved_as_profile', {
|
||||
defaultValue: 'Added "{{name}}" to your voices.',
|
||||
name: v.name,
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
flash(
|
||||
t('gallery.save_failed', {
|
||||
@@ -199,9 +181,6 @@ export default function ImportsZone({
|
||||
defaultValue: 'Could not save profile: {{message}}',
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
savingProfileRef.current = false;
|
||||
setSavingProfileId(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -403,14 +382,7 @@ export default function ImportsZone({
|
||||
key={v.id}
|
||||
className="flex items-center gap-[8px] px-[10px] py-[8px] bg-bg-elev-2 rounded-[8px] transition-colors hover:bg-bg-elev-1"
|
||||
>
|
||||
<button
|
||||
className={`${voicePlay} disabled:cursor-not-allowed disabled:opacity-50`}
|
||||
onClick={() => onPlayGallery(v)}
|
||||
disabled={Boolean(loadingPreviewId)}
|
||||
aria-busy={loadingPreviewId === v.id}
|
||||
aria-label={t('gallery.preview', { defaultValue: 'Preview' })}
|
||||
title={t('gallery.preview', { defaultValue: 'Preview' })}
|
||||
>
|
||||
<button className={voicePlay} onClick={() => onPlayGallery(v)}>
|
||||
{loadingPreviewId === v.id ? (
|
||||
<Loader className="spin" size={16} />
|
||||
) : playingId === v.id ? (
|
||||
@@ -434,50 +406,12 @@ export default function ImportsZone({
|
||||
<Scissors size={14} />
|
||||
</button>
|
||||
<button
|
||||
className={`${actionBtn} disabled:cursor-not-allowed disabled:opacity-40`}
|
||||
className={actionBtn}
|
||||
onClick={() => handleSaveProfile(v)}
|
||||
disabled={Boolean(savingProfileId)}
|
||||
aria-busy={savingProfileId === v.id}
|
||||
aria-label={t('gallery.use_voice', { defaultValue: 'Use voice' })}
|
||||
title={t('gallery.use_voice', { defaultValue: 'Use voice' })}
|
||||
>
|
||||
{savingProfileId === v.id ? (
|
||||
<Loader className="spin" size={14} aria-hidden="true" />
|
||||
) : (
|
||||
<UserPlus size={14} aria-hidden="true" />
|
||||
)}
|
||||
<UserPlus size={14} />
|
||||
</button>
|
||||
{onUseProfile ? (
|
||||
<Menu
|
||||
placement="bottom-end"
|
||||
disabled={Boolean(savingProfileId)}
|
||||
items={[
|
||||
{
|
||||
id: 'stories',
|
||||
icon: BookOpen,
|
||||
label: t('gallery.use_in_stories', { defaultValue: 'Use in Stories' }),
|
||||
onSelect: () => handleSaveProfile(v, 'stories'),
|
||||
},
|
||||
{
|
||||
id: 'audiobook',
|
||||
icon: Headphones,
|
||||
label: t('gallery.set_audiobook_default', {
|
||||
defaultValue: 'Set as Audiobook default',
|
||||
}),
|
||||
onSelect: () => handleSaveProfile(v, 'audiobook'),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<button
|
||||
className={`${actionBtn} disabled:cursor-not-allowed disabled:opacity-40`}
|
||||
disabled={Boolean(savingProfileId)}
|
||||
aria-label={t('gallery.more_actions', { defaultValue: 'More actions' })}
|
||||
title={t('gallery.more_actions', { defaultValue: 'More actions' })}
|
||||
>
|
||||
<Ellipsis size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</Menu>
|
||||
) : null}
|
||||
<button
|
||||
className="flex items-center justify-center w-[24px] h-[24px] bg-transparent text-[var(--text-secondary)] rounded-[4px] cursor-pointer hover:bg-[#3d1f1f] hover:text-[#fb4934]"
|
||||
onClick={() => handleDelete(v)}
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
saveVoiceAsProfile: vi.fn(),
|
||||
voice: { id: 'import-1', name: 'Imported Voice', duration: 4 },
|
||||
}));
|
||||
|
||||
vi.mock('../../api/hooks', () => ({
|
||||
useGalleryVoices: () => ({
|
||||
data: [mocks.voice],
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
vi.mock('../../api/gallery', () => ({
|
||||
deleteGalleryVoice: vi.fn(),
|
||||
downloadYoutubeClip: vi.fn(),
|
||||
previewVoiceUrl: vi.fn(),
|
||||
saveVoiceAsProfile: mocks.saveVoiceAsProfile,
|
||||
searchYoutube: vi.fn(),
|
||||
uploadVoiceClip: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../api/profiles', () => ({ importPersona: vi.fn() }));
|
||||
vi.mock('../../api/client', () => ({ apiFetch: vi.fn() }));
|
||||
vi.mock('../../utils/dialog', () => ({ askConfirm: vi.fn() }));
|
||||
vi.mock('../AudioTrimmer', () => ({ default: () => null }));
|
||||
|
||||
import ImportsZone from './ImportsZone';
|
||||
|
||||
const t = (_key, options = {}) => options.defaultValue || _key;
|
||||
|
||||
describe('ImportsZone Use voice', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('materializes once on a double click and hands off the returned profile', async () => {
|
||||
let finish;
|
||||
mocks.saveVoiceAsProfile.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
);
|
||||
const onUseProfile = vi.fn();
|
||||
render(
|
||||
<ImportsZone
|
||||
t={t}
|
||||
playingId={null}
|
||||
loadingPreviewId={null}
|
||||
onPlayGallery={vi.fn()}
|
||||
onUseProfile={onUseProfile}
|
||||
flash={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const useButton = screen.getByRole('button', { name: 'Use voice' });
|
||||
fireEvent.click(useButton);
|
||||
fireEvent.click(useButton);
|
||||
expect(mocks.saveVoiceAsProfile).toHaveBeenCalledOnce();
|
||||
expect(useButton).toBeDisabled();
|
||||
|
||||
const profile = { profile_id: 'profile-1', name: 'Server Voice' };
|
||||
finish(profile);
|
||||
await waitFor(() => expect(onUseProfile).toHaveBeenCalledWith(profile, 'studio'));
|
||||
expect(useButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('can send an imported voice directly to Stories', async () => {
|
||||
const profile = { profile_id: 'profile-2', name: 'Story Voice' };
|
||||
mocks.saveVoiceAsProfile.mockResolvedValue(profile);
|
||||
const onUseProfile = vi.fn();
|
||||
render(
|
||||
<ImportsZone
|
||||
t={t}
|
||||
playingId={null}
|
||||
loadingPreviewId={null}
|
||||
onPlayGallery={vi.fn()}
|
||||
onUseProfile={onUseProfile}
|
||||
flash={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.pointerDown(screen.getByRole('button', { name: 'More actions' }), {
|
||||
button: 0,
|
||||
ctrlKey: false,
|
||||
});
|
||||
fireEvent.click(await screen.findByRole('menuitem', { name: 'Use in Stories' }));
|
||||
|
||||
await waitFor(() => expect(onUseProfile).toHaveBeenCalledWith(profile, 'stories'));
|
||||
});
|
||||
});
|
||||
@@ -188,9 +188,6 @@ describe('ApiKeysPanel', () => {
|
||||
expect(screen.queryByText(/not set/i)).toBeNull();
|
||||
expect(container.querySelectorAll('.apikeys-row').length).toBe(0);
|
||||
|
||||
// API requests wait for the credential-scrubbing bootstrap before they
|
||||
// reach fetch, so do not assume the effect invokes fetch synchronously.
|
||||
await waitFor(() => expect(global.fetch).toHaveBeenCalledOnce());
|
||||
resolveFetch({
|
||||
ok: true,
|
||||
status: 200,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { addBreadcrumb } from '../../utils/breadcrumbs';
|
||||
import { useEngines, useSelectEngine } from '../../api/hooks';
|
||||
import { selectEngine } from '../../api/engines';
|
||||
import { notifyEngineSelected } from '../../utils/engineSelectToast';
|
||||
import EngineCompatibilityMatrix from '../EngineCompatibilityMatrix';
|
||||
import AsrOpenAICompatPanel from './AsrOpenAICompatPanel';
|
||||
@@ -17,23 +17,22 @@ import { SETTINGS_SECTION_SURFACE } from './primitives';
|
||||
* tabindex + arrow keys, active engine named in each tab caption) now
|
||||
* presents one family at a time instead, over compact fixed-height rows.
|
||||
*
|
||||
* The mounted matrix reads the app-wide `/engines` cache and issues one
|
||||
* `/model/loaded` probe. Switching tabs only re-slices that shared payload;
|
||||
* selection and installs invalidate it for every consumer.
|
||||
* Data contract is unchanged: the single mounted matrix issues exactly one
|
||||
* GET /engines + one GET /model/loaded per Settings open (switching tabs
|
||||
* re-slices the same payload — no refetch), `openSettingsTab('engines')`
|
||||
* still lands here, and `OMNIVOICE_*_BACKEND` env vars still win over any
|
||||
* pick made in the UI.
|
||||
*
|
||||
* The ASR tab additionally mounts the OpenAI-compatible remote ASR config
|
||||
* panel below the matrix — configure server URL / model / key, test the
|
||||
* connection, then activate with the engine's own "Use" button. Saving in
|
||||
* the panel bumps `configVersion`, which refetches the matrix so the
|
||||
* engine's row flips unavailable → available without a manual Refresh. */
|
||||
export default function EnginesTab({ initialFamily = 'tts', onFamilyChange }) {
|
||||
export default function EnginesTab() {
|
||||
const { t } = useTranslation();
|
||||
const [family, setFamily] = useState(initialFamily);
|
||||
const [family, setFamily] = useState('tts');
|
||||
const [configVersion, setConfigVersion] = useState(0);
|
||||
const enginesQuery = useEngines();
|
||||
const selectMutation = useSelectEngine();
|
||||
const onAsrConfigSaved = useCallback(() => setConfigVersion((v) => v + 1), []);
|
||||
useEffect(() => setFamily(initialFamily), [initialFamily]);
|
||||
|
||||
// Plan 02-04 / ENGINE-06 — engine selection is wired through the
|
||||
// matrix component's optional onSelect callback so the matrix doubles
|
||||
@@ -47,7 +46,7 @@ export default function EnginesTab({ initialFamily = 'tts', onFamilyChange }) {
|
||||
async (family, backendId, modelId) => {
|
||||
try {
|
||||
addBreadcrumb(`engine:${family}=${backendId}`);
|
||||
const r = await selectMutation.mutateAsync({ family, backendId, modelId });
|
||||
const r = await selectEngine(family, backendId, modelId);
|
||||
// Consume the routing echo: warn (not a bare success) when the pick
|
||||
// lands on a CPU fallback on this host. See notifyEngineSelected.
|
||||
notifyEngineSelected(r, t, family);
|
||||
@@ -55,7 +54,7 @@ export default function EnginesTab({ initialFamily = 'tts', onFamilyChange }) {
|
||||
toast.error(e.message || t('engines.switch_failed'));
|
||||
}
|
||||
},
|
||||
[selectMutation, t],
|
||||
[t],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -66,13 +65,9 @@ export default function EnginesTab({ initialFamily = 'tts', onFamilyChange }) {
|
||||
aria-label={t('settings.engines')}
|
||||
>
|
||||
<EngineCompatibilityMatrix
|
||||
family={family}
|
||||
sharedEngines={enginesQuery}
|
||||
family="tts"
|
||||
onSelect={onSelect}
|
||||
onFamilyChange={(next) => {
|
||||
setFamily(next);
|
||||
onFamilyChange?.(next);
|
||||
}}
|
||||
onFamilyChange={setFamily}
|
||||
reloadToken={configVersion}
|
||||
/>
|
||||
</section>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
// Keep toast side-channels out of the test (timers, portals).
|
||||
vi.mock('react-hot-toast', () => ({
|
||||
@@ -28,10 +27,9 @@ vi.mock('../../api/system', () => ({
|
||||
// The ASR tab mounts AsrOpenAICompatPanel, which loads its config over the
|
||||
// api client — mocked so switching tabs never hits the network here.
|
||||
const apiJson = vi.fn();
|
||||
const apiFetch = vi.fn();
|
||||
vi.mock('../../api/client', () => ({
|
||||
apiJson: (...a) => apiJson(...a),
|
||||
apiFetch: (...a) => apiFetch(...a),
|
||||
apiFetch: vi.fn(),
|
||||
apiPost: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -39,15 +37,6 @@ import { listEngines, selectEngine } from '../../api/engines';
|
||||
import { listLoadedModels } from '../../api/system';
|
||||
import EnginesTab from './EnginesTab';
|
||||
|
||||
function renderEnginesTab() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<EnginesTab />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function entry(id, name) {
|
||||
return {
|
||||
id,
|
||||
@@ -94,19 +83,12 @@ describe('EnginesTab', () => {
|
||||
vi.clearAllMocks();
|
||||
listEngines.mockResolvedValue(ENGINES);
|
||||
listLoadedModels.mockResolvedValue({ models: [], count: 0 });
|
||||
apiFetch.mockResolvedValue({
|
||||
json: async () => ({
|
||||
base_url: 'http://localhost:8000/v1',
|
||||
model: 'qwen3-asr',
|
||||
has_key: false,
|
||||
}),
|
||||
});
|
||||
// AsrOpenAICompatPanel's GET on mount (ASR tab only).
|
||||
apiJson.mockResolvedValue({ base_url: '', model: 'whisper-1', has_key: false });
|
||||
});
|
||||
|
||||
it('renders ONE tabbed section — TTS/ASR/LLM tab strip, one family at a time', async () => {
|
||||
renderEnginesTab();
|
||||
render(<EnginesTab />);
|
||||
await waitFor(() => screen.getByText('VoiceStudio (test)'));
|
||||
|
||||
// One settings card, not three stacked per-family matrices.
|
||||
@@ -128,7 +110,7 @@ describe('EnginesTab', () => {
|
||||
});
|
||||
|
||||
it('switching to the ASR tab shows ASR engines without refetching /engines', async () => {
|
||||
renderEnginesTab();
|
||||
render(<EnginesTab />);
|
||||
await waitFor(() => screen.getByText('VoiceStudio (test)'));
|
||||
|
||||
clickFamilyTab('ASR');
|
||||
@@ -140,32 +122,18 @@ describe('EnginesTab', () => {
|
||||
});
|
||||
|
||||
it('fetches GET /engines exactly once on mount', async () => {
|
||||
renderEnginesTab();
|
||||
render(<EnginesTab />);
|
||||
await waitFor(() => screen.getByText('VoiceStudio (test)'));
|
||||
expect(listEngines).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('probes GET /model/loaded exactly once on mount', async () => {
|
||||
renderEnginesTab();
|
||||
render(<EnginesTab />);
|
||||
await waitFor(() => screen.getByText('VoiceStudio (test)'));
|
||||
await waitFor(() => expect(listLoadedModels).toHaveBeenCalled());
|
||||
expect(listLoadedModels).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('refetches the shared inventory after saving OpenAI-compatible ASR config', async () => {
|
||||
renderEnginesTab();
|
||||
await screen.findByText('VoiceStudio (test)');
|
||||
clickFamilyTab('ASR');
|
||||
await screen.findByTestId('asr-openai-compat-model');
|
||||
|
||||
fireEvent.change(screen.getByTestId('asr-openai-compat-model'), {
|
||||
target: { value: 'qwen3-asr' },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId('asr-openai-compat-save'));
|
||||
|
||||
await waitFor(() => expect(listEngines).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it('clicking Use on an ASR engine selects it with family="asr"', async () => {
|
||||
selectEngine.mockResolvedValue({
|
||||
family: 'asr',
|
||||
@@ -175,7 +143,7 @@ describe('EnginesTab', () => {
|
||||
effective_device: 'cpu',
|
||||
routing_reason: null,
|
||||
});
|
||||
renderEnginesTab();
|
||||
render(<EnginesTab />);
|
||||
await waitFor(() => screen.getByText('VoiceStudio (test)'));
|
||||
|
||||
clickFamilyTab('ASR');
|
||||
@@ -188,7 +156,7 @@ describe('EnginesTab', () => {
|
||||
});
|
||||
|
||||
it('mounts the OpenAI-compatible ASR config panel on the ASR tab only', async () => {
|
||||
renderEnginesTab();
|
||||
render(<EnginesTab />);
|
||||
await waitFor(() => screen.getByText('VoiceStudio (test)'));
|
||||
// TTS tab: no ASR config panel.
|
||||
expect(screen.queryByTestId('asr-openai-compat-base-url')).not.toBeInTheDocument();
|
||||
|
||||
@@ -22,7 +22,6 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Brain, ExternalLink } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { apiJson, apiFetch, apiPost } from '../../api/client';
|
||||
import { useAppStore } from '../../store';
|
||||
import { SettingsSection, SettingRow, SettingsInput } from './primitives';
|
||||
import { Button, Badge, Select } from '../../ui';
|
||||
|
||||
@@ -229,19 +228,6 @@ export default function LLMProvidersPanel() {
|
||||
title={t('settings.llm_providers')}
|
||||
description={t('settings.llmp_desc')}
|
||||
>
|
||||
{/* Backlink half of the catalogue↔providers door: the provider picked
|
||||
here is what the catalogue's LLM family actually calls through. */}
|
||||
<p className="m-0 text-[length:var(--text-xs)] text-[color:var(--chrome-fg-muted)]">
|
||||
{t('settings.llmp_catalogue_note')}{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer border-0 bg-transparent p-0 text-[length:var(--text-xs)] text-[color:var(--chrome-accent)] underline-offset-2 hover:underline"
|
||||
onClick={() => useAppStore.getState().openCatalogue?.({ pane: 'engines', family: 'llm' })}
|
||||
data-testid="llmp-open-catalogue"
|
||||
>
|
||||
{t('settings.llmp_catalogue_link')}
|
||||
</button>
|
||||
</p>
|
||||
<SettingRow
|
||||
title={t('settings.llmp_provider')}
|
||||
hint={t('settings.llmp_provider_hint')}
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
* Settings → Sharing → Remote backend panel (parity program Wave 2.3).
|
||||
*
|
||||
* Point this app at a VoiceStudio backend running elsewhere (a GPU box over
|
||||
* Tailscale, a Docker deployment). Persists only the URL. A supplied master
|
||||
* credential is exchanged for a short-lived session and removed from the UI;
|
||||
* it never enters localStorage or an ordinary health request.
|
||||
* Tailscale, a Docker deployment). Stores the URL + API key in localStorage
|
||||
* — they are CLIENT-side settings — and reloads the app so api/client.ts
|
||||
* re-resolves the base. "Test" hits {url}/health (with the key) and shows
|
||||
* the remote's version + device.
|
||||
*
|
||||
* Saving is guarded: the URL must be a parseable http(s):// URL (a typo'd
|
||||
* base would brick every API call after the reload), and saving a URL that
|
||||
@@ -18,7 +19,6 @@ import { Server } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { LS_BACKEND_URL, LS_API_KEY, API } from '../../api/client';
|
||||
import { clearAdminSession, exchangeApiKey, getAdminSession } from '../../api/authSession';
|
||||
import { askConfirm } from '../../utils/dialog';
|
||||
import { disableRemoteBackend, probeRemoteBackend } from '../../utils/remoteBackendProbe';
|
||||
import { SettingsSection, SettingRow, InfoHint, SettingsInput } from './primitives';
|
||||
@@ -43,133 +43,60 @@ export function isValidBackendUrl(value) {
|
||||
}
|
||||
}
|
||||
|
||||
function storedBackendUrl() {
|
||||
try {
|
||||
return localStorage.getItem(LS_BACKEND_URL) || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function removeLegacyMaster() {
|
||||
try {
|
||||
localStorage.removeItem(LS_API_KEY);
|
||||
} catch {
|
||||
// A blocked storage API is equivalent to the legacy key not being usable.
|
||||
}
|
||||
}
|
||||
|
||||
export default function RemoteBackendPanel({ reload = () => window.location.reload() }) {
|
||||
const { t } = useTranslation();
|
||||
const [url, setUrl] = useState(storedBackendUrl);
|
||||
const [key, setKey] = useState('');
|
||||
const [url, setUrl] = useState(() => localStorage.getItem(LS_BACKEND_URL) || '');
|
||||
const [key, setKey] = useState(() => localStorage.getItem(LS_API_KEY) || '');
|
||||
const [probe, setProbe] = useState(null); // {ok, detail, target}
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [authenticatedTarget, setAuthenticatedTarget] = useState(null);
|
||||
const [initialTarget] = useState(() => (storedBackendUrl() || API).trim().replace(/\/+$/, ''));
|
||||
const [restoredSessionTarget] = useState(() => getAdminSession(initialTarget)?.apiBase ?? null);
|
||||
const hasSavedRemote = Boolean(storedBackendUrl());
|
||||
const hasSavedRemote = Boolean(localStorage.getItem(LS_BACKEND_URL));
|
||||
|
||||
const normalized = url.trim().replace(/\/+$/, '');
|
||||
|
||||
const onTest = async () => {
|
||||
if (testing || saving) return;
|
||||
setTesting(true);
|
||||
setProbe(null);
|
||||
const target = normalized || API;
|
||||
const master = key.trim();
|
||||
// Retain the secret only in this in-flight stack frame. A pending legacy
|
||||
// master in localStorage is left alone: a mere connection test must not
|
||||
// consume the key the bootstrap migration still needs to retry.
|
||||
setKey('');
|
||||
try {
|
||||
const result = await probeRemoteBackend(target);
|
||||
if (!result.ok || !master) {
|
||||
setProbe(result);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await exchangeApiKey(master, { apiBase: target });
|
||||
setAuthenticatedTarget(target);
|
||||
setProbe(result);
|
||||
} catch (error) {
|
||||
setAuthenticatedTarget(null);
|
||||
setProbe({
|
||||
ok: false,
|
||||
kind: error?.status ? 'http' : 'network',
|
||||
status: error?.status,
|
||||
target,
|
||||
});
|
||||
}
|
||||
setProbe(await probeRemoteBackend(target, key.trim()));
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onSave = async () => {
|
||||
if (testing || saving) return;
|
||||
setSaving(true);
|
||||
const master = key.trim();
|
||||
setKey('');
|
||||
try {
|
||||
if (normalized) {
|
||||
if (!isValidBackendUrl(normalized)) {
|
||||
toast.error(
|
||||
t('settings.remote_backend_invalid_url', {
|
||||
defaultValue:
|
||||
'Enter a valid URL starting with http:// or https:// (e.g. http://gpu-box:3900).',
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
// A wrong base bricks every API call after the reload — if this exact
|
||||
// URL hasn't passed a connection test, make the user confirm.
|
||||
const verified = probe?.ok && probe.target === normalized;
|
||||
if (!verified) {
|
||||
const go = await askConfirm(
|
||||
t('settings.remote_backend_confirm_unverified', {
|
||||
defaultValue:
|
||||
"This backend URL hasn't passed a connection test. Save it and reload anyway? " +
|
||||
"If it's wrong, the app can't reach any backend until you change it back here.",
|
||||
}),
|
||||
t('settings.remote_backend_confirm_title', { defaultValue: 'Use unverified backend?' }),
|
||||
);
|
||||
if (!go) return;
|
||||
}
|
||||
if (master) {
|
||||
try {
|
||||
await exchangeApiKey(master, { apiBase: normalized });
|
||||
setAuthenticatedTarget(normalized);
|
||||
} catch (error) {
|
||||
setAuthenticatedTarget(null);
|
||||
setProbe({
|
||||
ok: false,
|
||||
kind: error?.status ? 'http' : 'network',
|
||||
status: error?.status,
|
||||
target: normalized,
|
||||
});
|
||||
return;
|
||||
}
|
||||
} else if ((authenticatedTarget ?? restoredSessionTarget ?? initialTarget) !== normalized) {
|
||||
clearAdminSession();
|
||||
}
|
||||
localStorage.setItem(LS_BACKEND_URL, normalized);
|
||||
} else {
|
||||
// Disabling the remote backend is an explicit discard: the pending
|
||||
// legacy master goes with the connection it belonged to. Everywhere
|
||||
// else the durable key is consumed only by a successful exchange
|
||||
// (exchangeApiKey removes it), so an unreachable backend can't strand
|
||||
// the user by destroying their only copy.
|
||||
localStorage.removeItem(LS_BACKEND_URL);
|
||||
clearAdminSession();
|
||||
removeLegacyMaster();
|
||||
if (normalized) {
|
||||
if (!isValidBackendUrl(normalized)) {
|
||||
toast.error(
|
||||
t('settings.remote_backend_invalid_url', {
|
||||
defaultValue:
|
||||
'Enter a valid URL starting with http:// or https:// (e.g. http://gpu-box:3900).',
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
// api/client.ts resolves the base once at module load.
|
||||
reload();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
// A wrong base bricks every API call after the reload — if this exact
|
||||
// URL hasn't passed a connection test, make the user confirm.
|
||||
const verified = probe?.ok && probe.target === normalized;
|
||||
if (!verified) {
|
||||
const go = await askConfirm(
|
||||
t('settings.remote_backend_confirm_unverified', {
|
||||
defaultValue:
|
||||
"This backend URL hasn't passed a connection test. Save it and reload anyway? " +
|
||||
"If it's wrong, the app can't reach any backend until you change it back here.",
|
||||
}),
|
||||
t('settings.remote_backend_confirm_title', { defaultValue: 'Use unverified backend?' }),
|
||||
);
|
||||
if (!go) return;
|
||||
}
|
||||
localStorage.setItem(LS_BACKEND_URL, normalized);
|
||||
} else {
|
||||
localStorage.removeItem(LS_BACKEND_URL);
|
||||
}
|
||||
if (key.trim()) localStorage.setItem(LS_API_KEY, key.trim());
|
||||
else localStorage.removeItem(LS_API_KEY);
|
||||
// api/client.ts resolves the base once at module load.
|
||||
reload();
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -214,8 +141,6 @@ export default function RemoteBackendPanel({ reload = () => window.location.relo
|
||||
type="password"
|
||||
value={key}
|
||||
onChange={(e) => setKey(e.target.value)}
|
||||
autoComplete="off"
|
||||
disabled={testing || saving}
|
||||
placeholder={t('settings.remote_backend_key_placeholder', {
|
||||
defaultValue: 'value of OMNIVOICE_API_KEY on the server',
|
||||
})}
|
||||
@@ -231,26 +156,19 @@ export default function RemoteBackendPanel({ reload = () => window.location.relo
|
||||
size="sm"
|
||||
onClick={onTest}
|
||||
loading={testing}
|
||||
disabled={testing || saving}
|
||||
disabled={testing}
|
||||
data-testid="remote-backend-test"
|
||||
>
|
||||
{t('settings.remote_backend_test', { defaultValue: 'Test connection' })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={onSave}
|
||||
loading={saving}
|
||||
disabled={testing || saving}
|
||||
data-testid="remote-backend-save"
|
||||
>
|
||||
<Button variant="subtle" size="sm" onClick={onSave} data-testid="remote-backend-save">
|
||||
{t('settings.remote_backend_save', { defaultValue: 'Save & reload' })}
|
||||
</Button>
|
||||
{hasSavedRemote && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={() => void disableRemoteBackend(reload)}
|
||||
onClick={() => disableRemoteBackend(reload)}
|
||||
data-testid="remote-backend-disable"
|
||||
>
|
||||
{t('settings.remote_backend_use_local')}
|
||||
|
||||
@@ -12,16 +12,6 @@ vi.mock('../../api/client', () => ({
|
||||
API: 'http://127.0.0.1:3900',
|
||||
}));
|
||||
|
||||
const authMocks = vi.hoisted(() => ({
|
||||
exchangeApiKey: vi.fn(),
|
||||
clearAdminSession: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../api/authSession', async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
exchangeApiKey: authMocks.exchangeApiKey,
|
||||
clearAdminSession: authMocks.clearAdminSession,
|
||||
}));
|
||||
|
||||
// Shared confirmation dialog (Tauri-aware) — controlled per test.
|
||||
const { askConfirm } = vi.hoisted(() => ({ askConfirm: vi.fn() }));
|
||||
vi.mock('../../utils/dialog', () => ({ askConfirm }));
|
||||
@@ -53,21 +43,11 @@ describe('RemoteBackendPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
authMocks.exchangeApiKey.mockResolvedValue({
|
||||
transport: 'bearer',
|
||||
expiresAt: Date.now() / 1000 + 60,
|
||||
});
|
||||
authMocks.clearAdminSession.mockImplementation(() =>
|
||||
sessionStorage.removeItem('ov_admin_session'),
|
||||
);
|
||||
reload = vi.fn();
|
||||
});
|
||||
|
||||
const setUrl = (value) =>
|
||||
fireEvent.change(screen.getByTestId('remote-backend-url'), { target: { value } });
|
||||
const setKey = (value) =>
|
||||
fireEvent.change(screen.getByTestId('remote-backend-key'), { target: { value } });
|
||||
const clickSave = () => fireEvent.click(screen.getByTestId('remote-backend-save'));
|
||||
|
||||
it('rejects an invalid URL instead of saving and reloading into a broken app', async () => {
|
||||
@@ -117,71 +97,6 @@ describe('RemoteBackendPanel', () => {
|
||||
expect(localStorage.getItem('ov_backend_url')).toBe('http://gpu-box:3900');
|
||||
});
|
||||
|
||||
it('exchanges a test credential only after the public health probe succeeds', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue(healthResponse('0.4.2'));
|
||||
render(<RemoteBackendPanel reload={reload} />);
|
||||
setUrl('http://gpu-box:3900');
|
||||
setKey('master-secret');
|
||||
|
||||
fireEvent.click(screen.getByTestId('remote-backend-test'));
|
||||
|
||||
await screen.findByText('OK — 0.4.2 on cuda');
|
||||
expect(authMocks.exchangeApiKey).toHaveBeenCalledWith('master-secret', {
|
||||
apiBase: 'http://gpu-box:3900',
|
||||
});
|
||||
expect(screen.getByTestId('remote-backend-key')).toHaveValue('');
|
||||
expect(localStorage.getItem('ov_api_key')).toBeNull();
|
||||
expect(global.fetch.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
authMocks.exchangeApiKey.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it('does not exchange or retain a credential when the health probe fails', async () => {
|
||||
global.fetch = vi.fn().mockRejectedValue(new TypeError('Failed to fetch'));
|
||||
render(<RemoteBackendPanel reload={reload} />);
|
||||
setUrl('http://gpu-box:3900');
|
||||
setKey('master-secret');
|
||||
|
||||
fireEvent.click(screen.getByTestId('remote-backend-test'));
|
||||
|
||||
await screen.findByText(/Failed/);
|
||||
expect(authMocks.exchangeApiKey).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId('remote-backend-key')).toHaveValue('');
|
||||
expect(localStorage.getItem('ov_api_key')).toBeNull();
|
||||
});
|
||||
|
||||
it('blocks save and reload when credential exchange fails', async () => {
|
||||
askConfirm.mockResolvedValue(true);
|
||||
authMocks.exchangeApiKey.mockRejectedValueOnce(
|
||||
Object.assign(new Error('generic'), { status: 401 }),
|
||||
);
|
||||
render(<RemoteBackendPanel reload={reload} />);
|
||||
setUrl('http://gpu-box:3900');
|
||||
setKey('master-secret');
|
||||
|
||||
clickSave();
|
||||
|
||||
await screen.findByText(/HTTP 401/);
|
||||
expect(localStorage.getItem('ov_backend_url')).toBeNull();
|
||||
expect(localStorage.getItem('ov_api_key')).toBeNull();
|
||||
expect(screen.getByTestId('remote-backend-key')).toHaveValue('');
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not exchange the same credential twice after Test succeeds', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue(healthResponse('0.4.2'));
|
||||
render(<RemoteBackendPanel reload={reload} />);
|
||||
setUrl('http://gpu-box:3900');
|
||||
setKey('master-secret');
|
||||
fireEvent.click(screen.getByTestId('remote-backend-test'));
|
||||
await screen.findByText('OK — 0.4.2 on cuda');
|
||||
|
||||
clickSave();
|
||||
await waitFor(() => expect(reload).toHaveBeenCalledOnce());
|
||||
expect(authMocks.exchangeApiKey).toHaveBeenCalledOnce();
|
||||
expect(askConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('classifies a wrong 7443 service and succeeds when retried with the HTTP API', async () => {
|
||||
global.fetch = vi
|
||||
.fn()
|
||||
@@ -198,37 +113,14 @@ describe('RemoteBackendPanel', () => {
|
||||
expect(global.fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('offers an explicit disable action that clears the remote URL and key', async () => {
|
||||
it('offers an explicit disable action that clears the remote URL and key', () => {
|
||||
localStorage.setItem('ov_backend_url', 'http://old-box:3900');
|
||||
localStorage.setItem('ov_api_key', 'secret');
|
||||
render(<RemoteBackendPanel reload={reload} />);
|
||||
fireEvent.click(screen.getByTestId('remote-backend-disable'));
|
||||
await waitFor(() => expect(reload).toHaveBeenCalledOnce());
|
||||
expect(localStorage.getItem('ov_backend_url')).toBeNull();
|
||||
expect(localStorage.getItem('ov_api_key')).toBeNull();
|
||||
expect(authMocks.clearAdminSession).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears a restored session before switching targets without a new key', async () => {
|
||||
localStorage.setItem('ov_backend_url', 'http://old-box:3900');
|
||||
sessionStorage.setItem(
|
||||
'ov_admin_session',
|
||||
JSON.stringify({
|
||||
token: `ovs_admin_session_${'S'.repeat(43)}`,
|
||||
expiresAt: Date.now() / 1000 + 3600,
|
||||
apiBase: 'http://old-box:3900',
|
||||
}),
|
||||
);
|
||||
askConfirm.mockResolvedValue(true);
|
||||
render(<RemoteBackendPanel reload={reload} />);
|
||||
setUrl('http://new-box:3900');
|
||||
|
||||
clickSave();
|
||||
|
||||
await waitFor(() => expect(reload).toHaveBeenCalledOnce());
|
||||
expect(authMocks.exchangeApiKey).not.toHaveBeenCalled();
|
||||
expect(authMocks.clearAdminSession).toHaveBeenCalledOnce();
|
||||
expect(localStorage.getItem('ov_backend_url')).toBe('http://new-box:3900');
|
||||
expect(reload).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('clears both settings and reloads without confirmation when the URL is emptied', async () => {
|
||||
@@ -236,20 +128,13 @@ describe('RemoteBackendPanel', () => {
|
||||
localStorage.setItem('ov_api_key', 'k');
|
||||
render(<RemoteBackendPanel reload={reload} />);
|
||||
setUrl('');
|
||||
setKey('');
|
||||
fireEvent.change(screen.getByTestId('remote-backend-key'), { target: { value: '' } });
|
||||
clickSave();
|
||||
|
||||
await waitFor(() => expect(reload).toHaveBeenCalled());
|
||||
expect(askConfirm).not.toHaveBeenCalled();
|
||||
expect(localStorage.getItem('ov_backend_url')).toBeNull();
|
||||
expect(localStorage.getItem('ov_api_key')).toBeNull();
|
||||
expect(authMocks.clearAdminSession).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('never pre-fills the master credential from legacy localStorage', () => {
|
||||
localStorage.setItem('ov_api_key', 'legacy-master');
|
||||
render(<RemoteBackendPanel reload={reload} />);
|
||||
expect(screen.getByTestId('remote-backend-key')).toHaveValue('');
|
||||
});
|
||||
|
||||
it('renders localized strings and labelled inputs (no hardcoded-English bypass)', () => {
|
||||
|
||||
@@ -13,7 +13,6 @@ import { playBlobAudio } from '../utils/media';
|
||||
import { PRESETS } from '../utils/constants';
|
||||
import {
|
||||
instructToFormValue,
|
||||
instructToVdStates,
|
||||
mergeDescribedAttrs,
|
||||
buildDesignInstruct,
|
||||
} from '../utils/voiceInstruct';
|
||||
@@ -96,40 +95,25 @@ export default function useProfiles({ loadHistory, loadProfiles }) {
|
||||
(profile) => {
|
||||
setSelectedProfile(profile.id);
|
||||
setRefText(profile.ref_text || '');
|
||||
// Profile selection replaces the whole voice context. An Auto-language
|
||||
// profile must reset an explicit language left by the previous voice.
|
||||
setLanguage(profile.language || 'Auto');
|
||||
setInstruct(profile.instruct || '');
|
||||
if (profile.language && profile.language !== 'Auto') setLanguage(profile.language);
|
||||
// The profile's kind picks the "Define voice" method implicitly: design
|
||||
// profiles open the design controls, everything else the audio path.
|
||||
setDefineMethod(profile.kind === 'design' ? 'design' : 'audio');
|
||||
// Design profiles (0005) carry their category picks — restore the sliders
|
||||
// so selecting one makes it re-editable, not just re-usable.
|
||||
if (profile.kind === 'design') {
|
||||
// Always replace the prior recipe. Older/imported design profiles may
|
||||
// have no vd_states (or malformed JSON); their validator-safe instruct
|
||||
// still reconstructs the controls, and an empty recipe resets every
|
||||
// category to Auto instead of leaking the previously selected voice.
|
||||
let next = instructToVdStates(profile.instruct || '');
|
||||
if (profile.vd_states) {
|
||||
try {
|
||||
const parsed = JSON.parse(profile.vd_states);
|
||||
// #983: a profile saved by an older/foreign client (or hand-edited)
|
||||
// can carry a partial shape — mergeDescribedAttrs guarantees every
|
||||
// CATEGORIES key is present and validates every value.
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
next = mergeDescribedAttrs({ ...next, ...parsed });
|
||||
}
|
||||
} catch {
|
||||
/* instruct-derived fallback above is already complete */
|
||||
}
|
||||
if (profile.kind === 'design' && profile.vd_states) {
|
||||
try {
|
||||
const parsed = JSON.parse(profile.vd_states);
|
||||
// #983: a profile saved by an older/foreign client (or hand-edited)
|
||||
// can carry a partial shape — mergeDescribedAttrs (already used for
|
||||
// the "describe your voice" restore path) guarantees every
|
||||
// CATEGORIES key is present, defaulting missing/unknown ones to
|
||||
// 'Auto', so DesignMethodPanel never sees an undefined category.
|
||||
if (parsed && typeof parsed === 'object') setVdStates(mergeDescribedAttrs(parsed));
|
||||
} catch {
|
||||
/* malformed stored state — sliders keep their current values */
|
||||
}
|
||||
setVdStates(next);
|
||||
// The profile recipe already lives in the sliders. Mirroring it into
|
||||
// free text makes buildDesignInstruct report every token as a duplicate
|
||||
// and can resurrect stale prose from older profiles.
|
||||
setInstruct('');
|
||||
} else {
|
||||
setInstruct(profile.instruct || '');
|
||||
}
|
||||
},
|
||||
[setRefText, setInstruct, setLanguage, setVdStates, setDefineMethod],
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const store = vi.hoisted(() => ({
|
||||
setRefText: vi.fn(),
|
||||
setInstruct: vi.fn(),
|
||||
setLanguage: vi.fn(),
|
||||
setVdStates: vi.fn(),
|
||||
setDefineMethod: vi.fn(),
|
||||
language: 'Auto',
|
||||
mode: 'studio',
|
||||
steps: 16,
|
||||
cfg: 2,
|
||||
dubLang: 'English',
|
||||
dubSegments: [],
|
||||
text: '',
|
||||
}));
|
||||
|
||||
vi.mock('../store', () => ({ useAppStore: (selector) => selector(store) }));
|
||||
|
||||
import useProfiles from './useProfiles';
|
||||
|
||||
const allAuto = {
|
||||
Gender: 'Auto',
|
||||
Age: 'Auto',
|
||||
Pitch: 'Auto',
|
||||
Style: 'Auto',
|
||||
EnglishAccent: 'Auto',
|
||||
ChineseDialect: 'Auto',
|
||||
};
|
||||
|
||||
describe('useProfiles design-profile selection', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('reconstructs missing vd_states from instruct instead of retaining stale sliders', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useProfiles({ loadHistory: vi.fn(), loadProfiles: vi.fn() }),
|
||||
);
|
||||
|
||||
act(() =>
|
||||
result.current.handleSelectProfile({
|
||||
id: 'legacy-design',
|
||||
kind: 'design',
|
||||
instruct: 'female, young adult, high pitch',
|
||||
vd_states: null,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(store.setVdStates).toHaveBeenCalledWith({
|
||||
...allAuto,
|
||||
Gender: 'female',
|
||||
Age: 'young adult',
|
||||
Pitch: 'high pitch',
|
||||
});
|
||||
expect(store.setInstruct).toHaveBeenCalledWith('');
|
||||
expect(store.setDefineMethod).toHaveBeenCalledWith('design');
|
||||
});
|
||||
|
||||
it('resets to a complete Auto recipe when legacy design metadata is unusable', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useProfiles({ loadHistory: vi.fn(), loadProfiles: vi.fn() }),
|
||||
);
|
||||
|
||||
act(() =>
|
||||
result.current.handleSelectProfile({
|
||||
id: 'broken-design',
|
||||
kind: 'design',
|
||||
instruct: 'unsupported prose',
|
||||
vd_states: '{broken',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(store.setVdStates).toHaveBeenCalledWith(allAuto);
|
||||
expect(store.setLanguage).toHaveBeenCalledWith('Auto');
|
||||
});
|
||||
|
||||
it('fills a partial stored recipe from the validated instruct', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useProfiles({ loadHistory: vi.fn(), loadProfiles: vi.fn() }),
|
||||
);
|
||||
|
||||
act(() =>
|
||||
result.current.handleSelectProfile({
|
||||
id: 'partial-design',
|
||||
kind: 'design',
|
||||
instruct: 'female, high pitch, american accent',
|
||||
vd_states: JSON.stringify({ Gender: 'male' }),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(store.setVdStates).toHaveBeenCalledWith({
|
||||
...allAuto,
|
||||
Gender: 'male',
|
||||
Pitch: 'high pitch',
|
||||
EnglishAccent: 'american accent',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -12,17 +12,18 @@
|
||||
* { kind: "ping" } // keepalive, ignored
|
||||
*/
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { API, apiUrl } from '../api/client';
|
||||
import { authenticatedWsUrl } from '../api/authSession';
|
||||
import { wsUrl, apiUrl } from '../api/client';
|
||||
|
||||
const WS_EVENTS_URL = wsUrl('/ws/events');
|
||||
|
||||
// HTTP health-check URL (derived from same base as WS). We poll this before
|
||||
// creating the WebSocket so the first attempt doesn't fail with ECONNREFUSED
|
||||
// when the Python backend hasn't finished starting Uvicorn (~14s on cold start).
|
||||
//
|
||||
// Must be the auth-exempt liveness endpoint /health (in backend _SHELL_PATHS),
|
||||
// NOT /model/status: this is a raw fetch() that does NOT carry the LAN PIN or
|
||||
// short-lived administrator session apiFetch attaches. In LAN-share / remote
|
||||
// mode a gated path returns 401, which would reject this probe forever and the
|
||||
// NOT /model/status: this is a raw fetch() that does NOT carry the LAN PIN /
|
||||
// remote API-key headers apiFetch attaches. In LAN-share / remote-API mode a
|
||||
// gated path returns 401, which would reject this probe forever and the
|
||||
// WebSocket would never open. /health is exempt from both gates and returns
|
||||
// 200 as soon as Uvicorn is up — exactly the liveness signal this probe needs.
|
||||
const HEALTH_CHECK_URL = apiUrl('/health');
|
||||
@@ -40,9 +41,6 @@ export default function useRealtimeEvents(handlers) {
|
||||
const reconnectTimerRef = useRef(null);
|
||||
const retryCountRef = useRef(0);
|
||||
const mountedRef = useRef(true);
|
||||
const connectingRef = useRef(false);
|
||||
const connectRef = useRef(() => {});
|
||||
const openWebSocketRef = useRef(async () => {});
|
||||
|
||||
// Keep handlers ref current without causing reconnects
|
||||
useEffect(() => {
|
||||
@@ -53,13 +51,13 @@ export default function useRealtimeEvents(handlers) {
|
||||
if (!mountedRef.current) return;
|
||||
const delay = Math.min(2000 * Math.pow(2, retryCountRef.current), 60_000);
|
||||
retryCountRef.current++;
|
||||
reconnectTimerRef.current = setTimeout(() => connectRef.current(), delay);
|
||||
reconnectTimerRef.current = setTimeout(connect, delay);
|
||||
}, []);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (!mountedRef.current) return;
|
||||
// Don't double-connect
|
||||
if (connectingRef.current || (wsRef.current && wsRef.current.readyState <= 1)) return;
|
||||
if (wsRef.current && wsRef.current.readyState <= 1) return;
|
||||
|
||||
// ── Phase 1: Wait for backend HTTP to be reachable ────────────────
|
||||
// Background: the Python backend takes ~14s to import torch/fastapi/etc
|
||||
@@ -71,10 +69,9 @@ export default function useRealtimeEvents(handlers) {
|
||||
if (!res.ok) throw new Error(`health check returned ${res.status}`);
|
||||
// Backend is up — proceed to Phase 2
|
||||
if (!mountedRef.current) return;
|
||||
if (connectingRef.current || (wsRef.current && wsRef.current.readyState <= 1)) return;
|
||||
if (wsRef.current && wsRef.current.readyState <= 1) return;
|
||||
retryCountRef.current = 0; // reset backoff — health passed
|
||||
connectingRef.current = true;
|
||||
void openWebSocketRef.current();
|
||||
openWebSocket();
|
||||
})
|
||||
.catch(() => {
|
||||
// Backend not ready yet — schedule reconnect (no error log)
|
||||
@@ -82,17 +79,11 @@ export default function useRealtimeEvents(handlers) {
|
||||
});
|
||||
}, [scheduleReconnect]);
|
||||
|
||||
async function openWebSocket() {
|
||||
function openWebSocket() {
|
||||
if (!mountedRef.current) return;
|
||||
|
||||
try {
|
||||
// Browser WebSockets cannot set Authorization. Cross-origin clients mint
|
||||
// a fresh, path-bound, one-use ticket for every connection attempt;
|
||||
// same-origin cookie and loopback clients receive a credential-free URL.
|
||||
const endpoint = await authenticatedWsUrl('/ws/events', { apiBase: API });
|
||||
if (!mountedRef.current) return;
|
||||
if (wsRef.current && wsRef.current.readyState <= 1) return;
|
||||
const ws = new WebSocket(endpoint);
|
||||
const ws = new WebSocket(WS_EVENTS_URL);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
@@ -132,35 +123,27 @@ export default function useRealtimeEvents(handlers) {
|
||||
if (retryCountRef.current <= 5) {
|
||||
console.debug(`[ws/events] closed (code=${e.code}), reconnecting in ${delay}ms`);
|
||||
}
|
||||
reconnectTimerRef.current = setTimeout(() => connectRef.current(), delay);
|
||||
reconnectTimerRef.current = setTimeout(connect, delay);
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
// onerror is always followed by onclose, so we just let onclose handle reconnect
|
||||
ws.close();
|
||||
};
|
||||
} catch {
|
||||
// Authentication and transport failures may wrap request metadata. Keep
|
||||
// logs useful without ever serializing a credential-bearing exception.
|
||||
console.warn('[ws/events] connection failed');
|
||||
} catch (err) {
|
||||
console.warn('[ws/events] connection failed:', err);
|
||||
const delay = Math.min(1000 * Math.pow(2, retryCountRef.current), 30_000);
|
||||
retryCountRef.current++;
|
||||
reconnectTimerRef.current = setTimeout(() => connectRef.current(), delay);
|
||||
} finally {
|
||||
connectingRef.current = false;
|
||||
reconnectTimerRef.current = setTimeout(connect, delay);
|
||||
}
|
||||
}
|
||||
|
||||
connectRef.current = connect;
|
||||
openWebSocketRef.current = openWebSocket;
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
connectingRef.current = false;
|
||||
if (reconnectTimerRef.current) {
|
||||
clearTimeout(reconnectTimerRef.current);
|
||||
reconnectTimerRef.current = null;
|
||||
|
||||
@@ -36,8 +36,6 @@
|
||||
"open_models": "فتح النماذج"
|
||||
},
|
||||
"settings": {
|
||||
"remote_backend_test": "اختبار الاتصال",
|
||||
"remote_backend_save": "حفظ وإعادة التحميل",
|
||||
"remote_backend_recovery_title": "تعذّر الوصول إلى الواجهة الخلفية البعيدة",
|
||||
"remote_backend_recovery_hint": "يمكنك تغيير الواجهة الخلفية البعيدة لاحقًا من الإعدادات ← المشاركة.",
|
||||
"remote_backend_use_local": "استخدام الواجهة الخلفية المحلية",
|
||||
@@ -60,8 +58,6 @@
|
||||
"credentials": "بيانات الاعتماد",
|
||||
"llm_providers": "مقدمو خدمات LLM",
|
||||
"llmp_desc": "يشغّل ترجمة Cinematic وAutofit — يعيد نموذج اللغة (LLM) صياغة كل سطر ليناسب الوقت المتاح لمقطعه حتى يبقى توقيت الفيديو متوافقًا. تُخزَّن المفاتيح مشفّرة؛ ويعمل مقدمو الخدمة المحليون (Ollama/LM Studio) دون اتصال بالإنترنت تمامًا.",
|
||||
"llmp_catalogue_note": "المقدم المعلَّم كنشط هو من يستجيب عندما تكون عائلة محركات LLM في الكتالوج مضبوطة على \"متوافق مع OpenAI\".",
|
||||
"llmp_catalogue_link": "افتح عائلة LLM في الكتالوج",
|
||||
"llmp_provider": "مقدم الخدمة",
|
||||
"llmp_provider_hint": "اختر مقدم خدمة لإعداده. يُستخدم النشط لترجمة Cinematic/Autofit. لا يحتاج المقدمون المحليون إلى مفتاح، لكن يجب أن يكون خادمهم قيد التشغيل.",
|
||||
"llmp_local_tag": "محلي",
|
||||
@@ -286,36 +282,7 @@
|
||||
"models_dir_effective": "المستخدم الآن",
|
||||
"models_dir_configured": "المُعَدّ",
|
||||
"models_dir_default": "استخدام الإعداد الافتراضي",
|
||||
"models_dir_restart": "↻ أعد تشغيل VoiceStudio لاستخدام الموقع الجديد.",
|
||||
"worker_join": "انضمام",
|
||||
"worker_join_code": "رمز الانضمام",
|
||||
"worker_join_code_hint": "يُستخدم مرة واحدة وتنتهي صلاحيته خلال 15 دقيقة. أنشئه على الجهاز الذي سيرسل العمل.",
|
||||
"worker_join_desc": "اسمح لنسخة أخرى من VoiceStudio بإرسال المهام إلى هذا الجهاز. الصق رمز الانضمام الذي عرضته — أو امسح رمز QR الخاص بها بهاتفك والصقه هنا.",
|
||||
"worker_join_env": "المتغير OMNIVOICE_WORKER_MODE مضبوط في بيئة هذا الجهاز، وهو من يقرر — غيّره هناك.",
|
||||
"worker_join_no_endpoint": "لا توجد جهة تحكم محفوظة.",
|
||||
"worker_join_ok": "تم الانضمام. هذا الجهاز يستقبل العمل الآن.",
|
||||
"worker_join_placeholder": "ovw_…",
|
||||
"worker_join_rejoin": "الانضمام إلى جهاز آخر",
|
||||
"worker_join_stopped": "متوقف",
|
||||
"worker_join_take_work": "استقبال العمل من",
|
||||
"worker_join_title": "إعارة وحدة معالجة الرسومات في هذا الجهاز",
|
||||
"worker_join_working": "يعمل",
|
||||
"workers_add_hint_qr": "أنشئ رمزًا، ثم امسح رمز QR من الجهاز الآخر أو الصق الرمز في إعدادات العاملين البعيدين هناك.",
|
||||
"workers_approve": "موافقة",
|
||||
"workers_last_seen": "آخر ظهور {{when}}",
|
||||
"workers_qr_alt": "رمز QR يحمل هذا الرمز — امسحه من الجهاز الآخر",
|
||||
"workers_secret_done": "تم",
|
||||
"workers_seen_hr": "قبل {{count}} ساعة",
|
||||
"workers_seen_min": "قبل {{count}} دقيقة",
|
||||
"workers_seen_now": "الآن",
|
||||
"workers_step_1": "ثبّت VoiceStudio على الجهاز الذي يحتوي وحدة معالجة الرسومات.",
|
||||
"workers_step_2": "أنشئ رمزًا في الأعلى.",
|
||||
"workers_step_3": "امسح رمز QR هناك، أو الصق الرمز في إعدادات العاملين البعيدين لديه.",
|
||||
"workers_summary_none": "لا أحد متصل",
|
||||
"workers_summary_online": "{{count}} متصل",
|
||||
"workers_token_expired": "انتهت الصلاحية — أنشئ رمزًا جديدًا",
|
||||
"workers_token_expires_in": "تنتهي الصلاحية خلال {{time}}",
|
||||
"workers_token_qr_hint": "على الجهاز الآخر: الإعدادات ← النظام ← العاملون البعيدون ← انضمام، ثم امسح أو الصق."
|
||||
"models_dir_restart": "↻ أعد تشغيل VoiceStudio لاستخدام الموقع الجديد."
|
||||
},
|
||||
"bootstrap": {
|
||||
"title": "VoiceStudio",
|
||||
@@ -581,15 +548,6 @@
|
||||
"define_from_audio": "من الصوت",
|
||||
"define_voice": "تحديد الصوت",
|
||||
"save_design_as_profile": "حفظ التصميم كملف تعريف صوتي",
|
||||
"generating_done_status": "انتهى التوليد",
|
||||
"generating_status": "جارٍ توليد الصوت…",
|
||||
"identity": "الهوية",
|
||||
"identity_auto": "تلقائي — النموذج يقرر",
|
||||
"insert": "إدراج",
|
||||
"insert_token": "إدراج رمز تعبير",
|
||||
"script": "النص",
|
||||
"starting_points": "نقاط البداية",
|
||||
"voice_kicker": "الصوت",
|
||||
"seed_label": "بذرة",
|
||||
"seed_placeholder": "عشوائية في كل مرة",
|
||||
"seed_keep": "احتفظ بهذه البذرة",
|
||||
@@ -710,7 +668,6 @@
|
||||
"ready": "جاهز",
|
||||
"unavailable": "غير متاح",
|
||||
"use": "استخدم",
|
||||
"configureProviders": "إعداد مقدمي الخدمة",
|
||||
"loading": "جارٍ تحميل المحركات…",
|
||||
"refresh": "تحديث",
|
||||
"matrixTitle": "مصفوفة توافق المحرك",
|
||||
@@ -720,9 +677,7 @@
|
||||
"activeEngine": "نشط {{family}}: {{engine}}",
|
||||
"engineCompatLabel": "{{family}} توافق المحرك",
|
||||
"active": "نشط",
|
||||
"whyUnavailable": "ما الذي يحتاجه",
|
||||
"sectionReady": "جاهزة للاستخدام",
|
||||
"sectionMore": "أضف المزيد من المحركات",
|
||||
"whyUnavailable": "لماذا غير متوفر؟",
|
||||
"lastError": "الخطأ الأخير: {{error}}",
|
||||
"installedAndReady": "مثبتة وجاهزة",
|
||||
"notInstalled": "غير مثبت",
|
||||
@@ -1335,9 +1290,6 @@
|
||||
"reset": "إعادة تعيين",
|
||||
"preview": "معاينة",
|
||||
"use_voice": "استخدم الصوت",
|
||||
"more_actions": "المزيد من الإجراءات",
|
||||
"use_in_stories": "استخدمه في القصص",
|
||||
"set_audiobook_default": "تعيين كصوت افتراضي للكتاب المسموع",
|
||||
"open_designer": "فتح في المصمم",
|
||||
"no_matches": "لا توجد أصوات تتطابق مع هذه المرشحات.",
|
||||
"load_more": "تحميل المزيد",
|
||||
@@ -1636,9 +1588,7 @@
|
||||
"searchIssues": "البحث عن مشكلات مشابهة",
|
||||
"unexpected": "خطأ غير متوقع: {{message}}",
|
||||
"backend_shutting_down": "يجري إغلاق VoiceStudio. أعد فتح التطبيق وحاول مرة أخرى.",
|
||||
"crash_broken_env": "توقّف أثناء تحميل اعتمادات Python الخاصة به، فالمشكلة ليست في الذاكرة ولا في كرت الرسوميات — البيئة ناقصة أو بقيت نصف محدَّثة. استخدم «تنظيف وإعادة المحاولة» في الإعدادات ← السجلات ← الخادم الخلفي، فهو يعيد بناءها من الصفر ويصلحها في مكانها دون المساس بأصواتك أو مشاريعك. إذا استمر الفشل، فإن تفاصيل الانهيار تذكر اسم الحزمة التي تعذّر استيرادها.",
|
||||
"crash_vram_default": "على وحدات معالجة الرسومات الأصغر، السبب المعتاد هو نفاد ذاكرة VRAM أثناء تحميل نموذج ASR فوق نموذج TTS: أفرغ نموذج TTS أولاً، أو اختر نموذج ASR أصغر من كتالوج النماذج ← النماذج.",
|
||||
"stream_cut_backend_alive": "انتهى البث مبكرًا، لكن الخادم الخلفي ما يزال يعمل — أي أنه لم ينهر. في بيئة مقدَّمة عبر خادم أو حاويات، يكون السبب عادةً وكيلًا عكسيًا أو موزع حمل يخزّن الاتصال مؤقتًا أو ينهي مهلته: عطّل التخزين المؤقت للاستجابة على هذا المسار (nginx: proxy_buffering off; X-Accel-Buffering: no) وارفع مهلة القراءة لديه. تشغيل تطبيق سطح المكتب مباشرة، أو على localhost دون وكيل، سيؤكد ذلك."
|
||||
"crash_broken_env": "توقّف أثناء تحميل اعتمادات Python الخاصة به، فالمشكلة ليست في الذاكرة ولا في كرت الرسوميات — البيئة ناقصة أو بقيت نصف محدَّثة. استخدم «Clean & Retry» في الإعدادات ← السجلات ← الخادم الخلفي، فهو يعيد بناءها من الصفر ويصلحها في مكانها دون المساس بأصواتك أو مشاريعك. إذا استمر الفشل، فإن تفاصيل الانهيار تذكر اسم الحزمة التي تعذّر استيرادها."
|
||||
},
|
||||
"keyboard": {
|
||||
"title": "اختصارات لوحة المفاتيح",
|
||||
@@ -1646,8 +1596,6 @@
|
||||
"or": "أو",
|
||||
"nav": "الملاحة",
|
||||
"nav_cheatsheet": "عرض ورقة الغش هذه",
|
||||
"nav_enginePickerKey": "Cmd/Ctrl+E",
|
||||
"nav_workspacesKey": "Cmd/Ctrl+1–9",
|
||||
"nav_closeModal": "إغلاق مشروط / إلغاء",
|
||||
"nav_save": "حفظ المشروع/الالتزام بالقطع",
|
||||
"segmentEditor": "محرر المقطع",
|
||||
@@ -2583,22 +2531,5 @@
|
||||
"longform": "سرد القصص",
|
||||
"asr": "التفريغ النصي"
|
||||
}
|
||||
},
|
||||
"compute": {
|
||||
"add_machine": "إضافة جهاز",
|
||||
"manage": "إعدادات العاملين البعيدين",
|
||||
"off_hint": "كل شيء يعمل على هذا الجهاز. فعّل «بعيد» لاستخدام جهاز آخر.",
|
||||
"quick_settings": "الحوسبة — أين تُنفَّذ المهام",
|
||||
"remote": "بعيد",
|
||||
"title": "أين تُنفَّذ المهام",
|
||||
"token_once": "امسحه ضوئيًا أو الصقه على الجهاز الآخر. يُعرض مرة واحدة فقط."
|
||||
},
|
||||
"voices": {
|
||||
"active": "الصوت النشط",
|
||||
"active_clone_recipe": "مستنسخ من مقطعك المرجعي",
|
||||
"cta_clone": "أسقط مقطعًا مدته 3 ثوانٍ في «صوت» ← لاستنساخ صوت",
|
||||
"cta_design": "صِف صوتًا في «صوت» ← لتصميمه",
|
||||
"new": "صوت جديد",
|
||||
"none_selected": "لم يُحدد أي صوت — صِف واحدًا، أو أسقط ملفًا صوتيًا، أو اختر من الأسفل."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,8 +36,6 @@
|
||||
"open_models": "Modelle öffnen"
|
||||
},
|
||||
"settings": {
|
||||
"remote_backend_test": "Verbindung testen",
|
||||
"remote_backend_save": "Speichern und neu laden",
|
||||
"remote_backend_recovery_title": "Remote-Backend nicht erreichbar",
|
||||
"remote_backend_recovery_hint": "Du kannst das Remote-Backend später unter Einstellungen → Freigabe ändern.",
|
||||
"remote_backend_use_local": "Lokales Backend verwenden",
|
||||
@@ -60,8 +58,6 @@
|
||||
"credentials": "Anmeldedaten",
|
||||
"llm_providers": "LLM-Anbieter",
|
||||
"llmp_desc": "Treibt die Cinematic- und Autofit-Übersetzung an — das LLM formuliert jede Zeile so um, dass sie ins Zeitbudget ihres Segments passt und das Video-Timing erhalten bleibt. Schlüssel werden verschlüsselt gespeichert; lokale Anbieter (Ollama/LM Studio) bleiben vollständig offline.",
|
||||
"llmp_catalogue_note": "Der als aktiv markierte Anbieter antwortet, wann immer die LLM-Engine-Familie im Katalog auf „OpenAI-kompatibel“ steht.",
|
||||
"llmp_catalogue_link": "LLM-Familie im Katalog öffnen",
|
||||
"llmp_provider": "Anbieter",
|
||||
"llmp_provider_hint": "Anbieter zum Konfigurieren auswählen. Der aktive wird für die Cinematic/Autofit-Übersetzung verwendet. Lokale Anbieter benötigen keinen Schlüssel, ihr Server muss jedoch laufen.",
|
||||
"llmp_local_tag": "lokal",
|
||||
@@ -286,36 +282,7 @@
|
||||
"models_dir_effective": "Derzeit verwendet",
|
||||
"models_dir_configured": "Konfiguriert",
|
||||
"models_dir_default": "Standard wird verwendet",
|
||||
"models_dir_restart": "↻ Starte VoiceStudio neu, um den neuen Speicherort zu verwenden.",
|
||||
"worker_join": "Beitreten",
|
||||
"worker_join_code": "Beitrittscode",
|
||||
"worker_join_code_hint": "Nur einmal gültig, läuft in 15 Minuten ab. Erzeugen Sie ihn auf dem Rechner, der die Aufträge senden wird.",
|
||||
"worker_join_desc": "Lassen Sie eine andere VoiceStudio-Instanz Aufträge an diesen Rechner senden. Fügen Sie den dort angezeigten Beitrittscode ein — oder scannen Sie den QR-Code mit Ihrem Handy und fügen Sie ihn hier ein.",
|
||||
"worker_join_env": "OMNIVOICE_WORKER_MODE ist in der Umgebung dieses Rechners gesetzt und hat daher Vorrang — ändern Sie es dort.",
|
||||
"worker_join_no_endpoint": "Keine Control Plane gespeichert.",
|
||||
"worker_join_ok": "Beigetreten. Dieser Rechner nimmt jetzt Aufträge an.",
|
||||
"worker_join_placeholder": "ovw_…",
|
||||
"worker_join_rejoin": "Einem anderen beitreten",
|
||||
"worker_join_stopped": "Gestoppt",
|
||||
"worker_join_take_work": "Aufträge annehmen von",
|
||||
"worker_join_title": "Die GPU dieses Rechners verleihen",
|
||||
"worker_join_working": "Arbeitet",
|
||||
"workers_add_hint_qr": "Erzeugen Sie ein Token, scannen Sie dann den QR-Code vom anderen Rechner oder fügen Sie den Code dort in den Remote-Worker-Einstellungen ein.",
|
||||
"workers_approve": "Freigeben",
|
||||
"workers_last_seen": "zuletzt gesehen {{when}}",
|
||||
"workers_qr_alt": "QR-Code mit diesem Code — vom anderen Rechner scannen",
|
||||
"workers_secret_done": "Fertig",
|
||||
"workers_seen_hr": "vor {{count}} h",
|
||||
"workers_seen_min": "vor {{count}} min",
|
||||
"workers_seen_now": "gerade eben",
|
||||
"workers_step_1": "Installieren Sie VoiceStudio auf dem Rechner mit der GPU.",
|
||||
"workers_step_2": "Erzeugen Sie oben ein Token.",
|
||||
"workers_step_3": "Scannen Sie dort den QR-Code oder fügen Sie den Code in die dortigen Remote-Worker-Einstellungen ein.",
|
||||
"workers_summary_none": "Niemand verbunden",
|
||||
"workers_summary_online": "{{count}} online",
|
||||
"workers_token_expired": "Abgelaufen — erzeugen Sie ein neues",
|
||||
"workers_token_expires_in": "Läuft ab in {{time}}",
|
||||
"workers_token_qr_hint": "Auf dem anderen Rechner: Einstellungen → System → Remote-Worker → Beitreten, dann scannen oder einfügen."
|
||||
"models_dir_restart": "↻ Starte VoiceStudio neu, um den neuen Speicherort zu verwenden."
|
||||
},
|
||||
"bootstrap": {
|
||||
"title": "VoiceStudio",
|
||||
@@ -581,15 +548,6 @@
|
||||
"define_from_audio": "Aus Audio",
|
||||
"define_voice": "Stimme definieren",
|
||||
"save_design_as_profile": "Design als Profil speichern",
|
||||
"generating_done_status": "Generierung abgeschlossen",
|
||||
"generating_status": "Audio wird generiert…",
|
||||
"identity": "Identität",
|
||||
"identity_auto": "Auto — das Modell entscheidet",
|
||||
"insert": "Einfügen",
|
||||
"insert_token": "Ausdrucks-Token einfügen",
|
||||
"script": "Skript",
|
||||
"starting_points": "Ausgangspunkte",
|
||||
"voice_kicker": "Stimme",
|
||||
"seed_label": "Samen",
|
||||
"seed_placeholder": "jedes Mal zufällig",
|
||||
"seed_keep": "Behalte diesen Samen",
|
||||
@@ -710,7 +668,6 @@
|
||||
"ready": "fertig",
|
||||
"unavailable": "nicht verfügbar",
|
||||
"use": "Benutzen",
|
||||
"configureProviders": "Anbieter konfigurieren",
|
||||
"loading": "Motoren werden geladen…",
|
||||
"refresh": "Aktualisieren",
|
||||
"matrixTitle": "Engine-Kompatibilitätsmatrix",
|
||||
@@ -720,9 +677,7 @@
|
||||
"activeEngine": "Aktiv {{family}}: {{engine}}",
|
||||
"engineCompatLabel": "{{family}} Motorkompatibilität",
|
||||
"active": "aktiv",
|
||||
"whyUnavailable": "Was benötigt wird",
|
||||
"sectionReady": "Einsatzbereit",
|
||||
"sectionMore": "Weitere Motoren hinzufügen",
|
||||
"whyUnavailable": "Warum nicht verfügbar?",
|
||||
"lastError": "Letzter Fehler: {{error}}",
|
||||
"installedAndReady": "Installiert und fertig",
|
||||
"notInstalled": "Nicht installiert",
|
||||
@@ -1335,9 +1290,6 @@
|
||||
"reset": "Zurücksetzen",
|
||||
"preview": "Vorschau",
|
||||
"use_voice": "Benutzen Sie die Stimme",
|
||||
"more_actions": "Weitere Aktionen",
|
||||
"use_in_stories": "In Geschichten verwenden",
|
||||
"set_audiobook_default": "Als Standardstimme für Hörbücher festlegen",
|
||||
"open_designer": "Im Designer öffnen",
|
||||
"no_matches": "Keine Stimmen entsprechen diesen Filtern.",
|
||||
"load_more": "Mehr laden",
|
||||
@@ -1636,9 +1588,7 @@
|
||||
"searchIssues": "Ähnliche Probleme suchen",
|
||||
"unexpected": "Unerwarteter Fehler: {{message}}",
|
||||
"backend_shutting_down": "VoiceStudio wird beendet. Öffnen Sie die App erneut und versuchen Sie es noch einmal.",
|
||||
"crash_broken_env": "Er ist beim Laden seiner eigenen Python-Abhängigkeiten gestorben — es geht also weder um Speicher noch um Ihre GPU, sondern um eine unvollständige oder halb aktualisierte Umgebung. Nutzen Sie „Bereinigen & Wiederholen“ unter Einstellungen → Logs → Backend: Das baut sie von Grund auf neu und repariert sie an Ort und Stelle, ohne Ihre Stimmen oder Projekte anzurühren. Schlägt es danach weiter fehl, nennen die Absturzdetails das Paket, das sich nicht importieren ließ.",
|
||||
"crash_vram_default": "Auf kleineren GPUs ist die übliche Ursache, dass beim Laden des ASR-Modells zusätzlich zum TTS-Modell der VRAM ausgeht: Entladen Sie zuerst das TTS-Modell, oder wählen Sie unter Modellkatalog → Modelle ein kleineres ASR-Modell.",
|
||||
"stream_cut_backend_alive": "Der Stream endete vorzeitig, aber das Backend läuft noch — es ist also nicht abgestürzt. In einem Server- oder Container-Setup liegt das meist an einem Reverse-Proxy oder Load-Balancer, der die Verbindung puffert oder per Timeout beendet: Deaktivieren Sie das Response-Buffering für diese Route (nginx: proxy_buffering off; X-Accel-Buffering: no) und erhöhen Sie das Lese-Timeout. Wenn Sie die Desktop-App direkt oder auf localhost ohne Proxy ausführen, lässt sich das bestätigen."
|
||||
"crash_broken_env": "Er ist beim Laden seiner eigenen Python-Abhängigkeiten gestorben — es geht also weder um Speicher noch um deine GPU, sondern um eine unvollständige oder halb aktualisierte Umgebung. Nutze „Clean & Retry“ unter Einstellungen → Logs → Backend: Das baut sie von Grund auf neu und repariert sie an Ort und Stelle, ohne deine Stimmen oder Projekte anzurühren. Schlägt es danach weiter fehl, nennen die Absturzdetails das Paket, das sich nicht importieren ließ."
|
||||
},
|
||||
"keyboard": {
|
||||
"title": "Tastaturkürzel",
|
||||
@@ -1646,8 +1596,6 @@
|
||||
"or": "oder",
|
||||
"nav": "Navigation",
|
||||
"nav_cheatsheet": "Zeige diesen Spickzettel",
|
||||
"nav_enginePickerKey": "Cmd/Ctrl+E",
|
||||
"nav_workspacesKey": "Cmd/Ctrl+1–9",
|
||||
"nav_closeModal": "Modal schließen / abbrechen",
|
||||
"nav_save": "Projekt speichern / Trimmen festschreiben",
|
||||
"segmentEditor": "Segmenteditor",
|
||||
@@ -2583,22 +2531,5 @@
|
||||
"longform": "Story-Vertonung",
|
||||
"asr": "Transkription"
|
||||
}
|
||||
},
|
||||
"compute": {
|
||||
"add_machine": "Rechner hinzufügen",
|
||||
"manage": "Remote-Worker-Einstellungen",
|
||||
"off_hint": "Alles läuft auf diesem Rechner. Schalten Sie Remote ein, um einen anderen zu nutzen.",
|
||||
"quick_settings": "Rechenleistung — wo Aufträge laufen",
|
||||
"remote": "Remote",
|
||||
"title": "Wo Aufträge laufen",
|
||||
"token_once": "Auf dem anderen Rechner scannen oder einfügen. Wird nur einmal angezeigt."
|
||||
},
|
||||
"voices": {
|
||||
"active": "Aktive Stimme",
|
||||
"active_clone_recipe": "Aus Ihrem Referenzclip geklont",
|
||||
"cta_clone": "Legen Sie einen 3-Sekunden-Clip in Stimme ← ab, um eine zu klonen",
|
||||
"cta_design": "Beschreiben Sie eine in Stimme ←, um sie zu designen",
|
||||
"new": "Neue Stimme",
|
||||
"none_selected": "Keine Stimme ausgewählt — beschreiben Sie eine, legen Sie Audio ab oder wählen Sie unten eine aus."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -373,16 +373,7 @@
|
||||
"define_by_design": "By design",
|
||||
"define_from_audio": "From audio",
|
||||
"define_voice": "Define voice",
|
||||
"save_design_as_profile": "Save design as profile",
|
||||
"generating_done_status": "Generation finished",
|
||||
"generating_status": "Generating audio…",
|
||||
"identity": "Identity",
|
||||
"identity_auto": "Auto — the model decides",
|
||||
"insert": "Insert",
|
||||
"insert_token": "Insert expression token",
|
||||
"script": "Script",
|
||||
"starting_points": "Starting points",
|
||||
"voice_kicker": "Voice"
|
||||
"save_design_as_profile": "Save design as profile"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
@@ -416,8 +407,6 @@
|
||||
"credentials": "Credentials",
|
||||
"llm_providers": "LLM Providers",
|
||||
"llmp_desc": "Powers Cinematic & Autofit translation — the LLM rewrites each line to fit its segment's time budget so the video timing holds. Keys are stored encrypted; local providers (Ollama/LM Studio) stay fully offline.",
|
||||
"llmp_catalogue_note": "The provider marked active is what answers whenever the LLM engine family is set to OpenAI-compatible in the catalogue.",
|
||||
"llmp_catalogue_link": "Open the LLM family in the catalogue",
|
||||
"llmp_provider": "Provider",
|
||||
"llmp_provider_hint": "Pick a provider to configure. The active one is used for Cinematic/Autofit translation. Local providers need no key but require their server to be running.",
|
||||
"llmp_local_tag": "local",
|
||||
@@ -861,36 +850,7 @@
|
||||
"models_dir_effective": "Effective now",
|
||||
"models_dir_configured": "Configured",
|
||||
"models_dir_default": "Using default",
|
||||
"models_dir_restart": "↻ Restart VoiceStudio to use the new location.",
|
||||
"worker_join": "Join",
|
||||
"worker_join_code": "Join code",
|
||||
"worker_join_code_hint": "Single-use and expires in 15 minutes. Generate it on the machine that will send the work.",
|
||||
"worker_join_desc": "Let another copy of VoiceStudio send jobs to this machine. Paste the join code it showed you — or scan its QR with your phone and paste it here.",
|
||||
"worker_join_env": "OMNIVOICE_WORKER_MODE is set in this machine’s environment, so it decides — change it there.",
|
||||
"worker_join_no_endpoint": "No control plane remembered.",
|
||||
"worker_join_ok": "Joined. This machine is now taking work.",
|
||||
"worker_join_placeholder": "ovw_…",
|
||||
"worker_join_rejoin": "Join a different one",
|
||||
"worker_join_stopped": "Stopped",
|
||||
"worker_join_take_work": "Take work from",
|
||||
"worker_join_title": "Lend this machine's GPU",
|
||||
"worker_join_working": "Working",
|
||||
"workers_add_hint_qr": "Generate a token, then scan the QR from the other machine or paste the code into its Remote workers settings.",
|
||||
"workers_approve": "Approve",
|
||||
"workers_last_seen": "last seen {{when}}",
|
||||
"workers_qr_alt": "QR code carrying this code — scan it from the other machine",
|
||||
"workers_secret_done": "Done",
|
||||
"workers_seen_hr": "{{count}}h ago",
|
||||
"workers_seen_min": "{{count}}m ago",
|
||||
"workers_seen_now": "just now",
|
||||
"workers_step_1": "Install VoiceStudio on the machine with the GPU.",
|
||||
"workers_step_2": "Generate a token above.",
|
||||
"workers_step_3": "Scan the QR there, or paste the code into its Remote workers settings.",
|
||||
"workers_summary_none": "Nobody connected",
|
||||
"workers_summary_online": "{{count}} online",
|
||||
"workers_token_expired": "Expired — generate a new one",
|
||||
"workers_token_expires_in": "Expires in {{time}}",
|
||||
"workers_token_qr_hint": "On the other machine: Settings → System → Remote workers → Join, then scan or paste."
|
||||
"models_dir_restart": "↻ Restart VoiceStudio to use the new location."
|
||||
},
|
||||
"about": {
|
||||
"app": "App",
|
||||
@@ -1620,9 +1580,6 @@
|
||||
"reset": "Reset",
|
||||
"preview": "Preview",
|
||||
"use_voice": "Use voice",
|
||||
"more_actions": "More actions",
|
||||
"use_in_stories": "Use in Stories",
|
||||
"set_audiobook_default": "Set as Audiobook default",
|
||||
"open_designer": "Open in Designer",
|
||||
"no_matches": "No voices match these filters.",
|
||||
"load_more": "Load more",
|
||||
@@ -2000,7 +1957,6 @@
|
||||
"ready": "ready",
|
||||
"unavailable": "Unavailable",
|
||||
"use": "Use",
|
||||
"configureProviders": "Configure providers",
|
||||
"loading": "Loading engines…",
|
||||
"refresh": "Refresh",
|
||||
"matrixTitle": "Engine Compatibility Matrix",
|
||||
@@ -2015,9 +1971,7 @@
|
||||
"activeEngine": "Active {{family}}: {{engine}}",
|
||||
"engineCompatLabel": "{{family}} engine compatibility",
|
||||
"active": "active",
|
||||
"whyUnavailable": "What it needs",
|
||||
"sectionReady": "Ready to use",
|
||||
"sectionMore": "Add more engines",
|
||||
"whyUnavailable": "Why unavailable?",
|
||||
"lastError": "Last error: {{error}}",
|
||||
"installedAndReady": "Installed and ready",
|
||||
"notInstalled": "Not installed",
|
||||
@@ -2095,9 +2049,7 @@
|
||||
"searchIssues": "Search similar issues",
|
||||
"unexpected": "Unexpected error: {{message}}",
|
||||
"backend_shutting_down": "VoiceStudio is shutting down. Reopen the app and try again.",
|
||||
"crash_broken_env": "It died while loading its own Python dependencies, so this is not about memory or your GPU — the environment is incomplete or was left half-updated. Use \"Clean & Retry\" in Settings → Logs → Backend, which rebuilds it from scratch; that repairs it in place, without touching your voices or projects. If it still fails afterwards, the crash details name the exact package that would not import.",
|
||||
"crash_vram_default": "On smaller GPUs the usual cause is running out of VRAM while loading the ASR model on top of the TTS model: flush the TTS model first, or pick a smaller ASR model in Model Catalogue → Models.",
|
||||
"stream_cut_backend_alive": "The stream ended early, but the backend is still running — so it did not crash. In a served or containerised setup this is usually a reverse proxy or load balancer buffering or timing out the connection: disable response buffering for this route (nginx: proxy_buffering off; X-Accel-Buffering: no) and raise its read timeout. Running the desktop app directly, or on localhost without a proxy, will confirm it."
|
||||
"crash_broken_env": "It died while loading its own Python dependencies, so this is not about memory or your GPU — the environment is incomplete or was left half-updated. Use \"Clean & Retry\" in Settings → Logs → Backend, which rebuilds it from scratch; that repairs it in place, without touching your voices or projects. If it still fails afterwards, the crash details name the exact package that would not import."
|
||||
},
|
||||
"crash": {
|
||||
"notice": "The voice backend crashed ({{exit}}) {{ago}} ago and is being restarted automatically.",
|
||||
@@ -2181,8 +2133,6 @@
|
||||
"or": "or",
|
||||
"nav": "Navigation",
|
||||
"nav_cheatsheet": "Show this cheatsheet",
|
||||
"nav_enginePickerKey": "Cmd/Ctrl+E",
|
||||
"nav_workspacesKey": "Cmd/Ctrl+1–9",
|
||||
"nav_closeModal": "Close modal / cancel",
|
||||
"nav_save": "Save project / commit trim",
|
||||
"segmentEditor": "Segment editor",
|
||||
@@ -3086,22 +3036,5 @@
|
||||
"longform": "story narration",
|
||||
"asr": "transcription"
|
||||
}
|
||||
},
|
||||
"compute": {
|
||||
"add_machine": "Add a machine",
|
||||
"manage": "Remote worker settings",
|
||||
"off_hint": "Everything runs on this machine. Turn Remote on to use another.",
|
||||
"quick_settings": "Compute — where jobs run",
|
||||
"remote": "Remote",
|
||||
"title": "Where jobs run",
|
||||
"token_once": "Scan or paste this on the other machine. Shown once."
|
||||
},
|
||||
"voices": {
|
||||
"active": "Active voice",
|
||||
"active_clone_recipe": "Cloned from your reference clip",
|
||||
"cta_clone": "Drop a 3s clip in Voice ← to clone one",
|
||||
"cta_design": "Describe one in Voice ← to design it",
|
||||
"new": "New voice",
|
||||
"none_selected": "No voice selected — describe one, drop audio, or pick below."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,8 +36,6 @@
|
||||
"open_models": "Abrir modelos"
|
||||
},
|
||||
"settings": {
|
||||
"remote_backend_test": "Probar conexión",
|
||||
"remote_backend_save": "Guardar y recargar",
|
||||
"remote_backend_recovery_title": "No se puede acceder al backend remoto",
|
||||
"remote_backend_recovery_hint": "Puedes cambiar el backend remoto más tarde en Ajustes → Compartir.",
|
||||
"remote_backend_use_local": "Usar backend local",
|
||||
@@ -60,8 +58,6 @@
|
||||
"credentials": "Credenciales",
|
||||
"llm_providers": "Proveedores LLM",
|
||||
"llmp_desc": "Impulsa la traducción Cinematic y Autofit: el LLM reescribe cada línea para ajustarse al tiempo disponible de su segmento y mantener la sincronización del vídeo. Las claves se guardan cifradas; los proveedores locales (Ollama/LM Studio) funcionan totalmente sin conexión.",
|
||||
"llmp_catalogue_note": "El proveedor marcado como activo es el que responde siempre que la familia de motores LLM del catálogo esté en «compatible con OpenAI».",
|
||||
"llmp_catalogue_link": "Abrir la familia LLM en el catálogo",
|
||||
"llmp_provider": "Proveedor",
|
||||
"llmp_provider_hint": "Elige un proveedor para configurarlo. El activo se usa para la traducción Cinematic/Autofit. Los proveedores locales no necesitan clave, pero su servidor debe estar en ejecución.",
|
||||
"llmp_local_tag": "local",
|
||||
@@ -286,36 +282,7 @@
|
||||
"models_dir_effective": "En uso ahora",
|
||||
"models_dir_configured": "Configurada",
|
||||
"models_dir_default": "Usando la predeterminada",
|
||||
"models_dir_restart": "↻ Reinicia VoiceStudio para usar la nueva ubicación.",
|
||||
"worker_join": "Unirse",
|
||||
"worker_join_code": "Código de unión",
|
||||
"worker_join_code_hint": "De un solo uso y caduca en 15 minutos. Genéralo en el equipo que enviará el trabajo.",
|
||||
"worker_join_desc": "Permite que otra copia de VoiceStudio envíe trabajos a este equipo. Pega el código de unión que te mostró — o escanea su QR con el móvil y pégalo aquí.",
|
||||
"worker_join_env": "OMNIVOICE_WORKER_MODE está definido en el entorno de este equipo, así que decide él — cámbialo allí.",
|
||||
"worker_join_no_endpoint": "Ningún plano de control recordado.",
|
||||
"worker_join_ok": "Unido. Este equipo ya está aceptando trabajo.",
|
||||
"worker_join_placeholder": "ovw_…",
|
||||
"worker_join_rejoin": "Unirse a otro",
|
||||
"worker_join_stopped": "Detenido",
|
||||
"worker_join_take_work": "Aceptar trabajo de",
|
||||
"worker_join_title": "Prestar la GPU de este equipo",
|
||||
"worker_join_working": "Trabajando",
|
||||
"workers_add_hint_qr": "Genera un token y luego escanea el QR desde el otro equipo o pega el código en sus ajustes de Trabajadores remotos.",
|
||||
"workers_approve": "Aprobar",
|
||||
"workers_last_seen": "visto por última vez {{when}}",
|
||||
"workers_qr_alt": "Código QR con este código — escanéalo desde el otro equipo",
|
||||
"workers_secret_done": "Hecho",
|
||||
"workers_seen_hr": "hace {{count}} h",
|
||||
"workers_seen_min": "hace {{count}} min",
|
||||
"workers_seen_now": "ahora mismo",
|
||||
"workers_step_1": "Instala VoiceStudio en el equipo con la GPU.",
|
||||
"workers_step_2": "Genera un token arriba.",
|
||||
"workers_step_3": "Escanea el QR allí, o pega el código en sus ajustes de Trabajadores remotos.",
|
||||
"workers_summary_none": "Nadie conectado",
|
||||
"workers_summary_online": "{{count}} en línea",
|
||||
"workers_token_expired": "Caducado — genera uno nuevo",
|
||||
"workers_token_expires_in": "Caduca en {{time}}",
|
||||
"workers_token_qr_hint": "En el otro equipo: Ajustes → Sistema → Trabajadores remotos → Unirse, y luego escanea o pega."
|
||||
"models_dir_restart": "↻ Reinicia VoiceStudio para usar la nueva ubicación."
|
||||
},
|
||||
"bootstrap": {
|
||||
"title": "VoiceStudio",
|
||||
@@ -581,15 +548,6 @@
|
||||
"define_from_audio": "Desde audio",
|
||||
"define_voice": "Definir voz",
|
||||
"save_design_as_profile": "Guardar diseño como perfil",
|
||||
"generating_done_status": "Generación terminada",
|
||||
"generating_status": "Generando audio…",
|
||||
"identity": "Identidad",
|
||||
"identity_auto": "Auto — el modelo decide",
|
||||
"insert": "Insertar",
|
||||
"insert_token": "Insertar token de expresión",
|
||||
"script": "Guión",
|
||||
"starting_points": "Puntos de partida",
|
||||
"voice_kicker": "Voz",
|
||||
"seed_label": "semilla",
|
||||
"seed_placeholder": "aleatorio cada vez",
|
||||
"seed_keep": "Mantén esta semilla",
|
||||
@@ -710,7 +668,6 @@
|
||||
"ready": "listo",
|
||||
"unavailable": "no disponible",
|
||||
"use": "uso",
|
||||
"configureProviders": "Configurar proveedores",
|
||||
"loading": "Cargando motores…",
|
||||
"refresh": "Actualizar",
|
||||
"matrixTitle": "Matriz de compatibilidad de motores",
|
||||
@@ -720,9 +677,7 @@
|
||||
"activeEngine": "Activo {{family}}: {{engine}}",
|
||||
"engineCompatLabel": "{{family}} compatibilidad del motor",
|
||||
"active": "activo",
|
||||
"whyUnavailable": "Qué necesita",
|
||||
"sectionReady": "Listos para usar",
|
||||
"sectionMore": "Añadir más motores",
|
||||
"whyUnavailable": "¿Por qué no está disponible?",
|
||||
"lastError": "Último error: {{error}}",
|
||||
"installedAndReady": "Instalado y listo",
|
||||
"notInstalled": "No instalado",
|
||||
@@ -1335,9 +1290,6 @@
|
||||
"reset": "Reiniciar",
|
||||
"preview": "Vista previa",
|
||||
"use_voice": "usar voz",
|
||||
"more_actions": "Más acciones",
|
||||
"use_in_stories": "Usar en Historias",
|
||||
"set_audiobook_default": "Establecer como voz predeterminada para Audiolibro",
|
||||
"open_designer": "Abrir en Diseñador",
|
||||
"no_matches": "Ninguna voz coincide con estos filtros.",
|
||||
"load_more": "Cargar más",
|
||||
@@ -1636,9 +1588,7 @@
|
||||
"searchIssues": "Buscar problemas similares",
|
||||
"unexpected": "Error inesperado: {{message}}",
|
||||
"backend_shutting_down": "VoiceStudio se está cerrando. Vuelve a abrir la aplicación e inténtalo de nuevo.",
|
||||
"crash_broken_env": "Murió mientras cargaba sus propias dependencias de Python, así que no es cuestión de memoria ni de tu GPU: el entorno está incompleto o quedó a medio actualizar. Usa «Limpiar y reintentar» en Configuración → Registros → Backend, que lo reconstruye desde cero y lo repara sin tocar tus voces ni tus proyectos. Si sigue fallando, los detalles del fallo indican el paquete exacto que no se pudo importar.",
|
||||
"crash_vram_default": "En GPU más pequeñas, la causa habitual es quedarse sin VRAM al cargar el modelo ASR junto con el modelo TTS: libera primero el modelo TTS de la memoria, o elige un modelo ASR más pequeño en Catálogo de modelos → Modelos.",
|
||||
"stream_cut_backend_alive": "El stream terminó antes de tiempo, pero el backend sigue en ejecución — así que no se bloqueó. En una instalación servida o en contenedor, esto suele deberse a un proxy inverso o balanceador de carga que almacena en búfer la conexión o la corta por tiempo de espera: desactiva el almacenamiento en búfer de la respuesta para esta ruta (nginx: proxy_buffering off; X-Accel-Buffering: no) y aumenta su tiempo de espera de lectura. Ejecutar la aplicación de escritorio directamente, o en localhost sin proxy, lo confirmará."
|
||||
"crash_broken_env": "Murió mientras cargaba sus propias dependencias de Python, así que no es cuestión de memoria ni de tu GPU: el entorno está incompleto o quedó a medio actualizar. Usa «Clean & Retry» en Configuración → Registros → Backend, que lo reconstruye desde cero y lo repara sin tocar tus voces ni tus proyectos. Si sigue fallando, los detalles del fallo indican el paquete exacto que no se pudo importar."
|
||||
},
|
||||
"keyboard": {
|
||||
"title": "Atajos de teclado",
|
||||
@@ -1646,8 +1596,6 @@
|
||||
"or": "o",
|
||||
"nav": "Navegación",
|
||||
"nav_cheatsheet": "Mostrar esta hoja de trucos",
|
||||
"nav_enginePickerKey": "Cmd/Ctrl+E",
|
||||
"nav_workspacesKey": "Cmd/Ctrl+1–9",
|
||||
"nav_closeModal": "Cerrar modal/cancelar",
|
||||
"nav_save": "Guardar proyecto/comprobar recorte",
|
||||
"segmentEditor": "editor de segmentos",
|
||||
@@ -2583,22 +2531,5 @@
|
||||
"longform": "narración de historias",
|
||||
"asr": "transcripción"
|
||||
}
|
||||
},
|
||||
"compute": {
|
||||
"add_machine": "Añadir un equipo",
|
||||
"manage": "Ajustes de trabajadores remotos",
|
||||
"off_hint": "Todo se ejecuta en este equipo. Activa Remoto para usar otro.",
|
||||
"quick_settings": "Cómputo — dónde se ejecutan los trabajos",
|
||||
"remote": "Remoto",
|
||||
"title": "Dónde se ejecutan los trabajos",
|
||||
"token_once": "Escanéalo o pégalo en el otro equipo. Se muestra una sola vez."
|
||||
},
|
||||
"voices": {
|
||||
"active": "Voz activa",
|
||||
"active_clone_recipe": "Clonada a partir de tu clip de referencia",
|
||||
"cta_clone": "Suelta un clip de 3 s en Voz ← para clonar una",
|
||||
"cta_design": "Describe una en Voz ← para diseñarla",
|
||||
"new": "Nueva voz",
|
||||
"none_selected": "Ninguna voz seleccionada — describe una, suelta un audio o elige abajo."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,8 +36,6 @@
|
||||
"open_models": "Ouvrir les modèles"
|
||||
},
|
||||
"settings": {
|
||||
"remote_backend_test": "Tester la connexion",
|
||||
"remote_backend_save": "Enregistrer et recharger",
|
||||
"remote_backend_recovery_title": "Impossible de joindre le backend distant",
|
||||
"remote_backend_recovery_hint": "Vous pourrez modifier le backend distant plus tard dans Réglages → Partage.",
|
||||
"remote_backend_use_local": "Utiliser le backend local",
|
||||
@@ -60,8 +58,6 @@
|
||||
"credentials": "Identifiants",
|
||||
"llm_providers": "Fournisseurs LLM",
|
||||
"llmp_desc": "Alimente la traduction Cinematic et Autofit — le LLM reformule chaque ligne pour respecter le budget temps de son segment afin de préserver le timing de la vidéo. Les clés sont stockées chiffrées ; les fournisseurs locaux (Ollama/LM Studio) restent entièrement hors ligne.",
|
||||
"llmp_catalogue_note": "Le fournisseur marqué comme actif est celui qui répond dès que la famille de moteurs LLM du catalogue est réglée sur « compatible OpenAI ».",
|
||||
"llmp_catalogue_link": "Ouvrir la famille LLM dans le catalogue",
|
||||
"llmp_provider": "Fournisseur",
|
||||
"llmp_provider_hint": "Choisissez un fournisseur à configurer. Le fournisseur actif est utilisé pour la traduction Cinematic/Autofit. Les fournisseurs locaux ne nécessitent aucune clé, mais leur serveur doit être en cours d'exécution.",
|
||||
"llmp_local_tag": "local",
|
||||
@@ -286,36 +282,7 @@
|
||||
"models_dir_effective": "Utilisé actuellement",
|
||||
"models_dir_configured": "Configuré",
|
||||
"models_dir_default": "Valeur par défaut",
|
||||
"models_dir_restart": "↻ Redémarrez VoiceStudio pour utiliser le nouvel emplacement.",
|
||||
"worker_join": "Rejoindre",
|
||||
"worker_join_code": "Code d'association",
|
||||
"worker_join_code_hint": "À usage unique, expire dans 15 minutes. Générez-le sur la machine qui enverra les tâches.",
|
||||
"worker_join_desc": "Autorisez une autre copie de VoiceStudio à envoyer des tâches à cette machine. Collez le code d'association qu'elle vous a montré — ou scannez son QR avec votre téléphone et collez-le ici.",
|
||||
"worker_join_env": "OMNIVOICE_WORKER_MODE est défini dans l'environnement de cette machine, c'est donc lui qui décide — modifiez-le là-bas.",
|
||||
"worker_join_no_endpoint": "Aucun plan de contrôle mémorisé.",
|
||||
"worker_join_ok": "Association réussie. Cette machine accepte désormais des tâches.",
|
||||
"worker_join_placeholder": "ovw_…",
|
||||
"worker_join_rejoin": "En rejoindre une autre",
|
||||
"worker_join_stopped": "Arrêté",
|
||||
"worker_join_take_work": "Accepter des tâches de",
|
||||
"worker_join_title": "Prêter le GPU de cette machine",
|
||||
"worker_join_working": "Au travail",
|
||||
"workers_add_hint_qr": "Générez un jeton, puis scannez le QR depuis l'autre machine ou collez le code dans ses paramètres Workers distants.",
|
||||
"workers_approve": "Approuver",
|
||||
"workers_last_seen": "vu pour la dernière fois {{when}}",
|
||||
"workers_qr_alt": "Code QR contenant ce code — à scanner depuis l'autre machine",
|
||||
"workers_secret_done": "Terminé",
|
||||
"workers_seen_hr": "il y a {{count}} h",
|
||||
"workers_seen_min": "il y a {{count}} min",
|
||||
"workers_seen_now": "à l'instant",
|
||||
"workers_step_1": "Installez VoiceStudio sur la machine équipée du GPU.",
|
||||
"workers_step_2": "Générez un jeton ci-dessus.",
|
||||
"workers_step_3": "Scannez-y le QR, ou collez le code dans ses paramètres Workers distants.",
|
||||
"workers_summary_none": "Personne n'est connecté",
|
||||
"workers_summary_online": "{{count}} en ligne",
|
||||
"workers_token_expired": "Expiré — générez-en un nouveau",
|
||||
"workers_token_expires_in": "Expire dans {{time}}",
|
||||
"workers_token_qr_hint": "Sur l'autre machine : Paramètres → Système → Workers distants → Rejoindre, puis scannez ou collez."
|
||||
"models_dir_restart": "↻ Redémarrez VoiceStudio pour utiliser le nouvel emplacement."
|
||||
},
|
||||
"bootstrap": {
|
||||
"title": "VoiceStudio",
|
||||
@@ -581,15 +548,6 @@
|
||||
"define_from_audio": "À partir de l'audio",
|
||||
"define_voice": "Définir la voix",
|
||||
"save_design_as_profile": "Enregistrer la conception en tant que profil",
|
||||
"generating_done_status": "Génération terminée",
|
||||
"generating_status": "Génération de l'audio…",
|
||||
"identity": "Identité",
|
||||
"identity_auto": "Auto — le modèle décide",
|
||||
"insert": "Insérer",
|
||||
"insert_token": "Insérer un jeton d'expression",
|
||||
"script": "Scénario",
|
||||
"starting_points": "Points de départ",
|
||||
"voice_kicker": "Voix",
|
||||
"seed_label": "Semence",
|
||||
"seed_placeholder": "aléatoire à chaque fois",
|
||||
"seed_keep": "Gardez cette graine",
|
||||
@@ -710,7 +668,6 @@
|
||||
"ready": "prêt",
|
||||
"unavailable": "indisponible",
|
||||
"use": "Utiliser",
|
||||
"configureProviders": "Configurer les fournisseurs",
|
||||
"loading": "Chargement des moteurs…",
|
||||
"refresh": "Actualiser",
|
||||
"matrixTitle": "Matrice de compatibilité des moteurs",
|
||||
@@ -720,9 +677,7 @@
|
||||
"activeEngine": "Actif {{family}} : {{engine}}",
|
||||
"engineCompatLabel": "Compatibilité moteur {{family}}",
|
||||
"active": "actif",
|
||||
"whyUnavailable": "Ce qu'il lui faut",
|
||||
"sectionReady": "Prêts à l'emploi",
|
||||
"sectionMore": "Ajouter d'autres moteurs",
|
||||
"whyUnavailable": "Pourquoi indisponible ?",
|
||||
"lastError": "Dernière erreur : {{error}}",
|
||||
"installedAndReady": "Installé et prêt",
|
||||
"notInstalled": "Non installé",
|
||||
@@ -1335,9 +1290,6 @@
|
||||
"reset": "Réinitialiser",
|
||||
"preview": "Aperçu",
|
||||
"use_voice": "Utiliser la voix",
|
||||
"more_actions": "Plus d’actions",
|
||||
"use_in_stories": "Utiliser dans les Histoires",
|
||||
"set_audiobook_default": "Définir comme voix par défaut du Livre audio",
|
||||
"open_designer": "Ouvrir dans le Concepteur",
|
||||
"no_matches": "Aucune voix ne correspond à ces filtres.",
|
||||
"load_more": "Charger plus",
|
||||
@@ -1636,9 +1588,7 @@
|
||||
"searchIssues": "Rechercher des problèmes similaires",
|
||||
"unexpected": "Erreur inattendue : {{message}}",
|
||||
"backend_shutting_down": "VoiceStudio est en cours de fermeture. Rouvrez l’application et réessayez.",
|
||||
"crash_broken_env": "Il est mort en chargeant ses propres dépendances Python : ce n'est donc ni la mémoire ni votre GPU, mais un environnement incomplet ou à moitié mis à jour. Utilisez « Nettoyer et réessayer » dans Paramètres → Journaux → Backend, qui le reconstruit de zéro et le répare sur place, sans toucher à vos voix ni à vos projets. Si l'échec persiste, les détails du plantage nomment le paquet qui refusait de s'importer.",
|
||||
"crash_vram_default": "Sur les GPU plus modestes, la cause habituelle est un manque de VRAM lors du chargement du modèle ASR en plus du modèle TTS : déchargez d'abord le modèle TTS, ou choisissez un modèle ASR plus petit dans Catalogue de modèles → Modèles.",
|
||||
"stream_cut_backend_alive": "Le flux s'est terminé prématurément, mais le backend tourne toujours — il n'a donc pas planté. Dans une installation servie ou conteneurisée, c'est généralement un reverse proxy ou un répartiteur de charge qui met la connexion en tampon ou la coupe par timeout : désactivez la mise en tampon des réponses pour cette route (nginx: proxy_buffering off; X-Accel-Buffering: no) et augmentez son délai de lecture. Lancer l'application de bureau directement, ou sur localhost sans proxy, permettra de le confirmer."
|
||||
"crash_broken_env": "Il est mort en chargeant ses propres dépendances Python : ce n'est donc ni la mémoire ni votre GPU, mais un environnement incomplet ou à moitié mis à jour. Utilisez « Clean & Retry » dans Paramètres → Journaux → Backend, qui le reconstruit de zéro et le répare sur place, sans toucher à vos voix ni à vos projets. Si l'échec persiste, les détails du plantage nomment le paquet qui refusait de s'importer."
|
||||
},
|
||||
"keyboard": {
|
||||
"title": "Raccourcis clavier",
|
||||
@@ -1646,8 +1596,6 @@
|
||||
"or": "ou",
|
||||
"nav": "Navigation",
|
||||
"nav_cheatsheet": "Afficher cette aide-mémoire",
|
||||
"nav_enginePickerKey": "Cmd/Ctrl+E",
|
||||
"nav_workspacesKey": "Cmd/Ctrl+1–9",
|
||||
"nav_closeModal": "Fermer modal / annuler",
|
||||
"nav_save": "Enregistrer le projet/commettre le trim",
|
||||
"segmentEditor": "Editeur de segments",
|
||||
@@ -2583,22 +2531,5 @@
|
||||
"longform": "narration d'histoires",
|
||||
"asr": "transcription"
|
||||
}
|
||||
},
|
||||
"compute": {
|
||||
"add_machine": "Ajouter une machine",
|
||||
"manage": "Paramètres des workers distants",
|
||||
"off_hint": "Tout s'exécute sur cette machine. Activez Distant pour en utiliser une autre.",
|
||||
"quick_settings": "Calcul — où les tâches s'exécutent",
|
||||
"remote": "Distant",
|
||||
"title": "Où les tâches s'exécutent",
|
||||
"token_once": "Scannez-le ou collez-le sur l'autre machine. Affiché une seule fois."
|
||||
},
|
||||
"voices": {
|
||||
"active": "Voix active",
|
||||
"active_clone_recipe": "Clonée à partir de votre extrait de référence",
|
||||
"cta_clone": "Déposez un extrait de 3 s dans Voix ← pour en cloner une",
|
||||
"cta_design": "Décrivez-en une dans Voix ← pour la concevoir",
|
||||
"new": "Nouvelle voix",
|
||||
"none_selected": "Aucune voix sélectionnée — décrivez-en une, déposez un audio ou choisissez ci-dessous."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,8 +36,6 @@
|
||||
"open_models": "मॉडल खोलें"
|
||||
},
|
||||
"settings": {
|
||||
"remote_backend_test": "कनेक्शन जाँचें",
|
||||
"remote_backend_save": "सहेजें और फिर से लोड करें",
|
||||
"remote_backend_recovery_title": "रिमोट बैकएंड से संपर्क नहीं हो सका",
|
||||
"remote_backend_recovery_hint": "आप बाद में सेटिंग्स → शेयरिंग में रिमोट बैकएंड बदल सकते हैं।",
|
||||
"remote_backend_use_local": "लोकल बैकएंड इस्तेमाल करें",
|
||||
@@ -60,8 +58,6 @@
|
||||
"credentials": "क्रेडेंशियल",
|
||||
"llm_providers": "LLM प्रदाता",
|
||||
"llmp_desc": "Cinematic और Autofit अनुवाद को संचालित करता है — LLM हर पंक्ति को इस तरह फिर से लिखता है कि वह अपने खंड के समय-बजट में फ़िट हो और वीडियो की टाइमिंग बनी रहे। कुंजियाँ एन्क्रिप्ट करके संग्रहीत की जाती हैं; स्थानीय प्रदाता (Ollama/LM Studio) पूरी तरह ऑफ़लाइन रहते हैं।",
|
||||
"llmp_catalogue_note": "सक्रिय चिह्नित प्रदाता ही जवाब देता है, जब कैटलॉग में LLM इंजन परिवार \"OpenAI-संगत\" पर सेट हो।",
|
||||
"llmp_catalogue_link": "कैटलॉग में LLM परिवार खोलें",
|
||||
"llmp_provider": "प्रदाता",
|
||||
"llmp_provider_hint": "कॉन्फ़िगर करने के लिए एक प्रदाता चुनें। सक्रिय प्रदाता का उपयोग Cinematic/Autofit अनुवाद के लिए होता है। स्थानीय प्रदाताओं को कुंजी की ज़रूरत नहीं, लेकिन उनका सर्वर चालू होना चाहिए।",
|
||||
"llmp_local_tag": "स्थानीय",
|
||||
@@ -286,36 +282,7 @@
|
||||
"models_dir_effective": "अभी प्रभावी",
|
||||
"models_dir_configured": "कॉन्फ़िगर किया गया",
|
||||
"models_dir_default": "डिफ़ॉल्ट उपयोग हो रहा है",
|
||||
"models_dir_restart": "↻ नया स्थान उपयोग करने के लिए VoiceStudio रीस्टार्ट करें।",
|
||||
"worker_join": "जुड़ें",
|
||||
"worker_join_code": "जॉइन कोड",
|
||||
"worker_join_code_hint": "एक बार उपयोग होता है और 15 मिनट में समाप्त हो जाता है। इसे उस मशीन पर बनाएँ जो काम भेजेगी।",
|
||||
"worker_join_desc": "VoiceStudio की दूसरी कॉपी को इस मशीन पर काम भेजने दें। उसने जो जॉइन कोड दिखाया था उसे पेस्ट करें — या उसका QR अपने फ़ोन से स्कैन करके यहाँ पेस्ट करें।",
|
||||
"worker_join_env": "इस मशीन के एनवायरनमेंट में OMNIVOICE_WORKER_MODE सेट है, इसलिए वही तय करता है — उसे वहीं बदलें।",
|
||||
"worker_join_no_endpoint": "कोई कंट्रोल प्लेन याद नहीं है।",
|
||||
"worker_join_ok": "जुड़ गए। यह मशीन अब काम ले रही है।",
|
||||
"worker_join_placeholder": "ovw_…",
|
||||
"worker_join_rejoin": "किसी दूसरे से जुड़ें",
|
||||
"worker_join_stopped": "रुका हुआ",
|
||||
"worker_join_take_work": "इससे काम लें",
|
||||
"worker_join_title": "इस मशीन का GPU उधार दें",
|
||||
"worker_join_working": "काम कर रहा है",
|
||||
"workers_add_hint_qr": "टोकन बनाएँ, फिर दूसरी मशीन से QR स्कैन करें या कोड उसकी रिमोट वर्कर सेटिंग्स में पेस्ट करें।",
|
||||
"workers_approve": "स्वीकृत करें",
|
||||
"workers_last_seen": "आख़िरी बार देखा गया {{when}}",
|
||||
"workers_qr_alt": "इस कोड वाला QR कोड — इसे दूसरी मशीन से स्कैन करें",
|
||||
"workers_secret_done": "हो गया",
|
||||
"workers_seen_hr": "{{count}} घं. पहले",
|
||||
"workers_seen_min": "{{count}} मि. पहले",
|
||||
"workers_seen_now": "अभी-अभी",
|
||||
"workers_step_1": "GPU वाली मशीन पर VoiceStudio इंस्टॉल करें।",
|
||||
"workers_step_2": "ऊपर एक टोकन बनाएँ।",
|
||||
"workers_step_3": "वहाँ QR स्कैन करें, या कोड उसकी रिमोट वर्कर सेटिंग्स में पेस्ट करें।",
|
||||
"workers_summary_none": "कोई जुड़ा नहीं है",
|
||||
"workers_summary_online": "{{count}} ऑनलाइन",
|
||||
"workers_token_expired": "समाप्त हो गया — नया बनाएँ",
|
||||
"workers_token_expires_in": "{{time}} में समाप्त होगा",
|
||||
"workers_token_qr_hint": "दूसरी मशीन पर: सेटिंग्स → सिस्टम → रिमोट वर्कर → जुड़ें, फिर स्कैन या पेस्ट करें।"
|
||||
"models_dir_restart": "↻ नया स्थान उपयोग करने के लिए VoiceStudio रीस्टार्ट करें।"
|
||||
},
|
||||
"bootstrap": {
|
||||
"title": "VoiceStudio",
|
||||
@@ -581,15 +548,6 @@
|
||||
"define_from_audio": "ऑडियो से",
|
||||
"define_voice": "आवाज़ परिभाषित करें",
|
||||
"save_design_as_profile": "डिज़ाइन को प्रोफ़ाइल के रूप में सहेजें",
|
||||
"generating_done_status": "जनरेशन पूरा हुआ",
|
||||
"generating_status": "ऑडियो जनरेट हो रहा है…",
|
||||
"identity": "पहचान",
|
||||
"identity_auto": "ऑटो — मॉडल तय करता है",
|
||||
"insert": "डालें",
|
||||
"insert_token": "एक्सप्रेशन टोकन डालें",
|
||||
"script": "स्क्रिप्ट",
|
||||
"starting_points": "शुरुआती बिंदु",
|
||||
"voice_kicker": "आवाज़",
|
||||
"seed_label": "बीज",
|
||||
"seed_placeholder": "हर बार यादृच्छिक",
|
||||
"seed_keep": "इस बीज को अपने पास रखें",
|
||||
@@ -710,7 +668,6 @@
|
||||
"ready": "तैयार",
|
||||
"unavailable": "अनुपलब्ध",
|
||||
"use": "उपयोग करें",
|
||||
"configureProviders": "प्रदाता कॉन्फ़िगर करें",
|
||||
"loading": "इंजन लोड हो रहे हैं...",
|
||||
"refresh": "ताज़ा करें",
|
||||
"matrixTitle": "इंजन अनुकूलता मैट्रिक्स",
|
||||
@@ -720,9 +677,7 @@
|
||||
"activeEngine": "सक्रिय {{family}}: {{engine}}",
|
||||
"engineCompatLabel": "{{family}} इंजन अनुकूलता",
|
||||
"active": "सक्रिय",
|
||||
"whyUnavailable": "इसे क्या चाहिए",
|
||||
"sectionReady": "उपयोग के लिए तैयार",
|
||||
"sectionMore": "और इंजन जोड़ें",
|
||||
"whyUnavailable": "अनुपलब्ध क्यों?",
|
||||
"lastError": "अंतिम त्रुटि: {{error}}",
|
||||
"installedAndReady": "स्थापित और तैयार",
|
||||
"notInstalled": "स्थापित नहीं",
|
||||
@@ -1335,9 +1290,6 @@
|
||||
"reset": "रीसेट करें",
|
||||
"preview": "पूर्वावलोकन",
|
||||
"use_voice": "आवाज का प्रयोग करें",
|
||||
"more_actions": "अधिक कार्रवाइयाँ",
|
||||
"use_in_stories": "कहानियों में उपयोग करें",
|
||||
"set_audiobook_default": "ऑडियोबुक की डिफ़ॉल्ट आवाज़ के रूप में सेट करें",
|
||||
"open_designer": "डिज़ाइनर में खोलें",
|
||||
"no_matches": "कोई भी आवाज़ इन फ़िल्टर से मेल नहीं खाती।",
|
||||
"load_more": "और अधिक लोड करें",
|
||||
@@ -1636,9 +1588,7 @@
|
||||
"searchIssues": "मिलते-जुलते मुद्दे खोजें",
|
||||
"unexpected": "अप्रत्याशित त्रुटि: {{message}}",
|
||||
"backend_shutting_down": "VoiceStudio बंद हो रहा है। ऐप दोबारा खोलें और फिर कोशिश करें।",
|
||||
"crash_broken_env": "यह अपनी ही Python निर्भरताएँ लोड करते समय बंद हो गया, इसलिए मामला मेमोरी या GPU का नहीं है — एनवायरनमेंट अधूरा है या आधा-अधूरा अपडेट रह गया। सेटिंग्स → लॉग → बैकएंड में \"साफ़ करें और पुनः प्रयास करें\" चलाएँ; यह उसे नए सिरे से बनाकर वहीं ठीक कर देता है, आपकी आवाज़ों या प्रोजेक्ट्स को छुए बिना। फिर भी विफल हो तो क्रैश विवरण उस पैकेज का नाम बताता है जो इम्पोर्ट नहीं हो पाया।",
|
||||
"crash_vram_default": "छोटे GPU पर आम कारण है TTS मॉडल के ऊपर ASR मॉडल लोड करते समय VRAM का ख़त्म हो जाना: पहले TTS मॉडल को हटाएँ, या मॉडल कैटलॉग → मॉडल में कोई छोटा ASR मॉडल चुनें।",
|
||||
"stream_cut_backend_alive": "स्ट्रीम जल्दी ख़त्म हो गई, लेकिन बैकएंड अभी भी चल रहा है — यानी वह क्रैश नहीं हुआ। सर्वर या कंटेनर सेटअप में इसका कारण आमतौर पर कोई रिवर्स प्रॉक्सी या लोड बैलेंसर होता है जो कनेक्शन को बफ़र करता है या टाइमआउट पर काट देता है: इस रूट के लिए रिस्पॉन्स बफ़रिंग बंद करें (nginx: proxy_buffering off; X-Accel-Buffering: no) और उसका रीड टाइमआउट बढ़ाएँ। डेस्कटॉप ऐप को सीधे चलाना, या बिना प्रॉक्सी के localhost पर चलाना, इसकी पुष्टि कर देगा।"
|
||||
"crash_broken_env": "यह अपनी ही Python निर्भरताएँ लोड करते समय बंद हो गया, इसलिए मामला मेमोरी या GPU का नहीं है — एनवायरनमेंट अधूरा है या आधा-अधूरा अपडेट रह गया। सेटिंग्स → लॉग → बैकएंड में \"Clean & Retry\" चलाएँ; यह उसे नए सिरे से बनाकर वहीं ठीक कर देता है, आपकी आवाज़ों या प्रोजेक्ट्स को छुए बिना। फिर भी विफल हो तो क्रैश विवरण उस पैकेज का नाम बताता है जो इम्पोर्ट नहीं हो पाया।"
|
||||
},
|
||||
"keyboard": {
|
||||
"title": "कीबोर्ड शॉर्टकट",
|
||||
@@ -1646,8 +1596,6 @@
|
||||
"or": "या",
|
||||
"nav": "नेविगेशन",
|
||||
"nav_cheatsheet": "यह चीटशीट दिखाओ",
|
||||
"nav_enginePickerKey": "Cmd/Ctrl+E",
|
||||
"nav_workspacesKey": "Cmd/Ctrl+1–9",
|
||||
"nav_closeModal": "मोडल बंद करें / रद्द करें",
|
||||
"nav_save": "प्रोजेक्ट सहेजें/कमिट ट्रिम करें",
|
||||
"segmentEditor": "खंड संपादक",
|
||||
@@ -2583,22 +2531,5 @@
|
||||
"longform": "कहानी वर्णन",
|
||||
"asr": "ट्रांसक्रिप्शन"
|
||||
}
|
||||
},
|
||||
"compute": {
|
||||
"add_machine": "मशीन जोड़ें",
|
||||
"manage": "रिमोट वर्कर सेटिंग्स",
|
||||
"off_hint": "सब कुछ इसी मशीन पर चलता है। दूसरी मशीन उपयोग करने के लिए रिमोट चालू करें।",
|
||||
"quick_settings": "कंप्यूट — काम कहाँ चलते हैं",
|
||||
"remote": "रिमोट",
|
||||
"title": "काम कहाँ चलते हैं",
|
||||
"token_once": "इसे दूसरी मशीन पर स्कैन करें या पेस्ट करें। सिर्फ़ एक बार दिखाया जाता है।"
|
||||
},
|
||||
"voices": {
|
||||
"active": "सक्रिय आवाज़",
|
||||
"active_clone_recipe": "आपकी संदर्भ क्लिप से क्लोन की गई",
|
||||
"cta_clone": "एक क्लोन करने के लिए आवाज ← में 3 सेकंड की क्लिप छोड़ें",
|
||||
"cta_design": "डिज़ाइन करने के लिए आवाज ← में उसका वर्णन करें",
|
||||
"new": "नई आवाज़",
|
||||
"none_selected": "कोई आवाज़ चुनी नहीं गई — किसी का वर्णन करें, ऑडियो छोड़ें, या नीचे से चुनें।"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,8 +36,6 @@
|
||||
"open_models": "Buka model"
|
||||
},
|
||||
"settings": {
|
||||
"remote_backend_test": "Uji koneksi",
|
||||
"remote_backend_save": "Simpan dan muat ulang",
|
||||
"remote_backend_recovery_title": "Backend jarak jauh tidak dapat dijangkau",
|
||||
"remote_backend_recovery_hint": "Anda dapat mengubah backend jarak jauh nanti di Pengaturan → Berbagi.",
|
||||
"remote_backend_use_local": "Gunakan backend lokal",
|
||||
@@ -60,8 +58,6 @@
|
||||
"credentials": "Kredensial",
|
||||
"llm_providers": "Penyedia LLM",
|
||||
"llmp_desc": "Menggerakkan terjemahan Cinematic & Autofit — LLM menulis ulang setiap baris agar sesuai dengan jatah waktu segmennya sehingga pengaturan waktu video tetap terjaga. Kunci disimpan terenkripsi; penyedia lokal (Ollama/LM Studio) sepenuhnya offline.",
|
||||
"llmp_catalogue_note": "Penyedia yang ditandai aktif adalah yang menjawab ketika keluarga mesin LLM di katalog disetel ke \"kompatibel OpenAI\".",
|
||||
"llmp_catalogue_link": "Buka keluarga LLM di katalog",
|
||||
"llmp_provider": "Penyedia",
|
||||
"llmp_provider_hint": "Pilih penyedia untuk dikonfigurasi. Penyedia aktif digunakan untuk terjemahan Cinematic/Autofit. Penyedia lokal tidak memerlukan kunci, tetapi servernya harus berjalan.",
|
||||
"llmp_local_tag": "lokal",
|
||||
@@ -286,36 +282,7 @@
|
||||
"models_dir_effective": "Sedang digunakan",
|
||||
"models_dir_configured": "Dikonfigurasi",
|
||||
"models_dir_default": "Menggunakan bawaan",
|
||||
"models_dir_restart": "↻ Mulai ulang VoiceStudio untuk memakai lokasi baru.",
|
||||
"worker_join": "Gabung",
|
||||
"worker_join_code": "Kode gabung",
|
||||
"worker_join_code_hint": "Sekali pakai dan kedaluwarsa dalam 15 menit. Buat di mesin yang akan mengirim pekerjaan.",
|
||||
"worker_join_desc": "Izinkan salinan VoiceStudio lain mengirim pekerjaan ke mesin ini. Tempelkan kode gabung yang ditampilkannya — atau pindai QR-nya dengan ponsel Anda lalu tempelkan di sini.",
|
||||
"worker_join_env": "OMNIVOICE_WORKER_MODE disetel di lingkungan mesin ini, jadi itulah yang menentukan — ubah di sana.",
|
||||
"worker_join_no_endpoint": "Tidak ada control plane yang tersimpan.",
|
||||
"worker_join_ok": "Bergabung. Mesin ini sekarang menerima pekerjaan.",
|
||||
"worker_join_placeholder": "ovw_…",
|
||||
"worker_join_rejoin": "Gabung ke yang lain",
|
||||
"worker_join_stopped": "Berhenti",
|
||||
"worker_join_take_work": "Menerima pekerjaan dari",
|
||||
"worker_join_title": "Pinjamkan GPU mesin ini",
|
||||
"worker_join_working": "Bekerja",
|
||||
"workers_add_hint_qr": "Buat token, lalu pindai QR dari mesin yang lain atau tempelkan kodenya ke pengaturan Pekerja jarak jauh di sana.",
|
||||
"workers_approve": "Setujui",
|
||||
"workers_last_seen": "terakhir terlihat {{when}}",
|
||||
"workers_qr_alt": "Kode QR yang memuat kode ini — pindai dari mesin yang lain",
|
||||
"workers_secret_done": "Selesai",
|
||||
"workers_seen_hr": "{{count}} jam lalu",
|
||||
"workers_seen_min": "{{count}} mnt lalu",
|
||||
"workers_seen_now": "baru saja",
|
||||
"workers_step_1": "Pasang VoiceStudio di mesin yang memiliki GPU.",
|
||||
"workers_step_2": "Buat token di atas.",
|
||||
"workers_step_3": "Pindai QR di sana, atau tempelkan kodenya ke pengaturan Pekerja jarak jauh di sana.",
|
||||
"workers_summary_none": "Tidak ada yang terhubung",
|
||||
"workers_summary_online": "{{count}} online",
|
||||
"workers_token_expired": "Kedaluwarsa — buat yang baru",
|
||||
"workers_token_expires_in": "Kedaluwarsa dalam {{time}}",
|
||||
"workers_token_qr_hint": "Di mesin yang lain: Pengaturan → Sistem → Pekerja jarak jauh → Gabung, lalu pindai atau tempelkan."
|
||||
"models_dir_restart": "↻ Mulai ulang VoiceStudio untuk memakai lokasi baru."
|
||||
},
|
||||
"bootstrap": {
|
||||
"title": "VoiceStudio",
|
||||
@@ -581,15 +548,6 @@
|
||||
"define_from_audio": "Dari audio",
|
||||
"define_voice": "Tentukan suara",
|
||||
"save_design_as_profile": "Simpan desain sebagai profil",
|
||||
"generating_done_status": "Pembuatan selesai",
|
||||
"generating_status": "Membuat audio…",
|
||||
"identity": "Identitas",
|
||||
"identity_auto": "Otomatis — model yang menentukan",
|
||||
"insert": "Sisipkan",
|
||||
"insert_token": "Sisipkan token ekspresi",
|
||||
"script": "Naskah",
|
||||
"starting_points": "Titik awal",
|
||||
"voice_kicker": "Suara",
|
||||
"seed_label": "Benih",
|
||||
"seed_placeholder": "acak setiap saat",
|
||||
"seed_keep": "Simpan benih ini",
|
||||
@@ -710,7 +668,6 @@
|
||||
"ready": "siap",
|
||||
"unavailable": "tidak tersedia",
|
||||
"use": "Gunakan",
|
||||
"configureProviders": "Konfigurasikan penyedia",
|
||||
"loading": "Memuat mesin…",
|
||||
"refresh": "Segarkan",
|
||||
"matrixTitle": "Matriks Kompatibilitas Mesin",
|
||||
@@ -720,9 +677,7 @@
|
||||
"activeEngine": "Aktif {{family}}: {{engine}}",
|
||||
"engineCompatLabel": "{{family}} kompatibilitas mesin",
|
||||
"active": "aktif",
|
||||
"whyUnavailable": "Apa yang dibutuhkan",
|
||||
"sectionReady": "Siap digunakan",
|
||||
"sectionMore": "Tambahkan mesin lainnya",
|
||||
"whyUnavailable": "Mengapa tidak tersedia?",
|
||||
"lastError": "Kesalahan terakhir: {{error}}",
|
||||
"installedAndReady": "Terpasang dan siap",
|
||||
"notInstalled": "Tidak dipasang",
|
||||
@@ -1335,9 +1290,6 @@
|
||||
"reset": "Setel ulang",
|
||||
"preview": "Pratinjau",
|
||||
"use_voice": "Gunakan suara",
|
||||
"more_actions": "Tindakan lainnya",
|
||||
"use_in_stories": "Gunakan di Cerita",
|
||||
"set_audiobook_default": "Atur sebagai suara default Buku Audio",
|
||||
"open_designer": "Buka di Desainer",
|
||||
"no_matches": "Tidak ada suara yang cocok dengan filter ini.",
|
||||
"load_more": "Muat lebih banyak",
|
||||
@@ -1636,9 +1588,7 @@
|
||||
"searchIssues": "Cari masalah serupa",
|
||||
"unexpected": "Kesalahan tak terduga: {{message}}",
|
||||
"backend_shutting_down": "VoiceStudio sedang ditutup. Buka kembali aplikasinya lalu coba lagi.",
|
||||
"crash_broken_env": "Ia mati saat memuat dependensi Python-nya sendiri, jadi ini bukan soal memori atau GPU Anda — lingkungannya tidak lengkap atau tertinggal setengah diperbarui. Gunakan \"Bersihkan & Coba Lagi\" di Pengaturan → Log → Backend, yang membangunnya ulang dari nol dan memperbaikinya di tempat, tanpa menyentuh suara atau proyek Anda. Jika masih gagal, detail crash menyebutkan paket persis yang gagal diimpor.",
|
||||
"crash_vram_default": "Pada GPU yang lebih kecil, penyebab umumnya adalah kehabisan VRAM saat memuat model ASR di atas model TTS: kosongkan model TTS terlebih dahulu, atau pilih model ASR yang lebih kecil di Katalog model → Model.",
|
||||
"stream_cut_backend_alive": "Stream berakhir lebih awal, tetapi backend masih berjalan — jadi backend tidak mogok. Pada penyiapan server atau kontainer, ini biasanya karena reverse proxy atau load balancer yang mem-buffer atau memutus koneksi karena batas waktu: nonaktifkan buffering respons untuk rute ini (nginx: proxy_buffering off; X-Accel-Buffering: no) dan naikkan batas waktu bacanya. Menjalankan aplikasi desktop secara langsung, atau di localhost tanpa proxy, akan memastikannya."
|
||||
"crash_broken_env": "Ia mati saat memuat dependensi Python-nya sendiri, jadi ini bukan soal memori atau GPU Anda — lingkungannya tidak lengkap atau tertinggal setengah diperbarui. Gunakan \"Clean & Retry\" di Pengaturan → Log → Backend, yang membangunnya ulang dari nol dan memperbaikinya di tempat, tanpa menyentuh suara atau proyek Anda. Jika masih gagal, detail crash menyebutkan paket persis yang gagal diimpor."
|
||||
},
|
||||
"keyboard": {
|
||||
"title": "Pintasan keyboard",
|
||||
@@ -1646,8 +1596,6 @@
|
||||
"or": "atau",
|
||||
"nav": "Navigasi",
|
||||
"nav_cheatsheet": "Tunjukkan lembar contekan ini",
|
||||
"nav_enginePickerKey": "Cmd/Ctrl+E",
|
||||
"nav_workspacesKey": "Cmd/Ctrl+1–9",
|
||||
"nav_closeModal": "Tutup modal / batalkan",
|
||||
"nav_save": "Simpan proyek/komit trim",
|
||||
"segmentEditor": "Editor segmen",
|
||||
@@ -2583,22 +2531,5 @@
|
||||
"longform": "narasi cerita",
|
||||
"asr": "transkripsi"
|
||||
}
|
||||
},
|
||||
"compute": {
|
||||
"add_machine": "Tambahkan mesin",
|
||||
"manage": "Pengaturan pekerja jarak jauh",
|
||||
"off_hint": "Semuanya berjalan di mesin ini. Aktifkan Jarak jauh untuk memakai mesin lain.",
|
||||
"quick_settings": "Komputasi — tempat pekerjaan berjalan",
|
||||
"remote": "Jarak jauh",
|
||||
"title": "Tempat pekerjaan berjalan",
|
||||
"token_once": "Pindai atau tempelkan ini di mesin yang lain. Hanya ditampilkan sekali."
|
||||
},
|
||||
"voices": {
|
||||
"active": "Suara aktif",
|
||||
"active_clone_recipe": "Dikloning dari klip referensi Anda",
|
||||
"cta_clone": "Letakkan klip 3 detik di Suara ← untuk mengkloning suara",
|
||||
"cta_design": "Deskripsikan suara di Suara ← untuk mendesainnya",
|
||||
"new": "Suara baru",
|
||||
"none_selected": "Belum ada suara yang dipilih — deskripsikan satu, letakkan audio, atau pilih di bawah."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,8 +36,6 @@
|
||||
"open_models": "Apri modelli"
|
||||
},
|
||||
"settings": {
|
||||
"remote_backend_test": "Verifica connessione",
|
||||
"remote_backend_save": "Salva e ricarica",
|
||||
"remote_backend_recovery_title": "Impossibile raggiungere il backend remoto",
|
||||
"remote_backend_recovery_hint": "Puoi modificare il backend remoto in seguito in Impostazioni → Condivisione.",
|
||||
"remote_backend_use_local": "Usa il backend locale",
|
||||
@@ -60,8 +58,6 @@
|
||||
"credentials": "Credenziali",
|
||||
"llm_providers": "Provider LLM",
|
||||
"llmp_desc": "Alimenta la traduzione Cinematic e Autofit: il LLM riscrive ogni riga per rientrare nel budget di tempo del suo segmento, preservando il timing del video. Le chiavi sono salvate cifrate; i provider locali (Ollama/LM Studio) restano completamente offline.",
|
||||
"llmp_catalogue_note": "Il provider contrassegnato come attivo è quello che risponde ogni volta che la famiglia di motori LLM nel catalogo è impostata su \"compatibile OpenAI\".",
|
||||
"llmp_catalogue_link": "Apri la famiglia LLM nel catalogo",
|
||||
"llmp_provider": "Provider",
|
||||
"llmp_provider_hint": "Scegli un provider da configurare. Quello attivo viene usato per la traduzione Cinematic/Autofit. I provider locali non richiedono chiavi, ma il loro server deve essere in esecuzione.",
|
||||
"llmp_local_tag": "locale",
|
||||
@@ -286,36 +282,7 @@
|
||||
"models_dir_effective": "In uso ora",
|
||||
"models_dir_configured": "Configurata",
|
||||
"models_dir_default": "Uso predefinito",
|
||||
"models_dir_restart": "↻ Riavvia VoiceStudio per usare la nuova posizione.",
|
||||
"worker_join": "Collegati",
|
||||
"worker_join_code": "Codice di collegamento",
|
||||
"worker_join_code_hint": "Monouso e scade tra 15 minuti. Generalo sul computer che invierà il lavoro.",
|
||||
"worker_join_desc": "Consenti a un'altra copia di VoiceStudio di inviare lavori a questo computer. Incolla il codice di collegamento che ti ha mostrato — oppure scansiona il suo QR con il telefono e incollalo qui.",
|
||||
"worker_join_env": "OMNIVOICE_WORKER_MODE è impostato nell'ambiente di questo computer, quindi decide lui — modificalo lì.",
|
||||
"worker_join_no_endpoint": "Nessun piano di controllo memorizzato.",
|
||||
"worker_join_ok": "Collegato. Questo computer ora accetta lavoro.",
|
||||
"worker_join_placeholder": "ovw_…",
|
||||
"worker_join_rejoin": "Collegati a un altro",
|
||||
"worker_join_stopped": "Fermato",
|
||||
"worker_join_take_work": "Accetta lavoro da",
|
||||
"worker_join_title": "Presta la GPU di questo computer",
|
||||
"worker_join_working": "Al lavoro",
|
||||
"workers_add_hint_qr": "Genera un token, poi scansiona il QR dall'altro computer o incolla il codice nelle sue impostazioni Worker remoti.",
|
||||
"workers_approve": "Approva",
|
||||
"workers_last_seen": "visto l'ultima volta {{when}}",
|
||||
"workers_qr_alt": "Codice QR con questo codice — scansionalo dall'altro computer",
|
||||
"workers_secret_done": "Fatto",
|
||||
"workers_seen_hr": "{{count}} h fa",
|
||||
"workers_seen_min": "{{count}} min fa",
|
||||
"workers_seen_now": "proprio ora",
|
||||
"workers_step_1": "Installa VoiceStudio sul computer con la GPU.",
|
||||
"workers_step_2": "Genera un token qui sopra.",
|
||||
"workers_step_3": "Scansiona lì il QR, oppure incolla il codice nelle sue impostazioni Worker remoti.",
|
||||
"workers_summary_none": "Nessuno connesso",
|
||||
"workers_summary_online": "{{count}} online",
|
||||
"workers_token_expired": "Scaduto — generane uno nuovo",
|
||||
"workers_token_expires_in": "Scade tra {{time}}",
|
||||
"workers_token_qr_hint": "Sull'altro computer: Impostazioni → Sistema → Worker remoti → Collegati, poi scansiona o incolla."
|
||||
"models_dir_restart": "↻ Riavvia VoiceStudio per usare la nuova posizione."
|
||||
},
|
||||
"bootstrap": {
|
||||
"title": "VoiceStudio",
|
||||
@@ -581,15 +548,6 @@
|
||||
"define_from_audio": "Da audio",
|
||||
"define_voice": "Definisci la voce",
|
||||
"save_design_as_profile": "Salva il progetto come profilo",
|
||||
"generating_done_status": "Generazione completata",
|
||||
"generating_status": "Generazione dell'audio…",
|
||||
"identity": "Identità",
|
||||
"identity_auto": "Auto — decide il modello",
|
||||
"insert": "Inserisci",
|
||||
"insert_token": "Inserisci token di espressione",
|
||||
"script": "Copione",
|
||||
"starting_points": "Punti di partenza",
|
||||
"voice_kicker": "Voce",
|
||||
"seed_label": "Seme",
|
||||
"seed_placeholder": "casuale ogni volta",
|
||||
"seed_keep": "Conserva questo seme",
|
||||
@@ -710,7 +668,6 @@
|
||||
"ready": "pronto",
|
||||
"unavailable": "non disponibile",
|
||||
"use": "Utilizzare",
|
||||
"configureProviders": "Configura i provider",
|
||||
"loading": "Caricamento motori…",
|
||||
"refresh": "Aggiorna",
|
||||
"matrixTitle": "Matrice di compatibilità del motore",
|
||||
@@ -720,9 +677,7 @@
|
||||
"activeEngine": "Attivo {{family}}: {{engine}}",
|
||||
"engineCompatLabel": "{{family}} compatibilità motore",
|
||||
"active": "attivo",
|
||||
"whyUnavailable": "Cosa serve",
|
||||
"sectionReady": "Pronti all'uso",
|
||||
"sectionMore": "Aggiungi altri motori",
|
||||
"whyUnavailable": "Perché non disponibile?",
|
||||
"lastError": "Ultimo errore: {{error}}",
|
||||
"installedAndReady": "Installato e pronto",
|
||||
"notInstalled": "Non installato",
|
||||
@@ -1335,9 +1290,6 @@
|
||||
"reset": "Ripristina",
|
||||
"preview": "Anteprima",
|
||||
"use_voice": "Usa la voce",
|
||||
"more_actions": "Altre azioni",
|
||||
"use_in_stories": "Usa nelle Storie",
|
||||
"set_audiobook_default": "Imposta come voce predefinita per Audiolibro",
|
||||
"open_designer": "Apri in Progettazione",
|
||||
"no_matches": "Nessuna voce corrisponde a questi filtri.",
|
||||
"load_more": "Carica di più",
|
||||
@@ -1636,9 +1588,7 @@
|
||||
"searchIssues": "Cerca problemi simili",
|
||||
"unexpected": "Errore imprevisto: {{message}}",
|
||||
"backend_shutting_down": "VoiceStudio si sta chiudendo. Riapri l’app e riprova.",
|
||||
"crash_broken_env": "È morto mentre caricava le proprie dipendenze Python, quindi non c'entrano né la memoria né la GPU: l'ambiente è incompleto o è rimasto aggiornato a metà. Usa «Pulisci e riprova» in Impostazioni → Log → Backend, che lo ricostruisce da zero e lo ripara sul posto, senza toccare le tue voci o i tuoi progetti. Se continua a fallire, i dettagli del crash indicano il pacchetto che non si importava.",
|
||||
"crash_vram_default": "Sulle GPU più piccole la causa più comune è l'esaurimento della VRAM quando il modello ASR viene caricato insieme al modello TTS: scarica prima il modello TTS, oppure scegli un modello ASR più piccolo in Catalogo modelli → Modelli.",
|
||||
"stream_cut_backend_alive": "Lo stream è terminato in anticipo, ma il backend è ancora in esecuzione — quindi non è andato in crash. In una configurazione servita o containerizzata di solito è un reverse proxy o un load balancer che bufferizza la connessione o la interrompe per timeout: disattiva il buffering delle risposte per questa route (nginx: proxy_buffering off; X-Accel-Buffering: no) e aumenta il suo timeout di lettura. Eseguire l'app desktop direttamente, o su localhost senza proxy, lo confermerà."
|
||||
"crash_broken_env": "È morto mentre caricava le proprie dipendenze Python, quindi non c'entrano né la memoria né la GPU: l'ambiente è incompleto o è rimasto aggiornato a metà. Usa «Clean & Retry» in Impostazioni → Log → Backend, che lo ricostruisce da zero e lo ripara sul posto, senza toccare le tue voci o i tuoi progetti. Se continua a fallire, i dettagli del crash indicano il pacchetto che non si importava."
|
||||
},
|
||||
"keyboard": {
|
||||
"title": "Scorciatoie da tastiera",
|
||||
@@ -1646,8 +1596,6 @@
|
||||
"or": "o",
|
||||
"nav": "Navigazione",
|
||||
"nav_cheatsheet": "Mostra questo foglietto illustrativo",
|
||||
"nav_enginePickerKey": "Cmd/Ctrl+E",
|
||||
"nav_workspacesKey": "Cmd/Ctrl+1–9",
|
||||
"nav_closeModal": "Chiudi modale/annulla",
|
||||
"nav_save": "Salva progetto / conferma ritaglio",
|
||||
"segmentEditor": "Redattore di segmenti",
|
||||
@@ -2583,22 +2531,5 @@
|
||||
"longform": "narrazione di storie",
|
||||
"asr": "trascrizione"
|
||||
}
|
||||
},
|
||||
"compute": {
|
||||
"add_machine": "Aggiungi un computer",
|
||||
"manage": "Impostazioni dei worker remoti",
|
||||
"off_hint": "Tutto viene eseguito su questo computer. Attiva Remoto per usarne un altro.",
|
||||
"quick_settings": "Calcolo — dove vengono eseguiti i lavori",
|
||||
"remote": "Remoto",
|
||||
"title": "Dove vengono eseguiti i lavori",
|
||||
"token_once": "Scansionalo o incollalo sull'altro computer. Mostrato una sola volta."
|
||||
},
|
||||
"voices": {
|
||||
"active": "Voce attiva",
|
||||
"active_clone_recipe": "Clonata dalla tua clip di riferimento",
|
||||
"cta_clone": "Trascina una clip di 3 s in Voce ← per clonarne una",
|
||||
"cta_design": "Descrivine una in Voce ← per progettarla",
|
||||
"new": "Nuova voce",
|
||||
"none_selected": "Nessuna voce selezionata — descrivine una, trascina un audio o scegli qui sotto."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,8 +36,6 @@
|
||||
"open_models": "モデルを開く"
|
||||
},
|
||||
"settings": {
|
||||
"remote_backend_test": "接続をテスト",
|
||||
"remote_backend_save": "保存して再読み込み",
|
||||
"remote_backend_recovery_title": "リモートバックエンドに接続できません",
|
||||
"remote_backend_recovery_hint": "リモートバックエンドは後で「設定」→「共有」から変更できます。",
|
||||
"remote_backend_use_local": "ローカルバックエンドを使用",
|
||||
@@ -60,8 +58,6 @@
|
||||
"credentials": "資格情報",
|
||||
"llm_providers": "LLM プロバイダー",
|
||||
"llmp_desc": "Cinematic・Autofit 翻訳を支える機能です。LLM が各行をセグメントの時間枠に収まるように書き換え、動画のタイミングを保ちます。キーは暗号化して保存され、ローカルプロバイダー(Ollama/LM Studio)は完全にオフラインで動作します。",
|
||||
"llmp_catalogue_note": "カタログで LLM エンジンファミリーが「OpenAI 互換」に設定されている間は、アクティブに設定されたプロバイダーが応答します。",
|
||||
"llmp_catalogue_link": "カタログで LLM ファミリーを開く",
|
||||
"llmp_provider": "プロバイダー",
|
||||
"llmp_provider_hint": "設定するプロバイダーを選択してください。アクティブなプロバイダーが Cinematic/Autofit 翻訳に使用されます。ローカルプロバイダーはキー不要ですが、サーバーが起動している必要があります。",
|
||||
"llmp_local_tag": "ローカル",
|
||||
@@ -286,36 +282,7 @@
|
||||
"models_dir_effective": "現在使用中",
|
||||
"models_dir_configured": "設定済み",
|
||||
"models_dir_default": "既定値を使用",
|
||||
"models_dir_restart": "↻ 新しい場所を使用するには VoiceStudio を再起動してください。",
|
||||
"worker_join": "参加",
|
||||
"worker_join_code": "参加コード",
|
||||
"worker_join_code_hint": "使い切りで、15 分で期限切れになります。作業を送る側のマシンで生成してください。",
|
||||
"worker_join_desc": "別の VoiceStudio からこのマシンにジョブを送れるようにします。表示された参加コードを貼り付けるか、その QR をスマートフォンでスキャンしてここに貼り付けてください。",
|
||||
"worker_join_env": "このマシンの環境変数に OMNIVOICE_WORKER_MODE が設定されているため、そちらが優先されます。変更はそちらで行ってください。",
|
||||
"worker_join_no_endpoint": "記憶されたコントロールプレーンはありません。",
|
||||
"worker_join_ok": "参加しました。このマシンは作業を受け付けています。",
|
||||
"worker_join_placeholder": "ovw_…",
|
||||
"worker_join_rejoin": "別のものに参加",
|
||||
"worker_join_stopped": "停止中",
|
||||
"worker_join_take_work": "作業の受け取り元",
|
||||
"worker_join_title": "このマシンの GPU を貸す",
|
||||
"worker_join_working": "作業中",
|
||||
"workers_add_hint_qr": "トークンを生成し、もう一方のマシンから QR をスキャンするか、そのマシンの「リモートワーカー」設定にコードを貼り付けてください。",
|
||||
"workers_approve": "承認",
|
||||
"workers_last_seen": "最終確認 {{when}}",
|
||||
"workers_qr_alt": "このコードを含む QR コード — もう一方のマシンからスキャンしてください",
|
||||
"workers_secret_done": "完了",
|
||||
"workers_seen_hr": "{{count}} 時間前",
|
||||
"workers_seen_min": "{{count}} 分前",
|
||||
"workers_seen_now": "たった今",
|
||||
"workers_step_1": "GPU のあるマシンに VoiceStudio をインストールします。",
|
||||
"workers_step_2": "上でトークンを生成します。",
|
||||
"workers_step_3": "そのマシンで QR をスキャンするか、そのマシンの「リモートワーカー」設定にコードを貼り付けます。",
|
||||
"workers_summary_none": "接続なし",
|
||||
"workers_summary_online": "{{count}} 台オンライン",
|
||||
"workers_token_expired": "期限切れ — 新しく生成してください",
|
||||
"workers_token_expires_in": "あと {{time}} で期限切れ",
|
||||
"workers_token_qr_hint": "もう一方のマシンで: 設定 → システム → リモートワーカー → 参加 を開き、スキャンするか貼り付けてください。"
|
||||
"models_dir_restart": "↻ 新しい場所を使用するには VoiceStudio を再起動してください。"
|
||||
},
|
||||
"bootstrap": {
|
||||
"title": "VoiceStudio",
|
||||
@@ -581,15 +548,6 @@
|
||||
"define_from_audio": "音声から",
|
||||
"define_voice": "音声の定義",
|
||||
"save_design_as_profile": "デザインをプロファイルとして保存",
|
||||
"generating_done_status": "生成が完了しました",
|
||||
"generating_status": "音声を生成中…",
|
||||
"identity": "声の個性",
|
||||
"identity_auto": "自動 — モデルが判断します",
|
||||
"insert": "挿入",
|
||||
"insert_token": "表現トークンを挿入",
|
||||
"script": "台本",
|
||||
"starting_points": "出発点",
|
||||
"voice_kicker": "声",
|
||||
"seed_label": "種子",
|
||||
"seed_placeholder": "毎回ランダム",
|
||||
"seed_keep": "この種を保管しておいてください",
|
||||
@@ -710,7 +668,6 @@
|
||||
"ready": "準備完了",
|
||||
"unavailable": "利用不可",
|
||||
"use": "使用する",
|
||||
"configureProviders": "プロバイダーを設定",
|
||||
"loading": "エンジンをロード中…",
|
||||
"refresh": "リフレッシュ",
|
||||
"matrixTitle": "エンジン互換性マトリックス",
|
||||
@@ -720,9 +677,7 @@
|
||||
"activeEngine": "アクティブ {{family}}: {{engine}}",
|
||||
"engineCompatLabel": "{{family}} エンジンの互換性",
|
||||
"active": "アクティブな",
|
||||
"whyUnavailable": "必要なもの",
|
||||
"sectionReady": "すぐに使える",
|
||||
"sectionMore": "エンジンを追加",
|
||||
"whyUnavailable": "なぜ利用できないのでしょうか?",
|
||||
"lastError": "最後のエラー: {{error}}",
|
||||
"installedAndReady": "インストールされて準備完了",
|
||||
"notInstalled": "インストールされていません",
|
||||
@@ -1335,9 +1290,6 @@
|
||||
"reset": "リセット",
|
||||
"preview": "プレビュー",
|
||||
"use_voice": "音声を使用する",
|
||||
"more_actions": "その他の操作",
|
||||
"use_in_stories": "ストーリーで使用",
|
||||
"set_audiobook_default": "オーディオブックのデフォルト音声に設定",
|
||||
"open_designer": "デザイナーで開く",
|
||||
"no_matches": "これらのフィルターに一致する音声はありません。",
|
||||
"load_more": "さらにロードする",
|
||||
@@ -1636,9 +1588,7 @@
|
||||
"searchIssues": "類似の問題を検索",
|
||||
"unexpected": "予期しないエラー: {{message}}",
|
||||
"backend_shutting_down": "VoiceStudio を終了しています。アプリを開き直してからもう一度お試しください。",
|
||||
"crash_broken_env": "自身の Python 依存関係を読み込んでいる最中に停止しました。メモリや GPU の問題ではなく、環境が不完全か、更新が中途半端なまま残っています。設定 → ログ → バックエンド の「クリーンアップして再試行」を実行してください。環境をゼロから作り直してその場で修復し、音声やプロジェクトには手を触れません。それでも失敗する場合は、クラッシュ詳細に読み込めなかったパッケージ名が出ています。",
|
||||
"crash_vram_default": "小さめの GPU では、TTS モデルを読み込んだまま ASR モデルを読み込む際に VRAM が不足するのがよくある原因です。先に TTS モデルをアンロードするか、モデルカタログ → モデル でより小さい ASR モデルを選んでください。",
|
||||
"stream_cut_backend_alive": "ストリームは途中で終了しましたが、バックエンドはまだ動作しています。つまりクラッシュではありません。サーバー経由やコンテナ環境では、リバースプロキシやロードバランサーが接続をバッファリングまたはタイムアウトさせているのがよくある原因です。このルートのレスポンスバッファリングを無効にし(nginx: proxy_buffering off; X-Accel-Buffering: no)、読み取りタイムアウトを延ばしてください。デスクトップアプリを直接実行するか、プロキシなしの localhost で実行すれば確認できます。"
|
||||
"crash_broken_env": "自身の Python 依存関係を読み込んでいる最中に停止しました。メモリや GPU の問題ではなく、環境が不完全か、更新が中途半端なまま残っています。設定 → ログ → バックエンド の「Clean & Retry」を実行してください。環境をゼロから作り直してその場で修復し、音声やプロジェクトには手を触れません。それでも失敗する場合は、クラッシュ詳細に読み込めなかったパッケージ名が出ています。"
|
||||
},
|
||||
"keyboard": {
|
||||
"title": "キーボードショートカット",
|
||||
@@ -1646,8 +1596,6 @@
|
||||
"or": "または",
|
||||
"nav": "ナビゲーション",
|
||||
"nav_cheatsheet": "このチートシートを表示する",
|
||||
"nav_enginePickerKey": "Cmd/Ctrl+E",
|
||||
"nav_workspacesKey": "Cmd/Ctrl+1–9",
|
||||
"nav_closeModal": "モーダルを閉じる/キャンセル",
|
||||
"nav_save": "プロジェクトの保存 / トリムのコミット",
|
||||
"segmentEditor": "セグメントエディター",
|
||||
@@ -2583,22 +2531,5 @@
|
||||
"longform": "ストーリー朗読",
|
||||
"asr": "文字起こし"
|
||||
}
|
||||
},
|
||||
"compute": {
|
||||
"add_machine": "マシンを追加",
|
||||
"manage": "リモートワーカー設定",
|
||||
"off_hint": "すべてこのマシンで実行されます。他のマシンを使うにはリモートをオンにしてください。",
|
||||
"quick_settings": "コンピュート — ジョブの実行場所",
|
||||
"remote": "リモート",
|
||||
"title": "ジョブの実行場所",
|
||||
"token_once": "もう一方のマシンでこれをスキャンするか貼り付けてください。表示は一度だけです。"
|
||||
},
|
||||
"voices": {
|
||||
"active": "アクティブな声",
|
||||
"active_clone_recipe": "リファレンスクリップからクローンされました",
|
||||
"cta_clone": "「声」← に 3 秒のクリップをドロップしてクローン",
|
||||
"cta_design": "「声」← で説明してデザイン",
|
||||
"new": "新しい声",
|
||||
"none_selected": "声が選択されていません — 説明するか、音声をドロップするか、下から選んでください。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,8 +36,6 @@
|
||||
"open_models": "모델 열기"
|
||||
},
|
||||
"settings": {
|
||||
"remote_backend_test": "연결 테스트",
|
||||
"remote_backend_save": "저장 후 새로고침",
|
||||
"remote_backend_recovery_title": "원격 백엔드에 연결할 수 없음",
|
||||
"remote_backend_recovery_hint": "나중에 설정 → 공유에서 원격 백엔드를 변경할 수 있습니다.",
|
||||
"remote_backend_use_local": "로컬 백엔드 사용",
|
||||
@@ -60,8 +58,6 @@
|
||||
"credentials": "자격 증명",
|
||||
"llm_providers": "LLM 제공업체",
|
||||
"llmp_desc": "Cinematic 및 Autofit 번역을 구동합니다. LLM이 각 줄을 해당 구간의 시간 안에 맞게 다시 써서 영상 타이밍을 유지합니다. 키는 암호화되어 저장되며, 로컬 제공업체(Ollama/LM Studio)는 완전히 오프라인으로 작동합니다.",
|
||||
"llmp_catalogue_note": "카탈로그에서 LLM 엔진 계열이 \"OpenAI 호환\"으로 설정되어 있으면 활성으로 표시된 제공업체가 응답합니다.",
|
||||
"llmp_catalogue_link": "카탈로그에서 LLM 계열 열기",
|
||||
"llmp_provider": "제공업체",
|
||||
"llmp_provider_hint": "구성할 제공업체를 선택하세요. 활성 제공업체가 Cinematic/Autofit 번역에 사용됩니다. 로컬 제공업체는 키가 필요 없지만 서버가 실행 중이어야 합니다.",
|
||||
"llmp_local_tag": "로컬",
|
||||
@@ -286,36 +282,7 @@
|
||||
"models_dir_effective": "현재 사용 중",
|
||||
"models_dir_configured": "설정됨",
|
||||
"models_dir_default": "기본값 사용",
|
||||
"models_dir_restart": "↻ 새 위치를 사용하려면 VoiceStudio를 다시 시작하세요.",
|
||||
"worker_join": "참여",
|
||||
"worker_join_code": "참여 코드",
|
||||
"worker_join_code_hint": "일회용이며 15분 후 만료됩니다. 작업을 보낼 컴퓨터에서 생성하세요.",
|
||||
"worker_join_desc": "다른 VoiceStudio가 이 컴퓨터로 작업을 보낼 수 있게 합니다. 그쪽에 표시된 참여 코드를 붙여 넣거나, QR을 휴대폰으로 스캔해 여기에 붙여 넣으세요.",
|
||||
"worker_join_env": "이 컴퓨터의 환경에 OMNIVOICE_WORKER_MODE가 설정되어 있어 그 값이 우선합니다. 변경은 거기에서 하세요.",
|
||||
"worker_join_no_endpoint": "기억된 컨트롤 플레인이 없습니다.",
|
||||
"worker_join_ok": "참여했습니다. 이 컴퓨터가 이제 작업을 받고 있습니다.",
|
||||
"worker_join_placeholder": "ovw_…",
|
||||
"worker_join_rejoin": "다른 곳에 참여",
|
||||
"worker_join_stopped": "중지됨",
|
||||
"worker_join_take_work": "작업을 받는 곳",
|
||||
"worker_join_title": "이 컴퓨터의 GPU 빌려주기",
|
||||
"worker_join_working": "작업 중",
|
||||
"workers_add_hint_qr": "토큰을 생성한 뒤 다른 컴퓨터에서 QR을 스캔하거나, 그 컴퓨터의 원격 워커 설정에 코드를 붙여 넣으세요.",
|
||||
"workers_approve": "승인",
|
||||
"workers_last_seen": "마지막 접속 {{when}}",
|
||||
"workers_qr_alt": "이 코드를 담은 QR 코드 — 다른 컴퓨터에서 스캔하세요",
|
||||
"workers_secret_done": "완료",
|
||||
"workers_seen_hr": "{{count}}시간 전",
|
||||
"workers_seen_min": "{{count}}분 전",
|
||||
"workers_seen_now": "방금 전",
|
||||
"workers_step_1": "GPU가 있는 컴퓨터에 VoiceStudio를 설치하세요.",
|
||||
"workers_step_2": "위에서 토큰을 생성하세요.",
|
||||
"workers_step_3": "거기에서 QR을 스캔하거나, 그 컴퓨터의 원격 워커 설정에 코드를 붙여 넣으세요.",
|
||||
"workers_summary_none": "연결된 워커 없음",
|
||||
"workers_summary_online": "{{count}}대 온라인",
|
||||
"workers_token_expired": "만료됨 — 새로 생성하세요",
|
||||
"workers_token_expires_in": "{{time}} 후 만료",
|
||||
"workers_token_qr_hint": "다른 컴퓨터에서: 설정 → 시스템 → 원격 워커 → 참여로 이동한 뒤 스캔하거나 붙여 넣으세요."
|
||||
"models_dir_restart": "↻ 새 위치를 사용하려면 VoiceStudio를 다시 시작하세요."
|
||||
},
|
||||
"bootstrap": {
|
||||
"title": "VoiceStudio",
|
||||
@@ -581,15 +548,6 @@
|
||||
"define_from_audio": "오디오에서",
|
||||
"define_voice": "음성 정의",
|
||||
"save_design_as_profile": "디자인을 프로필로 저장",
|
||||
"generating_done_status": "생성 완료",
|
||||
"generating_status": "오디오 생성 중…",
|
||||
"identity": "목소리 특성",
|
||||
"identity_auto": "자동 — 모델이 결정합니다",
|
||||
"insert": "삽입",
|
||||
"insert_token": "표현 토큰 삽입",
|
||||
"script": "대본",
|
||||
"starting_points": "시작점",
|
||||
"voice_kicker": "음성",
|
||||
"seed_label": "종자",
|
||||
"seed_placeholder": "매번 무작위로",
|
||||
"seed_keep": "이 씨앗을 보관하세요",
|
||||
@@ -710,7 +668,6 @@
|
||||
"ready": "준비",
|
||||
"unavailable": "이용할 수 없음",
|
||||
"use": "사용",
|
||||
"configureProviders": "제공업체 설정",
|
||||
"loading": "엔진 로드 중…",
|
||||
"refresh": "새로고침",
|
||||
"matrixTitle": "엔진 호환성 매트릭스",
|
||||
@@ -720,9 +677,7 @@
|
||||
"activeEngine": "활성 {{family}}: {{engine}}",
|
||||
"engineCompatLabel": "{{family}} 엔진 호환성",
|
||||
"active": "활성",
|
||||
"whyUnavailable": "필요한 것",
|
||||
"sectionReady": "바로 사용 가능",
|
||||
"sectionMore": "엔진 추가",
|
||||
"whyUnavailable": "왜 사용할 수 없나요?",
|
||||
"lastError": "마지막 오류: {{error}}",
|
||||
"installedAndReady": "설치 및 준비 완료",
|
||||
"notInstalled": "설치되지 않음",
|
||||
@@ -1335,9 +1290,6 @@
|
||||
"reset": "재설정",
|
||||
"preview": "미리보기",
|
||||
"use_voice": "음성 사용",
|
||||
"more_actions": "추가 작업",
|
||||
"use_in_stories": "스토리에서 사용",
|
||||
"set_audiobook_default": "오디오북 기본 음성으로 설정",
|
||||
"open_designer": "디자이너에서 열기",
|
||||
"no_matches": "이 필터와 일치하는 음성이 없습니다.",
|
||||
"load_more": "더 로드하기",
|
||||
@@ -1636,9 +1588,7 @@
|
||||
"searchIssues": "유사한 문제 검색",
|
||||
"unexpected": "예기치 않은 오류: {{message}}",
|
||||
"backend_shutting_down": "VoiceStudio를 종료하는 중입니다. 앱을 다시 열고 시도하세요.",
|
||||
"crash_broken_env": "자체 Python 의존성을 불러오는 도중에 종료됐습니다. 메모리나 GPU 문제가 아니라 환경이 불완전하거나 업데이트가 중간에 멈춘 상태입니다. 설정 → 로그 → 백엔드 의 \"정리 후 재시도\"를 사용하세요. 환경을 처음부터 다시 만들어 그 자리에서 복구하며, 음성이나 프로젝트는 건드리지 않습니다. 그래도 실패하면 크래시 세부 정보에 가져오지 못한 패키지 이름이 나옵니다.",
|
||||
"crash_vram_default": "작은 GPU에서는 TTS 모델이 로드된 상태에서 ASR 모델을 불러오는 동안 VRAM이 부족한 것이 흔한 원인입니다. 먼저 TTS 모델을 언로드하거나, 모델 카탈로그 → 모델에서 더 작은 ASR 모델을 선택하세요.",
|
||||
"stream_cut_backend_alive": "스트림이 일찍 끝났지만 백엔드는 계속 실행 중입니다. 즉, 크래시는 아닙니다. 서버 또는 컨테이너 환경에서는 보통 리버스 프록시나 로드 밸런서가 연결을 버퍼링하거나 시간 초과시키는 것이 원인입니다. 이 경로의 응답 버퍼링을 끄고(nginx: proxy_buffering off; X-Accel-Buffering: no) 읽기 시간 제한을 늘리세요. 데스크톱 앱을 직접 실행하거나 프록시 없이 localhost에서 실행해 보면 확인할 수 있습니다."
|
||||
"crash_broken_env": "자체 Python 의존성을 불러오는 도중에 종료됐습니다. 메모리나 GPU 문제가 아니라 환경이 불완전하거나 업데이트가 중간에 멈춘 상태입니다. 설정 → 로그 → 백엔드 의 \"Clean & Retry\"를 사용하세요. 환경을 처음부터 다시 만들어 그 자리에서 복구하며, 음성이나 프로젝트는 건드리지 않습니다. 그래도 실패하면 크래시 세부 정보에 가져오지 못한 패키지 이름이 나옵니다."
|
||||
},
|
||||
"keyboard": {
|
||||
"title": "키보드 단축키",
|
||||
@@ -1646,8 +1596,6 @@
|
||||
"or": "또는",
|
||||
"nav": "네비게이션",
|
||||
"nav_cheatsheet": "이 치트시트를 보여주세요",
|
||||
"nav_enginePickerKey": "Cmd/Ctrl+E",
|
||||
"nav_workspacesKey": "Cmd/Ctrl+1–9",
|
||||
"nav_closeModal": "모달 닫기/취소",
|
||||
"nav_save": "프로젝트 저장/트림 커밋",
|
||||
"segmentEditor": "세그먼트 편집기",
|
||||
@@ -2583,22 +2531,5 @@
|
||||
"longform": "스토리 내레이션",
|
||||
"asr": "전사"
|
||||
}
|
||||
},
|
||||
"compute": {
|
||||
"add_machine": "컴퓨터 추가",
|
||||
"manage": "원격 워커 설정",
|
||||
"off_hint": "모든 작업이 이 컴퓨터에서 실행됩니다. 다른 컴퓨터를 사용하려면 원격을 켜세요.",
|
||||
"quick_settings": "컴퓨팅 — 작업 실행 위치",
|
||||
"remote": "원격",
|
||||
"title": "작업 실행 위치",
|
||||
"token_once": "다른 컴퓨터에서 이 코드를 스캔하거나 붙여 넣으세요. 한 번만 표시됩니다."
|
||||
},
|
||||
"voices": {
|
||||
"active": "사용 중인 음성",
|
||||
"active_clone_recipe": "참조 클립에서 복제됨",
|
||||
"cta_clone": "음성 ← 에 3초 클립을 놓아 복제하세요",
|
||||
"cta_design": "음성 ← 에서 설명해 디자인하세요",
|
||||
"new": "새 음성",
|
||||
"none_selected": "선택된 음성이 없습니다 — 설명하거나, 오디오를 놓거나, 아래에서 선택하세요."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,8 +36,6 @@
|
||||
"open_models": "Modellen openen"
|
||||
},
|
||||
"settings": {
|
||||
"remote_backend_test": "Verbinding testen",
|
||||
"remote_backend_save": "Opslaan en herladen",
|
||||
"remote_backend_recovery_title": "Kan de externe backend niet bereiken",
|
||||
"remote_backend_recovery_hint": "Je kunt de externe backend later wijzigen via Instellingen → Delen.",
|
||||
"remote_backend_use_local": "Lokale backend gebruiken",
|
||||
@@ -60,8 +58,6 @@
|
||||
"credentials": "Inloggegevens",
|
||||
"llm_providers": "LLM-providers",
|
||||
"llmp_desc": "Drijft de Cinematic- en Autofit-vertaling aan — de LLM herschrijft elke regel zodat die binnen het tijdsbudget van zijn segment past en de videotiming behouden blijft. Sleutels worden versleuteld opgeslagen; lokale providers (Ollama/LM Studio) blijven volledig offline.",
|
||||
"llmp_catalogue_note": "De als actief gemarkeerde provider antwoordt zodra de LLM-enginefamilie in de catalogus op \"OpenAI-compatibel\" staat.",
|
||||
"llmp_catalogue_link": "LLM-familie in de catalogus openen",
|
||||
"llmp_provider": "Provider",
|
||||
"llmp_provider_hint": "Kies een provider om te configureren. De actieve wordt gebruikt voor Cinematic/Autofit-vertaling. Lokale providers hebben geen sleutel nodig, maar hun server moet wel draaien.",
|
||||
"llmp_local_tag": "lokaal",
|
||||
@@ -286,36 +282,7 @@
|
||||
"models_dir_effective": "Nu in gebruik",
|
||||
"models_dir_configured": "Geconfigureerd",
|
||||
"models_dir_default": "Standaard wordt gebruikt",
|
||||
"models_dir_restart": "↻ Start VoiceStudio opnieuw om de nieuwe locatie te gebruiken.",
|
||||
"worker_join": "Koppelen",
|
||||
"worker_join_code": "Koppelcode",
|
||||
"worker_join_code_hint": "Eenmalig te gebruiken en verloopt over 15 minuten. Genereer hem op de machine die het werk gaat versturen.",
|
||||
"worker_join_desc": "Laat een andere kopie van VoiceStudio taken naar deze machine sturen. Plak de koppelcode die daar werd getoond — of scan de QR met je telefoon en plak hem hier.",
|
||||
"worker_join_env": "OMNIVOICE_WORKER_MODE is ingesteld in de omgeving van deze machine, dus die bepaalt het — wijzig het daar.",
|
||||
"worker_join_no_endpoint": "Geen control plane onthouden.",
|
||||
"worker_join_ok": "Gekoppeld. Deze machine neemt nu werk aan.",
|
||||
"worker_join_placeholder": "ovw_…",
|
||||
"worker_join_rejoin": "Aan een andere koppelen",
|
||||
"worker_join_stopped": "Gestopt",
|
||||
"worker_join_take_work": "Werk aannemen van",
|
||||
"worker_join_title": "De GPU van deze machine uitlenen",
|
||||
"worker_join_working": "Aan het werk",
|
||||
"workers_add_hint_qr": "Genereer een token, scan daarna de QR vanaf de andere machine of plak de code in de instellingen voor Externe workers van die machine.",
|
||||
"workers_approve": "Goedkeuren",
|
||||
"workers_last_seen": "laatst gezien {{when}}",
|
||||
"workers_qr_alt": "QR-code met deze code — scan hem vanaf de andere machine",
|
||||
"workers_secret_done": "Klaar",
|
||||
"workers_seen_hr": "{{count}} u geleden",
|
||||
"workers_seen_min": "{{count}} min geleden",
|
||||
"workers_seen_now": "zojuist",
|
||||
"workers_step_1": "Installeer VoiceStudio op de machine met de GPU.",
|
||||
"workers_step_2": "Genereer hierboven een token.",
|
||||
"workers_step_3": "Scan daar de QR, of plak de code in de instellingen voor Externe workers van die machine.",
|
||||
"workers_summary_none": "Niemand verbonden",
|
||||
"workers_summary_online": "{{count}} online",
|
||||
"workers_token_expired": "Verlopen — genereer een nieuwe",
|
||||
"workers_token_expires_in": "Verloopt over {{time}}",
|
||||
"workers_token_qr_hint": "Op de andere machine: Instellingen → Systeem → Externe workers → Koppelen, en scan of plak daar."
|
||||
"models_dir_restart": "↻ Start VoiceStudio opnieuw om de nieuwe locatie te gebruiken."
|
||||
},
|
||||
"bootstrap": {
|
||||
"title": "VoiceStudio",
|
||||
@@ -585,16 +552,7 @@
|
||||
"seed_placeholder": "elke keer willekeurig",
|
||||
"seed_keep": "Bewaar dit zaad",
|
||||
"seed_reroll": "Nieuw zaad",
|
||||
"seed_reroll_hint": "Rol een nieuw willekeurig zaadje en bewaar het",
|
||||
"generating_done_status": "Generatie voltooid",
|
||||
"generating_status": "Audio genereren…",
|
||||
"identity": "Identiteit",
|
||||
"identity_auto": "Automatisch — het model beslist",
|
||||
"insert": "Invoegen",
|
||||
"insert_token": "Expressietoken invoegen",
|
||||
"script": "Script",
|
||||
"starting_points": "Startpunten",
|
||||
"voice_kicker": "Stem"
|
||||
"seed_reroll_hint": "Rol een nieuw willekeurig zaadje en bewaar het"
|
||||
},
|
||||
"about": {
|
||||
"app": "App",
|
||||
@@ -710,7 +668,6 @@
|
||||
"ready": "klaar",
|
||||
"unavailable": "niet beschikbaar",
|
||||
"use": "Gebruik",
|
||||
"configureProviders": "Providers configureren",
|
||||
"loading": "Motoren laden…",
|
||||
"refresh": "Vernieuwen",
|
||||
"matrixTitle": "Motorcompatibiliteitsmatrix",
|
||||
@@ -720,9 +677,7 @@
|
||||
"activeEngine": "Actief {{family}}: {{engine}}",
|
||||
"engineCompatLabel": "{{family}} motorcompatibiliteit",
|
||||
"active": "actief",
|
||||
"whyUnavailable": "Wat het nodig heeft",
|
||||
"sectionReady": "Klaar voor gebruik",
|
||||
"sectionMore": "Meer motoren toevoegen",
|
||||
"whyUnavailable": "Waarom niet beschikbaar?",
|
||||
"lastError": "Laatste fout: {{error}}",
|
||||
"installedAndReady": "Geïnstalleerd en klaar",
|
||||
"notInstalled": "Niet geïnstalleerd",
|
||||
@@ -1335,9 +1290,6 @@
|
||||
"reset": "Opnieuw instellen",
|
||||
"preview": "Voorbeeld",
|
||||
"use_voice": "Gebruik stem",
|
||||
"more_actions": "Meer acties",
|
||||
"use_in_stories": "In Verhalen gebruiken",
|
||||
"set_audiobook_default": "Instellen als standaardstem voor Audioboek",
|
||||
"open_designer": "Openen in Ontwerper",
|
||||
"no_matches": "Er zijn geen stemmen die overeenkomen met deze filters.",
|
||||
"load_more": "Laad meer",
|
||||
@@ -1636,9 +1588,7 @@
|
||||
"searchIssues": "Vergelijkbare problemen zoeken",
|
||||
"unexpected": "Onverwachte fout: {{message}}",
|
||||
"backend_shutting_down": "VoiceStudio wordt afgesloten. Open de app opnieuw en probeer het nog eens.",
|
||||
"crash_broken_env": "Hij stierf tijdens het laden van zijn eigen Python-afhankelijkheden, dus dit gaat niet over geheugen of je GPU — de omgeving is onvolledig of half bijgewerkt blijven staan. Gebruik \"Wissen & Opnieuw proberen\" bij Instellingen → Logs → Backend: dat bouwt hem helemaal opnieuw op en repareert hem ter plekke, zonder je stemmen of projecten aan te raken. Blijft het misgaan, dan noemen de crashdetails het pakket dat niet te importeren was.",
|
||||
"crash_vram_default": "Op kleinere GPU's is de gebruikelijke oorzaak dat het VRAM-geheugen opraakt bij het laden van het ASR-model bovenop het TTS-model: ontlaad eerst het TTS-model, of kies een kleiner ASR-model in Modelcatalogus → Modellen.",
|
||||
"stream_cut_backend_alive": "De stream stopte te vroeg, maar de backend draait nog — hij is dus niet gecrasht. In een server- of containeropstelling is dit meestal een reverse proxy of load balancer die de verbinding buffert of door een time-out afbreekt: schakel responsbuffering voor deze route uit (nginx: proxy_buffering off; X-Accel-Buffering: no) en verhoog de leestime-out ervan. Draai je de desktopapp direct, of op localhost zonder proxy, dan bevestigt dat het."
|
||||
"crash_broken_env": "Hij stierf tijdens het laden van zijn eigen Python-afhankelijkheden, dus dit gaat niet over geheugen of je GPU — de omgeving is onvolledig of half bijgewerkt blijven staan. Gebruik \"Clean & Retry\" bij Instellingen → Logs → Backend: dat bouwt hem helemaal opnieuw op en repareert hem ter plekke, zonder je stemmen of projecten aan te raken. Blijft het misgaan, dan noemen de crashdetails het pakket dat niet te importeren was."
|
||||
},
|
||||
"keyboard": {
|
||||
"title": "Sneltoetsen",
|
||||
@@ -1646,8 +1596,6 @@
|
||||
"or": "of",
|
||||
"nav": "Navigatie",
|
||||
"nav_cheatsheet": "Laat dit spiekbriefje zien",
|
||||
"nav_enginePickerKey": "Cmd/Ctrl+E",
|
||||
"nav_workspacesKey": "Cmd/Ctrl+1–9",
|
||||
"nav_closeModal": "Modaal sluiten / annuleren",
|
||||
"nav_save": "Project opslaan / trimmen vastleggen",
|
||||
"segmentEditor": "Segmenteditor",
|
||||
@@ -2583,22 +2531,5 @@
|
||||
"longform": "verhaalvertelling",
|
||||
"asr": "transcriptie"
|
||||
}
|
||||
},
|
||||
"compute": {
|
||||
"add_machine": "Machine toevoegen",
|
||||
"manage": "Instellingen voor externe workers",
|
||||
"off_hint": "Alles draait op deze machine. Zet Extern aan om een andere te gebruiken.",
|
||||
"quick_settings": "Rekenkracht — waar taken draaien",
|
||||
"remote": "Extern",
|
||||
"title": "Waar taken draaien",
|
||||
"token_once": "Scan of plak dit op de andere machine. Wordt maar één keer getoond."
|
||||
},
|
||||
"voices": {
|
||||
"active": "Actieve stem",
|
||||
"active_clone_recipe": "Gekloond van je referentieclip",
|
||||
"cta_clone": "Zet een clip van 3 s neer in Stem ← om er een te klonen",
|
||||
"cta_design": "Beschrijf een stem in Stem ← om die te ontwerpen",
|
||||
"new": "Nieuwe stem",
|
||||
"none_selected": "Geen stem geselecteerd — beschrijf er een, zet audio neer of kies hieronder."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,8 +36,6 @@
|
||||
"open_models": "Otwórz modele"
|
||||
},
|
||||
"settings": {
|
||||
"remote_backend_test": "Testuj połączenie",
|
||||
"remote_backend_save": "Zapisz i wczytaj ponownie",
|
||||
"remote_backend_recovery_title": "Nie można połączyć się ze zdalnym backendem",
|
||||
"remote_backend_recovery_hint": "Zdalny backend możesz później zmienić w Ustawienia → Udostępnianie.",
|
||||
"remote_backend_use_local": "Użyj lokalnego backendu",
|
||||
@@ -60,8 +58,6 @@
|
||||
"credentials": "Dane uwierzytelniające",
|
||||
"llm_providers": "Dostawcy LLM",
|
||||
"llmp_desc": "Zasila tłumaczenie Cinematic i Autofit — LLM przeredagowuje każdą linię tak, aby zmieściła się w budżecie czasowym swojego segmentu i synchronizacja wideo została zachowana. Klucze są przechowywane w postaci zaszyfrowanej; lokalni dostawcy (Ollama/LM Studio) działają całkowicie offline.",
|
||||
"llmp_catalogue_note": "Dostawca oznaczony jako aktywny odpowiada zawsze, gdy rodzina silników LLM w katalogu jest ustawiona na „zgodny z OpenAI”.",
|
||||
"llmp_catalogue_link": "Otwórz rodzinę LLM w katalogu",
|
||||
"llmp_provider": "Dostawca",
|
||||
"llmp_provider_hint": "Wybierz dostawcę do skonfigurowania. Aktywny jest używany do tłumaczenia Cinematic/Autofit. Lokalni dostawcy nie wymagają klucza, ale ich serwer musi być uruchomiony.",
|
||||
"llmp_local_tag": "lokalny",
|
||||
@@ -286,36 +282,7 @@
|
||||
"models_dir_effective": "Używane teraz",
|
||||
"models_dir_configured": "Skonfigurowano",
|
||||
"models_dir_default": "Używana domyślna",
|
||||
"models_dir_restart": "↻ Uruchom ponownie VoiceStudio, aby użyć nowej lokalizacji.",
|
||||
"worker_join": "Dołącz",
|
||||
"worker_join_code": "Kod dołączania",
|
||||
"worker_join_code_hint": "Jednorazowy, wygasa po 15 minutach. Wygeneruj go na komputerze, który będzie wysyłać zadania.",
|
||||
"worker_join_desc": "Pozwól innej kopii VoiceStudio wysyłać zadania na ten komputer. Wklej kod dołączania, który ci pokazała — albo zeskanuj jej kod QR telefonem i wklej go tutaj.",
|
||||
"worker_join_env": "W środowisku tego komputera ustawiono OMNIVOICE_WORKER_MODE, więc to ta zmienna decyduje — zmień ją tam.",
|
||||
"worker_join_no_endpoint": "Brak zapamiętanego węzła sterującego.",
|
||||
"worker_join_ok": "Dołączono. Ten komputer przyjmuje teraz zadania.",
|
||||
"worker_join_placeholder": "ovw_…",
|
||||
"worker_join_rejoin": "Dołącz do innego",
|
||||
"worker_join_stopped": "Zatrzymano",
|
||||
"worker_join_take_work": "Przyjmuj zadania od",
|
||||
"worker_join_title": "Użycz GPU tego komputera",
|
||||
"worker_join_working": "Pracuje",
|
||||
"workers_add_hint_qr": "Wygeneruj token, a następnie zeskanuj kod QR z drugiego komputera albo wklej kod w jego ustawieniach „Zdalne workery”.",
|
||||
"workers_approve": "Zatwierdź",
|
||||
"workers_last_seen": "ostatnio widziany {{when}}",
|
||||
"workers_qr_alt": "Kod QR z tym kodem — zeskanuj go z drugiego komputera",
|
||||
"workers_secret_done": "Gotowe",
|
||||
"workers_seen_hr": "{{count}} godz. temu",
|
||||
"workers_seen_min": "{{count}} min temu",
|
||||
"workers_seen_now": "przed chwilą",
|
||||
"workers_step_1": "Zainstaluj VoiceStudio na komputerze z GPU.",
|
||||
"workers_step_2": "Wygeneruj token powyżej.",
|
||||
"workers_step_3": "Zeskanuj tam kod QR albo wklej kod w jego ustawieniach „Zdalne workery”.",
|
||||
"workers_summary_none": "Nikt nie jest połączony",
|
||||
"workers_summary_online": "{{count}} online",
|
||||
"workers_token_expired": "Wygasł — wygeneruj nowy",
|
||||
"workers_token_expires_in": "Wygasa za {{time}}",
|
||||
"workers_token_qr_hint": "Na drugim komputerze: Ustawienia → Systemu → Zdalne workery → Dołącz, potem zeskanuj albo wklej."
|
||||
"models_dir_restart": "↻ Uruchom ponownie VoiceStudio, aby użyć nowej lokalizacji."
|
||||
},
|
||||
"bootstrap": {
|
||||
"title": "VoiceStudio",
|
||||
@@ -585,16 +552,7 @@
|
||||
"seed_placeholder": "za każdym razem losowo",
|
||||
"seed_keep": "Zachowaj to ziarno",
|
||||
"seed_reroll": "Nowe ziarno",
|
||||
"seed_reroll_hint": "Rzuć nowe losowe ziarno i zachowaj je",
|
||||
"generating_done_status": "Generowanie zakończone",
|
||||
"generating_status": "Generowanie dźwięku…",
|
||||
"identity": "Tożsamość",
|
||||
"identity_auto": "Auto — decyduje model",
|
||||
"insert": "Wstaw",
|
||||
"insert_token": "Wstaw token ekspresji",
|
||||
"script": "Skrypt",
|
||||
"starting_points": "Punkty wyjścia",
|
||||
"voice_kicker": "Głos"
|
||||
"seed_reroll_hint": "Rzuć nowe losowe ziarno i zachowaj je"
|
||||
},
|
||||
"about": {
|
||||
"app": "Aplikacja",
|
||||
@@ -710,7 +668,6 @@
|
||||
"ready": "gotowy",
|
||||
"unavailable": "niedostępne",
|
||||
"use": "Użyj",
|
||||
"configureProviders": "Skonfiguruj dostawców",
|
||||
"loading": "Ładowanie silników…",
|
||||
"refresh": "Odśwież",
|
||||
"matrixTitle": "Matryca kompatybilności silników",
|
||||
@@ -720,9 +677,7 @@
|
||||
"activeEngine": "Aktywny {{family}}: {{engine}}",
|
||||
"engineCompatLabel": "{{family}} kompatybilność silnika",
|
||||
"active": "aktywny",
|
||||
"whyUnavailable": "Czego potrzebuje",
|
||||
"sectionReady": "Gotowe do użycia",
|
||||
"sectionMore": "Dodaj więcej silników",
|
||||
"whyUnavailable": "Dlaczego niedostępne?",
|
||||
"lastError": "Ostatni błąd: {{error}}",
|
||||
"installedAndReady": "Zainstalowany i gotowy",
|
||||
"notInstalled": "Nie zainstalowano",
|
||||
@@ -1335,9 +1290,6 @@
|
||||
"reset": "Zresetuj",
|
||||
"preview": "Podgląd",
|
||||
"use_voice": "Użyj głosu",
|
||||
"more_actions": "Więcej akcji",
|
||||
"use_in_stories": "Użyj w Historiach",
|
||||
"set_audiobook_default": "Ustaw jako domyślny głos książki audio",
|
||||
"open_designer": "Otwórz w Projektancie",
|
||||
"no_matches": "Żaden głos nie pasuje do tych filtrów.",
|
||||
"load_more": "Załaduj więcej",
|
||||
@@ -1636,9 +1588,7 @@
|
||||
"searchIssues": "Szukaj podobnych problemów",
|
||||
"unexpected": "Nieoczekiwany błąd: {{message}}",
|
||||
"backend_shutting_down": "VoiceStudio się zamyka. Otwórz aplikację ponownie i spróbuj jeszcze raz.",
|
||||
"crash_broken_env": "Zakończył się podczas ładowania własnych zależności Pythona, więc nie chodzi o pamięć ani o kartę graficzną — środowisko jest niekompletne albo zostało zaktualizowane w połowie. Użyj „Wyczyść i ponów” w Ustawienia → Logi → Backend: odbudowuje je od zera i naprawia w miejscu, nie ruszając twoich głosów ani projektów. Jeśli nadal się nie udaje, szczegóły awarii wskazują pakiet, którego nie dało się zaimportować.",
|
||||
"crash_vram_default": "Na mniejszych GPU zwykłą przyczyną jest brak pamięci VRAM podczas ładowania modelu ASR obok już załadowanego modelu TTS: najpierw zwolnij model TTS albo wybierz mniejszy model ASR w Katalogu modeli → Modele.",
|
||||
"stream_cut_backend_alive": "Strumień urwał się przedwcześnie, ale backend nadal działa — a więc nie uległ awarii. W konfiguracji serwerowej lub kontenerowej zwykle oznacza to, że odwrotne proxy albo load balancer buforuje połączenie lub przerywa je po limicie czasu: wyłącz buforowanie odpowiedzi dla tej trasy (nginx: proxy_buffering off; X-Accel-Buffering: no) i zwiększ jego limit czasu odczytu. Uruchomienie aplikacji desktopowej bezpośrednio albo na localhost bez proxy pozwoli to potwierdzić."
|
||||
"crash_broken_env": "Zakończył się podczas ładowania własnych zależności Pythona, więc nie chodzi o pamięć ani o kartę graficzną — środowisko jest niekompletne albo zostało zaktualizowane w połowie. Użyj „Clean & Retry” w Ustawienia → Logi → Backend: odbudowuje je od zera i naprawia w miejscu, nie ruszając twoich głosów ani projektów. Jeśli nadal się nie udaje, szczegóły awarii wskazują pakiet, którego nie dało się zaimportować."
|
||||
},
|
||||
"keyboard": {
|
||||
"title": "Skróty klawiaturowe",
|
||||
@@ -1646,8 +1596,6 @@
|
||||
"or": "lub",
|
||||
"nav": "Nawigacja",
|
||||
"nav_cheatsheet": "Pokaż tę ściągawkę",
|
||||
"nav_enginePickerKey": "Cmd/Ctrl+E",
|
||||
"nav_workspacesKey": "Cmd/Ctrl+1–9",
|
||||
"nav_closeModal": "Zamknij modalne / anuluj",
|
||||
"nav_save": "Zapisz projekt / zatwierdź przycięcie",
|
||||
"segmentEditor": "Edytor segmentów",
|
||||
@@ -2583,22 +2531,5 @@
|
||||
"longform": "narracja opowiadań",
|
||||
"asr": "transkrypcja"
|
||||
}
|
||||
},
|
||||
"compute": {
|
||||
"add_machine": "Dodaj komputer",
|
||||
"manage": "Ustawienia zdalnych workerów",
|
||||
"off_hint": "Wszystko działa na tym komputerze. Włącz „Zdalnie”, aby użyć innego.",
|
||||
"quick_settings": "Obliczenia — gdzie wykonywane są zadania",
|
||||
"remote": "Zdalnie",
|
||||
"title": "Gdzie wykonywane są zadania",
|
||||
"token_once": "Zeskanuj lub wklej to na drugim komputerze. Pokazywane tylko raz."
|
||||
},
|
||||
"voices": {
|
||||
"active": "Aktywny głos",
|
||||
"active_clone_recipe": "Sklonowany z twojego klipu referencyjnego",
|
||||
"cta_clone": "Upuść 3-sekundowy klip w „Głos” ←, aby sklonować głos",
|
||||
"cta_design": "Opisz głos w „Głos” ←, aby go zaprojektować",
|
||||
"new": "Nowy głos",
|
||||
"none_selected": "Nie wybrano głosu — opisz go, upuść audio albo wybierz poniżej."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,8 +36,6 @@
|
||||
"open_models": "Abrir modelos"
|
||||
},
|
||||
"settings": {
|
||||
"remote_backend_test": "Testar conexão",
|
||||
"remote_backend_save": "Salvar e recarregar",
|
||||
"remote_backend_recovery_title": "Não foi possível acessar o backend remoto",
|
||||
"remote_backend_recovery_hint": "Você pode alterar o backend remoto depois em Configurações → Compartilhamento.",
|
||||
"remote_backend_use_local": "Usar backend local",
|
||||
@@ -60,8 +58,6 @@
|
||||
"credentials": "Credenciais",
|
||||
"llm_providers": "Provedores de LLM",
|
||||
"llmp_desc": "Alimenta a tradução Cinematic e Autofit — o LLM reescreve cada linha para caber no tempo do seu segmento, mantendo a sincronização do vídeo. As chaves são armazenadas criptografadas; provedores locais (Ollama/LM Studio) permanecem totalmente offline.",
|
||||
"llmp_catalogue_note": "O provedor marcado como ativo é o que responde sempre que a família de motores LLM no catálogo estiver definida como \"compatível com OpenAI\".",
|
||||
"llmp_catalogue_link": "Abrir a família LLM no catálogo",
|
||||
"llmp_provider": "Provedor",
|
||||
"llmp_provider_hint": "Escolha um provedor para configurar. O ativo é usado na tradução Cinematic/Autofit. Provedores locais não precisam de chave, mas o servidor deles precisa estar em execução.",
|
||||
"llmp_local_tag": "local",
|
||||
@@ -286,36 +282,7 @@
|
||||
"models_dir_effective": "Em uso agora",
|
||||
"models_dir_configured": "Configurado",
|
||||
"models_dir_default": "Usando o padrão",
|
||||
"models_dir_restart": "↻ Reinicie o VoiceStudio para usar o novo local.",
|
||||
"worker_join": "Associar",
|
||||
"worker_join_code": "Código de associação",
|
||||
"worker_join_code_hint": "De uso único e expira em 15 minutos. Gere-o na máquina que vai enviar o trabalho.",
|
||||
"worker_join_desc": "Permita que outra cópia do VoiceStudio envie trabalhos para esta máquina. Cole o código de associação que ela mostrou — ou escaneie o QR dela com o telefone e cole-o aqui.",
|
||||
"worker_join_env": "OMNIVOICE_WORKER_MODE está definido no ambiente desta máquina, então é ele que decide — altere-o lá.",
|
||||
"worker_join_no_endpoint": "Nenhum plano de controle memorizado.",
|
||||
"worker_join_ok": "Associado. Esta máquina agora aceita trabalho.",
|
||||
"worker_join_placeholder": "ovw_…",
|
||||
"worker_join_rejoin": "Associar a outro",
|
||||
"worker_join_stopped": "Parado",
|
||||
"worker_join_take_work": "Aceitar trabalho de",
|
||||
"worker_join_title": "Emprestar a GPU desta máquina",
|
||||
"worker_join_working": "Em atividade",
|
||||
"workers_add_hint_qr": "Gere um token, depois escaneie o QR a partir da outra máquina ou cole o código nas configurações de Workers remotos dela.",
|
||||
"workers_approve": "Aprovar",
|
||||
"workers_last_seen": "visto pela última vez {{when}}",
|
||||
"workers_qr_alt": "Código QR com este código — escaneie-o a partir da outra máquina",
|
||||
"workers_secret_done": "Concluído",
|
||||
"workers_seen_hr": "há {{count}} h",
|
||||
"workers_seen_min": "há {{count}} min",
|
||||
"workers_seen_now": "agora mesmo",
|
||||
"workers_step_1": "Instale o VoiceStudio na máquina com a GPU.",
|
||||
"workers_step_2": "Gere um token acima.",
|
||||
"workers_step_3": "Escaneie o QR lá, ou cole o código nas configurações de Workers remotos dela.",
|
||||
"workers_summary_none": "Ninguém conectado",
|
||||
"workers_summary_online": "{{count}} online",
|
||||
"workers_token_expired": "Expirado — gere um novo",
|
||||
"workers_token_expires_in": "Expira em {{time}}",
|
||||
"workers_token_qr_hint": "Na outra máquina: Configurações → Sistema → Workers remotos → Associar, depois escaneie ou cole."
|
||||
"models_dir_restart": "↻ Reinicie o VoiceStudio para usar o novo local."
|
||||
},
|
||||
"bootstrap": {
|
||||
"title": "VoiceStudio",
|
||||
@@ -581,15 +548,6 @@
|
||||
"define_from_audio": "A partir de áudio",
|
||||
"define_voice": "Definir voz",
|
||||
"save_design_as_profile": "Salvar design como perfil",
|
||||
"generating_done_status": "Geração concluída",
|
||||
"generating_status": "Gerando áudio…",
|
||||
"identity": "Identidade",
|
||||
"identity_auto": "Auto — o modelo decide",
|
||||
"insert": "Inserir",
|
||||
"insert_token": "Inserir token de expressão",
|
||||
"script": "Roteiro",
|
||||
"starting_points": "Pontos de partida",
|
||||
"voice_kicker": "Voz",
|
||||
"seed_label": "Semente",
|
||||
"seed_placeholder": "aleatório de cada vez",
|
||||
"seed_keep": "Guarde esta semente",
|
||||
@@ -710,7 +668,6 @@
|
||||
"ready": "pronto",
|
||||
"unavailable": "indisponível",
|
||||
"use": "Usar",
|
||||
"configureProviders": "Configurar provedores",
|
||||
"loading": "Carregando motores…",
|
||||
"refresh": "Atualizar",
|
||||
"matrixTitle": "Matriz de compatibilidade do motor",
|
||||
@@ -720,9 +677,7 @@
|
||||
"activeEngine": "Ativo {{family}}: {{engine}}",
|
||||
"engineCompatLabel": "{{family}} compatibilidade do motor",
|
||||
"active": "ativo",
|
||||
"whyUnavailable": "O que falta",
|
||||
"sectionReady": "Prontos para usar",
|
||||
"sectionMore": "Adicionar mais motores",
|
||||
"whyUnavailable": "Por que indisponível?",
|
||||
"lastError": "Último erro: {{error}}",
|
||||
"installedAndReady": "Instalado e pronto",
|
||||
"notInstalled": "Não instalado",
|
||||
@@ -1335,9 +1290,6 @@
|
||||
"reset": "Redefinir",
|
||||
"preview": "Visualização",
|
||||
"use_voice": "Usar voz",
|
||||
"more_actions": "Mais ações",
|
||||
"use_in_stories": "Usar em Histórias",
|
||||
"set_audiobook_default": "Definir como voz padrão do Audiolivro",
|
||||
"open_designer": "Abrir no Designer",
|
||||
"no_matches": "Nenhuma voz corresponde a esses filtros.",
|
||||
"load_more": "Carregar mais",
|
||||
@@ -1636,9 +1588,7 @@
|
||||
"searchIssues": "Pesquisar problemas semelhantes",
|
||||
"unexpected": "Erro inesperado: {{message}}",
|
||||
"backend_shutting_down": "O VoiceStudio está sendo encerrado. Reabra o aplicativo e tente novamente.",
|
||||
"crash_broken_env": "Ele morreu enquanto carregava as próprias dependências de Python, então não é questão de memória nem da sua GPU — o ambiente está incompleto ou ficou atualizado pela metade. Use \"Limpar e Repetir\" em Configurações → Logs → Backend, que o reconstrói do zero e o repara no lugar, sem tocar nas suas vozes ou projetos. Se continuar falhando, os detalhes da falha nomeiam o pacote exato que não importava.",
|
||||
"crash_vram_default": "Em GPUs menores, a causa habitual é ficar sem VRAM ao carregar o modelo ASR junto com o modelo TTS: descarregue primeiro o modelo TTS, ou escolha um modelo ASR menor em Catálogo de modelos → Modelos.",
|
||||
"stream_cut_backend_alive": "O stream terminou mais cedo, mas o backend continua em execução — portanto, não travou. Em uma instalação servida ou em contêiner, isso geralmente é um proxy reverso ou balanceador de carga fazendo buffer da conexão ou encerrando-a por tempo limite: desative o buffering de resposta para esta rota (nginx: proxy_buffering off; X-Accel-Buffering: no) e aumente o tempo limite de leitura. Executar o aplicativo desktop diretamente, ou em localhost sem proxy, confirmará isso."
|
||||
"crash_broken_env": "Ele morreu enquanto carregava as próprias dependências de Python, então não é questão de memória nem da sua GPU — o ambiente está incompleto ou ficou atualizado pela metade. Use \"Clean & Retry\" em Configurações → Logs → Backend, que o reconstrói do zero e o repara no lugar, sem tocar nas suas vozes ou projetos. Se continuar falhando, os detalhes da falha nomeiam o pacote exato que não importava."
|
||||
},
|
||||
"keyboard": {
|
||||
"title": "Atalhos de teclado",
|
||||
@@ -1646,8 +1596,6 @@
|
||||
"or": "ou",
|
||||
"nav": "Navegação",
|
||||
"nav_cheatsheet": "Mostrar esta folha de dicas",
|
||||
"nav_enginePickerKey": "Cmd/Ctrl+E",
|
||||
"nav_workspacesKey": "Cmd/Ctrl+1–9",
|
||||
"nav_closeModal": "Fechar modal/cancelar",
|
||||
"nav_save": "Salvar projeto/comprometer corte",
|
||||
"segmentEditor": "Editor de segmento",
|
||||
@@ -2583,22 +2531,5 @@
|
||||
"longform": "narração de histórias",
|
||||
"asr": "transcrição"
|
||||
}
|
||||
},
|
||||
"compute": {
|
||||
"add_machine": "Adicionar uma máquina",
|
||||
"manage": "Configurações de workers remotos",
|
||||
"off_hint": "Tudo é executado nesta máquina. Ative Remoto para usar outra.",
|
||||
"quick_settings": "Computação — onde os trabalhos são executados",
|
||||
"remote": "Remoto",
|
||||
"title": "Onde os trabalhos são executados",
|
||||
"token_once": "Escaneie ou cole isto na outra máquina. Mostrado apenas uma vez."
|
||||
},
|
||||
"voices": {
|
||||
"active": "Voz ativa",
|
||||
"active_clone_recipe": "Clonada a partir do seu clipe de referência",
|
||||
"cta_clone": "Solte um clipe de 3 s em Voz ← para clonar uma",
|
||||
"cta_design": "Descreva uma em Voz ← para desenhá-la",
|
||||
"new": "Nova voz",
|
||||
"none_selected": "Nenhuma voz selecionada — descreva uma, solte um áudio ou escolha abaixo."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,8 +36,6 @@
|
||||
"open_models": "Открыть модели"
|
||||
},
|
||||
"settings": {
|
||||
"remote_backend_test": "Проверить подключение",
|
||||
"remote_backend_save": "Сохранить и перезагрузить",
|
||||
"remote_backend_recovery_title": "Не удалось подключиться к удалённому бэкенду",
|
||||
"remote_backend_recovery_hint": "Позже удалённый бэкенд можно изменить в разделе «Настройки → Общий доступ».",
|
||||
"remote_backend_use_local": "Использовать локальный бэкенд",
|
||||
@@ -60,8 +58,6 @@
|
||||
"credentials": "Ключи",
|
||||
"llm_providers": "Поставщики LLM",
|
||||
"llmp_desc": "Обеспечивает перевод Cinematic и Autofit — LLM переписывает каждую строку так, чтобы она укладывалась в отведённое время своего сегмента и синхронизация видео сохранялась. Ключи хранятся в зашифрованном виде; локальные поставщики (Ollama/LM Studio) работают полностью офлайн.",
|
||||
"llmp_catalogue_note": "Отвечает именно поставщик, отмеченный как активный, когда семейство движков LLM в каталоге настроено на «совместимый с OpenAI».",
|
||||
"llmp_catalogue_link": "Открыть семейство LLM в каталоге",
|
||||
"llmp_provider": "Поставщик",
|
||||
"llmp_provider_hint": "Выберите поставщика для настройки. Активный используется для перевода Cinematic/Autofit. Локальным поставщикам ключ не нужен, но их сервер должен быть запущен.",
|
||||
"llmp_local_tag": "локальный",
|
||||
@@ -286,36 +282,7 @@
|
||||
"models_dir_effective": "Используется сейчас",
|
||||
"models_dir_configured": "Настроено",
|
||||
"models_dir_default": "Используется по умолчанию",
|
||||
"models_dir_restart": "↻ Перезапустите VoiceStudio, чтобы использовать новое расположение.",
|
||||
"worker_join": "Подключиться",
|
||||
"worker_join_code": "Код подключения",
|
||||
"worker_join_code_hint": "Одноразовый и истекает через 15 минут. Создайте его на машине, которая будет отправлять задачи.",
|
||||
"worker_join_desc": "Разрешите другой копии VoiceStudio отправлять задачи на эту машину. Вставьте код подключения, который она показала, — или отсканируйте её QR-код телефоном и вставьте его сюда.",
|
||||
"worker_join_env": "В окружении этой машины задана переменная OMNIVOICE_WORKER_MODE, поэтому решает она — меняйте её там.",
|
||||
"worker_join_no_endpoint": "Управляющий узел не запомнен.",
|
||||
"worker_join_ok": "Подключено. Эта машина теперь принимает задачи.",
|
||||
"worker_join_placeholder": "ovw_…",
|
||||
"worker_join_rejoin": "Подключиться к другой машине",
|
||||
"worker_join_stopped": "Остановлено",
|
||||
"worker_join_take_work": "Принимать задачи от",
|
||||
"worker_join_title": "Одолжить GPU этой машины",
|
||||
"worker_join_working": "Работает",
|
||||
"workers_add_hint_qr": "Создайте токен, затем отсканируйте QR-код с другой машины или вставьте код в её настройки «Удалённые воркеры».",
|
||||
"workers_approve": "Одобрить",
|
||||
"workers_last_seen": "последний раз в сети {{when}}",
|
||||
"workers_qr_alt": "QR-код с этим кодом — отсканируйте его с другой машины",
|
||||
"workers_secret_done": "Готово",
|
||||
"workers_seen_hr": "{{count}} ч назад",
|
||||
"workers_seen_min": "{{count}} мин назад",
|
||||
"workers_seen_now": "только что",
|
||||
"workers_step_1": "Установите VoiceStudio на машину с GPU.",
|
||||
"workers_step_2": "Создайте токен выше.",
|
||||
"workers_step_3": "Отсканируйте там QR-код или вставьте код в её настройки «Удалённые воркеры».",
|
||||
"workers_summary_none": "Никто не подключён",
|
||||
"workers_summary_online": "{{count}} в сети",
|
||||
"workers_token_expired": "Истёк — создайте новый",
|
||||
"workers_token_expires_in": "Истекает через {{time}}",
|
||||
"workers_token_qr_hint": "На другой машине: Настройки → Система → Удалённые воркеры → Подключиться, затем отсканируйте или вставьте."
|
||||
"models_dir_restart": "↻ Перезапустите VoiceStudio, чтобы использовать новое расположение."
|
||||
},
|
||||
"bootstrap": {
|
||||
"title": "VoiceStudio",
|
||||
@@ -585,16 +552,7 @@
|
||||
"seed_placeholder": "случайный каждый раз",
|
||||
"seed_keep": "Держи это семя",
|
||||
"seed_reroll": "Новое семя",
|
||||
"seed_reroll_hint": "Сгенерируйте новое случайное семя и сохраните его.",
|
||||
"generating_done_status": "Генерация завершена",
|
||||
"generating_status": "Генерируем аудио…",
|
||||
"identity": "Идентичность",
|
||||
"identity_auto": "Авто — решает модель",
|
||||
"insert": "Вставить",
|
||||
"insert_token": "Вставить токен экспрессии",
|
||||
"script": "Скрипт",
|
||||
"starting_points": "Отправные точки",
|
||||
"voice_kicker": "Голос"
|
||||
"seed_reroll_hint": "Сверните новое случайное семя и сохраните его."
|
||||
},
|
||||
"about": {
|
||||
"app": "Приложение",
|
||||
@@ -710,7 +668,6 @@
|
||||
"ready": "готовый",
|
||||
"unavailable": "недоступен",
|
||||
"use": "Использовать",
|
||||
"configureProviders": "Настроить поставщиков",
|
||||
"loading": "Загрузка двигателей…",
|
||||
"refresh": "Обновить",
|
||||
"matrixTitle": "Матрица совместимости двигателей",
|
||||
@@ -720,9 +677,7 @@
|
||||
"activeEngine": "Активен {{family}}: {{engine}}",
|
||||
"engineCompatLabel": "Совместимость с двигателем {{family}}",
|
||||
"active": "активный",
|
||||
"whyUnavailable": "Что нужно",
|
||||
"sectionReady": "Готовы к использованию",
|
||||
"sectionMore": "Добавить больше двигателей",
|
||||
"whyUnavailable": "Почему недоступен?",
|
||||
"lastError": "Последняя ошибка: {{error}}",
|
||||
"installedAndReady": "Установлен и готов",
|
||||
"notInstalled": "Не установлено",
|
||||
@@ -1335,9 +1290,6 @@
|
||||
"reset": "Перезагрузить",
|
||||
"preview": "Предварительный просмотр",
|
||||
"use_voice": "Использовать голос",
|
||||
"more_actions": "Дополнительные действия",
|
||||
"use_in_stories": "Использовать в Историях",
|
||||
"set_audiobook_default": "Назначить голосом по умолчанию для аудиокниги",
|
||||
"open_designer": "Открыть в дизайнере",
|
||||
"no_matches": "Ни один голос не соответствует этим фильтрам.",
|
||||
"load_more": "Загрузить больше",
|
||||
@@ -1636,9 +1588,7 @@
|
||||
"searchIssues": "Искать похожие проблемы",
|
||||
"unexpected": "Непредвиденная ошибка: {{message}}",
|
||||
"backend_shutting_down": "VoiceStudio завершает работу. Откройте приложение заново и повторите попытку.",
|
||||
"crash_broken_env": "Он завершился при загрузке собственных зависимостей Python, так что дело не в памяти и не в видеокарте — окружение неполное или обновилось наполовину. Используйте «Очистить и повторить» в Настройки → Логи → Бэкенд: это пересоберёт окружение с нуля и починит его на месте, не трогая ваши голоса и проекты. Если ошибка останется, в подробностях сбоя указан пакет, который не импортировался.",
|
||||
"crash_vram_default": "На небольших видеокартах обычная причина — нехватка видеопамяти (VRAM) при загрузке модели ASR поверх модели TTS: сначала выгрузите модель TTS или выберите меньшую модель ASR в Каталоге моделей → Модели.",
|
||||
"stream_cut_backend_alive": "Поток оборвался раньше времени, но бэкенд всё ещё работает — значит, он не падал. В серверной или контейнерной установке причиной обычно является обратный прокси или балансировщик нагрузки, который буферизует соединение или обрывает его по тайм-ауту: отключите буферизацию ответов для этого маршрута (nginx: proxy_buffering off; X-Accel-Buffering: no) и увеличьте его тайм-аут чтения. Запуск настольного приложения напрямую или на localhost без прокси подтвердит это."
|
||||
"crash_broken_env": "Он завершился при загрузке собственных зависимостей Python, так что дело не в памяти и не в видеокарте — окружение неполное или обновилось наполовину. Используйте «Clean & Retry» в Настройки → Логи → Бэкенд: это пересоберёт окружение с нуля и починит его на месте, не трогая ваши голоса и проекты. Если ошибка останется, в подробностях сбоя указан пакет, который не импортировался."
|
||||
},
|
||||
"keyboard": {
|
||||
"title": "Сочетания клавиш",
|
||||
@@ -1646,8 +1596,6 @@
|
||||
"or": "или",
|
||||
"nav": "Навигация",
|
||||
"nav_cheatsheet": "Показать эту шпаргалку",
|
||||
"nav_enginePickerKey": "Cmd/Ctrl+E",
|
||||
"nav_workspacesKey": "Cmd/Ctrl+1–9",
|
||||
"nav_closeModal": "Закрыть модально/отменить",
|
||||
"nav_save": "Сохранить проект/зафиксировать обрезку",
|
||||
"segmentEditor": "Редактор сегментов",
|
||||
@@ -2583,22 +2531,5 @@
|
||||
"longform": "озвучивание историй",
|
||||
"asr": "расшифровка"
|
||||
}
|
||||
},
|
||||
"compute": {
|
||||
"add_machine": "Добавить машину",
|
||||
"manage": "Настройки удалённых воркеров",
|
||||
"off_hint": "Всё выполняется на этой машине. Включите «Удалённо», чтобы использовать другую.",
|
||||
"quick_settings": "Вычисления — где выполняются задачи",
|
||||
"remote": "Удалённо",
|
||||
"title": "Где выполняются задачи",
|
||||
"token_once": "Отсканируйте или вставьте это на другой машине. Показывается один раз."
|
||||
},
|
||||
"voices": {
|
||||
"active": "Активный голос",
|
||||
"active_clone_recipe": "Клонирован из вашего референсного клипа",
|
||||
"cta_clone": "Перетащите клип на 3 с в «Голос» ←, чтобы клонировать голос",
|
||||
"cta_design": "Опишите голос в «Голос» ←, чтобы создать его",
|
||||
"new": "Новый голос",
|
||||
"none_selected": "Голос не выбран — опишите его, перетащите аудио или выберите ниже."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,8 +36,6 @@
|
||||
"open_models": "Öppna modeller"
|
||||
},
|
||||
"settings": {
|
||||
"remote_backend_test": "Testa anslutningen",
|
||||
"remote_backend_save": "Spara och ladda om",
|
||||
"remote_backend_recovery_title": "Det går inte att nå fjärrbackend",
|
||||
"remote_backend_recovery_hint": "Du kan ändra fjärrbackend senare under Inställningar → Delning.",
|
||||
"remote_backend_use_local": "Använd lokal backend",
|
||||
@@ -60,8 +58,6 @@
|
||||
"credentials": "Autentisering",
|
||||
"llm_providers": "LLM-leverantörer",
|
||||
"llmp_desc": "Driver Cinematic- och Autofit-översättning — LLM:en skriver om varje rad så att den ryms inom segmentets tidsbudget och videons timing hålls. Nycklar lagras krypterat; lokala leverantörer (Ollama/LM Studio) förblir helt offline.",
|
||||
"llmp_catalogue_note": "Leverantören som är markerad som aktiv är den som svarar när LLM-motorfamiljen i katalogen är inställd på \"OpenAI-kompatibel\".",
|
||||
"llmp_catalogue_link": "Öppna LLM-familjen i katalogen",
|
||||
"llmp_provider": "Leverantör",
|
||||
"llmp_provider_hint": "Välj en leverantör att konfigurera. Den aktiva används för Cinematic/Autofit-översättning. Lokala leverantörer behöver ingen nyckel, men deras server måste vara igång.",
|
||||
"llmp_local_tag": "lokal",
|
||||
@@ -286,36 +282,7 @@
|
||||
"models_dir_effective": "Används nu",
|
||||
"models_dir_configured": "Konfigurerad",
|
||||
"models_dir_default": "Använder standard",
|
||||
"models_dir_restart": "↻ Starta om VoiceStudio för att använda den nya platsen.",
|
||||
"worker_join": "Anslut",
|
||||
"worker_join_code": "Anslutningskod",
|
||||
"worker_join_code_hint": "Engångskod som går ut om 15 minuter. Skapa den på maskinen som ska skicka jobben.",
|
||||
"worker_join_desc": "Låt en annan kopia av VoiceStudio skicka jobb till den här maskinen. Klistra in anslutningskoden den visade — eller skanna dess QR-kod med telefonen och klistra in den här.",
|
||||
"worker_join_env": "OMNIVOICE_WORKER_MODE är satt i den här maskinens miljö, så den avgör — ändra den där.",
|
||||
"worker_join_no_endpoint": "Ingen kontrollplan sparad.",
|
||||
"worker_join_ok": "Ansluten. Den här maskinen tar nu emot jobb.",
|
||||
"worker_join_placeholder": "ovw_…",
|
||||
"worker_join_rejoin": "Anslut till en annan",
|
||||
"worker_join_stopped": "Stoppad",
|
||||
"worker_join_take_work": "Ta emot jobb från",
|
||||
"worker_join_title": "Låna ut den här maskinens GPU",
|
||||
"worker_join_working": "Arbetar",
|
||||
"workers_add_hint_qr": "Skapa en token, skanna sedan QR-koden från den andra maskinen eller klistra in koden i dess inställningar för Fjärrarbetare.",
|
||||
"workers_approve": "Godkänn",
|
||||
"workers_last_seen": "senast sedd {{when}}",
|
||||
"workers_qr_alt": "QR-kod med den här koden — skanna den från den andra maskinen",
|
||||
"workers_secret_done": "Klar",
|
||||
"workers_seen_hr": "för {{count}} tim sedan",
|
||||
"workers_seen_min": "för {{count}} min sedan",
|
||||
"workers_seen_now": "nyss",
|
||||
"workers_step_1": "Installera VoiceStudio på maskinen med GPU:n.",
|
||||
"workers_step_2": "Skapa en token ovan.",
|
||||
"workers_step_3": "Skanna QR-koden där, eller klistra in koden i dess inställningar för Fjärrarbetare.",
|
||||
"workers_summary_none": "Ingen ansluten",
|
||||
"workers_summary_online": "{{count}} online",
|
||||
"workers_token_expired": "Har gått ut — skapa en ny",
|
||||
"workers_token_expires_in": "Går ut om {{time}}",
|
||||
"workers_token_qr_hint": "På den andra maskinen: Inställningar → System → Fjärrarbetare → Anslut, skanna eller klistra sedan in."
|
||||
"models_dir_restart": "↻ Starta om VoiceStudio för att använda den nya platsen."
|
||||
},
|
||||
"bootstrap": {
|
||||
"title": "VoiceStudio",
|
||||
@@ -585,16 +552,7 @@
|
||||
"seed_placeholder": "slumpmässigt varje gång",
|
||||
"seed_keep": "Behåll detta frö",
|
||||
"seed_reroll": "Nytt frö",
|
||||
"seed_reroll_hint": "Rulla ett nytt slumpmässigt frö och behåll det",
|
||||
"generating_done_status": "Genereringen är klar",
|
||||
"generating_status": "Genererar ljud…",
|
||||
"identity": "Identitet",
|
||||
"identity_auto": "Auto — modellen bestämmer",
|
||||
"insert": "Infoga",
|
||||
"insert_token": "Infoga uttryckstoken",
|
||||
"script": "Manus",
|
||||
"starting_points": "Utgångspunkter",
|
||||
"voice_kicker": "Röst"
|
||||
"seed_reroll_hint": "Rulla ett nytt slumpmässigt frö och behåll det"
|
||||
},
|
||||
"about": {
|
||||
"app": "App",
|
||||
@@ -710,7 +668,6 @@
|
||||
"ready": "redo",
|
||||
"unavailable": "otillgänglig",
|
||||
"use": "Använd",
|
||||
"configureProviders": "Konfigurera leverantörer",
|
||||
"loading": "Laddar motorer...",
|
||||
"refresh": "Uppdatera",
|
||||
"matrixTitle": "Motorkompatibilitetsmatris",
|
||||
@@ -720,9 +677,7 @@
|
||||
"activeEngine": "Aktiv {{family}}: {{engine}}",
|
||||
"engineCompatLabel": "{{family}} motorkompatibilitet",
|
||||
"active": "aktiv",
|
||||
"whyUnavailable": "Vad som krävs",
|
||||
"sectionReady": "Redo att använda",
|
||||
"sectionMore": "Lägg till fler motorer",
|
||||
"whyUnavailable": "Varför inte tillgänglig?",
|
||||
"lastError": "Senaste fel: {{error}}",
|
||||
"installedAndReady": "Installerad och klar",
|
||||
"notInstalled": "Ej installerad",
|
||||
@@ -1335,9 +1290,6 @@
|
||||
"reset": "Återställ",
|
||||
"preview": "Förhandsgranska",
|
||||
"use_voice": "Använd röst",
|
||||
"more_actions": "Fler åtgärder",
|
||||
"use_in_stories": "Använd i Berättelser",
|
||||
"set_audiobook_default": "Ange som standardröst för Ljudbok",
|
||||
"open_designer": "Öppna i Designer",
|
||||
"no_matches": "Inga röster matchar dessa filter.",
|
||||
"load_more": "Ladda mer",
|
||||
@@ -1636,9 +1588,7 @@
|
||||
"searchIssues": "Sök liknande problem",
|
||||
"unexpected": "Oväntat fel: {{message}}",
|
||||
"backend_shutting_down": "VoiceStudio stängs av. Öppna appen igen och försök på nytt.",
|
||||
"crash_broken_env": "Den dog när den läste in sina egna Python-beroenden, så det handlar varken om minne eller om din GPU — miljön är ofullständig eller halvuppdaterad. Använd ”Rensa & Försök igen” under Inställningar → Loggar → Backend, som bygger om den från grunden och reparerar den på plats utan att röra dina röster eller projekt. Misslyckas det ändå anger kraschdetaljerna exakt vilket paket som inte gick att importera.",
|
||||
"crash_vram_default": "På mindre GPU:er är den vanliga orsaken att VRAM-minnet tar slut när ASR-modellen läses in ovanpå TTS-modellen: ladda ur TTS-modellen först, eller välj en mindre ASR-modell under Modellkatalog → Modeller.",
|
||||
"stream_cut_backend_alive": "Strömmen avbröts i förtid, men backend körs fortfarande — den kraschade alltså inte. I en serverad eller containerbaserad miljö beror det oftast på en omvänd proxy eller lastbalanserare som buffrar anslutningen eller bryter den efter en tidsgräns: stäng av svarsbuffring för den här rutten (nginx: proxy_buffering off; X-Accel-Buffering: no) och höj dess tidsgräns för läsning. Att köra skrivbordsappen direkt, eller på localhost utan proxy, bekräftar det."
|
||||
"crash_broken_env": "Den dog när den läste in sina egna Python-beroenden, så det handlar varken om minne eller om din GPU — miljön är ofullständig eller halvuppdaterad. Använd ”Clean & Retry” under Inställningar → Loggar → Backend, som bygger om den från grunden och reparerar den på plats utan att röra dina röster eller projekt. Misslyckas det ändå anger kraschdetaljerna exakt vilket paket som inte gick att importera."
|
||||
},
|
||||
"keyboard": {
|
||||
"title": "Kortkommandon",
|
||||
@@ -1646,8 +1596,6 @@
|
||||
"or": "eller",
|
||||
"nav": "Navigering",
|
||||
"nav_cheatsheet": "Visa detta cheatsheet",
|
||||
"nav_enginePickerKey": "Cmd/Ctrl+E",
|
||||
"nav_workspacesKey": "Cmd/Ctrl+1–9",
|
||||
"nav_closeModal": "Stäng modal / avbryt",
|
||||
"nav_save": "Spara projekt / commit trim",
|
||||
"segmentEditor": "Segmentredigerare",
|
||||
@@ -2583,22 +2531,5 @@
|
||||
"longform": "berättarröst",
|
||||
"asr": "transkribering"
|
||||
}
|
||||
},
|
||||
"compute": {
|
||||
"add_machine": "Lägg till en maskin",
|
||||
"manage": "Inställningar för fjärrarbetare",
|
||||
"off_hint": "Allt körs på den här maskinen. Slå på Fjärr för att använda en annan.",
|
||||
"quick_settings": "Beräkning — var jobben körs",
|
||||
"remote": "Fjärr",
|
||||
"title": "Var jobben körs",
|
||||
"token_once": "Skanna eller klistra in detta på den andra maskinen. Visas bara en gång."
|
||||
},
|
||||
"voices": {
|
||||
"active": "Aktiv röst",
|
||||
"active_clone_recipe": "Klonad från ditt referensklipp",
|
||||
"cta_clone": "Släpp ett 3-sekundersklipp i Röst ← för att klona en",
|
||||
"cta_design": "Beskriv en röst i Röst ← för att designa den",
|
||||
"new": "Ny röst",
|
||||
"none_selected": "Ingen röst vald — beskriv en, släpp ljud eller välj nedan."
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user