Compare commits
57
Commits
@@ -25,6 +25,12 @@ What you expected to happen.
|
||||
|
||||
If applicable, add screenshots or paste relevant logs from **Settings → Logs**.
|
||||
|
||||
> **Tip:** **Settings → About → "Save diagnostic bundle"** produces a zip
|
||||
> (self-check report, recent errors, scrubbed log tails) you can drag onto
|
||||
> this issue — it answers most environment questions below automatically.
|
||||
> Headless installs: `python backend/main.py --diagnose` prints the same
|
||||
> self-check (`--deep` also test-loads the active engine).
|
||||
|
||||
## Environment
|
||||
|
||||
- **OS:** [e.g. macOS 15.2, Windows 11, Ubuntu 24.04]
|
||||
@@ -32,6 +38,7 @@ If applicable, add screenshots or paste relevant logs from **Settings → Logs**
|
||||
- **Version:** [e.g. v0.2.7 — check Settings → About]
|
||||
- **GPU:** [e.g. NVIDIA RTX 4090 / Apple M3 Pro / CPU only]
|
||||
- **RAM:** [e.g. 16 GB]
|
||||
- **Active TTS engine:** [e.g. omnivoice — check Settings → Engines]
|
||||
|
||||
## Additional context
|
||||
|
||||
|
||||
@@ -1,16 +1,30 @@
|
||||
# Publish Docker images to GitHub Container Registry (GHCR).
|
||||
#
|
||||
# Triggers:
|
||||
# - push of a tag matching `v*` (e.g. `v0.2.7`) → pushed as :0.2.7 + :latest
|
||||
# - workflow_dispatch → pushed as :sha-<short> (for testing)
|
||||
# - push of a tag matching `v*` (e.g. `v0.3.0`) → :0.3.0, :0.3, :latest, :sha-
|
||||
# - push to main branch → :main, :sha- (rolling "edge" build)
|
||||
# - workflow_dispatch → :sha- only (ad-hoc test build)
|
||||
#
|
||||
# Tag ↔ image mapping
|
||||
# :latest — always the most recent versioned release (set on every v* tag push)
|
||||
# :0.3.0 — exact version from the git tag
|
||||
# :0.3 — major.minor floating tag (updated on every patch within the minor)
|
||||
# :main — latest commit on main; may be ahead of the last tagged release
|
||||
# :sha-xxxx — specific commit SHA; produced by workflow_dispatch
|
||||
#
|
||||
# Images land at: ghcr.io/debpalash/omnivoice-studio
|
||||
#
|
||||
# NOTE: the Docker image is the headless web-server build of OmniVoice (FastAPI
|
||||
# backend + pre-built React frontend served over HTTP). The Tauri desktop
|
||||
# auto-updater and its update-channel toggle are desktop-only features; they do
|
||||
# NOT apply to the Docker image.
|
||||
|
||||
name: Docker (GHCR)
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -42,19 +56,30 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Extracts semver tags from the git ref:
|
||||
# v0.2.7 → 0.2.7, latest
|
||||
# manual dispatch → sha-abc1234
|
||||
# Tag strategy (`:sha-<short>` is emitted on every trigger):
|
||||
# v0.3.0 tag push → :0.3.0, :0.3, :latest, :sha-
|
||||
# main branch push → :main, :sha-
|
||||
# workflow_dispatch → :sha- only
|
||||
#
|
||||
# Fix for stale :latest (issues #249, #251):
|
||||
# The previous rule used `enable={{is_default_branch}}`, which evaluates
|
||||
# to false on tag pushes (detached HEAD) — so :latest was never updated
|
||||
# when a release tag was pushed. The version / :latest / :main rules are
|
||||
# gated on `github.event_name == 'push'` so a manual workflow_dispatch can
|
||||
# only ever produce a throwaway `:sha-` tag (never republish a mutable
|
||||
# tag), and :latest additionally excludes prerelease tags (those contain a
|
||||
# `-`, e.g. v1.0.0-rc.1) so a prerelease can't clobber :latest.
|
||||
- name: Extract metadata (tags, labels)
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{version}},enable=${{ github.event_name == 'push' }}
|
||||
type=semver,pattern={{major}}.{{minor}},enable=${{ github.event_name == 'push' }}
|
||||
type=raw,value=latest,enable=${{ github.event_name == 'push' && github.ref_type == 'tag' && !contains(github.ref, '-') }}
|
||||
type=raw,value=main,enable=${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
type=sha,prefix=sha-,format=short
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
|
||||
+163
-69
@@ -144,16 +144,18 @@ jobs:
|
||||
rust_target: x86_64-pc-windows-msvc
|
||||
bundles: "msi,updater"
|
||||
|
||||
# Linux: ship .deb + .AppImage. AppImage is universal (no distro
|
||||
# package-manager dep), runs on any glibc-2.31+ host. Now viable
|
||||
# because the thin uv-venv installer is ~10 MB (vs the prior ~2 GB
|
||||
# PyInstaller payload that exceeded linuxdeploy limits). FUSE
|
||||
# unavailability on GH runners handled via APPIMAGE_EXTRACT_AND_RUN=1.
|
||||
# Linux: ship .AppImage only. AppImage is universal (no distro
|
||||
# package-manager dep), runs on any glibc-2.31+ host, and is the
|
||||
# Linux auto-update target. The .deb target was dropped: tauri-bundler
|
||||
# fails it with "Failed to create control scripts: No such file or
|
||||
# directory" (no custom deb config of ours is at fault) — revisit on a
|
||||
# tauri-cli bump. FUSE unavailability on GH runners is handled via
|
||||
# APPIMAGE_EXTRACT_AND_RUN=1.
|
||||
- os: ubuntu-22.04
|
||||
arch: x86_64-unknown-linux-gnu
|
||||
label: "Linux x64"
|
||||
rust_target: x86_64-unknown-linux-gnu
|
||||
bundles: "deb,appimage,updater"
|
||||
bundles: "appimage,updater"
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
name: ${{ matrix.label }}
|
||||
@@ -359,25 +361,76 @@ jobs:
|
||||
echo 'RELEASE_BODY_EOF'
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Apple code-signing is OPT-IN and OFF by default. Export the APPLE_*
|
||||
# secrets to $GITHUB_ENV ONLY for a stable `v*` release with the
|
||||
# MACOS_SIGNING_ENABLED repo variable set. On every other path (preview,
|
||||
# or stable without the var) the APPLE_* vars stay ABSENT — NOT empty.
|
||||
# This matters: Tauri's macOS bundler runs `security import` whenever
|
||||
# APPLE_CERTIFICATE is *present* (even ""), which fails the whole build;
|
||||
# absence makes it skip cert import and bundle unsigned (users clear
|
||||
# quarantine via `xattr -cr`, see docs/install/macos.md). A static `env:`
|
||||
# on the build step can't express "absent", so signing lives here.
|
||||
# To enable signed stable releases: fix the signing secrets, then set the
|
||||
# repo variable MACOS_SIGNING_ENABLED = true.
|
||||
- name: Configure Apple signing (stable, opt-in)
|
||||
if: runner.os == 'macOS' && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && vars.MACOS_SIGNING_ENABLED == 'true'
|
||||
env:
|
||||
C: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
CP: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
SI: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
AID: ${{ secrets.APPLE_ID }}
|
||||
AP: ${{ secrets.APPLE_PASSWORD }}
|
||||
TID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: |
|
||||
{
|
||||
echo "APPLE_CERTIFICATE<<__OV_EOF__"
|
||||
echo "$C"
|
||||
echo "__OV_EOF__"
|
||||
echo "APPLE_CERTIFICATE_PASSWORD=$CP"
|
||||
echo "APPLE_SIGNING_IDENTITY=$SI"
|
||||
echo "APPLE_ID=$AID"
|
||||
echo "APPLE_PASSWORD=$AP"
|
||||
echo "APPLE_TEAM_ID=$TID"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
# Stamp each preview build with a unique, monotonically increasing semver
|
||||
# PRERELEASE so the updater actually offers it (a rolling preview that
|
||||
# always reported the static 0.3.0 never looked "newer", so no update was
|
||||
# ever delivered). Ephemeral, CI-only — never committed. Tauri reads the
|
||||
# bundle + updater version from tauri.conf.json, so rewriting it here
|
||||
# stamps the artifacts + latest.json. `0.3.0-preview.N` is a prerelease of
|
||||
# the current target, so previews converge to stable when 0.3.0 ships
|
||||
# (0.3.0 > 0.3.0-preview.N). NOTE: the Windows MSI ProductVersion strips
|
||||
# the prerelease (→ 0.3.0), a wrinkle to verify for win preview→preview
|
||||
# upgrades; mac/linux replace the bundle wholesale and are unaffected.
|
||||
- name: Stamp preview version
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.publish_preview
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
CONF=frontend/src-tauri/tauri.conf.json
|
||||
BASE=$(jq -r .version "$CONF")
|
||||
# MSI/WiX requires the semver pre-release identifier to be numeric-only
|
||||
# (and <= 65535). "preview.N" hard-fails the Windows bundler, so the
|
||||
# preview stamp is BASE-N — still sorts below the stable BASE for the
|
||||
# updater, still unique per run.
|
||||
PREVIEW_VERSION="${BASE}-${{ github.run_number }}"
|
||||
tmp=$(mktemp)
|
||||
jq --arg v "$PREVIEW_VERSION" '.version = $v' "$CONF" > "$tmp"
|
||||
mv "$tmp" "$CONF"
|
||||
echo "Stamped preview version: $PREVIEW_VERSION"
|
||||
|
||||
- name: Build + release (Tauri)
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
# macOS Developer-ID code-signing + notarization (#134 / #72).
|
||||
# tauri-action signs + notarizes the .app/.dmg ONLY when these are
|
||||
# non-empty; with the secrets unset they resolve to empty strings and
|
||||
# the build stays unsigned (today's behavior — users clear quarantine
|
||||
# via `xattr -cr`, see docs/install/macos.md). To enable, add the repo
|
||||
# secrets documented in docs/install/macos.md → "For maintainers".
|
||||
# No-op on the Windows/Linux matrix legs.
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
# macOS Apple signing (#134 / #72) is configured by the preceding
|
||||
# "Configure Apple signing" step — it exports APPLE_* to $GITHUB_ENV
|
||||
# only on the opt-in stable path, leaving them ABSENT (not "") on
|
||||
# preview/unsigned paths so Tauri's bundler skips cert import. A static
|
||||
# env: here would always set them to "" and break the mac build.
|
||||
# GH runners disable FUSE, so linuxdeploy's AppImage can't mount
|
||||
# itself at bundle time. This env tells linuxdeploy to extract-and-run
|
||||
# instead, which works without FUSE.
|
||||
@@ -398,10 +451,13 @@ jobs:
|
||||
includeUpdaterJson: true
|
||||
|
||||
# ── Installer smoke (Phase 0 GATE-03) ─────────────────────────────
|
||||
# Boot the just-built bundle on this matrix leg, poll /health, fail
|
||||
# the release if it doesn't come up. Catches bundle-only regressions
|
||||
# (PyInstaller missing-module, Tauri sidecar path mismatch, etc.)
|
||||
# that the in-process smoke matrix on ci.yml cannot see.
|
||||
# Structural verification of the installed/extracted bundle. The thin
|
||||
# uv-venv installer ships NO frozen backend binary (the venv is built on
|
||||
# first launch via the bundled `uv`), so there is nothing to boot with
|
||||
# `--health-check` here. Instead assert the bundle carries the shell
|
||||
# binary, the bundled `uv` sidecar, and the backend source resources
|
||||
# (pyproject.toml + backend/main.py) — the real "is the bundle complete"
|
||||
# regression that ci.yml's in-process smoke can't catch.
|
||||
- name: Installer smoke (macOS)
|
||||
if: runner.os == 'macOS'
|
||||
timeout-minutes: 5
|
||||
@@ -410,27 +466,20 @@ jobs:
|
||||
set -euo pipefail
|
||||
DMG=$(find frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle/dmg -name "*.dmg" | head -1)
|
||||
echo "Smoke-testing DMG: $DMG"
|
||||
MOUNT=$(hdiutil attach -nobrowse -readonly "$DMG" | tail -1 | awk '{print $3}')
|
||||
# Grab the full mount path — the volume name has a space ("OmniVoice
|
||||
# Studio"), so `awk '{print $3}'` would truncate it to /Volumes/OmniVoice.
|
||||
MOUNT=$(hdiutil attach -nobrowse -readonly "$DMG" | tail -1 | grep -oE '/Volumes/.*$')
|
||||
APP=$(find "$MOUNT" -maxdepth 2 -name "*.app" | head -1)
|
||||
# RESEARCH Pitfall #5: do NOT launch the Tauri WebView shell on a headless runner — it hangs.
|
||||
# The `--health-check` flag lives in backend/main.py (Python), not the Rust WebView main.
|
||||
# Strategy: invoke the bundled Python backend directly, bypassing Tauri's window code.
|
||||
BACKEND=$(find "$APP/Contents" -type f \( -name 'backend' -o -name 'backend.app' -o -name 'main.py' \) -perm +111 2>/dev/null | head -1)
|
||||
if [ -z "$BACKEND" ]; then
|
||||
# Fallback: try the PyInstaller sidecar location Tauri uses.
|
||||
BACKEND=$(find "$APP/Contents/Resources" -type f \( -name 'backend*' -o -name 'omnivoice*' \) -perm +111 2>/dev/null | head -1)
|
||||
fi
|
||||
if [ -z "$BACKEND" ]; then
|
||||
echo "FAIL — could not locate bundled backend binary in $APP. Contents:"
|
||||
find "$APP/Contents" -type f -perm +111 | head -30
|
||||
hdiutil detach "$MOUNT" || true
|
||||
exit 1
|
||||
fi
|
||||
echo "Launching bundled backend: $BACKEND --health-check"
|
||||
"$BACKEND" --health-check
|
||||
EXIT=$?
|
||||
fail() { echo "FAIL — $1"; find "$APP/Contents" -maxdepth 4 -type f 2>/dev/null | head -40; hdiutil detach "$MOUNT" || true; exit 1; }
|
||||
[ -n "$APP" ] || { echo "FAIL — no .app inside DMG"; hdiutil detach "$MOUNT" || true; exit 1; }
|
||||
# Thin uv-venv installer ships no frozen backend to boot — verify the
|
||||
# bundle is complete: shell binary + bundled uv sidecar + backend source.
|
||||
ls "$APP/Contents/MacOS"/* >/dev/null 2>&1 || fail "no shell binary in Contents/MacOS"
|
||||
find "$APP/Contents" -type f -name 'uv' | grep -q . || fail "bundled uv sidecar missing"
|
||||
find "$APP/Contents" -type f -name 'pyproject.toml' | grep -q . || fail "backend resource pyproject.toml missing"
|
||||
find "$APP/Contents" -type f -path '*/backend/main.py' | grep -q . || fail "backend source backend/main.py missing"
|
||||
echo "OK — bundle has shell + uv + backend resources"
|
||||
hdiutil detach "$MOUNT" || true
|
||||
exit $EXIT
|
||||
|
||||
- name: Installer smoke (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
@@ -442,24 +491,15 @@ jobs:
|
||||
echo "Smoke-testing MSI: $MSI"
|
||||
# /quiet = no UI, /norestart = don't reboot the runner if a dep asks
|
||||
msiexec.exe //i "$(cygpath -w "$MSI")" //quiet //norestart
|
||||
# Tauri installs to "Program Files\OmniVoice Studio\..." by default. Backend is a sidecar binary
|
||||
# (RESEARCH Pitfall #5) — not the Tauri WebView .exe — so locate by name pattern.
|
||||
BACKEND=$(find "/c/Program Files/OmniVoice Studio" -type f \( -name 'backend.exe' -o -name 'omnivoice-backend.exe' -o -name 'main.exe' \) 2>/dev/null | head -1)
|
||||
if [ -z "$BACKEND" ]; then
|
||||
echo "FAIL — bundled backend .exe not found under C:/Program Files/OmniVoice Studio. Contents:"
|
||||
find "/c/Program Files/OmniVoice Studio" -type f -name '*.exe' | head -20
|
||||
exit 1
|
||||
fi
|
||||
echo "Launching bundled backend: $BACKEND --health-check"
|
||||
"$BACKEND" --health-check &
|
||||
BACKEND_PID=$!
|
||||
# Wait for completion (--health-check is a short-lived, exits-after-200 invocation)
|
||||
wait $BACKEND_PID
|
||||
EXIT=$?
|
||||
# RESEARCH Pitfall #2: cleanup orphaned PyInstaller child processes on port 3900.
|
||||
# Safe on GH-hosted ephemeral runners; REQUIRED if/when we move to self-hosted Windows.
|
||||
taskkill //F //T //PID $BACKEND_PID 2>/dev/null || echo "backend process already exited cleanly"
|
||||
exit $EXIT
|
||||
INSTALL="/c/Program Files/OmniVoice Studio"
|
||||
fail() { echo "FAIL — $1. Contents:"; find "$INSTALL" -maxdepth 4 -type f 2>/dev/null | head -40; exit 1; }
|
||||
# Thin uv-venv installer ships no frozen backend .exe — verify the
|
||||
# install is complete: shell exe + bundled uv + backend source resources.
|
||||
test -f "$INSTALL/omnivoice-studio.exe" || fail "shell exe missing"
|
||||
test -f "$INSTALL/uv.exe" || fail "bundled uv missing"
|
||||
find "$INSTALL" -type f -name 'pyproject.toml' | grep -q . || fail "backend resource pyproject.toml missing"
|
||||
find "$INSTALL" -type f -path '*backend*main.py' | grep -q . || fail "backend source main.py missing"
|
||||
echo "OK — MSI installed shell + uv + backend resources"
|
||||
|
||||
- name: Installer smoke (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
@@ -469,20 +509,25 @@ jobs:
|
||||
set -euo pipefail
|
||||
# Use the AppImage — single-file, no installer needed.
|
||||
APPIMAGE=$(find frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle/appimage -name "*.AppImage" | head -1)
|
||||
# Resolve to an absolute path BEFORE the cd below — `--appimage-extract`
|
||||
# always writes ./squashfs-root into the CWD, so we cd into a temp dir,
|
||||
# at which point a relative AppImage path would no longer resolve.
|
||||
APPIMAGE=$(realpath "$APPIMAGE")
|
||||
echo "Smoke-testing AppImage: $APPIMAGE"
|
||||
chmod +x "$APPIMAGE"
|
||||
# GH runners have no FUSE — extract before running (mirrors APPIMAGE_EXTRACT_AND_RUN=1 used at build time).
|
||||
EXTRACT_DIR="$(mktemp -d)"
|
||||
cd "$EXTRACT_DIR"
|
||||
"$APPIMAGE" --appimage-extract >/dev/null
|
||||
# Tauri's AppRun lives at squashfs-root/AppRun; the actual binary is in squashfs-root/usr/bin/
|
||||
BIN=$(find squashfs-root -type f -name "OmniVoice Studio" -o -name "omnivoice-studio" 2>/dev/null | head -1)
|
||||
if [ -z "$BIN" ]; then
|
||||
BIN="$EXTRACT_DIR/squashfs-root/AppRun"
|
||||
fi
|
||||
echo "Launching under xvfb-run: $BIN --health-check"
|
||||
sudo apt-get install -y xvfb >/dev/null 2>&1 || true
|
||||
xvfb-run -a "$BIN" --health-check
|
||||
ROOT="$EXTRACT_DIR/squashfs-root"
|
||||
fail() { echo "FAIL — $1"; find "$ROOT" -maxdepth 5 -type f 2>/dev/null | head -40; exit 1; }
|
||||
# Thin uv-venv installer: verify the AppImage carries the shell binary,
|
||||
# the bundled uv sidecar, and the backend source resources.
|
||||
{ [ -f "$ROOT/AppRun" ] || find "$ROOT" -type f \( -name "OmniVoice Studio" -o -name "omnivoice-studio" \) | grep -q .; } || fail "shell binary / AppRun missing"
|
||||
find "$ROOT" -type f -name 'uv' | grep -q . || fail "bundled uv sidecar missing"
|
||||
find "$ROOT" -type f -name 'pyproject.toml' | grep -q . || fail "backend resource pyproject.toml missing"
|
||||
find "$ROOT" -type f -path '*/backend/main.py' | grep -q . || fail "backend source backend/main.py missing"
|
||||
echo "OK — AppImage has shell + uv + backend resources"
|
||||
|
||||
# ── Compute SHA-256 checksums (Phase 0 GATE-05) ───────────────────
|
||||
# Native OS tools: shasum -a 256 (POSIX) / Get-FileHash (Windows).
|
||||
@@ -499,7 +544,13 @@ jobs:
|
||||
|
||||
# Gather artifact paths per matrix leg's `bundles` (msi/app/dmg/deb/appimage/updater).
|
||||
# `find` is portable across all three runners (Git Bash on Windows).
|
||||
mapfile -t ARTIFACTS < <(find "$BUNDLE_DIR" -type f \
|
||||
# NB: macOS runners use /bin/bash 3.2, which has no `mapfile` (a bash 4+
|
||||
# builtin) — using it 127'd this step and dropped the macOS SHA256SUMS
|
||||
# for v0.3.1 and v0.3.2. A `while read` loop is portable to bash 3.2.
|
||||
ARTIFACTS=()
|
||||
while IFS= read -r artifact; do
|
||||
ARTIFACTS+=("$artifact")
|
||||
done < <(find "$BUNDLE_DIR" -type f \
|
||||
\( -name "*.dmg" -o -name "*.app.tar.gz" -o -name "*.app.tar.gz.sig" \
|
||||
-o -name "*.msi" -o -name "*.msi.sig" \
|
||||
-o -name "*.AppImage" -o -name "*.AppImage.sig" \
|
||||
@@ -543,3 +594,46 @@ jobs:
|
||||
body_path: ${{ steps.checksums.outputs.checksums_file }}
|
||||
files: ${{ steps.checksums.outputs.checksums_file }}
|
||||
fail_on_unmatched_files: true
|
||||
|
||||
# ── Auto-generated preview release notes ──────────────────────────────────
|
||||
# tauri-action publishes the rolling `preview` release with the plain
|
||||
# changelog-fallback body ("Auto-generated release for main…"). Replace it
|
||||
# with GitHub's auto-generated notes (What's Changed by PR + Contributors +
|
||||
# Full Changelog) once the matrix has finished. Runs once (no matrix race),
|
||||
# preview-only — stable `v*` releases keep their CHANGELOG section + the
|
||||
# appended checksums.
|
||||
preview-notes:
|
||||
needs: build
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.publish_preview
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Generate + apply GitHub release notes to the preview release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
NOTES=$(gh api --method POST "repos/$REPO/releases/generate-notes" -f tag_name=preview --jq .body)
|
||||
# Build a Contributors avatar strip from the PR authors GitHub listed
|
||||
# in the notes ("by @handle in …"). Inline <a>/<img> render on the
|
||||
# release page (GitHub strips inline styles, so avatars are square).
|
||||
CONTRIB=""
|
||||
HANDLES=$(printf '%s\n' "$NOTES" | grep -oE 'by @[A-Za-z0-9-]+' | sed 's/^by @//' | sort -u || true)
|
||||
if [ -n "$HANDLES" ]; then
|
||||
CONTRIB=$'## Contributors\n\n'
|
||||
while IFS= read -r h; do
|
||||
[ -z "$h" ] && continue
|
||||
CONTRIB="$CONTRIB<a href=\"https://github.com/$h\" title=\"@$h\"><img src=\"https://github.com/$h.png?size=64\" width=\"48\" alt=\"@$h\"/></a> "
|
||||
done <<< "$HANDLES"
|
||||
fi
|
||||
{
|
||||
echo "> 🧪 **Rolling preview build from \`main\`** — newest features, less tested. Opt in via **Settings → About → Update channel → Preview**; switch back to Stable any time."
|
||||
echo ""
|
||||
echo "$NOTES"
|
||||
echo ""
|
||||
echo "$CONTRIB"
|
||||
} > /tmp/preview-notes.md
|
||||
gh release edit preview --repo "$REPO" --notes-file /tmp/preview-notes.md
|
||||
echo "Applied auto-generated release notes + contributors to the preview release."
|
||||
|
||||
@@ -129,3 +129,8 @@ marketing.md
|
||||
.claude/skills/speckit-*/
|
||||
.antigravitycli/
|
||||
|
||||
playwright-report/
|
||||
.last-run.json
|
||||
|
||||
# probe — generated HTML reports
|
||||
tests/probe/reports/
|
||||
|
||||
@@ -6,6 +6,79 @@ The format is loosely based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
Versions track the desktop app (`tauri.conf.json` + `frontend/src-tauri/Cargo.toml`).
|
||||
The bundled TTS model package (`pyproject.toml`) is versioned independently.
|
||||
|
||||
## [0.3.5] — 2026-06-03
|
||||
|
||||
### Fixed
|
||||
- **Speaker diarization failed on PyTorch ≥ 2.6** (`Weights only load failed …
|
||||
Unsupported global: torch.torch_version.TorchVersion`) even with the pyannote
|
||||
license accepted. PyTorch 2.6 made `torch.load` default to
|
||||
`weights_only=True`, whose secure unpickler rejects the pyannote checkpoint's
|
||||
metadata globals. The diarization loader now registers the same safe-globals
|
||||
allowlist the WhisperX VAD load already uses, so the secure load succeeds.
|
||||
(#270)
|
||||
|
||||
## [0.3.4] — 2026-06-03
|
||||
|
||||
### Fixed
|
||||
- **Transcription on Windows + NVIDIA failed with `Could not locate
|
||||
cudnn_ops_infer64_8.dll`.** WhisperX/faster-whisper need cuDNN 8 (via
|
||||
CTranslate2); when the side-loaded `cudnn8_compat` libs are missing, the
|
||||
**PyTorch Whisper** backend (Settings → Models) now works as a drop-in
|
||||
fallback — it builds its own transformers pipeline on PyTorch's cuDNN-9
|
||||
stack, with no CTranslate2/cuDNN-8 dependency and no
|
||||
`OMNIVOICE_PRELOAD_TTS_ASR=1` required. (#255)
|
||||
|
||||
## [0.3.3] — 2026-06-03
|
||||
|
||||
### Fixed
|
||||
- **Settings → About showed the wrong architecture in the Docker/web build.**
|
||||
The "Architecture" row rendered the *client browser's* platform
|
||||
(`navigator.platform` → e.g. "Win32"); it now reports the **server's** CPU
|
||||
architecture from the backend (`platform.machine()`), correct for both the
|
||||
desktop app and Docker. The blank version/GPU/RAM/VRAM in the same report
|
||||
were the loopback-gate 403s already fixed in v0.3.2. (#262)
|
||||
|
||||
### CI
|
||||
- The release SHA-256 checksum step no longer uses `mapfile` (a bash 4+
|
||||
builtin) — it broke on the macOS runner's bash 3.2 and dropped the macOS
|
||||
`SHA256SUMS` for v0.3.1/v0.3.2. Now portable to bash 3.2.
|
||||
|
||||
## [0.3.2] — 2026-06-03
|
||||
|
||||
### Fixed
|
||||
- **"Loopback origin required" all over the Docker UI** (and a blank version).
|
||||
The `/system/*` and `/api/settings/*` routes are restricted to a loopback
|
||||
origin, but Docker's NAT makes every request look non-loopback, so the gate
|
||||
403'd the operator out of the admin UI — including `/system/info` (blanking
|
||||
the version) and HF-token entry. The Docker image now runs with
|
||||
`OMNIVOICE_SERVER_MODE=1`, which relaxes the gate for the headless
|
||||
deployment; exposure is governed by the `-p` port mapping plus the optional
|
||||
share PIN. Desktop builds are unaffected — their loopback boundary (and the
|
||||
denial of admin routes to LAN share guests) is unchanged. (#261)
|
||||
|
||||
## [0.3.1] — 2026-06-03
|
||||
|
||||
First tagged build of the 0.3 line off `main` — it ships the accumulated
|
||||
`[0.3.0]` work below plus the fixes here. (The `[0.3.0]` milestone heading is
|
||||
kept for the qualitative "actually useful" release.)
|
||||
|
||||
### Fixed
|
||||
- **Voice-clone / export download crashed in the Docker & browser build** with
|
||||
`TypeError: Cannot read properties of undefined (reading 'invoke')`. The
|
||||
export button called the Tauri save dialog unconditionally; outside the
|
||||
desktop shell it now falls back to a standard browser download of the file
|
||||
served at `/audio/<path>`. (#256)
|
||||
- **Docker container showed no version** (a dash) in Settings → About, and the
|
||||
desktop-only update-channel toggle appeared in the web build. The running
|
||||
version is now read from the backend (`/system/info` `app_version`, `/health`
|
||||
`version`); the updater UI is hidden outside Tauri. Also corrected the
|
||||
version-check command in the Docker docs (`omnivoice`, not
|
||||
`omnivoice-studio`). (#249)
|
||||
- **Transcription failures were masked** by a generic "Transcribe stream
|
||||
dropped" message. The transcribe SSE stream now surfaces the real, sanitized
|
||||
cause (with an actionable hint) instead of silently dropping when model load
|
||||
or VRAM offload fails. (#255)
|
||||
|
||||
## [0.3.0] — Unreleased
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,136 +1,715 @@
|
||||
# Functional Source License, Version 1.1, ALv2 Future License
|
||||
# OmniVoice Studio — License
|
||||
|
||||
## Abbreviation
|
||||
|
||||
FSL-1.1-ALv2
|
||||
AGPL-3.0-only
|
||||
|
||||
## Notice
|
||||
|
||||
Copyright 2024-present Palash Debnath and OmniVoice Studio contributors.
|
||||
|
||||
OmniVoice Studio is **free for personal, educational, research, and
|
||||
non-commercial use** under the terms below. Two years after each release is
|
||||
published, that release converts automatically to the Apache License,
|
||||
Version 2.0 (see "Grant of Future License").
|
||||
OmniVoice Studio is **free and open-source software, licensed under the GNU
|
||||
Affero General Public License, Version 3 (AGPL-3.0)**. You are free to use,
|
||||
copy, modify, and redistribute it — and that **includes commercial and internal
|
||||
business use**: run the app, use its outputs commercially, sell the audio you
|
||||
produce with it, provide professional/client services with it, and deploy it
|
||||
within your organization.
|
||||
|
||||
**Business / enterprise users** that fall outside the Permitted Purposes
|
||||
below — primarily those building a competing product or service on top of
|
||||
OmniVoice Studio — need a commercial license. Pricing tiers are coming
|
||||
soon. For inquiries in the meantime, contact `OmniVoice@palash.dev`.
|
||||
Because this is the **Affero** GPL, one additional obligation applies: if you
|
||||
modify OmniVoice Studio and make that modified version available to others over
|
||||
a network, you must also offer those users the complete corresponding source
|
||||
code of your modified version under these same AGPL-3.0 terms. See the full
|
||||
text below.
|
||||
|
||||
A **commercial license is available** for organizations that want to embed
|
||||
OmniVoice Studio in a closed-source or proprietary product or service without
|
||||
the AGPL-3.0 copyleft obligations. Pricing tiers are coming soon; for inquiries
|
||||
contact `OmniVoice@palash.dev`.
|
||||
|
||||
(This Notice is a plain-language summary; the binding terms are the full GNU
|
||||
AGPL-3.0 text reproduced below.)
|
||||
|
||||
### Scope
|
||||
|
||||
These terms cover the OmniVoice Studio application — the Tauri desktop
|
||||
shell (`frontend/src-tauri/`), the React frontend (`frontend/src/`), the
|
||||
FastAPI backend (`backend/`), and supporting build / packaging scripts
|
||||
(`scripts/`, `Dockerfile`, `docker-compose.yml`, `.github/`).
|
||||
These terms cover the OmniVoice Studio application — the Tauri desktop shell
|
||||
(`frontend/src-tauri/`), the React frontend (`frontend/src/`), the FastAPI
|
||||
backend (`backend/`), and supporting build / packaging scripts (`scripts/`,
|
||||
`Dockerfile`, `docker-compose.yml`, `.github/`).
|
||||
|
||||
The bundled `omnivoice/` Python package — the underlying TTS model by
|
||||
Han Zhu — is **separately licensed under Apache License 2.0** by its
|
||||
upstream authors and is not relicensed here. See `pyproject.toml`.
|
||||
The bundled `omnivoice/` Python package — the underlying TTS model by Han Zhu —
|
||||
is **separately licensed under Apache License 2.0** by its upstream authors and
|
||||
is not relicensed here. Apache License 2.0 is compatible with, and may be
|
||||
combined under, the GNU AGPL-3.0. See `pyproject.toml`.
|
||||
|
||||
Third-party dependencies retain their own licenses. See `Cargo.lock`,
|
||||
`bun.lock`, and `uv.lock` for the resolved set.
|
||||
|
||||
### Reference
|
||||
|
||||
The full canonical text of the FSL-1.1-ALv2 follows verbatim. The
|
||||
authoritative copy lives at <https://fsl.software/>.
|
||||
The full canonical text of the GNU Affero General Public License, Version 3
|
||||
follows verbatim. The authoritative copy lives at
|
||||
<https://www.gnu.org/licenses/agpl-3.0.txt>.
|
||||
|
||||
---
|
||||
|
||||
## Terms and Conditions
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
### Licensor ("We")
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
The party offering the Software under these Terms and Conditions.
|
||||
Preamble
|
||||
|
||||
### The Software
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The "Software" is each version of the software that we make available under
|
||||
these Terms and Conditions, as indicated by our inclusion of these Terms and
|
||||
Conditions with the Software.
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
### License Grant
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Subject to your compliance with this License Grant and the Patents,
|
||||
Redistribution and Trademark clauses below, we hereby grant you the right to
|
||||
use, copy, modify, create derivative works, publicly perform, publicly display
|
||||
and redistribute the Software for any Permitted Purpose identified below.
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
### Permitted Purpose
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
A Permitted Purpose is any purpose other than a Competing Use. A Competing Use
|
||||
means making the Software available to others in a commercial product or
|
||||
service that:
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
1. substitutes for the Software;
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
2. substitutes for any other product or service we offer using the Software
|
||||
that exists as of the date we make the Software available; or
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
3. offers the same or substantially similar functionality as the Software.
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
Permitted Purposes specifically include using the Software:
|
||||
0. Definitions.
|
||||
|
||||
1. for your internal use and access;
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
2. for non-commercial education;
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
3. for non-commercial research; and
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
4. in connection with professional services that you provide to a licensee
|
||||
using the Software in accordance with these Terms and Conditions.
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
### Patents
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To the extent your use for a Permitted Purpose would necessarily infringe our
|
||||
patents, the license grant above includes a license under our patents. If you
|
||||
make a claim against any party that the Software infringes or contributes to
|
||||
the infringement of any patent, then your patent license to the Software ends
|
||||
immediately.
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
### Redistribution
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
The Terms and Conditions apply to all copies, modifications and derivatives of
|
||||
the Software.
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
If you redistribute any copies, modifications or derivatives of the Software,
|
||||
you must include a copy of or a link to these Terms and Conditions and not
|
||||
remove any copyright notices provided in or with the Software.
|
||||
1. Source Code.
|
||||
|
||||
### Disclaimer
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR
|
||||
PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT.
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE
|
||||
SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES,
|
||||
EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE.
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
### Trademarks
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
Except for displaying the License Details and identifying us as the origin of
|
||||
the Software, you have no right under these Terms and Conditions to use our
|
||||
trademarks, trade names, service marks or product names.
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
## Grant of Future License
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
We hereby irrevocably grant you an additional license to use the Software under
|
||||
the Apache License, Version 2.0 that is effective on the second anniversary of
|
||||
the date we make the Software available. On or after that date, you may use the
|
||||
Software under the Apache License, Version 2.0, in which case the following
|
||||
will apply:
|
||||
2. Basic Permissions.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License"); you may not use
|
||||
this file except in compliance with the License.
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may obtain a copy of the License at
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software distributed
|
||||
under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
|
||||
CONDITIONS OF ANY KIND, either express or implied. See the License for the
|
||||
specific language governing permissions and limitations under the License.
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<p>
|
||||
<a href="https://github.com/debpalash/OmniVoice-Studio/stargazers"><img src="https://img.shields.io/github/stars/debpalash/OmniVoice-Studio?style=flat-square&color=f59e0b" alt="Stars" /></a>
|
||||
<a href="https://github.com/debpalash/OmniVoice-Studio/releases/latest"><img src="https://img.shields.io/github/v/release/debpalash/OmniVoice-Studio?style=flat-square&color=10b981" alt="Release" /></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-FSL--1.1--ALv2-blue?style=flat-square" alt="License" /></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-AGPL--3.0-blue?style=flat-square" alt="License" /></a>
|
||||
<a href="https://github.com/debpalash/OmniVoice-Studio/issues"><img src="https://img.shields.io/github/issues/debpalash/OmniVoice-Studio?style=flat-square&color=ef4444" alt="Issues" /></a>
|
||||
<a href="https://discord.gg/bzQavDfVV9"><img src="https://img.shields.io/badge/Discord-Join_Community-5865F2?style=flat-square&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
</p>
|
||||
@@ -17,6 +17,7 @@
|
||||
<a href="#features">Features</a> ·
|
||||
<a href="#why-omnivoice-studio">Why OmniVoice Studio?</a> ·
|
||||
<a href="#tts-engines">TTS Engines</a> ·
|
||||
<a href="#asr-engines">ASR Engines</a> ·
|
||||
<a href="#contributing">Contributing</a> ·
|
||||
<a href="https://discord.gg/bzQavDfVV9">Discord</a> ·
|
||||
<a href="README_CN.md"><strong>简体中文</strong></a>
|
||||
@@ -28,6 +29,9 @@
|
||||
<a href="https://github.com/debpalash/OmniVoice-Studio/releases/download/v0.2.7/OmniVoice.Studio_0.2.7_amd64.AppImage"><img src="https://img.shields.io/badge/Linux-AppImage_(x64)-FCC624?style=for-the-badge&logo=linux&logoColor=black" alt="Download Linux AppImage" /></a>
|
||||
<a href="https://github.com/debpalash/OmniVoice-Studio/releases/download/v0.2.7/OmniVoice.Studio_0.2.7_amd64.deb"><img src="https://img.shields.io/badge/Debian-.deb-A81D33?style=for-the-badge&logo=debian&logoColor=white" alt="Download Debian .deb" /></a>
|
||||
</p>
|
||||
<p>
|
||||
<sub><b>macOS:</b> first launch needs a one-time approval — right-click → <b>Open</b> (or System Settings → Privacy & Security → <b>"Open Anyway"</b> on macOS 15). No Terminal needed. <a href="docs/install/macos.md#gatekeeper-quarantine">Why?</a></sub>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
@@ -121,9 +125,13 @@ Per-OS install guides — pick yours and follow it end-to-end:
|
||||
- **Linux** — [docs/install/linux.md](docs/install/linux.md)
|
||||
- **Docker** — [docs/install/docker.md](docs/install/docker.md)
|
||||
|
||||
Stuck? See [docs/install/troubleshooting.md](docs/install/troubleshooting.md)
|
||||
for the top 10 install errors. The in-app error UI deeplinks to those entries
|
||||
when something breaks at runtime.
|
||||
Stuck? Run the built-in self-check first — **Settings → About → "Run
|
||||
self-check"** in the app, or `uv run python backend/main.py --diagnose` from
|
||||
a checkout (`--deep` also test-loads the active engine). Then see
|
||||
[docs/install/troubleshooting.md](docs/install/troubleshooting.md) for the
|
||||
top 10 install errors. The in-app error UI deeplinks to those entries when
|
||||
something breaks at runtime, and **Settings → About → "Save diagnostic
|
||||
bundle"** packages scrubbed logs + the self-check report for bug reports.
|
||||
|
||||
For Hugging Face token setup, see
|
||||
[docs/setup/huggingface-token.md](docs/setup/huggingface-token.md). For
|
||||
@@ -186,7 +194,7 @@ ElevenLabs charges **$5–$330/mo** and processes your audio on their servers. O
|
||||
|
||||
| | **ElevenLabs** | **OmniVoice Studio** |
|
||||
|---|---|---|
|
||||
| **Pricing** | $5–$330/mo, per-character billing | Free for personal use · [Commercial license](#license) for business |
|
||||
| **Pricing** | $5–$330/mo, per-character billing | Free & open-source (AGPL-3.0) · [Commercial license](#license) for proprietary use |
|
||||
| **Voice Cloning** | ✅ 3s clip | ✅ 3s clip, zero-shot |
|
||||
| **Voice Design** | ✅ Gender, age | ✅ Gender, age, accent, pitch, style, dialect |
|
||||
| **Languages** | 32 | **646** |
|
||||
@@ -237,6 +245,22 @@ OmniVoice ships a multi-engine TTS backend. The default engine (OmniVoice) is al
|
||||
|
||||
> **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.
|
||||
|
||||
### ASR Engines
|
||||
|
||||
OmniVoice ships a multi-engine ASR (speech-to-text) backend that powers dictation, video dubbing, and subtitle generation — all fully local. **WhisperX** is the cross-platform default; the rest are opt-in and auto-detected. Switch in **Settings → ASR Engine** or via the `OMNIVOICE_ASR_BACKEND` env var.
|
||||
|
||||
| Engine | `OMNIVOICE_ASR_BACKEND` | Languages | Best for |
|
||||
|--------|-------------------------|:---------:|----------|
|
||||
| **WhisperX** (default) | `whisperx` | ~100 | Dubbing & subtitles — word-level timing via wav2vec2 forced alignment |
|
||||
| **Faster-Whisper** | `faster-whisper` | ~100 | Fast transcription on Linux / macOS / Windows (CTranslate2) |
|
||||
| **MLX Whisper** | `mlx-whisper` | ~100 | Native Apple Silicon speed (Apple MLX / Metal) |
|
||||
| **PyTorch Whisper** | `pytorch-whisper` | ~100 | CUDA / CPU fallback via 🤗 Transformers |
|
||||
| **Parakeet TDT** | `nemo-parakeet` | English + 25 EU | SOTA English accuracy, auto language detection (NVIDIA NeMo, GPU only) |
|
||||
| **Moonshine** | `moonshine` | English | Edge / low-latency, ONNX |
|
||||
| **FunASR** | `funasr` | 50+ | All-in-one multilingual — built-in VAD + inline speaker diarization (SenseVoice) |
|
||||
|
||||
> 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. Every engine runs on-device — no API keys, no cloud.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
@@ -341,7 +365,7 @@ Yes. MPS acceleration is auto-detected. MLX-optimized Whisper models are availab
|
||||
<details>
|
||||
<summary><b>Can I use this commercially?</b></summary>
|
||||
<br/>
|
||||
Personal, educational, internal-team, and non-commercial use is free under <a href="https://fsl.software/">FSL-1.1-ALv2</a>. Building a competing product or service on top of OmniVoice Studio requires a commercial license — see <a href="#license">License</a>. Pricing tiers coming soon. Each release converts to Apache 2.0 two years after publication.
|
||||
<b>Yes — commercial use is free.</b> OmniVoice Studio is free and open-source under the <a href="https://www.gnu.org/licenses/agpl-3.0.html">GNU AGPL-3.0</a>. So personal, educational, research, <b>and commercial / business use are all free</b>: run it, sell the audio you make with it, dub your own or a client's videos, deploy it across your team. Because AGPL is a <b>network copyleft</b> license, if you <b>modify</b> OmniVoice Studio and make that modified version available to others over a network, you must offer those users the source of your modified version under the same AGPL terms. Want to embed OmniVoice in a <b>closed-source or proprietary</b> product without those obligations? A <b>commercial license</b> is available — see <a href="#license">License</a>.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -360,13 +384,13 @@ Yes. OmniVoice uses a <b>built-in backend registry</b>. To add an engine in ~50
|
||||
|
||||
## License
|
||||
|
||||
OmniVoice Studio is source-available under the [**Functional Source License (FSL-1.1-ALv2)**](https://fsl.software/).
|
||||
OmniVoice Studio is free and open-source software under the [**GNU Affero General Public License v3.0 (AGPL-3.0)**](https://www.gnu.org/licenses/agpl-3.0.html).
|
||||
|
||||
**Free** for personal, educational, research, internal team, and non-commercial use. Each release **converts to Apache 2.0 automatically two years after publication**.
|
||||
**Free for any use — including commercial and internal business use.** Run it, sell the audio you produce with it, dub your own or clients' videos, roll it out across your team — all free, no license needed. As a **network copyleft** license, AGPL adds one obligation: if you **modify** OmniVoice Studio and offer that modified version to others over a network, you must make the complete corresponding source of your modified version available to them under the same AGPL-3.0 terms.
|
||||
|
||||
**Business / enterprise** users building a competing product or service on top of OmniVoice Studio need a commercial license. **Pricing tiers coming soon.** For inquiries in the meantime, reach out at **OmniVoice@palash.dev**.
|
||||
A **commercial license** is available for organizations that want to embed OmniVoice Studio in a **closed-source or proprietary** product or service without the AGPL-3.0 copyleft obligations. **Pricing tiers coming soon.** Inquiries: **OmniVoice@palash.dev**.
|
||||
|
||||
See [`LICENSE`](LICENSE) for the full terms.
|
||||
The bundled `omnivoice/` TTS model by Han Zhu remains Apache-2.0 upstream. See [`LICENSE`](LICENSE) for the full, binding terms.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+7
-5
@@ -7,7 +7,7 @@
|
||||
<p>
|
||||
<a href="https://github.com/debpalash/OmniVoice-Studio/stargazers"><img src="https://img.shields.io/github/stars/debpalash/OmniVoice-Studio?style=flat-square&color=f59e0b" alt="Star" /></a>
|
||||
<a href="https://github.com/debpalash/OmniVoice-Studio/releases/latest"><img src="https://img.shields.io/github/v/release/debpalash/OmniVoice-Studio?style=flat-square&color=10b981" alt="版本" /></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-FSL--1.1--ALv2-blue?style=flat-square" alt="许可证" /></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-AGPL--3.0-blue?style=flat-square" alt="许可证" /></a>
|
||||
<a href="https://github.com/debpalash/OmniVoice-Studio/issues"><img src="https://img.shields.io/github/issues/debpalash/OmniVoice-Studio?style=flat-square&color=ef4444" alt="Issues" /></a>
|
||||
<a href="https://discord.gg/bzQavDfVV9"><img src="https://img.shields.io/badge/Discord-加入社区-5865F2?style=flat-square&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
</p>
|
||||
@@ -459,7 +459,7 @@ OmniVoice 配备多引擎 TTS 后端。默认引擎(OmniVoice)始终可用
|
||||
<details>
|
||||
<summary><b>可以用于商业用途吗?</b></summary>
|
||||
<br/>
|
||||
个人、教育、内部团队和非商业用途在 <a href="https://fsl.software/">FSL-1.1-ALv2</a> 下免费。在 OmniVoice Studio 基础上构建竞争产品或服务需要商业许可证——参见<a href="#许可证">许可证</a>。定价方案即将推出。每个版本在发布两年后自动转换为 Apache 2.0。
|
||||
<b>可以——商业使用免费。</b>OmniVoice Studio 是基于 <a href="https://www.gnu.org/licenses/agpl-3.0.html">GNU AGPL-3.0</a> 的自由开源软件。个人、教育、研究<b>以及商业/企业用途均免费</b>:运行它、出售用它生成的音频、为自己或客户的视频配音、在团队中部署。由于 AGPL 是<b>网络著佐权(copyleft)</b>许可证,如果你<b>修改</b>了 OmniVoice Studio 并通过网络向他人提供该修改版本,你必须依据相同的 AGPL 条款向这些用户提供你修改版本的源代码。希望将 OmniVoice 嵌入<b>闭源或专有</b>产品而不受这些义务约束?可获取<b>商业许可证</b>——参见<a href="#许可证">许可证</a>。
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -478,11 +478,13 @@ OmniVoice 配备多引擎 TTS 后端。默认引擎(OmniVoice)始终可用
|
||||
|
||||
## 许可证
|
||||
|
||||
OmniVoice Studio 在 [**Functional Source License (FSL-1.1-ALv2)**](https://fsl.software/) 下提供源码。
|
||||
OmniVoice Studio 是基于 [**GNU Affero 通用公共许可证 v3.0(AGPL-3.0)**](https://www.gnu.org/licenses/agpl-3.0.html) 的自由开源软件。
|
||||
|
||||
**免费**用于个人、教育、研究、内部团队和非商业用途。每个版本在**发布两年后自动转换为 Apache 2.0**。
|
||||
**可免费用于任何用途——包括商业和企业内部用途。** 运行它、出售用它生成的音频、为自己或客户的视频配音、在团队中推广——全部免费,无需许可证。作为**网络著佐权(copyleft)**许可证,AGPL 增加了一项义务:如果你**修改**了 OmniVoice Studio 并通过网络向他人提供该修改版本,你必须依据相同的 AGPL-3.0 条款向他们提供该修改版本的完整对应源代码。
|
||||
|
||||
**商业/企业**用户在 OmniVoice Studio 基础上构建竞争产品或服务需要商业许可证。**定价方案即将推出。** 在此期间如有疑问,请发送邮件至 **OmniVoice@palash.dev**。
|
||||
希望将 OmniVoice Studio 嵌入**闭源或专有**产品或服务、又不受 AGPL-3.0 著佐权义务约束的组织,可获取**商业许可证**。**定价方案即将推出。** 如有疑问:**OmniVoice@palash.dev**。
|
||||
|
||||
捆绑的 `omnivoice/`(由朱涵开发的 TTS 模型)在上游仍为 Apache-2.0 许可。完整且具约束力的条款请参见 [`LICENSE`](LICENSE)。
|
||||
|
||||
参见 [`LICENSE`](LICENSE) 查看完整条款。
|
||||
|
||||
|
||||
@@ -5,9 +5,12 @@ These are intentionally tiny — one concern per dependency — so they can be
|
||||
composed at the route or router level without surprises.
|
||||
|
||||
Currently exposed:
|
||||
- `require_loopback`: 403 unless the request came from a loopback origin.
|
||||
- `require_loopback`: 403 unless the request came from a loopback origin
|
||||
(bypassed in explicit server mode — see `_server_mode`).
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
|
||||
@@ -19,6 +22,28 @@ from fastapi import HTTPException, Request
|
||||
# the guard: nothing here matches a non-loopback origin.
|
||||
_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"})
|
||||
|
||||
_TRUTHY = frozenset({"1", "true", "yes", "on"})
|
||||
|
||||
|
||||
def _server_mode() -> bool:
|
||||
"""Whether this process is a headless server deployment (Docker image).
|
||||
|
||||
In Docker the loopback gate is *unenforceable*: Docker's network NAT
|
||||
rewrites ``request.client.host`` to the bridge gateway (e.g. 172.17.0.1)
|
||||
even for a localhost-only ``-p 127.0.0.1:3900:3900`` mapping, so every
|
||||
request looks non-loopback and the gate 403s the operator out of the
|
||||
system/settings routes they need (issue #261 — incl. ``/system/info``,
|
||||
which blanks the version display).
|
||||
|
||||
The Docker image sets ``OMNIVOICE_SERVER_MODE=1`` to opt out of the gate.
|
||||
Network exposure then rests on the operator's port mapping plus the
|
||||
optional share PIN (``NetworkAccessMiddleware`` still 401s unauthenticated
|
||||
non-loopback clients whenever a PIN is set). The desktop build never sets
|
||||
this, so its loopback boundary — including denying LAN share guests access
|
||||
to admin routes — is unchanged. Read at call time so it stays testable.
|
||||
"""
|
||||
return os.environ.get("OMNIVOICE_SERVER_MODE", "").strip().lower() in _TRUTHY
|
||||
|
||||
|
||||
def require_loopback(request: Request) -> None:
|
||||
"""Reject any request whose `client.host` is not a loopback address.
|
||||
@@ -35,7 +60,14 @@ def require_loopback(request: Request) -> None:
|
||||
Returns None on success (FastAPI dependency convention). Raises 403
|
||||
on rejection — the response body is `{"detail": "loopback origin required"}`
|
||||
so existing tests for `/system/set-env` keep passing without modification.
|
||||
|
||||
In server mode (Docker, see `_server_mode`) the gate is a no-op: the
|
||||
loopback origin is unenforceable there and exposure is governed by the
|
||||
deployment's port mapping + the optional share PIN instead.
|
||||
"""
|
||||
host = request.client.host if request.client else None
|
||||
if host not in _LOOPBACK_HOSTS:
|
||||
raise HTTPException(status_code=403, detail="loopback origin required")
|
||||
if host in _LOOPBACK_HOSTS:
|
||||
return
|
||||
if _server_mode():
|
||||
return
|
||||
raise HTTPException(status_code=403, detail="loopback origin required")
|
||||
|
||||
@@ -41,6 +41,17 @@ _PREVIEW_DIR = Path(OUTPUTS_DIR) / "archetype_previews"
|
||||
# Seed fixed so repeated renders of the same archetype are reproducible
|
||||
# (mirrors scripts/render_demos_omnivoice.py).
|
||||
_PREVIEW_SEED = 42
|
||||
# Diffusion steps for previews. 16 under-converges: certain (script, seed)
|
||||
# points — notably the "social" sample script at seed 42 — collapse to a
|
||||
# degenerate tonal buzz (The Hype Host / Podcaster / Vlogger, issue follow-up).
|
||||
# 32 reliably converges to speech across the gallery's instruct/script space
|
||||
# at a one-time (cached) render cost.
|
||||
_PREVIEW_NUM_STEP = 32
|
||||
# Spectral-flatness floor below which a render is a degenerate tonal artifact
|
||||
# rather than speech. Real, mastered speech sits ~0.04–0.07; a tonal buzz
|
||||
# collapses to <0.005. 0.015 separates the two with wide margin and sits well
|
||||
# below even breathy/whisper voices (which are broadband → high flatness).
|
||||
_DEGENERATE_FLATNESS = 0.015
|
||||
|
||||
|
||||
def _preview_key(a: dict) -> str:
|
||||
@@ -80,6 +91,41 @@ def _is_blank_audio(audio_tensor) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _spectral_flatness(audio_tensor) -> Optional[float]:
|
||||
"""Geometric-mean / arithmetic-mean of the power spectrum.
|
||||
|
||||
~1.0 for broadband noise, →0 for a pure tone. The degenerate diffusion
|
||||
renders this guards against are near-pure tonal buzzes (flatness <0.005),
|
||||
distinct from both silence (caught by ``_is_blank_audio``) and real speech
|
||||
(~0.04+). Returns ``None`` if it can't be computed so callers don't act on
|
||||
a bad measurement.
|
||||
"""
|
||||
try:
|
||||
import torch
|
||||
|
||||
t = audio_tensor if isinstance(audio_tensor, torch.Tensor) else torch.as_tensor(audio_tensor)
|
||||
t = t.detach().to("cpu", dtype=torch.float32).flatten()
|
||||
if t.numel() < 1024 or not torch.isfinite(t).all():
|
||||
return None
|
||||
spec = torch.fft.rfft(t * torch.hann_window(t.numel())).abs().pow(2) + 1e-12
|
||||
return float(torch.exp(torch.mean(torch.log(spec))) / torch.mean(spec))
|
||||
except Exception: # never let the checker itself block a render
|
||||
return None
|
||||
|
||||
|
||||
def _is_unusable_audio(audio_tensor) -> bool:
|
||||
"""True if a render is silent/non-finite OR a degenerate tonal buzz.
|
||||
|
||||
The blank guard alone misses the tonal-collapse failure mode: a buzz is
|
||||
*loud* (peaks near -2 dBFS after normalize), so it sails past the silence
|
||||
floor and — without this — gets cached and served as the preview.
|
||||
"""
|
||||
if _is_blank_audio(audio_tensor):
|
||||
return True
|
||||
flatness = _spectral_flatness(audio_tensor)
|
||||
return flatness is not None and flatness < _DEGENERATE_FLATNESS
|
||||
|
||||
|
||||
async def _render_archetype_wav(a: dict, out_path: Path) -> None:
|
||||
"""Render an archetype's sample script to ``out_path`` using the live engine.
|
||||
|
||||
@@ -112,7 +158,7 @@ async def _render_archetype_wav(a: dict, out_path: Path) -> None:
|
||||
None, # ref_text
|
||||
a["instruct"], # instruct
|
||||
None, # duration
|
||||
16, # num_step
|
||||
_PREVIEW_NUM_STEP, # num_step
|
||||
2.0, # guidance_scale
|
||||
1.0, # speed
|
||||
None, # t_shift
|
||||
@@ -126,13 +172,14 @@ async def _render_archetype_wav(a: dict, out_path: Path) -> None:
|
||||
)
|
||||
|
||||
audio_tensor = await loop.run_in_executor(_gpu_pool, _infer, _PREVIEW_SEED)
|
||||
if _is_blank_audio(audio_tensor):
|
||||
# Static message only — the archetype id derives from the request path
|
||||
# param, and CodeQL flags logging request-derived data (clear-text /
|
||||
# log-injection). The seed is a module constant, safe to log.
|
||||
logger.warning("Archetype rendered blank at seed %d — retrying once", _PREVIEW_SEED)
|
||||
if _is_unusable_audio(audio_tensor):
|
||||
# Blank OR a degenerate tonal buzz — retry once on a different seed to
|
||||
# step off the bad diffusion trajectory. Static message only: the
|
||||
# archetype id is request-derived (CodeQL log-injection); the seed is a
|
||||
# module constant, safe to log.
|
||||
logger.warning("Archetype rendered unusable at seed %d — retrying once", _PREVIEW_SEED)
|
||||
audio_tensor = await loop.run_in_executor(_gpu_pool, _infer, _PREVIEW_SEED + 1)
|
||||
if _is_blank_audio(audio_tensor):
|
||||
if _is_unusable_audio(audio_tensor):
|
||||
raise RuntimeError("the voice engine returned no audible audio for this archetype")
|
||||
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -200,7 +247,14 @@ async def preview_archetype(archetype_id: str):
|
||||
f"unavailable. See Settings → Logs → Backend. Error: {e}"
|
||||
),
|
||||
)
|
||||
return FileResponse(str(cache_path), media_type="audio/wav")
|
||||
# no-cache (not no-store): the URL is stable but its bytes change when an
|
||||
# archetype's preview is re-rendered, so force the client to revalidate
|
||||
# against the ETag instead of serving a stale cached clip indefinitely.
|
||||
return FileResponse(
|
||||
str(cache_path),
|
||||
media_type="audio/wav",
|
||||
headers={"Cache-Control": "no-cache"},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/archetypes/{archetype_id}/use")
|
||||
|
||||
@@ -366,14 +366,28 @@ _prep_event_helper = dub_pipeline.prep_event # alias; we keep the module-local
|
||||
|
||||
|
||||
@router.get("/dub/transcribe-stream/{job_id}")
|
||||
async def dub_transcribe_stream(job_id: str):
|
||||
async def dub_transcribe_stream(job_id: str, num_speakers: Optional[int] = None):
|
||||
"""Stream per-chunk segments via SSE, then emit diarized final pass.
|
||||
|
||||
Pre-flight checks (missing job, missing audio, ASR not loaded) are emitted
|
||||
as in-stream `error` events rather than HTTP errors, because EventSource
|
||||
on the client can't read non-2xx response bodies — a 503 there surfaces
|
||||
as an opaque "network error" instead of the actionable message we want.
|
||||
|
||||
`num_speakers` is an optional hint passed straight to pyannote. Left unset,
|
||||
pyannote auto-detects the count — but its auto-detect can collapse a
|
||||
multi-speaker clip to a single speaker (issue #274). When the user knows
|
||||
the exact count, supplying it forces pyannote to return that many speakers.
|
||||
"""
|
||||
# Clamp to a sane range; ignore anything non-positive / absurd so a bad
|
||||
# query string can never break the diarization call. None → auto-detect.
|
||||
if num_speakers is not None:
|
||||
try:
|
||||
num_speakers = int(num_speakers)
|
||||
num_speakers = num_speakers if 1 <= num_speakers <= 20 else None
|
||||
except (TypeError, ValueError):
|
||||
num_speakers = None
|
||||
|
||||
job = _get_job(job_id)
|
||||
|
||||
preflight_error: Optional[str] = None
|
||||
@@ -384,24 +398,38 @@ async def dub_transcribe_stream(job_id: str):
|
||||
if not job:
|
||||
preflight_error = "Job not found. It may have been cleaned up or was never created."
|
||||
else:
|
||||
_model = await get_model()
|
||||
asr_audio_target = job.get("vocals_path")
|
||||
if not asr_audio_target or not os.path.exists(asr_audio_target):
|
||||
asr_audio_target = job.get("audio_path")
|
||||
if not asr_audio_target or not os.path.exists(asr_audio_target):
|
||||
preflight_error = "No audio available for transcription."
|
||||
else:
|
||||
from services.asr_backend import get_active_asr_backend
|
||||
try:
|
||||
_asr_backend = get_active_asr_backend(asr_pipe=getattr(_model, "_asr_pipe", None))
|
||||
if _asr_backend.id == "pytorch-whisper" and getattr(_model, "_asr_pipe", None) is None:
|
||||
preflight_error = (
|
||||
"No ASR backend is ready. Install WhisperX/faster-whisper/MLX Whisper "
|
||||
"or set OMNIVOICE_PRELOAD_TTS_ASR=1 before launch to use the PyTorch fallback."
|
||||
# Guard the model load: if it raises, the SSE stream would otherwise die
|
||||
# before emitting any event, and the UI shows a misleading generic
|
||||
# "stream dropped" message instead of the real cause (issue #255).
|
||||
try:
|
||||
_model = await get_model()
|
||||
except Exception as e:
|
||||
logger.exception("transcribe preflight: model load failed (job=%s)", job_id)
|
||||
from core.failure import build_failure
|
||||
f = build_failure(e, stage="transcribe-preflight", include_diagnostic=False)
|
||||
preflight_error = f["reason"] + (f" — {f['hint']}" if f.get("hint") else "")
|
||||
_model = None
|
||||
if _model is not None:
|
||||
asr_audio_target = job.get("vocals_path")
|
||||
if not asr_audio_target or not os.path.exists(asr_audio_target):
|
||||
asr_audio_target = job.get("audio_path")
|
||||
if not asr_audio_target or not os.path.exists(asr_audio_target):
|
||||
preflight_error = "No audio available for transcription."
|
||||
else:
|
||||
from services.asr_backend import get_active_asr_backend
|
||||
try:
|
||||
# The PyTorch-Whisper backend lazily builds its own pipeline
|
||||
# when no preloaded `_asr_pipe` is present (issue #255), so it
|
||||
# no longer needs OMNIVOICE_PRELOAD_TTS_ASR=1 — don't reject it
|
||||
# here; any load failure surfaces per-chunk with a real cause.
|
||||
_asr_backend = get_active_asr_backend(asr_pipe=getattr(_model, "_asr_pipe", None))
|
||||
except Exception as e:
|
||||
from core.failure import build_failure
|
||||
f = build_failure(e, stage="transcribe-preflight", include_diagnostic=False)
|
||||
preflight_error = "ASR backend initialization failed: " + f["reason"] + (
|
||||
f" — {f['hint']}" if f.get("hint") else ""
|
||||
)
|
||||
except Exception as e:
|
||||
preflight_error = f"ASR backend initialization failed: {e}"
|
||||
scene_cuts = job.get("scene_cuts") or []
|
||||
scene_cuts = job.get("scene_cuts") or []
|
||||
|
||||
async def gen():
|
||||
if preflight_error:
|
||||
@@ -429,7 +457,12 @@ async def dub_transcribe_stream(job_id: str):
|
||||
|
||||
# Free VRAM: move TTS model to CPU so WhisperX + VAD can fit.
|
||||
# Only offloads when free GPU memory is < 4 GB (e.g. laptop GPUs).
|
||||
await loop.run_in_executor(_cpu_pool, offload_tts_for_asr)
|
||||
# Non-fatal: an offload failure must not drop the stream (#255) —
|
||||
# transcription can still proceed (it just has less headroom).
|
||||
try:
|
||||
await loop.run_in_executor(_cpu_pool, offload_tts_for_asr)
|
||||
except Exception as e:
|
||||
logger.warning("offload_tts_for_asr failed (continuing): %s", e)
|
||||
|
||||
all_segments: list[dict] = []
|
||||
detected_lang = None
|
||||
@@ -540,15 +573,23 @@ async def dub_transcribe_stream(job_id: str):
|
||||
# whisperx's VAD load, or an unsupported audio format.
|
||||
if not all_segments:
|
||||
# Deduplicate while preserving order so one root cause doesn't
|
||||
# repeat N times in the UI toast.
|
||||
# repeat N times in the UI toast. Sanitize each message so home
|
||||
# paths / tokens from a backend traceback never leak (#255).
|
||||
from core.failure import sanitize, build_failure
|
||||
seen = set()
|
||||
uniq: list[str] = []
|
||||
for msg in chunk_errors:
|
||||
if msg and msg not in seen:
|
||||
seen.add(msg)
|
||||
uniq.append(msg)
|
||||
s = sanitize(msg)
|
||||
if s and s not in seen:
|
||||
seen.add(s)
|
||||
uniq.append(s)
|
||||
if uniq:
|
||||
detail = "Transcription produced no segments. " + " | ".join(uniq[:3])
|
||||
# Add the actionable hint for a recognized failure class
|
||||
# (e.g. pkg_resources missing → install setuptools).
|
||||
hint = build_failure(" ".join(uniq), stage="transcribe", include_diagnostic=False).get("hint")
|
||||
if hint:
|
||||
detail += f" — {hint}"
|
||||
else:
|
||||
detail = (
|
||||
"Transcription produced no segments. The audio may be silent, "
|
||||
@@ -644,7 +685,15 @@ async def dub_transcribe_stream(job_id: str):
|
||||
},
|
||||
)
|
||||
try:
|
||||
diar = diar_pipe(asr_audio_target)
|
||||
# Pass the user's speaker-count hint through to pyannote when
|
||||
# provided (#274). pyannote's apply() accepts num_speakers;
|
||||
# omit it entirely when None so we don't depend on the kwarg
|
||||
# existing in every pyannote build.
|
||||
if num_speakers:
|
||||
logger.info("Diarizing with num_speakers=%d (user hint)", num_speakers)
|
||||
diar = diar_pipe(asr_audio_target, num_speakers=num_speakers)
|
||||
else:
|
||||
diar = diar_pipe(asr_audio_target)
|
||||
return assign_speakers_from_diarization(all_segments, diar), None
|
||||
except Exception as e:
|
||||
logger.error(f"Diarization failed: {e}")
|
||||
|
||||
@@ -20,6 +20,45 @@ from core import event_bus
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger("omnivoice.generate")
|
||||
|
||||
|
||||
def _render_with_pauses(gen_span, segments, sample_rate):
|
||||
"""Synthesize ``[(text, pause_ms), ...]`` spans and stitch silence between
|
||||
them (issue #276).
|
||||
|
||||
``gen_span(text) -> torch.Tensor`` synthesizes one text span (raw model
|
||||
output). A silence buffer of ``pause_ms`` is inserted after a span when
|
||||
requested, matching the audio tensor's channel dims / dtype / device.
|
||||
Returns the concatenated waveform. Kept model-free (``gen_span`` is injected)
|
||||
so the stitching is unit-testable without loading the TTS model.
|
||||
"""
|
||||
import torch
|
||||
|
||||
items = [] # ('a', tensor) for audio, ('s', n_samples) for silence
|
||||
for span_text, pause_ms in segments:
|
||||
if span_text and span_text.strip():
|
||||
items.append(("a", gen_span(span_text)))
|
||||
if pause_ms > 0:
|
||||
n = int(round(sample_rate * pause_ms / 1000.0))
|
||||
if n > 0:
|
||||
items.append(("s", n))
|
||||
|
||||
ref = next((t for kind, t in items if kind == "a"), None)
|
||||
if ref is None:
|
||||
# No speakable text (e.g. the input was only pause markers) — emit the
|
||||
# requested silence so the caller still gets a valid clip.
|
||||
total = sum(n for kind, n in items if kind == "s") or 1
|
||||
return torch.zeros(total, dtype=torch.float32)
|
||||
|
||||
parts = []
|
||||
for kind, val in items:
|
||||
if kind == "a":
|
||||
parts.append(val)
|
||||
else:
|
||||
shape = list(ref.shape)
|
||||
shape[-1] = val
|
||||
parts.append(torch.zeros(*shape, dtype=ref.dtype, device=ref.device))
|
||||
return torch.cat(parts, dim=-1)
|
||||
|
||||
def _run_inference(
|
||||
model, text, language, ref_audio_path, ref_text, instruct, duration,
|
||||
num_step, guidance_scale, speed, t_shift, denoise,
|
||||
@@ -38,17 +77,37 @@ def _run_inference(
|
||||
if position_temperature is not None: kwargs["position_temperature"] = position_temperature
|
||||
if class_temperature is not None: kwargs["class_temperature"] = class_temperature
|
||||
|
||||
audios = model.generate(
|
||||
text=text, language=language, ref_audio=ref_audio_path,
|
||||
ref_text=ref_text, instruct=instruct, duration=duration,
|
||||
num_step=num_step, guidance_scale=guidance_scale, speed=speed,
|
||||
denoise=denoise, postprocess_output=postprocess_output,
|
||||
**kwargs
|
||||
)
|
||||
audio_out = audios[0]
|
||||
|
||||
sr = model.sampling_rate if hasattr(model, 'sampling_rate') else 24000
|
||||
|
||||
# Inline [pause Nms] markers (issue #276): split the text and stitch
|
||||
# silence between independently-synthesized spans. Fully opt-in — text
|
||||
# without a marker takes the unchanged single-shot path below.
|
||||
from omnivoice.utils.text import parse_pause_markers
|
||||
segments = parse_pause_markers(text)
|
||||
has_pause = len(segments) > 1 or (segments and segments[0][1] > 0)
|
||||
|
||||
if has_pause:
|
||||
def _gen_span(span_text):
|
||||
# Per-span duration is left to the model; an explicit overall
|
||||
# `duration` can't be meaningfully split across spans.
|
||||
return model.generate(
|
||||
text=span_text, language=language, ref_audio=ref_audio_path,
|
||||
ref_text=ref_text, instruct=instruct, duration=None,
|
||||
num_step=num_step, guidance_scale=guidance_scale, speed=speed,
|
||||
denoise=denoise, postprocess_output=postprocess_output,
|
||||
**kwargs
|
||||
)[0]
|
||||
audio_out = _render_with_pauses(_gen_span, segments, sr)
|
||||
else:
|
||||
audios = model.generate(
|
||||
text=text, language=language, ref_audio=ref_audio_path,
|
||||
ref_text=ref_text, instruct=instruct, duration=duration,
|
||||
num_step=num_step, guidance_scale=guidance_scale, speed=speed,
|
||||
denoise=denoise, postprocess_output=postprocess_output,
|
||||
**kwargs
|
||||
)
|
||||
audio_out = audios[0]
|
||||
|
||||
# Apply DSP effect preset
|
||||
_effect_preset = effect_preset or "broadcast"
|
||||
|
||||
@@ -164,6 +223,16 @@ async def generate_speech(
|
||||
postprocess_output, layer_penalty_factor, position_temperature,
|
||||
class_temperature, used_seed, effect_preset,
|
||||
)
|
||||
# Invisible AudioSeal provenance watermark on the final audio. Embedding
|
||||
# was previously only wired into the dub pipeline (dub_generate.py), so
|
||||
# plain TTS came out unmarked despite the setting being on. embed_watermark
|
||||
# self-gates on the user's watermark setting + AudioSeal availability and
|
||||
# passes the audio through unchanged on any failure, so it never breaks
|
||||
# generation.
|
||||
from services.watermark import embed_watermark
|
||||
audio_tensor = await loop.run_in_executor(
|
||||
_gpu_pool, embed_watermark, audio_tensor, _model.sampling_rate
|
||||
)
|
||||
gen_time = round(time.time() - start_time, 2)
|
||||
|
||||
audio_id = str(uuid.uuid4())[:8]
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import os
|
||||
import sys
|
||||
import platform
|
||||
import time
|
||||
import uuid
|
||||
import psutil
|
||||
import asyncio
|
||||
@@ -15,6 +17,7 @@ import torch
|
||||
import shutil
|
||||
|
||||
from core.config import OUTPUTS_DIR, DATA_DIR, CRASH_LOG_PATH, LOG_PATH, IDLE_TIMEOUT_SECONDS
|
||||
from core.version import APP_VERSION
|
||||
from services.model_manager import get_model_status, get_best_device
|
||||
from services.ffmpeg_utils import find_ffmpeg, run_ffmpeg
|
||||
|
||||
@@ -38,6 +41,57 @@ _is_cuda = torch.cuda.is_available()
|
||||
psutil.cpu_percent(interval=None)
|
||||
|
||||
|
||||
def _detect_cpu_model() -> str:
|
||||
"""Human-readable CPU model. platform.processor() is empty on most
|
||||
Linux distros, so read /proc/cpuinfo there; sysctl on macOS."""
|
||||
try:
|
||||
if sys.platform.startswith("linux"):
|
||||
with open("/proc/cpuinfo") as f:
|
||||
for line in f:
|
||||
if line.lower().startswith("model name"):
|
||||
return line.split(":", 1)[1].strip()
|
||||
if sys.platform == "darwin":
|
||||
import subprocess
|
||||
return subprocess.check_output(
|
||||
["sysctl", "-n", "machdep.cpu.brand_string"], text=True, timeout=5
|
||||
).strip()
|
||||
return platform.processor() or ""
|
||||
except Exception:
|
||||
return platform.processor() or ""
|
||||
|
||||
|
||||
def _detect_gpu() -> tuple[str, float]:
|
||||
"""(gpu_name, vram_total_gb) — static for the process lifetime.
|
||||
|
||||
MPS has unified memory, so there's no separate VRAM figure to report;
|
||||
the name alone tells a bug-report reader what hardware this is.
|
||||
"""
|
||||
try:
|
||||
if _is_cuda:
|
||||
props = torch.cuda.get_device_properties(0)
|
||||
return torch.cuda.get_device_name(0), round(props.total_memory / (1024 ** 3), 1)
|
||||
if _is_mac:
|
||||
return "Apple Silicon (MPS)", 0.0
|
||||
except Exception:
|
||||
pass
|
||||
return "", 0.0
|
||||
|
||||
|
||||
# Static hardware facts, captured once — /system/info is hit on every
|
||||
# Settings page load and must stay cheap.
|
||||
_CPU_MODEL = _detect_cpu_model()
|
||||
_GPU_NAME, _VRAM_TOTAL_GB = _detect_gpu()
|
||||
_RAM_TOTAL_GB = round(psutil.virtual_memory().total / (1024 ** 3), 1)
|
||||
_OS_VERSION = platform.platform()
|
||||
|
||||
|
||||
def _disk_free_gb() -> float:
|
||||
try:
|
||||
return round(shutil.disk_usage(DATA_DIR).free / (1024 ** 3), 1)
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
|
||||
def _ui_port() -> int:
|
||||
"""The Vite UI dev-server port, single-sourced from OMNIVOICE_UI_PORT.
|
||||
|
||||
@@ -168,6 +222,7 @@ def system_info():
|
||||
try:
|
||||
_ffmpeg = find_ffmpeg()
|
||||
return {
|
||||
"app_version": APP_VERSION,
|
||||
"data_dir": DATA_DIR,
|
||||
"outputs_dir": OUTPUTS_DIR,
|
||||
"crash_log_path": CRASH_LOG_PATH,
|
||||
@@ -179,6 +234,14 @@ def system_info():
|
||||
"device": get_best_device(),
|
||||
"python": sys.version.split()[0],
|
||||
"platform": sys.platform,
|
||||
"arch": platform.machine(),
|
||||
"os_version": _OS_VERSION,
|
||||
"cpu_model": _CPU_MODEL,
|
||||
"cpu_count": psutil.cpu_count(logical=True) or 0,
|
||||
"ram_total_gb": _RAM_TOTAL_GB,
|
||||
"gpu_name": _GPU_NAME,
|
||||
"vram_total_gb": _VRAM_TOTAL_GB,
|
||||
"disk_free_gb": _disk_free_gb(),
|
||||
"ffmpeg_ok": bool(_ffmpeg),
|
||||
"ffmpeg_path": _ffmpeg or "",
|
||||
"proxy_url": os.environ.get("HTTP_PROXY") or os.environ.get("http_proxy") or "",
|
||||
@@ -193,6 +256,7 @@ def system_info():
|
||||
except Exception as e:
|
||||
logger.exception("system_info failed — returning safe defaults")
|
||||
return {
|
||||
"app_version": APP_VERSION,
|
||||
"data_dir": DATA_DIR,
|
||||
"outputs_dir": OUTPUTS_DIR,
|
||||
"crash_log_path": str(CRASH_LOG_PATH),
|
||||
@@ -204,6 +268,14 @@ def system_info():
|
||||
"device": "cpu",
|
||||
"python": sys.version.split()[0],
|
||||
"platform": sys.platform,
|
||||
"arch": platform.machine(),
|
||||
"os_version": _OS_VERSION,
|
||||
"cpu_model": _CPU_MODEL,
|
||||
"cpu_count": psutil.cpu_count(logical=True) or 0,
|
||||
"ram_total_gb": _RAM_TOTAL_GB,
|
||||
"gpu_name": _GPU_NAME,
|
||||
"vram_total_gb": _VRAM_TOTAL_GB,
|
||||
"disk_free_gb": _disk_free_gb(),
|
||||
"proxy_url": "",
|
||||
"share_enabled": network_share.get_state().enabled,
|
||||
"share_port": network_share.get_state().share_port,
|
||||
@@ -226,11 +298,19 @@ def _tail_file(path: str, tail: int):
|
||||
def _tauri_log_candidates():
|
||||
"""Likely paths for Tauri-side logs, most useful first.
|
||||
|
||||
`tauri-plugin-log` writes to `~/Library/Logs/<bundle_id>/<file_name>.log`
|
||||
by default on macOS. Our bundle id is `com.debpalash.omnivoice-studio`
|
||||
(see frontend/src-tauri/tauri.conf.json). lib.rs also redirects the
|
||||
spawned backend's stdout/stderr to `~/Library/Logs/OmniVoice/backend.log`
|
||||
which is where `print()` calls and uvicorn startup banners land.
|
||||
Two distinct producers, both per-platform:
|
||||
|
||||
- `tauri-plugin-log` writes `tauri.log` to the app log dir
|
||||
(`~/Library/Logs/<bundle_id>` on macOS, `$XDG_DATA_HOME/<bundle_id>/logs`
|
||||
on Linux, `%LOCALAPPDATA%\\<bundle_id>\\logs` on Windows). Bundle id is
|
||||
`com.debpalash.omnivoice-studio` (frontend/src-tauri/tauri.conf.json).
|
||||
- backend.rs::backend_log_path() redirects the spawned backend's
|
||||
stdout/stderr to `backend.log` / `backend_err.log` under
|
||||
`~/Library/Logs/OmniVoice` (macOS), `$XDG_STATE_HOME/OmniVoice` falling
|
||||
back to `~/.local/state/OmniVoice` (Linux), and
|
||||
`%LOCALAPPDATA%\\OmniVoice\\Logs` (Windows). This is where uvicorn
|
||||
startup banners and hard-crash tracebacks land — keep all three OS
|
||||
shapes listed or sidecar crashes become invisible off-macOS.
|
||||
"""
|
||||
home = os.path.expanduser("~")
|
||||
bid = "com.debpalash.omnivoice-studio"
|
||||
@@ -242,14 +322,21 @@ def _tauri_log_candidates():
|
||||
os.path.join(home, "Library/Logs/OmniVoice/backend_err.log"),
|
||||
]
|
||||
if sys.platform.startswith("linux"):
|
||||
state_dir = os.environ.get("XDG_STATE_HOME") or os.path.join(home, ".local/state")
|
||||
return [
|
||||
os.path.join(home, ".local/share", bid, "logs", "tauri.log"),
|
||||
os.path.join(home, ".config", bid, "logs", "tauri.log"),
|
||||
os.path.join(state_dir, "OmniVoice", "backend.log"),
|
||||
os.path.join(state_dir, "OmniVoice", "backend_err.log"),
|
||||
]
|
||||
if sys.platform.startswith("win"):
|
||||
appdata = os.environ.get("APPDATA", home)
|
||||
localappdata = os.environ.get("LOCALAPPDATA") or os.path.join(home, "AppData", "Local")
|
||||
return [
|
||||
os.path.join(localappdata, bid, "logs", "tauri.log"),
|
||||
os.path.join(appdata, bid, "logs", "tauri.log"),
|
||||
os.path.join(localappdata, "OmniVoice", "Logs", "backend.log"),
|
||||
os.path.join(localappdata, "OmniVoice", "Logs", "backend_err.log"),
|
||||
]
|
||||
return []
|
||||
|
||||
@@ -563,9 +650,57 @@ def system_notifications():
|
||||
"action": None,
|
||||
})
|
||||
|
||||
# 5. A previous session logged a crash the user never saw.
|
||||
# crash_log grew past the last acknowledged size AND predates this
|
||||
# process — i.e. it happened last run, not just now (errors from the
|
||||
# current session already surfaced as toasts).
|
||||
try:
|
||||
if _crashed_last_session():
|
||||
notes.append({
|
||||
"id": "crash-last-session",
|
||||
"level": "error",
|
||||
"title": "Last session ended with an error",
|
||||
"message": (
|
||||
"A crash was logged before this session started. "
|
||||
"Review the backend log and consider filing a report."
|
||||
),
|
||||
"action": {
|
||||
"label": "View logs",
|
||||
"type": "navigate",
|
||||
"target": "settings",
|
||||
},
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"notifications": notes, "count": len(notes)}
|
||||
|
||||
|
||||
# Process start time — anchors "did the crash happen before this run?".
|
||||
_PROCESS_START_TS = time.time()
|
||||
|
||||
|
||||
def _crashed_last_session() -> bool:
|
||||
from core.prefs import get as prefs_get
|
||||
|
||||
if not os.path.exists(CRASH_LOG_PATH):
|
||||
return False
|
||||
size = os.path.getsize(CRASH_LOG_PATH)
|
||||
acked = int(prefs_get("crash_log_acked_size", 0) or 0)
|
||||
if size <= acked:
|
||||
return False
|
||||
return os.path.getmtime(CRASH_LOG_PATH) < _PROCESS_START_TS
|
||||
|
||||
|
||||
@router.post("/system/crash/ack")
|
||||
async def ack_crash():
|
||||
"""Mark the current crash log as seen — dismisses the
|
||||
'crash-last-session' notification until the log grows again."""
|
||||
size = os.path.getsize(CRASH_LOG_PATH) if os.path.exists(CRASH_LOG_PATH) else 0
|
||||
prefs_set("crash_log_acked_size", size)
|
||||
return {"acked_size": size}
|
||||
|
||||
|
||||
# ── Environment variable setter ───────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -783,6 +918,59 @@ def hf_token_state():
|
||||
}
|
||||
|
||||
|
||||
# ── Error journal ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/system/errors/recent")
|
||||
def recent_errors(limit: int = Query(20, ge=1, le=50)):
|
||||
"""Recent unhandled backend errors, newest first — structured, deduped
|
||||
(count per fingerprint), classified (error_class), pre-scrubbed. The
|
||||
bug-report pipeline reads this to auto-attach the most recent backend
|
||||
failure; Settings → Logs can render it as a triage view.
|
||||
"""
|
||||
from core import error_journal
|
||||
|
||||
errors = error_journal.recent(limit)
|
||||
return {"errors": errors, "count": len(errors)}
|
||||
|
||||
|
||||
# ── Diagnostic bundle ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/system/diagnostic-bundle")
|
||||
async def diagnostic_bundle(network: bool = Query(False, description="Include the hub reachability probe")):
|
||||
"""Build the drag-onto-a-GitHub-issue zip (core.diagnostic_bundle):
|
||||
self-check report, recent error journal, scrubbed log tails. Returns the
|
||||
local path so the UI can reveal it in the file manager. The path itself
|
||||
is NOT scrubbed — this response never leaves the machine; the zip's
|
||||
*contents* are scrubbed because the zip does.
|
||||
"""
|
||||
from core.diagnostic_bundle import build_bundle
|
||||
|
||||
path = await asyncio.to_thread(build_bundle, network)
|
||||
return {"path": path, "filename": os.path.basename(path)}
|
||||
|
||||
|
||||
# ── Self-check diagnostics ────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/system/diagnose")
|
||||
async def system_diagnose(
|
||||
network: bool = Query(True, description="Include the HuggingFace hub reachability probe"),
|
||||
deep: bool = Query(False, description="Also load the active engine and synthesize a short utterance (may cold-load the model — minutes on first run)"),
|
||||
):
|
||||
"""Run the self-check suite (core.diagnose) and return the structured report.
|
||||
|
||||
The hub probe can block up to ~5s (and ``deep=true`` far longer), so the
|
||||
whole run goes through a threadpool; pass ``network=false`` for an
|
||||
instant offline report. Output is pre-scrubbed (core.scrub) — safe to
|
||||
paste into a GitHub issue.
|
||||
"""
|
||||
from core.diagnose import run_diagnostics
|
||||
|
||||
return await asyncio.to_thread(run_diagnostics, network, deep)
|
||||
|
||||
|
||||
# ── Phase 1 Wave 3 — macOS Gatekeeper quarantine probe (#54) ────────────
|
||||
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ class SystemInfoResponse(BaseModel):
|
||||
"""GET /system/info"""
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
app_version: str = ""
|
||||
data_dir: str
|
||||
outputs_dir: str
|
||||
crash_log_path: str
|
||||
@@ -36,6 +37,14 @@ class SystemInfoResponse(BaseModel):
|
||||
device: str = "cpu"
|
||||
python: str = ""
|
||||
platform: str = ""
|
||||
arch: str = ""
|
||||
os_version: str = ""
|
||||
cpu_model: str = ""
|
||||
cpu_count: int = 0
|
||||
ram_total_gb: float = 0.0
|
||||
gpu_name: str = ""
|
||||
vram_total_gb: float = 0.0
|
||||
disk_free_gb: float = 0.0
|
||||
error: str | None = None
|
||||
ffmpeg_ok: bool = False
|
||||
ffmpeg_path: str = ""
|
||||
|
||||
@@ -58,11 +58,11 @@ models:
|
||||
size_gb: 0.08
|
||||
platforms: [darwin-arm64]
|
||||
|
||||
- repo_id: "Systran/faster-whisper-large-v3-turbo"
|
||||
- repo_id: "deepdml/faster-whisper-large-v3-turbo-ct2"
|
||||
label: "Whisper large-v3 Turbo (5× faster, 0.8B)"
|
||||
role: ASR
|
||||
size_gb: 1.6
|
||||
note: "Best speed/quality tradeoff. 5× faster than large-v3 with minimal WER loss."
|
||||
note: "Best speed/quality tradeoff. 5× faster than large-v3 with minimal WER loss. Community CTranslate2 conversion (no official Systran/OpenAI turbo repo) — re-verify availability on catalog audits."
|
||||
|
||||
- repo_id: "Systran/faster-distil-whisper-large-v3"
|
||||
label: "Distil-Whisper large-v3 (distilled, fast)"
|
||||
@@ -110,11 +110,11 @@ models:
|
||||
size_gb: 0.12
|
||||
note: "Variable-length processing, sub-200ms latency. Great for CPU/edge. Requires moonshine-onnx."
|
||||
|
||||
- repo_id: "UsefulSensors/moonshine-small"
|
||||
label: "Moonshine small (edge-optimized, 300M, ONNX)"
|
||||
- repo_id: "UsefulSensors/moonshine-tiny"
|
||||
label: "Moonshine tiny (edge-optimized, 27M, ONNX)"
|
||||
role: ASR
|
||||
size_gb: 0.6
|
||||
note: "Higher accuracy than base, still fast. Requires moonshine-onnx."
|
||||
size_gb: 0.05
|
||||
note: "Smallest/fastest Moonshine, sub-200ms latency. Lower accuracy than base. Requires moonshine-onnx."
|
||||
|
||||
# ── Diarisation ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -8,10 +8,10 @@ taxonomy. Each archetype carries an ``instruct`` string (e.g.
|
||||
|
||||
Two tiers (the "hybrid" gallery model):
|
||||
|
||||
* **Featured** — ~24 hand-curated archetypes spanning the seven use-case
|
||||
categories. Pre-rendered preview WAVs are produced by
|
||||
``scripts/render_demos_omnivoice.py``; until a WAV exists the API renders one
|
||||
on demand.
|
||||
* **Featured** — ~51 hand-curated archetypes (24 English across the seven
|
||||
use-case categories + 27 multilingual designed voices in nine more languages).
|
||||
Pre-rendered preview WAVs are produced by ``scripts/render_demos_omnivoice.py``;
|
||||
until a WAV exists the API renders one on demand.
|
||||
* **Generated** — the full combinatorial space of gender × age × pitch ×
|
||||
accent (English) and gender × age × pitch × dialect (Chinese), pruned of
|
||||
physically-implausible combinations (no "child + very low pitch"). This is
|
||||
@@ -308,7 +308,69 @@ def _make_featured():
|
||||
return out
|
||||
|
||||
|
||||
_FEATURED = _make_featured()
|
||||
# ── Featured: multilingual designed voices ────────────────────────────────────
|
||||
# The voice-design *timbre* axes (gender/age/pitch) are language-independent, and
|
||||
# the spoken language of a designed voice is driven by the preview *text*, not by
|
||||
# the instruct — the same neutral instruct renders in any of OmniVoice's 646
|
||||
# languages (the exact ``model.generate(text=…, language=…, instruct=…)`` call
|
||||
# the Generate tab already makes). So we ship a curated set in the major languages
|
||||
# the app already localizes its UI into, giving the gallery more than English +
|
||||
# Chinese out of the box.
|
||||
#
|
||||
# These carry **no accent/dialect token**: accents are English-only and dialects
|
||||
# Chinese-only, so a "spanish accent" token doesn't exist in the taxonomy and
|
||||
# would crash synthesis (the issue-#89 failure mode). Using only the universal
|
||||
# gender/age/pitch axes keeps every instruct inside the validator's vocabulary —
|
||||
# ``test_archetypes.py`` enforces this independently.
|
||||
#
|
||||
# Each ``language`` label must match an entry in ``frontend/src/languages.json``
|
||||
# verbatim, because the string flows straight into ``model.generate(language=…)``
|
||||
# with no normalization. ("Arabic" is intentionally omitted — it is not in that
|
||||
# list.) ``_ML_SAMPLES`` is functional demo/eval text (like ``_ZH_SAMPLE``); the
|
||||
# Japanese/Korean lines are covered by this file's ``test_no_hardcoded_cjk.py``
|
||||
# allowlist entry.
|
||||
_ML_SAMPLES = {
|
||||
"Spanish": "Hola y bienvenido a esta breve demostración de voz. Espero que disfrutes escuchando cómo suena.",
|
||||
"French": "Bonjour et bienvenue dans cette courte démonstration vocale. J'espère que cette voix vous plaira.",
|
||||
"German": "Hallo und willkommen zu dieser kurzen Sprachdemo. Ich hoffe, diese Stimme gefällt dir.",
|
||||
"Italian": "Ciao e benvenuto in questa breve dimostrazione vocale. Spero che questa voce ti piaccia.",
|
||||
"Portuguese": "Olá e bem-vindo a esta breve demonstração de voz. Espero que goste de ouvir como ela soa.",
|
||||
"Russian": "Здравствуйте и добро пожаловать в эту короткую демонстрацию голоса. Надеюсь, вам понравится, как он звучит.",
|
||||
"Hindi": "नमस्ते और इस छोटे से वॉइस डेमो में आपका स्वागत है। मुझे आशा है कि आपको यह आवाज़ पसंद आएगी।",
|
||||
"Japanese": "こんにちは。この短い音声デモへようこそ。この声を気に入っていただけるとうれしいです。",
|
||||
"Korean": "안녕하세요. 이 짧은 음성 데모에 오신 것을 환영합니다. 이 목소리가 마음에 드시길 바랍니다.",
|
||||
}
|
||||
|
||||
# Three reusable, language-independent roles. (gender, age, pitch, use_case,
|
||||
# role, icon) — instruct is built from gender/age/pitch only.
|
||||
_ML_ROLES = [
|
||||
("female", "middle-aged", "low pitch", "narration", "Narrator", "BookOpen"),
|
||||
("male", "young adult", "moderate pitch", "informative", "Explainer", "GraduationCap"),
|
||||
("female", "young adult", "moderate pitch", "conversational", "Companion", "MessagesSquare"),
|
||||
]
|
||||
|
||||
|
||||
def _make_multilingual():
|
||||
"""Build the featured archetypes for the non-EN/ZH languages.
|
||||
|
||||
Cross-products ``_ML_SAMPLES`` (one localized preview script per language)
|
||||
with ``_ML_ROLES`` (the reusable, language-independent timbre roles), so each
|
||||
language gets the same curated set of neutral-instruct designed voices.
|
||||
"""
|
||||
out = []
|
||||
for language, script in _ML_SAMPLES.items():
|
||||
lang_slug = language.lower()
|
||||
for gender, age, pitch, uc, role, icon in _ML_ROLES:
|
||||
out.append(_build(
|
||||
gender, age, pitch,
|
||||
use_case=uc, name=f"{language} {role}", icon=icon,
|
||||
language=language, script=script,
|
||||
featured=True, fid=f"ml_{lang_slug}_{role.lower()}",
|
||||
))
|
||||
return out
|
||||
|
||||
|
||||
_FEATURED = _make_featured() + _make_multilingual()
|
||||
_FEATURED_KEYS = {(a["instruct"], a["language"]) for a in _FEATURED}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
"""Self-check diagnostics — answers "why doesn't it work on my machine?"
|
||||
|
||||
One pass over everything a working install needs: Python, compute device,
|
||||
ffmpeg, HF token, disk, data-dir permissions, RAM, TTS engines, and (when
|
||||
requested) network reachability of the HuggingFace hub. Surfaced two ways:
|
||||
|
||||
- ``GET /system/diagnose`` (Settings > About → "Run self-check")
|
||||
- ``python main.py --diagnose`` for headless installs / issue triage
|
||||
|
||||
Every ``detail``/``hint`` string is passed through ``core.scrub`` before it
|
||||
leaves this module, so the report is safe to paste straight into a GitHub
|
||||
issue — that's its whole purpose.
|
||||
|
||||
Check shape:
|
||||
|
||||
{"id": str, "label": str, "status": "ok"|"warn"|"fail",
|
||||
"detail": str, "hint": Optional[str]}
|
||||
|
||||
``fail`` = the app cannot do its job (no disk, unwritable data dir).
|
||||
``warn`` = degraded but usable (CPU-only, no HF token, hub unreachable).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
from core.config import DATA_DIR
|
||||
from core.scrub import scrub_text
|
||||
from core.version import APP_VERSION
|
||||
|
||||
OK = "ok"
|
||||
WARN = "warn"
|
||||
FAIL = "fail"
|
||||
|
||||
# Below this much free disk the model cache can't even hold one engine.
|
||||
_DISK_FAIL_GB = 2
|
||||
_DISK_WARN_GB = 10
|
||||
_RAM_WARN_GB = 8
|
||||
|
||||
_HUB_URL = "https://huggingface.co"
|
||||
_HUB_TIMEOUT_S = 5
|
||||
|
||||
|
||||
def _check(check_id: str, label: str, status: str, detail: str, hint: str | None = None) -> dict:
|
||||
return {
|
||||
"id": check_id,
|
||||
"label": label,
|
||||
"status": status,
|
||||
"detail": scrub_text(detail),
|
||||
"hint": scrub_text(hint) if hint else None,
|
||||
}
|
||||
|
||||
|
||||
def _check_python() -> dict:
|
||||
return _check(
|
||||
"python", "Python runtime", OK,
|
||||
f"{sys.version.split()[0]} on {platform.platform()}",
|
||||
)
|
||||
|
||||
|
||||
def _check_device() -> dict:
|
||||
try:
|
||||
from services.model_manager import get_best_device
|
||||
device = get_best_device()
|
||||
except Exception as e:
|
||||
return _check(
|
||||
"device", "Compute device", FAIL,
|
||||
f"device detection failed: {e}",
|
||||
"Reinstall may be needed - torch could not initialize.",
|
||||
)
|
||||
gpu_name = ""
|
||||
try:
|
||||
import torch
|
||||
if torch.cuda.is_available():
|
||||
gpu_name = torch.cuda.get_device_name(0)
|
||||
except Exception:
|
||||
pass
|
||||
if device == "cpu":
|
||||
return _check(
|
||||
"device", "Compute device", WARN,
|
||||
"cpu (no GPU acceleration detected)",
|
||||
"Generation will be slow. If this machine has a GPU, check CUDA/ROCm drivers (Linux/Windows) or that you're on Apple Silicon (macOS).",
|
||||
)
|
||||
detail = f"{device} ({gpu_name})" if gpu_name else device
|
||||
return _check("device", "Compute device", OK, detail)
|
||||
|
||||
|
||||
def _check_ffmpeg() -> dict:
|
||||
try:
|
||||
from services.ffmpeg_utils import find_ffmpeg
|
||||
path = find_ffmpeg()
|
||||
except Exception:
|
||||
path = None
|
||||
if path:
|
||||
return _check("ffmpeg", "ffmpeg", OK, str(path))
|
||||
return _check(
|
||||
"ffmpeg", "ffmpeg", FAIL,
|
||||
"not found on PATH or FFMPEG_PATH",
|
||||
"Dubbing and audio conversion need ffmpeg: brew install ffmpeg (macOS), apt install ffmpeg (Linux), or set the path in Settings > General.",
|
||||
)
|
||||
|
||||
|
||||
def _check_hf_token() -> dict:
|
||||
# Presence only — the resolver never hands us the raw token and we
|
||||
# wouldn't print it anyway.
|
||||
try:
|
||||
from services import token_resolver
|
||||
present = token_resolver.resolve() is not None
|
||||
except Exception:
|
||||
present = False
|
||||
if present:
|
||||
return _check("hf_token", "HuggingFace token", OK, "configured")
|
||||
return _check(
|
||||
"hf_token", "HuggingFace token", WARN,
|
||||
"not set",
|
||||
"Downloads may be rate-limited and speaker diarization won't work. Set one in Settings > Credentials.",
|
||||
)
|
||||
|
||||
|
||||
def _check_disk() -> dict:
|
||||
try:
|
||||
usage = shutil.disk_usage(DATA_DIR)
|
||||
except Exception as e:
|
||||
return _check("disk", "Disk space", WARN, f"could not stat {DATA_DIR}: {e}")
|
||||
free_gb = usage.free / (1024 ** 3)
|
||||
detail = f"{free_gb:.1f} GB free at {DATA_DIR}"
|
||||
if free_gb < _DISK_FAIL_GB:
|
||||
return _check(
|
||||
"disk", "Disk space", FAIL, detail,
|
||||
"Model downloads need several GB. Free up space or move OMNIVOICE_DATA_DIR to a larger volume.",
|
||||
)
|
||||
if free_gb < _DISK_WARN_GB:
|
||||
return _check(
|
||||
"disk", "Disk space", WARN, detail,
|
||||
"Engine model downloads can be 1-4 GB each; you may run out mid-download.",
|
||||
)
|
||||
return _check("disk", "Disk space", OK, detail)
|
||||
|
||||
|
||||
def _check_data_dir() -> dict:
|
||||
probe = os.path.join(DATA_DIR, ".diagnose_write_probe")
|
||||
try:
|
||||
with open(probe, "w") as f:
|
||||
f.write("ok")
|
||||
os.remove(probe)
|
||||
return _check("data_dir", "Data directory", OK, f"writable: {DATA_DIR}")
|
||||
except Exception as e:
|
||||
return _check(
|
||||
"data_dir", "Data directory", FAIL,
|
||||
f"not writable: {DATA_DIR} ({e})",
|
||||
"Voices, projects, and logs all live here. Fix permissions or point OMNIVOICE_DATA_DIR somewhere writable.",
|
||||
)
|
||||
|
||||
|
||||
def _check_ram() -> dict:
|
||||
try:
|
||||
import psutil
|
||||
total_gb = psutil.virtual_memory().total / (1024 ** 3)
|
||||
except Exception as e:
|
||||
return _check("ram", "System memory", WARN, f"could not read: {e}")
|
||||
detail = f"{total_gb:.1f} GB total"
|
||||
if total_gb < _RAM_WARN_GB:
|
||||
return _check(
|
||||
"ram", "System memory", WARN, detail,
|
||||
"Large engines may swap or OOM below 8 GB. Prefer lighter engines and close other apps while generating.",
|
||||
)
|
||||
return _check("ram", "System memory", OK, detail)
|
||||
|
||||
|
||||
def _check_engines() -> dict:
|
||||
try:
|
||||
from services.tts_backend import list_backends, active_backend_id
|
||||
backends = list_backends()
|
||||
active = active_backend_id()
|
||||
except Exception as e:
|
||||
return _check("engines", "TTS engines", WARN, f"could not enumerate: {e}")
|
||||
available = [b["id"] for b in backends if b.get("available")]
|
||||
detail = f"active: {active}; available: {', '.join(available) or 'none'}"
|
||||
active_row = next((b for b in backends if b.get("id") == active), None)
|
||||
if active_row is not None and not active_row.get("available"):
|
||||
reason = active_row.get("reason") or "unavailable"
|
||||
return _check(
|
||||
"engines", "TTS engines", FAIL,
|
||||
f"{detail} - active engine '{active}' is unavailable: {reason}",
|
||||
active_row.get("install_hint") or "Pick a different engine in Settings > Engines.",
|
||||
)
|
||||
if not available:
|
||||
return _check(
|
||||
"engines", "TTS engines", FAIL, detail,
|
||||
"No usable TTS engine. Install one from Settings > Engines.",
|
||||
)
|
||||
return _check("engines", "TTS engines", OK, detail)
|
||||
|
||||
|
||||
_DEEP_TIMEOUT_S = 180
|
||||
|
||||
|
||||
def _check_deep_synthesis() -> dict:
|
||||
"""Actually load the active engine and synthesize a short utterance.
|
||||
|
||||
Catches "installed but broken" — the most common issue category — which
|
||||
the presence checks above can't see. Opt-in only (?deep=true / --deep):
|
||||
it may cold-load the model (minutes + a multi-GB download on a fresh
|
||||
install), so it must never run on a casual Settings-page self-check.
|
||||
"""
|
||||
try:
|
||||
from services.model_manager import get_model_status
|
||||
if get_model_status().get("status") == "loading":
|
||||
return _check(
|
||||
"deep_synth", "Deep synthesis", WARN,
|
||||
"skipped - a model load is already in progress",
|
||||
"Re-run once the current load finishes.",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
import concurrent.futures
|
||||
import time as _time
|
||||
|
||||
def _synth():
|
||||
import services.model_manager as mm
|
||||
from services.tts_backend import get_active_tts_backend, active_backend_id
|
||||
backend = get_active_tts_backend(model=mm.model)
|
||||
wav = backend.generate("Diagnostics check, one two three.", num_step=4)
|
||||
return active_backend_id(), int(wav.shape[-1]) / max(1, backend.sample_rate)
|
||||
|
||||
t0 = _time.perf_counter()
|
||||
ex = concurrent.futures.ThreadPoolExecutor(max_workers=1)
|
||||
try:
|
||||
engine_id, audio_s = ex.submit(_synth).result(timeout=_DEEP_TIMEOUT_S)
|
||||
except concurrent.futures.TimeoutError:
|
||||
return _check(
|
||||
"deep_synth", "Deep synthesis", FAIL,
|
||||
f"timed out after {_DEEP_TIMEOUT_S}s - engine load or synthesis hung",
|
||||
"If this is a first run, the model may still be downloading - retry later. Otherwise check the backend log for where it stalled.",
|
||||
)
|
||||
except Exception as e:
|
||||
return _check(
|
||||
"deep_synth", "Deep synthesis", FAIL,
|
||||
f"active engine failed: {type(e).__name__}: {e}",
|
||||
"The engine is installed but not producing audio. The error above is the lead; Settings > Logs has the full trace.",
|
||||
)
|
||||
finally:
|
||||
# Never block the report on a hung worker; the thread is left to
|
||||
# finish (or hang) on its own — the timeout verdict already shipped.
|
||||
ex.shutdown(wait=False)
|
||||
elapsed = _time.perf_counter() - t0
|
||||
if audio_s <= 0:
|
||||
return _check(
|
||||
"deep_synth", "Deep synthesis", FAIL,
|
||||
f"engine '{engine_id}' returned empty audio in {elapsed:.1f}s",
|
||||
"Synthesis ran but produced no samples - engine output is broken.",
|
||||
)
|
||||
return _check(
|
||||
"deep_synth", "Deep synthesis", OK,
|
||||
f"engine '{engine_id}' produced {audio_s:.1f}s of audio in {elapsed:.1f}s",
|
||||
)
|
||||
|
||||
|
||||
def _check_network() -> dict:
|
||||
# Any HTTP response — even a 4xx — proves the hub is reachable; that's
|
||||
# all model downloads need to get started. urllib honors HTTP(S)_PROXY.
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
req = urllib.request.Request(_HUB_URL, method="HEAD")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=_HUB_TIMEOUT_S):
|
||||
pass
|
||||
return _check("network", "HuggingFace hub", OK, f"{_HUB_URL} reachable")
|
||||
except urllib.error.HTTPError:
|
||||
return _check("network", "HuggingFace hub", OK, f"{_HUB_URL} reachable")
|
||||
except Exception as e:
|
||||
return _check(
|
||||
"network", "HuggingFace hub", WARN,
|
||||
f"{_HUB_URL} unreachable: {e}",
|
||||
"Model downloads will fail until this resolves. Behind a restricted network, set a proxy in Settings > General or configure a mirror via HF_ENDPOINT.",
|
||||
)
|
||||
|
||||
|
||||
def run_diagnostics(include_network: bool = True, deep: bool = False) -> dict:
|
||||
"""Run every check and return the structured report.
|
||||
|
||||
``include_network=False`` skips the hub probe — used by tests and by
|
||||
callers that need the report to come back instantly offline.
|
||||
``deep=True`` additionally loads the active engine and synthesizes a
|
||||
short utterance (may take minutes on a cold install — opt-in only).
|
||||
"""
|
||||
checks = [
|
||||
_check_python(),
|
||||
_check_device(),
|
||||
_check_ffmpeg(),
|
||||
_check_hf_token(),
|
||||
_check_disk(),
|
||||
_check_data_dir(),
|
||||
_check_ram(),
|
||||
_check_engines(),
|
||||
]
|
||||
if include_network:
|
||||
checks.append(_check_network())
|
||||
if deep:
|
||||
checks.append(_check_deep_synthesis())
|
||||
|
||||
counts = {OK: 0, WARN: 0, FAIL: 0}
|
||||
for c in checks:
|
||||
counts[c["status"]] += 1
|
||||
return {
|
||||
"app_version": APP_VERSION,
|
||||
"platform": scrub_text(platform.platform()),
|
||||
"checks": checks,
|
||||
"summary": {
|
||||
"ok": counts[FAIL] == 0,
|
||||
"passed": counts[OK],
|
||||
"warnings": counts[WARN],
|
||||
"failures": counts[FAIL],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def format_text(report: dict) -> str:
|
||||
"""Human-readable rendering for `--diagnose` / pasting into an issue.
|
||||
|
||||
ASCII-only on purpose — Windows consoles with legacy code pages must
|
||||
not choke on the output.
|
||||
"""
|
||||
tag = {OK: "[ OK ]", WARN: "[WARN]", FAIL: "[FAIL]"}
|
||||
lines = [
|
||||
f"OmniVoice Studio self-check - v{report['app_version']} on {report['platform']}",
|
||||
"",
|
||||
]
|
||||
for c in report["checks"]:
|
||||
lines.append(f"{tag[c['status']]} {c['label']}: {c['detail']}")
|
||||
if c.get("hint"):
|
||||
lines.append(f" hint: {c['hint']}")
|
||||
s = report["summary"]
|
||||
lines.append("")
|
||||
lines.append(
|
||||
f"{s['passed']} ok, {s['warnings']} warning(s), {s['failures']} failure(s) - "
|
||||
+ ("looks healthy" if s["ok"] else "needs attention")
|
||||
)
|
||||
return "\n".join(lines)
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Diagnostic bundle — everything a maintainer needs, in one drag-and-drop.
|
||||
|
||||
The prefilled GitHub Issues URL caps out around 8k characters, so logs can
|
||||
never ride along with a report. This module zips the full picture instead:
|
||||
|
||||
omnivoice-diagnostics-<timestamp>.zip
|
||||
├── meta.json app version, platform, python, generated-at
|
||||
├── self_check.txt human-readable diagnose report
|
||||
├── self_check.json same, structured
|
||||
├── errors.json recent error journal (deduped, classified)
|
||||
└── logs/
|
||||
├── omnivoice.log.txt last 500 lines, scrubbed
|
||||
└── crash_log.txt last 200 lines, scrubbed
|
||||
|
||||
Settings → About → "Save diagnostic bundle" builds it and reveals the file;
|
||||
the user drags it onto their GitHub issue. Every text member is passed
|
||||
through core.scrub — the bundle is built TO leave the machine, so it must
|
||||
be safe by construction. The zip is written to OUTPUTS_DIR (user-visible,
|
||||
already revealed-in-folder elsewhere in the app).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import sys
|
||||
import time
|
||||
import zipfile
|
||||
|
||||
from core.config import OUTPUTS_DIR, LOG_PATH, CRASH_LOG_PATH
|
||||
from core.scrub import scrub_text
|
||||
from core.version import APP_VERSION
|
||||
|
||||
_LOG_TAIL_LINES = 500
|
||||
_CRASH_TAIL_LINES = 200
|
||||
|
||||
|
||||
def _scrubbed_tail(path: str, max_lines: int) -> str:
|
||||
"""Last `max_lines` of `path`, scrubbed. Missing/unreadable file → a
|
||||
one-line note instead of a hard failure (the bundle must always build)."""
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
||||
lines = f.readlines()
|
||||
except FileNotFoundError:
|
||||
return f"(no file at {scrub_text(path)})\n"
|
||||
except Exception as e:
|
||||
return f"(could not read {scrub_text(path)}: {scrub_text(str(e))})\n"
|
||||
return scrub_text("".join(lines[-max_lines:]))
|
||||
|
||||
|
||||
def build_bundle(include_network: bool = False) -> str:
|
||||
"""Build the zip and return its absolute path.
|
||||
|
||||
``include_network=False`` by default: the bundle is usually requested
|
||||
exactly when something is wrong, and a hung hub probe shouldn't add 5s
|
||||
to "save the evidence".
|
||||
"""
|
||||
from core.diagnose import run_diagnostics, format_text
|
||||
from core import error_journal
|
||||
|
||||
report = run_diagnostics(include_network=include_network)
|
||||
|
||||
meta = {
|
||||
"app_version": APP_VERSION,
|
||||
"platform": scrub_text(platform.platform()),
|
||||
"python": sys.version.split()[0],
|
||||
"generated_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
||||
}
|
||||
|
||||
stamp = time.strftime("%Y%m%d-%H%M%S")
|
||||
os.makedirs(OUTPUTS_DIR, exist_ok=True)
|
||||
out_path = os.path.join(OUTPUTS_DIR, f"omnivoice-diagnostics-{stamp}.zip")
|
||||
|
||||
with zipfile.ZipFile(out_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
zf.writestr("meta.json", json.dumps(meta, indent=2, ensure_ascii=False))
|
||||
zf.writestr("self_check.txt", format_text(report))
|
||||
zf.writestr("self_check.json", json.dumps(report, indent=2, ensure_ascii=False))
|
||||
zf.writestr(
|
||||
"errors.json",
|
||||
json.dumps(error_journal.recent(50), indent=2, ensure_ascii=False),
|
||||
)
|
||||
zf.writestr("logs/omnivoice.log.txt", _scrubbed_tail(LOG_PATH, _LOG_TAIL_LINES))
|
||||
zf.writestr("logs/crash_log.txt", _scrubbed_tail(CRASH_LOG_PATH, _CRASH_TAIL_LINES))
|
||||
|
||||
return out_path
|
||||
@@ -0,0 +1,206 @@
|
||||
"""Ring journal of recent backend errors — the "what just broke" store.
|
||||
|
||||
The global exception handler (main.py) records every unhandled exception
|
||||
here. Unlike crash_log.txt (append-only plain text for humans), the journal
|
||||
is structured and deduplicated, so the UI and the bug-report pipeline can
|
||||
answer:
|
||||
|
||||
- what was the most recent backend error? (auto-attach to a report)
|
||||
- is it the same error repeating? (count by fingerprint, "x14 since start")
|
||||
- what KIND of failure is it? (error_class — GPU_OOM, HF_AUTH_FAILED, …)
|
||||
|
||||
Everything stored is pre-scrubbed (core.scrub) because journal entries feed
|
||||
the diagnostic bundle and prefilled GitHub issues. In-memory ring of
|
||||
``_MAX_ENTRIES`` fingerprints, mirrored to ``DATA_DIR/error_journal.jsonl``
|
||||
(rewritten on each record — entry count is small, atomicity beats append
|
||||
here) so the journal survives restarts and the crash it just recorded.
|
||||
|
||||
``error_class`` values: the install-time classes reuse the locked taxonomy
|
||||
keys from core.error_docs_map (HF_AUTH_FAILED, PYANNOTE_LICENSE_REQUIRED) so
|
||||
docs deeplinks keep working; runtime classes (GPU_OOM, DISK_FULL,
|
||||
NETWORK_ERROR, FFMPEG_MISSING) are journal-local and fall back to
|
||||
DEFAULT_DOCS in lookup(). Don't add them to ERROR_DOCS without following
|
||||
the 4-step mirror contract documented there.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
|
||||
from core.config import DATA_DIR
|
||||
from core.scrub import scrub_text
|
||||
|
||||
JOURNAL_PATH = os.path.join(DATA_DIR, "error_journal.jsonl")
|
||||
|
||||
_MAX_ENTRIES = 50
|
||||
_MAX_TRACE_CHARS = 4000
|
||||
|
||||
_lock = threading.Lock()
|
||||
# fingerprint -> entry, oldest first (move_to_end on repeat).
|
||||
_entries: "OrderedDict[str, dict]" = OrderedDict()
|
||||
|
||||
|
||||
# Ordered: first match wins, most specific patterns up top.
|
||||
_CLASS_RULES: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
("GPU_OOM", (
|
||||
"cuda out of memory",
|
||||
"mps backend out of memory",
|
||||
"hip out of memory",
|
||||
"out of memory on device",
|
||||
)),
|
||||
("PYANNOTE_LICENSE_REQUIRED", (
|
||||
"pyannote", # only meaningful combined with an auth marker — see classify()
|
||||
)),
|
||||
("HF_AUTH_FAILED", (
|
||||
"401 client error",
|
||||
"403 client error",
|
||||
"gatedrepoerror",
|
||||
"repository not found",
|
||||
"invalid user token",
|
||||
"huggingface_hub.errors",
|
||||
)),
|
||||
("DISK_FULL", (
|
||||
"no space left on device",
|
||||
"errno 28",
|
||||
"disk quota exceeded",
|
||||
)),
|
||||
("FFMPEG_MISSING", (
|
||||
"ffmpeg not found",
|
||||
"ffmpeg is not installed",
|
||||
"no such file or directory: 'ffmpeg'",
|
||||
)),
|
||||
("NETWORK_ERROR", (
|
||||
"connection refused",
|
||||
"connection reset",
|
||||
"connection aborted",
|
||||
"timed out",
|
||||
"timeout",
|
||||
"name or service not known",
|
||||
"temporary failure in name resolution",
|
||||
"ssl",
|
||||
"proxyerror",
|
||||
)),
|
||||
)
|
||||
|
||||
_AUTH_MARKERS = ("401", "403", "gated", "access", "token")
|
||||
|
||||
|
||||
def classify_exception(exc: BaseException, trace: str = "") -> str:
|
||||
"""Best-effort classification of an exception into a stable class key.
|
||||
|
||||
Pattern-matching on message text is inherently fuzzy — the goal is
|
||||
triage ("which docs page / which hint"), not perfection. UNKNOWN is an
|
||||
acceptable answer.
|
||||
"""
|
||||
blob = f"{type(exc).__name__}: {exc}\n{trace}".lower()
|
||||
for cls, needles in _CLASS_RULES:
|
||||
if cls == "PYANNOTE_LICENSE_REQUIRED":
|
||||
# pyannote in the trace alone is too broad (any diarization bug
|
||||
# would match); require an auth/gating marker alongside it.
|
||||
if "pyannote" in blob and any(m in blob for m in _AUTH_MARKERS):
|
||||
return cls
|
||||
continue
|
||||
if any(n in blob for n in needles):
|
||||
return cls
|
||||
return "UNKNOWN"
|
||||
|
||||
|
||||
def _fingerprint(error_class: str, exc: BaseException) -> str:
|
||||
import hashlib
|
||||
raw = f"{error_class}|{type(exc).__name__}|{scrub_text(str(exc))[:200]}"
|
||||
return hashlib.sha1(raw.encode("utf-8", "replace")).hexdigest()[:16]
|
||||
|
||||
|
||||
def _persist_locked() -> None:
|
||||
"""Rewrite the JSONL mirror from the in-memory ring. Caller holds _lock.
|
||||
Never raises — losing persistence must not break the exception handler."""
|
||||
try:
|
||||
tmp = JOURNAL_PATH + ".tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
for entry in _entries.values():
|
||||
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
||||
os.replace(tmp, JOURNAL_PATH)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _hydrate() -> None:
|
||||
"""Load persisted entries at import so 'recent errors' survives restarts
|
||||
(and shows the error that killed the previous run)."""
|
||||
try:
|
||||
with open(JOURNAL_PATH, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
try:
|
||||
entry = json.loads(line)
|
||||
fp = entry.get("fingerprint")
|
||||
if fp:
|
||||
_entries[fp] = entry
|
||||
except Exception:
|
||||
continue
|
||||
while len(_entries) > _MAX_ENTRIES:
|
||||
_entries.popitem(last=False)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
_hydrate()
|
||||
|
||||
|
||||
def record(exc: BaseException, route: str = "", trace: str = "") -> dict:
|
||||
"""Record an unhandled exception. Returns the (scrubbed) journal entry.
|
||||
|
||||
Never raises — this runs inside the global exception handler, where a
|
||||
second failure would shadow the one being reported.
|
||||
"""
|
||||
try:
|
||||
error_class = classify_exception(exc, trace)
|
||||
fp = _fingerprint(error_class, exc)
|
||||
now = time.strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with _lock:
|
||||
existing = _entries.get(fp)
|
||||
if existing:
|
||||
existing["count"] = int(existing.get("count", 1)) + 1
|
||||
existing["last_seen"] = now
|
||||
existing["route"] = scrub_text(route) or existing.get("route", "")
|
||||
_entries.move_to_end(fp)
|
||||
entry = existing
|
||||
else:
|
||||
entry = {
|
||||
"fingerprint": fp,
|
||||
"error_class": error_class,
|
||||
"type": type(exc).__name__,
|
||||
"message": scrub_text(str(exc)),
|
||||
"route": scrub_text(route),
|
||||
"trace": scrub_text(trace)[:_MAX_TRACE_CHARS],
|
||||
"first_seen": now,
|
||||
"last_seen": now,
|
||||
"count": 1,
|
||||
}
|
||||
_entries[fp] = entry
|
||||
while len(_entries) > _MAX_ENTRIES:
|
||||
_entries.popitem(last=False)
|
||||
_persist_locked()
|
||||
return entry
|
||||
except Exception:
|
||||
return {"error_class": "UNKNOWN", "type": type(exc).__name__, "count": 1}
|
||||
|
||||
|
||||
def recent(limit: int = 20) -> list[dict]:
|
||||
"""Most recent errors first."""
|
||||
with _lock:
|
||||
items = list(_entries.values())
|
||||
return list(reversed(items))[: max(1, min(limit, _MAX_ENTRIES))]
|
||||
|
||||
|
||||
def clear() -> None:
|
||||
with _lock:
|
||||
_entries.clear()
|
||||
try:
|
||||
os.remove(JOURNAL_PATH)
|
||||
except OSError:
|
||||
pass
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Privacy scrubber for diagnostic text that may leave the machine.
|
||||
|
||||
Everything OmniVoice renders into a bug report or diagnostic dump goes
|
||||
through ``scrub_text()`` before it can reach a prefilled GitHub Issues URL
|
||||
(the only outbound path — see CLAUDE.md Capability 2). The scrubber is the
|
||||
backend twin of ``frontend/src/utils/bugReport.js``'s ``scrubText`` and
|
||||
must stay at least as strict:
|
||||
|
||||
- home directories → ``~`` (macOS ``/Users/<name>``, Linux ``/home/<name>``,
|
||||
Windows ``C:\\Users\\<name>``, plus the *actual* ``$HOME`` of this process)
|
||||
- credential-shaped substrings → ``***REDACTED***`` (HF tokens, GitHub
|
||||
PATs, OpenAI-style ``sk-`` keys)
|
||||
- values of env vars whose NAME matches ``*TOKEN*|*KEY*|*SECRET*|
|
||||
*PASSWORD*|*CREDENTIAL*`` — so a stack trace that interpolated a real
|
||||
secret still comes out clean
|
||||
|
||||
Unlike ``core.logging_filter`` (which rewrites log records in-flight and
|
||||
must stay cheap), this module runs on report-sized strings at report time,
|
||||
so it can afford the env-var sweep.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
REDACTED = "***REDACTED***"
|
||||
|
||||
# Env-var NAMES whose values must never appear in scrubbed output.
|
||||
_SECRET_NAME_RE = re.compile(r"TOKEN|KEY|SECRET|PASSWORD|CREDENTIAL", re.IGNORECASE)
|
||||
|
||||
# Credential-shaped substrings, independent of where they came from.
|
||||
# Thresholds mirror core.logging_filter: long enough that identifiers like
|
||||
# `hf_hub` or `sk-learn` survive, short enough that real tokens never do.
|
||||
_TOKEN_PATTERNS = (
|
||||
re.compile(r"hf_[A-Za-z0-9]{30,}"), # HuggingFace
|
||||
re.compile(r"github_pat_[A-Za-z0-9_]{20,}"), # GitHub fine-grained PAT
|
||||
re.compile(r"gh[pousr]_[A-Za-z0-9]{30,}"), # GitHub classic tokens
|
||||
re.compile(r"sk-[A-Za-z0-9_\-]{20,}"), # OpenAI-style API keys
|
||||
)
|
||||
|
||||
# Home-directory shapes for all three supported platforms. Matched
|
||||
# pattern-wise (not just this machine's $HOME) so paths quoted from a
|
||||
# user's pasted log on another OS get cleaned too.
|
||||
_HOME_PATTERNS = (
|
||||
re.compile(r"/Users/[^/\s\"']+"), # macOS
|
||||
re.compile(r"/home/[^/\s\"']+"), # Linux
|
||||
re.compile(r"[A-Za-z]:\\Users\\[^\\\s\"']+"), # Windows
|
||||
)
|
||||
|
||||
# Values shorter than this are too entropy-poor to be real secrets and too
|
||||
# likely to shred unrelated text (e.g. PASSWORD_MIN_LENGTH=8 would otherwise
|
||||
# turn every "8" in the report into ***REDACTED***).
|
||||
_MIN_SECRET_LEN = 8
|
||||
|
||||
|
||||
def _env_secret_values() -> list[str]:
|
||||
"""Values of secret-named env vars, longest first so overlapping
|
||||
values (e.g. a token and its prefix) redact cleanly."""
|
||||
vals = [
|
||||
v
|
||||
for k, v in os.environ.items()
|
||||
if _SECRET_NAME_RE.search(k) and v and len(v) >= _MIN_SECRET_LEN
|
||||
]
|
||||
return sorted(vals, key=len, reverse=True)
|
||||
|
||||
|
||||
def scrub_text(text: str | None) -> str:
|
||||
"""Return ``text`` with secrets and home paths redacted.
|
||||
|
||||
Never raises — scrubbing failure must not block a bug report, and a
|
||||
partially-scrubbed string is still better than an unscrubbed one, so
|
||||
each pass is independent.
|
||||
"""
|
||||
if not text:
|
||||
return "" if text is None else str(text)
|
||||
s = str(text)
|
||||
|
||||
# 1. Exact env-var secret values (most specific — run first).
|
||||
try:
|
||||
for val in _env_secret_values():
|
||||
s = s.replace(val, REDACTED)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2. Credential-shaped substrings.
|
||||
for pat in _TOKEN_PATTERNS:
|
||||
try:
|
||||
s = pat.sub(REDACTED, s)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 3. This process's real home dir (covers symlinked/nonstandard homes
|
||||
# the generic patterns miss), then the per-OS shapes.
|
||||
try:
|
||||
home = os.path.expanduser("~")
|
||||
if home and home not in ("/", "~"):
|
||||
s = s.replace(home, "~")
|
||||
except Exception:
|
||||
pass
|
||||
for pat in _HOME_PATTERNS:
|
||||
try:
|
||||
s = pat.sub("~", s)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return s
|
||||
@@ -12,4 +12,4 @@ from importlib.metadata import PackageNotFoundError, version
|
||||
try:
|
||||
APP_VERSION = version("omnivoice")
|
||||
except PackageNotFoundError: # non-installed source checkout
|
||||
APP_VERSION = "0.3.0"
|
||||
APP_VERSION = "0.3.5"
|
||||
|
||||
+50
-2
@@ -19,6 +19,22 @@ if sys.platform == "win32":
|
||||
os.environ.setdefault("TORCHDYNAMO_DISABLE", "1")
|
||||
os.environ.setdefault("TORCHINDUCTOR_DISABLE", "1")
|
||||
|
||||
# The backend's stdout/stderr are pipes owned by the desktop shell that
|
||||
# spawned it. If that shell exits while the backend survives (crash,
|
||||
# relaunch, orphan), the pipes close — and the next write raises
|
||||
# BrokenPipeError. transformers' tqdm weight-loading bar writes constantly,
|
||||
# so an orphaned backend couldn't load the model at all (caught in the wild
|
||||
# by the in-app diagnostic report). Wrap stdio so EPIPE is swallowed
|
||||
# process-wide: logs are best-effort for a server, model loading is not.
|
||||
# (utils.hf_progress.SafeFileWrapper — same wrapper the patched hub tqdm
|
||||
# already uses for its own fp.)
|
||||
from utils.hf_progress import SafeFileWrapper as _SafeStdio # noqa: E402
|
||||
|
||||
if not getattr(sys.stdout, "_is_safe_wrapper", False):
|
||||
sys.stdout = _SafeStdio(sys.stdout)
|
||||
if not getattr(sys.stderr, "_is_safe_wrapper", False):
|
||||
sys.stderr = _SafeStdio(sys.stderr)
|
||||
|
||||
try:
|
||||
import dotenv
|
||||
|
||||
@@ -482,6 +498,13 @@ async def global_exception_handler(request: Request, exc: Exception):
|
||||
except Exception:
|
||||
logger.exception("Failed to write crash log")
|
||||
logger.exception("Unhandled exception for %s", request.url)
|
||||
# Structured journal entry (dedup + error_class) — feeds /system/errors/
|
||||
# recent, the diagnostic bundle, and the bug-report pipeline. record()
|
||||
# never raises; a journal failure must not shadow the real error.
|
||||
from core import error_journal
|
||||
_entry = error_journal.record(
|
||||
exc, route=str(request.url.path), trace=traceback.format_exc()
|
||||
)
|
||||
# CORSMiddleware doesn't always get a shot at `exception_handler`-created
|
||||
# responses, which leaves the browser reporting every 500 as a bare CORS
|
||||
# error. Attach the headers manually so the real `detail` bubbles up.
|
||||
@@ -491,7 +514,11 @@ async def global_exception_handler(request: Request, exc: Exception):
|
||||
headers["Access-Control-Allow-Origin"] = origin
|
||||
headers["Access-Control-Allow-Credentials"] = "true"
|
||||
headers["Vary"] = "Origin"
|
||||
return JSONResponse({"detail": str(exc)}, status_code=500, headers=headers)
|
||||
return JSONResponse(
|
||||
{"detail": str(exc), "error_class": _entry.get("error_class")},
|
||||
status_code=500,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
|
||||
_LOOPBACK_CLIENTS = {"127.0.0.1", "::1"}
|
||||
@@ -606,7 +633,7 @@ def health():
|
||||
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
||||
device = "mps"
|
||||
|
||||
return {"status": "ok", "device": device}
|
||||
return {"status": "ok", "device": device, "version": APP_VERSION}
|
||||
|
||||
|
||||
app.include_router(system.router)
|
||||
@@ -693,8 +720,29 @@ if __name__ == "__main__":
|
||||
help="Boot the server, poll /health, exit 0 on success / 1 on timeout. "
|
||||
"Used by the release-time installer smoke step in .github/workflows/release.yml.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--diagnose",
|
||||
action="store_true",
|
||||
help="Run the self-check suite (device, ffmpeg, HF token, disk, engines, "
|
||||
"network) without starting the server. Exit 0 if healthy, 1 if any "
|
||||
"check fails. Output is scrubbed — safe to paste into a GitHub issue.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--deep",
|
||||
action="store_true",
|
||||
help="With --diagnose: also load the active TTS engine and synthesize a "
|
||||
"short utterance. Catches 'installed but broken'. May cold-load the "
|
||||
"model (minutes + a large download on a fresh install).",
|
||||
)
|
||||
args, _unknown = parser.parse_known_args()
|
||||
|
||||
if args.diagnose:
|
||||
from core.diagnose import run_diagnostics, format_text
|
||||
|
||||
_report = run_diagnostics(deep=args.deep)
|
||||
print(format_text(_report), flush=True)
|
||||
sys.exit(0 if _report["summary"]["ok"] else 1)
|
||||
|
||||
# Single-sourced from OMNIVOICE_PORT so the bare `python main.py` path and
|
||||
# `--health-check` agree with the Rust sidecar / uvicorn-CLI `--port`.
|
||||
_port = network_share.backend_port()
|
||||
|
||||
@@ -115,13 +115,40 @@ class WhisperXBackend(ASRBackend):
|
||||
# ships a checkpoint with a new pickle class, the load fails loudly
|
||||
# and we extend `_allow_vad_pickle_globals()`.
|
||||
self._allow_vad_pickle_globals()
|
||||
self._asr = whisperx.load_model(
|
||||
self._model_name,
|
||||
device=self._device,
|
||||
compute_type=self._compute_type,
|
||||
# vad_method="silero" is the default; keep it so short gaps
|
||||
# get cleaned up before transcription.
|
||||
)
|
||||
try:
|
||||
self._asr = whisperx.load_model(
|
||||
self._model_name,
|
||||
device=self._device,
|
||||
compute_type=self._compute_type,
|
||||
# vad_method="silero" is the default; keep it so short gaps
|
||||
# get cleaned up before transcription.
|
||||
)
|
||||
except RuntimeError as e:
|
||||
# CUDA OOM: a resident TTS model + the GPU worker pool can starve
|
||||
# VRAM on small (e.g. 8 GB laptop) GPUs, so loading large-v3 on
|
||||
# CUDA dies here — which previously surfaced as a bare 500 from
|
||||
# /dub/transcribe with no guidance. Fall back to CPU (slower, but
|
||||
# dubbing still works and keeps the same model/accuracy) instead.
|
||||
# Only triggers on a CUDA OOM, so the MPS/CPU paths are untouched.
|
||||
if self._device == "cuda" and "out of memory" in str(e).lower():
|
||||
logger.warning(
|
||||
"whisperx CUDA OOM loading %s — retrying on CPU (slower). "
|
||||
"Free VRAM (Flush the TTS model) for GPU-speed ASR. Detail: %s",
|
||||
self._model_name, e,
|
||||
)
|
||||
try:
|
||||
import torch
|
||||
torch.cuda.empty_cache()
|
||||
except Exception: # noqa: BLE001 — cache clear is best-effort
|
||||
pass
|
||||
self._device, self._compute_type = "cpu", "int8"
|
||||
self._asr = whisperx.load_model(
|
||||
self._model_name,
|
||||
device=self._device,
|
||||
compute_type=self._compute_type,
|
||||
)
|
||||
else:
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def _allow_vad_pickle_globals():
|
||||
@@ -550,22 +577,32 @@ class PyTorchWhisperBackend(ASRBackend):
|
||||
def _ensure_pipe(self):
|
||||
if self._pipe is not None:
|
||||
return
|
||||
# Fall back to grabbing the TTS model's ASR head.
|
||||
import asyncio
|
||||
from services.model_manager import get_model
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
if loop.is_running():
|
||||
raise RuntimeError(
|
||||
"PyTorchWhisperBackend needs the ASR pipe — pass it via constructor "
|
||||
"when calling from an async context."
|
||||
)
|
||||
model = loop.run_until_complete(get_model())
|
||||
except RuntimeError:
|
||||
model = asyncio.run(get_model())
|
||||
self._pipe = getattr(model, "_asr_pipe", None)
|
||||
if self._pipe is None:
|
||||
raise RuntimeError("Loaded TTS model has no `_asr_pipe` attribute.")
|
||||
# Build a standalone transformers Whisper pipeline on demand. This runs
|
||||
# on PyTorch's own stack (cuDNN 9 ships with torch), so it works as a
|
||||
# fallback on machines where WhisperX / faster-whisper can't load
|
||||
# cuDNN 8 (the `cudnn_ops_infer64_8.dll` failure, issue #255) — and it
|
||||
# needs neither OMNIVOICE_PRELOAD_TTS_ASR=1 nor a loaded TTS model.
|
||||
# When the TTS model already has an ASR head, dub_core passes it via the
|
||||
# constructor and this path is skipped.
|
||||
import torch
|
||||
from transformers import pipeline as hf_pipeline
|
||||
from services.model_manager import get_best_device
|
||||
|
||||
model_name = os.environ.get(
|
||||
"OMNIVOICE_PYTORCH_ASR_MODEL", "openai/whisper-large-v3-turbo"
|
||||
)
|
||||
device = get_best_device()
|
||||
asr_dtype = torch.float16 if str(device).startswith("cuda") else torch.float32
|
||||
logger.info(
|
||||
"PyTorchWhisperBackend: loading standalone ASR pipeline %s on %s",
|
||||
model_name, device,
|
||||
)
|
||||
self._pipe = hf_pipeline(
|
||||
"automatic-speech-recognition",
|
||||
model=model_name,
|
||||
dtype=asr_dtype,
|
||||
device_map=device,
|
||||
)
|
||||
|
||||
def transcribe(self, audio_path: str, *, word_timestamps: bool = True) -> dict:
|
||||
import soundfile as sf
|
||||
|
||||
@@ -633,6 +633,18 @@ def get_diarization_pipeline(return_error: bool = False):
|
||||
try:
|
||||
torch = _lazy_torch()
|
||||
_ensure_pyannote_hf_token_compat() # #167: use_auth_token -> token
|
||||
# PyTorch 2.6 flipped torch.load's default to weights_only=True, whose
|
||||
# secure unpickler rejects the pyannote checkpoint's metadata globals
|
||||
# (torch_version.TorchVersion, omegaconf nodes, …) — surfacing as
|
||||
# "Weights only load failed / Unsupported global" and breaking
|
||||
# diarization on torch>=2.6 even after the license is accepted (#270).
|
||||
# Reuse the exact allowlist the WhisperX VAD load registers so the
|
||||
# secure load path succeeds; it is idempotent and per-process.
|
||||
try:
|
||||
from services.asr_backend import WhisperXBackend
|
||||
WhisperXBackend._allow_vad_pickle_globals()
|
||||
except Exception as _glob_e:
|
||||
logger.debug("pyannote safe-globals allowlist skipped: %s", _glob_e)
|
||||
from pyannote.audio import Pipeline
|
||||
logger.info("Loading Pyannote Diarization Pipeline...")
|
||||
_diar_pipeline = Pipeline.from_pretrained("pyannote/speaker-diarization-3.1", use_auth_token=hf_token)
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Unit tests for the archetype-preview quality guard (``api.routers.archetypes``).
|
||||
|
||||
Background: the Hype Host / Podcaster / Vlogger previews shipped a loud tonal
|
||||
*buzz* instead of speech. The renderer pinned ``num_step=16`` + ``seed=42`` and
|
||||
the "social" sample script collapsed to a near-pure tone at that point; the
|
||||
old silence-only guard missed it (the buzz is loud, not silent) so the garbage
|
||||
was cached and served.
|
||||
|
||||
These tests cover the fix *without the 5 GB model / a GPU*: they drive the pure
|
||||
``_spectral_flatness`` / ``_is_unusable_audio`` helpers with synthetic signals,
|
||||
and assert the render constants didn't regress. The real end-to-end render is
|
||||
verified manually (spectral flatness back in the speech range + Whisper ASR).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
# Stub core.config before the router imports OUTPUTS_DIR / VOICES_DIR from it.
|
||||
_TMP = tempfile.mkdtemp(prefix="omnivoice_preview_q_")
|
||||
_config = types.ModuleType("core.config")
|
||||
_config.DATA_DIR = _TMP
|
||||
_config.VOICES_DIR = str(Path(_TMP) / "voices")
|
||||
_config.OUTPUTS_DIR = str(Path(_TMP) / "outputs")
|
||||
sys.modules["core.config"] = _config
|
||||
|
||||
torch = pytest.importorskip("torch") # noqa: E402
|
||||
|
||||
from api.routers import archetypes as arch # noqa: E402
|
||||
|
||||
SR = 24_000
|
||||
N = SR * 3 # 3 s clips
|
||||
|
||||
|
||||
def _pure_tone(hz: float = 220.0) -> "torch.Tensor":
|
||||
t = torch.arange(N, dtype=torch.float32) / SR
|
||||
return 0.8 * torch.sin(2 * math.pi * hz * t)
|
||||
|
||||
|
||||
def _white_noise() -> "torch.Tensor":
|
||||
g = torch.Generator().manual_seed(0)
|
||||
return 0.5 * (torch.rand(N, generator=g) * 2 - 1)
|
||||
|
||||
|
||||
def _speech_like() -> "torch.Tensor":
|
||||
"""Broadband + harmonic + amplitude-modulated — a coarse stand-in for voiced
|
||||
speech: several harmonics (formant-ish), additive noise (consonants), and a
|
||||
syllabic envelope (word gaps). Flatness lands between a pure tone and noise.
|
||||
"""
|
||||
g = torch.Generator().manual_seed(1)
|
||||
t = torch.arange(N, dtype=torch.float32) / SR
|
||||
harm = sum(torch.sin(2 * math.pi * f * t) / (i + 1)
|
||||
for i, f in enumerate((130.0, 260.0, 390.0, 520.0)))
|
||||
noise = 0.3 * (torch.rand(N, generator=g) * 2 - 1)
|
||||
env = 0.5 + 0.5 * torch.sin(2 * math.pi * 4.0 * t).clamp(min=0) # ~4 Hz syllables
|
||||
sig = (harm + noise) * env
|
||||
return 0.7 * sig / sig.abs().max()
|
||||
|
||||
|
||||
# ── _spectral_flatness ──────────────────────────────────────────────────────
|
||||
def test_flatness_orders_tone_below_speech_below_noise():
|
||||
tone = arch._spectral_flatness(_pure_tone())
|
||||
speech = arch._spectral_flatness(_speech_like())
|
||||
noise = arch._spectral_flatness(_white_noise())
|
||||
assert tone is not None and speech is not None and noise is not None
|
||||
assert tone < arch._DEGENERATE_FLATNESS < speech < noise
|
||||
|
||||
|
||||
def test_flatness_returns_none_on_too_short_or_nonfinite():
|
||||
assert arch._spectral_flatness(torch.zeros(16)) is None
|
||||
bad = torch.full((4096,), float("nan"))
|
||||
assert arch._spectral_flatness(bad) is None
|
||||
|
||||
|
||||
# ── _is_unusable_audio ──────────────────────────────────────────────────────
|
||||
def test_pure_tone_is_unusable():
|
||||
# The degenerate-buzz failure mode: loud (passes the silence guard) but tonal.
|
||||
tone = _pure_tone()
|
||||
assert tone.abs().max() > 0.02 # not silent
|
||||
assert arch._is_unusable_audio(tone) is True
|
||||
|
||||
|
||||
def test_silence_is_unusable():
|
||||
assert arch._is_unusable_audio(torch.zeros(N)) is True
|
||||
|
||||
|
||||
def test_speech_like_is_usable():
|
||||
assert arch._is_unusable_audio(_speech_like()) is False
|
||||
|
||||
|
||||
# ── Constants didn't regress ────────────────────────────────────────────────
|
||||
def test_preview_render_constants():
|
||||
# 16 steps under-converged on the social script; the fix bumped it.
|
||||
assert arch._PREVIEW_NUM_STEP >= 24
|
||||
assert 0 < arch._DEGENERATE_FLATNESS < 0.03
|
||||
@@ -172,6 +172,38 @@ def test_filter_by_accent():
|
||||
assert all("british accent" in a["instruct"] for a in res)
|
||||
|
||||
|
||||
# ── (h2) Multilingual designed voices ship beyond English + Chinese ───────────
|
||||
_ML_LANGS = {
|
||||
"Spanish", "French", "German", "Italian", "Portuguese",
|
||||
"Russian", "Hindi", "Japanese", "Korean",
|
||||
}
|
||||
|
||||
|
||||
def test_multilingual_featured_languages_present():
|
||||
featured_langs = {a["language"] for a in archetypes.list_archetypes(featured=True)}
|
||||
missing = _ML_LANGS - featured_langs
|
||||
assert not missing, f"missing curated multilingual languages: {missing}"
|
||||
|
||||
|
||||
def test_multilingual_archetypes_are_neutral_timbre_with_valid_tokens():
|
||||
# A designed voice's spoken language is the preview text, not the instruct —
|
||||
# so these carry no English-accent / Chinese-dialect token, only the
|
||||
# universal gender/age/pitch axes that exist in every language.
|
||||
for lang in _ML_LANGS:
|
||||
res = archetypes.list_archetypes(lang=lang)
|
||||
assert res, f"no archetypes for language {lang!r}"
|
||||
for a in res:
|
||||
assert a["language"] == lang
|
||||
assert a["sample_script"].strip(), f"{a['id']} missing sample_script"
|
||||
toks = set(_tokens(a["instruct"]))
|
||||
assert toks, f"{a['id']} has empty instruct"
|
||||
assert all(t in _VALID_TOKENS for t in toks), (
|
||||
f"{a['id']} emits invalid token (instruct={a['instruct']!r})"
|
||||
)
|
||||
assert not (toks & _ACCENTS), f"{a['id']} should carry no English accent"
|
||||
assert not (toks & _DIALECTS), f"{a['id']} should carry no Chinese dialect"
|
||||
|
||||
|
||||
# ── (i) Lookup by id ──────────────────────────────────────────────────────────
|
||||
def test_get_archetype_roundtrip():
|
||||
sample = ALL[0]
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""WhisperX CUDA-OOM → CPU fallback (api/services parity for small GPUs).
|
||||
|
||||
On an 8 GB laptop GPU with the TTS model resident, whisperx's CTranslate2
|
||||
load of large-v3 dies with `RuntimeError: CUDA failed with error out of
|
||||
memory`, which previously surfaced as a bare 500 from /dub/transcribe. The
|
||||
backend now retries on CPU (slower, same model/accuracy). This test forces the
|
||||
OOM deterministically (no GPU needed) and asserts the device switch.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
_config = types.ModuleType("core.config")
|
||||
_config.DATA_DIR = tempfile.mkdtemp(prefix="omnivoice_asr_oom_")
|
||||
_config.VOICES_DIR = _config.DATA_DIR
|
||||
_config.OUTPUTS_DIR = _config.DATA_DIR
|
||||
sys.modules["core.config"] = _config
|
||||
|
||||
whisperx = pytest.importorskip("whisperx")
|
||||
|
||||
from services.asr_backend import WhisperXBackend # noqa: E402
|
||||
|
||||
|
||||
def test_cuda_oom_falls_back_to_cpu(monkeypatch):
|
||||
calls = []
|
||||
|
||||
def fake_load_model(name, device, compute_type, **kw):
|
||||
calls.append((device, compute_type))
|
||||
if device == "cuda":
|
||||
raise RuntimeError("CUDA failed with error out of memory")
|
||||
return object() # CPU load succeeds
|
||||
|
||||
monkeypatch.setattr(whisperx, "load_model", fake_load_model)
|
||||
|
||||
be = WhisperXBackend()
|
||||
# Force the CUDA starting point regardless of the CI host's hardware.
|
||||
be._device, be._compute_type = "cuda", "float16"
|
||||
be._allow_vad_pickle_globals = lambda: None # skip torch pickle allowlist
|
||||
|
||||
be._ensure_asr()
|
||||
|
||||
assert be._asr is not None # didn't raise — recovered
|
||||
assert be._device == "cpu" and be._compute_type == "int8"
|
||||
assert [d for d, _ in calls] == ["cuda", "cpu"] # tried CUDA, then CPU
|
||||
|
||||
|
||||
def test_non_oom_runtime_error_still_raises(monkeypatch):
|
||||
def fake_load_model(name, device, compute_type, **kw):
|
||||
raise RuntimeError("some other failure") # not an OOM → must propagate
|
||||
|
||||
monkeypatch.setattr(whisperx, "load_model", fake_load_model)
|
||||
|
||||
be = WhisperXBackend()
|
||||
be._device, be._compute_type = "cuda", "float16"
|
||||
be._allow_vad_pickle_globals = lambda: None
|
||||
with pytest.raises(RuntimeError, match="some other failure"):
|
||||
be._ensure_asr()
|
||||
@@ -15,7 +15,7 @@
|
||||
},
|
||||
"frontend": {
|
||||
"name": "omnivoice-studio",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.5",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@fontsource-variable/source-serif-4": "^5.2.9",
|
||||
@@ -54,6 +54,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@tauri-apps/api": "^2.11.0",
|
||||
"@tauri-apps/cli": "^2.11.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
@@ -66,6 +67,7 @@
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.6.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"playwright-core": "1.60.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.10",
|
||||
"vitest": "^4.1.5",
|
||||
@@ -203,6 +205,8 @@
|
||||
|
||||
"@oxc-project/types": ["@oxc-project/types@0.127.0", "", {}, "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ=="],
|
||||
|
||||
"@playwright/test": ["@playwright/test@1.60.0", "", { "dependencies": { "playwright": "1.60.0" }, "bin": { "playwright": "cli.js" } }, "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag=="],
|
||||
|
||||
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
|
||||
|
||||
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
|
||||
|
||||
@@ -29,6 +29,13 @@ ENV HF_HOME=/app/omnivoice_data/huggingface
|
||||
# Allow bare imports (from core.config, from services.*, etc.) when
|
||||
# uvicorn is started as `backend.main:app` from WORKDIR /app.
|
||||
ENV PYTHONPATH=/app/backend
|
||||
# Headless server deployment: relax the desktop-only loopback origin gate.
|
||||
# Docker's network NAT rewrites the client host to the bridge gateway, so the
|
||||
# gate would otherwise 403 the operator out of /system/* and /api/settings/*
|
||||
# ("Loopback origin required", issue #261). Exposure is governed by the
|
||||
# operator's `-p` port mapping plus the optional share PIN. Desktop builds
|
||||
# never set this, so their loopback boundary is unchanged.
|
||||
ENV OMNIVOICE_SERVER_MODE=1
|
||||
|
||||
# Install system dependencies (FFmpeg is critical for torchaudio/scene splitting)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
|
||||
@@ -46,6 +46,12 @@ services:
|
||||
# OMNIVOICE_BIND_HOST=0.0.0.0 here only opens the container's own
|
||||
# interface. The backend default is 127.0.0.1 (see backend/main.py).
|
||||
- OMNIVOICE_BIND_HOST=0.0.0.0
|
||||
# Headless server: relax the desktop-only loopback origin gate so the
|
||||
# web UI's /system/* and /api/settings/* routes work through Docker's
|
||||
# NAT (issue #261). Already baked into the image; shown here so it's
|
||||
# discoverable. If you front the container with your own auth proxy on
|
||||
# loopback, set this to 0 to re-enable the strict gate.
|
||||
- OMNIVOICE_SERVER_MODE=1
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://localhost:3900/health"]
|
||||
interval: 30s
|
||||
@@ -76,6 +82,9 @@ services:
|
||||
# service above. The host-side `127.0.0.1:3900:3900` mapping keeps
|
||||
# LAN reachability off by default.
|
||||
- OMNIVOICE_BIND_HOST=0.0.0.0
|
||||
# See the CPU service above — relaxes the loopback origin gate for the
|
||||
# headless Docker deployment (issue #261). Set to 0 to re-enable it.
|
||||
- OMNIVOICE_SERVER_MODE=1
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://localhost:3900/health"]
|
||||
interval: 30s
|
||||
|
||||
@@ -4,6 +4,18 @@ For headless servers, dedicated GPUs, or "I want one command" deployments.
|
||||
The docker image bundles the backend; the UI is served over HTTP and you open
|
||||
it in a normal browser.
|
||||
|
||||
> **Image ↔ version mapping**
|
||||
>
|
||||
> | Tag | What you get |
|
||||
> |-----|--------------|
|
||||
> | `:latest` | Most recent versioned release (updated on every `v*` git tag) |
|
||||
> | `:0.3.0` | Exact release version |
|
||||
> | `:0.3` | Latest patch within the 0.3 minor |
|
||||
> | `:main` | Latest commit on `main` — may be ahead of the last release |
|
||||
> | `:sha-xxxxxxx` | Specific commit (produced by manual workflow dispatch) |
|
||||
>
|
||||
> **Note on the update-channel toggle:** The update-channel UI (Settings → About → Update channel) is part of the Tauri desktop app's built-in auto-updater. It does **not** apply to the Docker image — the Docker image is the headless web-server build. To update your Docker deployment, pull the new image tag and recreate the container (`docker compose pull && docker compose up -d`).
|
||||
|
||||
## Pull and run (CPU)
|
||||
|
||||
```bash
|
||||
@@ -100,6 +112,21 @@ Two paths are worth persisting across container restarts:
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Container reports 0.2.7 but image is tagged 0.3.x:** This was a workflow bug
|
||||
(fixes #249, #251) — the `:latest` tag was not being updated on release tag
|
||||
pushes. Pull the image again after the fix is merged: `docker pull ghcr.io/debpalash/omnivoice-studio:latest`.
|
||||
The running version is now shown in **Settings → About → Version** (read live
|
||||
from the backend), so the web UI no longer displays a dash in Docker.
|
||||
- **Checking which version is running:** `docker exec omnivoice python -c "import importlib.metadata; print(importlib.metadata.version('omnivoice'))"`, or hit the `/health` endpoint — it returns `{"status": "ok", "device": ..., "version": "0.3.x"}`.
|
||||
- **"Loopback origin required" errors (and a blank version):** the desktop
|
||||
build restricts the `/system/*` and `/api/settings/*` routes to a loopback
|
||||
origin, but Docker's NAT makes every request look non-loopback, so the gate
|
||||
used to 403 the whole admin UI (issue #261). The image now ships with
|
||||
`OMNIVOICE_SERVER_MODE=1`, which relaxes that gate for the headless
|
||||
deployment — exposure is instead governed by your `-p` port mapping (keep the
|
||||
`127.0.0.1:` prefix to stay local) plus the optional share PIN. If you front
|
||||
the container with your own auth proxy on loopback, set `OMNIVOICE_SERVER_MODE=0`
|
||||
to re-enable the strict gate.
|
||||
- **Media-preview 404 in LAN mode:** see the [LAN access](#lan-access) section
|
||||
above — the `window.location.host` fix shipped in v0.3.
|
||||
- **GPU not detected:** verify `docker run --rm --gpus all nvidia/cuda:12.8.0-base-ubuntu22.04 nvidia-smi` succeeds first.
|
||||
|
||||
@@ -4,6 +4,32 @@ The top 10 errors users have actually hit on `v0.2.x`, with their causes and
|
||||
fixes. Most have a deeplink anchor that the in-app error UI's "Open docs for
|
||||
this error" button targets directly.
|
||||
|
||||
## Start here: self-diagnosis
|
||||
|
||||
<a id="self-diagnosis"></a>
|
||||
|
||||
Before digging through the entries below, let the app diagnose itself:
|
||||
|
||||
- **In the app:** **Settings → About → "Run self-check"** verifies your
|
||||
compute device (CUDA/MPS/CPU), ffmpeg, HuggingFace token, disk space,
|
||||
data-directory permissions, RAM, installed TTS engines, and hub
|
||||
reachability — each with a hint when something's off.
|
||||
- **Headless / terminal:**
|
||||
|
||||
```bash
|
||||
uv run python backend/main.py --diagnose # same checks, exits 1 on failure
|
||||
uv run python backend/main.py --diagnose --deep # also loads the active engine
|
||||
# and synthesizes a test utterance
|
||||
```
|
||||
|
||||
`--deep` catches "installed but broken" engines. On a fresh install it may
|
||||
cold-load the model (minutes, plus a large download).
|
||||
|
||||
- **Filing an issue?** **Settings → About → "Save diagnostic bundle"**
|
||||
produces a zip (self-check report, recent classified errors, scrubbed log
|
||||
tails) you can drag straight onto the GitHub issue. Home paths and
|
||||
anything token-shaped are redacted before they leave your machine.
|
||||
|
||||
## 1. `pkg_resources` missing (ModuleNotFoundError)
|
||||
|
||||
<a id="pkg_resources-missing"></a>
|
||||
@@ -121,7 +147,24 @@ falling back to faster-whisper`.
|
||||
path and is still fast. If you want the latest CT2 wheels, run `uv sync`
|
||||
from a fresh source checkout.
|
||||
|
||||
## 10. IndexTTS / CosyVoice / ChatterboxTTS clash
|
||||
## 10. Windows: `Could not locate cudnn_ops_infer64_8.dll` during transcription
|
||||
|
||||
**Symptom:** on Windows + NVIDIA, transcription/dubbing fails and the backend
|
||||
log shows `Could not locate cudnn_ops_infer64_8.dll`. Settings → Models shows
|
||||
WhisperX or faster-whisper selected.
|
||||
|
||||
**Cause:** WhisperX and faster-whisper run on **CTranslate2**, which needs
|
||||
**cuDNN 8**, but PyTorch 2.8 ships cuDNN 9. OmniVoice side-loads a cuDNN-8 copy
|
||||
from `.venv\Lib\site-packages\cudnn8_compat\`; if that folder is missing
|
||||
(some upgrade paths don't install it), CTranslate2 can't find the DLL.
|
||||
|
||||
**Fix:** switch the ASR backend to **PyTorch Whisper** in **Settings → Models**.
|
||||
It runs on PyTorch's own stack (cuDNN 9, bundled with torch) and needs no
|
||||
cuDNN-8 DLL — it loads its Whisper pipeline on demand (no extra env var). To
|
||||
keep using faster-whisper/WhisperX instead, reinstall to restore the bundled
|
||||
`cudnn8_compat` libraries.
|
||||
|
||||
## 11. IndexTTS / CosyVoice / ChatterboxTTS clash
|
||||
|
||||
**Symptom:** installing one of these engines breaks the others — e.g. after
|
||||
installing CosyVoice, IndexTTS errors out with import conflicts.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,115 @@
|
||||
# Preview channel: versioning + rollback — design spec
|
||||
|
||||
- **Date:** 2026-06-01
|
||||
- **Status:** Phase A implemented; Phase B = proposed design, pending review
|
||||
- **Ships on:** v0.3.0 line
|
||||
|
||||
## Problem
|
||||
|
||||
The Preview update channel (Settings → About → Update channel → Preview) builds
|
||||
from `main` and publishes to a rolling `preview` GitHub prerelease. But two gaps
|
||||
make it not actually work as an update channel:
|
||||
|
||||
1. **No versioning.** Every preview build stamped the static `tauri.conf.json`
|
||||
version (`0.3.0`). Tauri's updater only offers an update when the manifest
|
||||
version is **semver-greater** than the installed one, so `0.3.0 == 0.3.0` →
|
||||
"no update." Preview users install once and never receive the next preview.
|
||||
2. **No rollback.** The Tauri updater only moves forward. There is no way to
|
||||
return to an earlier preview build (e.g., when a fresh `main` build
|
||||
regresses) without a manual reinstall.
|
||||
|
||||
## Phase A — forward versioning (DONE)
|
||||
|
||||
`release.yml` stamps each preview build, on the `workflow_dispatch +
|
||||
publish_preview` path only, with a unique monotonic semver prerelease:
|
||||
|
||||
```
|
||||
<base>-preview.<github.run_number> e.g. 0.3.0-preview.42
|
||||
```
|
||||
|
||||
via an ephemeral, never-committed rewrite of `tauri.conf.json`'s `version`
|
||||
(Tauri reads the bundle + updater version from there). Properties:
|
||||
|
||||
- **Monotonic** (`run_number` only increases) → `…preview.43 > …preview.42`, so
|
||||
the updater offers each newer preview.
|
||||
- **Prerelease of the current target** → when stable `0.3.0` ships,
|
||||
`0.3.0 > 0.3.0-preview.N`, so preview users **converge to stable** (matches
|
||||
the channel's preview→stable fallback).
|
||||
- **No `+build` metadata** — kept out to avoid `+`-in-filename / MSI edge cases.
|
||||
Commit traceability lives in the release notes (the `preview-notes` job
|
||||
already renders the commit range + Contributors).
|
||||
|
||||
### Known caveat (Windows MSI)
|
||||
|
||||
The Windows MSI `ProductVersion` is a 4-field numeric (`a.b.c.d`) and **strips
|
||||
the semver prerelease** → every preview MSI reports `0.3.0`. The Tauri updater
|
||||
compares the **full** semver from `latest.json` (so it still *offers* the new
|
||||
preview and runs the new MSI), but `msiexec` installing an MSI whose
|
||||
`ProductVersion` is unchanged is a "reinstall," not an "upgrade." **Action:**
|
||||
verify Windows preview→preview actually replaces files in testing. macOS/Linux
|
||||
replace the bundle wholesale and are unaffected. If Windows misbehaves, the
|
||||
fallback is a numeric scheme (`0.3.<run_number>`) at the cost of clean
|
||||
convergence — decide after a real Windows test.
|
||||
|
||||
## Phase B — version catalog + rollback (PROPOSED)
|
||||
|
||||
### Publish model: per-version prereleases
|
||||
|
||||
Each preview build publishes a **distinct** prerelease tagged
|
||||
`preview-<version>` (e.g. `preview-0.3.0-preview.42`), self-contained: signed
|
||||
artifacts + its own `latest.json`. Separately, the rolling `preview` tag's
|
||||
`latest.json` **mirrors the newest** so the default forward-update keeps reading
|
||||
a stable URL (`releases/download/preview/latest.json`).
|
||||
|
||||
- **Retention:** keep the last ~10 `preview-*` releases; a cleanup step prunes
|
||||
older releases + tags. These releases *are* the rollback catalog — so unlike
|
||||
Phase A's tidy-up instinct, we deliberately **keep** old artifacts.
|
||||
|
||||
### App side: a "Preview builds" picker
|
||||
|
||||
In Settings → About → Update channel (shown when on Preview):
|
||||
|
||||
- List available builds from the GitHub Releases API (prereleases matching
|
||||
`preview-*`): version, date, commit range, and an **alembic-head** marker
|
||||
(see Data safety).
|
||||
- Each row → **Install**. Choosing an *older* build is the rollback.
|
||||
- **Install path** reuses the Rust updater commands from #199, extended with:
|
||||
- an explicit endpoint (`…/preview-<chosen>/latest.json`), and
|
||||
- **`allow_downgrades`** (Tauri `UpdaterBuilder::version_comparator`, e.g.
|
||||
`|current, candidate| candidate != current`) so it installs even when the
|
||||
target is older than the running version.
|
||||
- Every build is minisign-signed → rollback installs are verified too.
|
||||
|
||||
### ⚠️ Data safety: DB schema + rollback
|
||||
|
||||
Alembic migrations are forward-only and tested for **upgrade**. Rolling the app
|
||||
back across a migration means an *older* app meets a `omnivoice_data` DB at a
|
||||
**newer** schema head than it expects — which can break (the inverse of the
|
||||
"backward-compatible data" constraint). Mitigations, in order of effort:
|
||||
|
||||
1. **Tag each preview with its alembic head** (a build-time `alembic heads`
|
||||
captured into the release notes / a sidecar field). The picker marks builds
|
||||
as "safe to roll back to" (same head) vs "data-incompatible (newer schema)."
|
||||
2. **Warn on cross-schema rollback** in the picker; require explicit confirm.
|
||||
3. (Later) implement + test alembic **downgrade** paths for the affected
|
||||
revisions so rollback is truly safe.
|
||||
|
||||
Phase B should at least do (1)+(2); (3) is per-migration follow-up work.
|
||||
|
||||
## Implementation outline (Phase B)
|
||||
|
||||
- `release.yml`: per-version `preview-<version>` publish + mirror newest →
|
||||
rolling `preview/latest.json`; retention/prune step; capture alembic head.
|
||||
- `backend`: small endpoint to expose the running alembic head (for the picker's
|
||||
safety check), or read it client-side from the release metadata.
|
||||
- `frontend/src-tauri` (`updater_channel.rs`): `install_specific(version)` with
|
||||
endpoint override + `allow_downgrades`; `list_preview_builds()` via GH API.
|
||||
- `frontend` (Settings): the "Preview builds" picker + rollback confirm dialog;
|
||||
i18n (en + zh-CN, then backfill the rest).
|
||||
|
||||
## Open decisions
|
||||
|
||||
1. Base scheme: **`0.3.0-preview.N`** (chosen) vs `0.3.1-preview.N`.
|
||||
2. Retention count (proposed **10**).
|
||||
3. Whether to gate rollback across alembic heads behind a hard block or a
|
||||
confirm-with-warning (proposed: confirm-with-warning + a clear marker).
|
||||
@@ -0,0 +1,164 @@
|
||||
# Updates in the status bar — design spec
|
||||
|
||||
**Date:** 2026-06-02
|
||||
**Status:** Approved (brainstorm) → ready for implementation plan
|
||||
**Branch:** `feat/updates-status-bar`
|
||||
|
||||
## Problem / goal
|
||||
|
||||
The auto-update surface is a floating pill (`UpdateBadge`) fixed at top-right. The
|
||||
user wants updates to live in the **bottom status bar** (`LogsFooter`): a persistent
|
||||
version indicator plus an **Updates tab/panel** that exposes the changelog, the
|
||||
stable/preview channel switcher, and update/release history — so updates have a
|
||||
permanent, discoverable home instead of a transient floating pill.
|
||||
|
||||
## Decisions (from brainstorm)
|
||||
|
||||
1. **Placement:** a persistent **version chip** on the right of the always-visible
|
||||
28px `LogsFooter` bar, plus an **"Updates" tab** that expands the footer into an
|
||||
**Updates panel**. Clicking the chip opens the panel.
|
||||
2. **Idle behavior:** the chip is **always on** — shows `v<current> ✓` when up to
|
||||
date and morphs into available / downloading / ready / error states.
|
||||
3. **Data source:** **GitHub Releases (live)**, fetched through a Rust command.
|
||||
Changelog and "history" **unify into one Releases list** (release history with
|
||||
the running version marked "current"). This is *not* a personal install log.
|
||||
4. **Channel switcher:** the panel gets an editable stable/preview switcher that
|
||||
**coexists with the existing Settings → About switcher**; both read/write the
|
||||
same Rust `get_update_channel` / `set_update_channel`, kept in sync via a single
|
||||
store-held `updateChannel` value.
|
||||
|
||||
## Existing code this builds on
|
||||
|
||||
- `frontend/src/components/LogsFooter.jsx` / `.css` — the bottom bar. Fixed,
|
||||
`z-index: 40`, 28px collapsed, expands 180–720px. Tabs come from a `SOURCES`
|
||||
array rendered as `.logs-footer__pill` buttons; active tab persists to
|
||||
localStorage `omnivoice.logs.active`. Body renders per active tab; the
|
||||
Notifications tab is the precedent for a non-log custom tab body. Sets CSS var
|
||||
`--logs-footer-height`.
|
||||
- `frontend/src/components/UpdateBadge.jsx` / `.css` — current floating pill,
|
||||
mounted at `App.jsx` (`<UpdateBadge/>`). Renders available / downloading / ready
|
||||
/ error (idle+checking → null). **Current `main` adds a dismiss (X) button on the
|
||||
error state + a `dismissUpdate` store action + `update.dismiss` key** — must be
|
||||
preserved.
|
||||
- `frontend/src/store/updaterSlice.ts` — `updateStatus | updateVersion |
|
||||
updateNotes | updateProgress | updateError`; setters
|
||||
`setUpdateChecking/Available/Idle/Progress/Ready/Error` + `dismissUpdate`. **Not
|
||||
persisted.** No history field.
|
||||
- `frontend/src/utils/updater.js` — `isTauri()`, `currentChannel()`
|
||||
(`invoke('get_update_channel')`), `checkForUpdate(store)`
|
||||
(`invoke('check_update',{channel})`), `installUpdate(store)`
|
||||
(`invoke('install_update',{channel})`, listens `update://progress`, `relaunch()`).
|
||||
- `frontend/src/utils/updateChannel.js` — `UPDATE_CHANNELS = ['stable','preview']`,
|
||||
`normalizeChannel()`.
|
||||
- `frontend/src/pages/Settings.jsx` (About tab) — existing channel `Segmented`
|
||||
(`changeChannel` → `set_update_channel`), endpoint display, "Check for updates".
|
||||
- Rust (`src-tauri/src`) — commands `get_update_channel`, `set_update_channel`,
|
||||
`check_update`, `install_update`. **New:** `list_releases`.
|
||||
- `CHANGELOG.md` exists at repo root but is not surfaced (not used by this design;
|
||||
release notes come from GitHub).
|
||||
|
||||
## Architecture
|
||||
|
||||
### A. Bar chip — `UpdateStatusChip`
|
||||
New component rendered on the **right** side of `LogsFooter`'s top bar (near the
|
||||
Discord/donate cluster). Subscribes to `updaterSlice`. State → presentation:
|
||||
|
||||
| `updateStatus` | chip |
|
||||
|---|---|
|
||||
| `idle` (up to date) | `v<current> ✓` (subtle/dim) |
|
||||
| `available` | `⬆ <version> · Update` (click row installs in panel) |
|
||||
| `downloading` | `↺ Updating <pct>%` |
|
||||
| `ready` | `↺ Restart` |
|
||||
| `error` | `⚠ Failed · Retry` (+ dismiss preserved) |
|
||||
| `checking` | brief `… Checking` (or stay on prior chip) |
|
||||
|
||||
- Click → open the footer to the Updates tab (`openTo('updates')`).
|
||||
- **Current version source:** `@tauri-apps/api/app` `getVersion()` on mount, stored
|
||||
in `updaterSlice.appVersion` (fallback: hidden chip in non-Tauri/dev where version
|
||||
is unknown). The chip is the *indicator only*; primary actions live in the panel
|
||||
(Install is reachable from chip via opening panel; Retry/Restart may act inline to
|
||||
preserve today's one-click behavior — see Open Questions resolved below).
|
||||
|
||||
### B. Updates panel — `UpdatesPanel`
|
||||
Rendered as the footer body when `active === 'updates'`. Sections top→bottom:
|
||||
|
||||
1. **Live row** — mirrors chip state with the actionable control:
|
||||
- available → `Update available · <v>` + **Install** (gated while `dubStep ===
|
||||
'generating'`, same toast as today).
|
||||
- downloading → progress bar + %; ready → **Restart**; error → message +
|
||||
**Retry** + **Dismiss**; idle → `Up to date · v<current>` + **Check now**.
|
||||
2. **Channel** — `Segmented` stable/preview bound to store `updateChannel`; onChange
|
||||
→ `set_update_channel` + refetch releases. Stays in sync with Settings.
|
||||
3. **Releases list** — scrollable; each row: version, date, `prerelease` tag,
|
||||
expandable notes. The **running version** is marked `current`. Loading and
|
||||
empty/offline states handled (see Data).
|
||||
|
||||
### C. Data — `list_releases` Rust command + `releasesSlice`
|
||||
- **Rust** `list_releases(channel) -> Vec<ReleaseInfo>`: GET
|
||||
`https://api.github.com/repos/{owner}/{repo}/releases` via existing `reqwest`
|
||||
(no auth; `User-Agent` set). Map to `{ version, name, date, prerelease, notes }`.
|
||||
**Stable** channel filters out `prerelease`; **preview** includes them. Sorted
|
||||
newest first. Short in-memory cache (e.g. 5 min) to avoid refetch spam.
|
||||
- **Frontend** transient `releasesSlice` (NOT persisted): `releases`,
|
||||
`releasesStatus: 'idle'|'loading'|'loaded'|'error'`, `loadReleases(channel)`
|
||||
(calls the command; sets error on failure). Fetched lazily when the panel first
|
||||
opens and on channel change.
|
||||
- **Offline / failure:** panel shows "Couldn't load releases (offline?)" + a retry
|
||||
button. The live update flow (`check_update`/`install_update`) and the rest of the
|
||||
app are unaffected. Non-Tauri/dev → releases unavailable, panel shows the same
|
||||
empty state; live update no-ops as today.
|
||||
|
||||
### D. Removals / moves
|
||||
- Remove floating `<UpdateBadge/>` from `App.jsx`.
|
||||
- `UpdateBadge`'s state logic moves into `UpdateStatusChip` (incl. dismiss + dub-busy
|
||||
gating). `UpdateBadge.jsx`/`.css` retired (or renamed to the chip). The existing
|
||||
`update.*` i18n keys are reused.
|
||||
|
||||
## State / store
|
||||
|
||||
- `updaterSlice`: add `appVersion: string | null` + `setAppVersion`. Everything else
|
||||
unchanged (still not persisted).
|
||||
- New `updateChannelSlice` (or fold into updaterSlice): `updateChannel:
|
||||
'stable'|'preview'`, `setUpdateChannel(ch)` (writes via Rust). Settings + panel both
|
||||
bind here → single source of truth, auto-synced.
|
||||
- New `releasesSlice` (transient): releases + status + `loadReleases`.
|
||||
|
||||
## i18n (hard rule)
|
||||
|
||||
New `updates.*` keys added to **all 21 locale files** (parity enforced; CJK guard
|
||||
stays green — no hardcoded user-facing strings). Reuse `update.*` and
|
||||
`about.channel_*` where possible. New keys (indicative): `updates.tab`,
|
||||
`updates.up_to_date`, `updates.check_now`, `updates.current`, `updates.releases`,
|
||||
`updates.prerelease`, `updates.load_error`, `updates.retry_load`,
|
||||
`updates.installed_version`.
|
||||
|
||||
## Cross-platform parity (strict rule)
|
||||
|
||||
The chip + panel are pure frontend; `list_releases` is platform-agnostic Rust. The
|
||||
feature is **default-on** and behaves identically on macOS / Windows / Linux. No
|
||||
OS-gated default behavior. Updater itself remains Tauri-only (no-ops in dev/web) —
|
||||
the chip degrades to hidden/version-only there, identically across platforms.
|
||||
|
||||
## Testing
|
||||
|
||||
- vitest: chip state→presentation mapping for all six states; panel render with a
|
||||
mock releases array (current-version marking, prerelease filtering by channel);
|
||||
channel switch calls `set_update_channel` + triggers reload; releases load-error →
|
||||
offline empty state; dub-busy gating on Install.
|
||||
- Keep the existing `updater.test.js` (#216 guard) + `updaterSlice.test.ts` green.
|
||||
- i18n parity test + CJK guard must pass.
|
||||
|
||||
## Out of scope (YAGNI)
|
||||
|
||||
- Personal install-history log ("you installed X on date Y").
|
||||
- Per-release manual download / rollback / downgrade.
|
||||
- Rich markdown rendering of notes beyond the current plain/`pre-wrap` treatment.
|
||||
- Auto-refresh/polling of the releases list (fetch on open + channel change only).
|
||||
|
||||
## Open questions — resolved
|
||||
|
||||
- **Chip vs panel for one-click actions:** chip shows state; Restart/Retry remain
|
||||
one-click from the chip (preserve today's behavior); Install opens the panel (it's
|
||||
the consequential action and benefits from showing notes first).
|
||||
- **Changelog vs history:** unified into the Releases list (GitHub source decision).
|
||||
- **Version chip when version unknown (dev/web):** hidden.
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
/** Every routable view (the `mode` values in App.jsx). */
|
||||
export const MODES = [
|
||||
'launchpad', 'clone', 'design', 'gallery', 'dub', 'stories',
|
||||
'projects', 'queue', 'tools', 'transcriptions', 'settings', 'donate',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Fatal client errors that mean a view failed to LOAD — code-split chunk /
|
||||
* dynamic-import failures (the "Use design → Importing a module script failed"
|
||||
* regression) and uncaught exceptions. Deliberately NOT matching network/API
|
||||
* noise (5xx, fetch failures) — backend health is covered elsewhere, and a
|
||||
* flaky API shouldn't fail a UI-mount test.
|
||||
*/
|
||||
const FATAL = [
|
||||
/Importing a module script failed/i,
|
||||
/Failed to fetch dynamically imported module/i,
|
||||
/error loading dynamically imported module/i,
|
||||
/ChunkLoadError/i,
|
||||
];
|
||||
|
||||
export type ErrorSink = { fatal: string[]; all: string[] };
|
||||
|
||||
/** Attach console/pageerror listeners; returns a sink you assert on later. */
|
||||
export function collectErrors(page: Page): ErrorSink {
|
||||
const sink: ErrorSink = { fatal: [], all: [] };
|
||||
const record = (text: string) => {
|
||||
sink.all.push(text);
|
||||
if (FATAL.some((re) => re.test(text))) sink.fatal.push(text);
|
||||
};
|
||||
page.on('pageerror', (err) => record(`pageerror: ${err.message}`));
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') record(`console.error: ${msg.text()}`);
|
||||
});
|
||||
return sink;
|
||||
}
|
||||
|
||||
/**
|
||||
* Land directly on a view by seeding the zustand-persist store (key
|
||||
* `omnivoice.app`) before the app boots. A shallow merge over slice defaults,
|
||||
* so only `mode` is forced.
|
||||
*/
|
||||
export async function gotoMode(page: Page, mode: string): Promise<void> {
|
||||
await page.addInitScript((m) => {
|
||||
localStorage.setItem('omnivoice.app', JSON.stringify({ state: { mode: m }, version: 4 }));
|
||||
}, mode);
|
||||
await page.goto('/');
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { collectErrors, gotoMode } from './_helpers';
|
||||
|
||||
test.describe('OmniVoice Gallery', () => {
|
||||
test('heading is "OmniVoice Gallery"', async ({ page }) => {
|
||||
await gotoMode(page, 'gallery');
|
||||
await expect(page.getByRole('heading', { name: /OmniVoice Gallery/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('facet dropdowns use the dark theme, not the OS-default light surface', async ({ page }) => {
|
||||
await gotoMode(page, 'gallery');
|
||||
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
|
||||
// and NOT transparent (rgba(0,0,0,0), the broken undefined-var state).
|
||||
const bg = await select.evaluate((el) => getComputedStyle(el).backgroundColor);
|
||||
expect(bg).toBe('rgba(255, 255, 255, 0.04)');
|
||||
});
|
||||
|
||||
test('opening an archetype in the Designer mounts the design view (no chunk-load failure)', async ({ page }) => {
|
||||
const errors = collectErrors(page);
|
||||
await gotoMode(page, 'gallery');
|
||||
|
||||
// Cards load from the backend; wait for the first one.
|
||||
const designerBtn = page.locator('.archetype-card .designer-btn').first();
|
||||
await expect(designerBtn).toBeVisible({ timeout: 20_000 });
|
||||
await designerBtn.click();
|
||||
|
||||
// The design view (CloneDesignTab — the lazy chunk that failed when Vite
|
||||
// was down) must mount. Its prompt/personality UI is the tell.
|
||||
await expect(
|
||||
page.getByText(/personality|prompt|steps/i).first()
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await expect(page.getByText(/this tab hit a snag/i)).toHaveCount(0);
|
||||
expect(errors.fatal, errors.fatal.join('\n')).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { MODES, collectErrors, gotoMode } from './_helpers';
|
||||
|
||||
// Every view must mount without a code-split/import failure or an uncaught
|
||||
// exception, and without tripping the ErrorBoundary fallback. This is the
|
||||
// regression guard for "Use design → Importing a module script failed" (a dead
|
||||
// Vite/module server) and any lazy() page that fails to load.
|
||||
for (const mode of MODES) {
|
||||
test(`view "${mode}" mounts without fatal client errors`, async ({ page }) => {
|
||||
const errors = collectErrors(page);
|
||||
await gotoMode(page, mode);
|
||||
|
||||
// Give the lazy chunk time to fetch + the Suspense boundary to resolve.
|
||||
// (No networkidle wait — views with a live WS/SSE log stream, e.g. Settings,
|
||||
// never reach it.)
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// The ErrorBoundary fallback copy ("This tab hit a snag.") must not show.
|
||||
const snag = page.getByText(/this tab hit a snag/i);
|
||||
await expect(snag).toHaveCount(0);
|
||||
|
||||
expect(errors.fatal, `fatal errors in "${mode}":\n${errors.fatal.join('\n')}`).toEqual([]);
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"name": "omnivoice-studio",
|
||||
"private": true,
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.5",
|
||||
"license": "AGPL-3.0-only",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -14,7 +15,8 @@
|
||||
"test:watch": "vitest",
|
||||
"test:legacy": "node --experimental-strip-types --no-warnings --test ../tests/frontend/*.test.mjs",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri"
|
||||
"tauri": "tauri",
|
||||
"e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
@@ -54,6 +56,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@tauri-apps/api": "^2.11.0",
|
||||
"@tauri-apps/cli": "^2.11.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
@@ -66,6 +69,7 @@
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.6.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"playwright-core": "1.60.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.10",
|
||||
"vitest": "^4.1.5"
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
// E2E runs against the Vite dev server (UI on :3901, backend on :3900). It
|
||||
// drives the SYSTEM chromium (no `playwright install` browser download) — set
|
||||
// PLAYWRIGHT_CHROMIUM to override the path. reuseExistingServer keeps a dev
|
||||
// session you already have running; CI starts its own `bun run dev`.
|
||||
const PORT = Number(process.env.E2E_PORT || 3901);
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
timeout: 45_000,
|
||||
expect: { timeout: 10_000 },
|
||||
fullyParallel: false,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
reporter: [['list']],
|
||||
use: {
|
||||
baseURL: `http://localhost:${PORT}`,
|
||||
headless: true,
|
||||
trace: 'retain-on-failure',
|
||||
launchOptions: {
|
||||
executablePath: process.env.PLAYWRIGHT_CHROMIUM || '/usr/bin/chromium',
|
||||
},
|
||||
},
|
||||
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
|
||||
webServer: {
|
||||
command: 'bun run dev',
|
||||
url: `http://localhost:${PORT}`,
|
||||
reuseExistingServer: true,
|
||||
timeout: 60_000,
|
||||
},
|
||||
});
|
||||
Generated
+259
-13
@@ -256,6 +256,28 @@ version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-rs"
|
||||
version = "1.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00"
|
||||
dependencies = [
|
||||
"aws-lc-sys",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-sys"
|
||||
version = "0.41.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cmake",
|
||||
"dunce",
|
||||
"fs_extra",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.21.7"
|
||||
@@ -536,6 +558,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
"libc",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
@@ -590,6 +614,15 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cmake"
|
||||
version = "0.1.58"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.7"
|
||||
@@ -619,6 +652,16 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.10.1"
|
||||
@@ -642,7 +685,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"core-graphics-types",
|
||||
"foreign-types",
|
||||
"libc",
|
||||
@@ -655,7 +698,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"core-graphics-types",
|
||||
"foreign-types",
|
||||
"libc",
|
||||
@@ -668,7 +711,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"libc",
|
||||
]
|
||||
|
||||
@@ -1025,6 +1068,15 @@ version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "endi"
|
||||
version = "1.1.1"
|
||||
@@ -1037,7 +1089,7 @@ version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0cf6f550bbbdd5fe66f39d429cb2604bcdacbf00dca0f5bbe2e9306a0009b7c6"
|
||||
dependencies = [
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"core-graphics 0.24.0",
|
||||
"foreign-types-shared",
|
||||
"libc",
|
||||
@@ -1245,6 +1297,22 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs4"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8640e34b88f7652208ce9e88b1a37a2ae95227d84abec377ccd3c5cfeb141ed4"
|
||||
dependencies = [
|
||||
"rustix",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs_extra"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
||||
|
||||
[[package]]
|
||||
name = "funty"
|
||||
version = "2.0.0"
|
||||
@@ -1461,8 +1529,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasi",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1472,9 +1542,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi 5.3.0",
|
||||
"wasip2",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1656,6 +1728,25 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
"fnv",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"http",
|
||||
"indexmap 2.14.0",
|
||||
"slab",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.12.3"
|
||||
@@ -1769,6 +1860,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
@@ -1812,9 +1904,11 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"system-configuration",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"windows-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2149,6 +2243,16 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jobserver"
|
||||
version = "0.1.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.97"
|
||||
@@ -2291,6 +2395,12 @@ dependencies = [
|
||||
"value-bag",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "markup5ever"
|
||||
version = "0.38.0"
|
||||
@@ -2778,12 +2888,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "omnivoice-studio"
|
||||
version = "0.3.0"
|
||||
version = "0.3.5"
|
||||
dependencies = [
|
||||
"dirs-next",
|
||||
"enigo",
|
||||
"fs4",
|
||||
"libc",
|
||||
"log",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sysinfo",
|
||||
@@ -2961,7 +3073,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
||||
dependencies = [
|
||||
"phf_shared 0.11.3",
|
||||
"rand",
|
||||
"rand 0.8.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3237,6 +3349,62 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
"pin-project-lite",
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"bytes",
|
||||
"getrandom 0.3.4",
|
||||
"lru-slab",
|
||||
"rand 0.9.4",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
"thiserror 2.0.18",
|
||||
"tinyvec",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-udp"
|
||||
version = "0.5.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
|
||||
dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.45"
|
||||
@@ -3271,8 +3439,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
"rand_chacha 0.3.1",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
|
||||
dependencies = [
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3282,7 +3460,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3294,6 +3482,15 @@ dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "raw-window-handle"
|
||||
version = "0.6.2"
|
||||
@@ -3406,8 +3603,10 @@ checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
@@ -3416,8 +3615,10 @@ dependencies = [
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"rustls-platform-verifier",
|
||||
@@ -3514,7 +3715,7 @@ dependencies = [
|
||||
"borsh",
|
||||
"bytes",
|
||||
"num-traits",
|
||||
"rand",
|
||||
"rand 0.8.6",
|
||||
"rkyv",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3555,6 +3756,7 @@ version = "0.23.40"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"log",
|
||||
"once_cell",
|
||||
"ring",
|
||||
@@ -3582,6 +3784,7 @@ version = "1.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -3591,7 +3794,7 @@ version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
|
||||
dependencies = [
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"jni 0.22.4",
|
||||
"log",
|
||||
@@ -3618,6 +3821,7 @@ version = "0.103.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
@@ -3717,7 +3921,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
@@ -4152,6 +4356,27 @@ dependencies = [
|
||||
"windows 0.57.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"core-foundation 0.9.4",
|
||||
"system-configuration-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration-sys"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-deps"
|
||||
version = "6.2.2"
|
||||
@@ -4173,7 +4398,7 @@ checksum = "1cf65722394c2ac443e80120064987f8914ee1d4e4e36e63cdf10f2990f01159"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"block2 0.6.2",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"core-graphics 0.25.0",
|
||||
"crossbeam-channel",
|
||||
"dbus",
|
||||
@@ -5378,6 +5603,16 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-time"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web_atoms"
|
||||
version = "0.2.4"
|
||||
@@ -5735,6 +5970,17 @@ dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||
dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
"windows-result 0.4.1",
|
||||
"windows-strings 0.5.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.1.2"
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
[package]
|
||||
name = "omnivoice-studio"
|
||||
version = "0.3.0"
|
||||
version = "0.3.5"
|
||||
description = "OmniVoice Studio – AI voice cloning & dubbing desktop app"
|
||||
authors = ["Debpalash"]
|
||||
license = "AGPL-3.0"
|
||||
license = "AGPL-3.0-only"
|
||||
repository = ""
|
||||
edition = "2021"
|
||||
rust-version = "1.77.2"
|
||||
@@ -38,12 +38,17 @@ enigo = { version = "0.3", features = ["serde"] }
|
||||
# bundled pyproject.toml — which installs torch, whisperx, etc.
|
||||
# ureq is used for HTTP health checks and ffmpeg downloads.
|
||||
ureq = "2"
|
||||
# reqwest is used for GitHub Releases API calls (list_releases command).
|
||||
reqwest = { version = "0.13", features = ["json"] }
|
||||
|
||||
# ── Rust IPC commands (cross-platform) ──
|
||||
# get_sysinfo: CPU + RAM metrics without HTTP round-trip
|
||||
sysinfo = { version = "0.33", default-features = false, features = ["system"] }
|
||||
# hf_cache_scan: walk HF cache directory 3-5× faster than Python
|
||||
walkdir = "2"
|
||||
# First-run setup screen: per-path free-disk-space probe (statvfs /
|
||||
# GetDiskFreeSpaceExW) for the minimum-storage install gate
|
||||
fs4 = "0.13"
|
||||
# Cross-platform home/config directories for pill autostart registration
|
||||
dirs-next = "2"
|
||||
|
||||
|
||||
@@ -239,12 +239,28 @@ pub fn spawn_backend<R: tauri::Runtime>(app: &tauri::AppHandle<R>, progress: Opt
|
||||
env.push(("HF_HUB_DISABLE_SYMLINKS_WARNING".into(), "1".into()));
|
||||
env.push(("HF_HUB_DISABLE_SYMLINKS".into(), "1".into()));
|
||||
}
|
||||
// HF endpoint precedence: process env (power user) > setup-screen custom
|
||||
// mirror > region preset.
|
||||
let cfg = load_config(app);
|
||||
if let Ok(hf_ep) = std::env::var("HF_ENDPOINT") {
|
||||
env.push(("HF_ENDPOINT".into(), hf_ep));
|
||||
} else {
|
||||
let cfg = load_config(app);
|
||||
if cfg.region == "china" {
|
||||
env.push(("HF_ENDPOINT".into(), "https://hf-mirror.com".into()));
|
||||
} else if let Some(hf_mirror) = cfg.mirrors.hf_endpoint.as_deref() {
|
||||
env.push(("HF_ENDPOINT".into(), hf_mirror.into()));
|
||||
} else if cfg.region == "china" {
|
||||
env.push(("HF_ENDPOINT".into(), "https://hf-mirror.com".into()));
|
||||
}
|
||||
// Storage layout chosen on the setup screen. Unset (None) means platform
|
||||
// default — we deliberately don't set the env vars then, so legacy
|
||||
// installs keep byte-identical behavior. Process env still wins so a
|
||||
// power user can relocate per-launch.
|
||||
if std::env::var("OMNIVOICE_DATA_DIR").is_err() {
|
||||
if let Some(data_dir) = crate::setup::resolved_data_dir(app) {
|
||||
env.push(("OMNIVOICE_DATA_DIR".into(), data_dir.to_string_lossy().into()));
|
||||
}
|
||||
}
|
||||
if std::env::var("OMNIVOICE_CACHE_DIR").is_err() {
|
||||
if let Some(models_dir) = crate::setup::resolved_models_dir(app) {
|
||||
env.push(("OMNIVOICE_CACHE_DIR".into(), models_dir.to_string_lossy().into()));
|
||||
}
|
||||
}
|
||||
let app_data = app.path().app_local_data_dir().unwrap_or_default();
|
||||
|
||||
@@ -19,6 +19,11 @@ use crate::{BackendState, backend_port};
|
||||
#[derive(Clone, Serialize, Debug)]
|
||||
#[serde(tag = "stage", rename_all = "snake_case")]
|
||||
pub enum BootstrapStage {
|
||||
/// First run with nothing installed: parked on the setup screen waiting
|
||||
/// for the user to confirm an install plan (mode, storage, mirrors).
|
||||
/// Nothing downloads or installs in this stage — `complete_setup` is the
|
||||
/// only way out of it.
|
||||
AwaitingSetup,
|
||||
/// Working out whether we need to bootstrap at all.
|
||||
Checking,
|
||||
/// Fetching the standalone `uv` binary from astral-sh/uv releases.
|
||||
@@ -196,12 +201,12 @@ pub fn retry_bootstrap(app: tauri::AppHandle, state: tauri::State<'_, BootstrapS
|
||||
|
||||
#[tauri::command]
|
||||
pub fn clean_and_retry_bootstrap(app: tauri::AppHandle, state: tauri::State<'_, BootstrapState>) {
|
||||
if let Ok(data_dir) = app.path().app_local_data_dir() {
|
||||
let project_dir = data_dir.join("project");
|
||||
if project_dir.is_dir() {
|
||||
log::info!("Clean retry: removing {}", project_dir.display());
|
||||
let _ = fs::remove_dir_all(&project_dir);
|
||||
}
|
||||
// env_root honors the setup-screen choice (portable / custom env dir), so
|
||||
// clean-retry removes the venv the bootstrap actually uses.
|
||||
let project_dir = crate::setup::env_root(&app).join("project");
|
||||
if project_dir.is_dir() {
|
||||
log::info!("Clean retry: removing {}", project_dir.display());
|
||||
let _ = fs::remove_dir_all(&project_dir);
|
||||
}
|
||||
// Kill any zombie backend still occupying the port from the deleted
|
||||
// project dir, otherwise bootstrap will "attach" to the stale process.
|
||||
@@ -320,11 +325,14 @@ fn rocm_torch_reinstall_args(rocm_index_url: &str) -> Vec<String> {
|
||||
]
|
||||
}
|
||||
|
||||
/// Whether the user opted into the AMD ROCm torch build via
|
||||
/// OMNIVOICE_TORCH_VARIANT=rocm. Default (unset/other) → false (CUDA/CPU path
|
||||
/// unchanged). Returns the ROCm wheel index to use when enabled.
|
||||
fn rocm_opt_in() -> Option<String> {
|
||||
let variant = std::env::var("OMNIVOICE_TORCH_VARIANT").ok()?;
|
||||
/// Whether the user opted into the AMD ROCm torch build — via the
|
||||
/// OMNIVOICE_TORCH_VARIANT env var (power users, takes precedence) or the
|
||||
/// setup screen's Compute choice persisted in config (`configured_variant`).
|
||||
/// Default (unset/"auto") → None (CUDA/CPU path unchanged). Returns the ROCm
|
||||
/// wheel index to use when enabled.
|
||||
fn rocm_opt_in(configured_variant: &str) -> Option<String> {
|
||||
let variant = std::env::var("OMNIVOICE_TORCH_VARIANT")
|
||||
.unwrap_or_else(|_| configured_variant.to_string());
|
||||
if !variant.eq_ignore_ascii_case("rocm") {
|
||||
return None;
|
||||
}
|
||||
@@ -355,7 +363,9 @@ pub fn ensure_venv_ready<R: tauri::Runtime>(app: &tauri::AppHandle<R>, progress:
|
||||
}
|
||||
}
|
||||
|
||||
let app_data = app.path().app_local_data_dir().ok()?;
|
||||
// Root chosen on the setup screen: app_local_data_dir by default, the
|
||||
// exe-adjacent folder in portable mode, or a user-picked custom dir.
|
||||
let app_data = crate::setup::env_root(app);
|
||||
let project_dir = app_data.join("project");
|
||||
let venv_dir = project_dir.join(".venv");
|
||||
let venv_py = venv_python_path(&venv_dir);
|
||||
@@ -369,7 +379,27 @@ pub fn ensure_venv_ready<R: tauri::Runtime>(app: &tauri::AppHandle<R>, progress:
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status();
|
||||
if matches!(uvicorn_check, Ok(ref s) if s.success()) {
|
||||
// #248: also verify pkg_resources is importable. Venvs created before the
|
||||
// setuptools<80 pin (commit 675cc20, fixes #224) have setuptools 80+, which
|
||||
// dropped the bundled pkg_resources. whisperx / ctranslate2 import it at
|
||||
// runtime, so dubbing/transcription crashes silently on those installs even
|
||||
// though uvicorn starts fine. We detect this here so we can force a repair
|
||||
// sync rather than handing back a broken venv.
|
||||
let pkg_resources_ok = if matches!(uvicorn_check, Ok(ref s) if s.success()) {
|
||||
let mut pr_check = Command::new(&venv_py);
|
||||
scrub_python_env(&mut pr_check);
|
||||
matches!(
|
||||
pr_check
|
||||
.args(["-c", "import pkg_resources"])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status(),
|
||||
Ok(ref s) if s.success()
|
||||
)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if matches!(uvicorn_check, Ok(ref s) if s.success()) && pkg_resources_ok {
|
||||
// Always sync source dirs from bundle so code fixes land on
|
||||
// existing installs without requiring a full clean+reinstall.
|
||||
let resource_dir = app.path().resource_dir().ok();
|
||||
@@ -401,10 +431,21 @@ pub fn ensure_venv_ready<R: tauri::Runtime>(app: &tauri::AppHandle<R>, progress:
|
||||
}
|
||||
return Some((venv_py, backend_dir));
|
||||
}
|
||||
log::warn!(
|
||||
"Venv exists at {} but uvicorn is not importable — re-running uv sync",
|
||||
venv_dir.display()
|
||||
);
|
||||
if matches!(uvicorn_check, Ok(ref s) if s.success()) {
|
||||
// uvicorn is fine but pkg_resources is missing (#248): setuptools>=80 was
|
||||
// installed before the <80 pin landed (issue #224). Force a repair sync
|
||||
// to downgrade setuptools to a version that ships pkg_resources.
|
||||
log::warn!(
|
||||
"Venv at {} is missing pkg_resources (setuptools>=80 pre-dates the <80 pin) \
|
||||
— re-running uv sync to repair (#248)",
|
||||
venv_dir.display()
|
||||
);
|
||||
} else {
|
||||
log::warn!(
|
||||
"Venv exists at {} but uvicorn is not importable — re-running uv sync",
|
||||
venv_dir.display()
|
||||
);
|
||||
}
|
||||
if let Some(p) = progress {
|
||||
set_stage(p, BootstrapStage::InstallingDeps);
|
||||
}
|
||||
@@ -423,6 +464,64 @@ pub fn ensure_venv_ready<R: tauri::Runtime>(app: &tauri::AppHandle<R>, progress:
|
||||
repair_cmd.current_dir(&project_dir);
|
||||
let repair_status = run_streaming(app, "installing_deps", &mut repair_cmd);
|
||||
if matches!(repair_status, Ok(ref s) if s.success()) {
|
||||
// #248: after the repair sync, ensure pkg_resources landed. The repair
|
||||
// path is also triggered when pkg_resources is missing (see above), so
|
||||
// we must verify here rather than trusting that uv sync alone fixed it
|
||||
// (e.g. if the bundled uv.lock still pins setuptools>=80 somehow).
|
||||
let mut pr_repair_check = Command::new(&venv_py);
|
||||
scrub_python_env(&mut pr_repair_check);
|
||||
let pr_ok = matches!(
|
||||
pr_repair_check
|
||||
.args(["-c", "import pkg_resources"])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status(),
|
||||
Ok(ref s) if s.success()
|
||||
);
|
||||
if !pr_ok {
|
||||
log::warn!("pkg_resources still missing after repair sync — installing setuptools<80 directly (#248)");
|
||||
emit_log(app, "installing_deps",
|
||||
"Repairing pkg_resources: installing setuptools<80 (#248)");
|
||||
let mut st_cmd = Command::new(&uv_path);
|
||||
scrub_python_env(&mut st_cmd);
|
||||
apply_uv_http_env(&mut st_cmd);
|
||||
st_cmd
|
||||
.args(["pip", "install", "setuptools>=75,<80"])
|
||||
.current_dir(&project_dir);
|
||||
match run_streaming(app, "installing_deps", &mut st_cmd) {
|
||||
Ok(ref s) if s.success() => {
|
||||
log::info!("setuptools<80 installed after repair sync; pkg_resources now available (#248)");
|
||||
}
|
||||
other => {
|
||||
log::error!("Failed to install setuptools<80 after repair sync: {:?} — dubbing may fail (#248)", other);
|
||||
}
|
||||
}
|
||||
// Re-verify pkg_resources is importable after the targeted install.
|
||||
let mut pr_post_check = Command::new(&venv_py);
|
||||
scrub_python_env(&mut pr_post_check);
|
||||
let pr_final_ok = matches!(
|
||||
pr_post_check
|
||||
.args(["-c", "import pkg_resources"])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status(),
|
||||
Ok(ref s) if s.success()
|
||||
);
|
||||
if !pr_final_ok {
|
||||
// Repair could not restore pkg_resources — fail loudly instead of
|
||||
// handing back a venv that will crash on the first ASR/dub call. The
|
||||
// "pkg_resources" text routes to the PKG_RESOURCES_MISSING failure
|
||||
// mapping (clear, doc-linked remediation in the UI). (#248)
|
||||
fail(
|
||||
progress,
|
||||
"pkg_resources is missing from the backend venv and the automatic \
|
||||
setuptools repair did not restore it. Open a terminal and run \
|
||||
`uv pip install 'setuptools>=75,<80'` in the backend venv, then \
|
||||
restart. (#248)",
|
||||
);
|
||||
return None;
|
||||
}
|
||||
}
|
||||
return Some((venv_py, backend_dir));
|
||||
}
|
||||
fail(progress, &format!("Repair uv sync failed: {:?}", repair_status));
|
||||
@@ -496,17 +595,26 @@ pub fn ensure_venv_ready<R: tauri::Runtime>(app: &tauri::AppHandle<R>, progress:
|
||||
set_stage(p, BootstrapStage::CreatingVenv);
|
||||
}
|
||||
// plan-03 (#130): mirror cascade + system-Python fallback so first-run
|
||||
// survives a GitHub-blocked network. Try in order: (1) default GitHub host,
|
||||
// survives a GitHub-blocked network. Try in order: (0) the user's custom
|
||||
// mirror from the setup screen, when set, (1) default GitHub host,
|
||||
// (2) gh-proxy mirror, (3) system Python (only if >= 3.11) — each with
|
||||
// longer timeouts/retries. Stop at the first that succeeds.
|
||||
let mut venv_attempts: Vec<(&str, Vec<&str>, Vec<(&str, &str)>)> = vec![
|
||||
("default", vec!["venv", "--python", "3.11", "--managed-python"], vec![]),
|
||||
(
|
||||
"gh-proxy mirror",
|
||||
let user_cfg = crate::config::load_config(app);
|
||||
let custom_mirrors = user_cfg.mirrors.clone();
|
||||
let mut venv_attempts: Vec<(&str, Vec<&str>, Vec<(&str, String)>)> = Vec::new();
|
||||
if let Some(custom_py_mirror) = custom_mirrors.python_downloads.clone() {
|
||||
venv_attempts.push((
|
||||
"custom mirror (setup screen)",
|
||||
vec!["venv", "--python", "3.11", "--managed-python"],
|
||||
vec![("UV_PYTHON_INSTALL_MIRROR", PY_INSTALL_MIRROR)],
|
||||
),
|
||||
];
|
||||
vec![("UV_PYTHON_INSTALL_MIRROR", custom_py_mirror)],
|
||||
));
|
||||
}
|
||||
venv_attempts.push(("default", vec!["venv", "--python", "3.11", "--managed-python"], vec![]));
|
||||
venv_attempts.push((
|
||||
"gh-proxy mirror",
|
||||
vec!["venv", "--python", "3.11", "--managed-python"],
|
||||
vec![("UV_PYTHON_INSTALL_MIRROR", PY_INSTALL_MIRROR.to_string())],
|
||||
));
|
||||
// Always try the system Python as the LAST resort (mirrors blocked too).
|
||||
// No `--python 3.11` pin and no pre-gate: uv's own interpreter discovery is
|
||||
// the authority — with `only-system` + the project's `requires-python =
|
||||
@@ -517,7 +625,7 @@ pub fn ensure_venv_ready<R: tauri::Runtime>(app: &tauri::AppHandle<R>, progress:
|
||||
venv_attempts.push((
|
||||
"system-python",
|
||||
vec!["venv"],
|
||||
vec![("UV_PYTHON_PREFERENCE", "only-system")],
|
||||
vec![("UV_PYTHON_PREFERENCE", "only-system".to_string())],
|
||||
));
|
||||
|
||||
let mut venv_ok = false;
|
||||
@@ -525,7 +633,7 @@ pub fn ensure_venv_ready<R: tauri::Runtime>(app: &tauri::AppHandle<R>, progress:
|
||||
let mut venv_cmd = Command::new(&uv_path);
|
||||
scrub_python_env(&mut venv_cmd); // #144: don't inherit AppImage's bundled Python
|
||||
apply_uv_http_env(&mut venv_cmd);
|
||||
for &(k, v) in envs {
|
||||
for (k, v) in envs {
|
||||
venv_cmd.env(k, v);
|
||||
}
|
||||
venv_cmd.args(args.iter()).current_dir(&project_dir);
|
||||
@@ -558,8 +666,10 @@ pub fn ensure_venv_ready<R: tauri::Runtime>(app: &tauri::AppHandle<R>, progress:
|
||||
.args(["sync", "--no-dev", "--verbose"])
|
||||
.current_dir(&project_dir);
|
||||
}
|
||||
let effective_region = get_effective_region(app);
|
||||
if effective_region == "china" {
|
||||
// PyPI index precedence: explicit setup-screen mirror > region preset.
|
||||
if let Some(pypi) = custom_mirrors.pypi_index.as_deref() {
|
||||
sync_cmd.env("UV_INDEX_URL", pypi);
|
||||
} else if get_effective_region(app) == "china" {
|
||||
sync_cmd.env("UV_INDEX_URL", "https://mirrors.aliyun.com/pypi/simple/");
|
||||
}
|
||||
let sync_status = run_streaming(app, "installing_deps", &mut sync_cmd);
|
||||
@@ -574,13 +684,50 @@ docs/install/troubleshooting.md).",
|
||||
return None;
|
||||
}
|
||||
|
||||
// #248 belt-and-suspenders: after every uv sync, verify that pkg_resources is
|
||||
// importable. If it isn't (setuptools>=80 somehow landed — e.g. no lock file in
|
||||
// bundle, or the lock was resolved without our pin), run a targeted
|
||||
// `uv pip install "setuptools<80"` to repair the venv without touching anything
|
||||
// else. This is safe on all platforms (pure-Python wheel, no native code).
|
||||
{
|
||||
let mut pr_verify = Command::new(&venv_py);
|
||||
scrub_python_env(&mut pr_verify);
|
||||
let pr_ok = matches!(
|
||||
pr_verify
|
||||
.args(["-c", "import pkg_resources"])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status(),
|
||||
Ok(ref s) if s.success()
|
||||
);
|
||||
if !pr_ok {
|
||||
log::warn!("pkg_resources not importable after uv sync — installing setuptools<80 (#248)");
|
||||
emit_log(app, "installing_deps",
|
||||
"pkg_resources missing (setuptools>=80) — installing setuptools<80 to fix (#248)");
|
||||
let mut st_cmd = Command::new(&uv_path);
|
||||
scrub_python_env(&mut st_cmd);
|
||||
apply_uv_http_env(&mut st_cmd);
|
||||
st_cmd
|
||||
.args(["pip", "install", "setuptools>=75,<80"])
|
||||
.current_dir(&project_dir);
|
||||
match run_streaming(app, "installing_deps", &mut st_cmd) {
|
||||
Ok(ref s) if s.success() => {
|
||||
log::info!("setuptools<80 installed; pkg_resources now available (#248)");
|
||||
}
|
||||
other => {
|
||||
log::error!("Failed to install setuptools<80: {:?} — dubbing may fail (#248)", other);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Opt-in AMD ROCm (#124): the default install ships the CUDA torch build,
|
||||
// so AMD-only machines fall back to CPU. If the user set
|
||||
// OMNIVOICE_TORCH_VARIANT=rocm, reinstall torch/torchaudio from the ROCm
|
||||
// wheel index. Non-fatal: a failure keeps the working CUDA/CPU build rather
|
||||
// than breaking first-run. Default (unset) leaves everything unchanged.
|
||||
if let Some(rocm_url) = rocm_opt_in() {
|
||||
log::info!("OMNIVOICE_TORCH_VARIANT=rocm → reinstalling torch from {}", rocm_url);
|
||||
if let Some(rocm_url) = rocm_opt_in(&user_cfg.torch_variant) {
|
||||
log::info!("ROCm torch variant selected → reinstalling torch from {}", rocm_url);
|
||||
let mut rocm_cmd = Command::new(&uv_path);
|
||||
scrub_python_env(&mut rocm_cmd); // #144: don't inherit AppImage's bundled Python
|
||||
apply_uv_http_env(&mut rocm_cmd);
|
||||
@@ -649,23 +796,65 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rocm_opt_in_gates_strictly_on_the_env_var() {
|
||||
fn rocm_opt_in_gates_on_env_var_or_config() {
|
||||
// This test owns OMNIVOICE_TORCH_VARIANT / _INDEX for its duration; no
|
||||
// other test reads them.
|
||||
std::env::remove_var("OMNIVOICE_TORCH_VARIANT");
|
||||
std::env::remove_var("OMNIVOICE_TORCH_INDEX");
|
||||
assert!(rocm_opt_in().is_none(), "unset → no ROCm (default CUDA/CPU path)");
|
||||
assert!(rocm_opt_in("auto").is_none(), "unset+auto → no ROCm (default CUDA/CPU path)");
|
||||
assert_eq!(
|
||||
rocm_opt_in("rocm").as_deref(),
|
||||
Some(ROCM_TORCH_INDEX),
|
||||
"setup-screen config alone opts in"
|
||||
);
|
||||
|
||||
std::env::set_var("OMNIVOICE_TORCH_VARIANT", "cuda");
|
||||
assert!(rocm_opt_in().is_none(), "non-rocm value → no ROCm");
|
||||
assert!(rocm_opt_in("rocm").is_none(), "env var wins over config (explicit non-rocm)");
|
||||
|
||||
std::env::set_var("OMNIVOICE_TORCH_VARIANT", "ROCm");
|
||||
assert_eq!(rocm_opt_in().as_deref(), Some(ROCM_TORCH_INDEX), "case-insensitive opt-in → default index");
|
||||
assert_eq!(rocm_opt_in("auto").as_deref(), Some(ROCM_TORCH_INDEX), "case-insensitive env opt-in → default index");
|
||||
|
||||
std::env::set_var("OMNIVOICE_TORCH_INDEX", "https://example.test/rocm6.3");
|
||||
assert_eq!(rocm_opt_in().as_deref(), Some("https://example.test/rocm6.3"), "index override honored");
|
||||
assert_eq!(rocm_opt_in("auto").as_deref(), Some("https://example.test/rocm6.3"), "index override honored");
|
||||
|
||||
std::env::remove_var("OMNIVOICE_TORCH_VARIANT");
|
||||
std::env::remove_var("OMNIVOICE_TORCH_INDEX");
|
||||
}
|
||||
|
||||
/// #248: verify that the setuptools repair install uses the correct specifier.
|
||||
/// The specifier `"setuptools>=75,<80"` must be passed as a single argument so
|
||||
/// pip/uv interprets the range constraint as one requirement, not two.
|
||||
#[test]
|
||||
fn setuptools_repair_uses_correct_specifier() {
|
||||
// Mirror the exact args slice used in both repair branches so a regression
|
||||
// (e.g. accidentally splitting into ["setuptools>=75", ",<80"]) is caught
|
||||
// here rather than silently installing the latest setuptools.
|
||||
let repair_args: &[&str] = &["pip", "install", "setuptools>=75,<80"];
|
||||
|
||||
// The version specifier must be the third positional argument — one string,
|
||||
// not split. This is the key property the review bot flagged: a split arg
|
||||
// would make uv install the latest setuptools and leave pkg_resources absent.
|
||||
assert_eq!(repair_args[0], "pip");
|
||||
assert_eq!(repair_args[1], "install");
|
||||
assert_eq!(repair_args[2], "setuptools>=75,<80",
|
||||
"specifier must be a single arg; splitting it would bypass the <80 bound");
|
||||
|
||||
// The single-string specifier must contain both bounds.
|
||||
let specifier = repair_args[2];
|
||||
assert!(specifier.contains("setuptools"), "arg must name the package");
|
||||
assert!(specifier.contains(">=75"), "lower bound must be >=75");
|
||||
assert!(specifier.contains("<80"), "upper bound must be <80 to keep pkg_resources");
|
||||
// No comma-split: the entire range is in one argument with no spaces.
|
||||
assert!(!specifier.contains(' '), "specifier must not contain spaces (would be split by shell)");
|
||||
|
||||
// Verify 79.x satisfies the range
|
||||
let v79: (u32, u32) = (79, 0);
|
||||
assert!(v79.0 >= 75 && v79.0 < 80, "79.x must satisfy >=75,<80");
|
||||
// Verify 80.x does NOT satisfy
|
||||
let v80: (u32, u32) = (80, 0);
|
||||
assert!(!(v80.0 >= 75 && v80.0 < 80), "80.x must NOT satisfy <80");
|
||||
// Verify 82.x (what was installed before #224 fix) does NOT satisfy
|
||||
let v82: (u32, u32) = (82, 0);
|
||||
assert!(!(v82.0 >= 75 && v82.0 < 80), "82.x (pre-fix version) must NOT satisfy <80");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,12 +43,64 @@ pub struct AppConfig {
|
||||
/// stable on every launch.
|
||||
#[serde(default = "default_update_channel")]
|
||||
pub update_channel: String,
|
||||
/// True once the user has confirmed the first-run setup screen (or an
|
||||
/// existing pre-setup-screen install was detected and silently migrated).
|
||||
/// While false on a machine with no venv, the bootstrap parks in
|
||||
/// `AwaitingSetup` and nothing downloads or installs.
|
||||
#[serde(default)]
|
||||
pub setup_complete: bool,
|
||||
/// "installed" (platform dirs, default) | "portable" (everything lives in
|
||||
/// `OmniVoiceStudio-Data/` next to the executable / AppImage).
|
||||
#[serde(default = "default_install_mode")]
|
||||
pub install_mode: String,
|
||||
/// Custom root for the managed Python env (`<dir>/project/.venv`).
|
||||
/// None → `app_local_data_dir()` (legacy behavior, byte-identical).
|
||||
#[serde(default)]
|
||||
pub env_dir: Option<String>,
|
||||
/// Custom backend data dir (voices/projects/db) → OMNIVOICE_DATA_DIR.
|
||||
/// None → backend platform default (env var not set at all).
|
||||
#[serde(default)]
|
||||
pub data_dir: Option<String>,
|
||||
/// Custom model-cache dir → OMNIVOICE_CACHE_DIR (backend maps to HF_HOME,
|
||||
/// HF_HUB_CACHE, TORCH_HOME). None → library defaults.
|
||||
#[serde(default)]
|
||||
pub models_dir: Option<String>,
|
||||
/// UI locale chosen on the setup screen, mirrored here so the Rust side
|
||||
/// (tray menus, dialogs) can localize in the future. The webview keeps its
|
||||
/// own copy in localStorage; this field is informational.
|
||||
#[serde(default)]
|
||||
pub locale: Option<String>,
|
||||
/// "auto" (CUDA/MPS/CPU autodetect, default) | "rocm" (AMD wheel reinstall
|
||||
/// after sync). Env var OMNIVOICE_TORCH_VARIANT still wins for power users.
|
||||
#[serde(default = "default_torch_variant")]
|
||||
pub torch_variant: String,
|
||||
/// Explicit mirror URLs that take precedence over region presets.
|
||||
#[serde(default)]
|
||||
pub mirrors: MirrorOverrides,
|
||||
}
|
||||
|
||||
/// Per-source mirror overrides from the setup screen's Advanced section.
|
||||
/// Each empty/None field falls back to the region preset for that source.
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MirrorOverrides {
|
||||
/// PyPI simple-index URL → UV_INDEX_URL during `uv sync`.
|
||||
#[serde(default)]
|
||||
pub pypi_index: Option<String>,
|
||||
/// Hugging Face endpoint → HF_ENDPOINT for the backend process.
|
||||
#[serde(default)]
|
||||
pub hf_endpoint: Option<String>,
|
||||
/// python-build-standalone release base → UV_PYTHON_INSTALL_MIRROR.
|
||||
#[serde(default)]
|
||||
pub python_downloads: Option<String>,
|
||||
}
|
||||
|
||||
pub fn default_region() -> String { "auto".into() }
|
||||
pub fn default_dictation_shortcut() -> String { "CmdOrCtrl+Shift+Space".into() }
|
||||
pub fn default_launch_as_widget() -> bool { false }
|
||||
pub fn default_update_channel() -> String { "stable".into() }
|
||||
pub fn default_install_mode() -> String { "installed".into() }
|
||||
pub fn default_torch_variant() -> String { "auto".into() }
|
||||
|
||||
impl Default for AppConfig {
|
||||
fn default() -> Self {
|
||||
@@ -57,12 +109,30 @@ impl Default for AppConfig {
|
||||
dictation_shortcut: default_dictation_shortcut(),
|
||||
launch_as_widget: default_launch_as_widget(),
|
||||
update_channel: default_update_channel(),
|
||||
setup_complete: false,
|
||||
install_mode: default_install_mode(),
|
||||
env_dir: None,
|
||||
data_dir: None,
|
||||
models_dir: None,
|
||||
locale: None,
|
||||
torch_variant: default_torch_variant(),
|
||||
mirrors: MirrorOverrides::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A `config.json` inside the exe-adjacent portable folder marks (and wins
|
||||
/// over) the standard location — so a portable install keeps working when the
|
||||
/// folder is moved to another machine/disk, with zero state left behind.
|
||||
fn portable_config_file() -> Option<PathBuf> {
|
||||
crate::setup::portable_base()
|
||||
.map(|b| b.join("config.json"))
|
||||
.filter(|p| p.is_file())
|
||||
}
|
||||
|
||||
pub fn config_path<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> Option<PathBuf> {
|
||||
app.path().app_local_data_dir().ok().map(|d: PathBuf| d.join("config.json"))
|
||||
portable_config_file()
|
||||
.or_else(|| app.path().app_local_data_dir().ok().map(|d: PathBuf| d.join("config.json")))
|
||||
}
|
||||
|
||||
pub fn load_config<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> AppConfig {
|
||||
@@ -87,18 +157,26 @@ pub fn load_config_pre_app() -> AppConfig {
|
||||
const BUNDLE_IDENTIFIER: &str = "com.debpalash.omnivoice-studio";
|
||||
|
||||
fn config_path_pre_app() -> Option<PathBuf> {
|
||||
dirs_next::data_local_dir().map(|d| d.join(BUNDLE_IDENTIFIER).join("config.json"))
|
||||
portable_config_file()
|
||||
.or_else(|| dirs_next::data_local_dir().map(|d| d.join(BUNDLE_IDENTIFIER).join("config.json")))
|
||||
}
|
||||
|
||||
pub fn save_config<R: tauri::Runtime>(app: &tauri::AppHandle<R>, cfg: &AppConfig) {
|
||||
if let Some(p) = config_path(app) {
|
||||
if let Some(parent) = p.parent() {
|
||||
let _ = fs::create_dir_all(parent);
|
||||
}
|
||||
let _ = fs::write(&p, serde_json::to_string_pretty(cfg).unwrap_or_default());
|
||||
let _ = save_config_at(&p, cfg);
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the config to an explicit path (used by `complete_setup` to seed the
|
||||
/// portable folder before `config_path` starts resolving to it).
|
||||
pub fn save_config_at(path: &PathBuf, cfg: &AppConfig) -> Result<(), String> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| format!("mkdir {}: {e}", parent.display()))?;
|
||||
}
|
||||
let body = serde_json::to_string_pretty(cfg).map_err(|e| e.to_string())?;
|
||||
fs::write(path, body).map_err(|e| format!("write {}: {e}", path.display()))
|
||||
}
|
||||
|
||||
// ── Region helpers ────────────────────────────────────────────────────────
|
||||
|
||||
pub const VALID_REGIONS: &[&str] = &["auto", "global", "china", "russia", "restricted"];
|
||||
@@ -164,6 +242,46 @@ pub fn set_region(app: tauri::AppHandle, region: String) -> String {
|
||||
r.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A config.json written by any pre-setup-screen build must keep parsing
|
||||
/// with all new fields at safe defaults — this is what makes the setup
|
||||
/// gate invisible to existing installs.
|
||||
#[test]
|
||||
fn legacy_config_parses_with_safe_defaults() {
|
||||
let legacy = r#"{"region":"china","dictation_shortcut":"CmdOrCtrl+Shift+Space","launch_as_widget":false,"update_channel":"preview"}"#;
|
||||
let cfg: AppConfig = serde_json::from_str(legacy).expect("legacy config must parse");
|
||||
assert_eq!(cfg.region, "china");
|
||||
assert_eq!(cfg.update_channel, "preview");
|
||||
assert!(!cfg.setup_complete, "legacy installs must default to setup_complete=false (venv detection migrates them)");
|
||||
assert_eq!(cfg.install_mode, "installed");
|
||||
assert_eq!(cfg.env_dir, None);
|
||||
assert_eq!(cfg.data_dir, None);
|
||||
assert_eq!(cfg.models_dir, None);
|
||||
assert_eq!(cfg.torch_variant, "auto");
|
||||
assert!(cfg.mirrors.pypi_index.is_none());
|
||||
assert!(cfg.mirrors.hf_endpoint.is_none());
|
||||
assert!(cfg.mirrors.python_downloads.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_roundtrips_new_fields() {
|
||||
let mut cfg = AppConfig::default();
|
||||
cfg.setup_complete = true;
|
||||
cfg.install_mode = "portable".into();
|
||||
cfg.models_dir = Some("/mnt/big/models".into());
|
||||
cfg.mirrors.hf_endpoint = Some("https://hf-mirror.com".into());
|
||||
let json = serde_json::to_string(&cfg).unwrap();
|
||||
let back: AppConfig = serde_json::from_str(&json).unwrap();
|
||||
assert!(back.setup_complete);
|
||||
assert_eq!(back.install_mode, "portable");
|
||||
assert_eq!(back.models_dir.as_deref(), Some("/mnt/big/models"));
|
||||
assert_eq!(back.mirrors.hf_endpoint.as_deref(), Some("https://hf-mirror.com"));
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_update_channel(app: tauri::AppHandle) -> String {
|
||||
load_config(&app).update_channel
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
//! commands – Tauri IPC commands (sysinfo, logs, HF cache, paste, tray, dictation)
|
||||
|
||||
pub mod config;
|
||||
pub mod setup;
|
||||
pub mod bootstrap;
|
||||
pub mod tools;
|
||||
pub mod backend;
|
||||
@@ -95,12 +96,16 @@ pub fn run() {
|
||||
bootstrap::get_bootstrap_logs,
|
||||
bootstrap::retry_bootstrap,
|
||||
bootstrap::clean_and_retry_bootstrap,
|
||||
setup::get_setup_state,
|
||||
setup::check_install_target,
|
||||
setup::complete_setup,
|
||||
config::get_region,
|
||||
config::set_region,
|
||||
config::get_update_channel,
|
||||
config::set_update_channel,
|
||||
updater_channel::check_update,
|
||||
updater_channel::install_update,
|
||||
updater_channel::list_releases,
|
||||
commands::get_sysinfo,
|
||||
commands::read_log_tail,
|
||||
commands::hf_cache_scan,
|
||||
@@ -510,6 +515,14 @@ pub fn run() {
|
||||
set_stage(&stage_handle, BootstrapStage::Ready);
|
||||
return;
|
||||
}
|
||||
// `--setup` re-opens the install-plan screen on demand — it
|
||||
// must win over the attach-to-healthy-backend shortcut, or a
|
||||
// running backend would skip straight past it.
|
||||
if std::env::args().any(|a| a == "--setup") {
|
||||
log::info!("--setup flag — opening the setup screen");
|
||||
set_stage(&stage_handle, BootstrapStage::AwaitingSetup);
|
||||
return;
|
||||
}
|
||||
if backend::backend_healthy(backend_port()) {
|
||||
log::info!(
|
||||
"Port {} already serving OmniVoice backend — attaching",
|
||||
@@ -526,6 +539,16 @@ pub fn run() {
|
||||
backend::kill_orphan_on_port(backend_port());
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
// First-run gate: never auto-install. With nothing on disk to
|
||||
// attach to, park on the setup screen and wait for the user to
|
||||
// confirm an install plan — `complete_setup` restarts the
|
||||
// bootstrap from there. Existing installs (venv present) are
|
||||
// detected inside is_first_run and migrate straight through.
|
||||
if setup::is_first_run(&app_handle) {
|
||||
log::info!("First run — awaiting setup screen confirmation before installing");
|
||||
set_stage(&stage_handle, BootstrapStage::AwaitingSetup);
|
||||
return;
|
||||
}
|
||||
let child = backend::spawn_backend(&app_handle, Some(&stage_handle));
|
||||
if let Ok(mut guard) = app_handle.state::<BackendState>().process.lock() {
|
||||
*guard = child;
|
||||
|
||||
@@ -0,0 +1,767 @@
|
||||
//! First-run install setup: the pre-bootstrap configuration surface.
|
||||
//!
|
||||
//! Nothing downloads or installs until the user confirms an [`InstallPlan`]
|
||||
//! via `complete_setup`. The module is split into:
|
||||
//! - requirements: minimum-disk constants (measured, with headroom)
|
||||
//! - disk: per-path free-space / writability probing
|
||||
//! - paths: portable-base + platform default dir resolution
|
||||
//! - plan: InstallPlan validation + application
|
||||
//! - commands: the three Tauri IPC entry points
|
||||
//!
|
||||
//! Resolution helpers (`env_root`, `resolved_data_dir`, `resolved_models_dir`)
|
||||
//! are consumed by `bootstrap.rs` / `backend.rs` so the chosen layout is the
|
||||
//! single source of truth for every later spawn.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::Manager;
|
||||
|
||||
use crate::bootstrap::{set_stage, BootstrapStage, BootstrapState};
|
||||
use crate::config::{self, MirrorOverrides};
|
||||
|
||||
// ── Requirements ──────────────────────────────────────────────────────────
|
||||
|
||||
pub const GIB: u64 = 1024 * 1024 * 1024;
|
||||
|
||||
/// Python environment (venv + torch/whisperx/demucs wheels). Measured at
|
||||
/// 7.8 GiB on Linux x64 CUDA (v0.3.5); rounded up for pip build temp files.
|
||||
pub const REQUIRED_ENV_BYTES: u64 = 9 * GIB;
|
||||
|
||||
/// Default model set (TTS checkpoint + whisper + demucs in the HF cache).
|
||||
/// Measured at 6.1 GiB after a full clone+dub session; headroom for revisions.
|
||||
pub const REQUIRED_MODELS_BYTES: u64 = 7 * GIB;
|
||||
|
||||
/// Voice data, generation outputs, SQLite DB. Grows with use; 1 GiB floor so
|
||||
/// a first session never hits a full disk mid-render.
|
||||
pub const REQUIRED_DATA_BYTES: u64 = GIB;
|
||||
|
||||
/// Folder created next to the executable / AppImage in portable mode. The
|
||||
/// whole install (env + models + voices + config) lives inside it, so moving
|
||||
/// `app + this folder` together relocates the install.
|
||||
pub const PORTABLE_DIR_NAME: &str = "OmniVoiceStudio-Data";
|
||||
|
||||
// ── Disk probing ──────────────────────────────────────────────────────────
|
||||
|
||||
mod disk {
|
||||
use super::*;
|
||||
|
||||
/// The chosen directory usually doesn't exist yet — walk up to the
|
||||
/// nearest ancestor that does, since that's where space/permissions live.
|
||||
pub fn nearest_existing(path: &Path) -> PathBuf {
|
||||
let mut cur = path.to_path_buf();
|
||||
while !cur.exists() {
|
||||
match cur.parent() {
|
||||
Some(p) => cur = p.to_path_buf(),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
cur
|
||||
}
|
||||
|
||||
pub fn available_bytes(path: &Path) -> Option<u64> {
|
||||
fs4::available_space(nearest_existing(path)).ok()
|
||||
}
|
||||
|
||||
/// Stable identity of the filesystem holding `path`, so requirements for
|
||||
/// dirs that share a disk are summed before comparing against free space.
|
||||
#[cfg(unix)]
|
||||
pub fn fs_key(path: &Path) -> Option<String> {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
fs::metadata(nearest_existing(path)).ok().map(|m| format!("dev:{}", m.dev()))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
pub fn fs_key(path: &Path) -> Option<String> {
|
||||
// Windows: the drive prefix (`C:\`) identifies the volume.
|
||||
nearest_existing(path)
|
||||
.components()
|
||||
.next()
|
||||
.map(|c| format!("vol:{}", c.as_os_str().to_string_lossy().to_uppercase()))
|
||||
}
|
||||
|
||||
/// Probe writability of the nearest existing ancestor with a real write —
|
||||
/// permission bits lie (ACLs, read-only mounts, translocation), a temp
|
||||
/// file doesn't. Never creates the target dir itself; that only happens
|
||||
/// on `complete_setup`.
|
||||
pub fn writable(path: &Path) -> bool {
|
||||
let base = nearest_existing(path);
|
||||
if !base.is_dir() {
|
||||
return false;
|
||||
}
|
||||
let probe = base.join(format!(".omnivoice-write-test-{}", std::process::id()));
|
||||
match fs::write(&probe, b"ok") {
|
||||
Ok(()) => {
|
||||
let _ = fs::remove_file(&probe);
|
||||
true
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Path resolution ───────────────────────────────────────────────────────
|
||||
|
||||
/// Directory that would hold a portable install: next to the executable —
|
||||
/// or next to the `.AppImage` file on Linux (the mounted exe path is an
|
||||
/// ephemeral squashfs mount, useless as an anchor).
|
||||
pub fn portable_base() -> Option<PathBuf> {
|
||||
if let Ok(appimage) = std::env::var("APPIMAGE") {
|
||||
return Path::new(&appimage).parent().map(|p| p.join(PORTABLE_DIR_NAME));
|
||||
}
|
||||
let exe = std::env::current_exe().ok()?;
|
||||
let mut anchor = exe.parent()?.to_path_buf();
|
||||
// macOS: step out of `Foo.app/Contents/MacOS` so the data folder sits
|
||||
// beside the .app bundle, not inside it (inside breaks code signing).
|
||||
if let Some(app_bundle) = anchor
|
||||
.ancestors()
|
||||
.find(|a| a.extension().map(|e| e == "app").unwrap_or(false))
|
||||
{
|
||||
anchor = app_bundle.parent()?.to_path_buf();
|
||||
}
|
||||
Some(anchor.join(PORTABLE_DIR_NAME))
|
||||
}
|
||||
|
||||
/// Mirror of `backend/core/config.py::get_app_data_dir()` platform defaults —
|
||||
/// shown in the UI so the user sees concrete paths, never "(default)".
|
||||
pub fn default_data_dir() -> PathBuf {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
dirs_next::home_dir().unwrap_or_default().join("Library/Application Support/OmniVoice")
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
std::env::var("APPDATA")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_default()
|
||||
.join("OmniVoice")
|
||||
}
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
{
|
||||
dirs_next::home_dir().unwrap_or_default().join(".omnivoice")
|
||||
}
|
||||
}
|
||||
|
||||
/// Default HF model cache (mirrors huggingface_hub + the backend's Windows
|
||||
/// MAX_PATH redirect in `backend/core/config.py`).
|
||||
pub fn default_models_dir() -> PathBuf {
|
||||
if let Ok(hf_home) = std::env::var("HF_HOME") {
|
||||
return PathBuf::from(hf_home);
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
std::env::var("LOCALAPPDATA")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_default()
|
||||
.join("OmniVoice")
|
||||
.join("hf_cache")
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
dirs_next::home_dir().unwrap_or_default().join(".cache/huggingface")
|
||||
}
|
||||
}
|
||||
|
||||
/// Root that holds the managed Python project (`<root>/project/.venv`).
|
||||
/// Single source of truth for bootstrap + clean-retry + backend spawn.
|
||||
pub fn env_root<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> PathBuf {
|
||||
let cfg = config::load_config(app);
|
||||
if cfg.install_mode == "portable" {
|
||||
if let Some(base) = portable_base() {
|
||||
return base.join("env");
|
||||
}
|
||||
}
|
||||
if let Some(dir) = cfg.env_dir.as_deref().filter(|s| !s.is_empty()) {
|
||||
return PathBuf::from(dir);
|
||||
}
|
||||
app.path().app_local_data_dir().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// User-chosen backend data dir (voices/projects/db) → `OMNIVOICE_DATA_DIR`.
|
||||
/// `None` = backend platform default; we deliberately don't set the env var
|
||||
/// then, so legacy installs keep byte-identical behavior.
|
||||
pub fn resolved_data_dir<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> Option<PathBuf> {
|
||||
let cfg = config::load_config(app);
|
||||
if cfg.install_mode == "portable" {
|
||||
return portable_base().map(|b| b.join("data"));
|
||||
}
|
||||
cfg.data_dir.as_deref().filter(|s| !s.is_empty()).map(PathBuf::from)
|
||||
}
|
||||
|
||||
/// User-chosen model cache dir → `OMNIVOICE_CACHE_DIR` (backend maps it to
|
||||
/// HF_HOME / HF_HUB_CACHE / TORCH_HOME). Same `None` = default contract.
|
||||
pub fn resolved_models_dir<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> Option<PathBuf> {
|
||||
let cfg = config::load_config(app);
|
||||
if cfg.install_mode == "portable" {
|
||||
return portable_base().map(|b| b.join("data").join("models"));
|
||||
}
|
||||
cfg.models_dir.as_deref().filter(|s| !s.is_empty()).map(PathBuf::from)
|
||||
}
|
||||
|
||||
// ── First-run detection ───────────────────────────────────────────────────
|
||||
|
||||
/// True only when there is nothing to attach to and the user has never
|
||||
/// completed (or implicitly owned) an install:
|
||||
/// - `setup_complete` in config → returning user
|
||||
/// - dev tree with a `.venv` → contributor running from source
|
||||
/// - existing bootstrapped venv → pre-setup-screen install: migrate
|
||||
/// silently (mark complete) instead of re-asking questions whose answers
|
||||
/// are already on disk.
|
||||
pub fn is_first_run<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> bool {
|
||||
let cfg = config::load_config(app);
|
||||
if cfg.setup_complete {
|
||||
return false;
|
||||
}
|
||||
if let Some(dev_root) = crate::bootstrap::find_dev_project_root() {
|
||||
if crate::bootstrap::venv_python_path(&dev_root.join(".venv")).is_file() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let existing_venv = crate::bootstrap::venv_python_path(&env_root(app).join("project").join(".venv"));
|
||||
if existing_venv.is_file() {
|
||||
let mut cfg = cfg;
|
||||
cfg.setup_complete = true;
|
||||
config::save_config(app, &cfg);
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
// ── IPC payloads ──────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SetupState {
|
||||
pub first_run: bool,
|
||||
/// "linux" | "macos" | "windows" — lets the UI hide platform-specific
|
||||
/// opt-ins (e.g. the Linux-only ROCm torch variant) per the
|
||||
/// cross-platform parity rule: identical defaults everywhere,
|
||||
/// platform-only choices never shown where they can't work.
|
||||
pub os: &'static str,
|
||||
pub defaults: SetupDefaults,
|
||||
pub portable: PortableSupport,
|
||||
pub requirements: Requirements,
|
||||
pub hardware: HardwareInfo,
|
||||
}
|
||||
|
||||
/// What the machine offers, shown on the Compute card so the accelerator
|
||||
/// choice is informed rather than a guess. Detection is best-effort and
|
||||
/// must never block setup: every probe degrades to None/CPU.
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HardwareInfo {
|
||||
/// Marketing name when detectable ("NVIDIA GeForce RTX 4070 …").
|
||||
pub gpu: Option<String>,
|
||||
/// "cuda" | "rocm" | "mps" | "cpu" — which torch path this maps to.
|
||||
pub kind: String,
|
||||
/// Human OS name: distro PRETTY_NAME on Linux ("CachyOS", "Ubuntu 24.04"),
|
||||
/// "macOS" / "Windows" elsewhere. The install matrix (OS family × distro
|
||||
/// × arch × GPU vendor) is what users file bug reports with — show it.
|
||||
pub os_name: String,
|
||||
/// "x86_64" | "aarch64" | … — Apple Silicon vs Intel mac, ARM Linux
|
||||
/// (Asahi/Jetson) vs x64 all behave differently for wheels.
|
||||
pub arch: &'static str,
|
||||
pub cpu_cores: usize,
|
||||
pub ram_gb: f64,
|
||||
}
|
||||
|
||||
/// Distro-aware OS label. Linux reads /etc/os-release PRETTY_NAME (falls
|
||||
/// back to NAME, then "Linux"); macOS/Windows are just themselves.
|
||||
fn os_pretty_name() -> String {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if let Ok(body) = fs::read_to_string("/etc/os-release") {
|
||||
for key in ["PRETTY_NAME=", "NAME="] {
|
||||
if let Some(line) = body.lines().find(|l| l.starts_with(key)) {
|
||||
let v = line[key.len()..].trim().trim_matches('"');
|
||||
if !v.is_empty() {
|
||||
return v.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"Linux".to_string()
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
"macOS".to_string()
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
"Windows".to_string()
|
||||
}
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
|
||||
{
|
||||
std::env::consts::OS.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_hardware() -> HardwareInfo {
|
||||
use std::process::Command;
|
||||
let cores = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(0);
|
||||
let ram_gb = {
|
||||
let mut sys = sysinfo::System::new();
|
||||
sys.refresh_memory();
|
||||
(sys.total_memory() as f64 / (1024.0 * 1024.0 * 1024.0) * 10.0).round() / 10.0
|
||||
};
|
||||
let os_name = os_pretty_name();
|
||||
let arch = std::env::consts::ARCH;
|
||||
let base = move |gpu: Option<String>, kind: &str| HardwareInfo {
|
||||
gpu,
|
||||
kind: kind.into(),
|
||||
os_name: os_name.clone(),
|
||||
arch,
|
||||
cpu_cores: cores,
|
||||
ram_gb,
|
||||
};
|
||||
|
||||
// NVIDIA: nvidia-smi ships with the driver on Linux + Windows.
|
||||
let mut smi = Command::new("nvidia-smi");
|
||||
smi.args(["--query-gpu=name", "--format=csv,noheader"]);
|
||||
// Windows: a GUI app spawning a console binary flashes a cmd window —
|
||||
// on the very first screen a user ever sees. CREATE_NO_WINDOW stops it.
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
smi.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
|
||||
}
|
||||
if let Ok(out) = smi.output() {
|
||||
if out.status.success() {
|
||||
if let Some(name) = String::from_utf8_lossy(&out.stdout).lines().next() {
|
||||
let name = name.trim();
|
||||
if !name.is_empty() {
|
||||
return base(Some(name.to_string()), "cuda");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apple Silicon → MPS.
|
||||
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
|
||||
{
|
||||
return base(Some("Apple Silicon".into()), "mps");
|
||||
}
|
||||
|
||||
// AMD on Linux: a DRM card with vendor 0x1002 → ROCm candidate. No
|
||||
// marketing name without lspci, so stay generic.
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if let Ok(entries) = fs::read_dir("/sys/class/drm") {
|
||||
for e in entries.flatten() {
|
||||
let vendor = e.path().join("device").join("vendor");
|
||||
if let Ok(v) = fs::read_to_string(&vendor) {
|
||||
if v.trim() == "0x1002" {
|
||||
return base(Some("AMD GPU".into()), "rocm");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
base(None, "cpu")
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SetupDefaults {
|
||||
pub install_mode: String,
|
||||
pub env_dir: String,
|
||||
pub data_dir: String,
|
||||
pub models_dir: String,
|
||||
pub region: String,
|
||||
pub update_channel: String,
|
||||
pub torch_variant: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PortableSupport {
|
||||
pub available: bool,
|
||||
pub base_dir: Option<String>,
|
||||
/// Machine-readable reason when unavailable: "not_writable" | "no_anchor".
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Requirements {
|
||||
pub env_bytes: u64,
|
||||
pub models_bytes: u64,
|
||||
pub data_bytes: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TargetCheck {
|
||||
pub path: String,
|
||||
pub exists: bool,
|
||||
pub writable: bool,
|
||||
pub free_bytes: Option<u64>,
|
||||
pub fs_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InstallPlan {
|
||||
pub install_mode: String,
|
||||
#[serde(default)]
|
||||
pub env_dir: Option<String>,
|
||||
#[serde(default)]
|
||||
pub data_dir: Option<String>,
|
||||
#[serde(default)]
|
||||
pub models_dir: Option<String>,
|
||||
#[serde(default)]
|
||||
pub region: Option<String>,
|
||||
#[serde(default)]
|
||||
pub locale: Option<String>,
|
||||
#[serde(default)]
|
||||
pub update_channel: Option<String>,
|
||||
#[serde(default)]
|
||||
pub torch_variant: Option<String>,
|
||||
#[serde(default)]
|
||||
pub mirrors: Option<MirrorOverrides>,
|
||||
}
|
||||
|
||||
// ── Plan validation + application ─────────────────────────────────────────
|
||||
|
||||
fn none_if_default(chosen: &Option<String>, default: &Path) -> Option<String> {
|
||||
chosen
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.filter(|s| Path::new(s) != default)
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
fn valid_mirror(url: &Option<String>) -> Result<Option<String>, String> {
|
||||
match url.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
|
||||
None => Ok(None),
|
||||
Some(u) if u.starts_with("http://") || u.starts_with("https://") => Ok(Some(u.to_string())),
|
||||
Some(u) => Err(format!("Mirror URL must start with http(s):// — got: {u}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// (target dir, bytes required there) for the chosen layout.
|
||||
fn space_targets(plan: &InstallPlan, env_default: &Path) -> Vec<(PathBuf, u64)> {
|
||||
if plan.install_mode == "portable" {
|
||||
// Everything shares one folder → one combined requirement.
|
||||
let base = portable_base().unwrap_or_default();
|
||||
return vec![(base, REQUIRED_ENV_BYTES + REQUIRED_MODELS_BYTES + REQUIRED_DATA_BYTES)];
|
||||
}
|
||||
let dir_of = |s: &Option<String>, d: &Path| {
|
||||
s.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| d.to_path_buf())
|
||||
};
|
||||
vec![
|
||||
(dir_of(&plan.env_dir, env_default), REQUIRED_ENV_BYTES),
|
||||
(dir_of(&plan.data_dir, &default_data_dir()), REQUIRED_DATA_BYTES),
|
||||
(dir_of(&plan.models_dir, &default_models_dir()), REQUIRED_MODELS_BYTES),
|
||||
]
|
||||
}
|
||||
|
||||
/// Authoritative install gate: group targets by filesystem, sum what each
|
||||
/// volume must hold, and refuse the plan when any volume falls short. The UI
|
||||
/// runs the same math for live feedback; this is the backstop that actually
|
||||
/// "won't let install".
|
||||
fn check_space(targets: &[(PathBuf, u64)]) -> Result<(), String> {
|
||||
use std::collections::HashMap;
|
||||
let mut by_fs: HashMap<String, (PathBuf, u64)> = HashMap::new();
|
||||
for (dir, need) in targets {
|
||||
let key = disk::fs_key(dir).unwrap_or_else(|| dir.to_string_lossy().into_owned());
|
||||
let entry = by_fs.entry(key).or_insert_with(|| (dir.clone(), 0));
|
||||
entry.1 += need;
|
||||
}
|
||||
for (dir, need) in by_fs.values() {
|
||||
let free = disk::available_bytes(dir)
|
||||
.ok_or_else(|| format!("Could not determine free space for {}", dir.display()))?;
|
||||
if free < *need {
|
||||
return Err(format!(
|
||||
"Not enough free space on the disk holding {}: needs ~{:.1} GB, only {:.1} GB available.",
|
||||
dir.display(),
|
||||
*need as f64 / GIB as f64,
|
||||
free as f64 / GIB as f64,
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_writable(targets: &[(PathBuf, u64)]) -> Result<(), String> {
|
||||
for (dir, _) in targets {
|
||||
if !disk::writable(dir) {
|
||||
return Err(format!("Directory is not writable: {}", dir.display()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Tauri commands ────────────────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_setup_state(app: tauri::AppHandle) -> SetupState {
|
||||
let cfg = config::load_config(&app);
|
||||
let env_default = app.path().app_local_data_dir().unwrap_or_default();
|
||||
|
||||
let portable = match portable_base() {
|
||||
Some(base) if disk::writable(&base) => PortableSupport {
|
||||
available: true,
|
||||
base_dir: Some(base.to_string_lossy().into_owned()),
|
||||
reason: None,
|
||||
},
|
||||
Some(base) => PortableSupport {
|
||||
available: false,
|
||||
base_dir: Some(base.to_string_lossy().into_owned()),
|
||||
reason: Some("not_writable".into()),
|
||||
},
|
||||
None => PortableSupport { available: false, base_dir: None, reason: Some("no_anchor".into()) },
|
||||
};
|
||||
|
||||
SetupState {
|
||||
first_run: is_first_run(&app),
|
||||
os: std::env::consts::OS,
|
||||
defaults: SetupDefaults {
|
||||
install_mode: cfg.install_mode,
|
||||
env_dir: env_default.to_string_lossy().into_owned(),
|
||||
data_dir: default_data_dir().to_string_lossy().into_owned(),
|
||||
models_dir: default_models_dir().to_string_lossy().into_owned(),
|
||||
region: cfg.region,
|
||||
update_channel: cfg.update_channel,
|
||||
torch_variant: cfg.torch_variant,
|
||||
},
|
||||
portable,
|
||||
requirements: Requirements {
|
||||
env_bytes: REQUIRED_ENV_BYTES,
|
||||
models_bytes: REQUIRED_MODELS_BYTES,
|
||||
data_bytes: REQUIRED_DATA_BYTES,
|
||||
},
|
||||
hardware: detect_hardware(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn check_install_target(path: String) -> TargetCheck {
|
||||
let p = PathBuf::from(path.trim());
|
||||
TargetCheck {
|
||||
exists: p.exists(),
|
||||
writable: disk::writable(&p),
|
||||
free_bytes: disk::available_bytes(&p),
|
||||
fs_key: disk::fs_key(&p),
|
||||
path: p.to_string_lossy().into_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate the plan, persist it, then start the (until now deliberately
|
||||
/// parked) bootstrap. Any `Err` keeps the app in `AwaitingSetup` with the
|
||||
/// message surfaced on the setup screen — nothing was installed.
|
||||
#[tauri::command]
|
||||
pub fn complete_setup(
|
||||
app: tauri::AppHandle,
|
||||
state: tauri::State<'_, BootstrapState>,
|
||||
plan: InstallPlan,
|
||||
) -> Result<(), String> {
|
||||
if !matches!(plan.install_mode.as_str(), "installed" | "portable") {
|
||||
return Err(format!("Unknown install mode: {}", plan.install_mode));
|
||||
}
|
||||
if plan.install_mode == "portable" && portable_base().map(|b| disk::writable(&b)) != Some(true) {
|
||||
return Err("Portable mode is unavailable: the folder next to the app is not writable.".into());
|
||||
}
|
||||
|
||||
let mirrors = match &plan.mirrors {
|
||||
None => MirrorOverrides::default(),
|
||||
Some(m) => MirrorOverrides {
|
||||
pypi_index: valid_mirror(&m.pypi_index)?,
|
||||
hf_endpoint: valid_mirror(&m.hf_endpoint)?,
|
||||
python_downloads: valid_mirror(&m.python_downloads)?,
|
||||
},
|
||||
};
|
||||
|
||||
let env_default = app.path().app_local_data_dir().unwrap_or_default();
|
||||
let targets = space_targets(&plan, &env_default);
|
||||
check_writable(&targets)?;
|
||||
check_space(&targets)?;
|
||||
|
||||
let mut cfg = config::load_config(&app);
|
||||
cfg.setup_complete = true;
|
||||
cfg.install_mode = plan.install_mode.clone();
|
||||
cfg.env_dir = none_if_default(&plan.env_dir, &env_default);
|
||||
cfg.data_dir = none_if_default(&plan.data_dir, &default_data_dir());
|
||||
cfg.models_dir = none_if_default(&plan.models_dir, &default_models_dir());
|
||||
cfg.mirrors = mirrors;
|
||||
if let Some(region) = plan.region.as_deref().filter(|r| config::VALID_REGIONS.contains(r)) {
|
||||
cfg.region = region.to_string();
|
||||
}
|
||||
if let Some(channel) = plan.update_channel.as_deref().filter(|c| config::VALID_CHANNELS.contains(c)) {
|
||||
cfg.update_channel = channel.to_string();
|
||||
}
|
||||
if let Some(variant) = plan.torch_variant.as_deref().filter(|v| ["auto", "rocm"].contains(v)) {
|
||||
// ROCm wheels exist for Linux only — clamp anywhere else so a stray
|
||||
// payload can't configure an install that has no wheels to pull.
|
||||
cfg.torch_variant = if variant == "rocm" && !cfg!(target_os = "linux") {
|
||||
"auto".to_string()
|
||||
} else {
|
||||
variant.to_string()
|
||||
};
|
||||
}
|
||||
cfg.locale = plan.locale.clone().filter(|l| !l.is_empty());
|
||||
|
||||
if plan.install_mode == "portable" {
|
||||
// Create the portable folder and seed config.json INSIDE it first, so
|
||||
// `config_path` resolves portable from here on and the whole install
|
||||
// (env + data + config) travels as one folder.
|
||||
let base = portable_base().ok_or("Portable anchor disappeared")?;
|
||||
fs::create_dir_all(&base).map_err(|e| format!("Could not create {}: {e}", base.display()))?;
|
||||
config::save_config_at(&base.join("config.json"), &cfg)?;
|
||||
} else {
|
||||
for (dir, _) in &targets {
|
||||
fs::create_dir_all(dir).map_err(|e| format!("Could not create {}: {e}", dir.display()))?;
|
||||
}
|
||||
}
|
||||
config::save_config(&app, &cfg);
|
||||
|
||||
log::info!(
|
||||
"Setup complete (mode={}, env={}, data={}, models={}) — starting bootstrap",
|
||||
cfg.install_mode,
|
||||
cfg.env_dir.as_deref().unwrap_or("<default>"),
|
||||
cfg.data_dir.as_deref().unwrap_or("<default>"),
|
||||
cfg.models_dir.as_deref().unwrap_or("<default>"),
|
||||
);
|
||||
set_stage(&state.stage, BootstrapStage::Checking);
|
||||
crate::bootstrap::retry_bootstrap(app, state);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn nearest_existing_walks_up_to_a_real_dir() {
|
||||
let tmp = std::env::temp_dir();
|
||||
let ghost = tmp.join("omnivoice-no-such-dir").join("deeper").join("still-deeper");
|
||||
let found = disk::nearest_existing(&ghost);
|
||||
assert!(found.exists(), "must resolve to an existing ancestor");
|
||||
assert!(ghost.starts_with(&found));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn available_bytes_reports_space_for_temp_dir() {
|
||||
let free = disk::available_bytes(&std::env::temp_dir());
|
||||
assert!(free.is_some(), "temp dir must report free space");
|
||||
assert!(free.unwrap() > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fs_key_is_stable_and_groups_same_volume() {
|
||||
let tmp = std::env::temp_dir();
|
||||
let a = disk::fs_key(&tmp);
|
||||
let b = disk::fs_key(&tmp.join("does-not-exist-yet"));
|
||||
assert!(a.is_some());
|
||||
assert_eq!(a, b, "child of the same volume must share the fs key");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writable_accepts_temp_and_rejects_nonsense() {
|
||||
assert!(disk::writable(&std::env::temp_dir().join("new-subdir-not-created")));
|
||||
#[cfg(unix)]
|
||||
assert!(
|
||||
!disk::writable(Path::new("/proc/omnivoice-definitely-not-writable")),
|
||||
"procfs is not writable"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn space_targets_portable_collapses_to_one_combined_requirement() {
|
||||
let plan = InstallPlan {
|
||||
install_mode: "portable".into(),
|
||||
env_dir: None, data_dir: None, models_dir: None,
|
||||
region: None, locale: None, update_channel: None,
|
||||
torch_variant: None, mirrors: None,
|
||||
};
|
||||
let targets = space_targets(&plan, Path::new("/unused"));
|
||||
assert_eq!(targets.len(), 1);
|
||||
assert_eq!(targets[0].1, REQUIRED_ENV_BYTES + REQUIRED_MODELS_BYTES + REQUIRED_DATA_BYTES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn space_targets_installed_checks_each_location() {
|
||||
let plan = InstallPlan {
|
||||
install_mode: "installed".into(),
|
||||
env_dir: Some("/x/env".into()),
|
||||
data_dir: Some("/y/data".into()),
|
||||
models_dir: None, // default
|
||||
region: None, locale: None, update_channel: None,
|
||||
torch_variant: None, mirrors: None,
|
||||
};
|
||||
let targets = space_targets(&plan, Path::new("/default-env"));
|
||||
assert_eq!(targets.len(), 3);
|
||||
assert_eq!(targets[0], (PathBuf::from("/x/env"), REQUIRED_ENV_BYTES));
|
||||
assert_eq!(targets[1], (PathBuf::from("/y/data"), REQUIRED_DATA_BYTES));
|
||||
assert_eq!(targets[2].1, REQUIRED_MODELS_BYTES);
|
||||
assert_eq!(targets[2].0, default_models_dir());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_space_sums_requirements_sharing_a_volume() {
|
||||
// Both targets resolve to the temp-dir volume; an absurd combined
|
||||
// requirement must fail even when each alone might pass.
|
||||
let tmp = std::env::temp_dir();
|
||||
let huge = 1024 * 1024 * GIB; // 1 EiB — no consumer disk has this
|
||||
let res = check_space(&[(tmp.clone(), huge), (tmp.join("sub"), huge)]);
|
||||
assert!(res.is_err(), "1 EiB×2 on one volume must be rejected");
|
||||
let msg = res.unwrap_err();
|
||||
assert!(msg.contains("Not enough free space"), "msg: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_space_accepts_tiny_requirements() {
|
||||
assert!(check_space(&[(std::env::temp_dir(), 1)]).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirror_validation_requires_http_scheme() {
|
||||
assert_eq!(valid_mirror(&None).unwrap(), None);
|
||||
assert_eq!(valid_mirror(&Some(" ".into())).unwrap(), None);
|
||||
assert_eq!(
|
||||
valid_mirror(&Some("https://hf-mirror.com".into())).unwrap().as_deref(),
|
||||
Some("https://hf-mirror.com")
|
||||
);
|
||||
assert!(valid_mirror(&Some("ftp://nope".into())).is_err());
|
||||
assert!(valid_mirror(&Some("hf-mirror.com".into())).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_if_default_strips_defaults_and_blanks() {
|
||||
let d = Path::new("/default/dir");
|
||||
assert_eq!(none_if_default(&None, d), None);
|
||||
assert_eq!(none_if_default(&Some("".into()), d), None);
|
||||
assert_eq!(none_if_default(&Some("/default/dir".into()), d), None);
|
||||
assert_eq!(none_if_default(&Some("/custom".into()), d), Some("/custom".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_hardware_never_panics_and_reports_the_full_matrix() {
|
||||
let hw = detect_hardware();
|
||||
assert!(["cuda", "rocm", "mps", "cpu"].contains(&hw.kind.as_str()), "kind: {}", hw.kind);
|
||||
assert!(hw.ram_gb >= 0.0);
|
||||
assert!(!hw.os_name.is_empty(), "os_name must always resolve (distro or OS family)");
|
||||
assert!(!hw.arch.is_empty(), "arch must always resolve");
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
assert_eq!(hw.arch, "x86_64");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requirements_match_measured_reality() {
|
||||
// Guard against accidental edits: env ≥ measured 7.8 GiB, models ≥
|
||||
// measured 6.1 GiB — shrinking below measurements would let installs
|
||||
// start that are guaranteed to die mid-download.
|
||||
assert!(REQUIRED_ENV_BYTES >= 8 * GIB);
|
||||
assert!(REQUIRED_MODELS_BYTES >= 7 * GIB);
|
||||
assert!(REQUIRED_DATA_BYTES >= GIB / 2);
|
||||
}
|
||||
}
|
||||
@@ -100,3 +100,67 @@ pub async fn install_update(app: AppHandle, channel: String) -> Result<(), Strin
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── GitHub releases (changelog/history panel) ─────────────────────────────
|
||||
|
||||
const RELEASES_API: &str =
|
||||
"https://api.github.com/repos/debpalash/OmniVoice-Studio/releases?per_page=30";
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ReleaseInfo {
|
||||
pub version: String,
|
||||
pub name: String,
|
||||
pub date: String,
|
||||
pub prerelease: bool,
|
||||
pub notes: String,
|
||||
}
|
||||
|
||||
/// Fetch the project's GitHub releases for the changelog/history panel.
|
||||
/// `channel` is accepted for symmetry with the other update commands; channel
|
||||
/// filtering is applied on the frontend (prepareReleases) so this returns all.
|
||||
#[tauri::command]
|
||||
pub async fn list_releases(_channel: String) -> Result<Vec<ReleaseInfo>, String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.unwrap_or_default();
|
||||
let resp = client
|
||||
.get(RELEASES_API)
|
||||
.header("User-Agent", "OmniVoice-Studio")
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("releases request failed: {e}"))?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("releases request status {}", resp.status()));
|
||||
}
|
||||
let arr: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("releases parse failed: {e}"))?;
|
||||
let mut out = Vec::new();
|
||||
if let Some(items) = arr.as_array() {
|
||||
for it in items {
|
||||
let tag = it.get("tag_name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
out.push(ReleaseInfo {
|
||||
version: tag.trim_start_matches('v').to_string(),
|
||||
name: it
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or(tag)
|
||||
.to_string(),
|
||||
date: it
|
||||
.get("published_at")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.chars()
|
||||
.take(10)
|
||||
.collect(),
|
||||
prerelease: it.get("prerelease").and_then(|v| v.as_bool()).unwrap_or(false),
|
||||
notes: it.get("body").and_then(|v| v.as_str()).unwrap_or("").to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "OmniVoice Studio",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.5",
|
||||
"identifier": "com.debpalash.omnivoice-studio",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
+131
-55
@@ -21,13 +21,11 @@ const VoicePreview = lazy(() => import('./components/VoicePreview'));
|
||||
const LogsFooter = lazy(() => import('./components/LogsFooter'));
|
||||
const ProjectsPage = lazy(() => import('./pages/Projects'));
|
||||
const VoiceGallery = lazy(() => import('./pages/VoiceGallery'));
|
||||
const DonatePage = lazy(() => import('./pages/DonatePage'));
|
||||
const EnterprisePage = lazy(() => import('./pages/EnterprisePage'));
|
||||
const SupportPage = lazy(() => import('./pages/SupportPage'));
|
||||
const TranscriptionsPage = lazy(() => import('./pages/Transcriptions'));
|
||||
const StoriesEditor = lazy(() => import('./components/StoriesEditor'));
|
||||
|
||||
import Header from './components/Header';
|
||||
import UpdateBadge from './components/UpdateBadge';
|
||||
import NavRail from './components/NavRail';
|
||||
import ErrorBoundary from './components/ErrorBoundary';
|
||||
import FloatingPill from './components/FloatingPill';
|
||||
@@ -48,9 +46,11 @@ import useProfiles from './hooks/useProfiles';
|
||||
import useTTS from './hooks/useTTS';
|
||||
import useDubWorkflow from './hooks/useDubWorkflow';
|
||||
|
||||
const LazyFallback = () => <div className="app-lazy-fallback">Loading…</div>;
|
||||
const LazyFallback = () => <div className="app-lazy-fallback">{i18n.t('app.loading')}</div>;
|
||||
|
||||
import { Toaster, toast } from 'react-hot-toast';
|
||||
import { toastErrorWithReport } from './utils/errorToast';
|
||||
import { addBreadcrumb } from './utils/breadcrumbs';
|
||||
import {
|
||||
POPULAR_LANGS, POPULAR_ISO, TAGS, CATEGORIES, PRESETS, CLONE_MAX_SECONDS,
|
||||
} from './utils/constants';
|
||||
@@ -62,7 +62,9 @@ import { saveProject as apiSaveProject, loadProject as apiLoadProject, deletePro
|
||||
import { exportAction, exportReveal, exportRecord } from './api/exports';
|
||||
|
||||
import { isTauri, doubleClickMaximize, fileToMediaUrl, playBlobAudio, playPing } from './utils/media';
|
||||
import { checkForUpdate } from './utils/updater';
|
||||
import { browserDownload } from './utils/download';
|
||||
import { checkForUpdate, fetchAppVersion } from './utils/updater';
|
||||
import { syncChannel } from './utils/channelControl';
|
||||
import i18n from './i18n';
|
||||
|
||||
function App() {
|
||||
@@ -102,6 +104,9 @@ function App() {
|
||||
}, [locale, theme, font]);
|
||||
const mode = useAppStore(s => s.mode);
|
||||
const setMode = useAppStore(s => s.setMode);
|
||||
// Breadcrumb every view change — mode names are a closed set, so this is
|
||||
// privacy-safe by construction (see utils/breadcrumbs.js).
|
||||
useEffect(() => { addBreadcrumb(`view:${mode}`); }, [mode]);
|
||||
const [navRailSide, setNavRailSide] = useState(() => {
|
||||
try { return localStorage.getItem('omnivoice.navRailSide') || 'left'; } catch { return 'left'; }
|
||||
});
|
||||
@@ -365,6 +370,13 @@ function App() {
|
||||
const [setupNeeded, setSetupNeeded] = useState(false);
|
||||
const [setupChecked, setSetupChecked] = useState(false);
|
||||
useEffect(() => {
|
||||
// Gate the probe on the bootstrap being 'ready' — before that there is
|
||||
// no backend to answer. Probing from mount burned the 30-attempt ceiling
|
||||
// during the setup/installing acts (minutes long on a first run), so the
|
||||
// wizard was silently skipped straight into the studio once the install
|
||||
// finished. Keyed on bootstrapStage: the probe (re)runs the moment the
|
||||
// backend becomes reachable.
|
||||
if (bootstrapStage !== 'ready') return undefined;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const { setupStatus } = await import('./api/setup');
|
||||
@@ -383,7 +395,39 @@ function App() {
|
||||
if (!cancelled) setSetupChecked(true);
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
}, [bootstrapStage]);
|
||||
|
||||
// ── First sound ──
|
||||
// Onboarding should end with the product doing the thing: the moment the
|
||||
// studio mounts after the wizard, generate one short line locally and play
|
||||
// it. Best-effort by design — a first impression must never surface an
|
||||
// error, so every failure path is silent.
|
||||
useEffect(() => {
|
||||
if (!setupChecked || setupNeeded || bootstrapStage !== 'ready') return;
|
||||
let pending = false;
|
||||
try {
|
||||
pending = sessionStorage.getItem('omnivoice.firstSound') === '1';
|
||||
if (pending) sessionStorage.removeItem('omnivoice.firstSound');
|
||||
} catch { /* private mode */ }
|
||||
if (!pending) return;
|
||||
(async () => {
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('text', i18n.t('firstrun.first_sound_text',
|
||||
'Welcome to your studio. Every word you hear was generated on this machine, just now.'));
|
||||
// Functional model prompt (not user-facing copy) — keeps the demo
|
||||
// voice warm without depending on seeded profiles.
|
||||
fd.append('instruct', 'A warm, friendly narrator voice, medium pace');
|
||||
fd.append('num_step', '16');
|
||||
const res = await fetch(`${API}/generate`, { method: 'POST', body: fd });
|
||||
if (!res.ok) return;
|
||||
const blob = await res.blob();
|
||||
await playBlobAudio(blob);
|
||||
toast.success(i18n.t('firstrun.first_sound_done',
|
||||
'That voice? Generated seconds ago, locally. Welcome in.'), { duration: 7000 });
|
||||
} catch { /* silent — see above */ }
|
||||
})();
|
||||
}, [setupChecked, setupNeeded, bootstrapStage]);
|
||||
|
||||
// ── Tauri auto-updater ──
|
||||
// On boot, ask GitHub Releases if a newer build is available. If yes,
|
||||
@@ -395,10 +439,18 @@ function App() {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (!('__TAURI_INTERNALS__' in window)) return;
|
||||
if (import.meta.env.DEV) return;
|
||||
// Non-blocking: surface availability into the store. The UpdateBadge lets
|
||||
// the user install + restart when they choose (with a progress bar), so an
|
||||
// update never interrupts in-flight work.
|
||||
// Non-blocking: surface availability into the store. The UpdateStatusChip
|
||||
// in LogsFooter lets the user install + restart when they choose (with a
|
||||
// progress bar), so an update never interrupts in-flight work.
|
||||
fetchAppVersion().then(v => useAppStore.getState().setAppVersion(v));
|
||||
syncChannel(useAppStore.getState());
|
||||
checkForUpdate(useAppStore.getState());
|
||||
// Re-check periodically so a long-running session still gets notified, not
|
||||
// only at boot. checkForUpdate no-ops while a download/restart is already
|
||||
// in flight, so this can't interrupt an install.
|
||||
const SIX_HOURS = 6 * 60 * 60 * 1000;
|
||||
const id = setInterval(() => checkForUpdate(useAppStore.getState()), SIX_HOURS);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
// ── DESKTOP NATIVE INTEGRATION ──
|
||||
@@ -498,7 +550,27 @@ function App() {
|
||||
});
|
||||
|
||||
const handleNativeExport = async (e, sourceIdentifier, fallbackName, mode) => {
|
||||
addBreadcrumb('export');
|
||||
if (e) { e.preventDefault(); e.stopPropagation(); }
|
||||
// Browser / Docker web build: there is no Tauri shell, so the native save
|
||||
// dialog is unavailable — invoking it throws "Cannot read properties of
|
||||
// undefined (reading 'invoke')" (issue #256). Fall back to a plain HTTP
|
||||
// blob download of the file already served at /audio/<path>.
|
||||
if (!isTauri) {
|
||||
const niceName = (fallbackName || sourceIdentifier || 'audio').split('/').pop();
|
||||
try {
|
||||
const finalName = await browserDownload(`${API}/audio/${sourceIdentifier}`, niceName);
|
||||
toast.success(i18n.t('app.toast_downloaded', { name: finalName }));
|
||||
try {
|
||||
await exportRecord({ filename: finalName, destination_path: `~/Downloads/${finalName}`, mode });
|
||||
loadExportHistory();
|
||||
} catch (err) { console.warn('exportRecord (browser export path) failed:', err); }
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toastErrorWithReport(i18n.t('app.toast_export_failed', { message: err?.message || err }), err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { save } = await import('@tauri-apps/plugin-dialog');
|
||||
const ext = fallbackName.includes('.') ? fallbackName.split('.').pop() : 'wav';
|
||||
@@ -506,28 +578,20 @@ function App() {
|
||||
if (!destPath) return; // User cancelled
|
||||
|
||||
await exportAction({ source_filename: sourceIdentifier, destination_path: destPath, mode });
|
||||
toast.success(`Exported: ${fallbackName}`);
|
||||
toast.success(i18n.t('app.toast_exported', { name: fallbackName }));
|
||||
loadExportHistory();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast.error(`Export failed: ${err?.message || err}`);
|
||||
toastErrorWithReport(i18n.t('app.toast_export_failed', { message: err?.message || err }), err);
|
||||
}
|
||||
};
|
||||
const revealInFolder = async (filePath) => {
|
||||
try {
|
||||
await exportReveal({ path: filePath });
|
||||
} catch (err) {
|
||||
toast.error(`Could not open folder: ${err.message}`);
|
||||
toast.error(i18n.t('app.toast_open_folder_failed', { message: err.message }));
|
||||
}
|
||||
};
|
||||
const parseFilenameFromContentDisposition = (header) => {
|
||||
if (!header) return null;
|
||||
const utf8 = header.match(/filename\*=(?:UTF-8|utf-8)''([^;]+)/i);
|
||||
if (utf8) { try { return decodeURIComponent(utf8[1].trim().replace(/^"|"$/g, '')); } catch { /* ignore */ } }
|
||||
const plain = header.match(/filename="?([^";]+)"?/i);
|
||||
return plain ? plain[1].trim() : null;
|
||||
};
|
||||
|
||||
const triggerDownload = async (url, fallbackName) => {
|
||||
const extGuess = (fallbackName.includes('.') ? fallbackName.split('.').pop() : 'bin').toLowerCase();
|
||||
const modeGuess = ['mp4','mov','mkv','webm'].includes(extGuess)
|
||||
@@ -543,7 +607,7 @@ function App() {
|
||||
filters: [{ name: modeGuess === 'video' ? 'Video' : 'Audio', extensions: [extGuess] }],
|
||||
});
|
||||
if (!destPath) return; // user cancelled
|
||||
toast.loading(`Saving ${fallbackName}...`, { id: fallbackName });
|
||||
toast.loading(i18n.t('app.toast_saving', { name: fallbackName }), { id: fallbackName });
|
||||
const sep = url.includes('?') ? '&' : '?';
|
||||
const res = await fetch(`${url}${sep}save_path=${encodeURIComponent(destPath)}`);
|
||||
if (!res.ok) {
|
||||
@@ -551,42 +615,30 @@ function App() {
|
||||
throw new Error(err.detail || 'Save failed');
|
||||
}
|
||||
const data = await res.json();
|
||||
toast.success(`Saved: ${data.path}`, { id: fallbackName });
|
||||
toast.success(i18n.t('app.toast_saved', { path: data.path }), { id: fallbackName });
|
||||
try {
|
||||
await exportRecord({ filename: data.display_name || fallbackName, destination_path: data.path, mode: modeGuess });
|
||||
loadExportHistory();
|
||||
} catch (err) { console.warn('exportRecord (Tauri save path) failed:', err); }
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast.error(`Save error: ${err.message}`, { id: fallbackName });
|
||||
toast.error(i18n.t('app.toast_save_error', { message: err.message }), { id: fallbackName });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Browser path: standard blob download.
|
||||
try {
|
||||
toast.loading(`Processing ${fallbackName}...`, { id: fallbackName });
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error("Download failed");
|
||||
const serverName = parseFilenameFromContentDisposition(response.headers.get('content-disposition'));
|
||||
const finalName = serverName || fallbackName || 'download';
|
||||
const blob = await response.blob();
|
||||
const localUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = localUrl;
|
||||
a.download = finalName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(localUrl);
|
||||
toast.success(`Downloaded ${finalName}`, { id: fallbackName });
|
||||
toast.loading(i18n.t('app.toast_processing', { name: fallbackName }), { id: fallbackName });
|
||||
const finalName = await browserDownload(url, fallbackName);
|
||||
toast.success(i18n.t('app.toast_downloaded', { name: finalName }), { id: fallbackName });
|
||||
try {
|
||||
await exportRecord({ filename: finalName, destination_path: `~/Downloads/${finalName}`, mode: modeGuess });
|
||||
loadExportHistory();
|
||||
} catch (err) { console.warn('exportRecord (browser download path) failed:', err); }
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast.error(`Download error: ${err.message}`, { id: fallbackName });
|
||||
toast.error(i18n.t('app.toast_download_error', { message: err.message }), { id: fallbackName });
|
||||
}
|
||||
};
|
||||
// Pre-flight for audio/video exports. If any segments are at preview
|
||||
@@ -634,7 +686,7 @@ function App() {
|
||||
// ═══ STUDIO PROJECT CRUD ═══
|
||||
const saveProject = async () => {
|
||||
if (dubStep === 'idle') {
|
||||
toast.error("Please click 'Upload & Transcribe' first so the video is processed on the server before saving.");
|
||||
toast.error(i18n.t('app.toast_upload_first'));
|
||||
return;
|
||||
}
|
||||
const name = activeProjectName || dubFilename || `Project ${new Date().toLocaleString()}`;
|
||||
@@ -652,10 +704,10 @@ function App() {
|
||||
try {
|
||||
const data = await apiSaveProject(statePayload, activeProjectId);
|
||||
setActiveProject(data.id, name);
|
||||
toast.success(activeProjectId ? 'Project saved' : 'Project created');
|
||||
toast.success(activeProjectId ? i18n.t('app.toast_project_saved') : i18n.t('app.toast_project_created'));
|
||||
loadProjects();
|
||||
} catch (err) {
|
||||
toast.error('Save failed: ' + err.message);
|
||||
toast.error(i18n.t('app.toast_save_failed', { message: err.message }));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -683,7 +735,7 @@ function App() {
|
||||
// the last generate.
|
||||
setLastGenFingerprints(s.segHashes || {});
|
||||
setSpeakerClones(s.speakerClones || {});
|
||||
toast.success(`Opened: ${data.name}`);
|
||||
toast.success(i18n.t('app.toast_opened', { name: data.name }));
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
@@ -698,7 +750,7 @@ function App() {
|
||||
setActiveProject(null);
|
||||
}
|
||||
loadProjects();
|
||||
toast.success('Project deleted');
|
||||
toast.success(i18n.t('app.toast_project_deleted'));
|
||||
} catch (err) { toast.error(err.message); }
|
||||
};
|
||||
|
||||
@@ -738,7 +790,7 @@ function App() {
|
||||
|
||||
// Switch to studio tab
|
||||
setSidebarTab('projects');
|
||||
toast.success('Restored previous generation state');
|
||||
toast.success(i18n.t('app.toast_restored_state'));
|
||||
};
|
||||
|
||||
const deleteHistory = async (id, type) => {
|
||||
@@ -751,13 +803,24 @@ function App() {
|
||||
} else {
|
||||
loadHistory();
|
||||
}
|
||||
toast.success('History item deleted');
|
||||
toast.success(i18n.t('app.toast_history_deleted'));
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Install-plan screen outranks everything — both on a true first run and
|
||||
// when explicitly requested via `--setup`. Without this, a live backend
|
||||
// answering /setup/status would route straight to the model wizard and the
|
||||
// awaiting_setup stage would never get to render.
|
||||
if (bootstrapStage === 'awaiting_setup') {
|
||||
return (
|
||||
<div style={{ zoom: uiScale }}>
|
||||
<BootstrapSplash stage={bootstrapStage} message={bootstrapMessage} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// First-run gate: if /setup/status says models aren't on disk yet, render
|
||||
// the wizard instead of the main studio. Dismisses itself once the user
|
||||
// completes the download (or clicks "Skip" if they want to limp along).
|
||||
@@ -774,10 +837,13 @@ function App() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (setupNeeded) {
|
||||
if (setupNeeded && bootstrapStage === 'ready') {
|
||||
// Render outside the `app-container` grid so the wizard spans the full
|
||||
// viewport instead of getting squeezed into whatever grid cell the
|
||||
// studio layout reserves for the main content column.
|
||||
// studio layout reserves for the main content column. Gated on the
|
||||
// bootstrap being 'ready': while the stage is still settling (checking /
|
||||
// awaiting_setup racing the first poll), the wizard must not steal the
|
||||
// mount from the install-plan screen.
|
||||
return (
|
||||
<div
|
||||
className="app-wizard-wrap"
|
||||
@@ -798,7 +864,13 @@ function App() {
|
||||
className="app-wizard-dragstrip"
|
||||
/>
|
||||
<Suspense fallback={<LazyFallback />}>
|
||||
<SetupWizard onReady={() => setSetupNeeded(false)} />
|
||||
<SetupWizard onReady={() => {
|
||||
// First-sound handoff: the studio's first act after onboarding is
|
||||
// to speak. sessionStorage (not localStorage) so it never replays
|
||||
// on later launches — only on the run that finished the wizard.
|
||||
try { sessionStorage.setItem('omnivoice.firstSound', '1'); } catch { /* private mode */ }
|
||||
setSetupNeeded(false);
|
||||
}} />
|
||||
</Suspense>
|
||||
<Suspense fallback={null}>
|
||||
<LogsFooter />
|
||||
@@ -830,7 +902,7 @@ function App() {
|
||||
file={pendingTrimFile}
|
||||
maxSeconds={CLONE_MAX_SECONDS}
|
||||
onCancel={() => setPendingTrimFile(null)}
|
||||
onConfirm={(trimmed) => { setPendingTrimFile(null); setRefAudio(trimmed); setSelectedProfile(null); toast.success('Trimmed audio loaded'); }}
|
||||
onConfirm={(trimmed) => { setPendingTrimFile(null); setRefAudio(trimmed); setSelectedProfile(null); toast.success(i18n.t('app.trimmed_loaded')); }}
|
||||
/>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
@@ -844,7 +916,6 @@ function App() {
|
||||
<FloatingPill />
|
||||
|
||||
|
||||
<UpdateBadge />
|
||||
<Header
|
||||
mode={mode} setMode={setMode}
|
||||
sysStats={sysStats} modelStatus={modelStatus}
|
||||
@@ -853,8 +924,12 @@ function App() {
|
||||
onFlushMemory={async (unloadModel) => {
|
||||
try {
|
||||
const r = await apiFlushMemory(unloadModel);
|
||||
toast.success(`Flushed — RAM ${r.ram_after}G · VRAM ${r.vram_after}G${r.unloaded_model ? ' · model unloaded' : ''}`);
|
||||
} catch (e) { toast.error('Flush failed: ' + e.message); }
|
||||
toast.success(i18n.t('app.toast_flushed', {
|
||||
ram: r.ram_after,
|
||||
vram: r.vram_after,
|
||||
unloaded: r.unloaded_model ? i18n.t('app.toast_model_unloaded') : '',
|
||||
}));
|
||||
} catch (e) { toast.error(i18n.t('app.toast_flush_failed', { message: e.message })); }
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -930,13 +1005,13 @@ function App() {
|
||||
) : mode === 'donate' ? (
|
||||
<ErrorBoundary name="donate">
|
||||
<Suspense fallback={<LazyFallback />}>
|
||||
<DonatePage onBack={() => setMode('launchpad')} onEnterprise={() => setMode('enterprise')} />
|
||||
<SupportPage initialView="support" onBack={() => setMode('launchpad')} />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
) : mode === 'enterprise' ? (
|
||||
<ErrorBoundary name="enterprise">
|
||||
<Suspense fallback={<LazyFallback />}>
|
||||
<EnterprisePage onBack={() => setMode('launchpad')} />
|
||||
<SupportPage initialView="license" onBack={() => setMode('launchpad')} />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
) : mode === 'launchpad' ? (
|
||||
@@ -946,6 +1021,7 @@ function App() {
|
||||
profiles={profiles}
|
||||
studioProjects={studioProjects}
|
||||
dubHistory={dubHistory}
|
||||
exportHistory={exportHistory}
|
||||
setMode={setMode}
|
||||
setIsCompareModalOpen={setIsCompareModalOpen}
|
||||
handleSelectProfile={handleSelectProfile}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { transcribeStreamUrl } from './dub';
|
||||
|
||||
// #274: the optional speaker-count hint is appended only when it's a positive
|
||||
// integer; otherwise the backend auto-detects.
|
||||
describe('transcribeStreamUrl', () => {
|
||||
it('omits num_speakers when not provided', () => {
|
||||
expect(transcribeStreamUrl('job1')).toMatch(/\/dub\/transcribe-stream\/job1$/);
|
||||
});
|
||||
|
||||
it('omits num_speakers for null / 0 / negative / NaN', () => {
|
||||
for (const v of [null, undefined, 0, -3, NaN] as (number | null | undefined)[]) {
|
||||
expect(transcribeStreamUrl('j', v)).not.toContain('num_speakers');
|
||||
}
|
||||
});
|
||||
|
||||
it('appends a positive integer hint', () => {
|
||||
expect(transcribeStreamUrl('j', 3)).toContain('num_speakers=3');
|
||||
});
|
||||
|
||||
it('floors a fractional hint', () => {
|
||||
expect(transcribeStreamUrl('j', 2.9)).toContain('num_speakers=2');
|
||||
});
|
||||
});
|
||||
@@ -39,8 +39,14 @@ export async function dubIngestUrl(
|
||||
);
|
||||
}
|
||||
|
||||
export function transcribeStreamUrl(jobId: string): string {
|
||||
return `${API}/dub/transcribe-stream/${jobId}`;
|
||||
export function transcribeStreamUrl(jobId: string, numSpeakers?: number | null): string {
|
||||
const base = `${API}/dub/transcribe-stream/${jobId}`;
|
||||
// Optional pyannote speaker-count hint (#274). Only appended when a positive
|
||||
// integer; otherwise the backend auto-detects.
|
||||
if (numSpeakers && Number.isFinite(numSpeakers) && numSpeakers > 0) {
|
||||
return `${base}?num_speakers=${Math.floor(numSpeakers)}`;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
export async function dubAbort(jobId: string): Promise<void> {
|
||||
|
||||
@@ -60,6 +60,7 @@ export interface SystemInfo {
|
||||
app_version?: string;
|
||||
python?: string;
|
||||
platform?: string;
|
||||
arch?: string;
|
||||
device?: string;
|
||||
data_dir?: string;
|
||||
outputs_dir?: string;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
decodeToMonoLowRate, DEFAULT_PEAK_BUCKETS,
|
||||
} from '../utils/audioTrim.js';
|
||||
import { Dialog, Button } from '../ui';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import './AudioTrimmer.css';
|
||||
|
||||
const EDGE_GRAB_PX = 10;
|
||||
@@ -29,6 +30,7 @@ function fmtHMS(t) {
|
||||
}
|
||||
|
||||
export default function AudioTrimmer({ file, maxSeconds = 15, onConfirm, onCancel }) {
|
||||
const { t } = useTranslation();
|
||||
const waveRef = useRef(null);
|
||||
const rulerRef = useRef(null);
|
||||
const audioRef = useRef(null);
|
||||
@@ -99,7 +101,7 @@ export default function AudioTrimmer({ file, maxSeconds = 15, onConfirm, onCance
|
||||
peaksRef.current = refined;
|
||||
setPeakProgress(1);
|
||||
} catch (e) {
|
||||
if (!cancelled) setError('Decode failed: ' + (e.message || e));
|
||||
if (!cancelled) setError(t('trimmer.decode_failed', { message: e.message || e }));
|
||||
setDecoding(false);
|
||||
}
|
||||
})();
|
||||
@@ -465,7 +467,7 @@ export default function AudioTrimmer({ file, maxSeconds = 15, onConfirm, onCance
|
||||
const doPlay = () => {
|
||||
try { a.currentTime = s; } catch (err) { console.warn('currentTime set failed', err); }
|
||||
a.play().then(() => setPlaying(true)).catch((err) => {
|
||||
setError('Playback failed: ' + (err.message || err));
|
||||
setError(t('trimmer.playback_failed', { message: err.message || err }));
|
||||
});
|
||||
};
|
||||
// HAVE_METADATA = 1 is enough to set currentTime on most browsers.
|
||||
@@ -473,7 +475,7 @@ export default function AudioTrimmer({ file, maxSeconds = 15, onConfirm, onCance
|
||||
doPlay();
|
||||
} else {
|
||||
a.addEventListener('loadedmetadata', doPlay, { once: true });
|
||||
a.addEventListener('error', () => setError('Audio load failed'), { once: true });
|
||||
a.addEventListener('error', () => setError(t('trimmer.audio_load_failed')), { once: true });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -569,18 +571,18 @@ export default function AudioTrimmer({ file, maxSeconds = 15, onConfirm, onCance
|
||||
open
|
||||
onClose={onCancel}
|
||||
size="xl"
|
||||
title={<><Scissors size={15} color="var(--color-brand)" /> Trim reference audio</>}
|
||||
title={<><Scissors size={15} color="var(--color-brand)" /> {t('trimmer.title')}</>}
|
||||
>
|
||||
<div ref={containerRef} tabIndex={-1} className="audio-trimmer">
|
||||
<div className="audio-trimmer__meta">
|
||||
<span>{decoding
|
||||
? 'Decoding audio…'
|
||||
? t('trimmer.decoding')
|
||||
: (audioMeta
|
||||
? `Length ${fmtHMS(audioMeta.duration)} · ${audioMeta.sampleRate} Hz${peakProgress > 0 && peakProgress < 1 ? ` · rendering waveform ${Math.round(peakProgress * 100)}%` : ''}`
|
||||
? `${t('trimmer.meta_length', { duration: fmtHMS(audioMeta.duration), sampleRate: audioMeta.sampleRate })}${peakProgress > 0 && peakProgress < 1 ? ` · ${t('trimmer.meta_rendering', { percent: Math.round(peakProgress * 100) })}` : ''}`
|
||||
: '…')
|
||||
}</span>
|
||||
<span className="audio-trimmer__hint">
|
||||
scroll = zoom · shift+scroll = pan · alt+drag = pan · ⏐ ⟵ ⟶ ⏐ keys adjust handles
|
||||
{t('trimmer.keyboard_hint')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -588,12 +590,12 @@ export default function AudioTrimmer({ file, maxSeconds = 15, onConfirm, onCance
|
||||
|
||||
{/* Zoom controls */}
|
||||
<div className="audio-trimmer__toolbar">
|
||||
<Button variant="subtle" iconSize="md" onClick={zoomIn} disabled={!ready} title="Zoom in (+)"><ZoomIn size={12}/></Button>
|
||||
<Button variant="subtle" iconSize="md" onClick={zoomOut} disabled={!ready} title="Zoom out (-)"><ZoomOut size={12}/></Button>
|
||||
<Button variant="subtle" iconSize="md" onClick={fitAll} disabled={!ready} title="Fit all (Home)"><Maximize2 size={12}/></Button>
|
||||
<Button variant="chip" size="sm" onClick={fitSelection} disabled={!ready} title="Fit selection (End)">FIT SEL</Button>
|
||||
<Button variant="subtle" iconSize="md" onClick={zoomIn} disabled={!ready} title={t('trimmer.zoom_in')}><ZoomIn size={12}/></Button>
|
||||
<Button variant="subtle" iconSize="md" onClick={zoomOut} disabled={!ready} title={t('trimmer.zoom_out')}><ZoomOut size={12}/></Button>
|
||||
<Button variant="subtle" iconSize="md" onClick={fitAll} disabled={!ready} title={t('trimmer.fit_all')}><Maximize2 size={12}/></Button>
|
||||
<Button variant="chip" size="sm" onClick={fitSelection} disabled={!ready} title={t('trimmer.fit_selection')}>{t('trimmer.fit_sel_btn')}</Button>
|
||||
<div className="audio-trimmer__view-info">
|
||||
View {fmtHMS(viewStart)} → {fmtHMS(viewEnd)} ({fmtSec(viewEnd - viewStart, viewEnd - viewStart < 10 ? 2 : 0)})
|
||||
{t('trimmer.view_range', { start: fmtHMS(viewStart), end: fmtHMS(viewEnd), duration: fmtSec(viewEnd - viewStart, viewEnd - viewStart < 10 ? 2 : 0) })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -606,7 +608,7 @@ export default function AudioTrimmer({ file, maxSeconds = 15, onConfirm, onCance
|
||||
{/* Numeric fields */}
|
||||
<div className="audio-trimmer__fields">
|
||||
<label className="trim-field">
|
||||
<span className="trim-field__label">Start</span>
|
||||
<span className="trim-field__label">{t('trimmer.start_label')}</span>
|
||||
<input
|
||||
type="text" inputMode="decimal" value={startInput}
|
||||
onChange={(e) => setStartInput(e.target.value)}
|
||||
@@ -614,10 +616,10 @@ export default function AudioTrimmer({ file, maxSeconds = 15, onConfirm, onCance
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); commitStartInput(); } }}
|
||||
className="trim-field__input"
|
||||
/>
|
||||
<span className="trim-field__unit">s</span>
|
||||
<span className="trim-field__unit">{t('trimmer.unit_seconds')}</span>
|
||||
</label>
|
||||
<label className="trim-field">
|
||||
<span className="trim-field__label">End</span>
|
||||
<span className="trim-field__label">{t('trimmer.end_label')}</span>
|
||||
<input
|
||||
type="text" inputMode="decimal" value={endInput}
|
||||
onChange={(e) => setEndInput(e.target.value)}
|
||||
@@ -625,21 +627,21 @@ export default function AudioTrimmer({ file, maxSeconds = 15, onConfirm, onCance
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); commitEndInput(); } }}
|
||||
className="trim-field__input"
|
||||
/>
|
||||
<span className="trim-field__unit">s</span>
|
||||
<span className="trim-field__unit">{t('trimmer.unit_seconds')}</span>
|
||||
</label>
|
||||
<div className="trim-field trim-field--readonly">
|
||||
<span className="trim-field__label">Length</span>
|
||||
<span className="trim-field__label">{t('trimmer.length_label')}</span>
|
||||
<span className={`trim-field__value ${tooLong ? 'is-err' : ''}`}>
|
||||
{(duration_ms / 1000).toFixed(2)}s
|
||||
{(duration_ms / 1000).toFixed(2)}{t('trimmer.unit_seconds')}
|
||||
</span>
|
||||
<span className="trim-field__unit">{tooLong ? `>${maxSeconds}s` : tooShort ? 'too short' : 'ok'}</span>
|
||||
<span className="trim-field__unit">{tooLong ? t('trimmer.too_long', { max: maxSeconds }) : tooShort ? t('trimmer.too_short') : t('trimmer.length_ok')}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="icon"
|
||||
iconSize="md"
|
||||
active={loop}
|
||||
onClick={() => setLoop((v) => !v)}
|
||||
title="Loop preview"
|
||||
title={t('trimmer.loop_preview')}
|
||||
>
|
||||
<Repeat size={12} />
|
||||
</Button>
|
||||
@@ -654,19 +656,19 @@ export default function AudioTrimmer({ file, maxSeconds = 15, onConfirm, onCance
|
||||
leading={playing ? <Pause size={12} /> : <Play size={12} />}
|
||||
className="audio-trimmer__play-btn"
|
||||
>
|
||||
{playing ? 'Pause' : 'Preview selection'}
|
||||
{playing ? t('trimmer.pause') : t('trimmer.preview_selection')}
|
||||
</Button>
|
||||
<span className="audio-trimmer__kbd-hint">Space to play · Enter to confirm · Esc to cancel</span>
|
||||
<span className="audio-trimmer__kbd-hint">{t('trimmer.play_hint')}</span>
|
||||
|
||||
<div className="audio-trimmer__actions-right">
|
||||
<Button variant="ghost" onClick={onCancel}>Cancel</Button>
|
||||
<Button variant="ghost" onClick={onCancel}>{t('trimmer.cancel')}</Button>
|
||||
<Button
|
||||
variant={(tooLong || tooShort) ? 'danger' : 'primary'}
|
||||
disabled={!ready || tooLong || tooShort}
|
||||
onClick={handleConfirm}
|
||||
leading={<Check size={12} />}
|
||||
>
|
||||
Use trimmed
|
||||
{t('trimmer.use_trimmed')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useRef, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Upload, Film, Globe, X, Plus, Loader } from 'lucide-react';
|
||||
import { Button } from '../ui';
|
||||
import MultiLangPicker from './MultiLangPicker';
|
||||
@@ -17,6 +18,7 @@ export default function BatchAddDialog({
|
||||
profiles = [],
|
||||
onEnqueue, // async (files, settings) => void
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [files, setFiles] = useState([]);
|
||||
const [langs, setLangs] = useState([{ lang: 'Spanish', code: 'es' }]);
|
||||
const [voiceId, setVoiceId] = useState('');
|
||||
@@ -53,7 +55,7 @@ export default function BatchAddDialog({
|
||||
<div className="batch-add" onClick={e => e.stopPropagation()}>
|
||||
<div className="batch-add__head">
|
||||
<span className="batch-add__title">
|
||||
<Plus size={13} /> Add Videos to Queue
|
||||
<Plus size={13} /> {t('batch.add_to_queue_title')}
|
||||
</span>
|
||||
<button type="button" className="batch-add__close" onClick={onClose}>
|
||||
<X size={13} />
|
||||
@@ -70,8 +72,8 @@ export default function BatchAddDialog({
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<Upload size={24} />
|
||||
<span>Drop video files here or click to browse</span>
|
||||
<span className="batch-add__drop-hint">MP4 · MOV · MKV · WEBM</span>
|
||||
<span>{t('batch.drop_hint_text')}</span>
|
||||
<span className="batch-add__drop-hint">{t('batch.drop_formats')}</span>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
@@ -89,12 +91,12 @@ export default function BatchAddDialog({
|
||||
{/* File list */}
|
||||
{files.length > 0 && (
|
||||
<div className="batch-add__files">
|
||||
<span className="batch-add__kicker">FILES ({files.length})</span>
|
||||
<span className="batch-add__kicker">{t('batch.files_kicker', { count: files.length })}</span>
|
||||
{files.map((f, i) => (
|
||||
<div key={`${f.name}-${i}`} className="batch-add__file-row">
|
||||
<Film size={10} />
|
||||
<span className="batch-add__file-name">{f.name}</span>
|
||||
<span className="batch-add__file-size">{(f.size / 1024 / 1024).toFixed(1)} MB</span>
|
||||
<span className="batch-add__file-size">{t('batch.file_size_mb', { size: (f.size / 1024 / 1024).toFixed(1) })}</span>
|
||||
<button type="button" className="batch-add__file-x" onClick={() => removeFile(i)}>
|
||||
<X size={9} />
|
||||
</button>
|
||||
@@ -106,27 +108,27 @@ export default function BatchAddDialog({
|
||||
{/* Settings */}
|
||||
<div className="batch-add__settings">
|
||||
<div className="batch-add__field">
|
||||
<span className="batch-add__kicker"><Globe size={9} /> TARGET LANGUAGES</span>
|
||||
<span className="batch-add__kicker"><Globe size={9} /> {t('batch.target_languages')}</span>
|
||||
<MultiLangPicker selected={langs} onChange={setLangs} />
|
||||
</div>
|
||||
|
||||
<div className="batch-add__field">
|
||||
<span className="batch-add__kicker">VOICE</span>
|
||||
<span className="batch-add__kicker">{t('batch.voice_kicker')}</span>
|
||||
<select
|
||||
className="input-base batch-add__select"
|
||||
value={voiceId}
|
||||
onChange={e => setVoiceId(e.target.value)}
|
||||
>
|
||||
<option value="">Default</option>
|
||||
<option value="">{t('batch.default_option')}</option>
|
||||
{profiles.filter(p => !p.instruct).length > 0 && (
|
||||
<optgroup label="Clone Profiles">
|
||||
<optgroup label={t('batch.clone_profiles')}>
|
||||
{profiles.filter(p => !p.instruct).map(p => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
{PRESETS.length > 0 && (
|
||||
<optgroup label="Presets">
|
||||
<optgroup label={t('batch.presets')}>
|
||||
{PRESETS.map(p => (
|
||||
<option key={p.id} value={`preset:${p.id}`}>{p.name}</option>
|
||||
))}
|
||||
@@ -137,7 +139,7 @@ export default function BatchAddDialog({
|
||||
|
||||
<label className="batch-add__toggle">
|
||||
<input type="checkbox" checked={preserveBg} onChange={e => setPreserveBg(e.target.checked)} />
|
||||
<span>Preserve background audio (music/FX)</span>
|
||||
<span>{t('batch.preserve_bg')}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -145,10 +147,10 @@ export default function BatchAddDialog({
|
||||
<div className="batch-add__foot">
|
||||
<span className="batch-add__estimate">
|
||||
{files.length > 0 && langs.length > 0
|
||||
? `${files.length} video${files.length > 1 ? 's' : ''} × ${langs.length} lang${langs.length > 1 ? 's' : ''} = ${files.length * langs.length} job${files.length * langs.length > 1 ? 's' : ''}`
|
||||
: 'Select files and languages'}
|
||||
? t('batch.estimate', { videos: files.length, langs: langs.length, jobs: files.length * langs.length })
|
||||
: t('batch.select_files_langs')}
|
||||
</span>
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>Cancel</Button>
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>{t('common.cancel')}</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
@@ -157,10 +159,11 @@ export default function BatchAddDialog({
|
||||
loading={submitting}
|
||||
leading={!submitting && <Plus size={10} />}
|
||||
>
|
||||
Add to Queue
|
||||
{t('batch.add_to_queue')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,364 +1,4 @@
|
||||
.bootstrap-splash {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--chrome-bg, #141414);
|
||||
color: var(--chrome-fg, #eee);
|
||||
font-family: 'Inter Variable', 'Inter', system-ui, sans-serif;
|
||||
z-index: 9999;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.bootstrap-splash__card {
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
background: color-mix(in srgb, var(--chrome-fg, #eee) 4%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--chrome-fg, #eee) 10%, transparent);
|
||||
border-radius: 14px;
|
||||
padding: 2rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.bootstrap-splash__title-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.bootstrap-splash__card h1 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.bootstrap-splash__version {
|
||||
font-size: 0.72rem;
|
||||
opacity: 0.45;
|
||||
font-family: 'IBM Plex Mono', ui-monospace, monospace;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.bootstrap-splash__region {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.bootstrap-splash__region-select {
|
||||
background: color-mix(in srgb, var(--chrome-fg, #eee) 8%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--chrome-fg, #eee) 12%, transparent);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-size: 0.72rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23999'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.4rem center;
|
||||
padding-right: 1.4rem;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.bootstrap-splash__region-select:hover {
|
||||
background-color: color-mix(in srgb, var(--chrome-fg, #eee) 14%, transparent);
|
||||
}
|
||||
.bootstrap-splash__region-select:focus {
|
||||
outline: 1px solid var(--chrome-accent, #8ec07c);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.bootstrap-splash__region-select option {
|
||||
background: #1a1a1a;
|
||||
color: #eee;
|
||||
}
|
||||
|
||||
.bootstrap-splash__lang {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.bootstrap-splash__lang-select {
|
||||
background: color-mix(in srgb, var(--chrome-fg, #eee) 8%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--chrome-fg, #eee) 12%, transparent);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-size: 0.72rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23999'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.4rem center;
|
||||
padding-right: 1.4rem;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.bootstrap-splash__lang-select:hover {
|
||||
background-color: color-mix(in srgb, var(--chrome-fg, #eee) 14%, transparent);
|
||||
}
|
||||
.bootstrap-splash__lang-select:focus {
|
||||
outline: 1px solid var(--chrome-accent, #8ec07c);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.bootstrap-splash__lang-select option {
|
||||
background: #1a1a1a;
|
||||
color: #eee;
|
||||
}
|
||||
|
||||
.bootstrap-splash__suggestion {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: color-mix(in srgb, var(--chrome-accent, #8ec07c) 12%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--chrome-accent, #8ec07c) 20%, transparent);
|
||||
color: #eee;
|
||||
padding: 0.45rem 0.75rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.78rem;
|
||||
margin-top: 0.25rem;
|
||||
margin-bottom: 1.25rem;
|
||||
animation: bootstrapSplashSlideDown 0.2s ease-out;
|
||||
}
|
||||
|
||||
.bootstrap-splash__suggestion-actions {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.bootstrap-splash__suggestion-actions button {
|
||||
background: color-mix(in srgb, var(--chrome-accent, #8ec07c) 20%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--chrome-accent, #8ec07c) 35%, transparent);
|
||||
color: #eee;
|
||||
padding: 0.15rem 0.45rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.72rem;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.bootstrap-splash__suggestion-actions button:hover {
|
||||
background: color-mix(in srgb, var(--chrome-accent, #8ec07c) 35%, transparent);
|
||||
}
|
||||
|
||||
@keyframes bootstrapSplashSlideDown {
|
||||
from { opacity: 0; transform: translateY(-6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.bootstrap-splash__status {
|
||||
margin: 0 0 1.25rem;
|
||||
font-size: 0.95rem;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.bootstrap-splash__bar {
|
||||
height: 4px;
|
||||
width: 100%;
|
||||
border-radius: 3px;
|
||||
background: color-mix(in srgb, var(--chrome-fg, #eee) 8%, transparent);
|
||||
overflow: hidden;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.bootstrap-splash__bar-fill {
|
||||
height: 100%;
|
||||
background: var(--chrome-accent, #8ec07c);
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
.bootstrap-splash__steps {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.bootstrap-splash__steps li {
|
||||
padding-left: 1.5rem;
|
||||
position: relative;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.bootstrap-splash__steps li::before {
|
||||
content: '○';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.bootstrap-splash__steps li.done {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.bootstrap-splash__steps li.done::before {
|
||||
content: '✓';
|
||||
color: var(--chrome-accent, #8ec07c);
|
||||
}
|
||||
|
||||
.bootstrap-splash__steps li.active {
|
||||
opacity: 1;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.bootstrap-splash__steps li.active::before {
|
||||
content: '●';
|
||||
color: var(--chrome-accent, #8ec07c);
|
||||
animation: bootstrap-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes bootstrap-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
.bootstrap-splash__error {
|
||||
font-family: 'IBM Plex Mono', ui-monospace, monospace;
|
||||
font-size: 0.8rem;
|
||||
background: color-mix(in srgb, #ef4444 12%, transparent);
|
||||
border: 1px solid color-mix(in srgb, #ef4444 35%, transparent);
|
||||
color: #fca5a5;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 250px;
|
||||
overflow-y: auto;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.bootstrap-splash__sub-progress {
|
||||
margin: -0.5rem 0 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.bootstrap-splash__sub-bar {
|
||||
height: 3px;
|
||||
width: 100%;
|
||||
border-radius: 2px;
|
||||
background: color-mix(in srgb, var(--chrome-fg, #eee) 6%, transparent);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bootstrap-splash__sub-bar-fill {
|
||||
height: 100%;
|
||||
background: color-mix(in srgb, var(--chrome-accent, #8ec07c) 70%, transparent);
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
.bootstrap-splash__sub-label {
|
||||
font-family: 'IBM Plex Mono', ui-monospace, monospace;
|
||||
font-size: 0.72rem;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.bootstrap-splash__log-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
.bootstrap-splash__log-toggle {
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
opacity: 0.65;
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
padding: 0.25rem 0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.bootstrap-splash__log-toggle:hover { opacity: 1; }
|
||||
|
||||
.bootstrap-splash__log-count {
|
||||
flex: 1;
|
||||
opacity: 0.45;
|
||||
font-size: 0.72rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.bootstrap-splash__logs {
|
||||
margin: 0.5rem 0 0;
|
||||
max-height: 280px;
|
||||
min-height: 100px;
|
||||
overflow-y: auto;
|
||||
font-family: 'IBM Plex Mono', ui-monospace, monospace;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.45;
|
||||
background: color-mix(in srgb, var(--chrome-fg, #eee) 4%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--chrome-fg, #eee) 8%, transparent);
|
||||
border-radius: 8px;
|
||||
padding: 0.6rem 0.75rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
opacity: 0.85;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.bootstrap-splash__copy-btn {
|
||||
margin-top: 0.5rem;
|
||||
background: color-mix(in srgb, var(--chrome-fg, #eee) 8%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--chrome-fg, #eee) 12%, transparent);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.35rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.bootstrap-splash__copy-btn:hover {
|
||||
background: color-mix(in srgb, var(--chrome-fg, #eee) 14%, transparent);
|
||||
}
|
||||
|
||||
/* ── Error hints + retry actions ── */
|
||||
.bootstrap-splash__hints {
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.9;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.bootstrap-splash__hints strong { display: block; margin-bottom: 0.35rem; }
|
||||
.bootstrap-splash__hints ul {
|
||||
margin: 0; padding-left: 1.25rem;
|
||||
display: flex; flex-direction: column; gap: 0.25rem;
|
||||
}
|
||||
.bootstrap-splash__hints li { opacity: 0.85; }
|
||||
|
||||
.bootstrap-splash__actions {
|
||||
display: flex; gap: 0.5rem; margin-top: 1rem;
|
||||
}
|
||||
.bootstrap-splash__retry-btn {
|
||||
flex: 1;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid color-mix(in srgb, var(--chrome-fg, #eee) 15%, transparent);
|
||||
background: color-mix(in srgb, var(--chrome-accent, #8ec07c) 15%, transparent);
|
||||
color: var(--chrome-fg, #eee);
|
||||
font: inherit; font-size: 0.82rem; font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.bootstrap-splash__retry-btn:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--chrome-accent, #8ec07c) 25%, transparent);
|
||||
}
|
||||
.bootstrap-splash__retry-btn:disabled { opacity: 0.5; cursor: wait; }
|
||||
.bootstrap-splash__retry-btn--danger {
|
||||
background: color-mix(in srgb, #ef4444 12%, transparent);
|
||||
border-color: color-mix(in srgb, #ef4444 30%, transparent);
|
||||
}
|
||||
.bootstrap-splash__retry-btn--danger:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, #ef4444 22%, transparent);
|
||||
}
|
||||
/* The bootstrap splash now renders entirely in the shared first-run design
|
||||
* system (frs-* classes in FirstRunSetup.css) so that setup → install →
|
||||
* model wizard reads as one continuous experience. This file intentionally
|
||||
* carries no rules; it remains so stale imports keep resolving. */
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* First-run bootstrap splash.
|
||||
* First-run bootstrap splash — the "installing" act of the first-run journey.
|
||||
*
|
||||
* Two data sources drive this UI:
|
||||
* 1. `bootstrap_status` Tauri command (polled every 1 s) — coarse stage.
|
||||
@@ -7,14 +7,24 @@
|
||||
* from `uv sync`, ffmpeg byte counts, etc. The log panel shows the
|
||||
* last N lines so users can see *something* happening during the 5–10
|
||||
* min dependency install.
|
||||
*
|
||||
* Visual language: the same "studio console" system as FirstRunSetup
|
||||
* (frs-* classes from FirstRunSetup.css) — whisper waveform masthead,
|
||||
* engraved mono section titles, LED step rail, segmented progress meter —
|
||||
* so setup → install → model wizard reads as one continuous experience.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Suspense, lazy, useEffect, useRef, useState } from 'react';
|
||||
import { copyText } from "../utils/copyText";
|
||||
import './FirstRunSetup.css';
|
||||
import './BootstrapSplash.css';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import i18n, { LANGUAGES } from '../i18n';
|
||||
import { useAppStore } from '../store';
|
||||
|
||||
// First-run only: keep the setup screen out of the main bundle so every
|
||||
// regular launch pays nothing for it.
|
||||
const FirstRunSetup = lazy(() => import('./FirstRunSetup'));
|
||||
|
||||
const getSystemLanguage = () => {
|
||||
if (typeof navigator === 'undefined') return 'en';
|
||||
const navLang = navigator.language || (navigator.languages && navigator.languages[0]) || 'en';
|
||||
@@ -68,6 +78,12 @@ function detectHints(message, logs) {
|
||||
return hints;
|
||||
}
|
||||
|
||||
function formatEta(seconds) {
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) return '';
|
||||
if (seconds < 60) return '<1m';
|
||||
return `${Math.round(seconds / 60)}m`;
|
||||
}
|
||||
|
||||
function formatBytes(n) {
|
||||
if (!n || n < 0) return '';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
@@ -77,6 +93,26 @@ function formatBytes(n) {
|
||||
return `${v.toFixed(v < 10 ? 1 : 0)} ${units[i]}`;
|
||||
}
|
||||
|
||||
/** Whisper waveform — same speech-cadence silhouette as the setup screen. */
|
||||
function Waveform({ bars = 96 }) {
|
||||
const heights = Array.from({ length: bars }, (_, i) => {
|
||||
const t = i / bars;
|
||||
const v = Math.abs(
|
||||
Math.sin(t * Math.PI * 7.3) * 0.55 +
|
||||
Math.sin(t * Math.PI * 2.1 + 1.2) * 0.3 +
|
||||
Math.sin(t * Math.PI * 17.0 + 0.4) * 0.15
|
||||
);
|
||||
return 0.18 + v * 0.82;
|
||||
});
|
||||
return (
|
||||
<div className="frs-wave" aria-hidden="true">
|
||||
{heights.map((h, i) => (
|
||||
<span key={i} className="frs-wave__bar" style={{ '--h': h, '--d': `${(i * 73) % 1400}ms` }} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BootstrapSplash({ stage, message }) {
|
||||
const { t } = useTranslation();
|
||||
const locale = useAppStore(s => s.locale);
|
||||
@@ -117,6 +153,8 @@ export function BootstrapSplash({ stage, message }) {
|
||||
const [region, setRegionState] = useState('auto');
|
||||
const [retrying, setRetrying] = useState(false);
|
||||
const logRef = useRef(null);
|
||||
const prevProgRef = useRef(null); // {bytes, t} — last progress event
|
||||
const rateRef = useRef(0); // EMA bytes/sec across events
|
||||
|
||||
const handleRetry = async () => {
|
||||
if (retrying) return;
|
||||
@@ -131,7 +169,7 @@ export function BootstrapSplash({ stage, message }) {
|
||||
|
||||
const handleCleanRetry = async () => {
|
||||
if (retrying) return;
|
||||
if (!confirm('This will delete the cached Python environment and re-download all dependencies (~5-10 min). Continue?')) return;
|
||||
if (!confirm(t('bootstrap.clean_retry_confirm'))) return;
|
||||
setRetrying(true);
|
||||
try {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
@@ -202,7 +240,18 @@ export function BootstrapSplash({ stage, message }) {
|
||||
});
|
||||
});
|
||||
unlistenProgress = await listen('bootstrap-progress', (e) => {
|
||||
setProgress(e.payload || null);
|
||||
const payload = e.payload || null;
|
||||
// EMA byte-rate from successive events → ETA for the long stretch.
|
||||
if (payload?.bytes_done != null) {
|
||||
const now = Date.now();
|
||||
const prev = prevProgRef.current;
|
||||
if (prev && payload.bytes_done > prev.bytes && now > prev.t) {
|
||||
const inst = (payload.bytes_done - prev.bytes) / ((now - prev.t) / 1000);
|
||||
rateRef.current = rateRef.current ? rateRef.current * 0.7 + inst * 0.3 : inst;
|
||||
}
|
||||
prevProgRef.current = { bytes: payload.bytes_done, t: now };
|
||||
}
|
||||
setProgress(payload);
|
||||
});
|
||||
} catch {
|
||||
/* not in Tauri or listen unavailable — silent */
|
||||
@@ -224,7 +273,6 @@ export function BootstrapSplash({ stage, message }) {
|
||||
}, [logs, logsOpen]);
|
||||
|
||||
// Auto-expand logs on failure so users can see + copy the full output.
|
||||
// Also expand on failure (in case user collapsed manually).
|
||||
useEffect(() => {
|
||||
if (isFailed) setLogsOpen(true);
|
||||
}, [isFailed]);
|
||||
@@ -244,134 +292,196 @@ export function BootstrapSplash({ stage, message }) {
|
||||
|
||||
const stageProgress = progress && progress.stage === stage ? progress : null;
|
||||
const pctFromBytes = stageProgress?.percent != null ? stageProgress.percent : null;
|
||||
// Overall journey progress: completed steps + byte-progress within the
|
||||
// current step when the backend reports it.
|
||||
const overallPct = Math.min(
|
||||
100,
|
||||
((stepIndex + (pctFromBytes != null ? pctFromBytes / 100 : 0.4)) / STEPS.length) * 100,
|
||||
);
|
||||
|
||||
// First run with nothing installed: Rust parks in `awaiting_setup` and the
|
||||
// install-plan screen takes over. complete_setup advances the stage, and
|
||||
// the regular progress UI below resumes automatically on the next poll.
|
||||
// (Checked after every hook above so the setup → install transition keeps
|
||||
// the hook order stable.)
|
||||
if (stage === 'awaiting_setup') {
|
||||
return (
|
||||
<Suspense fallback={<div className="frs"><div className="frs__atmo" /></div>}>
|
||||
<FirstRunSetup />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bootstrap-splash">
|
||||
<div className="bootstrap-splash__card">
|
||||
<div className="bootstrap-splash__title-row">
|
||||
<h1>{t('bootstrap.title', 'OmniVoice Studio')}</h1>
|
||||
<span className="bootstrap-splash__version">v{APP_VERSION}</span>
|
||||
<div className="bootstrap-splash__region">
|
||||
<select
|
||||
className="bootstrap-splash__region-select"
|
||||
value={region}
|
||||
onChange={(e) => handleRegionChange(e.target.value)}
|
||||
>
|
||||
<option value="auto">🌐 {t('bootstrap.auto_detect', 'Auto-detect')}</option>
|
||||
<option value="global">🌐 Global (direct)</option>
|
||||
<option value="china">🇨🇳 China (mirror)</option>
|
||||
<option value="russia">🇷🇺 Russia (mirror)</option>
|
||||
<option value="restricted">🌍 Restricted (mirror)</option>
|
||||
</select>
|
||||
<div className="frs">
|
||||
<div className="frs__atmo" aria-hidden="true" />
|
||||
<div className="frs__deck frs__deck--focus">
|
||||
|
||||
{/* ── Masthead: same identity as the setup screen ───────────────── */}
|
||||
<header className="frs__mast frs-rise" style={{ '--rise': 0 }} data-tauri-drag-region>
|
||||
<Waveform />
|
||||
{/* Journey rail: act 2 of the install flow (setup already done). */}
|
||||
<nav className="frs-wsteps frs-wsteps--journey" aria-label={t('bootstrap.title', 'OmniVoice Studio')}>
|
||||
<span className="frs-wstep is-done">
|
||||
<span className="frs-wstep__led" aria-hidden="true" />
|
||||
{t('firstrun.stage_setup', 'Setup')}
|
||||
</span>
|
||||
<span className="frs-wstep is-active">
|
||||
<span className="frs-wstep__led" aria-hidden="true" />
|
||||
{t('firstrun.installing_title', 'Installing')}
|
||||
</span>
|
||||
<span className="frs-wstep">
|
||||
<span className="frs-wstep__led" aria-hidden="true" />
|
||||
{t('firstrun.stage_models', 'Models & engines')}
|
||||
</span>
|
||||
</nav>
|
||||
<div className="frs__mast-row">
|
||||
<div className="frs__mast-text">
|
||||
<h1 className="frs__title">{t('bootstrap.title', 'OmniVoice Studio')}</h1>
|
||||
<p className="frs__subtitle" aria-live="polite">{label}</p>
|
||||
</div>
|
||||
<div className="frs__mast-meta">
|
||||
<div className="frs__mast-selects">
|
||||
<select
|
||||
className="frs-select frs-select--lang"
|
||||
value={locale}
|
||||
onChange={(e) => handleLocaleChange(e.target.value)}
|
||||
aria-label={t('firstrun.language', 'Language')}
|
||||
>
|
||||
{LANGUAGES.map((l) => (
|
||||
<option key={l.code} value={l.code}>{l.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="frs-select frs-select--lang"
|
||||
value={region}
|
||||
onChange={(e) => handleRegionChange(e.target.value)}
|
||||
aria-label={t('firstrun.region_label', 'Download region')}
|
||||
>
|
||||
<option value="auto">🌐 {t('bootstrap.auto_detect', 'Auto-detect')}</option>
|
||||
<option value="global">🌐 {t('bootstrap.region_global')}</option>
|
||||
<option value="china">🇨🇳 {t('bootstrap.region_china')}</option>
|
||||
<option value="russia">🇷🇺 {t('bootstrap.region_russia')}</option>
|
||||
<option value="restricted">🌍 {t('bootstrap.region_restricted')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bootstrap-splash__lang" style={{ marginLeft: '0.5rem' }}>
|
||||
<select
|
||||
className="bootstrap-splash__lang-select"
|
||||
value={locale}
|
||||
onChange={(e) => handleLocaleChange(e.target.value)}
|
||||
>
|
||||
{LANGUAGES.map((l) => (
|
||||
<option key={l.code} value={l.code}>{l.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{showSuggestion && (
|
||||
<div className="bootstrap-splash__suggestion">
|
||||
<div className="frs-banner frs-rise" style={{ '--rise': 1 }}>
|
||||
<span>🌐 {t('bootstrap.suggest_lang', { lang: LANGUAGES.find(l => l.code === systemLang)?.label || systemLang })}</span>
|
||||
<div className="bootstrap-splash__suggestion-actions">
|
||||
<button onClick={acceptSuggestion}>{t('common.yes', 'Yes')}</button>
|
||||
<button onClick={dismissSuggestion}>{t('common.no', 'No')}</button>
|
||||
<div className="frs-banner__actions">
|
||||
<button type="button" className="frs-btn frs-btn--quiet" onClick={acceptSuggestion}>{t('common.yes', 'Yes')}</button>
|
||||
<button type="button" className="frs-btn frs-btn--quiet" onClick={dismissSuggestion}>{t('common.no', 'No')}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<p className="bootstrap-splash__status">{label}</p>
|
||||
|
||||
{isFailed ? (
|
||||
<>
|
||||
<pre className="bootstrap-splash__error">{message || 'Unknown error'}</pre>
|
||||
<div className="bootstrap-splash__hints">
|
||||
<strong>💡 {t('bootstrap.what_to_try', 'What to try:')}</strong>
|
||||
<section className="frs-panel frs-rise" style={{ '--rise': 1 }}>
|
||||
<h2 className="frs-panel__title">{t('bootstrap.failed', 'Setup failed')}</h2>
|
||||
<pre className="frs__error">{message || t('bootstrap.unknown_error')}</pre>
|
||||
<div className="frs-hints">
|
||||
<span className="frs-hints__label">💡 {t('bootstrap.what_to_try', 'What to try:')}</span>
|
||||
<ul>
|
||||
{detectHints(message, logs).map((h, i) => <li key={i}>{h}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="bootstrap-splash__actions">
|
||||
<button className="bootstrap-splash__retry-btn" onClick={handleRetry} disabled={retrying}>
|
||||
{retrying ? '⏳ ' + t('bootstrap.retrying', 'Retrying…') : '🔄 ' + t('bootstrap.retry', 'Retry')}
|
||||
</button>
|
||||
<button className="bootstrap-splash__retry-btn bootstrap-splash__retry-btn--danger" onClick={handleCleanRetry} disabled={retrying}>
|
||||
<div className="frs-banner__actions frs-banner__actions--end">
|
||||
<button
|
||||
type="button"
|
||||
className="frs-btn frs-btn--quiet"
|
||||
onClick={handleCleanRetry}
|
||||
disabled={retrying}
|
||||
>
|
||||
🧹 {t('bootstrap.clean_retry', 'Clean & Retry')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`frs-btn frs-btn--primary ${retrying ? '' : 'is-armed'}`}
|
||||
onClick={handleRetry}
|
||||
disabled={retrying}
|
||||
>
|
||||
<span className="frs-btn__led" aria-hidden="true" />
|
||||
{retrying ? t('bootstrap.retrying', 'Retrying…') : t('bootstrap.retry', 'Retry')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
</section>
|
||||
) : (
|
||||
<>
|
||||
<div className="bootstrap-splash__bar">
|
||||
<div
|
||||
className="bootstrap-splash__bar-fill"
|
||||
style={{ width: `${((stepIndex + 1) / STEPS.length) * 100}%` }}
|
||||
/>
|
||||
<section className="frs-panel frs-rise" style={{ '--rise': 1 }}>
|
||||
<h2 className="frs-panel__title">{t('firstrun.installing_title', 'Installing')}</h2>
|
||||
{/* Overall journey meter — the same LED segments as the setup
|
||||
screen's disk gate, now measuring progress instead of space. */}
|
||||
<div className="frs-meter frs-meter--progress" role="progressbar" aria-valuenow={Math.round(overallPct)} aria-valuemin={0} aria-valuemax={100}>
|
||||
<span className="frs-meter__fill" style={{ width: `${overallPct}%` }} />
|
||||
</div>
|
||||
{stageProgress && (
|
||||
<div className="bootstrap-splash__sub-progress">
|
||||
<div className="bootstrap-splash__sub-bar">
|
||||
<div
|
||||
className="bootstrap-splash__sub-bar-fill"
|
||||
style={{ width: `${pctFromBytes ?? 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="bootstrap-splash__sub-label">
|
||||
{formatBytes(stageProgress.bytes_done)}
|
||||
{stageProgress.bytes_total > 0
|
||||
? ` / ${formatBytes(stageProgress.bytes_total)}`
|
||||
: ''}
|
||||
{pctFromBytes != null ? ` (${pctFromBytes}%)` : ''}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<ol className="bootstrap-splash__steps">
|
||||
<ol className="frs-steps">
|
||||
{STEPS.map((s, i) => (
|
||||
<li
|
||||
key={s}
|
||||
className={
|
||||
i < stepIndex ? 'done' :
|
||||
i === stepIndex ? 'active' :
|
||||
'pending'
|
||||
}
|
||||
className={[
|
||||
'frs-step',
|
||||
i < stepIndex ? 'is-done' : i === stepIndex ? 'is-active' : 'is-pending',
|
||||
].join(' ')}
|
||||
>
|
||||
{t(`bootstrap.${s}`, STAGE_LABEL[s])}
|
||||
<span className="frs-step__led" aria-hidden="true" />
|
||||
<span className="frs-step__label">{t(`bootstrap.${s}`, STAGE_LABEL[s])}</span>
|
||||
{i === stepIndex && stageProgress && (
|
||||
<span className="frs-step__bytes">
|
||||
{formatBytes(stageProgress.bytes_done)}
|
||||
{stageProgress.bytes_total > 0 ? ` / ${formatBytes(stageProgress.bytes_total)}` : ''}
|
||||
{pctFromBytes != null ? ` (${pctFromBytes}%)` : ''}
|
||||
{stageProgress.bytes_total > 0 && rateRef.current > 0 && stageProgress.bytes_done < stageProgress.bytes_total && (
|
||||
` · ${t('firstrun.eta_left', {
|
||||
eta: formatEta((stageProgress.bytes_total - stageProgress.bytes_done) / rateRef.current),
|
||||
defaultValue: '~{{eta}} left',
|
||||
})}`
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</>
|
||||
)}
|
||||
{/* Live log panel — always visible so users see what's happening */}
|
||||
<div className="bootstrap-splash__log-header">
|
||||
<button
|
||||
type="button"
|
||||
className="bootstrap-splash__log-toggle"
|
||||
onClick={() => setLogsOpen((v) => !v)}
|
||||
>
|
||||
{logsOpen ? '▾ ' + t('bootstrap.hide_logs', 'Hide logs') : '▸ ' + t('bootstrap.show_logs', 'Show logs')}
|
||||
</button>
|
||||
<span className="bootstrap-splash__log-count">
|
||||
{logs.length > 0 && t('bootstrap.lines', { count: logs.length })}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="bootstrap-splash__copy-btn"
|
||||
onClick={handleCopyLogs}
|
||||
>
|
||||
{copied ? '✓ ' + t('bootstrap.copied', 'Copied!') : '📋 ' + t('bootstrap.copy', 'Copy')}
|
||||
</button>
|
||||
</div>
|
||||
{logsOpen && (
|
||||
<pre className="bootstrap-splash__logs" ref={logRef}>
|
||||
{logs.length === 0
|
||||
? t('bootstrap.waiting_output', 'Waiting for output…')
|
||||
: logs.map((l, i) => `[${l.stage}] ${l.line}`).join('\n')}
|
||||
</pre>
|
||||
<p className="frs__trust">
|
||||
{t('firstrun.resume_note', 'Interrupted downloads resume automatically — closing the app is safe.')}
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── Live log — always reachable, quiet by design ───────────────── */}
|
||||
<section className="frs-panel frs-rise" style={{ '--rise': 2 }}>
|
||||
<h2 className="frs-panel__title">
|
||||
{t('firstrun.activity_title', 'Activity')}
|
||||
<span className="frs-log__meta">
|
||||
{logs.length > 0 && t('bootstrap.lines', { count: logs.length })}
|
||||
</span>
|
||||
</h2>
|
||||
<div className="frs-log__bar">
|
||||
<button type="button" className="frs-btn frs-btn--quiet" onClick={() => setLogsOpen(v => !v)}>
|
||||
{logsOpen ? '▾ ' + t('bootstrap.hide_logs', 'Hide logs') : '▸ ' + t('bootstrap.show_logs', 'Show logs')}
|
||||
</button>
|
||||
<button type="button" className="frs-btn frs-btn--quiet" onClick={handleCopyLogs}>
|
||||
{copied ? '✓ ' + t('bootstrap.copied', 'Copied!') : '📋 ' + t('bootstrap.copy', 'Copy')}
|
||||
</button>
|
||||
</div>
|
||||
{logsOpen && (
|
||||
<pre className="frs-log" ref={logRef}>
|
||||
{logs.length === 0
|
||||
? t('bootstrap.waiting_output', 'Waiting for output…')
|
||||
: logs.map((l) => `[${l.stage}] ${l.line}`).join('\n')}
|
||||
</pre>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<footer className="frs__foot frs-rise" style={{ '--rise': 3 }}>
|
||||
<div className="frs__foot-row">
|
||||
<span className="frs__totals">
|
||||
<span className="frs__plate">OVS · v{APP_VERSION}</span>
|
||||
</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -392,6 +502,7 @@ export function useBootstrapStage(pollMs = 1000) {
|
||||
|
||||
let cancelled = false;
|
||||
let timer = null;
|
||||
let misses = 0;
|
||||
const invoke = async () => {
|
||||
try {
|
||||
const { invoke: tauriInvoke } = await import('@tauri-apps/api/core');
|
||||
@@ -408,13 +519,24 @@ export function useBootstrapStage(pollMs = 1000) {
|
||||
try {
|
||||
const res = await tauriInvoke('bootstrap_status');
|
||||
if (cancelled) return;
|
||||
misses = 0;
|
||||
// Rust returns { stage: 'ready' } or { stage: 'failed', message: '…' } etc.
|
||||
setState({ stage: res.stage || 'ready', message: res.message || null });
|
||||
if (res.stage !== 'ready' && res.stage !== 'failed') {
|
||||
timer = setTimeout(tick, pollMs);
|
||||
}
|
||||
} catch {
|
||||
setState({ stage: 'ready', message: null });
|
||||
// A transient IPC hiccup (e.g. the very first poll racing webview
|
||||
// init) must NOT permanently declare 'ready' — that kills the poll
|
||||
// loop and silently skips the awaiting_setup / progress screens.
|
||||
// Retry a few times before conceding.
|
||||
misses += 1;
|
||||
if (cancelled) return;
|
||||
if (misses < 5) {
|
||||
timer = setTimeout(tick, pollMs);
|
||||
} else {
|
||||
setState({ stage: 'ready', message: null });
|
||||
}
|
||||
}
|
||||
};
|
||||
tick();
|
||||
|
||||
@@ -3,6 +3,7 @@ import { copyText } from "../utils/copyText";
|
||||
import { X, Loader } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useAppStore } from '../store';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import './CaptureWidget.css';
|
||||
|
||||
import { API as API_BASE } from '../api/client';
|
||||
@@ -35,6 +36,7 @@ function formatElapsed(ms) {
|
||||
* Records → transcribes → auto-pastes → auto-dismisses.
|
||||
*/
|
||||
export default function CaptureWidget({ onDismiss }) {
|
||||
const { t } = useTranslation();
|
||||
const [state, setState] = useState('idle'); // idle | recording | transcribing | done | error
|
||||
const [transcript, setTranscript] = useState('');
|
||||
const [duration, setDuration] = useState(0);
|
||||
@@ -254,11 +256,11 @@ export default function CaptureWidget({ onDismiss }) {
|
||||
const isMac = navigator.platform?.includes('Mac');
|
||||
const isWindows = navigator.platform?.includes('Win');
|
||||
const hint = isMac
|
||||
? 'macOS: open System Settings → Privacy & Security → Microphone and enable OmniVoice.'
|
||||
? t('capture.mic_hint_mac')
|
||||
: isWindows
|
||||
? 'Windows: open Settings → Privacy & security → Microphone and allow OmniVoice.'
|
||||
: 'Linux: check that your user is in the audio group and the WebView has mic access.';
|
||||
toast.error(`Microphone access denied. ${hint}`, { duration: 6000 });
|
||||
? t('capture.mic_hint_windows')
|
||||
: t('capture.mic_hint_linux');
|
||||
toast.error(t('capture.mic_denied_toast', { hint }), { duration: 6000 });
|
||||
setTrayRecording(false);
|
||||
setState('error');
|
||||
}
|
||||
@@ -320,7 +322,7 @@ export default function CaptureWidget({ onDismiss }) {
|
||||
await applyResult(data);
|
||||
} catch (err) {
|
||||
if (wsHadFinalRef.current) return;
|
||||
toast.error(`Transcription failed: ${err.message}`);
|
||||
toast.error(t('capture.transcription_failed', { message: err.message }));
|
||||
setState('error');
|
||||
setTranscript('');
|
||||
}
|
||||
@@ -348,19 +350,19 @@ export default function CaptureWidget({ onDismiss }) {
|
||||
let emoji = '';
|
||||
if (state === 'recording') {
|
||||
emoji = '🎙️';
|
||||
label = partialText || 'Listening…';
|
||||
label = partialText || t('capture.listening_label');
|
||||
} else if (state === 'transcribing') {
|
||||
emoji = '📝';
|
||||
label = partialText || 'Transcribing…';
|
||||
label = partialText || t('capture.transcribing_label');
|
||||
} else if (state === 'done' && transcript) {
|
||||
emoji = '✅';
|
||||
label = 'Pasted';
|
||||
label = t('capture.pasted');
|
||||
} else if (state === 'done' && !transcript) {
|
||||
emoji = '⚠️';
|
||||
label = 'No speech detected';
|
||||
label = t('capture.no_speech');
|
||||
} else if (state === 'error') {
|
||||
emoji = '❌';
|
||||
label = 'Mic access denied';
|
||||
label = t('capture.mic_denied');
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -389,7 +391,7 @@ export default function CaptureWidget({ onDismiss }) {
|
||||
|
||||
{/* Dismiss — only on done/error */}
|
||||
{(state === 'done' || state === 'error') && (
|
||||
<button className="capture-pill__dismiss" onClick={dismiss} aria-label="Dismiss">
|
||||
<button className="capture-pill__dismiss" onClick={dismiss} aria-label={t('common.dismiss')}>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { User, Mic, ChevronDown, Check, Shuffle, Volume2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import './CastingView.css';
|
||||
|
||||
/**
|
||||
@@ -26,6 +27,7 @@ export default function CastingView({
|
||||
onPreview,
|
||||
}) {
|
||||
const [openDropdown, setOpenDropdown] = useState(null);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const assign = useCallback((speakerId, profileId) => {
|
||||
const next = { ...assignments, [speakerId]: profileId };
|
||||
@@ -54,19 +56,19 @@ export default function CastingView({
|
||||
<div className="casting-view">
|
||||
<div className="casting-view__header">
|
||||
<h3 className="casting-view__title">
|
||||
<User size={14} /> Speaker Casting
|
||||
<User size={14} /> {t('casting.title')}
|
||||
</h3>
|
||||
<div className="casting-view__actions">
|
||||
<button
|
||||
className="casting-view__auto-btn"
|
||||
onClick={autoAssignAll}
|
||||
title="Auto-assign voices from extracted speaker clones"
|
||||
title={t('casting.auto_assign_title')}
|
||||
>
|
||||
<Shuffle size={12} /> Auto-cast
|
||||
<Shuffle size={12} /> {t('casting.auto_cast')}
|
||||
</button>
|
||||
{allAssigned && (
|
||||
<span className="casting-view__badge">
|
||||
<Check size={10} /> All cast
|
||||
<Check size={10} /> {t('casting.all_cast')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -82,7 +84,7 @@ export default function CastingView({
|
||||
if (match) autoName = match;
|
||||
}
|
||||
const assignedProfile = isAuto
|
||||
? { name: `🎤 From video (${autoName})`, type: 'clone' }
|
||||
? { name: t('casting.from_video', { name: autoName }), type: 'clone' }
|
||||
: profiles.find(p => p.id === currentAssignment);
|
||||
|
||||
return (
|
||||
@@ -98,7 +100,7 @@ export default function CastingView({
|
||||
<div className="casting-row__info">
|
||||
<span className="casting-row__name">{speaker.label || speaker.id}</span>
|
||||
<span className="casting-row__meta">
|
||||
{speaker.segments_count || 0} segments
|
||||
{t('casting.segments_count', { count: speaker.segments_count || 0 })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -119,7 +121,7 @@ export default function CastingView({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="casting-row__unassigned">Assign voice…</span>
|
||||
<span className="casting-row__unassigned">{t('casting.assign_voice')}</span>
|
||||
</>
|
||||
)}
|
||||
<ChevronDown size={12} />
|
||||
@@ -167,9 +169,9 @@ export default function CastingView({
|
||||
))}
|
||||
|
||||
{profiles.length === 0 && !autoClones[speaker.id] && (
|
||||
<div className="casting-dropdown__empty">
|
||||
No voice profiles saved yet.
|
||||
</div>
|
||||
<div className="casting-dropdown__empty">
|
||||
{t('casting.no_profiles')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -180,7 +182,7 @@ export default function CastingView({
|
||||
<button
|
||||
className="casting-row__preview"
|
||||
onClick={() => onPreview(currentAssignment)}
|
||||
title="Preview voice"
|
||||
title={t('casting.preview_voice')}
|
||||
>
|
||||
<Volume2 size={12} />
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { CheckCircle, ArrowRight, X, Sparkles, Languages, Mic } from 'lucide-react';
|
||||
import { Button } from '../ui';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import './Misc.css';
|
||||
|
||||
/**
|
||||
@@ -16,62 +17,50 @@ import './Misc.css';
|
||||
* pipeline directly from the banner's CTA (translate, generate, etc).
|
||||
*/
|
||||
|
||||
const STAGE_CONFIG = {
|
||||
asr: {
|
||||
icon: Mic,
|
||||
accent: '#b8bb26',
|
||||
title: 'Transcripts ready',
|
||||
cta: 'Translate',
|
||||
ctaIcon: Languages,
|
||||
hint: 'Fix any ASR errors now — tight diction saves TTS attempts later.',
|
||||
},
|
||||
translate: {
|
||||
icon: Languages,
|
||||
accent: '#83a598',
|
||||
title: 'Translations ready',
|
||||
cta: 'Generate dub',
|
||||
ctaIcon: Sparkles,
|
||||
hint: 'Skim the target text. Over-length lines get speed-boosted; you can also edit directly.',
|
||||
},
|
||||
done: {
|
||||
icon: CheckCircle,
|
||||
accent: '#8ec07c',
|
||||
title: 'Dub complete',
|
||||
cta: null,
|
||||
hint: 'Review timing and sync ratios. Tweak any line and hit "Regen changed" for a fast partial redo.',
|
||||
},
|
||||
const STAGE_ICONS = {
|
||||
asr: { icon: Mic, accent: '#b8bb26', ctaIcon: Languages },
|
||||
translate: { icon: Languages, accent: '#83a598', ctaIcon: Sparkles },
|
||||
done: { icon: CheckCircle, accent: '#8ec07c' },
|
||||
};
|
||||
|
||||
const STAGE_KEYS = {
|
||||
asr: { title: 'checkpoint.asr_title', cta: 'checkpoint.asr_cta', hint: 'checkpoint.asr_hint' },
|
||||
translate: { title: 'checkpoint.translate_title', cta: 'checkpoint.translate_cta', hint: 'checkpoint.translate_hint' },
|
||||
done: { title: 'checkpoint.done_title', cta: null, hint: 'checkpoint.done_hint' },
|
||||
};
|
||||
|
||||
export default function CheckpointBanner({ stage, count, onContinue, onDismiss, continueLoading }) {
|
||||
const cfg = STAGE_CONFIG[stage];
|
||||
if (!cfg) return null;
|
||||
const { t } = useTranslation();
|
||||
const icons = STAGE_ICONS[stage];
|
||||
const keys = STAGE_KEYS[stage];
|
||||
if (!icons || !keys) return null;
|
||||
|
||||
const Icon = cfg.icon;
|
||||
const CtaIcon = cfg.ctaIcon;
|
||||
const Icon = icons.icon;
|
||||
const CtaIcon = icons.ctaIcon;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="checkpoint-banner ckpt-banner"
|
||||
style={{ borderLeft: `2px solid ${cfg.accent}` }}
|
||||
style={{ borderLeft: `2px solid ${icons.accent}` }}
|
||||
role="status"
|
||||
>
|
||||
<Icon size={14} color={cfg.accent} className="ckpt-icon" />
|
||||
<Icon size={14} color={icons.accent} className="ckpt-icon" />
|
||||
<div className="ckpt-body">
|
||||
<div className="ckpt-head">
|
||||
<span className="ckpt-title">
|
||||
{cfg.title}
|
||||
{t(keys.title)}
|
||||
</span>
|
||||
{typeof count === 'number' && (
|
||||
<span className="ckpt-count">
|
||||
{count} segment{count === 1 ? '' : 's'}
|
||||
{t('checkpoint.segment', { count })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="ckpt-hint">
|
||||
{cfg.hint}
|
||||
{t(keys.hint)}
|
||||
</span>
|
||||
</div>
|
||||
{cfg.cta && onContinue && (
|
||||
{keys.cta && onContinue && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
@@ -80,7 +69,7 @@ export default function CheckpointBanner({ stage, count, onContinue, onDismiss,
|
||||
leading={CtaIcon ? <CtaIcon size={10} /> : null}
|
||||
trailing={<ArrowRight size={10} />}
|
||||
>
|
||||
{cfg.cta}
|
||||
{t(keys.cta)}
|
||||
</Button>
|
||||
)}
|
||||
{onDismiss && (
|
||||
@@ -88,7 +77,7 @@ export default function CheckpointBanner({ stage, count, onContinue, onDismiss,
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onDismiss}
|
||||
title="Dismiss — won't reappear for this stage until reload"
|
||||
title={t('checkpoint.dismiss_title')}
|
||||
iconSize="sm"
|
||||
>
|
||||
<X size={10} />
|
||||
|
||||
@@ -4,6 +4,7 @@ import { toast } from 'react-hot-toast';
|
||||
import { PRESETS } from '../utils/constants';
|
||||
import { generateSpeech } from '../api/generate';
|
||||
import { Button, Panel, Field, Textarea, Select } from '../ui';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import './CompareModal.css';
|
||||
|
||||
export default function CompareModal({
|
||||
@@ -20,6 +21,7 @@ export default function CompareModal({
|
||||
fileToMediaUrl, loadHistory,
|
||||
}) {
|
||||
const drawerRef = useRef(null);
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -43,7 +45,7 @@ export default function CompareModal({
|
||||
setCompareResultB(null);
|
||||
|
||||
const generateVoice = async (voiceId) => {
|
||||
setCompareProgress('Preparing voice...');
|
||||
setCompareProgress(t('compare.preparing_voice'));
|
||||
const formData = new FormData();
|
||||
formData.append('text', compareText);
|
||||
let fin_prof = voiceId;
|
||||
@@ -72,17 +74,17 @@ export default function CompareModal({
|
||||
};
|
||||
|
||||
try {
|
||||
setCompareProgress('Generating Voice A...');
|
||||
setCompareProgress(t('compare.generating_voice_a'));
|
||||
const audioA = await generateVoice(compareVoiceA);
|
||||
setCompareResultA(audioA);
|
||||
setCompareProgress('Generating Voice B...');
|
||||
setCompareProgress(t('compare.generating_voice_b'));
|
||||
const audioB = await generateVoice(compareVoiceB);
|
||||
setCompareResultB(audioB);
|
||||
setCompareProgress('');
|
||||
toast.success('Comparison complete!');
|
||||
toast.success(t('compare.comparison_complete'));
|
||||
loadHistory();
|
||||
} catch (err) {
|
||||
toast.error('Play failed: ' + err.message);
|
||||
toast.error(t('compare.play_failed', { message: err.message }));
|
||||
setCompareProgress('');
|
||||
} finally {
|
||||
setIsComparing(false);
|
||||
@@ -94,18 +96,18 @@ export default function CompareModal({
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="compare-drawer" role="dialog" aria-modal="false" aria-label="A/B Voice Comparison">
|
||||
<div className="compare-drawer" role="dialog" aria-modal="false" aria-label={t('compare.title')}>
|
||||
<div className="compare-drawer__sheet" ref={drawerRef}>
|
||||
<header className="compare-drawer__head">
|
||||
<span className="compare-drawer__handle" aria-hidden="true" />
|
||||
<span className="compare-drawer__title">
|
||||
<Scale size={14} /> A/B Voice Comparison
|
||||
<Scale size={14} /> {t('compare.title')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="compare-drawer__close"
|
||||
onClick={onClose}
|
||||
aria-label="Close comparison"
|
||||
aria-label={t('compare.close')}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
@@ -113,10 +115,10 @@ export default function CompareModal({
|
||||
|
||||
<div className="compare-drawer__body">
|
||||
<p className="ui-compare__desc">
|
||||
Compare two voices side by side to make casting decisions. App stays interactive behind.
|
||||
{t('compare.desc')}
|
||||
</p>
|
||||
|
||||
<Field label="Test phrase">
|
||||
<Field label={t('compare.test_phrase')}>
|
||||
<Textarea
|
||||
value={compareText}
|
||||
onChange={e => setCompareText(e.target.value)}
|
||||
@@ -128,7 +130,7 @@ export default function CompareModal({
|
||||
<div className="ui-compare__grid">
|
||||
<CompareSide
|
||||
accent="var(--color-brand)"
|
||||
label="Voice A"
|
||||
label={t('compare.voice_a')}
|
||||
profiles={profiles}
|
||||
value={compareVoiceA}
|
||||
onChange={setCompareVoiceA}
|
||||
@@ -136,7 +138,7 @@ export default function CompareModal({
|
||||
/>
|
||||
<CompareSide
|
||||
accent="var(--color-success)"
|
||||
label="Voice B"
|
||||
label={t('compare.voice_b')}
|
||||
profiles={profiles}
|
||||
value={compareVoiceB}
|
||||
onChange={setCompareVoiceB}
|
||||
@@ -146,7 +148,7 @@ export default function CompareModal({
|
||||
</div>
|
||||
|
||||
<footer className="compare-drawer__foot">
|
||||
<Button variant="ghost" onClick={onClose}>Close</Button>
|
||||
<Button variant="ghost" onClick={onClose}>{t('compare.close_btn')}</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
loading={isComparing}
|
||||
@@ -154,7 +156,7 @@ export default function CompareModal({
|
||||
onClick={runCompare}
|
||||
leading={!isComparing && <Play size={12} />}
|
||||
>
|
||||
{isComparing ? (compareProgress || 'Comparing…') : 'Compare'}
|
||||
{isComparing ? (compareProgress || t('compare.comparing')) : t('compare.compare_btn')}
|
||||
</Button>
|
||||
</footer>
|
||||
</div>
|
||||
@@ -163,6 +165,7 @@ export default function CompareModal({
|
||||
}
|
||||
|
||||
function CompareSide({ accent, label, profiles, value, onChange, audio }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Panel variant="flat" padding="sm">
|
||||
<h3 className="ui-compare__head" style={{ color: accent }}>
|
||||
@@ -170,15 +173,15 @@ function CompareSide({ accent, label, profiles, value, onChange, audio }) {
|
||||
</h3>
|
||||
<Field>
|
||||
<Select value={value} onChange={e => onChange(e.target.value)}>
|
||||
<option value="">— Select voice —</option>
|
||||
<option value="">{t('compare.select_voice')}</option>
|
||||
{profiles.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
{PRESETS.map(p => <option key={p.id} value={`preset:${p.id}`}>{p.name} (Preset)</option>)}
|
||||
{PRESETS.map(p => <option key={p.id} value={`preset:${p.id}`}>{p.name} {t('compare.preset_suffix')}</option>)}
|
||||
</Select>
|
||||
</Field>
|
||||
{audio ? (
|
||||
<audio src={audio} controls className="ui-compare__audio" />
|
||||
) : (
|
||||
<div className="ui-compare__audio-empty">No audio yet</div>
|
||||
<div className="ui-compare__audio-empty">{t('compare.no_audio')}</div>
|
||||
)}
|
||||
</Panel>
|
||||
);
|
||||
|
||||
@@ -30,21 +30,21 @@ import './DictationDemo.css';
|
||||
const SCRIPTS = [
|
||||
{
|
||||
id: 'en_conversational',
|
||||
label: 'Conversational',
|
||||
labelKey: 'demo.script_conversational',
|
||||
language: 'English',
|
||||
text: 'Schedule a meeting with Pat for Tuesday at three PM and remind me to bring the quarterly report.',
|
||||
wav: '/demo_audio/dictation/en_conversational.wav',
|
||||
},
|
||||
{
|
||||
id: 'en_technical',
|
||||
label: 'Technical vocabulary',
|
||||
labelKey: 'demo.script_technical',
|
||||
language: 'English',
|
||||
text: 'Patch the WebGPU shader in renderer.tsx, then bump pnpm to nine point fifteen and rerun the Vitest suite.',
|
||||
wav: '/demo_audio/dictation/en_technical.wav',
|
||||
},
|
||||
{
|
||||
id: 'fr_reservation',
|
||||
label: 'Non-English (French)',
|
||||
labelKey: 'demo.script_french',
|
||||
language: 'French',
|
||||
text: 'Bonjour, je voudrais réserver une table pour deux personnes à vingt heures.',
|
||||
wav: '/demo_audio/dictation/fr_reservation.wav',
|
||||
@@ -194,8 +194,12 @@ export default function DictationDemo({ embedded = false }) {
|
||||
}
|
||||
})();
|
||||
|
||||
// No bundled samples on disk → don't render a demo that can't work.
|
||||
if (assetsAvailable === false) return null;
|
||||
// The hotkey card always has something real to teach (the registered
|
||||
// shortcut + live press-to-verify) — only the replayable script cards
|
||||
// depend on the bundled WAVs, which installs don't always ship. Hiding
|
||||
// the whole panel left the wizard's "Try dictation" act completely
|
||||
// blank on every such install (#119/#124 follow-up, refined).
|
||||
const showScripts = assetsAvailable !== false;
|
||||
|
||||
return (
|
||||
<section className={`dictation-demo ${embedded ? 'dictation-demo--embedded' : ''}`}>
|
||||
@@ -206,10 +210,16 @@ export default function DictationDemo({ embedded = false }) {
|
||||
{statusBadge}
|
||||
</header>
|
||||
|
||||
<p className="dictation-demo__lede">{t('demo.dictation_lede')}</p>
|
||||
<p className="dictation-demo__lede">
|
||||
{showScripts
|
||||
? t('demo.dictation_lede')
|
||||
: t('demo.dictation_lede_hotkey_only',
|
||||
'Hold the shortcut above anywhere on your desktop, speak, release — the text lands in whatever app has focus. Press it now to verify it works.')}
|
||||
</p>
|
||||
|
||||
<audio ref={audioRef} onEnded={() => setPlayingId(null)} preload="none" />
|
||||
|
||||
{showScripts && (
|
||||
<div className="dictation-demo__scripts">
|
||||
{SCRIPTS.map((s) => {
|
||||
const isPlaying = playingId === s.id;
|
||||
@@ -218,7 +228,7 @@ export default function DictationDemo({ embedded = false }) {
|
||||
<div key={s.id} className="dictation-demo__card">
|
||||
<div className="dictation-demo__card-head">
|
||||
<span className="dictation-demo__lang">{s.language}</span>
|
||||
<span className="dictation-demo__card-label">{s.label}</span>
|
||||
<span className="dictation-demo__card-label">{t(s.labelKey)}</span>
|
||||
</div>
|
||||
<blockquote className="dictation-demo__script">{s.text}</blockquote>
|
||||
<div className="dictation-demo__card-actions">
|
||||
@@ -227,7 +237,7 @@ export default function DictationDemo({ embedded = false }) {
|
||||
variant="subtle"
|
||||
onClick={() => togglePlay(s)}
|
||||
leading={isPlaying ? <Pause size={11} /> : <Play size={11} />}
|
||||
aria-label={isPlaying ? `Pause ${s.label}` : `Hear ${s.label}`}
|
||||
aria-label={isPlaying ? t('demo.aria_pause', { label: t(s.labelKey) }) : t('demo.aria_hear', { label: t(s.labelKey) })}
|
||||
>
|
||||
{isPlaying ? t('demo.dictation_stop') : t('demo.dictation_hear')}
|
||||
</Button>
|
||||
@@ -237,7 +247,7 @@ export default function DictationDemo({ embedded = false }) {
|
||||
onClick={() => replay(s)}
|
||||
loading={tx.state === 'loading'}
|
||||
leading={tx.state !== 'loading' && <Mic size={11} />}
|
||||
aria-label={`Replay ${s.label} through transcriber`}
|
||||
aria-label={t('demo.aria_replay', { label: t(s.labelKey) })}
|
||||
>
|
||||
{tx.state === 'loading' ? t('demo.dictation_transcribing') : t('demo.dictation_replay')}
|
||||
</Button>
|
||||
@@ -256,6 +266,7 @@ export default function DictationDemo({ embedded = false }) {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Sparkles, X } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Dialog, Button, Textarea, Field, Badge } from '../ui';
|
||||
import { apiPost } from '../api/client';
|
||||
import './Misc.css';
|
||||
@@ -17,6 +18,7 @@ import './Misc.css';
|
||||
* always the canonical input.
|
||||
*/
|
||||
export default function DirectionDialog({ open, seg, onSave, onClose }) {
|
||||
const { t } = useTranslation();
|
||||
const [text, setText] = useState('');
|
||||
const [preview, setPreview] = useState(null);
|
||||
const [parsing, setParsing] = useState(false);
|
||||
@@ -35,7 +37,7 @@ export default function DirectionDialog({ open, seg, onSave, onClose }) {
|
||||
try {
|
||||
setPreview(await apiPost('/tools/direction', { text }));
|
||||
} catch (e) {
|
||||
toast.error(`Preview failed: ${e.message}`);
|
||||
toast.error(t('direction.previewFailed', { message: e.message }));
|
||||
} finally {
|
||||
setParsing(false);
|
||||
}
|
||||
@@ -56,7 +58,7 @@ export default function DirectionDialog({ open, seg, onSave, onClose }) {
|
||||
open
|
||||
onClose={onClose}
|
||||
size="md"
|
||||
title={<><Sparkles size={14} /> Direction for segment #{seg?.id?.slice?.(0, 6) || ''}</>}
|
||||
title={<><Sparkles size={14} /> {t('direction.title', { id: seg?.id?.slice?.(0, 6) || '' })}</>}
|
||||
footer={
|
||||
<>
|
||||
{seg?.direction && (
|
||||
@@ -66,28 +68,26 @@ export default function DirectionDialog({ open, seg, onSave, onClose }) {
|
||||
leading={<X size={11} />}
|
||||
className="dir-clear-btn"
|
||||
>
|
||||
Clear
|
||||
{t('direction.clear')}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" onClick={onClose}>Cancel</Button>
|
||||
<Button variant="primary" onClick={save} loading={saving}>Save direction</Button>
|
||||
<Button variant="ghost" onClick={onClose}>{t('direction.cancel')}</Button>
|
||||
<Button variant="primary" onClick={save} loading={saving}>{t('direction.saveDirection')}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="direction-dialog__desc">
|
||||
Tell the pipeline how this line should feel. Plain English works — the system
|
||||
maps your words onto a stable taxonomy (energy / emotion / pace / intimacy / formality),
|
||||
then threads the taxonomy through Cinematic translation, TTS, and slot-fit.
|
||||
{t('direction.desc')}
|
||||
</p>
|
||||
|
||||
<Field label="Direction" hint={
|
||||
seg?.text ? <>Line: <em>"{seg.text.slice(0, 80)}{seg.text.length > 80 ? '…' : ''}"</em></> : null
|
||||
<Field label={t('direction.label')} hint={
|
||||
seg?.text ? <>{t('direction.lineHint', { text: seg.text.slice(0, 80) + (seg.text.length > 80 ? '…' : '') })}</> : null
|
||||
}>
|
||||
<Textarea
|
||||
rows={3}
|
||||
value={text}
|
||||
onChange={e => setText(e.target.value)}
|
||||
placeholder="e.g. urgent and surprised / warm, hopeful / whispered, intimate"
|
||||
placeholder={t('direction.placeholder')}
|
||||
autoFocus
|
||||
/>
|
||||
</Field>
|
||||
@@ -99,7 +99,7 @@ export default function DirectionDialog({ open, seg, onSave, onClose }) {
|
||||
loading={parsing}
|
||||
disabled={!text.trim()}
|
||||
>
|
||||
Preview parse
|
||||
{t('direction.previewParse')}
|
||||
</Button>
|
||||
{preview && (
|
||||
<Badge tone={preview.method === 'llm' ? 'violet' : 'neutral'} size="xs">
|
||||
@@ -111,19 +111,19 @@ export default function DirectionDialog({ open, seg, onSave, onClose }) {
|
||||
{preview && (
|
||||
<div className="direction-dialog__preview">
|
||||
<div>
|
||||
<strong>TTS instruct:</strong> <code>{preview.instruct_prompt || '— (nothing parsed)'}</code>
|
||||
<strong>{t('direction.ttsInstruct')}</strong> <code>{preview.instruct_prompt || t('direction.nothingParsed')}</code>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Translate hint:</strong> <em>{preview.translate_hint || '—'}</em>
|
||||
<strong>{t('direction.translateHint')}</strong> <em>{preview.translate_hint || '—'}</em>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Rate bias:</strong> <code>{preview.rate_bias?.toFixed?.(2)}</code>
|
||||
{preview.rate_bias > 1.05 && <> · <span className="dir-rate-up">speeds up</span></>}
|
||||
{preview.rate_bias < 0.95 && <> · <span className="dir-rate-down">slows down</span></>}
|
||||
<strong>{t('direction.rateBias')}</strong> <code>{preview.rate_bias?.toFixed?.(2)}</code>
|
||||
{preview.rate_bias > 1.05 && <> · <span className="dir-rate-up">{t('direction.speedsUp')}</span></>}
|
||||
{preview.rate_bias < 0.95 && <> · <span className="dir-rate-down">{t('direction.slowsDown')}</span></>}
|
||||
</div>
|
||||
{Object.keys(preview.tokens || {}).length > 0 && (
|
||||
<details>
|
||||
<summary>taxonomy tokens</summary>
|
||||
<summary>{t('direction.taxonomyTokens')}</summary>
|
||||
<pre>{JSON.stringify(preview.tokens, null, 2)}</pre>
|
||||
</details>
|
||||
)}
|
||||
|
||||
@@ -75,32 +75,32 @@ function DubSegmentRow({
|
||||
let fitBadge = null;
|
||||
if (fitStatus) {
|
||||
if (fitStatus.status === 'fits') {
|
||||
fitBadge = { color: '#b8bb26', Icon: CheckCircle, label: 'Fits', title: 'Natural-rate audio fit inside the slot.' };
|
||||
fitBadge = { color: '#b8bb26', Icon: CheckCircle, label: t('segment.fit_fits'), title: t('segment.fit_fits_title') };
|
||||
} else if (fitStatus.status === 'overflows') {
|
||||
const over = fitStatus.overflow_s || 0;
|
||||
fitBadge = {
|
||||
color: over > 0.5 ? '#fb4934' : '#fabd2f',
|
||||
Icon: AlertCircle,
|
||||
label: `Overflows +${over.toFixed(2)}s`,
|
||||
title: `Translated text was longer than the original slot by ${over.toFixed(2)}s. The audio was hard-trimmed; shorten the text or switch Timing to "Stretch Video".`,
|
||||
label: t('segment.fit_overflows', { seconds: over.toFixed(2) }),
|
||||
title: t('segment.fit_overflows_title', { seconds: over.toFixed(2) }),
|
||||
};
|
||||
} else if (fitStatus.status === 'video_stretched') {
|
||||
const r = fitStatus.stretch_ratio || 1.0;
|
||||
fitBadge = {
|
||||
color: r > 1.18 ? '#fb4934' : r > 1.05 ? '#fabd2f' : '#83a598',
|
||||
Icon: Circle,
|
||||
label: `Video ${r.toFixed(2)}×`,
|
||||
title: `Stretch Video mode: this segment's video was slowed to ${r.toFixed(2)}× to fit the natural dub audio.`,
|
||||
label: t('segment.fit_stretched', { ratio: r.toFixed(2) }),
|
||||
title: t('segment.fit_stretched_title', { ratio: r.toFixed(2) }),
|
||||
};
|
||||
}
|
||||
} else if (seg.sync_ratio !== undefined) {
|
||||
const r = seg.sync_ratio;
|
||||
if (r > 1.25) {
|
||||
fitBadge = { color: '#fb4934', Icon: AlertCircle, label: `${Math.round(r * 100)}%`, title: `TTS audio is ${Math.round(r * 100)}% of the slot — heavily compressed.` };
|
||||
fitBadge = { color: '#fb4934', Icon: AlertCircle, label: `${Math.round(r * 100)}%`, title: t('segment.fit_compressed_title', { pct: Math.round(r * 100) }) };
|
||||
} else if (r >= 0.95 && r <= 1.05) {
|
||||
fitBadge = { color: '#b8bb26', Icon: CheckCircle, label: 'Fits', title: 'Audio fit inside the slot.' };
|
||||
fitBadge = { color: '#b8bb26', Icon: CheckCircle, label: t('segment.fit_fits'), title: t('segment.fit_audio_title') };
|
||||
} else {
|
||||
fitBadge = { color: '#fabd2f', Icon: Circle, label: `${Math.round(r * 100)}%`, title: `TTS audio is ${Math.round(r * 100)}% of the slot.` };
|
||||
fitBadge = { color: '#fabd2f', Icon: Circle, label: `${Math.round(r * 100)}%`, title: t('segment.fit_ratio_title', { pct: Math.round(r * 100) }) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,7 +152,7 @@ function DubSegmentRow({
|
||||
defaultValue={formatTime(seg.start)}
|
||||
key={`start-${seg.id}-${seg.start}`}
|
||||
disabled={disabled}
|
||||
title="Click to edit start time (m:ss.s). Enter to commit, Esc to cancel."
|
||||
title={t('segment.time_edit_title')}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') e.target.blur();
|
||||
|
||||
@@ -84,7 +84,7 @@ export default function DubbingDemo({ onDismiss }) {
|
||||
if (!manifest) {
|
||||
return (
|
||||
<div className="dubbing-demo dubbing-demo--loading">
|
||||
Loading dubbing demo…
|
||||
{t('demo.dubbing_loading')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -115,7 +115,7 @@ export default function DubbingDemo({ onDismiss }) {
|
||||
type="button"
|
||||
className="dubbing-demo__dismiss"
|
||||
onClick={onDismiss}
|
||||
aria-label="Dismiss dubbing demo"
|
||||
aria-label={t('demo.dubbing_dismiss')}
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
@@ -125,7 +125,7 @@ export default function DubbingDemo({ onDismiss }) {
|
||||
|
||||
<div className="dubbing-demo__players">
|
||||
<div className="dubbing-demo__pane">
|
||||
<div className="dubbing-demo__pane-label">{source.label} <span>· original</span></div>
|
||||
<div className="dubbing-demo__pane-label">{source.label} <span>· {t('demo.original_tag')}</span></div>
|
||||
<video
|
||||
ref={sourceRef}
|
||||
src={`${base}/${source.video}`}
|
||||
@@ -137,7 +137,7 @@ export default function DubbingDemo({ onDismiss }) {
|
||||
</div>
|
||||
<div className="dubbing-demo__pane">
|
||||
<div className="dubbing-demo__pane-label">
|
||||
{dubbed.label} <span>· dubbed</span>
|
||||
{dubbed.label} <span>· {t('demo.dubbed_tag')}</span>
|
||||
</div>
|
||||
<video
|
||||
ref={dubbedRef}
|
||||
|
||||
@@ -38,8 +38,25 @@
|
||||
|
||||
.engine-matrix__table {
|
||||
width: 100%;
|
||||
/* Responsive: the fixed-width columns (status/gpu/isolation/actions) +
|
||||
the flexible name column need ~840px. On a narrow Settings pane they used
|
||||
to collapse and OVERLAP (name text under the badges). Instead keep the
|
||||
table's shape and let it scroll horizontally — the data-table treatment
|
||||
used elsewhere — so every column stays legible at any width. */
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* Header + body share one min-width so columns stay aligned while scrolling. */
|
||||
.engine-matrix__table .ui-table-header,
|
||||
.engine-matrix__body {
|
||||
min-width: 840px;
|
||||
}
|
||||
|
||||
/* Fixed-width cells must not shrink below their column width (that shrink was
|
||||
what caused the overlap); the name column keeps a sane floor and still grows. */
|
||||
.engine-matrix__cell { flex-shrink: 0; }
|
||||
.engine-matrix__cell--name { min-width: 200px; }
|
||||
|
||||
.engine-matrix__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Cpu, Mic, MessageSquare, Activity, AlertTriangle, CheckCircle2, RefreshCw, Layers } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { toastErrorWithReport } from '../utils/errorToast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { listEngines, getEngineHealth } from '../api/engines';
|
||||
import { Badge, Button, Segmented, Table } from '../ui';
|
||||
import SupertonicLicenseDialog from './SupertonicLicenseDialog';
|
||||
@@ -75,14 +76,6 @@ const GPU_LABEL = {
|
||||
|
||||
const TEST_COOLDOWN_MS = 5000;
|
||||
|
||||
const COLUMNS = [
|
||||
{ key: 'name', label: 'Engine', flex: 3 },
|
||||
{ key: 'status', label: 'Install state', width: 130, align: 'center' },
|
||||
{ key: 'gpu', label: 'GPU compat', width: 170, align: 'left' },
|
||||
{ key: 'isolation', label: 'Isolation', width: 110, align: 'center' },
|
||||
{ key: 'action', label: 'Actions', width: 220, align: 'right' },
|
||||
];
|
||||
|
||||
/** Subset of the unified engine entry the matrix actually reads. */
|
||||
function normalizeEntry(entry) {
|
||||
return {
|
||||
@@ -108,6 +101,7 @@ export default function EngineCompatibilityMatrix({
|
||||
apiListEngines = listEngines,
|
||||
apiGetEngineHealth = getEngineHealth,
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
@@ -132,11 +126,11 @@ export default function EngineCompatibilityMatrix({
|
||||
} catch (e) {
|
||||
const msg = e?.message || String(e);
|
||||
setError(msg);
|
||||
toast.error(`Failed to load engines: ${msg}`);
|
||||
toastErrorWithReport(t('engines.loadFailed', { message: msg }), e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [apiListEngines]);
|
||||
}, [apiListEngines, t]);
|
||||
|
||||
useEffect(() => { reload(); }, [reload]);
|
||||
|
||||
@@ -188,19 +182,27 @@ export default function EngineCompatibilityMatrix({
|
||||
}
|
||||
}, [apiGetEngineHealth, healthByEngine]);
|
||||
|
||||
const COLUMNS = [
|
||||
{ key: 'name', label: t('engines.matrixTitle').split(' ')[0] || 'Engine', flex: 3 },
|
||||
{ key: 'status', label: t('engines.status'), width: 130, align: 'center' },
|
||||
{ key: 'gpu', label: 'GPU compat', width: 170, align: 'left' },
|
||||
{ key: 'isolation', label: 'Isolation', width: 110, align: 'center' },
|
||||
{ key: 'action', label: 'Actions', width: 220, align: 'right' },
|
||||
];
|
||||
|
||||
if (loading && !data) {
|
||||
return (
|
||||
<section className="engine-matrix engine-matrix--loading" aria-busy="true">
|
||||
<span className="engine-matrix__muted">Loading engines…</span>
|
||||
<span className="engine-matrix__muted">{t('engines.loading')}</span>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
if (error && !data) {
|
||||
return (
|
||||
<section className="engine-matrix engine-matrix--error" role="alert">
|
||||
<AlertTriangle size={14} /> Could not load engines: {error}
|
||||
<AlertTriangle size={14} /> {t('engines.couldNotLoad', { message: error })}
|
||||
<Button size="sm" variant="subtle" onClick={reload} leading={<RefreshCw size={11} />}>
|
||||
Retry
|
||||
{t('engines.retry')}
|
||||
</Button>
|
||||
</section>
|
||||
);
|
||||
@@ -213,7 +215,7 @@ export default function EngineCompatibilityMatrix({
|
||||
<section className="engine-matrix">
|
||||
<header className="engine-matrix__head">
|
||||
<h3 className="engine-matrix__title">
|
||||
<Layers size={14} /> Engine Compatibility Matrix
|
||||
<Layers size={14} /> {t('engines.matrixTitle')}
|
||||
</h3>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -222,7 +224,7 @@ export default function EngineCompatibilityMatrix({
|
||||
loading={loading}
|
||||
leading={<RefreshCw size={11} />}
|
||||
>
|
||||
Refresh
|
||||
{t('engines.refresh')}
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
@@ -233,7 +235,7 @@ export default function EngineCompatibilityMatrix({
|
||||
onChange={setActiveFamily}
|
||||
items={families.map((f) => ({
|
||||
value: f,
|
||||
title: `Active ${FAMILY_META[f].label}: ${data[f].active}`,
|
||||
title: t('engines.activeEngine', { family: FAMILY_META[f].label, engine: data[f].active }),
|
||||
label: (
|
||||
<span className="engine-matrix__tab-label">
|
||||
<span className="engine-matrix__tab-family">{FAMILY_META[f].label}</span>
|
||||
@@ -244,7 +246,7 @@ export default function EngineCompatibilityMatrix({
|
||||
/>
|
||||
)}
|
||||
|
||||
<Table className="engine-matrix__table" role="table" aria-label={`${activeFamily} engine compatibility`}>
|
||||
<Table className="engine-matrix__table" role="table" aria-label={t('engines.engineCompatLabel', { family: activeFamily })}>
|
||||
<Table.Header columns={COLUMNS} />
|
||||
<div className="engine-matrix__body" role="rowgroup">
|
||||
{backends.map((b) => {
|
||||
@@ -261,7 +263,7 @@ export default function EngineCompatibilityMatrix({
|
||||
<div role="cell" className="engine-matrix__cell engine-matrix__cell--name" style={{ flex: 3 }}>
|
||||
<span className="engine-matrix__name">
|
||||
{b.display_name}
|
||||
{isActive && <Badge tone="brand" size="xs">active</Badge>}
|
||||
{isActive && <Badge tone="brand" size="xs">{t('engines.active')}</Badge>}
|
||||
</span>
|
||||
<code className="engine-matrix__id">{b.id}</code>
|
||||
{/* For available rows, show install_hint inline (one line — usually
|
||||
@@ -275,7 +277,7 @@ export default function EngineCompatibilityMatrix({
|
||||
)}
|
||||
{!b.available && (b.reason || b.install_hint || b.last_error) && (
|
||||
<details className="engine-matrix__why">
|
||||
<summary className="engine-matrix__why-summary">Why unavailable?</summary>
|
||||
<summary className="engine-matrix__why-summary">{t('engines.whyUnavailable')}</summary>
|
||||
<div className="engine-matrix__why-body">
|
||||
{b.reason && (
|
||||
<span className="engine-matrix__reason">{b.reason}</span>
|
||||
@@ -285,7 +287,7 @@ export default function EngineCompatibilityMatrix({
|
||||
)}
|
||||
{b.last_error && b.last_error !== b.reason && (
|
||||
<span className="engine-matrix__last-error" data-testid="last-error">
|
||||
Last error: {b.last_error}
|
||||
{t('engines.lastError', { error: b.last_error })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -298,11 +300,11 @@ export default function EngineCompatibilityMatrix({
|
||||
role="cell"
|
||||
className="engine-matrix__cell engine-matrix__cell--center"
|
||||
style={{ width: 130 }}
|
||||
title={b.available ? 'Installed and ready' : (b.reason || 'Not installed')}
|
||||
title={b.available ? t('engines.installedAndReady') : (b.reason || t('engines.notInstalled'))}
|
||||
>
|
||||
{b.available
|
||||
? <Badge tone="success" size="xs"><CheckCircle2 size={10} /> Available</Badge>
|
||||
: <Badge tone="warn" size="xs"><AlertTriangle size={10} /> Unavailable</Badge>}
|
||||
? <Badge tone="success" size="xs"><CheckCircle2 size={10} /> {t('engines.available')}</Badge>
|
||||
: <Badge tone="warn" size="xs"><AlertTriangle size={10} /> {t('engines.unavailable')}</Badge>}
|
||||
</div>
|
||||
|
||||
{/* GPU compat chips */}
|
||||
@@ -322,8 +324,8 @@ export default function EngineCompatibilityMatrix({
|
||||
className="engine-matrix__cell engine-matrix__cell--center"
|
||||
style={{ width: 110 }}
|
||||
title={b.isolation_mode === 'subprocess'
|
||||
? 'Runs in its own subprocess + venv'
|
||||
: 'Runs in the OmniVoice Python process'}
|
||||
? t('engines.subprocessTitle')
|
||||
: t('engines.inProcessTitle')}
|
||||
>
|
||||
<Badge tone={ISOLATION_TONE[b.isolation_mode] || 'neutral'} size="xs">
|
||||
{b.isolation_mode}
|
||||
@@ -350,7 +352,7 @@ export default function EngineCompatibilityMatrix({
|
||||
leading={!health?.inflight && <Activity size={11} />}
|
||||
aria-label={`Test ${b.display_name}`}
|
||||
>
|
||||
{health?.inflight ? 'Testing…' : 'Test engine'}
|
||||
{health?.inflight ? t('engines.testing') : t('engines.testEngine')}
|
||||
</Button>
|
||||
)}
|
||||
{!b.available && (
|
||||
@@ -363,7 +365,7 @@ export default function EngineCompatibilityMatrix({
|
||||
leading={!health?.inflight && <RefreshCw size={11} />}
|
||||
aria-label={`Re-check ${b.display_name}`}
|
||||
>
|
||||
{health?.inflight ? 'Re-checking…' : 'Re-check'}
|
||||
{health?.inflight ? t('engines.rechecking') : t('engines.recheck')}
|
||||
</Button>
|
||||
)}
|
||||
{health && !health.inflight && (
|
||||
@@ -373,8 +375,8 @@ export default function EngineCompatibilityMatrix({
|
||||
title={health.message}
|
||||
>
|
||||
{health.ok
|
||||
? `${health.latency_ms} ms`
|
||||
: `failed`}
|
||||
? t('engines.latencyMs', { ms: health.latency_ms })
|
||||
: t('engines.failed')}
|
||||
</span>
|
||||
)}
|
||||
{onSelect && b.available && !isActive && (
|
||||
@@ -384,7 +386,7 @@ export default function EngineCompatibilityMatrix({
|
||||
onClick={() => onSelect(activeFamily, b.id)}
|
||||
aria-label={`Use ${b.display_name}`}
|
||||
>
|
||||
Use
|
||||
{t('engines.use')}
|
||||
</Button>
|
||||
)}
|
||||
{/* TTS-05: license-acceptance entry point. Surfaced when
|
||||
@@ -401,7 +403,7 @@ export default function EngineCompatibilityMatrix({
|
||||
onClick={() => setLicenseDialogFor(b.id)}
|
||||
aria-label={`Review and accept ${b.display_name} license`}
|
||||
>
|
||||
Accept license
|
||||
{t('engines.acceptLicense')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -410,7 +412,7 @@ export default function EngineCompatibilityMatrix({
|
||||
})}
|
||||
{backends.length === 0 && (
|
||||
<div className="engine-matrix__empty" role="row">
|
||||
<span role="cell">No backends registered.</span>
|
||||
<span role="cell">{t('engines.noBackends')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import React from 'react';
|
||||
import { AlertCircle, BookOpen, RefreshCw } from 'lucide-react';
|
||||
import { AlertCircle, BookOpen, Bug, RefreshCw, Search } from 'lucide-react';
|
||||
import i18next from 'i18next';
|
||||
import { classifyError, openDocsFor } from '../utils/errorDocsMap';
|
||||
import { openExternal } from '../api/external';
|
||||
import { buildBugReportUrl, buildIssueSearchUrl } from '../utils/bugReport';
|
||||
import './WaveformErrorBoundary.css';
|
||||
|
||||
export default class ErrorBoundary extends React.Component {
|
||||
@@ -35,6 +38,26 @@ export default class ErrorBoundary extends React.Component {
|
||||
}
|
||||
};
|
||||
|
||||
report = async () => {
|
||||
// Prefilled GitHub Issues URL with the scrubbed error attached — the
|
||||
// user reviews everything on github.com before anything is submitted.
|
||||
try {
|
||||
await openExternal(await buildBugReportUrl({ error: this.state.error }));
|
||||
} catch (err) {
|
||||
console.warn('[ErrorBoundary] report failed', err);
|
||||
}
|
||||
};
|
||||
|
||||
searchIssues = async () => {
|
||||
// "Has someone already hit this?" — issue search in the browser, so a
|
||||
// duplicate gets a 👍 on the existing thread instead of a new report.
|
||||
try {
|
||||
await openExternal(buildIssueSearchUrl(this.state.error));
|
||||
} catch (err) {
|
||||
console.warn('[ErrorBoundary] issue search failed', err);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
if (!this.state.error) return this.props.children;
|
||||
|
||||
@@ -44,10 +67,10 @@ export default class ErrorBoundary extends React.Component {
|
||||
<div className="errbnd-card">
|
||||
<AlertCircle size={32} color="var(--chrome-severity-err)" className="errbnd-icon" />
|
||||
<h2 className="errbnd-title">
|
||||
This tab hit a snag.
|
||||
{i18next.t('errors.title')}
|
||||
</h2>
|
||||
<p className="errbnd-desc">
|
||||
Don't worry — the rest of the app still works. You can switch tabs, or try again below.
|
||||
{i18next.t('errors.desc')}
|
||||
</p>
|
||||
<pre className="errbnd-trace">{msg}</pre>
|
||||
<div className="errbnd-actions">
|
||||
@@ -55,15 +78,31 @@ export default class ErrorBoundary extends React.Component {
|
||||
onClick={this.reset}
|
||||
className="btn-primary errbnd-retry"
|
||||
>
|
||||
<RefreshCw size={12} /> Try again
|
||||
<RefreshCw size={12} /> {i18next.t('errors.tryAgain')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={this.openDocs}
|
||||
className="btn-secondary errbnd-docs"
|
||||
title="Open the docs page for this error in your browser"
|
||||
title={i18next.t('errors.openDocs')}
|
||||
>
|
||||
<BookOpen size={12} /> Open docs for this error
|
||||
<BookOpen size={12} /> {i18next.t('errors.openDocs')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={this.searchIssues}
|
||||
className="btn-secondary errbnd-search"
|
||||
title={i18next.t('errors.searchIssues')}
|
||||
>
|
||||
<Search size={12} /> {i18next.t('errors.searchIssues')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={this.report}
|
||||
className="btn-secondary errbnd-report"
|
||||
title={i18next.t('reportBug.title')}
|
||||
>
|
||||
<Bug size={12} /> {i18next.t('errors.report')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,936 @@
|
||||
/* First-run install setup — "studio console".
|
||||
*
|
||||
* Desktop-first: a wide deck of rack-unit panels floating directly on an
|
||||
* atmospheric backdrop (no outer chassis box). Engraved mono labels, serif
|
||||
* masthead, a breathing waveform, LED capacity meters for the disk gate,
|
||||
* LED option cards, and an "armed" install button.
|
||||
*
|
||||
* Constraints honored:
|
||||
* - every font/asset is bundled (first runs may be on restricted networks)
|
||||
* - all motion is transform/opacity only and respects reduced-motion
|
||||
* - colors derive from the app's chrome tokens so themes stay coherent
|
||||
*/
|
||||
|
||||
.frs {
|
||||
--frs-accent: var(--chrome-accent, #e8a3b4);
|
||||
--frs-ok: var(--chrome-severity-ok, #98971a);
|
||||
--frs-err: var(--chrome-severity-err, #d4554a);
|
||||
--frs-ink: var(--chrome-fg, #ece6dd);
|
||||
--frs-bg: var(--chrome-bg, #121013);
|
||||
--frs-line: color-mix(in srgb, var(--frs-ink) 11%, transparent);
|
||||
--frs-line-strong: color-mix(in srgb, var(--frs-ink) 20%, transparent);
|
||||
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
background: var(--frs-bg);
|
||||
color: var(--frs-ink);
|
||||
font-family: var(--font-sans, 'Inter Variable', system-ui, sans-serif);
|
||||
z-index: 9999;
|
||||
/* Extra top clearance: the native titlebar (GTK headerbar / macOS
|
||||
traffic lights / Windows controls) overlays the top of the window —
|
||||
content must start below it, never under it. */
|
||||
padding: 3.4rem 2.5rem 2rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* No backdrop decoration: the journey sits on a clean flat surface — corner
|
||||
glows and grain read as banding/noise artifacts on many panels. The empty
|
||||
.frs__atmo element is kept harmless for layout stability. */
|
||||
.frs__atmo { display: none; }
|
||||
|
||||
/* ── Deck: the whole console, borderless, wide ─────────────────────────── */
|
||||
|
||||
.frs__deck {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 1240px;
|
||||
margin: auto 0; /* vertical centering when content is short */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.frs__loading {
|
||||
margin: auto;
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* ── Entry choreography: everything rises in, staggered ────────────────── */
|
||||
|
||||
.frs-rise {
|
||||
animation: frs-rise 640ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
animation-delay: calc(var(--rise, 0) * 80ms);
|
||||
}
|
||||
|
||||
@keyframes frs-rise {
|
||||
from { opacity: 0; transform: translateY(14px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* ── Masthead ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.frs__mast {
|
||||
padding-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.frs__mast-row {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 2rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.frs__title {
|
||||
margin: 0;
|
||||
font-family: var(--font-serif, 'Source Serif 4 Variable', Georgia, serif);
|
||||
font-size: clamp(1.7rem, 3.2vw, 2.3rem);
|
||||
font-weight: 620;
|
||||
letter-spacing: -0.014em;
|
||||
line-height: 1.08;
|
||||
}
|
||||
|
||||
.frs__subtitle {
|
||||
margin: 0.5rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.5;
|
||||
opacity: 0.72;
|
||||
max-width: 62ch;
|
||||
}
|
||||
|
||||
.frs__mast-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 0.45rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.frs__mast-selects {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Mirrors hang under the region select they extend; the fields open as a
|
||||
right-aligned column so the masthead stays balanced. */
|
||||
.frs__advanced--mast { text-align: right; }
|
||||
.frs__advanced--mast .frs__mirror-fields {
|
||||
grid-template-columns: 1fr;
|
||||
min-width: 320px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* Serial: quiet engraved model/version text, no badge box. */
|
||||
.frs__plate {
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.14em;
|
||||
opacity: 0.6;
|
||||
font-variant-numeric: tabular-nums;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Waveform: a voice, breathing across the full width ────────────────── */
|
||||
|
||||
/* Whisper-quiet: a thin breathing trace, not a billboard. */
|
||||
.frs-wave {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 22px;
|
||||
overflow: hidden;
|
||||
mask-image: linear-gradient(90deg, transparent, #000 5%, #000 95%, transparent);
|
||||
-webkit-mask-image: linear-gradient(90deg, transparent, #000 5%, #000 95%, transparent);
|
||||
}
|
||||
|
||||
.frs-wave__bar {
|
||||
flex: 1 0 auto;
|
||||
width: 2px;
|
||||
height: 100%;
|
||||
border-radius: 1px;
|
||||
background: color-mix(in srgb, var(--frs-accent) 70%, transparent);
|
||||
transform: scaleY(var(--h, 0.4));
|
||||
transform-origin: center;
|
||||
opacity: 0.45;
|
||||
animation: frs-breathe 2.8s ease-in-out infinite alternate;
|
||||
animation-delay: var(--d, 0ms);
|
||||
}
|
||||
|
||||
@keyframes frs-breathe {
|
||||
from { transform: scaleY(calc(var(--h, 0.4) * 0.55)); opacity: 0.22; }
|
||||
to { transform: scaleY(var(--h, 0.4)); opacity: 0.65; }
|
||||
}
|
||||
|
||||
/* ── Wide grid: storage rail (left, 7fr) + decision rail (right, 5fr) ──── */
|
||||
|
||||
.frs__grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 7fr) minmax(0, 5fr);
|
||||
gap: 1.1rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.frs__col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ── Sections: no boxes — an engraved title rule and whitespace carry the
|
||||
structure. Borders appear only where state demands them. ─────────────── */
|
||||
|
||||
.frs-panel {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.frs-panel__title {
|
||||
margin: 0;
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.22em;
|
||||
opacity: 0.68;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
/* Engraved rule running out from the title. */
|
||||
.frs-panel__title::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, var(--frs-line-strong), transparent);
|
||||
}
|
||||
|
||||
/* ── LED option cards (mode / compute / channel) ───────────────────────── */
|
||||
|
||||
.frs__options {
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.frs__options--two { grid-template-columns: 1fr 1fr; }
|
||||
|
||||
.frs-opt {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
padding: 0.75rem 2rem 0.8rem 0.9rem;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
background: color-mix(in srgb, var(--frs-ink) 4%, transparent);
|
||||
border: none;
|
||||
border-radius: 9px;
|
||||
cursor: pointer;
|
||||
transition: background 140ms ease, transform 140ms ease, box-shadow 140ms ease;
|
||||
}
|
||||
|
||||
.frs-opt--compact { padding-top: 0.6rem; padding-bottom: 0.65rem; }
|
||||
|
||||
.frs-opt:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--frs-ink) 7%, transparent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.frs-opt.is-active {
|
||||
background: color-mix(in srgb, var(--frs-accent) 10%, transparent);
|
||||
}
|
||||
|
||||
.frs-opt:disabled { opacity: 0.42; cursor: not-allowed; }
|
||||
|
||||
.frs-opt__led {
|
||||
position: absolute;
|
||||
top: 0.75rem;
|
||||
right: 0.75rem;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--frs-ink) 14%, transparent);
|
||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.6);
|
||||
transition: background 160ms ease, box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.frs-opt.is-active .frs-opt__led {
|
||||
background: var(--frs-accent);
|
||||
box-shadow:
|
||||
0 0 6px 1px color-mix(in srgb, var(--frs-accent) 70%, transparent),
|
||||
inset 0 0 2px rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.frs-opt__head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.frs-opt__name { font-size: 0.82rem; font-weight: 650; letter-spacing: 0.01em; }
|
||||
|
||||
.frs-opt__badge {
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.56rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
color: color-mix(in srgb, var(--frs-ok) 85%, var(--frs-ink));
|
||||
background: color-mix(in srgb, var(--frs-ok) 14%, transparent);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Verbosity diet: the description unfolds (smoothly) only on the selected
|
||||
card — collapsed cards keep it as a tooltip. */
|
||||
.frs-opt__desc {
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.45;
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 260ms cubic-bezier(0.22, 1, 0.36, 1), opacity 260ms ease;
|
||||
}
|
||||
|
||||
.frs-opt.is-active .frs-opt__desc {
|
||||
max-height: 4.5em;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* ── Detected hardware readout (Compute panel) ─────────────────────────── */
|
||||
|
||||
.frs__hw {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.1rem 0.1rem 0.3rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.frs__hw-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
background: var(--frs-ok);
|
||||
box-shadow: 0 0 6px 1px color-mix(in srgb, var(--frs-ok) 60%, transparent);
|
||||
animation: frs-hw-pulse 2.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes frs-hw-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.45; }
|
||||
}
|
||||
|
||||
.frs__hw-label {
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.58rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
opacity: 0.65;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.frs__hw-value {
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.66rem;
|
||||
opacity: 0.85;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ── Storage rows + LED capacity meters ────────────────────────────────── */
|
||||
|
||||
.frs-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.6rem 0.8rem;
|
||||
border-radius: 9px;
|
||||
transition: background 160ms ease;
|
||||
}
|
||||
|
||||
.frs-row:hover { background: color-mix(in srgb, var(--frs-ink) 4%, transparent); }
|
||||
|
||||
/* Blocked: a red tint + edge bar — state, not another box. */
|
||||
.frs-row--blocked {
|
||||
background: color-mix(in srgb, var(--frs-err) 6%, transparent);
|
||||
box-shadow: inset 2px 0 0 color-mix(in srgb, var(--frs-err) 70%, transparent);
|
||||
}
|
||||
|
||||
.frs-row__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.12rem;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.frs-row__label { font-size: 0.8rem; font-weight: 620; }
|
||||
|
||||
.frs-row__path {
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.66rem;
|
||||
opacity: 0.65;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 52ch;
|
||||
direction: rtl; /* ellipsize the head — the tail of a path matters */
|
||||
text-align: left;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
.frs-row__gauge {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 0.3rem;
|
||||
min-width: 170px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.frs-row__gauge .frs-meter { width: 100%; }
|
||||
|
||||
/* One quiet line: "needs ~9 GB · 449 GB free". */
|
||||
.frs-row__readout {
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.64rem;
|
||||
opacity: 0.68;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.frs-row__readout.is-low {
|
||||
color: var(--frs-err);
|
||||
opacity: 1;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* The meter: segmented LEDs. Lit = consumed by the install, dim = headroom. */
|
||||
.frs-meter {
|
||||
position: relative;
|
||||
height: 10px;
|
||||
border-radius: 3px;
|
||||
background: color-mix(in srgb, var(--frs-ink) 7%, transparent);
|
||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.55);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.frs-meter__fill {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
border-radius: 3px 0 0 3px;
|
||||
background: linear-gradient(90deg,
|
||||
color-mix(in srgb, var(--frs-ok) 80%, var(--frs-ink)),
|
||||
var(--frs-accent));
|
||||
/* LED segmentation: 5px lit / 2px gap notches. */
|
||||
-webkit-mask-image: repeating-linear-gradient(90deg, #000 0 5px, transparent 5px 7px);
|
||||
mask-image: repeating-linear-gradient(90deg, #000 0 5px, transparent 5px 7px);
|
||||
transition: width 480ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.frs-meter--over .frs-meter__fill {
|
||||
background: var(--frs-err);
|
||||
animation: frs-alarm 1s steps(2, jump-none) infinite;
|
||||
}
|
||||
|
||||
@keyframes frs-alarm {
|
||||
to { opacity: 0.45; }
|
||||
}
|
||||
|
||||
/* ── Fields ────────────────────────────────────────────────────────────── */
|
||||
|
||||
.frs-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.28rem;
|
||||
font-size: 0.7rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.frs-field > span {
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.6rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.frs-select,
|
||||
.frs-input {
|
||||
background: color-mix(in srgb, var(--frs-ink) 8%, transparent);
|
||||
border: none;
|
||||
color: inherit;
|
||||
color-scheme: dark;
|
||||
font: inherit;
|
||||
font-size: 0.76rem;
|
||||
padding: 0.45rem 0.6rem;
|
||||
border-radius: 7px;
|
||||
min-width: 0;
|
||||
transition: background 140ms ease;
|
||||
}
|
||||
|
||||
.frs-select:hover,
|
||||
.frs-input:hover { background: color-mix(in srgb, var(--frs-ink) 12%, transparent); }
|
||||
|
||||
.frs-select:focus-visible,
|
||||
.frs-input:focus-visible,
|
||||
.frs-opt:focus-visible,
|
||||
.frs-btn:focus-visible {
|
||||
outline: 2px solid color-mix(in srgb, var(--frs-accent) 65%, transparent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.frs-select { cursor: pointer; }
|
||||
|
||||
.frs-select option {
|
||||
background: var(--frs-bg);
|
||||
color: var(--frs-ink);
|
||||
}
|
||||
|
||||
.frs-select--lang { font-size: 0.7rem; padding: 0.3rem 0.5rem; }
|
||||
|
||||
.frs-input::placeholder { opacity: 0.32; }
|
||||
|
||||
/* ── Advanced mirrors disclosure ───────────────────────────────────────── */
|
||||
|
||||
.frs__advanced { min-width: 0; }
|
||||
|
||||
.frs__advanced summary {
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.62rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
opacity: 0.65;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
padding: 0.15rem 0;
|
||||
list-style: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.frs__advanced summary::-webkit-details-marker { display: none; }
|
||||
|
||||
.frs__advanced summary::before {
|
||||
content: '▸';
|
||||
font-size: 0.55rem;
|
||||
transition: transform 140ms ease;
|
||||
}
|
||||
|
||||
.frs__advanced[open] summary::before { transform: rotate(90deg); }
|
||||
|
||||
.frs__advanced summary:hover { opacity: 0.85; }
|
||||
|
||||
.frs__mirror-fields {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 0.6rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* ── Footer: gate + armed button ───────────────────────────────────────── */
|
||||
|
||||
.frs__foot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
padding-top: 0.6rem;
|
||||
}
|
||||
|
||||
.frs__foot-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.frs__totals {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.72rem;
|
||||
opacity: 0.68;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.frs__totals-sep { opacity: 0.65; }
|
||||
|
||||
.frs__blocker {
|
||||
margin: 0;
|
||||
font-size: 0.74rem;
|
||||
color: var(--frs-err);
|
||||
}
|
||||
|
||||
.frs__error {
|
||||
margin: 0;
|
||||
padding: 0.5rem 0.7rem;
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.66rem;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: var(--frs-err);
|
||||
background: color-mix(in srgb, var(--frs-err) 8%, transparent);
|
||||
border-radius: 8px;
|
||||
box-shadow: inset 2px 0 0 color-mix(in srgb, var(--frs-err) 70%, transparent);
|
||||
}
|
||||
|
||||
/* ── Buttons ───────────────────────────────────────────────────────────── */
|
||||
|
||||
.frs-btn {
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 650;
|
||||
padding: 0.5rem 1.15rem;
|
||||
border-radius: 9px;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: background 140ms ease, border-color 140ms ease,
|
||||
opacity 140ms ease, box-shadow 240ms ease, transform 140ms ease;
|
||||
}
|
||||
|
||||
.frs-btn:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
|
||||
.frs-btn--primary {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: var(--frs-accent);
|
||||
color: var(--frs-bg);
|
||||
padding: 0.55rem 1.4rem;
|
||||
}
|
||||
|
||||
.frs-btn__led {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--frs-bg) 55%, transparent);
|
||||
transition: background 200ms ease, box-shadow 200ms ease;
|
||||
}
|
||||
|
||||
/* Armed: the button is live — LED lights, halo pulses. The single loudest
|
||||
element on screen, exactly when it becomes actionable. */
|
||||
.frs-btn--primary.is-armed .frs-btn__led {
|
||||
background: var(--frs-bg);
|
||||
box-shadow: 0 0 5px 1px color-mix(in srgb, var(--frs-bg) 60%, transparent);
|
||||
}
|
||||
|
||||
.frs-btn--primary.is-armed:hover {
|
||||
transform: translateY(-1px);
|
||||
background: color-mix(in srgb, var(--frs-accent) 88%, white);
|
||||
}
|
||||
|
||||
/* Quiet picker: reads as a text action until pointed at. */
|
||||
.frs-btn--quiet {
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
opacity: 0.65;
|
||||
font-weight: 500;
|
||||
padding: 0.34rem 0.6rem;
|
||||
font-size: 0.68rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.frs-btn--quiet:hover:not(:disabled) {
|
||||
opacity: 1;
|
||||
background: color-mix(in srgb, var(--frs-ink) 9%, transparent);
|
||||
}
|
||||
|
||||
/* ── Reduced motion: hold every frame still ────────────────────────────── */
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.frs-rise { animation: none; }
|
||||
.frs-wave__bar { animation: none; }
|
||||
.frs-meter--over .frs-meter__fill { animation: none; }
|
||||
.frs__hw-dot { animation: none; }
|
||||
.frs-meter__fill { transition: none; }
|
||||
}
|
||||
|
||||
/* ── Responsive: deck collapses gracefully ─────────────────────────────── */
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.frs { padding: 1.5rem; }
|
||||
.frs__grid { grid-template-columns: 1fr; }
|
||||
.frs__mirror-fields { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.frs { padding: 1rem; }
|
||||
.frs__mast-row { flex-direction: column; align-items: flex-start; gap: 0.8rem; }
|
||||
.frs__options--two { grid-template-columns: 1fr; }
|
||||
.frs-row { flex-wrap: wrap; }
|
||||
.frs-row__gauge { width: 100%; }
|
||||
.frs__foot-row { flex-direction: column; align-items: stretch; }
|
||||
.frs-btn--primary { justify-content: center; }
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════════════
|
||||
Shared first-run journey pieces — used by the installing screen
|
||||
(BootstrapSplash) and the model wizard (SetupWizard) so the whole
|
||||
setup → install → models flow speaks one visual language.
|
||||
═══════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* Focused acts (installing) read better narrow. */
|
||||
.frs__deck--focus { max-width: 760px; }
|
||||
|
||||
/* ── LED step rail ─────────────────────────────────────────────────────── */
|
||||
|
||||
.frs-steps {
|
||||
list-style: none;
|
||||
margin: 0.2rem 0 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.frs-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
font-size: 0.78rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.frs-step__led {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
background: color-mix(in srgb, var(--frs-ink) 14%, transparent);
|
||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.6);
|
||||
transition: background 200ms ease, box-shadow 200ms ease;
|
||||
}
|
||||
|
||||
.frs-step.is-done .frs-step__led {
|
||||
background: var(--frs-ok);
|
||||
box-shadow: 0 0 5px 1px color-mix(in srgb, var(--frs-ok) 50%, transparent);
|
||||
}
|
||||
|
||||
.frs-step.is-active .frs-step__led {
|
||||
background: var(--frs-accent);
|
||||
box-shadow: 0 0 6px 1px color-mix(in srgb, var(--frs-accent) 70%, transparent);
|
||||
animation: frs-hw-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.frs-step.is-done .frs-step__label { opacity: 0.68; }
|
||||
.frs-step.is-active .frs-step__label { font-weight: 650; }
|
||||
.frs-step.is-pending { opacity: 0.45; }
|
||||
|
||||
.frs-step__bytes {
|
||||
margin-left: auto;
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.64rem;
|
||||
opacity: 0.6;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Progress variant of the LED meter — taller, journey-wide. */
|
||||
.frs-meter--progress { height: 8px; }
|
||||
|
||||
/* ── Live log panel ────────────────────────────────────────────────────── */
|
||||
|
||||
.frs-log__bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.frs-log__meta {
|
||||
margin-left: auto;
|
||||
font-size: 0.6rem;
|
||||
letter-spacing: 0.08em;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.frs-log {
|
||||
margin: 0;
|
||||
padding: 0.6rem 0.75rem;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.64rem;
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: color-mix(in srgb, var(--frs-ink) 70%, transparent);
|
||||
background: color-mix(in srgb, var(--frs-bg) 55%, transparent);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* ── Inline banner (e.g. language suggestion) ──────────────────────────── */
|
||||
|
||||
.frs-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.8rem;
|
||||
padding: 0.5rem 0.8rem;
|
||||
border-radius: 9px;
|
||||
font-size: 0.76rem;
|
||||
background: color-mix(in srgb, var(--frs-accent) 8%, transparent);
|
||||
}
|
||||
|
||||
.frs-banner__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.frs-banner__actions--end { justify-content: flex-end; margin-top: 0.3rem; }
|
||||
|
||||
/* ── Failure hints ─────────────────────────────────────────────────────── */
|
||||
|
||||
.frs-hints { font-size: 0.74rem; line-height: 1.5; }
|
||||
|
||||
.frs-hints__label { font-weight: 650; }
|
||||
|
||||
.frs-hints ul {
|
||||
margin: 0.35rem 0 0;
|
||||
padding-left: 1.1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* ── Wizard chrome (model/engine selection act) ────────────────────────── */
|
||||
|
||||
.frs-wsteps {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.9rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.frs-wstep {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
font: inherit;
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
color: inherit;
|
||||
opacity: 0.45;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0.25rem 0.1rem;
|
||||
cursor: pointer;
|
||||
transition: opacity 140ms ease;
|
||||
}
|
||||
|
||||
.frs-wstep:hover { opacity: 0.8; }
|
||||
.frs-wstep.is-active { opacity: 1; }
|
||||
.frs-wstep.is-done { opacity: 0.7; }
|
||||
|
||||
.frs-wstep__led {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--frs-ink) 14%, transparent);
|
||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.frs-wstep.is-active .frs-wstep__led {
|
||||
background: var(--frs-accent);
|
||||
box-shadow: 0 0 6px 1px color-mix(in srgb, var(--frs-accent) 70%, transparent);
|
||||
}
|
||||
|
||||
.frs-wstep.is-done .frs-wstep__led {
|
||||
background: var(--frs-ok);
|
||||
box-shadow: 0 0 5px 1px color-mix(in srgb, var(--frs-ok) 50%, transparent);
|
||||
}
|
||||
|
||||
/* Journey rail on setup/install acts: a quiet breadcrumb of the three
|
||||
stages, between the waveform and the headline. Non-interactive spans. */
|
||||
.frs-wsteps--journey {
|
||||
margin-top: 0.8rem;
|
||||
gap: 1.2rem;
|
||||
}
|
||||
|
||||
.frs-wsteps--journey .frs-wstep { cursor: default; }
|
||||
|
||||
/* Embedded app panels (Model Store / Engines) keep their own internals;
|
||||
this shell just gives them breathing room inside the act. */
|
||||
.frs-embed { min-width: 0; }
|
||||
|
||||
.frs-wnav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding-top: 0.6rem;
|
||||
}
|
||||
|
||||
.frs-wnav__group { display: flex; align-items: center; gap: 0.5rem; }
|
||||
|
||||
/* Status check rows (preflight) — same row language as storage. */
|
||||
.frs-check {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.6rem;
|
||||
padding: 0.5rem 0.7rem;
|
||||
border-radius: 9px;
|
||||
}
|
||||
|
||||
.frs-check:hover { background: color-mix(in srgb, var(--frs-ink) 4%, transparent); }
|
||||
|
||||
.frs-check__led {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.35rem;
|
||||
background: color-mix(in srgb, var(--frs-ink) 14%, transparent);
|
||||
}
|
||||
|
||||
.frs-check--pass .frs-check__led {
|
||||
background: var(--frs-ok);
|
||||
box-shadow: 0 0 5px 1px color-mix(in srgb, var(--frs-ok) 50%, transparent);
|
||||
}
|
||||
|
||||
.frs-check--warn .frs-check__led {
|
||||
background: var(--chrome-severity-warn, #d79921);
|
||||
box-shadow: 0 0 5px 1px color-mix(in srgb, var(--chrome-severity-warn, #d79921) 50%, transparent);
|
||||
}
|
||||
|
||||
.frs-check--fail .frs-check__led {
|
||||
background: var(--frs-err);
|
||||
box-shadow: 0 0 5px 1px color-mix(in srgb, var(--frs-err) 50%, transparent);
|
||||
}
|
||||
|
||||
.frs-check__body { display: flex; flex-direction: column; gap: 0.12rem; min-width: 0; }
|
||||
.frs-check__title { font-size: 0.78rem; font-weight: 620; }
|
||||
.frs-check__detail { font-size: 0.7rem; opacity: 0.6; line-height: 1.45; }
|
||||
.frs-check__fix { font-size: 0.7rem; line-height: 1.45; }
|
||||
.frs-check--fail .frs-check__fix { color: var(--frs-err); }
|
||||
.frs-check--warn .frs-check__fix { color: var(--chrome-severity-warn, #d79921); }
|
||||
|
||||
/* Quiet reassurance lines (trust statement, resume note) — present, never loud. */
|
||||
.frs__trust {
|
||||
margin: 0;
|
||||
font-size: 0.68rem;
|
||||
line-height: 1.5;
|
||||
opacity: 0.45;
|
||||
}
|
||||
@@ -0,0 +1,604 @@
|
||||
/**
|
||||
* First-run install setup screen — "studio console" treatment.
|
||||
*
|
||||
* Rendered by BootstrapSplash while the Rust side is parked in the
|
||||
* `awaiting_setup` stage — nothing has been downloaded or installed yet.
|
||||
* The user picks install mode (installed/portable), storage locations,
|
||||
* compute variant, network mirrors and update channel; every chosen
|
||||
* directory is live-checked for free space against the minimum the install
|
||||
* needs (Rust re-validates on submit — the UI gate is a mirror, not the
|
||||
* authority). "Start installation" is the only thing that kicks off the
|
||||
* bootstrap.
|
||||
*
|
||||
* Design language: powering on a piece of studio hardware. Serif masthead
|
||||
* (Source Serif 4), engraved mono panel labels (IBM Plex Mono), a breathing
|
||||
* waveform, and disk space rendered as LED capacity meters. Desktop-first:
|
||||
* a wide two-column deck of rack panels floating directly on the backdrop
|
||||
* (no outer chassis box), collapsing to one column on narrow windows. All
|
||||
* motion is CSS-only (transform/opacity) and honors prefers-reduced-motion;
|
||||
* every asset is bundled — a first run may be on a restricted network.
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import i18n, { LANGUAGES } from '../i18n';
|
||||
import { useAppStore } from '../store';
|
||||
import './FirstRunSetup.css';
|
||||
|
||||
const APP_VERSION = __APP_VERSION__ || '0.0.0';
|
||||
const GIB = 1024 * 1024 * 1024;
|
||||
|
||||
const fmtGB = (bytes) =>
|
||||
bytes == null ? '—' : `${(bytes / GIB).toFixed(bytes < 10 * GIB ? 1 : 0)} GB`;
|
||||
|
||||
const invoke = async (...args) => {
|
||||
const { invoke: tauriInvoke } = await import('@tauri-apps/api/core');
|
||||
return tauriInvoke(...args);
|
||||
};
|
||||
|
||||
/** Debounced live probe of one install target (free space / writability). */
|
||||
function useTargetCheck(path) {
|
||||
const [check, setCheck] = useState(null);
|
||||
useEffect(() => {
|
||||
if (!path) { setCheck(null); return; }
|
||||
let cancelled = false;
|
||||
const t = setTimeout(async () => {
|
||||
try {
|
||||
const res = await invoke('check_install_target', { path });
|
||||
if (!cancelled) setCheck(res);
|
||||
} catch { if (!cancelled) setCheck(null); }
|
||||
}, 250);
|
||||
return () => { cancelled = true; clearTimeout(t); };
|
||||
}, [path]);
|
||||
return check;
|
||||
}
|
||||
|
||||
/** Breathing waveform masthead — bar heights are stable per mount. */
|
||||
function Waveform({ bars = 96 }) {
|
||||
const heights = useMemo(
|
||||
() => Array.from({ length: bars }, (_, i) => {
|
||||
// Deterministic pseudo-random silhouette: layered sines read as speech
|
||||
// cadence (syllables + phrase envelope) rather than white noise.
|
||||
const t = i / bars;
|
||||
const v = Math.abs(
|
||||
Math.sin(t * Math.PI * 7.3) * 0.55 +
|
||||
Math.sin(t * Math.PI * 2.1 + 1.2) * 0.3 +
|
||||
Math.sin(t * Math.PI * 17.0 + 0.4) * 0.15
|
||||
);
|
||||
return 0.18 + v * 0.82;
|
||||
}),
|
||||
[bars],
|
||||
);
|
||||
return (
|
||||
<div className="frs-wave" aria-hidden="true">
|
||||
{heights.map((h, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="frs-wave__bar"
|
||||
style={{ '--h': h, '--d': `${(i * 73) % 1400}ms` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* LED capacity meter: how much of the volume's free space this install
|
||||
* consumes. Lit = consumed by the install, dim = remaining headroom.
|
||||
* Overflows (need > free) clamp to full and switch to the alarm color.
|
||||
*/
|
||||
function CapacityMeter({ need, free }) {
|
||||
const ratio = free > 0 ? need / free : 1;
|
||||
const pct = Math.min(100, Math.max(3, ratio * 100));
|
||||
return (
|
||||
<div
|
||||
className={`frs-meter ${ratio > 1 ? 'frs-meter--over' : ''}`}
|
||||
role="img"
|
||||
aria-label={`${fmtGB(need)} / ${fmtGB(free)}`}
|
||||
>
|
||||
<span className="frs-meter__fill" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One storage location row: label, path, space readout, Change… picker.
|
||||
* The LED meter only appears when it carries information — the disk is
|
||||
* getting tight (install would consume >35% of free space) or blocked.
|
||||
* At 449 GB free vs 9 GB needed a bar is a meaningless sliver; a quiet
|
||||
* one-line readout is cleaner. */
|
||||
function StorageRow({ label, desc, path, need, check, onPick }) {
|
||||
const { t } = useTranslation();
|
||||
const lowSpace = check?.freeBytes != null && check.freeBytes < need;
|
||||
const notWritable = check && !check.writable;
|
||||
const blocked = lowSpace || notWritable;
|
||||
const tight = check?.freeBytes != null && need / check.freeBytes > 0.35;
|
||||
return (
|
||||
<div className={`frs-row ${blocked ? 'frs-row--blocked' : ''}`}>
|
||||
<div className="frs-row__text" title={desc}>
|
||||
<span className="frs-row__label">{label}</span>
|
||||
<code className="frs-row__path" title={path}>{path}</code>
|
||||
</div>
|
||||
<div className="frs-row__gauge">
|
||||
{(blocked || tight) && check?.freeBytes != null && (
|
||||
<CapacityMeter need={need} free={check.freeBytes} />
|
||||
)}
|
||||
<span className={`frs-row__readout ${lowSpace ? 'is-low' : ''}`}>
|
||||
{check == null
|
||||
? t('firstrun.checking', 'checking…')
|
||||
: notWritable
|
||||
? t('firstrun.not_writable', 'not writable')
|
||||
: <>
|
||||
{t('firstrun.needs', { size: fmtGB(need), defaultValue: 'needs ~{{size}}' })}
|
||||
{' · '}
|
||||
{t('firstrun.free', { size: fmtGB(check.freeBytes), defaultValue: '{{size}} free' })}
|
||||
</>}
|
||||
</span>
|
||||
</div>
|
||||
{onPick && (
|
||||
<button type="button" className="frs-btn frs-btn--quiet" onClick={onPick}>
|
||||
{t('firstrun.change', 'Change…')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Section: engraved mono title + rule — structure by line, not by box. */
|
||||
function Panel({ title, delay, className = '', children }) {
|
||||
return (
|
||||
<section className={`frs-panel frs-rise ${className}`} style={{ '--rise': delay }}>
|
||||
<h2 className="frs-panel__title">{title}</h2>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Arrow-key navigation for a radio group (WAI-ARIA radio pattern):
|
||||
* Left/Up selects the previous enabled option, Right/Down the next.
|
||||
* Selection follows focus, exactly like native radios. */
|
||||
export function radioGroupNav(e, values, current, select) {
|
||||
let delta = 0;
|
||||
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') delta = 1;
|
||||
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') delta = -1;
|
||||
else return;
|
||||
e.preventDefault();
|
||||
const idx = Math.max(0, values.indexOf(current));
|
||||
const next = values[(idx + delta + values.length) % values.length];
|
||||
select(next);
|
||||
}
|
||||
|
||||
/** LED radio option — used for install mode, compute and update channel.
|
||||
* Verbosity diet: the description unfolds only on the selected card; the
|
||||
* rest expose it as a tooltip. One expanded card per group keeps the page
|
||||
* calm without hiding information. Roving tabindex: only the selected
|
||||
* option is in the tab order; arrows move within the group. */
|
||||
function OptionCard({ active, disabled, onSelect, name, desc, badge, compact }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
tabIndex={active ? 0 : -1}
|
||||
className={`frs-opt ${compact ? 'frs-opt--compact' : ''} ${active ? 'is-active' : ''}`}
|
||||
disabled={disabled}
|
||||
title={active ? undefined : desc}
|
||||
onClick={() => !disabled && onSelect()}
|
||||
>
|
||||
<span className="frs-opt__led" aria-hidden="true" />
|
||||
<span className="frs-opt__head">
|
||||
<span className="frs-opt__name">{name}</span>
|
||||
{badge && <span className="frs-opt__badge">{badge}</span>}
|
||||
</span>
|
||||
<span className="frs-opt__desc">{desc}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FirstRunSetup() {
|
||||
const { t } = useTranslation();
|
||||
const locale = useAppStore((s) => s.locale);
|
||||
const setLocale = useAppStore((s) => s.setLocale);
|
||||
|
||||
const [setup, setSetup] = useState(null); // get_setup_state payload
|
||||
const [plan, setPlan] = useState(null); // user's editable choices
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [serverError, setServerError] = useState(null);
|
||||
const mounted = useRef(true);
|
||||
useEffect(() => () => { mounted.current = false; }, []);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const s = await invoke('get_setup_state');
|
||||
if (!mounted.current) return;
|
||||
setSetup(s);
|
||||
setPlan({
|
||||
installMode: s.portable.available && s.defaults.installMode === 'portable' ? 'portable' : 'installed',
|
||||
envDir: s.defaults.envDir,
|
||||
dataDir: s.defaults.dataDir,
|
||||
modelsDir: s.defaults.modelsDir,
|
||||
region: s.defaults.region,
|
||||
updateChannel: s.defaults.updateChannel,
|
||||
// Pre-select ROCm when the machine looks AMD — detection is shown
|
||||
// on the card, and the user can always flip back to Auto.
|
||||
torchVariant: s.hardware?.kind === 'rocm' ? 'rocm' : s.defaults.torchVariant,
|
||||
mirrors: { pypiIndex: '', hfEndpoint: '', pythonDownloads: '' },
|
||||
});
|
||||
} catch (e) {
|
||||
if (mounted.current) setServerError(String(e));
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const portable = plan?.installMode === 'portable';
|
||||
const req = setup?.requirements;
|
||||
const hw = setup?.hardware;
|
||||
const combinedNeed = req ? req.envBytes + req.modelsBytes + req.dataBytes : 0;
|
||||
|
||||
// Live target probes — in portable mode only the anchor folder matters.
|
||||
const portableBase = setup?.portable?.baseDir || '';
|
||||
const envCheck = useTargetCheck(portable ? null : plan?.envDir);
|
||||
const dataCheck = useTargetCheck(portable ? null : plan?.dataDir);
|
||||
const modelsCheck = useTargetCheck(portable ? null : plan?.modelsDir);
|
||||
const portableCheck = useTargetCheck(portable ? portableBase : null);
|
||||
|
||||
// Mirror of the Rust gate: group targets by filesystem, sum requirements,
|
||||
// block when any volume falls short or isn't writable.
|
||||
const blockers = useMemo(() => {
|
||||
if (!plan || !req) return [{ key: 'loading' }];
|
||||
const targets = portable
|
||||
? [{ check: portableCheck, need: combinedNeed, label: portableBase }]
|
||||
: [
|
||||
{ check: envCheck, need: req.envBytes, label: plan.envDir },
|
||||
{ check: dataCheck, need: req.dataBytes, label: plan.dataDir },
|
||||
{ check: modelsCheck, need: req.modelsBytes, label: plan.modelsDir },
|
||||
];
|
||||
if (targets.some((x) => x.check == null)) return [{ key: 'loading' }];
|
||||
const out = [];
|
||||
for (const { check, label } of targets) {
|
||||
if (!check.writable) out.push({ key: 'not_writable', label });
|
||||
}
|
||||
const byFs = new Map();
|
||||
for (const { check, need } of targets) {
|
||||
const k = check.fsKey || check.path;
|
||||
const cur = byFs.get(k) || { need: 0, free: check.freeBytes };
|
||||
cur.need += need;
|
||||
cur.free = Math.min(cur.free ?? Infinity, check.freeBytes ?? Infinity);
|
||||
byFs.set(k, cur);
|
||||
}
|
||||
for (const { need, free } of byFs.values()) {
|
||||
if (free != null && free < need) out.push({ key: 'space', need, free });
|
||||
}
|
||||
return out;
|
||||
}, [plan, req, portable, portableBase, combinedNeed, envCheck, dataCheck, modelsCheck, portableCheck]);
|
||||
|
||||
const pickDir = useCallback(async (field) => {
|
||||
try {
|
||||
const { open } = await import('@tauri-apps/plugin-dialog');
|
||||
const dir = await open({ directory: true, defaultPath: plan?.[field] || undefined });
|
||||
if (typeof dir === 'string' && dir) setPlan((p) => ({ ...p, [field]: dir }));
|
||||
} catch (e) { console.error('folder pick failed', e); }
|
||||
}, [plan]);
|
||||
|
||||
const set = useCallback((patch) => setPlan((p) => ({ ...p, ...patch })), []);
|
||||
|
||||
const start = useCallback(async () => {
|
||||
if (!plan || submitting) return;
|
||||
setSubmitting(true);
|
||||
setServerError(null);
|
||||
try {
|
||||
const clean = (s) => (s && s.trim() ? s.trim() : null);
|
||||
await invoke('complete_setup', {
|
||||
plan: {
|
||||
installMode: plan.installMode,
|
||||
envDir: clean(plan.envDir),
|
||||
dataDir: clean(plan.dataDir),
|
||||
modelsDir: clean(plan.modelsDir),
|
||||
region: plan.region,
|
||||
locale,
|
||||
updateChannel: plan.updateChannel,
|
||||
torchVariant: plan.torchVariant,
|
||||
mirrors: {
|
||||
pypiIndex: clean(plan.mirrors.pypiIndex),
|
||||
hfEndpoint: clean(plan.mirrors.hfEndpoint),
|
||||
pythonDownloads: clean(plan.mirrors.pythonDownloads),
|
||||
},
|
||||
},
|
||||
});
|
||||
// Success: the stage poll in App.jsx leaves `awaiting_setup` and the
|
||||
// normal bootstrap progress UI takes over. Nothing to do here.
|
||||
} catch (e) {
|
||||
if (mounted.current) { setServerError(String(e)); setSubmitting(false); }
|
||||
}
|
||||
}, [plan, submitting, locale]);
|
||||
|
||||
if (!setup || !plan) {
|
||||
return (
|
||||
<div className="frs">
|
||||
<div className="frs__atmo" aria-hidden="true" />
|
||||
<div className="frs__loading">
|
||||
{serverError
|
||||
? <pre className="frs__error">{serverError}</pre>
|
||||
: t('firstrun.loading', 'Preparing setup…')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const blocked = blockers.length > 0;
|
||||
const spaceBlocker = blockers.find((b) => b.key === 'space');
|
||||
// The full machine identity — OS/distro · arch · GPU · CPU · RAM — the
|
||||
// exact matrix cell this install is for (and what bug reports cite).
|
||||
const hwLine = hw
|
||||
? [
|
||||
[hw.osName, hw.arch].filter(Boolean).join(' '),
|
||||
hw.gpu,
|
||||
hw.cpuCores ? `${hw.cpuCores}×CPU` : null,
|
||||
hw.ramGb ? `${hw.ramGb} GB RAM` : null,
|
||||
].filter(Boolean).join(' · ')
|
||||
: null;
|
||||
// ROCm wheels are Linux-only — never offer a choice that can't work on
|
||||
// this platform (Rust clamps it server-side too).
|
||||
const rocmAvailable = setup.os === 'linux';
|
||||
|
||||
return (
|
||||
<div className="frs">
|
||||
<div className="frs__atmo" aria-hidden="true" />
|
||||
<div className="frs__deck">
|
||||
|
||||
{/* ── Masthead: waveform + serif headline + serial plate ────────── */}
|
||||
<header className="frs__mast frs-rise" style={{ '--rise': 0 }} data-tauri-drag-region>
|
||||
<Waveform />
|
||||
{/* Journey rail: this page is stage 1 of the install flow. */}
|
||||
<nav className="frs-wsteps frs-wsteps--journey" aria-label={t('firstrun.title', 'Set up OmniVoice Studio')}>
|
||||
<span className="frs-wstep is-active">
|
||||
<span className="frs-wstep__led" aria-hidden="true" />
|
||||
{t('firstrun.stage_setup', 'Setup')}
|
||||
</span>
|
||||
<span className="frs-wstep">
|
||||
<span className="frs-wstep__led" aria-hidden="true" />
|
||||
{t('firstrun.installing_title', 'Installing')}
|
||||
</span>
|
||||
<span className="frs-wstep">
|
||||
<span className="frs-wstep__led" aria-hidden="true" />
|
||||
{t('firstrun.stage_models', 'Models & engines')}
|
||||
</span>
|
||||
</nav>
|
||||
<div className="frs__mast-row">
|
||||
<div className="frs__mast-text">
|
||||
<h1 className="frs__title">{t('firstrun.title', 'Set up OmniVoice Studio')}</h1>
|
||||
<p className="frs__subtitle">
|
||||
{t('firstrun.subtitle', 'Nothing is installed yet — review where everything goes, then start. You can change these later in Settings.')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="frs__mast-meta">
|
||||
{/* Language + download region live together: the two "where am
|
||||
I" choices, settled before anything else. Custom mirrors
|
||||
hang quietly beneath them, where they belong. */}
|
||||
<div className="frs__mast-selects">
|
||||
<select
|
||||
className="frs-select frs-select--lang"
|
||||
value={locale}
|
||||
onChange={(e) => { setLocale(e.target.value); i18n.changeLanguage(e.target.value); }}
|
||||
aria-label={t('firstrun.language', 'Language')}
|
||||
>
|
||||
{LANGUAGES.map((l) => <option key={l.code} value={l.code}>{l.label}</option>)}
|
||||
</select>
|
||||
<select
|
||||
className="frs-select frs-select--lang"
|
||||
value={plan.region}
|
||||
onChange={(e) => set({ region: e.target.value })}
|
||||
aria-label={t('firstrun.region_label', 'Download region')}
|
||||
>
|
||||
<option value="auto">🌐 {t('bootstrap.auto_detect', 'Auto-detect')}</option>
|
||||
<option value="global">🌐 {t('bootstrap.region_global', 'Global (direct)')}</option>
|
||||
<option value="china">🇨🇳 {t('bootstrap.region_china', 'China (mirror)')}</option>
|
||||
<option value="russia">🇷🇺 {t('bootstrap.region_russia', 'Russia (mirror)')}</option>
|
||||
<option value="restricted">🌍 {t('bootstrap.region_restricted', 'Restricted (mirror)')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<details className="frs__advanced frs__advanced--mast">
|
||||
<summary>{t('firstrun.mirrors_title', 'Custom mirrors (advanced)')}</summary>
|
||||
<div className="frs__mirror-fields">
|
||||
{[
|
||||
['pypiIndex', t('firstrun.mirror_pypi', 'PyPI index URL'), 'https://mirrors.aliyun.com/pypi/simple/'],
|
||||
['hfEndpoint', t('firstrun.mirror_hf', 'Hugging Face endpoint'), 'https://hf-mirror.com'],
|
||||
['pythonDownloads', t('firstrun.mirror_python', 'Python downloads mirror'), 'https://gh-proxy.com/…'],
|
||||
].map(([field, label, ph]) => (
|
||||
<label key={field} className="frs-field">
|
||||
<span>{label}</span>
|
||||
<input
|
||||
className="frs-input"
|
||||
type="url"
|
||||
placeholder={ph}
|
||||
value={plan.mirrors[field]}
|
||||
onChange={(e) => set({ mirrors: { ...plan.mirrors, [field]: e.target.value } })}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ── Wide deck: storage rail (left) + decision rail (right) ────── */}
|
||||
<div className="frs__grid">
|
||||
<div className="frs__col frs__col--main">
|
||||
|
||||
<Panel title={t('firstrun.mode_title', 'Install mode')} delay={1}>
|
||||
<div className="frs__options frs__options--two" role="radiogroup" aria-label={t('firstrun.mode_title', 'Install mode')} onKeyDown={(e) => radioGroupNav(e, setup.portable.available ? ['installed', 'portable'] : ['installed'], plan.installMode, (v) => set({ installMode: v }))}>
|
||||
<OptionCard
|
||||
active={!portable}
|
||||
onSelect={() => set({ installMode: 'installed' })}
|
||||
name={t('firstrun.mode_installed', 'Installed')}
|
||||
desc={t('firstrun.mode_installed_desc', 'Uses standard system folders. Recommended for most users.')}
|
||||
/>
|
||||
<OptionCard
|
||||
active={portable}
|
||||
disabled={!setup.portable.available}
|
||||
onSelect={() => set({ installMode: 'portable' })}
|
||||
name={t('firstrun.mode_portable', 'Portable')}
|
||||
desc={setup.portable.available
|
||||
? t('firstrun.mode_portable_desc', 'Everything lives in one folder next to the app — move it to another disk or machine as a unit.')
|
||||
: t('firstrun.mode_portable_unavailable', 'Unavailable: the folder next to the app is not writable.')}
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel title={t('firstrun.storage_title', 'Storage')} delay={2}>
|
||||
{portable ? (
|
||||
<StorageRow
|
||||
label={t('firstrun.portable_folder', 'Portable folder')}
|
||||
desc={t('firstrun.portable_folder_desc', 'App environment, models, and your voice data — one folder, fully movable.')}
|
||||
path={portableBase}
|
||||
need={combinedNeed}
|
||||
check={portableCheck}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<StorageRow
|
||||
label={t('firstrun.env_dir', 'App environment')}
|
||||
desc={t('firstrun.env_dir_desc', 'Python runtime and AI libraries.')}
|
||||
path={plan.envDir}
|
||||
need={req.envBytes}
|
||||
check={envCheck}
|
||||
onPick={() => pickDir('envDir')}
|
||||
/>
|
||||
<StorageRow
|
||||
label={t('firstrun.data_dir', 'Voice data & projects')}
|
||||
desc={t('firstrun.data_dir_desc', 'Your voices, dubs, outputs and project database.')}
|
||||
path={plan.dataDir}
|
||||
need={req.dataBytes}
|
||||
check={dataCheck}
|
||||
onPick={() => pickDir('dataDir')}
|
||||
/>
|
||||
<StorageRow
|
||||
label={t('firstrun.models_dir', 'Model cache')}
|
||||
desc={t('firstrun.models_dir_desc', 'Downloaded AI models — the largest and most relocatable part.')}
|
||||
path={plan.modelsDir}
|
||||
need={req.modelsBytes}
|
||||
check={modelsCheck}
|
||||
onPick={() => pickDir('modelsDir')}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="frs__col frs__col--side">
|
||||
|
||||
<Panel title={t('firstrun.compute_title', 'Compute')} delay={2}>
|
||||
{hwLine && (
|
||||
<div className="frs__hw" title={hwLine}>
|
||||
<span className="frs__hw-dot" aria-hidden="true" />
|
||||
<span className="frs__hw-label">
|
||||
{t('firstrun.compute_detected', { defaultValue: 'Detected' })}
|
||||
</span>
|
||||
<span className="frs__hw-value">{hwLine}</span>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="frs__options"
|
||||
role="radiogroup"
|
||||
aria-label={t('firstrun.compute_title', 'Compute')}
|
||||
onKeyDown={(e) => radioGroupNav(e, rocmAvailable ? ['auto', 'rocm'] : ['auto'], plan.torchVariant, (v) => set({ torchVariant: v }))}
|
||||
>
|
||||
<OptionCard
|
||||
compact
|
||||
active={plan.torchVariant === 'auto'}
|
||||
onSelect={() => set({ torchVariant: 'auto' })}
|
||||
name={t('firstrun.compute_auto', 'Auto (NVIDIA CUDA / Apple MPS / CPU)')}
|
||||
desc={t('firstrun.compute_auto_desc', 'Picks the best backend on this machine at runtime — CUDA on NVIDIA, MPS on Apple Silicon, CPU otherwise.')}
|
||||
badge={hw?.kind === 'cuda' || hw?.kind === 'mps'
|
||||
? t('firstrun.compute_match', { defaultValue: 'matches this machine' })
|
||||
: null}
|
||||
/>
|
||||
{rocmAvailable && (
|
||||
<OptionCard
|
||||
compact
|
||||
active={plan.torchVariant === 'rocm'}
|
||||
onSelect={() => set({ torchVariant: 'rocm' })}
|
||||
name={t('firstrun.compute_rocm', 'AMD GPU (ROCm, Linux)')}
|
||||
desc={t('firstrun.compute_rocm_desc', 'Installs PyTorch ROCm wheels for AMD graphics cards on Linux. Leave on Auto if unsure.')}
|
||||
badge={hw?.kind === 'rocm'
|
||||
? t('firstrun.compute_match', { defaultValue: 'matches this machine' })
|
||||
: null}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel title={t('firstrun.channel_label', 'Update channel')} delay={3}>
|
||||
<div
|
||||
className="frs__options"
|
||||
role="radiogroup"
|
||||
aria-label={t('firstrun.channel_label', 'Update channel')}
|
||||
onKeyDown={(e) => radioGroupNav(e, ['stable', 'preview'], plan.updateChannel, (v) => set({ updateChannel: v }))}
|
||||
>
|
||||
<OptionCard
|
||||
compact
|
||||
active={plan.updateChannel === 'stable'}
|
||||
onSelect={() => set({ updateChannel: 'stable' })}
|
||||
name={t('firstrun.channel_stable', 'Stable')}
|
||||
desc={t('firstrun.channel_stable_desc', 'Tested releases only — updates arrive after community validation.')}
|
||||
/>
|
||||
<OptionCard
|
||||
compact
|
||||
active={plan.updateChannel === 'preview'}
|
||||
onSelect={() => set({ updateChannel: 'preview' })}
|
||||
name={t('firstrun.channel_preview', 'Preview (latest main)')}
|
||||
desc={t('firstrun.channel_preview_desc', 'Rolling builds from the latest main — new engines and fixes first, occasional rough edges.')}
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Footer: gate + arm ────────────────────────────────────────── */}
|
||||
<footer className="frs__foot frs-rise" style={{ '--rise': 5 }}>
|
||||
{serverError && <pre className="frs__error">{serverError}</pre>}
|
||||
{spaceBlocker && (
|
||||
<p className="frs__blocker">
|
||||
{t('firstrun.insufficient_space', {
|
||||
need: fmtGB(spaceBlocker.need),
|
||||
free: fmtGB(spaceBlocker.free),
|
||||
defaultValue: 'Not enough free space: this layout needs ~{{need}} on one disk, only {{free}} available. Pick a different location.',
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{blockers.some((b) => b.key === 'not_writable') && (
|
||||
<p className="frs__blocker">
|
||||
{t('firstrun.blocked_not_writable', 'A chosen folder is not writable — pick a different location.')}
|
||||
</p>
|
||||
)}
|
||||
<div className="frs__foot-row">
|
||||
<span className="frs__totals">
|
||||
<span className="frs__plate">OVS · v{APP_VERSION}</span>
|
||||
<span className="frs__totals-sep" aria-hidden="true">—</span>
|
||||
{t('firstrun.total_required', {
|
||||
size: fmtGB(combinedNeed),
|
||||
defaultValue: 'Total disk needed: ~{{size}} (one-time download on first use)',
|
||||
})}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`frs-btn frs-btn--primary ${!blocked && !submitting ? 'is-armed' : ''}`}
|
||||
disabled={blocked || submitting}
|
||||
onClick={start}
|
||||
>
|
||||
<span className="frs-btn__led" aria-hidden="true" />
|
||||
{submitting
|
||||
? t('firstrun.starting', 'Starting…')
|
||||
: t('firstrun.start', 'Start installation')}
|
||||
</button>
|
||||
</div>
|
||||
{/* The product's whole thesis, said where the user decides. */}
|
||||
<p className="frs__trust">
|
||||
{t('firstrun.trust_line', 'Everything runs and stays on this machine — no account, no cloud, no telemetry.')}
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { X, CheckCircle, AlertCircle } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppStore } from '../store';
|
||||
import './FloatingPill.css';
|
||||
|
||||
@@ -35,6 +36,7 @@ const STAGE_LABELS = {
|
||||
};
|
||||
|
||||
export default function FloatingPill() {
|
||||
const { t } = useTranslation();
|
||||
const visible = useAppStore(s => s.visible);
|
||||
const stage = useAppStore(s => s.stage);
|
||||
const label = useAppStore(s => s.label);
|
||||
@@ -127,8 +129,8 @@ export default function FloatingPill() {
|
||||
<button
|
||||
className="floating-pill__dismiss"
|
||||
onClick={handleDismiss}
|
||||
title={cancellable ? 'Cancel' : 'Dismiss'}
|
||||
aria-label={cancellable ? 'Cancel operation' : 'Dismiss status'}
|
||||
title={cancellable ? t('common.cancel') : t('common.dismiss')}
|
||||
aria-label={cancellable ? t('common.cancelOp') : t('common.dismissStatus')}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
|
||||
@@ -7,15 +7,15 @@ import NotificationPanel from './NotificationPanel';
|
||||
import { useAppStore } from '../store';
|
||||
|
||||
const VIEW_META = {
|
||||
launchpad: { label: 'Launchpad', Icon: Globe, accent: '#f3a5b6', kicker: 'Studio' },
|
||||
clone: { label: 'Voice Clone', Icon: Fingerprint, accent: '#d3869b', kicker: 'Studio' },
|
||||
design: { label: 'Voice Design', Icon: Wand2, accent: '#8ec07c', kicker: 'Studio' },
|
||||
dub: { label: 'Dubbing', Icon: Film, accent: '#fe8019', kicker: 'Studio' },
|
||||
projects: { label: 'OmniDrive', Icon: FolderOpen, accent: '#83a598', kicker: 'Library' },
|
||||
gallery: { label: 'Gallery', Icon: Library, accent: '#b8bb26', kicker: 'Library' },
|
||||
transcriptions: { label: 'Transcriptions', Icon: FileText, accent: '#d3869b', kicker: 'Library' },
|
||||
settings: { label: 'Settings', Icon: Settings2, accent: '#fabd2f', kicker: 'Preferences' },
|
||||
enterprise: { label: 'Commercial License', Icon: Building2, accent: '#fe8019', kicker: 'Licensing' },
|
||||
launchpad: { labelKey: 'header.label_launchpad', Icon: Globe, accent: '#f3a5b6', kickerKey: 'header.kicker_studio' },
|
||||
clone: { labelKey: 'header.label_clone', Icon: Fingerprint, accent: '#d3869b', kickerKey: 'header.kicker_studio' },
|
||||
design: { labelKey: 'header.label_design', Icon: Wand2, accent: '#8ec07c', kickerKey: 'header.kicker_studio' },
|
||||
dub: { labelKey: 'header.label_dub', Icon: Film, accent: '#fe8019', kickerKey: 'header.kicker_studio' },
|
||||
projects: { labelKey: 'header.label_projects', Icon: FolderOpen, accent: '#83a598', kickerKey: 'header.kicker_library' },
|
||||
gallery: { labelKey: 'header.label_gallery', Icon: Library, accent: '#b8bb26', kickerKey: 'header.kicker_library' },
|
||||
transcriptions: { labelKey: 'header.label_transcriptions', Icon: FileText, accent: '#d3869b', kickerKey: 'header.kicker_library' },
|
||||
settings: { labelKey: 'header.label_settings', Icon: Settings2, accent: '#fabd2f', kickerKey: 'header.kicker_preferences' },
|
||||
enterprise: { labelKey: 'header.label_enterprise', Icon: Building2, accent: '#fe8019', kickerKey: 'header.kicker_licensing' },
|
||||
};
|
||||
|
||||
function WaveBars({ color = '#f3a5b6', active }) {
|
||||
@@ -148,11 +148,11 @@ export default function Header({
|
||||
<div className="hq-col-left__spacer" />
|
||||
<div className="hq-view-title">
|
||||
<span className="hq-view-dot" style={dotStyle} />
|
||||
<span className="hq-view-kicker">{view.kicker}</span>
|
||||
<span className="hq-view-kicker">{t(view.kickerKey)}</span>
|
||||
<ChevronRight size={10} color="#504945" className="hq-breadcrumb-sep" />
|
||||
<span className="hq-view-label" style={labelStyle}>
|
||||
<ViewIcon size={12} className="hq-view-icon" />
|
||||
{view.label}
|
||||
{t(view.labelKey)}
|
||||
</span>
|
||||
{activeProjectName ? (
|
||||
<>
|
||||
@@ -212,7 +212,7 @@ export default function Header({
|
||||
dot
|
||||
className={`hq-stats__status-badge ${modelStatus === 'loading' ? 'ui-badge--pulse' : ''}`}
|
||||
>
|
||||
{modelStatus === 'ready' ? 'Ready' : modelStatus === 'loading' ? 'Loading…' : 'Idle'}
|
||||
{modelStatus === 'ready' ? t('header.status_ready') : modelStatus === 'loading' ? t('header.status_loading') : t('header.status_idle')}
|
||||
</Badge>
|
||||
</span>
|
||||
{onFlushMemory && (
|
||||
@@ -221,14 +221,14 @@ export default function Header({
|
||||
ref={flushBtnRef}
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
title="Memory management"
|
||||
title={t('header.memory_management')}
|
||||
loading={flushing}
|
||||
leading={!flushing && <Zap size={8} />}
|
||||
trailing={<ChevronDown size={8} />}
|
||||
onClick={() => setFlushOpen(o => !o)}
|
||||
className="hq-flush-btn"
|
||||
>
|
||||
Flush
|
||||
{t('header.flush')}
|
||||
</Button>
|
||||
{flushOpen && createPortal(
|
||||
<div
|
||||
@@ -236,9 +236,9 @@ export default function Header({
|
||||
style={{ top: dropdownPos.top, left: dropdownPos.left }}
|
||||
ref={dropdownRef}
|
||||
>
|
||||
<div className="hq-flush-dropdown__header">Loaded Models</div>
|
||||
<div className="hq-flush-dropdown__header">{t('header.loaded_models')}</div>
|
||||
{loadedModels.length === 0 ? (
|
||||
<div className="hq-flush-dropdown__empty">No models loaded</div>
|
||||
<div className="hq-flush-dropdown__empty">{t('header.no_models')}</div>
|
||||
) : (
|
||||
loadedModels.map(m => (
|
||||
<div key={m.id} className="hq-flush-dropdown__item">
|
||||
@@ -255,7 +255,7 @@ export default function Header({
|
||||
disabled={unloading === m.id}
|
||||
aria-label={`Unload ${m.name}`}
|
||||
>
|
||||
{unloading === m.id ? '…' : 'Unload'}
|
||||
{unloading === m.id ? '…' : t('header.unload')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -270,7 +270,7 @@ export default function Header({
|
||||
try { await onFlushMemory(false); } finally { setFlushing(false); }
|
||||
}}
|
||||
>
|
||||
<Zap size={10} /> Flush caches
|
||||
<Zap size={10} /> {t('header.flush_caches')}
|
||||
</button>
|
||||
<button
|
||||
className="hq-flush-dropdown__action hq-flush-dropdown__action--danger"
|
||||
@@ -280,7 +280,7 @@ export default function Header({
|
||||
try { await onFlushMemory(true); } finally { setFlushing(false); }
|
||||
}}
|
||||
>
|
||||
<Trash2 size={10} /> Unload all + flush
|
||||
<Trash2 size={10} /> {t('header.unload_all_flush')}
|
||||
</button>
|
||||
</div>,
|
||||
document.body
|
||||
|
||||
@@ -1,54 +1,57 @@
|
||||
import React from 'react';
|
||||
import { Command, X } from 'lucide-react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import './KeyboardCheatsheet.css';
|
||||
|
||||
const SECTIONS = [
|
||||
{
|
||||
title: 'Navigation',
|
||||
items: [
|
||||
['?', 'Show this cheatsheet'],
|
||||
['Esc', 'Close modal / cancel'],
|
||||
['Cmd/Ctrl+S', 'Save project / commit trim'],
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Segment editor',
|
||||
items: [
|
||||
['Cmd/Ctrl+D', 'Split segment at cursor'],
|
||||
['Cmd/Ctrl+M', 'Merge with next segment'],
|
||||
['Cmd/Ctrl+Z', 'Undo'],
|
||||
['Cmd/Ctrl+Shift+Z', 'Redo'],
|
||||
['Click row', 'Primary action'],
|
||||
['Shift+click row', 'Range select'],
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Audio trimmer',
|
||||
items: [
|
||||
['Space', 'Preview play / pause'],
|
||||
['← / →', 'Nudge start handle'],
|
||||
['Ctrl+← / →', 'Nudge end handle'],
|
||||
['Shift+arrow', 'Fine nudge'],
|
||||
['Alt+arrow', 'Coarse nudge'],
|
||||
['+ / −', 'Zoom in / out'],
|
||||
['Home / End', 'Fit all / Fit selection'],
|
||||
['Enter', 'Confirm trim'],
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Dub',
|
||||
items: [
|
||||
['Cmd/Ctrl+Enter', 'Generate dub'],
|
||||
['Cmd/Ctrl+B', 'Toggle sidebar'],
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function Kbd({ children }) {
|
||||
return <span className="kcs-kbd">{children}</span>;
|
||||
}
|
||||
|
||||
export default function KeyboardCheatsheet({ open, onClose }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const SECTIONS = [
|
||||
{
|
||||
title: t('keyboard.nav'),
|
||||
items: [
|
||||
['?', t('keyboard.nav_cheatsheet')],
|
||||
['Esc', t('keyboard.nav_closeModal')],
|
||||
['Cmd/Ctrl+S', t('keyboard.nav_save')],
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('keyboard.segmentEditor'),
|
||||
items: [
|
||||
['Cmd/Ctrl+D', t('keyboard.seg_split')],
|
||||
['Cmd/Ctrl+M', t('keyboard.seg_merge')],
|
||||
['Cmd/Ctrl+Z', t('keyboard.seg_undo')],
|
||||
['Cmd/Ctrl+Shift+Z', t('keyboard.seg_redo')],
|
||||
['Click row', t('keyboard.seg_click')],
|
||||
['Shift+click row', t('keyboard.seg_shiftClick')],
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('keyboard.trimmer'),
|
||||
items: [
|
||||
['Space', t('keyboard.trim_playPause')],
|
||||
['← / →', t('keyboard.trim_nudgeStart')],
|
||||
['Ctrl+← / →', t('keyboard.trim_nudgeEnd')],
|
||||
['Shift+arrow', t('keyboard.trim_fineNudge')],
|
||||
['Alt+arrow', t('keyboard.trim_coarseNudge')],
|
||||
['+ / −', t('keyboard.trim_zoomIn')],
|
||||
['Home / End', t('keyboard.trim_fitAll')],
|
||||
['Enter', t('keyboard.trim_confirm')],
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('keyboard.dub'),
|
||||
items: [
|
||||
['Cmd/Ctrl+Enter', t('keyboard.dub_generate')],
|
||||
['Cmd/Ctrl+B', t('keyboard.dub_sidebar')],
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div onClick={onClose} className="kcs-overlay">
|
||||
@@ -56,7 +59,7 @@ export default function KeyboardCheatsheet({ open, onClose }) {
|
||||
<div className="kcs-header">
|
||||
<div className="kcs-header__left">
|
||||
<Command size={16} color="var(--chrome-accent)" />
|
||||
<h2 className="kcs-title">Keyboard shortcuts</h2>
|
||||
<h2 className="kcs-title">{t('keyboard.title')}</h2>
|
||||
</div>
|
||||
<button onClick={onClose} className="kcs-close">
|
||||
<X size={16} />
|
||||
@@ -77,7 +80,7 @@ export default function KeyboardCheatsheet({ open, onClose }) {
|
||||
<span className="kcs-key-group">
|
||||
{group.split('+').map((k) => <Kbd key={k}>{k}</Kbd>)}
|
||||
</span>
|
||||
{i < arr.length - 1 && <span className="kcs-or">or</span>}
|
||||
{i < arr.length - 1 && <span className="kcs-or">{t('keyboard.or')}</span>}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</span>
|
||||
@@ -89,7 +92,9 @@ export default function KeyboardCheatsheet({ open, onClose }) {
|
||||
</div>
|
||||
|
||||
<div className="kcs-footer">
|
||||
Press <Kbd>?</Kbd> any time to open this.
|
||||
<Trans i18nKey="keyboard.footer" components={{ 1: <Kbd /> }}>
|
||||
{'Press <1>?</1> any time to open this.'}
|
||||
</Trans>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,12 +2,16 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { copyText } from "../utils/copyText";
|
||||
import {
|
||||
ChevronUp, ChevronDown, RefreshCw, Trash2, Copy, Bug, X,
|
||||
AlertTriangle, AlertCircle, Info, FileText, Heart,
|
||||
AlertTriangle, AlertCircle, Info, FileText, Heart, Download,
|
||||
} from 'lucide-react';
|
||||
import UpdatesPanel from './UpdatesPanel';
|
||||
import UpdateStatusChip from './UpdateStatusChip';
|
||||
import toast from 'react-hot-toast';
|
||||
import { clearSystemLogs, clearTauriLogs } from '../api/system';
|
||||
import { useSystemLogs, useTauriLogs, useClearLogs, useClearTauriLogs } from '../api/hooks';
|
||||
import { getFrontendLogs, clearFrontendLogs } from '../utils/consoleBuffer';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppStore } from '../store';
|
||||
import NetworkToggle from './NetworkToggle';
|
||||
import './LogsFooter.css';
|
||||
|
||||
@@ -23,6 +27,7 @@ const SOURCES = [
|
||||
{ id: 'backend', label: 'Backend', icon: FileText },
|
||||
{ id: 'frontend', label: 'Frontend', icon: FileText },
|
||||
{ id: 'tauri', label: 'Tauri', icon: FileText },
|
||||
{ id: 'updates', label: 'Updates', icon: Download },
|
||||
// Notifications used to live here as a 4th pill but that duplicated the
|
||||
// header's bell+badge (single source of truth for notifications). The
|
||||
// footer is logs-only now; bell handles notifications.
|
||||
@@ -137,6 +142,7 @@ export default function LogsFooter() {
|
||||
localStorage.removeItem('omnivoice.logs.collapsed');
|
||||
}
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
const { t } = useTranslation();
|
||||
const [height, setHeight] = useState(() => {
|
||||
const v = Number(localStorage.getItem(LS_HEIGHT));
|
||||
return Number.isFinite(v) && v >= MIN_H && v <= MAX_H ? v : 300;
|
||||
@@ -250,6 +256,7 @@ export default function LogsFooter() {
|
||||
backend: countLevels(lines.backend),
|
||||
frontend: countLevels(lines.frontend),
|
||||
tauri: countLevels(lines.tauri),
|
||||
updates: { error: 0, warn: 0, total: 0 },
|
||||
notifications: {
|
||||
error: notifications.filter(n => n.level === 'error').length,
|
||||
warn: notifications.filter(n => n.level === 'warn').length,
|
||||
@@ -285,9 +292,9 @@ export default function LogsFooter() {
|
||||
else if (active === 'tauri') await clearTauriLogs();
|
||||
else if (active === 'frontend') clearFrontendLogs();
|
||||
setLines(prev => ({ ...prev, [active]: [] }));
|
||||
toast.success(`${active} log cleared`);
|
||||
toast.success(t('logs.log_cleared', { source: active }));
|
||||
} catch (e) {
|
||||
toast.error(`Clear failed: ${e?.message || e}`);
|
||||
toast.error(t('logs.clear_failed', { message: e?.message || e }));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -295,9 +302,9 @@ export default function LogsFooter() {
|
||||
try {
|
||||
const raw = (lines[active] || []).join('\n');
|
||||
await copyText(raw);
|
||||
toast.success(`Copied ${active} log`);
|
||||
toast.success(t('logs.log_copied', { source: active }));
|
||||
} catch (e) {
|
||||
toast.error(`Copy failed: ${e?.message || e}`);
|
||||
toast.error(t('logs.copy_failed', { message: e?.message || e }));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -321,9 +328,9 @@ export default function LogsFooter() {
|
||||
}).join('\n\n');
|
||||
try {
|
||||
await copyText(header + body);
|
||||
toast.success('Diagnostic report copied — paste it into a GitHub issue.');
|
||||
toast.success(t('logs.report_copied'));
|
||||
} catch (e) {
|
||||
toast.error(`Report failed: ${e?.message || e}`);
|
||||
toast.error(t('logs.report_failed', { message: e?.message || e }));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -339,7 +346,7 @@ export default function LogsFooter() {
|
||||
ref={dragRef}
|
||||
className="logs-footer__resize"
|
||||
onMouseDown={onDragStart}
|
||||
title="Drag to resize"
|
||||
title={t('logs.drag_resize')}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -352,13 +359,13 @@ export default function LogsFooter() {
|
||||
type="button"
|
||||
className="logs-footer__toggle"
|
||||
onClick={() => setCollapsed(c => !c)}
|
||||
title={collapsed ? 'Expand logs' : 'Collapse logs'}
|
||||
aria-label={collapsed ? 'Expand logs panel' : 'Collapse logs panel'}
|
||||
title={collapsed ? t('logs.expand') : t('logs.collapse')}
|
||||
aria-label={collapsed ? t('logs.expand_aria') : t('logs.collapse_aria')}
|
||||
aria-expanded={!collapsed}
|
||||
>
|
||||
{collapsed ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||
</button>
|
||||
<span className="logs-footer__title">Logs</span>
|
||||
<span className="logs-footer__title">{t('logs.title')}</span>
|
||||
{SOURCES.map(s => (
|
||||
<SourcePill
|
||||
key={s.id}
|
||||
@@ -372,30 +379,31 @@ export default function LogsFooter() {
|
||||
<div className="logs-footer__right">
|
||||
{!collapsed && (
|
||||
<div className="logs-footer__actions">
|
||||
<button className="logs-footer__icon-btn" onClick={refreshAll} disabled={loading} title="Refresh" aria-label="Refresh logs">
|
||||
<button className="logs-footer__icon-btn" onClick={refreshAll} disabled={loading} title={t('logs.refresh')} aria-label={t('logs.refresh_aria')}>
|
||||
<RefreshCw size={12} className={loading ? 'spinner' : ''} />
|
||||
</button>
|
||||
<button className="logs-footer__icon-btn" onClick={onCopy} title="Copy visible log" aria-label="Copy visible log">
|
||||
<button className="logs-footer__icon-btn" onClick={onCopy} title={t('logs.copy_visible')} aria-label={t('logs.copy_visible_aria')}>
|
||||
<Copy size={12} />
|
||||
</button>
|
||||
<button className="logs-footer__icon-btn" onClick={onClear} title="Clear" aria-label="Clear log">
|
||||
<button className="logs-footer__icon-btn" onClick={onClear} title={t('logs.clear')} aria-label={t('logs.clear_aria')}>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
<button className="logs-footer__icon-btn logs-footer__icon-btn--report" onClick={onReportIssue} title="Report issue (copy diagnostic)" aria-label="Report issue">
|
||||
<button className="logs-footer__icon-btn logs-footer__icon-btn--report" onClick={onReportIssue} title={t('logs.report_issue')} aria-label={t('logs.report_issue_aria')}>
|
||||
<Bug size={12} />
|
||||
</button>
|
||||
<button className="logs-footer__icon-btn" onClick={() => setCollapsed(true)} title="Close" aria-label="Close logs panel">
|
||||
<button className="logs-footer__icon-btn" onClick={() => setCollapsed(true)} title={t('logs.close')} aria-label={t('logs.close_aria')}>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<UpdateStatusChip onOpen={() => openTo('updates')} />
|
||||
<NetworkToggle />
|
||||
<button
|
||||
type="button"
|
||||
className="logs-footer__discord"
|
||||
onClick={() => { import('../api/external').then(m => m.openExternal('https://discord.gg/bzQavDfVV9')); }}
|
||||
title="Join our Discord"
|
||||
aria-label="Join our Discord community"
|
||||
title={t('logs.join_discord')}
|
||||
aria-label={t('logs.join_discord_aria')}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.947 2.418-2.157 2.418z"/></svg>
|
||||
</button>
|
||||
@@ -403,19 +411,25 @@ export default function LogsFooter() {
|
||||
type="button"
|
||||
className="logs-footer__donate"
|
||||
onClick={() => useAppStore.getState().setMode?.('donate')}
|
||||
title="Support this project"
|
||||
aria-label="Support this project"
|
||||
title={t('logs.support_project')}
|
||||
aria-label={t('logs.support_project_aria')}
|
||||
>
|
||||
<DonateHeart />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!collapsed && active !== 'notifications' && (
|
||||
{!collapsed && active === 'updates' && (
|
||||
<div className="logs-footer__body">
|
||||
<UpdatesPanel />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!collapsed && active !== 'notifications' && active !== 'updates' && (
|
||||
<div ref={scrollRef} className="logs-footer__body">
|
||||
{current.length === 0 && (
|
||||
<div className="logs-footer__empty">
|
||||
{active === 'frontend' ? 'No frontend console output yet.' : 'No lines.'}
|
||||
{active === 'frontend' ? t('logs.empty_frontend_short') : t('logs.empty_lines')}
|
||||
</div>
|
||||
)}
|
||||
{current.map((line, i) => {
|
||||
@@ -434,7 +448,7 @@ export default function LogsFooter() {
|
||||
<div className="logs-footer__body logs-footer__notif-body">
|
||||
{notifications.length === 0 ? (
|
||||
<div className="logs-footer__empty">
|
||||
✅ All clear — no issues detected
|
||||
{t('logs.all_clear')}
|
||||
</div>
|
||||
) : (
|
||||
notifications.map(notif => (
|
||||
@@ -443,6 +457,14 @@ export default function LogsFooter() {
|
||||
className={`logs-footer__notif-item logs-footer__notif-item--${notif.level} ${notif.action ? 'logs-footer__notif-item--clickable' : ''}`}
|
||||
onClick={() => {
|
||||
if (!notif.action) return;
|
||||
// Acting on the crash notice acknowledges it — the backend
|
||||
// stores the seen crash-log size so it doesn't re-fire
|
||||
// every session until a NEW crash grows the log.
|
||||
if (notif.id === 'crash-last-session') {
|
||||
import('../api/client')
|
||||
.then(({ API }) => fetch(`${API}/system/crash/ack`, { method: 'POST' }))
|
||||
.catch(() => {});
|
||||
}
|
||||
if (notif.action.type === 'navigate') {
|
||||
useAppStore.getState().setMode?.(notif.action.target);
|
||||
setCollapsed(true);
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useState, useMemo, useRef, useEffect } from 'react';
|
||||
import { X, Search, Globe, Plus } from 'lucide-react';
|
||||
import { POPULAR_LANGS } from '../utils/constants';
|
||||
import { LANG_CODES } from '../utils/languages';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import './MultiLangPicker.css';
|
||||
|
||||
/**
|
||||
@@ -15,6 +16,7 @@ export default function MultiLangPicker({
|
||||
onChange, // (newSelected) => void
|
||||
disabled = false,
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [dropOpen, setDropOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const dropRef = useRef(null);
|
||||
@@ -89,7 +91,7 @@ export default function MultiLangPicker({
|
||||
type="button"
|
||||
className="multi-lang__add"
|
||||
onClick={() => setDropOpen(!dropOpen)}
|
||||
title="Add language"
|
||||
title={t('dub.add_language')}
|
||||
>
|
||||
<Plus size={10} />
|
||||
</button>
|
||||
@@ -98,7 +100,7 @@ export default function MultiLangPicker({
|
||||
|
||||
{selected.length > 0 && (
|
||||
<div className="multi-lang__summary">
|
||||
{selected.length} language{selected.length > 1 ? 's' : ''} selected
|
||||
{t('dub.languages_selected', { count: selected.length })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -110,14 +112,14 @@ export default function MultiLangPicker({
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
placeholder="Search languages…"
|
||||
placeholder={t('dub.search_languages')}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="multi-lang__list">
|
||||
{popularFiltered.length > 0 && (
|
||||
<>
|
||||
<div className="multi-lang__section">Popular</div>
|
||||
<div className="multi-lang__section">{t('dub.popular')}</div>
|
||||
{popularFiltered.map(item => (
|
||||
<button
|
||||
key={item.code}
|
||||
@@ -131,7 +133,7 @@ export default function MultiLangPicker({
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<div className="multi-lang__section">All Languages</div>
|
||||
<div className="multi-lang__section">{t('dub.all_languages')}</div>
|
||||
{filteredLangs.slice(0, 50).map(lc => (
|
||||
<button
|
||||
key={lc.code}
|
||||
@@ -145,11 +147,11 @@ export default function MultiLangPicker({
|
||||
))}
|
||||
{filteredLangs.length > 50 && (
|
||||
<div className="multi-lang__more">
|
||||
+{filteredLangs.length - 50} more — type to narrow
|
||||
{t('dub.more_to_narrow', { count: filteredLangs.length - 50 })}
|
||||
</div>
|
||||
)}
|
||||
{filteredLangs.length === 0 && popularFiltered.length === 0 && (
|
||||
<div className="multi-lang__empty">No matches</div>
|
||||
<div className="multi-lang__empty">{t('dub.no_matches')}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -54,8 +54,8 @@ export default function NavRail({ mode, setMode, side = 'left', onFlipSide }) {
|
||||
))}
|
||||
<button
|
||||
onClick={onFlipSide}
|
||||
title={`Move rail to the ${side === 'left' ? 'right' : 'left'}`}
|
||||
aria-label="Flip rail side"
|
||||
title={side === 'left' ? t('nav.move_rail_right') : t('nav.move_rail_left')}
|
||||
aria-label={t('nav.flip_rail')}
|
||||
className="rail-btn rail-flip"
|
||||
>
|
||||
<ArrowLeftRight size={15} />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// frontend/src/components/NetworkToggle.jsx
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { copyText } from "../utils/copyText";
|
||||
import QRCode from 'qrcode';
|
||||
import { Wifi, WifiOff, Copy, ExternalLink } from 'lucide-react';
|
||||
@@ -9,6 +10,7 @@ import { openExternal } from '../api/external';
|
||||
import './NetworkToggle.css';
|
||||
|
||||
export default function NetworkToggle() {
|
||||
const { t } = useTranslation();
|
||||
const [st, setSt] = useState({ enabled: false });
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -38,17 +40,17 @@ export default function NetworkToggle() {
|
||||
const enable = async () => {
|
||||
setBusy(true);
|
||||
try { setSt(await apiPost('/system/network/enable')); setConfirming(false); setOpen(true); }
|
||||
catch (e) { toast.error(`Could not enable sharing: ${e.message}`); }
|
||||
catch (e) { toast.error(t('network.enable_error', { message: e.message })); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
const disable = async () => {
|
||||
setBusy(true);
|
||||
try { await apiPost('/system/network/disable'); await refresh(); setOpen(false); }
|
||||
catch (e) { toast.error(`Could not disable: ${e.message}`); }
|
||||
catch (e) { toast.error(t('network.disable_error', { message: e.message })); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const copy = (text) => { copyText(text); toast.success('Copied'); };
|
||||
const copy = (text) => { copyText(text); toast.success(t('network.copied')); };
|
||||
|
||||
return (
|
||||
<div className="net-toggle">
|
||||
@@ -56,31 +58,30 @@ export default function NetworkToggle() {
|
||||
className={`net-toggle__pill ${st.enabled ? 'net-toggle__pill--on' : ''}`}
|
||||
onClick={st.enabled ? () => setOpen((o) => !o) : () => setConfirming((c) => !c)}
|
||||
disabled={busy}
|
||||
title={st.enabled ? 'Sharing on — click for details' : 'Share on your network'}
|
||||
title={st.enabled ? t('network.sharing_on_title') : t('network.share_on_network')}
|
||||
>
|
||||
{st.enabled ? <Wifi size={12} /> : <WifiOff size={12} />}
|
||||
<span>{busy ? 'Switching…' : st.enabled ? 'Network' : 'Local'}</span>
|
||||
<span>{busy ? t('network.switching') : st.enabled ? t('network.network') : t('network.local')}</span>
|
||||
</button>
|
||||
|
||||
{!st.enabled && confirming && (
|
||||
<div className="net-toggle__panel net-toggle__panel--confirm">
|
||||
<div className="net-toggle__panel-title">Share on your network?</div>
|
||||
<div className="net-toggle__panel-title">{t('network.share_confirm_title')}</div>
|
||||
<p className="net-toggle__hint">
|
||||
Other devices on your Wi-Fi/Ethernet will be able to reach OmniVoice
|
||||
using the access PIN shown once it's on.
|
||||
{t('network.share_confirm_hint')}
|
||||
</p>
|
||||
<div className="net-toggle__confirm-actions">
|
||||
<button type="button" className="net-toggle__cancel" onClick={() => setConfirming(false)} disabled={busy}>Cancel</button>
|
||||
<button type="button" className="net-toggle__enable" onClick={enable} disabled={busy}>{busy ? 'Enabling…' : 'Enable'}</button>
|
||||
<button type="button" className="net-toggle__cancel" onClick={() => setConfirming(false)} disabled={busy}>{t('common.cancel')}</button>
|
||||
<button type="button" className="net-toggle__enable" onClick={enable} disabled={busy}>{busy ? t('network.enabling') : t('network.enable')}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{st.enabled && open && (
|
||||
<div className="net-toggle__panel">
|
||||
<div className="net-toggle__panel-title">Shared on your network</div>
|
||||
<div className="net-toggle__panel-title">{t('network.shared_title')}</div>
|
||||
{(st.lan_addresses || []).length === 0 && (
|
||||
<p className="net-toggle__hint">No reachable network interface — connect to Wi-Fi/Ethernet.</p>
|
||||
<p className="net-toggle__hint">{t('network.no_interface')}</p>
|
||||
)}
|
||||
{(st.lan_addresses || []).map((ip) => {
|
||||
const url = `http://${ip}:${st.share_port}/?pin=${st.pin}`;
|
||||
@@ -89,18 +90,19 @@ export default function NetworkToggle() {
|
||||
<div className="net-toggle__row-main">
|
||||
<code className="net-toggle__addr">{ip}:{st.share_port}</code>
|
||||
<div className="net-toggle__row-actions">
|
||||
<button type="button" className="net-toggle__iconbtn" onClick={() => copy(url)} aria-label={`Copy ${ip}`} title="Copy link"><Copy size={12} /></button>
|
||||
<button type="button" className="net-toggle__iconbtn" onClick={() => openExternal(url)} aria-label={`Open ${ip}`} title="Open in browser"><ExternalLink size={12} /></button>
|
||||
<button type="button" className="net-toggle__iconbtn" onClick={() => copy(url)} aria-label={`Copy ${ip}`} title={t('network.copy_link')}><Copy size={12} /></button>
|
||||
<button type="button" className="net-toggle__iconbtn" onClick={() => openExternal(url)} aria-label={`Open ${ip}`} title={t('network.open_in_browser')}><ExternalLink size={12} /></button>
|
||||
</div>
|
||||
</div>
|
||||
{qrs[ip] && <img className="net-toggle__qr" src={qrs[ip]} alt={`QR for ${ip}`} width={104} height={104} />}
|
||||
{qrs[ip] && <img className="net-toggle__qr" src={qrs[ip]} alt={t('network.qr_alt', { ip })} width={104} height={104} />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="net-toggle__pin">PIN: <strong>{st.pin}</strong></div>
|
||||
<button type="button" className="net-toggle__off" onClick={disable} disabled={busy}>Stop sharing</button>
|
||||
<div className="net-toggle__pin">{t('network.pin')} <strong>{st.pin}</strong></div>
|
||||
<button type="button" className="net-toggle__off" onClick={disable} disabled={busy}>{t('network.stop_sharing')}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CheckCircle, AlertTriangle, XCircle, Loader } from 'lucide-react';
|
||||
import { usePreflight, useModelStatus } from '../api/hooks';
|
||||
import './ReadinessChecklist.css';
|
||||
@@ -26,6 +27,7 @@ const StatusIcon = ({ status, size = 14 }) => {
|
||||
};
|
||||
|
||||
export default function ReadinessChecklist({ compact = false, showWhenAllPass = false }) {
|
||||
const { t } = useTranslation();
|
||||
const { data: preflight, isLoading: preflightLoading } = usePreflight();
|
||||
const { data: modelData, isLoading: modelLoading } = useModelStatus();
|
||||
|
||||
@@ -40,16 +42,16 @@ export default function ReadinessChecklist({ compact = false, showWhenAllPass =
|
||||
const modelErr = modelData?.error || null;
|
||||
const modelCheck = {
|
||||
id: 'asr-model',
|
||||
label: 'ASR Model',
|
||||
label: t('readiness.asr_model'),
|
||||
status: modelStatus === 'ready' ? 'pass'
|
||||
: modelStatus === 'loading' ? 'loading'
|
||||
: modelStatus === 'error' || modelData?.sub_stage === 'error' ? 'fail'
|
||||
: 'warn',
|
||||
detail: modelStatus === 'ready' ? 'Loaded and ready'
|
||||
: modelStatus === 'loading' ? (modelDetail || 'Loading… (this may take 1-2 minutes on first run)')
|
||||
: (modelData?.sub_stage === 'error' ? (modelErr || 'Failed to load') : 'Not loaded yet — will load on first transcription'),
|
||||
detail: modelStatus === 'ready' ? t('readiness.loaded_ready')
|
||||
: modelStatus === 'loading' ? (modelDetail || t('readiness.loading_first_run'))
|
||||
: (modelData?.sub_stage === 'error' ? (modelErr || t('readiness.failed_to_load')) : t('readiness.not_loaded_yet')),
|
||||
fix: (modelStatus === 'error' || modelData?.sub_stage === 'error')
|
||||
? (modelErr ? `Error: ${modelErr}. Check logs and try restarting.` : 'Check logs for model loading errors. Try restarting.')
|
||||
? (modelErr ? t('readiness.error_check_logs', { error: modelErr }) : t('readiness.check_logs_restart'))
|
||||
: null,
|
||||
};
|
||||
checks.push(modelCheck);
|
||||
@@ -68,16 +70,16 @@ export default function ReadinessChecklist({ compact = false, showWhenAllPass =
|
||||
// LLM configuration (check for translate endpoint)
|
||||
const llmCheck = {
|
||||
id: 'llm',
|
||||
label: 'LLM (Cinematic)',
|
||||
label: t('readiness.llm_cinematic'),
|
||||
status: 'warn',
|
||||
detail: 'Configure TRANSLATE_BASE_URL for Cinematic translation quality',
|
||||
fix: 'Set TRANSLATE_BASE_URL and TRANSLATE_API_KEY environment variables. Works with Ollama, OpenAI, LM Studio, etc.',
|
||||
detail: t('readiness.llm_configure'),
|
||||
fix: t('readiness.llm_set_env'),
|
||||
};
|
||||
// If we have preflight and there's a network check passing, LLM is at least possible
|
||||
if (preflight?.checks) {
|
||||
const netCheck = preflight.checks.find(c => c.id === 'network');
|
||||
if (netCheck?.status === 'pass') {
|
||||
llmCheck.detail = 'Optional — set TRANSLATE_BASE_URL for Cinematic quality';
|
||||
llmCheck.detail = t('readiness.llm_optional');
|
||||
}
|
||||
}
|
||||
checks.push(llmCheck);
|
||||
@@ -95,7 +97,7 @@ export default function ReadinessChecklist({ compact = false, showWhenAllPass =
|
||||
<div className="readiness-checklist">
|
||||
<div className="readiness-checklist__title">
|
||||
<span className="readiness-checklist__title-icon">🔍</span>
|
||||
Checking system…
|
||||
{t('readiness.checking_system')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -108,7 +110,7 @@ export default function ReadinessChecklist({ compact = false, showWhenAllPass =
|
||||
return (
|
||||
<div className="readiness-checklist__all-pass">
|
||||
<CheckCircle size={14} />
|
||||
All systems ready
|
||||
{t('readiness.all_ready')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -137,7 +139,7 @@ export default function ReadinessChecklist({ compact = false, showWhenAllPass =
|
||||
<span className="readiness-checklist__title-icon">
|
||||
{anyFail ? '⚠️' : '✅'}
|
||||
</span>
|
||||
System Readiness
|
||||
{t('readiness.system_readiness')}
|
||||
</div>
|
||||
<ul className="readiness-checklist__list">
|
||||
{checks.map(check => (
|
||||
@@ -156,3 +158,4 @@ export default function ReadinessChecklist({ compact = false, showWhenAllPass =
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,98 +8,27 @@
|
||||
* never POST to GitHub directly, and never bypass the user's review —
|
||||
* opt-in by construction, no separate consent dialog needed.
|
||||
*
|
||||
* What gets captured (no secrets):
|
||||
* - OS + arch
|
||||
* - OmniVoice version (Vite injects __APP_VERSION__ at build time)
|
||||
* - Browser/webview UA
|
||||
* - Active TTS engine (best-effort fetch)
|
||||
* - Optional user-typed description
|
||||
*
|
||||
* What gets stripped:
|
||||
* - $HOME path → ~/
|
||||
* - Anything matching /TOKEN|KEY|SECRET/i in env vars
|
||||
* - Audio file contents (we don't include them)
|
||||
* Capture + scrubbing live in utils/bugReport.js (shared with the
|
||||
* ErrorBoundary's report action and error toasts): version, OS, GPU/CPU/
|
||||
* RAM, active TTS engine — home paths and credential-shaped strings are
|
||||
* redacted, audio contents never included.
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { Bug } from 'lucide-react';
|
||||
import { Button } from '../ui';
|
||||
import { openExternal } from '../api/external';
|
||||
import { API } from '../api/client';
|
||||
import { buildBugReportUrl } from '../utils/bugReport';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const APP_VERSION = (typeof __APP_VERSION__ !== 'undefined' && __APP_VERSION__) || 'unknown';
|
||||
|
||||
const ISSUES_URL = 'https://github.com/debpalash/OmniVoice-Studio/issues/new';
|
||||
|
||||
function stripHome(s) {
|
||||
if (!s) return s;
|
||||
// Best-effort home redaction — works for the most common /Users/<name>/
|
||||
// and /home/<name>/ paths. We don't know the actual $HOME from JS, so
|
||||
// pattern-match the prefix.
|
||||
return String(s)
|
||||
.replace(/\/Users\/[^/]+/g, '~')
|
||||
.replace(/\/home\/[^/]+/g, '~')
|
||||
.replace(/[A-Z]:\\Users\\[^\\]+/g, '~');
|
||||
}
|
||||
|
||||
async function captureContext() {
|
||||
const lines = [
|
||||
`**Version:** \`${APP_VERSION}\``,
|
||||
`**Platform:** \`${navigator?.userAgent || 'unknown'}\``,
|
||||
];
|
||||
|
||||
// Best-effort backend system info — silently skip if backend is down.
|
||||
try {
|
||||
const r = await fetch(`${API}/system/info`);
|
||||
if (r.ok) {
|
||||
const j = await r.json();
|
||||
// /system/info exposes `platform` (sys.platform) + `device` (best
|
||||
// compute device). Map to those — older field names (os/torch_device/
|
||||
// gpu) never existed on this endpoint, so they silently dropped.
|
||||
if (j?.platform) lines.push(`**OS:** \`${j.platform}\``);
|
||||
if (j?.python) lines.push(`**Python:** \`${j.python}\``);
|
||||
if (j?.device) lines.push(`**Compute device:** \`${stripHome(j.device)}\``);
|
||||
}
|
||||
} catch { /* backend probably not up yet */ }
|
||||
|
||||
try {
|
||||
const r = await fetch(`${API}/engines`);
|
||||
if (r.ok) {
|
||||
const j = await r.json();
|
||||
const active = j?.tts?.active;
|
||||
if (active) lines.push(`**Active TTS engine:** \`${active}\``);
|
||||
}
|
||||
} catch { /* noop */ }
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export default function ReportBugButton({ size = 'sm', variant = 'subtle', label = 'Report a bug' }) {
|
||||
export default function ReportBugButton({ size = 'sm', variant = 'subtle', label, error }) {
|
||||
const { t } = useTranslation();
|
||||
const displayLabel = label || t('reportBug.label');
|
||||
const [building, setBuilding] = useState(false);
|
||||
|
||||
const handleClick = async () => {
|
||||
setBuilding(true);
|
||||
try {
|
||||
const ctx = await captureContext();
|
||||
const body = [
|
||||
'<!-- Click Submit at the bottom of this page to file the issue.',
|
||||
' Review the auto-captured environment info below and add anything',
|
||||
' about what you were doing when the bug happened. -->',
|
||||
'',
|
||||
'## Describe the bug',
|
||||
'',
|
||||
'<!-- e.g. "Synthesize failed in Design mode after picking Narrator personality" -->',
|
||||
'',
|
||||
'## Environment',
|
||||
'',
|
||||
ctx,
|
||||
'',
|
||||
'## What I was doing',
|
||||
'',
|
||||
'<!-- step-by-step would help us reproduce -->',
|
||||
'',
|
||||
].join('\n');
|
||||
const url = `${ISSUES_URL}?title=${encodeURIComponent('[Bug] ')}&labels=${encodeURIComponent('bug')}&body=${encodeURIComponent(body)}`;
|
||||
await openExternal(url);
|
||||
await openExternal(await buildBugReportUrl({ error }));
|
||||
} finally {
|
||||
setBuilding(false);
|
||||
}
|
||||
@@ -112,9 +41,9 @@ export default function ReportBugButton({ size = 'sm', variant = 'subtle', label
|
||||
onClick={handleClick}
|
||||
loading={building}
|
||||
leading={!building && <Bug size={12} />}
|
||||
title="Opens a prefilled GitHub Issues page in your browser. Nothing is sent until you click Submit."
|
||||
title={t('reportBug.title')}
|
||||
>
|
||||
{label}
|
||||
{displayLabel}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Search, ChevronDown, Check, Star, Clock } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const MAX_DISPLAY = 200;
|
||||
|
||||
@@ -32,6 +33,7 @@ export default function SearchableSelect({
|
||||
buttonClassName = 'input-base',
|
||||
size = 'md',
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const [highlight, setHighlight] = useState(0);
|
||||
@@ -161,7 +163,7 @@ export default function SearchableSelect({
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="ss-search-input"
|
||||
placeholder="Search…"
|
||||
placeholder={t('common.search')}
|
||||
value={query}
|
||||
onChange={e => { setQuery(e.target.value); setHighlight(0); }}
|
||||
onKeyDown={onKey}
|
||||
@@ -170,12 +172,12 @@ export default function SearchableSelect({
|
||||
|
||||
<div ref={listRef} className="ss-list">
|
||||
{flatItems.length === 0 && (
|
||||
<div className="ss-empty">No matches</div>
|
||||
<div className="ss-empty">{t('common.no_matches')}</div>
|
||||
)}
|
||||
|
||||
{pinned.length > 0 && (
|
||||
<div className="ss-group-label">
|
||||
{recents.length ? <><Clock size={9}/> Recent & Popular</> : <><Star size={9}/> Popular</>}
|
||||
{recents.length ? <><Clock size={9}/> {t('common.recent_and_popular')}</> : <><Star size={9}/> {t('common.popular_label')}</>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -204,7 +206,7 @@ export default function SearchableSelect({
|
||||
})}
|
||||
|
||||
{!query && filtered.length > MAX_DISPLAY && (
|
||||
<div className="ss-more">Showing {MAX_DISPLAY} of {filtered.length}. Type to search…</div>
|
||||
<div className="ss-more">{t('common.showing_of', { shown: MAX_DISPLAY, total: filtered.length })}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { clearDubHistory } from '../api/dub';
|
||||
import { clearHistory as clearGenHistory } from '../api/generate';
|
||||
import { Button } from '../ui';
|
||||
import { useAppStore } from '../store';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import './Sidebar.css';
|
||||
import { askConfirm } from '../utils/dialog';
|
||||
|
||||
@@ -59,6 +60,7 @@ export default function Sidebar(props) {
|
||||
const dubStep = useAppStore(s => s.dubStep);
|
||||
const activeProjectId = useAppStore(s => s.activeProjectId);
|
||||
|
||||
const { t } = useTranslation();
|
||||
const [sbQuery, setSbQuery] = useState('');
|
||||
const qLower = sbQuery.trim().toLowerCase();
|
||||
const matchesSearch = (s) => !qLower || (s || '').toLowerCase().includes(qLower);
|
||||
@@ -79,12 +81,12 @@ export default function Sidebar(props) {
|
||||
), [exportHistory, qLower]);
|
||||
|
||||
const handleClearHistory = async () => {
|
||||
if (!(await askConfirm(`Clear all ${history.length + dubHistory.length} history items? This cannot be undone.`))) return;
|
||||
if (!(await askConfirm(t('sidebar.clear_confirm', { count: history.length + dubHistory.length })))) return;
|
||||
await clearGenHistory();
|
||||
await clearDubHistory();
|
||||
await loadHistory();
|
||||
await loadDubHistory();
|
||||
toast.success('History cleared');
|
||||
toast.success(t('sidebar.history_cleared'));
|
||||
};
|
||||
|
||||
const tabCount = {
|
||||
@@ -92,7 +94,7 @@ export default function Sidebar(props) {
|
||||
history: history.length + dubHistory.length,
|
||||
downloads: exportHistory.length,
|
||||
};
|
||||
const tabLabel = { projects: 'Drive', history: 'History', downloads: 'Exports' };
|
||||
const tabLabel = { projects: t('sidebar.tab_drive'), history: t('sidebar.tab_history'), downloads: t('sidebar.tab_exports') };
|
||||
|
||||
return (
|
||||
<div className={`glass-panel history-panel sidebar ${isSidebarCollapsed ? 'is-collapsed' : ''}`}>
|
||||
@@ -158,7 +160,7 @@ export default function Sidebar(props) {
|
||||
leading={<Save size={13} />}
|
||||
className={`sidebar__save-btn sidebar__save-btn--full ${activeProjectId ? 'is-active-project' : ''}`}
|
||||
>
|
||||
{activeProjectId ? 'Save Dub Project' : 'Save as New Dub Project'}
|
||||
{activeProjectId ? t('sidebar.save_project') : t('sidebar.save_new_project')}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
@@ -168,7 +170,7 @@ export default function Sidebar(props) {
|
||||
className="sidebar__section-title"
|
||||
onClick={() => setIsSidebarProjectsCollapsed(!isSidebarProjectsCollapsed)}
|
||||
>
|
||||
<span>{mode === 'dub' ? 'Dub Projects' : (mode === 'clone' ? 'Voice Clones' : 'Designed Voices')}</span>
|
||||
<span>{mode === 'dub' ? t('sidebar.dub_projects') : (mode === 'clone' ? t('sidebar.voice_clones') : t('sidebar.designed_voices'))}</span>
|
||||
{isSidebarProjectsCollapsed ? <ChevronDown size={12} /> : <ChevronUp size={12} />}
|
||||
</div>
|
||||
)}
|
||||
@@ -180,8 +182,8 @@ export default function Sidebar(props) {
|
||||
{filteredProjects.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Film}
|
||||
title="No saved dub projects"
|
||||
hint="Upload a video and click Save to keep your work."
|
||||
title={t('sidebar.no_dub_projects')}
|
||||
hint={t('sidebar.no_dub_hint')}
|
||||
/>
|
||||
) : (
|
||||
filteredProjects.map(proj => (
|
||||
@@ -191,7 +193,7 @@ export default function Sidebar(props) {
|
||||
>
|
||||
<div className="history-row-head">
|
||||
<span className="history-kind history-kind--audio">
|
||||
<Film size={9} /> Dub
|
||||
<Film size={9} /> {t('sidebar.dub_label')}
|
||||
</span>
|
||||
<span className="history-meta" title={new Date(proj.updated_at * 1000).toLocaleString()}>
|
||||
{timeAgo(proj.updated_at * 1000)}
|
||||
@@ -208,7 +210,7 @@ export default function Sidebar(props) {
|
||||
</div>
|
||||
<div className="history-actions">
|
||||
<button className="history-action-btn accent" onClick={(e) => { e.stopPropagation(); loadProject(proj.id); }}>
|
||||
<FolderOpen size={10} /> Open
|
||||
<FolderOpen size={10} /> {t('sidebar.open')}
|
||||
</button>
|
||||
<button className="history-action-btn danger history-action-icon" onClick={(e) => { e.stopPropagation(); deleteProject(proj.id); }} title="Delete">
|
||||
<Trash2 size={10} />
|
||||
@@ -225,8 +227,8 @@ export default function Sidebar(props) {
|
||||
{filteredProfiles.filter(p => mode === "clone" ? !p.instruct : !!p.instruct).length === 0 ? (
|
||||
<EmptyState
|
||||
icon={mode === 'clone' ? Fingerprint : Wand2}
|
||||
title={`No ${mode === 'clone' ? 'voice clones' : 'designed voices'} yet`}
|
||||
hint={mode === 'clone' ? 'Record or upload audio, then click Save as Voice Profile.' : 'Generate a voice and save it from History.'}
|
||||
title={`${mode === 'clone' ? t('sidebar.no_clones') : t('sidebar.no_designs')}`}
|
||||
hint={mode === 'clone' ? t('sidebar.no_clones_hint') : t('sidebar.no_designs_hint')}
|
||||
/>
|
||||
) : (
|
||||
(mode === 'clone' ? filteredProfiles.filter(p => !p.instruct) : filteredProfiles.filter(p => !!p.instruct)).map(proj => {
|
||||
@@ -240,9 +242,9 @@ export default function Sidebar(props) {
|
||||
>
|
||||
<div className="history-row-head">
|
||||
<span className="history-kind" style={{ color: accent, borderColor: `${accent}40` }}>
|
||||
<KindIcon size={9} /> {proj.is_locked ? 'Locked' : (mode === 'clone' ? 'Clone' : 'Design')}
|
||||
<KindIcon size={9} /> {proj.is_locked ? t('sidebar.locked') : (mode === 'clone' ? t('sidebar.clone_label') : t('sidebar.design_label'))}
|
||||
</span>
|
||||
{proj.is_locked ? <span className="history-meta history-meta--locked">consistent</span> : null}
|
||||
{proj.is_locked ? <span className="history-meta history-meta--locked">{t('sidebar.consistent')}</span> : null}
|
||||
</div>
|
||||
<div className="history-title">{proj.name}</div>
|
||||
{proj.instruct ? <div className="history-subtitle history-subtitle--italic">{proj.instruct}</div> : null}
|
||||
@@ -261,7 +263,7 @@ export default function Sidebar(props) {
|
||||
</button>
|
||||
)}
|
||||
<button className="history-action-btn" onClick={(e) => { e.stopPropagation(); handleSelectProfile(proj); }}>
|
||||
<Check size={10} /> Select
|
||||
<Check size={10} /> {t('sidebar.select')}
|
||||
</button>
|
||||
{onOpenVoicePreview && (
|
||||
<button
|
||||
@@ -269,7 +271,7 @@ export default function Sidebar(props) {
|
||||
onClick={(e) => { e.stopPropagation(); onOpenVoicePreview(proj.id); }}
|
||||
title="Open interactive voice preview"
|
||||
>
|
||||
<Volume2 size={10} /> Try
|
||||
<Volume2 size={10} /> {t('sidebar.try_voice')}
|
||||
</button>
|
||||
)}
|
||||
{proj.is_locked ? (
|
||||
@@ -320,12 +322,12 @@ export default function Sidebar(props) {
|
||||
{/* ── HISTORY TAB ── */}
|
||||
{sidebarTab === 'history' && (
|
||||
<>
|
||||
{!isSidebarCollapsed && <div className="sidebar__subtitle">Generation history · Stored in SQLite</div>}
|
||||
{!isSidebarCollapsed && <div className="sidebar__subtitle">{t('sidebar.history_subtitle')}</div>}
|
||||
{(history.length + dubHistory.length) === 0 ? (
|
||||
<EmptyState
|
||||
icon={History}
|
||||
title="No generation history"
|
||||
hint="Synthesize audio or dub a video — results will appear here."
|
||||
title={t('sidebar.no_history')}
|
||||
hint={t('sidebar.no_history_hint')}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
@@ -335,7 +337,7 @@ export default function Sidebar(props) {
|
||||
>
|
||||
<div className="history-row-head">
|
||||
<span className="history-kind history-kind--audio">
|
||||
<Film size={9} /> Dub
|
||||
<Film size={9} /> {t('sidebar.dub_label')}
|
||||
</span>
|
||||
<span className="history-meta">{item.segments_count} segs · {Math.round(item.duration || 0)}s</span>
|
||||
</div>
|
||||
@@ -345,7 +347,7 @@ export default function Sidebar(props) {
|
||||
</div>
|
||||
<div className="history-actions">
|
||||
<button className="history-action-btn accent" onClick={(e) => { e.stopPropagation(); restoreDubHistory(item); }}>
|
||||
<FolderOpen size={10} /> Open
|
||||
<FolderOpen size={10} /> {t('sidebar.open')}
|
||||
</button>
|
||||
<button className="history-action-btn danger history-action-icon" onClick={(e) => { e.stopPropagation(); deleteHistory(item.id, 'dub'); }} title="Delete">
|
||||
<Trash2 size={10} />
|
||||
@@ -380,12 +382,12 @@ export default function Sidebar(props) {
|
||||
{item.audio_path ? (
|
||||
<div className="history-actions">
|
||||
<button className="history-action-btn accent" onClick={(e) => { e.stopPropagation(); handleSaveHistoryAsProfile(item); }}>
|
||||
<Save size={10} /> Save
|
||||
<Save size={10} /> {t('sidebar.save_label')}
|
||||
</button>
|
||||
{item.profile_id ? (
|
||||
<button className="history-action-btn accent history-action-icon"
|
||||
onClick={(e) => { e.stopPropagation(); handleLockProfile(item.profile_id, item.id, item.seed); }}
|
||||
title="Lock voice identity">
|
||||
title={t('sidebar.lock_identity')}>
|
||||
<Lock size={10} />
|
||||
</button>
|
||||
) : null}
|
||||
@@ -435,7 +437,7 @@ export default function Sidebar(props) {
|
||||
leading={<Trash2 size={10} />}
|
||||
className="sidebar__clear"
|
||||
>
|
||||
Clear History
|
||||
{t('sidebar.clear_history')}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
@@ -444,12 +446,12 @@ export default function Sidebar(props) {
|
||||
{/* ── DOWNLOADS TAB ── */}
|
||||
{sidebarTab === 'downloads' && (
|
||||
<>
|
||||
{!isSidebarCollapsed && <div className="sidebar__subtitle">Recent Exports</div>}
|
||||
{!isSidebarCollapsed && <div className="sidebar__subtitle">{t('sidebar.recent_exports')}</div>}
|
||||
{exportHistory.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={DownloadCloud}
|
||||
title="No downloaded outputs"
|
||||
hint="Export a file via Tauri to see it tracked here."
|
||||
title={t('sidebar.no_exports')}
|
||||
hint={t('sidebar.no_exports_hint')}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
@@ -470,10 +472,10 @@ export default function Sidebar(props) {
|
||||
<span className="history-meta">{timeAgo(item.created_at * 1000)}</span>
|
||||
</div>
|
||||
<div className="history-title">{item.filename}</div>
|
||||
<div className="history-subtitle">in {parentFolder}</div>
|
||||
<div className="history-subtitle">{t('sidebar.in_folder', { folder: parentFolder })}</div>
|
||||
<div className="history-actions">
|
||||
<button className="history-action-btn accent" onClick={(e) => { e.stopPropagation(); revealInFolder(item.destination_path); }}>
|
||||
<FolderOpen size={10} /> Show in folder
|
||||
<FolderOpen size={10} /> {t('sidebar.show_in_folder')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -459,3 +459,68 @@
|
||||
font-size: var(--text-xs);
|
||||
padding: 3px 6px;
|
||||
}
|
||||
|
||||
/* ── Redesign: grouped toolbar, chapter bars, readable column ──────── */
|
||||
|
||||
/* Center the editor at a comfortable reading width (header + script align). */
|
||||
.stories-editor { max-width: 1040px; margin-inline: auto; }
|
||||
|
||||
/* Toolbar: logical clusters (project · content · output) with thin dividers. */
|
||||
.stories-editor__actions { flex-wrap: wrap; }
|
||||
.stories-editor__group { display: inline-flex; align-items: center; gap: 4px; }
|
||||
.stories-editor__divider {
|
||||
align-self: stretch;
|
||||
width: 1px;
|
||||
min-height: 18px;
|
||||
margin: 0 4px;
|
||||
background: var(--color-border);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Chapter = a section heading, not a voiced line. */
|
||||
.stories-chapter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 16px 0 4px;
|
||||
padding: 7px 10px;
|
||||
border-radius: var(--radius-md, 8px);
|
||||
border-left: 3px solid var(--color-accent);
|
||||
background: var(--color-bg-elevated, rgba(255, 255, 255, 0.03));
|
||||
}
|
||||
.stories-chapter--dragover { outline: 1px dashed var(--color-accent); outline-offset: 2px; }
|
||||
.stories-chapter__grip { display: flex; color: var(--color-fg-dim, #a89884); cursor: grab; opacity: 0.4; }
|
||||
.stories-chapter:hover .stories-chapter__grip { opacity: 1; }
|
||||
.stories-chapter__icon { flex: none; color: var(--color-accent); }
|
||||
.stories-chapter__title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
color: var(--color-fg);
|
||||
padding: 2px 0;
|
||||
}
|
||||
.stories-chapter__title::placeholder { color: var(--color-fg-dim, #a89884); font-weight: 600; }
|
||||
.stories-chapter__del {
|
||||
flex: none;
|
||||
display: flex;
|
||||
padding: 4px;
|
||||
border-radius: 6px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-fg-dim, #a89884);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
}
|
||||
.stories-chapter:hover .stories-chapter__del { opacity: 0.7; }
|
||||
.stories-chapter__del:hover { opacity: 1; color: var(--color-danger, #fb4934); }
|
||||
|
||||
/* Quiet a line's secondary actions until the row is hovered or active. */
|
||||
.stories-track__actions { opacity: 0.5; transition: opacity 0.12s ease; }
|
||||
.stories-track:hover .stories-track__actions,
|
||||
.stories-track--active .stories-track__actions { opacity: 1; }
|
||||
|
||||
@@ -37,6 +37,14 @@ function download(blob, filename) {
|
||||
setTimeout(() => URL.revokeObjectURL(url), 10000);
|
||||
}
|
||||
|
||||
// A chapter line is any track whose text is a markdown heading (`# …`). It
|
||||
// renders as a section bar (no voice/tune/preview), and storyExport keys its
|
||||
// chapter cues off the same prefix — keep the two in sync.
|
||||
// Lenient on purpose: a heading with an empty title is still `# ` (or `#`), and
|
||||
// it must stay a chapter while the user edits the title — otherwise clearing the
|
||||
// text would flip the bar back into a voiced line card mid-edit.
|
||||
const isChapterText = (s) => /^\s*#{1,6}(\s|$)/.test(s || '');
|
||||
|
||||
// Sentence-aware splitter for the "Paste & auto-split" panel. Walks the text
|
||||
// and breaks at the closest sentence boundary that keeps each chunk under
|
||||
// `maxChars`. Falls back to whitespace, then to the hard cap.
|
||||
@@ -207,7 +215,7 @@ export default function StoriesEditor({ profiles = [] }) {
|
||||
const openProject = useCallback((id) => { loadProject(id); setProjectsOpen(false); }, [loadProject]);
|
||||
|
||||
const addChapter = useCallback(() => {
|
||||
const n = tracks.filter((tk) => /^#{1,6}\s+/.test((tk.text || '').trim())).length + 1;
|
||||
const n = tracks.filter((tk) => isChapterText(tk.text)).length + 1;
|
||||
setTracks((prev) => [...prev, makeTrack('narrator', `# ${t('stories.chapterN', { n })}`)]);
|
||||
}, [tracks, setTracks, t]);
|
||||
|
||||
@@ -393,40 +401,56 @@ export default function StoriesEditor({ profiles = [] }) {
|
||||
</div>
|
||||
<div className="stories-editor__actions">
|
||||
<input ref={fileInputRef} type="file" accept=".txt,.srt,text/plain" onChange={onImportFile} hidden />
|
||||
<Button size="sm" variant="ghost" onClick={() => setProjectsOpen((v) => !v)} aria-label={t('stories.projects')}>
|
||||
<Folder size={13} /> {currentProject ? currentProject.name : t('stories.projects')}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setCastOpen((v) => !v)} aria-label={t('stories.cast')}>
|
||||
<Users size={13} /> {t('stories.cast')}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => fileInputRef.current && fileInputRef.current.click()} aria-label={t('stories.import')}>
|
||||
<Upload size={13} /> {t('stories.import')}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setSplitOpen((v) => !v)} aria-label={t('stories.pasteSplit')}>
|
||||
<Scissors size={13} /> {t('stories.pasteSplit')}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={addTrack} aria-label={t('stories.addLine')}>
|
||||
<Plus size={13} /> {t('stories.addLine')}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={addChapter} aria-label={t('stories.addChapter')}>
|
||||
<Bookmark size={13} /> {t('stories.addChapter')}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={exportStemsAll} disabled={tracks.length === 0 || exporting} aria-label={t('stories.stems')}>
|
||||
<Layers size={13} /> {t('stories.stems')}
|
||||
</Button>
|
||||
<select
|
||||
className="stories-editor__format"
|
||||
value={exportFormat}
|
||||
onChange={(e) => setExportFormat(e.target.value)}
|
||||
aria-label={t('stories.format')}
|
||||
title={t('stories.format')}
|
||||
>
|
||||
<option value="wav">WAV</option>
|
||||
<option value="mp3">MP3</option>
|
||||
</select>
|
||||
<Button size="sm" onClick={generateAll} disabled={tracks.length === 0 || exporting}>
|
||||
<Download size={13} /> {exporting ? `${exportPct}%` : t('stories.generateAll')}
|
||||
</Button>
|
||||
|
||||
{/* Project */}
|
||||
<div className="stories-editor__group">
|
||||
<Button size="sm" variant="ghost" onClick={() => setProjectsOpen((v) => !v)} aria-label={t('stories.projects')}>
|
||||
<Folder size={13} /> {currentProject ? currentProject.name : t('stories.projects')}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setCastOpen((v) => !v)} aria-label={t('stories.cast')}>
|
||||
<Users size={13} /> {t('stories.cast')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<span className="stories-editor__divider" aria-hidden="true" />
|
||||
|
||||
{/* Content */}
|
||||
<div className="stories-editor__group">
|
||||
<Button size="sm" variant="ghost" onClick={() => fileInputRef.current && fileInputRef.current.click()} aria-label={t('stories.import')}>
|
||||
<Upload size={13} /> {t('stories.import')}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setSplitOpen((v) => !v)} aria-label={t('stories.pasteSplit')}>
|
||||
<Scissors size={13} /> {t('stories.pasteSplit')}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={addTrack} aria-label={t('stories.addLine')}>
|
||||
<Plus size={13} /> {t('stories.addLine')}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={addChapter} aria-label={t('stories.addChapter')}>
|
||||
<Bookmark size={13} /> {t('stories.addChapter')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<span className="stories-editor__divider" aria-hidden="true" />
|
||||
|
||||
{/* Output */}
|
||||
<div className="stories-editor__group">
|
||||
<Button size="sm" variant="ghost" onClick={exportStemsAll} disabled={tracks.length === 0 || exporting} aria-label={t('stories.stems')}>
|
||||
<Layers size={13} /> {t('stories.stems')}
|
||||
</Button>
|
||||
<select
|
||||
className="stories-editor__format"
|
||||
value={exportFormat}
|
||||
onChange={(e) => setExportFormat(e.target.value)}
|
||||
aria-label={t('stories.format')}
|
||||
title={t('stories.format')}
|
||||
>
|
||||
<option value="wav">WAV</option>
|
||||
<option value="mp3">MP3</option>
|
||||
</select>
|
||||
<Button size="sm" onClick={generateAll} disabled={tracks.length === 0 || exporting}>
|
||||
<Download size={13} /> {exporting ? `${exportPct}%` : t('stories.generateAll')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -552,6 +576,53 @@ export default function StoriesEditor({ profiles = [] }) {
|
||||
) : (
|
||||
<div className="stories-editor__tracks" role="list">
|
||||
{tracks.map((track) => {
|
||||
const dragProps = {
|
||||
draggable: true,
|
||||
onDragStart: (e) => { dragId.current = track.id; e.dataTransfer.effectAllowed = 'move'; },
|
||||
onDragOver: (e) => { e.preventDefault(); if (dragOver !== track.id) setDragOver(track.id); },
|
||||
onDragLeave: () => setDragOver((d) => (d === track.id ? null : d)),
|
||||
onDrop: (e) => {
|
||||
e.preventDefault();
|
||||
if (dragId.current != null && dragId.current !== track.id) {
|
||||
setTracks((prev) => reorder(prev, dragId.current, track.id));
|
||||
}
|
||||
dragId.current = null;
|
||||
setDragOver(null);
|
||||
},
|
||||
};
|
||||
|
||||
// Chapters render as a section bar — no voice / tune / preview.
|
||||
if (isChapterText(track.text)) {
|
||||
const title = track.text.replace(/^#{1,6}\s*/, '');
|
||||
return (
|
||||
<div
|
||||
key={track.id}
|
||||
role="listitem"
|
||||
className={['stories-chapter', dragOver === track.id ? 'stories-chapter--dragover' : ''].filter(Boolean).join(' ')}
|
||||
{...dragProps}
|
||||
>
|
||||
<div className="stories-chapter__grip" aria-hidden="true"><GripVertical size={14} /></div>
|
||||
<Bookmark size={15} className="stories-chapter__icon" aria-hidden="true" />
|
||||
<input
|
||||
className="stories-chapter__title"
|
||||
value={title}
|
||||
onChange={(e) => updateTrack(track.id, 'text', `# ${e.target.value}`)}
|
||||
placeholder={t('stories.addChapter')}
|
||||
aria-label={t('stories.addChapter')}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="stories-chapter__del"
|
||||
onClick={(e) => { e.stopPropagation(); removeTrack(track.id); }}
|
||||
title={t('stories.removeLine')}
|
||||
aria-label={t('stories.removeLine')}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const member = castMember(cast, track.character);
|
||||
const inheritedId = member && member.profileId;
|
||||
const inheritedName = inheritedId ? profileName(inheritedId) : null;
|
||||
@@ -566,18 +637,7 @@ export default function StoriesEditor({ profiles = [] }) {
|
||||
dragOver === track.id ? 'stories-track--dragover' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
onClick={() => setActiveTrack(track.id)}
|
||||
draggable
|
||||
onDragStart={(e) => { dragId.current = track.id; e.dataTransfer.effectAllowed = 'move'; }}
|
||||
onDragOver={(e) => { e.preventDefault(); if (dragOver !== track.id) setDragOver(track.id); }}
|
||||
onDragLeave={() => setDragOver((d) => (d === track.id ? null : d))}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
if (dragId.current != null && dragId.current !== track.id) {
|
||||
setTracks((prev) => reorder(prev, dragId.current, track.id));
|
||||
}
|
||||
dragId.current = null;
|
||||
setDragOver(null);
|
||||
}}
|
||||
{...dragProps}
|
||||
>
|
||||
<div className="stories-track__grip" aria-hidden="true"><GripVertical size={14} /></div>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { apiPost } from '../api/client';
|
||||
import './SupertonicLicenseDialog.css';
|
||||
@@ -35,6 +36,7 @@ const LICENSE_URLS = {
|
||||
};
|
||||
|
||||
export default function SupertonicLicenseDialog({ open, onClose, onAccepted }) {
|
||||
const { t } = useTranslation();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// Escape closes the dialog ‑‑ mirrors browser-standard modal UX.
|
||||
@@ -54,16 +56,16 @@ export default function SupertonicLicenseDialog({ open, onClose, onAccepted }) {
|
||||
engine_id: 'supertonic3',
|
||||
accepted: true,
|
||||
});
|
||||
toast.success('Supertonic-3 license accepted.');
|
||||
toast.success(t('license.accepted_toast'));
|
||||
onAccepted?.();
|
||||
onClose?.();
|
||||
} catch (e) {
|
||||
const msg = e?.message || String(e);
|
||||
toast.error(`Failed to record license acceptance: ${msg}`);
|
||||
toast.error(t('license.accept_error', { message: msg }));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [onAccepted, onClose]);
|
||||
}, [onAccepted, onClose, t]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
@@ -81,21 +83,18 @@ export default function SupertonicLicenseDialog({ open, onClose, onAccepted }) {
|
||||
>
|
||||
<div className="supertonic-license__card">
|
||||
<h2 id="supertonic-license-title" className="supertonic-license__title">
|
||||
Supertonic-3 — License Acceptance
|
||||
{t('license.title')}
|
||||
</h2>
|
||||
|
||||
<p className="supertonic-license__intro">
|
||||
Supertonic-3 ships under two distinct licenses. Please review
|
||||
both before enabling the engine.
|
||||
{t('license.intro')}
|
||||
</p>
|
||||
|
||||
<div className="supertonic-license__sections">
|
||||
<section className="supertonic-license__section">
|
||||
<h3>SDK Code · MIT</h3>
|
||||
<h3>{t('license.sdk_heading')}</h3>
|
||||
<p>
|
||||
The Python inference SDK (
|
||||
<code>supertonic</code>
|
||||
) is MIT-licensed. Permissive use, including commercial.
|
||||
{t('license.sdk_desc')}
|
||||
</p>
|
||||
<a
|
||||
href={LICENSE_URLS.code}
|
||||
@@ -103,17 +102,14 @@ export default function SupertonicLicenseDialog({ open, onClose, onAccepted }) {
|
||||
rel="noopener noreferrer"
|
||||
className="supertonic-license__link"
|
||||
>
|
||||
Read the MIT license →
|
||||
{t('license.read_mit')}
|
||||
</a>
|
||||
</section>
|
||||
|
||||
<section className="supertonic-license__section">
|
||||
<h3>Model Weights · OpenRAIL-M</h3>
|
||||
<h3>{t('license.model_heading')}</h3>
|
||||
<p>
|
||||
The Supertonic-3 model weights are released under the
|
||||
OpenRAIL-M license. This license restricts use to
|
||||
non-malicious purposes ‑‑ see the linked license for the
|
||||
full set of use-based restrictions.
|
||||
{t('license.model_desc')}
|
||||
</p>
|
||||
<a
|
||||
href={LICENSE_URLS.model}
|
||||
@@ -121,16 +117,13 @@ export default function SupertonicLicenseDialog({ open, onClose, onAccepted }) {
|
||||
rel="noopener noreferrer"
|
||||
className="supertonic-license__link"
|
||||
>
|
||||
Read the OpenRAIL-M license →
|
||||
{t('license.read_openrail')}
|
||||
</a>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<p className="supertonic-license__footer">
|
||||
Clicking Accept records your acceptance in OmniVoice's
|
||||
local settings and enables the engine. Your acceptance is
|
||||
stored on this machine only ‑‑ nothing is reported to
|
||||
Supertone Inc. or any third party.
|
||||
{t('license.footer')}
|
||||
</p>
|
||||
|
||||
<div className="supertonic-license__actions">
|
||||
@@ -140,7 +133,7 @@ export default function SupertonicLicenseDialog({ open, onClose, onAccepted }) {
|
||||
onClick={onClose}
|
||||
disabled={submitting}
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -149,10 +142,11 @@ export default function SupertonicLicenseDialog({ open, onClose, onAccepted }) {
|
||||
disabled={submitting}
|
||||
autoFocus
|
||||
>
|
||||
{submitting ? 'Saving…' : 'Accept'}
|
||||
{submitting ? t('license.saving') : t('license.accept')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
.update-badge {
|
||||
position: fixed;
|
||||
top: 38px;
|
||||
right: 12px;
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.update-badge__btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: rgba(184, 187, 38, 0.14);
|
||||
border: 1px solid rgba(184, 187, 38, 0.45);
|
||||
color: #b8bb26;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.update-badge__btn:hover { background: rgba(184, 187, 38, 0.22); }
|
||||
.update-badge__btn--ready {
|
||||
background: rgba(131, 165, 152, 0.18);
|
||||
border-color: rgba(131, 165, 152, 0.5);
|
||||
color: #8ec07c;
|
||||
}
|
||||
.update-badge__progress {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: var(--chrome-bg, #1d2021);
|
||||
border: 1px solid var(--chrome-border, #3a3a3a);
|
||||
color: #ebdbb2;
|
||||
font-size: 11px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.update-badge__bar { width: 60px; height: 3px; background: rgba(255, 255, 255, 0.12); border-radius: 2px; overflow: hidden; }
|
||||
.update-badge__bar span { display: block; height: 100%; background: #b8bb26; }
|
||||
@@ -1,47 +0,0 @@
|
||||
// Non-blocking auto-update surface: a small pill that appears when an update is
|
||||
// available / downloading / ready. Replaces the old blocking ask() dialog so an
|
||||
// update never interrupts in-flight work — the user installs when they choose,
|
||||
// and the action is gated while a dub job is running.
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Download, Loader, RotateCw } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useAppStore } from '../store';
|
||||
import { installUpdate } from '../utils/updater';
|
||||
import './UpdateBadge.css';
|
||||
|
||||
export default function UpdateBadge() {
|
||||
const { t } = useTranslation();
|
||||
const status = useAppStore((s) => s.updateStatus);
|
||||
const version = useAppStore((s) => s.updateVersion);
|
||||
const progress = useAppStore((s) => s.updateProgress);
|
||||
const dubStep = useAppStore((s) => s.dubStep);
|
||||
|
||||
if (status === 'idle' || status === 'checking' || status === 'error') return null;
|
||||
|
||||
const busy = dubStep === 'generating';
|
||||
const onInstall = () => {
|
||||
if (busy) { toast(t('update.busy'), { icon: '⏳' }); return; }
|
||||
installUpdate(useAppStore.getState());
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="update-badge" role="status">
|
||||
{status === 'available' && (
|
||||
<button type="button" className="update-badge__btn" onClick={onInstall} title={t('update.install_hint')}>
|
||||
<Download size={12} /> {t('update.available', { version: version || '' })} · {t('update.install')}
|
||||
</button>
|
||||
)}
|
||||
{status === 'downloading' && (
|
||||
<span className="update-badge__progress">
|
||||
<Loader size={12} className="spinner" /> {t('update.downloading', { pct: Math.round(progress) })}
|
||||
<span className="update-badge__bar"><span style={{ width: `${progress}%` }} /></span>
|
||||
</span>
|
||||
)}
|
||||
{status === 'ready' && (
|
||||
<button type="button" className="update-badge__btn update-badge__btn--ready" onClick={onInstall}>
|
||||
<RotateCw size={12} /> {t('update.restart')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/* frontend/src/components/UpdateStatusChip.css */
|
||||
.update-chip {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
height: 20px; padding: 0 8px;
|
||||
background: none; border: 1px solid transparent; border-radius: 999px;
|
||||
font: inherit; font-size: 11px; font-weight: 600; cursor: pointer;
|
||||
color: var(--text-dim, #a89984);
|
||||
}
|
||||
.update-chip:hover { color: #ebdbb2; }
|
||||
.update-chip--idle { opacity: 0.75; }
|
||||
.update-chip--available { color: #b8bb26; border-color: rgba(184, 187, 38, 0.5); }
|
||||
.update-chip--downloading { color: #83a598; }
|
||||
.update-chip--ready { color: #b8bb26; border-color: rgba(184, 187, 38, 0.6); }
|
||||
.update-chip--error { color: #fb4934; border-color: rgba(251, 73, 52, 0.5); }
|
||||
.update-chip .spinner { animation: spin 1s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@@ -0,0 +1,57 @@
|
||||
// frontend/src/components/UpdateStatusChip.jsx
|
||||
// Persistent update indicator that lives in the LogsFooter bar (replaces the
|
||||
// old floating UpdateBadge). Shows current version when idle; morphs into
|
||||
// available / downloading / ready / error. Click opens the Updates panel.
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Check, ArrowUp, Loader, RotateCw, AlertTriangle } from 'lucide-react';
|
||||
import { useAppStore } from '../store';
|
||||
import { chipPresentation } from '../utils/updatePresentation';
|
||||
import { installUpdate } from '../utils/updater';
|
||||
import toast from 'react-hot-toast';
|
||||
import './UpdateStatusChip.css';
|
||||
|
||||
const ICONS = { check: Check, up: ArrowUp, spin: Loader, restart: RotateCw, alert: AlertTriangle };
|
||||
|
||||
export default function UpdateStatusChip({ onOpen }) {
|
||||
const { t } = useTranslation();
|
||||
const status = useAppStore((s) => s.updateStatus);
|
||||
const version = useAppStore((s) => s.updateVersion);
|
||||
const appVersion = useAppStore((s) => s.appVersion);
|
||||
const progress = useAppStore((s) => s.updateProgress);
|
||||
const dubStep = useAppStore((s) => s.dubStep);
|
||||
|
||||
const p = chipPresentation(status, { appVersion, version, progress });
|
||||
if (!p) return null;
|
||||
const Icon = ICONS[p.icon] || Check;
|
||||
|
||||
const labelText = {
|
||||
idle: p.label,
|
||||
available: t('update.available', { version: p.label }),
|
||||
downloading: t('update.downloading', { pct: p.label.replace('%', '') }),
|
||||
ready: t('update.restart'),
|
||||
error: t('update.failed'),
|
||||
}[p.variant];
|
||||
|
||||
// ready stays one-click (preserve today's behavior); others open the panel.
|
||||
const onClick = () => {
|
||||
if (p.variant === 'ready') {
|
||||
// Don't relaunch out from under an in-flight dub/transcription job.
|
||||
if (dubStep === 'generating') { toast(t('update.busy'), { icon: '⏳' }); return; }
|
||||
installUpdate(useAppStore.getState());
|
||||
return;
|
||||
}
|
||||
onOpen?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`update-chip update-chip--${p.variant}`}
|
||||
onClick={onClick}
|
||||
title={t('updates.tab')}
|
||||
>
|
||||
<Icon size={12} className={p.icon === 'spin' ? 'spinner' : ''} />
|
||||
<span className="update-chip__label">{labelText}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/* frontend/src/components/UpdatesPanel.css */
|
||||
.updates-panel { padding: 8px 12px; overflow-y: auto; font-size: 12px; color: #d5c4a1; }
|
||||
.updates-panel__live { display: flex; align-items: center; min-height: 26px; }
|
||||
.updates-panel__cta {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
background: rgba(184,187,38,0.14); border: 1px solid rgba(184,187,38,0.5);
|
||||
color: #b8bb26; border-radius: 999px; padding: 3px 10px; font: inherit; cursor: pointer;
|
||||
}
|
||||
.updates-panel__ok, .updates-panel__err, .updates-panel__progress { display: inline-flex; align-items: center; gap: 8px; }
|
||||
.updates-panel__err { color: #fb4934; }
|
||||
.updates-panel__link { background: none; border: none; color: #83a598; cursor: pointer; font: inherit; display: inline-flex; align-items: center; gap: 3px; }
|
||||
.updates-panel__icon { background: none; border: none; color: #fb4934; cursor: pointer; padding: 0 2px; }
|
||||
.updates-panel__bar { display: inline-block; width: 80px; height: 3px; background: rgba(255,255,255,0.12); border-radius: 2px; overflow: hidden; }
|
||||
.updates-panel__bar span { display: block; height: 100%; background: #b8bb26; }
|
||||
.updates-panel__channel { display: flex; align-items: center; gap: 10px; margin: 8px 0; }
|
||||
.updates-panel__seg { display: inline-flex; border: 1px solid var(--chrome-border, #3a3a3a); border-radius: 6px; overflow: hidden; }
|
||||
.updates-panel__segbtn { background: none; border: none; color: var(--text-dim, #a89984); padding: 2px 10px; font: inherit; cursor: pointer; }
|
||||
.updates-panel__segbtn.is-active { background: rgba(131,165,152,0.18); color: #ebdbb2; }
|
||||
.updates-panel__rel-head { font-weight: 600; color: var(--text-dim, #a89984); margin: 6px 0 4px; }
|
||||
.updates-panel__rel-empty { color: var(--text-dim, #a89984); display: inline-flex; gap: 8px; align-items: center; padding: 6px 0; }
|
||||
.updates-panel__rel { border-top: 1px solid var(--chrome-border, #2a2a2a); padding: 6px 0; }
|
||||
.updates-panel__rel.is-current { background: rgba(184,187,38,0.06); }
|
||||
.updates-panel__rel-row { display: flex; align-items: center; gap: 8px; }
|
||||
.updates-panel__rel-ver { font-weight: 600; }
|
||||
.updates-panel__rel-tag { font-size: 10px; color: #b8bb26; border: 1px solid rgba(184,187,38,0.5); border-radius: 999px; padding: 0 6px; }
|
||||
.updates-panel__rel-pre { font-size: 10px; color: #d79921; border: 1px solid rgba(215,153,33,0.5); border-radius: 999px; padding: 0 6px; }
|
||||
.updates-panel__rel-date { margin-left: auto; color: var(--text-dim, #a89984); }
|
||||
.updates-panel__rel-notes { white-space: pre-wrap; overflow-wrap: break-word; margin: 4px 0 0; font-size: 11px; line-height: 1.4; color: #bdae93; }
|
||||
@@ -0,0 +1,117 @@
|
||||
// frontend/src/components/UpdatesPanel.jsx
|
||||
// Body of the LogsFooter "Updates" tab: live update row + channel switcher +
|
||||
// GitHub releases (changelog/history) list. Data is transient (releasesSlice).
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Download, RotateCw, AlertTriangle, RefreshCw, X } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useAppStore } from '../store';
|
||||
import { installUpdate, checkForUpdate } from '../utils/updater';
|
||||
import { prepareReleases } from '../utils/updatePresentation';
|
||||
import { setChannel } from '../utils/channelControl';
|
||||
import './UpdatesPanel.css';
|
||||
|
||||
export default function UpdatesPanel() {
|
||||
const { t } = useTranslation();
|
||||
const status = useAppStore((s) => s.updateStatus);
|
||||
const version = useAppStore((s) => s.updateVersion);
|
||||
const error = useAppStore((s) => s.updateError);
|
||||
const progress = useAppStore((s) => s.updateProgress);
|
||||
const appVersion = useAppStore((s) => s.appVersion);
|
||||
const channel = useAppStore((s) => s.updateChannel);
|
||||
const releases = useAppStore((s) => s.releases);
|
||||
const releasesStatus = useAppStore((s) => s.releasesStatus);
|
||||
const loadReleases = useAppStore((s) => s.loadReleases);
|
||||
const dismissUpdate = useAppStore((s) => s.dismissUpdate);
|
||||
const dubStep = useAppStore((s) => s.dubStep);
|
||||
|
||||
useEffect(() => { loadReleases(channel); }, [channel, loadReleases]);
|
||||
|
||||
const busy = dubStep === 'generating';
|
||||
const onInstall = () => {
|
||||
if (busy) { toast(t('update.busy'), { icon: '⏳' }); return; }
|
||||
installUpdate(useAppStore.getState());
|
||||
};
|
||||
const rows = prepareReleases(releases, channel, appVersion);
|
||||
|
||||
return (
|
||||
<div className="updates-panel">
|
||||
<div className="updates-panel__live">
|
||||
{status === 'available' && (
|
||||
<button className="updates-panel__cta" onClick={onInstall}>
|
||||
<Download size={13} /> {t('update.available', { version: version || '' })} · {t('update.install')}
|
||||
</button>
|
||||
)}
|
||||
{status === 'downloading' && (
|
||||
<span className="updates-panel__progress">
|
||||
{t('update.downloading', { pct: Math.round(progress) })}
|
||||
<span className="updates-panel__bar"><span style={{ width: `${progress}%` }} /></span>
|
||||
</span>
|
||||
)}
|
||||
{status === 'ready' && (
|
||||
<button className="updates-panel__cta" onClick={onInstall}>
|
||||
<RotateCw size={13} /> {t('update.restart')}
|
||||
</button>
|
||||
)}
|
||||
{status === 'error' && (
|
||||
<span className="updates-panel__err">
|
||||
<AlertTriangle size={13} /> {error || t('update.failed')}
|
||||
<button className="updates-panel__link" onClick={onInstall}>{t('update.retry')}</button>
|
||||
<button className="updates-panel__icon" onClick={dismissUpdate} aria-label={t('update.dismiss')}><X size={13} /></button>
|
||||
</span>
|
||||
)}
|
||||
{(status === 'idle' || status === 'checking') && (
|
||||
<span className="updates-panel__ok">
|
||||
{t('updates.up_to_date', { version: appVersion || '' })}
|
||||
<button className="updates-panel__link" onClick={() => checkForUpdate(useAppStore.getState())}>
|
||||
<RefreshCw size={12} /> {t('updates.check_now')}
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="updates-panel__channel">
|
||||
<span>{t('about.update_channel')}</span>
|
||||
<div className="updates-panel__seg" role="radiogroup" aria-label={t('about.update_channel')}>
|
||||
{['stable', 'preview'].map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={channel === c}
|
||||
className={`updates-panel__segbtn ${channel === c ? 'is-active' : ''}`}
|
||||
onClick={() => setChannel(useAppStore.getState(), c).catch((e) => toast(t('settings.channel_set_failed', { message: e?.message || e }), { icon: '⚠️' }))}
|
||||
>
|
||||
{t(`about.channel_${c}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="updates-panel__releases">
|
||||
<div className="updates-panel__rel-head">{t('updates.releases')}</div>
|
||||
{releasesStatus === 'error' && (
|
||||
<div className="updates-panel__rel-empty">
|
||||
{t('updates.load_error')}
|
||||
<button className="updates-panel__link" onClick={() => loadReleases(channel)}>{t('updates.retry_load')}</button>
|
||||
</div>
|
||||
)}
|
||||
{releasesStatus === 'loading' && <div className="updates-panel__rel-empty">{t('updates.loading')}</div>}
|
||||
{releasesStatus === 'loaded' && rows.length === 0 && (
|
||||
<div className="updates-panel__rel-empty">{t('updates.none')}</div>
|
||||
)}
|
||||
{rows.map((r) => (
|
||||
<div key={r.name || r.version} className={`updates-panel__rel ${r.current ? 'is-current' : ''}`}>
|
||||
<div className="updates-panel__rel-row">
|
||||
<span className="updates-panel__rel-ver">v{r.version}</span>
|
||||
{r.current && <span className="updates-panel__rel-tag">{t('updates.current')}</span>}
|
||||
{r.prerelease && <span className="updates-panel__rel-pre">{t('updates.prerelease')}</span>}
|
||||
<span className="updates-panel__rel-date">{r.date}</span>
|
||||
</div>
|
||||
{r.notes && <pre className="updates-panel__rel-notes">{r.notes}</pre>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useRef, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Volume2, Play, Square, Loader, X, Mic } from 'lucide-react';
|
||||
import { generateSpeech } from '../api/generate';
|
||||
import { PRESETS } from '../utils/constants';
|
||||
@@ -12,7 +13,6 @@ import './VoicePreview.css';
|
||||
* sentence, hits Play → hears TTS output instantly (8 inference steps for
|
||||
* speed). The result is disposable — it doesn't save to history.
|
||||
*/
|
||||
const DEFAULT_TEXT = 'Hello! This is a preview of how I sound in this voice.';
|
||||
|
||||
export default function VoicePreview({
|
||||
open,
|
||||
@@ -21,7 +21,8 @@ export default function VoicePreview({
|
||||
initialProfileId = '',
|
||||
fileToMediaUrl,
|
||||
}) {
|
||||
const [text, setText] = useState(DEFAULT_TEXT);
|
||||
const { t } = useTranslation();
|
||||
const [text, setText] = useState(() => t('voicePreview.default_text'));
|
||||
const [voiceId, setVoiceId] = useState(initialProfileId);
|
||||
const [audioUrl, setAudioUrl] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -106,13 +107,13 @@ export default function VoicePreview({
|
||||
<div className="voice-preview">
|
||||
<div className="voice-preview__head">
|
||||
<span className="voice-preview__title">
|
||||
<Volume2 size={13} /> Voice Preview
|
||||
<Volume2 size={13} /> {t('voicePreview.title')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="voice-preview__close"
|
||||
onClick={onClose}
|
||||
aria-label="Close preview"
|
||||
aria-label={t('voicePreview.close')}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
@@ -124,23 +125,23 @@ export default function VoicePreview({
|
||||
value={voiceId}
|
||||
onChange={e => setVoiceId(e.target.value)}
|
||||
>
|
||||
<option value="">Default voice</option>
|
||||
<option value="">{t('voicePreview.default_voice')}</option>
|
||||
{profiles.filter(p => !p.instruct).length > 0 && (
|
||||
<optgroup label="Clone Profiles">
|
||||
<optgroup label={t('voicePreview.clone_profiles')}>
|
||||
{profiles.filter(p => !p.instruct).map(p => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
{profiles.filter(p => !!p.instruct).length > 0 && (
|
||||
<optgroup label="Designed Voices">
|
||||
<optgroup label={t('voicePreview.designed_voices')}>
|
||||
{profiles.filter(p => !!p.instruct).map(p => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
{PRESETS.length > 0 && (
|
||||
<optgroup label="Presets">
|
||||
<optgroup label={t('voicePreview.presets')}>
|
||||
{PRESETS.map(p => (
|
||||
<option key={p.id} value={`preset:${p.id}`}>{p.name}</option>
|
||||
))}
|
||||
@@ -153,7 +154,7 @@ export default function VoicePreview({
|
||||
value={text}
|
||||
onChange={e => setText(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="Type something to hear…"
|
||||
placeholder={t('voicePreview.placeholder')}
|
||||
spellCheck={false}
|
||||
/>
|
||||
|
||||
@@ -173,7 +174,7 @@ export default function VoicePreview({
|
||||
<div className="voice-preview__foot">
|
||||
{loading ? (
|
||||
<Button variant="ghost" size="sm" onClick={handleStop} leading={<Square size={10} />}>
|
||||
Stop
|
||||
{t('voicePreview.stop')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
@@ -184,11 +185,12 @@ export default function VoicePreview({
|
||||
loading={loading}
|
||||
leading={!loading && <Play size={10} />}
|
||||
>
|
||||
{audioUrl ? 'Regenerate' : 'Preview'}
|
||||
{audioUrl ? t('voicePreview.regenerate') : t('voicePreview.preview')}
|
||||
</Button>
|
||||
)}
|
||||
<span className="voice-preview__hint">8 steps · fast preview</span>
|
||||
<span className="voice-preview__hint">{t('voicePreview.hint')}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* WizardLibrary — the first-run "stock the studio" act as ONE unified list.
|
||||
*
|
||||
* Models and engines are different things (weights vs backends), but the
|
||||
* user's question is singular — "what do I need to get?" — so every
|
||||
* installable is a row of the same grammar:
|
||||
*
|
||||
* LED · name · chip (required / engine / optional) · size · one action
|
||||
*
|
||||
* Required models lead (they gate the wizard's continue), the TTS engines
|
||||
* follow (Use = switch, heavy installs deferred to Settings), and the long
|
||||
* tail of optional models folds behind a quiet count. Live download
|
||||
* progress rides the same SSE stream the Settings model store uses; the
|
||||
* full management surface (search, HF token, deletes) stays in Settings —
|
||||
* a first run needs a checklist, not a store.
|
||||
*/
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useModels, useInstallModel } from '../api/hooks';
|
||||
import { setupDownloadStreamUrl } from '../api/setup';
|
||||
import { listEngines, selectEngine } from '../api/engines';
|
||||
|
||||
const fmtGB = (gb) => (gb == null ? '' : `${gb.toFixed(gb < 10 ? 1 : 0)} GB`);
|
||||
|
||||
/** Aggregate one repo's SSE file events: percent done + ETA from rates. */
|
||||
function aggregate(files) {
|
||||
let done = 0;
|
||||
let total = 0;
|
||||
let rate = 0;
|
||||
for (const f of Object.values(files)) {
|
||||
done += f.downloaded || 0;
|
||||
total += f.total || 0;
|
||||
if ((f.total || 0) > (f.downloaded || 0)) rate += f.rate || 0;
|
||||
}
|
||||
const pct = total > 0 ? Math.min(100, Math.round((done / total) * 100)) : null;
|
||||
const etaSec = rate > 0 && total > done ? (total - done) / rate : null;
|
||||
return { pct, etaSec };
|
||||
}
|
||||
|
||||
function formatEta(seconds) {
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) return '';
|
||||
if (seconds < 60) return '<1m';
|
||||
return `${Math.round(seconds / 60)}m`;
|
||||
}
|
||||
|
||||
function Row({ led, name, chip, chipTone, size, action, sub }) {
|
||||
return (
|
||||
<div className="frs-row swiz-lib__row">
|
||||
<span className={`swiz-lib__led swiz-lib__led--${led}`} aria-hidden="true" />
|
||||
<div className="frs-row__text">
|
||||
<span className="frs-row__label">
|
||||
{name}
|
||||
{chip && <span className={`frs-opt__badge swiz-lib__chip swiz-lib__chip--${chipTone}`}>{chip}</span>}
|
||||
</span>
|
||||
{sub && <span className="swiz-lib__sub">{sub}</span>}
|
||||
</div>
|
||||
<span className="frs-row__readout">{size}</span>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WizardLibrary() {
|
||||
const { t } = useTranslation();
|
||||
const modelsQuery = useModels();
|
||||
const installMutation = useInstallModel();
|
||||
const [engines, setEngines] = useState(null);
|
||||
const [progress, setProgress] = useState({}); // { repo_id: { phase, files } }
|
||||
const [showTail, setShowTail] = useState(false);
|
||||
const [switching, setSwitching] = useState(null);
|
||||
const esRef = useRef(null);
|
||||
|
||||
const models = useMemo(() => {
|
||||
const list = modelsQuery.data;
|
||||
return Array.isArray(list) ? list : (list?.models ?? []);
|
||||
}, [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());
|
||||
esRef.current = es;
|
||||
es.onmessage = (evt) => {
|
||||
try {
|
||||
const ev = JSON.parse(evt.data);
|
||||
if (!ev?.repo_id) return;
|
||||
setProgress((prev) => {
|
||||
const cur = prev[ev.repo_id] || { phase: 'active', files: {} };
|
||||
if (ev.phase === 'install_start') return { ...prev, [ev.repo_id]: { phase: 'active', files: {} } };
|
||||
if (ev.phase === 'install_done' || ev.phase === 'install_error') {
|
||||
if (ev.phase === 'install_done') modelsQuery.refetch();
|
||||
const next = { ...prev };
|
||||
delete next[ev.repo_id];
|
||||
return next;
|
||||
}
|
||||
if (!ev.filename) return prev;
|
||||
const files = { ...cur.files, [ev.filename]: { downloaded: ev.downloaded || 0, total: ev.total || 0, rate: ev.rate || 0 } };
|
||||
return { ...prev, [ev.repo_id]: { ...cur, files } };
|
||||
});
|
||||
} catch { /* keepalive */ }
|
||||
};
|
||||
return () => es.close();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const install = (repoId) => {
|
||||
setProgress((p) => ({ ...p, [repoId]: { phase: 'active', files: {} } }));
|
||||
installMutation.mutate(repoId, {
|
||||
onError: (e) => {
|
||||
toast.error(e?.message || 'install failed');
|
||||
setProgress((p) => { const n = { ...p }; delete n[repoId]; return n; });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const useEngine = async (id) => {
|
||||
setSwitching(id);
|
||||
try {
|
||||
const r = await selectEngine('tts', id);
|
||||
setEngines((e) => (e ? { ...e, active: r.active } : e));
|
||||
} catch (e) {
|
||||
toast.error(e?.message || 'switch failed');
|
||||
} finally {
|
||||
setSwitching(null);
|
||||
}
|
||||
};
|
||||
|
||||
const supported = models.filter((m) => m.supported !== false);
|
||||
const required = supported.filter((m) => m.required);
|
||||
const optional = supported.filter((m) => !m.required);
|
||||
|
||||
const modelRow = (m, chip, chipTone) => {
|
||||
const p = progress[m.repo_id];
|
||||
const { pct, etaSec } = p ? aggregate(p.files) : { pct: null, etaSec: null };
|
||||
const downloading = !!p;
|
||||
return (
|
||||
<Row
|
||||
key={m.repo_id}
|
||||
led={m.installed ? 'ok' : downloading ? 'busy' : 'off'}
|
||||
name={m.label}
|
||||
chip={chip}
|
||||
chipTone={chipTone}
|
||||
size={fmtGB(m.size_gb)}
|
||||
sub={downloading ? (
|
||||
<span className="swiz-lib__bar"><span style={{ width: `${pct ?? 4}%` }} /></span>
|
||||
) : null}
|
||||
action={m.installed ? (
|
||||
<span className="swiz-lib__state">✓</span>
|
||||
) : downloading ? (
|
||||
<span className="swiz-lib__state swiz-lib__state--busy">
|
||||
{pct != null ? `${pct}%` : t('firstrun.lib_downloading', 'downloading…')}
|
||||
{etaSec != null && ` · ${t('firstrun.eta_left', { eta: formatEta(etaSec), defaultValue: '~{{eta}} left' })}`}
|
||||
</span>
|
||||
) : (
|
||||
<button type="button" className="frs-btn frs-btn--quiet swiz-lib__act" onClick={() => install(m.repo_id)}>
|
||||
{t('firstrun.lib_download', 'Download')}
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="swiz-lib">
|
||||
{required.map((m) => modelRow(m, t('firstrun.chip_required', 'required'), 'req'))}
|
||||
|
||||
{(engines?.backends ?? []).map((b) => (
|
||||
<Row
|
||||
key={b.id}
|
||||
led={b.id === engines.active ? 'active' : b.available ? 'ok' : 'off'}
|
||||
name={b.display_name}
|
||||
chip={t('firstrun.chip_engine', 'engine')}
|
||||
chipTone="eng"
|
||||
size=""
|
||||
action={b.id === engines.active ? (
|
||||
<span className="swiz-lib__state swiz-lib__state--active">{t('firstrun.lib_active', 'active')}</span>
|
||||
) : b.available ? (
|
||||
<button
|
||||
type="button"
|
||||
className="frs-btn frs-btn--quiet swiz-lib__act"
|
||||
disabled={switching === b.id}
|
||||
onClick={() => useEngine(b.id)}
|
||||
>
|
||||
{t('firstrun.lib_use', 'Use')}
|
||||
</button>
|
||||
) : (
|
||||
<span className="swiz-lib__state" title={b.reason || undefined}>
|
||||
{t('firstrun.lib_in_settings', 'install later in Settings')}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{optional.length > 0 && !showTail && (
|
||||
<button type="button" className="frs-btn frs-btn--quiet swiz-lib__more" onClick={() => setShowTail(true)}>
|
||||
▸ {t('firstrun.lib_show_all', { count: optional.length, defaultValue: 'Show {{count}} optional models' })}
|
||||
</button>
|
||||
)}
|
||||
{showTail && optional.map((m) => modelRow(m, t('firstrun.chip_optional', 'optional'), 'opt'))}
|
||||
{Object.keys(progress).length > 0 && (
|
||||
<p className="frs__trust">
|
||||
{t('firstrun.resume_note', 'Interrupted downloads resume automatically — closing the app is safe.')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import { copyText } from "../../utils/copyText";
|
||||
import QRCode from 'qrcode';
|
||||
import { Wifi, Globe, Copy, ExternalLink } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { apiJson, apiPost } from '../../api/client';
|
||||
import { openExternal } from '../../api/external';
|
||||
import NetworkToggle from '../NetworkToggle';
|
||||
@@ -29,6 +30,7 @@ import './SharingPanel.css';
|
||||
const TAILSCALE_DOWNLOAD_URL = 'https://tailscale.com/download';
|
||||
|
||||
export default function SharingPanel() {
|
||||
const { t } = useTranslation();
|
||||
const [status, setStatus] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -62,16 +64,16 @@ export default function SharingPanel() {
|
||||
const saveSharePort = async () => {
|
||||
const n = Number(sharePortInput);
|
||||
if (!Number.isInteger(n) || n < 1024 || n > 65535) {
|
||||
toast.error('Enter a port between 1024 and 65535');
|
||||
toast.error(t('sharing.port_error'));
|
||||
return;
|
||||
}
|
||||
setSavingPort(true);
|
||||
try {
|
||||
await apiPost('/system/set-env', { key: 'OMNIVOICE_SHARE_PORT', value: String(n) });
|
||||
setPorts((p) => (p ? { ...p, share_port_base: n } : p));
|
||||
toast.success('LAN-share port saved — applies next time you enable sharing');
|
||||
toast.success(t('sharing.port_saved'));
|
||||
} catch (e) {
|
||||
toast.error(`Could not save port: ${e.message}`);
|
||||
toast.error(t('sharing.port_save_failed', { message: e.message }));
|
||||
} finally {
|
||||
setSavingPort(false);
|
||||
}
|
||||
@@ -120,13 +122,13 @@ export default function SharingPanel() {
|
||||
if (r?.ok) {
|
||||
setUrl(r.url || '');
|
||||
setNote(r.note || '');
|
||||
toast.success('Tailscale serve enabled');
|
||||
toast.success(t('sharing.tailscale_enabled'));
|
||||
await refresh();
|
||||
} else {
|
||||
toast.error(r?.error || 'Could not enable Tailscale');
|
||||
toast.error(r?.error || t('sharing.tailscale_enable_failed'));
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(`Could not enable Tailscale: ${e.message}`);
|
||||
toast.error(t('sharing.tailscale_enable_error', { message: e.message }));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -137,21 +139,21 @@ export default function SharingPanel() {
|
||||
try {
|
||||
const r = await apiPost('/system/tailscale/disable');
|
||||
if (r && r.ok === false) {
|
||||
toast.error(r.error || 'Could not disable Tailscale');
|
||||
toast.error(r.error || t('sharing.tailscale_disable_failed'));
|
||||
} else {
|
||||
setUrl('');
|
||||
setNote('');
|
||||
toast.success('Tailscale serve disabled');
|
||||
toast.success(t('sharing.tailscale_disabled'));
|
||||
}
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
toast.error(`Could not disable Tailscale: ${e.message}`);
|
||||
toast.error(t('sharing.tailscale_disable_error', { message: e.message }));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copy = (text) => { copyText(text); toast.success('Copied'); };
|
||||
const copy = (text) => { copyText(text); toast.success(t('sharing.copied')); };
|
||||
|
||||
const installed = !!status?.installed;
|
||||
const running = !!status?.running;
|
||||
@@ -159,23 +161,20 @@ export default function SharingPanel() {
|
||||
return (
|
||||
<section className="sharingpanel" aria-labelledby="sharingpanel-heading">
|
||||
<h3 id="sharingpanel-heading" className="sharingpanel__title">
|
||||
<Wifi size={14} /> Sharing & Remote Access
|
||||
<Wifi size={14} /> {t('sharing.title')}
|
||||
</h3>
|
||||
|
||||
<p className="sharingpanel__help">
|
||||
Expose this running OmniVoice instance to your other machines without
|
||||
restarting it. Loopback-only is the default — nothing is shared until
|
||||
you turn it on here.
|
||||
{t('sharing.help')}
|
||||
</p>
|
||||
|
||||
{/* ── LAN sharing ──────────────────────────────────────────────── */}
|
||||
<div className="sharingpanel__section" data-testid="sharing-lan">
|
||||
<h4 className="sharingpanel__subtitle">
|
||||
<Wifi size={12} /> Local network
|
||||
<Wifi size={12} /> {t('sharing.local_network')}
|
||||
</h4>
|
||||
<p className="sharingpanel__subhelp">
|
||||
Share on your Wi-Fi / Ethernet with a one-time access PIN. Other
|
||||
devices scan the QR code or open the link.
|
||||
{t('sharing.local_help')}
|
||||
</p>
|
||||
<NetworkToggle />
|
||||
</div>
|
||||
@@ -184,27 +183,26 @@ export default function SharingPanel() {
|
||||
{ports && (
|
||||
<div className="sharingpanel__section" data-testid="sharing-ports">
|
||||
<h4 className="sharingpanel__subtitle">
|
||||
<Globe size={12} /> Ports
|
||||
<Globe size={12} /> {t('sharing.ports_title')}
|
||||
</h4>
|
||||
<p className="sharingpanel__subhelp">
|
||||
These are set via environment variables read at startup. Change the
|
||||
backend or UI port by setting the variable and restarting OmniVoice.
|
||||
{t('sharing.ports_help')}
|
||||
</p>
|
||||
|
||||
<div className="sharingpanel__row">
|
||||
<span>Backend port</span>
|
||||
<span>{t('sharing.backend_port')}</span>
|
||||
<code className="sharingpanel__addr" data-testid="port-backend">{ports.backend_port}</code>
|
||||
<code className="sharingpanel__envname">OMNIVOICE_PORT</code>
|
||||
</div>
|
||||
|
||||
<div className="sharingpanel__row">
|
||||
<span>UI port</span>
|
||||
<span>{t('sharing.ui_port')}</span>
|
||||
<code className="sharingpanel__addr" data-testid="port-ui">{ports.ui_port}</code>
|
||||
<code className="sharingpanel__envname">OMNIVOICE_UI_PORT</code>
|
||||
</div>
|
||||
|
||||
<div className="sharingpanel__row">
|
||||
<label htmlFor="share-port-input">LAN-share port</label>
|
||||
<label htmlFor="share-port-input">{t('sharing.lan_share_port')}</label>
|
||||
<input
|
||||
id="share-port-input"
|
||||
type="number"
|
||||
@@ -223,12 +221,11 @@ export default function SharingPanel() {
|
||||
disabled={savingPort}
|
||||
data-testid="port-share-save"
|
||||
>
|
||||
{savingPort ? 'Saving…' : 'Save'}
|
||||
{savingPort ? t('sharing.saving') : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
<p className="sharingpanel__note">
|
||||
Backend and UI ports apply on restart. The LAN-share port applies
|
||||
next time you enable sharing.
|
||||
{t('sharing.ports_note')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -236,18 +233,17 @@ export default function SharingPanel() {
|
||||
{/* ── Tailscale ────────────────────────────────────────────────── */}
|
||||
<div className="sharingpanel__section" data-testid="sharing-tailscale">
|
||||
<h4 className="sharingpanel__subtitle">
|
||||
<Globe size={12} /> Tailscale (private remote access)
|
||||
<Globe size={12} /> {t('sharing.tailscale_title')}
|
||||
</h4>
|
||||
|
||||
{loading && !status && (
|
||||
<p className="sharingpanel__subhelp">Checking for Tailscale…</p>
|
||||
<p className="sharingpanel__subhelp">{t('sharing.tailscale_checking')}</p>
|
||||
)}
|
||||
|
||||
{status && !installed && (
|
||||
<div className="sharingpanel__tailscale-absent" data-testid="tailscale-absent">
|
||||
<p className="sharingpanel__subhelp">
|
||||
Tailscale not detected. Install it to reach OmniVoice securely
|
||||
from anywhere on your private tailnet.
|
||||
{t('sharing.tailscale_absent')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
@@ -255,7 +251,7 @@ export default function SharingPanel() {
|
||||
onClick={() => openExternal(TAILSCALE_DOWNLOAD_URL)}
|
||||
data-testid="tailscale-install"
|
||||
>
|
||||
<ExternalLink size={12} /> Install Tailscale
|
||||
<ExternalLink size={12} /> {t('sharing.tailscale_install')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -264,8 +260,8 @@ export default function SharingPanel() {
|
||||
<div className="sharingpanel__tailscale-present">
|
||||
<p className="sharingpanel__subhelp">
|
||||
{running
|
||||
? 'Tailscale is running. Serve OmniVoice over your private tailnet.'
|
||||
: 'Tailscale is installed but not logged in. Start and sign in to Tailscale first.'}
|
||||
? t('sharing.tailscale_running')
|
||||
: t('sharing.tailscale_not_logged_in')}
|
||||
</p>
|
||||
|
||||
{!url ? (
|
||||
@@ -276,7 +272,7 @@ export default function SharingPanel() {
|
||||
disabled={busy}
|
||||
data-testid="tailscale-enable"
|
||||
>
|
||||
{busy ? 'Enabling…' : 'Enable Tailscale serve'}
|
||||
{busy ? t('sharing.tailscale_enabling') : t('sharing.tailscale_enable_btn')}
|
||||
</button>
|
||||
) : (
|
||||
<div className="sharingpanel__tailscale-url">
|
||||
@@ -286,8 +282,8 @@ export default function SharingPanel() {
|
||||
type="button"
|
||||
className="sharingpanel__iconbtn"
|
||||
onClick={() => copy(url)}
|
||||
aria-label="Copy Tailscale URL"
|
||||
title="Copy link"
|
||||
aria-label={t('sharing.tailscale_copy')}
|
||||
title={t('sharing.tailscale_copy')}
|
||||
data-testid="tailscale-copy"
|
||||
>
|
||||
<Copy size={12} />
|
||||
@@ -296,8 +292,8 @@ export default function SharingPanel() {
|
||||
type="button"
|
||||
className="sharingpanel__iconbtn"
|
||||
onClick={() => openExternal(url)}
|
||||
aria-label="Open Tailscale URL"
|
||||
title="Open in browser"
|
||||
aria-label={t('sharing.tailscale_open')}
|
||||
title={t('sharing.tailscale_open')}
|
||||
data-testid="tailscale-open"
|
||||
>
|
||||
<ExternalLink size={12} />
|
||||
@@ -308,7 +304,7 @@ export default function SharingPanel() {
|
||||
<img
|
||||
className="sharingpanel__qr"
|
||||
src={qr}
|
||||
alt="QR code for the Tailscale URL"
|
||||
alt={t('sharing.tailscale_qr_alt')}
|
||||
width={104}
|
||||
height={104}
|
||||
/>
|
||||
@@ -320,7 +316,7 @@ export default function SharingPanel() {
|
||||
disabled={busy}
|
||||
data-testid="tailscale-disable"
|
||||
>
|
||||
{busy ? 'Disabling…' : 'Stop Tailscale serve'}
|
||||
{busy ? t('sharing.tailscale_disabling') : t('sharing.tailscale_disable_btn')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -10,6 +10,10 @@ import { apiPost } from '../api/client';
|
||||
import { API } from '../api/client';
|
||||
import { playPing, isTauri } from '../utils/media';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { toastErrorWithReport } from '../utils/errorToast';
|
||||
import { addBreadcrumb } from '../utils/breadcrumbs';
|
||||
import i18next from 'i18next';
|
||||
const t = i18next.t.bind(i18next);
|
||||
|
||||
/**
|
||||
* Encapsulates the entire dub pipeline workflow:
|
||||
@@ -66,7 +70,11 @@ export default function useDubWorkflow({ loadProjects, loadProfiles, loadDubHist
|
||||
|
||||
// ── SSE: wait for transcription stream ──
|
||||
const _waitForTranscribe = useCallback((jobId, ctrl) => new Promise((resolve, reject) => {
|
||||
const evt = new EventSource(transcribeStreamUrl(jobId));
|
||||
// Read the optional speaker-count hint at stream-open time (#274) so the
|
||||
// user's choice for this job is honoured without threading it through the
|
||||
// three call sites. null → pyannote auto-detect.
|
||||
const numSpeakers = useAppStore.getState().dubNumSpeakers;
|
||||
const evt = new EventSource(transcribeStreamUrl(jobId, numSpeakers));
|
||||
let gotFinal = false;
|
||||
const close = () => { try { evt.close(); } catch {} };
|
||||
const onAbortSignal = () => { close(); reject(Object.assign(new Error('aborted'), { name: 'AbortError' })); };
|
||||
@@ -198,7 +206,7 @@ export default function useDubWorkflow({ loadProjects, loadProfiles, loadDubHist
|
||||
// ── Handlers ──
|
||||
const handleDubUpload = useCallback(async (dubVideoFile) => {
|
||||
if (!dubVideoFile) return;
|
||||
setDubStep('uploading'); setDubError(''); setDubFailure(null); setDubTracks([]); setDubPrepStage('download');
|
||||
addBreadcrumb('dub:upload'); setDubStep('uploading'); setDubError(''); setDubFailure(null); setDubTracks([]); setDubPrepStage('download');
|
||||
setDubPrepProgress({ percent: null, speedBps: null, etaS: null, stageStartedAt: Date.now() });
|
||||
const ctrl = new AbortController();
|
||||
dubAbortCtrlRef.current = ctrl;
|
||||
@@ -206,24 +214,24 @@ export default function useDubWorkflow({ loadProjects, loadProfiles, loadDubHist
|
||||
dubClientJobIdRef.current = clientJobId;
|
||||
setDubJobId(clientJobId);
|
||||
const inputType = useAppStore.getState().dubInputType || 'video'; // #119
|
||||
useAppStore.getState().showPill('loading-model', inputType === 'audio' ? 'Preparing audio…' : 'Preparing video…', { cancellable: true });
|
||||
useAppStore.getState().showPill('loading-model', inputType === 'audio' ? t('dub_workflow.preparing_audio') : t('dub_workflow.preparing_video'), { cancellable: true });
|
||||
try {
|
||||
const data = await dubUpload(dubVideoFile, clientJobId, { signal: ctrl.signal, inputType });
|
||||
setDubJobId(data.job_id); if (data.filename) setDubFilename(data.filename);
|
||||
setDubTaskId(data.task_id); setDubPrepStage('extract');
|
||||
useAppStore.getState().showPill('loading-model', 'Extracting audio & scenes…', { cancellable: true });
|
||||
useAppStore.getState().showPill('loading-model', t('dub_workflow.extracting_audio_scenes'), { cancellable: true });
|
||||
await _waitForPrep(data.task_id, ctrl);
|
||||
setDubStep('transcribing'); setDubPrepStage(null);
|
||||
setTranscribeStart(Date.now()); setDubSegments([]);
|
||||
useAppStore.getState().showPill('transcribing', 'Transcribing audio…', { cancellable: true });
|
||||
useAppStore.getState().showPill('transcribing', t('dub_workflow.transcribing_audio'), { cancellable: true });
|
||||
await _waitForTranscribe(data.job_id, ctrl);
|
||||
setTranscribeStart(null); setDubStep('editing');
|
||||
useAppStore.getState().completePill('Transcription complete');
|
||||
useAppStore.getState().completePill(t('dub_workflow.transcription_complete'));
|
||||
loadProjects(); loadProfiles();
|
||||
} catch (err) {
|
||||
setDubPrepStage(null);
|
||||
if (err.name === 'AbortError') { toast('Upload cancelled'); setDubStep('idle'); useAppStore.getState().dismissPill(); }
|
||||
else { setDubError(err.message); setDubStep('idle'); toast.error('Upload failed: ' + err.message); useAppStore.getState().errorPill(err.message); }
|
||||
if (err.name === 'AbortError') { toast(t('dub_workflow.upload_cancelled')); setDubStep('idle'); useAppStore.getState().dismissPill(); }
|
||||
else { setDubError(err.message); setDubStep('idle'); toastErrorWithReport(t('dub_workflow.upload_failed', { message: err.message }), err); useAppStore.getState().errorPill(err.message); }
|
||||
setTranscribeStart(null);
|
||||
} finally { dubAbortCtrlRef.current = null; }
|
||||
}, [setDubStep, setDubError, setDubFailure, setDubTracks, setDubPrepStage, setDubJobId, setDubFilename, setDubTaskId, setDubSegments, _waitForPrep, _waitForTranscribe, loadProjects, loadProfiles]);
|
||||
@@ -231,31 +239,31 @@ export default function useDubWorkflow({ loadProjects, loadProfiles, loadDubHist
|
||||
const handleDubIngestUrl = useCallback(async (url, opts = {}) => {
|
||||
const clean = (url || '').trim();
|
||||
if (!clean) return;
|
||||
setDubStep('uploading'); setDubError(''); setDubFailure(null); setDubTracks([]); setDubPrepStage('download');
|
||||
addBreadcrumb('dub:ingest-url'); setDubStep('uploading'); setDubError(''); setDubFailure(null); setDubTracks([]); setDubPrepStage('download');
|
||||
setDubPrepProgress({ percent: null, speedBps: null, etaS: null, stageStartedAt: Date.now() });
|
||||
const ctrl = new AbortController();
|
||||
dubAbortCtrlRef.current = ctrl;
|
||||
const clientJobId = Math.random().toString(36).slice(2, 10);
|
||||
dubClientJobIdRef.current = clientJobId;
|
||||
setDubJobId(clientJobId);
|
||||
useAppStore.getState().showPill('loading-model', 'Downloading video…', { cancellable: true });
|
||||
useAppStore.getState().showPill('loading-model', t('dub_workflow.downloading_video'), { cancellable: true });
|
||||
try {
|
||||
const data = await dubIngestUrl(clean, clientJobId, { signal: ctrl.signal, fetchSubs: !!opts.fetchSubs, subLangs: opts.subLangs });
|
||||
setDubJobId(data.job_id); setDubTaskId(data.task_id);
|
||||
useAppStore.getState().showPill('loading-model', 'Extracting audio & scenes…', { cancellable: true });
|
||||
useAppStore.getState().showPill('loading-model', t('dub_workflow.extracting_audio_scenes'), { cancellable: true });
|
||||
await _waitForPrep(data.task_id, ctrl);
|
||||
setDubStep('transcribing'); setDubPrepStage(null);
|
||||
setTranscribeStart(Date.now()); setDubSegments([]);
|
||||
useAppStore.getState().showPill('transcribing', 'Transcribing audio…', { cancellable: true });
|
||||
useAppStore.getState().showPill('transcribing', t('dub_workflow.transcribing_audio'), { cancellable: true });
|
||||
await _waitForTranscribe(data.job_id, ctrl);
|
||||
setTranscribeStart(null); setDubStep('editing');
|
||||
useAppStore.getState().completePill('Transcription complete');
|
||||
useAppStore.getState().completePill(t('dub_workflow.transcription_complete'));
|
||||
loadProjects(); loadProfiles();
|
||||
toast.success('Ingested ' + clean.slice(0, 60));
|
||||
toast.success(t('dub_workflow.ingested', { url: clean.slice(0, 60) }));
|
||||
} catch (err) {
|
||||
setDubPrepStage(null);
|
||||
if (err.name === 'AbortError') { toast('Ingest cancelled'); setDubStep('idle'); useAppStore.getState().dismissPill(); }
|
||||
else { setDubError(err.message); setDubStep('idle'); toast.error('URL ingest failed: ' + err.message); useAppStore.getState().errorPill(err.message); }
|
||||
if (err.name === 'AbortError') { toast(t('dub_workflow.ingest_cancelled')); setDubStep('idle'); useAppStore.getState().dismissPill(); }
|
||||
else { setDubError(err.message); setDubStep('idle'); toastErrorWithReport(t('dub_workflow.ingest_failed', { message: err.message }), err); useAppStore.getState().errorPill(err.message); }
|
||||
setTranscribeStart(null);
|
||||
} finally { dubAbortCtrlRef.current = null; }
|
||||
}, [setDubStep, setDubError, setDubFailure, setDubTracks, setDubPrepStage, setDubJobId, setDubTaskId, setDubSegments, _waitForPrep, _waitForTranscribe, loadProjects, loadProfiles]);
|
||||
@@ -277,14 +285,14 @@ export default function useDubWorkflow({ loadProjects, loadProfiles, loadDubHist
|
||||
setTranscribeStart(null); setDubStep('editing'); loadProjects();
|
||||
} catch (err) {
|
||||
setTranscribeStart(null);
|
||||
if (err.name === 'AbortError') { toast('Retry cancelled'); setDubStep('idle'); }
|
||||
else { setDubError(err.message); setDubStep('idle'); toast.error('Transcription failed: ' + err.message); }
|
||||
if (err.name === 'AbortError') { toast(t('dub_workflow.retry_cancelled')); setDubStep('idle'); }
|
||||
else { setDubError(err.message); setDubStep('idle'); toastErrorWithReport(t('dub_workflow.transcription_failed', { message: err.message }), err); }
|
||||
} finally { dubAbortCtrlRef.current = null; }
|
||||
}, [dubJobId, setDubError, setDubSegments, setDubStep, _waitForTranscribe, loadProjects]);
|
||||
|
||||
const handleDubImportSrt = useCallback(async (file) => {
|
||||
if (!dubJobId) {
|
||||
toast.error('Upload or ingest a video first — there is no job to attach subtitles to.');
|
||||
toast.error(t('dub_workflow.import_srt_no_job'));
|
||||
return;
|
||||
}
|
||||
if (!file) return;
|
||||
@@ -298,14 +306,14 @@ export default function useDubWorkflow({ loadProjects, loadProfiles, loadDubHist
|
||||
})));
|
||||
setDubStep('editing');
|
||||
const stats = res?.stats || {};
|
||||
const noteParts = [`Imported ${stats.imported ?? segs.length} cue(s) from ${file.name || '.srt'}`];
|
||||
if (stats.skipped_malformed) noteParts.push(`${stats.skipped_malformed} skipped (malformed)`);
|
||||
if (stats.dropped_overlap) noteParts.push(`${stats.dropped_overlap} dropped (overlap)`);
|
||||
if (stats.clamped_to_duration) noteParts.push(`${stats.clamped_to_duration} clamped to media length`);
|
||||
const noteParts = [t('dub_workflow.imported_cues', { count: stats.imported ?? segs.length, file: file.name || '.srt' })];
|
||||
if (stats.skipped_malformed) noteParts.push(t('dub_workflow.skipped_malformed', { count: stats.skipped_malformed }));
|
||||
if (stats.dropped_overlap) noteParts.push(t('dub_workflow.dropped_overlap', { count: stats.dropped_overlap }));
|
||||
if (stats.clamped_to_duration) noteParts.push(t('dub_workflow.clamped_to_duration', { count: stats.clamped_to_duration }));
|
||||
toast.success(noteParts.join(' · '), { duration: 6000 });
|
||||
loadProjects();
|
||||
} catch (err) {
|
||||
const msg = err?.message || 'SRT import failed';
|
||||
const msg = err?.message || t('dub_workflow.srt_import_failed');
|
||||
setDubError(msg);
|
||||
toast.error(msg);
|
||||
}
|
||||
@@ -318,8 +326,8 @@ export default function useDubWorkflow({ loadProjects, loadProfiles, loadDubHist
|
||||
const data = await dubCleanupSegments(dubJobId);
|
||||
setDubSegments(data.segments || []);
|
||||
const delta = before - (data.after ?? data.segments.length);
|
||||
toast.success(delta > 0 ? `Cleaned ${delta} fragment${delta === 1 ? '' : 's'}` : 'Segments already clean');
|
||||
} catch (err) { toast.error('Clean up failed: ' + err.message); }
|
||||
toast.success(delta > 0 ? t('dub_workflow.cleaned', { count: delta }) : t('dub_workflow.segments_clean'));
|
||||
} catch (err) { toast.error(t('dub_workflow.cleanup_failed', { message: err.message })); }
|
||||
}, [dubJobId, dubSegments, setDubSegments]);
|
||||
|
||||
const handleTranslateAll = useCallback(async () => {
|
||||
@@ -361,26 +369,27 @@ export default function useDubWorkflow({ loadProjects, loadProfiles, loadDubHist
|
||||
};
|
||||
}));
|
||||
if (data.cinematic_skipped === 'no-llm-configured') {
|
||||
toast('Cinematic quality needs an LLM — set TRANSLATE_BASE_URL + TRANSLATE_API_KEY (Ollama works locally). Falling back to Fast.', { icon: 'ℹ️', duration: 7000 });
|
||||
toast(t('dub_workflow.cinematic_no_llm'), { icon: 'ℹ️', duration: 7000 });
|
||||
}
|
||||
if (errors.length) {
|
||||
const unique = [...new Set(errors.map(e => e.error))];
|
||||
toast.error(`${errors.length}/${data.translated.length} segment${errors.length === 1 ? '' : 's'} failed: ${unique[0].slice(0, 120)}`, { duration: 6000 });
|
||||
toast.error(t('dub_workflow.translate_errors', { errorCount: errors.length, totalCount: data.translated.length, firstError: unique[0].slice(0, 120) }), { duration: 6000 });
|
||||
} else {
|
||||
const qLabel = data.quality_used === 'cinematic' ? ' (Cinematic)' : '';
|
||||
toast.success(`Translated ${data.translated.length} segment${data.translated.length === 1 ? '' : 's'} → ${data.target_lang}${qLabel}`);
|
||||
const qLabel = data.quality_used === 'cinematic' ? t('dub_workflow.translated_cinematic_suffix') : '';
|
||||
toast.success(t('dub_workflow.translated_segments', { count: data.translated.length, lang: data.target_lang }) + qLabel);
|
||||
}
|
||||
} catch (err) { setDubError('Translation failed: ' + err.message); }
|
||||
} catch (err) { setDubError(t('dub_workflow.translation_failed', { message: err.message })); }
|
||||
setIsTranslating(false);
|
||||
}, [dubSegments, dubLangCode, translateProvider, translateQuality, glossaryTerms, setIsTranslating, setDubSegments, setDubError]);
|
||||
|
||||
const handleDubGenerate = useCallback(async (opts = {}) => {
|
||||
addBreadcrumb('dub:generate');
|
||||
const regenOnly = Array.isArray(opts.regenOnly) && opts.regenOnly.length ? opts.regenOnly : null;
|
||||
const preview = !!opts.preview;
|
||||
setDubStep('generating');
|
||||
setDubProgress({ current: 0, total: dubSegments.length, text: '' });
|
||||
setDubError('');
|
||||
const genLabel = regenOnly ? `Regenerating ${regenOnly.length} segment${regenOnly.length > 1 ? 's' : ''}…` : 'Generating dub…';
|
||||
const genLabel = regenOnly ? t('dub_workflow.regenerating', { count: regenOnly.length }) : t('dub_workflow.generating_dub');
|
||||
useAppStore.getState().showPill('generating', genLabel, { cancellable: true });
|
||||
try {
|
||||
const body = {
|
||||
@@ -423,7 +432,7 @@ export default function useDubWorkflow({ loadProjects, loadProfiles, loadDubHist
|
||||
if (evt.type === 'progress') {
|
||||
setDubProgress({ current: evt.current + 1, total: evt.total, text: evt.text });
|
||||
useAppStore.getState().setPillProgress(Math.round(((evt.current + 1) / evt.total) * 100));
|
||||
useAppStore.getState().setPillLabel(`Generating dub… ${evt.current + 1}/${evt.total}`);
|
||||
useAppStore.getState().setPillLabel(t('dub_workflow.generating_progress', { current: evt.current + 1, total: evt.total }));
|
||||
} else if (evt.type === 'done') {
|
||||
sawDone = true;
|
||||
setDubStep('done');
|
||||
@@ -449,7 +458,7 @@ export default function useDubWorkflow({ loadProjects, loadProfiles, loadDubHist
|
||||
try { const plan = await apiPost('/tools/incremental', { segments: dubSegments.map(s => ({ id: String(s.id), text: s.text, target_lang: s.target_lang, profile_id: s.profile_id, instruct: s.instruct, speed: s.speed, direction: s.direction })) }); setLastGenFingerprints(plan.fingerprints || {}); } catch (err) { console.warn('Incremental plan fallback failed:', err); }
|
||||
}
|
||||
} else if (evt.type === 'cancelled') {
|
||||
wasCancelled = true; setDubStep('editing'); setDubError('Generation aborted.'); toast('Dubbing aborted', { icon: '⏹' });
|
||||
wasCancelled = true; setDubStep('editing'); setDubError(t('dub_workflow.generation_aborted')); toast(t('dub_workflow.dubbing_aborted'), { icon: '⏹' });
|
||||
} else if (evt.type === 'error') setDubError(p => p + `\nSeg ${evt.segment}: ${evt.error}`);
|
||||
} catch (err) { console.warn('Dub generate SSE handler failed:', err); }
|
||||
}
|
||||
@@ -457,10 +466,10 @@ export default function useDubWorkflow({ loadProjects, loadProfiles, loadDubHist
|
||||
}
|
||||
setDubTaskId(null);
|
||||
if (!wasCancelled) {
|
||||
if (!sawDone) throw new Error('Generation stream ended before completion');
|
||||
if (!sawDone) throw new Error(t('dub_workflow.generation_stream_ended'));
|
||||
if (dubStep !== 'done') setDubStep('done');
|
||||
loadDubHistory(); loadProjects(); playPing();
|
||||
useAppStore.getState().completePill('Dub complete');
|
||||
useAppStore.getState().completePill(t('dub_workflow.dub_complete'));
|
||||
} else { useAppStore.getState().dismissPill(); }
|
||||
} catch (err) {
|
||||
setDubError(err.message); setDubStep('editing'); setDubTaskId(null);
|
||||
@@ -476,7 +485,7 @@ export default function useDubWorkflow({ loadProjects, loadProfiles, loadDubHist
|
||||
await tasksCancel(dubTaskId);
|
||||
} catch (e) {
|
||||
setDubStep(prevStep);
|
||||
toast.error('Failed to stop');
|
||||
toast.error(t('dub_workflow.stop_failed'));
|
||||
}
|
||||
}, [dubTaskId, dubStep, setDubStep]);
|
||||
|
||||
|
||||
@@ -6,6 +6,10 @@ import { probeAudioDuration } from '../utils/format';
|
||||
import { CLONE_MAX_SECONDS, PRESETS } from '../utils/constants';
|
||||
import { buildDesignInstruct } from '../utils/voiceInstruct';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { toastErrorWithReport } from '../utils/errorToast';
|
||||
import { addBreadcrumb } from '../utils/breadcrumbs';
|
||||
import i18next from 'i18next';
|
||||
const t = i18next.t.bind(i18next);
|
||||
|
||||
/**
|
||||
* Encapsulates TTS generation logic, streaming response handling,
|
||||
@@ -44,7 +48,7 @@ export default function useTTS({ selectedProfile, setSelectedProfile, loadHistor
|
||||
if (dur && dur > CLONE_MAX_SECONDS) {
|
||||
setPendingTrimFile(file);
|
||||
setSelectedProfile(null);
|
||||
toast(`Audio is ${dur.toFixed(1)}s — trim to ≤${CLONE_MAX_SECONDS}s for best cloning`);
|
||||
toast(t('tts_errors.trim_hint', { duration: dur.toFixed(1), max: CLONE_MAX_SECONDS }));
|
||||
return;
|
||||
}
|
||||
setRefAudio(file);
|
||||
@@ -65,8 +69,9 @@ export default function useTTS({ selectedProfile, setSelectedProfile, loadHistor
|
||||
}, [text, insertTag]);
|
||||
|
||||
const handleGenerate = useCallback(async () => {
|
||||
if (!text.trim()) return toast.error("Please enter text");
|
||||
if (mode === 'clone' && !refAudio && !selectedProfile) return toast.error("Upload an audio or select a voice profile");
|
||||
if (!text.trim()) return toast.error(t('tts_errors.enter_text'));
|
||||
if (mode === 'clone' && !refAudio && !selectedProfile) return toast.error(t('tts_errors.upload_or_select'));
|
||||
addBreadcrumb(`generate:start (${mode})`);
|
||||
setIsGenerating(true);
|
||||
setGenerationTime(0);
|
||||
const st = Date.now();
|
||||
@@ -112,10 +117,10 @@ export default function useTTS({ selectedProfile, setSelectedProfile, loadHistor
|
||||
// the same category" (#114).
|
||||
const { instruct: finalInstruct, unsupported, duplicates } = buildDesignInstruct(vdStates, instruct);
|
||||
if (unsupported.length) {
|
||||
toast(`Ignored unsupported instruct: ${unsupported.join(', ')}`, { icon: '⚠️' });
|
||||
toast(t('tts_errors.ignored_unsupported', { items: unsupported.join(', ') }), { icon: '⚠️' });
|
||||
}
|
||||
if (duplicates.length) {
|
||||
toast(`Ignored (category already set): ${duplicates.join(', ')}`, { icon: '⚠️' });
|
||||
toast(t('tts_errors.ignored_duplicate', { items: duplicates.join(', ') }), { icon: '⚠️' });
|
||||
}
|
||||
if (finalInstruct) formData.append("instruct", finalInstruct);
|
||||
if (selectedProfile) {
|
||||
@@ -154,10 +159,13 @@ export default function useTTS({ selectedProfile, setSelectedProfile, loadHistor
|
||||
setSidebarTab('history');
|
||||
playPing();
|
||||
} catch (err) {
|
||||
const msg = err?.name === 'AbortError'
|
||||
? 'Generation timed out — the model may still be downloading. Check Settings → Logs, then try again.'
|
||||
: ("Error: " + err.message);
|
||||
toast.error(msg);
|
||||
// Timeouts are user-recoverable (retry / shorter input) — plain toast.
|
||||
// Real generation failures get the "Report this bug" action.
|
||||
if (err?.name === 'AbortError') {
|
||||
toast.error(t('tts_errors.timeout'));
|
||||
} else {
|
||||
toastErrorWithReport(t('tts_errors.error_prefix', { message: err.message }), err);
|
||||
}
|
||||
} finally {
|
||||
if (abortTimer) clearTimeout(abortTimer);
|
||||
clearInterval(timerRef.current);
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
{
|
||||
"update": {
|
||||
"available": "يتوفّر التحديث {{version}}",
|
||||
"install": "تثبيت وإعادة التشغيل",
|
||||
"install_hint": "نزّل التحديث وأعد التشغيل إلى الإصدار الجديد",
|
||||
"downloading": "جارٍ التحديث… {{pct}}%",
|
||||
"restart": "أعد التشغيل للتحديث",
|
||||
"busy": "أكمل الدبلجة أولاً — ثم ثبّت التحديث."
|
||||
},
|
||||
"nav": {
|
||||
"clone": "استنساخ",
|
||||
"design": "تصميم",
|
||||
@@ -16,7 +8,10 @@
|
||||
"launchpad": "لوحة الإطلاق",
|
||||
"gallery": "معرض",
|
||||
"transcripts": "النصوص",
|
||||
"omnidrive": "أومني درايف"
|
||||
"omnidrive": "أومني درايف",
|
||||
"move_rail_right": "حرك السكة إلى اليمين",
|
||||
"move_rail_left": "حرك السكة إلى اليسار",
|
||||
"flip_rail": "الوجه الجانب السكك الحديدية"
|
||||
},
|
||||
"settings": {
|
||||
"general": "عام",
|
||||
@@ -45,7 +40,40 @@
|
||||
"ffmpeg_missing": "لم يتم العثور عليه",
|
||||
"ffmpeg_current": "المسار الحالي",
|
||||
"ffmpeg_desc": "قم بتعيين مسار ffmpeg مخصص في حالة فشل الاكتشاف التلقائي.",
|
||||
"ffmpeg_saved": "تم تعيين مسار FFmpeg - أعد تشغيل الواجهة الخلفية للتطبيق."
|
||||
"ffmpeg_saved": "تم تعيين مسار FFmpeg - أعد تشغيل الواجهة الخلفية للتطبيق.",
|
||||
"diagnostics_copied": "تم نسخ بيانات التشخيص، ولصقها في تقرير المشكلة.",
|
||||
"updater_desktop": "يعمل التحديث فقط في تطبيق سطح المكتب.",
|
||||
"latest_version": "أنت على الإصدار الأحدث.",
|
||||
"logs_load_failed": "فشل تحميل السجلات: {{message}}",
|
||||
"clear_frontend_confirm": "هل تريد مسح المخزن المؤقت لسجل الواجهة الأمامية في الذاكرة؟",
|
||||
"clear_frontend_title": "مسح السجلات",
|
||||
"frontend_logs_cleared": "تم مسح سجلات الواجهة الأمامية",
|
||||
"clear_tauri_confirm": "هل تريد اقتطاع ملفات السجل من جانب Tauri؟ سيستمر نظام التشغيل في كتابة إدخالات جديدة.",
|
||||
"clear_tauri_title": "مسح سجلات Tauri",
|
||||
"nothing_to_clear": "لا يوجد شيء لمسحه - لا يوجد ملف سجل Tauri على القرص حتى الآن.",
|
||||
"cleared_tauri_one": "تم مسح {{count}} ملف سجل Tauri",
|
||||
"cleared_tauri_other": "تم مسح {{count}} ملفات سجل Tauri",
|
||||
"clear_tauri_failed": "فشل في مسح سجلات Tauri: {{message}}",
|
||||
"clear_backend_confirm": "هل تريد مسح وقت تشغيل الواجهة الخلفية + سجلات الأعطال؟ لا يمكن التراجع عن هذا.",
|
||||
"clear_backend_title": "مسح السجلات",
|
||||
"backend_logs_cleared": "تم مسح سجلات الواجهة الخلفية",
|
||||
"clear_backend_failed": "فشل في مسح السجلات",
|
||||
"copy_failed": "فشل النسخ: {{message}}",
|
||||
"update_check_failed": "فشل التحقق من التحديث: {{message}}",
|
||||
"save_failed": "فشل الحفظ: {{message}}",
|
||||
"clear_failed": "فشل المسح: {{message}}",
|
||||
"engine_switched": "{{family}} → {{engine}}",
|
||||
"channel_set_failed": "فشل تعيين القناة: {{message}}",
|
||||
"updater_downloading": "جارٍ التنزيل {{version}}…",
|
||||
"updater_installed": "تم التثبيت - إعادة التشغيل.",
|
||||
"updater_available_title": "التحديث متاح",
|
||||
"updater_available_body": "الإصدار {{version}} متاح.\n\n{{notes}}\n\nتحميل وتثبيت الآن؟",
|
||||
"updater_notes_fallback": "راجع ملاحظات الإصدار على GitHub.",
|
||||
"shortcut_load_failed": "تعذر تحميل الاختصار: {{message}}",
|
||||
"shortcut_set": "تم ضبط اختصار الإملاء على {{shortcut}}",
|
||||
"shortcut_register_failed": "تعذر التسجيل: {{message}}",
|
||||
"shortcut_reset": "إعادة التعيين إلى الوضع الافتراضي",
|
||||
"shortcut_reset_failed": "فشلت إعادة التعيين: {{message}}"
|
||||
},
|
||||
"bootstrap": {
|
||||
"title": "OmniVoice Studio",
|
||||
@@ -69,7 +97,13 @@
|
||||
"waiting_output": "في انتظار البيانات…",
|
||||
"auto_detect": "تحديد تلقائي",
|
||||
"suggest_lang": "تغيير إلى اللغة العربية؟",
|
||||
"select_lang": "اللغة:"
|
||||
"select_lang": "اللغة:",
|
||||
"region_global": "عالمي (مباشر)",
|
||||
"region_china": "الصين (المرآة)",
|
||||
"region_russia": "روسيا (مرآة)",
|
||||
"region_restricted": "مقيد (مرآة)",
|
||||
"unknown_error": "خطأ غير معروف",
|
||||
"retrying": "جارٍ إعادة المحاولة..."
|
||||
},
|
||||
"stories": {
|
||||
"title": "محرر القصص",
|
||||
@@ -158,7 +192,17 @@
|
||||
"reload": "فرض تحديث واجهة المستخدم",
|
||||
"backend": "الخلفية",
|
||||
"frontend": "الواجهة الأمامية",
|
||||
"tauri": "تاوري"
|
||||
"tauri": "تاوري",
|
||||
"cancelOp": "إلغاء العملية",
|
||||
"dismiss": "استبعاد",
|
||||
"dismissStatus": "تجاهل الحالة",
|
||||
"search": "بحث…",
|
||||
"no_matches": "لا توجد مباريات",
|
||||
"recent_and_popular": "الأخيرة والشعبية",
|
||||
"popular_label": "شعبية",
|
||||
"showing_of": "عرض {{shown}} من {{total}}. اكتب للبحث...",
|
||||
"yes": "نعم",
|
||||
"no": "لا"
|
||||
},
|
||||
"launchpad": {
|
||||
"greeting": "مرحبا هناك",
|
||||
@@ -180,7 +224,16 @@
|
||||
"locked": "مغلق",
|
||||
"open": "مفتوح",
|
||||
"try_it": "جربه",
|
||||
"audio_only": "الصوت فقط"
|
||||
"audio_only": "الصوت فقط",
|
||||
"stories_title": "قصص",
|
||||
"stories_desc": "كتب صوتية متعددة الأصوات - ألقي شخصياتك، وأدخل النص، وقم بالتصدير حسب الفصل.",
|
||||
"gallery_title": "معرض الصوت",
|
||||
"gallery_desc": "تصفح الأصوات المصممة الجاهزة حسب اللهجة والعمر والأسلوب - بدون إعداد.",
|
||||
"transcripts_title": "النصوص",
|
||||
"transcripts_desc": "حوّل الصوت أو الفيديو إلى نص قابل للتحرير والبحث، عبر 646 لغة.",
|
||||
"recent_files": "الملفات الأخيرة",
|
||||
"view_all_files": "عرض كافة الملفات",
|
||||
"file": "ملف"
|
||||
},
|
||||
"clone": {
|
||||
"prompt": "موجه",
|
||||
@@ -271,11 +324,6 @@
|
||||
"outputs": "النواتج",
|
||||
"crash_log": "سجل الأعطال",
|
||||
"update_endpoint": "تحديث نقطة النهاية",
|
||||
"update_channel": "قناة التحديث",
|
||||
"channel_stable": "مستقر",
|
||||
"channel_preview": "معاينة",
|
||||
"channel_set": "تم ضبط قناة التحديث على {{channel}}",
|
||||
"channel_preview_hint": "تتابع المعاينة أحدث إصدار من main — ميزات أحدث وأقل اختبارًا. تعود إلى المستقر إذا كان هناك إصدار مستقر أحدث.",
|
||||
"yes": "نعم",
|
||||
"no": "لا",
|
||||
"web_preview": "معاينة الويب",
|
||||
@@ -284,7 +332,12 @@
|
||||
"copy_diagnostics": "نسخ التشخيص",
|
||||
"github": "أومنيفويس على جيثب",
|
||||
"model_card": "البطاقة النموذجية",
|
||||
"commercial_license": "رخصة تجارية"
|
||||
"commercial_license": "رخصة تجارية",
|
||||
"update_channel": "قناة التحديث",
|
||||
"channel_stable": "مستقر",
|
||||
"channel_preview": "معاينة",
|
||||
"channel_set": "تم ضبط قناة التحديث على {{channel}}",
|
||||
"channel_preview_hint": "تتابع المعاينة أحدث إصدار من main — ميزات أحدث وأقل اختبارًا. تعود إلى المستقر إذا كان هناك إصدار مستقر أحدث."
|
||||
},
|
||||
"privacy": {
|
||||
"desc": "كل شيء يعمل على <1>هذا الجهاز</1>. لا يغادر الصوت والفيديو والنصوص جهاز الكمبيوتر الخاص بك أبدًا ما لم تستخدم صراحةً مترجمًا عبر الإنترنت (Google، DeepL، وما إلى ذلك) أو تضغط على HuggingFace.",
|
||||
@@ -340,7 +393,30 @@
|
||||
"unavailable": "غير متاح",
|
||||
"use": "استخدم",
|
||||
"loading": "جارٍ تحميل المحركات…",
|
||||
"refresh": "تحديث"
|
||||
"refresh": "تحديث",
|
||||
"matrixTitle": "مصفوفة توافق المحرك",
|
||||
"loadFailed": "فشل تحميل المحركات: {{message}}",
|
||||
"couldNotLoad": "لا يمكن تحميل المحركات: {{message}}",
|
||||
"retry": "أعد المحاولة",
|
||||
"activeEngine": "نشط {{family}}: {{engine}}",
|
||||
"engineCompatLabel": "{{family}} توافق المحرك",
|
||||
"active": "نشط",
|
||||
"whyUnavailable": "لماذا غير متوفر؟",
|
||||
"lastError": "الخطأ الأخير: {{error}}",
|
||||
"installedAndReady": "مثبتة وجاهزة",
|
||||
"notInstalled": "غير مثبت",
|
||||
"available": "متاح",
|
||||
"subprocessTitle": "يعمل في العملية الفرعية الخاصة به + venv",
|
||||
"inProcessTitle": "يعمل في عملية OmniVoice Python",
|
||||
"testEngine": "محرك الاختبار",
|
||||
"testing": "اختبار…",
|
||||
"recheck": "أعد الفحص",
|
||||
"rechecking": "إعادة الفحص…",
|
||||
"latencyMs": "{{ms}} مللي ثانية",
|
||||
"failed": "فشل",
|
||||
"acceptLicense": "قبول الترخيص",
|
||||
"noBackends": "لم يتم تسجيل أي الواجهات الخلفية.",
|
||||
"switch_failed": "فشل في تبديل المحرك"
|
||||
},
|
||||
"capture": {
|
||||
"desc": "تعمل مفاتيح التشغيل السريع العامة فقط في تطبيق سطح المكتب. تستخدم واجهة مستخدم الويب اختصارًا <1>Ctrl+Shift+Space</1> داخل الصفحة أثناء التركيز على النافذة.",
|
||||
@@ -352,13 +428,55 @@
|
||||
"record_shortcut": "سجل الاختصار",
|
||||
"recording": "تسجيل…",
|
||||
"save": "حفظ",
|
||||
"reset_default": "إعادة التعيين إلى الوضع الافتراضي"
|
||||
"reset_default": "إعادة التعيين إلى الوضع الافتراضي",
|
||||
"listening_label": "جاري الاستماع…",
|
||||
"transcribing_label": "جارٍ النسخ…",
|
||||
"pasted": "تم لصقه",
|
||||
"no_speech": "لم يتم اكتشاف أي كلام",
|
||||
"mic_denied": "تم رفض الوصول إلى الميكروفون",
|
||||
"mic_denied_toast": "تم رفض الوصول إلى الميكروفون. {{hint}}",
|
||||
"mic_hint_mac": "macOS: افتح إعدادات النظام ← الخصوصية والأمان ← الميكروفون وقم بتمكين OmniVoice.",
|
||||
"mic_hint_windows": "Windows: افتح الإعدادات ← الخصوصية والأمان ← الميكروفون واسمح لـ OmniVoice.",
|
||||
"mic_hint_linux": "Linux: تأكد من وجود المستخدم الخاص بك في المجموعة الصوتية وأن WebView لديه حق الوصول إلى الميكروفون.",
|
||||
"transcription_failed": "فشل النسخ: {{message}}"
|
||||
},
|
||||
"logs": {
|
||||
"no_tauri_log": "لم يتم تسجيل دخول Tauri على القرص حتى الآن — قم بتشغيله عبر إصدار سطح المكتب لإنتاج واحد",
|
||||
"empty_frontend": "لم يتم التقاط أي إدخالات لوحدة التحكم الأمامية حتى الآن. تفاعل مع التطبيق — ستظهر كل وحدة تحكم* هنا.",
|
||||
"empty_tauri": "لا يوجد سجل Tauri متاح. يعمل في غلاف سطح المكتب فقط.",
|
||||
"empty_backend": "سجل وقت التشغيل فارغ. سيظهر النشاط هنا عندما تقوم الواجهة الخلفية بتسجيله."
|
||||
"empty_backend": "سجل وقت التشغيل فارغ. سيظهر النشاط هنا عندما تقوم الواجهة الخلفية بتسجيله.",
|
||||
"title": "سجلات",
|
||||
"source_backend": "الخلفية",
|
||||
"source_frontend": "الواجهة الأمامية",
|
||||
"source_tauri": "تاوري",
|
||||
"expand": "قم بتوسيع السجلات",
|
||||
"collapse": "طي السجلات",
|
||||
"expand_aria": "قم بتوسيع لوحة السجلات",
|
||||
"collapse_aria": "طي لوحة السجلات",
|
||||
"drag_resize": "اسحب لتغيير الحجم",
|
||||
"refresh": "تحديث",
|
||||
"refresh_aria": "تحديث السجلات",
|
||||
"copy_visible": "نسخ السجل المرئي",
|
||||
"copy_visible_aria": "نسخ السجل المرئي",
|
||||
"clear": "واضح",
|
||||
"clear_aria": "مسح السجل",
|
||||
"report_issue": "الإبلاغ عن مشكلة (تشخيص النسخ)",
|
||||
"report_issue_aria": "الإبلاغ عن مشكلة",
|
||||
"close": "إغلاق",
|
||||
"close_aria": "إغلاق لوحة السجلات",
|
||||
"join_discord": "انضم إلى خلافنا",
|
||||
"join_discord_aria": "انضم إلى مجتمع Discord الخاص بنا",
|
||||
"support_project": "دعم هذا المشروع",
|
||||
"support_project_aria": "دعم هذا المشروع",
|
||||
"empty_frontend_short": "لا يوجد إخراج لوحدة التحكم الأمامية حتى الآن.",
|
||||
"empty_lines": "لا خطوط.",
|
||||
"all_clear": "✅ كل شيء واضح — لم يتم اكتشاف أية مشكلات",
|
||||
"log_cleared": "تم مسح سجل {{source}}",
|
||||
"clear_failed": "فشل المسح: {{message}}",
|
||||
"log_copied": "تم نسخ سجل {{source}}",
|
||||
"copy_failed": "فشل النسخ: {{message}}",
|
||||
"report_copied": "تم نسخ التقرير التشخيصي، والصقه في مشكلة GitHub.",
|
||||
"report_failed": "فشل التقرير: {{message}}"
|
||||
},
|
||||
"voice_profile": {
|
||||
"test_text": "مرحبًا – هذا اختبار لهذا الصوت.",
|
||||
@@ -526,7 +644,17 @@
|
||||
"install_already": "تم تثبيت {{engine}} بالفعل",
|
||||
"install_ok": "{{engine}} تم التثبيت",
|
||||
"install_failed": "فشل التثبيت: {{message}}",
|
||||
"prep_elapsed": "انقضى {{time}}"
|
||||
"prep_elapsed": "انقضى {{time}}",
|
||||
"add_language": "أضف لغة",
|
||||
"search_languages": "لغات البحث...",
|
||||
"languages_selected_one": "تم تحديد لغة {{count}}",
|
||||
"languages_selected_other": "تم تحديد {{count}} اللغات",
|
||||
"more_to_narrow": "+{{count}} المزيد - اكتب للتضييق",
|
||||
"no_matches": "لا توجد مباريات",
|
||||
"diagnostic_copied": "منقول التشخيص",
|
||||
"copy_failed": "فشل النسخ",
|
||||
"open_docs": "افتح المستندات",
|
||||
"copy_diagnostic": "نسخ التشخيص"
|
||||
},
|
||||
"glossary": {
|
||||
"title": "مسرد",
|
||||
@@ -601,7 +729,17 @@
|
||||
"more_actions_title": "المزيد من الإجراءات",
|
||||
"speaker_pick": "اختر…",
|
||||
"speaker_title_detected": "مكبر الصوت - اختر مما تم اكتشافه، أو اكتب اسمًا مخصصًا",
|
||||
"speaker_title_custom": "مكبر الصوت - اكتب اسمًا (لم يتم اكتشاف أي نسخ للكتابة)"
|
||||
"speaker_title_custom": "مكبر الصوت - اكتب اسمًا (لم يتم اكتشاف أي نسخ للكتابة)",
|
||||
"time_edit_title": "انقر لتعديل وقت البدء (m:ss.s). أدخل للالتزام، Esc للإلغاء.",
|
||||
"fit_fits": "يناسب",
|
||||
"fit_fits_title": "يتناسب الصوت ذو المعدل الطبيعي داخل الفتحة.",
|
||||
"fit_overflows": "الفائض +{{seconds}}s",
|
||||
"fit_overflows_title": "كان النص المترجم أطول من الفتحة الأصلية بمقدار {{seconds}}s. كان الصوت مقصوصًا بشدة. قم بتقصير النص أو قم بتبديل التوقيت إلى \"تمدد الفيديو\".",
|
||||
"fit_stretched": "فيديو {{ratio}}×",
|
||||
"fit_stretched_title": "وضع الفيديو الممتد: تم إبطاء فيديو هذا المقطع إلى {{ratio}}× ليناسب الصوت الطبيعي المدبلج.",
|
||||
"fit_compressed_title": "يمثل صوت تحويل النص إلى كلام (TTS) {{pct}}% من الفتحة - وهو مضغوط بشدة.",
|
||||
"fit_audio_title": "الصوت مناسب داخل الفتحة.",
|
||||
"fit_ratio_title": "يمثل صوت تحويل النص إلى كلام (TTS) {{pct}}% من الفتحة."
|
||||
},
|
||||
"voice": {
|
||||
"personality": "الشخصية",
|
||||
@@ -696,10 +834,24 @@
|
||||
"status_running": "تشغيل",
|
||||
"status_done": "تم",
|
||||
"status_failed": "فشل",
|
||||
"status_cancelled": "تم الإلغاء"
|
||||
"status_cancelled": "تم الإلغاء",
|
||||
"add_to_queue_title": "إضافة مقاطع فيديو إلى قائمة الانتظار",
|
||||
"drop_hint_text": "قم بإسقاط ملفات الفيديو هنا أو انقر للتصفح",
|
||||
"drop_formats": "MP4 · MOV · MKV · ويب إم",
|
||||
"files_kicker": "الملفات ({{count}})",
|
||||
"file_size_mb": "{{size}} ميغابايت",
|
||||
"target_languages": "اللغات المستهدفة",
|
||||
"voice_kicker": "صوت",
|
||||
"default_option": "الافتراضي",
|
||||
"clone_profiles": "ملفات تعريف الاستنساخ",
|
||||
"presets": "الإعدادات المسبقة",
|
||||
"preserve_bg": "الحفاظ على صوت الخلفية (الموسيقى/FX)",
|
||||
"estimate": "{{videos}} مقاطع الفيديو × {{langs}} اللغة (اللغات) = {{jobs}} الوظيفة (الوظائف)",
|
||||
"select_files_langs": "حدد الملفات واللغات",
|
||||
"add_to_queue": "إضافة إلى قائمة الانتظار"
|
||||
},
|
||||
"gallery": {
|
||||
"title": "معرض",
|
||||
"title": "OmniVoice معرض",
|
||||
"search_placeholder": "بحث في يوتيوب...",
|
||||
"all_voices": "جميع الأصوات ({{count}})",
|
||||
"no_voices": "لا توجد أصوات بعد",
|
||||
@@ -714,6 +866,14 @@
|
||||
"youtube_results": "نتائج يوتيوب ({{count}})",
|
||||
"clone_profile": "الملف الشخصي استنساخ",
|
||||
"crop_audio": "اقتصاص الصوت",
|
||||
"cat_disney": "ديزني",
|
||||
"cat_anime": "أنيمي",
|
||||
"cat_marvel": "مارفل / دي سي",
|
||||
"cat_celebs": "المشاهير",
|
||||
"cat_politicians": "السياسيون",
|
||||
"cat_news": "مذيعي الأخبار",
|
||||
"cat_gaming": "الألعاب",
|
||||
"cat_books": "كتب / أفلام",
|
||||
"subtitle": "مئات من الأصوات المصممة الجاهزة - اختر واحدة وانطلق.",
|
||||
"zone_archetypes": "النماذج الأولية",
|
||||
"zone_imports": "وارداتي",
|
||||
@@ -817,8 +977,7 @@
|
||||
"back": "العودة إلى الاستوديو",
|
||||
"badge": "رخصة تجارية",
|
||||
"hero_title": "شحن أصوات الذكاء الاصطناعي في الإنتاج",
|
||||
"hero_desc": "OmniVoice Studio متاح المصدر بموجب ترخيص المصدر الوظيفي (FSL). يمكن لمعظم المستخدمين التقييم وإنشاء نماذج أولية وحتى النشر داخليًا بدون اتفاقية تجارية. لا تحتاج إلى ترخيص تجاري إلا إذا كنت تقوم ببناء منتج أو خدمة منافسة، أو إذا كانت حالة الاستخدام الخاصة بك تقع خارج حدود FSL.",
|
||||
"hero_note": "يتطلب إنشاء منتج أو خدمة منافسة أو نشرها على نطاق واسع (على سبيل المثال، خدمة واجهة برمجة تطبيقات الدفع لكل استخدام) ترخيصًا تجاريًا. ستتوفر مستويات التسعير قريبًا - تواصل معنا في هذه الأثناء.",
|
||||
"hero_desc": "OmniVoice Studio برنامج حر ومفتوح المصدر بموجب رخصة GNU Affero العمومية الإصدار 3 (AGPL-3.0) — مجاني للاستخدام، بما في ذلك الاستخدام التجاري والداخلي في الشركات. لا تحتاج إلى ترخيص تجاري إلا إذا أردت تضمين OmniVoice Studio في منتج أو خدمة مغلقة المصدر أو احتكارية دون التزامات الحقوق المتروكة (copyleft) في AGPL-3.0.",
|
||||
"why_title": "لماذا تختار الشركات OmniVoice",
|
||||
"pricing_title": "التسعير",
|
||||
"faq_title": "الأسئلة الشائعة",
|
||||
@@ -838,7 +997,8 @@
|
||||
"benefit_source": "مصدر متاح الأساسية",
|
||||
"benefit_source_desc": "الرؤية الكاملة في المكدس. التدقيق والشوكة والتكيف ضمن شروط الترخيص.",
|
||||
"benefit_lang": "646 لغة",
|
||||
"benefit_lang_desc": "قم بالنسخ والترجمة والدبلجة عبر 646 لغة بجودة تضاهي المستوى البشري."
|
||||
"benefit_lang_desc": "قم بالنسخ والترجمة والدبلجة عبر 646 لغة بجودة تضاهي المستوى البشري.",
|
||||
"hero_note": "الاستخدام والاستضافة الذاتية والاستخدام التجاري كلها مجانية بموجب AGPL-3.0 — حتى على نطاق واسع. AGPL رخصة حقوق متروكة شبكية: إذا عدّلت OmniVoice وقدّمت النسخة المعدّلة للآخرين عبر الشبكة، فعليك مشاركة شيفرتك المصدرية المعدّلة بالشروط نفسها. الترخيص التجاري يرفع هذه الالتزامات عن عمليات النشر الاحتكارية مغلقة المصدر. خطط الأسعار قادمة قريبًا — تواصل معنا في هذه الأثناء."
|
||||
},
|
||||
"exportModal": {
|
||||
"export": "تصدير",
|
||||
@@ -927,7 +1087,478 @@
|
||||
"dubbing_title": "شاهد الدبلجة أثناء العمل",
|
||||
"dubbing_sync": "التشغيل المتزامن",
|
||||
"dubbing_picker": "جرب لغة أخرى:",
|
||||
"dubbing_cta": "قم بتشغيل هذا على الفيديو الخاص بك →"
|
||||
"dubbing_cta": "قم بتشغيل هذا على الفيديو الخاص بك →",
|
||||
"dubbing_loading": "جارٍ تحميل العرض التوضيحي للدبلجة...",
|
||||
"dubbing_dismiss": "رفض الدبلجة التجريبية",
|
||||
"original_tag": "original",
|
||||
"dubbed_tag": "يطلق عليها اسم",
|
||||
"script_conversational": "محادثة",
|
||||
"script_technical": "المفردات التقنية",
|
||||
"script_french": "غير الإنجليزية (الفرنسية)",
|
||||
"aria_pause": "إيقاف مؤقت {{label}}",
|
||||
"aria_hear": "استمع {{label}}",
|
||||
"aria_replay": "إعادة تشغيل {{label}} من خلال الناسخ",
|
||||
"dictation_lede_hotkey_only": "اضغط مطوّلًا على الاختصار أعلاه في أي مكان على سطح المكتب وتحدث ثم أفلت — سيظهر النص في التطبيق النشط. اضغطه الآن للتحقق."
|
||||
},
|
||||
"direction": {
|
||||
"title": "اتجاه المقطع #{{id}}",
|
||||
"desc": "أخبر خط الأنابيب كيف يجب أن يشعر هذا الخط. تعمل اللغة الإنجليزية البسيطة — يقوم النظام بتعيين كلماتك على تصنيف مستقر (الطاقة / العاطفة / السرعة / العلاقة الحميمة / الشكليات)، ثم يقوم بربط التصنيف من خلال الترجمة السينمائية، وتحويل النص إلى كلام، والملاءمة.",
|
||||
"label": "الاتجاه",
|
||||
"lineHint": "السطر: \"{{text}}\"",
|
||||
"placeholder": "على سبيل المثال عاجل ومتفاجئ / دافئ، متفائل / هامس، حميم",
|
||||
"previewParse": "تحليل المعاينة",
|
||||
"previewFailed": "فشلت المعاينة: {{message}}",
|
||||
"clear": "واضح",
|
||||
"cancel": "إلغاء",
|
||||
"saveDirection": "حفظ الاتجاه",
|
||||
"ttsInstruct": "تعليمات تحويل النص إلى كلام:",
|
||||
"nothingParsed": "- (لم يتم تحليل أي شيء)",
|
||||
"translateHint": "تلميح الترجمة:",
|
||||
"rateBias": "تحيز المعدل:",
|
||||
"speedsUp": "يسرع",
|
||||
"slowsDown": "يبطئ",
|
||||
"taxonomyTokens": "رموز التصنيف"
|
||||
},
|
||||
"errors": {
|
||||
"title": "ضربت علامة التبويب هذه عقبة.",
|
||||
"desc": "لا تقلق، فباقي التطبيق لا يزال يعمل. يمكنك تبديل علامات التبويب أو المحاولة مرة أخرى أدناه.",
|
||||
"tryAgain": "حاول مرة أخرى",
|
||||
"openDocs": "افتح المستندات لهذا الخطأ"
|
||||
},
|
||||
"keyboard": {
|
||||
"title": "اختصارات لوحة المفاتيح",
|
||||
"footer": "اضغط على <1>?</1> في أي وقت لفتح هذا.",
|
||||
"or": "أو",
|
||||
"nav": "الملاحة",
|
||||
"nav_cheatsheet": "عرض ورقة الغش هذه",
|
||||
"nav_closeModal": "إغلاق مشروط / إلغاء",
|
||||
"nav_save": "حفظ المشروع/الالتزام بالقطع",
|
||||
"segmentEditor": "محرر المقطع",
|
||||
"seg_split": "تقسيم الجزء عند المؤشر",
|
||||
"seg_merge": "دمج مع الجزء التالي",
|
||||
"seg_undo": "تراجع",
|
||||
"seg_redo": "إعادة",
|
||||
"seg_click": "العمل الأساسي",
|
||||
"seg_shiftClick": "حدد النطاق",
|
||||
"trimmer": "أداة تشذيب الصوت",
|
||||
"trim_playPause": "معاينة التشغيل / الإيقاف المؤقت",
|
||||
"trim_nudgeStart": "دفع مقبض البداية",
|
||||
"trim_nudgeEnd": "مقبض نهاية الدفع",
|
||||
"trim_fineNudge": "دفعة جيدة",
|
||||
"trim_coarseNudge": "دفعة خشنة",
|
||||
"trim_zoomIn": "تكبير / تصغير",
|
||||
"trim_fitAll": "تناسب الجميع / اختيار مناسب",
|
||||
"trim_confirm": "تأكيد القطع",
|
||||
"dub": "يصفه",
|
||||
"dub_generate": "توليد يصفه",
|
||||
"dub_sidebar": "تبديل الشريط الجانبي"
|
||||
},
|
||||
"network": {
|
||||
"sharing_on_title": "المشاركة قيد التشغيل - انقر للحصول على التفاصيل",
|
||||
"share_on_network": "شارك على شبكتك",
|
||||
"switching": "التبديل…",
|
||||
"network": "الشبكة",
|
||||
"local": "محلي",
|
||||
"share_confirm_title": "هل تريد المشاركة على شبكتك؟",
|
||||
"share_confirm_hint": "ستتمكن الأجهزة الأخرى الموجودة على شبكة Wi-Fi/Ethernet لديك من الوصول إلى OmniVoice باستخدام رقم التعريف الشخصي (PIN) للوصول الذي يظهر بمجرد تشغيله.",
|
||||
"enabling": "جارٍ التمكين…",
|
||||
"enable": "تمكين",
|
||||
"shared_title": "تمت مشاركتها على شبكتك",
|
||||
"no_interface": "لا توجد واجهة شبكة يمكن الوصول إليها — اتصل بشبكة Wi-Fi/Ethernet.",
|
||||
"copy_link": "انسخ الرابط",
|
||||
"open_in_browser": "فتح في المتصفح",
|
||||
"qr_alt": "QR لـ {{ip}}",
|
||||
"pin": "رقم التعريف الشخصي:",
|
||||
"stop_sharing": "توقف عن المشاركة",
|
||||
"copied": "منقول",
|
||||
"enable_error": "تعذر تمكين المشاركة: {{message}}",
|
||||
"disable_error": "تعذر التعطيل: {{message}}"
|
||||
},
|
||||
"readiness": {
|
||||
"checking_system": "نظام فحص…",
|
||||
"all_ready": "جميع الأنظمة جاهزة",
|
||||
"system_readiness": "جاهزية النظام",
|
||||
"asr_model": "نموذج ASR",
|
||||
"loaded_ready": "محملة وجاهزة",
|
||||
"loading_first_run": "جارٍ التحميل... (قد يستغرق هذا من دقيقة إلى دقيقتين عند التشغيل لأول مرة)",
|
||||
"failed_to_load": "فشل التحميل",
|
||||
"not_loaded_yet": "لم يتم تحميله بعد - سيتم تحميله عند النسخ الأول",
|
||||
"error_check_logs": "خطأ: {{error}}. تحقق من السجلات وحاول إعادة التشغيل.",
|
||||
"check_logs_restart": "تحقق من السجلات بحثًا عن أخطاء تحميل النموذج. حاول إعادة التشغيل.",
|
||||
"llm_cinematic": "ماجستير في القانون (السينمائي)",
|
||||
"llm_configure": "قم بتكوين TRANSLATE_BASE_URL لجودة الترجمة السينمائية",
|
||||
"llm_set_env": "قم بتعيين متغيرات البيئة TRANSLATE_BASE_URL وTRANSLATE_API_KEY. يعمل مع Ollama وOpenAI وLM Studio وما إلى ذلك.",
|
||||
"llm_optional": "اختياري — قم بتعيين TRANSLATE_BASE_URL للجودة السينمائية"
|
||||
},
|
||||
"license": {
|
||||
"title": "Supertonic-3 — قبول الترخيص",
|
||||
"intro": "يتم شحن Supertonic-3 بموجب رخصتين مختلفتين. يرجى مراجعة كليهما قبل تمكين المحرك.",
|
||||
"sdk_heading": "كود SDK · معهد ماساتشوستس للتكنولوجيا",
|
||||
"sdk_desc": "إن مجموعة أدوات تطوير البرامج (SDK) لاستدلال Python (الأسرع من الصوت) مرخصة من معهد ماساتشوستس للتكنولوجيا. الاستخدام المسموح به، بما في ذلك الاستخدام التجاري.",
|
||||
"read_mit": "اقرأ ترخيص معهد ماساتشوستس للتكنولوجيا →",
|
||||
"model_heading": "أوزان النموذج · OpenRAIL-M",
|
||||
"model_desc": "تم إصدار أوزان طراز Supertonic-3 بموجب ترخيص OpenRAIL-M. يقيد هذا الترخيص الاستخدام للأغراض غير الضارة - راجع الترخيص المرتبط للتعرف على المجموعة الكاملة من القيود القائمة على الاستخدام.",
|
||||
"read_openrail": "اقرأ ترخيص OpenRAIL-M →",
|
||||
"footer": "يؤدي النقر فوق \"قبول\" إلى تسجيل قبولك في إعدادات OmniVoice المحلية وتمكين المحرك. يتم تخزين موافقتك على هذا الجهاز فقط - ولا يتم إبلاغ شركة Supertone Inc. أو أي طرف ثالث بأي شيء.",
|
||||
"saving": "جارٍ الحفظ…",
|
||||
"accept": "قبول",
|
||||
"accepted_toast": "تم قبول ترخيص Supertonic-3.",
|
||||
"accept_error": "فشل في تسجيل قبول الترخيص: {{message}}"
|
||||
},
|
||||
"voicePreview": {
|
||||
"title": "معاينة الصوت",
|
||||
"close": "إغلاق المعاينة",
|
||||
"default_text": "مرحبا! هذه معاينة لكيفية صوتي في هذا الصوت.",
|
||||
"default_voice": "الصوت الافتراضي",
|
||||
"clone_profiles": "ملفات تعريف الاستنساخ",
|
||||
"designed_voices": "أصوات مصممة",
|
||||
"presets": "الإعدادات المسبقة",
|
||||
"placeholder": "اكتب شيئًا لسماعه...",
|
||||
"stop": "توقف",
|
||||
"regenerate": "تجديد",
|
||||
"preview": "معاينة",
|
||||
"hint": "8 خطوات · معاينة سريعة"
|
||||
},
|
||||
"header": {
|
||||
"kicker_studio": "استوديو",
|
||||
"kicker_library": "مكتبة",
|
||||
"kicker_preferences": "التفضيلات",
|
||||
"kicker_licensing": "الترخيص",
|
||||
"label_launchpad": "لوحة الإطلاق",
|
||||
"label_clone": "استنساخ الصوت",
|
||||
"label_design": "التصميم الصوتي",
|
||||
"label_dub": "الدبلجة",
|
||||
"label_projects": "أومني درايف",
|
||||
"label_gallery": "معرض",
|
||||
"label_transcriptions": "النسخ",
|
||||
"label_settings": "الإعدادات",
|
||||
"label_enterprise": "رخصة تجارية",
|
||||
"status_ready": "جاهز",
|
||||
"status_loading": "جارٍ التحميل…",
|
||||
"status_idle": "خامل",
|
||||
"memory_management": "إدارة الذاكرة",
|
||||
"flush": "دافق",
|
||||
"loaded_models": "النماذج المحملة",
|
||||
"no_models": "لم يتم تحميل أي نماذج",
|
||||
"unload": "تفريغ",
|
||||
"flush_caches": "مسح المخابئ",
|
||||
"unload_all_flush": "تفريغ كافة + تدفق"
|
||||
},
|
||||
"sidebar": {
|
||||
"tab_drive": "قيادة",
|
||||
"tab_history": "التاريخ",
|
||||
"tab_exports": "الصادرات",
|
||||
"save_project": "احفظ مشروع Dub",
|
||||
"save_new_project": "حفظ كمشروع Dub جديد",
|
||||
"dub_projects": "مشاريع دبلجة",
|
||||
"voice_clones": "استنساخ الصوت",
|
||||
"designed_voices": "أصوات مصممة",
|
||||
"no_dub_projects": "لا توجد مشاريع دبلجة محفوظة",
|
||||
"no_dub_hint": "قم بتحميل مقطع فيديو وانقر فوق \"حفظ\" للاحتفاظ بعملك.",
|
||||
"no_clones": "لا توجد استنساخ صوتي حتى الآن",
|
||||
"no_clones_hint": "قم بتسجيل الصوت أو تحميله، ثم انقر فوق \"حفظ كملف صوتي\".",
|
||||
"no_designs": "لا توجد أصوات مصممة بعد",
|
||||
"no_designs_hint": "توليد صوت وحفظه من التاريخ.",
|
||||
"history_subtitle": "تاريخ الأجيال · مخزنة في SQLite",
|
||||
"no_history": "لا تاريخ الجيل",
|
||||
"no_history_hint": "قم بتجميع الصوت أو دبلجة مقطع فيديو — ستظهر النتائج هنا.",
|
||||
"clear_history": "مسح التاريخ",
|
||||
"clear_confirm": "هل تريد مسح جميع عناصر السجل {{count}}؟ لا يمكن التراجع عن هذا.",
|
||||
"history_cleared": "تم مسح التاريخ",
|
||||
"recent_exports": "الصادرات الأخيرة",
|
||||
"no_exports": "لا توجد مخرجات تم تنزيلها",
|
||||
"no_exports_hint": "قم بتصدير ملف عبر Tauri لمشاهدته متتبعًا هنا.",
|
||||
"show_in_folder": "عرض في المجلد",
|
||||
"open": "مفتوح",
|
||||
"select": "اختر",
|
||||
"try_voice": "حاول",
|
||||
"consistent": "متسقة",
|
||||
"locked": "مغلق",
|
||||
"clone_label": "استنساخ",
|
||||
"design_label": "التصميم",
|
||||
"dub_label": "يصفه",
|
||||
"save_label": "حفظ",
|
||||
"lock_identity": "قفل الهوية الصوتية",
|
||||
"in_folder": "في {{folder}}"
|
||||
},
|
||||
"trimmer": {
|
||||
"title": "تقليم الصوت المرجعي",
|
||||
"decoding": "فك تشفير الصوت...",
|
||||
"meta_length": "الطول {{duration}} · {{sampleRate}} هرتز",
|
||||
"meta_rendering": "تقديم الشكل الموجي {{percent}}%",
|
||||
"keyboard_hint": "التمرير = التكبير · التحول + التمرير = عموم · البديل + السحب = عموم · ⏐ ⟵ ⟶ ⏐ مفاتيح ضبط المقابض",
|
||||
"zoom_in": "تكبير (+)",
|
||||
"zoom_out": "التصغير (-)",
|
||||
"fit_all": "تناسب الجميع (الصفحة الرئيسية)",
|
||||
"fit_selection": "اختيار الملاءمة (النهاية)",
|
||||
"fit_sel_btn": "فيت سيل",
|
||||
"view_range": "عرض {{start}} → {{end}} ({{duration}})",
|
||||
"start_label": "ابدأ",
|
||||
"end_label": "نهاية",
|
||||
"length_label": "الطول",
|
||||
"too_long": ">{{max}}s",
|
||||
"too_short": "قصير جدًا",
|
||||
"length_ok": "حسنا",
|
||||
"loop_preview": "معاينة الحلقة",
|
||||
"pause": "وقفة",
|
||||
"preview_selection": "معاينة التحديد",
|
||||
"play_hint": "مساحة للعب · أدخل للتأكيد · Esc للإلغاء",
|
||||
"cancel": "إلغاء",
|
||||
"use_trimmed": "استخدام قلصت",
|
||||
"decode_failed": "فشل فك التشفير: {{message}}",
|
||||
"playback_failed": "فشل التشغيل: {{message}}",
|
||||
"audio_load_failed": "فشل تحميل الصوت",
|
||||
"unit_seconds": "ق"
|
||||
},
|
||||
"casting": {
|
||||
"title": "صب المتحدث",
|
||||
"auto_assign_title": "تعيين الأصوات تلقائيًا من نسخ السماعات المستخرجة",
|
||||
"auto_cast": "الإرسال التلقائي",
|
||||
"all_cast": "كل الممثلين",
|
||||
"segments_count": "{{count}} المقاطع",
|
||||
"assign_voice": "تخصيص صوت…",
|
||||
"from_video": "🎤 من الفيديو ({{name}})",
|
||||
"no_profiles": "لم يتم حفظ أي ملفات تعريف صوتية حتى الآن.",
|
||||
"preview_voice": "معاينة الصوت"
|
||||
},
|
||||
"checkpoint": {
|
||||
"asr_title": "النصوص جاهزة",
|
||||
"asr_cta": "ترجمة",
|
||||
"asr_hint": "قم بإصلاح أي أخطاء ASR الآن — فالأسلوب المحكم يحفظ محاولات تحويل النص إلى كلام (TTS) لاحقًا.",
|
||||
"translate_title": "الترجمات جاهزة",
|
||||
"translate_cta": "توليد يصفه",
|
||||
"translate_hint": "قم بمسح النص المستهدف. الخطوط الطويلة يتم تعزيز سرعتها؛ يمكنك أيضًا التعديل مباشرةً.",
|
||||
"done_title": "اكتمل الدبلجة",
|
||||
"done_hint": "مراجعة التوقيت ونسب المزامنة. قم بتعديل أي سطر واضغط على \"تم تغيير Regen\" لإعادة جزئية سريعة.",
|
||||
"segment_one": "{{count}} المقطع",
|
||||
"segment_other": "{{count}} المقاطع",
|
||||
"dismiss_title": "تجاهل — لن يظهر مرة أخرى في هذه المرحلة حتى إعادة التحميل"
|
||||
},
|
||||
"compare": {
|
||||
"title": "مقارنة صوت أ/ب",
|
||||
"close": "مقارنة قريبة",
|
||||
"desc": "قارن بين صوتين جنبًا إلى جنب لاتخاذ قرارات الاختيار. يبقى التطبيق تفاعليًا في الخلف.",
|
||||
"test_phrase": "عبارة اختبارية",
|
||||
"voice_a": "صوت أ",
|
||||
"voice_b": "صوت ب",
|
||||
"select_voice": "— اختر الصوت —",
|
||||
"preset_suffix": "(مسبقا)",
|
||||
"no_audio": "لا يوجد صوت بعد",
|
||||
"close_btn": "إغلاق",
|
||||
"comparing": "مقارنة…",
|
||||
"compare_btn": "قارن",
|
||||
"preparing_voice": "تحضير الصوت...",
|
||||
"generating_voice_a": "توليد صوت...",
|
||||
"generating_voice_b": "توليد صوت ب...",
|
||||
"comparison_complete": "المقارنة كاملة!",
|
||||
"play_failed": "فشل التشغيل: {{message}}"
|
||||
},
|
||||
"models": {
|
||||
"hf_token_set_toast": "مجموعة الرموز المميزة HuggingFace - تمكين التنزيلات الأسرع",
|
||||
"hf_token_save_failed": "فشل حفظ الرمز المميز",
|
||||
"install_started": "بدأ التثبيت — التقدم في الصف",
|
||||
"delete_confirm": "هل تريد حذف {{repoId}}؟ ويمكنك إعادة تثبيته لاحقًا.",
|
||||
"delete_confirm_title": "حذف النموذج",
|
||||
"deleted": "تم حذفه {{repoId}}",
|
||||
"reinstall_confirm": "هل تريد إعادة تثبيت {{repoId}}؟ سيؤدي هذا إلى حذف النسخة الحالية وتنزيلها مرة أخرى.",
|
||||
"reinstall_confirm_title": "إعادة تثبيت النموذج",
|
||||
"reinstalling": "إعادة التثبيت",
|
||||
"recommended_installed": "النماذج الموصى بها مثبتة بالفعل.",
|
||||
"started_downloading_one": "بدأ تنزيل نموذج {{count}}",
|
||||
"started_downloading_other": "بدأ تنزيل نماذج {{count}}",
|
||||
"install_failed": "فشل التثبيت: {{message}}",
|
||||
"removing_cached": "جارٍ إزالة المراجعات المخزنة مؤقتًا...",
|
||||
"resolving_metadata": "حل البيانات الوصفية الريبو",
|
||||
"retry_attempt": "إعادة المحاولة {{attempt}} — {{error}}",
|
||||
"connecting_hf": "جارٍ الاتصال بـ HuggingFace…",
|
||||
"resolving_files_one": "جارٍ حل ملف {{count}}...",
|
||||
"resolving_files_other": "جارٍ حل ملفات {{count}}...",
|
||||
"resolving_files_active": "حل {{count}} ملف (ملفات)... · {{file}}",
|
||||
"measuring": "قياس…",
|
||||
"files_progress": "{{done}}/{{total}} الملفات",
|
||||
"install_error": "فشل التثبيت: {{error}}",
|
||||
"view_on_hf": "عرض على HuggingFace",
|
||||
"install_btn": "تثبيت",
|
||||
"reinstall_btn": "أعد التثبيت",
|
||||
"downloading": "جاري التحميل",
|
||||
"deleting": "حذف",
|
||||
"working": "العمل",
|
||||
"installed": "مثبتة",
|
||||
"not_installed": "غير مثبت",
|
||||
"required_tag": "مطلوب",
|
||||
"delete_btn": "حذف",
|
||||
"hf_token_btn": "رمز HF",
|
||||
"hf_set_title": "قم بتعيين رمز HuggingFace للتنزيل بشكل أسرع",
|
||||
"get_token": "الحصول على رمز →",
|
||||
"reco_installed_for": "الحزمة الموصى بها المثبتة لـ {{device}}",
|
||||
"reco_for": "موصى به لـ {{device}}",
|
||||
"starting": "جارٍ البدء…",
|
||||
"required_size": "مطلوب ~{{size}} غيغابايت",
|
||||
"all_size": "الكل ~{{size}} غيغابايت",
|
||||
"req_tag": "مطلوب",
|
||||
"search_placeholder": "نماذج البحث...",
|
||||
"search_label": "نماذج البحث",
|
||||
"no_matches": "لا توجد نماذج تتطابق مع المرشحات الخاصة بك.",
|
||||
"sort_by": "فرز حسب {{column}}",
|
||||
"column_model": "نموذج",
|
||||
"column_role": "الدور",
|
||||
"column_size": "الحجم",
|
||||
"column_status": "الحالة",
|
||||
"ready_badge": "جاهز",
|
||||
"loading_badge": "جارٍ التحميل…",
|
||||
"idle_badge": "خامل",
|
||||
"started_downloading_required_one": "بدأ تنزيل النموذج المطلوب {{count}}",
|
||||
"started_downloading_required_other": "بدأ تنزيل {{count}} النماذج المطلوبة"
|
||||
},
|
||||
"enterprise_faq": {
|
||||
"q_internal_tools": "هل أحتاج إلى ترخيص للأدوات الداخلية؟",
|
||||
"a_internal_tools": "لا. استخدام موظفيك ومتعاقديك — بما في ذلك التعديل والاستضافة الذاتية داخليًا — مجاني بموجب AGPL-3.0. لا يلزم الترخيص التجاري إلا إذا ضمّنت OmniVoice في منتج أو خدمة مغلقة المصدر أو احتكارية ولم ترغب في الالتزام بمتطلبات AGPL لمشاركة الشيفرة المصدرية.",
|
||||
"q_try_before": "هل يمكنني المحاولة قبل الالتزام؟",
|
||||
"a_try_before": "نعم. التطبيق الكامل مجاني للتنزيل والتشغيل والاستضافة الذاتية بموجب AGPL-3.0 — دون أي اتفاقية. عندما تكون مستعدًا لمناقشة ترخيص تجاري (للاستخدام الاحتكاري)، راسلنا عبر البريد الإلكتروني وسنرتّب التفاصيل معًا.",
|
||||
"q_watermark": "ماذا عن العلامة المائية؟",
|
||||
"a_watermark": "العلامة المائية غير المرئية AudioSeal مضمّنة افتراضيًا للجميع. يمكن لحاملي الترخيص التجاري تعطيلها في الإعدادات → الخصوصية."
|
||||
},
|
||||
"gallery_extra": {
|
||||
"save_prompt": "أدخل اسمًا لملف التعريف الصوتي هذا:",
|
||||
"saved_as_profile": "تم حفظ الصوت كملف شخصي!",
|
||||
"save_profile_failed": "فشل حفظ الملف الشخصي",
|
||||
"download_failed": "فشل التنزيل: {{message}}",
|
||||
"trim_load_failed": "فشل تحميل الصوت للاقتطاع: {{message}}",
|
||||
"upload_crop_failed": "فشل تحميل الصوت المقصوص: {{message}}"
|
||||
},
|
||||
"dub_workflow": {
|
||||
"preparing_audio": "جارٍ تحضير الصوت…",
|
||||
"preparing_video": "جارٍ تحضير الفيديو…",
|
||||
"extracting_audio_scenes": "استخراج الصوت والمشاهد...",
|
||||
"transcribing_audio": "جارٍ تحويل الصوت إلى نص…",
|
||||
"transcription_complete": "اكتمل النسخ",
|
||||
"upload_cancelled": "تم إلغاء التحميل",
|
||||
"upload_failed": "فشل التحميل: {{message}}",
|
||||
"downloading_video": "جارٍ تنزيل الفيديو…",
|
||||
"ingested": "تم تناولها {{url}}",
|
||||
"ingest_cancelled": "تم إلغاء الاستيعاب",
|
||||
"ingest_failed": "فشل استيعاب عنوان URL: {{message}}",
|
||||
"retry_cancelled": "تم إلغاء إعادة المحاولة",
|
||||
"transcription_failed": "فشل النسخ: {{message}}",
|
||||
"import_srt_no_job": "قم بتحميل مقطع فيديو أو استيعابه أولاً - لا توجد مهمة لإرفاق الترجمة بها.",
|
||||
"imported_cues": "تم استيراد {{count}} إشارة (إشارات) من {{file}}",
|
||||
"skipped_malformed": "تم تخطي {{count}} (مشوه)",
|
||||
"dropped_overlap": "تم إسقاط {{count}} (تداخل)",
|
||||
"clamped_to_duration": "{{count}} مثبت بطول الوسائط",
|
||||
"srt_import_failed": "فشل استيراد SRT",
|
||||
"cleaned_one": "تم تنظيف جزء {{count}}",
|
||||
"cleaned_other": "تنظيف {{count}} شظايا",
|
||||
"segments_clean": "قطاعات نظيفة بالفعل",
|
||||
"cleanup_failed": "فشل التنظيف: {{message}}",
|
||||
"cinematic_no_llm": "تحتاج الجودة السينمائية إلى شهادة LLM - قم بتعيين TRANSLATE_BASE_URL + TRANSLATE_API_KEY (تعمل Ollama محليًا). العودة إلى سريع.",
|
||||
"translate_errors": "{{errorCount}}/{{totalCount}} فشل المقطع (المقاطع): {{firstError}}",
|
||||
"translated_segments": "تمت ترجمة {{count}} مقطع (مقاطع) → {{lang}}",
|
||||
"translated_cinematic_suffix": "(سينمائي)",
|
||||
"translation_failed": "فشلت الترجمة: {{message}}",
|
||||
"regenerating": "جارٍ إعادة إنشاء {{count}} مقطع (مقاطع)...",
|
||||
"generating_dub": "جارٍ إنشاء الدبلجة…",
|
||||
"generating_progress": "جارٍ إنشاء الدبلجة… {{current}}/{{total}}",
|
||||
"generation_aborted": "تم إجهاض الجيل.",
|
||||
"dubbing_aborted": "تم إلغاء الدبلجة",
|
||||
"generation_stream_ended": "انتهى تيار الإنشاء قبل اكتماله",
|
||||
"dub_complete": "اكتمل الدبلجة",
|
||||
"stop_failed": "فشل في التوقف",
|
||||
"save_first": "الرجاء النقر فوق \"تحميل ونسخ\" أولاً حتى تتم معالجة الفيديو على الخادم قبل حفظه.",
|
||||
"project_saved": "تم حفظ المشروع",
|
||||
"project_created": "تم إنشاء المشروع",
|
||||
"save_failed": "فشل الحفظ: {{message}}",
|
||||
"opened_project": "مفتوح: {{name}}",
|
||||
"delete_project_confirm": "هل تريد حذف هذا المشروع؟ لا يمكن التراجع عن هذا.",
|
||||
"project_deleted": "تم حذف المشروع",
|
||||
"delete_history_confirm": "هل تريد حذف عنصر السجل هذا؟",
|
||||
"history_deleted": "تم حذف عنصر السجل",
|
||||
"restored_state": "تمت استعادة حالة الجيل السابق",
|
||||
"upgrading_preview": "جارٍ ترقية مقطع (مقاطع) جودة المعاينة {{count}} إلى الجودة الكاملة..."
|
||||
},
|
||||
"tts_errors": {
|
||||
"enter_text": "الرجاء إدخال النص",
|
||||
"upload_or_select": "قم بتحميل ملف صوتي أو حدد ملفًا صوتيًا",
|
||||
"trim_hint": "الصوت هو {{duration}}s — قم بقصه إلى ≥{{max}}s للحصول على أفضل استنساخ",
|
||||
"timeout": "انتهت مهلة الجيل - ربما لا يزال النموذج قيد التنزيل. تحقق من الإعدادات ← السجلات، ثم حاول مرة أخرى.",
|
||||
"error_prefix": "خطأ: {{message}}",
|
||||
"ignored_unsupported": "تم تجاهل التعليمات غير المدعومة: {{items}}",
|
||||
"ignored_duplicate": "تم التجاهل (تم تعيين الفئة بالفعل): {{items}}"
|
||||
},
|
||||
"sharing": {
|
||||
"title": "المشاركة والوصول عن بعد",
|
||||
"help": "اعرض مثيل OmniVoice الجاري تشغيله على أجهزتك الأخرى دون إعادة تشغيله. الاسترجاع فقط هو الخيار الافتراضي — لا تتم مشاركة أي شيء حتى تقوم بتشغيله هنا.",
|
||||
"local_network": "الشبكة المحلية",
|
||||
"local_help": "شارك على شبكة Wi-Fi / Ethernet الخاصة بك باستخدام رقم التعريف الشخصي (PIN) للوصول لمرة واحدة. تقوم الأجهزة الأخرى بمسح رمز الاستجابة السريعة أو فتح الرابط.",
|
||||
"ports_title": "الموانئ",
|
||||
"ports_help": "يتم تعيينها عبر متغيرات البيئة التي تتم قراءتها عند بدء التشغيل. قم بتغيير الواجهة الخلفية أو منفذ واجهة المستخدم عن طريق تعيين المتغير وإعادة تشغيل OmniVoice.",
|
||||
"backend_port": "منفذ الخلفية",
|
||||
"ui_port": "منفذ واجهة المستخدم",
|
||||
"lan_share_port": "منفذ مشاركة LAN",
|
||||
"port_error": "أدخل منفذًا بين 1024 و65535",
|
||||
"port_saved": "تم حفظ منفذ مشاركة LAN - ينطبق في المرة التالية التي تقوم فيها بتمكين المشاركة",
|
||||
"port_save_failed": "تعذر حفظ المنفذ: {{message}}",
|
||||
"saving": "جارٍ الحفظ…",
|
||||
"ports_note": "يتم تطبيق منافذ الواجهة الخلفية وواجهة المستخدم عند إعادة التشغيل. يتم تطبيق منفذ مشاركة LAN في المرة التالية التي تقوم فيها بتمكين المشاركة.",
|
||||
"tailscale_title": "Tailscale (الوصول عن بعد الخاص)",
|
||||
"tailscale_checking": "جارٍ التحقق من مقياس الذيل…",
|
||||
"tailscale_absent": "لم يتم الكشف عن مقياس الذيل. قم بتثبيته للوصول إلى OmniVoice بشكل آمن من أي مكان على شبكتك الخاصة.",
|
||||
"tailscale_install": "تثبيت Tailscale",
|
||||
"tailscale_running": "مقياس الذيل قيد التشغيل. قم بخدمة OmniVoice عبر الشبكة الخلفية الخاصة بك.",
|
||||
"tailscale_not_logged_in": "تم تثبيت Tailscale ولكن لم يتم تسجيل الدخول. ابدأ وقم بتسجيل الدخول إلى Tailscale أولاً.",
|
||||
"tailscale_enabled": "تم تمكين خدمة Tailscale",
|
||||
"tailscale_enable_failed": "لا يمكن تمكين Tailscale",
|
||||
"tailscale_enable_error": "تعذر تمكين Tailscale: {{message}}",
|
||||
"tailscale_disabled": "تم تعطيل خدمة Tailscale",
|
||||
"tailscale_disable_failed": "لا يمكن تعطيل Tailscale",
|
||||
"tailscale_disable_error": "تعذر تعطيل Tailscale: {{message}}",
|
||||
"tailscale_enabling": "جارٍ التمكين…",
|
||||
"tailscale_enable_btn": "تمكين خدمة Tailscale",
|
||||
"tailscale_disabling": "جارٍ التعطيل...",
|
||||
"tailscale_disable_btn": "إيقاف خدمة Tailscale",
|
||||
"tailscale_copy": "انسخ الرابط",
|
||||
"tailscale_open": "فتح في المتصفح",
|
||||
"copied": "منقول",
|
||||
"tailscale_qr_alt": "رمز الاستجابة السريعة لعنوان URL لـ Tailscale"
|
||||
},
|
||||
"reportBug": {
|
||||
"label": "الإبلاغ عن خطأ",
|
||||
"title": "يفتح صفحة مشكلات GitHub المملوءة مسبقًا في متصفحك. لا يتم إرسال أي شيء حتى تقوم بالنقر فوق إرسال."
|
||||
},
|
||||
"app": {
|
||||
"loading": "جارٍ التحميل…",
|
||||
"trimmed_loaded": "تم تحميل الصوت المقطوع",
|
||||
"toast_exported": "تم التصدير: {{name}}",
|
||||
"toast_export_failed": "فشل التصدير: {{message}}",
|
||||
"toast_open_folder_failed": "لا يمكن فتح المجلد: {{message}}",
|
||||
"toast_saving": "جارٍ حفظ {{name}}...",
|
||||
"toast_saved": "تم الحفظ: {{path}}",
|
||||
"toast_save_error": "خطأ في الحفظ: {{message}}",
|
||||
"toast_processing": "معالجة {{name}}...",
|
||||
"toast_downloaded": "تم التنزيل {{name}}",
|
||||
"toast_download_error": "خطأ في التنزيل: {{message}}",
|
||||
"toast_upload_first": "الرجاء النقر فوق \"تحميل ونسخ\" أولاً حتى تتم معالجة الفيديو على الخادم قبل حفظه.",
|
||||
"toast_save_failed": "فشل الحفظ: {{message}}",
|
||||
"toast_opened": "مفتوح: {{name}}",
|
||||
"toast_project_deleted": "تم حذف المشروع",
|
||||
"toast_restored_state": "تمت استعادة حالة الجيل السابق",
|
||||
"toast_history_deleted": "تم حذف عنصر السجل",
|
||||
"toast_flushed": "متدفق — ذاكرة الوصول العشوائي {{ram}}G · VRAM {{vram}}G{{unloaded}}",
|
||||
"toast_model_unloaded": "· تم تفريغ النموذج",
|
||||
"toast_flush_failed": "فشل التدفق: {{message}}",
|
||||
"toast_project_saved": "تم حفظ المشروع",
|
||||
"toast_project_created": "تم إنشاء المشروع"
|
||||
},
|
||||
"update": {
|
||||
"available": "يتوفّر التحديث {{version}}",
|
||||
"install": "تثبيت وإعادة التشغيل",
|
||||
"install_hint": "نزّل التحديث وأعد التشغيل إلى الإصدار الجديد",
|
||||
"downloading": "جارٍ التحديث… {{pct}}%",
|
||||
"restart": "أعد التشغيل للتحديث",
|
||||
"busy": "أكمل الدبلجة أولاً — ثم ثبّت التحديث.",
|
||||
"whats_new": "ما هو الجديد",
|
||||
"failed": "فشل التحديث",
|
||||
"retry": "أعد المحاولة",
|
||||
"dismiss": "استبعاد"
|
||||
},
|
||||
"archetypes": {
|
||||
"featured": "مميز",
|
||||
@@ -945,5 +1576,93 @@
|
||||
"facet_accent": "لهجة",
|
||||
"facet_lang": "اللغة",
|
||||
"facet_whisper": "الهمس"
|
||||
},
|
||||
"support": {
|
||||
"tab_support": "الدعم",
|
||||
"tab_license": "رخصة تجارية",
|
||||
"toggle_label": "الدعم أو الترخيص التجاري",
|
||||
"other_ways": "طرق أخرى للمساعدة",
|
||||
"star_github": "نجمة على جيثب",
|
||||
"join_discord": "انضم إلى الفتنة"
|
||||
},
|
||||
"updates": {
|
||||
"tab": "التحديثات",
|
||||
"up_to_date": "محدث · v{{version}}",
|
||||
"check_now": "تحقق الآن",
|
||||
"releases": "الإصدارات",
|
||||
"current": "الحالي",
|
||||
"prerelease": "معاينة",
|
||||
"loading": "جارٍ تحميل الإصدارات…",
|
||||
"none": "لم يتم العثور على أي إصدارات",
|
||||
"load_error": "تعذر تحميل الإصدارات (غير متصل؟)",
|
||||
"retry_load": "أعد المحاولة"
|
||||
},
|
||||
"firstrun": {
|
||||
"loading": "جارٍ تجهيز الإعداد…",
|
||||
"title": "إعداد OmniVoice Studio",
|
||||
"subtitle": "لم يتم تثبيت أي شيء بعد — راجع أماكن حفظ كل شيء ثم ابدأ. يمكنك تغييرها لاحقًا من الإعدادات.",
|
||||
"language": "اللغة",
|
||||
"mode_title": "وضع التثبيت",
|
||||
"mode_installed": "مثبَّت",
|
||||
"mode_installed_desc": "يستخدم مجلدات النظام القياسية. يُنصح به لمعظم المستخدمين.",
|
||||
"mode_portable": "محمول",
|
||||
"mode_portable_desc": "كل شيء في مجلد واحد بجوار التطبيق — انقله كوحدة واحدة إلى قرص أو جهاز آخر.",
|
||||
"mode_portable_unavailable": "غير متاح: المجلد بجوار التطبيق غير قابل للكتابة.",
|
||||
"storage_title": "التخزين",
|
||||
"portable_folder": "المجلد المحمول",
|
||||
"portable_folder_desc": "بيئة التشغيل والنماذج وبيانات صوتك — مجلد واحد قابل للنقل بالكامل.",
|
||||
"env_dir": "بيئة التطبيق",
|
||||
"env_dir_desc": "بيئة Python ومكتبات الذكاء الاصطناعي.",
|
||||
"data_dir": "بيانات الصوت والمشاريع",
|
||||
"data_dir_desc": "أصواتك ودبلجاتك ومخرجاتك وقاعدة بيانات المشاريع.",
|
||||
"models_dir": "ذاكرة النماذج المؤقتة",
|
||||
"models_dir_desc": "نماذج الذكاء الاصطناعي المنزَّلة — الجزء الأكبر والأسهل في النقل.",
|
||||
"needs": "يحتاج ~{{size}}",
|
||||
"free": "متاح {{size}}",
|
||||
"checking": "جارٍ الفحص…",
|
||||
"not_writable": "غير قابل للكتابة",
|
||||
"change": "تغيير…",
|
||||
"compute_title": "المعالجة",
|
||||
"compute_label": "GPU / مسرّع",
|
||||
"compute_auto": "تلقائي (NVIDIA CUDA / Apple MPS / CPU)",
|
||||
"compute_rocm": "بطاقة AMD (ROCm، Linux)",
|
||||
"channel_label": "قناة التحديث",
|
||||
"channel_stable": "مستقرة",
|
||||
"channel_preview": "معاينة (أحدث main)",
|
||||
"network_title": "الشبكة",
|
||||
"region_label": "منطقة التنزيل",
|
||||
"mirrors_title": "مرايا مخصصة (متقدم)",
|
||||
"mirror_pypi": "عنوان فهرس PyPI",
|
||||
"mirror_hf": "نقطة نهاية Hugging Face",
|
||||
"mirror_python": "مرآة تنزيلات Python",
|
||||
"insufficient_space": "المساحة غير كافية: يحتاج هذا التوزيع إلى ~{{need}} على قرص واحد، والمتاح {{free}} فقط. اختر موقعًا آخر.",
|
||||
"blocked_not_writable": "أحد المجلدات المختارة غير قابل للكتابة — اختر موقعًا آخر.",
|
||||
"total_required": "إجمالي المساحة المطلوبة: ~{{size}} (تنزيل لمرة واحدة عند أول استخدام)",
|
||||
"start": "بدء التثبيت",
|
||||
"starting": "جارٍ البدء…",
|
||||
"compute_detected": "تم الاكتشاف",
|
||||
"compute_match": "يطابق هذا الجهاز",
|
||||
"compute_auto_desc": "يختار أفضل واجهة خلفية لهذا الجهاز عند التشغيل — CUDA على NVIDIA وMPS على Apple Silicon وإلا CPU.",
|
||||
"compute_rocm_desc": "يثبّت حزم PyTorch ROCm لبطاقات AMD على Linux. اتركه على تلقائي إن لم تكن متأكدًا.",
|
||||
"channel_stable_desc": "إصدارات مُختبرة فقط — تصل التحديثات بعد تحقق المجتمع.",
|
||||
"channel_preview_desc": "بنى متجددة من أحدث main — محركات وإصلاحات جديدة أولًا، مع خشونة طفيفة أحيانًا.",
|
||||
"installing_title": "جارٍ التثبيت",
|
||||
"activity_title": "النشاط",
|
||||
"stage_setup": "الإعداد",
|
||||
"stage_models": "النماذج والمحركات",
|
||||
"chip_required": "مطلوب",
|
||||
"chip_optional": "اختياري",
|
||||
"chip_engine": "محرك",
|
||||
"lib_download": "تنزيل",
|
||||
"lib_downloading": "جارٍ التنزيل…",
|
||||
"lib_use": "استخدام",
|
||||
"lib_active": "نشط",
|
||||
"lib_in_settings": "ثبّته لاحقًا من الإعدادات",
|
||||
"lib_show_all": "عرض {{count}} نموذجًا اختياريًا",
|
||||
"trust_line": "كل شيء يعمل ويبقى على هذا الجهاز — بلا حساب ولا سحابة ولا قياس عن بُعد.",
|
||||
"resume_note": "تُستأنف التنزيلات المتقطعة تلقائيًا — إغلاق التطبيق آمن.",
|
||||
"eta_left": "متبقٍ ~{{eta}}",
|
||||
"first_sound_text": "مرحبًا بك في الاستوديو الخاص بك. كل كلمة تسمعها وُلِّدت على هذا الجهاز للتو.",
|
||||
"first_sound_done": "ذلك الصوت؟ وُلِّد قبل ثوانٍ، محليًا. مرحبًا بك."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
{
|
||||
"update": {
|
||||
"available": "Update {{version}} verfügbar",
|
||||
"install": "Installieren und neu starten",
|
||||
"install_hint": "Update herunterladen und in die neue Version neu starten",
|
||||
"downloading": "Wird aktualisiert… {{pct}} %",
|
||||
"restart": "Zum Aktualisieren neu starten",
|
||||
"busy": "Beende zuerst deine Synchronisation – dann installiere das Update."
|
||||
},
|
||||
"nav": {
|
||||
"clone": "Klonen",
|
||||
"design": "Designen",
|
||||
@@ -16,7 +8,10 @@
|
||||
"launchpad": "Launchpad",
|
||||
"gallery": "Galerie",
|
||||
"transcripts": "Transkripte",
|
||||
"omnidrive": "OmniDrive"
|
||||
"omnidrive": "OmniDrive",
|
||||
"move_rail_right": "Schiene nach rechts verschieben",
|
||||
"move_rail_left": "Schiene nach links verschieben",
|
||||
"flip_rail": "Schienenseite umdrehen"
|
||||
},
|
||||
"settings": {
|
||||
"general": "Allgemein",
|
||||
@@ -45,7 +40,40 @@
|
||||
"ffmpeg_missing": "Nicht gefunden",
|
||||
"ffmpeg_current": "Aktueller Pfad",
|
||||
"ffmpeg_desc": "Legen Sie einen benutzerdefinierten ffmpeg-Pfad fest, wenn die automatische Erkennung fehlschlägt.",
|
||||
"ffmpeg_saved": "FFmpeg-Pfad festgelegt – Backend zur Anwendung neu starten."
|
||||
"ffmpeg_saved": "FFmpeg-Pfad festgelegt – Backend zur Anwendung neu starten.",
|
||||
"diagnostics_copied": "Diagnose kopiert – in Ihren Problembericht einfügen.",
|
||||
"updater_desktop": "Updater läuft nur in der Desktop-App.",
|
||||
"latest_version": "Sie sind auf der neuesten Version.",
|
||||
"logs_load_failed": "Protokolle konnten nicht geladen werden: {{message}}",
|
||||
"clear_frontend_confirm": "Den In-Memory-Frontend-Protokollpuffer löschen?",
|
||||
"clear_frontend_title": "Protokolle löschen",
|
||||
"frontend_logs_cleared": "Frontend-Protokolle gelöscht",
|
||||
"clear_tauri_confirm": "Die Tauri-seitigen Protokolldateien kürzen? Das Betriebssystem schreibt weiterhin neue Einträge.",
|
||||
"clear_tauri_title": "Tauri-Protokolle löschen",
|
||||
"nothing_to_clear": "Nichts zu löschen – noch keine Tauri-Protokolldatei auf der Festplatte.",
|
||||
"cleared_tauri_one": "{{count}} Tauri-Protokolldatei gelöscht",
|
||||
"cleared_tauri_other": "{{count}} Tauri-Protokolldateien gelöscht",
|
||||
"clear_tauri_failed": "Tauri-Protokolle konnten nicht gelöscht werden: {{message}}",
|
||||
"clear_backend_confirm": "Backend-Laufzeit- und Absturzprotokolle löschen? Dies kann nicht rückgängig gemacht werden.",
|
||||
"clear_backend_title": "Protokolle löschen",
|
||||
"backend_logs_cleared": "Backend-Protokolle gelöscht",
|
||||
"clear_backend_failed": "Protokolle konnten nicht gelöscht werden",
|
||||
"copy_failed": "Kopieren fehlgeschlagen: {{message}}",
|
||||
"update_check_failed": "Update-Prüfung fehlgeschlagen: {{message}}",
|
||||
"save_failed": "Speichern fehlgeschlagen: {{message}}",
|
||||
"clear_failed": "Löschen fehlgeschlagen: {{message}}",
|
||||
"engine_switched": "{{family}} → {{engine}}",
|
||||
"channel_set_failed": "Kanal konnte nicht festgelegt werden: {{message}}",
|
||||
"updater_downloading": "{{version}} wird heruntergeladen…",
|
||||
"updater_installed": "Installiert – Neustart.",
|
||||
"updater_available_title": "Update verfügbar",
|
||||
"updater_available_body": "Version {{version}} ist verfügbar.\n\n{{notes}}\n\nJetzt herunterladen und installieren?",
|
||||
"updater_notes_fallback": "Siehe Versionshinweise auf GitHub.",
|
||||
"shortcut_load_failed": "Verknüpfung konnte nicht geladen werden: {{message}}",
|
||||
"shortcut_set": "Diktatverknüpfung auf {{shortcut}} festgelegt",
|
||||
"shortcut_register_failed": "Konnte nicht registriert werden: {{message}}",
|
||||
"shortcut_reset": "Auf Standard zurücksetzen",
|
||||
"shortcut_reset_failed": "Zurücksetzen fehlgeschlagen: {{message}}"
|
||||
},
|
||||
"bootstrap": {
|
||||
"title": "OmniVoice Studio",
|
||||
@@ -70,7 +98,13 @@
|
||||
"suggest_lang": "Auf Deutsch umstellen?",
|
||||
"select_lang": "Sprache:",
|
||||
"lines_one": "{{count}} Zeile",
|
||||
"lines_other": "{{count}} Zeilen"
|
||||
"lines_other": "{{count}} Zeilen",
|
||||
"region_global": "Global (direkt)",
|
||||
"region_china": "China (Spiegel)",
|
||||
"region_russia": "Russland (Spiegel)",
|
||||
"region_restricted": "Eingeschränkt (Spiegel)",
|
||||
"unknown_error": "Unbekannter Fehler",
|
||||
"retrying": "Erneuter Versuch…"
|
||||
},
|
||||
"stories": {
|
||||
"title": "Story-Editor",
|
||||
@@ -159,7 +193,17 @@
|
||||
"reload": "Neuladen der Benutzeroberfläche erzwingen",
|
||||
"backend": "Backend",
|
||||
"frontend": "Frontend",
|
||||
"tauri": "Tauri"
|
||||
"tauri": "Tauri",
|
||||
"cancelOp": "Vorgang abbrechen",
|
||||
"dismiss": "Entlassen",
|
||||
"dismissStatus": "Status entlassen",
|
||||
"search": "Suchen…",
|
||||
"no_matches": "Keine Übereinstimmungen",
|
||||
"recent_and_popular": "Neu und beliebt",
|
||||
"popular_label": "Beliebt",
|
||||
"showing_of": "Zeigt {{shown}} von {{total}}. Geben Sie Folgendes ein, um zu suchen …",
|
||||
"yes": "Ja",
|
||||
"no": "Nein"
|
||||
},
|
||||
"launchpad": {
|
||||
"greeting": "Hallo",
|
||||
@@ -181,7 +225,16 @@
|
||||
"locked": "GESPERRT",
|
||||
"open": "Offen",
|
||||
"try_it": "Probieren Sie es aus",
|
||||
"audio_only": "Nur Audio"
|
||||
"audio_only": "Nur Audio",
|
||||
"stories_title": "Geschichten",
|
||||
"stories_desc": "Mehrsprachige Hörbücher – Besetzung Ihrer Charaktere, Einfügen eines Skripts, Export nach Kapitel.",
|
||||
"gallery_title": "Sprachgalerie",
|
||||
"gallery_desc": "Durchsuchen Sie vorgefertigte Stimmen nach Akzent, Alter und Stil – ohne Einrichtung.",
|
||||
"transcripts_title": "Transkripte",
|
||||
"transcripts_desc": "Verwandeln Sie Audio oder Video in bearbeitbaren, durchsuchbaren Text – in 646 Sprachen.",
|
||||
"recent_files": "Aktuelle Dateien",
|
||||
"view_all_files": "Alle Dateien anzeigen",
|
||||
"file": "Datei"
|
||||
},
|
||||
"clone": {
|
||||
"prompt": "Prompt",
|
||||
@@ -272,11 +325,6 @@
|
||||
"outputs": "Ausgänge",
|
||||
"crash_log": "Absturzprotokoll",
|
||||
"update_endpoint": "Endpunkt aktualisieren",
|
||||
"update_channel": "Update-Kanal",
|
||||
"channel_stable": "Stabil",
|
||||
"channel_preview": "Vorschau",
|
||||
"channel_set": "Update-Kanal auf {{channel}} gesetzt",
|
||||
"channel_preview_hint": "Die Vorschau folgt dem neuesten main-Build – neuere Funktionen, weniger getestet. Wechselt zurück zu Stabil, wenn eine stabile Version voraus ist.",
|
||||
"yes": "ja",
|
||||
"no": "Nein",
|
||||
"web_preview": "Webvorschau",
|
||||
@@ -285,7 +333,12 @@
|
||||
"copy_diagnostics": "Diagnose kopieren",
|
||||
"github": "OmniVoice auf GitHub",
|
||||
"model_card": "Modellkarte",
|
||||
"commercial_license": "Kommerzielle Lizenz"
|
||||
"commercial_license": "Kommerzielle Lizenz",
|
||||
"update_channel": "Update-Kanal",
|
||||
"channel_stable": "Stabil",
|
||||
"channel_preview": "Vorschau",
|
||||
"channel_set": "Update-Kanal auf {{channel}} gesetzt",
|
||||
"channel_preview_hint": "Die Vorschau folgt dem neuesten main-Build – neuere Funktionen, weniger getestet. Wechselt zurück zu Stabil, wenn eine stabile Version voraus ist."
|
||||
},
|
||||
"privacy": {
|
||||
"desc": "Alles läuft auf <1>dieser Maschine</1>. Ihre Audio-, Video- und Transkripte verlassen niemals Ihren Computer, es sei denn, Sie verwenden ausdrücklich einen Online-Übersetzer (Google, DeepL usw.) oder pushen an HuggingFace.",
|
||||
@@ -341,7 +394,30 @@
|
||||
"unavailable": "nicht verfügbar",
|
||||
"use": "Benutzen",
|
||||
"loading": "Motoren werden geladen…",
|
||||
"refresh": "Aktualisieren"
|
||||
"refresh": "Aktualisieren",
|
||||
"matrixTitle": "Engine-Kompatibilitätsmatrix",
|
||||
"loadFailed": "Fehler beim Laden der Engines: {{message}}",
|
||||
"couldNotLoad": "Engines konnten nicht geladen werden: {{message}}",
|
||||
"retry": "Versuchen Sie es noch einmal",
|
||||
"activeEngine": "Aktiv {{family}}: {{engine}}",
|
||||
"engineCompatLabel": "{{family}} Motorkompatibilität",
|
||||
"active": "aktiv",
|
||||
"whyUnavailable": "Warum nicht verfügbar?",
|
||||
"lastError": "Letzter Fehler: {{error}}",
|
||||
"installedAndReady": "Installiert und fertig",
|
||||
"notInstalled": "Nicht installiert",
|
||||
"available": "Verfügbar",
|
||||
"subprocessTitle": "Läuft in einem eigenen Unterprozess + venv",
|
||||
"inProcessTitle": "Wird im OmniVoice-Python-Prozess ausgeführt",
|
||||
"testEngine": "Testmotor",
|
||||
"testing": "Testen…",
|
||||
"recheck": "Überprüfen Sie es noch einmal",
|
||||
"rechecking": "Nochmals prüfen…",
|
||||
"latencyMs": "{{ms}} ms",
|
||||
"failed": "gescheitert",
|
||||
"acceptLicense": "Lizenz akzeptieren",
|
||||
"noBackends": "Keine Backends registriert.",
|
||||
"switch_failed": "Motorwechsel fehlgeschlagen"
|
||||
},
|
||||
"capture": {
|
||||
"desc": "Globale Hotkeys funktionieren nur in der Desktop-App. Die Web-Benutzeroberfläche verwendet eine In-Page-Verknüpfung <1>Strg+Umschalt+Leertaste</1>, während das Fenster den Fokus hat.",
|
||||
@@ -353,13 +429,55 @@
|
||||
"record_shortcut": "Verknüpfung aufzeichnen",
|
||||
"recording": "Aufnahme…",
|
||||
"save": "Speichern",
|
||||
"reset_default": "Auf Standard zurücksetzen"
|
||||
"reset_default": "Auf Standard zurücksetzen",
|
||||
"listening_label": "Zuhören…",
|
||||
"transcribing_label": "Transkribieren…",
|
||||
"pasted": "Eingefügt",
|
||||
"no_speech": "Keine Sprache erkannt",
|
||||
"mic_denied": "Mikrofonzugriff verweigert",
|
||||
"mic_denied_toast": "Mikrofonzugriff verweigert. {{hint}}",
|
||||
"mic_hint_mac": "macOS: Öffnen Sie Systemeinstellungen → Datenschutz und Sicherheit → Mikrofon und aktivieren Sie OmniVoice.",
|
||||
"mic_hint_windows": "Windows: Öffnen Sie Einstellungen → Datenschutz und Sicherheit → Mikrofon und erlauben Sie OmniVoice.",
|
||||
"mic_hint_linux": "Linux: Überprüfen Sie, ob sich Ihr Benutzer in der Audiogruppe befindet und WebView Zugriff auf das Mikrofon hat.",
|
||||
"transcription_failed": "Transkription fehlgeschlagen: {{message}}"
|
||||
},
|
||||
"logs": {
|
||||
"no_tauri_log": "Noch kein Tauri-Log auf der Festplatte – starten Sie es über den Desktop-Build, um eines zu erstellen",
|
||||
"empty_frontend": "Es wurden noch keine Frontend-Konsoleneinträge erfasst. Interagieren Sie mit der App – jede Konsole.* wird hier angezeigt.",
|
||||
"empty_tauri": "Kein Tauri-Protokoll verfügbar. Läuft nur in der Desktop-Shell.",
|
||||
"empty_backend": "Das Laufzeitprotokoll ist leer. Die Aktivität wird hier angezeigt, wenn das Backend sie protokolliert."
|
||||
"empty_backend": "Das Laufzeitprotokoll ist leer. Die Aktivität wird hier angezeigt, wenn das Backend sie protokolliert.",
|
||||
"title": "Protokolle",
|
||||
"source_backend": "Backend",
|
||||
"source_frontend": "Frontend",
|
||||
"source_tauri": "Tauri",
|
||||
"expand": "Protokolle erweitern",
|
||||
"collapse": "Protokolle ausblenden",
|
||||
"expand_aria": "Erweitern Sie den Protokollbereich",
|
||||
"collapse_aria": "Protokollbereich ausblenden",
|
||||
"drag_resize": "Ziehen Sie, um die Größe zu ändern",
|
||||
"refresh": "Aktualisieren",
|
||||
"refresh_aria": "Protokolle aktualisieren",
|
||||
"copy_visible": "Sichtbares Protokoll kopieren",
|
||||
"copy_visible_aria": "Sichtbares Protokoll kopieren",
|
||||
"clear": "Klar",
|
||||
"clear_aria": "Protokoll löschen",
|
||||
"report_issue": "Problem melden (Diagnose kopieren)",
|
||||
"report_issue_aria": "Problem melden",
|
||||
"close": "Schließen",
|
||||
"close_aria": "Schließen Sie das Protokollfenster",
|
||||
"join_discord": "Treten Sie unserem Discord bei",
|
||||
"join_discord_aria": "Treten Sie unserer Discord-Community bei",
|
||||
"support_project": "Unterstützen Sie dieses Projekt",
|
||||
"support_project_aria": "Unterstützen Sie dieses Projekt",
|
||||
"empty_frontend_short": "Noch keine Frontend-Konsolenausgabe.",
|
||||
"empty_lines": "Keine Linien.",
|
||||
"all_clear": "✅ Alles klar – keine Probleme festgestellt",
|
||||
"log_cleared": "{{source}}-Protokoll gelöscht",
|
||||
"clear_failed": "Löschen fehlgeschlagen: {{message}}",
|
||||
"log_copied": "{{source}}-Protokoll kopiert",
|
||||
"copy_failed": "Kopieren fehlgeschlagen: {{message}}",
|
||||
"report_copied": "Diagnosebericht kopiert – fügen Sie ihn in ein GitHub-Problem ein.",
|
||||
"report_failed": "Bericht fehlgeschlagen: {{message}}"
|
||||
},
|
||||
"voice_profile": {
|
||||
"test_text": "Hallo, dies ist ein Test dieser Stimme.",
|
||||
@@ -527,7 +645,17 @@
|
||||
"install_already": "{{engine}} wurde bereits installiert",
|
||||
"install_ok": "{{engine}} installiert",
|
||||
"install_failed": "Installation fehlgeschlagen: {{message}}",
|
||||
"prep_elapsed": "{{time}} abgelaufen"
|
||||
"prep_elapsed": "{{time}} abgelaufen",
|
||||
"add_language": "Sprache hinzufügen",
|
||||
"search_languages": "Sprachen suchen…",
|
||||
"languages_selected_one": "{{count}} Sprache ausgewählt",
|
||||
"languages_selected_other": "{{count}} Sprachen ausgewählt",
|
||||
"more_to_narrow": "+{{count}} mehr – zum Eingrenzen eingeben",
|
||||
"no_matches": "Keine Übereinstimmungen",
|
||||
"diagnostic_copied": "Diagnose kopiert",
|
||||
"copy_failed": "Das Kopieren ist fehlgeschlagen",
|
||||
"open_docs": "Dokumente öffnen",
|
||||
"copy_diagnostic": "Diagnose kopieren"
|
||||
},
|
||||
"glossary": {
|
||||
"title": "Glossar",
|
||||
@@ -602,7 +730,17 @@
|
||||
"more_actions_title": "Weitere Aktionen",
|
||||
"speaker_pick": "Wählen Sie…",
|
||||
"speaker_title_detected": "Lautsprecher: Wählen Sie einen der erkannten Namen aus oder geben Sie einen benutzerdefinierten Namen ein",
|
||||
"speaker_title_custom": "Sprecher – Geben Sie einen Namen ein (keine Diarisierungsklone erkannt)"
|
||||
"speaker_title_custom": "Sprecher – Geben Sie einen Namen ein (keine Diarisierungsklone erkannt)",
|
||||
"time_edit_title": "Klicken Sie hier, um die Startzeit (m:ss.s) zu bearbeiten. Geben Sie zum Festschreiben die Eingabetaste ein, zum Abbrechen die Esc-Taste.",
|
||||
"fit_fits": "Passt",
|
||||
"fit_fits_title": "Audio mit natürlicher Geschwindigkeit passt in den Steckplatz.",
|
||||
"fit_overflows": "Überläufe +{{seconds}}s",
|
||||
"fit_overflows_title": "Der übersetzte Text war um {{seconds}}s länger als der ursprüngliche Text. Der Ton war stark gekürzt; kürzen Sie den Text oder stellen Sie das Timing auf „Video dehnen“ um.",
|
||||
"fit_stretched": "Video {{ratio}}×",
|
||||
"fit_stretched_title": "Modus „Video dehnen“: Das Video dieses Segments wurde auf {{ratio}}× verlangsamt, um dem natürlichen Dub-Audio zu entsprechen.",
|
||||
"fit_compressed_title": "TTS-Audio macht {{pct}} % des Slots aus – stark komprimiert.",
|
||||
"fit_audio_title": "Audio passt in den Steckplatz.",
|
||||
"fit_ratio_title": "TTS-Audio macht {{pct}} % des Slots aus."
|
||||
},
|
||||
"voice": {
|
||||
"personality": "Persönlichkeit",
|
||||
@@ -697,10 +835,24 @@
|
||||
"status_running": "laufen",
|
||||
"status_done": "erledigt",
|
||||
"status_failed": "gescheitert",
|
||||
"status_cancelled": "abgesagt"
|
||||
"status_cancelled": "abgesagt",
|
||||
"add_to_queue_title": "Fügen Sie Videos zur Warteschlange hinzu",
|
||||
"drop_hint_text": "Legen Sie Videodateien hier ab oder klicken Sie zum Durchsuchen",
|
||||
"drop_formats": "MP4 · MOV · MKV · WEBM",
|
||||
"files_kicker": "DATEIEN ({{count}})",
|
||||
"file_size_mb": "{{size}} MB",
|
||||
"target_languages": "ZIELSPRACHEN",
|
||||
"voice_kicker": "STIMME",
|
||||
"default_option": "Standard",
|
||||
"clone_profiles": "Profile klonen",
|
||||
"presets": "Voreinstellungen",
|
||||
"preserve_bg": "Hintergrundaudio (Musik/FX) beibehalten",
|
||||
"estimate": "{{videos}} Video(s) × {{langs}} Sprache(n) = {{jobs}} Job(s)",
|
||||
"select_files_langs": "Wählen Sie Dateien und Sprachen aus",
|
||||
"add_to_queue": "Zur Warteschlange hinzufügen"
|
||||
},
|
||||
"gallery": {
|
||||
"title": "Galerie",
|
||||
"title": "OmniVoice Galerie",
|
||||
"search_placeholder": "YouTube durchsuchen…",
|
||||
"all_voices": "Alle Stimmen ({{count}})",
|
||||
"no_voices": "Noch keine Stimmen",
|
||||
@@ -715,6 +867,14 @@
|
||||
"youtube_results": "YouTube-Ergebnisse ({{count}})",
|
||||
"clone_profile": "Profil klonen",
|
||||
"crop_audio": "Audio zuschneiden",
|
||||
"cat_disney": "Disney",
|
||||
"cat_anime": "Anime",
|
||||
"cat_marvel": "Marvel/DC",
|
||||
"cat_celebs": "Prominente",
|
||||
"cat_politicians": "Politiker",
|
||||
"cat_news": "Nachrichtensprecher",
|
||||
"cat_gaming": "Spielen",
|
||||
"cat_books": "Bücher/Filme",
|
||||
"subtitle": "Hunderte vorgefertigte Stimmen – wählen Sie eine aus und legen Sie los.",
|
||||
"zone_archetypes": "Archetypen",
|
||||
"zone_imports": "Meine Importe",
|
||||
@@ -818,8 +978,7 @@
|
||||
"back": "Zurück zum Studio",
|
||||
"badge": "Kommerzielle Lizenz",
|
||||
"hero_title": "Versenden Sie KI-Stimmen in der Produktion",
|
||||
"hero_desc": "OmniVoice Studio ist als Quelle unter der Functional Source License (FSL) verfügbar. Die meisten Benutzer können ohne kommerzielle Vereinbarung evaluieren, Prototypen erstellen und sogar intern bereitstellen. Sie benötigen eine kommerzielle Lizenz nur, wenn Sie ein konkurrierendes Produkt oder eine konkurrierende Dienstleistung entwickeln oder wenn Ihr Anwendungsfall außerhalb der FSL-Grenzen liegt.",
|
||||
"hero_note": "Für den Aufbau eines Konkurrenzprodukts oder -dienstes oder für die Bereitstellung in großem Maßstab (z. B. Bereitstellung einer Pay-per-Use-API) ist eine kommerzielle Lizenz erforderlich. Die Preisstufen folgen in Kürze – nehmen Sie in der Zwischenzeit Kontakt mit uns auf.",
|
||||
"hero_desc": "OmniVoice Studio ist freie Open-Source-Software unter der GNU Affero General Public License v3 (AGPL-3.0) — kostenlos nutzbar, auch für kommerzielle und interne geschäftliche Zwecke. Eine kommerzielle Lizenz benötigen Sie nur, wenn Sie OmniVoice Studio ohne die Copyleft-Pflichten der AGPL-3.0 in ein Closed-Source- oder proprietäres Produkt oder einen entsprechenden Dienst einbetten möchten.",
|
||||
"why_title": "Warum Unternehmen sich für OmniVoice entscheiden",
|
||||
"pricing_title": "Preise",
|
||||
"faq_title": "Häufige Fragen",
|
||||
@@ -839,7 +998,8 @@
|
||||
"benefit_source": "Quellverfügbarer Kern",
|
||||
"benefit_source_desc": "Volle Sicht auf den Stapel. Prüfen, forken und anpassen Sie innerhalb der Lizenzbedingungen.",
|
||||
"benefit_lang": "646 Sprachen",
|
||||
"benefit_lang_desc": "Transkribieren, übersetzen und synchronisieren Sie in 646 Sprachen mit menschlicher Qualität."
|
||||
"benefit_lang_desc": "Transkribieren, übersetzen und synchronisieren Sie in 646 Sprachen mit menschlicher Qualität.",
|
||||
"hero_note": "Nutzung, Self-Hosting und kommerzielle Nutzung sind unter der AGPL-3.0 kostenlos — auch im großen Maßstab. Die AGPL ist eine Netzwerk-Copyleft-Lizenz: Wenn Sie OmniVoice modifizieren und diese modifizierte Version anderen über ein Netzwerk anbieten, müssen Sie Ihren geänderten Quellcode zu denselben Bedingungen offenlegen. Eine kommerzielle Lizenz hebt diese Copyleft-Pflichten für proprietäre Closed-Source-Deployments auf. Preismodelle folgen in Kürze — melden Sie sich in der Zwischenzeit gerne."
|
||||
},
|
||||
"exportModal": {
|
||||
"export": "Exportieren",
|
||||
@@ -928,7 +1088,478 @@
|
||||
"dubbing_title": "Erleben Sie Synchronisation in Aktion",
|
||||
"dubbing_sync": "Synchronisierte Wiedergabe",
|
||||
"dubbing_picker": "Versuchen Sie es mit einer anderen Sprache:",
|
||||
"dubbing_cta": "Führen Sie dies in Ihrem eigenen Video aus →"
|
||||
"dubbing_cta": "Führen Sie dies in Ihrem eigenen Video aus →",
|
||||
"dubbing_loading": "Synchronisationsdemo wird geladen…",
|
||||
"dubbing_dismiss": "Synchronisationsdemo schließen",
|
||||
"original_tag": "Original",
|
||||
"dubbed_tag": "synchronisiert",
|
||||
"script_conversational": "Konversation",
|
||||
"script_technical": "Fachvokabular",
|
||||
"script_french": "Nicht-Englisch (Französisch)",
|
||||
"aria_pause": "Pause {{label}}",
|
||||
"aria_hear": "Hören Sie {{label}}",
|
||||
"aria_replay": "Wiederholen Sie {{label}} durch den Transkriptor",
|
||||
"dictation_lede_hotkey_only": "Halte das Tastenkürzel oben überall auf dem Desktop gedrückt, sprich und lass los — der Text landet in der fokussierten App. Drücke es jetzt zum Verifizieren."
|
||||
},
|
||||
"direction": {
|
||||
"title": "Richtung für Segment #{{id}}",
|
||||
"desc": "Sagen Sie der Pipeline, wie sich diese Zeile anfühlen soll. Einfaches Englisch funktioniert – das System ordnet Ihre Wörter einer stabilen Taxonomie zu (Energie / Emotion / Tempo / Intimität / Formalität) und fädelt die Taxonomie dann durch filmische Übersetzung, TTS und Slot-Fit ein.",
|
||||
"label": "Richtung",
|
||||
"lineHint": "Zeile: „{{text}}“",
|
||||
"placeholder": "z.B. dringend und überrascht / warm, hoffnungsvoll / geflüstert, intim",
|
||||
"previewParse": "Vorschauanalyse",
|
||||
"previewFailed": "Vorschau fehlgeschlagen: {{message}}",
|
||||
"clear": "Klar",
|
||||
"cancel": "Abbrechen",
|
||||
"saveDirection": "Richtung speichern",
|
||||
"ttsInstruct": "TTS weist an:",
|
||||
"nothingParsed": "– (nichts analysiert)",
|
||||
"translateHint": "Hinweis zum Übersetzen:",
|
||||
"rateBias": "Ratenverzerrung:",
|
||||
"speedsUp": "beschleunigt",
|
||||
"slowsDown": "verlangsamt sich",
|
||||
"taxonomyTokens": "Taxonomie-Token"
|
||||
},
|
||||
"errors": {
|
||||
"title": "Dieser Tab ist ins Stocken geraten.",
|
||||
"desc": "Keine Sorge – der Rest der App funktioniert weiterhin. Sie können die Registerkarten wechseln oder es unten noch einmal versuchen.",
|
||||
"tryAgain": "Versuchen Sie es erneut",
|
||||
"openDocs": "Öffnen Sie die Dokumentation zu diesem Fehler"
|
||||
},
|
||||
"keyboard": {
|
||||
"title": "Tastaturkürzel",
|
||||
"footer": "Drücken Sie jederzeit <1>?</1>, um dies zu öffnen.",
|
||||
"or": "oder",
|
||||
"nav": "Navigation",
|
||||
"nav_cheatsheet": "Zeige diesen Spickzettel",
|
||||
"nav_closeModal": "Modal schließen / abbrechen",
|
||||
"nav_save": "Projekt speichern / Trimmen festschreiben",
|
||||
"segmentEditor": "Segmenteditor",
|
||||
"seg_split": "Segment am Cursor teilen",
|
||||
"seg_merge": "Mit dem nächsten Segment zusammenführen",
|
||||
"seg_undo": "Rückgängig machen",
|
||||
"seg_redo": "Wiederholen",
|
||||
"seg_click": "Primäre Aktion",
|
||||
"seg_shiftClick": "Bereichsauswahl",
|
||||
"trimmer": "Audio-Trimmer",
|
||||
"trim_playPause": "Vorschauwiedergabe/Pause",
|
||||
"trim_nudgeStart": "Startgriff anstoßen",
|
||||
"trim_nudgeEnd": "Griff am Ende anstoßen",
|
||||
"trim_fineNudge": "Feiner Anstoß",
|
||||
"trim_coarseNudge": "Grober Anstoß",
|
||||
"trim_zoomIn": "Vergrößern/verkleinern",
|
||||
"trim_fitAll": "Alle anpassen / Auswahl anpassen",
|
||||
"trim_confirm": "Trimmen bestätigen",
|
||||
"dub": "Dub",
|
||||
"dub_generate": "Dub generieren",
|
||||
"dub_sidebar": "Seitenleiste umschalten"
|
||||
},
|
||||
"network": {
|
||||
"sharing_on_title": "Teilen auf – klicken Sie für Details",
|
||||
"share_on_network": "Teilen Sie es in Ihrem Netzwerk",
|
||||
"switching": "Wechseln…",
|
||||
"network": "Netzwerk",
|
||||
"local": "Lokal",
|
||||
"share_confirm_title": "In Ihrem Netzwerk teilen?",
|
||||
"share_confirm_hint": "Andere Geräte in Ihrem WLAN/Ethernet können OmniVoice über die angezeigte Zugangs-PIN erreichen, sobald es eingeschaltet ist.",
|
||||
"enabling": "Aktivieren…",
|
||||
"enable": "Aktivieren",
|
||||
"shared_title": "In Ihrem Netzwerk geteilt",
|
||||
"no_interface": "Keine erreichbare Netzwerkschnittstelle – stellen Sie eine Verbindung zu WLAN/Ethernet her.",
|
||||
"copy_link": "Link kopieren",
|
||||
"open_in_browser": "Im Browser öffnen",
|
||||
"qr_alt": "QR für {{ip}}",
|
||||
"pin": "PIN:",
|
||||
"stop_sharing": "Hören Sie auf zu teilen",
|
||||
"copied": "Kopiert",
|
||||
"enable_error": "Die Freigabe konnte nicht aktiviert werden: {{message}}",
|
||||
"disable_error": "Konnte nicht deaktiviert werden: {{message}}"
|
||||
},
|
||||
"readiness": {
|
||||
"checking_system": "Prüfsystem…",
|
||||
"all_ready": "Alle Systeme bereit",
|
||||
"system_readiness": "Systembereitschaft",
|
||||
"asr_model": "ASR-Modell",
|
||||
"loaded_ready": "Geladen und fertig",
|
||||
"loading_first_run": "Wird geladen… (dies kann beim ersten Durchlauf 1-2 Minuten dauern)",
|
||||
"failed_to_load": "Laden fehlgeschlagen",
|
||||
"not_loaded_yet": "Noch nicht geladen – wird bei der ersten Transkription geladen",
|
||||
"error_check_logs": "Fehler: {{error}}. Überprüfen Sie die Protokolle und versuchen Sie einen Neustart.",
|
||||
"check_logs_restart": "Überprüfen Sie die Protokolle auf Modellladefehler. Versuchen Sie einen Neustart.",
|
||||
"llm_cinematic": "LLM (Film)",
|
||||
"llm_configure": "Konfigurieren Sie TRANSLATE_BASE_URL für die filmische Übersetzungsqualität",
|
||||
"llm_set_env": "Legen Sie die Umgebungsvariablen TRANSLATE_BASE_URL und TRANSLATE_API_KEY fest. Funktioniert mit Ollama, OpenAI, LM Studio usw.",
|
||||
"llm_optional": "Optional – TRANSLATE_BASE_URL für Kinoqualität festlegen"
|
||||
},
|
||||
"license": {
|
||||
"title": "Supertonic-3 – Lizenzannahme",
|
||||
"intro": "Supertonic-3 wird unter zwei unterschiedlichen Lizenzen ausgeliefert. Bitte überprüfen Sie beides, bevor Sie die Engine aktivieren.",
|
||||
"sdk_heading": "SDK-Code · MIT",
|
||||
"sdk_desc": "Das Python-Inferenz-SDK (supertonic) ist MIT-lizenziert. Zulässige Nutzung, auch kommerziell.",
|
||||
"read_mit": "Lesen Sie die MIT-Lizenz →",
|
||||
"model_heading": "Modellgewichte · OpenRAIL-M",
|
||||
"model_desc": "Die Supertonic-3-Modellgewichte werden unter der OpenRAIL-M-Lizenz veröffentlicht. Diese Lizenz beschränkt die Nutzung auf nicht böswillige Zwecke. Den vollständigen Satz nutzungsbasierter Einschränkungen finden Sie in der verlinkten Lizenz.",
|
||||
"read_openrail": "Lesen Sie die OpenRAIL-M-Lizenz →",
|
||||
"footer": "Wenn Sie auf „Akzeptieren“ klicken, wird Ihre Zustimmung in den lokalen Einstellungen von OmniVoice erfasst und die Engine aktiviert. Ihre Zustimmung wird nur auf diesem Gerät gespeichert – keine Meldung an Supertone Inc. oder Dritte.",
|
||||
"saving": "Sparen…",
|
||||
"accept": "Akzeptiere",
|
||||
"accepted_toast": "Supertonic-3-Lizenz akzeptiert.",
|
||||
"accept_error": "Lizenzannahme konnte nicht aufgezeichnet werden: {{message}}"
|
||||
},
|
||||
"voicePreview": {
|
||||
"title": "Sprachvorschau",
|
||||
"close": "Vorschau schließen",
|
||||
"default_text": "Hallo! Dies ist eine Vorschau darauf, wie ich mit dieser Stimme klinge.",
|
||||
"default_voice": "Standardstimme",
|
||||
"clone_profiles": "Profile klonen",
|
||||
"designed_voices": "Gestaltete Stimmen",
|
||||
"presets": "Voreinstellungen",
|
||||
"placeholder": "Geben Sie etwas ein, das Sie hören möchten …",
|
||||
"stop": "Stopp",
|
||||
"regenerate": "Regenerieren",
|
||||
"preview": "Vorschau",
|
||||
"hint": "8 Schritte · schnelle Vorschau"
|
||||
},
|
||||
"header": {
|
||||
"kicker_studio": "Studio",
|
||||
"kicker_library": "Bibliothek",
|
||||
"kicker_preferences": "Präferenzen",
|
||||
"kicker_licensing": "Lizenzierung",
|
||||
"label_launchpad": "Launchpad",
|
||||
"label_clone": "Sprachklon",
|
||||
"label_design": "Sprachdesign",
|
||||
"label_dub": "Synchronisation",
|
||||
"label_projects": "OmniDrive",
|
||||
"label_gallery": "Galerie",
|
||||
"label_transcriptions": "Transkriptionen",
|
||||
"label_settings": "Einstellungen",
|
||||
"label_enterprise": "Kommerzielle Lizenz",
|
||||
"status_ready": "Bereit",
|
||||
"status_loading": "Laden…",
|
||||
"status_idle": "Leerlauf",
|
||||
"memory_management": "Speicherverwaltung",
|
||||
"flush": "Spülen",
|
||||
"loaded_models": "Geladene Modelle",
|
||||
"no_models": "Keine Modelle geladen",
|
||||
"unload": "Entladen",
|
||||
"flush_caches": "Caches leeren",
|
||||
"unload_all_flush": "Alles entladen + spülen"
|
||||
},
|
||||
"sidebar": {
|
||||
"tab_drive": "Fahren",
|
||||
"tab_history": "Geschichte",
|
||||
"tab_exports": "Exporte",
|
||||
"save_project": "Dub-Projekt speichern",
|
||||
"save_new_project": "Als neues Dub-Projekt speichern",
|
||||
"dub_projects": "Dub-Projekte",
|
||||
"voice_clones": "Sprachklone",
|
||||
"designed_voices": "Gestaltete Stimmen",
|
||||
"no_dub_projects": "Keine gespeicherten Dub-Projekte",
|
||||
"no_dub_hint": "Laden Sie ein Video hoch und klicken Sie auf Speichern, um Ihre Arbeit zu behalten.",
|
||||
"no_clones": "Noch keine Sprachklone",
|
||||
"no_clones_hint": "Nehmen Sie Audio auf oder laden Sie es hoch und klicken Sie dann auf Als Sprachprofil speichern.",
|
||||
"no_designs": "Noch keine entworfenen Stimmen",
|
||||
"no_designs_hint": "Erzeugen Sie eine Stimme und speichern Sie sie im Verlauf.",
|
||||
"history_subtitle": "Generationsverlauf · In SQLite gespeichert",
|
||||
"no_history": "Keine Generationsgeschichte",
|
||||
"no_history_hint": "Audio synthetisieren oder ein Video überspielen – die Ergebnisse werden hier angezeigt.",
|
||||
"clear_history": "Verlauf löschen",
|
||||
"clear_confirm": "Alle {{count}}-Verlaufselemente löschen? Dies kann nicht rückgängig gemacht werden.",
|
||||
"history_cleared": "Verlauf gelöscht",
|
||||
"recent_exports": "Aktuelle Exporte",
|
||||
"no_exports": "Keine heruntergeladenen Ausgaben",
|
||||
"no_exports_hint": "Exportieren Sie eine Datei über Tauri, um sie hier zu verfolgen.",
|
||||
"show_in_folder": "Im Ordner anzeigen",
|
||||
"open": "Offen",
|
||||
"select": "Auswählen",
|
||||
"try_voice": "Versuchen Sie es",
|
||||
"consistent": "konsistent",
|
||||
"locked": "Gesperrt",
|
||||
"clone_label": "Klonen",
|
||||
"design_label": "Design",
|
||||
"dub_label": "Dub",
|
||||
"save_label": "Speichern",
|
||||
"lock_identity": "Sprachidentität sperren",
|
||||
"in_folder": "in {{folder}}"
|
||||
},
|
||||
"trimmer": {
|
||||
"title": "Referenzaudio zuschneiden",
|
||||
"decoding": "Audio dekodieren…",
|
||||
"meta_length": "Länge {{duration}} · {{sampleRate}} Hz",
|
||||
"meta_rendering": "Wellenform rendern {{percent}}%",
|
||||
"keyboard_hint": "Scrollen = Zoomen · Umschalt+Scrollen = Schwenken · Alt+Ziehen = Schwenken · ⏐ ⟵ ⟶ ⏐ Tasten passen Ziehpunkte an",
|
||||
"zoom_in": "Vergrößern (+)",
|
||||
"zoom_out": "Verkleinern (-)",
|
||||
"fit_all": "Alle anpassen (Startseite)",
|
||||
"fit_selection": "Auswahl anpassen (Ende)",
|
||||
"fit_sel_btn": "FIT SEL",
|
||||
"view_range": "Ansicht {{start}} → {{end}} ({{duration}})",
|
||||
"start_label": "Starten",
|
||||
"end_label": "Ende",
|
||||
"length_label": "Länge",
|
||||
"too_long": ">{{max}}s",
|
||||
"too_short": "zu kurz",
|
||||
"length_ok": "ok",
|
||||
"loop_preview": "Loop-Vorschau",
|
||||
"pause": "Pause",
|
||||
"preview_selection": "Vorschauauswahl",
|
||||
"play_hint": "Leertaste zum Abspielen · Eingabetaste zum Bestätigen · Esc zum Abbrechen",
|
||||
"cancel": "Abbrechen",
|
||||
"use_trimmed": "Beschnitten verwenden",
|
||||
"decode_failed": "Dekodierung fehlgeschlagen: {{message}}",
|
||||
"playback_failed": "Wiedergabe fehlgeschlagen: {{message}}",
|
||||
"audio_load_failed": "Das Laden der Audiodaten ist fehlgeschlagen",
|
||||
"unit_seconds": "s"
|
||||
},
|
||||
"casting": {
|
||||
"title": "Sprecher-Casting",
|
||||
"auto_assign_title": "Weisen Sie automatisch Stimmen aus extrahierten Sprecherklonen zu",
|
||||
"auto_cast": "Automatische Besetzung",
|
||||
"all_cast": "Alles besetzt",
|
||||
"segments_count": "{{count}} Segmente",
|
||||
"assign_voice": "Stimme zuweisen…",
|
||||
"from_video": "🎤 Aus Video ({{name}})",
|
||||
"no_profiles": "Noch keine Sprachprofile gespeichert.",
|
||||
"preview_voice": "Vorschau der Stimme"
|
||||
},
|
||||
"checkpoint": {
|
||||
"asr_title": "Transkripte bereit",
|
||||
"asr_cta": "Übersetzen",
|
||||
"asr_hint": "Beheben Sie jetzt alle ASR-Fehler – eine straffe Diktion erspart spätere TTS-Versuche.",
|
||||
"translate_title": "Übersetzungen bereit",
|
||||
"translate_cta": "Dub generieren",
|
||||
"translate_hint": "Überfliegen Sie den Zieltext. Überlange Leitungen werden schneller; Sie können es auch direkt bearbeiten.",
|
||||
"done_title": "Dub abgeschlossen",
|
||||
"done_hint": "Überprüfen Sie Timing und Synchronisierungsverhältnisse. Optimieren Sie eine beliebige Zeile und klicken Sie auf „Geändert regenerieren“, um eine teilweise Wiederherstellung durchzuführen.",
|
||||
"segment_one": "{{count}}-Segment",
|
||||
"segment_other": "{{count}} Segmente",
|
||||
"dismiss_title": "Verwerfen – wird für diese Phase erst wieder angezeigt, wenn es neu geladen wird"
|
||||
},
|
||||
"compare": {
|
||||
"title": "A/B-Stimmenvergleich",
|
||||
"close": "Enger Vergleich",
|
||||
"desc": "Vergleichen Sie zwei Stimmen nebeneinander, um Casting-Entscheidungen zu treffen. App bleibt interaktiv hinter.",
|
||||
"test_phrase": "Testphrase",
|
||||
"voice_a": "Stimme A",
|
||||
"voice_b": "Stimme B",
|
||||
"select_voice": "— Stimme auswählen —",
|
||||
"preset_suffix": "(Voreingestellt)",
|
||||
"no_audio": "Noch kein Ton",
|
||||
"close_btn": "Schließen",
|
||||
"comparing": "Vergleichen…",
|
||||
"compare_btn": "Vergleichen",
|
||||
"preparing_voice": "Stimme vorbereiten...",
|
||||
"generating_voice_a": "Erzeugen von Sprach-A...",
|
||||
"generating_voice_b": "Erzeugen von Sprachb...",
|
||||
"comparison_complete": "Vergleich abgeschlossen!",
|
||||
"play_failed": "Wiedergabe fehlgeschlagen: {{message}}"
|
||||
},
|
||||
"models": {
|
||||
"hf_token_set_toast": "HuggingFace-Token-Set – schnellere Downloads ermöglicht",
|
||||
"hf_token_save_failed": "Token konnte nicht gespeichert werden",
|
||||
"install_started": "Installation gestartet – Fortschritt in der Zeile",
|
||||
"delete_confirm": "{{repoId}} löschen? Sie können es später erneut installieren.",
|
||||
"delete_confirm_title": "Modell löschen",
|
||||
"deleted": "Gelöscht {{repoId}}",
|
||||
"reinstall_confirm": "{{repoId}} neu installieren? Dadurch wird die aktuelle Kopie gelöscht und erneut heruntergeladen.",
|
||||
"reinstall_confirm_title": "Modell neu installieren",
|
||||
"reinstalling": "Neuinstallation",
|
||||
"recommended_installed": "Empfohlene Modelle sind bereits installiert.",
|
||||
"started_downloading_one": "Mit dem Herunterladen des Modells {{count}} begonnen",
|
||||
"started_downloading_other": "Mit dem Herunterladen von {{count}}-Modellen begonnen",
|
||||
"install_failed": "Installation fehlgeschlagen: {{message}}",
|
||||
"removing_cached": "Zwischengespeicherte Revisionen werden entfernt…",
|
||||
"resolving_metadata": "Repo-Metadaten auflösen",
|
||||
"retry_attempt": "Wiederholungsversuch {{attempt}} – {{error}}",
|
||||
"connecting_hf": "Verbindung zu HuggingFace herstellen…",
|
||||
"resolving_files_one": "Datei {{count}} wird aufgelöst…",
|
||||
"resolving_files_other": "{{count}}-Dateien werden aufgelöst…",
|
||||
"resolving_files_active": "Auflösen von {{count}} Datei(en)… · {{file}}",
|
||||
"measuring": "messen…",
|
||||
"files_progress": "{{done}}/{{total}} Dateien",
|
||||
"install_error": "Installation fehlgeschlagen: {{error}}",
|
||||
"view_on_hf": "Auf HuggingFace ansehen",
|
||||
"install_btn": "Installieren",
|
||||
"reinstall_btn": "Neu installieren",
|
||||
"downloading": "Herunterladen",
|
||||
"deleting": "löschen",
|
||||
"working": "arbeiten",
|
||||
"installed": "installiert",
|
||||
"not_installed": "nicht installiert",
|
||||
"required_tag": "erforderlich",
|
||||
"delete_btn": "Löschen",
|
||||
"hf_token_btn": "HF-Token",
|
||||
"hf_set_title": "Legen Sie das HuggingFace-Token für schnellere Downloads fest",
|
||||
"get_token": "Token holen →",
|
||||
"reco_installed_for": "Empfohlenes Bundle für {{device}} installiert",
|
||||
"reco_for": "Empfohlen für {{device}}",
|
||||
"starting": "Beginnend mit …",
|
||||
"required_size": "Erforderlich: ~{{size}} GB",
|
||||
"all_size": "Alle ~{{size}} GB",
|
||||
"req_tag": "erf",
|
||||
"search_placeholder": "Modelle suchen…",
|
||||
"search_label": "Modelle suchen",
|
||||
"no_matches": "Kein Modell entspricht Ihren Filtern.",
|
||||
"sort_by": "Sortieren nach {{column}}",
|
||||
"column_model": "Modell",
|
||||
"column_role": "Rolle",
|
||||
"column_size": "Größe",
|
||||
"column_status": "Status",
|
||||
"ready_badge": "Bereit",
|
||||
"loading_badge": "Laden…",
|
||||
"idle_badge": "Leerlauf",
|
||||
"started_downloading_required_one": "Mit dem Herunterladen des erforderlichen Modells {{count}} begonnen",
|
||||
"started_downloading_required_other": "Mit dem Herunterladen der für {{count}} erforderlichen Modelle begonnen"
|
||||
},
|
||||
"enterprise_faq": {
|
||||
"q_internal_tools": "Benötige ich eine Lizenz für interne Tools?",
|
||||
"a_internal_tools": "Nein. Die Nutzung durch Ihre Mitarbeitenden und Auftragnehmer — einschließlich interner Modifikation und internem Self-Hosting — ist unter der AGPL-3.0 kostenlos. Eine kommerzielle Lizenz ist nur nötig, wenn Sie OmniVoice in ein Closed-Source- oder proprietäres Produkt oder einen Dienst einbetten und die Quelloffenlegungspflichten der AGPL nicht erfüllen möchten.",
|
||||
"q_try_before": "Kann ich es versuchen, bevor ich mich verpflichte?",
|
||||
"a_try_before": "Ja. Die vollständige App lässt sich unter der AGPL-3.0 kostenlos herunterladen, ausführen und selbst hosten — ganz ohne Vertrag. Wenn Sie über eine kommerzielle Lizenz (für proprietäre Nutzung) sprechen möchten, schreiben Sie uns eine E-Mail und wir klären die Details gemeinsam.",
|
||||
"q_watermark": "Was ist mit dem Wasserzeichen?",
|
||||
"a_watermark": "Das unsichtbare AudioSeal-Wasserzeichen ist standardmäßig für alle eingebettet. Kommerzielle Lizenznehmer können es unter Einstellungen → Datenschutz deaktivieren."
|
||||
},
|
||||
"gallery_extra": {
|
||||
"save_prompt": "Geben Sie einen Namen für dieses Sprachprofil ein:",
|
||||
"saved_as_profile": "Stimme als Profil gespeichert!",
|
||||
"save_profile_failed": "Profil konnte nicht gespeichert werden",
|
||||
"download_failed": "Download fehlgeschlagen: {{message}}",
|
||||
"trim_load_failed": "Audio zum Zuschneiden konnte nicht geladen werden: {{message}}",
|
||||
"upload_crop_failed": "Zugeschnittene Stimme konnte nicht hochgeladen werden: {{message}}"
|
||||
},
|
||||
"dub_workflow": {
|
||||
"preparing_audio": "Audio wird vorbereitet…",
|
||||
"preparing_video": "Video wird vorbereitet…",
|
||||
"extracting_audio_scenes": "Extrahieren von Audio und Szenen…",
|
||||
"transcribing_audio": "Audio transkribieren…",
|
||||
"transcription_complete": "Transkription abgeschlossen",
|
||||
"upload_cancelled": "Upload abgebrochen",
|
||||
"upload_failed": "Hochladen fehlgeschlagen: {{message}}",
|
||||
"downloading_video": "Video wird heruntergeladen…",
|
||||
"ingested": "Aufgenommen {{url}}",
|
||||
"ingest_cancelled": "Aufnahme abgebrochen",
|
||||
"ingest_failed": "URL-Aufnahme fehlgeschlagen: {{message}}",
|
||||
"retry_cancelled": "Wiederholungsversuch abgebrochen",
|
||||
"transcription_failed": "Transkription fehlgeschlagen: {{message}}",
|
||||
"import_srt_no_job": "Laden Sie zuerst ein Video hoch oder nehmen Sie es auf – es gibt keinen Job, dem Sie Untertitel hinzufügen müssen.",
|
||||
"imported_cues": "{{count}} Cue(s) von {{file}} importiert",
|
||||
"skipped_malformed": "{{count}} übersprungen (fehlerhaft)",
|
||||
"dropped_overlap": "{{count}} gelöscht (Überlappung)",
|
||||
"clamped_to_duration": "{{count}} auf Medienlänge geklemmt",
|
||||
"srt_import_failed": "Der SRT-Import ist fehlgeschlagen",
|
||||
"cleaned_one": "Bereinigtes {{count}}-Fragment",
|
||||
"cleaned_other": "Bereinigte {{count}}-Fragmente",
|
||||
"segments_clean": "Segmente bereits sauber",
|
||||
"cleanup_failed": "Bereinigung fehlgeschlagen: {{message}}",
|
||||
"cinematic_no_llm": "Für filmische Qualität ist ein LLM erforderlich – setzen Sie TRANSLATE_BASE_URL + TRANSLATE_API_KEY (Ollama funktioniert lokal). Zurückgreifen auf Fast.",
|
||||
"translate_errors": "{{errorCount}}/{{totalCount}} Segment(e) fehlgeschlagen: {{firstError}}",
|
||||
"translated_segments": "{{count}} Segment(e) → {{lang}} übersetzt",
|
||||
"translated_cinematic_suffix": "(Filmisch)",
|
||||
"translation_failed": "Übersetzung fehlgeschlagen: {{message}}",
|
||||
"regenerating": "{{count}} Segment(e) werden neu generiert…",
|
||||
"generating_dub": "Dub erzeugen…",
|
||||
"generating_progress": "Dub wird erzeugt… {{current}}/{{total}}",
|
||||
"generation_aborted": "Generation abgebrochen.",
|
||||
"dubbing_aborted": "Überspielen abgebrochen",
|
||||
"generation_stream_ended": "Der Generierungsstrom wurde vor Abschluss beendet",
|
||||
"dub_complete": "Dub abgeschlossen",
|
||||
"stop_failed": "Stoppen fehlgeschlagen",
|
||||
"save_first": "Bitte klicken Sie zuerst auf „Hochladen und transkribieren“, damit das Video vor dem Speichern auf dem Server verarbeitet wird.",
|
||||
"project_saved": "Projekt gespeichert",
|
||||
"project_created": "Projekt erstellt",
|
||||
"save_failed": "Speichern fehlgeschlagen: {{message}}",
|
||||
"opened_project": "Geöffnet: {{name}}",
|
||||
"delete_project_confirm": "Dieses Projekt löschen? Dies kann nicht rückgängig gemacht werden.",
|
||||
"project_deleted": "Projekt gelöscht",
|
||||
"delete_history_confirm": "Dieses Verlaufselement löschen?",
|
||||
"history_deleted": "Verlaufselement gelöscht",
|
||||
"restored_state": "Wiederhergestellter Zustand der vorherigen Generation",
|
||||
"upgrading_preview": "Upgrade von {{count}} Segment(en) in Vorschauqualität auf volle Qualität…"
|
||||
},
|
||||
"tts_errors": {
|
||||
"enter_text": "Bitte geben Sie Text ein",
|
||||
"upload_or_select": "Laden Sie ein Audio hoch oder wählen Sie ein Sprachprofil aus",
|
||||
"trim_hint": "Audio ist {{duration}}s – für bestes Klonen auf ≤{{max}}s kürzen",
|
||||
"timeout": "Zeitüberschreitung bei der Generierung – das Modell wird möglicherweise noch heruntergeladen. Überprüfen Sie Einstellungen → Protokolle und versuchen Sie es dann erneut.",
|
||||
"error_prefix": "Fehler: {{message}}",
|
||||
"ignored_unsupported": "Nicht unterstützte Anweisung ignoriert: {{items}}",
|
||||
"ignored_duplicate": "Ignoriert (Kategorie bereits festgelegt): {{items}}"
|
||||
},
|
||||
"sharing": {
|
||||
"title": "Teilen und Fernzugriff",
|
||||
"help": "Stellen Sie diese laufende OmniVoice-Instanz Ihren anderen Computern zur Verfügung, ohne sie neu zu starten. „Nur Loopback“ ist die Standardeinstellung – es wird nichts geteilt, bis Sie es hier aktivieren.",
|
||||
"local_network": "Lokales Netzwerk",
|
||||
"local_help": "Teilen Sie mit einer einmaligen Zugangs-PIN über Ihr WLAN/Ethernet. Andere Geräte scannen den QR-Code oder öffnen den Link.",
|
||||
"ports_title": "Häfen",
|
||||
"ports_help": "Diese werden über Umgebungsvariablen festgelegt, die beim Start gelesen werden. Ändern Sie den Backend- oder UI-Port, indem Sie die Variable festlegen und OmniVoice neu starten.",
|
||||
"backend_port": "Backend-Port",
|
||||
"ui_port": "UI-Port",
|
||||
"lan_share_port": "LAN-Freigabeport",
|
||||
"port_error": "Geben Sie einen Port zwischen 1024 und 65535 ein",
|
||||
"port_saved": "LAN-Freigabe-Port gespeichert – gilt, wenn Sie die Freigabe das nächste Mal aktivieren",
|
||||
"port_save_failed": "Port konnte nicht gespeichert werden: {{message}}",
|
||||
"saving": "Sparen…",
|
||||
"ports_note": "Backend- und UI-Ports werden beim Neustart angewendet. Der LAN-Freigabeport wird angewendet, wenn Sie das nächste Mal die Freigabe aktivieren.",
|
||||
"tailscale_title": "Tailscale (privater Fernzugriff)",
|
||||
"tailscale_checking": "Suche nach Tailscale…",
|
||||
"tailscale_absent": "Tailscale nicht erkannt. Installieren Sie es, um von überall in Ihrem privaten Tailnet sicher auf OmniVoice zuzugreifen.",
|
||||
"tailscale_install": "Tailscale installieren",
|
||||
"tailscale_running": "Tailscale läuft. Bedienen Sie OmniVoice über Ihr privates Tailnet.",
|
||||
"tailscale_not_logged_in": "Tailscale ist installiert, aber nicht angemeldet. Starten Sie zunächst Tailscale und melden Sie sich an.",
|
||||
"tailscale_enabled": "Tailscale-Aufschlag aktiviert",
|
||||
"tailscale_enable_failed": "Tailscale konnte nicht aktiviert werden",
|
||||
"tailscale_enable_error": "Tailscale konnte nicht aktiviert werden: {{message}}",
|
||||
"tailscale_disabled": "Tailscale-Aufschlag deaktiviert",
|
||||
"tailscale_disable_failed": "Tailscale konnte nicht deaktiviert werden",
|
||||
"tailscale_disable_error": "Tailscale konnte nicht deaktiviert werden: {{message}}",
|
||||
"tailscale_enabling": "Aktivieren…",
|
||||
"tailscale_enable_btn": "Tailscale-Serve aktivieren",
|
||||
"tailscale_disabling": "Deaktivieren…",
|
||||
"tailscale_disable_btn": "Stoppen Sie den Tailscale-Aufschlag",
|
||||
"tailscale_copy": "Link kopieren",
|
||||
"tailscale_open": "Im Browser öffnen",
|
||||
"copied": "Kopiert",
|
||||
"tailscale_qr_alt": "QR-Code für die Tailscale-URL"
|
||||
},
|
||||
"reportBug": {
|
||||
"label": "Melden Sie einen Fehler",
|
||||
"title": "Öffnet eine vorab ausgefüllte GitHub-Problemseite in Ihrem Browser. Es wird nichts gesendet, bis Sie auf „Senden“ klicken."
|
||||
},
|
||||
"app": {
|
||||
"loading": "Laden…",
|
||||
"trimmed_loaded": "Zugeschnittenes Audio geladen",
|
||||
"toast_exported": "Exportiert: {{name}}",
|
||||
"toast_export_failed": "Export fehlgeschlagen: {{message}}",
|
||||
"toast_open_folder_failed": "Ordner konnte nicht geöffnet werden: {{message}}",
|
||||
"toast_saving": "Speichern {{name}}...",
|
||||
"toast_saved": "Gespeichert: {{path}}",
|
||||
"toast_save_error": "Speicherfehler: {{message}}",
|
||||
"toast_processing": "Verarbeitung {{name}}...",
|
||||
"toast_downloaded": "Heruntergeladen {{name}}",
|
||||
"toast_download_error": "Download-Fehler: {{message}}",
|
||||
"toast_upload_first": "Bitte klicken Sie zuerst auf „Hochladen und transkribieren“, damit das Video vor dem Speichern auf dem Server verarbeitet wird.",
|
||||
"toast_save_failed": "Speichern fehlgeschlagen: {{message}}",
|
||||
"toast_opened": "Geöffnet: {{name}}",
|
||||
"toast_project_deleted": "Projekt gelöscht",
|
||||
"toast_restored_state": "Wiederhergestellter Zustand der vorherigen Generation",
|
||||
"toast_history_deleted": "Verlaufselement gelöscht",
|
||||
"toast_flushed": "Gespült – RAM {{ram}}G · VRAM {{vram}}G{{unloaded}}",
|
||||
"toast_model_unloaded": "· Modell entladen",
|
||||
"toast_flush_failed": "Spülung fehlgeschlagen: {{message}}",
|
||||
"toast_project_saved": "Projekt gespeichert",
|
||||
"toast_project_created": "Projekt erstellt"
|
||||
},
|
||||
"update": {
|
||||
"available": "Update {{version}} verfügbar",
|
||||
"install": "Installieren und neu starten",
|
||||
"install_hint": "Update herunterladen und in die neue Version neu starten",
|
||||
"downloading": "Wird aktualisiert… {{pct}} %",
|
||||
"restart": "Zum Aktualisieren neu starten",
|
||||
"busy": "Beende zuerst deine Synchronisation – dann installiere das Update.",
|
||||
"whats_new": "Was ist neu?",
|
||||
"failed": "Update fehlgeschlagen",
|
||||
"retry": "Versuchen Sie es noch einmal",
|
||||
"dismiss": "Entlassen"
|
||||
},
|
||||
"archetypes": {
|
||||
"featured": "Hervorgehoben",
|
||||
@@ -946,5 +1577,93 @@
|
||||
"facet_accent": "Akzent",
|
||||
"facet_lang": "Sprache",
|
||||
"facet_whisper": "Flüstern"
|
||||
},
|
||||
"support": {
|
||||
"tab_support": "Unterstützung",
|
||||
"tab_license": "Kommerzielle Lizenz",
|
||||
"toggle_label": "Support oder kommerzielle Lizenz",
|
||||
"other_ways": "Andere Möglichkeiten zu helfen",
|
||||
"star_github": "Stern auf GitHub",
|
||||
"join_discord": "Treten Sie Discord bei"
|
||||
},
|
||||
"updates": {
|
||||
"tab": "Aktualisierungen",
|
||||
"up_to_date": "Auf dem neuesten Stand · v{{version}}",
|
||||
"check_now": "Jetzt prüfen",
|
||||
"releases": "Veröffentlichungen",
|
||||
"current": "aktuell",
|
||||
"prerelease": "Vorschau",
|
||||
"loading": "Veröffentlichungen werden geladen…",
|
||||
"none": "Keine Veröffentlichungen gefunden",
|
||||
"load_error": "Veröffentlichungen konnten nicht geladen werden (offline?)",
|
||||
"retry_load": "Versuchen Sie es noch einmal"
|
||||
},
|
||||
"firstrun": {
|
||||
"loading": "Einrichtung wird vorbereitet…",
|
||||
"title": "OmniVoice Studio einrichten",
|
||||
"subtitle": "Noch ist nichts installiert – prüfe, wo alles gespeichert wird, und starte dann. Später in den Einstellungen änderbar.",
|
||||
"language": "Sprache",
|
||||
"mode_title": "Installationsmodus",
|
||||
"mode_installed": "Installiert",
|
||||
"mode_installed_desc": "Nutzt die Standard-Systemordner. Für die meisten empfohlen.",
|
||||
"mode_portable": "Portabel",
|
||||
"mode_portable_desc": "Alles liegt in einem Ordner neben der App – als Einheit auf andere Laufwerke oder Rechner verschiebbar.",
|
||||
"mode_portable_unavailable": "Nicht verfügbar: Der Ordner neben der App ist nicht beschreibbar.",
|
||||
"storage_title": "Speicher",
|
||||
"portable_folder": "Portabler Ordner",
|
||||
"portable_folder_desc": "Laufzeitumgebung, Modelle und deine Sprachdaten – ein Ordner, komplett verschiebbar.",
|
||||
"env_dir": "App-Umgebung",
|
||||
"env_dir_desc": "Python-Runtime und KI-Bibliotheken.",
|
||||
"data_dir": "Sprachdaten & Projekte",
|
||||
"data_dir_desc": "Deine Stimmen, Synchronisationen, Ausgaben und die Projektdatenbank.",
|
||||
"models_dir": "Modell-Cache",
|
||||
"models_dir_desc": "Heruntergeladene KI-Modelle – der größte und am leichtesten verlagerbare Teil.",
|
||||
"needs": "benötigt ~{{size}}",
|
||||
"free": "{{size}} frei",
|
||||
"checking": "prüfe…",
|
||||
"not_writable": "nicht beschreibbar",
|
||||
"change": "Ändern…",
|
||||
"compute_title": "Rechenleistung",
|
||||
"compute_label": "GPU / Beschleuniger",
|
||||
"compute_auto": "Auto (NVIDIA CUDA / Apple MPS / CPU)",
|
||||
"compute_rocm": "AMD-GPU (ROCm, Linux)",
|
||||
"channel_label": "Update-Kanal",
|
||||
"channel_stable": "Stabil",
|
||||
"channel_preview": "Vorschau (aktueller main)",
|
||||
"network_title": "Netzwerk",
|
||||
"region_label": "Download-Region",
|
||||
"mirrors_title": "Eigene Mirrors (erweitert)",
|
||||
"mirror_pypi": "PyPI-Index-URL",
|
||||
"mirror_hf": "Hugging-Face-Endpoint",
|
||||
"mirror_python": "Python-Download-Mirror",
|
||||
"insufficient_space": "Zu wenig Speicherplatz: Dieses Layout braucht ~{{need}} auf einem Laufwerk, nur {{free}} verfügbar. Wähle einen anderen Ort.",
|
||||
"blocked_not_writable": "Ein gewählter Ordner ist nicht beschreibbar – wähle einen anderen Ort.",
|
||||
"total_required": "Benötigter Speicher insgesamt: ~{{size}} (einmaliger Download beim ersten Start)",
|
||||
"start": "Installation starten",
|
||||
"starting": "Starte…",
|
||||
"compute_detected": "Erkannt",
|
||||
"compute_match": "passt zu diesem Rechner",
|
||||
"compute_auto_desc": "Wählt zur Laufzeit das beste Backend dieses Rechners — CUDA auf NVIDIA, MPS auf Apple Silicon, sonst CPU.",
|
||||
"compute_rocm_desc": "Installiert PyTorch-ROCm-Wheels für AMD-Grafikkarten unter Linux. Im Zweifel auf Auto lassen.",
|
||||
"channel_stable_desc": "Nur getestete Releases — Updates kommen nach Community-Validierung.",
|
||||
"channel_preview_desc": "Rollende Builds vom neuesten main — neue Engines und Fixes zuerst, gelegentlich kleine Kanten.",
|
||||
"installing_title": "Installation",
|
||||
"activity_title": "Aktivität",
|
||||
"stage_setup": "Einrichtung",
|
||||
"stage_models": "Modelle & Engines",
|
||||
"chip_required": "erforderlich",
|
||||
"chip_optional": "optional",
|
||||
"chip_engine": "Engine",
|
||||
"lib_download": "Herunterladen",
|
||||
"lib_downloading": "lädt…",
|
||||
"lib_use": "Verwenden",
|
||||
"lib_active": "aktiv",
|
||||
"lib_in_settings": "später in den Einstellungen",
|
||||
"lib_show_all": "{{count}} optionale Modelle anzeigen",
|
||||
"trust_line": "Alles läuft und bleibt auf diesem Rechner — kein Konto, keine Cloud, keine Telemetrie.",
|
||||
"resume_note": "Unterbrochene Downloads werden automatisch fortgesetzt — die App zu schließen ist sicher.",
|
||||
"eta_left": "noch ~{{eta}}",
|
||||
"first_sound_text": "Willkommen in deinem Studio. Jedes Wort, das du hörst, wurde gerade eben auf diesem Rechner erzeugt.",
|
||||
"first_sound_done": "Diese Stimme? Vor Sekunden lokal erzeugt. Willkommen."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,23 @@
|
||||
"install_hint": "Download the update and restart into the new version",
|
||||
"downloading": "Updating… {{pct}}%",
|
||||
"restart": "Restart to update",
|
||||
"busy": "Finish your dub first — then install the update."
|
||||
"busy": "Finish your dub first — then install the update.",
|
||||
"whats_new": "What's new",
|
||||
"failed": "Update failed",
|
||||
"retry": "Retry",
|
||||
"dismiss": "Dismiss"
|
||||
},
|
||||
"updates": {
|
||||
"tab": "Updates",
|
||||
"up_to_date": "Up to date · v{{version}}",
|
||||
"check_now": "Check now",
|
||||
"releases": "Releases",
|
||||
"current": "current",
|
||||
"prerelease": "preview",
|
||||
"loading": "Loading releases…",
|
||||
"none": "No releases found",
|
||||
"load_error": "Couldn't load releases (offline?)",
|
||||
"retry_load": "Retry"
|
||||
},
|
||||
"stories": {
|
||||
"title": "Stories Editor",
|
||||
@@ -77,25 +93,6 @@
|
||||
"exportDone": "Audiobook downloaded",
|
||||
"exportFailed": "Export failed — check the backend is running."
|
||||
},
|
||||
"common": {
|
||||
"open": "Open",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"delete": "Delete",
|
||||
"loading": "Loading…",
|
||||
"error": "Something went wrong",
|
||||
"languages_count": "646 languages",
|
||||
"clear": "Clear",
|
||||
"refresh": "Refresh",
|
||||
"close": "Close",
|
||||
"back": "Back",
|
||||
"play": "Play",
|
||||
"pause": "Pause",
|
||||
"reload": "Force Reload UI",
|
||||
"backend": "Backend",
|
||||
"frontend": "Frontend",
|
||||
"tauri": "Tauri"
|
||||
},
|
||||
"nav": {
|
||||
"launchpad": "Launchpad",
|
||||
"clone": "Clone",
|
||||
@@ -105,7 +102,10 @@
|
||||
"transcripts": "Transcripts",
|
||||
"omnidrive": "OmniDrive",
|
||||
"settings": "Settings",
|
||||
"stories": "Stories"
|
||||
"stories": "Stories",
|
||||
"move_rail_right": "Move rail to the right",
|
||||
"move_rail_left": "Move rail to the left",
|
||||
"flip_rail": "Flip rail side"
|
||||
},
|
||||
"launchpad": {
|
||||
"greeting": "hello there",
|
||||
@@ -117,6 +117,10 @@
|
||||
"design_desc": "Build a new voice from a sentence. Gender, age, accent, mood — your call.",
|
||||
"dub_title": "Video Dubbing",
|
||||
"dub_desc": "Transcribe, translate, re-voice. Keep each speaker, line up the timing, ship it.",
|
||||
"stories_title": "Stories",
|
||||
"stories_desc": "Multi-voice audiobooks — cast your characters, drop in a script, export by chapter.",
|
||||
"gallery_title": "Voice Gallery",
|
||||
"gallery_desc": "Browse ready-made designed voices by accent, age and style — no setup.",
|
||||
"ab_compare": "A/B Compare",
|
||||
"ab_compare_title": "Try two voices side by side",
|
||||
"cloned_voices": "Cloned Voices",
|
||||
@@ -127,7 +131,12 @@
|
||||
"locked": "LOCKED",
|
||||
"open": "Open",
|
||||
"try_it": "Try it",
|
||||
"audio_only": "Audio Only"
|
||||
"audio_only": "Audio Only",
|
||||
"transcripts_title": "Transcripts",
|
||||
"transcripts_desc": "Turn audio or video into editable, searchable text — across 646 languages.",
|
||||
"recent_files": "Recent files",
|
||||
"view_all_files": "View all files",
|
||||
"file": "File"
|
||||
},
|
||||
"clone": {
|
||||
"prompt": "Prompt",
|
||||
@@ -225,10 +234,53 @@
|
||||
"ffmpeg_missing": "Not found",
|
||||
"ffmpeg_current": "Current path",
|
||||
"ffmpeg_desc": "Set a custom ffmpeg path if auto-detection fails.",
|
||||
"ffmpeg_saved": "FFmpeg path set — restart backend to apply."
|
||||
"ffmpeg_saved": "FFmpeg path set — restart backend to apply.",
|
||||
"diagnostics_copied": "Diagnostics copied — paste into your issue report.",
|
||||
"updater_desktop": "Updater only runs in the desktop app.",
|
||||
"latest_version": "You're on the latest version.",
|
||||
"logs_load_failed": "Failed to load logs: {{message}}",
|
||||
"clear_frontend_confirm": "Clear the in-memory frontend log buffer?",
|
||||
"clear_frontend_title": "Clear logs",
|
||||
"frontend_logs_cleared": "Frontend logs cleared",
|
||||
"clear_tauri_confirm": "Truncate the Tauri-side log files? The OS will continue to write new entries.",
|
||||
"clear_tauri_title": "Clear Tauri logs",
|
||||
"nothing_to_clear": "Nothing to clear — no Tauri log file on disk yet.",
|
||||
"cleared_tauri_one": "Cleared {{count}} Tauri log file",
|
||||
"cleared_tauri_other": "Cleared {{count}} Tauri log files",
|
||||
"clear_tauri_failed": "Failed to clear Tauri logs: {{message}}",
|
||||
"clear_backend_confirm": "Clear the backend runtime + crash logs? This cannot be undone.",
|
||||
"clear_backend_title": "Clear logs",
|
||||
"backend_logs_cleared": "Backend logs cleared",
|
||||
"clear_backend_failed": "Failed to clear logs",
|
||||
"copy_failed": "Copy failed: {{message}}",
|
||||
"update_check_failed": "Update check failed: {{message}}",
|
||||
"save_failed": "Save failed: {{message}}",
|
||||
"clear_failed": "Clear failed: {{message}}",
|
||||
"engine_switched": "{{family}} → {{engine}}",
|
||||
"channel_set_failed": "Failed to set channel: {{message}}",
|
||||
"updater_downloading": "Downloading {{version}}…",
|
||||
"updater_installed": "Installed — relaunching.",
|
||||
"updater_available_title": "Update available",
|
||||
"updater_available_body": "Version {{version}} is available.\n\n{{notes}}\n\nDownload and install now?",
|
||||
"updater_notes_fallback": "See release notes on GitHub.",
|
||||
"shortcut_load_failed": "Could not load shortcut: {{message}}",
|
||||
"shortcut_set": "Dictation shortcut set to {{shortcut}}",
|
||||
"shortcut_register_failed": "Couldn't register: {{message}}",
|
||||
"shortcut_reset": "Reset to default",
|
||||
"shortcut_reset_failed": "Reset failed: {{message}}"
|
||||
},
|
||||
"about": {
|
||||
"app": "App",
|
||||
"self_check": "Run self-check",
|
||||
"self_check_failed": "Self-check failed: {{message}}",
|
||||
"self_check_ok": "OK",
|
||||
"self_check_warn": "Warning",
|
||||
"self_check_fail": "Failed",
|
||||
"self_check_healthy": "All checks passed — this install looks healthy.",
|
||||
"self_check_attention": "{{count}} check(s) failed — see the hints above.",
|
||||
"save_bundle": "Save diagnostic bundle",
|
||||
"bundle_saved": "Diagnostic bundle saved: {{filename}}",
|
||||
"bundle_failed": "Could not build the diagnostic bundle: {{message}}",
|
||||
"version": "Version",
|
||||
"tauri_runtime": "Tauri runtime",
|
||||
"platform": "Platform",
|
||||
@@ -305,19 +357,6 @@
|
||||
"group_deepl": "DeepL Custom Endpoint",
|
||||
"group_microsoft": "Microsoft Translator Custom Endpoint"
|
||||
},
|
||||
"engines": {
|
||||
"review_on": "Review",
|
||||
"review_off": "Rapid-fire",
|
||||
"banners_on": "Stage banners on",
|
||||
"banners_off": "Stage banners off",
|
||||
"backend": "Backend",
|
||||
"status": "Status",
|
||||
"ready": "ready",
|
||||
"unavailable": "unavailable",
|
||||
"use": "Use",
|
||||
"loading": "Loading engines…",
|
||||
"refresh": "Refresh"
|
||||
},
|
||||
"capture": {
|
||||
"desc": "Global hotkeys only work in the desktop app. The web UI uses an in-page <1>Ctrl+Shift+Space</1> shortcut while the window has focus.",
|
||||
"desc_detail": "The hotkey works system-wide while OmniVoice is running — it focuses the window and starts dictation. Avoid combos already claimed by the OS (on macOS, <1>⌘+Space</1> is Spotlight and <2>⌘+⇧+Space</2> cycles input sources). If registration fails, pick a different combo.",
|
||||
@@ -328,13 +367,17 @@
|
||||
"record_shortcut": "Record shortcut",
|
||||
"recording": "Recording…",
|
||||
"save": "Save",
|
||||
"reset_default": "Reset to default"
|
||||
},
|
||||
"logs": {
|
||||
"no_tauri_log": "No Tauri log on disk yet — launch via the desktop build to produce one",
|
||||
"empty_frontend": "No frontend console entries captured yet. Interact with the app — every console.* will appear here.",
|
||||
"empty_tauri": "No Tauri log available. Runs in the desktop shell only.",
|
||||
"empty_backend": "Runtime log is empty. Activity will appear here as the backend logs it."
|
||||
"reset_default": "Reset to default",
|
||||
"listening_label": "Listening…",
|
||||
"transcribing_label": "Transcribing…",
|
||||
"pasted": "Pasted",
|
||||
"no_speech": "No speech detected",
|
||||
"mic_denied": "Mic access denied",
|
||||
"mic_denied_toast": "Microphone access denied. {{hint}}",
|
||||
"mic_hint_mac": "macOS: open System Settings → Privacy & Security → Microphone and enable OmniVoice.",
|
||||
"mic_hint_windows": "Windows: open Settings → Privacy & security → Microphone and allow OmniVoice.",
|
||||
"mic_hint_linux": "Linux: check that your user is in the audio group and the WebView has mic access.",
|
||||
"transcription_failed": "Transcription failed: {{message}}"
|
||||
},
|
||||
"voice_profile": {
|
||||
"test_text": "Hello — this is a test of this voice.",
|
||||
@@ -417,6 +460,9 @@
|
||||
"export_wav": "WAV",
|
||||
"export_srt": "SRT",
|
||||
"upload_transcribe": "Upload & Transcribe",
|
||||
"num_speakers_label": "Speakers",
|
||||
"num_speakers_auto": "Auto",
|
||||
"num_speakers_help": "How many speakers are in this video? Leave blank to auto-detect. Set a number if auto-detect merges multiple speakers into one.",
|
||||
"multi_lang": "Multi-lang",
|
||||
"prep_download": "Downloading video…",
|
||||
"prep_extract": "Extracting audio…",
|
||||
@@ -463,6 +509,12 @@
|
||||
"clean_up_title": "Merge tiny fragments and adjacent short segments",
|
||||
"popular": "Popular",
|
||||
"all_languages": "All languages",
|
||||
"add_language": "Add language",
|
||||
"search_languages": "Search languages…",
|
||||
"languages_selected_one": "{{count}} language selected",
|
||||
"languages_selected_other": "{{count}} languages selected",
|
||||
"more_to_narrow": "+{{count}} more — type to narrow",
|
||||
"no_matches": "No matches",
|
||||
"engine_label": "Engine",
|
||||
"install_engine": "Install this engine",
|
||||
"installing_engine": "…installing",
|
||||
@@ -502,7 +554,11 @@
|
||||
"install_already": "{{engine}} was already installed",
|
||||
"install_ok": "{{engine}} installed",
|
||||
"install_failed": "Install failed: {{message}}",
|
||||
"prep_elapsed": "{{time}} elapsed"
|
||||
"prep_elapsed": "{{time}} elapsed",
|
||||
"diagnostic_copied": "Diagnostic copied",
|
||||
"copy_failed": "Copy failed",
|
||||
"open_docs": "Open docs",
|
||||
"copy_diagnostic": "Copy diagnostic"
|
||||
},
|
||||
"glossary": {
|
||||
"title": "Glossary",
|
||||
@@ -577,7 +633,17 @@
|
||||
"more_actions_title": "More actions",
|
||||
"speaker_pick": "Pick…",
|
||||
"speaker_title_detected": "Speaker — pick from detected, or type a custom name",
|
||||
"speaker_title_custom": "Speaker — type a name (no diarization clones detected)"
|
||||
"speaker_title_custom": "Speaker — type a name (no diarization clones detected)",
|
||||
"time_edit_title": "Click to edit start time (m:ss.s). Enter to commit, Esc to cancel.",
|
||||
"fit_fits": "Fits",
|
||||
"fit_fits_title": "Natural-rate audio fit inside the slot.",
|
||||
"fit_overflows": "Overflows +{{seconds}}s",
|
||||
"fit_overflows_title": "Translated text was longer than the original slot by {{seconds}}s. The audio was hard-trimmed; shorten the text or switch Timing to \"Stretch Video\".",
|
||||
"fit_stretched": "Video {{ratio}}×",
|
||||
"fit_stretched_title": "Stretch Video mode: this segment's video was slowed to {{ratio}}× to fit the natural dub audio.",
|
||||
"fit_compressed_title": "TTS audio is {{pct}}% of the slot — heavily compressed.",
|
||||
"fit_audio_title": "Audio fit inside the slot.",
|
||||
"fit_ratio_title": "TTS audio is {{pct}}% of the slot."
|
||||
},
|
||||
"voice": {
|
||||
"personality": "Personality",
|
||||
@@ -672,10 +738,24 @@
|
||||
"status_running": "running",
|
||||
"status_done": "done",
|
||||
"status_failed": "failed",
|
||||
"status_cancelled": "cancelled"
|
||||
"status_cancelled": "cancelled",
|
||||
"add_to_queue_title": "Add Videos to Queue",
|
||||
"drop_hint_text": "Drop video files here or click to browse",
|
||||
"drop_formats": "MP4 · MOV · MKV · WEBM",
|
||||
"files_kicker": "FILES ({{count}})",
|
||||
"file_size_mb": "{{size}} MB",
|
||||
"target_languages": "TARGET LANGUAGES",
|
||||
"voice_kicker": "VOICE",
|
||||
"default_option": "Default",
|
||||
"clone_profiles": "Clone Profiles",
|
||||
"presets": "Presets",
|
||||
"preserve_bg": "Preserve background audio (music/FX)",
|
||||
"estimate": "{{videos}} video(s) × {{langs}} lang(s) = {{jobs}} job(s)",
|
||||
"select_files_langs": "Select files and languages",
|
||||
"add_to_queue": "Add to Queue"
|
||||
},
|
||||
"gallery": {
|
||||
"title": "Gallery",
|
||||
"title": "OmniVoice Gallery",
|
||||
"search_placeholder": "Search YouTube…",
|
||||
"all_voices": "All Voices ({{count}})",
|
||||
"no_voices": "No voices yet",
|
||||
@@ -810,8 +890,8 @@
|
||||
"back": "Back to Studio",
|
||||
"badge": "Commercial License",
|
||||
"hero_title": "Ship AI voices in production",
|
||||
"hero_desc": "OmniVoice Studio is source-available under the Functional Source License (FSL). Most users can evaluate, prototype, and even deploy internally without a commercial agreement. You need a commercial license only if you are building a competing product or service, or if your use case falls outside the FSL’s boundaries.",
|
||||
"hero_note": "Building a competing product or service or deploying at scale (e.g., serving pay-per-use API) requires a commercial license. Pricing tiers coming soon — get in touch in the meantime.",
|
||||
"hero_desc": "OmniVoice Studio is free and open-source software under the GNU Affero General Public License v3 (AGPL-3.0) — free to use, including for commercial and internal business use. A commercial license is needed only if you want to embed OmniVoice Studio in a closed-source or proprietary product or service without AGPL-3.0's copyleft obligations.",
|
||||
"hero_note": "Use, self-hosting, and commercial use are all free under the AGPL-3.0 — including at scale. AGPL is a network-copyleft license: if you modify OmniVoice and offer that modified version to others over a network, you must share your modified source under the same terms. A commercial license lifts those copyleft obligations for proprietary, closed-source deployments. Pricing tiers are coming soon — get in touch in the meantime.",
|
||||
"why_title": "Why businesses choose OmniVoice",
|
||||
"pricing_title": "Pricing",
|
||||
"faq_title": "Common questions",
|
||||
@@ -920,7 +1000,18 @@
|
||||
"dubbing_title": "See dubbing in action",
|
||||
"dubbing_sync": "Synced playback",
|
||||
"dubbing_picker": "Try another language:",
|
||||
"dubbing_cta": "Run this on your own video →"
|
||||
"dubbing_cta": "Run this on your own video →",
|
||||
"dubbing_loading": "Loading dubbing demo…",
|
||||
"dubbing_dismiss": "Dismiss dubbing demo",
|
||||
"original_tag": "original",
|
||||
"dubbed_tag": "dubbed",
|
||||
"script_conversational": "Conversational",
|
||||
"script_technical": "Technical vocabulary",
|
||||
"script_french": "Non-English (French)",
|
||||
"aria_pause": "Pause {{label}}",
|
||||
"aria_hear": "Hear {{label}}",
|
||||
"aria_replay": "Replay {{label}} through transcriber",
|
||||
"dictation_lede_hotkey_only": "Hold the shortcut above anywhere on your desktop, speak, release — the text lands in whatever app has focus. Press it now to verify it works."
|
||||
},
|
||||
"bootstrap": {
|
||||
"title": "OmniVoice Studio",
|
||||
@@ -944,6 +1035,642 @@
|
||||
"waiting_output": "Waiting for output…",
|
||||
"auto_detect": "Auto-detect",
|
||||
"suggest_lang": "Switch to {{lang}}?",
|
||||
"select_lang": "Language:"
|
||||
"select_lang": "Language:",
|
||||
"region_global": "Global (direct)",
|
||||
"region_china": "China (mirror)",
|
||||
"region_russia": "Russia (mirror)",
|
||||
"region_restricted": "Restricted (mirror)",
|
||||
"unknown_error": "Unknown error",
|
||||
"retrying": "Retrying…"
|
||||
},
|
||||
"direction": {
|
||||
"title": "Direction for segment #{{id}}",
|
||||
"desc": "Tell the pipeline how this line should feel. Plain English works — the system maps your words onto a stable taxonomy (energy / emotion / pace / intimacy / formality), then threads the taxonomy through Cinematic translation, TTS, and slot-fit.",
|
||||
"label": "Direction",
|
||||
"lineHint": "Line: \"{{text}}\"",
|
||||
"placeholder": "e.g. urgent and surprised / warm, hopeful / whispered, intimate",
|
||||
"previewParse": "Preview parse",
|
||||
"previewFailed": "Preview failed: {{message}}",
|
||||
"clear": "Clear",
|
||||
"cancel": "Cancel",
|
||||
"saveDirection": "Save direction",
|
||||
"ttsInstruct": "TTS instruct:",
|
||||
"nothingParsed": "— (nothing parsed)",
|
||||
"translateHint": "Translate hint:",
|
||||
"rateBias": "Rate bias:",
|
||||
"speedsUp": "speeds up",
|
||||
"slowsDown": "slows down",
|
||||
"taxonomyTokens": "taxonomy tokens"
|
||||
},
|
||||
"engines": {
|
||||
"review_on": "Review",
|
||||
"review_off": "Rapid-fire",
|
||||
"banners_on": "Stage banners on",
|
||||
"banners_off": "Stage banners off",
|
||||
"backend": "Backend",
|
||||
"status": "Status",
|
||||
"ready": "ready",
|
||||
"unavailable": "Unavailable",
|
||||
"use": "Use",
|
||||
"loading": "Loading engines…",
|
||||
"refresh": "Refresh",
|
||||
"matrixTitle": "Engine Compatibility Matrix",
|
||||
"loadFailed": "Failed to load engines: {{message}}",
|
||||
"couldNotLoad": "Could not load engines: {{message}}",
|
||||
"retry": "Retry",
|
||||
"activeEngine": "Active {{family}}: {{engine}}",
|
||||
"engineCompatLabel": "{{family}} engine compatibility",
|
||||
"active": "active",
|
||||
"whyUnavailable": "Why unavailable?",
|
||||
"lastError": "Last error: {{error}}",
|
||||
"installedAndReady": "Installed and ready",
|
||||
"notInstalled": "Not installed",
|
||||
"available": "Available",
|
||||
"subprocessTitle": "Runs in its own subprocess + venv",
|
||||
"inProcessTitle": "Runs in the OmniVoice Python process",
|
||||
"testEngine": "Test engine",
|
||||
"testing": "Testing…",
|
||||
"recheck": "Re-check",
|
||||
"rechecking": "Re-checking…",
|
||||
"latencyMs": "{{ms}} ms",
|
||||
"failed": "failed",
|
||||
"acceptLicense": "Accept license",
|
||||
"noBackends": "No backends registered.",
|
||||
"switch_failed": "Failed to switch engine"
|
||||
},
|
||||
"errors": {
|
||||
"title": "This tab hit a snag.",
|
||||
"desc": "Don't worry — the rest of the app still works. You can switch tabs, or try again below.",
|
||||
"tryAgain": "Try again",
|
||||
"openDocs": "Open docs for this error",
|
||||
"report": "Report this bug",
|
||||
"searchIssues": "Search similar issues",
|
||||
"unexpected": "Unexpected error: {{message}}"
|
||||
},
|
||||
"common": {
|
||||
"open": "Open",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"delete": "Delete",
|
||||
"loading": "Loading…",
|
||||
"error": "Something went wrong",
|
||||
"languages_count": "646 languages",
|
||||
"clear": "Clear",
|
||||
"refresh": "Refresh",
|
||||
"close": "Close",
|
||||
"back": "Back",
|
||||
"play": "Play",
|
||||
"pause": "Pause",
|
||||
"reload": "Force Reload UI",
|
||||
"backend": "Backend",
|
||||
"frontend": "Frontend",
|
||||
"tauri": "Tauri",
|
||||
"cancelOp": "Cancel operation",
|
||||
"dismiss": "Dismiss",
|
||||
"dismissStatus": "Dismiss status",
|
||||
"search": "Search…",
|
||||
"no_matches": "No matches",
|
||||
"recent_and_popular": "Recent & Popular",
|
||||
"popular_label": "Popular",
|
||||
"showing_of": "Showing {{shown}} of {{total}}. Type to search…",
|
||||
"yes": "Yes",
|
||||
"no": "No"
|
||||
},
|
||||
"keyboard": {
|
||||
"title": "Keyboard shortcuts",
|
||||
"footer": "Press <1>?</1> any time to open this.",
|
||||
"or": "or",
|
||||
"nav": "Navigation",
|
||||
"nav_cheatsheet": "Show this cheatsheet",
|
||||
"nav_closeModal": "Close modal / cancel",
|
||||
"nav_save": "Save project / commit trim",
|
||||
"segmentEditor": "Segment editor",
|
||||
"seg_split": "Split segment at cursor",
|
||||
"seg_merge": "Merge with next segment",
|
||||
"seg_undo": "Undo",
|
||||
"seg_redo": "Redo",
|
||||
"seg_click": "Primary action",
|
||||
"seg_shiftClick": "Range select",
|
||||
"trimmer": "Audio trimmer",
|
||||
"trim_playPause": "Preview play / pause",
|
||||
"trim_nudgeStart": "Nudge start handle",
|
||||
"trim_nudgeEnd": "Nudge end handle",
|
||||
"trim_fineNudge": "Fine nudge",
|
||||
"trim_coarseNudge": "Coarse nudge",
|
||||
"trim_zoomIn": "Zoom in / out",
|
||||
"trim_fitAll": "Fit all / Fit selection",
|
||||
"trim_confirm": "Confirm trim",
|
||||
"dub": "Dub",
|
||||
"dub_generate": "Generate dub",
|
||||
"dub_sidebar": "Toggle sidebar"
|
||||
},
|
||||
"network": {
|
||||
"sharing_on_title": "Sharing on — click for details",
|
||||
"share_on_network": "Share on your network",
|
||||
"switching": "Switching…",
|
||||
"network": "Network",
|
||||
"local": "Local",
|
||||
"share_confirm_title": "Share on your network?",
|
||||
"share_confirm_hint": "Other devices on your Wi-Fi/Ethernet will be able to reach OmniVoice using the access PIN shown once it's on.",
|
||||
"enabling": "Enabling…",
|
||||
"enable": "Enable",
|
||||
"shared_title": "Shared on your network",
|
||||
"no_interface": "No reachable network interface — connect to Wi-Fi/Ethernet.",
|
||||
"copy_link": "Copy link",
|
||||
"open_in_browser": "Open in browser",
|
||||
"qr_alt": "QR for {{ip}}",
|
||||
"pin": "PIN:",
|
||||
"stop_sharing": "Stop sharing",
|
||||
"copied": "Copied",
|
||||
"enable_error": "Could not enable sharing: {{message}}",
|
||||
"disable_error": "Could not disable: {{message}}"
|
||||
},
|
||||
"readiness": {
|
||||
"checking_system": "Checking system…",
|
||||
"all_ready": "All systems ready",
|
||||
"system_readiness": "System Readiness",
|
||||
"asr_model": "ASR Model",
|
||||
"loaded_ready": "Loaded and ready",
|
||||
"loading_first_run": "Loading… (this may take 1-2 minutes on first run)",
|
||||
"failed_to_load": "Failed to load",
|
||||
"not_loaded_yet": "Not loaded yet — will load on first transcription",
|
||||
"error_check_logs": "Error: {{error}}. Check logs and try restarting.",
|
||||
"check_logs_restart": "Check logs for model loading errors. Try restarting.",
|
||||
"llm_cinematic": "LLM (Cinematic)",
|
||||
"llm_configure": "Configure TRANSLATE_BASE_URL for Cinematic translation quality",
|
||||
"llm_set_env": "Set TRANSLATE_BASE_URL and TRANSLATE_API_KEY environment variables. Works with Ollama, OpenAI, LM Studio, etc.",
|
||||
"llm_optional": "Optional — set TRANSLATE_BASE_URL for Cinematic quality"
|
||||
},
|
||||
"license": {
|
||||
"title": "Supertonic-3 — License Acceptance",
|
||||
"intro": "Supertonic-3 ships under two distinct licenses. Please review both before enabling the engine.",
|
||||
"sdk_heading": "SDK Code · MIT",
|
||||
"sdk_desc": "The Python inference SDK (supertonic) is MIT-licensed. Permissive use, including commercial.",
|
||||
"read_mit": "Read the MIT license →",
|
||||
"model_heading": "Model Weights · OpenRAIL-M",
|
||||
"model_desc": "The Supertonic-3 model weights are released under the OpenRAIL-M license. This license restricts use to non-malicious purposes — see the linked license for the full set of use-based restrictions.",
|
||||
"read_openrail": "Read the OpenRAIL-M license →",
|
||||
"footer": "Clicking Accept records your acceptance in OmniVoice's local settings and enables the engine. Your acceptance is stored on this machine only — nothing is reported to Supertone Inc. or any third party.",
|
||||
"saving": "Saving…",
|
||||
"accept": "Accept",
|
||||
"accepted_toast": "Supertonic-3 license accepted.",
|
||||
"accept_error": "Failed to record license acceptance: {{message}}"
|
||||
},
|
||||
"voicePreview": {
|
||||
"title": "Voice Preview",
|
||||
"close": "Close preview",
|
||||
"default_text": "Hello! This is a preview of how I sound in this voice.",
|
||||
"default_voice": "Default voice",
|
||||
"clone_profiles": "Clone Profiles",
|
||||
"designed_voices": "Designed Voices",
|
||||
"presets": "Presets",
|
||||
"placeholder": "Type something to hear…",
|
||||
"stop": "Stop",
|
||||
"regenerate": "Regenerate",
|
||||
"preview": "Preview",
|
||||
"hint": "8 steps · fast preview"
|
||||
},
|
||||
"header": {
|
||||
"kicker_studio": "Studio",
|
||||
"kicker_library": "Library",
|
||||
"kicker_preferences": "Preferences",
|
||||
"kicker_licensing": "Licensing",
|
||||
"label_launchpad": "Launchpad",
|
||||
"label_clone": "Voice Clone",
|
||||
"label_design": "Voice Design",
|
||||
"label_dub": "Dubbing",
|
||||
"label_projects": "OmniDrive",
|
||||
"label_gallery": "Gallery",
|
||||
"label_transcriptions": "Transcriptions",
|
||||
"label_settings": "Settings",
|
||||
"label_enterprise": "Commercial License",
|
||||
"status_ready": "Ready",
|
||||
"status_loading": "Loading…",
|
||||
"status_idle": "Idle",
|
||||
"memory_management": "Memory management",
|
||||
"flush": "Flush",
|
||||
"loaded_models": "Loaded Models",
|
||||
"no_models": "No models loaded",
|
||||
"unload": "Unload",
|
||||
"flush_caches": "Flush caches",
|
||||
"unload_all_flush": "Unload all + flush"
|
||||
},
|
||||
"sidebar": {
|
||||
"tab_drive": "Drive",
|
||||
"tab_history": "History",
|
||||
"tab_exports": "Exports",
|
||||
"save_project": "Save Dub Project",
|
||||
"save_new_project": "Save as New Dub Project",
|
||||
"dub_projects": "Dub Projects",
|
||||
"voice_clones": "Voice Clones",
|
||||
"designed_voices": "Designed Voices",
|
||||
"no_dub_projects": "No saved dub projects",
|
||||
"no_dub_hint": "Upload a video and click Save to keep your work.",
|
||||
"no_clones": "No voice clones yet",
|
||||
"no_clones_hint": "Record or upload audio, then click Save as Voice Profile.",
|
||||
"no_designs": "No designed voices yet",
|
||||
"no_designs_hint": "Generate a voice and save it from History.",
|
||||
"history_subtitle": "Generation history · Stored in SQLite",
|
||||
"no_history": "No generation history",
|
||||
"no_history_hint": "Synthesize audio or dub a video — results will appear here.",
|
||||
"clear_history": "Clear History",
|
||||
"clear_confirm": "Clear all {{count}} history items? This cannot be undone.",
|
||||
"history_cleared": "History cleared",
|
||||
"recent_exports": "Recent Exports",
|
||||
"no_exports": "No downloaded outputs",
|
||||
"no_exports_hint": "Export a file via Tauri to see it tracked here.",
|
||||
"show_in_folder": "Show in folder",
|
||||
"open": "Open",
|
||||
"select": "Select",
|
||||
"try_voice": "Try",
|
||||
"consistent": "consistent",
|
||||
"locked": "Locked",
|
||||
"clone_label": "Clone",
|
||||
"design_label": "Design",
|
||||
"dub_label": "Dub",
|
||||
"save_label": "Save",
|
||||
"lock_identity": "Lock voice identity",
|
||||
"in_folder": "in {{folder}}"
|
||||
},
|
||||
"logs": {
|
||||
"no_tauri_log": "No Tauri log on disk yet — launch via the desktop build to produce one",
|
||||
"empty_frontend": "No frontend console entries captured yet. Interact with the app — every console.* will appear here.",
|
||||
"empty_tauri": "No Tauri log available. Runs in the desktop shell only.",
|
||||
"empty_backend": "Runtime log is empty. Activity will appear here as the backend logs it.",
|
||||
"title": "Logs",
|
||||
"source_backend": "Backend",
|
||||
"source_frontend": "Frontend",
|
||||
"source_tauri": "Tauri",
|
||||
"expand": "Expand logs",
|
||||
"collapse": "Collapse logs",
|
||||
"expand_aria": "Expand logs panel",
|
||||
"collapse_aria": "Collapse logs panel",
|
||||
"drag_resize": "Drag to resize",
|
||||
"refresh": "Refresh",
|
||||
"refresh_aria": "Refresh logs",
|
||||
"copy_visible": "Copy visible log",
|
||||
"copy_visible_aria": "Copy visible log",
|
||||
"clear": "Clear",
|
||||
"clear_aria": "Clear log",
|
||||
"report_issue": "Report issue (copy diagnostic)",
|
||||
"report_issue_aria": "Report issue",
|
||||
"close": "Close",
|
||||
"close_aria": "Close logs panel",
|
||||
"join_discord": "Join our Discord",
|
||||
"join_discord_aria": "Join our Discord community",
|
||||
"support_project": "Support this project",
|
||||
"support_project_aria": "Support this project",
|
||||
"empty_frontend_short": "No frontend console output yet.",
|
||||
"empty_lines": "No lines.",
|
||||
"all_clear": "✅ All clear — no issues detected",
|
||||
"log_cleared": "{{source}} log cleared",
|
||||
"clear_failed": "Clear failed: {{message}}",
|
||||
"log_copied": "Copied {{source}} log",
|
||||
"copy_failed": "Copy failed: {{message}}",
|
||||
"report_copied": "Diagnostic report copied — paste it into a GitHub issue.",
|
||||
"report_failed": "Report failed: {{message}}"
|
||||
},
|
||||
"trimmer": {
|
||||
"title": "Trim reference audio",
|
||||
"decoding": "Decoding audio…",
|
||||
"meta_length": "Length {{duration}} · {{sampleRate}} Hz",
|
||||
"meta_rendering": "rendering waveform {{percent}}%",
|
||||
"keyboard_hint": "scroll = zoom · shift+scroll = pan · alt+drag = pan · ⏐ ⟵ ⟶ ⏐ keys adjust handles",
|
||||
"zoom_in": "Zoom in (+)",
|
||||
"zoom_out": "Zoom out (-)",
|
||||
"fit_all": "Fit all (Home)",
|
||||
"fit_selection": "Fit selection (End)",
|
||||
"fit_sel_btn": "FIT SEL",
|
||||
"view_range": "View {{start}} → {{end}} ({{duration}})",
|
||||
"start_label": "Start",
|
||||
"end_label": "End",
|
||||
"length_label": "Length",
|
||||
"too_long": ">{{max}}s",
|
||||
"too_short": "too short",
|
||||
"length_ok": "ok",
|
||||
"loop_preview": "Loop preview",
|
||||
"pause": "Pause",
|
||||
"preview_selection": "Preview selection",
|
||||
"play_hint": "Space to play · Enter to confirm · Esc to cancel",
|
||||
"cancel": "Cancel",
|
||||
"use_trimmed": "Use trimmed",
|
||||
"decode_failed": "Decode failed: {{message}}",
|
||||
"playback_failed": "Playback failed: {{message}}",
|
||||
"audio_load_failed": "Audio load failed",
|
||||
"unit_seconds": "s"
|
||||
},
|
||||
"casting": {
|
||||
"title": "Speaker Casting",
|
||||
"auto_assign_title": "Auto-assign voices from extracted speaker clones",
|
||||
"auto_cast": "Auto-cast",
|
||||
"all_cast": "All cast",
|
||||
"segments_count": "{{count}} segments",
|
||||
"assign_voice": "Assign voice…",
|
||||
"from_video": "🎤 From video ({{name}})",
|
||||
"no_profiles": "No voice profiles saved yet.",
|
||||
"preview_voice": "Preview voice"
|
||||
},
|
||||
"checkpoint": {
|
||||
"asr_title": "Transcripts ready",
|
||||
"asr_cta": "Translate",
|
||||
"asr_hint": "Fix any ASR errors now — tight diction saves TTS attempts later.",
|
||||
"translate_title": "Translations ready",
|
||||
"translate_cta": "Generate dub",
|
||||
"translate_hint": "Skim the target text. Over-length lines get speed-boosted; you can also edit directly.",
|
||||
"done_title": "Dub complete",
|
||||
"done_hint": "Review timing and sync ratios. Tweak any line and hit \"Regen changed\" for a fast partial redo.",
|
||||
"segment_one": "{{count}} segment",
|
||||
"segment_other": "{{count}} segments",
|
||||
"dismiss_title": "Dismiss — won't reappear for this stage until reload"
|
||||
},
|
||||
"compare": {
|
||||
"title": "A/B Voice Comparison",
|
||||
"close": "Close comparison",
|
||||
"desc": "Compare two voices side by side to make casting decisions. App stays interactive behind.",
|
||||
"test_phrase": "Test phrase",
|
||||
"voice_a": "Voice A",
|
||||
"voice_b": "Voice B",
|
||||
"select_voice": "— Select voice —",
|
||||
"preset_suffix": "(Preset)",
|
||||
"no_audio": "No audio yet",
|
||||
"close_btn": "Close",
|
||||
"comparing": "Comparing…",
|
||||
"compare_btn": "Compare",
|
||||
"preparing_voice": "Preparing voice...",
|
||||
"generating_voice_a": "Generating Voice A...",
|
||||
"generating_voice_b": "Generating Voice B...",
|
||||
"comparison_complete": "Comparison complete!",
|
||||
"play_failed": "Play failed: {{message}}"
|
||||
},
|
||||
"models": {
|
||||
"hf_token_set_toast": "HuggingFace token set — faster downloads enabled",
|
||||
"hf_token_save_failed": "Failed to save token",
|
||||
"install_started": "Install started — progress in the row",
|
||||
"delete_confirm": "Delete {{repoId}}? You can reinstall it later.",
|
||||
"delete_confirm_title": "Delete model",
|
||||
"deleted": "Deleted {{repoId}}",
|
||||
"reinstall_confirm": "Reinstall {{repoId}}? This will delete the current copy and download again.",
|
||||
"reinstall_confirm_title": "Reinstall model",
|
||||
"reinstalling": "Reinstalling",
|
||||
"recommended_installed": "Recommended models are already installed.",
|
||||
"started_downloading_one": "Started downloading {{count}} model",
|
||||
"started_downloading_other": "Started downloading {{count}} models",
|
||||
"install_failed": "Install failed: {{message}}",
|
||||
"removing_cached": "Removing cached revisions…",
|
||||
"resolving_metadata": "Resolving repo metadata",
|
||||
"retry_attempt": "Retry attempt {{attempt}} — {{error}}",
|
||||
"connecting_hf": "Connecting to HuggingFace…",
|
||||
"resolving_files_one": "Resolving {{count}} file…",
|
||||
"resolving_files_other": "Resolving {{count}} files…",
|
||||
"resolving_files_active": "Resolving {{count}} file(s)… · {{file}}",
|
||||
"measuring": "measuring…",
|
||||
"files_progress": "{{done}}/{{total}} files",
|
||||
"install_error": "Install failed: {{error}}",
|
||||
"view_on_hf": "View on HuggingFace",
|
||||
"install_btn": "Install",
|
||||
"reinstall_btn": "Reinstall",
|
||||
"downloading": "downloading",
|
||||
"deleting": "deleting",
|
||||
"working": "working",
|
||||
"installed": "installed",
|
||||
"not_installed": "not installed",
|
||||
"required_tag": "required",
|
||||
"delete_btn": "Delete",
|
||||
"hf_token_btn": "HF Token",
|
||||
"hf_set_title": "Set HuggingFace token for faster downloads",
|
||||
"get_token": "Get token →",
|
||||
"reco_installed_for": "Recommended bundle installed for {{device}}",
|
||||
"reco_for": "Recommended for {{device}}",
|
||||
"starting": "Starting…",
|
||||
"required_size": "Required ~{{size}} GB",
|
||||
"all_size": "All ~{{size}} GB",
|
||||
"req_tag": "req",
|
||||
"search_placeholder": "Search models…",
|
||||
"search_label": "Search models",
|
||||
"no_matches": "No models match your filters.",
|
||||
"sort_by": "Sort by {{column}}",
|
||||
"column_model": "Model",
|
||||
"column_role": "Role",
|
||||
"column_size": "Size",
|
||||
"column_status": "Status",
|
||||
"ready_badge": "Ready",
|
||||
"loading_badge": "Loading…",
|
||||
"idle_badge": "Idle",
|
||||
"started_downloading_required_one": "Started downloading {{count}} required model",
|
||||
"started_downloading_required_other": "Started downloading {{count}} required models"
|
||||
},
|
||||
"enterprise_faq": {
|
||||
"q_internal_tools": "Do I need a license for internal tools?",
|
||||
"a_internal_tools": "No. Use by your employees and contractors — including modifying and self-hosting internally — is free under the AGPL-3.0. A commercial license is only needed if you embed OmniVoice in a closed-source or proprietary product or service and don't want to comply with AGPL's source-sharing obligations.",
|
||||
"q_try_before": "Can I try before committing?",
|
||||
"a_try_before": "Yes. The full app is free to download, run, and self-host under the AGPL-3.0 — no agreement required. When you're ready to discuss a commercial (proprietary-use) license, email us and we'll work through the details together.",
|
||||
"q_watermark": "What about the watermark?",
|
||||
"a_watermark": "The invisible AudioSeal watermark is embedded by default for everyone. Commercial licensees can disable it in Settings → Privacy."
|
||||
},
|
||||
"gallery_extra": {
|
||||
"save_prompt": "Enter a name for this voice profile:",
|
||||
"saved_as_profile": "Voice saved as profile!",
|
||||
"save_profile_failed": "Failed to save profile",
|
||||
"download_failed": "Download failed: {{message}}",
|
||||
"trim_load_failed": "Failed to load audio for trimming: {{message}}",
|
||||
"upload_crop_failed": "Failed to upload cropped voice: {{message}}"
|
||||
},
|
||||
"dub_workflow": {
|
||||
"preparing_audio": "Preparing audio…",
|
||||
"preparing_video": "Preparing video…",
|
||||
"extracting_audio_scenes": "Extracting audio & scenes…",
|
||||
"transcribing_audio": "Transcribing audio…",
|
||||
"transcription_complete": "Transcription complete",
|
||||
"upload_cancelled": "Upload cancelled",
|
||||
"upload_failed": "Upload failed: {{message}}",
|
||||
"downloading_video": "Downloading video…",
|
||||
"ingested": "Ingested {{url}}",
|
||||
"ingest_cancelled": "Ingest cancelled",
|
||||
"ingest_failed": "URL ingest failed: {{message}}",
|
||||
"retry_cancelled": "Retry cancelled",
|
||||
"transcription_failed": "Transcription failed: {{message}}",
|
||||
"import_srt_no_job": "Upload or ingest a video first — there is no job to attach subtitles to.",
|
||||
"imported_cues": "Imported {{count}} cue(s) from {{file}}",
|
||||
"skipped_malformed": "{{count}} skipped (malformed)",
|
||||
"dropped_overlap": "{{count}} dropped (overlap)",
|
||||
"clamped_to_duration": "{{count}} clamped to media length",
|
||||
"srt_import_failed": "SRT import failed",
|
||||
"cleaned_one": "Cleaned {{count}} fragment",
|
||||
"cleaned_other": "Cleaned {{count}} fragments",
|
||||
"segments_clean": "Segments already clean",
|
||||
"cleanup_failed": "Clean up failed: {{message}}",
|
||||
"cinematic_no_llm": "Cinematic quality needs an LLM — set TRANSLATE_BASE_URL + TRANSLATE_API_KEY (Ollama works locally). Falling back to Fast.",
|
||||
"translate_errors": "{{errorCount}}/{{totalCount}} segment(s) failed: {{firstError}}",
|
||||
"translated_segments": "Translated {{count}} segment(s) → {{lang}}",
|
||||
"translated_cinematic_suffix": " (Cinematic)",
|
||||
"translation_failed": "Translation failed: {{message}}",
|
||||
"regenerating": "Regenerating {{count}} segment(s)…",
|
||||
"generating_dub": "Generating dub…",
|
||||
"generating_progress": "Generating dub… {{current}}/{{total}}",
|
||||
"generation_aborted": "Generation aborted.",
|
||||
"dubbing_aborted": "Dubbing aborted",
|
||||
"generation_stream_ended": "Generation stream ended before completion",
|
||||
"dub_complete": "Dub complete",
|
||||
"stop_failed": "Failed to stop",
|
||||
"save_first": "Please click 'Upload & Transcribe' first so the video is processed on the server before saving.",
|
||||
"project_saved": "Project saved",
|
||||
"project_created": "Project created",
|
||||
"save_failed": "Save failed: {{message}}",
|
||||
"opened_project": "Opened: {{name}}",
|
||||
"delete_project_confirm": "Delete this project? This cannot be undone.",
|
||||
"project_deleted": "Project deleted",
|
||||
"delete_history_confirm": "Delete this history item?",
|
||||
"history_deleted": "History item deleted",
|
||||
"restored_state": "Restored previous generation state",
|
||||
"upgrading_preview": "Upgrading {{count}} preview-quality segment(s) to full quality…"
|
||||
},
|
||||
"tts_errors": {
|
||||
"enter_text": "Please enter text",
|
||||
"upload_or_select": "Upload an audio or select a voice profile",
|
||||
"trim_hint": "Audio is {{duration}}s — trim to ≤{{max}}s for best cloning",
|
||||
"timeout": "Generation timed out — the model may still be downloading. Check Settings → Logs, then try again.",
|
||||
"error_prefix": "Error: {{message}}",
|
||||
"ignored_unsupported": "Ignored unsupported instruct: {{items}}",
|
||||
"ignored_duplicate": "Ignored (category already set): {{items}}"
|
||||
},
|
||||
"sharing": {
|
||||
"title": "Sharing & Remote Access",
|
||||
"help": "Expose this running OmniVoice instance to your other machines without restarting it. Loopback-only is the default — nothing is shared until you turn it on here.",
|
||||
"local_network": "Local network",
|
||||
"local_help": "Share on your Wi-Fi / Ethernet with a one-time access PIN. Other devices scan the QR code or open the link.",
|
||||
"ports_title": "Ports",
|
||||
"ports_help": "These are set via environment variables read at startup. Change the backend or UI port by setting the variable and restarting OmniVoice.",
|
||||
"backend_port": "Backend port",
|
||||
"ui_port": "UI port",
|
||||
"lan_share_port": "LAN-share port",
|
||||
"port_error": "Enter a port between 1024 and 65535",
|
||||
"port_saved": "LAN-share port saved — applies next time you enable sharing",
|
||||
"port_save_failed": "Could not save port: {{message}}",
|
||||
"saving": "Saving…",
|
||||
"ports_note": "Backend and UI ports apply on restart. The LAN-share port applies next time you enable sharing.",
|
||||
"tailscale_title": "Tailscale (private remote access)",
|
||||
"tailscale_checking": "Checking for Tailscale…",
|
||||
"tailscale_absent": "Tailscale not detected. Install it to reach OmniVoice securely from anywhere on your private tailnet.",
|
||||
"tailscale_install": "Install Tailscale",
|
||||
"tailscale_running": "Tailscale is running. Serve OmniVoice over your private tailnet.",
|
||||
"tailscale_not_logged_in": "Tailscale is installed but not logged in. Start and sign in to Tailscale first.",
|
||||
"tailscale_enabled": "Tailscale serve enabled",
|
||||
"tailscale_enable_failed": "Could not enable Tailscale",
|
||||
"tailscale_enable_error": "Could not enable Tailscale: {{message}}",
|
||||
"tailscale_disabled": "Tailscale serve disabled",
|
||||
"tailscale_disable_failed": "Could not disable Tailscale",
|
||||
"tailscale_disable_error": "Could not disable Tailscale: {{message}}",
|
||||
"tailscale_enabling": "Enabling…",
|
||||
"tailscale_enable_btn": "Enable Tailscale serve",
|
||||
"tailscale_disabling": "Disabling…",
|
||||
"tailscale_disable_btn": "Stop Tailscale serve",
|
||||
"tailscale_copy": "Copy link",
|
||||
"tailscale_open": "Open in browser",
|
||||
"copied": "Copied",
|
||||
"tailscale_qr_alt": "QR code for the Tailscale URL"
|
||||
},
|
||||
"reportBug": {
|
||||
"label": "Report a bug",
|
||||
"title": "Opens a prefilled GitHub Issues page in your browser. Nothing is sent until you click Submit."
|
||||
},
|
||||
"app": {
|
||||
"loading": "Loading…",
|
||||
"trimmed_loaded": "Trimmed audio loaded",
|
||||
"toast_exported": "Exported: {{name}}",
|
||||
"toast_export_failed": "Export failed: {{message}}",
|
||||
"toast_open_folder_failed": "Could not open folder: {{message}}",
|
||||
"toast_saving": "Saving {{name}}...",
|
||||
"toast_saved": "Saved: {{path}}",
|
||||
"toast_save_error": "Save error: {{message}}",
|
||||
"toast_processing": "Processing {{name}}...",
|
||||
"toast_downloaded": "Downloaded {{name}}",
|
||||
"toast_download_error": "Download error: {{message}}",
|
||||
"toast_upload_first": "Please click 'Upload & Transcribe' first so the video is processed on the server before saving.",
|
||||
"toast_save_failed": "Save failed: {{message}}",
|
||||
"toast_opened": "Opened: {{name}}",
|
||||
"toast_project_deleted": "Project deleted",
|
||||
"toast_restored_state": "Restored previous generation state",
|
||||
"toast_history_deleted": "History item deleted",
|
||||
"toast_flushed": "Flushed — RAM {{ram}}G · VRAM {{vram}}G{{unloaded}}",
|
||||
"toast_model_unloaded": " · model unloaded",
|
||||
"toast_flush_failed": "Flush failed: {{message}}",
|
||||
"toast_project_saved": "Project saved",
|
||||
"toast_project_created": "Project created"
|
||||
},
|
||||
"support": {
|
||||
"tab_support": "Support",
|
||||
"tab_license": "Commercial License",
|
||||
"toggle_label": "Support or commercial license",
|
||||
"other_ways": "Other ways to help",
|
||||
"star_github": "Star on GitHub",
|
||||
"join_discord": "Join Discord"
|
||||
},
|
||||
"firstrun": {
|
||||
"loading": "Preparing setup…",
|
||||
"title": "Set up OmniVoice Studio",
|
||||
"subtitle": "Nothing is installed yet — review where everything goes, then start. You can change these later in Settings.",
|
||||
"language": "Language",
|
||||
"mode_title": "Install mode",
|
||||
"mode_installed": "Installed",
|
||||
"mode_installed_desc": "Uses standard system folders. Recommended for most users.",
|
||||
"mode_portable": "Portable",
|
||||
"mode_portable_desc": "Everything lives in one folder next to the app — move it to another disk or machine as a unit.",
|
||||
"mode_portable_unavailable": "Unavailable: the folder next to the app is not writable.",
|
||||
"storage_title": "Storage",
|
||||
"portable_folder": "Portable folder",
|
||||
"portable_folder_desc": "App environment, models, and your voice data — one folder, fully movable.",
|
||||
"env_dir": "App environment",
|
||||
"env_dir_desc": "Python runtime and AI libraries.",
|
||||
"data_dir": "Voice data & projects",
|
||||
"data_dir_desc": "Your voices, dubs, outputs and project database.",
|
||||
"models_dir": "Model cache",
|
||||
"models_dir_desc": "Downloaded AI models — the largest and most relocatable part.",
|
||||
"needs": "needs ~{{size}}",
|
||||
"free": "{{size}} free",
|
||||
"checking": "checking…",
|
||||
"not_writable": "not writable",
|
||||
"change": "Change…",
|
||||
"compute_title": "Compute",
|
||||
"compute_label": "GPU / accelerator",
|
||||
"compute_auto": "Auto (NVIDIA CUDA / Apple MPS / CPU)",
|
||||
"compute_rocm": "AMD GPU (ROCm, Linux)",
|
||||
"channel_label": "Update channel",
|
||||
"channel_stable": "Stable",
|
||||
"channel_preview": "Preview (latest main)",
|
||||
"network_title": "Network",
|
||||
"region_label": "Download region",
|
||||
"mirrors_title": "Custom mirrors (advanced)",
|
||||
"mirror_pypi": "PyPI index URL",
|
||||
"mirror_hf": "Hugging Face endpoint",
|
||||
"mirror_python": "Python downloads mirror",
|
||||
"insufficient_space": "Not enough free space: this layout needs ~{{need}} on one disk, only {{free}} available. Pick a different location.",
|
||||
"blocked_not_writable": "A chosen folder is not writable — pick a different location.",
|
||||
"total_required": "Total disk needed: ~{{size}} (one-time download on first use)",
|
||||
"start": "Start installation",
|
||||
"starting": "Starting…",
|
||||
"compute_detected": "Detected",
|
||||
"compute_match": "matches this machine",
|
||||
"compute_auto_desc": "Picks the best backend on this machine at runtime — CUDA on NVIDIA, MPS on Apple Silicon, CPU otherwise.",
|
||||
"compute_rocm_desc": "Installs PyTorch ROCm wheels for AMD graphics cards on Linux. Leave on Auto if unsure.",
|
||||
"channel_stable_desc": "Tested releases only — updates arrive after community validation.",
|
||||
"channel_preview_desc": "Rolling builds from the latest main — new engines and fixes first, occasional rough edges.",
|
||||
"installing_title": "Installing",
|
||||
"activity_title": "Activity",
|
||||
"stage_setup": "Setup",
|
||||
"stage_models": "Models & engines",
|
||||
"chip_required": "required",
|
||||
"chip_optional": "optional",
|
||||
"chip_engine": "engine",
|
||||
"lib_download": "Download",
|
||||
"lib_downloading": "downloading…",
|
||||
"lib_use": "Use",
|
||||
"lib_active": "active",
|
||||
"lib_in_settings": "install later in Settings",
|
||||
"lib_show_all": "Show {{count}} optional models",
|
||||
"trust_line": "Everything runs and stays on this machine — no account, no cloud, no telemetry.",
|
||||
"resume_note": "Interrupted downloads resume automatically — closing the app is safe.",
|
||||
"eta_left": "~{{eta}} left",
|
||||
"first_sound_text": "Welcome to your studio. Every word you hear was generated on this machine, just now.",
|
||||
"first_sound_done": "That voice? Generated seconds ago, locally. Welcome in."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
{
|
||||
"update": {
|
||||
"available": "Actualización {{version}} disponible",
|
||||
"install": "Instalar y reiniciar",
|
||||
"install_hint": "Descarga la actualización y reinicia en la nueva versión",
|
||||
"downloading": "Actualizando… {{pct}}%",
|
||||
"restart": "Reiniciar para actualizar",
|
||||
"busy": "Termina tu doblaje primero — luego instala la actualización."
|
||||
},
|
||||
"nav": {
|
||||
"clone": "Clonar",
|
||||
"design": "Diseñar",
|
||||
@@ -16,7 +8,10 @@
|
||||
"launchpad": "Plataforma de lanzamiento",
|
||||
"gallery": "Galería",
|
||||
"transcripts": "Transcripciones",
|
||||
"omnidrive": "OmniDrive"
|
||||
"omnidrive": "OmniDrive",
|
||||
"move_rail_right": "Mover el carril hacia la derecha",
|
||||
"move_rail_left": "Mover el carril hacia la izquierda",
|
||||
"flip_rail": "Lado del riel abatible"
|
||||
},
|
||||
"settings": {
|
||||
"general": "General",
|
||||
@@ -45,7 +40,40 @@
|
||||
"ffmpeg_missing": "No encontrado",
|
||||
"ffmpeg_current": "Camino actual",
|
||||
"ffmpeg_desc": "Establezca una ruta ffmpeg personalizada si falla la detección automática.",
|
||||
"ffmpeg_saved": "Conjunto de ruta FFmpeg: reinicie el backend para aplicar."
|
||||
"ffmpeg_saved": "Conjunto de ruta FFmpeg: reinicie el backend para aplicar.",
|
||||
"diagnostics_copied": "Diagnóstico copiado: péguelo en su informe de problemas.",
|
||||
"updater_desktop": "El actualizador solo se ejecuta en la aplicación de escritorio.",
|
||||
"latest_version": "Estás en la última versión.",
|
||||
"logs_load_failed": "No se pudieron cargar los registros: {{message}}",
|
||||
"clear_frontend_confirm": "¿Borrar el búfer de registro de interfaz en memoria?",
|
||||
"clear_frontend_title": "Borrar registros",
|
||||
"frontend_logs_cleared": "Registros de interfaz borrados",
|
||||
"clear_tauri_confirm": "¿Truncar los archivos de registro del lado Tauri? El sistema operativo seguirá escribiendo nuevas entradas.",
|
||||
"clear_tauri_title": "Borrar registros de Tauri",
|
||||
"nothing_to_clear": "No hay nada que borrar: todavía no hay ningún archivo de registro de Tauri en el disco.",
|
||||
"cleared_tauri_one": "Se borró el archivo de registro {{count}} Tauri",
|
||||
"cleared_tauri_other": "Se borraron {{count}} archivos de registro de Tauri",
|
||||
"clear_tauri_failed": "No se pudieron borrar los registros de Tauri: {{message}}",
|
||||
"clear_backend_confirm": "¿Borrar el tiempo de ejecución del backend y los registros de fallos? Esto no se puede deshacer.",
|
||||
"clear_backend_title": "Borrar registros",
|
||||
"backend_logs_cleared": "Registros de backend borrados",
|
||||
"clear_backend_failed": "No se pudieron borrar los registros",
|
||||
"copy_failed": "Copia fallida: {{message}}",
|
||||
"update_check_failed": "Error en la verificación de actualización: {{message}}",
|
||||
"save_failed": "Error al guardar: {{message}}",
|
||||
"clear_failed": "Borrado fallido: {{message}}",
|
||||
"engine_switched": "{{family}} → {{engine}}",
|
||||
"channel_set_failed": "No se pudo configurar el canal: {{message}}",
|
||||
"updater_downloading": "Descargando {{version}}…",
|
||||
"updater_installed": "Instalado - relanzando.",
|
||||
"updater_available_title": "Actualización disponible",
|
||||
"updater_available_body": "La versión {{version}} está disponible.\n\n{{notes}}\n\n¿Descargar e instalar ahora?",
|
||||
"updater_notes_fallback": "Consulte las notas de la versión en GitHub.",
|
||||
"shortcut_load_failed": "No se pudo cargar el acceso directo: {{message}}",
|
||||
"shortcut_set": "Atajo de dictado configurado en {{shortcut}}",
|
||||
"shortcut_register_failed": "No se pudo registrar: {{message}}",
|
||||
"shortcut_reset": "Restablecer los valores predeterminados",
|
||||
"shortcut_reset_failed": "Error al restablecer: {{message}}"
|
||||
},
|
||||
"bootstrap": {
|
||||
"title": "OmniVoice Studio",
|
||||
@@ -70,7 +98,13 @@
|
||||
"suggest_lang": "¿Cambiar al Español?",
|
||||
"select_lang": "Idioma:",
|
||||
"lines_one": "{{count}} línea",
|
||||
"lines_other": "{{count}} líneas"
|
||||
"lines_other": "{{count}} líneas",
|
||||
"region_global": "Global (directo)",
|
||||
"region_china": "China (espejo)",
|
||||
"region_russia": "Rusia (espejo)",
|
||||
"region_restricted": "Restringido (espejo)",
|
||||
"unknown_error": "Error desconocido",
|
||||
"retrying": "Reintentando…"
|
||||
},
|
||||
"stories": {
|
||||
"title": "Editor de historias",
|
||||
@@ -159,7 +193,17 @@
|
||||
"reload": "Forzar recarga de interfaz de usuario",
|
||||
"backend": "backend",
|
||||
"frontend": "Interfaz",
|
||||
"tauri": "Tauro"
|
||||
"tauri": "Tauro",
|
||||
"cancelOp": "Cancelar operación",
|
||||
"dismiss": "Descartar",
|
||||
"dismissStatus": "Descartar estado",
|
||||
"search": "Buscar…",
|
||||
"no_matches": "No hay coincidencias",
|
||||
"recent_and_popular": "Reciente y popular",
|
||||
"popular_label": "populares",
|
||||
"showing_of": "Mostrando {{shown}} de {{total}}. Escribe para buscar…",
|
||||
"yes": "si",
|
||||
"no": "No"
|
||||
},
|
||||
"launchpad": {
|
||||
"greeting": "hola",
|
||||
@@ -181,7 +225,16 @@
|
||||
"locked": "BLOQUEADO",
|
||||
"open": "Abierto",
|
||||
"try_it": "Pruébalo",
|
||||
"audio_only": "Sólo audio"
|
||||
"audio_only": "Sólo audio",
|
||||
"stories_title": "Historias",
|
||||
"stories_desc": "Audiolibros con varias voces: elige tus personajes, introduce un guión y exporta por capítulo.",
|
||||
"gallery_title": "Galería de voz",
|
||||
"gallery_desc": "Explore voces diseñadas ya preparadas por acento, edad y estilo, sin configuración.",
|
||||
"transcripts_title": "Transcripciones",
|
||||
"transcripts_desc": "Convierta audio o vídeo en texto editable y con capacidad de búsqueda, en 646 idiomas.",
|
||||
"recent_files": "Archivos recientes",
|
||||
"view_all_files": "Ver todos los archivos",
|
||||
"file": "Archivo"
|
||||
},
|
||||
"clone": {
|
||||
"prompt": "rápido",
|
||||
@@ -272,11 +325,6 @@
|
||||
"outputs": "Salidas",
|
||||
"crash_log": "Registro de fallos",
|
||||
"update_endpoint": "Actualizar punto final",
|
||||
"update_channel": "Canal de actualización",
|
||||
"channel_stable": "Estable",
|
||||
"channel_preview": "Vista previa",
|
||||
"channel_set": "Canal de actualización configurado en {{channel}}",
|
||||
"channel_preview_hint": "La vista previa sigue la última compilación de main: funciones más nuevas, menos probadas. Vuelve a estable si hay una versión estable más reciente.",
|
||||
"yes": "si",
|
||||
"no": "no",
|
||||
"web_preview": "vista previa web",
|
||||
@@ -285,7 +333,12 @@
|
||||
"copy_diagnostics": "Copiar diagnóstico",
|
||||
"github": "OmniVoice en GitHub",
|
||||
"model_card": "tarjeta modelo",
|
||||
"commercial_license": "Licencia Comercial"
|
||||
"commercial_license": "Licencia Comercial",
|
||||
"update_channel": "Canal de actualización",
|
||||
"channel_stable": "Estable",
|
||||
"channel_preview": "Vista previa",
|
||||
"channel_set": "Canal de actualización configurado en {{channel}}",
|
||||
"channel_preview_hint": "La vista previa sigue la última compilación de main: funciones más nuevas, menos probadas. Vuelve a estable si hay una versión estable más reciente."
|
||||
},
|
||||
"privacy": {
|
||||
"desc": "Todo se ejecuta en <1>esta máquina</1>. Su audio, video y transcripciones nunca salen de su computadora a menos que utilice explícitamente un traductor en línea (Google, DeepL, etc.) o acceda a HuggingFace.",
|
||||
@@ -341,7 +394,30 @@
|
||||
"unavailable": "no disponible",
|
||||
"use": "uso",
|
||||
"loading": "Cargando motores…",
|
||||
"refresh": "Actualizar"
|
||||
"refresh": "Actualizar",
|
||||
"matrixTitle": "Matriz de compatibilidad de motores",
|
||||
"loadFailed": "No se pudieron cargar los motores: {{message}}",
|
||||
"couldNotLoad": "No se pudieron cargar los motores: {{message}}",
|
||||
"retry": "Reintentar",
|
||||
"activeEngine": "Activo {{family}}: {{engine}}",
|
||||
"engineCompatLabel": "{{family}} compatibilidad del motor",
|
||||
"active": "activo",
|
||||
"whyUnavailable": "¿Por qué no está disponible?",
|
||||
"lastError": "Último error: {{error}}",
|
||||
"installedAndReady": "Instalado y listo",
|
||||
"notInstalled": "No instalado",
|
||||
"available": "Disponible",
|
||||
"subprocessTitle": "Se ejecuta en su propio subproceso + venv",
|
||||
"inProcessTitle": "Se ejecuta en el proceso OmniVoice Python",
|
||||
"testEngine": "motor de prueba",
|
||||
"testing": "Probando…",
|
||||
"recheck": "Vuelva a comprobar",
|
||||
"rechecking": "Volviendo a comprobar…",
|
||||
"latencyMs": "{{ms}} ms",
|
||||
"failed": "falló",
|
||||
"acceptLicense": "Aceptar licencia",
|
||||
"noBackends": "No hay backends registrados.",
|
||||
"switch_failed": "No se pudo cambiar de motor"
|
||||
},
|
||||
"capture": {
|
||||
"desc": "Las teclas de acceso rápido globales solo funcionan en la aplicación de escritorio. La interfaz de usuario web utiliza un acceso directo <1>Ctrl+Shift+Espacio</1> en la página mientras la ventana tiene el foco.",
|
||||
@@ -353,13 +429,55 @@
|
||||
"record_shortcut": "Grabar acceso directo",
|
||||
"recording": "Grabando…",
|
||||
"save": "Guardar",
|
||||
"reset_default": "Restablecer los valores predeterminados"
|
||||
"reset_default": "Restablecer los valores predeterminados",
|
||||
"listening_label": "Escuchando…",
|
||||
"transcribing_label": "Transcribiendo…",
|
||||
"pasted": "Pegado",
|
||||
"no_speech": "No se detectó voz",
|
||||
"mic_denied": "Acceso al micrófono denegado",
|
||||
"mic_denied_toast": "Acceso al micrófono denegado. {{hint}}",
|
||||
"mic_hint_mac": "macOS: abra Configuración del sistema → Privacidad y seguridad → Micrófono y habilite OmniVoice.",
|
||||
"mic_hint_windows": "Windows: abra Configuración → Privacidad y seguridad → Micrófono y permita OmniVoice.",
|
||||
"mic_hint_linux": "Linux: verifique que su usuario esté en el grupo de audio y que WebView tenga acceso al micrófono.",
|
||||
"transcription_failed": "Error de transcripción: {{message}}"
|
||||
},
|
||||
"logs": {
|
||||
"no_tauri_log": "Aún no hay ningún registro de Tauri en el disco: inicie a través de la compilación de escritorio para producir uno",
|
||||
"empty_frontend": "Aún no se han capturado entradas de la consola frontal. Interactúa con la aplicación: todas las consolas* aparecerán aquí.",
|
||||
"empty_tauri": "No hay registros de Tauri disponibles. Se ejecuta únicamente en el shell del escritorio.",
|
||||
"empty_backend": "El registro de tiempo de ejecución está vacío. La actividad aparecerá aquí cuando el backend la registre."
|
||||
"empty_backend": "El registro de tiempo de ejecución está vacío. La actividad aparecerá aquí cuando el backend la registre.",
|
||||
"title": "Registros",
|
||||
"source_backend": "backend",
|
||||
"source_frontend": "Interfaz",
|
||||
"source_tauri": "Tauro",
|
||||
"expand": "Expandir registros",
|
||||
"collapse": "Contraer registros",
|
||||
"expand_aria": "Expandir el panel de registros",
|
||||
"collapse_aria": "Contraer panel de registros",
|
||||
"drag_resize": "Arrastra para cambiar el tamaño",
|
||||
"refresh": "Actualizar",
|
||||
"refresh_aria": "Actualizar registros",
|
||||
"copy_visible": "Copiar registro visible",
|
||||
"copy_visible_aria": "Copiar registro visible",
|
||||
"clear": "Borrar",
|
||||
"clear_aria": "Borrar registro",
|
||||
"report_issue": "Informar problema (diagnóstico de copia)",
|
||||
"report_issue_aria": "Informar problema",
|
||||
"close": "Cerrar",
|
||||
"close_aria": "Cerrar panel de registros",
|
||||
"join_discord": "Únete a nuestra discordia",
|
||||
"join_discord_aria": "Únete a nuestra comunidad de Discord",
|
||||
"support_project": "Apoya este proyecto",
|
||||
"support_project_aria": "Apoya este proyecto",
|
||||
"empty_frontend_short": "Aún no hay salida de la consola frontal.",
|
||||
"empty_lines": "Sin colas.",
|
||||
"all_clear": "✅ Todo claro: no se detectaron problemas",
|
||||
"log_cleared": "{{source}} registro borrado",
|
||||
"clear_failed": "Borrado fallido: {{message}}",
|
||||
"log_copied": "Registro {{source}} copiado",
|
||||
"copy_failed": "Copia fallida: {{message}}",
|
||||
"report_copied": "Informe de diagnóstico copiado: péguelo en un problema de GitHub.",
|
||||
"report_failed": "Informe fallido: {{message}}"
|
||||
},
|
||||
"voice_profile": {
|
||||
"test_text": "Hola, esta es una prueba de esta voz.",
|
||||
@@ -527,7 +645,17 @@
|
||||
"install_already": "{{engine}} ya estaba instalado",
|
||||
"install_ok": "{{engine}} instalado",
|
||||
"install_failed": "Error de instalación: {{message}}",
|
||||
"prep_elapsed": "{{time}} transcurrido"
|
||||
"prep_elapsed": "{{time}} transcurrido",
|
||||
"add_language": "Agregar idioma",
|
||||
"search_languages": "Buscar idiomas…",
|
||||
"languages_selected_one": "{{count}} idioma seleccionado",
|
||||
"languages_selected_other": "{{count}} idiomas seleccionados",
|
||||
"more_to_narrow": "+{{count}} más — escribe para limitar",
|
||||
"no_matches": "No hay coincidencias",
|
||||
"diagnostic_copied": "Diagnóstico copiado",
|
||||
"copy_failed": "Copia fallida",
|
||||
"open_docs": "Documentos abiertos",
|
||||
"copy_diagnostic": "Copiar diagnóstico"
|
||||
},
|
||||
"glossary": {
|
||||
"title": "Glosario",
|
||||
@@ -602,7 +730,17 @@
|
||||
"more_actions_title": "Más acciones",
|
||||
"speaker_pick": "Elige…",
|
||||
"speaker_title_detected": "Altavoz: elija uno de los detectados o escriba un nombre personalizado",
|
||||
"speaker_title_custom": "Orador: escriba un nombre (no se detectaron clones de diarización)"
|
||||
"speaker_title_custom": "Orador: escriba un nombre (no se detectaron clones de diarización)",
|
||||
"time_edit_title": "Haga clic para editar la hora de inicio (m:ss.s). Ingrese para confirmar, Esc para cancelar.",
|
||||
"fit_fits": "Se adapta",
|
||||
"fit_fits_title": "El audio de velocidad natural cabe dentro de la ranura.",
|
||||
"fit_overflows": "Se desborda +{{seconds}}s",
|
||||
"fit_overflows_title": "El texto traducido era más largo que el espacio original en {{seconds}}s. El audio estaba muy recortado; acorte el texto o cambie el tiempo a \"Estirar vídeo\".",
|
||||
"fit_stretched": "Vídeo {{ratio}}×",
|
||||
"fit_stretched_title": "Modo Stretch Video: el video de este segmento se ralentizó a {{ratio}}× para adaptarse al audio doblado natural.",
|
||||
"fit_compressed_title": "El audio TTS es el {{pct}}% de la ranura: muy comprimido.",
|
||||
"fit_audio_title": "El audio encaja dentro de la ranura.",
|
||||
"fit_ratio_title": "El audio TTS es el {{pct}}% de la ranura."
|
||||
},
|
||||
"voice": {
|
||||
"personality": "Personalidad",
|
||||
@@ -697,10 +835,24 @@
|
||||
"status_running": "corriendo",
|
||||
"status_done": "hecho",
|
||||
"status_failed": "falló",
|
||||
"status_cancelled": "cancelado"
|
||||
"status_cancelled": "cancelado",
|
||||
"add_to_queue_title": "Agregar videos a la cola",
|
||||
"drop_hint_text": "Suelte archivos de vídeo aquí o haga clic para explorar",
|
||||
"drop_formats": "MP4 · MOV · MKV · WEBM",
|
||||
"files_kicker": "ARCHIVOS ({{count}})",
|
||||
"file_size_mb": "{{size}} MB",
|
||||
"target_languages": "IDIOMAS OBJETIVO",
|
||||
"voice_kicker": "VOZ",
|
||||
"default_option": "Predeterminado",
|
||||
"clone_profiles": "Clonar perfiles",
|
||||
"presets": "Preajustes",
|
||||
"preserve_bg": "Conservar audio de fondo (música/FX)",
|
||||
"estimate": "{{videos}} vídeo(s) × {{langs}} idioma(s) = {{jobs}} trabajo(s)",
|
||||
"select_files_langs": "Seleccionar archivos e idiomas",
|
||||
"add_to_queue": "Agregar a la cola"
|
||||
},
|
||||
"gallery": {
|
||||
"title": "Galería",
|
||||
"title": "OmniVoice Galería",
|
||||
"search_placeholder": "Buscar en YouTube…",
|
||||
"all_voices": "Todas las voces ({{count}})",
|
||||
"no_voices": "Aún no hay voces",
|
||||
@@ -715,6 +867,14 @@
|
||||
"youtube_results": "Resultados de YouTube ({{count}})",
|
||||
"clone_profile": "Clonar perfil",
|
||||
"crop_audio": "Recortar audio",
|
||||
"cat_disney": "disney",
|
||||
"cat_anime": "animado",
|
||||
"cat_marvel": "maravilla/dc",
|
||||
"cat_celebs": "celebridades",
|
||||
"cat_politicians": "Políticos",
|
||||
"cat_news": "Presentadores de noticias",
|
||||
"cat_gaming": "Juegos",
|
||||
"cat_books": "Libros/Películas",
|
||||
"subtitle": "Cientos de voces diseñadas ya preparadas: elige una y listo.",
|
||||
"zone_archetypes": "Arquetipos",
|
||||
"zone_imports": "Mis Importaciones",
|
||||
@@ -818,8 +978,7 @@
|
||||
"back": "Volver al estudio",
|
||||
"badge": "Licencia Comercial",
|
||||
"hero_title": "Enviar voces de IA en producción",
|
||||
"hero_desc": "OmniVoice Studio está disponible bajo la Licencia de fuente funcional (FSL). La mayoría de los usuarios pueden evaluar, crear prototipos e incluso implementar internamente sin un acuerdo comercial. Necesita una licencia comercial sólo si está creando un producto o servicio de la competencia, o si su caso de uso queda fuera de los límites de la FSL.",
|
||||
"hero_note": "Para crear un producto o servicio competitivo o implementarlo a escala (por ejemplo, ofrecer API de pago por uso) se requiere una licencia comercial. Próximamente niveles de precios; póngase en contacto mientras tanto.",
|
||||
"hero_desc": "OmniVoice Studio es software libre y de código abierto bajo la GNU Affero General Public License v3 (AGPL-3.0): gratuito para cualquier uso, incluido el uso comercial y empresarial interno. Solo necesitas una licencia comercial si quieres integrar OmniVoice Studio en un producto o servicio propietario o de código cerrado sin las obligaciones copyleft de la AGPL-3.0.",
|
||||
"why_title": "Por qué las empresas eligen OmniVoice",
|
||||
"pricing_title": "Precios",
|
||||
"faq_title": "Preguntas comunes",
|
||||
@@ -839,7 +998,8 @@
|
||||
"benefit_source": "Núcleo disponible en la fuente",
|
||||
"benefit_source_desc": "Visibilidad total de la pila. Audite, bifurque y adapte dentro de los términos de la licencia.",
|
||||
"benefit_lang": "646 idiomas",
|
||||
"benefit_lang_desc": "Transcribe, traduce y dobla en 646 idiomas con calidad de nivel humano."
|
||||
"benefit_lang_desc": "Transcribe, traduce y dobla en 646 idiomas con calidad de nivel humano.",
|
||||
"hero_note": "El uso, el autoalojamiento y el uso comercial son gratuitos bajo la AGPL-3.0, incluso a gran escala. La AGPL es una licencia copyleft de red: si modificas OmniVoice y ofreces esa versión modificada a terceros a través de una red, debes compartir tu código fuente modificado bajo los mismos términos. Una licencia comercial elimina esas obligaciones copyleft para despliegues propietarios de código cerrado. Los planes de precios llegarán pronto; mientras tanto, contáctanos."
|
||||
},
|
||||
"exportModal": {
|
||||
"export": "Exportar",
|
||||
@@ -928,7 +1088,478 @@
|
||||
"dubbing_title": "Ver doblaje en acción",
|
||||
"dubbing_sync": "Reproducción sincronizada",
|
||||
"dubbing_picker": "Prueba con otro idioma:",
|
||||
"dubbing_cta": "Ejecute esto en su propio video →"
|
||||
"dubbing_cta": "Ejecute esto en su propio video →",
|
||||
"dubbing_loading": "Cargando demostración de doblaje…",
|
||||
"dubbing_dismiss": "Descartar demostración de doblaje",
|
||||
"original_tag": "originales",
|
||||
"dubbed_tag": "apodado",
|
||||
"script_conversational": "conversacional",
|
||||
"script_technical": "Vocabulario técnico",
|
||||
"script_french": "No inglés (francés)",
|
||||
"aria_pause": "Pausa {{label}}",
|
||||
"aria_hear": "Escuchar {{label}}",
|
||||
"aria_replay": "Reproducir {{label}} a través del transcriptor",
|
||||
"dictation_lede_hotkey_only": "Mantén pulsado el atajo de arriba en cualquier lugar del escritorio, habla y suéltalo: el texto aparecerá en la app con foco. Púlsalo ahora para verificarlo."
|
||||
},
|
||||
"direction": {
|
||||
"title": "Dirección del segmento #{{id}}",
|
||||
"desc": "Dígale al oleoducto cómo debería sentirse esta línea. El inglés simple funciona: el sistema asigna sus palabras a una taxonomía estable (energía/emoción/ritmo/intimidad/formalidad) y luego integra la taxonomía a través de traducción cinematográfica, TTS y ajuste de ranura.",
|
||||
"label": "Dirección",
|
||||
"lineHint": "Línea: \"{{text}}\"",
|
||||
"placeholder": "por ej. urgente y sorprendido / cálido, esperanzado / susurrado, íntimo",
|
||||
"previewParse": "Análisis de vista previa",
|
||||
"previewFailed": "Error en la vista previa: {{message}}",
|
||||
"clear": "Borrar",
|
||||
"cancel": "Cancelar",
|
||||
"saveDirection": "Guardar dirección",
|
||||
"ttsInstruct": "TTS instruye:",
|
||||
"nothingParsed": "- (nada analizado)",
|
||||
"translateHint": "Traducir pista:",
|
||||
"rateBias": "Sesgo de tarifas:",
|
||||
"speedsUp": "acelera",
|
||||
"slowsDown": "se ralentiza",
|
||||
"taxonomyTokens": "fichas de taxonomía"
|
||||
},
|
||||
"errors": {
|
||||
"title": "Esta pestaña tuvo un problema.",
|
||||
"desc": "No te preocupes, el resto de la aplicación sigue funcionando. Puedes cambiar de pestaña o volver a intentarlo a continuación.",
|
||||
"tryAgain": "Inténtalo de nuevo",
|
||||
"openDocs": "Abrir documentos para este error"
|
||||
},
|
||||
"keyboard": {
|
||||
"title": "Atajos de teclado",
|
||||
"footer": "Presione <1>?</1> en cualquier momento para abrir esto.",
|
||||
"or": "o",
|
||||
"nav": "Navegación",
|
||||
"nav_cheatsheet": "Mostrar esta hoja de trucos",
|
||||
"nav_closeModal": "Cerrar modal/cancelar",
|
||||
"nav_save": "Guardar proyecto/comprobar recorte",
|
||||
"segmentEditor": "editor de segmentos",
|
||||
"seg_split": "Dividir segmento en el cursor",
|
||||
"seg_merge": "Fusionarse con el siguiente segmento",
|
||||
"seg_undo": "Deshacer",
|
||||
"seg_redo": "Rehacer",
|
||||
"seg_click": "acción primaria",
|
||||
"seg_shiftClick": "Seleccionar rango",
|
||||
"trimmer": "Recortador de audio",
|
||||
"trim_playPause": "Vista previa de reproducción/pausa",
|
||||
"trim_nudgeStart": "Empujar el mango de inicio",
|
||||
"trim_nudgeEnd": "Mango de extremo de empuje",
|
||||
"trim_fineNudge": "Buen empujón",
|
||||
"trim_coarseNudge": "empujón grueso",
|
||||
"trim_zoomIn": "Acercar/alejar",
|
||||
"trim_fitAll": "Ajustar todo/Ajustar selección",
|
||||
"trim_confirm": "Confirmar recorte",
|
||||
"dub": "Doblar",
|
||||
"dub_generate": "Generar doblaje",
|
||||
"dub_sidebar": "Alternar barra lateral"
|
||||
},
|
||||
"network": {
|
||||
"sharing_on_title": "Compartir en: haga clic para obtener más detalles",
|
||||
"share_on_network": "Comparte en tu red",
|
||||
"switching": "Cambiando…",
|
||||
"network": "Red",
|
||||
"local": "locales",
|
||||
"share_confirm_title": "¿Compartir en tu red?",
|
||||
"share_confirm_hint": "Otros dispositivos en su Wi-Fi/Ethernet podrán acceder a OmniVoice utilizando el PIN de acceso que se muestra una vez que esté encendido.",
|
||||
"enabling": "Habilitando…",
|
||||
"enable": "Habilitar",
|
||||
"shared_title": "Compartido en tu red",
|
||||
"no_interface": "No hay una interfaz de red accesible: conéctese a Wi-Fi/Ethernet.",
|
||||
"copy_link": "Copiar enlace",
|
||||
"open_in_browser": "Abrir en el navegador",
|
||||
"qr_alt": "QR para {{ip}}",
|
||||
"pin": "PIN:",
|
||||
"stop_sharing": "dejar de compartir",
|
||||
"copied": "Copiado",
|
||||
"enable_error": "No se pudo habilitar el uso compartido: {{message}}",
|
||||
"disable_error": "No se pudo desactivar: {{message}}"
|
||||
},
|
||||
"readiness": {
|
||||
"checking_system": "Sistema de comprobación...",
|
||||
"all_ready": "Todos los sistemas listos",
|
||||
"system_readiness": "Preparación del sistema",
|
||||
"asr_model": "Modelo ASR",
|
||||
"loaded_ready": "Cargado y listo",
|
||||
"loading_first_run": "Cargando... (esto puede tardar entre 1 y 2 minutos en la primera ejecución)",
|
||||
"failed_to_load": "No se pudo cargar",
|
||||
"not_loaded_yet": "Aún no cargado: se cargará en la primera transcripción",
|
||||
"error_check_logs": "Error: {{error}}. Verifique los registros e intente reiniciar.",
|
||||
"check_logs_restart": "Verifique los registros para detectar errores de carga del modelo. Intenta reiniciar.",
|
||||
"llm_cinematic": "LLM (Cinemático)",
|
||||
"llm_configure": "Configure TRANSLATE_BASE_URL para calidad de traducción cinematográfica",
|
||||
"llm_set_env": "Establezca las variables de entorno TRANSLATE_BASE_URL y TRANSLATE_API_KEY. Funciona con Ollama, OpenAI, LM Studio, etc.",
|
||||
"llm_optional": "Opcional: configure TRANSLATE_BASE_URL para calidad cinematográfica"
|
||||
},
|
||||
"license": {
|
||||
"title": "Supertonic-3 — Aceptación de licencia",
|
||||
"intro": "Supertonic-3 se envía bajo dos licencias distintas. Revise ambos antes de habilitar el motor.",
|
||||
"sdk_heading": "Código SDK · MIT",
|
||||
"sdk_desc": "El SDK de inferencia de Python (supertonic) tiene licencia MIT. Uso permisivo, incluido el comercial.",
|
||||
"read_mit": "Leer la licencia MIT →",
|
||||
"model_heading": "Pesos modelo · OpenRAIL-M",
|
||||
"model_desc": "Los pesos del modelo Supertonic-3 se publican bajo la licencia OpenRAIL-M. Esta licencia restringe el uso a fines no maliciosos; consulte la licencia vinculada para conocer el conjunto completo de restricciones basadas en el uso.",
|
||||
"read_openrail": "Lea la licencia OpenRAIL-M →",
|
||||
"footer": "Al hacer clic en Aceptar se registra su aceptación en la configuración local de OmniVoice y se habilita el motor. Su aceptación se almacena únicamente en esta máquina; no se informa nada a Supertone Inc. ni a ningún tercero.",
|
||||
"saving": "Guardando…",
|
||||
"accept": "Aceptar",
|
||||
"accepted_toast": "Se acepta licencia Supertonic-3.",
|
||||
"accept_error": "No se pudo registrar la aceptación de la licencia: {{message}}"
|
||||
},
|
||||
"voicePreview": {
|
||||
"title": "Vista previa de voz",
|
||||
"close": "Cerrar vista previa",
|
||||
"default_text": "¡Hola! Esta es una vista previa de cómo sueno con esta voz.",
|
||||
"default_voice": "Voz predeterminada",
|
||||
"clone_profiles": "Clonar perfiles",
|
||||
"designed_voices": "Voces diseñadas",
|
||||
"presets": "Preajustes",
|
||||
"placeholder": "Escribe algo para escuchar...",
|
||||
"stop": "Detener",
|
||||
"regenerate": "regenerar",
|
||||
"preview": "Vista previa",
|
||||
"hint": "8 pasos · vista previa rápida"
|
||||
},
|
||||
"header": {
|
||||
"kicker_studio": "Estudio",
|
||||
"kicker_library": "Biblioteca",
|
||||
"kicker_preferences": "Preferencias",
|
||||
"kicker_licensing": "Licencias",
|
||||
"label_launchpad": "Plataforma de lanzamiento",
|
||||
"label_clone": "Clon de voz",
|
||||
"label_design": "Diseño de voz",
|
||||
"label_dub": "Doblaje",
|
||||
"label_projects": "OmniDrive",
|
||||
"label_gallery": "Galería",
|
||||
"label_transcriptions": "Transcripciones",
|
||||
"label_settings": "Configuración",
|
||||
"label_enterprise": "Licencia Comercial",
|
||||
"status_ready": "Listo",
|
||||
"status_loading": "Cargando…",
|
||||
"status_idle": "inactivo",
|
||||
"memory_management": "Gestión de memoria",
|
||||
"flush": "al ras",
|
||||
"loaded_models": "Modelos cargados",
|
||||
"no_models": "No hay modelos cargados",
|
||||
"unload": "descargar",
|
||||
"flush_caches": "Vaciar cachés",
|
||||
"unload_all_flush": "Descargar todo + enjuagar"
|
||||
},
|
||||
"sidebar": {
|
||||
"tab_drive": "conducir",
|
||||
"tab_history": "Historia",
|
||||
"tab_exports": "Exportaciones",
|
||||
"save_project": "Guardar proyecto de doblaje",
|
||||
"save_new_project": "Guardar como nuevo proyecto de doblaje",
|
||||
"dub_projects": "Proyectos de doblaje",
|
||||
"voice_clones": "Clones de voz",
|
||||
"designed_voices": "Voces diseñadas",
|
||||
"no_dub_projects": "No hay proyectos de doblaje guardados",
|
||||
"no_dub_hint": "Sube un vídeo y haz clic en Guardar para conservar tu trabajo.",
|
||||
"no_clones": "Aún no hay clones de voz",
|
||||
"no_clones_hint": "Grabe o cargue audio, luego haga clic en Guardar como perfil de voz.",
|
||||
"no_designs": "Aún no hay voces diseñadas",
|
||||
"no_designs_hint": "Genera una voz y guárdala del Historial.",
|
||||
"history_subtitle": "Historial de generación · Almacenado en SQLite",
|
||||
"no_history": "Sin historial generacional",
|
||||
"no_history_hint": "Sintetiza audio o copia un vídeo: los resultados aparecerán aquí.",
|
||||
"clear_history": "Borrar historial",
|
||||
"clear_confirm": "¿Borrar todos los {{count}} elementos del historial? Esto no se puede deshacer.",
|
||||
"history_cleared": "Historial borrado",
|
||||
"recent_exports": "Exportaciones recientes",
|
||||
"no_exports": "No hay salidas descargadas",
|
||||
"no_exports_hint": "Exporte un archivo a través de Tauri para verlo rastreado aquí.",
|
||||
"show_in_folder": "Mostrar en carpeta",
|
||||
"open": "Abierto",
|
||||
"select": "Seleccionar",
|
||||
"try_voice": "Pruebe",
|
||||
"consistent": "consistente",
|
||||
"locked": "bloqueado",
|
||||
"clone_label": "clonar",
|
||||
"design_label": "Diseño",
|
||||
"dub_label": "Doblar",
|
||||
"save_label": "Guardar",
|
||||
"lock_identity": "Bloquear identidad de voz",
|
||||
"in_folder": "en {{folder}}"
|
||||
},
|
||||
"trimmer": {
|
||||
"title": "Recortar audio de referencia",
|
||||
"decoding": "Decodificando audio…",
|
||||
"meta_length": "Longitud {{duration}} · {{sampleRate}} Hz",
|
||||
"meta_rendering": "representación de forma de onda {{percent}}%",
|
||||
"keyboard_hint": "desplazamiento = zoom · mayúsculas+desplazamiento = panorámica · alt+arrastrar = panorámica · teclas ⏐ ⟵ ⟶ ⏐ ajustan los controles",
|
||||
"zoom_in": "Acercar (+)",
|
||||
"zoom_out": "Alejar (-)",
|
||||
"fit_all": "Se adapta a todos (Inicio)",
|
||||
"fit_selection": "Selección de ajuste (Fin)",
|
||||
"fit_sel_btn": "AJUSTE SEL",
|
||||
"view_range": "Ver {{start}} → {{end}} ({{duration}})",
|
||||
"start_label": "Empezar",
|
||||
"end_label": "Fin",
|
||||
"length_label": "Longitud",
|
||||
"too_long": ">{{max}}s",
|
||||
"too_short": "demasiado corto",
|
||||
"length_ok": "bien",
|
||||
"loop_preview": "Vista previa del bucle",
|
||||
"pause": "Pausa",
|
||||
"preview_selection": "Selección de vista previa",
|
||||
"play_hint": "Espacio para jugar · Enter para confirmar · Esc para cancelar",
|
||||
"cancel": "Cancelar",
|
||||
"use_trimmed": "Usar recortado",
|
||||
"decode_failed": "Error de decodificación: {{message}}",
|
||||
"playback_failed": "Error de reproducción: {{message}}",
|
||||
"audio_load_failed": "Falló la carga de audio",
|
||||
"unit_seconds": "s"
|
||||
},
|
||||
"casting": {
|
||||
"title": "Casting de oradores",
|
||||
"auto_assign_title": "Asignar automáticamente voces a partir de clones de altavoces extraídos",
|
||||
"auto_cast": "emisión automática",
|
||||
"all_cast": "Todo el elenco",
|
||||
"segments_count": "{{count}} segmentos",
|
||||
"assign_voice": "Asignar voz…",
|
||||
"from_video": "🎤 Del vídeo ({{name}})",
|
||||
"no_profiles": "Aún no se han guardado perfiles de voz.",
|
||||
"preview_voice": "Vista previa de voz"
|
||||
},
|
||||
"checkpoint": {
|
||||
"asr_title": "Transcripciones listas",
|
||||
"asr_cta": "Traducir",
|
||||
"asr_hint": "Corrija cualquier error de ASR ahora: la dicción precisa ahorra intentos de TTS más adelante.",
|
||||
"translate_title": "Traducciones listas",
|
||||
"translate_cta": "Generar doblaje",
|
||||
"translate_hint": "Hojee el texto de destino. Las líneas demasiado largas aumentan la velocidad; También puedes editar directamente.",
|
||||
"done_title": "doblaje completo",
|
||||
"done_hint": "Revise las proporciones de sincronización y sincronización. Modifica cualquier línea y presiona \"Regeneración cambiada\" para una rehacer parcial rápida.",
|
||||
"segment_one": "{{count}} segmento",
|
||||
"segment_other": "{{count}} segmentos",
|
||||
"dismiss_title": "Descartar: no volverá a aparecer en esta etapa hasta que se vuelva a cargar"
|
||||
},
|
||||
"compare": {
|
||||
"title": "Comparación de voz A/B",
|
||||
"close": "Cerrar comparación",
|
||||
"desc": "Compara dos voces una al lado de la otra para tomar decisiones sobre el reparto. La aplicación permanece interactiva detrás.",
|
||||
"test_phrase": "frase de prueba",
|
||||
"voice_a": "Voz A",
|
||||
"voice_b": "Voz B",
|
||||
"select_voice": "— Seleccionar voz —",
|
||||
"preset_suffix": "(Preestablecido)",
|
||||
"no_audio": "Aún no hay audio",
|
||||
"close_btn": "Cerrar",
|
||||
"comparing": "Comparando…",
|
||||
"compare_btn": "Comparar",
|
||||
"preparing_voice": "Preparando voz...",
|
||||
"generating_voice_a": "Generando voz A...",
|
||||
"generating_voice_b": "Generando voz B...",
|
||||
"comparison_complete": "¡Comparación completa!",
|
||||
"play_failed": "Error al reproducir: {{message}}"
|
||||
},
|
||||
"models": {
|
||||
"hf_token_set_toast": "Conjunto de tokens HuggingFace: descargas más rápidas habilitadas",
|
||||
"hf_token_save_failed": "No se pudo guardar el token",
|
||||
"install_started": "Instalación iniciada: progreso en la fila",
|
||||
"delete_confirm": "¿Eliminar {{repoId}}? Puedes reinstalarlo más tarde.",
|
||||
"delete_confirm_title": "Eliminar modelo",
|
||||
"deleted": "Eliminado {{repoId}}",
|
||||
"reinstall_confirm": "¿Reinstalar {{repoId}}? Esto eliminará la copia actual y la descargará nuevamente.",
|
||||
"reinstall_confirm_title": "Reinstalar el modelo",
|
||||
"reinstalling": "Reinstalar",
|
||||
"recommended_installed": "Los modelos recomendados ya están instalados.",
|
||||
"started_downloading_one": "Comenzó a descargar el modelo {{count}}",
|
||||
"started_downloading_other": "Comenzó a descargar modelos {{count}}",
|
||||
"install_failed": "Error de instalación: {{message}}",
|
||||
"removing_cached": "Eliminando revisiones almacenadas en caché…",
|
||||
"resolving_metadata": "Resolución de metadatos del repositorio",
|
||||
"retry_attempt": "Reintentar {{attempt}} — {{error}}",
|
||||
"connecting_hf": "Conectándose a HuggingFace…",
|
||||
"resolving_files_one": "Resolviendo el archivo {{count}}…",
|
||||
"resolving_files_other": "Resolviendo archivos {{count}}…",
|
||||
"resolving_files_active": "Resolviendo {{count}} archivo(s)… · {{file}}",
|
||||
"measuring": "midiendo…",
|
||||
"files_progress": "Archivos {{done}}/{{total}}",
|
||||
"install_error": "Error de instalación: {{error}}",
|
||||
"view_on_hf": "Ver en HuggingFace",
|
||||
"install_btn": "Instalar",
|
||||
"reinstall_btn": "Reinstalar",
|
||||
"downloading": "descargando",
|
||||
"deleting": "eliminando",
|
||||
"working": "trabajando",
|
||||
"installed": "instalado",
|
||||
"not_installed": "no instalado",
|
||||
"required_tag": "requerido",
|
||||
"delete_btn": "Eliminar",
|
||||
"hf_token_btn": "Ficha HF",
|
||||
"hf_set_title": "Configure el token HuggingFace para descargas más rápidas",
|
||||
"get_token": "Obtener token →",
|
||||
"reco_installed_for": "Paquete recomendado instalado para {{device}}",
|
||||
"reco_for": "Recomendado para {{device}}",
|
||||
"starting": "Empezando…",
|
||||
"required_size": "Requerido ~{{size}} GB",
|
||||
"all_size": "Todo ~{{size}} GB",
|
||||
"req_tag": "req",
|
||||
"search_placeholder": "Buscar modelos…",
|
||||
"search_label": "Buscar modelos",
|
||||
"no_matches": "Ningún modelo coincide con tus filtros.",
|
||||
"sort_by": "Ordenar por {{column}}",
|
||||
"column_model": "modelo",
|
||||
"column_role": "Rol",
|
||||
"column_size": "Tamaño",
|
||||
"column_status": "Estado",
|
||||
"ready_badge": "Listo",
|
||||
"loading_badge": "Cargando…",
|
||||
"idle_badge": "inactivo",
|
||||
"started_downloading_required_one": "Comenzó a descargar {{count}} modelo requerido",
|
||||
"started_downloading_required_other": "Comenzó a descargar {{count}} modelos requeridos"
|
||||
},
|
||||
"enterprise_faq": {
|
||||
"q_internal_tools": "¿Necesito una licencia para herramientas internas?",
|
||||
"a_internal_tools": "No. El uso por parte de tus empleados y contratistas —incluida la modificación y el autoalojamiento interno— es gratuito bajo la AGPL-3.0. Solo necesitas una licencia comercial si integras OmniVoice en un producto o servicio propietario o de código cerrado y no quieres cumplir las obligaciones de compartir el código fuente de la AGPL.",
|
||||
"q_try_before": "¿Puedo intentarlo antes de comprometerme?",
|
||||
"a_try_before": "Sí. La aplicación completa se puede descargar, ejecutar y autoalojar gratis bajo la AGPL-3.0, sin ningún acuerdo. Cuando quieras hablar de una licencia comercial (uso propietario), escríbenos y resolveremos los detalles juntos.",
|
||||
"q_watermark": "¿Qué pasa con la marca de agua?",
|
||||
"a_watermark": "La marca de agua invisible AudioSeal está incrustada de forma predeterminada para todos. Los licenciatarios comerciales pueden desactivarla en Configuración → Privacidad."
|
||||
},
|
||||
"gallery_extra": {
|
||||
"save_prompt": "Ingrese un nombre para este perfil de voz:",
|
||||
"saved_as_profile": "¡Voz guardada como perfil!",
|
||||
"save_profile_failed": "No se pudo guardar el perfil",
|
||||
"download_failed": "Error de descarga: {{message}}",
|
||||
"trim_load_failed": "No se pudo cargar el audio para recortar: {{message}}",
|
||||
"upload_crop_failed": "No se pudo cargar la voz recortada: {{message}}"
|
||||
},
|
||||
"dub_workflow": {
|
||||
"preparing_audio": "Preparando audio…",
|
||||
"preparing_video": "Preparando vídeo...",
|
||||
"extracting_audio_scenes": "Extrayendo audio y escenas...",
|
||||
"transcribing_audio": "Transcribiendo audio…",
|
||||
"transcription_complete": "Transcripción completa",
|
||||
"upload_cancelled": "Subida cancelada",
|
||||
"upload_failed": "Error al cargar: {{message}}",
|
||||
"downloading_video": "Descargando vídeo…",
|
||||
"ingested": "Ingerido {{url}}",
|
||||
"ingest_cancelled": "Ingesta cancelada",
|
||||
"ingest_failed": "Falló la ingesta de URL: {{message}}",
|
||||
"retry_cancelled": "Reintento cancelado",
|
||||
"transcription_failed": "Error de transcripción: {{message}}",
|
||||
"import_srt_no_job": "Primero cargue o ingiera un video: no hay ningún trabajo al que adjuntar subtítulos.",
|
||||
"imported_cues": "{{count}} cue(s) importadas de {{file}}",
|
||||
"skipped_malformed": "{{count}} omitido (mal formado)",
|
||||
"dropped_overlap": "{{count}} caído (superposición)",
|
||||
"clamped_to_duration": "{{count}} sujetado a la longitud del medio",
|
||||
"srt_import_failed": "Error al importar SRT",
|
||||
"cleaned_one": "Fragmento {{count}} limpio",
|
||||
"cleaned_other": "Fragmentos {{count}} limpios",
|
||||
"segments_clean": "Segmentos ya limpios",
|
||||
"cleanup_failed": "Error de limpieza: {{message}}",
|
||||
"cinematic_no_llm": "La calidad cinematográfica necesita un LLM: configure TRANSLATE_BASE_URL + TRANSLATE_API_KEY (Ollama funciona localmente). Volviendo a Fast.",
|
||||
"translate_errors": "{{errorCount}}/{{totalCount}} segmento(s) fallidos: {{firstError}}",
|
||||
"translated_segments": "{{count}} segmento(s) traducido(s) → {{lang}}",
|
||||
"translated_cinematic_suffix": "(Cinemático)",
|
||||
"translation_failed": "Error de traducción: {{message}}",
|
||||
"regenerating": "Regenerando {{count}} segmento(s)…",
|
||||
"generating_dub": "Generando doblaje…",
|
||||
"generating_progress": "Generando doblaje… {{current}}/{{total}}",
|
||||
"generation_aborted": "Generación abortada.",
|
||||
"dubbing_aborted": "Doblaje cancelado",
|
||||
"generation_stream_ended": "El flujo de generación finalizó antes de completarse",
|
||||
"dub_complete": "doblaje completo",
|
||||
"stop_failed": "No se pudo detener",
|
||||
"save_first": "Primero haga clic en \"Cargar y transcribir\" para que el video se procese en el servidor antes de guardarlo.",
|
||||
"project_saved": "Proyecto guardado",
|
||||
"project_created": "Proyecto creado",
|
||||
"save_failed": "Error al guardar: {{message}}",
|
||||
"opened_project": "Abierto: {{name}}",
|
||||
"delete_project_confirm": "¿Eliminar este proyecto? Esto no se puede deshacer.",
|
||||
"project_deleted": "Proyecto eliminado",
|
||||
"delete_history_confirm": "¿Eliminar este elemento del historial?",
|
||||
"history_deleted": "Elemento del historial eliminado",
|
||||
"restored_state": "Estado de generación anterior restaurado",
|
||||
"upgrading_preview": "Actualizando {{count}} segmentos de calidad de vista previa a calidad completa..."
|
||||
},
|
||||
"tts_errors": {
|
||||
"enter_text": "Por favor ingresa texto",
|
||||
"upload_or_select": "Sube un audio o selecciona un perfil de voz",
|
||||
"trim_hint": "El audio es {{duration}}s; recórtelo a ≤{{max}}s para una mejor clonación.",
|
||||
"timeout": "Se agotó el tiempo de generación: es posible que el modelo aún se esté descargando. Verifique Configuración → Registros y vuelva a intentarlo.",
|
||||
"error_prefix": "Error: {{message}}",
|
||||
"ignored_unsupported": "Instrucción no admitida ignorada: {{items}}",
|
||||
"ignored_duplicate": "Ignorado (categoría ya establecida): {{items}}"
|
||||
},
|
||||
"sharing": {
|
||||
"title": "Compartir y acceso remoto",
|
||||
"help": "Exponga esta instancia de OmniVoice en ejecución a sus otras máquinas sin reiniciarla. Solo bucle invertido es el valor predeterminado: no se comparte nada hasta que lo activas aquí.",
|
||||
"local_network": "red local",
|
||||
"local_help": "Comparte en tu Wi-Fi/Ethernet con un PIN de acceso único. Otros dispositivos escanean el código QR o abren el enlace.",
|
||||
"ports_title": "Puertos",
|
||||
"ports_help": "Estos se configuran mediante variables de entorno leídas al inicio. Cambie el backend o el puerto de la interfaz de usuario configurando la variable y reiniciando OmniVoice.",
|
||||
"backend_port": "Puerto de fondo",
|
||||
"ui_port": "Puerto de interfaz de usuario",
|
||||
"lan_share_port": "Puerto LAN compartido",
|
||||
"port_error": "Ingrese un puerto entre 1024 y 65535",
|
||||
"port_saved": "Puerto LAN compartido guardado: se aplica la próxima vez que habilites el uso compartido",
|
||||
"port_save_failed": "No se pudo guardar el puerto: {{message}}",
|
||||
"saving": "Guardando…",
|
||||
"ports_note": "Los puertos de backend y UI se aplican al reiniciar. El puerto LAN compartido se aplica la próxima vez que habilite el uso compartido.",
|
||||
"tailscale_title": "Tailscale (acceso remoto privado)",
|
||||
"tailscale_checking": "Comprobando la escala trasera...",
|
||||
"tailscale_absent": "Escala de cola no detectada. Instálelo para comunicarse con OmniVoice de forma segura desde cualquier lugar de su red privada.",
|
||||
"tailscale_install": "Instalar escala trasera",
|
||||
"tailscale_running": "La escala de cola se está ejecutando. Ofrezca OmniVoice a través de su tailnet privada.",
|
||||
"tailscale_not_logged_in": "Tailscale está instalado pero no ha iniciado sesión. Inicie e inicie sesión en Tailscale primero.",
|
||||
"tailscale_enabled": "Servicio de escala final habilitado",
|
||||
"tailscale_enable_failed": "No se pudo habilitar Tailscale",
|
||||
"tailscale_enable_error": "No se pudo habilitar Tailscale: {{message}}",
|
||||
"tailscale_disabled": "Servicio de escala de cola inhabilitado",
|
||||
"tailscale_disable_failed": "No se pudo desactivar Tailscale",
|
||||
"tailscale_disable_error": "No se pudo desactivar Tailscale: {{message}}",
|
||||
"tailscale_enabling": "Habilitando…",
|
||||
"tailscale_enable_btn": "Habilitar el servicio Tailscale",
|
||||
"tailscale_disabling": "Deshabilitando…",
|
||||
"tailscale_disable_btn": "Detener el servicio Tailscale",
|
||||
"tailscale_copy": "Copiar enlace",
|
||||
"tailscale_open": "Abrir en el navegador",
|
||||
"copied": "Copiado",
|
||||
"tailscale_qr_alt": "Código QR para la URL de Tailscale"
|
||||
},
|
||||
"reportBug": {
|
||||
"label": "Informar un error",
|
||||
"title": "Abre una página de Problemas de GitHub precargada en su navegador. No se envía nada hasta que haga clic en Enviar."
|
||||
},
|
||||
"app": {
|
||||
"loading": "Cargando…",
|
||||
"trimmed_loaded": "Audio recortado cargado",
|
||||
"toast_exported": "Exportado: {{name}}",
|
||||
"toast_export_failed": "Error al exportar: {{message}}",
|
||||
"toast_open_folder_failed": "No se pudo abrir la carpeta: {{message}}",
|
||||
"toast_saving": "Guardando {{name}}...",
|
||||
"toast_saved": "Guardado: {{path}}",
|
||||
"toast_save_error": "Error al guardar: {{message}}",
|
||||
"toast_processing": "Procesando {{name}}...",
|
||||
"toast_downloaded": "Descargado {{name}}",
|
||||
"toast_download_error": "Error de descarga: {{message}}",
|
||||
"toast_upload_first": "Primero haga clic en \"Cargar y transcribir\" para que el video se procese en el servidor antes de guardarlo.",
|
||||
"toast_save_failed": "Error al guardar: {{message}}",
|
||||
"toast_opened": "Abierto: {{name}}",
|
||||
"toast_project_deleted": "Proyecto eliminado",
|
||||
"toast_restored_state": "Estado de generación anterior restaurado",
|
||||
"toast_history_deleted": "Elemento del historial eliminado",
|
||||
"toast_flushed": "Vaciado — RAM {{ram}}G · VRAM {{vram}}G{{unloaded}}",
|
||||
"toast_model_unloaded": "· modelo descargado",
|
||||
"toast_flush_failed": "Error de descarga: {{message}}",
|
||||
"toast_project_saved": "Proyecto guardado",
|
||||
"toast_project_created": "Proyecto creado"
|
||||
},
|
||||
"update": {
|
||||
"available": "Actualización {{version}} disponible",
|
||||
"install": "Instalar y reiniciar",
|
||||
"install_hint": "Descarga la actualización y reinicia en la nueva versión",
|
||||
"downloading": "Actualizando… {{pct}}%",
|
||||
"restart": "Reiniciar para actualizar",
|
||||
"busy": "Termina tu doblaje primero — luego instala la actualización.",
|
||||
"whats_new": "¿Qué hay de nuevo?",
|
||||
"failed": "La actualización falló",
|
||||
"retry": "Reintentar",
|
||||
"dismiss": "Descartar"
|
||||
},
|
||||
"archetypes": {
|
||||
"featured": "Destacado",
|
||||
@@ -946,5 +1577,93 @@
|
||||
"facet_accent": "acento",
|
||||
"facet_lang": "Idioma",
|
||||
"facet_whisper": "susurro"
|
||||
},
|
||||
"support": {
|
||||
"tab_support": "Soporte",
|
||||
"tab_license": "Licencia Comercial",
|
||||
"toggle_label": "Soporte o licencia comercial",
|
||||
"other_ways": "Otras formas de ayudar",
|
||||
"star_github": "Estrella en GitHub",
|
||||
"join_discord": "Únete a la discordia"
|
||||
},
|
||||
"updates": {
|
||||
"tab": "Actualizaciones",
|
||||
"up_to_date": "Actualizado · v{{version}}",
|
||||
"check_now": "Compruébalo ahora",
|
||||
"releases": "Lanzamientos",
|
||||
"current": "actual",
|
||||
"prerelease": "vista previa",
|
||||
"loading": "Cargando lanzamientos…",
|
||||
"none": "No se encontraron lanzamientos",
|
||||
"load_error": "No se pudieron cargar las versiones (¿sin conexión?)",
|
||||
"retry_load": "Reintentar"
|
||||
},
|
||||
"firstrun": {
|
||||
"loading": "Preparando la configuración…",
|
||||
"title": "Configurar OmniVoice Studio",
|
||||
"subtitle": "Aún no se ha instalado nada: revisa dónde irá cada cosa y luego comienza. Podrás cambiarlo después en Ajustes.",
|
||||
"language": "Idioma",
|
||||
"mode_title": "Modo de instalación",
|
||||
"mode_installed": "Instalado",
|
||||
"mode_installed_desc": "Usa las carpetas estándar del sistema. Recomendado para la mayoría.",
|
||||
"mode_portable": "Portátil",
|
||||
"mode_portable_desc": "Todo vive en una carpeta junto a la aplicación: muévela a otro disco o equipo como una unidad.",
|
||||
"mode_portable_unavailable": "No disponible: la carpeta junto a la aplicación no es escribible.",
|
||||
"storage_title": "Almacenamiento",
|
||||
"portable_folder": "Carpeta portátil",
|
||||
"portable_folder_desc": "Entorno, modelos y tus datos de voz: una sola carpeta, totalmente movible.",
|
||||
"env_dir": "Entorno de la aplicación",
|
||||
"env_dir_desc": "Runtime de Python y bibliotecas de IA.",
|
||||
"data_dir": "Datos de voz y proyectos",
|
||||
"data_dir_desc": "Tus voces, doblajes, salidas y la base de datos de proyectos.",
|
||||
"models_dir": "Caché de modelos",
|
||||
"models_dir_desc": "Modelos de IA descargados: la parte más grande y fácil de reubicar.",
|
||||
"needs": "necesita ~{{size}}",
|
||||
"free": "{{size}} libres",
|
||||
"checking": "comprobando…",
|
||||
"not_writable": "no escribible",
|
||||
"change": "Cambiar…",
|
||||
"compute_title": "Cómputo",
|
||||
"compute_label": "GPU / acelerador",
|
||||
"compute_auto": "Auto (NVIDIA CUDA / Apple MPS / CPU)",
|
||||
"compute_rocm": "GPU AMD (ROCm, Linux)",
|
||||
"channel_label": "Canal de actualizaciones",
|
||||
"channel_stable": "Estable",
|
||||
"channel_preview": "Preview (último main)",
|
||||
"network_title": "Red",
|
||||
"region_label": "Región de descarga",
|
||||
"mirrors_title": "Mirrors personalizados (avanzado)",
|
||||
"mirror_pypi": "URL del índice PyPI",
|
||||
"mirror_hf": "Endpoint de Hugging Face",
|
||||
"mirror_python": "Mirror de descargas de Python",
|
||||
"insufficient_space": "Espacio insuficiente: esta configuración necesita ~{{need}} en un mismo disco y solo hay {{free}} disponibles. Elige otra ubicación.",
|
||||
"blocked_not_writable": "Una carpeta elegida no es escribible: elige otra ubicación.",
|
||||
"total_required": "Espacio total necesario: ~{{size}} (descarga única en el primer uso)",
|
||||
"start": "Iniciar instalación",
|
||||
"starting": "Iniciando…",
|
||||
"compute_detected": "Detectado",
|
||||
"compute_match": "coincide con este equipo",
|
||||
"compute_auto_desc": "Elige el mejor backend de este equipo en tiempo de ejecución: CUDA en NVIDIA, MPS en Apple Silicon, CPU en otro caso.",
|
||||
"compute_rocm_desc": "Instala las wheels ROCm de PyTorch para tarjetas AMD en Linux. Deja Auto si tienes dudas.",
|
||||
"channel_stable_desc": "Solo versiones probadas: las actualizaciones llegan tras la validación de la comunidad.",
|
||||
"channel_preview_desc": "Builds continuas del último main: nuevos motores y arreglos antes, con algún borde áspero ocasional.",
|
||||
"installing_title": "Instalando",
|
||||
"activity_title": "Actividad",
|
||||
"stage_setup": "Configuración",
|
||||
"stage_models": "Modelos y motores",
|
||||
"chip_required": "requerido",
|
||||
"chip_optional": "opcional",
|
||||
"chip_engine": "motor",
|
||||
"lib_download": "Descargar",
|
||||
"lib_downloading": "descargando…",
|
||||
"lib_use": "Usar",
|
||||
"lib_active": "activo",
|
||||
"lib_in_settings": "instalar luego en Ajustes",
|
||||
"lib_show_all": "Mostrar {{count}} modelos opcionales",
|
||||
"trust_line": "Todo se ejecuta y permanece en esta máquina: sin cuenta, sin nube, sin telemetría.",
|
||||
"resume_note": "Las descargas interrumpidas se reanudan solas; cerrar la app es seguro.",
|
||||
"eta_left": "quedan ~{{eta}}",
|
||||
"first_sound_text": "Bienvenido a tu estudio. Cada palabra que oyes se generó en esta máquina, ahora mismo.",
|
||||
"first_sound_done": "¿Esa voz? Generada hace segundos, en local. Bienvenido."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
{
|
||||
"update": {
|
||||
"available": "Mise à jour {{version}} disponible",
|
||||
"install": "Installer et redémarrer",
|
||||
"install_hint": "Télécharger la mise à jour et redémarrer dans la nouvelle version",
|
||||
"downloading": "Mise à jour… {{pct}} %",
|
||||
"restart": "Redémarrer pour mettre à jour",
|
||||
"busy": "Terminez d'abord votre doublage, puis installez la mise à jour."
|
||||
},
|
||||
"nav": {
|
||||
"clone": "Cloner",
|
||||
"design": "Concevoir",
|
||||
@@ -16,7 +8,10 @@
|
||||
"launchpad": "Barre de lancement",
|
||||
"gallery": "Galerie",
|
||||
"transcripts": "Transcriptions",
|
||||
"omnidrive": "OmniDrive"
|
||||
"omnidrive": "OmniDrive",
|
||||
"move_rail_right": "Déplacer le rail vers la droite",
|
||||
"move_rail_left": "Déplacer le rail vers la gauche",
|
||||
"flip_rail": "Côté rail rabattable"
|
||||
},
|
||||
"settings": {
|
||||
"general": "Général",
|
||||
@@ -45,7 +40,40 @@
|
||||
"ffmpeg_missing": "Pas trouvé",
|
||||
"ffmpeg_current": "Chemin actuel",
|
||||
"ffmpeg_desc": "Définissez un chemin ffmpeg personnalisé si la détection automatique échoue.",
|
||||
"ffmpeg_saved": "Ensemble de chemins FFmpeg – redémarrez le backend pour appliquer."
|
||||
"ffmpeg_saved": "Ensemble de chemins FFmpeg – redémarrez le backend pour appliquer.",
|
||||
"diagnostics_copied": "Diagnostics copiés : collez-les dans votre rapport de problème.",
|
||||
"updater_desktop": "Le programme de mise à jour ne s'exécute que dans l'application de bureau.",
|
||||
"latest_version": "Vous êtes sur la dernière version.",
|
||||
"logs_load_failed": "Échec du chargement des journaux : {{message}}",
|
||||
"clear_frontend_confirm": "Effacer le tampon du journal frontal en mémoire ?",
|
||||
"clear_frontend_title": "Effacer les journaux",
|
||||
"frontend_logs_cleared": "Journaux frontend effacés",
|
||||
"clear_tauri_confirm": "Tronquer les fichiers journaux côté Tauri ? Le système d'exploitation continuera à écrire de nouvelles entrées.",
|
||||
"clear_tauri_title": "Effacer les journaux Tauri",
|
||||
"nothing_to_clear": "Rien à effacer – pas encore de fichier journal Tauri sur le disque.",
|
||||
"cleared_tauri_one": "Fichier journal {{count}} Tauri effacé",
|
||||
"cleared_tauri_other": "Fichiers journaux {{count}} Tauri effacés",
|
||||
"clear_tauri_failed": "Échec de la suppression des journaux Tauri : {{message}}",
|
||||
"clear_backend_confirm": "Effacer le runtime backend + les journaux de crash ? Cela ne peut pas être annulé.",
|
||||
"clear_backend_title": "Effacer les journaux",
|
||||
"backend_logs_cleared": "Journaux back-end effacés",
|
||||
"clear_backend_failed": "Échec de la suppression des journaux",
|
||||
"copy_failed": "Échec de la copie : {{message}}",
|
||||
"update_check_failed": "Échec de la vérification de la mise à jour : {{message}}",
|
||||
"save_failed": "Échec de l'enregistrement : {{message}}",
|
||||
"clear_failed": "Échec de la suppression : {{message}}",
|
||||
"engine_switched": "{{family}} → {{engine}}",
|
||||
"channel_set_failed": "Échec de la définition du canal : {{message}}",
|
||||
"updater_downloading": "Téléchargement de {{version}}…",
|
||||
"updater_installed": "Installé — relance.",
|
||||
"updater_available_title": "Mise à jour disponible",
|
||||
"updater_available_body": "La version {{version}} est disponible.\n\n{{notes}}\n\nTélécharger et installer maintenant ?",
|
||||
"updater_notes_fallback": "Voir les notes de version sur GitHub.",
|
||||
"shortcut_load_failed": "Impossible de charger le raccourci : {{message}}",
|
||||
"shortcut_set": "Raccourci de dictée défini sur {{shortcut}}",
|
||||
"shortcut_register_failed": "Impossible de s'inscrire : {{message}}",
|
||||
"shortcut_reset": "Réinitialiser aux valeurs par défaut",
|
||||
"shortcut_reset_failed": "Échec de la réinitialisation : {{message}}"
|
||||
},
|
||||
"bootstrap": {
|
||||
"title": "OmniVoice Studio",
|
||||
@@ -70,7 +98,13 @@
|
||||
"suggest_lang": "Passer au Français ?",
|
||||
"select_lang": "Langue :",
|
||||
"lines_one": "Ligne {{count}}",
|
||||
"lines_other": "{{count}} lignes"
|
||||
"lines_other": "{{count}} lignes",
|
||||
"region_global": "Mondial (direct)",
|
||||
"region_china": "Chine (miroir)",
|
||||
"region_russia": "Russie (miroir)",
|
||||
"region_restricted": "Restreint (miroir)",
|
||||
"unknown_error": "Erreur inconnue",
|
||||
"retrying": "Nouvelle tentative…"
|
||||
},
|
||||
"stories": {
|
||||
"title": "Éditeur d'histoires",
|
||||
@@ -159,7 +193,17 @@
|
||||
"reload": "Forcer le rechargement de l'interface utilisateur",
|
||||
"backend": "Back-end",
|
||||
"frontend": "Front-end",
|
||||
"tauri": "Taureau"
|
||||
"tauri": "Taureau",
|
||||
"cancelOp": "Annuler l'opération",
|
||||
"dismiss": "Rejeter",
|
||||
"dismissStatus": "Statut de rejet",
|
||||
"search": "Rechercher…",
|
||||
"no_matches": "Aucune correspondance",
|
||||
"recent_and_popular": "Récents et populaires",
|
||||
"popular_label": "Populaire",
|
||||
"showing_of": "Montrant {{shown}} sur {{total}}. Tapez pour rechercher…",
|
||||
"yes": "Oui",
|
||||
"no": "Non"
|
||||
},
|
||||
"launchpad": {
|
||||
"greeting": "bonjour",
|
||||
@@ -181,7 +225,16 @@
|
||||
"locked": "VERROUILLÉ",
|
||||
"open": "Ouvert",
|
||||
"try_it": "Essayez-le",
|
||||
"audio_only": "Audio uniquement"
|
||||
"audio_only": "Audio uniquement",
|
||||
"stories_title": "Histoires",
|
||||
"stories_desc": "Livres audio multi-voix : lancez vos personnages, insérez un script, exportez par chapitre.",
|
||||
"gallery_title": "Galerie vocale",
|
||||
"gallery_desc": "Parcourez les voix prêtes à l’emploi par accent, âge et style – aucune configuration.",
|
||||
"transcripts_title": "Transcriptions",
|
||||
"transcripts_desc": "Transformez l'audio ou la vidéo en texte modifiable et consultable, dans 646 langues.",
|
||||
"recent_files": "Fichiers récents",
|
||||
"view_all_files": "Afficher tous les fichiers",
|
||||
"file": "Fichier"
|
||||
},
|
||||
"clone": {
|
||||
"prompt": "Invite",
|
||||
@@ -272,11 +325,6 @@
|
||||
"outputs": "Sorties",
|
||||
"crash_log": "Journal des incidents",
|
||||
"update_endpoint": "Mettre à jour le point de terminaison",
|
||||
"update_channel": "Canal de mise à jour",
|
||||
"channel_stable": "Stable",
|
||||
"channel_preview": "Aperçu",
|
||||
"channel_set": "Canal de mise à jour réglé sur {{channel}}",
|
||||
"channel_preview_hint": "L'aperçu suit la dernière version de main : fonctionnalités plus récentes, moins testées. Revient à la version stable si une version stable est plus avancée.",
|
||||
"yes": "oui",
|
||||
"no": "non",
|
||||
"web_preview": "aperçu Web",
|
||||
@@ -285,7 +333,12 @@
|
||||
"copy_diagnostics": "Copier les diagnostics",
|
||||
"github": "OmniVoice sur GitHub",
|
||||
"model_card": "Carte modèle",
|
||||
"commercial_license": "Licence commerciale"
|
||||
"commercial_license": "Licence commerciale",
|
||||
"update_channel": "Canal de mise à jour",
|
||||
"channel_stable": "Stable",
|
||||
"channel_preview": "Aperçu",
|
||||
"channel_set": "Canal de mise à jour réglé sur {{channel}}",
|
||||
"channel_preview_hint": "L'aperçu suit la dernière version de main : fonctionnalités plus récentes, moins testées. Revient à la version stable si une version stable est plus avancée."
|
||||
},
|
||||
"privacy": {
|
||||
"desc": "Tout fonctionne sur <1>cette machine</1>. Votre audio, vidéo et transcriptions ne quittent jamais votre ordinateur, sauf si vous utilisez explicitement un traducteur en ligne (Google, DeepL, etc.) ou si vous poussez vers HuggingFace.",
|
||||
@@ -341,7 +394,30 @@
|
||||
"unavailable": "indisponible",
|
||||
"use": "Utiliser",
|
||||
"loading": "Chargement des moteurs…",
|
||||
"refresh": "Actualiser"
|
||||
"refresh": "Actualiser",
|
||||
"matrixTitle": "Matrice de compatibilité des moteurs",
|
||||
"loadFailed": "Échec du chargement des moteurs : {{message}}",
|
||||
"couldNotLoad": "Impossible de charger les moteurs : {{message}}",
|
||||
"retry": "Réessayer",
|
||||
"activeEngine": "Actif {{family}} : {{engine}}",
|
||||
"engineCompatLabel": "Compatibilité moteur {{family}}",
|
||||
"active": "actif",
|
||||
"whyUnavailable": "Pourquoi indisponible ?",
|
||||
"lastError": "Dernière erreur : {{error}}",
|
||||
"installedAndReady": "Installé et prêt",
|
||||
"notInstalled": "Non installé",
|
||||
"available": "Disponible",
|
||||
"subprocessTitle": "S'exécute dans son propre sous-processus + venv",
|
||||
"inProcessTitle": "S'exécute dans le processus OmniVoice Python",
|
||||
"testEngine": "Moteur de test",
|
||||
"testing": "Test…",
|
||||
"recheck": "Revérifier",
|
||||
"rechecking": "Revérification…",
|
||||
"latencyMs": "{{ms}} ms",
|
||||
"failed": "échoué",
|
||||
"acceptLicense": "Accepter la licence",
|
||||
"noBackends": "Aucun backend enregistré.",
|
||||
"switch_failed": "Impossible de changer de moteur"
|
||||
},
|
||||
"capture": {
|
||||
"desc": "Les raccourcis clavier globaux ne fonctionnent que dans l'application de bureau. L'interface utilisateur Web utilise un raccourci <1>Ctrl+Shift+Espace</1> sur la page lorsque la fenêtre a le focus.",
|
||||
@@ -353,13 +429,55 @@
|
||||
"record_shortcut": "Raccourci d'enregistrement",
|
||||
"recording": "Enregistrement…",
|
||||
"save": "Enregistrer",
|
||||
"reset_default": "Réinitialiser aux valeurs par défaut"
|
||||
"reset_default": "Réinitialiser aux valeurs par défaut",
|
||||
"listening_label": "A l'écoute…",
|
||||
"transcribing_label": "Transcription…",
|
||||
"pasted": "Collé",
|
||||
"no_speech": "Aucune parole détectée",
|
||||
"mic_denied": "Accès au micro refusé",
|
||||
"mic_denied_toast": "Accès au microphone refusé. {{hint}}",
|
||||
"mic_hint_mac": "macOS : ouvrez Paramètres système → Confidentialité et sécurité → Microphone et activez OmniVoice.",
|
||||
"mic_hint_windows": "Windows : ouvrez Paramètres → Confidentialité et sécurité → Microphone et autorisez OmniVoice.",
|
||||
"mic_hint_linux": "Linux : vérifiez que votre utilisateur est dans le groupe audio et que WebView a accès au micro.",
|
||||
"transcription_failed": "Échec de la transcription : {{message}}"
|
||||
},
|
||||
"logs": {
|
||||
"no_tauri_log": "Pas encore de connexion Tauri sur le disque - lancez-le via la version de bureau pour en produire un",
|
||||
"empty_frontend": "Aucune entrée de console frontale capturée pour l'instant. Interagissez avec l'application : chaque console.* apparaîtra ici.",
|
||||
"empty_tauri": "Aucun journal Tauri disponible. Fonctionne uniquement dans le shell du bureau.",
|
||||
"empty_backend": "Le journal d'exécution est vide. L'activité apparaîtra ici au fur et à mesure que le backend l'enregistrera."
|
||||
"empty_backend": "Le journal d'exécution est vide. L'activité apparaîtra ici au fur et à mesure que le backend l'enregistrera.",
|
||||
"title": "Journaux",
|
||||
"source_backend": "Back-end",
|
||||
"source_frontend": "Front-end",
|
||||
"source_tauri": "Taureau",
|
||||
"expand": "Développer les journaux",
|
||||
"collapse": "Réduire les journaux",
|
||||
"expand_aria": "Développer le panneau des journaux",
|
||||
"collapse_aria": "Réduire le panneau des journaux",
|
||||
"drag_resize": "Faites glisser pour redimensionner",
|
||||
"refresh": "Actualiser",
|
||||
"refresh_aria": "Actualiser les journaux",
|
||||
"copy_visible": "Copier le journal visible",
|
||||
"copy_visible_aria": "Copier le journal visible",
|
||||
"clear": "Effacer",
|
||||
"clear_aria": "Effacer le journal",
|
||||
"report_issue": "Signaler un problème (copie de diagnostic)",
|
||||
"report_issue_aria": "Signaler un problème",
|
||||
"close": "Fermer",
|
||||
"close_aria": "Fermer le panneau des journaux",
|
||||
"join_discord": "Rejoignez notre Discord",
|
||||
"join_discord_aria": "Rejoignez notre communauté Discord",
|
||||
"support_project": "Soutenez ce projet",
|
||||
"support_project_aria": "Soutenez ce projet",
|
||||
"empty_frontend_short": "Pas encore de sortie de console frontale.",
|
||||
"empty_lines": "Aucune ligne.",
|
||||
"all_clear": "✅ Tout est clair – aucun problème détecté",
|
||||
"log_cleared": "Journal {{source}} effacé",
|
||||
"clear_failed": "Échec de la suppression : {{message}}",
|
||||
"log_copied": "Journal {{source}} copié",
|
||||
"copy_failed": "Échec de la copie : {{message}}",
|
||||
"report_copied": "Rapport de diagnostic copié : collez-le dans un problème GitHub.",
|
||||
"report_failed": "Échec du rapport : {{message}}"
|
||||
},
|
||||
"voice_profile": {
|
||||
"test_text": "Bonjour, c'est un test de cette voix.",
|
||||
@@ -527,7 +645,17 @@
|
||||
"install_already": "{{engine}} était déjà installé",
|
||||
"install_ok": "{{engine}} installé",
|
||||
"install_failed": "Échec de l'installation : {{message}}",
|
||||
"prep_elapsed": "{{time}} écoulé"
|
||||
"prep_elapsed": "{{time}} écoulé",
|
||||
"add_language": "Ajouter une langue",
|
||||
"search_languages": "Rechercher des langues…",
|
||||
"languages_selected_one": "{{count}} langue sélectionnée",
|
||||
"languages_selected_other": "{{count}} langues sélectionnées",
|
||||
"more_to_narrow": "+{{count}} plus — tapez pour affiner",
|
||||
"no_matches": "Aucune correspondance",
|
||||
"diagnostic_copied": "Diagnostic copié",
|
||||
"copy_failed": "Échec de la copie",
|
||||
"open_docs": "Ouvrir des documents",
|
||||
"copy_diagnostic": "Copier le diagnostic"
|
||||
},
|
||||
"glossary": {
|
||||
"title": "Glossaire",
|
||||
@@ -602,7 +730,17 @@
|
||||
"more_actions_title": "Plus de propositions",
|
||||
"speaker_pick": "Choisissez…",
|
||||
"speaker_title_detected": "Haut-parleur : choisissez parmi ceux détectés ou saisissez un nom personnalisé",
|
||||
"speaker_title_custom": "Haut-parleur : saisissez un nom (aucun clone de diarisation détecté)"
|
||||
"speaker_title_custom": "Haut-parleur : saisissez un nom (aucun clone de diarisation détecté)",
|
||||
"time_edit_title": "Cliquez pour modifier l'heure de début (m:ss.s). Entrez pour valider, Esc pour annuler.",
|
||||
"fit_fits": "Convient",
|
||||
"fit_fits_title": "L'audio à débit naturel s'adapte à l'intérieur de la fente.",
|
||||
"fit_overflows": "Débordements +{{seconds}}s",
|
||||
"fit_overflows_title": "Le texte traduit était plus long que l'emplacement d'origine de {{seconds}}s. L’audio était dur ; raccourcissez le texte ou réglez le timing sur « Étirer la vidéo ».",
|
||||
"fit_stretched": "Vidéo {{ratio}}×",
|
||||
"fit_stretched_title": "Mode vidéo extensible : la vidéo de ce segment a été ralentie à {{ratio}}× pour s'adapter à l'audio naturel du doublage.",
|
||||
"fit_compressed_title": "L'audio TTS représente {{pct}}% de l'emplacement — fortement compressé.",
|
||||
"fit_audio_title": "L'audio s'adapte à l'intérieur de la fente.",
|
||||
"fit_ratio_title": "L'audio TTS représente {{pct}}% de l'emplacement."
|
||||
},
|
||||
"voice": {
|
||||
"personality": "Personnalité",
|
||||
@@ -697,10 +835,24 @@
|
||||
"status_running": "courir",
|
||||
"status_done": "fait",
|
||||
"status_failed": "échoué",
|
||||
"status_cancelled": "annulé"
|
||||
"status_cancelled": "annulé",
|
||||
"add_to_queue_title": "Ajouter des vidéos à la file d'attente",
|
||||
"drop_hint_text": "Déposez les fichiers vidéo ici ou cliquez pour parcourir",
|
||||
"drop_formats": "MP4 · MOV · MKV · WEBM",
|
||||
"files_kicker": "FICHIERS ({{count}})",
|
||||
"file_size_mb": "{{size}} Mo",
|
||||
"target_languages": "LANGUES CIBLES",
|
||||
"voice_kicker": "VOIX",
|
||||
"default_option": "Par défaut",
|
||||
"clone_profiles": "Cloner des profils",
|
||||
"presets": "Préréglages",
|
||||
"preserve_bg": "Préserver l'audio de fond (musique/FX)",
|
||||
"estimate": "{{videos}} vidéo(s) × {{langs}} langue(s) = {{jobs}} travail(s)",
|
||||
"select_files_langs": "Sélectionnez les fichiers et les langues",
|
||||
"add_to_queue": "Ajouter à la file d'attente"
|
||||
},
|
||||
"gallery": {
|
||||
"title": "Galerie",
|
||||
"title": "OmniVoice Galerie",
|
||||
"search_placeholder": "Rechercher sur YouTube…",
|
||||
"all_voices": "Toutes les voix ({{count}})",
|
||||
"no_voices": "Pas encore de voix",
|
||||
@@ -715,6 +867,14 @@
|
||||
"youtube_results": "Résultats YouTube ({{count}})",
|
||||
"clone_profile": "Cloner le profil",
|
||||
"crop_audio": "Recadrer l'audio",
|
||||
"cat_disney": "Disney",
|
||||
"cat_anime": "Anime",
|
||||
"cat_marvel": "Marvel/DC",
|
||||
"cat_celebs": "Célébrités",
|
||||
"cat_politicians": "Politiciens",
|
||||
"cat_news": "Présentateurs de nouvelles",
|
||||
"cat_gaming": "Jeux",
|
||||
"cat_books": "Livres/Films",
|
||||
"subtitle": "Des centaines de voix prêtes à l'emploi : choisissez-en une et c'est parti.",
|
||||
"zone_archetypes": "Archétypes",
|
||||
"zone_imports": "Mes importations",
|
||||
@@ -818,8 +978,7 @@
|
||||
"back": "Retour à l'Atelier",
|
||||
"badge": "Licence commerciale",
|
||||
"hero_title": "Expédier les voix de l'IA en production",
|
||||
"hero_desc": "OmniVoice Studio est disponible sous la licence Functional Source (FSL). La plupart des utilisateurs peuvent évaluer, prototyper et même déployer en interne sans accord commercial. Vous n’avez besoin d’une licence commerciale que si vous créez un produit ou un service concurrent, ou si votre cas d’utilisation dépasse les limites du FSL.",
|
||||
"hero_note": "La création d'un produit ou d'un service concurrent ou son déploiement à grande échelle (par exemple, la fourniture d'une API payante à l'utilisation) nécessite une licence commerciale. Niveaux de tarification à venir – contactez-nous en attendant.",
|
||||
"hero_desc": "OmniVoice Studio est un logiciel libre et open source sous licence GNU Affero General Public License v3 (AGPL-3.0) — gratuit pour tout usage, y compris commercial et interne en entreprise. Une licence commerciale n'est nécessaire que si vous souhaitez intégrer OmniVoice Studio dans un produit ou service propriétaire ou à code fermé, sans les obligations copyleft de l'AGPL-3.0.",
|
||||
"why_title": "Pourquoi les entreprises choisissent OmniVoice",
|
||||
"pricing_title": "Tarifs",
|
||||
"faq_title": "Questions courantes",
|
||||
@@ -839,7 +998,8 @@
|
||||
"benefit_source": "Noyau disponible à la source",
|
||||
"benefit_source_desc": "Visibilité totale sur la pile. Auditez, forkez et adaptez selon les termes de la licence.",
|
||||
"benefit_lang": "646 langues",
|
||||
"benefit_lang_desc": "Transcrivez, traduisez et doublez dans 646 langues avec une qualité humaine."
|
||||
"benefit_lang_desc": "Transcrivez, traduisez et doublez dans 646 langues avec une qualité humaine.",
|
||||
"hero_note": "L'utilisation, l'auto-hébergement et l'usage commercial sont gratuits sous AGPL-3.0 — y compris à grande échelle. L'AGPL est une licence copyleft réseau : si vous modifiez OmniVoice et proposez cette version modifiée à des tiers via un réseau, vous devez partager votre code source modifié aux mêmes conditions. Une licence commerciale lève ces obligations copyleft pour les déploiements propriétaires à code fermé. Les tarifs arrivent bientôt — contactez-nous d'ici là."
|
||||
},
|
||||
"exportModal": {
|
||||
"export": "Exporter",
|
||||
@@ -928,7 +1088,478 @@
|
||||
"dubbing_title": "Voir le doublage en action",
|
||||
"dubbing_sync": "Lecture synchronisée",
|
||||
"dubbing_picker": "Essayez une autre langue :",
|
||||
"dubbing_cta": "Exécutez ceci sur votre propre vidéo →"
|
||||
"dubbing_cta": "Exécutez ceci sur votre propre vidéo →",
|
||||
"dubbing_loading": "Chargement de la démo de doublage…",
|
||||
"dubbing_dismiss": "Ignorer la démo de doublage",
|
||||
"original_tag": "originale",
|
||||
"dubbed_tag": "doublé",
|
||||
"script_conversational": "Conversationnel",
|
||||
"script_technical": "Vocabulaire technique",
|
||||
"script_french": "Non anglais (français)",
|
||||
"aria_pause": "Pause {{label}}",
|
||||
"aria_hear": "Écoutez {{label}}",
|
||||
"aria_replay": "Rejouer {{label}} via le transcripteur",
|
||||
"dictation_lede_hotkey_only": "Maintenez le raccourci ci-dessus n'importe où sur votre bureau, parlez, relâchez — le texte arrive dans l'application active. Appuyez maintenant pour vérifier."
|
||||
},
|
||||
"direction": {
|
||||
"title": "Direction pour le segment #{{id}}",
|
||||
"desc": "Dites au pipeline ce que devrait ressentir cette ligne. L'anglais simple fonctionne : le système mappe vos mots sur une taxonomie stable (énergie/émotion/rythme/intimité/formalité), puis enchaîne la taxonomie via la traduction cinématographique, le TTS et l'ajustement par fente.",
|
||||
"label": "Direction",
|
||||
"lineHint": "Ligne : \"{{text}}\"",
|
||||
"placeholder": "par ex. urgent et surpris / chaleureux, plein d'espoir / murmuré, intime",
|
||||
"previewParse": "Aperçu de l'analyse",
|
||||
"previewFailed": "Échec de l'aperçu : {{message}}",
|
||||
"clear": "Effacer",
|
||||
"cancel": "Annuler",
|
||||
"saveDirection": "Enregistrer la direction",
|
||||
"ttsInstruct": "Instruire TTS :",
|
||||
"nothingParsed": "— (rien d'analysé)",
|
||||
"translateHint": "Indice de traduction :",
|
||||
"rateBias": "Biais de taux :",
|
||||
"speedsUp": "accélère",
|
||||
"slowsDown": "ralentit",
|
||||
"taxonomyTokens": "jetons de taxonomie"
|
||||
},
|
||||
"errors": {
|
||||
"title": "Cet onglet a rencontré un problème.",
|
||||
"desc": "Ne vous inquiétez pas, le reste de l'application fonctionne toujours. Vous pouvez changer d'onglet ou réessayer ci-dessous.",
|
||||
"tryAgain": "Réessayez",
|
||||
"openDocs": "Ouvrir la documentation pour cette erreur"
|
||||
},
|
||||
"keyboard": {
|
||||
"title": "Raccourcis clavier",
|
||||
"footer": "Appuyez sur <1>?</1> à tout moment pour l'ouvrir.",
|
||||
"or": "ou",
|
||||
"nav": "Navigation",
|
||||
"nav_cheatsheet": "Afficher cette aide-mémoire",
|
||||
"nav_closeModal": "Fermer modal / annuler",
|
||||
"nav_save": "Enregistrer le projet/commettre le trim",
|
||||
"segmentEditor": "Editeur de segments",
|
||||
"seg_split": "Diviser le segment au niveau du curseur",
|
||||
"seg_merge": "Fusionner avec le segment suivant",
|
||||
"seg_undo": "Annuler",
|
||||
"seg_redo": "Refaire",
|
||||
"seg_click": "Action primaire",
|
||||
"seg_shiftClick": "Sélection de plage",
|
||||
"trimmer": "Tondeuse audio",
|
||||
"trim_playPause": "Aperçu de la lecture/pause",
|
||||
"trim_nudgeStart": "Poignée de démarrage par poussée",
|
||||
"trim_nudgeEnd": "Poignée d'extrémité poussée",
|
||||
"trim_fineNudge": "Bon coup de pouce",
|
||||
"trim_coarseNudge": "Coup de pouce grossier",
|
||||
"trim_zoomIn": "Zoom avant/arrière",
|
||||
"trim_fitAll": "Tout adapter/Sélection d'ajustement",
|
||||
"trim_confirm": "Confirmer la coupe",
|
||||
"dub": "Doublage",
|
||||
"dub_generate": "Générer un doublage",
|
||||
"dub_sidebar": "Basculer la barre latérale"
|
||||
},
|
||||
"network": {
|
||||
"sharing_on_title": "Partage sur — cliquez pour plus de détails",
|
||||
"share_on_network": "Partagez sur votre réseau",
|
||||
"switching": "Commutation…",
|
||||
"network": "Réseau",
|
||||
"local": "Locale",
|
||||
"share_confirm_title": "Partager sur votre réseau ?",
|
||||
"share_confirm_hint": "Les autres appareils connectés à votre réseau Wi-Fi/Ethernet pourront accéder à OmniVoice en utilisant le code PIN d'accès affiché une fois allumé.",
|
||||
"enabling": "Activation…",
|
||||
"enable": "Activer",
|
||||
"shared_title": "Partagé sur votre réseau",
|
||||
"no_interface": "Aucune interface réseau accessible – connectez-vous au Wi-Fi/Ethernet.",
|
||||
"copy_link": "Copier le lien",
|
||||
"open_in_browser": "Ouvrir dans le navigateur",
|
||||
"qr_alt": "QR pour {{ip}}",
|
||||
"pin": "NIP :",
|
||||
"stop_sharing": "Arrêter de partager",
|
||||
"copied": "Copié",
|
||||
"enable_error": "Impossible d'activer le partage : {{message}}",
|
||||
"disable_error": "Impossible de désactiver : {{message}}"
|
||||
},
|
||||
"readiness": {
|
||||
"checking_system": "Système de vérification…",
|
||||
"all_ready": "Tous les systèmes sont prêts",
|
||||
"system_readiness": "État de préparation du système",
|
||||
"asr_model": "Modèle ASR",
|
||||
"loaded_ready": "Chargé et prêt",
|
||||
"loading_first_run": "Chargement… (cela peut prendre 1 à 2 minutes lors de la première exécution)",
|
||||
"failed_to_load": "Échec du chargement",
|
||||
"not_loaded_yet": "Pas encore chargé – se chargera lors de la première transcription",
|
||||
"error_check_logs": "Erreur : {{error}}. Vérifiez les journaux et essayez de redémarrer.",
|
||||
"check_logs_restart": "Vérifiez les journaux pour détecter les erreurs de chargement du modèle. Essayez de redémarrer.",
|
||||
"llm_cinematic": "LLM (Cinématique)",
|
||||
"llm_configure": "Configurer TRANSLATE_BASE_URL pour la qualité de traduction cinématographique",
|
||||
"llm_set_env": "Définissez les variables d'environnement TRANSLATE_BASE_URL et TRANSLATE_API_KEY. Fonctionne avec Ollama, OpenAI, LM Studio, etc.",
|
||||
"llm_optional": "Facultatif : définissez TRANSLATE_BASE_URL pour la qualité cinématographique"
|
||||
},
|
||||
"license": {
|
||||
"title": "Supertonic-3 — Acceptation de la licence",
|
||||
"intro": "Supertonic-3 est expédié sous deux licences distinctes. Veuillez vérifier les deux avant d'activer le moteur.",
|
||||
"sdk_heading": "Code SDK · MIT",
|
||||
"sdk_desc": "Le SDK d'inférence Python (supertonic) est sous licence MIT. Utilisation permissive, y compris commerciale.",
|
||||
"read_mit": "Lire la licence MIT →",
|
||||
"model_heading": "Poids des modèles · OpenRAIL-M",
|
||||
"model_desc": "Les poids du modèle Supertonic-3 sont publiés sous la licence OpenRAIL-M. Cette licence restreint l'utilisation à des fins non malveillantes — consultez la licence liée pour l'ensemble complet des restrictions basées sur l'utilisation.",
|
||||
"read_openrail": "Lire la licence OpenRAIL-M →",
|
||||
"footer": "Cliquer sur Accepter enregistre votre acceptation dans les paramètres locaux d'OmniVoice et active le moteur. Votre acceptation est stockée sur cette machine uniquement — rien n'est signalé à Supertone Inc. ou à un tiers.",
|
||||
"saving": "Sauvegarde…",
|
||||
"accept": "Accepter",
|
||||
"accepted_toast": "Licence Supertonic-3 acceptée.",
|
||||
"accept_error": "Échec de l'enregistrement de l'acceptation de la licence : {{message}}"
|
||||
},
|
||||
"voicePreview": {
|
||||
"title": "Aperçu vocal",
|
||||
"close": "Fermer l'aperçu",
|
||||
"default_text": "Bonjour ! Ceci est un aperçu de la façon dont je sonne avec cette voix.",
|
||||
"default_voice": "Voix par défaut",
|
||||
"clone_profiles": "Cloner des profils",
|
||||
"designed_voices": "Voix conçues",
|
||||
"presets": "Préréglages",
|
||||
"placeholder": "Tapez quelque chose à entendre…",
|
||||
"stop": "Arrêter",
|
||||
"regenerate": "Régénérer",
|
||||
"preview": "Aperçu",
|
||||
"hint": "8 étapes · aperçu rapide"
|
||||
},
|
||||
"header": {
|
||||
"kicker_studio": "Atelier",
|
||||
"kicker_library": "Bibliothèque",
|
||||
"kicker_preferences": "Préférences",
|
||||
"kicker_licensing": "Licence",
|
||||
"label_launchpad": "Barre de lancement",
|
||||
"label_clone": "Clonage vocal",
|
||||
"label_design": "Conception vocale",
|
||||
"label_dub": "Doublage",
|
||||
"label_projects": "OmniDrive",
|
||||
"label_gallery": "Galerie",
|
||||
"label_transcriptions": "Transcriptions",
|
||||
"label_settings": "Paramètres",
|
||||
"label_enterprise": "Licence commerciale",
|
||||
"status_ready": "Prêt",
|
||||
"status_loading": "Chargement…",
|
||||
"status_idle": "Inactif",
|
||||
"memory_management": "Gestion de la mémoire",
|
||||
"flush": "Rincer",
|
||||
"loaded_models": "Modèles chargés",
|
||||
"no_models": "Aucun modèle chargé",
|
||||
"unload": "Décharger",
|
||||
"flush_caches": "Vider les caches",
|
||||
"unload_all_flush": "Tout décharger + rincer"
|
||||
},
|
||||
"sidebar": {
|
||||
"tab_drive": "Conduire",
|
||||
"tab_history": "Histoire",
|
||||
"tab_exports": "Exportations",
|
||||
"save_project": "Enregistrer le projet de doublage",
|
||||
"save_new_project": "Enregistrer en tant que nouveau projet de doublage",
|
||||
"dub_projects": "Projets de doublage",
|
||||
"voice_clones": "Clones vocaux",
|
||||
"designed_voices": "Voix conçues",
|
||||
"no_dub_projects": "Aucun projet de doublage enregistré",
|
||||
"no_dub_hint": "Téléchargez une vidéo et cliquez sur Enregistrer pour conserver votre travail.",
|
||||
"no_clones": "Pas encore de clones de voix",
|
||||
"no_clones_hint": "Enregistrez ou téléchargez de l'audio, puis cliquez sur Enregistrer en tant que profil vocal.",
|
||||
"no_designs": "Aucune voix conçue pour l'instant",
|
||||
"no_designs_hint": "Générez une voix et enregistrez-la depuis l'historique.",
|
||||
"history_subtitle": "Historique des générations · Stocké dans SQLite",
|
||||
"no_history": "Pas d'historique de génération",
|
||||
"no_history_hint": "Synthétisez de l'audio ou doublez une vidéo : les résultats apparaîtront ici.",
|
||||
"clear_history": "Effacer l'historique",
|
||||
"clear_confirm": "Effacer tous les éléments de l'historique {{count}} ? Cela ne peut pas être annulé.",
|
||||
"history_cleared": "Historique effacé",
|
||||
"recent_exports": "Exportations récentes",
|
||||
"no_exports": "Aucune sortie téléchargée",
|
||||
"no_exports_hint": "Exportez un fichier via Tauri pour le voir suivi ici.",
|
||||
"show_in_folder": "Afficher dans le dossier",
|
||||
"open": "Ouvert",
|
||||
"select": "Sélectionnez",
|
||||
"try_voice": "Essayez",
|
||||
"consistent": "cohérent",
|
||||
"locked": "Verrouillé",
|
||||
"clone_label": "Cloner",
|
||||
"design_label": "Conception",
|
||||
"dub_label": "Doublage",
|
||||
"save_label": "Enregistrer",
|
||||
"lock_identity": "Verrouiller l'identité vocale",
|
||||
"in_folder": "en {{folder}}"
|
||||
},
|
||||
"trimmer": {
|
||||
"title": "Couper l'audio de référence",
|
||||
"decoding": "Décodage audio…",
|
||||
"meta_length": "Longueur {{duration}} · {{sampleRate}} Hz",
|
||||
"meta_rendering": "rendu de la forme d'onde {{percent}}%",
|
||||
"keyboard_hint": "scroll = zoom · maj+scroll = panoramique · alt+glisser = panoramique · ⏐ ⟵ ⟶ ⏐ les touches ajustent les poignées",
|
||||
"zoom_in": "Zoomer (+)",
|
||||
"zoom_out": "Zoom arrière (-)",
|
||||
"fit_all": "Convient à tous (Accueil)",
|
||||
"fit_selection": "Ajuster la sélection (Fin)",
|
||||
"fit_sel_btn": "AJUSTEMENT SÉL.",
|
||||
"view_range": "Afficher {{start}} → {{end}} ({{duration}})",
|
||||
"start_label": "Commencer",
|
||||
"end_label": "Fin",
|
||||
"length_label": "Longueur",
|
||||
"too_long": ">{{max}}s",
|
||||
"too_short": "trop court",
|
||||
"length_ok": "ok",
|
||||
"loop_preview": "Aperçu de la boucle",
|
||||
"pause": "Pause",
|
||||
"preview_selection": "Aperçu de la sélection",
|
||||
"play_hint": "Espace pour jouer · Enter pour confirmer · Esc pour annuler",
|
||||
"cancel": "Annuler",
|
||||
"use_trimmed": "Utiliser coupé",
|
||||
"decode_failed": "Échec du décodage : {{message}}",
|
||||
"playback_failed": "Échec de la lecture : {{message}}",
|
||||
"audio_load_failed": "Échec du chargement audio",
|
||||
"unit_seconds": "s"
|
||||
},
|
||||
"casting": {
|
||||
"title": "Casting de haut-parleurs",
|
||||
"auto_assign_title": "Attribuer automatiquement les voix des clones de haut-parleurs extraits",
|
||||
"auto_cast": "Diffusion automatique",
|
||||
"all_cast": "Tous les acteurs",
|
||||
"segments_count": "{{count}} segments",
|
||||
"assign_voice": "Attribuer une voix…",
|
||||
"from_video": "🎤 À partir de la vidéo ({{name}})",
|
||||
"no_profiles": "Aucun profil vocal n'a encore été enregistré.",
|
||||
"preview_voice": "Aperçu de la voix"
|
||||
},
|
||||
"checkpoint": {
|
||||
"asr_title": "Transcriptions prêtes",
|
||||
"asr_cta": "Traduire",
|
||||
"asr_hint": "Corrigez toutes les erreurs ASR maintenant : une diction serrée enregistre les tentatives TTS plus tard.",
|
||||
"translate_title": "Traductions prêtes",
|
||||
"translate_cta": "Générer un doublage",
|
||||
"translate_hint": "Parcourez le texte cible. Les lignes trop longues sont accélérées ; vous pouvez également modifier directement.",
|
||||
"done_title": "Doublage terminé",
|
||||
"done_hint": "Examinez les ratios de synchronisation et de synchronisation. Ajustez n'importe quelle ligne et appuyez sur \"Regen changé\" pour une restauration partielle rapide.",
|
||||
"segment_one": "{{count}} segment",
|
||||
"segment_other": "{{count}} segments",
|
||||
"dismiss_title": "Ignorer - ne réapparaîtra pas pour cette étape jusqu'au rechargement"
|
||||
},
|
||||
"compare": {
|
||||
"title": "Comparaison vocale A/B",
|
||||
"close": "Comparaison étroite",
|
||||
"desc": "Comparez deux voix côte à côte pour prendre des décisions de casting. L'application reste interactive.",
|
||||
"test_phrase": "Phrase de test",
|
||||
"voice_a": "Voix A",
|
||||
"voice_b": "Voix B",
|
||||
"select_voice": "— Sélectionnez la voix —",
|
||||
"preset_suffix": "(Préréglé)",
|
||||
"no_audio": "Pas encore de son",
|
||||
"close_btn": "Fermer",
|
||||
"comparing": "En comparant…",
|
||||
"compare_btn": "Comparez",
|
||||
"preparing_voice": "Préparation de la voix...",
|
||||
"generating_voice_a": "Génération de la voix A...",
|
||||
"generating_voice_b": "Génération de la voix B...",
|
||||
"comparison_complete": "Comparaison terminée !",
|
||||
"play_failed": "Échec de la lecture : {{message}}"
|
||||
},
|
||||
"models": {
|
||||
"hf_token_set_toast": "Ensemble de jetons HuggingFace – téléchargements plus rapides activés",
|
||||
"hf_token_save_failed": "Échec de l'enregistrement du jeton",
|
||||
"install_started": "Installation démarrée – progression dans la ligne",
|
||||
"delete_confirm": "Supprimer {{repoId}} ? Vous pourrez le réinstaller plus tard.",
|
||||
"delete_confirm_title": "Supprimer le modèle",
|
||||
"deleted": "Supprimé {{repoId}}",
|
||||
"reinstall_confirm": "Réinstaller {{repoId}} ? Cela supprimera la copie actuelle et la téléchargera à nouveau.",
|
||||
"reinstall_confirm_title": "Réinstaller le modèle",
|
||||
"reinstalling": "Réinstallation",
|
||||
"recommended_installed": "Les modèles recommandés sont déjà installés.",
|
||||
"started_downloading_one": "J'ai commencé à télécharger le modèle {{count}}",
|
||||
"started_downloading_other": "J'ai commencé à télécharger les modèles {{count}}",
|
||||
"install_failed": "Échec de l'installation : {{message}}",
|
||||
"removing_cached": "Suppression des révisions mises en cache…",
|
||||
"resolving_metadata": "Résolution des métadonnées du dépôt",
|
||||
"retry_attempt": "Nouvelle tentative {{attempt}} — {{error}}",
|
||||
"connecting_hf": "Connexion à HuggingFace…",
|
||||
"resolving_files_one": "Résolution du fichier {{count}}…",
|
||||
"resolving_files_other": "Résolution des fichiers {{count}}…",
|
||||
"resolving_files_active": "Résolution de {{count}} fichier(s)… · {{file}}",
|
||||
"measuring": "mesurer…",
|
||||
"files_progress": "Fichiers {{done}}/{{total}}",
|
||||
"install_error": "Échec de l'installation : {{error}}",
|
||||
"view_on_hf": "Voir sur HuggingFace",
|
||||
"install_btn": "Installer",
|
||||
"reinstall_btn": "Réinstaller",
|
||||
"downloading": "téléchargement",
|
||||
"deleting": "suppression",
|
||||
"working": "travailler",
|
||||
"installed": "installé",
|
||||
"not_installed": "non installé",
|
||||
"required_tag": "requis",
|
||||
"delete_btn": "Supprimer",
|
||||
"hf_token_btn": "Jeton HF",
|
||||
"hf_set_title": "Définir le jeton HuggingFace pour des téléchargements plus rapides",
|
||||
"get_token": "Obtenir un jeton →",
|
||||
"reco_installed_for": "Offre groupée recommandée installée pour {{device}}",
|
||||
"reco_for": "Recommandé pour {{device}}",
|
||||
"starting": "Commencer…",
|
||||
"required_size": "Requis ~{{size}} Go",
|
||||
"all_size": "Tous ~{{size}} Go",
|
||||
"req_tag": "demande",
|
||||
"search_placeholder": "Rechercher des modèles…",
|
||||
"search_label": "Rechercher des modèles",
|
||||
"no_matches": "Aucun modèle ne correspond à vos filtres.",
|
||||
"sort_by": "Trier par {{column}}",
|
||||
"column_model": "Modèle",
|
||||
"column_role": "Rôle",
|
||||
"column_size": "Taille",
|
||||
"column_status": "Statut",
|
||||
"ready_badge": "Prêt",
|
||||
"loading_badge": "Chargement…",
|
||||
"idle_badge": "Inactif",
|
||||
"started_downloading_required_one": "Début du téléchargement du modèle requis {{count}}",
|
||||
"started_downloading_required_other": "Début du téléchargement des {{count}} modèles requis"
|
||||
},
|
||||
"enterprise_faq": {
|
||||
"q_internal_tools": "Ai-je besoin d’une licence pour les outils internes ?",
|
||||
"a_internal_tools": "Non. L'utilisation par vos employés et prestataires — y compris la modification et l'auto-hébergement en interne — est gratuite sous AGPL-3.0. Une licence commerciale n'est nécessaire que si vous intégrez OmniVoice dans un produit ou service propriétaire ou à code fermé sans vouloir respecter les obligations de partage du code source de l'AGPL.",
|
||||
"q_try_before": "Puis-je essayer avant de m'engager ?",
|
||||
"a_try_before": "Oui. L'application complète est gratuite à télécharger, exécuter et auto-héberger sous AGPL-3.0 — aucun accord requis. Quand vous serez prêt à discuter d'une licence commerciale (usage propriétaire), écrivez-nous et nous verrons les détails ensemble.",
|
||||
"q_watermark": "Et le filigrane ?",
|
||||
"a_watermark": "Le filigrane invisible AudioSeal est intégré par défaut pour tout le monde. Les titulaires de licence commerciale peuvent le désactiver dans Paramètres → Confidentialité."
|
||||
},
|
||||
"gallery_extra": {
|
||||
"save_prompt": "Saisissez un nom pour ce profil vocal :",
|
||||
"saved_as_profile": "Voix enregistrée en tant que profil !",
|
||||
"save_profile_failed": "Échec de l'enregistrement du profil",
|
||||
"download_failed": "Échec du téléchargement : {{message}}",
|
||||
"trim_load_failed": "Échec du chargement de l'audio à découper : {{message}}",
|
||||
"upload_crop_failed": "Échec de l'importation de la voix tronquée : {{message}}"
|
||||
},
|
||||
"dub_workflow": {
|
||||
"preparing_audio": "Préparation du son…",
|
||||
"preparing_video": "Préparation de la vidéo…",
|
||||
"extracting_audio_scenes": "Extraction de l'audio et des scènes…",
|
||||
"transcribing_audio": "Transcription audio…",
|
||||
"transcription_complete": "Transcription terminée",
|
||||
"upload_cancelled": "Téléchargement annulé",
|
||||
"upload_failed": "Échec du téléchargement : {{message}}",
|
||||
"downloading_video": "Téléchargement de la vidéo…",
|
||||
"ingested": "Ingéré {{url}}",
|
||||
"ingest_cancelled": "Ingestion annulée",
|
||||
"ingest_failed": "Échec de l'ingestion de l'URL : {{message}}",
|
||||
"retry_cancelled": "Nouvelle tentative annulée",
|
||||
"transcription_failed": "Échec de la transcription : {{message}}",
|
||||
"import_srt_no_job": "Téléchargez ou ingérez d’abord une vidéo – il n’y a aucune tâche à laquelle attacher des sous-titres.",
|
||||
"imported_cues": "Cues {{count}} importées de {{file}}",
|
||||
"skipped_malformed": "{{count}} ignoré (mal formé)",
|
||||
"dropped_overlap": "{{count}} supprimé (chevauchement)",
|
||||
"clamped_to_duration": "{{count}} fixé à la longueur du support",
|
||||
"srt_import_failed": "L'importation SRT a échoué",
|
||||
"cleaned_one": "Fragment {{count}} nettoyé",
|
||||
"cleaned_other": "Fragments {{count}} nettoyés",
|
||||
"segments_clean": "Segments déjà propres",
|
||||
"cleanup_failed": "Échec du nettoyage : {{message}}",
|
||||
"cinematic_no_llm": "La qualité cinématographique nécessite un LLM — définissez TRANSLATE_BASE_URL + TRANSLATE_API_KEY (Ollama fonctionne localement). Revenir à Fast.",
|
||||
"translate_errors": "Échec du ou des segments {{errorCount}}/{{totalCount}} : {{firstError}}",
|
||||
"translated_segments": "Segment(s) {{count}} traduit(s) → {{lang}}",
|
||||
"translated_cinematic_suffix": "(Cinématique)",
|
||||
"translation_failed": "Échec de la traduction : {{message}}",
|
||||
"regenerating": "Régénération de {{count}} segment(s)…",
|
||||
"generating_dub": "Génération du doublage…",
|
||||
"generating_progress": "Génération du doublage… {{current}}/{{total}}",
|
||||
"generation_aborted": "Génération avortée.",
|
||||
"dubbing_aborted": "Doublage interrompu",
|
||||
"generation_stream_ended": "Le flux de génération s'est terminé avant la fin",
|
||||
"dub_complete": "Doublage terminé",
|
||||
"stop_failed": "Impossible d'arrêter",
|
||||
"save_first": "Veuillez d'abord cliquer sur « Télécharger et transcrire » afin que la vidéo soit traitée sur le serveur avant de l'enregistrer.",
|
||||
"project_saved": "Projet enregistré",
|
||||
"project_created": "Projet créé",
|
||||
"save_failed": "Échec de l'enregistrement : {{message}}",
|
||||
"opened_project": "Ouvert : {{name}}",
|
||||
"delete_project_confirm": "Supprimer ce projet ? Cela ne peut pas être annulé.",
|
||||
"project_deleted": "Projet supprimé",
|
||||
"delete_history_confirm": "Supprimer cet élément de l'historique ?",
|
||||
"history_deleted": "Élément d'historique supprimé",
|
||||
"restored_state": "État de la génération précédente restauré",
|
||||
"upgrading_preview": "Mise à niveau de {{count}} segments en qualité d'aperçu vers une qualité complète…"
|
||||
},
|
||||
"tts_errors": {
|
||||
"enter_text": "Veuillez saisir du texte",
|
||||
"upload_or_select": "Téléchargez un audio ou sélectionnez un profil vocal",
|
||||
"trim_hint": "L'audio est de {{duration}}s — coupez à ≤{{max}}s pour un meilleur clonage",
|
||||
"timeout": "Le délai de génération a expiré : le téléchargement du modèle est peut-être encore en cours. Vérifiez Paramètres → Journaux, puis réessayez.",
|
||||
"error_prefix": "Erreur : {{message}}",
|
||||
"ignored_unsupported": "Instruction non prise en charge ignorée : {{items}}",
|
||||
"ignored_duplicate": "Ignoré (catégorie déjà définie) : {{items}}"
|
||||
},
|
||||
"sharing": {
|
||||
"title": "Partage et accès à distance",
|
||||
"help": "Exposez cette instance OmniVoice en cours d'exécution à vos autres machines sans la redémarrer. Le bouclage uniquement est la valeur par défaut : rien n'est partagé jusqu'à ce que vous l'activiez ici.",
|
||||
"local_network": "Réseau local",
|
||||
"local_help": "Partagez sur votre Wi-Fi / Ethernet avec un code PIN d'accès unique. D'autres appareils scannent le code QR ou ouvrent le lien.",
|
||||
"ports_title": "Ports",
|
||||
"ports_help": "Celles-ci sont définies via des variables d'environnement lues au démarrage. Modifiez le port backend ou UI en définissant la variable et en redémarrant OmniVoice.",
|
||||
"backend_port": "Port principal",
|
||||
"ui_port": "Port d'interface utilisateur",
|
||||
"lan_share_port": "Port de partage LAN",
|
||||
"port_error": "Entrez un port entre 1024 et 65535",
|
||||
"port_saved": "Port de partage LAN enregistré : s'appliquera la prochaine fois que vous activerez le partage",
|
||||
"port_save_failed": "Impossible d'enregistrer le port : {{message}}",
|
||||
"saving": "Sauvegarde…",
|
||||
"ports_note": "Les ports backend et UI s’appliquent au redémarrage. Le port de partage LAN s'appliquera la prochaine fois que vous activerez le partage.",
|
||||
"tailscale_title": "Tailscale (accès à distance privé)",
|
||||
"tailscale_checking": "Vérification de la queue…",
|
||||
"tailscale_absent": "Échelle arrière non détectée. Installez-le pour accéder à OmniVoice en toute sécurité depuis n'importe où sur votre réseau privé.",
|
||||
"tailscale_install": "Installer la balance arrière",
|
||||
"tailscale_running": "Tailscale est en cours d’exécution. Servez OmniVoice sur votre réseau privé.",
|
||||
"tailscale_not_logged_in": "Tailscale est installé mais n’est pas connecté. Démarrez et connectez-vous d’abord à Tailscale.",
|
||||
"tailscale_enabled": "Service Tailscale activé",
|
||||
"tailscale_enable_failed": "Impossible d'activer Tailscale",
|
||||
"tailscale_enable_error": "Impossible d'activer Tailscale : {{message}}",
|
||||
"tailscale_disabled": "Service Tailscale désactivé",
|
||||
"tailscale_disable_failed": "Impossible de désactiver Tailscale",
|
||||
"tailscale_disable_error": "Impossible de désactiver Tailscale : {{message}}",
|
||||
"tailscale_enabling": "Activation…",
|
||||
"tailscale_enable_btn": "Activer le service Tailscale",
|
||||
"tailscale_disabling": "Désactivation…",
|
||||
"tailscale_disable_btn": "Arrêter le service Tailscale",
|
||||
"tailscale_copy": "Copier le lien",
|
||||
"tailscale_open": "Ouvrir dans le navigateur",
|
||||
"copied": "Copié",
|
||||
"tailscale_qr_alt": "Code QR pour l'URL Tailscale"
|
||||
},
|
||||
"reportBug": {
|
||||
"label": "Signaler un bug",
|
||||
"title": "Ouvre une page de problèmes GitHub pré-remplie dans votre navigateur. Rien n'est envoyé jusqu'à ce que vous cliquiez sur Soumettre."
|
||||
},
|
||||
"app": {
|
||||
"loading": "Chargement…",
|
||||
"trimmed_loaded": "Audio découpé chargé",
|
||||
"toast_exported": "Exporté : {{name}}",
|
||||
"toast_export_failed": "Échec de l'exportation : {{message}}",
|
||||
"toast_open_folder_failed": "Impossible d'ouvrir le dossier : {{message}}",
|
||||
"toast_saving": "Sauvegarde de {{name}}...",
|
||||
"toast_saved": "Enregistré : {{path}}",
|
||||
"toast_save_error": "Erreur d'enregistrement : {{message}}",
|
||||
"toast_processing": "Traitement {{name}}...",
|
||||
"toast_downloaded": "Téléchargé {{name}}",
|
||||
"toast_download_error": "Erreur de téléchargement : {{message}}",
|
||||
"toast_upload_first": "Veuillez d'abord cliquer sur « Télécharger et transcrire » afin que la vidéo soit traitée sur le serveur avant de l'enregistrer.",
|
||||
"toast_save_failed": "Échec de l'enregistrement : {{message}}",
|
||||
"toast_opened": "Ouvert : {{name}}",
|
||||
"toast_project_deleted": "Projet supprimé",
|
||||
"toast_restored_state": "État de la génération précédente restauré",
|
||||
"toast_history_deleted": "Élément d'historique supprimé",
|
||||
"toast_flushed": "Vidé — RAM {{ram}}G · VRAM {{vram}}G{{unloaded}}",
|
||||
"toast_model_unloaded": "· modèle déchargé",
|
||||
"toast_flush_failed": "Échec du rinçage : {{message}}",
|
||||
"toast_project_saved": "Projet enregistré",
|
||||
"toast_project_created": "Projet créé"
|
||||
},
|
||||
"update": {
|
||||
"available": "Mise à jour {{version}} disponible",
|
||||
"install": "Installer et redémarrer",
|
||||
"install_hint": "Télécharger la mise à jour et redémarrer dans la nouvelle version",
|
||||
"downloading": "Mise à jour… {{pct}} %",
|
||||
"restart": "Redémarrer pour mettre à jour",
|
||||
"busy": "Terminez d'abord votre doublage, puis installez la mise à jour.",
|
||||
"whats_new": "Quoi de neuf",
|
||||
"failed": "La mise à jour a échoué",
|
||||
"retry": "Réessayer",
|
||||
"dismiss": "Rejeter"
|
||||
},
|
||||
"archetypes": {
|
||||
"featured": "En vedette",
|
||||
@@ -946,5 +1577,93 @@
|
||||
"facet_accent": "Accent",
|
||||
"facet_lang": "Langue",
|
||||
"facet_whisper": "Chuchoter"
|
||||
},
|
||||
"support": {
|
||||
"tab_support": "Assistance",
|
||||
"tab_license": "Licence commerciale",
|
||||
"toggle_label": "Support ou licence commerciale",
|
||||
"other_ways": "Autres moyens d'aider",
|
||||
"star_github": "Étoile sur GitHub",
|
||||
"join_discord": "Rejoignez Discorde"
|
||||
},
|
||||
"updates": {
|
||||
"tab": "Mises à jour",
|
||||
"up_to_date": "À jour · v{{version}}",
|
||||
"check_now": "Vérifiez maintenant",
|
||||
"releases": "Versions",
|
||||
"current": "actuelle",
|
||||
"prerelease": "aperçu",
|
||||
"loading": "Chargement des versions…",
|
||||
"none": "Aucune version trouvée",
|
||||
"load_error": "Impossible de charger les versions (hors ligne ?)",
|
||||
"retry_load": "Réessayer"
|
||||
},
|
||||
"firstrun": {
|
||||
"loading": "Préparation de la configuration…",
|
||||
"title": "Configurer OmniVoice Studio",
|
||||
"subtitle": "Rien n'est encore installé : vérifiez où tout sera placé, puis lancez. Modifiable plus tard dans les Réglages.",
|
||||
"language": "Langue",
|
||||
"mode_title": "Mode d'installation",
|
||||
"mode_installed": "Installé",
|
||||
"mode_installed_desc": "Utilise les dossiers système standard. Recommandé pour la plupart des utilisateurs.",
|
||||
"mode_portable": "Portable",
|
||||
"mode_portable_desc": "Tout vit dans un dossier à côté de l'application — déplacez-le vers un autre disque ou une autre machine d'un bloc.",
|
||||
"mode_portable_unavailable": "Indisponible : le dossier à côté de l'application n'est pas accessible en écriture.",
|
||||
"storage_title": "Stockage",
|
||||
"portable_folder": "Dossier portable",
|
||||
"portable_folder_desc": "Environnement, modèles et vos données vocales — un seul dossier, entièrement déplaçable.",
|
||||
"env_dir": "Environnement de l'application",
|
||||
"env_dir_desc": "Runtime Python et bibliothèques d'IA.",
|
||||
"data_dir": "Données vocales et projets",
|
||||
"data_dir_desc": "Vos voix, doublages, sorties et la base de données des projets.",
|
||||
"models_dir": "Cache des modèles",
|
||||
"models_dir_desc": "Modèles d'IA téléchargés — la partie la plus volumineuse et la plus facile à déplacer.",
|
||||
"needs": "requiert ~{{size}}",
|
||||
"free": "{{size}} libres",
|
||||
"checking": "vérification…",
|
||||
"not_writable": "non inscriptible",
|
||||
"change": "Modifier…",
|
||||
"compute_title": "Calcul",
|
||||
"compute_label": "GPU / accélérateur",
|
||||
"compute_auto": "Auto (NVIDIA CUDA / Apple MPS / CPU)",
|
||||
"compute_rocm": "GPU AMD (ROCm, Linux)",
|
||||
"channel_label": "Canal de mise à jour",
|
||||
"channel_stable": "Stable",
|
||||
"channel_preview": "Préversion (dernier main)",
|
||||
"network_title": "Réseau",
|
||||
"region_label": "Région de téléchargement",
|
||||
"mirrors_title": "Miroirs personnalisés (avancé)",
|
||||
"mirror_pypi": "URL de l'index PyPI",
|
||||
"mirror_hf": "Endpoint Hugging Face",
|
||||
"mirror_python": "Miroir de téléchargement Python",
|
||||
"insufficient_space": "Espace insuffisant : cette configuration nécessite ~{{need}} sur un même disque, seulement {{free}} disponibles. Choisissez un autre emplacement.",
|
||||
"blocked_not_writable": "Un dossier choisi n'est pas inscriptible — choisissez un autre emplacement.",
|
||||
"total_required": "Espace disque total : ~{{size}} (téléchargement unique au premier lancement)",
|
||||
"start": "Démarrer l'installation",
|
||||
"starting": "Démarrage…",
|
||||
"compute_detected": "Détecté",
|
||||
"compute_match": "correspond à cette machine",
|
||||
"compute_auto_desc": "Choisit le meilleur backend de cette machine à l'exécution — CUDA sur NVIDIA, MPS sur Apple Silicon, sinon CPU.",
|
||||
"compute_rocm_desc": "Installe les wheels ROCm de PyTorch pour les cartes AMD sous Linux. Laissez Auto en cas de doute.",
|
||||
"channel_stable_desc": "Uniquement des versions testées — les mises à jour arrivent après validation par la communauté.",
|
||||
"channel_preview_desc": "Builds continues du dernier main — nouveaux moteurs et correctifs en premier, quelques aspérités possibles.",
|
||||
"installing_title": "Installation",
|
||||
"activity_title": "Activité",
|
||||
"stage_setup": "Configuration",
|
||||
"stage_models": "Modèles et moteurs",
|
||||
"chip_required": "requis",
|
||||
"chip_optional": "optionnel",
|
||||
"chip_engine": "moteur",
|
||||
"lib_download": "Télécharger",
|
||||
"lib_downloading": "téléchargement…",
|
||||
"lib_use": "Utiliser",
|
||||
"lib_active": "actif",
|
||||
"lib_in_settings": "installer plus tard dans Réglages",
|
||||
"lib_show_all": "Afficher {{count}} modèles optionnels",
|
||||
"trust_line": "Tout s’exécute et reste sur cette machine — sans compte, sans cloud, sans télémétrie.",
|
||||
"resume_note": "Les téléchargements interrompus reprennent automatiquement — fermer l’application est sans risque.",
|
||||
"eta_left": "~{{eta}} restantes",
|
||||
"first_sound_text": "Bienvenue dans votre studio. Chaque mot que vous entendez vient d’être généré sur cette machine.",
|
||||
"first_sound_done": "Cette voix ? Générée il y a quelques secondes, en local. Bienvenue."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user