Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd7d20fe1e | ||
|
|
27ff5846c2 | ||
|
|
d14b37fab2 | ||
|
|
edf86c1800 | ||
|
|
437a995a0f | ||
|
|
03eb8f7d04 | ||
|
|
beca8cfea8 | ||
|
|
32103ad7b7 | ||
|
|
46c4445258 | ||
|
|
8bbab3fcc3 | ||
|
|
9ff5f38cb3 | ||
|
|
359c70bc21 | ||
|
|
0579ec91ad | ||
|
|
b6ec4e23f3 | ||
|
|
eb188931b5 | ||
|
|
86f23326ff | ||
|
|
ba4f64240a | ||
|
|
8ca7de89eb | ||
|
|
ed0b0b63cf | ||
|
|
0d80ab2cb0 | ||
|
|
6e3e014826 | ||
|
|
5bd8968aea | ||
|
|
48a7154810 | ||
|
|
5e135b9655 | ||
|
|
eeb3bf4452 | ||
|
|
126f23fd3e | ||
|
|
85b0db65bc | ||
|
|
45a5a5ce21 | ||
|
|
d57ecea804 | ||
|
|
6feafbd3be | ||
|
|
8f71c90f20 | ||
|
|
a481d459bd | ||
|
|
af6690840e | ||
|
|
75864a597f | ||
|
|
16294fed44 | ||
|
|
e2c4ea93b0 | ||
|
|
b3c18db33f | ||
|
|
e7fc37d438 | ||
|
|
b825d99337 | ||
|
|
b6a3eba1ac | ||
|
|
bef688e9dd | ||
|
|
958a79ef7f | ||
|
|
72d137e1f3 | ||
|
|
e8fdf0e244 | ||
|
|
83e71c5689 | ||
|
|
3bb401f4e5 | ||
|
|
86f5213055 | ||
|
|
6e600c48cb | ||
|
|
14f1257d1f | ||
|
|
be1ec3ade0 | ||
|
|
d58010fe1b | ||
|
|
da9315815d | ||
|
|
bb492086c9 | ||
|
|
641e660677 |
@@ -2,6 +2,8 @@
|
||||
# GitHub Sponsors isn't set up for this account — fund via Ko-fi or PayPal.
|
||||
|
||||
ko_fi: debpalash
|
||||
custom: ["https://paypal.me/palashCoder"]
|
||||
custom:
|
||||
- "https://paypal.me/palashCoder"
|
||||
- "https://github.com/debpalash/OmniVoice-Studio/blob/main/SPONSORS.md"
|
||||
# github: [debpalash] # not available
|
||||
# open_collective: omnivoice-studio
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
name: 🤝 Sponsorship inquiry
|
||||
description: Support OmniVoice and (optionally) claim a logo slot. Not for bugs or feature requests.
|
||||
title: "Sponsorship inquiry: "
|
||||
labels: ["sponsor"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for considering sponsoring **OmniVoice Studio** 💛
|
||||
|
||||
OmniVoice is free, local-first, and AGPL-3.0 — sponsorship keeps development going.
|
||||
See **[SPONSORS.md](https://github.com/debpalash/OmniVoice-Studio/blob/main/SPONSORS.md)** for tiers, placements, and logo guidelines.
|
||||
Prefer to just donate? [Ko-fi](https://ko-fi.com/debpalash) (recurring) or [PayPal](https://paypal.me/palashCoder) (one-time) — you don't need this form for that.
|
||||
- type: input
|
||||
id: name
|
||||
attributes:
|
||||
label: Name or organization
|
||||
description: How you'd like to be credited (person or company).
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: website
|
||||
attributes:
|
||||
label: Website / link
|
||||
description: The URL your name or logo should link to (homepage, product page, profile…).
|
||||
placeholder: https://example.com
|
||||
- type: input
|
||||
id: logo
|
||||
attributes:
|
||||
label: Logo URL (optional)
|
||||
description: Link to your logo (SVG preferred, else 2× PNG, transparent background). You can also attach it in the description below.
|
||||
placeholder: https://example.com/logo.svg
|
||||
- type: dropdown
|
||||
id: tier
|
||||
attributes:
|
||||
label: Tier you're interested in
|
||||
description: See SPONSORS.md for what each tier includes. Not sure? Pick "Not sure yet".
|
||||
options:
|
||||
- Backer
|
||||
- Bronze
|
||||
- Silver
|
||||
- Gold
|
||||
- Not sure yet — let's talk
|
||||
- Custom / annual arrangement
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: method
|
||||
attributes:
|
||||
label: How you'd like to support
|
||||
options:
|
||||
- Ko-fi (recurring)
|
||||
- Ko-fi (one-time)
|
||||
- PayPal (one-time)
|
||||
- Not sure yet — let's discuss
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: contact
|
||||
attributes:
|
||||
label: How should we reach you?
|
||||
description: Email or another contact. (GitHub will also notify you on this issue.)
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: notes
|
||||
attributes:
|
||||
label: Anything else?
|
||||
description: Questions, constraints, timeline, or context. Attach your logo here if you didn't link it above.
|
||||
- type: checkboxes
|
||||
id: ack
|
||||
attributes:
|
||||
label: Acknowledgements
|
||||
options:
|
||||
- label: I understand sponsorship is a thank-you, not a paywall — OmniVoice stays fully free and AGPL-3.0, and sponsors don't get gated features.
|
||||
required: true
|
||||
- label: If I provide a logo, I have the right to use it and grant OmniVoice permission to display it in the README, the app, and the project website.
|
||||
required: false
|
||||
@@ -190,6 +190,14 @@ jobs:
|
||||
# backlog that motivated the original drop is contained by
|
||||
# fail-fast:false — a slow Intel leg can delay the release run but
|
||||
# can't fail the other targets.
|
||||
#
|
||||
# #889 (2026-07): Intel macOS is now UNSUPPORTED for the local
|
||||
# backend — torch ≥2.3 ships no macOS x86_64 wheels, so the venv
|
||||
# bootstrap can never succeed on Intel. The shipped x64 artifact is
|
||||
# effectively UI-only (usable with a remote backend); the app now
|
||||
# pre-fails first-run bootstrap with an honest message on Intel.
|
||||
# Whether to keep shipping this x64 leg (UI-only) or drop it is an
|
||||
# OWNER CALL — deliberately not changed in the #889 PR.
|
||||
- os: macos-15-intel
|
||||
arch: x86_64-apple-darwin
|
||||
label: "macOS Intel"
|
||||
@@ -516,7 +524,9 @@ jobs:
|
||||
# Every other invocation — crucially the `v*` tag-push stable release
|
||||
# — evaluates these expressions to exactly their prior values.
|
||||
tagName: ${{ (needs.preview-gate.outputs.is_preview == 'true') && 'preview' || github.ref_name }}
|
||||
releaseName: ${{ (needs.preview-gate.outputs.is_preview == 'true') && 'OmniVoice Studio (Preview)' || format('OmniVoice Studio {0}', github.ref_name) }}
|
||||
# Version-first so the tag is readable in GitHub's truncated
|
||||
# release-list sidebar (which clips the title mid-string).
|
||||
releaseName: ${{ (needs.preview-gate.outputs.is_preview == 'true') && 'Preview — OmniVoice Studio' || format('{0} — OmniVoice Studio', github.ref_name) }}
|
||||
releaseBody: ${{ steps.changelog.outputs.body }}
|
||||
releaseDraft: ${{ (needs.preview-gate.outputs.is_preview == 'true') && 'false' || (inputs.draft || 'true') }}
|
||||
prerelease: ${{ needs.preview-gate.outputs.is_preview == 'true' }}
|
||||
|
||||
@@ -1,570 +0,0 @@
|
||||
# Test-install pipeline — CI-only verification that the app INSTALLS and
|
||||
# FIRST-RUNS (including the required default TTS model) on every supported
|
||||
# platform, producing throwaway installer artifacts.
|
||||
#
|
||||
# This workflow NEVER releases anything:
|
||||
# - no tag, no GitHub Release, no updater manifest, no publishing
|
||||
# - unsigned builds (updater artifacts disabled via a --config overlay, so
|
||||
# no TAURI_SIGNING_PRIVATE_KEY / APPLE_* secrets are needed or read)
|
||||
# - no version stamping/bumping — bundles carry whatever version is in git
|
||||
# - installers land as short-lived workflow ARTIFACTS (retention: 7 days)
|
||||
#
|
||||
# Two independent matrices per platform:
|
||||
# build — mirrors release.yml's bundle steps (uv + ffmpeg sidecars,
|
||||
# same `tauri build --target --bundles` invocation) minus
|
||||
# every tag/sign/publish part, then re-runs release.yml's
|
||||
# installer structural smoke (DMG mount / MSI quiet install /
|
||||
# AppImage extract) and uploads the installers.
|
||||
# first-run-smoke— sets up the backend venv exactly like the app's own first
|
||||
# launch (`uv sync --frozen --no-dev`, the command
|
||||
# lib.rs::ensure_venv_ready runs), boots the backend
|
||||
# headless, waits for the REQUIRED default model
|
||||
# (k2-fsa/OmniVoice, ~2.4 GB) to download + load, then runs
|
||||
# one real POST /generate synthesis and validates the WAV.
|
||||
#
|
||||
# Triggers: manual dispatch, or a push to the ci/test-install working branch
|
||||
# (so the run starts straight from the branch without merging to main).
|
||||
|
||||
name: Test Install (no release)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: ["ci/test-install"]
|
||||
|
||||
# Read-only token — this workflow must be structurally incapable of creating
|
||||
# tags/releases or pushing version bumps.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: test-install-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
# Run all JavaScript actions on Node 24 (mirrors ci.yml / release.yml).
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
# ── Installer builds (unsigned, artifacts only) ──────────────────────────
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
# Same platform set as release.yml. Note: the Intel-mac leg uses
|
||||
# macos-15-intel — macos-13 retired in Dec 2025; macos-15-intel is
|
||||
# GitHub's designated x86_64 migration target (see release.yml).
|
||||
- os: macos-14
|
||||
slug: macos-arm64
|
||||
label: "macOS Apple Silicon"
|
||||
rust_target: aarch64-apple-darwin
|
||||
bundles: "app,dmg"
|
||||
- os: macos-15-intel
|
||||
slug: macos-x64
|
||||
label: "macOS Intel"
|
||||
rust_target: x86_64-apple-darwin
|
||||
bundles: "app,dmg"
|
||||
# Windows: MSI only — NSIS fails at makensis near its ~2 GB stub
|
||||
# limit (see release.yml).
|
||||
- os: windows-2022
|
||||
slug: windows-x64
|
||||
label: "Windows x64"
|
||||
rust_target: x86_64-pc-windows-msvc
|
||||
bundles: "msi"
|
||||
# Linux: AppImage only — tauri-bundler's .deb target currently fails
|
||||
# with "Failed to create control scripts" (see release.yml).
|
||||
- os: ubuntu-22.04
|
||||
slug: linux-x64
|
||||
label: "Linux x64"
|
||||
rust_target: x86_64-unknown-linux-gnu
|
||||
bundles: "appimage"
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
name: Build (${{ matrix.label }})
|
||||
timeout-minutes: 90
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# ── Language runtimes (mirrors release.yml) ────────────────────────
|
||||
- name: Setup Rust (stable)
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.rust_target }}
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: frontend/src-tauri -> target
|
||||
key: ${{ matrix.rust_target }}-testinstall
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
|
||||
# ── Platform deps (Tauri host requirements only — no Python here) ─
|
||||
- name: macOS system deps
|
||||
if: runner.os == 'macOS'
|
||||
run: |
|
||||
brew install ffmpeg || true
|
||||
|
||||
- name: Linux system deps
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
build-essential curl wget file libxdo-dev libssl-dev \
|
||||
libayatana-appindicator3-dev librsvg2-dev \
|
||||
libasound2-dev ffmpeg
|
||||
|
||||
# ── Frontend build ─────────────────────────────────────────────────
|
||||
- name: Cache bun deps
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('frontend/bun.lock', 'bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-
|
||||
|
||||
- name: Install frontend deps
|
||||
working-directory: frontend
|
||||
run: bun install
|
||||
|
||||
# ── Sidecars (verbatim from release.yml) ───────────────────────────
|
||||
# Pinned uv version mirrors the `UV_VERSION` constant in lib.rs; bump
|
||||
# both together when refreshing.
|
||||
- name: Bundle uv (${{ matrix.rust_target }})
|
||||
shell: bash
|
||||
env:
|
||||
UV_VERSION: "0.11.7"
|
||||
TRIPLE: ${{ matrix.rust_target }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p frontend/src-tauri/binaries
|
||||
case "$TRIPLE" in
|
||||
aarch64-apple-darwin|x86_64-apple-darwin|x86_64-unknown-linux-gnu)
|
||||
ARCHIVE="tar.gz"
|
||||
;;
|
||||
x86_64-pc-windows-msvc)
|
||||
ARCHIVE="zip"
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported target for uv bundling: $TRIPLE"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
URL="https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/uv-${TRIPLE}.${ARCHIVE}"
|
||||
echo "Fetching $URL"
|
||||
WORK=$(mktemp -d)
|
||||
if [ "$ARCHIVE" = "zip" ]; then
|
||||
curl -fsSL "$URL" -o "$WORK/uv.zip"
|
||||
unzip -j -o "$WORK/uv.zip" -d "$WORK"
|
||||
mv "$WORK/uv.exe" "frontend/src-tauri/binaries/uv-${TRIPLE}.exe"
|
||||
else
|
||||
curl -fsSL "$URL" | tar -xz -C "$WORK"
|
||||
mv "$WORK/uv-${TRIPLE}/uv" "frontend/src-tauri/binaries/uv-${TRIPLE}"
|
||||
chmod +x "frontend/src-tauri/binaries/uv-${TRIPLE}"
|
||||
fi
|
||||
ls -la "frontend/src-tauri/binaries/"
|
||||
|
||||
# Same constant lives in frontend/src-tauri/src/tools.rs:
|
||||
# FFMPEG_BTBN_VERSION — bump together.
|
||||
- name: Bundle ffmpeg + ffprobe (${{ matrix.rust_target }})
|
||||
shell: bash
|
||||
env:
|
||||
TRIPLE: ${{ matrix.rust_target }}
|
||||
FFMPEG_BTBN_VERSION: "latest"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
BINDIR="frontend/src-tauri/binaries"
|
||||
mkdir -p "$BINDIR"
|
||||
WORK=$(mktemp -d)
|
||||
|
||||
case "$TRIPLE" in
|
||||
aarch64-apple-darwin|x86_64-apple-darwin)
|
||||
for TOOL in ffmpeg ffprobe; do
|
||||
if [ "$TOOL" = "ffmpeg" ]; then
|
||||
URL="https://evermeet.cx/ffmpeg/getrelease/zip"
|
||||
else
|
||||
URL="https://evermeet.cx/ffmpeg/getrelease/${TOOL}/zip"
|
||||
fi
|
||||
echo "Fetching $TOOL from evermeet.cx"
|
||||
curl -fsSL "$URL" -o "$WORK/${TOOL}.zip"
|
||||
unzip -o -j "$WORK/${TOOL}.zip" -d "$WORK"
|
||||
mv "$WORK/${TOOL}" "$BINDIR/${TOOL}-${TRIPLE}"
|
||||
chmod +x "$BINDIR/${TOOL}-${TRIPLE}"
|
||||
done
|
||||
;;
|
||||
x86_64-unknown-linux-gnu)
|
||||
URL="https://github.com/BtbN/FFmpeg-Builds/releases/download/${FFMPEG_BTBN_VERSION}/ffmpeg-master-${FFMPEG_BTBN_VERSION}-linux64-gpl.tar.xz"
|
||||
echo "Fetching ffmpeg from BtbN (linux64) — version=${FFMPEG_BTBN_VERSION}"
|
||||
curl -fsSL "$URL" -o "$WORK/ffmpeg.tar.xz"
|
||||
tar -xJf "$WORK/ffmpeg.tar.xz" -C "$WORK"
|
||||
EXTRACTED=$(find "$WORK" -type d -name "bin" | head -1)
|
||||
mv "$EXTRACTED/ffmpeg" "$BINDIR/ffmpeg-${TRIPLE}"
|
||||
mv "$EXTRACTED/ffprobe" "$BINDIR/ffprobe-${TRIPLE}"
|
||||
chmod +x "$BINDIR/ffmpeg-${TRIPLE}" "$BINDIR/ffprobe-${TRIPLE}"
|
||||
;;
|
||||
x86_64-pc-windows-msvc)
|
||||
URL="https://github.com/BtbN/FFmpeg-Builds/releases/download/${FFMPEG_BTBN_VERSION}/ffmpeg-master-${FFMPEG_BTBN_VERSION}-win64-gpl.zip"
|
||||
echo "Fetching ffmpeg from BtbN (win64) — version=${FFMPEG_BTBN_VERSION}"
|
||||
curl -fsSL "$URL" -o "$WORK/ffmpeg.zip"
|
||||
unzip -o "$WORK/ffmpeg.zip" -d "$WORK"
|
||||
EXTRACTED=$(find "$WORK" -type f -name "ffmpeg.exe" | head -1)
|
||||
EXTRACTED_DIR=$(dirname "$EXTRACTED")
|
||||
mv "$EXTRACTED_DIR/ffmpeg.exe" "$BINDIR/ffmpeg-${TRIPLE}.exe"
|
||||
mv "$EXTRACTED_DIR/ffprobe.exe" "$BINDIR/ffprobe-${TRIPLE}.exe"
|
||||
;;
|
||||
*)
|
||||
echo "⚠ No ffmpeg bundling for target: $TRIPLE (will download at first run)"
|
||||
;;
|
||||
esac
|
||||
ls -la "$BINDIR/"
|
||||
|
||||
# ── Tauri build — UNSIGNED, NO PUBLISH ─────────────────────────────
|
||||
# Invokes the tauri CLI directly (not tauri-action) so there is no
|
||||
# release codepath at all. A --config overlay turns off
|
||||
# createUpdaterArtifacts (tauri.conf.json has it on for release.yml),
|
||||
# because updater payload signing requires TAURI_SIGNING_PRIVATE_KEY —
|
||||
# deliberately absent here. macOS bundles still get the valid ad-hoc
|
||||
# seal from tauri.conf.json (bundle.macOS.signingIdentity = "-").
|
||||
- name: Tauri build (unsigned)
|
||||
working-directory: frontend
|
||||
shell: bash
|
||||
env:
|
||||
# GH runners have no FUSE; linuxdeploy must extract-and-run.
|
||||
APPIMAGE_EXTRACT_AND_RUN: 1
|
||||
run: |
|
||||
set -euo pipefail
|
||||
printf '%s\n' '{"bundle": {"createUpdaterArtifacts": false}}' > test-install-overlay.json
|
||||
bunx tauri build --target ${{ matrix.rust_target }} --bundles ${{ matrix.bundles }} --config test-install-overlay.json
|
||||
|
||||
# ── Installer smoke (mirrors release.yml's structural checks) ──────
|
||||
- name: Installer smoke (macOS)
|
||||
if: runner.os == 'macOS'
|
||||
timeout-minutes: 5
|
||||
shell: bash
|
||||
run: |
|
||||
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 | grep -oE '/Volumes/.*$')
|
||||
APP=$(find "$MOUNT" -maxdepth 2 -name "*.app" | head -1)
|
||||
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; }
|
||||
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
|
||||
|
||||
# Report-only signing verification (same script release.yml runs on
|
||||
# unsigned/preview paths) — asserts the ad-hoc seal is valid.
|
||||
- name: Verify macOS signing (report-only)
|
||||
if: runner.os == 'macOS'
|
||||
shell: bash
|
||||
run: |
|
||||
set -uo pipefail
|
||||
APP=$(find "frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle/macos" -maxdepth 1 -name '*.app' | head -1)
|
||||
[ -n "$APP" ] || { echo "FAIL — no .app found to verify"; exit 1; }
|
||||
echo "Unsigned test build → report-only verification."
|
||||
bash scripts/verify-macos-signing.sh "$APP"
|
||||
|
||||
- name: Installer smoke (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
timeout-minutes: 5
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
MSI=$(find frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle/msi -name "*.msi" | head -1)
|
||||
echo "Smoke-testing MSI: $MSI"
|
||||
msiexec.exe //i "$(cygpath -w "$MSI")" //quiet //norestart
|
||||
INSTALL="/c/Program Files/OmniVoice Studio"
|
||||
fail() { echo "FAIL — $1. Contents:"; find "$INSTALL" -maxdepth 4 -type f 2>/dev/null | head -40; exit 1; }
|
||||
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'
|
||||
timeout-minutes: 5
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
APPIMAGE=$(find frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle/appimage -name "*.AppImage" | head -1)
|
||||
APPIMAGE=$(realpath "$APPIMAGE")
|
||||
echo "Smoke-testing AppImage: $APPIMAGE"
|
||||
chmod +x "$APPIMAGE"
|
||||
EXTRACT_DIR="$(mktemp -d)"
|
||||
cd "$EXTRACT_DIR"
|
||||
"$APPIMAGE" --appimage-extract >/dev/null
|
||||
ROOT="$EXTRACT_DIR/squashfs-root"
|
||||
fail() { echo "FAIL — $1"; find "$ROOT" -maxdepth 5 -type f 2>/dev/null | head -40; exit 1; }
|
||||
{ [ -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"
|
||||
|
||||
# ── Collect + upload installers as short-lived artifacts ───────────
|
||||
- name: Collect installers
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
BUNDLE_DIR="frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle"
|
||||
mkdir -p test-install-artifacts
|
||||
find "$BUNDLE_DIR" -type f \
|
||||
\( -name "*.dmg" -o -name "*.msi" -o -name "*.AppImage" -o -name "*.deb" \) \
|
||||
-exec cp {} test-install-artifacts/ \;
|
||||
echo "Installers built:"
|
||||
ls -la test-install-artifacts/
|
||||
|
||||
- name: Upload installers (artifact only — NOT a release)
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: test-install-${{ matrix.slug }}
|
||||
path: test-install-artifacts/*
|
||||
retention-days: 7
|
||||
if-no-files-found: error
|
||||
|
||||
# ── First-run with required models (headless backend, per OS) ───────────
|
||||
# Replicates what the installed app does on first launch, without the GUI:
|
||||
# the same venv sync the Tauri shell runs, then backend boot → default
|
||||
# model download (k2-fsa/OmniVoice, ~2.4 GB) → one real synthesis.
|
||||
# CPU-only runners: device auto-detect resolves to cpu (or mps on the M1
|
||||
# runner) exactly as it would on a user's machine.
|
||||
first-run-smoke:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: macos-14
|
||||
slug: macos-arm64
|
||||
label: "macOS Apple Silicon"
|
||||
- os: macos-15-intel
|
||||
slug: macos-x64
|
||||
label: "macOS Intel"
|
||||
- os: windows-2022
|
||||
slug: windows-x64
|
||||
label: "Windows x64"
|
||||
- os: ubuntu-22.04
|
||||
slug: linux-x64
|
||||
label: "Linux x64"
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
name: First-run smoke (${{ matrix.label }})
|
||||
timeout-minutes: 75
|
||||
env:
|
||||
# Restricted-network resilience (mirrors ci.yml smoke-matrix).
|
||||
UV_HTTP_TIMEOUT: "120"
|
||||
UV_HTTP_RETRIES: "5"
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# The Linux venv pulls CUDA-enabled torch (+ nvidia libs); reclaim the
|
||||
# runner space the preinstalled toolchains occupy so venv + ~2.4 GB
|
||||
# model fit comfortably.
|
||||
- name: Free disk space (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo rm -rf /usr/local/lib/android /usr/share/dotnet /opt/ghc /usr/local/share/boost || true
|
||||
df -h /
|
||||
|
||||
# Graceful skip when a runner genuinely lacks disk: log clearly what
|
||||
# was (not) covered instead of failing the whole run on ENOSPC.
|
||||
- name: Disk space gate
|
||||
id: disk
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
df -Pk . "$HOME" || true
|
||||
FREE_WS=$(df -Pk . | awk 'NR==2 {print int($4/1048576)}')
|
||||
FREE_HOME=$(df -Pk "$HOME" | awk 'NR==2 {print int($4/1048576)}')
|
||||
FREE=$(( FREE_WS < FREE_HOME ? FREE_WS : FREE_HOME ))
|
||||
echo "Free disk: workspace=${FREE_WS}G home=${FREE_HOME}G -> min=${FREE}G"
|
||||
if [ "$FREE" -lt 12 ]; then
|
||||
echo "::warning::First-run model smoke SKIPPED on ${{ matrix.label }} — only ${FREE} GB free (< 12 GB needed for venv + ~2.4 GB default model). Installer build coverage is unaffected."
|
||||
echo "proceed=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "proceed=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Setup Python 3.11
|
||||
if: steps.disk.outputs.proceed == 'true'
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install uv
|
||||
if: steps.disk.outputs.proceed == 'true'
|
||||
uses: astral-sh/setup-uv@v3
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: "uv.lock"
|
||||
|
||||
- name: System deps (macOS)
|
||||
if: steps.disk.outputs.proceed == 'true' && runner.os == 'macOS'
|
||||
run: brew install ffmpeg libsndfile || true
|
||||
|
||||
- name: System deps (Windows)
|
||||
if: steps.disk.outputs.proceed == 'true' && runner.os == 'Windows'
|
||||
shell: bash
|
||||
run: |
|
||||
choco install ffmpeg -y --no-progress
|
||||
ffmpeg -version
|
||||
|
||||
- name: System deps (Linux)
|
||||
if: steps.disk.outputs.proceed == 'true' && runner.os == 'Linux'
|
||||
uses: awalsh128/cache-apt-pkgs-action@latest
|
||||
with:
|
||||
packages: ffmpeg libsndfile1
|
||||
version: 1.0
|
||||
|
||||
# Exactly the command the installed app's first launch runs
|
||||
# (lib.rs::ensure_venv_ready → `uv sync --frozen --no-dev`).
|
||||
#
|
||||
# Known platform gap surfaced by this smoke (2026-07-02): torch is locked
|
||||
# to 2.8.0, and PyTorch ships no macOS x86_64 wheels past 2.2.x — so the
|
||||
# locked dependency set cannot install on Intel Macs AT ALL. A real
|
||||
# Intel-Mac user's first launch hits the exact same wall. That is a
|
||||
# product bug, not a harness bug: surface it as a loud warning and skip
|
||||
# the rest of the smoke instead of failing a leg that can never pass
|
||||
# until the dependency gap is fixed.
|
||||
- name: Install backend venv (first-launch parity)
|
||||
if: steps.disk.outputs.proceed == 'true'
|
||||
id: venv
|
||||
shell: bash
|
||||
run: |
|
||||
set -uo pipefail
|
||||
if uv sync --frozen --no-dev 2>&1 | tee uv-sync.log; then
|
||||
echo "proceed=true" >> "$GITHUB_OUTPUT"
|
||||
elif grep -q "doesn't have a source distribution or wheel for the current platform" uv-sync.log; then
|
||||
echo "::warning::First-run smoke SKIPPED on ${{ matrix.label }} — the LOCKED dependency set cannot install on this platform (e.g. torch 2.8.0 has no macOS x86_64 wheels; PyTorch dropped Intel-mac support after 2.2.x). An end-user first launch on this platform fails the same way — this is a product-level dependency gap, not a CI harness issue."
|
||||
echo "proceed=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: First-run smoke — backend boot, required-model download, real synthesis
|
||||
if: steps.disk.outputs.proceed == 'true' && steps.venv.outputs.proceed == 'true'
|
||||
shell: bash
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
# Generous cold-load budget for CPU runners on a fresh HF cache.
|
||||
OMNIVOICE_MODEL_LOAD_TIMEOUT: "1800"
|
||||
run: |
|
||||
set -uo pipefail
|
||||
BASE="http://127.0.0.1:3900"
|
||||
|
||||
# GH macOS Apple Silicon runners ADVERTISE torch MPS, but the
|
||||
# virtualized Metal shared pool cannot actually allocate (even a
|
||||
# 256-byte alloc fails with "MPS backend out of memory") — a runner
|
||||
# limitation, not a product bug; real M1 machines run MPS fine.
|
||||
# Hide MPS via a CI-only sitecustomize so device auto-detect
|
||||
# resolves to CPU, keeping the smoke CPU-only as on the other legs.
|
||||
if [ "${RUNNER_OS:-}" = "macOS" ]; then
|
||||
mkdir -p ci-sitecustomize
|
||||
cat > ci-sitecustomize/sitecustomize.py <<'PY'
|
||||
# CI-only shim (lives ONLY inside the test-install workflow job):
|
||||
# GitHub's Apple Silicon runners expose torch.backends.mps as
|
||||
# available, but Metal allocations fail in the VM. Report MPS as
|
||||
# unavailable so the backend's device auto-detect picks CPU.
|
||||
try:
|
||||
import torch
|
||||
torch.backends.mps.is_available = lambda: False # type: ignore[assignment]
|
||||
except Exception:
|
||||
pass
|
||||
PY
|
||||
export PYTHONPATH="$PWD/ci-sitecustomize${PYTHONPATH:+:$PYTHONPATH}"
|
||||
echo "MPS hidden for this smoke (CI runner limitation) — forcing CPU."
|
||||
fi
|
||||
|
||||
uv run --no-sync python backend/main.py > backend.log 2>&1 &
|
||||
SERVER_PID=$!
|
||||
trap 'kill $SERVER_PID 2>/dev/null || true' EXIT
|
||||
echo "backend pid: $SERVER_PID"
|
||||
|
||||
fail() {
|
||||
echo "::error::${{ matrix.label }}: $1"
|
||||
echo "── backend.log (last 120 lines) ──"
|
||||
tail -120 backend.log || true
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Phase 1: liveness — /health (torch import makes cold boot slow).
|
||||
UP=0
|
||||
for i in $(seq 1 60); do
|
||||
if curl -sf "$BASE/health" >/dev/null 2>&1; then
|
||||
echo "Phase 1 OK — /health up after ~$((i*5))s"
|
||||
UP=1
|
||||
break
|
||||
fi
|
||||
kill -0 $SERVER_PID 2>/dev/null || fail "backend process died during boot"
|
||||
sleep 5
|
||||
done
|
||||
[ "$UP" = "1" ] || fail "backend /health not responding after 300s"
|
||||
|
||||
curl -sf "$BASE/system/info" | python -c "import sys,json; d=json.load(sys.stdin); print('device:', d.get('device'), '| platform:', d.get('platform'), '| python:', d.get('python'), '| version:', d.get('version'))" || true
|
||||
|
||||
# Phase 2: required-model bootstrap — the lifespan preload downloads
|
||||
# and loads the default checkpoint (k2-fsa/OmniVoice) on first run.
|
||||
echo "Phase 2 — waiting for required-model download + load (fresh HF cache)…"
|
||||
ELAPSED=0
|
||||
READY=0
|
||||
LAST=""
|
||||
while [ $ELAPSED -lt 1800 ]; do
|
||||
LAST=$(curl -sf "$BASE/model/status" 2>/dev/null || echo '{}')
|
||||
STATUS=$(printf '%s' "$LAST" | python -c "import sys,json; d=json.load(sys.stdin); print(d.get('status','?'))" 2>/dev/null || echo '?')
|
||||
DETAIL=$(printf '%s' "$LAST" | python -c "import sys,json; d=json.load(sys.stdin); print(d.get('sub_stage',''), d.get('progress',''), d.get('error',''))" 2>/dev/null || echo '')
|
||||
echo " [${ELAPSED}s] model status: $STATUS $DETAIL"
|
||||
if [ "$STATUS" = "ready" ]; then READY=1; break; fi
|
||||
kill -0 $SERVER_PID 2>/dev/null || fail "backend died during model load"
|
||||
sleep 15
|
||||
ELAPSED=$((ELAPSED+15))
|
||||
done
|
||||
[ "$READY" = "1" ] || fail "required model not ready after 1800s (last status: $LAST)"
|
||||
echo "Phase 2 OK — required model downloaded + loaded in ~${ELAPSED}s"
|
||||
|
||||
# Phase 3: one REAL synthesis through the default engine — the
|
||||
# end-to-end proof that a fresh install can produce audio.
|
||||
echo "Phase 3 — POST /generate (real synthesis)…"
|
||||
HTTP_CODE=$(curl -sS -o smoke_out.wav -w "%{http_code}" --max-time 1200 \
|
||||
-F "text=OmniVoice Studio first run smoke test. This sentence validates a fresh installation with the required model." \
|
||||
-F "num_step=4" \
|
||||
"$BASE/generate") || fail "generate request failed (curl transport error)"
|
||||
if [ "$HTTP_CODE" != "200" ]; then
|
||||
echo "response body (first 2000 bytes):"; head -c 2000 smoke_out.wav || true; echo
|
||||
fail "generate returned HTTP $HTTP_CODE"
|
||||
fi
|
||||
SIZE=$(wc -c < smoke_out.wav | tr -d ' ')
|
||||
HEAD4=$(head -c 4 smoke_out.wav)
|
||||
[ "$HEAD4" = "RIFF" ] || fail "output is not a RIFF/WAV file (got: $HEAD4)"
|
||||
[ "$SIZE" -gt 40000 ] || fail "output WAV suspiciously small (${SIZE} bytes)"
|
||||
echo "Phase 3 OK — real synthesis produced a ${SIZE}-byte WAV on a fresh install"
|
||||
|
||||
# Phase 4: report what was downloaded (model cache inventory).
|
||||
echo "Phase 4 — downloaded model inventory:"
|
||||
for C in "$HOME/.cache/huggingface" "${LOCALAPPDATA:-}/OmniVoice/hf_cache" "${HF_HOME:-}"; do
|
||||
if [ -n "$C" ] && [ -d "$C" ]; then
|
||||
du -sh "$C" 2>/dev/null || true
|
||||
find "$C" -maxdepth 3 -type d -name "models--*" 2>/dev/null | sed 's/^/ /' || true
|
||||
fi
|
||||
done
|
||||
|
||||
echo "FIRST-RUN SMOKE PASSED on ${{ matrix.label }}"
|
||||
|
||||
- name: Upload smoke evidence (log + WAV)
|
||||
if: always() && steps.disk.outputs.proceed == 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: first-run-smoke-${{ matrix.slug }}
|
||||
path: |
|
||||
backend.log
|
||||
smoke_out.wav
|
||||
retention-days: 7
|
||||
if-no-files-found: ignore
|
||||
@@ -6,46 +6,80 @@ 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.
|
||||
|
||||
## [Unreleased]
|
||||
## [0.3.10] — 2026-07-05
|
||||
|
||||
### Changed
|
||||
|
||||
- **The app now always opens maximized (not fullscreen).** Window size and
|
||||
position are no longer carried over from the previous session — one manual
|
||||
resize used to make every later launch reopen at that smaller size,
|
||||
overriding the intended maximized default. Same behavior on macOS
|
||||
(zoomed window, not a fullscreen Space), Windows, and Linux.
|
||||
The listening release — nine fixes in twenty-four hours, almost all driven by your v0.3.9 field reports (several with same-day turnaround). The dubbing pipeline stops lying: **Cinematic and Autofit can no longer invent dialogue**, the **speaker count you set is honored on every path** (and auto-cloning stops fabricating voices from guessed labels), and the timeline stops flashing invisible on Windows. Audiobook chapters with pauses render again. And one fix everyone should want: **updating can no longer leave you secretly running the old version** — a leftover backend from a previous install holding the port is now detected and replaced at launch. Plus: the Dub tab's LLM engine finally runs on the provider you configured in Settings, history timestamps stop reading "20617d ago", and the Engines page can't crash under concurrent load.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Confucius4-TTS is now validated end-to-end — and actually loads.** The
|
||||
opt-in engine's first live run (Apple Silicon, CPU) caught three
|
||||
scaffold-era faults: the sidecar could never import `confuciustts` (upstream
|
||||
ships no packaging, so the documented `pip install -e` fails — the sidecar
|
||||
and bootstrap probe now put the clone on `sys.path`, like upstream's own
|
||||
example), the assumed 24 kHz sample rate was wrong (confirmed **22 050 Hz**,
|
||||
now regression-tested), and the docs demanded an Amphion/MaskGCT install
|
||||
that doesn't exist (all weights auto-download from HuggingFace). CPU is
|
||||
~17× realtime, so CUDA stays the recommended path; `gpu_compat` now
|
||||
advertises `("cuda", "cpu")`. (#590)
|
||||
- **Audiobook/Stories chapters with a `[pause]` no longer fail to render.** Pause spans were built as 1-D silence while every TTS engine returns 2-D audio, so the chapter concatenation crashed with `Tensors must have same number of dimensions` — any chapter containing a pause failed on every attempt (reported with a precise trace in #897). Silence now matches the rendered audio's shape at the source, and the chunk concatenator defensively normalizes mixed ranks (including honest mono→stereo broadcast) so no engine can re-trigger the class. (#953)
|
||||
- **Cinematic and Autofit dubbing can no longer invent dialogue.** The refine and slot-fit passes accepted any non-empty LLM reply for Latin-script languages — hallucinated lines, refusals, or the critique itself could ship as the dub. Every reply is now checked against the original line (length window, target script, critique echo — tunable via `OMNIVOICE_REFINE_RATIO_MIN/MAX`), rejected output falls back to the literal translation with an `adapt-diverged`/`fit-diverged` marker, lines too short to honestly fill their slot skip LLM expansion entirely, and both passes pin `temperature=0.2` like the Fast path. (#950)
|
||||
- **Dub timeline boxes can no longer flash invisible during playback.** On some Windows GPU/WebView2 driver combos the segment boxes under the video vanished and reappeared while playing (first reported in #373; the earlier fix was incomplete) — the timeline lane still animated a CSS transform every playback tick, keeping the translucent boxes on a composited layer that the driver mis-painted. Boxes are now positioned in pure layout with fully opaque theme-aware fills (pixel-identical colors), removing the glitch class on every platform. (#951)
|
||||
- **The dub "Speakers" count now actually does something — on every path.** The hint only reached pyannote; the common fallbacks silently ignored it (the no-diarization heuristic was hardcoded to alternate two speakers, and the FunASR shortcut never consulted it). The heuristic now cycles the requested count, an explicit count routes through pyannote when available, every path that can only approximate (or must ignore) the setting says so in a visible warning, and the legacy endpoint + a new CLI `--speakers` flag accept it too. Auto voice-cloning also stops fabricating voices from guessed labels: reference slices under 1.5s are rejected, slices bordering another speaker's turn are avoided, and cloning is skipped with an honest warning when speaker labels came from the gap heuristic instead of real diarization. (#952)
|
||||
- **Settings → Engines can no longer 500 under concurrent loads.** The lazy TTS/ASR engine registries held a *live* dictionary iterator open across each engine's `is_available()` probe while `list_backends()` ran in a FastAPI threadpool — so a second concurrent `/engines` request materializing a lazy engine entry (`self[key] = cls`) mutated the dict mid-iteration and crashed the request with `RuntimeError: dictionary changed size during iteration`. Both registries now snapshot their keys before iterating (atomic under the GIL), immune to a concurrent insert; regression-tested for TTS and ASR. (#940)
|
||||
- **The Dub tab's LLM translation engine now runs on your configured LLM provider.** Picking "LLM (OpenAI-compatible)" silently required three hand-set environment variables even when a provider was already configured and tested in Settings → LLM Providers; it now resolves through a new "Dub translation" LLM skill (route it to any provider — remote or local — in Settings → LLM Skills, independently of Cinematic refinement), keeps the `TRANSLATE_*` env vars as a power-user override, bounds every call with the LLM timeout instead of the SDK's 600-second default, tells the Engine dropdown whether the engine is actually ready (and via which provider), and — when nothing is configured — returns a clear pointer to Settings → LLM Providers instead of a raw 401 per segment. (#944)
|
||||
- **Timestamps no longer show "20617d ago" in OmniDrive/Projects.** The backend stores record times in Unix seconds while some views assumed milliseconds, so generation-history cards rendered as ~1970 ("20617d ago") and sorted last; every relative-time label (OmniDrive, sidebar history, dub projects, batch queue, transcriptions) now goes through one unit-tolerant formatter, and records missing a timestamp show "—" instead of an epoch age.
|
||||
- **Updating can no longer leave you secretly running the old version.** If a backend from a previous version was still holding the port (an orphan that survived an update), the new app "attached" to it because it answered health checks — so every fix in the update appeared to change nothing (the reported "bound port blocked the newer version"). The launcher now compares the running backend's version against the app before attaching: same version attaches as before, a stale one is killed and the bundled backend is started in its place — on macOS, Windows, and Linux. (#947)
|
||||
|
||||
- **Parakeet TDT transcription now works without an NVIDIA GPU.** The
|
||||
`nemo-parakeet` ASR engine (parakeet-tdt-0.6b-v3, 25 languages, word
|
||||
timestamps) was hard-gated behind CUDA — but a live measurement on an Apple
|
||||
Silicon M2 shows it transcribing at ~10× realtime *on CPU*, roughly 20×
|
||||
faster than the default whisper-large-v3 on the same machine at equal
|
||||
accuracy. The false GPU gate is removed, so Mac and CPU-only users can now
|
||||
pick the dramatically faster engine in Settings → Engines.
|
||||
## [0.3.9] — 2026-07-04
|
||||
|
||||
- **8 GB GPUs: voice-clone/dub transcription no longer kills the backend.**
|
||||
On cards where the TTS model already held most of the VRAM (e.g. RTX
|
||||
4060 Ti 8 GB), loading whisper `large-v3` in float16 for a reference-clip
|
||||
or dub transcription died as a *native* CUDA out-of-memory abort — the
|
||||
whole backend process vanished with no error logged, and the app showed
|
||||
"Can't reach the local OmniVoice backend." A new VRAM preflight re-checks
|
||||
free GPU memory right before the ASR load and steps down float16 →
|
||||
int8 → CPU instead of attempting a load that can't fit (opt-out:
|
||||
`OMNIVOICE_ASR_VRAM_PREFLIGHT=0`). (#723)
|
||||
The dictation release — and a deep reliability pass driven by live-testing the entire app. **Dictation is rebuilt end-to-end**: instant feedback with a live waveform, words that commit about half a second after you stop speaking, clean punctuation, and text insertion that never lies about success. **LLM providers get one-click connection testing** with real diagnostics and model discovery, in all 21 languages. The app now **always opens maximized**, bottom buttons **can't hide under the footer** at small window sizes, and a wave of "out of memory / can't reach the backend / stuck at preparing" reports were traced to their real causes and fixed — including the silent VRAM crash on 8 GB cards, dead-IPC startup hangs after a Windows BSOD, and misleading error labels. Intel-Mac support status is now stated honestly, Confucius4-TTS is validated end-to-end, and Parakeet — roughly 20× faster than the default transcriber on CPU — is unlocked for every machine.
|
||||
|
||||
### Added
|
||||
|
||||
- **Sponsor OmniVoice.** A new `SPONSORS.md` (tiers, logo guidelines, how to sponsor), a README Sponsors section, and an in-app Sponsors area (Support page + a footer link) let people back the project — with a one-click "Become a sponsor" that opens a structured GitHub issue form, no account or token needed. Sponsorship is a thank-you, not a paywall: OmniVoice stays free and AGPL-3.0. (#923, #924)
|
||||
- **OpenAPI reference in Settings.** A new Settings → OpenAPI page embeds an interactive Scalar reference for OmniVoice's local backend API, with a one-click footer button. Fully local — Scalar is bundled, not loaded from a CDN, and phones home to nothing. (#928)
|
||||
- **Engine Self-test.** The Engines matrix gains a "Self-test" button for in-process TTS engines that runs a tiny real synthesis and reports duration + sample rate — proving an engine actually makes audio, not just imports — plus a copy-paste `export OMNIVOICE_*_DIR=…` setup line for opt-in engines right in the "Why unavailable?" panel. (#930)
|
||||
- **One canonical HuggingFace-token store + incomplete-download visibility.** The Model Store token field now saves to and is cleared from the same encrypted store as Settings → Credentials (no more two-stores split), and a truncated model cache shows an "incomplete · N MB" state with one-click Repair and Delete instead of masquerading as "not installed". (#927)
|
||||
- **Launchpad, reimagined as a deck of cards.** The seven feature cards now fan out with animated waveform faces in each card's accent color; hover or keyboard-focus any card and it comes forward while the rest tuck underneath, and the layout stays usable down to the minimum window size. (#904)
|
||||
- **See exactly what OmniVoice keeps on disk — and get warned before space runs out.** Settings → Storage shows real usage for the model cache (with your largest models), app data, engine environments and temp files, plus a free-space gauge and low-disk / near-full-volume warnings with one-click paths to open folders or reclaim space. (#906)
|
||||
- **A "What's new" changelog reader in Settings → Updates.** The available update's real release notes now render in-app, alongside an offline changelog viewer and a one-time "what's new" note after each update. (#909)
|
||||
- **Route each AI feature to its own LLM — or switch it off.** A new Settings → LLM Skills panel lists every LLM-powered capability (Cinematic/Autofit translation, slot fitting, glossary auto-extract, direction parsing, dictation cleanup) with a per-skill toggle and provider picker, so sensitive work can stay on a local model while heavier jobs use a remote one. Disabled skills fall back to the exact non-LLM behavior. (#912)
|
||||
- **A small thank-you moment, done right.** After a successful export, dub, audiobook, or batch run, OmniVoice may — rarely — show a friendly, dismissible note by the footer heart about supporting development: never more than once a session, at most every 7 days, never for brand-new users, with a permanent "don't ask again". The logs bar also gained an icon and the footer icons now share one size. (#898)
|
||||
|
||||
- **Dictation, rebuilt.** The dictation pill now shows a live waveform the moment the mic opens, streams words as you speak with real download/loading progress on first use, and finishes what you say in about half a second of silence instead of two-and-a-half. Transcripts come out properly capitalized and punctuated. Text insertion is now honest and safe: your clipboard is preserved and restored, failures show what to do (including a one-click jump to macOS Accessibility settings when permission is missing) instead of a false "Pasted", and Esc cancels cleanly at any point. The dictation model also pre-warms in the background after launch, so the first press of the hotkey no longer sits on a cold model load.
|
||||
|
||||
- **LLM Providers: one-click connection testing with real diagnostics.** The Test button in Settings → LLM Providers now measures round-trip latency and turns failures into plain-language guidance — bad key (401/403), wrong model or URL (404), rate-limited (429), or unreachable server — instead of a raw exception dump. A new "Fetch models" button lists every model your key can access so you pick from real names instead of guessing. The whole panel is now translated into all 21 languages, provider error messages never echo your API key, and the settings API gained full test coverage.
|
||||
|
||||
### Changed
|
||||
|
||||
- **A "Get in touch" page that actually guides you.** The Contact page is now clearly-labelled cards (report a bug, request a feature, get community help, support the project, report a security issue) with a sentence each on when to use them, instead of a flat link list. (#925)
|
||||
- **Release titles are version-first.** GitHub's release-list sidebar truncates the title, so "OmniVoice Studio v0.3.8" hid the version; releases are now named "vX.Y.Z — OmniVoice Studio" so the version is always visible. (#922)
|
||||
- **Launchpad feature cards now fill the window.** The seven cards (Voice Clone, Voice Design, Video Dubbing, Stories, Audiobook, Voice Gallery, Transcripts) span the full content width on a maximized display instead of a fixed ~780px fan, and reflow responsively (7→3→1 columns) down to the 900×600 minimum — driven by the shell's own width, keeping the animated card faces, hover/keyboard-focus raise, and reduced-motion fallback. (#915)
|
||||
- **LLM Providers settings, de-confused.** The old inline "LLM endpoint" box in Translation is gone — LLM Providers is now the one place that owns it. Fields pinned by an environment variable are shown disabled with an explainer instead of silently reverting, the make-active button explains when a provider is env-pinned, and the Cloudflare Account ID is remembered and editable. (#907)
|
||||
- **Intel Macs: honestly unsupported for the local backend.** PyTorch no longer ships Intel-Mac builds, so the backend cannot run there; instead of a cryptic dependency error, Intel users now get a clear explanation up front (with the remote-backend option), and the README/docs say so plainly. (#889, #891)
|
||||
|
||||
- **The app now always opens maximized (not fullscreen).** Window size and position are no longer carried over from the previous session — one manual resize used to make every later launch reopen at that smaller size, overriding the intended maximized default. Same behavior on macOS (zoomed window, not a fullscreen Space), Windows, and Linux.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Sherpa-ONNX "model not set" now reads as a setup problem, not out-of-memory.** Selecting the sherpa-onnx engine without `OMNIVOICE_SHERPA_MODEL` configured used to fail with a misleading "ran out of memory — press Flush" 500; it now names the exact variable, points at Settings → Engines, and the engine is marked unavailable-with-a-reason in the picker (with a copy-paste setup line) instead of selectable-but-broken. Generalized so any env-gated engine surfaces actionable setup guidance. (#919)
|
||||
- **Cinematic & Autofit now actually run on every translation engine.** Picking Cinematic or Autofit on the default Argos engine (or NLLB) used to silently fall back to Fast with a success toast; it now runs the full LLM refine + fit pass, the Autofit fit pass is bounded by the same wall-clock budget as Cinematic, and provider errors are scrubbed of keys/user-ids. (#910)
|
||||
- **Dictation no longer freezes on a slow or dead LLM.** Transcript refinement is now hard-bounded (default 4s): a placeholder key or unreachable endpoint falls back to clean unrefined text instead of stalling the paste ~51 seconds. The dictation model is genuinely pre-warmed and reused across sessions, REST transcription is polished like live dictation, and Settings flags a configured-but-failing LLM. (#911)
|
||||
- **Model installs fail loudly, not silently.** Failed downloads keep their mirror-aware reason on the row with Retry/Dismiss instead of vanishing after a moment; installs check free disk space up front before overrunning it; in-progress installs get a Cancel button; and the HF-mirror setting only asks for a restart when it actually changed. (#908)
|
||||
- **Engines settings, sharper and honest.** The Supertonic license "Accept" button works again (it was inert since it shipped), the engine matrix refreshes the instant you pick an engine, picking a GPU engine that lands on CPU now warns you with the reason, CPU-only engines stop being mislabelled "CPU fallback", and an in-process "Test engine" pass reads as a dependency check instead of a fake "0 ms" latency. (#905)
|
||||
- **Updates can no longer cost you data.** Before any database migration runs on first launch of a new version, the database is snapshotted next to itself (newest three kept), and a failed migration stops with the backup path named instead of silently running on a half-upgraded database; the environment self-heal now verifies it's actually broken before rebuilding. (#909)
|
||||
- **CUDA transcription now works on packaged NVIDIA installs — the cuDNN 8 compat libraries install automatically at launch.** The install step only existed in the dev-loop `scripts/setup.py`, which isn't bundled into the packaged app, so real installs never got the libs and WhisperX / faster-whisper failed with `Could not locate cudnn_ops_infer64_8.dll`. The Rust bootstrap now side-loads them on CUDA machines; CPU/AMD/ROCm boxes skip the download and cache the result so their launches stay instant. (#827, #869)
|
||||
- **`scripts/setup.py` no longer fails with `No module named pip` when installing the cuDNN 8 libs in the dev loop.** `uv venv` doesn't seed pip into the venv, so `python -m pip install` always broke; the script now uses `uv pip install --python` instead. (#869)
|
||||
- **Generation timeouts now give device-honest advice.** A CPU-only machine is no longer told the GPU is "VRAM-starved" or to "set the engine to CPU" — CPU hosts get compute-bound guidance (shorter text, the CPU-tuned GGUF/Supertonic-3 engines, the OMNIVOICE_GENERATE_TIMEOUT_S knob) while GPU hosts keep the VRAM-contention explanation. (#896)
|
||||
- **Model-download failures now name the mirror that failed.** When a Hugging Face mirror is configured and unreachable, every affected surface (generate, dub, Model Store installs) names the mirror and points at the exact setting instead of leaking a raw network error; auto-repair failures now say *why* the repair failed. (#874, #890)
|
||||
- **No more infinite "preparing" after an unclean shutdown.** If Windows corrupts the WebView cache (e.g. after a BSOD), the splash detects the dead IPC channel, proceeds via a direct backend health check, and — if truly stuck — offers a one-click "Repair and restart". (#879, #892)
|
||||
- **"Out of memory" is no longer the default excuse.** A failed model download mid-generation was mislabeled as OOM with useless "flush VRAM" advice; network failures are now classified honestly, only real OOM signatures get the OOM treatment, and first-use engine downloads retry once with a fresh connection. (#880, #893)
|
||||
- **Hung transcriptions recover the same way everywhere.** Chunked dub transcription now shares the same guarded-timeout + GPU-pool reset as the rest of the app, and repeated timeouts recommend the crash-isolated ASR engine — now properly selectable in Settings. (#730, #895)
|
||||
- **A raw `[Errno 22]` transcribe error now tells you what to fix.** When the OS rejects the temporary WAV write during dub transcription (a missing, read-only, or full temp directory, or antivirus interference), the stream used to dead-end as *"Transcription produced no segments. [Errno 22] Invalid argument"* with no next step; it now classifies the EINVAL and appends an actionable temp-dir/disk/AV hint — the same treatment the ffmpeg and compute-type failure classes already get. (#763)
|
||||
|
||||
- **Buttons can no longer hide under the logs footer on small windows.** The bottom status/logs bar was a fixed overlay that pages had to compensate for with padding — any view that missed it (voice-card grids in Gallery and Community, bottom action rows) clipped under the bar at small window sizes, a class previously patched one page at a time (#476, #504). The footer is now a real row of the app shell, so content physically ends at its top edge at every window size, collapsed or expanded — guarded by a new layout test plus a 900×600 Playwright check at the app's minimum window size.
|
||||
|
||||
- **Confucius4-TTS is now validated end-to-end — and actually loads.** The opt-in engine's first live run (Apple Silicon, CPU) caught three scaffold-era faults: the sidecar could never import `confuciustts` (upstream ships no packaging, so the documented `pip install -e` fails — the sidecar and bootstrap probe now put the clone on `sys.path`, like upstream's own example), the assumed 24 kHz sample rate was wrong (confirmed **22 050 Hz**, now regression-tested), and the docs demanded an Amphion/MaskGCT install that doesn't exist (all weights auto-download from HuggingFace). CPU is ~17× realtime, so CUDA stays the recommended path; `gpu_compat` now advertises `("cuda", "cpu")`. (#590)
|
||||
|
||||
- **Parakeet TDT transcription now works without an NVIDIA GPU.** The `nemo-parakeet` ASR engine (parakeet-tdt-0.6b-v3, 25 languages, word timestamps) was hard-gated behind CUDA — but a live measurement on an Apple Silicon M2 shows it transcribing at ~10× realtime *on CPU*, roughly 20× faster than the default whisper-large-v3 on the same machine at equal accuracy. The false GPU gate is removed, so Mac and CPU-only users can now pick the dramatically faster engine in Settings → Engines.
|
||||
|
||||
- **8 GB GPUs: voice-clone/dub transcription no longer kills the backend.** On cards where the TTS model already held most of the VRAM (e.g. RTX 4060 Ti 8 GB), loading whisper `large-v3` in float16 for a reference-clip or dub transcription died as a *native* CUDA out-of-memory abort — the whole backend process vanished with no error logged, and the app showed "Can't reach the local OmniVoice backend." A new VRAM preflight re-checks free GPU memory right before the ASR load and steps down float16 → int8 → CPU instead of attempting a load that can't fit (opt-out: `OMNIVOICE_ASR_VRAM_PREFLIGHT=0`). (#723)
|
||||
|
||||
### CI
|
||||
|
||||
- **A migration can no longer silence the app's logs.** Alembic's startup config was disabling every existing logger process-wide (a latent bug the new pre-migration backup logging exposed); fixed, and the migration-safety tests are now immune to full-suite ordering. (#909, #917)
|
||||
- **Deterministically green tests + real install proof.** Tests can no longer read the developer's real `.env` or app data (the order-dependent flake class, #878, #894), and a new cross-platform install-test workflow builds all four installers and proves a real first run — model download plus verified synthesis — on macOS, Windows, and Linux runners.
|
||||
|
||||
## [0.3.8] — 2026-07-01
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
<a href="#why-ovs">Why OVS</a> ·
|
||||
<a href="#tts-engines">TTS Engines</a> ·
|
||||
<a href="#asr-engines">ASR Engines</a> ·
|
||||
<a href="#openai-api">API</a> ·
|
||||
<a href="#sponsors">Sponsors</a> ·
|
||||
<a href="#sponsor--donate">Donate</a> ·
|
||||
<a href="#contributing">Contributing</a> ·
|
||||
<a href="https://discord.gg/bzQavDfVV9">Discord</a> ·
|
||||
@@ -30,11 +32,21 @@
|
||||
<br/>
|
||||
|
||||
<div align="center">
|
||||
<img src=".github/assets/social-preview.png" alt="OmniVoice Studio — The open-source ElevenLabs alternative" width="100%"/>
|
||||
<img src="docs/screenshot-launchpad.png" alt="OmniVoice Studio — Launchpad" width="100%"/>
|
||||
</div>
|
||||
|
||||
> **Your voice is the most personal data you have. So why rent it back from a cloud?** Every mainstream voice tool ships your audio to someone else's server and bills you monthly for the privilege. OmniVoice Studio flips that: clone, design, dub, and dictate on your own hardware — 646 languages, no meter running, nothing leaving your machine.
|
||||
|
||||
<div align="center">
|
||||
|
||||
| 🔑 No API keys | 🙅 No accounts | ☁️ No cloud | 💳 No subscription |
|
||||
|:---:|:---:|:---:|:---:|
|
||||
| nothing to paste in | nothing to sign up for | your audio stays home | it's your computer |
|
||||
|
||||
</div>
|
||||
|
||||
> [!WARNING]
|
||||
> **OmniVoice Studio is in active beta.** Things may break between releases. For the latest features and fixes, clone the repo and run from source rather than using pre-built installers. Bug reports and PRs are very welcome — [open an issue](https://github.com/debpalash/OmniVoice-Studio/issues) or [join Discord](https://discord.gg/bzQavDfVV9).
|
||||
> **OmniVoice Studio is in active beta.** Things may break between releases — for the latest features and fixes, clone the repo and run from source rather than the pre-built installers. Bug reports and PRs are very welcome: [open an issue](https://github.com/debpalash/OmniVoice-Studio/issues) or [join Discord](https://discord.gg/bzQavDfVV9).
|
||||
|
||||
<div align="center">
|
||||
<br/>
|
||||
@@ -46,7 +58,68 @@
|
||||
|
||||
<br/>
|
||||
|
||||
## Features
|
||||
<a id="screenshots"></a>
|
||||
|
||||
## 📸 See it in action
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="50%">
|
||||
<img src="docs/screenshot-studio.png" alt="Studio" width="100%"/>
|
||||
<br/><b>Studio</b><br/>
|
||||
<sub>Generate & clone in one workspace — a 3-second clip mirrors any voice, 646 languages, zero-shot.</sub>
|
||||
</td>
|
||||
<td align="center" width="50%">
|
||||
<img src="docs/screenshot-design.png" alt="Voice Design" width="100%"/>
|
||||
<br/><b>Voice Design</b><br/>
|
||||
<sub>Build new voices from scratch — gender, age, accent, pitch, emotion, dialect.</sub>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<img src="docs/screenshot-gallery.png" alt="Voice Gallery" width="100%"/>
|
||||
<br/><b>Voice Gallery</b><br/>
|
||||
<sub>Browse ready-made archetype voices with language filters — or build your own library.</sub>
|
||||
</td>
|
||||
<td align="center">
|
||||
<img src="docs/screenshot-dub.png" alt="Video Dubbing" width="100%"/>
|
||||
<br/><b>Video Dubbing</b><br/>
|
||||
<sub>A real dub, end to end: 37 segments transcribed, translated to Bengali, re-voiced, and timed — ready to export as MP4.</sub>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<img src="docs/screenshot-engines.png" alt="Settings — Engines" width="100%"/>
|
||||
<br/><b>Settings → Engines</b><br/>
|
||||
<sub>The engine compatibility matrix — 14 TTS engines with per-engine GPU preflight, no silent CPU fallback.</sub>
|
||||
</td>
|
||||
<td align="center">
|
||||
<img src="docs/screenshot-settings.png" alt="Settings — Models" width="100%"/>
|
||||
<br/><b>Settings → Models</b><br/>
|
||||
<sub>One-click model store — auto-detects your platform (CUDA / MPS / CPU) and recommends the right models.</sub>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<img src="docs/screenshot-openapi.png" alt="Settings — API Reference" width="100%"/>
|
||||
<br/><b>API Reference</b><br/>
|
||||
<sub>The full local REST API, embedded — every endpoint documented with copy-paste client snippets.</sub>
|
||||
</td>
|
||||
<td align="center">
|
||||
<img src="docs/screenshot-updates.png" alt="Settings — What's New" width="100%"/>
|
||||
<br/><b>What's New</b><br/>
|
||||
<sub>In-app changelog reader — see exactly what shipped in each release without leaving the app.</sub>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
---
|
||||
|
||||
<a id="features"></a>
|
||||
|
||||
## ✨ Features
|
||||
|
||||
The eight headliners — and twelve more waiting under the fold.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
@@ -74,76 +147,44 @@
|
||||
</td>
|
||||
<td align="center" valign="top">
|
||||
<h3>⌨️ Dictation Widget</h3>
|
||||
<p><code>⌘+⇧+Space</code> from <b>any app</b>.<br/>Transcribes, auto-pastes, disappears.</p>
|
||||
<p><kbd>⌘</kbd>+<kbd>⇧</kbd>+<kbd>Space</kbd> from <b>any app</b>.<br/>Transcribes, auto-pastes, disappears.</p>
|
||||
</td>
|
||||
<td align="center" valign="top">
|
||||
<h3>🔊 Vocal Isolation</h3>
|
||||
<p>Demucs-powered. Splits speech<br/>from music, <b>keeps the background</b>.</p>
|
||||
</td>
|
||||
<td align="center" valign="top">
|
||||
<h3>👥 Speaker Diarization</h3>
|
||||
<p>Pyannote + WhisperX.<br/><b>Auto-identifies</b> who said what.</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top">
|
||||
<h3>📦 Batch Queue</h3>
|
||||
<p>Drop <b>50 videos</b>, walk away.<br/>Progress bars per job.</p>
|
||||
</td>
|
||||
<td align="center" valign="top">
|
||||
<h3>🤖 MCP Server</h3>
|
||||
<p>Use OmniVoice from <b>Claude</b>,<br/>Cursor, or any MCP client.</p>
|
||||
</td>
|
||||
<td align="center" valign="top">
|
||||
<h3>🛡️ AI Watermark</h3>
|
||||
<p>AudioSeal (Meta). <b>Invisible</b>,<br/>survives compression.</p>
|
||||
</td>
|
||||
<td align="center" valign="top">
|
||||
<h3>🔬 Diagnostics</h3>
|
||||
<p>Self-check, error journal,<br/>scrubbed <b>diagnostic bundle</b>.</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top">
|
||||
<h3>🔐 100% Local</h3>
|
||||
<p>No keys, no cloud, no accounts.<br/><b>Your machine only</b>.</p>
|
||||
</td>
|
||||
<td align="center" valign="top">
|
||||
<h3>⚡ GPU Auto-Detect</h3>
|
||||
<p>CUDA · MPS · ROCm · CPU.<br/>≤8 GB? <b>Auto-offloads</b>.</p>
|
||||
</td>
|
||||
<td align="center" valign="top">
|
||||
<h3>🧩 Extensible</h3>
|
||||
<p>Subclass <code>TTSbackend</code>,<br/>add any engine in <b>~50 lines</b>.</p>
|
||||
</td>
|
||||
<td align="center" valign="top">
|
||||
<h3>🧭 Engine Routing</h3>
|
||||
<p>Preflight GPU check per engine.<br/><b>No silent CPU fallback</b>.</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top">
|
||||
<h3>🎒 Portable Personas</h3>
|
||||
<p>Export voices as <code>.ovsvoice</code><br/>bundles — identity + <b>watermark</b>.</p>
|
||||
</td>
|
||||
<td align="center" valign="top">
|
||||
<h3>♾️ Unlimited TTS</h3>
|
||||
<p>Sentence-chunked generation.<br/><b>No length cap</b>. Streaming via WS.</p>
|
||||
</td>
|
||||
<td align="center" valign="top">
|
||||
<h3>🌐 Remote Backend</h3>
|
||||
<p>Point UI at a remote server.<br/>Tailscale-friendly. <b>Bearer auth</b>.</p>
|
||||
</td>
|
||||
<td align="center" valign="top">
|
||||
<h3>🧠 Dictation + LLM</h3>
|
||||
<p>Local LLM cleanup of transcripts.<br/>Optional echo <b>cancellation</b>.</p>
|
||||
<h3>🤖 MCP Server</h3>
|
||||
<p>Use OmniVoice from <b>Claude</b>,<br/>Cursor, or any MCP client.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<details>
|
||||
<summary><b>…and 12 more</b> — isolation, diarization, batch, watermarking, diagnostics, and friends</summary>
|
||||
|
||||
<br/>
|
||||
|
||||
- 🔊 **Vocal Isolation** — Demucs-powered: splits speech from music and keeps the background bed.
|
||||
- 👥 **Speaker Diarization** — Pyannote + WhisperX auto-identify who said what.
|
||||
- 📦 **Batch Queue** — drop 50 videos, walk away; per-job progress bars.
|
||||
- 🛡️ **AI Watermark** — AudioSeal (Meta): invisible, survives compression.
|
||||
- 🔬 **Diagnostics** — self-check suite, error journal, scrubbed diagnostic bundles.
|
||||
- ⚡ **GPU Auto-Detect** — CUDA · MPS · ROCm · CPU; ≤8 GB VRAM auto-offloads.
|
||||
- 🧭 **Engine routing** — preflight GPU check per engine; no silent CPU fallback.
|
||||
- 🧩 **Extensible** — subclass `TTSBackend`, add any engine in ~50 lines.
|
||||
- 🎒 **Portable personas** — export voices as `.ovsvoice` bundles: identity + watermark.
|
||||
- ♾️ **Unlimited TTS** — sentence-chunked generation, no length cap, streaming via WebSocket.
|
||||
- 🌐 **Remote backend** — point the UI at a remote server; Tailscale-friendly, bearer auth.
|
||||
- 🧠 **Dictation + LLM** — local-LLM cleanup of transcripts, optional echo cancellation.
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## Quickstart
|
||||
<a id="quickstart"></a>
|
||||
|
||||
## ⚡ Quickstart
|
||||
|
||||
<div align="center">
|
||||
<a href="https://github.com/debpalash/OmniVoice-Studio/releases/latest"><img src="https://img.shields.io/badge/macOS-DMG_(Apple_Silicon)-000?style=for-the-badge&logo=apple&logoColor=white" alt="Download macOS DMG" /></a>
|
||||
@@ -152,16 +193,23 @@
|
||||
<a href="https://github.com/debpalash/OmniVoice-Studio/releases/latest"><img src="https://img.shields.io/badge/Debian-.deb-A81D33?style=for-the-badge&logo=debian&logoColor=white" alt="Download Debian .deb" /></a>
|
||||
<br/>
|
||||
<sub><b>macOS:</b> first launch needs a one-time approval — right-click → <b>Open</b> (or System Settings → Privacy & Security → <b>"Open Anyway"</b> on macOS 15). No Terminal needed. <a href="docs/install/macos.md#gatekeeper-quarantine">Why?</a></sub>
|
||||
<br/>
|
||||
<sub><b>Intel Macs are not supported for the local backend:</b> the app UI installs, but the Python backend cannot run because PyTorch no longer ships Intel-Mac (x86_64) wheels (<a href="https://github.com/debpalash/OmniVoice-Studio/issues/889">#889</a>) — see <a href="docs/install/macos.md">docs/install/macos.md</a>.</sub>
|
||||
</div>
|
||||
|
||||
Per-OS install guides — pick yours and follow it end-to-end:
|
||||
Pick your OS and follow the guide end-to-end:
|
||||
|
||||
- **macOS** — [docs/install/macos.md](docs/install/macos.md)
|
||||
- **Windows** — [docs/install/windows.md](docs/install/windows.md)
|
||||
- **Linux** — [docs/install/linux.md](docs/install/linux.md)
|
||||
- **Docker** — [docs/install/docker.md](docs/install/docker.md) · [Docker Hub: `palashdeb/omnivoice-studio`](https://hub.docker.com/r/palashdeb/omnivoice-studio)
|
||||
- 🍎 **macOS** — [docs/install/macos.md](docs/install/macos.md)
|
||||
- 🪟 **Windows** — [docs/install/windows.md](docs/install/windows.md)
|
||||
- 🐧 **Linux** — [docs/install/linux.md](docs/install/linux.md)
|
||||
- 🐳 **Docker** — [docs/install/docker.md](docs/install/docker.md) · [Docker Hub: `palashdeb/omnivoice-studio`](https://hub.docker.com/r/palashdeb/omnivoice-studio)
|
||||
|
||||
Stuck? Run the built-in self-check first — **Settings → About → "Run
|
||||
<details>
|
||||
<summary><b>🧰 Stuck? Self-checks, tokens & restricted networks</b></summary>
|
||||
|
||||
<br/>
|
||||
|
||||
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
|
||||
@@ -176,57 +224,13 @@ diarization-specific gating, see
|
||||
speed, the ⚡ fast-download (Xet) status, and restricted-network / mirror
|
||||
options, see [docs/downloading-models.md](docs/downloading-models.md).
|
||||
|
||||
## Screenshots
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="50%">
|
||||
<img src="docs/screenshot-clone.png" alt="Voice Clone" width="100%"/>
|
||||
<br/><b>Voice Clone</b><br/>
|
||||
<sub>Drop a 3-second clip → mirror any voice. 646 languages, zero-shot.</sub>
|
||||
</td>
|
||||
<td align="center" width="50%">
|
||||
<img src="docs/screenshot-design.png" alt="Voice Design" width="100%"/>
|
||||
<br/><b>Voice Design</b><br/>
|
||||
<sub>Build new voices from scratch — gender, age, accent, pitch, style.</sub>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<img src="docs/screenshot-dub.png" alt="Video Dubbing" width="100%"/>
|
||||
<br/><b>Video Dubbing</b><br/>
|
||||
<sub>Upload or paste a YouTube URL. Transcribe, translate, re-voice, export.</sub>
|
||||
</td>
|
||||
<td align="center">
|
||||
<img src="docs/screenshot-gallery.png" alt="Voice Gallery" width="100%"/>
|
||||
<br/><b>Voice Gallery</b><br/>
|
||||
<sub>Search YouTube, browse categories, download clips, build your library.</sub>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<img src="docs/screenshot-settings.png" alt="Settings — Models" width="100%"/>
|
||||
<br/><b>Settings → Models</b><br/>
|
||||
<sub>15 models. One-click install. Auto-detects your platform (CUDA / MPS / CPU).</sub>
|
||||
</td>
|
||||
<td align="center">
|
||||
<img src="docs/screenshot-libraryprojects.png" alt="Projects" width="100%"/>
|
||||
<br/><b>Projects</b><br/>
|
||||
<sub>Dub projects, voice profiles, generation history, exports — all searchable.</sub>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" colspan="2">
|
||||
<img src="docs/screenshot-logs.png" alt="Settings — Logs" width="100%"/>
|
||||
<br/><b>Settings → Logs</b><br/>
|
||||
<sub>Live backend, frontend, and Tauri runtime logs. Filter, refresh, clear.</sub>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## Why OVS?
|
||||
<a id="why-ovs"></a>
|
||||
|
||||
## 💡 Why OmniVoice?
|
||||
|
||||
ElevenLabs charges **$5–$330/mo** and processes your audio on their servers. OmniVoice Studio runs **on your hardware, with no usage limits.**
|
||||
|
||||
@@ -242,13 +246,13 @@ ElevenLabs charges **$5–$330/mo** and processes your audio on their servers. O
|
||||
| **API Keys** | Required | Not needed |
|
||||
| **GPU Support** | N/A (cloud) | CUDA · Apple Silicon · ROCm · CPU |
|
||||
| **Desktop App** | ❌ | ✅ macOS · Windows · Linux |
|
||||
| **TTS Engines** | 1 | **11** (OmniVoice, CosyVoice 3, GPT-SoVITS, VoxCPM2, MOSS-TTS-Nano, KittenTTS, MLX-Audio, Sherpa-ONNX, IndexTTS 2, OmniVoice GGUF, Supertonic 3) |
|
||||
| **TTS Engines** | 1 | **14** (OmniVoice, CosyVoice 3, GPT-SoVITS, VoxCPM2, MOSS-TTS-Nano, KittenTTS, MLX-Audio, Sherpa-ONNX, IndexTTS 2, OmniVoice GGUF, Supertonic 3, MOSS-TTS-v1.5, dots.tts, Confucius4-TTS) |
|
||||
| **ASR Engines** | 1 | **9** (WhisperX, Faster-Whisper, MLX Whisper, PyTorch Whisper, Parakeet, Moonshine, FunASR, isolated Faster-Whisper, sherpa-onnx live dictation) |
|
||||
| **MCP Server** | ❌ | ✅ Use from Claude, Cursor, any MCP client |
|
||||
| **Self-check** | ❌ | ✅ Diagnostics suite, error journal, scrubbed debug bundles |
|
||||
| **Customizable** | ❌ Closed | ✅ Fork it, extend it, ship it |
|
||||
|
||||
OmniVoice Studio gives you professional-grade AI tools without the subscription or the cloud.
|
||||
Professional-grade voice AI, minus the subscription and the cloud.
|
||||
|
||||
<div align="center">
|
||||
<br/>
|
||||
@@ -259,11 +263,11 @@ OmniVoice Studio gives you professional-grade AI tools without the subscription
|
||||
|
||||
---
|
||||
|
||||
## System Requirements
|
||||
## 🖥️ System Requirements
|
||||
|
||||
| | **Minimum** | **Recommended** |
|
||||
|---|---|---|
|
||||
| **OS** | Windows 10, macOS 12+, Ubuntu 20.04+ | Any modern 64-bit OS |
|
||||
| **OS** | Windows 10, macOS 12+ (Apple Silicon), Ubuntu 20.04+ | Any modern 64-bit OS |
|
||||
| **RAM** | 8 GB | 16 GB+ |
|
||||
| **VRAM (GPU)** | 4 GB (auto-offloads TTS to CPU) | 8 GB+ (NVIDIA RTX 3060+) |
|
||||
| **Disk** | 10 GB free (models + cache) | 20 GB+ SSD |
|
||||
@@ -273,9 +277,19 @@ OmniVoice Studio gives you professional-grade AI tools without the subscription
|
||||
> [!TIP]
|
||||
> On GPUs with **≤8 GB VRAM**, OmniVoice automatically offloads TTS to CPU during transcription — no config needed. A dedicated GPU is not required; the entire pipeline runs on CPU (just slower).
|
||||
|
||||
### TTS Engines
|
||||
> [!IMPORTANT]
|
||||
> **macOS Intel (x86_64) is unsupported for the local backend:** the app UI installs, but the Python backend cannot run because PyTorch no longer ships Intel-Mac wheels ([#889](https://github.com/debpalash/OmniVoice-Studio/issues/889)). Intel-Mac users can still point the UI at a remote backend on another machine — see [docs/install/macos.md](docs/install/macos.md).
|
||||
|
||||
OmniVoice ships a multi-engine TTS backend. The default engine (OmniVoice) is always available; additional engines are opt-in and auto-detected. Switch engines in **Settings → TTS Engine** or via the `OMNIVOICE_TTS_BACKEND` env var.
|
||||
<a id="tts-engines"></a>
|
||||
|
||||
### 🗣️ TTS Engines
|
||||
|
||||
**14 engines, one picker.** OmniVoice (default, 600+ languages) is always available; CosyVoice 3, GPT-SoVITS, VoxCPM2, MOSS-TTS-Nano, KittenTTS, MLX-Audio, and Sherpa-ONNX are opt-in and auto-detected — plus six lazy-installed heavyweights (IndexTTS 2, OmniVoice GGUF, Supertonic 3, MOSS-TTS-v1.5, dots.tts, Confucius4-TTS). Switch in **Settings → TTS Engine** or via the `OMNIVOICE_TTS_BACKEND` env var.
|
||||
|
||||
<details>
|
||||
<summary><b>📊 The full matrix</b> — 14 engines × platform × clone/instruct × license</summary>
|
||||
|
||||
<br/>
|
||||
|
||||
| Engine | Languages | Clone | Instruct | Linux | macOS ARM | Windows | License |
|
||||
|--------|:---------:|:-----:|:--------:|:-----:|:---------:|:-------:|:-------:|
|
||||
@@ -298,9 +312,18 @@ OmniVoice ships a multi-engine TTS backend. The default engine (OmniVoice) is al
|
||||
>
|
||||
> **MOSS-TTS-v1.5** (8B, ~16 GB weights) and **dots.tts** (2B, ~9 GB weights) are heavyweight opt-in engines that run in their own isolated venv from a local clone — see [MOSS-TTS-v1.5](docs/engines/moss-tts-v15.md) and [dots.tts](docs/engines/dots-tts.md). Neither claims Apple-Silicon **MPS** (upstream is CUDA/CPU only; on a Mac they run on CPU). dots.tts upstream is Linux/macOS only — no Windows path. **Confucius4-TTS** (14-language cross-lingual zero-shot cloning) is similar — its own Python 3.10 venv from a clone; CUDA recommended, CPU validated end-to-end (slow, ~17× realtime; no MPS — tested slower than CPU); see [Confucius4-TTS](docs/engines/confucius4-tts.md).
|
||||
|
||||
### ASR Engines
|
||||
</details>
|
||||
|
||||
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.
|
||||
<a id="asr-engines"></a>
|
||||
|
||||
### 🎧 ASR Engines
|
||||
|
||||
**9 engines, all fully local** — they power dictation, video dubbing, and subtitles. **WhisperX** is the cross-platform default (~100 languages, word-level timing); the rest are opt-in and auto-detected. Switch in **Settings → ASR Engine** or via the `OMNIVOICE_ASR_BACKEND` env var.
|
||||
|
||||
<details>
|
||||
<summary><b>📊 The full lineup</b> — 9 engines, what each is best at, and compute-type notes</summary>
|
||||
|
||||
<br/>
|
||||
|
||||
| Engine | `OMNIVOICE_ASR_BACKEND` | Languages | Best for |
|
||||
|--------|-------------------------|:---------:|----------|
|
||||
@@ -318,9 +341,11 @@ OmniVoice ships a multi-engine ASR (speech-to-text) backend that powers dictatio
|
||||
|
||||
> **GPU without efficient float16?** On older NVIDIA GPUs (Maxwell/Pascal, GTX 16xx) or after a CTranslate2/cuDNN mismatch, the CTranslate2 ASR engines (WhisperX, Faster-Whisper) can't run `float16` and OmniVoice automatically retries on `int8` — no config needed. If transcription still fails, pin the compute type with the `ASR_COMPUTE_TYPE` env var (escape hatch): `ASR_COMPUTE_TYPE=int8` (or `float32` for CPU). Set it to `int8` and restart the backend.
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
## 🏗️ Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
@@ -338,11 +363,50 @@ OmniVoice ships a multi-engine ASR (speech-to-text) backend that powers dictatio
|
||||
CUDA / MPS / ROCm / CPU (auto-detected + routed)
|
||||
```
|
||||
|
||||
<a id="openai-api"></a>
|
||||
|
||||
## 🔌 OpenAI-compatible API
|
||||
|
||||
Already have a script, agent, or tool that speaks OpenAI's audio API? Point it at `http://localhost:3900/v1` — no key needed, no code changes. The backend ships a drop-in surface for the audio endpoints, wired to whichever TTS/ASR engine you have active (and yes, `voice` accepts your cloned voice-profile IDs).
|
||||
|
||||
| Endpoint | What it does |
|
||||
|---|---|
|
||||
| `POST /v1/audio/speech` | TTS — text in; `mp3` / `wav` / `flac` / `opus` / `pcm` out. `tts-1` / `tts-1-hd` map to your active engine; OpenAI voice names (`alloy`, …) are accepted. |
|
||||
| `POST /v1/audio/transcriptions` | STT — audio file in; `json`, `text`, `verbose_json`, `srt`, or `vtt` out. `whisper-1` maps to your active ASR engine. |
|
||||
| `GET /v1/audio/voices` | OmniVoice extension — lists every voice profile and engine, so clients can discover your clones. |
|
||||
|
||||
```sh
|
||||
curl http://localhost:3900/v1/audio/speech \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model": "tts-1", "voice": "alloy", "input": "Generated on my own hardware.", "response_format": "wav"}' \
|
||||
--output speech.wav
|
||||
```
|
||||
|
||||
```python
|
||||
from openai import OpenAI
|
||||
client = OpenAI(base_url="http://localhost:3900/v1", api_key="none") # any string works — nothing checks it
|
||||
|
||||
result = client.audio.transcriptions.create(model="whisper-1", file=open("clip.wav", "rb"))
|
||||
print(result.text)
|
||||
```
|
||||
|
||||
Want the whole surface (100+ endpoints)? The full REST API reference is embedded in the app — **Settings → OpenAPI Reference** (Scalar-powered), or the `{}` button in the footer.
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
## 🗺️ Roadmap
|
||||
|
||||
### ✅ Shipped
|
||||
### 🔜 Up Next
|
||||
|
||||
- 🎬 **Lip-sync v2** — visual speech timing with wav2lip
|
||||
- 🌐 **Hosted Demo** — try OmniVoice without installing anything
|
||||
- 🔌 **Plugin Marketplace** — community-contributed TTS engines and effects
|
||||
- 🎵 **Real-time Voice Changer** — live microphone transformation during calls
|
||||
|
||||
<details>
|
||||
<summary><b>✅ Everything shipped so far</b> — the receipts, by category</summary>
|
||||
|
||||
<br/>
|
||||
|
||||
| Category | Features |
|
||||
|----------|----------|
|
||||
@@ -353,29 +417,26 @@ OmniVoice ships a multi-engine ASR (speech-to-text) backend that powers dictatio
|
||||
| **Multi-Lang** | Multi-language batch picker, batch dubbing queue with sequential GPU execution |
|
||||
| **Diarization** | Pyannote ML diarization, auto speaker clone extraction, per-speaker voice assignment |
|
||||
| **ASR** | 9 engines (WhisperX, Faster-Whisper, isolated Faster-Whisper, MLX Whisper, PyTorch Whisper, Parakeet TDT, Moonshine, FunASR/SenseVoice, sherpa-onnx live dictation), crash-isolated subprocess backend |
|
||||
| **TTS** | 11 engines (OmniVoice, CosyVoice 3, GPT-SoVITS, VoxCPM2, MOSS-TTS-Nano, KittenTTS, MLX-Audio, Sherpa-ONNX, + lazy: IndexTTS 2, OmniVoice GGUF, Supertonic 3), engine routing with GPU preflight |
|
||||
| **TTS** | 14 engines (OmniVoice, CosyVoice 3, GPT-SoVITS, VoxCPM2, MOSS-TTS-Nano, KittenTTS, MLX-Audio, Sherpa-ONNX, + lazy: IndexTTS 2, OmniVoice GGUF, Supertonic 3, MOSS-TTS-v1.5, dots.tts, Confucius4-TTS), engine routing with GPU preflight |
|
||||
| **Infra** | Docker deployment, CUDA/MPS/ROCm auto-detect, cuDNN 8 compat, VRAM-aware model offloading, engine routing (no silent CPU fallback), diagnostics suite & error journal, restricted-network mirror support |
|
||||
| **AI Provenance** | AudioSeal invisible watermarking (SynthID-like), video logo overlay, watermark detection API |
|
||||
| **UX** | Undo/redo, keyboard shortcuts, drag-and-drop, session persistence, glassmorphism design system, UI scale fix for Linux/WebKitGTK |
|
||||
| **Real-time Events** | WebSocket event bus — instant sidebar refresh on data mutations, exponential backoff reconnect |
|
||||
| **State Management** | Zustand store migration — `uiSlice`, `pillSlice`, `dubSlice`, `generateSlice`, `prefsSlice`, `glossarySlice` |
|
||||
| **Desktop** | Cross-platform Tauri installers (macOS DMG/Intel, Windows MSI, Linux deb/AppImage), auto-update infrastructure, single-instance enforcement, close-to-tray, macOS Gatekeeper fix |
|
||||
| **Desktop** | Cross-platform Tauri installers (macOS DMG — Apple Silicon; Intel unsupported for the local backend, #889 — Windows MSI, Linux deb/AppImage), auto-update infrastructure, single-instance enforcement, close-to-tray, macOS Gatekeeper fix |
|
||||
| **Dictation** | Global system-wide hotkey (`⌘+⇧+Space`), frameless floating widget, streaming ASR via WebSocket, auto-paste, customizable hotkey, local-LLM transcript refinement |
|
||||
| **Batch Pipeline** | Full batch TTS: extract → transcribe → translate → generate → mix → export, with live progress tracking |
|
||||
| **MCP Server** | OmniVoice as a local TTS/STT provider for Claude, Cursor, and any MCP client |
|
||||
| **Remote Backend** | Point the desktop UI at a remote backend URL with bearer auth (Tailscale-documented) |
|
||||
| **Reliability** | Stall watchdog on bootstrap splash, per-engine GPU compatibility matrix, actionable errors for non-executable engine binaries, setuptools auto-repair |
|
||||
|
||||
### 🔜 Up Next
|
||||
|
||||
- 🎬 **Lip-sync v2** — visual speech timing with wav2lip
|
||||
- 🌐 **Hosted Demo** — try OmniVoice without installing anything
|
||||
- 🔌 **Plugin Marketplace** — community-contributed TTS engines and effects
|
||||
- 🎵 **Real-time Voice Changer** — live microphone transformation during calls
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## Sponsor / Donate
|
||||
<a id="sponsor--donate"></a>
|
||||
|
||||
## 💜 Sponsor / Donate
|
||||
|
||||
OmniVoice Studio is built by one developer using Claude Code and AI agents — and the agent bills are real. Over the last three months I've spent thousands of dollars on Claude subscriptions to keep the features shipping, the bugs fixed, and your issues answered. If OmniVoice has created value for you, helping cover those bills means I can keep developing full-time.
|
||||
|
||||
@@ -396,31 +457,57 @@ OmniVoice Studio is built by one developer using Claude Code and AI agents — a
|
||||
|
||||
</div>
|
||||
|
||||
<a id="sponsors"></a>
|
||||
|
||||
### 🌟 Sponsors
|
||||
|
||||
OmniVoice is **free** and **AGPL-3.0** — no paid tier, no SaaS revenue. Sponsors keep development going, and in return get a logo slot here, in the app, and (for top tiers) on the project website. It's a thank-you, never a paywall. **[See tiers & become a sponsor →](SPONSORS.md)**
|
||||
|
||||
<div align="center">
|
||||
|
||||
<!-- SPONSORS:START — logo slots are filled here as sponsors come aboard; see SPONSORS.md -->
|
||||
|
||||
**Your logo here** — [become a sponsor](SPONSORS.md)
|
||||
|
||||
<!-- SPONSORS:END -->
|
||||
|
||||
</div>
|
||||
|
||||
<sub>💡 GitHub also shows a **Sponsor** button at the top of this repo, wired to the same links via <a href=".github/FUNDING.yml"><code>.github/FUNDING.yml</code></a>.</sub>
|
||||
|
||||
---
|
||||
|
||||
## Community
|
||||
## 💬 Community
|
||||
|
||||
<div align="center">
|
||||
<a href="https://discord.gg/bzQavDfVV9"><img src="https://img.shields.io/badge/💬_Discord-Join_Community-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Join Discord" /></a>
|
||||
<br/>
|
||||
<sub>We respond to setup questions within hours, not days.</sub>
|
||||
</div>
|
||||
|
||||
<details>
|
||||
<summary><b>What happens in there</b></summary>
|
||||
|
||||
<br/>
|
||||
|
||||
| Channel | What happens there |
|
||||
|---------|--------------------|
|
||||
| `#showcase` | Members share their dubs, clones, and voice designs |
|
||||
| `#help` | Setup issues, GPU troubleshooting, model questions |
|
||||
| `#feature-requests` | Vote on what gets built next |
|
||||
| `#dev` | Architecture discussions, PR reviews, engine integrations |
|
||||
| `#announcements` | Release notes, breaking changes, early access |
|
||||
| `#announcements` | Release news and the big moments — new versions land here first |
|
||||
| `#releases` + `#changelog` | Every build and exactly what's inside it |
|
||||
| `#issues` | Bug reports as forum posts — triaged straight into GitHub issues |
|
||||
| `#ideas` | Feature requests, discussed and voted on |
|
||||
| `#discuss-ideas` | Design talk before things get built |
|
||||
| `#general` | Setup help, GPU troubleshooting, and showing off your dubs |
|
||||
|
||||
**[→ Join the Discord](https://discord.gg/bzQavDfVV9)** — we respond to setup questions within hours, not days.
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
<a id="contributing"></a>
|
||||
|
||||
We welcome contributions of all kinds — bug fixes, new TTS engine adapters, UI improvements, docs, and translations.
|
||||
## 🤝 Contributing
|
||||
|
||||
Yes please — bug fixes, new TTS engine adapters, UI improvements, docs, translations. All of it.
|
||||
|
||||
- 📖 Read the **[Contributing Guide](CONTRIBUTING.md)** for setup, code style, and PR workflow
|
||||
- 🐛 Browse [good first issues](https://github.com/debpalash/OmniVoice-Studio/labels/good%20first%20issue)
|
||||
@@ -428,7 +515,7 @@ We welcome contributions of all kinds — bug fixes, new TTS engine adapters, UI
|
||||
|
||||
---
|
||||
|
||||
## FAQ
|
||||
## ❓ FAQ
|
||||
|
||||
<details>
|
||||
<summary><b>Is this really as good as ElevenLabs?</b></summary>
|
||||
@@ -439,7 +526,7 @@ For voice cloning and dubbing, yes — OmniVoice uses a state-of-the-art diffusi
|
||||
<details>
|
||||
<summary><b>Does it work on Apple Silicon (M1/M2/M3/M4)?</b></summary>
|
||||
<br/>
|
||||
Yes. MPS acceleration is auto-detected. MLX-optimized Whisper models are available for faster transcription on Apple hardware.
|
||||
Yes. MPS acceleration is auto-detected. MLX-optimized Whisper models are available for faster transcription on Apple hardware. <b>Intel Macs are not supported</b>: the app UI installs, but the local Python backend cannot run because PyTorch no longer ships Intel-Mac wheels (<a href="https://github.com/debpalash/OmniVoice-Studio/issues/889">#889</a>) — an Intel Mac can only be used with a remote backend.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -463,12 +550,14 @@ Yes. MPS acceleration is auto-detected. MLX-optimized Whisper models are availab
|
||||
<details>
|
||||
<summary><b>Can I add my own TTS engine?</b></summary>
|
||||
<br/>
|
||||
Yes. OmniVoice uses a <b>built-in backend registry</b>. To add an engine in ~50 lines, subclass <code>TTSBackend</code> in <code>backend/services/tts_backend.py</code> and add it to the <code>_REGISTRY</code> dictionary. Eleven engines are built in: OmniVoice, CosyVoice 3, GPT-SoVITS, MLX-Audio (14+ sub-engines), VoxCPM2, MOSS-TTS-Nano, KittenTTS, Sherpa-ONNX, plus lazy-registered IndexTTS 2, OmniVoice GGUF, and Supertonic 3. See the <a href="#tts-engines">TTS Engines</a> section for details.
|
||||
Yes. OmniVoice uses a <b>built-in backend registry</b>. To add an engine in ~50 lines, subclass <code>TTSBackend</code> in <code>backend/services/tts_backend.py</code> and add it to the <code>_REGISTRY</code> dictionary. Fourteen engines are built in: OmniVoice, CosyVoice 3, GPT-SoVITS, MLX-Audio (14+ sub-engines), VoxCPM2, MOSS-TTS-Nano, KittenTTS, Sherpa-ONNX, plus lazy-registered IndexTTS 2, OmniVoice GGUF, Supertonic 3, MOSS-TTS-v1.5, dots.tts, and Confucius4-TTS. See the <a href="#tts-engines">TTS Engines</a> section for details.
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
<a id="license"></a>
|
||||
|
||||
## 📜 License
|
||||
|
||||
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).
|
||||
|
||||
@@ -480,7 +569,7 @@ The bundled `omnivoice/` TTS model by Han Zhu remains Apache-2.0 upstream. See [
|
||||
|
||||
---
|
||||
|
||||
## Acknowledgments
|
||||
## 🙏 Acknowledgments
|
||||
|
||||
OmniVoice Studio is built on the shoulders of exceptional open-source work:
|
||||
|
||||
@@ -499,6 +588,17 @@ OmniVoice Studio is built on the shoulders of exceptional open-source work:
|
||||
|
||||
---
|
||||
|
||||
## 🧰 More local open-source from the maker
|
||||
|
||||
Like the local-first philosophy? It runs in the family:
|
||||
|
||||
| Project | What it is |
|
||||
|---------|------------|
|
||||
| [**Opal**](https://github.com/debpalash/Opal) 💠 | **Play everything.** The evolved media player for the next decades of entertainment — video, anime, comics, torrents, Jellyfin/Plex, with local AI built in. |
|
||||
| [**memxt**](https://github.com/debpalash/memxt) 🧠 | **The fastest benchmarked open-source AI memory system.** 100% local memory for AI agents, with MCP support. |
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
<br/>
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
<div align="center">
|
||||
<img src="docs/logo.png" alt="OmniVoice Logo" width="96" />
|
||||
<h1>Sponsor OmniVoice Studio</h1>
|
||||
<p><b>Keep the open-source ElevenLabs alternative free, local, and shipping.</b></p>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## Why sponsor?
|
||||
|
||||
OmniVoice Studio is built by one developer, in the open, using Claude Code and AI agents — and the agent bills are real. Over the last few months I've spent thousands of dollars on Claude subscriptions to keep features shipping, bugs fixed, and your issues answered.
|
||||
|
||||
OmniVoice is **free**, **fully local**, and **AGPL-3.0**. There's no paid tier, no accounts, no cloud, and no SaaS revenue — nothing runs on a server we bill you for, because nothing runs on a server at all. That's the whole point, and it's also why there's no recurring revenue to fund development. Sponsorship is what makes continued full-time work possible.
|
||||
|
||||
If OmniVoice has created value for you or your company, sponsoring means the next release keeps coming — and you get a thank-you (and, at most tiers, a logo slot) in return.
|
||||
|
||||
### Where your money goes
|
||||
|
||||
Every dollar goes to the cost of building OmniVoice — chiefly the **AI agent bills that keep it shipping** (Claude subscriptions and API usage), plus the occasional signing certificate, test hardware, and model-hosting costs. It is not a salary top-up; it's what keeps the lights on for continuous development.
|
||||
|
||||
---
|
||||
|
||||
## Sponsorship tiers
|
||||
|
||||
Tiers are about **visibility and gratitude** — what you get is placement, not gated features (see [Not a paywall](#not-a-paywall)). Higher tiers include everything in the tiers below them.
|
||||
|
||||
| Tier | Suggested monthly | What you get |
|
||||
|------|-------------------|--------------|
|
||||
| **🥉 Backer** | _set by owner_ <!-- OWNER: set amounts --> | Your name or handle listed in the **Backers** section of this file, with a link of your choice. |
|
||||
| **🟫 Bronze** | _set by owner_ <!-- OWNER: set amounts --> | Everything above, **plus** a small logo in `SPONSORS.md` **and** in the README [Sponsors section](README.md#sponsors). |
|
||||
| **🥈 Silver** | _set by owner_ <!-- OWNER: set amounts --> | Everything above, **plus** your logo in the **README** and in the app's **in-app Sponsors page footer** (as that page ships). |
|
||||
| **🥇 Gold** | _set by owner_ <!-- OWNER: set amounts --> | Everything above, **plus** a **prominent logo slot** and link on the project **website / landing page**. |
|
||||
|
||||
> **Amounts are set by the maintainer** — look for the `<!-- OWNER: set amounts -->` markers in this file's source. If you don't see a price that fits, say so in your inquiry; custom and annual arrangements are welcome.
|
||||
|
||||
Placements marked "as that page ships" (the in-app Sponsors page and the project website) are on the near-term roadmap. Until they exist, Silver/Gold logos live in `SPONSORS.md` and the README, and are added to the app and site the moment those land — no re-application needed.
|
||||
|
||||
---
|
||||
|
||||
## How to become a sponsor
|
||||
|
||||
**1. Open a sponsorship inquiry (recommended).** This opens a short GitHub form (name/org, logo, tier, contact) so we can get you set up:
|
||||
|
||||
> **[→ Open a sponsorship inquiry](https://github.com/debpalash/OmniVoice-Studio/issues/new?template=sponsor.yml)**
|
||||
|
||||
**2. Or start recurring support directly:**
|
||||
|
||||
- **Ko-fi (recurring or one-time):** [ko-fi.com/debpalash](https://ko-fi.com/debpalash)
|
||||
- **PayPal (one-time):** [paypal.me/palashCoder](https://paypal.me/palashCoder)
|
||||
|
||||
If you sponsor via Ko-fi/PayPal and want a logo slot, still open an inquiry (or drop a note there) so we know who to credit and where to link.
|
||||
|
||||
**3. Prefer to talk first?** Reach out directly:
|
||||
|
||||
- Email: <!-- OWNER: add your sponsor contact email here if you want one public -->
|
||||
- Or ask in the `#dev` / `#announcements` channels on [Discord](https://discord.gg/bzQavDfVV9).
|
||||
|
||||
---
|
||||
|
||||
## Logo & asset guidelines
|
||||
|
||||
To make your logo look sharp everywhere (README on GitHub, the in-app page, the website), please send:
|
||||
|
||||
- **Format:** **SVG preferred** (scales cleanly); otherwise **PNG at 2× resolution**.
|
||||
- **Background:** **transparent** — no baked-in white/black box.
|
||||
- **Contrast:** send a variant that stays legible on **both light and dark** backgrounds, or one light-mode and one dark-mode file (GitHub and the app both render in either theme).
|
||||
- **Dimensions:** legible at **~40px tall**; keep the wordmark within roughly **480px wide**. Landscape/wordmark shapes work best in the README row.
|
||||
- **File size:** keep SVGs under ~50 KB and PNGs under ~100 KB.
|
||||
- **Link target:** the destination URL you want the logo to point to (usually your homepage).
|
||||
|
||||
**How your logo gets added:**
|
||||
|
||||
- **Easiest:** attach the asset and link in your [sponsorship inquiry](https://github.com/debpalash/OmniVoice-Studio/issues/new?template=sponsor.yml) — the maintainer places it.
|
||||
- **Or open a PR:** add your asset under `docs/sponsors/` and an entry to the tables in this file. Silver/Gold logos are also wired into the app's in-app Sponsors page (via the `sponsors.js` manifest) and the project website as those surfaces ship.
|
||||
|
||||
By sponsoring you confirm you have the right to use the submitted logo and grant OmniVoice permission to display it in the contexts above. We won't alter your logo beyond scaling, and we'll remove it promptly on request.
|
||||
|
||||
---
|
||||
|
||||
## Current sponsors
|
||||
|
||||
OmniVoice doesn't have any sponsors yet — **you could be the first.** These slots fill in as sponsors come aboard.
|
||||
|
||||
### 🥇 Gold
|
||||
|
||||
_Be the first Gold sponsor — [claim this slot](#how-to-become-a-sponsor)._
|
||||
|
||||
### 🥈 Silver
|
||||
|
||||
_Open — [become a Silver sponsor](#how-to-become-a-sponsor)._
|
||||
|
||||
### 🟫 Bronze
|
||||
|
||||
_Open — [become a Bronze sponsor](#how-to-become-a-sponsor)._
|
||||
|
||||
### 🥉 Backers
|
||||
|
||||
_Open — [become a Backer](#how-to-become-a-sponsor)._
|
||||
|
||||
<!-- When a sponsor joins, add them to the matching section above:
|
||||
- Logo tiers (Bronze+): <a href="https://sponsor.example"><img src="docs/sponsors/name.svg" alt="Name" height="48" /></a>
|
||||
- Backers: - [Name / handle](https://link) -->
|
||||
|
||||
---
|
||||
|
||||
## Not a paywall
|
||||
|
||||
Sponsorship is a **thank-you, never a paywall.**
|
||||
|
||||
Every feature of OmniVoice Studio is and will remain **free** and **open-source under [AGPL-3.0](LICENSE)**. Sponsors do **not** get private builds, gated features, license exceptions, or anything that degrades the experience for people who don't (or can't) pay. What sponsors get is **visibility and our gratitude** — and the knowledge that they're directly funding the next release.
|
||||
|
||||
OmniVoice stays local-first and fully functional with zero dollars spent. Sponsoring just helps it keep getting better, faster.
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
<sub>Thank you for keeping local-first voice AI alive and free. ❤️</sub><br/>
|
||||
<sub>Questions? <a href="https://github.com/debpalash/OmniVoice-Studio/issues/new?template=sponsor.yml">Open an inquiry</a> · <a href="https://discord.gg/bzQavDfVV9">Discord</a></sub>
|
||||
</div>
|
||||
@@ -120,6 +120,15 @@ async def transcribe_audio(
|
||||
from services.refinement import collapse_repetitive_artifacts
|
||||
full_text = collapse_repetitive_artifacts(full_text)
|
||||
|
||||
# Cross-transport parity: deterministically polish the final text
|
||||
# (leading capital + terminal punctuation) exactly like the live
|
||||
# dictation socket (capture_ws) does, so the widget's POST fallback and
|
||||
# MCP/CLI callers get the same typed-looking result the WS returns —
|
||||
# not the raw "...test" the REST path used to leak. Segments stay raw
|
||||
# (their timings/verbatim recognition are the contract).
|
||||
from services.text_polish import polish_text
|
||||
full_text = polish_text(full_text)
|
||||
|
||||
# Calculate audio duration from segments if available
|
||||
duration = 0.0
|
||||
if segments:
|
||||
@@ -135,8 +144,12 @@ async def transcribe_audio(
|
||||
if _truthy(refine) and full_text:
|
||||
from services.refinement import maybe_refine
|
||||
refined = await asyncio.to_thread(maybe_refine, full_text)
|
||||
if refined and refined != full_text:
|
||||
refined_text = refined
|
||||
if refined:
|
||||
# Polish the refined text too, so both surfaced strings read as
|
||||
# typed text (mirrors the raw-vs-refined contract of the WS).
|
||||
refined = polish_text(refined)
|
||||
if refined != full_text:
|
||||
refined_text = refined
|
||||
|
||||
logger.info(
|
||||
"Capture transcription done: engine=%s, elapsed=%.2fs, duration=%.1fs, mode=%s, refined=%s",
|
||||
|
||||
@@ -19,7 +19,15 @@ Protocol:
|
||||
"segments": [...], "language": "en",
|
||||
"duration_s": 4.2, "transcription_time_s": 0.8,
|
||||
"engine": "mlx-whisper"}
|
||||
{"type": "error", "detail": "..."} — error
|
||||
{"type": "status", "stage": "downloading"|"loading"|"ready"}
|
||||
— model cold-start
|
||||
{"type": "error", "message": "...", "kind": "...",
|
||||
"detail": "..."} — error ("detail"
|
||||
kept for legacy)
|
||||
|
||||
Every ``final`` text is normalised by services.text_polish (leading
|
||||
capital for Latin scripts, terminal punctuation, single-spaced) so the
|
||||
pasted result reads like typed text. Partials are raw.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -32,6 +40,7 @@ import time
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
||||
from api.dependencies import _LOOPBACK_HOSTS, ws_remote_authorized
|
||||
from services.text_polish import polish_text
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger("omnivoice.capture_ws")
|
||||
@@ -302,20 +311,29 @@ async def ws_transcribe(websocket: WebSocket):
|
||||
if total_bytes > MIN_FINAL_BUFFER_BYTES:
|
||||
try:
|
||||
result = await _transcribe_buffer_full(audio_chunks, pcm_sr=pcm_sr)
|
||||
# Dictation v2: deterministic polish so the pasted final reads
|
||||
# like typed text (leading capital, terminal punctuation).
|
||||
result["text"] = polish_text(result.get("text", ""))
|
||||
# Wave 2.1: optional local-LLM refinement of the final text.
|
||||
# Off-thread (network call, not GPU); pass-through on any
|
||||
# failure or when no LLM backend is configured. The raw text
|
||||
# always ships too — clients paste refined_text ?? text.
|
||||
# HARD-BOUNDED (maybe_refine_async, ~4s OMNIVOICE_REFINE_TIMEOUT_S):
|
||||
# a slow/dead LLM can never delay this `final` beyond the budget —
|
||||
# it falls back to the unrefined (but polished) text. Best-effort:
|
||||
# never let refinement turn a good final into an error. The raw
|
||||
# text always ships too — clients paste refined_text ?? text.
|
||||
if result.get("text"):
|
||||
from services.refinement import maybe_refine
|
||||
refined = await asyncio.to_thread(maybe_refine, result["text"])
|
||||
if refined and refined != result["text"]:
|
||||
result["refined_text"] = refined
|
||||
try:
|
||||
from services.refinement import maybe_refine_async
|
||||
refined = await maybe_refine_async(result["text"])
|
||||
if refined and refined != result["text"]:
|
||||
result["refined_text"] = refined
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("Dictation refinement skipped: %s", e)
|
||||
if not await _safe_send({"type": "final", **result}):
|
||||
logger.debug("Skipped final send — client already disconnected")
|
||||
except Exception as e:
|
||||
logger.error("Final transcription failed: %s", e)
|
||||
await _safe_send({"type": "error", "detail": str(e)})
|
||||
await _safe_send({"type": "error", "message": str(e),
|
||||
"kind": "transcribe", "detail": str(e)})
|
||||
else:
|
||||
await _safe_send({
|
||||
"type": "final",
|
||||
@@ -340,10 +358,19 @@ async def ws_transcribe(websocket: WebSocket):
|
||||
# opt-in 1-byte type prefix when ?aec=1, else bare PCM) at ?sr= (default 16000).
|
||||
# This is the low-latency transport — no WebM/ffmpeg in the hot path.
|
||||
|
||||
# How often the offline-kind handler re-decodes the growing buffer for a live
|
||||
# partial (streaming-kind decodes every frame, no cadence needed).
|
||||
# How often the offline-kind handler re-decodes the live window for a partial
|
||||
# (streaming-kind decodes every frame, no cadence needed).
|
||||
SHERPA_OFFLINE_PARTIAL_S = float(os.environ.get("OMNIVOICE_SHERPA_OFFLINE_PARTIAL", "0.8"))
|
||||
|
||||
# Utterance gate for the offline-kind handler: once the trailing this-many
|
||||
# seconds of the live buffer fall below the RMS floor, the utterance is
|
||||
# COMMITTED — decoded, flushed as a `final`, and dropped from the buffer. Each
|
||||
# decode is thereby bounded by one utterance instead of the whole session
|
||||
# (the old full-buffer re-decode was O(n²)), and a sentence commits ~0.6s
|
||||
# after the user stops speaking instead of only at EOF.
|
||||
SHERPA_OFFLINE_SILENCE_S = float(os.environ.get("OMNIVOICE_SHERPA_OFFLINE_SILENCE", "0.6"))
|
||||
SHERPA_OFFLINE_RMS_FLOOR = float(os.environ.get("OMNIVOICE_SHERPA_OFFLINE_RMS", "0.01"))
|
||||
|
||||
|
||||
def _pcm16_to_f32(pcm: bytes):
|
||||
"""int16 little-endian mono PCM bytes → float32 numpy in [-1, 1]."""
|
||||
@@ -411,30 +438,64 @@ async def _recv_pcm_frame(websocket: WebSocket, aec):
|
||||
return "skip", b""
|
||||
|
||||
|
||||
async def _sherpa_load_with_status(websocket: WebSocket, backend, spec) -> bool:
|
||||
"""Build the recognizer off the event loop, narrating cold-start progress.
|
||||
|
||||
Sends ``{"type":"status","stage":"downloading"|"loading"}`` before the
|
||||
load ("downloading" when the pinned assets aren't in the HF cache yet;
|
||||
stage-only — HF's per-file progress isn't worth a callback plumb-through)
|
||||
and ``{"type":"status","stage":"ready"}`` after, so the widget can show
|
||||
*why* the first dictation takes a moment. Returns False when the load
|
||||
failed (the error frame is sent and the socket closed here).
|
||||
"""
|
||||
try:
|
||||
from services import sherpa_dictation as _sd
|
||||
stage = "loading" if _sd.is_installed(spec) else "downloading"
|
||||
except Exception:
|
||||
stage = "loading"
|
||||
try:
|
||||
await websocket.send_json({"type": "status", "stage": stage})
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await asyncio.to_thread(backend.ensure_loaded)
|
||||
except Exception as e:
|
||||
logger.error("sherpa dictation load failed (%s): %s", spec.id, e)
|
||||
try:
|
||||
await websocket.send_json({"type": "error", "message": str(e),
|
||||
"kind": "load", "detail": str(e)})
|
||||
await websocket.close()
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
try:
|
||||
await websocket.send_json({"type": "status", "stage": "ready"})
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
|
||||
|
||||
async def _run_sherpa_streaming(websocket: WebSocket, spec):
|
||||
"""True streaming: feed the OnlineRecognizer frame-by-frame, emit `partial`
|
||||
every time the decoded text grows, and `final` on sherpa's endpoint (silence)
|
||||
detection and on EOF. <300ms perceived latency on CPU for the tiny models.
|
||||
"""
|
||||
import numpy as np
|
||||
from services.asr_backend import SherpaDictationBackend
|
||||
from services.asr_backend import get_sherpa_dictation_backend
|
||||
|
||||
pcm_sr, aec = await _sherpa_session(websocket)
|
||||
logger.info("sherpa streaming dictation: model=%s sr=%d aec=%s",
|
||||
spec.id, pcm_sr, bool(aec))
|
||||
|
||||
backend = SherpaDictationBackend(model_id=spec.id)
|
||||
# Build the recognizer off the event loop (download-on-first-use + ONNX
|
||||
# session init can take a moment); keep the socket responsive.
|
||||
try:
|
||||
await asyncio.to_thread(backend.ensure_loaded)
|
||||
except Exception as e:
|
||||
logger.error("sherpa streaming load failed: %s", e)
|
||||
try:
|
||||
await websocket.send_json({"type": "error", "detail": str(e)})
|
||||
await websocket.close()
|
||||
except Exception:
|
||||
pass
|
||||
# Reuse the shared, per-model warm backend (#888): the recognizer is built
|
||||
# once and shared across sessions instead of rebuilt (1.3–2.5s) per connect,
|
||||
# so the first dictation is instant when the preload warmed it. Each session
|
||||
# still gets its own decode stream below.
|
||||
backend = get_sherpa_dictation_backend(spec.id)
|
||||
# Build the recognizer off the event loop if it isn't warm yet
|
||||
# (download-on-first-use + ONNX session init can take a moment); status
|
||||
# frames keep the widget honest.
|
||||
if not await _sherpa_load_with_status(websocket, backend, spec):
|
||||
return
|
||||
rec = backend._rec
|
||||
stream = rec.create_stream()
|
||||
@@ -484,7 +545,9 @@ async def _run_sherpa_streaming(websocket: WebSocket, spec):
|
||||
continue
|
||||
text, endpoint = await asyncio.to_thread(_decode_after_feed, pcm)
|
||||
if endpoint:
|
||||
# Commit this utterance; reset for the next one.
|
||||
# Commit this utterance (polished — it gets pasted); reset
|
||||
# for the next one.
|
||||
text = polish_text(text)
|
||||
if text:
|
||||
committed.append(text)
|
||||
await _send({"type": "final", "text": text,
|
||||
@@ -507,16 +570,20 @@ async def _run_sherpa_streaming(websocket: WebSocket, spec):
|
||||
except Exception as e:
|
||||
logger.debug("sherpa streaming flush failed: %s", e)
|
||||
tail_text = ""
|
||||
tail_text = polish_text(tail_text)
|
||||
if tail_text and tail_text != (committed[-1] if committed else None):
|
||||
committed.append(tail_text)
|
||||
|
||||
# Pieces are already polished; the join is too (polish is idempotent).
|
||||
full = " ".join(t for t in committed if t).strip()
|
||||
segments = [{"start": 0.0, "end": None, "text": t} for t in committed if t]
|
||||
if not client_disconnected:
|
||||
if full:
|
||||
# Hard-bounded refinement (~4s): never delays this summary `final`
|
||||
# beyond OMNIVOICE_REFINE_TIMEOUT_S even with a dead LLM endpoint.
|
||||
try:
|
||||
from services.refinement import maybe_refine
|
||||
refined = await asyncio.to_thread(maybe_refine, full)
|
||||
from services.refinement import maybe_refine_async
|
||||
refined = await maybe_refine_async(full)
|
||||
except Exception:
|
||||
refined = None
|
||||
payload = {"type": "final", "text": full, "segments": segments,
|
||||
@@ -534,32 +601,34 @@ async def _run_sherpa_streaming(websocket: WebSocket, spec):
|
||||
|
||||
|
||||
async def _run_sherpa_offline(websocket: WebSocket, spec):
|
||||
"""Offline-kind sherpa model with live partials: buffer raw PCM and
|
||||
re-decode the growing buffer every ~800ms so the user still sees text
|
||||
appear while speaking; finalize on EOF/silence."""
|
||||
from services.asr_backend import SherpaDictationBackend
|
||||
"""Offline-kind sherpa model with live partials, utterance-windowed.
|
||||
|
||||
Raw PCM accumulates in a *live* buffer holding only the current
|
||||
(uncommitted) utterance. Every ~800ms the live window is re-decoded for a
|
||||
``partial``; when the trailing ~0.6s of it fall below the RMS floor the
|
||||
utterance is committed — decoded once more, flushed as a ``final``, and
|
||||
its samples dropped — so per-partial cost is bounded by one utterance
|
||||
(not the whole session) and sentences commit as the user pauses instead
|
||||
of only at EOF."""
|
||||
from services.asr_backend import get_sherpa_dictation_backend
|
||||
|
||||
pcm_sr, aec = await _sherpa_session(websocket)
|
||||
logger.info("sherpa offline dictation: model=%s sr=%d aec=%s",
|
||||
spec.id, pcm_sr, bool(aec))
|
||||
|
||||
backend = SherpaDictationBackend(model_id=spec.id)
|
||||
try:
|
||||
await asyncio.to_thread(backend.ensure_loaded)
|
||||
except Exception as e:
|
||||
logger.error("sherpa offline load failed: %s", e)
|
||||
try:
|
||||
await websocket.send_json({"type": "error", "detail": str(e)})
|
||||
await websocket.close()
|
||||
except Exception:
|
||||
pass
|
||||
# Shared, per-model warm backend (#888) — built once, reused per session.
|
||||
backend = get_sherpa_dictation_backend(spec.id)
|
||||
if not await _sherpa_load_with_status(websocket, backend, spec):
|
||||
return
|
||||
|
||||
buf = bytearray()
|
||||
buf = bytearray() # live (uncommitted) PCM only
|
||||
committed: list[str] = [] # polished utterances already flushed
|
||||
last_partial = ""
|
||||
running = True
|
||||
client_disconnected = False
|
||||
last_audio = time.monotonic()
|
||||
# Trailing-silence gate window, in bytes of int16 mono PCM.
|
||||
sil_bytes = max(2, int(SHERPA_OFFLINE_SILENCE_S * pcm_sr) * 2)
|
||||
|
||||
async def _send(payload) -> bool:
|
||||
nonlocal client_disconnected
|
||||
@@ -572,8 +641,14 @@ async def _run_sherpa_offline(websocket: WebSocket, spec):
|
||||
client_disconnected = True
|
||||
return False
|
||||
|
||||
def _decode_buffer() -> str:
|
||||
samples = _pcm16_to_f32(bytes(buf))
|
||||
def _rms(pcm: bytes) -> float:
|
||||
samples = _pcm16_to_f32(pcm)
|
||||
if not len(samples):
|
||||
return 0.0
|
||||
return float((samples * samples).mean() ** 0.5)
|
||||
|
||||
def _decode_window(pcm: bytes) -> str:
|
||||
samples = _pcm16_to_f32(pcm)
|
||||
if not len(samples):
|
||||
return ""
|
||||
return backend._decode_offline(samples, pcm_sr)
|
||||
@@ -597,14 +672,43 @@ async def _run_sherpa_offline(websocket: WebSocket, spec):
|
||||
logger.debug("sherpa offline receive ended: %s", e)
|
||||
running = False
|
||||
|
||||
async def _commit(snapshot: bytes):
|
||||
"""Finalize one utterance: decode it off-thread, flush a polished
|
||||
`final`, drop its samples from the live buffer. `receive()` may
|
||||
append while we decode — only the snapshot's prefix is dropped."""
|
||||
nonlocal last_partial
|
||||
try:
|
||||
text = await asyncio.to_thread(_decode_window, snapshot)
|
||||
except Exception as e:
|
||||
logger.debug("sherpa offline commit decode failed: %s", e)
|
||||
return
|
||||
del buf[:len(snapshot)]
|
||||
last_partial = ""
|
||||
text = polish_text(text)
|
||||
if text:
|
||||
committed.append(text)
|
||||
await _send({"type": "final", "text": text,
|
||||
"segments": [{"start": 0.0, "end": None, "text": text}],
|
||||
"language": "auto", "engine": backend.id})
|
||||
|
||||
async def partials():
|
||||
nonlocal last_partial, running
|
||||
while running:
|
||||
await asyncio.sleep(SHERPA_OFFLINE_PARTIAL_S)
|
||||
if not running or len(buf) < 2000:
|
||||
continue
|
||||
snapshot = bytes(buf)
|
||||
if len(snapshot) > sil_bytes and \
|
||||
_rms(snapshot[-sil_bytes:]) < SHERPA_OFFLINE_RMS_FLOOR:
|
||||
if _rms(snapshot[:-sil_bytes]) >= SHERPA_OFFLINE_RMS_FLOOR:
|
||||
await _commit(snapshot)
|
||||
else:
|
||||
# Pure silence — drop it (keep the gate window for
|
||||
# continuity) so a long pause can't grow the buffer.
|
||||
del buf[:len(snapshot) - sil_bytes]
|
||||
continue
|
||||
try:
|
||||
text = await asyncio.to_thread(_decode_buffer)
|
||||
text = await asyncio.to_thread(_decode_window, snapshot)
|
||||
except Exception as e:
|
||||
logger.debug("sherpa offline partial failed: %s", e)
|
||||
continue
|
||||
@@ -624,20 +728,26 @@ async def _run_sherpa_offline(websocket: WebSocket, spec):
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
|
||||
# Drain the trailing (un-committed) utterance on EOF.
|
||||
try:
|
||||
full = await asyncio.to_thread(_decode_buffer)
|
||||
tail = await asyncio.to_thread(_decode_window, bytes(buf))
|
||||
except Exception as e:
|
||||
logger.error("sherpa offline final failed: %s", e)
|
||||
full = ""
|
||||
full = (full or "").strip()
|
||||
segments = [{"start": 0.0, "end": None, "text": full}] if full else []
|
||||
tail = ""
|
||||
tail = polish_text(tail)
|
||||
if tail:
|
||||
committed.append(tail)
|
||||
# Pieces are already polished; the join is too (polish is idempotent).
|
||||
full = " ".join(committed).strip()
|
||||
segments = [{"start": 0.0, "end": None, "text": t} for t in committed]
|
||||
if not client_disconnected:
|
||||
payload = {"type": "final", "text": full, "segments": segments,
|
||||
"language": "auto", "engine": backend.id}
|
||||
if full:
|
||||
# Hard-bounded refinement (~4s) — never delays the `final`.
|
||||
try:
|
||||
from services.refinement import maybe_refine
|
||||
refined = await asyncio.to_thread(maybe_refine, full)
|
||||
from services.refinement import maybe_refine_async
|
||||
refined = await maybe_refine_async(full)
|
||||
if refined and refined != full:
|
||||
payload["refined_text"] = refined
|
||||
except Exception:
|
||||
|
||||
@@ -16,6 +16,7 @@ from core.tasks import task_manager
|
||||
from core import event_bus
|
||||
from schemas.requests import DubIngestUrlRequest
|
||||
from services.model_manager import get_model, _gpu_pool, _cpu_pool, get_diarization_pipeline, offload_tts_for_asr, restore_tts_after_asr
|
||||
from services.asr_backend import ASRTimeoutError, reset_pool_after_wedge, run_transcribe_guarded
|
||||
from services.audio_io import _safe_soundfile_write
|
||||
from services.ffmpeg_utils import find_ffmpeg
|
||||
from services.segmentation import (
|
||||
@@ -35,24 +36,6 @@ router = APIRouter()
|
||||
logger = logging.getLogger("omnivoice.api")
|
||||
|
||||
|
||||
def _reset_pool_on_wedge(pool) -> None:
|
||||
"""Abandon a GPU pool whose worker is wedged on a timed-out transcribe (#730).
|
||||
|
||||
Python can't kill the stuck thread, but dropping the poisoned pool means the
|
||||
next submit (the next chunk, or a concurrent TTS generate) gets a fresh
|
||||
worker instead of queueing behind the wedged one — the same recovery the
|
||||
whole-file paths get inside ``run_transcribe_guarded``. Best-effort and a
|
||||
no-op for a pool without ``reset`` (a plain executor), so it never raises on
|
||||
the failure path it's trying to recover from.
|
||||
"""
|
||||
_reset = getattr(pool, "reset", None)
|
||||
if callable(_reset):
|
||||
try:
|
||||
_reset()
|
||||
except Exception:
|
||||
logger.exception("GPU pool reset after transcribe timeout failed")
|
||||
|
||||
|
||||
# ── Legacy-name aliases to services/dub_pipeline.py ────────────────────────
|
||||
# Phase 2.4 moved the business logic into a service. Other routers
|
||||
# (dub_generate, dub_translate, dub_export) + internal call sites below still
|
||||
@@ -392,6 +375,31 @@ _CHUNK_TRANSCRIBE_ATTEMPTS = max(1, int(os.environ.get("OMNIVOICE_TRANSCRIBE_CHU
|
||||
_sse_event = dub_pipeline.sse_event
|
||||
_prep_event_helper = dub_pipeline.prep_event # alias; we keep the module-local _prep_event below for the inline one-liner shape
|
||||
|
||||
#: User-facing warning emitted when auto voice cloning is skipped because the
|
||||
#: speaker labels came from the silence-gap heuristic (see _diarize /
|
||||
#: extract_speaker_clones — gap-based labels routinely mix two people's audio
|
||||
#: into one reference, which is how "made up" clone voices happen).
|
||||
CLONE_SKIP_HEURISTIC_MSG = (
|
||||
"auto voice cloning skipped: speaker labels are gap-based estimates — "
|
||||
"set up diarization (Settings → Models → pyannote) for per-speaker clones"
|
||||
)
|
||||
|
||||
|
||||
def _clamp_num_speakers(value) -> Optional[int]:
|
||||
"""Clamp the user's speaker-count hint to a sane 1–20 range.
|
||||
|
||||
Shared by the SSE and legacy transcribe endpoints so the two can't drift.
|
||||
None / non-int / out-of-range → None (auto-detect), so a bad query string
|
||||
can never break a diarization call.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
value = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return value if 1 <= value <= 20 else None
|
||||
|
||||
|
||||
@router.get("/dub/transcribe-stream/{job_id}")
|
||||
async def dub_transcribe_stream(
|
||||
@@ -410,15 +418,14 @@ async def dub_transcribe_stream(
|
||||
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.
|
||||
On paths that can't honor the hint exactly (inline ASR turns, the
|
||||
silence-gap heuristic) it is never silently dropped: the heuristic cycles
|
||||
the requested count and a `warning` SSE event tells the user how far the
|
||||
labels can be trusted.
|
||||
"""
|
||||
# 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
|
||||
num_speakers = _clamp_num_speakers(num_speakers)
|
||||
|
||||
job = _get_job(job_id)
|
||||
|
||||
@@ -580,35 +587,38 @@ async def dub_transcribe_stream(
|
||||
# the hole instead of leaving silent gaps.
|
||||
part = None
|
||||
for _attempt in range(1, _CHUNK_TRANSCRIBE_ATTEMPTS + 1):
|
||||
# A wedged chunk gets the SAME guarded-timeout + pool-reset
|
||||
# semantics as the whole-file paths (#730/#851):
|
||||
# run_transcribe_guarded bounds the call, abandons the poisoned
|
||||
# pool so the retry (and any concurrent TTS work) gets a fresh
|
||||
# worker, and raises the actionable ASRTimeoutError. Run it as
|
||||
# a task and poll so we can keep yielding pings — the
|
||||
# EventSource connection drops without them.
|
||||
pool_reset_by_guard = False
|
||||
task = asyncio.ensure_future(run_transcribe_guarded(
|
||||
_gpu_pool, _transcribe_chunk,
|
||||
what=f"Dub chunk {i + 1}/{chunks_n}",
|
||||
timeout=TRANSCRIBE_CHUNK_TIMEOUT_S,
|
||||
timeout_env="OMNIVOICE_TRANSCRIBE_CHUNK_TIMEOUT_S",
|
||||
))
|
||||
while True:
|
||||
done, _pending = await asyncio.wait({task}, timeout=5.0)
|
||||
if done:
|
||||
break
|
||||
yield _sse_event("ping", {})
|
||||
try:
|
||||
# wait_for in a loop to yield pings so the EventSource connection doesn't drop
|
||||
fut = loop.run_in_executor(_gpu_pool, _transcribe_chunk)
|
||||
waited = 0.0
|
||||
while True:
|
||||
done, pending = await asyncio.wait([fut], timeout=5.0)
|
||||
if done:
|
||||
part = done.pop().result()
|
||||
break
|
||||
yield _sse_event("ping", {})
|
||||
waited += 5.0
|
||||
if waited >= TRANSCRIBE_CHUNK_TIMEOUT_S:
|
||||
# Re-raise TimeoutError if we exceed the overall limit
|
||||
raise asyncio.TimeoutError()
|
||||
except asyncio.TimeoutError:
|
||||
part = task.result()
|
||||
except ASRTimeoutError as e:
|
||||
# The guard already reset the pool; keep the actionable
|
||||
# message (it names the durable fixes, and — after repeated
|
||||
# timeouts — the crash-isolated engine escape hatch).
|
||||
pool_reset_by_guard = True
|
||||
logger.error(
|
||||
"Transcribe chunk %d/%d timed out after %.0fs (attempt %d/%d, job=%s)",
|
||||
i + 1, chunks_n, TRANSCRIBE_CHUNK_TIMEOUT_S, _attempt,
|
||||
_CHUNK_TRANSCRIBE_ATTEMPTS, job_id,
|
||||
)
|
||||
# #730: the wedged chunk thread keeps holding its GPU-pool
|
||||
# worker. Abandon the poisoned pool so the retry (and any TTS
|
||||
# work) gets a fresh worker instead of queueing behind it.
|
||||
_reset_pool_on_wedge(_gpu_pool)
|
||||
part = {
|
||||
"chunks": [], "language": None,
|
||||
"error": f"Chunk {i+1} timed out after {TRANSCRIBE_CHUNK_TIMEOUT_S:.0f}s — "
|
||||
f"ASR backend may be stuck. Try restarting the server.",
|
||||
}
|
||||
part = {"chunks": [], "language": None, "error": str(e)}
|
||||
# Success → keep it. Failure/timeout → retry once on a fresh
|
||||
# worker (the internal _transcribe_chunk except returns an
|
||||
# error-part; the timeout path already reset the pool).
|
||||
@@ -619,7 +629,9 @@ async def dub_transcribe_stream(
|
||||
"Retrying transcribe chunk %d/%d after failure/timeout (next attempt %d/%d, job=%s)",
|
||||
i + 1, chunks_n, _attempt + 1, _CHUNK_TRANSCRIBE_ATTEMPTS, job_id,
|
||||
)
|
||||
_reset_pool_on_wedge(_gpu_pool)
|
||||
if not pool_reset_by_guard:
|
||||
reset_pool_after_wedge(
|
||||
_gpu_pool, what=f"Dub chunk {i + 1}/{chunks_n}")
|
||||
if part.get("error"):
|
||||
chunk_errors.append(part["error"])
|
||||
logger.warning("Chunk %d/%d error: %s", i + 1, chunks_n, part["error"])
|
||||
@@ -642,7 +654,11 @@ async def dub_transcribe_stream(
|
||||
snap_segment_starts(chunk_segs, audio_np, sr)
|
||||
except Exception as e:
|
||||
logger.warning("onset alignment skipped for chunk %d: %s", i, e)
|
||||
chunk_segs = assign_speakers_heuristic(chunk_segs)
|
||||
# Provisional per-chunk labels for the streaming UI only — the
|
||||
# final diarization pass below overwrites them. Honor the user's
|
||||
# speaker-count hint here too so the interim view doesn't flip
|
||||
# between 2 and N speakers.
|
||||
chunk_segs = assign_speakers_heuristic(chunk_segs, num_speakers)
|
||||
for s in chunk_segs:
|
||||
s["id"] = f"s{next_seg_id:05x}"
|
||||
s["text_original"] = s.get("text", "")
|
||||
@@ -696,33 +712,110 @@ async def dub_transcribe_stream(
|
||||
return
|
||||
|
||||
def _diarize():
|
||||
"""Returns (segments, warning_payload_or_None).
|
||||
"""Returns (segments, warning_payload_or_None, labels_source).
|
||||
|
||||
`labels_source` records where the speaker labels came from —
|
||||
`"pyannote"` | `"turns"` | `"heuristic"` — so downstream
|
||||
auto-clone extraction can refuse to cut reference audio from
|
||||
gap-based estimates (a mixed-speaker reference is how "made up"
|
||||
clone voices happen).
|
||||
|
||||
`warning_payload` is a structured dict
|
||||
`{detail, error_class, docs_url}` whenever we silently fell back
|
||||
to the silence-gap heuristic (no HF_TOKEN, model unavailable,
|
||||
license not accepted, or pyannote raised). The heuristic only
|
||||
detects speaker turns from >1.2s silences, so a rapid-fire
|
||||
man↔woman exchange will read as one speaker. Issue #78 — we
|
||||
attach an `error_class` so the front-end's errorDocsMap can
|
||||
render a "See docs" deeplink instead of a dead-end toast.
|
||||
license not accepted, or pyannote raised) — or whenever the
|
||||
user's `num_speakers` hint could not be honored exactly. The
|
||||
heuristic only detects speaker turns from >1.2s silences, so a
|
||||
rapid-fire man↔woman exchange will read as one speaker. Issue
|
||||
#78 — we attach an `error_class` so the front-end's errorDocsMap
|
||||
can render a "See docs" deeplink instead of a dead-end toast.
|
||||
"""
|
||||
# The active ASR backend already diarized inline (FunASR cam++):
|
||||
# use its speaker turns directly and skip pyannote entirely (#182).
|
||||
if asr_speaker_turns:
|
||||
logger.info("Using inline ASR diarization (%d turns); skipping pyannote.", len(asr_speaker_turns))
|
||||
assigned = assign_speakers_from_turns(all_segments, asr_speaker_turns)
|
||||
# #486: split any segment that spans two speakers' turns at the
|
||||
# word boundary (single-speaker segments pass through unchanged).
|
||||
return resplit_segments_by_turns(assigned, all_words, asr_speaker_turns), None
|
||||
|
||||
from services.model_manager import (
|
||||
DIARIZATION_ERR_LICENSE,
|
||||
DIARIZATION_ERR_NO_TOKEN,
|
||||
)
|
||||
from core import error_docs_map
|
||||
|
||||
diar_pipe, err_sentinel = get_diarization_pipeline(return_error=True)
|
||||
def _hint_suffix() -> str:
|
||||
"""Honest caveat appended to heuristic-fallback warnings when a
|
||||
multi-speaker hint is set: the count is now honored, but the
|
||||
heuristic can't attribute voices. (A hint of 1 IS fully
|
||||
honored — one label — so it needs no caveat.)"""
|
||||
if not num_speakers or num_speakers < 2:
|
||||
return ""
|
||||
return (
|
||||
f" Your speaker-count setting ({num_speakers}) is only "
|
||||
f"approximately honored: the heuristic cycles "
|
||||
f"{num_speakers} speaker labels on silence gaps instead "
|
||||
f"of recognizing voices, so lines may be attributed to "
|
||||
f"the wrong speaker."
|
||||
)
|
||||
|
||||
def _use_turns(crash: Exception | None = None, err_sentinel=None):
|
||||
"""Label from the ASR backend's inline speaker turns; warn when
|
||||
that means the user's explicit count can't be enforced."""
|
||||
logger.info(
|
||||
"Using inline ASR diarization (%d turns)%s.",
|
||||
len(asr_speaker_turns),
|
||||
"" if crash else "; skipping pyannote",
|
||||
)
|
||||
assigned = assign_speakers_from_turns(all_segments, asr_speaker_turns)
|
||||
# #486: split any segment that spans two speakers' turns at the
|
||||
# word boundary (single-speaker segments pass through unchanged).
|
||||
resplit = resplit_segments_by_turns(assigned, all_words, asr_speaker_turns)
|
||||
if not num_speakers:
|
||||
return resplit, None, "turns"
|
||||
error_class = (
|
||||
"HF_AUTH_FAILED"
|
||||
if err_sentinel == DIARIZATION_ERR_NO_TOKEN
|
||||
else "PYANNOTE_LICENSE_REQUIRED"
|
||||
)
|
||||
if crash:
|
||||
detail = (
|
||||
f"Speaker diarization crashed mid-run "
|
||||
f"({type(crash).__name__}); falling back to the ASR "
|
||||
f"engine's built-in speaker turns. Speaker-count hint "
|
||||
f"ignored: the detected count may differ from the "
|
||||
f"{num_speakers} you set."
|
||||
)
|
||||
else:
|
||||
detail = (
|
||||
f"Speaker-count hint ignored: pyannote diarization is "
|
||||
f"unavailable, so the ASR engine's built-in speaker "
|
||||
f"turns were used and the detected count may differ "
|
||||
f"from the {num_speakers} you set. Set up diarization "
|
||||
f"(Settings → Models → pyannote) to enforce an exact "
|
||||
f"speaker count."
|
||||
)
|
||||
return resplit, {
|
||||
"detail": detail,
|
||||
"error_class": error_class,
|
||||
"docs_url": error_docs_map.lookup(error_class),
|
||||
"speaker_hint": {"requested": num_speakers, "status": "ignored"},
|
||||
}, "turns"
|
||||
|
||||
# The active ASR backend already diarized inline (FunASR cam++):
|
||||
# its turns are the fast path and skip pyannote entirely (#182) —
|
||||
# but ONLY when the user didn't set an explicit speaker count.
|
||||
# Inline turns are labeled per-30s-chunk and can't be forced to N
|
||||
# speakers, so a set num_speakers prefers pyannote — the one
|
||||
# engine that honors an exact count. When pyannote can't load,
|
||||
# the turns are still the best labels available; use them and say
|
||||
# so instead of silently eating the hint.
|
||||
diar_pipe = None
|
||||
err_sentinel = None
|
||||
if asr_speaker_turns:
|
||||
if num_speakers:
|
||||
diar_pipe, err_sentinel = get_diarization_pipeline(return_error=True)
|
||||
if not diar_pipe:
|
||||
return _use_turns(err_sentinel=err_sentinel)
|
||||
logger.info(
|
||||
"num_speakers=%d set: preferring pyannote over %d inline "
|
||||
"ASR turns (only pyannote honors an exact count).",
|
||||
num_speakers, len(asr_speaker_turns),
|
||||
)
|
||||
else:
|
||||
diar_pipe, err_sentinel = get_diarization_pipeline(return_error=True)
|
||||
if not diar_pipe:
|
||||
# Phase 1 AUTH-01: ask the resolver (App → Env → HF-CLI),
|
||||
# not just the env var. This is the #35 fix — users who
|
||||
@@ -773,13 +866,20 @@ async def dub_transcribe_stream(
|
||||
f"heuristic; rapid speaker turns may be merged."
|
||||
)
|
||||
error_class = "PYANNOTE_LICENSE_REQUIRED"
|
||||
warning = {
|
||||
"detail": detail + _hint_suffix(),
|
||||
"error_class": error_class,
|
||||
"docs_url": error_docs_map.lookup(error_class),
|
||||
}
|
||||
if num_speakers:
|
||||
warning["speaker_hint"] = {
|
||||
"requested": num_speakers,
|
||||
"status": "approximate" if num_speakers > 1 else "honored",
|
||||
}
|
||||
return (
|
||||
assign_speakers_heuristic(all_segments),
|
||||
{
|
||||
"detail": detail,
|
||||
"error_class": error_class,
|
||||
"docs_url": error_docs_map.lookup(error_class),
|
||||
},
|
||||
assign_speakers_heuristic(all_segments, num_speakers),
|
||||
warning,
|
||||
"heuristic",
|
||||
)
|
||||
try:
|
||||
# Pass the user's speaker-count hint through to pyannote when
|
||||
@@ -794,9 +894,14 @@ async def dub_transcribe_stream(
|
||||
assigned = assign_speakers_from_diarization(all_segments, diar)
|
||||
# #486: split any segment that spans two speakers' turns at the
|
||||
# word boundary (single-speaker segments pass through unchanged).
|
||||
return resplit_segments_by_diarization(assigned, all_words, diar), None
|
||||
return resplit_segments_by_diarization(assigned, all_words, diar), None, "pyannote"
|
||||
except Exception as e:
|
||||
logger.error(f"Diarization failed: {e}")
|
||||
# Inline ASR turns beat the silence-gap heuristic as a crash
|
||||
# fallback (this path is reachable with turns present since a
|
||||
# set num_speakers routes turns-jobs through pyannote).
|
||||
if asr_speaker_turns:
|
||||
return _use_turns(crash=e)
|
||||
# Mid-run failure — classify against the same sentinels so a
|
||||
# post-load 401 (rare but possible after a token rotation)
|
||||
# still gets the right docs deeplink.
|
||||
@@ -807,36 +912,50 @@ async def dub_transcribe_stream(
|
||||
if err_class_post == DIARIZATION_ERR_LICENSE
|
||||
else "PYANNOTE_LICENSE_REQUIRED" # LOAD failures land here too
|
||||
)
|
||||
warning = {
|
||||
"detail": (
|
||||
f"Speaker diarization crashed mid-run "
|
||||
f"({type(e).__name__}); falling back to a silence-gap "
|
||||
f"heuristic. Rapid speaker turns may be merged."
|
||||
+ _hint_suffix()
|
||||
),
|
||||
"error_class": error_class,
|
||||
"docs_url": error_docs_map.lookup(error_class),
|
||||
}
|
||||
if num_speakers:
|
||||
warning["speaker_hint"] = {
|
||||
"requested": num_speakers,
|
||||
"status": "approximate" if num_speakers > 1 else "honored",
|
||||
}
|
||||
return (
|
||||
assign_speakers_heuristic(all_segments),
|
||||
{
|
||||
"detail": (
|
||||
f"Speaker diarization crashed mid-run "
|
||||
f"({type(e).__name__}); falling back to a silence-gap "
|
||||
f"heuristic. Rapid speaker turns may be merged."
|
||||
),
|
||||
"error_class": error_class,
|
||||
"docs_url": error_docs_map.lookup(error_class),
|
||||
},
|
||||
assign_speakers_heuristic(all_segments, num_speakers),
|
||||
warning,
|
||||
"heuristic",
|
||||
)
|
||||
|
||||
fut_diar = loop.run_in_executor(_gpu_pool, _diarize)
|
||||
final_segs = None
|
||||
diar_warning = None
|
||||
labels_source = "heuristic"
|
||||
while True:
|
||||
done, pending = await asyncio.wait([fut_diar], timeout=5.0)
|
||||
if done:
|
||||
final_segs, diar_warning = done.pop().result()
|
||||
final_segs, diar_warning, labels_source = done.pop().result()
|
||||
break
|
||||
yield _sse_event("ping", {})
|
||||
if diar_warning:
|
||||
logger.warning("diarization fallback: %s", diar_warning.get("detail"))
|
||||
yield _sse_event("warning", {
|
||||
payload = {
|
||||
"detail": diar_warning.get("detail"),
|
||||
"source": "diarization",
|
||||
"error_class": diar_warning.get("error_class"),
|
||||
"docs_url": diar_warning.get("docs_url"),
|
||||
})
|
||||
}
|
||||
# Machine-readable trail of what happened to the user's
|
||||
# speaker-count hint (the `detail` text carries the human story).
|
||||
if diar_warning.get("speaker_hint"):
|
||||
payload["speaker_hint"] = diar_warning["speaker_hint"]
|
||||
yield _sse_event("warning", payload)
|
||||
|
||||
job["segments"] = final_segs
|
||||
|
||||
@@ -848,17 +967,37 @@ async def dub_transcribe_stream(
|
||||
try:
|
||||
from services.speaker_clone import extract_speaker_clones, auto_profile_id
|
||||
vocals_for_clone = job.get("vocals_path") or asr_audio_target
|
||||
fut_clones = loop.run_in_executor(
|
||||
_cpu_pool, extract_speaker_clones,
|
||||
vocals_for_clone, final_segs, os.path.dirname(vocals_for_clone),
|
||||
)
|
||||
clones = None
|
||||
while True:
|
||||
done, pending = await asyncio.wait([fut_clones], timeout=5.0)
|
||||
if done:
|
||||
clones = done.pop().result()
|
||||
break
|
||||
yield _sse_event("ping", {})
|
||||
clones = {}
|
||||
if labels_source == "heuristic":
|
||||
# Clone-purity guard: heuristic labels are silence-gap
|
||||
# estimates, not voice identity — a per-speaker reference cut
|
||||
# from them routinely concatenates two people's audio and the
|
||||
# clone sounds "made up". Skip auto-clones and say so instead
|
||||
# of shipping bad ones. (extract_speaker_clones enforces the
|
||||
# same guard internally; this branch exists to surface the
|
||||
# warning to the user.)
|
||||
logger.info(
|
||||
"auto speaker clones skipped (labels_source=heuristic, job=%s)",
|
||||
job_id,
|
||||
)
|
||||
yield _sse_event("warning", {
|
||||
"detail": CLONE_SKIP_HEURISTIC_MSG,
|
||||
"source": "speaker_clone",
|
||||
})
|
||||
else:
|
||||
fut_clones = loop.run_in_executor(
|
||||
_cpu_pool, lambda: extract_speaker_clones(
|
||||
vocals_for_clone, final_segs,
|
||||
os.path.dirname(vocals_for_clone),
|
||||
labels_source=labels_source,
|
||||
),
|
||||
)
|
||||
while True:
|
||||
done, pending = await asyncio.wait([fut_clones], timeout=5.0)
|
||||
if done:
|
||||
clones = done.pop().result()
|
||||
break
|
||||
yield _sse_event("ping", {})
|
||||
# Wave 3.2: per-segment clone refs. Cut each long-enough segment's
|
||||
# own reference from the vocals so the dub of each line matches the
|
||||
# prosody of its source line. Short lines fall back to the
|
||||
@@ -972,7 +1111,14 @@ async def dub_transcribe_stream(
|
||||
|
||||
|
||||
@router.post("/dub/transcribe/{job_id}")
|
||||
async def dub_transcribe(job_id: str):
|
||||
async def dub_transcribe(job_id: str, num_speakers: Optional[int] = None):
|
||||
"""Legacy synchronous transcribe (kept for the headless CLI).
|
||||
|
||||
`num_speakers` mirrors the SSE endpoint's query param (same 1–20 clamp):
|
||||
an exact speaker count forwarded to pyannote, or cycled by the silence-gap
|
||||
heuristic when pyannote is unavailable. None → auto-detect.
|
||||
"""
|
||||
num_speakers = _clamp_num_speakers(num_speakers)
|
||||
job = _get_job(job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
@@ -1038,13 +1184,20 @@ async def dub_transcribe(job_id: str):
|
||||
if diar_pipe:
|
||||
try:
|
||||
diar_target = job.get("vocals_path") or job.get("audio_path")
|
||||
diarization = diar_pipe(diar_target)
|
||||
# Same hint pass-through as the SSE endpoint (#274): omit the
|
||||
# kwarg entirely when unset so we don't depend on it existing
|
||||
# in every pyannote build.
|
||||
if num_speakers:
|
||||
logger.info("Diarizing with num_speakers=%d (user hint)", num_speakers)
|
||||
diarization = diar_pipe(diar_target, num_speakers=num_speakers)
|
||||
else:
|
||||
diarization = diar_pipe(diar_target)
|
||||
segments = assign_speakers_from_diarization(segments, diarization)
|
||||
except Exception as e:
|
||||
logger.error(f"Pyannote diarization failed during inference: {e}. Falling back to heuristic.")
|
||||
segments = assign_speakers_heuristic(segments)
|
||||
segments = assign_speakers_heuristic(segments, num_speakers)
|
||||
else:
|
||||
segments = assign_speakers_heuristic(segments)
|
||||
segments = assign_speakers_heuristic(segments, num_speakers)
|
||||
|
||||
# Previously ran `segment_for_subtitles(segments)` here. Removed 2026-04-21 —
|
||||
# that splitter enforces Netflix's 17 CPS reading-speed ceiling which
|
||||
@@ -1068,7 +1221,6 @@ async def dub_transcribe(job_id: str):
|
||||
# call would otherwise hold its GPU-pool worker forever and starve
|
||||
# every other request into a "can't reach backend". run_transcribe_guarded
|
||||
# also resets the pool on timeout so capacity is restored.
|
||||
from services.asr_backend import run_transcribe_guarded
|
||||
segments_result = await run_transcribe_guarded(_gpu_pool, _transcribe, what="Dub")
|
||||
except asyncio.CancelledError:
|
||||
job["aborted"] = True
|
||||
|
||||
@@ -8,7 +8,7 @@ from fastapi.responses import JSONResponse
|
||||
|
||||
from schemas.requests import TranslateRequest
|
||||
from services.model_manager import _cpu_pool, _gpu_pool
|
||||
from services.translator import cinematic_available, cinematic_refine_many
|
||||
from services.translator import cinematic_available, cinematic_refine_many, _cinematic_budget
|
||||
from api.routers.dub_core import _get_job
|
||||
|
||||
router = APIRouter()
|
||||
@@ -302,15 +302,69 @@ async def dub_translate(req: TranslateRequest):
|
||||
translated = await loop.run_in_executor(_gpu_pool, _translate_nllb)
|
||||
if os.environ.get("OMNIVOICE_UNLOAD_NLLB", "1") == "1":
|
||||
_unload_nllb()
|
||||
return {"translated": translated, "target_lang": req.target_lang, "source_lang": src_lang,
|
||||
**_dialect_flags(req, applied=False)}
|
||||
# Cinematic/Autofit refine + rate-ratio badges must run for NLLB too
|
||||
# (previously this returned before _maybe_cinematic, so a Cinematic
|
||||
# pick on NLLB silently produced plain Fast output). Unloading NLLB
|
||||
# first is fine — the refine LLM is a separate network provider.
|
||||
return await _maybe_cinematic(translated, req, src_lang, loop)
|
||||
|
||||
# OpenAI / Ollama Local LLM Translation
|
||||
# LLM translation — resolves through the LLM Skills registry: per-skill
|
||||
# "Dub translation" override → global active provider (Settings → LLM
|
||||
# Providers). The keys users configure + test in the app now actually
|
||||
# power this engine; the raw TRANSLATE_* env vars stay working as a
|
||||
# power-user override so pre-skills setups see zero behavior change.
|
||||
if provider == "openai":
|
||||
base_url = os.environ.get("TRANSLATE_BASE_URL")
|
||||
model_name = os.environ.get("TRANSLATE_MODEL", "gpt-3.5-turbo")
|
||||
from openai import OpenAI
|
||||
client = OpenAI(base_url=base_url, api_key=api_key or "local")
|
||||
from services import llm_skills
|
||||
|
||||
llm_timeout = llm_skills._default_timeout()
|
||||
handle = None
|
||||
try:
|
||||
handle = llm_skills.resolve_skill_client("dub_translation")
|
||||
except Exception: # noqa: BLE001 — resolution must never 500 a translate
|
||||
logger.exception("dub_translation skill resolution failed; trying env fallback")
|
||||
if handle is not None:
|
||||
client = handle.client
|
||||
model_name = handle.model
|
||||
llm_timeout = handle.timeout
|
||||
# The provider-store key never touches env; resolve it so the
|
||||
# error scrubber below can redact it if a provider echoes it.
|
||||
try:
|
||||
from services import llm_providers
|
||||
api_key = llm_providers.resolve_api_key(
|
||||
llm_skills.effective_provider("dub_translation")) or api_key
|
||||
except Exception: # noqa: BLE001 — scrub-key resolution is best-effort
|
||||
pass
|
||||
elif os.environ.get("TRANSLATE_BASE_URL") or api_key:
|
||||
# Legacy env-only setup (no provider configured in-app).
|
||||
from openai import OpenAI
|
||||
# max_retries=0: a 429 + long Retry-After must not let one segment's
|
||||
# SDK call sleep+retry and blow the overall translate wall time.
|
||||
client = OpenAI(base_url=os.environ.get("TRANSLATE_BASE_URL"),
|
||||
api_key=api_key or "local", max_retries=0)
|
||||
model_name = os.environ.get("TRANSLATE_MODEL", "gpt-4o-mini")
|
||||
else:
|
||||
# Nothing configured anywhere — name the exact next step instead
|
||||
# of letting an empty key surface as a raw 401 per segment.
|
||||
try:
|
||||
reason = llm_skills.resolve_skill("dub_translation").reason
|
||||
except Exception: # noqa: BLE001
|
||||
reason = None
|
||||
if reason == "disabled":
|
||||
friendly = (
|
||||
"The LLM translation engine is turned off — enable the "
|
||||
"'Dub translation' skill in Settings → LLM Skills, or "
|
||||
"pick another engine in the Engine dropdown."
|
||||
)
|
||||
else:
|
||||
friendly = (
|
||||
"The LLM translation engine has no provider configured. "
|
||||
"Add and test one in Settings → LLM Providers (it powers "
|
||||
"this engine; route it per-skill in Settings → LLM "
|
||||
"Skills), or set TRANSLATE_BASE_URL + TRANSLATE_API_KEY "
|
||||
"+ TRANSLATE_MODEL. Or pick another engine in the "
|
||||
"Engine dropdown."
|
||||
)
|
||||
return JSONResponse(status_code=400, content={"error": friendly})
|
||||
|
||||
def _build_prompt(src_code: str, tgt_code: str) -> str:
|
||||
"""Build a system prompt that resists hallucinations on small
|
||||
@@ -372,6 +426,7 @@ async def dub_translate(req: TranslateRequest):
|
||||
res = client.chat.completions.create(
|
||||
model=model_name,
|
||||
temperature=0.2, # less drift than default 1.0
|
||||
timeout=llm_timeout, # bound per call (OMNIVOICE_LLM_TIMEOUT, 45s default)
|
||||
messages=[
|
||||
{"role": "system", "content": sys_for_attempt},
|
||||
{"role": "user", "content": seg.text},
|
||||
@@ -399,14 +454,22 @@ async def dub_translate(req: TranslateRequest):
|
||||
seg.id, attempt + 1, e,
|
||||
)
|
||||
# Both attempts failed — keep source text + flag error so the
|
||||
# frontend can surface "fallback to literal" warning.
|
||||
return {"id": seg.id, "text": seg.text, "error": last_err or "llm-failed"}
|
||||
# frontend can surface "fallback to literal" warning. Scrub the
|
||||
# provider error: some OpenAI-compatible providers echo the key
|
||||
# or a user_id in the body, which must not reach the UI verbatim.
|
||||
from core.scrub import scrub_provider_error
|
||||
return {"id": seg.id, "text": seg.text,
|
||||
"error": scrub_provider_error(last_err, api_key) or "llm-failed"}
|
||||
|
||||
tasks = [loop.run_in_executor(_cpu_pool, _translate_llm, seg) for seg in req.segments]
|
||||
translated = await asyncio.gather(*tasks)
|
||||
translated.sort(key=lambda x: str(x["id"]))
|
||||
return {"translated": translated, "target_lang": req.target_lang, "source_lang": src_lang,
|
||||
**_dialect_flags(req, applied=True)}
|
||||
# provider="openai" is already an LLM translation — _maybe_cinematic
|
||||
# skips the reflect/adapt re-refine (already_llm) but still stamps
|
||||
# rate-ratio badges and runs the bounded Autofit fit pass. Before
|
||||
# this it returned here, so Cinematic/Autofit on the LLM engine did
|
||||
# nothing.
|
||||
return await _maybe_cinematic(translated, req, src_lang, loop, already_llm=True)
|
||||
|
||||
# Offline Argos Translate
|
||||
if provider == "argos" or provider == "libretranslate":
|
||||
@@ -465,8 +528,11 @@ async def dub_translate(req: TranslateRequest):
|
||||
return results
|
||||
|
||||
translated = await loop.run_in_executor(_cpu_pool, _translate_argos)
|
||||
return {"translated": translated, "target_lang": req.target_lang, "source_lang": src_lang,
|
||||
**_dialect_flags(req, applied=False)}
|
||||
# Argos is the DEFAULT engine — routing it through _maybe_cinematic is
|
||||
# the headline fix: a user who picks Cinematic/Autofit on Argos now
|
||||
# gets the LLM refine + fit pass (and rate-ratio badges in Fast mode)
|
||||
# instead of silent plain-Fast output.
|
||||
return await _maybe_cinematic(translated, req, src_lang, loop)
|
||||
|
||||
# Legacy / API Deep_Translator logic.
|
||||
# Preflight the optional `deep_translator` dep once so we fail with a
|
||||
@@ -540,7 +606,11 @@ async def dub_translate(req: TranslateRequest):
|
||||
)
|
||||
time.sleep(0.25 * (attempt + 1))
|
||||
logger.error("translate %s -> %s gave up (provider=%s): %s", src_arg, seg_lc, provider, last_err)
|
||||
return {"id": seg.id, "text": seg.text, "error": last_err or "unknown"}
|
||||
# Scrub before it reaches the UI — DeepL/Microsoft errors can echo
|
||||
# the API key (same class as the OpenAI user_id leak).
|
||||
from core.scrub import scrub_provider_error
|
||||
return {"id": seg.id, "text": seg.text,
|
||||
"error": scrub_provider_error(last_err, _deepl_key or _msft_key or api_key) or "unknown"}
|
||||
|
||||
tasks = [loop.run_in_executor(_cpu_pool, _translate_single, seg) for seg in req.segments]
|
||||
translated = await asyncio.gather(*tasks)
|
||||
@@ -554,24 +624,19 @@ async def dub_translate(req: TranslateRequest):
|
||||
return JSONResponse(status_code=500, content={"error": str(e)})
|
||||
|
||||
|
||||
async def _maybe_cinematic(translated, req, src_lang, loop):
|
||||
"""If quality=cinematic and a usable LLM is configured, run REFLECT+ADAPT.
|
||||
Otherwise return Fast-mode shape unchanged.
|
||||
def _stamp_predicted_rate_ratio(translated, req) -> None:
|
||||
"""Stamp a predicted ``rate_ratio`` on every row that has a known slot.
|
||||
|
||||
No LLM needed — just the per-language CPS table from ``services/speech_rate``.
|
||||
The UI's ``seg-rate-badge`` reads it (Fast mode included) to show which
|
||||
segments will compress hard at generation time, so users can edit text or
|
||||
pick a heavier quality. Mutates ``translated`` in place; never raises.
|
||||
"""
|
||||
quality = (getattr(req, "quality", None) or "fast").lower()
|
||||
# Stamp the predicted rate_ratio on every translated row that has a
|
||||
# known slot. Works for Fast mode too — no LLM needed; just the CPS
|
||||
# table from services/speech_rate. The UI's `seg-rate-badge` reads
|
||||
# this value and shows users which segments will compress hard at
|
||||
# generation time, so they can edit text or pick Cinematic quality.
|
||||
try:
|
||||
from services.speech_rate import rate_ratio as _predict_rate_ratio
|
||||
slots = {str(s.id): getattr(s, "slot_seconds", None) for s in req.segments}
|
||||
for row in translated:
|
||||
seg_ref = next(
|
||||
(s for s in req.segments if str(s.id) == str(row["id"])),
|
||||
None,
|
||||
)
|
||||
slot = getattr(seg_ref, "slot_seconds", None) if seg_ref else None
|
||||
slot = slots.get(str(row["id"]))
|
||||
text = (row.get("text") or "").strip()
|
||||
if slot and text and not row.get("error"):
|
||||
row["rate_ratio"] = round(
|
||||
@@ -580,22 +645,119 @@ async def _maybe_cinematic(translated, req, src_lang, loop):
|
||||
except Exception as e:
|
||||
logger.debug("non-LLM rate_ratio prediction skipped: %s", e)
|
||||
|
||||
base = {"translated": translated, "target_lang": req.target_lang, "source_lang": src_lang,
|
||||
"quality_used": "fast", **_dialect_flags(req, applied=False)}
|
||||
|
||||
# Autofit is Cinematic + a strict "never exceed the slot" fit pass, so both
|
||||
# qualities take the LLM refine path below. Fast (and anything else) returns
|
||||
# the plain translation unchanged.
|
||||
async def _apply_fit_pass(rows, req, slots_by_id, source_by_id, quality, loop, deadline) -> None:
|
||||
"""Run the Autofit slot-fit pass over ``rows`` concurrently, in place.
|
||||
|
||||
Bounded by ``deadline`` (shared with the cinematic refine) so a slow /
|
||||
rate-limited LLM can't spin the fit pass per-segment unbounded — the old
|
||||
behavior, which ran one blocking ``adjust_for_slot`` per segment in the
|
||||
merge loop, outside any budget. Segments still running at the deadline keep
|
||||
their current text and get ``rate_error='fit-budget'``. Only rows with a
|
||||
slot + text + no prior error participate.
|
||||
"""
|
||||
strict = (quality == "autofit")
|
||||
items = []
|
||||
for row in rows:
|
||||
seg_id = str(row["id"])
|
||||
slot = slots_by_id.get(seg_id)
|
||||
text = row.get("text") or ""
|
||||
if slot and text and not row.get("error"):
|
||||
items.append((seg_id, text, float(slot), req.target_lang,
|
||||
source_by_id.get(seg_id), strict))
|
||||
if not items:
|
||||
return
|
||||
try:
|
||||
from services.speech_rate import adjust_for_slot_many
|
||||
fits = await adjust_for_slot_many(
|
||||
items, executor=_cpu_pool, deadline=deadline, loop=loop,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("rate-fit pass skipped: %s", e)
|
||||
return
|
||||
for row in rows:
|
||||
f = fits.get(str(row["id"]))
|
||||
if not f:
|
||||
continue
|
||||
if f.get("text"):
|
||||
row["text"] = f["text"]
|
||||
if f.get("rate_ratio") is not None:
|
||||
row["rate_ratio"] = f["rate_ratio"]
|
||||
if f.get("error"):
|
||||
row["rate_error"] = f["error"]
|
||||
|
||||
|
||||
async def _maybe_cinematic(translated, req, src_lang, loop, *, already_llm=False):
|
||||
"""Post-process a literal translation into Cinematic/Autofit output.
|
||||
|
||||
Runs for EVERY provider now (Argos/NLLB/Google/…/OpenAI). The three
|
||||
LLM-independent branches (nllb/argos) and the openai branch used to return
|
||||
*before* reaching this, so a Cinematic/Autofit pick on them — including the
|
||||
DEFAULT Argos engine — silently produced plain Fast output with a success
|
||||
toast. Fast mode still returns the plain translation (plus rate-ratio badges).
|
||||
|
||||
``already_llm`` (provider="openai"): the translation was itself produced by
|
||||
an LLM, so the REFLECT+ADAPT *re*-refine is skipped, but the bounded Autofit
|
||||
fit pass + rate-ratio stamping still run, and the dialect the translate
|
||||
prompt already baked in is reported as applied.
|
||||
"""
|
||||
quality = (getattr(req, "quality", None) or "fast").lower()
|
||||
|
||||
_stamp_predicted_rate_ratio(translated, req)
|
||||
|
||||
# #280 item 2 — regional dialect hint, guarded against a stale dialect from
|
||||
# another language. For already_llm the initial translate prompt already
|
||||
# applied it, so it's reported applied in the Fast-shape base too.
|
||||
dialect_hint = ""
|
||||
_dialect = getattr(req, "dialect", None)
|
||||
if _dialect and str(_dialect).lower().startswith(str(req.target_lang).lower()[:2]):
|
||||
dialect_hint = dialect_clause(_dialect)
|
||||
|
||||
base = {"translated": translated, "target_lang": req.target_lang, "source_lang": src_lang,
|
||||
"quality_used": "fast",
|
||||
**_dialect_flags(req, applied=(already_llm and bool(dialect_hint)))}
|
||||
|
||||
# Fast (and anything unrecognised) returns the plain translation unchanged.
|
||||
if quality not in ("cinematic", "autofit"):
|
||||
return base
|
||||
|
||||
source_by_id: dict[str, str] = {str(s.id): s.text for s in req.segments}
|
||||
slots_by_id = {
|
||||
str(s.id): getattr(s, "slot_seconds", None)
|
||||
for s in req.segments
|
||||
if getattr(s, "slot_seconds", None)
|
||||
}
|
||||
|
||||
# One wall-clock deadline shared by the whole LLM phase (refine + fit), so a
|
||||
# slow/rate-limited provider can't run either pass unbounded. <=0 disables.
|
||||
budget = _cinematic_budget()
|
||||
deadline = (loop.time() + budget) if budget and budget > 0 else None
|
||||
|
||||
# provider="openai": already an LLM translation → skip REFLECT+ADAPT, keep
|
||||
# the rate-ratio badges, still run the bounded fit pass.
|
||||
if already_llm:
|
||||
merged = []
|
||||
for row in translated:
|
||||
out = {"id": row["id"],
|
||||
"text": row.get("text", "") or "",
|
||||
"literal": row.get("text", "") or ""}
|
||||
if row.get("error"):
|
||||
out["error"] = row["error"]
|
||||
if "rate_ratio" in row:
|
||||
out["rate_ratio"] = row["rate_ratio"]
|
||||
merged.append(out)
|
||||
await _apply_fit_pass(merged, req, slots_by_id, source_by_id, quality, loop, deadline)
|
||||
return {"translated": merged, "target_lang": req.target_lang,
|
||||
"source_lang": src_lang, "quality_used": quality,
|
||||
**_dialect_flags(req, applied=bool(dialect_hint))}
|
||||
|
||||
# Non-LLM provider → the reflect/adapt refine needs a separately-configured
|
||||
# LLM (Settings → LLM Providers). Without one, degrade to Fast with a flag.
|
||||
if not cinematic_available():
|
||||
logger.warning("%s requested but no LLM configured — returning Fast result.", quality)
|
||||
base["cinematic_skipped"] = "no-llm-configured"
|
||||
return base
|
||||
|
||||
# Build a map from id → original segment (to fetch source text + direction).
|
||||
source_by_id: dict[str, str] = {str(s.id): s.text for s in req.segments}
|
||||
directions: dict[str, str] = {
|
||||
str(s.id): s.direction
|
||||
for s in req.segments
|
||||
@@ -603,7 +765,7 @@ async def _maybe_cinematic(translated, req, src_lang, loop):
|
||||
}
|
||||
pairs = []
|
||||
passthrough_index = {}
|
||||
for i, row in enumerate(translated):
|
||||
for row in translated:
|
||||
seg_id = str(row["id"])
|
||||
literal = row.get("text", "") or ""
|
||||
if row.get("error") or not literal.strip():
|
||||
@@ -614,12 +776,6 @@ async def _maybe_cinematic(translated, req, src_lang, loop):
|
||||
if not pairs:
|
||||
return base
|
||||
|
||||
# #280 item 2: thread the regional-dialect hint into the reflect/adapt
|
||||
# prompts. Guard against a stale dialect from another language.
|
||||
dialect_hint = ""
|
||||
if req.dialect and str(req.dialect).lower().startswith(str(req.target_lang).lower()[:2]):
|
||||
dialect_hint = dialect_clause(req.dialect)
|
||||
|
||||
refined = await cinematic_refine_many(
|
||||
pairs,
|
||||
source_lang=src_lang,
|
||||
@@ -631,16 +787,6 @@ async def _maybe_cinematic(translated, req, src_lang, loop):
|
||||
)
|
||||
refined_by_id = {r["id"]: r for r in refined}
|
||||
|
||||
# Phase 4.4 — speech-rate fit pass. Segment boundaries aren't in the
|
||||
# translate request (by design — translator is boundary-agnostic), so we
|
||||
# only run it when the caller supplied `slot_seconds` on each segment.
|
||||
# The frontend populates this for Cinematic calls from the edit view.
|
||||
slots_by_id = {
|
||||
str(s.id): getattr(s, "slot_seconds", None)
|
||||
for s in req.segments
|
||||
if getattr(s, "slot_seconds", None)
|
||||
}
|
||||
|
||||
merged = []
|
||||
for row in translated:
|
||||
seg_id = str(row["id"])
|
||||
@@ -659,32 +805,11 @@ async def _maybe_cinematic(translated, req, src_lang, loop):
|
||||
}
|
||||
if r.get("error"):
|
||||
out["error"] = r["error"]
|
||||
|
||||
# Optional slot-fit pass — only when the caller asked for cinematic
|
||||
# *and* provided a slot. Runs best-effort; no-LLM or mid-loop failure
|
||||
# just leaves the cinematic text untouched.
|
||||
slot = slots_by_id.get(seg_id)
|
||||
if slot and out["text"]:
|
||||
try:
|
||||
from services.speech_rate import adjust_for_slot
|
||||
fit = await asyncio.to_thread(
|
||||
adjust_for_slot,
|
||||
out["text"],
|
||||
slot_seconds=float(slot),
|
||||
target_lang=req.target_lang,
|
||||
source_text=source_by_id.get(seg_id),
|
||||
strict=(quality == "autofit"),
|
||||
)
|
||||
if fit.get("text"):
|
||||
out["text"] = fit["text"]
|
||||
out["rate_ratio"] = fit.get("rate_ratio")
|
||||
if fit.get("error"):
|
||||
out["rate_error"] = fit["error"]
|
||||
except Exception as e:
|
||||
logger.warning("rate-fit skipped for %s: %s", seg_id, e)
|
||||
|
||||
merged.append(out)
|
||||
|
||||
# Phase 4.4 speech-rate fit pass — now concurrent + bounded (see helper).
|
||||
await _apply_fit_pass(merged, req, slots_by_id, source_by_id, quality, loop, deadline)
|
||||
|
||||
return {
|
||||
"translated": merged,
|
||||
"target_lang": req.target_lang,
|
||||
|
||||
@@ -15,6 +15,8 @@ Environment variables (`OMNIVOICE_TTS_BACKEND`, `OMNIVOICE_ASR_BACKEND`,
|
||||
`OMNIVOICE_LLM_BACKEND`) still win over the UI choice so power-users can pin
|
||||
a backend without Settings silently undoing it.
|
||||
"""
|
||||
import os
|
||||
import threading
|
||||
from time import perf_counter
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
@@ -261,6 +263,169 @@ def engine_health(engine_id: str):
|
||||
}
|
||||
|
||||
|
||||
# ── Real-synthesis self-test (in-process TTS engines) ──────────────────────
|
||||
#
|
||||
# ``/health`` above is a liveness/import probe — for an in-process backend it
|
||||
# only calls ``is_available()`` and the UI labels the result "deps OK". This
|
||||
# route goes one step further: for an AVAILABLE, IN-PROCESS TTS engine it runs
|
||||
# a *tiny real synthesis* from a fixed short phrase and reports duration +
|
||||
# sample-rate + sample count, proving the engine actually emits audio rather
|
||||
# than merely importing. The Compat Matrix's "Self-test" button calls it.
|
||||
#
|
||||
# Guardrails (kept identical across macOS/Windows/Linux per the default-feature
|
||||
# rule — the phrase, timeout and gating don't branch on OS):
|
||||
# * TTS family + available + in-process only. Subprocess engines keep their
|
||||
# spawn-and-ping ``health_check`` (a real synth there is a sidecar
|
||||
# cold-start — out of scope for a click-to-test affordance).
|
||||
# * Bounded wall-clock timeout (``OMNIVOICE_SELFTEST_TIMEOUT_S``, default 90s):
|
||||
# a runaway synth returns ``ok=False`` / ``timed_out=True`` instead of
|
||||
# hanging the Settings panel. The orphaned worker is best-effort daemon.
|
||||
# * A process-wide lock serialises self-tests so a click-storm can't stack
|
||||
# concurrent model loads.
|
||||
# * Only ever on user click (POST) — never on Settings load. Loopback-gated.
|
||||
|
||||
# Deliberately short + ASCII so the synth stays CPU-cheap and the phrase never
|
||||
# trips the no-hardcoded-CJK guard.
|
||||
_SELFTEST_PHRASE = "OmniVoice engine self test."
|
||||
_SELFTEST_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _selftest_timeout_s() -> float:
|
||||
try:
|
||||
return max(1.0, float(os.environ.get("OMNIVOICE_SELFTEST_TIMEOUT_S", "90")))
|
||||
except (TypeError, ValueError):
|
||||
return 90.0
|
||||
|
||||
|
||||
def _sample_count(audio) -> int:
|
||||
"""Total sample count of an engine's ``generate()`` return, tolerant of
|
||||
torch.Tensor / numpy.ndarray / list shapes. 0 when it can't be measured."""
|
||||
try:
|
||||
shape = getattr(audio, "shape", None)
|
||||
if shape is not None and len(shape) > 0:
|
||||
return int(shape[-1])
|
||||
return int(len(audio))
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def _run_synth_bounded(backend, timeout_s: float) -> dict | None:
|
||||
"""Run one tiny synthesis in a daemon thread, bounded by ``timeout_s``.
|
||||
|
||||
Returns ``{"audio": .., "duration_ms": ..}`` on success, ``{"error": exc}``
|
||||
on a synth exception, or ``None`` when the timeout elapsed (worker left
|
||||
running best-effort — Python threads can't be force-killed)."""
|
||||
box: dict = {}
|
||||
|
||||
def _worker():
|
||||
t0 = perf_counter()
|
||||
try:
|
||||
audio = backend.generate(_SELFTEST_PHRASE, language="en", num_step=8)
|
||||
box["audio"] = audio
|
||||
except Exception as exc: # noqa: BLE001 — surfaced to the caller as ok=False
|
||||
box["error"] = exc
|
||||
finally:
|
||||
box["duration_ms"] = (perf_counter() - t0) * 1000.0
|
||||
|
||||
th = threading.Thread(target=_worker, name="engine-selftest", daemon=True)
|
||||
th.start()
|
||||
th.join(timeout_s)
|
||||
if th.is_alive():
|
||||
return None
|
||||
return box
|
||||
|
||||
|
||||
class SelfTestResponse(BaseModel):
|
||||
id: str
|
||||
ok: bool
|
||||
message: str
|
||||
duration_ms: float
|
||||
sample_rate: int | None = None
|
||||
num_samples: int | None = None
|
||||
audio_seconds: float | None = None
|
||||
timed_out: bool = False
|
||||
|
||||
|
||||
@router.post(
|
||||
"/engines/{engine_id}/selftest",
|
||||
response_model=SelfTestResponse,
|
||||
dependencies=[Depends(require_loopback)],
|
||||
)
|
||||
def engine_selftest(engine_id: str):
|
||||
"""Run a bounded, real synthesis on an available in-process TTS engine.
|
||||
|
||||
404 for an unknown TTS id; 400 when the engine is subprocess-isolated or
|
||||
not currently available (a real synth on either is meaningless). Never
|
||||
raises through to a 500 on a synth failure — the exception is captured into
|
||||
``ok=False`` / ``message`` so the panel renders a per-row failure."""
|
||||
if engine_id not in tts_backend._REGISTRY:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"unknown TTS engine id: {engine_id!r}",
|
||||
)
|
||||
cls = tts_backend._REGISTRY[engine_id]
|
||||
if getattr(cls, "_is_subprocess_isolated", False):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
f"{engine_id} is subprocess-isolated — self-test runs real "
|
||||
"synthesis for in-process engines only. Use Test engine "
|
||||
"(spawn-and-ping) for subprocess engines."
|
||||
),
|
||||
)
|
||||
try:
|
||||
ok, msg = cls.is_available()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
ok, msg = False, f"{type(exc).__name__}: {exc}"
|
||||
if not ok:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
f"{engine_id} is not available: {tts_backend._mask_hf_tokens(msg)}. "
|
||||
"Install/enable the engine, then self-test."
|
||||
),
|
||||
)
|
||||
|
||||
timeout_s = _selftest_timeout_s()
|
||||
# Serialise so a click-storm can't stack concurrent model loads.
|
||||
with _SELFTEST_LOCK:
|
||||
backend = _get_engine_instance(cls)
|
||||
res = _run_synth_bounded(backend, timeout_s)
|
||||
|
||||
if res is None:
|
||||
return SelfTestResponse(
|
||||
id=engine_id,
|
||||
ok=False,
|
||||
message=f"timed out after {timeout_s:.0f}s (model still loading?)",
|
||||
duration_ms=timeout_s * 1000.0,
|
||||
timed_out=True,
|
||||
)
|
||||
if "error" in res:
|
||||
exc = res["error"]
|
||||
return SelfTestResponse(
|
||||
id=engine_id,
|
||||
ok=False,
|
||||
message=tts_backend._mask_hf_tokens(f"{type(exc).__name__}: {exc}"),
|
||||
duration_ms=res.get("duration_ms", 0.0),
|
||||
)
|
||||
|
||||
n = _sample_count(res.get("audio"))
|
||||
try:
|
||||
sr = int(getattr(backend, "sample_rate", 0) or 0) or None
|
||||
except Exception:
|
||||
sr = None
|
||||
secs = round(n / sr, 3) if (sr and n) else None
|
||||
return SelfTestResponse(
|
||||
id=engine_id,
|
||||
ok=n > 0,
|
||||
message="synthesized" if n > 0 else "engine returned no audio",
|
||||
duration_ms=res["duration_ms"],
|
||||
sample_rate=sr,
|
||||
num_samples=n or None,
|
||||
audio_seconds=secs,
|
||||
)
|
||||
|
||||
|
||||
class SelectEngineRequest(BaseModel):
|
||||
family: str # "tts" | "asr" | "llm"
|
||||
backend_id: str
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import os
|
||||
import io
|
||||
import re
|
||||
import uuid
|
||||
import time
|
||||
import random
|
||||
@@ -142,6 +143,136 @@ def _apply_effect_chain(audio_out, sample_rate, effect_preset, *, skip_mastering
|
||||
return normalize_audio(audio_out, target_dBFS=-2.0)
|
||||
|
||||
|
||||
def _exception_chain(e):
|
||||
"""Yield ``e`` plus every ``__cause__``/``__context__`` beneath it
|
||||
(cycle-safe). Engines and hub libraries routinely wrap the original
|
||||
transport/allocator error, so classification must look at the whole
|
||||
chain, not just the outermost message."""
|
||||
seen = set()
|
||||
stack = [e]
|
||||
while stack:
|
||||
exc = stack.pop()
|
||||
if exc is None or id(exc) in seen:
|
||||
continue
|
||||
seen.add(id(exc))
|
||||
yield exc
|
||||
stack.append(exc.__cause__)
|
||||
stack.append(exc.__context__)
|
||||
|
||||
|
||||
# #880: transport-level exception type names from httpx (huggingface_hub ≥1.x
|
||||
# downloads over it) and requests/urllib3 (older engine deps). Any of these
|
||||
# anywhere in the exception chain means the network — not memory — killed the
|
||||
# generation.
|
||||
_NETWORK_EXC_NAMES = frozenset({
|
||||
# httpx
|
||||
"ConnectError", "ConnectTimeout", "ReadTimeout", "ReadError",
|
||||
"WriteError", "WriteTimeout", "PoolTimeout", "NetworkError",
|
||||
"TransportError", "RemoteProtocolError", "ProxyError", "CloseError",
|
||||
# requests / urllib3
|
||||
"ConnectionError", "ChunkedEncodingError", "MaxRetryError",
|
||||
"NewConnectionError", "ProtocolError",
|
||||
# stdlib socket-level drops mid-download
|
||||
"ConnectionResetError", "ConnectionAbortedError", "ConnectionRefusedError",
|
||||
# huggingface_hub: failed first-use download with nothing in the disk cache
|
||||
"LocalEntryNotFoundError",
|
||||
})
|
||||
|
||||
# Same class, but the transport error was stringified into a wrapper message
|
||||
# (so the type name is gone). All lowercase; matched against .lower().
|
||||
_NETWORK_MSG_SIGNATURES = (
|
||||
"client has been closed", # httpx closed-client lifecycle error (#880)
|
||||
"cannot send a request", # httpx: same error, message head
|
||||
"connection error", # requests / huggingface_hub wording
|
||||
"connection reset", # ECONNRESET mid-download
|
||||
"read timed out", # requests/urllib3 timeout wording
|
||||
"max retries exceeded", # urllib3 retry exhaustion
|
||||
"temporary failure in name resolution", # DNS down (glibc)
|
||||
"name or service not known", # DNS down (glibc)
|
||||
"getaddrinfo failed", # DNS down (Windows)
|
||||
)
|
||||
|
||||
|
||||
def _is_network_failure(e) -> bool:
|
||||
"""True iff the failure (anywhere in its chain) is an HTTP-client
|
||||
lifecycle / network-transport error — e.g. a first-use model download
|
||||
from the HF Hub dying mid-generation (#880)."""
|
||||
for exc in _exception_chain(e):
|
||||
if type(exc).__name__ in _NETWORK_EXC_NAMES:
|
||||
return True
|
||||
low = str(exc).lower()
|
||||
if any(sig in low for sig in _NETWORK_MSG_SIGNATURES):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# Signatures of an *actual* out-of-memory condition. All lowercase.
|
||||
_OOM_MSG_SIGNATURES = (
|
||||
"out of memory", # CUDA / MPS / generic torch wording
|
||||
"not enough memory", # torch CPU DefaultCPUAllocator
|
||||
"cannot allocate memory", # OS-level ENOMEM
|
||||
"std::bad_alloc", # C++ allocator failure
|
||||
"cublas_status_alloc_failed", # cuBLAS workspace allocation
|
||||
"cuda_error_out_of_memory", # raw CUDA driver error name
|
||||
"paging file is too small", # Windows [WinError 1455] mapping DLLs
|
||||
)
|
||||
|
||||
|
||||
def _is_oom_failure(e) -> bool:
|
||||
"""True iff the failure (anywhere in its chain) actually looks like an
|
||||
out-of-memory condition — the only case where the Flush hint is honest."""
|
||||
for exc in _exception_chain(e):
|
||||
if isinstance(exc, MemoryError):
|
||||
return True
|
||||
# torch.cuda.OutOfMemoryError subclasses RuntimeError; match by name
|
||||
# so this needs no torch import (and covers other frameworks' twins).
|
||||
if type(exc).__name__ == "OutOfMemoryError":
|
||||
return True
|
||||
low = str(exc).lower()
|
||||
if any(sig in low for sig in _OOM_MSG_SIGNATURES):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# #919: an engine that requires a model path / env var which isn't set (or is
|
||||
# set to a directory missing its model files) fails with a *configuration*
|
||||
# error, not a runtime one. The reporting user selected sherpa-onnx and hit
|
||||
# "OMNIVOICE_SHERPA_MODEL not set. Point it to a sherpa-onnx TTS model
|
||||
# directory …" — a pure setup problem — yet the OOM catch-all told them (on a
|
||||
# 63 GB-RAM box) to press Flush for memory they never ran out of. Classify the
|
||||
# whole CLASS of "engine not configured / required env var not set" errors so
|
||||
# any current or future opt-in engine (sherpa/Confucius4/dots/MOSS …) surfaces
|
||||
# actionable setup guidance instead of the memory hint. All lowercase; matched
|
||||
# over the whole exception chain (engines wrap the original error).
|
||||
_CONFIG_MSG_SIGNATURES = (
|
||||
"not set. point it to", # sherpa: OMNIVOICE_SHERPA_MODEL not set
|
||||
"no model.onnx found in", # sherpa: dir set but the model file is missing
|
||||
"not configured", # generic "engine not configured" wording
|
||||
"venv not found. set", # confucius4/dots/MOSS dedicated-venv opt-ins
|
||||
"unavailable: omnivoice_", # is_available() reason wrapped by _ensure_loaded
|
||||
)
|
||||
|
||||
# An OMNIVOICE_* engine env var named alongside "not set" / "point it to" /
|
||||
# "set omnivoice_…" is the strongest config-missing signal and generalizes to
|
||||
# any engine gated on such a var (issue #919 class).
|
||||
_CONFIG_ENV_RE = re.compile(r"omnivoice_[a-z0-9_]+")
|
||||
|
||||
|
||||
def _is_config_failure(e) -> bool:
|
||||
"""True iff the failure is a *configuration* problem — a required engine
|
||||
model path / env var that isn't set (or points nowhere) — rather than a
|
||||
runtime fault. The remedy is to set the value, never to Flush VRAM."""
|
||||
for exc in _exception_chain(e):
|
||||
low = str(exc).lower()
|
||||
if any(sig in low for sig in _CONFIG_MSG_SIGNATURES):
|
||||
return True
|
||||
if _CONFIG_ENV_RE.search(low) and (
|
||||
"not set" in low or "point it to" in low or "set omnivoice_" in low
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _oom_friendly_reraise(e):
|
||||
"""Best-effort cache flush + the user-facing OOM hint shared by both
|
||||
inference paths."""
|
||||
@@ -233,10 +364,56 @@ def _oom_friendly_reraise(e):
|
||||
f"Restart the app and try again; the Flush button won't help here. "
|
||||
f"Underlying error: {e}"
|
||||
) from e
|
||||
# #880: an httpx/requests transport failure surfacing from generation —
|
||||
# most commonly a first-use model download from the HF Hub dying with
|
||||
# httpx's "Cannot send a request, as the client has been closed" (the
|
||||
# shared client got closed mid-lifecycle), a connect/read timeout, or a
|
||||
# dropped connection — is NOT out of memory. The model never finished
|
||||
# loading, so Flush is the wrong remedy; retrying is. Matched over the
|
||||
# whole exception chain (type names + stringified signatures) because
|
||||
# engines wrap the original transport error.
|
||||
if _is_network_failure(e):
|
||||
raise RuntimeError(
|
||||
f"A model download or network call failed mid-generation (usually "
|
||||
f"the engine fetching its model files on first use). This is a "
|
||||
f"network problem, not a memory problem — flushing VRAM won't "
|
||||
f"help. Retry the generation; if it keeps failing, check your "
|
||||
f"internet connection and any HF_ENDPOINT/mirror setting. "
|
||||
f"Underlying error: {e}"
|
||||
) from e
|
||||
# #919: a required engine model path / env var that isn't set is a pure
|
||||
# CONFIGURATION problem, not a runtime one. sherpa-onnx's
|
||||
# "OMNIVOICE_SHERPA_MODEL not set. Point it to …" used to fall through to
|
||||
# the OOM catch-all, telling a user with 63 GB of RAM to press Flush. Point
|
||||
# at the real fix — set the variable — and never mention memory or Flush.
|
||||
# The underlying error already names the exact variable + what to point it
|
||||
# at (and Settings → Engines shows a copy-paste setup line), so keep it
|
||||
# front-and-center. Checked before the OOM branch so a config error can
|
||||
# never be mislabeled as memory.
|
||||
if _is_config_failure(e):
|
||||
raise RuntimeError(
|
||||
f"This TTS engine isn't set up yet — it needs a model path or "
|
||||
f"environment variable that isn't configured, so nothing was "
|
||||
f"generated. Set it as the underlying error describes (it names the "
|
||||
f"exact variable and what to point it at), then restart OmniVoice — "
|
||||
f"or pick a ready engine in Settings → Engines. This is a setup "
|
||||
f"problem, not a memory one. Underlying error: {e}"
|
||||
) from e
|
||||
# #880 (the class bug): the OOM hint used to be the catch-all fallback,
|
||||
# so ANY unrecognized error told the user to press Flush for memory they
|
||||
# never ran out of. Only claim OOM when something in the chain actually
|
||||
# looks like one; everything else surfaces as what it is — unrecognized —
|
||||
# with the real error front and center.
|
||||
if _is_oom_failure(e):
|
||||
raise RuntimeError(
|
||||
f"TTS engine stopped mid-generation. This usually means it ran out of memory. "
|
||||
f"Try the Flush button to reload the model, then regenerate. Underlying error: {e}"
|
||||
) from e
|
||||
raise RuntimeError(
|
||||
f"TTS engine stopped mid-generation. This usually means it ran out of memory. "
|
||||
f"Try the Flush button to reload the model, then regenerate. Underlying error: {e}"
|
||||
)
|
||||
f"TTS engine stopped mid-generation with an error OmniVoice doesn't "
|
||||
f"recognize. Retry once; if it keeps failing, please report it with "
|
||||
f"the full trace. Underlying error: {e}"
|
||||
) from e
|
||||
|
||||
|
||||
def _run_inference(
|
||||
|
||||
@@ -189,15 +189,21 @@ def auto_extract(project_id: str, req: AutoExtractRequest):
|
||||
Writes them as `auto=1` rows. Existing terms with the same (source,target)
|
||||
are NOT duplicated. Returns the full current glossary after the pass.
|
||||
"""
|
||||
from services.translator import _llm_client, _llm_model, _llm_timeout # reuse same client
|
||||
# Resolved through the LLM Skills registry so auto-extract can be toggled
|
||||
# or routed to its own provider (Settings → LLM Skills) independently of
|
||||
# the translation pipeline. None == disabled or no provider configured.
|
||||
from services import llm_skills
|
||||
|
||||
client = _llm_client()
|
||||
if client is None:
|
||||
handle = llm_skills.resolve_skill_client("glossary_extract")
|
||||
if handle is None:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=(
|
||||
"Auto-extract needs an LLM. Set TRANSLATE_BASE_URL + TRANSLATE_API_KEY "
|
||||
"(Ollama works locally: base_url=http://localhost:11434/v1) and try again."
|
||||
"Auto-extract needs an LLM. Set one up in Settings → LLM Providers "
|
||||
"(pick a provider, add its key, choose a model, Test) — or use local "
|
||||
"Ollama / LM Studio for a fully offline setup — and make sure the "
|
||||
"Glossary auto-extract skill is enabled in Settings → LLM Skills, "
|
||||
"then try again."
|
||||
),
|
||||
)
|
||||
|
||||
@@ -220,9 +226,9 @@ def auto_extract(project_id: str, req: AutoExtractRequest):
|
||||
)
|
||||
|
||||
try:
|
||||
res = client.chat.completions.create(
|
||||
model=_llm_model(),
|
||||
timeout=_llm_timeout(),
|
||||
res = handle.client.chat.completions.create(
|
||||
model=handle.model,
|
||||
timeout=handle.timeout,
|
||||
messages=[
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": user},
|
||||
@@ -231,9 +237,18 @@ def auto_extract(project_id: str, req: AutoExtractRequest):
|
||||
body = (res.choices[0].message.content or "").strip()
|
||||
except Exception as e:
|
||||
logger.warning("auto-extract LLM call failed: %s", e)
|
||||
# Scrub the provider error — some OpenAI-compatible providers echo the
|
||||
# API key or a user_id in the body, which must not reach the UI verbatim.
|
||||
from core.scrub import scrub_provider_error
|
||||
from services import llm_providers
|
||||
_p = llm_providers.active_provider()
|
||||
_key = llm_providers.resolve_api_key(_p) if _p else None
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"LLM didn't respond. Check Settings → Logs → Backend for the trace. Error: {e}",
|
||||
detail=(
|
||||
"LLM didn't respond. Check Settings → Logs → Backend for the trace. "
|
||||
f"Error: {scrub_provider_error(e, _key)}"
|
||||
),
|
||||
)
|
||||
|
||||
# Parse: SOURCE || TARGET || note (lines are allowed to be sloppy — we're forgiving).
|
||||
|
||||
@@ -12,6 +12,7 @@ The state endpoint duplicates `/system/hf-token/state` (which lives on
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import asdict
|
||||
@@ -134,12 +135,22 @@ class _RefinementBody(BaseModel):
|
||||
|
||||
|
||||
def _refinement_state():
|
||||
from services.refinement import get_refinement_config
|
||||
from services.llm_backend import get_active_llm_backend
|
||||
from services.refinement import (
|
||||
_skill_llm,
|
||||
get_last_refine_status,
|
||||
get_refinement_config,
|
||||
)
|
||||
|
||||
cfg = get_refinement_config()
|
||||
# The UI shows whether refinement can actually run (needs an LLM).
|
||||
cfg["llm_ready"] = get_active_llm_backend().id != "off"
|
||||
# `llm_ready` only means "an endpoint is CONFIGURED" — a placeholder/dead
|
||||
# endpoint still reads ready. It's resolved through the LLM Skills registry
|
||||
# so a disabled dictation_refinement skill / per-skill provider override
|
||||
# reads the same here as on the actual refine path. The honesty layer is
|
||||
# `last_refine_status`: {ok, reason, at} from the most recent final, so the
|
||||
# panel can flag a configured-but-failing LLM (the real safety is the hard
|
||||
# refine timeout, which keeps a dead endpoint from ever stalling the final).
|
||||
cfg["llm_ready"] = _skill_llm().id != "off"
|
||||
cfg["last_refine_status"] = get_last_refine_status()
|
||||
return cfg
|
||||
|
||||
|
||||
@@ -293,13 +304,58 @@ def set_active_llm_provider(body: _LLMActiveBody):
|
||||
return list_llm_providers()
|
||||
|
||||
|
||||
def _scrub_llm_detail(e: Exception, api_key: str | None) -> str:
|
||||
"""Scrubbed, UI-safe failure text. scrub_text() covers env secrets and
|
||||
home paths — but a STORE-persisted key isn't in the env, and some
|
||||
providers echo the key in error bodies, so redact the exact resolved key
|
||||
explicitly before the generic pass."""
|
||||
from core.scrub import scrub_text
|
||||
detail = f"{type(e).__name__}: {e}"
|
||||
if api_key and api_key != "local" and len(api_key) >= 8:
|
||||
detail = detail.replace(api_key, "•••")
|
||||
return scrub_text(detail)
|
||||
|
||||
|
||||
def _classify_llm_error(e: Exception) -> str:
|
||||
"""Map a provider-call failure to an actionable kind the UI can localize.
|
||||
|
||||
Kinds: auth (bad/missing key), not_found (model or endpoint path),
|
||||
rate_limit, network (DNS/conn/timeout), error (everything else).
|
||||
Status codes win when the OpenAI SDK provides one; exception-family
|
||||
names catch the non-HTTP failures (DNS, refused, TLS, timeout).
|
||||
"""
|
||||
status = getattr(e, "status_code", None)
|
||||
if status in (401, 403):
|
||||
return "auth"
|
||||
if status == 404:
|
||||
return "not_found"
|
||||
if status == 429:
|
||||
return "rate_limit"
|
||||
name = type(e).__name__
|
||||
if name in ("APIConnectionError", "APITimeoutError", "ConnectError",
|
||||
"ConnectTimeout", "TimeoutError"):
|
||||
return "network"
|
||||
if name == "AuthenticationError":
|
||||
return "auth"
|
||||
if name == "NotFoundError":
|
||||
return "not_found"
|
||||
if name == "RateLimitError":
|
||||
return "rate_limit"
|
||||
return "error"
|
||||
|
||||
|
||||
@router.post("/llm-providers/{provider_id}/test")
|
||||
def test_llm_provider(provider_id: str):
|
||||
"""One cheap round-trip against a provider to prove the key/URL work.
|
||||
|
||||
Temporarily activates the provider for the probe by resolving its config
|
||||
directly (does not change the persisted active selection).
|
||||
directly (does not change the persisted active selection). Returns
|
||||
latency_ms plus, on failure, a classified ``kind`` (config / auth /
|
||||
not_found / rate_limit / network / error) so the UI shows an actionable,
|
||||
localizable message instead of a raw exception string.
|
||||
"""
|
||||
import time as _time
|
||||
|
||||
from services import llm_providers
|
||||
p = llm_providers.get_provider(provider_id)
|
||||
if p is None:
|
||||
@@ -307,22 +363,117 @@ def test_llm_provider(provider_id: str):
|
||||
base_url = llm_providers.resolve_base_url(p)
|
||||
api_key = llm_providers.resolve_api_key(p)
|
||||
if not base_url:
|
||||
return {"ok": False, "detail": "No Base URL set for this provider."}
|
||||
return {"ok": False, "kind": "config", "detail": "No Base URL set for this provider."}
|
||||
if not api_key:
|
||||
return {"ok": False, "detail": "No API key configured for this provider."}
|
||||
return {"ok": False, "kind": "config", "detail": "No API key configured for this provider."}
|
||||
t0 = _time.monotonic()
|
||||
try:
|
||||
from openai import OpenAI
|
||||
client = OpenAI(api_key=api_key, base_url=base_url)
|
||||
# max_retries=0: this is an interactive probe with a live spinner — the
|
||||
# SDK's default 2 automatic retries turn a 429/timeout into a ~34s hang.
|
||||
# Surface the first failure immediately instead.
|
||||
client = OpenAI(api_key=api_key, base_url=base_url, max_retries=0)
|
||||
res = client.chat.completions.create(
|
||||
model=llm_providers.resolve_model(p),
|
||||
messages=[{"role": "user", "content": "Reply with the single word: ok"}],
|
||||
timeout=20,
|
||||
)
|
||||
reply = (res.choices[0].message.content or "").strip()
|
||||
return {"ok": True, "model": llm_providers.resolve_model(p), "reply": reply[:80]}
|
||||
return {
|
||||
"ok": True,
|
||||
"model": llm_providers.resolve_model(p),
|
||||
"reply": reply[:80],
|
||||
"latency_ms": int((_time.monotonic() - t0) * 1000),
|
||||
}
|
||||
except Exception as e: # noqa: BLE001 — surface a clean, scrubbed error to the UI
|
||||
from core.scrub import scrub_text
|
||||
return {"ok": False, "detail": scrub_text(f"{type(e).__name__}: {e}")}
|
||||
return {
|
||||
"ok": False,
|
||||
"kind": _classify_llm_error(e),
|
||||
"detail": _scrub_llm_detail(e, api_key),
|
||||
"latency_ms": int((_time.monotonic() - t0) * 1000),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/llm-providers/{provider_id}/models")
|
||||
def list_llm_provider_models(provider_id: str):
|
||||
"""List model ids the provider's key can access (OpenAI-compat /models).
|
||||
|
||||
Powers the model-picker datalist in Settings → LLM Providers so users
|
||||
don't have to guess model names. Read-only; failures return the same
|
||||
classified shape as /test; capped so a huge catalog can't bloat the UI.
|
||||
"""
|
||||
from services import llm_providers
|
||||
p = llm_providers.get_provider(provider_id)
|
||||
if p is None:
|
||||
raise HTTPException(status_code=404, detail=f"unknown provider {provider_id!r}")
|
||||
base_url = llm_providers.resolve_base_url(p)
|
||||
api_key = llm_providers.resolve_api_key(p)
|
||||
if not base_url or not api_key:
|
||||
return {"ok": False, "kind": "config", "models": []}
|
||||
try:
|
||||
from openai import OpenAI
|
||||
# max_retries=0: interactive probe — fail fast, don't burn ~34s on the
|
||||
# SDK's default retry ladder when the key/URL is wrong (matches /test).
|
||||
client = OpenAI(api_key=api_key, base_url=base_url, max_retries=0)
|
||||
ids = sorted(m.id for m in client.models.list(timeout=10))
|
||||
# Cap so a huge catalog can't bloat the datalist; flag the cap so the UI
|
||||
# can say "first 200 shown" rather than implying it's the full list.
|
||||
return {"ok": True, "models": ids[:200], "truncated": len(ids) > 200}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {
|
||||
"ok": False,
|
||||
"kind": _classify_llm_error(e),
|
||||
"detail": _scrub_llm_detail(e, api_key),
|
||||
"models": [],
|
||||
}
|
||||
|
||||
|
||||
# ── LLM Skills (Settings → LLM Skills) ─────────────────────────────────────
|
||||
# Per-feature enable/route control for every LLM consumption point. Each
|
||||
# skill can be toggled off (degrades exactly like "no LLM configured") or
|
||||
# routed to a specific provider (local Ollama/LM Studio vs a remote key)
|
||||
# instead of the one global active provider. Loopback-gated (router dep).
|
||||
|
||||
|
||||
class _LLMSkillBody(BaseModel):
|
||||
enabled: bool | None = Field(None, description="None leaves the toggle unchanged")
|
||||
provider_override: str | None = Field(
|
||||
None,
|
||||
description="provider id to route this skill to; '' or null clears "
|
||||
"it (skill follows the active provider). Omit to leave "
|
||||
"unchanged.",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/llm-skills")
|
||||
def list_llm_skills():
|
||||
"""Every LLM skill with its toggle, routing, and resolved ready status."""
|
||||
from services import llm_skills
|
||||
return {"skills": [llm_skills.describe(s.id) for s in llm_skills.all_skills()]}
|
||||
|
||||
|
||||
@router.put("/llm-skills/{skill_id}")
|
||||
def set_llm_skill(skill_id: str, body: _LLMSkillBody):
|
||||
"""Toggle a skill and/or set its provider routing.
|
||||
|
||||
Field semantics match the providers PUT: an omitted field is left
|
||||
unchanged; ``provider_override: ""``/``null`` clears the override.
|
||||
404 for an unknown skill or an unknown provider id.
|
||||
"""
|
||||
from services import llm_skills
|
||||
if llm_skills.get_skill(skill_id) is None:
|
||||
raise HTTPException(status_code=404, detail=f"unknown LLM skill {skill_id!r}")
|
||||
kwargs = {}
|
||||
if body.enabled is not None:
|
||||
kwargs["enabled"] = body.enabled
|
||||
if "provider_override" in body.model_fields_set:
|
||||
kwargs["provider_override"] = body.provider_override
|
||||
try:
|
||||
if kwargs:
|
||||
llm_skills.configure_skill(skill_id, **kwargs)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
return list_llm_skills()
|
||||
|
||||
|
||||
# ── License acceptance (Phase 3 Plan 03-01 / TTS-05) ──────────────────────
|
||||
@@ -488,6 +639,41 @@ def set_models_dir(body: _ModelsDirBody):
|
||||
return {"configured": path, "effective": _effective_models_dir(), "restart_required": True}
|
||||
|
||||
|
||||
# ── Storage report (Settings → Storage) ────────────────────────────────────
|
||||
# Per-volume disk totals + du-style sizes for everything the app owns (HF
|
||||
# model cache, app data subtotals, engine venvs, temp files) with server-side
|
||||
# warnings. Heavy directory walks run in a worker thread with per-category
|
||||
# deadlines and a 5-minute in-process cache (services.storage_report), so the
|
||||
# endpoint stays cheap on repeat Settings visits. Loopback-gated via the
|
||||
# router-level dep like every sibling.
|
||||
|
||||
|
||||
@router.get("/storage")
|
||||
async def get_storage_report(refresh: bool = Query(False)):
|
||||
"""Disk + per-category storage usage for the Settings → Storage panel.
|
||||
|
||||
`refresh=1` bypasses the 5-minute cache and rescans. `min_free_gb`
|
||||
reuses the setup wizard's constant so both surfaces warn at the same
|
||||
threshold.
|
||||
"""
|
||||
from api.routers.setup.wizard import MIN_FREE_GB
|
||||
from core.config import DATA_DIR
|
||||
from services import storage_report
|
||||
|
||||
try:
|
||||
return await asyncio.to_thread(
|
||||
storage_report.get_report,
|
||||
data_dir=DATA_DIR,
|
||||
hf_cache_dir=_effective_models_dir(),
|
||||
app_venv=storage_report.default_app_venv(),
|
||||
min_free_gb=MIN_FREE_GB,
|
||||
refresh=refresh,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("storage report failed")
|
||||
raise HTTPException(status_code=500, detail="Failed to compute storage report")
|
||||
|
||||
|
||||
# ── HF mirror endpoint (parity program Wave 4.3 / §R4 c) ──────────────────
|
||||
# Restricted-network users (e.g. behind the Great Firewall) need to point
|
||||
# huggingface_hub at a mirror. HF reads HF_ENDPOINT at import time, so a
|
||||
@@ -530,6 +716,11 @@ def set_hf_mirror(body: _HFMirrorBody):
|
||||
url = (body.url or "").strip().rstrip("/")
|
||||
if url and not url.startswith(("http://", "https://")):
|
||||
raise HTTPException(status_code=400, detail="Mirror URL must start with http(s)://")
|
||||
# Compare against the currently-persisted value (normalised the same way) so
|
||||
# a no-op save doesn't nag the user to restart. Only a real change to the
|
||||
# persisted endpoint can require a restart.
|
||||
previous = (user_env.get_user_env(_HF_ENDPOINT_ENV) or "").strip().rstrip("/")
|
||||
changed = url != previous
|
||||
try:
|
||||
if url:
|
||||
user_env.set_user_env(_HF_ENDPOINT_ENV, url)
|
||||
@@ -540,6 +731,53 @@ def set_hf_mirror(body: _HFMirrorBody):
|
||||
except Exception:
|
||||
logger.exception("set_hf_mirror failed")
|
||||
raise HTTPException(status_code=500, detail="Failed to persist mirror setting")
|
||||
# HF endpoint is read at import time by huggingface_hub, so the override
|
||||
# is only guaranteed once the backend restarts.
|
||||
return {"configured": url, "restart_required": True, "presets": _HF_MIRROR_PRESETS}
|
||||
# Model Store downloads pick up the new mirror immediately — the download
|
||||
# path resolves the endpoint per-call and we updated os.environ above. Only
|
||||
# transformers-side model *loads* (which read HF_ENDPOINT at import time)
|
||||
# need a restart, so restart_required is True ONLY when the value actually
|
||||
# changed — a no-op re-save never asks for a restart.
|
||||
return {"configured": url, "restart_required": changed, "presets": _HF_MIRROR_PRESETS}
|
||||
|
||||
|
||||
# ── Updates panel: shipped changelog + pre-migration DB backup state ────────
|
||||
# (feat/safe-updates). Both are read-only, local-first surfaces for
|
||||
# Settings → Updates: the "What's new" viewer reads the CHANGELOG.md that
|
||||
# ships with the app, and the backup line shows the newest pre-migration
|
||||
# snapshot written by core.db_backup before `alembic upgrade head` runs.
|
||||
|
||||
|
||||
@router.get("/changelog")
|
||||
def get_changelog(limit_versions: int = Query(5, ge=1, le=50)):
|
||||
"""Structured release notes from the shipped CHANGELOG.md (newest first).
|
||||
|
||||
Bullets are raw markdown-lite (bold leads, `code`, (#NNN) refs) — the
|
||||
frontend renders them safely without HTML. `available: false` when this
|
||||
install has no changelog (never an error: the viewer just hides)."""
|
||||
from core import changelog
|
||||
|
||||
path = changelog.changelog_path()
|
||||
if not path:
|
||||
return {"available": False, "releases": []}
|
||||
try:
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
releases = changelog.parse_changelog(fh.read(), limit_versions)
|
||||
except Exception:
|
||||
logger.exception("changelog parse failed")
|
||||
return {"available": False, "releases": []}
|
||||
return {"available": bool(releases), "releases": releases}
|
||||
|
||||
|
||||
@router.get("/db-backup")
|
||||
def get_db_backup_state():
|
||||
"""Newest pre-migration database backup (or none yet). Feeds the
|
||||
"your data is backed up before every update" line in Settings → Updates."""
|
||||
from core import db_backup
|
||||
from core.config import DB_PATH
|
||||
|
||||
latest = db_backup.latest_backup(DB_PATH)
|
||||
return {
|
||||
"available": latest is not None,
|
||||
"latest": latest,
|
||||
"count": len(db_backup.list_backups(DB_PATH)),
|
||||
"keep": db_backup.KEEP_BACKUPS,
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ from .models import ( # noqa: F401
|
||||
KNOWN_MODELS,
|
||||
invalidate_cache,
|
||||
snapshot_has_weights,
|
||||
disk_space_error,
|
||||
_MIN_WEIGHT_BYTES,
|
||||
_WEIGHT_FLOORS,
|
||||
)
|
||||
@@ -404,6 +405,26 @@ async def install_model(req: InstallModelRequest):
|
||||
try:
|
||||
_plan = snapshot_download(**_preflight_kwargs)
|
||||
_summary = compute_plan(_plan)
|
||||
# Disk-space guard (before a single byte flows): the preflight
|
||||
# gives an exact "to download" size, so reject an install that
|
||||
# would overrun the cache volume — with the numbers named —
|
||||
# instead of failing mid-download with a cryptic OSError. No-op
|
||||
# when it fits or the size is unknown. Same on every platform.
|
||||
_disk_err = disk_space_error(_summary["to_download_bytes"])
|
||||
if _disk_err:
|
||||
logger.info("model install %s: rejected — %s", req.repo_id, _disk_err)
|
||||
_resolving.set() # stop the heartbeat thread before we bail
|
||||
hf_progress.emit({
|
||||
"repo_id": req.repo_id,
|
||||
"filename": req.repo_id,
|
||||
"downloaded": 0, "total": 0, "pct": 0.0,
|
||||
"phase": "install_error",
|
||||
"error": _disk_err,
|
||||
})
|
||||
# A disk-full is not a transient network failure — don't set
|
||||
# a cooldown (freeing space, not waiting, is the fix). The
|
||||
# outer finally still cleans up the aggregator + context.
|
||||
return
|
||||
download_aggregator.start(
|
||||
req.repo_id,
|
||||
total_bytes=_summary["to_download_bytes"],
|
||||
@@ -507,12 +528,16 @@ async def install_model(req: InstallModelRequest):
|
||||
logger.info("model install failed for %s: %s", req.repo_id, e)
|
||||
import time as _time_fail
|
||||
_install_cooldowns[req.repo_id] = _time_fail.time()
|
||||
# #874: when the install failed because the configured HF mirror is
|
||||
# unreachable, name the mirror + the setting instead of leaking the
|
||||
# raw connectivity error. No-op for every other failure.
|
||||
from core.failure import append_hf_mirror_hint
|
||||
hf_progress.emit({
|
||||
"repo_id": req.repo_id,
|
||||
"filename": req.repo_id,
|
||||
"downloaded": 0, "total": 0, "pct": 0.0,
|
||||
"phase": "install_error",
|
||||
"error": str(e),
|
||||
"error": append_hf_mirror_hint(str(e)),
|
||||
})
|
||||
finally:
|
||||
_cancelled.discard(req.repo_id)
|
||||
|
||||
@@ -123,6 +123,66 @@ def hf_cache_dir() -> str:
|
||||
)
|
||||
|
||||
|
||||
# ── Disk-space guard (shared, single-sourced) ──────────────────────────────
|
||||
# MIN_FREE_GB is the headroom we insist on keeping free on the model-cache
|
||||
# volume — the wizard's absolute pre-install floor AND the extra buffer the
|
||||
# per-install check demands on top of the download itself, so an "Install all"
|
||||
# can't fill the disk to the brim (setup/download.py). Lives here — the lowest
|
||||
# module in the setup import graph — so the wizard, the /models header, and the
|
||||
# install endpoint can't drift apart (mirrors the weight-floor single-sourcing).
|
||||
_GIB = 1024 ** 3
|
||||
MIN_FREE_GB = 10
|
||||
|
||||
|
||||
def disk_free_bytes(path: "str | None" = None) -> int:
|
||||
"""Free bytes on the volume backing *path* (defaults to the HF cache).
|
||||
|
||||
Walks up to the nearest existing ancestor so a not-yet-created cache dir
|
||||
still probes the correct mount point. ``shutil.disk_usage`` is cross-platform
|
||||
(macOS/Windows/Linux) so this behaves identically everywhere. Never raises.
|
||||
"""
|
||||
import shutil
|
||||
try:
|
||||
p = Path(path or hf_cache_dir()).resolve()
|
||||
while not p.exists():
|
||||
parent = p.parent
|
||||
if parent == p: # reached the volume root
|
||||
break
|
||||
p = parent
|
||||
return int(shutil.disk_usage(str(p)).free)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def disk_space_error(to_download_bytes: "int | None", *, cache_dir: "str | None" = None) -> "str | None":
|
||||
"""Actionable message when *to_download_bytes* (+ MIN_FREE_GB headroom) won't
|
||||
fit on the cache volume; ``None`` when it fits, the size is unknown, or the
|
||||
volume can't be probed (never block on missing information).
|
||||
|
||||
Names the three numbers a user needs to act — needs X, headroom Y, have Z —
|
||||
so "Install all" can't silently overrun the disk (issue: no pre-install disk
|
||||
check). Platform-agnostic; applied identically on macOS/Windows/Linux.
|
||||
"""
|
||||
if not to_download_bytes or to_download_bytes <= 0:
|
||||
return None # unknown plan (older/gated repo, mirror without dry-run) → don't block
|
||||
cache = cache_dir or hf_cache_dir()
|
||||
free = disk_free_bytes(cache)
|
||||
if free <= 0:
|
||||
return None # couldn't probe the volume → don't block on missing info
|
||||
required = int(to_download_bytes) + MIN_FREE_GB * _GIB
|
||||
if free >= required:
|
||||
return None
|
||||
|
||||
def _gb(n: int) -> str:
|
||||
return f"{n / _GIB:.1f} GB"
|
||||
|
||||
return (
|
||||
f"Not enough disk space to install: this download needs {_gb(int(to_download_bytes))} "
|
||||
f"plus {MIN_FREE_GB} GB free headroom ({_gb(required)} total), but only {_gb(free)} "
|
||||
f"is free at {cache}. Free up space (or move the model cache to a bigger volume) and retry."
|
||||
)
|
||||
|
||||
|
||||
def _repo_dir_name(repo_id: str) -> str:
|
||||
"""HF cache dir name for a repo: 'k2-fsa/OmniVoice' → 'models--k2-fsa--OmniVoice'."""
|
||||
return "models--" + repo_id.replace("/", "--")
|
||||
@@ -391,6 +451,10 @@ def list_models():
|
||||
"models": out,
|
||||
"total_installed_bytes": sum(m["size_on_disk_bytes"] for m in out),
|
||||
"hf_cache_dir": hf_cache_dir(),
|
||||
# Free space on the cache volume, so the Model Store header can warn
|
||||
# BEFORE an "Install all" overruns the disk (pairs with the per-install
|
||||
# disk_space_error guard in setup/download.py).
|
||||
"disk_free_gb": round(disk_free_bytes() / _GIB, 1),
|
||||
"platform_tags": _current_platform_tags(),
|
||||
}
|
||||
_set_cache("models", response)
|
||||
|
||||
@@ -18,33 +18,20 @@ import sys
|
||||
from fastapi import APIRouter
|
||||
|
||||
from api.schemas import SetupStatusResponse, PreflightResponse
|
||||
from .models import REQUIRED_MODELS, hf_cache_dir, is_cached
|
||||
# MIN_FREE_GB + disk_free_bytes are single-sourced in ``.models`` (the lowest
|
||||
# module in the setup import graph) so the wizard gate, the /models header, and
|
||||
# the per-install disk guard can't drift apart.
|
||||
from .models import REQUIRED_MODELS, hf_cache_dir, is_cached, MIN_FREE_GB, disk_free_bytes
|
||||
|
||||
logger = logging.getLogger("omnivoice.setup.wizard")
|
||||
router = APIRouter()
|
||||
|
||||
MIN_FREE_GB = 10
|
||||
|
||||
|
||||
def _disk_free_gb(path: str) -> float:
|
||||
"""Return free GB on the volume containing *path*.
|
||||
|
||||
If *path* doesn't exist yet (e.g. after a fresh wipe), walk up to the
|
||||
nearest existing ancestor so ``shutil.disk_usage`` can still probe the
|
||||
correct mount point.
|
||||
"""
|
||||
try:
|
||||
from pathlib import Path
|
||||
p = Path(path).resolve()
|
||||
# Walk up until we find a directory that exists
|
||||
while not p.exists():
|
||||
parent = p.parent
|
||||
if parent == p: # root
|
||||
break
|
||||
p = parent
|
||||
return _shutil.disk_usage(str(p)).free / (1024 ** 3)
|
||||
except Exception:
|
||||
return 0.0
|
||||
"""Free GB on the volume containing *path* (thin GB wrapper over the shared
|
||||
``models.disk_free_bytes``, which walks up to the nearest existing ancestor
|
||||
for a not-yet-created path)."""
|
||||
return disk_free_bytes(path) / (1024 ** 3)
|
||||
|
||||
|
||||
# ── Setup Status ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Parse the shipped CHANGELOG.md into structured release notes.
|
||||
|
||||
Feeds ``GET /api/settings/changelog`` — the Settings → Updates "What's new"
|
||||
viewer. Local-first by design: the changelog ships with the app (repo root in
|
||||
dev; copied into the packaged project dir by the Tauri bootstrap alongside
|
||||
README.md), so the viewer works fully offline.
|
||||
|
||||
The house format (see CHANGELOG.md / the release-notes hard rule):
|
||||
|
||||
## [X.Y.Z] — DATE
|
||||
one-paragraph headline (the "intro")
|
||||
### Added / Fixed / Changed / ...
|
||||
- **Bold one-line lead.** 1-3 lines of plain-English why. (#NNN)
|
||||
|
||||
Bullets may be a single long line (recent sections) *or* hard-wrapped across
|
||||
indented continuation lines (older sections) — the parser normalizes both to
|
||||
one logical line per bullet. Bullets stay raw markdown-lite; the frontend's
|
||||
safe renderer handles **bold** / `code` / (#NNN) refs.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
#: ``## [0.3.9] — 2026-07-02`` (em/en dash or hyphen; date optional).
|
||||
_RELEASE_RE = re.compile(r"^##\s+\[(?P<version>[^\]]+)\]\s*(?:[—–-]\s*(?P<date>.+?))?\s*$")
|
||||
_SECTION_RE = re.compile(r"^###\s+(?P<title>.+?)\s*$")
|
||||
_BULLET_RE = re.compile(r"^\s*[-*]\s+(?P<text>.*\S)\s*$")
|
||||
|
||||
|
||||
def changelog_path() -> str | None:
|
||||
"""The shipped CHANGELOG.md, or None when this install doesn't have one.
|
||||
|
||||
``backend/core/changelog.py`` → two levels up is the project root: the
|
||||
repo root in dev, and ``<env>/project`` in packaged installs (where the
|
||||
bootstrap copies CHANGELOG.md next to README.md). ``OMNIVOICE_CHANGELOG``
|
||||
overrides for tests/containers.
|
||||
"""
|
||||
override = os.environ.get("OMNIVOICE_CHANGELOG")
|
||||
if override:
|
||||
return override if os.path.isfile(override) else None
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
candidate = os.path.join(os.path.dirname(os.path.dirname(here)), "CHANGELOG.md")
|
||||
return candidate if os.path.isfile(candidate) else None
|
||||
|
||||
|
||||
def _looks_like_release_version(version: str) -> bool:
|
||||
"""Only released ``X.Y.Z...`` sections (skip ``[Unreleased]`` etc.)."""
|
||||
return bool(re.match(r"^v?\d", version.strip()))
|
||||
|
||||
|
||||
def parse_changelog(text: str, limit_versions: int = 5) -> list[dict]:
|
||||
"""CHANGELOG.md text → newest-first list of releases::
|
||||
|
||||
{"version": "0.3.9", "date": "2026-07-02", "intro": "…",
|
||||
"sections": [{"title": "Fixed", "bullets": ["…", …]}, …]}
|
||||
|
||||
Tolerates both single-line bullets and older hard-wrapped bullets
|
||||
(continuation lines are joined with a space). Content between the version
|
||||
heading and the first ``###`` becomes ``intro`` (paragraphs joined by
|
||||
blank lines).
|
||||
"""
|
||||
releases: list[dict] = []
|
||||
release: dict | None = None
|
||||
section: dict | None = None
|
||||
intro_parts: list[str] = []
|
||||
bullet_open = False # last bullet may still absorb continuation lines
|
||||
intro_new_para = True
|
||||
|
||||
def close_release():
|
||||
nonlocal release, section, intro_parts, bullet_open, intro_new_para
|
||||
if release is not None:
|
||||
release["intro"] = "\n\n".join(p for p in intro_parts if p)
|
||||
release["sections"] = [s for s in release["sections"] if s["bullets"]]
|
||||
releases.append(release)
|
||||
release = None
|
||||
section = None
|
||||
intro_parts = []
|
||||
bullet_open = False
|
||||
intro_new_para = True
|
||||
|
||||
for raw in text.splitlines():
|
||||
m = _RELEASE_RE.match(raw)
|
||||
if m:
|
||||
close_release()
|
||||
if len(releases) >= limit_versions:
|
||||
break
|
||||
version = m.group("version").strip().lstrip("v")
|
||||
if not _looks_like_release_version(version):
|
||||
continue # e.g. [Unreleased] — skip until the next heading
|
||||
release = {
|
||||
"version": version,
|
||||
"date": (m.group("date") or "").strip(),
|
||||
"intro": "",
|
||||
"sections": [],
|
||||
}
|
||||
continue
|
||||
if release is None:
|
||||
continue
|
||||
|
||||
line = raw.strip()
|
||||
if not line:
|
||||
bullet_open = False
|
||||
intro_new_para = True
|
||||
continue
|
||||
|
||||
sm = _SECTION_RE.match(raw)
|
||||
if sm:
|
||||
section = {"title": sm.group("title"), "bullets": []}
|
||||
release["sections"].append(section)
|
||||
bullet_open = False
|
||||
continue
|
||||
|
||||
bm = _BULLET_RE.match(raw)
|
||||
if bm:
|
||||
if section is None:
|
||||
# Rare: a bullet before any ### heading — group it untitled.
|
||||
section = {"title": "", "bullets": []}
|
||||
release["sections"].append(section)
|
||||
section["bullets"].append(bm.group("text"))
|
||||
bullet_open = True
|
||||
continue
|
||||
|
||||
if section is not None:
|
||||
if bullet_open and section["bullets"]:
|
||||
# Hard-wrapped bullet continuation (older sections) → join.
|
||||
section["bullets"][-1] += " " + line
|
||||
continue
|
||||
|
||||
# Headline paragraph(s) before the first ### section.
|
||||
if intro_new_para or not intro_parts:
|
||||
intro_parts.append(line)
|
||||
else:
|
||||
intro_parts[-1] += " " + line
|
||||
intro_new_para = False
|
||||
|
||||
close_release()
|
||||
return releases[:limit_versions]
|
||||
@@ -3,6 +3,8 @@ import sqlite3
|
||||
import logging
|
||||
from contextlib import contextmanager
|
||||
from core.config import DB_PATH
|
||||
from core import db_backup
|
||||
from core.version import APP_VERSION
|
||||
|
||||
logger = logging.getLogger("omnivoice.db")
|
||||
|
||||
@@ -312,14 +314,88 @@ def init_db():
|
||||
_run_alembic_upgrade()
|
||||
|
||||
|
||||
class MigrationError(RuntimeError):
|
||||
"""A schema migration failed *while executing*. Startup must NOT continue
|
||||
on a possibly half-migrated database — the caller lets this propagate so
|
||||
the process stops with an actionable message naming the pre-migration
|
||||
backup (see ``core.db_backup``). Restore is deliberately manual: silently
|
||||
auto-restoring the snapshot could itself discard user data."""
|
||||
|
||||
|
||||
def _reconcile_after_alembic_skip() -> None:
|
||||
"""Converge the schema directly when alembic can't run at all (not
|
||||
importable, or stamped at a removed revision — #552/#547) so additive
|
||||
columns still land instead of 500-ing on `no such column`. Only for the
|
||||
"nothing was applied" classes; a mid-migration failure must NOT reach
|
||||
here (see MigrationError)."""
|
||||
try:
|
||||
conn = get_db()
|
||||
try:
|
||||
_reconcile_additive_columns(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("schema reconcile after alembic skip also failed: %s", exc)
|
||||
|
||||
|
||||
def _stamped_revisions(db_path: str) -> set | None:
|
||||
"""Revisions recorded in ``alembic_version`` (empty set = never stamped),
|
||||
or None when the DB can't be read."""
|
||||
try:
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
try:
|
||||
return {r[0] for r in conn.execute("SELECT version_num FROM alembic_version")}
|
||||
except sqlite3.OperationalError:
|
||||
return set() # table absent — nothing ever stamped
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
|
||||
|
||||
def _plan_alembic(cfg) -> str:
|
||||
"""Decide what an ``upgrade head`` run would actually do:
|
||||
|
||||
- ``up_to_date`` — stamped at head; upgrade is a no-op.
|
||||
- ``pending`` — migrations WILL execute (snapshot the DB first).
|
||||
- ``unknown_revision`` — stamped at a revision this build doesn't ship
|
||||
(preview→stable downgrade, #552/#547); upgrade would fail before
|
||||
applying anything, so skip it and reconcile additively instead.
|
||||
- ``indeterminate`` — can't tell; treat like pending (snapshot, run).
|
||||
"""
|
||||
try:
|
||||
from alembic.script import ScriptDirectory
|
||||
|
||||
script = ScriptDirectory.from_config(cfg)
|
||||
known = {rev.revision for rev in script.walk_revisions()}
|
||||
heads = set(script.get_heads())
|
||||
stamped = _stamped_revisions(DB_PATH)
|
||||
if stamped is None:
|
||||
return "indeterminate"
|
||||
if stamped and not stamped <= known:
|
||||
return "unknown_revision"
|
||||
if stamped == heads:
|
||||
return "up_to_date"
|
||||
return "pending"
|
||||
except Exception: # noqa: BLE001
|
||||
return "indeterminate"
|
||||
|
||||
|
||||
def _run_alembic_upgrade() -> None:
|
||||
"""Best-effort `alembic upgrade head` on startup. Non-fatal: if alembic
|
||||
isn't reachable (e.g. a stripped-down install) or its version is stamped at
|
||||
a revision no longer in versions/ (e.g. after running a preview build), log
|
||||
a warning and move on. The schema is still kept correct by
|
||||
_reconcile_additive_columns (run in init_db above and again here on failure)
|
||||
— CREATE TABLE IF NOT EXISTS alone does NOT add columns to a pre-existing
|
||||
table, so the reconcile is what actually guarantees additive columns land."""
|
||||
"""`alembic upgrade head` on startup, wrapped in the data-safety net.
|
||||
|
||||
Failure classes are handled differently on purpose:
|
||||
|
||||
- alembic unavailable / stamped at an unknown revision → **non-fatal**
|
||||
(nothing was applied; warn + `_reconcile_additive_columns` keeps the
|
||||
schema converged, exactly the pre-existing #552/#547 behavior).
|
||||
- migrations actually pending → the DB is snapshotted first
|
||||
(``omnivoice.db.backup-<version>-<n>``, newest 3 kept), then upgraded.
|
||||
- a migration fails **while executing** → raise :class:`MigrationError`:
|
||||
startup stops with a message naming the backup, instead of silently
|
||||
running the app on a half-migrated DB.
|
||||
"""
|
||||
try:
|
||||
import os
|
||||
from alembic import command
|
||||
@@ -335,18 +411,62 @@ def _run_alembic_upgrade() -> None:
|
||||
return
|
||||
cfg = Config(ini)
|
||||
cfg.set_main_option("sqlalchemy.url", f"sqlite:///{DB_PATH}")
|
||||
except Exception as exc: # noqa: BLE001 — alembic not importable / bad ini
|
||||
logger.warning("alembic upgrade head skipped: %s", exc)
|
||||
_reconcile_after_alembic_skip()
|
||||
return
|
||||
|
||||
plan = _plan_alembic(cfg)
|
||||
if plan == "up_to_date":
|
||||
return
|
||||
if plan == "unknown_revision":
|
||||
logger.warning(
|
||||
"alembic_version is stamped at a revision this build doesn't ship "
|
||||
"(preview/newer build ran on this DB) — skipping alembic and "
|
||||
"reconciling the schema additively (#552/#547)"
|
||||
)
|
||||
_reconcile_after_alembic_skip()
|
||||
return
|
||||
|
||||
# Migrations may actually execute: snapshot the DB first so a failed or
|
||||
# interrupted migration can never cost user data. A backup problem alone
|
||||
# must not brick startup (the >500 MB skip is by design), so log and go on.
|
||||
# ``db_backup``/``APP_VERSION`` are module-level imports (top of file), not
|
||||
# re-imported here: a test that patches ``core.db_backup.MAX_BACKUP_DB_BYTES``
|
||||
# on the object it imported at collection must see the same object this
|
||||
# function uses. A lazy ``from core import db_backup`` would re-resolve
|
||||
# through the (possibly re-imported) ``core`` package and silently miss the
|
||||
# patch after another suite purged ``core.*`` from ``sys.modules``.
|
||||
backup_path = None
|
||||
try:
|
||||
backup_path = db_backup.snapshot_before_migration(DB_PATH, APP_VERSION)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("Pre-migration DB backup failed — continuing without one")
|
||||
|
||||
try:
|
||||
command.upgrade(cfg, "head")
|
||||
except Exception as exc:
|
||||
# Don't block startup on a migration tooling problem. Converge the schema
|
||||
# directly so a swallowed failure (alembic not importable, or
|
||||
# alembic_version stamped at a removed revision) still lands the additive
|
||||
# columns instead of 500-ing on `no such column` (#552/#547).
|
||||
logger.warning("alembic upgrade head skipped: %s", exc)
|
||||
try:
|
||||
conn = get_db()
|
||||
try:
|
||||
_reconcile_additive_columns(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception as exc2: # noqa: BLE001
|
||||
logger.warning("schema reconcile after alembic failure also failed: %s", exc2)
|
||||
if "Can't locate revision" in str(exc):
|
||||
# Belt for an unknown-revision case _plan_alembic missed: alembic
|
||||
# bails before applying anything, so the old non-fatal path is safe.
|
||||
logger.warning("alembic upgrade head skipped: %s", exc)
|
||||
_reconcile_after_alembic_skip()
|
||||
return
|
||||
backup_note = (
|
||||
f"A backup of your data from just before the migration is at: {backup_path}"
|
||||
if backup_path
|
||||
else "No pre-migration backup was written this run (see the log above)"
|
||||
)
|
||||
msg = (
|
||||
f"Database migration failed while running: {exc}. "
|
||||
f"OmniVoice stopped instead of running on a partially migrated database, "
|
||||
f"and nothing was auto-restored (your database at {DB_PATH} was left "
|
||||
f"exactly as the failed migration left it). "
|
||||
f"{backup_note}. "
|
||||
"What to do: relaunch to retry; if it keeps failing, report it at "
|
||||
"https://github.com/debpalash/OmniVoice-Studio/issues (keep the backup file). "
|
||||
"To roll back manually: quit the app, replace omnivoice.db with the backup "
|
||||
"file, and reinstall the previous version."
|
||||
)
|
||||
logger.error(msg)
|
||||
raise MigrationError(msg) from exc
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Pre-migration SQLite safety net (data-safe updates).
|
||||
|
||||
Before ``alembic upgrade head`` applies *pending* migrations at startup —
|
||||
which is exactly the first launch of a new app version that changed the
|
||||
schema — the live database is snapshotted next to itself as
|
||||
``omnivoice.db.backup-<version>-<n>`` so a failed or interrupted migration
|
||||
can never cost user data (voices, projects, history, settings).
|
||||
|
||||
Design rules (owner intent: "never corrupt/erase user data on update"):
|
||||
|
||||
- Snapshots use the SQLite online-backup API (``sqlite3.Connection.backup``),
|
||||
not a file copy — the live DB runs in WAL mode, so a plain copy could miss
|
||||
everything still sitting in ``omnivoice.db-wal``.
|
||||
- Only the most recent ``KEEP_BACKUPS`` snapshots are kept; older ones are
|
||||
pruned so backups can't grow without bound.
|
||||
- DBs larger than ``MAX_BACKUP_DB_BYTES`` are skipped with a log line (a
|
||||
multi-hundred-MB copy on every schema upgrade is worse than the risk it
|
||||
hedges on those installs).
|
||||
- Restore is NEVER automatic. On migration failure the caller
|
||||
(``core.db._run_alembic_upgrade``) stops startup and names the backup path
|
||||
so the user (or a support thread) decides — a silent auto-restore could
|
||||
itself discard data written after the snapshot.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import time
|
||||
|
||||
logger = logging.getLogger("omnivoice.db.backup")
|
||||
|
||||
#: Keep this many snapshots; older ones are pruned after each new snapshot.
|
||||
KEEP_BACKUPS = 3
|
||||
|
||||
#: Skip the snapshot (with a log line) when the DB exceeds this size.
|
||||
MAX_BACKUP_DB_BYTES = 500 * 1024 * 1024
|
||||
|
||||
#: ``<db name>.backup-<version>-<n>`` — ``<version>`` may itself contain
|
||||
#: dashes (preview builds stamp ``0.3.9-41``), so the counter is the final
|
||||
#: ``-<digits>`` group.
|
||||
_BACKUP_SUFFIX_RE = re.compile(r"\.backup-(?P<version>.+)-(?P<n>\d+)$")
|
||||
|
||||
|
||||
def _sanitize_version(version: str) -> str:
|
||||
"""Version string → filesystem-safe fragment (defense in depth; real
|
||||
versions are semver and already safe)."""
|
||||
safe = re.sub(r"[^A-Za-z0-9._-]", "_", str(version).strip()) or "unknown"
|
||||
return safe[:64]
|
||||
|
||||
|
||||
def list_backups(db_path: str) -> list[str]:
|
||||
"""All backup files for ``db_path``, newest first (mtime desc)."""
|
||||
directory = os.path.dirname(os.path.abspath(db_path)) or "."
|
||||
base = os.path.basename(db_path)
|
||||
try:
|
||||
names = os.listdir(directory)
|
||||
except OSError:
|
||||
return []
|
||||
out = []
|
||||
for name in names:
|
||||
if not name.startswith(base + ".backup-"):
|
||||
continue
|
||||
if not _BACKUP_SUFFIX_RE.search(name[len(base):]):
|
||||
continue
|
||||
out.append(os.path.join(directory, name))
|
||||
out.sort(key=lambda p: (_mtime(p), p), reverse=True)
|
||||
return out
|
||||
|
||||
|
||||
def _mtime(path: str) -> float:
|
||||
try:
|
||||
return os.path.getmtime(path)
|
||||
except OSError:
|
||||
return 0.0
|
||||
|
||||
|
||||
def latest_backup(db_path: str) -> dict | None:
|
||||
"""Newest backup as ``{"path", "created_at", "size_bytes"}`` or None."""
|
||||
backups = list_backups(db_path)
|
||||
if not backups:
|
||||
return None
|
||||
path = backups[0]
|
||||
try:
|
||||
st = os.stat(path)
|
||||
except OSError:
|
||||
return None
|
||||
return {"path": path, "created_at": st.st_mtime, "size_bytes": st.st_size}
|
||||
|
||||
|
||||
def _next_counter(db_path: str, safe_version: str) -> int:
|
||||
"""Next free ``<n>`` for this version so a re-run never overwrites an
|
||||
earlier snapshot of the same version."""
|
||||
base = os.path.basename(db_path)
|
||||
prefix = f"{base}.backup-{safe_version}-"
|
||||
highest = 0
|
||||
for path in list_backups(db_path):
|
||||
name = os.path.basename(path)
|
||||
if not name.startswith(prefix):
|
||||
continue
|
||||
tail = name[len(prefix):]
|
||||
if tail.isdigit():
|
||||
highest = max(highest, int(tail))
|
||||
return highest + 1
|
||||
|
||||
|
||||
def prune_backups(db_path: str, keep: int = KEEP_BACKUPS) -> list[str]:
|
||||
"""Delete all but the ``keep`` newest backups. Returns deleted paths."""
|
||||
deleted = []
|
||||
for path in list_backups(db_path)[keep:]:
|
||||
try:
|
||||
os.remove(path)
|
||||
deleted.append(path)
|
||||
logger.info("Pruned old DB backup %s", path)
|
||||
except OSError as exc:
|
||||
logger.warning("Could not prune old DB backup %s: %s", path, exc)
|
||||
return deleted
|
||||
|
||||
|
||||
def snapshot_before_migration(db_path: str, version: str) -> str | None:
|
||||
"""Snapshot ``db_path`` to ``<db>.backup-<version>-<n>``.
|
||||
|
||||
Returns the backup path, or None when skipped (no DB yet, or DB larger
|
||||
than ``MAX_BACKUP_DB_BYTES``). Raises on an actual backup failure so the
|
||||
caller can decide (the caller treats that as "continue without a backup",
|
||||
logged loudly — a backup problem must not brick startup by itself).
|
||||
"""
|
||||
if not os.path.isfile(db_path):
|
||||
logger.debug("No DB at %s yet — nothing to back up", db_path)
|
||||
return None
|
||||
size = os.path.getsize(db_path)
|
||||
if size > MAX_BACKUP_DB_BYTES:
|
||||
logger.info(
|
||||
"Skipping pre-migration DB backup: %s is %.0f MB (> %.0f MB limit)",
|
||||
db_path, size / (1024 * 1024), MAX_BACKUP_DB_BYTES / (1024 * 1024),
|
||||
)
|
||||
return None
|
||||
|
||||
safe_version = _sanitize_version(version)
|
||||
target = f"{db_path}.backup-{safe_version}-{_next_counter(db_path, safe_version)}"
|
||||
tmp = f"{target}.part-{os.getpid()}"
|
||||
src = sqlite3.connect(db_path)
|
||||
try:
|
||||
dst = sqlite3.connect(tmp)
|
||||
try:
|
||||
# Online backup: consistent snapshot including WAL contents.
|
||||
src.backup(dst)
|
||||
dst.commit()
|
||||
finally:
|
||||
dst.close()
|
||||
except BaseException:
|
||||
try:
|
||||
os.remove(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
finally:
|
||||
src.close()
|
||||
os.replace(tmp, target)
|
||||
# A same-second rotation must still rank the new file newest.
|
||||
try:
|
||||
now = time.time()
|
||||
os.utime(target, (now, now))
|
||||
except OSError:
|
||||
pass
|
||||
logger.info("Pre-migration DB backup written: %s (%.1f MB)", target, size / (1024 * 1024))
|
||||
prune_backups(db_path)
|
||||
return target
|
||||
@@ -76,6 +76,12 @@ _CLASS_RULES: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
"connection refused",
|
||||
"connection reset",
|
||||
"connection aborted",
|
||||
# transformers' download-failure wording ("We couldn't connect to
|
||||
# '<endpoint>' to load the files") — the #874 mirror-down class was
|
||||
# journaled as UNKNOWN without these.
|
||||
"couldn't connect to",
|
||||
"could not connect to",
|
||||
"max retries exceeded",
|
||||
"timed out",
|
||||
"timeout",
|
||||
"name or service not known",
|
||||
|
||||
@@ -21,6 +21,7 @@ import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from core import error_docs_map
|
||||
from core.logging_filter import REDACTED, _HF_TOKEN_RE
|
||||
@@ -39,12 +40,134 @@ _HINTS: dict[str, str] = {
|
||||
"PYANNOTE_LICENSE_REQUIRED": "Accept the pyannote model licenses on Hugging Face, then retry.",
|
||||
"COMPUTE_TYPE_UNSUPPORTED": "Your GPU doesn't support float16 — OmniVoice retried on int8. If transcription still fails, set OMNIVOICE/ASR_COMPUTE_TYPE=int8 or use CPU.",
|
||||
"TRANSFORMERS_IMPORT": "Your transformers install is incomplete. Reinstall it (`uv pip install --reinstall transformers`) or switch ASR to faster-whisper (Settings → Models).",
|
||||
"OS_INVALID_ARGUMENT": "The OS rejected a file operation (Errno 22 / invalid argument) — in the transcribe path this is the temporary WAV write before ASR. It's almost always the temp directory: missing, read-only, on a full or removed drive, or blocked by antivirus. Check that your system TEMP/TMP folder exists and is writable and the drive has free space (add an OmniVoice antivirus exclusion if you use one), then retry.",
|
||||
"UNSUPPORTED_VIDEO_URL": "This link isn't a directly downloadable video. Paste a direct video page (e.g. a youtube.com/watch?v=… or douyin.com/video/<id> link), not a share/profile/feed link — or download the file and drop it in directly.",
|
||||
"VIDEO_DOWNLOAD_NETWORK": "The connection to the video server dropped mid-download (often a transient CDN/network blip or a regional rate-limit). Just retry — OmniVoice already cleaned up the partial download. If it keeps failing, check your network/VPN.",
|
||||
"BROKEN_VENV": "The Python backend environment was moved or damaged. OmniVoice rebuilds it automatically on the next launch; if it keeps failing, use Clean & Retry on the setup screen.",
|
||||
# HF_MIRROR_UNREACHABLE has a DYNAMIC hint (it names the configured mirror)
|
||||
# — see hf_mirror_hint(); build_failure special-cases it.
|
||||
}
|
||||
|
||||
|
||||
# ── HF mirror connectivity (#874) ────────────────────────────────────────────
|
||||
# When a non-default HF_ENDPOINT (a mirror, e.g. hf-mirror.com — set via
|
||||
# Settings → Models → Hugging Face mirror) is configured and a model
|
||||
# download/load fails with a connectivity error, the raw transformers/hf_hub
|
||||
# message ("We couldn't connect to 'https://hf-mirror.com' to load the files…")
|
||||
# gives the user no next step. This is the single classifier for that class,
|
||||
# shared by every surface: build_failure() (model status, dub/task events),
|
||||
# the global 500 handler (main.py — covers /generate and every other route
|
||||
# that can leak a model-load error), and the model-install SSE
|
||||
# (setup/download.py).
|
||||
|
||||
_OFFICIAL_HF_ENDPOINTS = {"https://huggingface.co", "https://hf.co"}
|
||||
|
||||
# Connectivity signatures across the layers an HF download failure surfaces
|
||||
# from: transformers' wording, huggingface_hub errors, requests/urllib3, and
|
||||
# raw socket/DNS failures (Linux/macOS/Windows variants).
|
||||
_HF_CONNECTIVITY_SIGNATURES = (
|
||||
"couldn't connect to", # transformers: "We couldn't connect to '<endpoint>' …"
|
||||
"could not connect to",
|
||||
"connection error", # huggingface_hub / requests
|
||||
"connection refused",
|
||||
"connection reset",
|
||||
"connection aborted",
|
||||
"max retries exceeded", # urllib3 via requests
|
||||
"failed to establish a new connection",
|
||||
"name or service not known", # Linux DNS
|
||||
"temporary failure in name resolution",
|
||||
"nodename nor servname provided", # macOS DNS
|
||||
"getaddrinfo failed", # Windows DNS
|
||||
"timed out",
|
||||
"an error happened while trying to locate the file on the hub", # LocalEntryNotFoundError
|
||||
"we cannot find the requested files", # LocalEntryNotFoundError
|
||||
)
|
||||
|
||||
# The failure must also be Hugging-Face-shaped — the configured endpoint/host
|
||||
# named in the message, or HF-download wording — so a random socket error
|
||||
# (e.g. a local LLM provider being down) doesn't get the mirror hint just
|
||||
# because a mirror happens to be configured.
|
||||
_HF_CONTEXT_MARKERS = (
|
||||
"huggingface",
|
||||
"hf_hub",
|
||||
"hf-hub",
|
||||
"load the files", # transformers
|
||||
"cached files", # transformers
|
||||
"the requested files", # LocalEntryNotFoundError
|
||||
"locate the file on the hub",
|
||||
"snapshot_download",
|
||||
)
|
||||
|
||||
|
||||
def configured_hf_mirror() -> str:
|
||||
"""The non-default Hugging Face endpoint (mirror) in effect, or "".
|
||||
|
||||
Same resolution the download paths use: ``HF_ENDPOINT`` env (what
|
||||
Settings → Models → Hugging Face mirror persists via user_env, and what
|
||||
the HF libraries read) with the ``hf_endpoint`` pref as fallback
|
||||
(mirrors setup/download.py's ``prefs.resolve``). Never raises.
|
||||
"""
|
||||
ep = (os.environ.get("HF_ENDPOINT") or "").strip()
|
||||
if not ep:
|
||||
try:
|
||||
from core import prefs
|
||||
|
||||
ep = str(prefs.get("hf_endpoint", "") or "").strip()
|
||||
except Exception:
|
||||
ep = ""
|
||||
ep = ep.rstrip("/")
|
||||
if not ep or ep.lower() in _OFFICIAL_HF_ENDPOINTS:
|
||||
return ""
|
||||
return ep
|
||||
|
||||
|
||||
def hf_mirror_hint(reason: Optional[str]) -> str:
|
||||
"""Actionable hint when ``reason`` is an HF-download connectivity failure
|
||||
and a non-default mirror endpoint is configured; "" otherwise.
|
||||
|
||||
The hint names the configured mirror, says it may be down, points at the
|
||||
setting (Settings → Models → Hugging Face mirror), suggests the official
|
||||
endpoint when the model isn't cached yet, and notes the restart
|
||||
requirement (HF reads HF_ENDPOINT at import time — see the hf-mirror
|
||||
endpoints in api/routers/settings.py). Never raises.
|
||||
"""
|
||||
mirror = configured_hf_mirror()
|
||||
if not mirror:
|
||||
return ""
|
||||
low = (reason or "").lower()
|
||||
if not any(sig in low for sig in _HF_CONNECTIVITY_SIGNATURES):
|
||||
return ""
|
||||
try:
|
||||
host = (urlsplit(mirror).netloc or "").lower()
|
||||
except Exception:
|
||||
host = ""
|
||||
if not (
|
||||
mirror.lower() in low
|
||||
or (host and host in low)
|
||||
or any(m in low for m in _HF_CONTEXT_MARKERS)
|
||||
):
|
||||
return ""
|
||||
return (
|
||||
f"Your Hugging Face mirror is set to {mirror}, which couldn't be "
|
||||
"reached — the mirror may be down or blocked on your network. If the "
|
||||
'model isn\'t in your local cache yet, switch to "Hugging Face '
|
||||
'(official)" in Settings → Models → Hugging Face mirror (or wait for '
|
||||
"the mirror to recover), then restart OmniVoice — the mirror setting "
|
||||
"is applied when the app starts."
|
||||
)
|
||||
|
||||
|
||||
def append_hf_mirror_hint(text: str) -> str:
|
||||
"""``"{text} — {hint}"`` when the mirror-connectivity class applies;
|
||||
``text`` unchanged otherwise. For surfaces that hand a raw error string to
|
||||
the UI (the global 500 handler, the model-install SSE). Never raises."""
|
||||
try:
|
||||
hint = hf_mirror_hint(text)
|
||||
except Exception:
|
||||
return text
|
||||
return f"{text} — {hint}" if hint else text
|
||||
|
||||
|
||||
def classify(reason: str) -> str:
|
||||
"""Map a failure reason to a docs-taxonomy key, or "" when unknown.
|
||||
|
||||
@@ -65,6 +188,19 @@ def classify(reason: str) -> str:
|
||||
# failure gets its hint rather than falling through to "".
|
||||
if "compute type" in low or "efficient float16" in low:
|
||||
return "COMPUTE_TYPE_UNSUPPORTED"
|
||||
# #763: a bare OS-level EINVAL ("[Errno 22] Invalid argument") while writing
|
||||
# the per-chunk temp WAV for transcription (tempfile.NamedTemporaryFile /
|
||||
# soundfile.write on the system temp dir) used to collapse into a dead-end
|
||||
# "produced no segments. [Errno 22] Invalid argument" toast with no next
|
||||
# step. errno 22 is EINVAL on every platform; in this path it's almost always
|
||||
# a temp dir that's missing, read-only, on a full/removed drive, or blocked
|
||||
# by antivirus. Name the class so build_failure attaches an actionable hint
|
||||
# instead of a raw errno. Matching the errno (not the generic "invalid
|
||||
# argument" wording) keeps this from mislabelling unrelated failures; the
|
||||
# transformers "errno 2" rule below is unaffected — it also requires the
|
||||
# transformers + site-packages markers, which this signature lacks.
|
||||
if "errno 22" in low:
|
||||
return "OS_INVALID_ARGUMENT"
|
||||
if (
|
||||
"could not import module" in low
|
||||
or "autofeatureextractor" in low
|
||||
@@ -88,6 +224,13 @@ def classify(reason: str) -> str:
|
||||
"token" in low or "auth" in low or "401" in low or "unauthorized" in low
|
||||
):
|
||||
return "HF_AUTH_FAILED"
|
||||
# #874: a model download that failed because the CONFIGURED HF mirror is
|
||||
# unreachable. Env-aware by design — the class only exists when a
|
||||
# non-default HF_ENDPOINT is configured. Checked BEFORE the video-download
|
||||
# network class so a model download's "timed out"/"connection reset"
|
||||
# names the mirror instead of the "video server".
|
||||
if hf_mirror_hint(reason):
|
||||
return "HF_MIRROR_UNREACHABLE"
|
||||
# Video download (#554/#536): a non-downloadable URL shape vs a transient
|
||||
# network drop — both previously surfaced as a bare yt-dlp string with no
|
||||
# next step. UNSUPPORTED first (more specific) so "Unable to download video:
|
||||
@@ -207,12 +350,15 @@ def build_failure(
|
||||
|
||||
reason = sanitize(raw) or error_class
|
||||
docs_topic = classify(raw)
|
||||
# HF_MIRROR_UNREACHABLE's hint is dynamic (it names the configured mirror)
|
||||
# so it can't live in the static _HINTS table.
|
||||
hint = hf_mirror_hint(raw) if docs_topic == "HF_MIRROR_UNREACHABLE" else _HINTS.get(docs_topic, "")
|
||||
fields: dict[str, Any] = {
|
||||
"reason": reason,
|
||||
"error": reason, # backward-compat mirror for older frontends
|
||||
"error_class": error_class,
|
||||
"stage": stage,
|
||||
"hint": _HINTS.get(docs_topic, ""),
|
||||
"hint": hint,
|
||||
"docs_topic": docs_topic,
|
||||
"docs_url": error_docs_map.ERROR_DOCS.get(docs_topic, ""),
|
||||
"detail": sanitize(raw),
|
||||
|
||||
@@ -134,3 +134,24 @@ def scrub_text(text: str | None) -> str:
|
||||
pass
|
||||
|
||||
return s
|
||||
|
||||
|
||||
def scrub_provider_error(detail: object, api_key: str | None = None) -> str:
|
||||
"""UI-safe text for an LLM/translation provider failure.
|
||||
|
||||
Some OpenAI-compatible providers echo the caller's key or a stable
|
||||
``user_id`` back inside their error bodies, and a raw ``str(exc)`` on the
|
||||
translate / glossary paths would surface that verbatim. This redacts the
|
||||
exact resolved ``api_key`` first (in the provider-registry case it isn't a
|
||||
shaped/known-env secret, so ``scrub_text`` alone can miss it) then runs the
|
||||
generic secret + home-path scrub. Never raises — scrubbing must not mask a
|
||||
failure with a new one. Mirrors ``settings._scrub_llm_detail`` so every
|
||||
surface redacts identically.
|
||||
"""
|
||||
s = str(detail if detail is not None else "")
|
||||
try:
|
||||
if api_key and api_key != "local" and len(api_key) >= _MIN_SECRET_LEN:
|
||||
s = s.replace(api_key, REDACTED)
|
||||
except Exception:
|
||||
pass
|
||||
return scrub_text(s)
|
||||
|
||||
@@ -24,7 +24,7 @@ from pathlib import Path
|
||||
# tests/test_app_version.py::test_all_version_files_in_lockstep and bumped by
|
||||
# release.yml's version-bump job, so it stays equal to
|
||||
# pyproject/tauri.conf/Cargo/package.json.
|
||||
_FALLBACK_VERSION = "0.3.8"
|
||||
_FALLBACK_VERSION = "0.3.10"
|
||||
|
||||
|
||||
def _fallback_version() -> str:
|
||||
|
||||
@@ -387,6 +387,32 @@ def _env_flag(name: str, default: bool = False) -> bool:
|
||||
return value.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _capture_preload_delay_s() -> float:
|
||||
"""Seconds after boot before the dictation (capture ASR) model warms.
|
||||
|
||||
Late enough that it never competes with startup I/O or the TTS preload;
|
||||
overridable via OMNIVOICE_CAPTURE_PRELOAD_DELAY (mostly for tests)."""
|
||||
raw = os.environ.get("OMNIVOICE_CAPTURE_PRELOAD_DELAY", "")
|
||||
try:
|
||||
v = float(raw)
|
||||
if v >= 0:
|
||||
return v
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return 30.0
|
||||
|
||||
|
||||
def _capture_preload_ram_ok(min_free_bytes: int = 4 * 1024**3) -> bool:
|
||||
"""RAM guard for the dictation warm-up: skip below 4 GB free so the
|
||||
background load never pushes a small machine into swap. If free memory
|
||||
can't be measured, warm anyway (the load path has its own error handling)."""
|
||||
try:
|
||||
import psutil
|
||||
return psutil.virtual_memory().available >= min_free_bytes
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
|
||||
def _mcp_start_timeout_s() -> float:
|
||||
"""Seconds to wait for the MCP session manager to start before giving up
|
||||
and serving without it (#632). Overridable via OMNIVOICE_MCP_START_TIMEOUT_S."""
|
||||
@@ -519,11 +545,19 @@ async def lifespan(app: FastAPI):
|
||||
worker_task = asyncio.create_task(task_manager.worker())
|
||||
# Warm the TTS model in the background so first /generate is instant.
|
||||
preload_task = asyncio.create_task(preload_model())
|
||||
# Capture ASR is useful to keep warm, but it is another large model in
|
||||
# unified memory on Apple Silicon. Keep launch lean by default; users who
|
||||
# prefer instant dictation can opt in with OMNIVOICE_PRELOAD_CAPTURE_ASR=1.
|
||||
if _env_flag("OMNIVOICE_PRELOAD_CAPTURE_ASR"):
|
||||
# Dictation v2: the capture ASR warms in the background BY DEFAULT — a
|
||||
# deferred (~30s post-boot) load off the event loop, so startup stays
|
||||
# lean and the first dictation is instant instead of a cold model load.
|
||||
# OMNIVOICE_PRELOAD_CAPTURE_ASR=0 opts out; the warm-up is also skipped
|
||||
# under 4 GB free RAM (checked at warm time, not boot time).
|
||||
if _env_flag("OMNIVOICE_PRELOAD_CAPTURE_ASR", default=True):
|
||||
async def _preload_capture_asr():
|
||||
await asyncio.sleep(_capture_preload_delay_s())
|
||||
if not _capture_preload_ram_ok():
|
||||
logger.info(
|
||||
"Capture ASR preload skipped: <4GB free RAM; "
|
||||
"dictation ASR will load on first use.")
|
||||
return
|
||||
loading_detail = None
|
||||
prev_loading_detail = None
|
||||
try:
|
||||
@@ -681,8 +715,16 @@ async def global_exception_handler(request: Request, exc: Exception):
|
||||
headers["Access-Control-Allow-Origin"] = origin
|
||||
headers["Access-Control-Allow-Credentials"] = "true"
|
||||
headers["Vary"] = "Origin"
|
||||
# #874: a model download that failed because the CONFIGURED Hugging Face
|
||||
# mirror (HF_ENDPOINT) is unreachable used to leak the raw transformers
|
||||
# message ("We couldn't connect to 'https://hf-mirror.com' …") as the 500
|
||||
# detail with no next step. Appending the shared mirror hint HERE covers
|
||||
# every route that can leak a model-load/download error (generate, dub,
|
||||
# archetypes, …), not just TTS generate. append_hf_mirror_hint is a no-op
|
||||
# for every other error and never raises.
|
||||
from core.failure import append_hf_mirror_hint
|
||||
return JSONResponse(
|
||||
{"detail": str(exc), "error_class": _entry.get("error_class")},
|
||||
{"detail": append_hf_mirror_hint(str(exc)), "error_class": _entry.get("error_class")},
|
||||
status_code=500,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
@@ -17,7 +17,13 @@ from core.config import DB_PATH # noqa: E402 — backend/ is on sys.path via al
|
||||
|
||||
config = context.config
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
# `disable_existing_loggers=False` is deliberate: this env runs *inside* the
|
||||
# live app (startup `alembic upgrade head`), so the default (True) would
|
||||
# disable every already-created application logger — e.g. silence
|
||||
# `omnivoice.db.backup`'s "Skipping pre-migration DB backup" line and the
|
||||
# rest of the app's logging for the remainder of the process. A migration
|
||||
# must never mute the app (or leak that mute across a test session).
|
||||
fileConfig(config.config_file_name, disable_existing_loggers=False)
|
||||
|
||||
# SQLite file URL. Honour an externally-set URL (tests pass one via
|
||||
# `cfg.set_main_option("sqlalchemy.url", ...)` to point at a fixture DB),
|
||||
|
||||
@@ -27,6 +27,7 @@ import asyncio
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
logger = logging.getLogger("omnivoice.asr")
|
||||
@@ -51,8 +52,95 @@ class ASRTimeoutError(TimeoutError):
|
||||
"""
|
||||
|
||||
|
||||
def reset_pool_after_wedge(executor, *, what: str = "ASR") -> bool:
|
||||
"""Abandon a GPU pool whose worker is wedged on a timed-out transcribe (#730).
|
||||
|
||||
Python can't kill the stuck thread, but dropping the poisoned pool means the
|
||||
next submit (a retry, the next chunk, or a concurrent TTS generate) gets a
|
||||
fresh worker instead of queueing behind the wedged one. This is the ONE
|
||||
recovery mechanism shared by every transcribe path — the whole-file guards
|
||||
(via :func:`run_transcribe_guarded`) and the chunked dub stream both route
|
||||
through it, so the semantics can't drift between them again.
|
||||
|
||||
Best-effort: an executor without ``reset()`` (a plain ThreadPoolExecutor in
|
||||
tests) is a no-op, and a failing reset never raises — this runs on the very
|
||||
failure path it's trying to recover from. Returns True when a reset ran.
|
||||
"""
|
||||
_reset = getattr(executor, "reset", None)
|
||||
if not callable(_reset):
|
||||
return False
|
||||
try:
|
||||
_reset()
|
||||
logger.warning(
|
||||
"%s transcribe wedged — abandoned the GPU-pool worker to restore "
|
||||
"capacity (#730).", what,
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
logger.exception("GPU pool reset after %s timeout failed", what)
|
||||
return False
|
||||
|
||||
|
||||
# ── Consecutive-timeout streak → recommend the crash-isolated engine ────────
|
||||
# A pool reset restores *capacity*, but the wedged CTranslate2/whisperx thread
|
||||
# keeps its VRAM until the process exits. When guarded transcribes keep timing
|
||||
# out back-to-back in one session, resets clearly aren't recovering the
|
||||
# underlying hang — the durable fix is the crash-isolated sidecar engine
|
||||
# (services.subprocess_asr, #393), whose child process CAN be hard-killed to
|
||||
# reclaim the hung call and its VRAM. We only *recommend* it (log + error
|
||||
# message); we never switch engines automatically (owner rule: no silent
|
||||
# behavior divergence).
|
||||
_TIMEOUT_STREAK_FOR_ISOLATED_HINT = 2
|
||||
_timeout_streak = 0
|
||||
_timeout_streak_lock = threading.Lock()
|
||||
|
||||
|
||||
def _note_transcribe_timeout() -> int:
|
||||
global _timeout_streak
|
||||
with _timeout_streak_lock:
|
||||
_timeout_streak += 1
|
||||
return _timeout_streak
|
||||
|
||||
|
||||
def _note_transcribe_success() -> None:
|
||||
global _timeout_streak
|
||||
with _timeout_streak_lock:
|
||||
_timeout_streak = 0
|
||||
|
||||
|
||||
def _isolated_engine_hint(streak: int) -> str:
|
||||
"""User-facing recommendation once resets stop recovering (streak ≥ 2).
|
||||
|
||||
Empty when the streak is below the threshold, or when the user is already
|
||||
on the isolated engine (recommending it to itself would be noise — the
|
||||
base message's smaller-model/CPU guidance is all that's left)."""
|
||||
if streak < _TIMEOUT_STREAK_FOR_ISOLATED_HINT:
|
||||
return ""
|
||||
try:
|
||||
if active_backend_id() == "faster-whisper-isolated":
|
||||
return ""
|
||||
except Exception: # noqa: BLE001 — the hint must never break the error path
|
||||
pass
|
||||
logger.warning(
|
||||
"%d consecutive ASR transcribe timeouts this session — pool resets are "
|
||||
"not recovering the hang. Recommend switching the ASR engine to "
|
||||
"'Faster-Whisper (crash-isolated subprocess)' [faster-whisper-isolated] "
|
||||
"in Settings → Engines. Not switching automatically (#730).", streak,
|
||||
)
|
||||
return (
|
||||
f"This is {streak} transcribe timeouts in a row this session, so pool "
|
||||
"resets aren't recovering the underlying hang. Recommended: switch the "
|
||||
"ASR engine to 'Faster-Whisper (crash-isolated subprocess)' "
|
||||
"(faster-whisper-isolated) in Settings → Engines — it runs "
|
||||
"transcription in a separate process that can be force-killed to "
|
||||
"reclaim a hung transcribe and its VRAM. OmniVoice never switches "
|
||||
"engines automatically."
|
||||
)
|
||||
|
||||
|
||||
async def run_transcribe_guarded(executor, fn, *, what: str = "ASR",
|
||||
timeout: float = ASR_TRANSCRIBE_TIMEOUT_S):
|
||||
timeout: float = ASR_TRANSCRIBE_TIMEOUT_S,
|
||||
timeout_env: str = "OMNIVOICE_ASR_TRANSCRIBE_TIMEOUT_S"):
|
||||
"""Run a blocking transcribe ``fn`` in ``executor`` with a hard wall-clock
|
||||
bound. On timeout, raise :class:`ASRTimeoutError` with guidance instead of
|
||||
letting the request hang forever.
|
||||
@@ -73,30 +161,30 @@ async def run_transcribe_guarded(executor, fn, *, what: str = "ASR",
|
||||
loop = asyncio.get_running_loop()
|
||||
fut = loop.run_in_executor(executor, fn)
|
||||
try:
|
||||
return await asyncio.wait_for(fut, timeout=timeout)
|
||||
result = await asyncio.wait_for(fut, timeout=timeout)
|
||||
except asyncio.TimeoutError:
|
||||
# Free the poisoned pool so a hung transcribe can't keep starving TTS /
|
||||
# other ASR work (the "can't reach backend" symptom, #730).
|
||||
_reset = getattr(executor, "reset", None)
|
||||
if callable(_reset):
|
||||
try:
|
||||
_reset()
|
||||
logger.warning(
|
||||
"%s transcription exceeded %.0fs — abandoned the GPU-pool "
|
||||
"worker to restore capacity (#730).", what, timeout,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("GPU pool reset after ASR timeout failed")
|
||||
raise ASRTimeoutError(
|
||||
reset_pool_after_wedge(executor, what=what)
|
||||
streak = _note_transcribe_timeout()
|
||||
msg = (
|
||||
f"{what} transcription exceeded {timeout:.0f}s and was abandoned — "
|
||||
"the backend is running, but the ASR model is too heavy for the "
|
||||
"available compute. Most often the GPU is VRAM-starved: the resident "
|
||||
"TTS model and a large ASR model (large-v3) contend for memory. "
|
||||
"Capacity was restored automatically, but for a durable fix Flush the "
|
||||
"TTS model to free VRAM, pick a smaller ASR model in Settings → "
|
||||
"Models, or set ASR to CPU. (Raise OMNIVOICE_ASR_TRANSCRIBE_TIMEOUT_S "
|
||||
"for very long single files.)"
|
||||
f"Models, or set ASR to CPU. (Raise {timeout_env} "
|
||||
"for very long transcribes.)"
|
||||
)
|
||||
hint = _isolated_engine_hint(streak)
|
||||
if hint:
|
||||
msg += " " + hint
|
||||
raise ASRTimeoutError(msg)
|
||||
# A completed transcribe (even a failed-but-returned one) proves the pool
|
||||
# isn't hung — only genuine timeouts count toward the consecutive streak.
|
||||
_note_transcribe_success()
|
||||
return result
|
||||
|
||||
|
||||
def _compute_type_candidates(device: str) -> list[str]:
|
||||
@@ -1336,6 +1424,11 @@ class SherpaDictationBackend(ASRBackend):
|
||||
)
|
||||
self._spec = spec
|
||||
self._rec = None # lazy OfflineRecognizer / OnlineRecognizer
|
||||
# One backend is shared across live-dictation WS sessions (see
|
||||
# get_sherpa_dictation_backend), so guard the one-time recognizer build
|
||||
# against two sessions racing to construct it concurrently. Each session
|
||||
# still owns its own decode stream — only the recognizer is shared.
|
||||
self._rec_lock = threading.Lock()
|
||||
|
||||
@property
|
||||
def spec(self):
|
||||
@@ -1353,14 +1446,25 @@ class SherpaDictationBackend(ASRBackend):
|
||||
def ensure_loaded(self) -> None:
|
||||
self._ensure_rec()
|
||||
|
||||
def warmup(self) -> None:
|
||||
"""Eagerly build the recognizer so the FIRST live-dictation session
|
||||
doesn't pay the 1.3–2.5s ONNX-session load (#888 'instant first
|
||||
dictation'). Called by the background capture-ASR preload; idempotent,
|
||||
and the built recognizer is reused across sessions via
|
||||
get_sherpa_dictation_backend (the same singleton the preload warms)."""
|
||||
self._ensure_rec()
|
||||
|
||||
def _ensure_rec(self):
|
||||
if self._rec is not None:
|
||||
return
|
||||
from services import sherpa_dictation as _sd
|
||||
if self._spec.streaming:
|
||||
self._rec = _sd.build_online_recognizer(self._spec)
|
||||
else:
|
||||
self._rec = _sd.build_offline_recognizer(self._spec)
|
||||
with self._rec_lock:
|
||||
if self._rec is not None:
|
||||
return
|
||||
from services import sherpa_dictation as _sd
|
||||
if self._spec.streaming:
|
||||
self._rec = _sd.build_online_recognizer(self._spec)
|
||||
else:
|
||||
self._rec = _sd.build_offline_recognizer(self._spec)
|
||||
|
||||
def transcribe(self, audio_path: str, *, word_timestamps: bool = True) -> dict:
|
||||
self._ensure_rec()
|
||||
@@ -1558,7 +1662,13 @@ class _LazyASRRegistry(dict):
|
||||
|
||||
def __iter__(self):
|
||||
seen = set()
|
||||
for k in dict.__iter__(self):
|
||||
# Snapshot the live keys before yielding — see _LazyRegistry.__iter__ in
|
||||
# tts_backend.py. A concurrent lazy __getitem__ inserts into self, and
|
||||
# list_backends() runs in a FastAPI threadpool, so a *live* dict iterator
|
||||
# held open across the per-engine is_available() probes would raise
|
||||
# "dictionary changed size during iteration". list() consumes it
|
||||
# atomically under the GIL, closing the window.
|
||||
for k in list(dict.__iter__(self)):
|
||||
seen.add(k)
|
||||
yield k
|
||||
for k in self._LAZY:
|
||||
@@ -1594,6 +1704,12 @@ _INSTALL_HINTS: dict[str, str] = {
|
||||
"moonshine": "pip install useful-moonshine (edge/CPU-optimized ASR)",
|
||||
"funasr": "pip install funasr (SenseVoiceSmall + FSMN-VAD; CUDA or CPU)",
|
||||
"sherpa-onnx-asr": "uv add sherpa-onnx (ONNX live dictation; CPU, cross-platform)",
|
||||
"faster-whisper-isolated": (
|
||||
"No extra install (reuses faster-whisper). Escape hatch for hanging "
|
||||
"transcribes: runs ASR in a separate process that can be force-killed "
|
||||
"to reclaim a hung transcribe and its VRAM (#730). Slightly slower per "
|
||||
"call than in-process faster-whisper."
|
||||
),
|
||||
}
|
||||
|
||||
# Most-recent failure per backend, so a transient probe error survives between
|
||||
@@ -1707,6 +1823,14 @@ def active_backend_id() -> str:
|
||||
return _auto_detect()
|
||||
|
||||
|
||||
# Subprocess-isolated backends must be process-wide singletons: their
|
||||
# ``__init__`` registers an atexit shutdown hook and the instance owns the
|
||||
# sidecar child process, so a fresh instance per request would leak handler
|
||||
# entries and respawn the sidecar (reloading its model) on every transcribe.
|
||||
# Same rationale as api.routers.engines._ENGINE_INSTANCES.
|
||||
_ISOLATED_INSTANCES: dict[str, "ASRBackend"] = {}
|
||||
|
||||
|
||||
def get_active_asr_backend(*, asr_pipe=None) -> ASRBackend:
|
||||
bid = active_backend_id()
|
||||
if bid == "pytorch-whisper":
|
||||
@@ -1719,7 +1843,14 @@ def get_active_asr_backend(*, asr_pipe=None) -> ASRBackend:
|
||||
return WhisperXBackend()
|
||||
if bid not in _REGISTRY:
|
||||
raise ValueError(f"Unknown ASR backend: {bid!r}. Known: {list(_REGISTRY)}")
|
||||
return _REGISTRY[bid]()
|
||||
cls = _REGISTRY[bid]
|
||||
if getattr(cls, "_is_subprocess_isolated", False):
|
||||
inst = _ISOLATED_INSTANCES.get(bid)
|
||||
if inst is None:
|
||||
inst = cls()
|
||||
_ISOLATED_INSTANCES[bid] = inst
|
||||
return inst
|
||||
return cls()
|
||||
|
||||
|
||||
def transcribe_reference(audio_path: str) -> str | None:
|
||||
@@ -1764,6 +1895,34 @@ _capture_backend: ASRBackend | None = None
|
||||
# The sherpa model id the cached capture backend was built for, so a model
|
||||
# switch in Settings rebuilds the singleton instead of serving the old model.
|
||||
_capture_backend_key: str | None = None
|
||||
# Guards the read-modify-write of the two globals above. Both the background
|
||||
# capture-ASR preload (runs in the GPU-pool thread) and the live-dictation WS
|
||||
# handlers (run on the event loop) resolve/replace the singleton, so the
|
||||
# check-then-build must be atomic to avoid two threads each building a model.
|
||||
_capture_backend_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_sherpa_dictation_backend(model_id: str) -> "SherpaDictationBackend":
|
||||
"""Return a shared, warm-cached :class:`SherpaDictationBackend` for
|
||||
``model_id``, building it at most once and reusing the recognizer across
|
||||
live-dictation WS sessions.
|
||||
|
||||
Live sessions previously constructed a FRESH backend per WebSocket connect,
|
||||
so every session reloaded the ONNX recognizer (1.3–2.5s "loading…") and the
|
||||
#888 background preload was a no-op. This reuses the SAME module-level
|
||||
``_capture_backend`` singleton the preload warms (when the ids match), and
|
||||
rebuilds on a model switch — identical invalidation to
|
||||
:func:`get_capture_asr_backend`. Thread-safe: the recognizer is shared;
|
||||
each session creates its own decode stream (see capture_ws)."""
|
||||
global _capture_backend, _capture_backend_key
|
||||
with _capture_backend_lock:
|
||||
if (isinstance(_capture_backend, SherpaDictationBackend)
|
||||
and _capture_backend_key == model_id):
|
||||
return _capture_backend
|
||||
backend = SherpaDictationBackend(model_id=model_id)
|
||||
_capture_backend = backend
|
||||
_capture_backend_key = model_id
|
||||
return backend
|
||||
|
||||
|
||||
def dictation_model_id() -> str | None:
|
||||
@@ -1803,49 +1962,52 @@ def get_capture_asr_backend() -> ASRBackend:
|
||||
"""
|
||||
global _capture_backend, _capture_backend_key
|
||||
|
||||
# 0. Honor an explicit sherpa dictation model selection.
|
||||
sherpa_id = dictation_model_id()
|
||||
if sherpa_id:
|
||||
ok, _ = SherpaDictationBackend.is_available()
|
||||
# Atomic resolve+build so the preload thread and a WS session (which may
|
||||
# call get_sherpa_dictation_backend concurrently) can't both build a model.
|
||||
with _capture_backend_lock:
|
||||
# 0. Honor an explicit sherpa dictation model selection.
|
||||
sherpa_id = dictation_model_id()
|
||||
if sherpa_id:
|
||||
ok, _ = SherpaDictationBackend.is_available()
|
||||
if ok:
|
||||
if not (isinstance(_capture_backend, SherpaDictationBackend)
|
||||
and _capture_backend_key == sherpa_id):
|
||||
try:
|
||||
_capture_backend = SherpaDictationBackend(model_id=sherpa_id)
|
||||
_capture_backend_key = sherpa_id
|
||||
except Exception as e: # noqa: BLE001 — fall through to Whisper
|
||||
logger.warning(
|
||||
"sherpa dictation model %r unavailable (%s) — falling "
|
||||
"back to Whisper capture engine", sherpa_id, e,
|
||||
)
|
||||
_capture_backend = None
|
||||
_capture_backend_key = None
|
||||
if _capture_backend is not None:
|
||||
return _capture_backend
|
||||
else:
|
||||
logger.info(
|
||||
"dictation.model_id=%r selected but sherpa-onnx not installed — "
|
||||
"falling back to Whisper capture engine", sherpa_id,
|
||||
)
|
||||
|
||||
if _capture_backend is not None and _capture_backend_key is None:
|
||||
return _capture_backend
|
||||
|
||||
# Prefer MLX Turbo on Apple Silicon
|
||||
ok, _ = MLXWhisperBackend.is_available()
|
||||
if ok:
|
||||
if not (isinstance(_capture_backend, SherpaDictationBackend)
|
||||
and _capture_backend_key == sherpa_id):
|
||||
try:
|
||||
_capture_backend = SherpaDictationBackend(model_id=sherpa_id)
|
||||
_capture_backend_key = sherpa_id
|
||||
except Exception as e: # noqa: BLE001 — fall through to Whisper
|
||||
logger.warning(
|
||||
"sherpa dictation model %r unavailable (%s) — falling "
|
||||
"back to Whisper capture engine", sherpa_id, e,
|
||||
)
|
||||
_capture_backend = None
|
||||
_capture_backend_key = None
|
||||
if _capture_backend is not None:
|
||||
return _capture_backend
|
||||
else:
|
||||
logger.info(
|
||||
"dictation.model_id=%r selected but sherpa-onnx not installed — "
|
||||
"falling back to Whisper capture engine", sherpa_id,
|
||||
)
|
||||
_capture_backend = MLXWhisperBackend(model_name=_MLX_MODEL_TURBO)
|
||||
_capture_backend_key = None
|
||||
return _capture_backend
|
||||
|
||||
if _capture_backend is not None and _capture_backend_key is None:
|
||||
return _capture_backend
|
||||
# Fall back to faster-whisper (CPU int8 on non-Apple)
|
||||
ok, _ = FasterWhisperBackend.is_available()
|
||||
if ok:
|
||||
_capture_backend = FasterWhisperBackend()
|
||||
_capture_backend_key = None
|
||||
return _capture_backend
|
||||
|
||||
# Prefer MLX Turbo on Apple Silicon
|
||||
ok, _ = MLXWhisperBackend.is_available()
|
||||
if ok:
|
||||
_capture_backend = MLXWhisperBackend(model_name=_MLX_MODEL_TURBO)
|
||||
# Last resort
|
||||
_capture_backend = PyTorchWhisperBackend()
|
||||
_capture_backend_key = None
|
||||
return _capture_backend
|
||||
|
||||
# Fall back to faster-whisper (CPU int8 on non-Apple)
|
||||
ok, _ = FasterWhisperBackend.is_available()
|
||||
if ok:
|
||||
_capture_backend = FasterWhisperBackend()
|
||||
_capture_backend_key = None
|
||||
return _capture_backend
|
||||
|
||||
# Last resort
|
||||
_capture_backend = PyTorchWhisperBackend()
|
||||
_capture_backend_key = None
|
||||
return _capture_backend
|
||||
|
||||
@@ -104,9 +104,10 @@ def synthesize_chapter(
|
||||
):
|
||||
"""Render a chapter's spans to one waveform via an injected ``synth``.
|
||||
|
||||
``synth(text, voice_id, speed)`` returns a 1-D float32 audio tensor for a
|
||||
span of text in the given voice (``speed`` may be ``None`` for the engine
|
||||
default). Long spans are split with the ``chunked_tts`` splitter and
|
||||
``synth(text, voice_id, speed)`` returns a float32 audio tensor — 1-D
|
||||
``(samples,)`` or ``(channels, samples)``; real engines emit ``(1, samples)``
|
||||
per the ``TTSBackend`` contract (#897) — for a span of text in the given
|
||||
voice (``speed`` may be ``None`` for the engine default). Long spans are split with the ``chunked_tts`` splitter and
|
||||
crossfaded; inter-span ``pause_ms_after`` becomes silence. ``lexicon`` (when
|
||||
given) respells each span's text before chunking so the engine pronounces
|
||||
tricky words correctly; a ``None``/empty lexicon is a no-op pass-through.
|
||||
@@ -118,23 +119,35 @@ def synthesize_chapter(
|
||||
from services.chunked_tts import concatenate_audio_chunks, split_text_into_chunks
|
||||
from services.pronunciation import apply_lexicon
|
||||
|
||||
parts: list = []
|
||||
items: list = [] # ("a", tensor) for audio, ("s", n_samples) for silence
|
||||
for span in spans:
|
||||
if span.text:
|
||||
chunks = split_text_into_chunks(apply_lexicon(span.text, lexicon))
|
||||
rendered = [synth(c, span.voice_id, span.speed) for c in chunks]
|
||||
rendered = [r for r in rendered if r is not None and getattr(r, "numel", lambda: 0)()]
|
||||
if len(rendered) == 1:
|
||||
parts.append(rendered[0])
|
||||
items.append(("a", rendered[0]))
|
||||
elif rendered:
|
||||
parts.append(concatenate_audio_chunks(rendered, sample_rate, crossfade_ms=crossfade_ms))
|
||||
items.append(("a", concatenate_audio_chunks(rendered, sample_rate, crossfade_ms=crossfade_ms)))
|
||||
if span.pause_ms_after > 0:
|
||||
n = int(sample_rate * span.pause_ms_after / 1000.0)
|
||||
if n > 0:
|
||||
parts.append(torch.zeros(n, dtype=torch.float32))
|
||||
items.append(("s", n))
|
||||
|
||||
if not parts:
|
||||
if not items:
|
||||
return torch.zeros(0, dtype=torch.float32), 0.0
|
||||
# Engines return (1, samples) per the TTSBackend contract while a bare
|
||||
# zeros(n) is 1-D — mixing the two crashed the final concat (#897). So
|
||||
# materialize inter-span silence AFTER the loop, matching the rendered
|
||||
# audio's channel dims / dtype / device (same pattern as generation.py's
|
||||
# _render_with_pauses). A silence-only chapter stays 1-D float32 as before.
|
||||
ref = next((t for kind, t in items if kind == "a"), None)
|
||||
parts: list = [
|
||||
val if kind == "a"
|
||||
else (torch.zeros(val, dtype=torch.float32) if ref is None
|
||||
else torch.zeros(*ref.shape[:-1], val, dtype=ref.dtype, device=ref.device))
|
||||
for kind, val in items
|
||||
]
|
||||
# Hard-concat spans + silences (crossfading silence would bleed the gap).
|
||||
audio = parts[0] if len(parts) == 1 else concatenate_audio_chunks(parts, sample_rate, crossfade_ms=0)
|
||||
return audio, audio.shape[-1] / float(sample_rate)
|
||||
|
||||
@@ -183,14 +183,43 @@ def _safe_hard_cut(segment: str, max_chars: int) -> int:
|
||||
return cut
|
||||
|
||||
|
||||
def _normalize_chunk_shapes(chunks: list) -> list:
|
||||
"""Coerce mixed-rank / mixed-channel chunks to one concat-compatible shape.
|
||||
|
||||
Engines return ``(1, samples)`` per the ``TTSBackend.generate`` contract,
|
||||
but silence buffers and some model paths hand over bare ``(samples,)``
|
||||
tensors — ``torch.cat`` then dies with "Tensors must have same number of
|
||||
dimensions" (#897). Promote lower-rank chunks with leading singleton dims
|
||||
to the highest rank present, then broadcast singleton channel dims up to
|
||||
the widest channel count (mono follows stereo). Rank-homogeneous,
|
||||
channel-homogeneous input is returned untouched, so all-1-D / all-2-D
|
||||
callers keep their exact output shape; a genuine channel conflict
|
||||
(e.g. 2 vs 3 channels) still raises, which is the honest outcome.
|
||||
"""
|
||||
target = max(c.dim() for c in chunks)
|
||||
if any(c.dim() != target for c in chunks):
|
||||
promoted = []
|
||||
for c in chunks:
|
||||
while c.dim() < target:
|
||||
c = c.unsqueeze(0)
|
||||
promoted.append(c)
|
||||
chunks = promoted
|
||||
if target > 1:
|
||||
lead = tuple(max(c.shape[i] for c in chunks) for i in range(target - 1))
|
||||
chunks = [c if tuple(c.shape[:-1]) == lead else c.expand(*lead, -1)
|
||||
for c in chunks]
|
||||
return chunks
|
||||
|
||||
|
||||
def concatenate_audio_chunks(chunks: list, sample_rate: int,
|
||||
crossfade_ms: int = DEFAULT_CROSSFADE_MS):
|
||||
"""Join per-chunk waveforms with a linear crossfade on the sample axis.
|
||||
|
||||
``chunks`` are torch tensors as returned by the engine (1-D, or N-D with
|
||||
samples on the last axis — matching what ``_render_with_pauses`` handles).
|
||||
Crossfade overlap is clamped to the shorter neighbor; ``crossfade_ms=0``
|
||||
is a hard concat.
|
||||
Mixed ranks / mono-vs-multichannel chunks are normalized to one shape
|
||||
first (#897), so no producer can crash the concat. Crossfade overlap is
|
||||
clamped to the shorter neighbor; ``crossfade_ms=0`` is a hard concat.
|
||||
"""
|
||||
import torch
|
||||
|
||||
@@ -199,6 +228,7 @@ def concatenate_audio_chunks(chunks: list, sample_rate: int,
|
||||
return torch.zeros(1, dtype=torch.float32)
|
||||
if len(chunks) == 1:
|
||||
return chunks[0]
|
||||
chunks = _normalize_chunk_shapes(chunks)
|
||||
|
||||
crossfade_samples = int(sample_rate * crossfade_ms / 1000)
|
||||
result = chunks[0]
|
||||
|
||||
@@ -26,6 +26,10 @@ from services.llm_backend import get_active_llm_backend, OffBackend
|
||||
|
||||
logger = logging.getLogger("omnivoice.director")
|
||||
|
||||
# LLM Skills registry id — Settings → LLM Skills can disable the LLM parse
|
||||
# or route it to a specific provider. Disabled == the heuristic parser.
|
||||
_SKILL_ID = "direction_parse"
|
||||
|
||||
|
||||
# ── Taxonomy (stable contract) ──────────────────────────────────────────────
|
||||
# Additive per dimension — multiple values allowed. Unknown tokens are ignored
|
||||
@@ -147,7 +151,10 @@ def parse(text: str) -> Direction:
|
||||
if not text or not text.strip():
|
||||
return Direction(source=text or "")
|
||||
|
||||
llm = get_active_llm_backend()
|
||||
from services import llm_skills
|
||||
# `active=` forwards this module's (monkeypatch-able) name so the
|
||||
# no-override path is byte-identical to the pre-skills behavior.
|
||||
llm = llm_skills.skill_backend(_SKILL_ID, active=lambda: get_active_llm_backend())
|
||||
if isinstance(llm, OffBackend):
|
||||
return _heuristic_parse(text)
|
||||
|
||||
|
||||
@@ -63,7 +63,21 @@ def resolve_routing(gpu_compat: tuple[str, ...], caps: HostCaps) -> RoutingResul
|
||||
"routing_reason": _caveat(caps),
|
||||
}
|
||||
|
||||
# 3. Host has an accelerator the engine lacks, but engine supports cpu
|
||||
# 3. CPU-native engine (declares ONLY cpu) has nothing to fall back FROM,
|
||||
# so on ANY accelerator host it is benign cpu_only (neutral), never a
|
||||
# warn-tone "CPU fallback". This must precede the fallback rule below —
|
||||
# a ("cpu",) engine matches `"cpu" in targets` too, and would otherwise
|
||||
# be mis-classed cpu_fallback on a GPU/MPS host. (A cpu host reaches
|
||||
# rule 5 unchanged, keeping its DirectML note.) Engines that *could*
|
||||
# accelerate elsewhere (e.g. ("cuda", "cpu")) are untouched.
|
||||
if fam != "cpu" and targets == ("cpu",):
|
||||
return {
|
||||
"effective_device": "cpu",
|
||||
"routing_status": "cpu_only",
|
||||
"routing_reason": None,
|
||||
}
|
||||
|
||||
# 4. Host has an accelerator the engine lacks, but engine supports cpu
|
||||
# → the no-silent-fallback signal.
|
||||
if fam != "cpu" and "cpu" in targets:
|
||||
if fam == "rocm" and "cuda" in targets and "rocm" not in targets:
|
||||
@@ -76,7 +90,7 @@ def resolve_routing(gpu_compat: tuple[str, ...], caps: HostCaps) -> RoutingResul
|
||||
"routing_reason": reason,
|
||||
}
|
||||
|
||||
# 4. Genuine CPU-only host (or DirectML, which the probe reports as cpu)
|
||||
# 5. Genuine CPU-only host (or DirectML, which the probe reports as cpu)
|
||||
# and engine supports cpu → benign; must not warn or block.
|
||||
if fam == "cpu" and "cpu" in targets:
|
||||
reason = None
|
||||
@@ -93,7 +107,7 @@ def resolve_routing(gpu_compat: tuple[str, ...], caps: HostCaps) -> RoutingResul
|
||||
"routing_reason": reason,
|
||||
}
|
||||
|
||||
# 5. Engine needs an accelerator this host lacks and has no cpu path.
|
||||
# 6. Engine needs an accelerator this host lacks and has no cpu path.
|
||||
first = targets[0]
|
||||
return {
|
||||
"effective_device": first,
|
||||
|
||||
@@ -54,9 +54,12 @@ class LLMBackend(ABC):
|
||||
def model_name(self) -> str: ...
|
||||
|
||||
@abstractmethod
|
||||
def chat(self, *, system: str, user: str, timeout: Optional[float] = None) -> str:
|
||||
def chat(self, *, system: str, user: str, timeout: Optional[float] = None,
|
||||
temperature: Optional[float] = None) -> str:
|
||||
"""One-shot chat completion. Returns the assistant content string.
|
||||
Raises on failure — callers decide whether to fallback gracefully.
|
||||
``temperature`` is only sent to the provider when set — callers that
|
||||
leave it None keep the provider default (existing behavior).
|
||||
"""
|
||||
|
||||
|
||||
@@ -67,8 +70,18 @@ class OpenAICompatBackend(LLMBackend):
|
||||
id = "openai-compat"
|
||||
display_name = "OpenAI-compatible (real OpenAI, Ollama, LM Studio, …)"
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self, provider=None):
|
||||
"""``provider``: optional ``llm_providers.Provider`` to bind this
|
||||
instance to (LLM Skills per-skill routing). None keeps the historical
|
||||
behavior — resolve the ACTIVE provider at call time."""
|
||||
self._client = None
|
||||
self._provider = provider
|
||||
|
||||
def _resolve_provider(self):
|
||||
if self._provider is not None:
|
||||
return self._provider
|
||||
from services import llm_providers
|
||||
return llm_providers.active_provider()
|
||||
|
||||
@classmethod
|
||||
def is_available(cls) -> tuple[bool, str]:
|
||||
@@ -97,7 +110,7 @@ class OpenAICompatBackend(LLMBackend):
|
||||
@property
|
||||
def model_name(self) -> str:
|
||||
from services import llm_providers
|
||||
p = llm_providers.active_provider()
|
||||
p = self._resolve_provider()
|
||||
if p is not None:
|
||||
return llm_providers.resolve_model(p)
|
||||
return os.environ.get("TRANSLATE_MODEL", "gpt-4o-mini")
|
||||
@@ -107,7 +120,7 @@ class OpenAICompatBackend(LLMBackend):
|
||||
return self._client
|
||||
from openai import OpenAI
|
||||
from services import llm_providers
|
||||
p = llm_providers.active_provider()
|
||||
p = self._resolve_provider()
|
||||
if p is None:
|
||||
raise RuntimeError("LLM not configured. See `is_available()` for the hint.")
|
||||
base_url = llm_providers.resolve_base_url(p)
|
||||
@@ -117,35 +130,48 @@ class OpenAICompatBackend(LLMBackend):
|
||||
kw = {"api_key": api_key}
|
||||
if base_url:
|
||||
kw["base_url"] = base_url
|
||||
self._client = OpenAI(**kw)
|
||||
# max_retries=0 so a 429 + Retry-After can't make one chat() sleep
|
||||
# through the Autofit fit-pass wall-clock budget (speech_rate).
|
||||
self._client = OpenAI(max_retries=0, **kw)
|
||||
return self._client
|
||||
|
||||
def chat(self, *, system: str, user: str, timeout: Optional[float] = None) -> str:
|
||||
def chat(self, *, system: str, user: str, timeout: Optional[float] = None,
|
||||
temperature: Optional[float] = None) -> str:
|
||||
return self.chat_messages(
|
||||
messages=[
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": user},
|
||||
],
|
||||
timeout=timeout,
|
||||
temperature=temperature,
|
||||
)
|
||||
|
||||
def chat_messages(self, *, messages: list[dict], timeout: Optional[float] = None) -> str:
|
||||
def chat_messages(self, *, messages: list[dict], timeout: Optional[float] = None,
|
||||
temperature: Optional[float] = None) -> str:
|
||||
"""One-shot completion over a full message list.
|
||||
|
||||
Additive surface for callers that need structured few-shot turns
|
||||
(dictation refinement, Wave 2.1) — small local models pattern-match
|
||||
and echo inline examples, so examples must arrive as prior chat
|
||||
turns, not inside the system prompt.
|
||||
|
||||
``temperature`` is only forwarded when set (Cinematic/Autofit pin 0.2
|
||||
— the provider default of 1.0 makes local models drift and invent);
|
||||
every other caller leaves it None and keeps the provider default.
|
||||
"""
|
||||
if timeout is None:
|
||||
try:
|
||||
timeout = float(os.environ.get("OMNIVOICE_LLM_TIMEOUT", "45"))
|
||||
except ValueError:
|
||||
timeout = 45.0
|
||||
kw = {}
|
||||
if temperature is not None:
|
||||
kw["temperature"] = temperature
|
||||
res = self._get_client().chat.completions.create(
|
||||
model=self.model_name,
|
||||
timeout=timeout,
|
||||
messages=messages,
|
||||
**kw,
|
||||
)
|
||||
return (res.choices[0].message.content or "").strip()
|
||||
|
||||
|
||||
@@ -172,17 +172,33 @@ def _env_first(names: tuple[str, ...]) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def resolve_base_url(p: Provider) -> str:
|
||||
def resolve_account_id(p: Provider) -> str:
|
||||
"""The Cloudflare-style account id: env override → stored → empty."""
|
||||
from services import settings_store
|
||||
return (
|
||||
(p.account_env and os.environ.get(p.account_env))
|
||||
or settings_store.get_text(f"llm.account.{p.id}")
|
||||
or ""
|
||||
)
|
||||
|
||||
|
||||
def resolve_base_url(p: Provider, *, substitute: bool = True) -> str:
|
||||
"""Resolve a provider's base URL (env → stored override → default).
|
||||
|
||||
``substitute`` interpolates ``{account_id}`` for account-scoped providers
|
||||
(Cloudflare) so the *client* gets a working URL. The UI passes
|
||||
``substitute=False`` so the field shows/saves the raw template — baking the
|
||||
substituted value back into a stored override would freeze the URL and make
|
||||
later account-id changes silently no-op (the bug this guards against).
|
||||
"""
|
||||
from services import settings_store
|
||||
val = (
|
||||
(p.base_url_env and os.environ.get(p.base_url_env))
|
||||
or settings_store.get_text(_BASE_URL_KEY + p.id)
|
||||
or p.default_base_url
|
||||
)
|
||||
if p.needs_account and val and "{account_id}" in val:
|
||||
acct = (p.account_env and os.environ.get(p.account_env)) or \
|
||||
settings_store.get_text(f"llm.account.{p.id}") or ""
|
||||
val = val.replace("{account_id}", acct)
|
||||
if substitute and p.needs_account and val and "{account_id}" in val:
|
||||
val = val.replace("{account_id}", resolve_account_id(p))
|
||||
return val or ""
|
||||
|
||||
|
||||
@@ -286,26 +302,55 @@ def save_overrides(pid: str, *, base_url: Optional[str] = None,
|
||||
from services import settings_store
|
||||
if pid not in _BY_ID:
|
||||
raise ValueError(f"unknown provider {pid!r}")
|
||||
p = _BY_ID[pid]
|
||||
if base_url is not None:
|
||||
settings_store.set_text(_BASE_URL_KEY + pid, base_url.strip())
|
||||
bu = base_url.strip()
|
||||
# Never freeze an override that equals the built-in default. Critical
|
||||
# for account-templated URLs (Cloudflare): persisting the shown value
|
||||
# would pin the base_url and stop later account-id edits from taking
|
||||
# effect. Clearing (→ empty) falls the resolver back to the default
|
||||
# template so substitution stays live. Also self-heals a stale override
|
||||
# if a provider's default URL changes in a future release.
|
||||
settings_store.set_text(_BASE_URL_KEY + pid, "" if bu == p.default_base_url else bu)
|
||||
if model is not None:
|
||||
settings_store.set_text(_MODEL_KEY + pid, model.strip())
|
||||
if account_id is not None:
|
||||
settings_store.set_text(f"llm.account.{pid}", account_id.strip())
|
||||
|
||||
|
||||
def _active_env_pin() -> Optional[str]:
|
||||
"""The provider id pinned by ``LLM_DEFAULT_PROVIDER`` (if set + valid)."""
|
||||
pick = os.environ.get("LLM_DEFAULT_PROVIDER")
|
||||
return pick if pick and pick in _BY_ID else None
|
||||
|
||||
|
||||
def describe(p: Provider) -> dict:
|
||||
"""Client-safe provider descriptor — NEVER includes the key material."""
|
||||
return {
|
||||
"""Client-safe provider descriptor — NEVER includes the key material.
|
||||
|
||||
The ``*_from_env`` booleans mirror ``key_from_env`` so the UI can disable an
|
||||
env-pinned field (and the make-active button) with an explainer instead of
|
||||
letting the user edit a value the resolver will silently override. ``base_url``
|
||||
is the RAW template (``substitute=False``) so an account-scoped default shows
|
||||
``{account_id}`` rather than a baked-in value; ``account_id`` is returned
|
||||
separately for account-scoped providers so the field can round-trip.
|
||||
"""
|
||||
d = {
|
||||
"id": p.id,
|
||||
"display_name": p.display_name,
|
||||
"local": p.local,
|
||||
"needs_account": p.needs_account,
|
||||
"signup_url": p.signup_url,
|
||||
"notes": p.notes,
|
||||
"base_url": resolve_base_url(p),
|
||||
"base_url": resolve_base_url(p, substitute=False),
|
||||
"model": resolve_model(p),
|
||||
"has_key": has_key(p),
|
||||
"key_from_env": bool(_env_first(p.key_envs)),
|
||||
"base_url_from_env": bool(p.base_url_env and os.environ.get(p.base_url_env)),
|
||||
"model_from_env": bool(p.model_env and os.environ.get(p.model_env)),
|
||||
"active_from_env": _active_env_pin() is not None,
|
||||
"configured": is_configured(p),
|
||||
}
|
||||
if p.needs_account:
|
||||
d["account_id"] = resolve_account_id(p)
|
||||
d["account_from_env"] = bool(p.account_env and os.environ.get(p.account_env))
|
||||
return d
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
"""LLM Skills registry — per-feature enable/route control for every LLM call.
|
||||
|
||||
Every LLM-powered capability ("skill") in the backend is registered here, so
|
||||
the Settings → LLM Skills panel can (a) toggle it and (b) route it to a
|
||||
specific provider (a local Ollama/LM Studio vs a remote key) instead of
|
||||
everything riding the one global active provider.
|
||||
|
||||
The six consumption points today:
|
||||
|
||||
dub_translation — api/routers/dub_translate.py (the Dub tab's direct
|
||||
"LLM" translation engine; provider=openai branch)
|
||||
cinematic_translation — services/translator.py (Cinematic + Autofit
|
||||
REFLECT/ADAPT rewrite; dub_translate quality gate)
|
||||
slot_fitting — services/speech_rate.py (trim/expand a line to its
|
||||
time slot; Autofit strict pass + /tools/rate-fit)
|
||||
glossary_extract — api/routers/glossary.py auto-extract
|
||||
direction_parse — services/director.py (natural-language direction →
|
||||
taxonomy tokens; /tools/direction + dub generate)
|
||||
dictation_refinement — services/refinement.py (dictation transcript
|
||||
cleanup on finals)
|
||||
|
||||
Design rules:
|
||||
|
||||
* **Disabled == unconfigured.** A disabled skill degrades through the exact
|
||||
same path the feature takes today when no LLM is configured (Fast
|
||||
translation fallback, refinement pass-through, heuristic direction parse,
|
||||
no-llm slot fit, 503 on glossary auto-extract). No new degradation modes.
|
||||
* **Override > active > none.** A per-skill provider override (persisted in
|
||||
settings_store) wins over the global active provider. No override → the
|
||||
active provider, resolved exactly as before (so existing setups see zero
|
||||
behavior change; all skills default to enabled with no override).
|
||||
* **Persistence** is two plaintext settings rows per skill:
|
||||
``llm_skill.<id>.enabled`` ("1"/"0", absent = enabled) and
|
||||
``llm_skill.<id>.provider`` (provider id, absent/empty = active provider).
|
||||
Keys stay in the provider registry (encrypted) — nothing secret here.
|
||||
* ``OMNIVOICE_LLM_BACKEND=off`` remains the global kill switch: it also
|
||||
silences skills routed through a per-skill override.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
logger = logging.getLogger("omnivoice.llm_skills")
|
||||
|
||||
_ENABLED_KEY = "llm_skill.{sid}.enabled"
|
||||
_PROVIDER_KEY = "llm_skill.{sid}.provider"
|
||||
|
||||
_UNSET = object()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LLMSkill:
|
||||
"""A registered LLM consumption point. name/description resolve via the
|
||||
frontend i18n layer (localization hard rule — no hardcoded UI text)."""
|
||||
|
||||
id: str
|
||||
name_key: str
|
||||
description_key: str
|
||||
|
||||
|
||||
def _skill(sid: str) -> LLMSkill:
|
||||
return LLMSkill(
|
||||
id=sid,
|
||||
name_key=f"settings.llmskills_{sid}_name",
|
||||
description_key=f"settings.llmskills_{sid}_desc",
|
||||
)
|
||||
|
||||
|
||||
# Display order in the settings panel: the dub pipeline first (translation →
|
||||
# refine → fit → glossary → direction), then dictation.
|
||||
_SKILLS: tuple[LLMSkill, ...] = (
|
||||
_skill("dub_translation"),
|
||||
_skill("cinematic_translation"),
|
||||
_skill("slot_fitting"),
|
||||
_skill("glossary_extract"),
|
||||
_skill("direction_parse"),
|
||||
_skill("dictation_refinement"),
|
||||
)
|
||||
|
||||
_BY_ID: dict[str, LLMSkill] = {s.id: s for s in _SKILLS}
|
||||
|
||||
|
||||
def all_skills() -> tuple[LLMSkill, ...]:
|
||||
return _SKILLS
|
||||
|
||||
|
||||
def get_skill(skill_id: str) -> Optional[LLMSkill]:
|
||||
return _BY_ID.get(skill_id)
|
||||
|
||||
|
||||
# ── Persistence (settings_store text rows) ─────────────────────────────────
|
||||
|
||||
|
||||
def is_enabled(skill_id: str) -> bool:
|
||||
"""Skill toggle. Absent row = enabled (all skills default on)."""
|
||||
from services import settings_store
|
||||
|
||||
raw = settings_store.get_text(_ENABLED_KEY.format(sid=skill_id))
|
||||
return raw != "0"
|
||||
|
||||
|
||||
def provider_override(skill_id: str) -> Optional[str]:
|
||||
"""The per-skill provider id, or None when the skill follows the active
|
||||
provider. A stored id that no longer exists in the registry reads as None
|
||||
(stale override — resolution falls back to the active provider)."""
|
||||
from services import llm_providers, settings_store
|
||||
|
||||
raw = (settings_store.get_text(_PROVIDER_KEY.format(sid=skill_id)) or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
if llm_providers.get_provider(raw) is None:
|
||||
logger.warning("llm_skills: stale provider override %r on %s — ignoring",
|
||||
raw, skill_id)
|
||||
return None
|
||||
return raw
|
||||
|
||||
|
||||
def configure_skill(skill_id: str, *, enabled: Optional[bool] = None,
|
||||
provider_override: Any = _UNSET) -> None:
|
||||
"""Persist a skill's toggle and/or provider routing.
|
||||
|
||||
``provider_override``: omit to leave unchanged; ``None``/``""`` clears it
|
||||
(skill follows the active provider); a provider id routes the skill there.
|
||||
Raises KeyError for an unknown skill, ValueError for an unknown provider.
|
||||
"""
|
||||
if skill_id not in _BY_ID:
|
||||
raise KeyError(f"unknown LLM skill {skill_id!r}. Known: {sorted(_BY_ID)}")
|
||||
from services import llm_providers, settings_store
|
||||
|
||||
if enabled is not None:
|
||||
settings_store.set_text(_ENABLED_KEY.format(sid=skill_id),
|
||||
"1" if enabled else "0")
|
||||
if provider_override is not _UNSET:
|
||||
pid = (provider_override or "").strip()
|
||||
if pid and llm_providers.get_provider(pid) is None:
|
||||
raise ValueError(f"unknown provider {pid!r}")
|
||||
settings_store.set_text(_PROVIDER_KEY.format(sid=skill_id), pid)
|
||||
|
||||
|
||||
# ── Resolution (override > active > none) ──────────────────────────────────
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SkillResolution:
|
||||
skill: LLMSkill
|
||||
enabled: bool
|
||||
provider: Optional[Any] # llm_providers.Provider or None
|
||||
source: str # "override" | "active" | "none"
|
||||
ready: bool
|
||||
reason: Optional[str] # None | "disabled" | "no_provider" | "unconfigured"
|
||||
|
||||
|
||||
def resolve_skill(skill_id: str) -> SkillResolution:
|
||||
"""Resolve a skill's effective provider + ready status.
|
||||
|
||||
Precedence: per-skill override → global active provider → none. Ready
|
||||
means enabled AND the effective provider is configured end-to-end.
|
||||
Raises KeyError for an unknown skill.
|
||||
"""
|
||||
skill = _BY_ID.get(skill_id)
|
||||
if skill is None:
|
||||
raise KeyError(f"unknown LLM skill {skill_id!r}. Known: {sorted(_BY_ID)}")
|
||||
from services import llm_providers
|
||||
|
||||
enabled = is_enabled(skill_id)
|
||||
override = provider_override(skill_id)
|
||||
if override:
|
||||
provider = llm_providers.get_provider(override)
|
||||
source = "override"
|
||||
else:
|
||||
provider = llm_providers.active_provider()
|
||||
source = "active" if provider is not None else "none"
|
||||
|
||||
if not enabled:
|
||||
ready, reason = False, "disabled"
|
||||
elif provider is None:
|
||||
ready, reason = False, "no_provider"
|
||||
elif not llm_providers.is_configured(provider):
|
||||
ready, reason = False, "unconfigured"
|
||||
else:
|
||||
ready, reason = True, None
|
||||
return SkillResolution(skill=skill, enabled=enabled, provider=provider,
|
||||
source=source, ready=ready, reason=reason)
|
||||
|
||||
|
||||
def effective_provider(skill_id: str) -> Optional[Any]:
|
||||
"""The provider a skill would call (override or active), or None."""
|
||||
return resolve_skill(skill_id).provider
|
||||
|
||||
|
||||
# ── Client / backend construction ───────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SkillClient:
|
||||
"""A ready-to-call OpenAI-compatible client bound to the skill's provider."""
|
||||
|
||||
client: Any # openai.OpenAI
|
||||
model: str
|
||||
provider_id: str
|
||||
timeout: float
|
||||
|
||||
|
||||
def _default_timeout() -> float:
|
||||
try:
|
||||
return float(os.environ.get("OMNIVOICE_LLM_TIMEOUT", "45"))
|
||||
except ValueError:
|
||||
return 45.0
|
||||
|
||||
|
||||
def resolve_skill_client(skill_id: str) -> Optional[SkillClient]:
|
||||
"""OpenAI-compat client + model for a skill, or None.
|
||||
|
||||
None when the skill is disabled, no provider resolves, the provider is
|
||||
unconfigured, or the openai package is missing — callers treat None
|
||||
exactly like "no LLM configured" (their existing degradation path).
|
||||
Raises KeyError for an unknown skill (programming error, not user state).
|
||||
"""
|
||||
res = resolve_skill(skill_id)
|
||||
if not res.ready:
|
||||
return None
|
||||
try:
|
||||
from openai import OpenAI
|
||||
except ImportError:
|
||||
logger.warning("openai package not installed — LLM skill %s unavailable.",
|
||||
skill_id)
|
||||
return None
|
||||
from services import llm_providers
|
||||
|
||||
api_key = llm_providers.resolve_api_key(res.provider)
|
||||
if not api_key:
|
||||
return None
|
||||
kw: dict[str, Any] = {"api_key": api_key}
|
||||
base_url = llm_providers.resolve_base_url(res.provider)
|
||||
if base_url:
|
||||
kw["base_url"] = base_url
|
||||
# max_retries=0: a rate-limited provider returning 429 + a long Retry-After
|
||||
# would otherwise let the SDK sleep+retry inside a single call, blowing the
|
||||
# skill's wall-clock budget (the cinematic pass budget, the glossary call
|
||||
# timeout) from inside one request. Fail fast — the per-call timeout and the
|
||||
# pass-level budget are the only bounds we want. Mirrors OpenAICompatBackend.
|
||||
return SkillClient(
|
||||
client=OpenAI(max_retries=0, **kw),
|
||||
model=llm_providers.resolve_model(res.provider),
|
||||
provider_id=res.provider.id,
|
||||
timeout=_default_timeout(),
|
||||
)
|
||||
|
||||
|
||||
def skill_backend(skill_id: str, active: Optional[Callable[[], Any]] = None):
|
||||
"""LLMBackend for a skill — the drop-in for ``get_active_llm_backend()``.
|
||||
|
||||
* disabled skill → OffBackend (same object the no-LLM path returns today,
|
||||
so every caller's ``id == "off"`` / ``isinstance(…, OffBackend)`` check
|
||||
degrades identically);
|
||||
* no override → the ``active`` callable (callers pass their module-local
|
||||
``get_active_llm_backend`` so existing monkeypatch seams keep working),
|
||||
defaulting to ``llm_backend.get_active_llm_backend`` — the exact legacy
|
||||
path, env/prefs overrides included;
|
||||
* override → an OpenAICompatBackend bound to that provider, or OffBackend
|
||||
when the provider is unconfigured, openai is missing, or the global
|
||||
``OMNIVOICE_LLM_BACKEND=off`` kill switch is set.
|
||||
"""
|
||||
from services.llm_backend import OffBackend, OpenAICompatBackend
|
||||
|
||||
res = resolve_skill(skill_id)
|
||||
if not res.enabled:
|
||||
return OffBackend()
|
||||
if res.source != "override":
|
||||
if active is not None:
|
||||
return active()
|
||||
from services import llm_backend
|
||||
return llm_backend.get_active_llm_backend()
|
||||
if os.environ.get("OMNIVOICE_LLM_BACKEND") == "off":
|
||||
return OffBackend()
|
||||
if not res.ready:
|
||||
return OffBackend()
|
||||
try:
|
||||
import openai # noqa: F401
|
||||
except ImportError:
|
||||
return OffBackend()
|
||||
return OpenAICompatBackend(provider=res.provider)
|
||||
|
||||
|
||||
# ── API descriptor ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def describe(skill_id: str) -> dict:
|
||||
"""Client-safe skill descriptor for GET /api/settings/llm-skills."""
|
||||
res = resolve_skill(skill_id)
|
||||
p = res.provider
|
||||
return {
|
||||
"id": res.skill.id,
|
||||
"name_key": res.skill.name_key,
|
||||
"description_key": res.skill.description_key,
|
||||
"enabled": res.enabled,
|
||||
"provider_override": provider_override(skill_id),
|
||||
"provider": p.id if p is not None else None,
|
||||
"provider_display_name": p.display_name if p is not None else None,
|
||||
"provider_local": p.local if p is not None else None,
|
||||
"provider_source": res.source,
|
||||
"ready": res.ready,
|
||||
"reason": res.reason,
|
||||
}
|
||||
@@ -249,15 +249,38 @@ async def run_on_gpu_pool_guarded(fn, *, what: str = "GPU job",
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("GPU pool reset after %s timeout failed", what)
|
||||
raise GpuJobTimeoutError(
|
||||
f"{what} exceeded {timeout:.0f}s and was abandoned — the backend is "
|
||||
"running, but the job was too heavy for the available compute. Most "
|
||||
"often the GPU is VRAM-starved (a resident model and this job contend "
|
||||
"for memory). Capacity was restored automatically; for a durable fix "
|
||||
"try shorter text, a lighter engine, or set the engine to CPU in "
|
||||
"Settings → Models. (Raise OMNIVOICE_GENERATE_TIMEOUT_S for very long "
|
||||
"single generations.)"
|
||||
raise GpuJobTimeoutError(_timeout_guidance(what, timeout))
|
||||
|
||||
|
||||
def _timeout_guidance(what: str, timeout: float) -> str:
|
||||
"""Device-aware timeout message (#896): a CPU-only host must never be told
|
||||
to "set the engine to CPU" or blamed on VRAM — on CPU the job is simply
|
||||
compute-bound. GPU hosts keep the VRAM-contention guidance."""
|
||||
family = "cuda" # conservative default: GPU wording if the probe fails
|
||||
try:
|
||||
from core.device_caps import detect_host_caps
|
||||
family = detect_host_caps().family
|
||||
except Exception: # noqa: BLE001 — guidance must never mask the timeout
|
||||
pass
|
||||
common = (
|
||||
f"{what} exceeded {timeout:.0f}s and was abandoned — the backend is "
|
||||
"running, but the job was too heavy for the available compute. "
|
||||
"Capacity was restored automatically; "
|
||||
)
|
||||
if family == "cpu":
|
||||
return common + (
|
||||
"this machine renders on CPU, where long generations are "
|
||||
"compute-bound. For a durable fix try shorter text or a lighter "
|
||||
"engine (OmniVoice GGUF and Supertonic-3 are CPU-tuned). If you "
|
||||
"expect very long single generations, raise "
|
||||
"OMNIVOICE_GENERATE_TIMEOUT_S."
|
||||
)
|
||||
return common + (
|
||||
"most often the GPU is VRAM-starved (a resident model and this job "
|
||||
"contend for memory). For a durable fix try shorter text, a lighter "
|
||||
"engine, or set the engine to CPU in Settings → Models. (Raise "
|
||||
"OMNIVOICE_GENERATE_TIMEOUT_S for very long single generations.)"
|
||||
)
|
||||
|
||||
|
||||
model = None # type: ignore
|
||||
@@ -632,6 +655,29 @@ def _hf_offline() -> bool:
|
||||
return _env_flag("HF_HUB_OFFLINE") or _env_flag("TRANSFORMERS_OFFLINE")
|
||||
|
||||
|
||||
# Why the LAST _repair_model_cache run failed ("" when it succeeded / hasn't
|
||||
# run). #886: the "could not be auto-repaired" message used to drop the cause
|
||||
# entirely, so a mirror outage, offline mode, or a full disk all read the same.
|
||||
_last_repair_error: str = ""
|
||||
|
||||
|
||||
def _repair_failure_detail() -> str:
|
||||
"""One sanitized clause naming why auto-repair failed, or "" (#886).
|
||||
|
||||
Feeds user-facing messages (the generate 500 detail / model status), so it
|
||||
goes through core.failure.sanitize — and because the cause text is now part
|
||||
of the surfaced error, the shared HF-mirror hint (#874) fires on it when
|
||||
the repair failed against an unreachable configured mirror."""
|
||||
if not _last_repair_error:
|
||||
return ""
|
||||
try:
|
||||
from core.failure import sanitize
|
||||
cause = sanitize(_last_repair_error)
|
||||
except Exception:
|
||||
cause = _last_repair_error
|
||||
return f" Auto-repair failed with: {cause}."
|
||||
|
||||
|
||||
def _repair_model_cache(checkpoint: str, *, force: bool = False) -> bool:
|
||||
"""Re-fetch a checkpoint's missing files in place and report success.
|
||||
|
||||
@@ -648,16 +694,22 @@ def _repair_model_cache(checkpoint: str, *, force: bool = False) -> bool:
|
||||
size won't be re-fetched by the default resume (#739). It re-downloads the
|
||||
whole snapshot, so it's the last resort the load path only reaches after a
|
||||
plain resume-repair didn't fix the cache."""
|
||||
global _last_repair_error
|
||||
_last_repair_error = ""
|
||||
if _hf_offline():
|
||||
logger.warning(
|
||||
"Model cache for %s is incomplete but HF offline mode is set — "
|
||||
"cannot auto-repair.", checkpoint,
|
||||
)
|
||||
_last_repair_error = (
|
||||
"Hugging Face offline mode is enabled (HF_HUB_OFFLINE/TRANSFORMERS_OFFLINE)"
|
||||
)
|
||||
return False
|
||||
try:
|
||||
from huggingface_hub import snapshot_download
|
||||
except Exception as imp_err: # pragma: no cover - huggingface_hub is a hard dep
|
||||
logger.warning("Cannot import snapshot_download to repair cache: %s", imp_err)
|
||||
_last_repair_error = f"{type(imp_err).__name__}: {imp_err}"
|
||||
return False
|
||||
dl_kwargs: dict = {"repo_id": checkpoint}
|
||||
endpoint = os.environ.get("HF_ENDPOINT")
|
||||
@@ -710,6 +762,7 @@ def _repair_model_cache(checkpoint: str, *, force: bool = False) -> bool:
|
||||
"Auto-repair of %s attempt %d/%d failed: %s",
|
||||
checkpoint, attempt, retries, e,
|
||||
)
|
||||
_last_repair_error = f"{type(e).__name__}: {e}"
|
||||
if attempt < retries and backoff:
|
||||
time.sleep(backoff * attempt)
|
||||
return False
|
||||
@@ -801,7 +854,8 @@ def _load_model_sync():
|
||||
if not _repair_model_cache(checkpoint):
|
||||
raise RuntimeError(
|
||||
f"The TTS model cache for {checkpoint} is incomplete "
|
||||
"(weights missing — usually an interrupted download). "
|
||||
"(weights missing — usually an interrupted download)."
|
||||
f"{_repair_failure_detail()} "
|
||||
"Open Settings → Models, delete the OmniVoice TTS model, "
|
||||
"and install it again."
|
||||
) from e
|
||||
@@ -830,8 +884,9 @@ def _load_model_sync():
|
||||
else:
|
||||
raise RuntimeError(
|
||||
f"The TTS model cache for {checkpoint} is incomplete and "
|
||||
"could not be auto-repaired. Open Settings → Models, "
|
||||
"delete the OmniVoice TTS model, and install it again."
|
||||
f"could not be auto-repaired.{_repair_failure_detail()} "
|
||||
"Open Settings → Models, delete the OmniVoice TTS model, "
|
||||
"and install it again."
|
||||
) from e2
|
||||
else:
|
||||
raise RuntimeError(
|
||||
|
||||
@@ -19,13 +19,69 @@ Two tiers, both applied only to FINAL transcripts (never partials):
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
logger = logging.getLogger("omnivoice.refinement")
|
||||
|
||||
# Hard wall-clock budget (seconds) for a single dictation refinement LLM call.
|
||||
# The dictation FINAL must never be delayed longer than this by a slow or dead
|
||||
# LLM endpoint — refinement is best-effort and falls back to the unrefined
|
||||
# (but polished) text on timeout. 4s keeps a healthy local model (Ollama /
|
||||
# LM Studio, sub-second on the tiny cleanup prompt) fully usable while turning
|
||||
# the old worst case — a placeholder/dead endpoint blocking the send ~51s until
|
||||
# the widget's 15s fallback fired — into a bounded ~4s at most. Env-tunable so
|
||||
# power users on a slow local LLM can raise it. Guarded by the regression tests
|
||||
# in tests/backend/services/test_refinement_llm.py and tests/test_capture_ws.py.
|
||||
_DEFAULT_REFINE_TIMEOUT_S = 4.0
|
||||
|
||||
|
||||
def _refine_timeout_s() -> float:
|
||||
"""The refinement LLM budget in seconds (OMNIVOICE_REFINE_TIMEOUT_S).
|
||||
|
||||
Falls back to :data:`_DEFAULT_REFINE_TIMEOUT_S` on an unset/invalid/non-
|
||||
positive value so a bad env var can never disable the bound."""
|
||||
raw = os.environ.get("OMNIVOICE_REFINE_TIMEOUT_S", "")
|
||||
try:
|
||||
v = float(raw)
|
||||
if v > 0:
|
||||
return v
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return _DEFAULT_REFINE_TIMEOUT_S
|
||||
|
||||
|
||||
# Most-recent refinement outcome, so the Settings panel can tell the user when a
|
||||
# configured LLM is actually failing/timing out (the honesty layer behind the
|
||||
# `llm_ready` flag, which only means "an endpoint is configured"). Best-effort,
|
||||
# process-local, cleared on success.
|
||||
_last_refine_status: dict | None = None
|
||||
|
||||
|
||||
def _note_refine_status(*, ok: bool, reason: str | None = None) -> None:
|
||||
global _last_refine_status
|
||||
_last_refine_status = {"ok": bool(ok), "reason": reason, "at": time.time()}
|
||||
|
||||
|
||||
def get_last_refine_status() -> dict | None:
|
||||
"""The last refinement outcome as ``{ok, reason, at}`` or None if refinement
|
||||
hasn't run this session. ``ok=False`` with ``reason`` ("timeout" or a short
|
||||
error string) means a configured LLM failed the most recent final."""
|
||||
return dict(_last_refine_status) if _last_refine_status else None
|
||||
|
||||
|
||||
def _short_reason(exc: Exception) -> str:
|
||||
"""A compact, non-leaky label for a refinement failure (for the UI hint)."""
|
||||
name = type(exc).__name__
|
||||
if "Timeout" in name or "timeout" in str(exc).lower():
|
||||
return "timeout"
|
||||
return name
|
||||
|
||||
# A token (or unit) must repeat at least this many times consecutively to be
|
||||
# treated as an STT artifact. Rhetorical repetition ("no, no, no, no, no" —
|
||||
# five repeats) stays below the threshold and survives.
|
||||
@@ -248,6 +304,19 @@ REFINEMENT_EXAMPLES: list[tuple[str, str]] = [
|
||||
# settings_store key holding the user's refinement config (plain JSON).
|
||||
_SETTINGS_KEY = "dictation_refinement"
|
||||
|
||||
# LLM Skills registry id — Settings → LLM Skills can disable refinement's LLM
|
||||
# use or route it to a specific provider. Disabled == identical pass-through
|
||||
# (the same path as "no LLM configured").
|
||||
_SKILL_ID = "dictation_refinement"
|
||||
|
||||
|
||||
def _skill_llm():
|
||||
"""The skill-resolved backend (OffBackend when disabled/unconfigured)."""
|
||||
from services import llm_skills
|
||||
from services.llm_backend import get_active_llm_backend
|
||||
|
||||
return llm_skills.skill_backend(_SKILL_ID, active=get_active_llm_backend)
|
||||
|
||||
|
||||
def get_refinement_config() -> dict:
|
||||
"""Read the persisted config: {auto, smart_cleanup, self_correction,
|
||||
@@ -274,43 +343,97 @@ def set_refinement_config(cfg: dict) -> dict:
|
||||
return merged
|
||||
|
||||
|
||||
def refine_transcript(transcript: str, flags: RefinementFlags | None = None) -> str:
|
||||
def refine_transcript(
|
||||
transcript: str,
|
||||
flags: RefinementFlags | None = None,
|
||||
*,
|
||||
timeout_s: float | None = None,
|
||||
) -> str:
|
||||
"""Run the transcript through the configured LLM. Raises on failure —
|
||||
callers decide the fallback (maybe_refine swallows into pass-through)."""
|
||||
from services.llm_backend import get_active_llm_backend
|
||||
callers decide the fallback (maybe_refine swallows into pass-through).
|
||||
|
||||
The LLM HTTP call is bounded by ``timeout_s`` (default: the refinement
|
||||
budget) so a dead/slow endpoint can't tie the call up for the client's full
|
||||
45s LLM timeout — the class of stall this whole module guards against."""
|
||||
flags = flags or RefinementFlags()
|
||||
backend = get_active_llm_backend()
|
||||
backend = _skill_llm()
|
||||
messages = [{"role": "system", "content": build_refinement_prompt(flags)}]
|
||||
for user_turn, assistant_turn in REFINEMENT_EXAMPLES:
|
||||
messages.append({"role": "user", "content": user_turn})
|
||||
messages.append({"role": "assistant", "content": assistant_turn})
|
||||
messages.append({"role": "user", "content": transcript})
|
||||
return backend.chat_messages(messages=messages).strip()
|
||||
budget = timeout_s if timeout_s is not None else _refine_timeout_s()
|
||||
return backend.chat_messages(messages=messages, timeout=budget).strip()
|
||||
|
||||
|
||||
def maybe_refine(transcript: str) -> str | None:
|
||||
def maybe_refine(transcript: str, *, timeout_s: float | None = None) -> str | None:
|
||||
"""Best-effort refinement for the dictation final path.
|
||||
|
||||
Returns the refined text, or None when refinement is off, no LLM
|
||||
backend is configured, the result is empty, or anything fails — the
|
||||
raw transcript always stands. Never raises.
|
||||
raw transcript always stands. Never raises. Records the outcome via
|
||||
:func:`get_last_refine_status` so the UI can flag a failing LLM.
|
||||
|
||||
Blocking (network I/O); the WS/REST callers run it off-thread. Prefer
|
||||
:func:`maybe_refine_async` on the live-dictation path — it adds the hard
|
||||
wall-clock bound so a slow endpoint can never delay the ``final`` send.
|
||||
"""
|
||||
if not transcript or not transcript.strip():
|
||||
return None
|
||||
cfg = get_refinement_config()
|
||||
if not cfg.get("auto", True):
|
||||
return None
|
||||
backend = _skill_llm()
|
||||
if backend.id == "off":
|
||||
# No LLM configured — or the dictation_refinement skill is disabled /
|
||||
# routed to an unconfigured provider — is not a failure. Leave the last
|
||||
# status untouched (same pass-through as today).
|
||||
return None
|
||||
try:
|
||||
cfg = get_refinement_config()
|
||||
if not cfg.get("auto", True):
|
||||
return None
|
||||
from services.llm_backend import get_active_llm_backend
|
||||
|
||||
backend = get_active_llm_backend()
|
||||
if backend.id == "off":
|
||||
return None
|
||||
refined = refine_transcript(transcript, RefinementFlags.from_dict(cfg))
|
||||
refined = refine_transcript(
|
||||
transcript, RefinementFlags.from_dict(cfg), timeout_s=timeout_s
|
||||
)
|
||||
if not refined:
|
||||
return None
|
||||
_note_refine_status(ok=True)
|
||||
return refined
|
||||
except Exception as e: # noqa: BLE001 — pass-through is the contract
|
||||
logger.warning("Dictation refinement skipped: %s", e)
|
||||
_note_refine_status(ok=False, reason=_short_reason(e))
|
||||
return None
|
||||
|
||||
|
||||
async def maybe_refine_async(
|
||||
transcript: str, *, timeout_s: float | None = None
|
||||
) -> str | None:
|
||||
"""Async, hard-time-bounded refinement for the live-dictation final path.
|
||||
|
||||
Runs :func:`maybe_refine` off-thread under a hard ``OMNIVOICE_REFINE_TIMEOUT_S``
|
||||
(~4s) budget so a slow or dead LLM endpoint can NEVER block the caller — and
|
||||
therefore the dictation ``final`` send — longer than the budget. On timeout
|
||||
(or any failure) it returns None and the raw, already-polished transcript
|
||||
stands. Never raises.
|
||||
|
||||
``asyncio.wait_for`` can't cancel the worker thread, but the LLM call it runs
|
||||
is itself bounded to the same budget (see :func:`refine_transcript`), so an
|
||||
orphaned thread unwinds shortly after rather than lingering the full 45s.
|
||||
"""
|
||||
if not transcript or not transcript.strip():
|
||||
return None
|
||||
budget = timeout_s if timeout_s is not None else _refine_timeout_s()
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
asyncio.to_thread(maybe_refine, transcript, timeout_s=budget),
|
||||
timeout=budget,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(
|
||||
"Dictation refinement exceeded its %.1fs budget — sending the "
|
||||
"unrefined final (set OMNIVOICE_REFINE_TIMEOUT_S to adjust).", budget,
|
||||
)
|
||||
_note_refine_status(ok=False, reason="timeout")
|
||||
return None
|
||||
except Exception as e: # noqa: BLE001 — best-effort; the raw final stands
|
||||
logger.warning("Dictation refinement failed: %s", e)
|
||||
_note_refine_status(ok=False, reason=_short_reason(e))
|
||||
return None
|
||||
|
||||
@@ -532,14 +532,41 @@ def assign_speakers_from_turns(
|
||||
return segments
|
||||
|
||||
|
||||
def assign_speakers_heuristic(segments: List[dict]) -> List[dict]:
|
||||
"""Two-speaker alternation based on silence gaps."""
|
||||
current = 1
|
||||
def assign_speakers_heuristic(
|
||||
segments: List[dict], num_speakers: Optional[int] = None
|
||||
) -> List[dict]:
|
||||
"""Silence-gap speaker assignment (used when no diarization model runs).
|
||||
|
||||
Base signal: a gap > SPEAKER_GAP seconds between consecutive segments is
|
||||
treated as a speaker change. Without a ``num_speakers`` hint this keeps
|
||||
the legacy behavior — alternate between exactly two labels. With a hint:
|
||||
|
||||
* ``num_speakers=1`` → every segment gets ``"Speaker 1"``.
|
||||
* ``num_speakers>=2`` → labels round-robin across N speakers at each
|
||||
gap boundary, so the user's requested count is represented instead of
|
||||
being silently capped at 2.
|
||||
|
||||
Limits (be honest with callers): this honors the *count*, not voice
|
||||
identity. The rotation order is arbitrary (a returning speaker gets the
|
||||
next label in the cycle, not their own), rapid exchanges with no
|
||||
> SPEAKER_GAP pause still collapse into one label, and N is an upper
|
||||
bound — audio with fewer gap boundaries than N yields fewer labels.
|
||||
Real per-speaker attribution needs pyannote (or an inline-diarizing ASR
|
||||
backend); callers should warn the user accordingly (see dub_core).
|
||||
Invalid hints (non-int, < 1) fall back to the legacy two-speaker cycle.
|
||||
"""
|
||||
try:
|
||||
n = int(num_speakers) if num_speakers is not None else 2
|
||||
except (TypeError, ValueError):
|
||||
n = 2
|
||||
if n < 1:
|
||||
n = 2
|
||||
current = 0 # zero-based rotation index; rendered one-based below
|
||||
last_end = 0.0
|
||||
for i, s in enumerate(segments):
|
||||
if i > 0 and (s["start"] - last_end) > SPEAKER_GAP:
|
||||
current = 2 if current == 1 else 1
|
||||
s["speaker_id"] = f"Speaker {current}"
|
||||
if i > 0 and n > 1 and (s["start"] - last_end) > SPEAKER_GAP:
|
||||
current = (current + 1) % n
|
||||
s["speaker_id"] = f"Speaker {current + 1}"
|
||||
last_end = s["end"]
|
||||
return segments
|
||||
|
||||
|
||||
@@ -34,6 +34,23 @@ _PROVIDER = os.environ.get("OMNIVOICE_SHERPA_ASR_PROVIDER", "cpu")
|
||||
_NUM_THREADS = int(os.environ.get("OMNIVOICE_SHERPA_ASR_THREADS", "2"))
|
||||
|
||||
|
||||
def _endpoint_rules() -> tuple[float, float]:
|
||||
"""Trailing-silence endpoint rules (seconds) for streaming recognizers.
|
||||
|
||||
Wispr-Flow-speed defaults (dictation v2): rule2 commits ~0.6s after speech
|
||||
stops, rule1 flushes after 1.0s of trailing non-speech — down from the
|
||||
upstream 2.4/1.2, which made every committed sentence feel laggy. Read at
|
||||
call time so the env overrides apply without a restart.
|
||||
"""
|
||||
def _f(env: str, default: float) -> float:
|
||||
try:
|
||||
return float(os.environ.get(env, "") or default)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return (_f("OMNIVOICE_DICTATION_ENDPOINT_R1", 1.0),
|
||||
_f("OMNIVOICE_DICTATION_ENDPOINT_R2", 0.6))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SherpaModelSpec:
|
||||
"""One downloadable sherpa-onnx dictation model.
|
||||
@@ -282,6 +299,7 @@ def build_online_recognizer(spec: SherpaModelSpec, *, download: bool = True):
|
||||
import sherpa_onnx
|
||||
|
||||
d = _resolve_model_dir(spec, download=download)
|
||||
rule1, rule2 = _endpoint_rules()
|
||||
|
||||
def p(role: str) -> str:
|
||||
return os.path.join(d, spec.files[role])
|
||||
@@ -296,8 +314,8 @@ def build_online_recognizer(spec: SherpaModelSpec, *, download: bool = True):
|
||||
provider=_PROVIDER,
|
||||
decoding_method="greedy_search",
|
||||
enable_endpoint_detection=True,
|
||||
rule1_min_trailing_silence=2.4,
|
||||
rule2_min_trailing_silence=1.2,
|
||||
rule1_min_trailing_silence=rule1,
|
||||
rule2_min_trailing_silence=rule2,
|
||||
rule3_min_utterance_length=20,
|
||||
)
|
||||
if spec.kind == "online-paraformer":
|
||||
@@ -309,8 +327,8 @@ def build_online_recognizer(spec: SherpaModelSpec, *, download: bool = True):
|
||||
provider=_PROVIDER,
|
||||
decoding_method="greedy_search",
|
||||
enable_endpoint_detection=True,
|
||||
rule1_min_trailing_silence=2.4,
|
||||
rule2_min_trailing_silence=1.2,
|
||||
rule1_min_trailing_silence=rule1,
|
||||
rule2_min_trailing_silence=rule2,
|
||||
rule3_min_utterance_length=20,
|
||||
)
|
||||
raise ValueError(f"{spec.id} is not a streaming model (kind={spec.kind})")
|
||||
|
||||
@@ -42,11 +42,25 @@ IDEAL_REF_DURATION_S = 8.0 # target window — long enough for prosody, short e
|
||||
# is the empirical floor below which our zero-shot clone gets unstable.
|
||||
MIN_SEGMENT_REF_DURATION_S = 3.0
|
||||
|
||||
# Clone-purity guards (speaker-hint fix): a per-speaker reference cut from
|
||||
# mislabeled or boundary-adjacent audio mixes two people's voices and the
|
||||
# resulting clone sounds "made up".
|
||||
# * A slice below MIN_SLICE_DURATION_S is too short to be a reliable
|
||||
# single-speaker sample (and diarization boundary jitter dominates it).
|
||||
# * A slice whose edges come within ADJACENT_TURN_GUARD_S of a *different*
|
||||
# speaker's turn risks bleeding that speaker's audio across the imprecise
|
||||
# boundary — deprioritized (scoring preference, not a hard filter, so
|
||||
# extraction still succeeds on dense dialogue).
|
||||
MIN_SLICE_DURATION_S = 1.5
|
||||
ADJACENT_TURN_GUARD_S = 0.3
|
||||
|
||||
|
||||
def extract_speaker_clones(
|
||||
vocals_path: str,
|
||||
segments: list[dict],
|
||||
out_dir: str,
|
||||
*,
|
||||
labels_source: str | None = None,
|
||||
) -> dict[str, dict]:
|
||||
"""Build a per-speaker reference sample from `vocals_path` + `segments`.
|
||||
|
||||
@@ -63,7 +77,20 @@ def extract_speaker_clones(
|
||||
|
||||
Speakers whose segments total < MIN_REF_DURATION_S are skipped — we'd
|
||||
rather fall back to the default TTS voice than ship a bad clone.
|
||||
|
||||
``labels_source`` records where the ``speaker_id`` labels came from
|
||||
(``"pyannote"`` | ``"turns"`` | ``"heuristic"``; ``None`` = unknown,
|
||||
treated as trusted for backward compatibility). ``"heuristic"`` labels
|
||||
are silence-gap *estimates*, not voice identity — a reference cut from
|
||||
them routinely concatenates two people's audio, so extraction is skipped
|
||||
entirely (the caller warns the user and falls back to the default voice).
|
||||
"""
|
||||
if labels_source == "heuristic":
|
||||
logger.info(
|
||||
"speaker_clone: skipping auto-clone extraction — speaker labels "
|
||||
"are gap-based heuristic estimates, not voice identity"
|
||||
)
|
||||
return {}
|
||||
if not vocals_path or not os.path.exists(vocals_path):
|
||||
logger.info("speaker_clone: no vocals track at %s; skipping", vocals_path)
|
||||
return {}
|
||||
@@ -88,7 +115,12 @@ def extract_speaker_clones(
|
||||
out: dict[str, dict] = {}
|
||||
|
||||
for speaker_id, items in by_speaker.items():
|
||||
chosen = _pick_reference_slices(items)
|
||||
chosen = _pick_reference_slices(
|
||||
items,
|
||||
speaker_id=speaker_id,
|
||||
all_segments=segments,
|
||||
labels_source=labels_source,
|
||||
)
|
||||
if not chosen:
|
||||
logger.info(
|
||||
"speaker_clone: %s has <%ss of usable audio; will fall back to default voice",
|
||||
@@ -194,31 +226,87 @@ def extract_segment_refs(
|
||||
# ── Internals ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _pick_reference_slices(items: list[tuple[int, dict]]) -> list[tuple[int, dict]]:
|
||||
def _adjacent_to_other_speaker(
|
||||
seg: dict, speaker_id: str, all_segments: list[dict] | None
|
||||
) -> bool:
|
||||
"""True when `seg`'s edges come within ADJACENT_TURN_GUARD_S of (or
|
||||
overlap) a segment attributed to a *different* speaker — a boundary where
|
||||
imprecise diarization timestamps risk bleeding the other voice into the
|
||||
reference slice."""
|
||||
if not all_segments:
|
||||
return False
|
||||
s0 = float(seg.get("start", 0.0))
|
||||
s1 = float(seg.get("end", 0.0))
|
||||
for other in all_segments:
|
||||
if other is seg:
|
||||
continue
|
||||
if (other.get("speaker_id") or "Speaker 1") == speaker_id:
|
||||
continue
|
||||
o0 = float(other.get("start", 0.0))
|
||||
o1 = float(other.get("end", 0.0))
|
||||
# Signed gap between the two spans; negative = overlap.
|
||||
if max(o0 - s1, s0 - o1) < ADJACENT_TURN_GUARD_S:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _pick_reference_slices(
|
||||
items: list[tuple[int, dict]],
|
||||
*,
|
||||
speaker_id: str | None = None,
|
||||
all_segments: list[dict] | None = None,
|
||||
labels_source: str | None = None,
|
||||
) -> list[tuple[int, dict]]:
|
||||
"""Select the subset of a speaker's segments to use as reference audio.
|
||||
|
||||
Strategy: take the single longest segment; if it's short, accumulate the
|
||||
next longest ones in original order until we clear IDEAL_REF_DURATION_S.
|
||||
Cap at MAX_REF_DURATION_S. Return [] if we can't reach MIN_REF_DURATION_S.
|
||||
Strategy: rank candidates clean-first (not temporally adjacent to a
|
||||
different speaker's turn — see ``_adjacent_to_other_speaker``), longest
|
||||
first within each tier, and accumulate until IDEAL_REF_DURATION_S is
|
||||
cleared. Adjacency is a scoring preference, NOT a hard filter — on dense
|
||||
dialogue where every slice borders another speaker, extraction still
|
||||
succeeds using the adjacent ones. Two hard guards protect clone purity:
|
||||
|
||||
* slices shorter than MIN_SLICE_DURATION_S are rejected outright
|
||||
(boundary jitter dominates them, so they're the likeliest to carry a
|
||||
second speaker's audio);
|
||||
* ``labels_source="heuristic"`` returns [] — gap-based labels are not
|
||||
voice identity, so no slice of them is safe to clone from.
|
||||
|
||||
Cap at MAX_REF_DURATION_S. Return [] if we can't reach
|
||||
MIN_REF_DURATION_S. When ``all_segments``/``speaker_id`` are not
|
||||
provided (legacy callers), adjacency scoring degrades to duration-only —
|
||||
the pre-guard behavior.
|
||||
"""
|
||||
if not items:
|
||||
return []
|
||||
if labels_source == "heuristic":
|
||||
return []
|
||||
if speaker_id is None:
|
||||
speaker_id = items[0][1].get("speaker_id") or "Speaker 1"
|
||||
|
||||
# Longest-first candidates. Keep original indices so we can preserve order.
|
||||
by_dur = sorted(
|
||||
def _dur(pair) -> float:
|
||||
return max(0.0, float(pair[1].get("end", 0.0)) - float(pair[1].get("start", 0.0)))
|
||||
|
||||
# Rank: clean (non-adjacent) before adjacent, longest first within each
|
||||
# tier. Keep original indices so we can restore transcript order below.
|
||||
ranked = sorted(
|
||||
items,
|
||||
key=lambda pair: (pair[1].get("end", 0.0) - pair[1].get("start", 0.0)),
|
||||
reverse=True,
|
||||
key=lambda pair: (
|
||||
_adjacent_to_other_speaker(pair[1], speaker_id, all_segments),
|
||||
-_dur(pair),
|
||||
),
|
||||
)
|
||||
|
||||
picked: list[tuple[int, dict]] = []
|
||||
total = 0.0
|
||||
for idx, seg in by_dur:
|
||||
dur = max(0.0, float(seg.get("end", 0.0)) - float(seg.get("start", 0.0)))
|
||||
if dur <= 0.0:
|
||||
for idx, seg in ranked:
|
||||
dur = _dur((idx, seg))
|
||||
if dur < MIN_SLICE_DURATION_S:
|
||||
continue
|
||||
if total + dur > MAX_REF_DURATION_S and picked:
|
||||
break
|
||||
# Ranking is no longer duration-monotonic, so a later (shorter or
|
||||
# adjacent) slice may still fit — skip, don't stop.
|
||||
continue
|
||||
picked.append((idx, seg))
|
||||
total += dur
|
||||
if total >= IDEAL_REF_DURATION_S:
|
||||
|
||||
@@ -18,9 +18,17 @@ import logging
|
||||
from typing import Iterable, Optional
|
||||
|
||||
from services.llm_backend import get_active_llm_backend, OffBackend
|
||||
# Shared LLM-output divergence guard (length window + target-script +
|
||||
# critique-echo). Lives in translator; translator never imports this module,
|
||||
# so there is no import cycle.
|
||||
from services.translator import refine_output_ok
|
||||
|
||||
logger = logging.getLogger("omnivoice.speech_rate")
|
||||
|
||||
# LLM Skills registry id — Settings → LLM Skills can disable the slot-fit
|
||||
# LLM pass or route it to a specific provider. Disabled == the no-llm path.
|
||||
_SKILL_ID = "slot_fitting"
|
||||
|
||||
# Per-language read-speed estimates (chars/sec at natural pace, counting
|
||||
# Python `len()` codepoints — not phonemes or graphemes). These are
|
||||
# rough; real speakers vary wildly. Numbers below come from a mix of
|
||||
@@ -87,9 +95,15 @@ _EXPAND_PROMPT = """\
|
||||
You are a dubbing writer. The user will give you a translated line + the exact
|
||||
time slot it must fit. The current line is TOO SHORT — add natural filler or
|
||||
gently flesh out the thought while keeping the meaning the same. Aim for a
|
||||
reading duration that matches the slot.
|
||||
reading duration that matches the slot. Never invent new information, names,
|
||||
or dialogue that is not already in the line; do not more than double the line.
|
||||
Reply with ONLY the new line. No quotes, no commentary."""
|
||||
|
||||
# Below this predicted rate ratio a line can never honestly fill its slot —
|
||||
# any LLM "expansion" that far would be fabricated dialogue. Skip the expand
|
||||
# pass entirely and keep the short line (slot-aware TTS absorbs the silence).
|
||||
_MIN_EXPANDABLE_RATIO = 0.15
|
||||
|
||||
|
||||
def adjust_for_slot(
|
||||
text: str,
|
||||
@@ -103,19 +117,41 @@ def adjust_for_slot(
|
||||
|
||||
Falls back to the input text if the LLM is off or the loop gives up.
|
||||
|
||||
``strict`` (Autofit mode) caps the accepted upper bound at 1.0 instead of
|
||||
``TOL_HIGH`` — i.e. the line must fit *within* the slot, never overrun it —
|
||||
so the target-language reading time can't exceed the segment and push the
|
||||
video timing out. A too-short line is still accepted down to ``TOL_LOW`` (we
|
||||
don't pad just to fill silence). Best-effort: after ``MAX_ATTEMPTS`` it
|
||||
returns the closest candidate seen, so a stubborn line degrades gracefully.
|
||||
``strict`` (Autofit mode) changes exactly one thing: the accepted upper
|
||||
bound is 1.0 instead of ``TOL_HIGH`` — the line must fit *within* the
|
||||
slot, never overrun it — so the target-language reading time can't exceed
|
||||
the segment and push the video timing out. Lines under ``TOL_LOW`` still
|
||||
go through the LLM expand pass in strict mode too (same as loose mode);
|
||||
padding is bounded by the divergence guard below, and a line under
|
||||
``_MIN_EXPANDABLE_RATIO`` is never expanded at all — it could only "fill"
|
||||
the slot with fabricated dialogue, so it stays short. Best-effort: after
|
||||
``MAX_ATTEMPTS`` it returns the closest candidate seen, so a stubborn line
|
||||
degrades gracefully.
|
||||
|
||||
Every LLM reply is validated with ``translator.refine_output_ok`` against
|
||||
the ORIGINAL input ``text`` (not the previous candidate — divergence
|
||||
compounds across attempts otherwise). A reply that fails the guard is
|
||||
discarded: the attempt is burned, ``current``/``best`` stay put, and if
|
||||
nothing valid ever came back the input text is returned with
|
||||
``error="fit-diverged"`` — a hallucinating model can no longer invent the
|
||||
dub line (v0.3.9 field report).
|
||||
"""
|
||||
tol_high = 1.0 if strict else TOL_HIGH
|
||||
initial_ratio = rate_ratio(text, slot_seconds, target_lang)
|
||||
if TOL_LOW <= initial_ratio <= tol_high:
|
||||
return {"text": text, "rate_ratio": initial_ratio, "attempts": 0}
|
||||
if initial_ratio < _MIN_EXPANDABLE_RATIO:
|
||||
return {
|
||||
"text": text,
|
||||
"rate_ratio": initial_ratio,
|
||||
"attempts": 0,
|
||||
"error": "fit-skip-short",
|
||||
}
|
||||
|
||||
llm = get_active_llm_backend()
|
||||
from services import llm_skills
|
||||
# `active=` forwards this module's (monkeypatch-able) name so the
|
||||
# no-override path is byte-identical to the pre-skills behavior.
|
||||
llm = llm_skills.skill_backend(_SKILL_ID, active=lambda: get_active_llm_backend())
|
||||
if isinstance(llm, OffBackend):
|
||||
return {
|
||||
"text": text,
|
||||
@@ -126,6 +162,7 @@ def adjust_for_slot(
|
||||
|
||||
current = text
|
||||
best = (current, initial_ratio)
|
||||
diverged = False
|
||||
for attempt in range(1, MAX_ATTEMPTS + 1):
|
||||
r = rate_ratio(current, slot_seconds, target_lang)
|
||||
if TOL_LOW <= r <= tol_high:
|
||||
@@ -143,23 +180,43 @@ def adjust_for_slot(
|
||||
user_lines.append(f"Source line (for meaning): {source_text}")
|
||||
|
||||
try:
|
||||
next_text = llm.chat(system=system, user="\n".join(user_lines))
|
||||
next_text = llm.chat(
|
||||
system=system, user="\n".join(user_lines),
|
||||
temperature=0.2, # pinned like the Fast path — default 1.0 drifts/invents
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("speech-rate attempt %d failed: %s", attempt, e)
|
||||
return {"text": best[0], "rate_ratio": best[1], "attempts": attempt - 1, "error": str(e)}
|
||||
|
||||
if next_text and next_text.strip():
|
||||
current = next_text.strip()
|
||||
candidate = next_text.strip()
|
||||
# Divergence guard — validate against the ORIGINAL text, not
|
||||
# `current`: each accepted reply becomes the next prompt's input,
|
||||
# so per-step checks would let drift compound across attempts.
|
||||
ok, reason = refine_output_ok(text, candidate, target_lang)
|
||||
if not ok:
|
||||
diverged = True
|
||||
logger.warning(
|
||||
"speech-rate attempt %d rejected (%s) — discarding candidate",
|
||||
attempt, reason,
|
||||
)
|
||||
continue # attempt burned; current/best untouched
|
||||
current = candidate
|
||||
new_r = rate_ratio(current, slot_seconds, target_lang)
|
||||
# Keep the best candidate seen so far in case we exhaust retries.
|
||||
if abs(new_r - 1.0) < abs(best[1] - 1.0):
|
||||
best = (current, new_r)
|
||||
|
||||
return {
|
||||
out = {
|
||||
"text": best[0],
|
||||
"rate_ratio": best[1],
|
||||
"attempts": MAX_ATTEMPTS,
|
||||
}
|
||||
# Every usable reply diverged and the input text survived unchanged —
|
||||
# surface it on the row (rate_error in dub_translate, like fit-budget).
|
||||
if diverged and best[0] == text:
|
||||
out["error"] = "fit-diverged"
|
||||
return out
|
||||
|
||||
|
||||
def adjust_many(pairs: Iterable[tuple[str, float, str, Optional[str]]]) -> list[dict]:
|
||||
@@ -171,3 +228,78 @@ def adjust_many(pairs: Iterable[tuple[str, float, str, Optional[str]]]) -> list[
|
||||
adjust_for_slot(t, slot_seconds=s, target_lang=tl, source_text=src)
|
||||
for (t, s, tl, src) in pairs
|
||||
]
|
||||
|
||||
|
||||
async def adjust_for_slot_many(
|
||||
items: Iterable[tuple],
|
||||
*,
|
||||
executor=None,
|
||||
concurrency: Optional[int] = None,
|
||||
deadline: Optional[float] = None,
|
||||
loop=None,
|
||||
) -> dict:
|
||||
"""Fan `adjust_for_slot` out across many segments concurrently, bounded by a
|
||||
shared wall-clock ``deadline``.
|
||||
|
||||
``items``: iterable of ``(key, text, slot_seconds, target_lang,
|
||||
source_text_or_None, strict)``. Returns ``{key: adjust_for_slot_result}``.
|
||||
|
||||
Why this exists: the Autofit fit pass used to run one `adjust_for_slot` per
|
||||
segment *sequentially* and *outside* any budget, so a 50-segment dub against
|
||||
a slow/rate-limited LLM spun ~50×(per-call timeout) unbounded. Here every
|
||||
segment runs on the executor under a bounded ``asyncio.Semaphore``, and any
|
||||
segment still running when the shared ``deadline`` passes degrades to a
|
||||
no-fit result (input text kept, predicted ``rate_ratio``, ``error`` =
|
||||
``"fit-budget"``) instead of hanging the translate. ``deadline`` is an
|
||||
absolute ``loop.time()``; ``None`` disables the bound (run to completion).
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
loop = loop or asyncio.get_running_loop()
|
||||
items = list(items)
|
||||
if not items:
|
||||
return {}
|
||||
sem = asyncio.Semaphore(concurrency or int(os.environ.get("OMNIVOICE_LLM_CONCURRENCY", "6")))
|
||||
|
||||
async def _one(key, text, slot, tgt, src, strict):
|
||||
async with sem:
|
||||
res = await loop.run_in_executor(
|
||||
executor,
|
||||
lambda: adjust_for_slot(
|
||||
text, slot_seconds=slot, target_lang=tgt,
|
||||
source_text=src, strict=strict,
|
||||
),
|
||||
)
|
||||
return key, res
|
||||
|
||||
def _degraded(text, slot, tgt) -> dict:
|
||||
return {
|
||||
"text": text,
|
||||
"rate_ratio": rate_ratio(text, slot, tgt),
|
||||
"attempts": 0,
|
||||
"error": "fit-budget",
|
||||
}
|
||||
|
||||
tasks = [asyncio.ensure_future(_one(*it)) for it in items]
|
||||
|
||||
if deadline is None:
|
||||
pairs_out = await asyncio.gather(*tasks)
|
||||
return dict(pairs_out)
|
||||
|
||||
timeout = max(0.0, deadline - loop.time())
|
||||
done, _pending = await asyncio.wait(tasks, timeout=timeout)
|
||||
out: dict = {}
|
||||
for task, it in zip(tasks, items):
|
||||
key, text, slot, tgt = it[0], it[1], it[2], it[3]
|
||||
if task in done and not task.cancelled():
|
||||
try:
|
||||
k, res = task.result()
|
||||
out[k] = res
|
||||
continue
|
||||
except Exception as e: # noqa: BLE001 — one slow seg must not sink the pass
|
||||
logger.warning("fit segment %s failed: %s", key, e)
|
||||
else:
|
||||
task.cancel() # stop awaiting; the executor thread is abandoned (#730 pattern)
|
||||
out[key] = _degraded(text, slot, tgt)
|
||||
return out
|
||||
|
||||
@@ -0,0 +1,471 @@
|
||||
"""Storage usage report for Settings → Storage.
|
||||
|
||||
Computes, for everything the app owns on disk:
|
||||
|
||||
* per-volume totals (total / used / free, grouped by ``st_dev`` so two
|
||||
roots on the same disk are reported once),
|
||||
* per-category directory sizes — the HF model cache (with the largest
|
||||
model dirs), the app data dir (broken into voices / outputs / dub_jobs /
|
||||
batch / preview / database / logs / other subtotals), the per-engine
|
||||
venvs under ``backend/engines/*/.venv`` (+ the app venv), and any
|
||||
``omnivoice*`` entries in the OS temp dir,
|
||||
* server-side ``warnings`` (low disk, volume pressure, unreadable paths)
|
||||
so every client renders the same guidance.
|
||||
|
||||
Directory walks are **bounded**: each top-level category gets a deadline
|
||||
(default 10 s) and returns a partial total (``complete: false`` + an
|
||||
``unreadable`` warning with ``reason: "timeout"``) when it expires. Results
|
||||
are cached in-process for 5 minutes; ``refresh`` bypasses the cache. The API
|
||||
layer runs the whole build in a worker thread so the event loop never blocks.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
CACHE_TTL_SECONDS = 300.0
|
||||
CATEGORY_TIMEOUT_SECONDS = 10.0
|
||||
TOP_MODEL_COUNT = 10
|
||||
VOLUME_PRESSURE_PERCENT = 90.0
|
||||
DEFAULT_MIN_FREE_GB = 10 # callers pass setup.wizard.MIN_FREE_GB — this is the standalone fallback
|
||||
|
||||
# DATA_DIR children we know by name (core.config constants + routers that
|
||||
# write there). Anything else lands in the "other" subtotal so the numbers
|
||||
# always add up to the real on-disk footprint.
|
||||
_DATA_CHILD_DIRS = ("voices", "outputs", "dub_jobs", "batch", "preview")
|
||||
_DB_PREFIX = "omnivoice.db" # omnivoice.db + -wal / -shm / -journal
|
||||
_LOG_FILES = ("crash_log.txt", "error_journal.jsonl")
|
||||
_LOG_PREFIX = "omnivoice.log" # rolling log + rotations
|
||||
|
||||
_GB = 1024 ** 3
|
||||
|
||||
|
||||
def default_engines_dir() -> str:
|
||||
"""``backend/engines`` — where per-engine venvs live (`<id>/.venv`)."""
|
||||
return str(Path(__file__).resolve().parents[1] / "engines")
|
||||
|
||||
|
||||
def default_app_venv() -> str | None:
|
||||
"""The venv this backend runs from, when it is one (None for system python)."""
|
||||
if sys.prefix != getattr(sys, "base_prefix", sys.prefix):
|
||||
return sys.prefix
|
||||
return None
|
||||
|
||||
|
||||
def _existing_ancestor(path: str) -> str:
|
||||
"""Deepest existing ancestor of ``path`` (for disk_usage on missing dirs)."""
|
||||
p = os.path.abspath(path)
|
||||
while p and not os.path.exists(p):
|
||||
parent = os.path.dirname(p)
|
||||
if parent == p:
|
||||
break
|
||||
p = parent
|
||||
return p
|
||||
|
||||
|
||||
def _mount_point(path: str) -> str:
|
||||
"""Mount point of the volume holding ``path`` (best-effort, cheap)."""
|
||||
p = _existing_ancestor(path)
|
||||
try:
|
||||
while p and not os.path.ismount(p):
|
||||
parent = os.path.dirname(p)
|
||||
if parent == p:
|
||||
break
|
||||
p = parent
|
||||
except OSError:
|
||||
pass
|
||||
return p or os.path.abspath(os.sep)
|
||||
|
||||
|
||||
def _dir_size(path: str, deadline: float) -> tuple[int, bool, str | None]:
|
||||
"""du-style size of ``path``: ``(bytes, complete, first_unreadable_path)``.
|
||||
|
||||
Never follows symlinks (lstat + walk default), never raises. Stops early
|
||||
and reports ``complete=False`` once ``deadline`` (time.monotonic) passes.
|
||||
"""
|
||||
err_path: str | None = None
|
||||
|
||||
def _onerror(e: OSError) -> None:
|
||||
nonlocal err_path
|
||||
if err_path is None:
|
||||
err_path = getattr(e, "filename", None) or path
|
||||
|
||||
try:
|
||||
if not os.path.exists(path):
|
||||
return 0, True, None
|
||||
if not os.path.isdir(path):
|
||||
return os.lstat(path).st_size, True, None
|
||||
except OSError:
|
||||
return 0, True, path
|
||||
|
||||
total = 0
|
||||
complete = True
|
||||
for root, _dirs, files in os.walk(path, onerror=_onerror):
|
||||
if time.monotonic() > deadline:
|
||||
complete = False
|
||||
break
|
||||
for name in files:
|
||||
fp = os.path.join(root, name)
|
||||
try:
|
||||
total += os.lstat(fp).st_size
|
||||
except OSError:
|
||||
if err_path is None:
|
||||
err_path = fp
|
||||
return total, complete, err_path
|
||||
|
||||
|
||||
def _sum_files(paths: list[str]) -> int:
|
||||
total = 0
|
||||
for p in paths:
|
||||
try:
|
||||
total += os.lstat(p).st_size
|
||||
except OSError:
|
||||
pass
|
||||
return total
|
||||
|
||||
|
||||
def _hf_model_dirs(cache_dir: str) -> list[str]:
|
||||
"""`models--org--name` dirs in the cache root and its `hub/` child.
|
||||
|
||||
HF_HUB_CACHE points straight at the hub dir; HF_HOME needs `/hub`
|
||||
appended — scanning both covers either env resolution.
|
||||
"""
|
||||
out: list[str] = []
|
||||
for base in (cache_dir, os.path.join(cache_dir, "hub")):
|
||||
try:
|
||||
with os.scandir(base) as it:
|
||||
out.extend(
|
||||
e.path for e in it
|
||||
if e.name.startswith("models--") and e.is_dir(follow_symlinks=False)
|
||||
)
|
||||
except OSError:
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def _model_display_name(dir_name: str) -> str:
|
||||
return dir_name.removeprefix("models--").replace("--", "/")
|
||||
|
||||
|
||||
def build_report(
|
||||
*,
|
||||
data_dir: str,
|
||||
hf_cache_dir: str,
|
||||
engines_dir: str | None = None,
|
||||
app_venv: str | None = None,
|
||||
temp_root: str | None = None,
|
||||
min_free_gb: float = DEFAULT_MIN_FREE_GB,
|
||||
category_timeout: float = CATEGORY_TIMEOUT_SECONDS,
|
||||
) -> dict:
|
||||
"""Build the full storage report (synchronous; call from a worker thread)."""
|
||||
engines_dir = engines_dir if engines_dir is not None else default_engines_dir()
|
||||
temp_root = temp_root if temp_root is not None else tempfile.gettempdir()
|
||||
warnings: list[dict] = []
|
||||
categories: list[dict] = []
|
||||
|
||||
def _warn_unreadable(category_id: str, path: str, reason: str) -> None:
|
||||
warnings.append({
|
||||
"kind": "unreadable",
|
||||
"severity": "warning",
|
||||
"category_id": category_id,
|
||||
"path": path,
|
||||
"reason": reason,
|
||||
})
|
||||
|
||||
def _finish(category_id: str, cat: dict, complete: bool, err_path: str | None) -> None:
|
||||
cat["complete"] = complete
|
||||
if not complete:
|
||||
_warn_unreadable(category_id, cat["path"], "timeout")
|
||||
if err_path is not None:
|
||||
_warn_unreadable(category_id, err_path, "permission")
|
||||
|
||||
# ── 1. HF model cache (+ top model dirs) ───────────────────────────────
|
||||
deadline = time.monotonic() + category_timeout
|
||||
hf_total = 0
|
||||
hf_complete = True
|
||||
hf_err: str | None = None
|
||||
models: list[dict] = []
|
||||
model_dirs = set(_hf_model_dirs(hf_cache_dir))
|
||||
seen: set[str] = set()
|
||||
for mdir in sorted(model_dirs):
|
||||
size, ok, err = _dir_size(mdir, deadline)
|
||||
hf_total += size
|
||||
hf_complete = hf_complete and ok
|
||||
hf_err = hf_err or err
|
||||
models.append({"name": _model_display_name(os.path.basename(mdir)), "bytes": size})
|
||||
seen.add(os.path.realpath(mdir))
|
||||
# Non-model remainder of the cache (datasets, xet chunks, token file, …):
|
||||
# walk the top-level entries that aren't model dirs so the category total
|
||||
# reflects the whole cache, not just models.
|
||||
try:
|
||||
with os.scandir(hf_cache_dir) as it:
|
||||
entries = list(it)
|
||||
except OSError:
|
||||
entries = []
|
||||
if os.path.exists(hf_cache_dir):
|
||||
hf_err = hf_err or hf_cache_dir
|
||||
for e in entries:
|
||||
if os.path.realpath(e.path) in seen:
|
||||
continue
|
||||
if e.name == "hub":
|
||||
# hub/ holds the model dirs (already counted) + misc; count the rest.
|
||||
try:
|
||||
with os.scandir(e.path) as hub_it:
|
||||
for h in hub_it:
|
||||
if os.path.realpath(h.path) in seen:
|
||||
continue
|
||||
size, ok, err = _dir_size(h.path, deadline)
|
||||
hf_total += size
|
||||
hf_complete = hf_complete and ok
|
||||
hf_err = hf_err or err
|
||||
except OSError:
|
||||
hf_err = hf_err or e.path
|
||||
continue
|
||||
size, ok, err = _dir_size(e.path, deadline)
|
||||
hf_total += size
|
||||
hf_complete = hf_complete and ok
|
||||
hf_err = hf_err or err
|
||||
models.sort(key=lambda m: m["bytes"], reverse=True)
|
||||
hf_cat = {
|
||||
"id": "hf_cache",
|
||||
"path": hf_cache_dir,
|
||||
"exists": os.path.isdir(hf_cache_dir),
|
||||
"bytes": hf_total,
|
||||
"items": models[:TOP_MODEL_COUNT],
|
||||
}
|
||||
_finish("hf_cache", hf_cat, hf_complete, hf_err)
|
||||
categories.append(hf_cat)
|
||||
|
||||
# ── 2. App data dir, broken into subtotals ─────────────────────────────
|
||||
deadline = time.monotonic() + category_timeout
|
||||
data_complete = True
|
||||
data_err: str | None = None
|
||||
children: list[dict] = []
|
||||
claimed: set[str] = set()
|
||||
|
||||
for name in _DATA_CHILD_DIRS:
|
||||
p = os.path.join(data_dir, name)
|
||||
size, ok, err = _dir_size(p, deadline)
|
||||
data_complete = data_complete and ok
|
||||
data_err = data_err or err
|
||||
claimed.add(name)
|
||||
children.append({"id": name, "path": p, "bytes": size, "complete": ok})
|
||||
|
||||
db_files = sorted(glob.glob(os.path.join(glob.escape(data_dir), _DB_PREFIX + "*")))
|
||||
claimed.update(os.path.basename(p) for p in db_files)
|
||||
children.append({
|
||||
"id": "database",
|
||||
"path": os.path.join(data_dir, _DB_PREFIX),
|
||||
"bytes": _sum_files(db_files),
|
||||
"complete": True,
|
||||
})
|
||||
|
||||
log_files = sorted(glob.glob(os.path.join(glob.escape(data_dir), _LOG_PREFIX + "*")))
|
||||
log_files += [os.path.join(data_dir, n) for n in _LOG_FILES]
|
||||
claimed.update(os.path.basename(p) for p in log_files)
|
||||
children.append({
|
||||
"id": "logs",
|
||||
"path": data_dir,
|
||||
"bytes": _sum_files(log_files),
|
||||
"complete": True,
|
||||
})
|
||||
|
||||
other_bytes = 0
|
||||
try:
|
||||
with os.scandir(data_dir) as it:
|
||||
for e in it:
|
||||
if e.name in claimed:
|
||||
continue
|
||||
if e.is_dir(follow_symlinks=False):
|
||||
size, ok, err = _dir_size(e.path, deadline)
|
||||
other_bytes += size
|
||||
data_complete = data_complete and ok
|
||||
data_err = data_err or err
|
||||
else:
|
||||
try:
|
||||
other_bytes += e.stat(follow_symlinks=False).st_size
|
||||
except OSError:
|
||||
data_err = data_err or e.path
|
||||
except OSError:
|
||||
if os.path.exists(data_dir):
|
||||
data_err = data_err or data_dir
|
||||
children.append({"id": "other", "path": data_dir, "bytes": other_bytes, "complete": True})
|
||||
|
||||
data_cat = {
|
||||
"id": "data",
|
||||
"path": data_dir,
|
||||
"exists": os.path.isdir(data_dir),
|
||||
"bytes": sum(c["bytes"] for c in children),
|
||||
"children": children,
|
||||
}
|
||||
_finish("data", data_cat, data_complete, data_err)
|
||||
categories.append(data_cat)
|
||||
|
||||
# ── 3. Engine venvs (+ the app venv) ───────────────────────────────────
|
||||
deadline = time.monotonic() + category_timeout
|
||||
venv_total = 0
|
||||
venv_complete = True
|
||||
venv_err: str | None = None
|
||||
venv_items: list[dict] = []
|
||||
try:
|
||||
with os.scandir(engines_dir) as it:
|
||||
engine_dirs = sorted(e.path for e in it if e.is_dir(follow_symlinks=False))
|
||||
except OSError:
|
||||
engine_dirs = []
|
||||
for edir in engine_dirs:
|
||||
venv_dir = os.path.join(edir, ".venv")
|
||||
if not os.path.isdir(venv_dir):
|
||||
continue
|
||||
size, ok, err = _dir_size(venv_dir, deadline)
|
||||
venv_total += size
|
||||
venv_complete = venv_complete and ok
|
||||
venv_err = venv_err or err
|
||||
venv_items.append({"name": os.path.basename(edir), "bytes": size})
|
||||
if app_venv:
|
||||
size, ok, err = _dir_size(app_venv, deadline)
|
||||
venv_total += size
|
||||
venv_complete = venv_complete and ok
|
||||
venv_err = venv_err or err
|
||||
venv_items.append({"name": "app", "bytes": size})
|
||||
venv_items.sort(key=lambda m: m["bytes"], reverse=True)
|
||||
venv_cat = {
|
||||
"id": "engine_venvs",
|
||||
"path": engines_dir,
|
||||
"exists": os.path.isdir(engines_dir),
|
||||
"bytes": venv_total,
|
||||
"items": venv_items,
|
||||
}
|
||||
_finish("engine_venvs", venv_cat, venv_complete, venv_err)
|
||||
categories.append(venv_cat)
|
||||
|
||||
# ── 4. Temp/working files the app owns (omnivoice* in the OS temp dir) ─
|
||||
deadline = time.monotonic() + category_timeout
|
||||
tmp_total = 0
|
||||
tmp_complete = True
|
||||
tmp_err: str | None = None
|
||||
for p in sorted(glob.glob(os.path.join(glob.escape(temp_root), "omnivoice*"))):
|
||||
size, ok, err = _dir_size(p, deadline)
|
||||
tmp_total += size
|
||||
tmp_complete = tmp_complete and ok
|
||||
tmp_err = tmp_err or err
|
||||
tmp_cat = {
|
||||
"id": "temp",
|
||||
"path": temp_root,
|
||||
"exists": os.path.isdir(temp_root),
|
||||
"bytes": tmp_total,
|
||||
"items": [],
|
||||
}
|
||||
_finish("temp", tmp_cat, tmp_complete, tmp_err)
|
||||
categories.append(tmp_cat)
|
||||
|
||||
# ── Volumes: group category roots by device, disk_usage once each ──────
|
||||
roots = {"hf_cache": hf_cache_dir, "data": data_dir, "engine_venvs": engines_dir, "temp": temp_root}
|
||||
by_dev: dict[object, dict] = {}
|
||||
for cid, root in roots.items():
|
||||
anchor = _existing_ancestor(root)
|
||||
try:
|
||||
dev: object = os.stat(anchor).st_dev
|
||||
except OSError:
|
||||
dev = anchor
|
||||
if dev not in by_dev:
|
||||
try:
|
||||
usage = shutil.disk_usage(anchor)
|
||||
except OSError:
|
||||
continue
|
||||
by_dev[dev] = {
|
||||
"path": _mount_point(anchor),
|
||||
"total_bytes": usage.total,
|
||||
"used_bytes": usage.used,
|
||||
"free_bytes": usage.free,
|
||||
"used_percent": round(usage.used / usage.total * 100.0, 1) if usage.total else 0.0,
|
||||
"roots": [],
|
||||
}
|
||||
by_dev[dev]["roots"].append(cid)
|
||||
volumes = list(by_dev.values())
|
||||
|
||||
# ── Server-side warnings ────────────────────────────────────────────────
|
||||
for v in volumes:
|
||||
free_gb = v["free_bytes"] / _GB
|
||||
base = {
|
||||
"path": v["path"],
|
||||
"free_gb": round(free_gb, 1),
|
||||
"min_free_gb": min_free_gb,
|
||||
"roots": v["roots"],
|
||||
}
|
||||
if free_gb < min_free_gb:
|
||||
warnings.append({"kind": "low_disk", "severity": "critical", **base})
|
||||
elif free_gb < 2 * min_free_gb:
|
||||
warnings.append({"kind": "low_disk", "severity": "low", **base})
|
||||
if v["used_percent"] > VOLUME_PRESSURE_PERCENT and ({"hf_cache", "data"} & set(v["roots"])):
|
||||
warnings.append({
|
||||
"kind": "volume_pressure",
|
||||
"severity": "warning",
|
||||
"path": v["path"],
|
||||
"used_percent": v["used_percent"],
|
||||
"roots": v["roots"],
|
||||
})
|
||||
|
||||
# Order: critical first, then the rest in computed order (stable sort).
|
||||
warnings.sort(key=lambda w: 0 if w["severity"] == "critical" else 1)
|
||||
|
||||
return {
|
||||
"generated_at": time.time(),
|
||||
"min_free_gb": min_free_gb,
|
||||
"volumes": volumes,
|
||||
"categories": categories,
|
||||
"warnings": warnings,
|
||||
}
|
||||
|
||||
|
||||
# ── In-process cache (5-minute TTL, refresh bypasses) ──────────────────────
|
||||
_cache_lock = threading.Lock()
|
||||
_cache: dict = {"key": None, "ts": 0.0, "report": None}
|
||||
|
||||
|
||||
def get_report(
|
||||
*,
|
||||
data_dir: str,
|
||||
hf_cache_dir: str,
|
||||
engines_dir: str | None = None,
|
||||
app_venv: str | None = None,
|
||||
temp_root: str | None = None,
|
||||
min_free_gb: float = DEFAULT_MIN_FREE_GB,
|
||||
category_timeout: float = CATEGORY_TIMEOUT_SECONDS,
|
||||
refresh: bool = False,
|
||||
ttl: float = CACHE_TTL_SECONDS,
|
||||
) -> dict:
|
||||
"""Cached ``build_report``. ``refresh=True`` forces a rescan."""
|
||||
key = (data_dir, hf_cache_dir, engines_dir, app_venv, temp_root, min_free_gb)
|
||||
if not refresh:
|
||||
with _cache_lock:
|
||||
fresh = (
|
||||
_cache["report"] is not None
|
||||
and _cache["key"] == key
|
||||
and (time.monotonic() - _cache["ts"]) < ttl
|
||||
)
|
||||
if fresh:
|
||||
return {**_cache["report"], "cached": True}
|
||||
report = build_report(
|
||||
data_dir=data_dir,
|
||||
hf_cache_dir=hf_cache_dir,
|
||||
engines_dir=engines_dir,
|
||||
app_venv=app_venv,
|
||||
temp_root=temp_root,
|
||||
min_free_gb=min_free_gb,
|
||||
category_timeout=category_timeout,
|
||||
)
|
||||
with _cache_lock:
|
||||
_cache.update(key=key, ts=time.monotonic(), report=report)
|
||||
return {**report, "cached": False}
|
||||
|
||||
|
||||
def clear_cache() -> None:
|
||||
"""Testing hook — drop the in-process cache."""
|
||||
with _cache_lock:
|
||||
_cache.update(key=None, ts=0.0, report=None)
|
||||
@@ -132,6 +132,10 @@ class IsolatedFasterWhisperBackend(SubprocessASRBackend):
|
||||
|
||||
id = "faster-whisper-isolated"
|
||||
display_name = "Faster-Whisper (crash-isolated subprocess)"
|
||||
# Same engine as FasterWhisperBackend, so the same device support — the
|
||||
# sidecar picks cuda/cpu itself via `_device()`. Without this the registry
|
||||
# default ("cpu",) would dishonestly report cpu_only routing on CUDA hosts.
|
||||
gpu_compat = ("cuda", "cpu")
|
||||
|
||||
@classmethod
|
||||
def is_available(cls) -> tuple[bool, str]:
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""
|
||||
Deterministic polish for dictation finals (dictation v2).
|
||||
|
||||
Every ``final`` that leaves ``/ws/transcribe`` passes through
|
||||
:func:`polish_text` so pasted dictation reads like typed text:
|
||||
|
||||
* leading capital -- Latin scripts only (CJK/Cyrillic/etc. untouched),
|
||||
* terminal punctuation -- a period is appended unless the text already
|
||||
ends with sentence-terminal punctuation (incl. the CJK fullwidth forms),
|
||||
* doubled spaces collapsed, leading/trailing whitespace stripped.
|
||||
|
||||
Purely rule-based -- no model, no locale detection, no network -- so it is
|
||||
byte-for-byte reproducible and idempotent (``polish(polish(x)) == polish(x)``).
|
||||
|
||||
CJK codepoints below are ``\\u``-escaped on purpose: this is functional
|
||||
punctuation handling (allowed), and the escapes keep this file outside the
|
||||
literal-CJK scan in ``tests/test_no_hardcoded_cjk.py`` without growing its
|
||||
allowlist.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
# Sentence-terminal punctuation that already "closes" a final -- Latin plus
|
||||
# the CJK fullwidth forms (U+3002 ideographic full stop, U+FF01 !, U+FF1F ?)
|
||||
# and ellipsis. A trailing closing quote/bracket after one of these still
|
||||
# counts as terminated ("He said \"hi.\"").
|
||||
_TERMINAL = ".!?\u2026\u3002\uff01\uff1f"
|
||||
_CLOSERS = "\"'\u201d\u2019\u00bb\u203a)]}\u300d\u300f\uff09\u3011"
|
||||
|
||||
# A dangling clause separator at the very end (ASR often stops mid-breath on
|
||||
# a comma) is swapped for a stop instead of stacking ",." punctuation.
|
||||
# Latin , ; : plus the CJK forms U+3001 U+FF0C U+FF1B U+FF1A.
|
||||
_DANGLING = ",;:\u3001\uff0c\uff1b\uff1a"
|
||||
|
||||
# CJK codepoints (kana, unified ideographs, compatibility + halfwidth forms)
|
||||
# -- used to pick the fullwidth stop U+3002 over "." for CJK sentences.
|
||||
_CJK = re.compile(
|
||||
"[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uff66-\uff9f]"
|
||||
)
|
||||
|
||||
_MULTISPACE = re.compile(r"[ \t]{2,}")
|
||||
|
||||
|
||||
def _is_latin_lower(ch: str) -> bool:
|
||||
"""Lowercase letter in a Latin block (ASCII, Latin-1, Latin Extended-A/B).
|
||||
|
||||
Capitalization is meaningless (CJK) or presumptuous (Cyrillic, Greek --
|
||||
the model's casing is trusted) outside Latin scripts.
|
||||
"""
|
||||
return ch.islower() and ord(ch) <= 0x024F
|
||||
|
||||
|
||||
def polish_text(text: str) -> str:
|
||||
"""Normalise one dictation final. Empty/whitespace-only input -> ``""``."""
|
||||
if not text:
|
||||
return ""
|
||||
out = _MULTISPACE.sub(" ", text).strip()
|
||||
if not out:
|
||||
return ""
|
||||
|
||||
# Leading capital (Latin scripts only).
|
||||
if _is_latin_lower(out[0]):
|
||||
out = out[0].upper() + out[1:]
|
||||
|
||||
# Already terminated -- possibly behind a closing quote/bracket?
|
||||
body = out.rstrip(_CLOSERS)
|
||||
if body and body[-1] in _TERMINAL:
|
||||
return out
|
||||
|
||||
# Swap a dangling comma/colon for the stop instead of stacking ",.".
|
||||
if out[-1] in _DANGLING:
|
||||
out = out[:-1].rstrip()
|
||||
if not out:
|
||||
return ""
|
||||
|
||||
# Script-matched stop: fullwidth U+3002 when the sentence ends in CJK.
|
||||
out += "\u3002" if _CJK.search(out[-1]) else "."
|
||||
return out
|
||||
@@ -92,9 +92,11 @@ REGISTRY: dict[str, dict] = {
|
||||
"category": "llm",
|
||||
"needs_key": True,
|
||||
"notes": (
|
||||
"Any OpenAI-compatible endpoint: GPT-4/5 (OpenAI), Claude (via OpenRouter), "
|
||||
"Gemini (OpenAI-compat mode), DeepSeek, Qwen, Ollama, LM Studio. "
|
||||
"Set TRANSLATE_BASE_URL + TRANSLATE_API_KEY + TRANSLATE_MODEL."
|
||||
"Uses the LLM provider you configure in Settings → LLM Providers "
|
||||
"(route it via the 'Dub translation' skill in Settings → LLM Skills): "
|
||||
"GPT (OpenAI), Claude (via OpenRouter), Gemini, DeepSeek, Qwen, "
|
||||
"Ollama, LM Studio. Power-user env override: TRANSLATE_BASE_URL + "
|
||||
"TRANSLATE_API_KEY + TRANSLATE_MODEL."
|
||||
),
|
||||
},
|
||||
}
|
||||
@@ -137,17 +139,45 @@ def install_command(engine: "str | dict | None") -> str | None:
|
||||
return f"uv pip install {pkg}" if pkg else None
|
||||
|
||||
|
||||
def _llm_configured() -> tuple[bool, "str | None"]:
|
||||
"""Whether the LLM translation engine has something to call, and via what.
|
||||
|
||||
Resolution mirrors the translate-time path in dub_translate.py: the
|
||||
"dub_translation" LLM skill (per-skill override → active provider from
|
||||
Settings → LLM Providers) first, then the TRANSLATE_* env override. Lets
|
||||
the Engine dropdown say "ready via <provider>" / "needs setup" up front
|
||||
instead of a per-segment failure after the user clicks Translate.
|
||||
"""
|
||||
try:
|
||||
from services import llm_skills
|
||||
res = llm_skills.resolve_skill("dub_translation")
|
||||
if res.ready and res.provider is not None:
|
||||
return True, res.provider.display_name
|
||||
except Exception: # noqa: BLE001 — a probe must never break list_engines()
|
||||
logger.debug("dub_translation skill probe failed", exc_info=True)
|
||||
if os.environ.get("TRANSLATE_BASE_URL") or os.environ.get("TRANSLATE_API_KEY"):
|
||||
return True, "env"
|
||||
return False, None
|
||||
|
||||
|
||||
def list_engines() -> list[dict]:
|
||||
"""Return a UI-ready list with per-engine availability stamped in."""
|
||||
out = []
|
||||
for e in REGISTRY.values():
|
||||
installed, reason = _probe(e)
|
||||
out.append({
|
||||
entry = {
|
||||
**e,
|
||||
"installed": installed,
|
||||
"availability_reason": reason,
|
||||
"install_command": install_command(e),
|
||||
})
|
||||
}
|
||||
# LLM engines additionally need a provider/key — surface configured-ness
|
||||
# so the UI can distinguish "importable" from "actually ready to call".
|
||||
if e.get("category") == "llm":
|
||||
configured, via = _llm_configured()
|
||||
entry["configured"] = configured
|
||||
entry["configured_via"] = via
|
||||
out.append(entry)
|
||||
return out
|
||||
|
||||
|
||||
|
||||
@@ -59,8 +59,9 @@ _ADAPT_PROMPT = """\
|
||||
You are a cinematic dubbing writer. Rewrite the literal translation using the
|
||||
editor's critique so it sounds natural, in-character, and fits the speaker's
|
||||
time slot. Keep meaning faithful but prefer native idiom over word-for-word
|
||||
accuracy. The output MUST be written in the same target language and script
|
||||
as the literal translation — never switch language or transliterate.
|
||||
accuracy. Never introduce facts, names, or dialogue that are not present in
|
||||
the source line. The output MUST be written in the same target language and
|
||||
script as the literal translation — never switch language or transliterate.
|
||||
Reply ONLY with the adapted translation — no quotes, no headers, no code
|
||||
fences, no commentary."""
|
||||
|
||||
@@ -93,38 +94,136 @@ def _looks_like_target_script(text: str, code: str, threshold: float = 0.5) -> b
|
||||
return (inside / len(letters)) >= threshold
|
||||
|
||||
|
||||
def _llm_client():
|
||||
"""Lazy-build the OpenAI-compatible client for the ACTIVE LLM provider.
|
||||
# ── Divergence guard (shared with speech_rate's Autofit fit pass) ────────────
|
||||
# For every Latin-script target `_looks_like_target_script` passes ANY text
|
||||
# unconditionally (no `_SCRIPT_RANGES` entry), so it was the only — and for
|
||||
# es/de/fr/… a no-op — gate on the ADAPT/fit LLM output. These checks close
|
||||
# that gap for the whole class: runaway length (hallucinated dialogue,
|
||||
# refusals, commentary) and the REFLECT critique echoed back as the "line".
|
||||
|
||||
Resolves through the LLM Providers registry (Settings → LLM Providers) so a
|
||||
provider configured there actually powers Cinematic/Autofit — previously this
|
||||
only read ``TRANSLATE_*``/``OPENAI_*`` directly, so the registry-configured
|
||||
provider was ignored (the "LLM not wired" bug). The registry's ``custom``
|
||||
provider still maps ``TRANSLATE_BASE_URL``/``TRANSLATE_API_KEY``, so legacy
|
||||
env setups keep working. Returns None if no provider is configured.
|
||||
"""
|
||||
_SHORT_REF_CHARS = 20 # below this, a length *ratio* is meaningless
|
||||
_SHORT_REF_ABS_SLACK = 120 # …use an absolute cap instead: ref + this many chars
|
||||
|
||||
|
||||
def _refine_ratio_bounds() -> tuple[float, float]:
|
||||
"""Accepted ``len(candidate)/len(reference)`` window for LLM refine output.
|
||||
Anything outside is treated as divergence and the caller degrades to its
|
||||
input text. Defaults [0.4, 2.5]; env-tunable like the cinematic budget."""
|
||||
try:
|
||||
from openai import OpenAI
|
||||
except ImportError:
|
||||
logger.warning("openai package not installed — cinematic mode unavailable.")
|
||||
return None
|
||||
from services import llm_providers
|
||||
p = llm_providers.active_provider()
|
||||
if p is None:
|
||||
return None
|
||||
base_url = llm_providers.resolve_base_url(p)
|
||||
api_key = llm_providers.resolve_api_key(p)
|
||||
if not api_key:
|
||||
return None
|
||||
kw = {"api_key": api_key}
|
||||
if base_url:
|
||||
kw["base_url"] = base_url
|
||||
return OpenAI(**kw)
|
||||
lo = float(os.environ.get("OMNIVOICE_REFINE_RATIO_MIN", "0.4"))
|
||||
except ValueError:
|
||||
lo = 0.4
|
||||
try:
|
||||
hi = float(os.environ.get("OMNIVOICE_REFINE_RATIO_MAX", "2.5"))
|
||||
except ValueError:
|
||||
hi = 2.5
|
||||
return lo, hi
|
||||
|
||||
|
||||
def _norm_overlap_text(s: str) -> str:
|
||||
return " ".join(s.lower().split())
|
||||
|
||||
|
||||
def _echoes_critique(candidate: str, critique: str) -> bool:
|
||||
"""True when the "adaptation" is really the REFLECT critique leaking through.
|
||||
|
||||
Deterministic on purpose (no fuzzy matching): exact match after
|
||||
case/whitespace normalization; containment — the full critique inside the
|
||||
candidate always counts, the candidate inside the critique only when it
|
||||
covers most of it (critiques legitimately quote short phrases from the
|
||||
line); or >0.8 token-set overlap.
|
||||
"""
|
||||
c = _norm_overlap_text(candidate)
|
||||
k = _norm_overlap_text(critique)
|
||||
if not c or not k:
|
||||
return False
|
||||
if c == k:
|
||||
return True
|
||||
if k in c: # critique embedded in the output
|
||||
return True
|
||||
if c in k and len(c) >= 0.6 * len(k): # output ≈ a big chunk of the critique
|
||||
return True
|
||||
ct, kt = set(c.split()), set(k.split())
|
||||
union = ct | kt
|
||||
return bool(union) and len(ct & kt) / len(union) > 0.8
|
||||
|
||||
|
||||
def refine_output_ok(
|
||||
reference: str,
|
||||
candidate: str,
|
||||
target_lang: str,
|
||||
*,
|
||||
critique: str | None = None,
|
||||
max_ratio: float | None = None,
|
||||
) -> tuple[bool, str | None]:
|
||||
"""Sanity-check one LLM refine output against the text it was rewriting.
|
||||
|
||||
Shared by the Cinematic ADAPT step here and by ``speech_rate``'s Autofit
|
||||
fit pass (speech_rate imports this; translator never imports speech_rate,
|
||||
so there is no cycle). Returns ``(ok, reason)`` — ``reason`` is ``None``
|
||||
when ok, otherwise a short machine-readable tag for logs/error mapping.
|
||||
|
||||
Checks, in order:
|
||||
• script — candidate must look like the target language's script
|
||||
(``_looks_like_target_script``; Latin-script targets pass, as before);
|
||||
• length — ``len(candidate)/len(reference)`` must sit inside
|
||||
[``OMNIVOICE_REFINE_RATIO_MIN``, ``OMNIVOICE_REFINE_RATIO_MAX``]
|
||||
(default 0.4–2.5; ``max_ratio`` overrides the upper bound). References
|
||||
shorter than ~20 chars use an absolute cap (reference + 120 chars)
|
||||
instead — a two-word line legitimately doubles or halves;
|
||||
• critique echo — the candidate must not be the critique itself.
|
||||
"""
|
||||
cand = (candidate or "").strip()
|
||||
ref = (reference or "").strip()
|
||||
if not cand:
|
||||
return False, "empty"
|
||||
if not _looks_like_target_script(cand, target_lang):
|
||||
return False, f"wrong-script:{target_lang}"
|
||||
lo, hi = _refine_ratio_bounds()
|
||||
if max_ratio is not None:
|
||||
hi = max_ratio
|
||||
if ref:
|
||||
if len(ref) < _SHORT_REF_CHARS:
|
||||
if len(cand) > len(ref) + _SHORT_REF_ABS_SLACK:
|
||||
return False, f"length-abs:{len(cand)}>{len(ref)}+{_SHORT_REF_ABS_SLACK}"
|
||||
else:
|
||||
ratio = len(cand) / len(ref)
|
||||
if not (lo <= ratio <= hi):
|
||||
return False, f"length-ratio:{ratio:.2f}"
|
||||
if critique and _echoes_critique(cand, critique):
|
||||
return False, "critique-echo"
|
||||
return True, None
|
||||
|
||||
|
||||
# The LLM Skills registry entry this pipeline resolves through — lets the
|
||||
# user disable Cinematic/Autofit's LLM use or route it to a specific provider
|
||||
# (Settings → LLM Skills) independently of the other LLM features.
|
||||
_SKILL_ID = "cinematic_translation"
|
||||
|
||||
|
||||
def _llm_client():
|
||||
"""Lazy-build the OpenAI-compatible client for the Cinematic skill.
|
||||
|
||||
Resolves through the LLM Skills registry: per-skill provider override →
|
||||
global active provider (Settings → LLM Providers). The registry's
|
||||
``custom`` provider still maps ``TRANSLATE_BASE_URL``/``TRANSLATE_API_KEY``,
|
||||
so legacy env setups keep working. Returns None if the skill is disabled
|
||||
or no provider is configured — the callers' Fast-fallback path.
|
||||
|
||||
The registry builds the client with ``max_retries=0`` (see
|
||||
``llm_skills.resolve_skill_client``) so a 429 + long Retry-After can't make
|
||||
one call sleep+retry past the cinematic wall-clock budget from inside a
|
||||
single request. The pass-level budget (``cinematic_refine_many``) and the
|
||||
per-call timeout stay the only bounds.
|
||||
"""
|
||||
from services import llm_skills
|
||||
handle = llm_skills.resolve_skill_client(_SKILL_ID)
|
||||
return handle.client if handle is not None else None
|
||||
|
||||
|
||||
def _llm_model() -> str:
|
||||
from services import llm_providers
|
||||
p = llm_providers.active_provider()
|
||||
from services import llm_providers, llm_skills
|
||||
p = llm_skills.effective_provider(_SKILL_ID)
|
||||
if p is not None:
|
||||
return llm_providers.resolve_model(p)
|
||||
return os.environ.get("TRANSLATE_MODEL", "gpt-4o-mini")
|
||||
@@ -176,6 +275,7 @@ def _chat(client, *, system: str, user: str) -> str:
|
||||
res = client.chat.completions.create(
|
||||
model=_llm_model(),
|
||||
timeout=_llm_timeout(),
|
||||
temperature=0.2, # pinned like the Fast path — default 1.0 drifts/invents
|
||||
messages=[
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": user},
|
||||
@@ -277,21 +377,28 @@ def cinematic_refine_sync(
|
||||
}
|
||||
|
||||
final = (adapted or "").strip() or literal_text
|
||||
# Refuse adaptations that drifted off the target script (e.g. local LLM
|
||||
# rewrote a Devanagari line in Latin/German). Caller still gets the
|
||||
# critique so the UI can show what happened, but the live text falls
|
||||
# back to the literal translation rather than corrupting the dub.
|
||||
if final is not literal_text and not _looks_like_target_script(final, target_lang):
|
||||
logger.warning(
|
||||
"cinematic adapt produced wrong-script output for %s — falling back to literal",
|
||||
target_lang,
|
||||
)
|
||||
return {
|
||||
"text": literal_text,
|
||||
"literal": literal_text,
|
||||
"critique": critique,
|
||||
"error": f"adapt-wrong-script:{target_lang}",
|
||||
}
|
||||
# Refuse adaptations that diverged from the line they were rewriting:
|
||||
# wrong script (e.g. a local LLM rewrote a Devanagari line in
|
||||
# Latin/German), runaway length (hallucinated dialogue, refusals,
|
||||
# commentary — the script check alone passes ANY text for Latin-script
|
||||
# targets), or the critique echoed back as the "adaptation". Caller still
|
||||
# gets the critique so the UI can show what happened, but the live text
|
||||
# falls back to the literal translation rather than corrupting the dub.
|
||||
if final is not literal_text:
|
||||
ok, reason = refine_output_ok(literal_text, final, target_lang, critique=critique)
|
||||
if not ok:
|
||||
logger.warning(
|
||||
"cinematic adapt diverged for %s (%s) — falling back to literal",
|
||||
target_lang, reason,
|
||||
)
|
||||
wrong_script = (reason or "").startswith("wrong-script")
|
||||
return {
|
||||
"text": literal_text,
|
||||
"literal": literal_text,
|
||||
"critique": critique,
|
||||
"error": (f"adapt-wrong-script:{target_lang}" if wrong_script
|
||||
else "adapt-diverged"),
|
||||
}
|
||||
return {
|
||||
"text": final,
|
||||
"literal": literal_text,
|
||||
|
||||
@@ -55,6 +55,59 @@ def _mask_hf_tokens(value):
|
||||
return _HF_TOKEN_MASK_RE.sub(_HF_TOKEN_MASK, value)
|
||||
|
||||
|
||||
# ── HF Hub closed-client recovery (#880) ────────────────────────────────────
|
||||
#
|
||||
# huggingface_hub ≥1.x shares ONE global httpx client across every download.
|
||||
# If anything closes it mid-lifecycle, every later hub call — e.g. an engine's
|
||||
# first-use model download inside the generate path — dies with httpx's
|
||||
# "Cannot send a request, as the client has been closed". The client is
|
||||
# recoverable: ``close_session()`` drops it and the next hub call builds a
|
||||
# fresh one, so the correct handling is a single targeted retry, not a
|
||||
# user-facing failure.
|
||||
|
||||
|
||||
def _is_closed_client_error(e) -> bool:
|
||||
"""True iff ``e`` (or anything in its __cause__/__context__ chain) is
|
||||
httpx's closed-client lifecycle error. Cycle-safe."""
|
||||
seen, stack = set(), [e]
|
||||
while stack:
|
||||
exc = stack.pop()
|
||||
if exc is None or id(exc) in seen:
|
||||
continue
|
||||
seen.add(id(exc))
|
||||
low = str(exc).lower()
|
||||
if "client has been closed" in low or "cannot send a request" in low:
|
||||
return True
|
||||
stack.append(exc.__cause__)
|
||||
stack.append(exc.__context__)
|
||||
return False
|
||||
|
||||
|
||||
def _retry_once_with_fresh_hf_client(loader, what: str):
|
||||
"""Run ``loader()`` — a model constructor that may download from the HF
|
||||
Hub on first use. On the specific closed-client failure above, reset the
|
||||
hub's shared client and retry exactly ONCE. Any other failure (and a
|
||||
repeat closed-client failure) propagates untouched, where the generation
|
||||
error classifier labels it as a network problem (#880)."""
|
||||
try:
|
||||
return loader()
|
||||
except Exception as e:
|
||||
if not _is_closed_client_error(e):
|
||||
raise
|
||||
logger.warning(
|
||||
"%s: HF Hub httpx client was closed mid-download (%s); "
|
||||
"retrying once with a fresh client.", what, e,
|
||||
)
|
||||
try:
|
||||
from huggingface_hub.utils import close_session
|
||||
close_session()
|
||||
except Exception: # pragma: no cover — hub too old / API renamed
|
||||
logger.warning(
|
||||
"%s: couldn't reset the HF Hub client; retrying anyway.", what,
|
||||
)
|
||||
return loader()
|
||||
|
||||
|
||||
# ── Protocol ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -587,7 +640,13 @@ class KittenTTSBackend(TTSBackend):
|
||||
"OMNIVOICE_KITTENTTS_MODEL", "KittenML/kitten-tts-mini-0.8"
|
||||
)
|
||||
logger.info("Loading KittenTTS from %s", checkpoint)
|
||||
self._model = KittenTTS(checkpoint)
|
||||
# #880: the first-use load downloads ~80 MB from the HF Hub inside the
|
||||
# generate path; if the hub's shared httpx client was closed
|
||||
# mid-lifecycle, retry once with a fresh client instead of failing
|
||||
# the whole generation.
|
||||
self._model = _retry_once_with_fresh_hf_client(
|
||||
lambda: KittenTTS(checkpoint), what="KittenTTS"
|
||||
)
|
||||
|
||||
def generate(self, text: str, **kw) -> torch.Tensor:
|
||||
import numpy as np
|
||||
@@ -1061,13 +1120,34 @@ class SherpaOnnxBackend(TTSBackend):
|
||||
def is_available(cls) -> tuple[bool, str]:
|
||||
try:
|
||||
import sherpa_onnx # noqa: F401
|
||||
return True, "ready"
|
||||
except ImportError as e:
|
||||
return False, (
|
||||
f"sherpa-onnx not installed: {e}. "
|
||||
"Install with: pip install sherpa-onnx. "
|
||||
"Download models from https://github.com/k2-fsa/sherpa-onnx/releases"
|
||||
)
|
||||
# #919: sherpa-onnx ships no bundled default model — it can only
|
||||
# synthesize once OMNIVOICE_SHERPA_MODEL points at a downloaded model
|
||||
# directory. Gate on it here (like the other path-configured opt-in
|
||||
# engines: Confucius4/dots/MOSS) so the picker marks it unavailable-
|
||||
# with-a-reason instead of letting a user select it, generate, and hit
|
||||
# a config error that used to be mislabeled as out-of-memory.
|
||||
model_dir = os.environ.get("OMNIVOICE_SHERPA_MODEL", "").strip()
|
||||
if not model_dir:
|
||||
return False, (
|
||||
"OMNIVOICE_SHERPA_MODEL not set. Point it to a sherpa-onnx TTS "
|
||||
"model directory (containing model.onnx + tokens.txt), then "
|
||||
"restart OmniVoice. Download models from "
|
||||
"https://github.com/k2-fsa/sherpa-onnx/releases"
|
||||
)
|
||||
if not os.path.isfile(os.path.join(model_dir, "model.onnx")):
|
||||
return False, (
|
||||
f"No model.onnx in OMNIVOICE_SHERPA_MODEL ({model_dir}). Point "
|
||||
"it at a sherpa-onnx TTS model directory containing model.onnx "
|
||||
"+ tokens.txt. Download models from "
|
||||
"https://github.com/k2-fsa/sherpa-onnx/releases"
|
||||
)
|
||||
return True, "ready"
|
||||
|
||||
@property
|
||||
def sample_rate(self) -> int:
|
||||
@@ -1200,7 +1280,13 @@ class _LazyRegistry(dict):
|
||||
# effect on every list_backends() call — we keep iteration light
|
||||
# and let the caller's __getitem__ trigger the import.
|
||||
seen: set[str] = set()
|
||||
for k in dict.__iter__(self):
|
||||
# Snapshot the live keys before yielding. A concurrent thread's lazy
|
||||
# __getitem__ inserts into self (self[key] = cls), and list_backends()
|
||||
# runs in a FastAPI threadpool — so holding a *live* dict iterator open
|
||||
# across the per-engine is_available() probes would raise
|
||||
# "dictionary changed size during iteration". list() consumes the
|
||||
# iterator atomically under the GIL, closing that window.
|
||||
for k in list(dict.__iter__(self)):
|
||||
seen.add(k)
|
||||
yield k
|
||||
for k in _LAZY_REGISTRY:
|
||||
@@ -1263,6 +1349,23 @@ _INSTALL_HINTS: dict[str, str] = {
|
||||
}
|
||||
|
||||
|
||||
# Copy-paste-ready setup line for opt-in engines gated behind a filesystem-path
|
||||
# env var (issue #498 / #590). The install_hint tells users a var exists; this
|
||||
# is the *exact* `export VAR=...` line to run, so they don't have to reconstruct
|
||||
# it from the docs. Surfaced verbatim in the Compat Matrix's "Why unavailable?"
|
||||
# disclosure with a Copy button. Single-sourced here so it can't drift from the
|
||||
# var each engine's is_available() actually reads. bash/zsh form (the dominant
|
||||
# clone-and-run workflow for these engines; dots.tts is *nix-only anyway).
|
||||
_SETUP_SNIPPETS: dict[str, str] = {
|
||||
"indextts2": "export OMNIVOICE_INDEXTTS_DIR=/path/to/index-tts",
|
||||
"moss-tts-v15": "export OMNIVOICE_MOSS_TTS_V15_DIR=/path/to/MOSS-TTS",
|
||||
"dots-tts": "export OMNIVOICE_DOTS_TTS_DIR=/path/to/dots.tts",
|
||||
"confucius4-tts": "export OMNIVOICE_CONFUCIUS4_TTS_DIR=/path/to/Confucius4-TTS",
|
||||
# #919: sherpa-onnx gates on a downloaded model dir (model.onnx + tokens.txt).
|
||||
"sherpa-onnx": "export OMNIVOICE_SHERPA_MODEL=/path/to/sherpa-onnx-model",
|
||||
}
|
||||
|
||||
|
||||
def list_backends() -> list[dict]:
|
||||
"""Enumerate every registered backend with its availability state.
|
||||
|
||||
@@ -1274,6 +1377,7 @@ def list_backends() -> list[dict]:
|
||||
"available": bool,
|
||||
"reason": Optional[str], # message when not available
|
||||
"install_hint": Optional[str],
|
||||
"setup_snippet": Optional[str], # exact `export VAR=...` for path-gated opt-in engines
|
||||
"last_error": Optional[str], # cached most-recent failure
|
||||
"isolation_mode": "in-process" | "subprocess",
|
||||
"gpu_compat": list[str], # subset of {cuda, rocm, mps, xpu, cpu}
|
||||
@@ -1337,6 +1441,8 @@ def list_backends() -> list[dict]:
|
||||
"available": ok,
|
||||
"reason": None if ok else _mask_hf_tokens(msg),
|
||||
"install_hint": _INSTALL_HINTS.get(bid),
|
||||
# Exact `export VAR=...` line for path-gated opt-in engines, or None.
|
||||
"setup_snippet": _SETUP_SNIPPETS.get(bid),
|
||||
"last_error": _LAST_ERRORS.get(bid),
|
||||
"isolation_mode": isolation,
|
||||
"gpu_compat": list(gpu_compat),
|
||||
|
||||
@@ -17,14 +17,25 @@ import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
||||
|
||||
from services import asr_backend # noqa: E402
|
||||
from services.asr_backend import ( # noqa: E402
|
||||
ASRTimeoutError,
|
||||
ASR_TRANSCRIBE_TIMEOUT_S,
|
||||
reset_pool_after_wedge,
|
||||
run_transcribe_guarded,
|
||||
)
|
||||
from concurrent.futures import ThreadPoolExecutor # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fresh_timeout_streak(monkeypatch):
|
||||
"""The consecutive-timeout streak (#730 residual B) is process-global
|
||||
session state; zero it per test so ordering can't leak recommendations,
|
||||
and pin the active engine so a dev box's prefs can't flip the hint."""
|
||||
monkeypatch.setattr(asr_backend, "_timeout_streak", 0)
|
||||
monkeypatch.setattr(asr_backend, "active_backend_id", lambda: "whisperx")
|
||||
|
||||
|
||||
def test_default_timeout_is_env_overridable(monkeypatch):
|
||||
# The constant is read at import; just assert it's a sane positive default.
|
||||
assert ASR_TRANSCRIBE_TIMEOUT_S > 0
|
||||
@@ -113,3 +124,115 @@ def test_timeout_without_reset_capable_pool_does_not_crash():
|
||||
|
||||
asyncio.run(_go())
|
||||
pool.shutdown(wait=False)
|
||||
|
||||
|
||||
# ── Residual B on #730: consecutive timeouts recommend the isolated engine ──
|
||||
|
||||
|
||||
def _hang_forever():
|
||||
time.sleep(5)
|
||||
return "never"
|
||||
|
||||
|
||||
async def _timeout_once(pool, timeout=0.1) -> str:
|
||||
with pytest.raises(ASRTimeoutError) as ei:
|
||||
await run_transcribe_guarded(pool, _hang_forever, what="Dub", timeout=timeout)
|
||||
return str(ei.value)
|
||||
|
||||
|
||||
def test_second_consecutive_timeout_recommends_isolated_engine():
|
||||
"""When guarded timeouts hit twice in a row in one session, pool resets
|
||||
clearly aren't recovering the hang — the error the user sees must name the
|
||||
crash-isolated escape-hatch engine (and make clear we never auto-switch)."""
|
||||
pool = ThreadPoolExecutor(max_workers=2)
|
||||
|
||||
async def _go():
|
||||
first = await _timeout_once(pool)
|
||||
assert "faster-whisper-isolated" not in first # one timeout ≠ a pattern
|
||||
second = await _timeout_once(pool)
|
||||
assert "faster-whisper-isolated" in second
|
||||
assert "Settings → Engines" in second
|
||||
assert "never switches engines automatically" in second
|
||||
|
||||
asyncio.run(_go())
|
||||
pool.shutdown(wait=False)
|
||||
|
||||
|
||||
def test_successful_transcribe_resets_the_timeout_streak():
|
||||
"""'Consecutive' must mean consecutive: a transcribe that completes between
|
||||
two timeouts proves the pool recovered, so the recommendation must not fire."""
|
||||
pool = ThreadPoolExecutor(max_workers=3)
|
||||
|
||||
async def _go():
|
||||
await _timeout_once(pool)
|
||||
out = await run_transcribe_guarded(pool, lambda: "ok", what="Dub", timeout=5.0)
|
||||
assert out == "ok"
|
||||
second = await _timeout_once(pool)
|
||||
assert "faster-whisper-isolated" not in second
|
||||
|
||||
asyncio.run(_go())
|
||||
pool.shutdown(wait=False)
|
||||
|
||||
|
||||
def test_no_recommendation_when_already_on_isolated_engine(monkeypatch):
|
||||
"""Recommending the isolated engine to a user already running it is noise —
|
||||
the base message's smaller-model/CPU guidance is all that's left."""
|
||||
monkeypatch.setattr(
|
||||
asr_backend, "active_backend_id", lambda: "faster-whisper-isolated"
|
||||
)
|
||||
pool = ThreadPoolExecutor(max_workers=2)
|
||||
|
||||
async def _go():
|
||||
await _timeout_once(pool)
|
||||
second = await _timeout_once(pool)
|
||||
assert "faster-whisper-isolated) in Settings" not in second
|
||||
assert "never switches engines automatically" not in second
|
||||
|
||||
asyncio.run(_go())
|
||||
pool.shutdown(wait=False)
|
||||
|
||||
|
||||
def test_timeout_env_name_is_parameterized():
|
||||
"""The chunked dub path passes its own knob; the message must name IT, not
|
||||
the whole-file env var (actionable errors point at the right dial)."""
|
||||
pool = ThreadPoolExecutor(max_workers=1)
|
||||
|
||||
async def _go():
|
||||
with pytest.raises(ASRTimeoutError) as ei:
|
||||
await run_transcribe_guarded(
|
||||
pool, _hang_forever, what="Dub chunk 1/3", timeout=0.1,
|
||||
timeout_env="OMNIVOICE_TRANSCRIBE_CHUNK_TIMEOUT_S",
|
||||
)
|
||||
msg = str(ei.value)
|
||||
assert "OMNIVOICE_TRANSCRIBE_CHUNK_TIMEOUT_S" in msg
|
||||
assert "OMNIVOICE_ASR_TRANSCRIBE_TIMEOUT_S" not in msg
|
||||
|
||||
asyncio.run(_go())
|
||||
pool.shutdown(wait=False)
|
||||
|
||||
|
||||
def test_reset_pool_after_wedge_is_shared_and_best_effort():
|
||||
"""One reset mechanism for every transcribe path (#730 residual A): it
|
||||
resets a reset-capable pool, no-ops a plain executor, and never raises."""
|
||||
|
||||
class _Pool:
|
||||
resets = 0
|
||||
|
||||
def reset(self):
|
||||
self.resets += 1
|
||||
|
||||
p = _Pool()
|
||||
assert reset_pool_after_wedge(p, what="Dub chunk 1/2") is True
|
||||
assert p.resets == 1
|
||||
|
||||
plain = ThreadPoolExecutor(max_workers=1)
|
||||
try:
|
||||
assert reset_pool_after_wedge(plain) is False
|
||||
finally:
|
||||
plain.shutdown(wait=False)
|
||||
|
||||
class _Broken:
|
||||
def reset(self):
|
||||
raise RuntimeError("reset blew up")
|
||||
|
||||
assert reset_pool_after_wedge(_Broken()) is False # must not raise
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
},
|
||||
"frontend": {
|
||||
"name": "omnivoice-studio",
|
||||
"version": "0.3.8",
|
||||
"version": "0.3.10",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@fontsource-variable/source-serif-4": "^5.2.9",
|
||||
@@ -31,6 +31,7 @@
|
||||
"@radix-ui/react-toggle": "^1.1.12",
|
||||
"@radix-ui/react-toggle-group": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.10",
|
||||
"@scalar/api-reference-react": "^0.9.52",
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
"@tanstack/react-query": "^5.101.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
@@ -84,6 +85,14 @@
|
||||
"packages": {
|
||||
"@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="],
|
||||
|
||||
"@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.13", "", { "dependencies": { "@ai-sdk/provider": "3.0.2", "@ai-sdk/provider-utils": "4.0.5", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-g7nE4PFtngOZNZSy1lOPpkC+FAiHxqBJXqyRMEG7NUrEVZlz5goBdtHg1YgWRJIX776JTXAmbOI5JreAKVAsVA=="],
|
||||
|
||||
"@ai-sdk/provider": ["@ai-sdk/provider@3.0.2", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-HrEmNt/BH/hkQ7zpi2o6N3k1ZR1QTb7z85WYhYygiTxOQuaml4CMtHCWRbric5WPU+RNsYI7r1EpyVQMKO1pYw=="],
|
||||
|
||||
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.5", "", { "dependencies": { "@ai-sdk/provider": "3.0.2", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Ow/X/SEkeExTTc1x+nYLB9ZHK2WUId8+9TlkamAx7Tl9vxU+cKzWx2dwjgMHeCN6twrgwkLrrtqckQeO4mxgVA=="],
|
||||
|
||||
"@ai-sdk/vue": ["@ai-sdk/vue@3.0.33", "", { "dependencies": { "@ai-sdk/provider-utils": "4.0.5", "ai": "6.0.33", "swrv": "^1.0.4" }, "peerDependencies": { "vue": "^3.3.4" } }, "sha512-czM9Js3a7f+Eo35gjEYEeJYUoPvMg5Dfi4bOLyDBghLqn0gaVg8yTmTaSuHCg+3K/+1xPjyXd4+2XcQIohWWiQ=="],
|
||||
|
||||
"@antfu/ni": ["@antfu/ni@30.2.0", "", { "dependencies": { "fzf": "^0.5.2", "package-manager-detector": "^1.6.0", "tinyexec": "^1.2.4", "tinyglobby": "^0.2.17" }, "bin": { "ni": "bin/ni.mjs", "nci": "bin/nci.mjs", "nr": "bin/nr.mjs", "nup": "bin/nup.mjs", "nd": "bin/nd.mjs", "nlx": "bin/nlx.mjs", "na": "bin/na.mjs", "nun": "bin/nun.mjs" } }, "sha512-/FOdAP1w8COnANVD3TtNj/tnpt/36RkU/ysKZTqx86x9acdhCqTFjDXNYVDyBg6UzcrTwWPUeY75ng7CWLNr+g=="],
|
||||
|
||||
"@asamuzakjp/css-color": ["@asamuzakjp/css-color@5.1.11", "", { "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@csstools/css-calc": "^3.2.0", "@csstools/css-color-parser": "^4.1.0", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg=="],
|
||||
@@ -130,6 +139,30 @@
|
||||
|
||||
"@bramus/specificity": ["@bramus/specificity@2.4.2", "", { "dependencies": { "css-tree": "^3.0.0" }, "bin": { "specificity": "bin/cli.js" } }, "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw=="],
|
||||
|
||||
"@codemirror/autocomplete": ["@codemirror/autocomplete@6.20.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g=="],
|
||||
|
||||
"@codemirror/commands": ["@codemirror/commands@6.10.4", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.7.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg=="],
|
||||
|
||||
"@codemirror/lang-css": ["@codemirror/lang-css@6.3.1", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.0.2", "@lezer/css": "^1.1.7" } }, "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg=="],
|
||||
|
||||
"@codemirror/lang-html": ["@codemirror/lang-html@6.4.11", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/lang-css": "^6.0.0", "@codemirror/lang-javascript": "^6.0.0", "@codemirror/language": "^6.4.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/css": "^1.1.0", "@lezer/html": "^1.3.12" } }, "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw=="],
|
||||
|
||||
"@codemirror/lang-javascript": ["@codemirror/lang-javascript@6.2.5", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.6.0", "@codemirror/lint": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/javascript": "^1.0.0" } }, "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A=="],
|
||||
|
||||
"@codemirror/lang-json": ["@codemirror/lang-json@6.0.2", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/json": "^1.0.0" } }, "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ=="],
|
||||
|
||||
"@codemirror/lang-xml": ["@codemirror/lang-xml@6.1.0", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.4.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/common": "^1.0.0", "@lezer/xml": "^1.0.0" } }, "sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg=="],
|
||||
|
||||
"@codemirror/lang-yaml": ["@codemirror/lang-yaml@6.1.3", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.2.0", "@lezer/lr": "^1.0.0", "@lezer/yaml": "^1.0.0" } }, "sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ=="],
|
||||
|
||||
"@codemirror/language": ["@codemirror/language@6.12.4", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A=="],
|
||||
|
||||
"@codemirror/lint": ["@codemirror/lint@6.9.7", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.42.0", "crelt": "^1.0.5" } }, "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg=="],
|
||||
|
||||
"@codemirror/state": ["@codemirror/state@6.7.0", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-Zbl9NyscLMZkfXPQnNAIIAFftidrA1UbcJEIMp24C0Bukc2I5T8wJS0wsXYsnDOqCFJUeJ1BITGNs5CqPDSmSg=="],
|
||||
|
||||
"@codemirror/view": ["@codemirror/view@6.43.4", "", { "dependencies": { "@codemirror/state": "^6.7.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-YImu23iyKfncJzT7sRy+rEqEhSc8RhOHqDxwy4WzXRKJwYm6iwf/9OJk5ctCAdZ6yi2ZqaGEvmf55fSVqMDrgg=="],
|
||||
|
||||
"@csstools/color-helpers": ["@csstools/color-helpers@6.0.2", "", {}, "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q=="],
|
||||
|
||||
"@csstools/css-calc": ["@csstools/css-calc@3.2.0", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w=="],
|
||||
@@ -170,7 +203,9 @@
|
||||
|
||||
"@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.8", "", { "dependencies": { "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A=="],
|
||||
|
||||
"@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
|
||||
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
|
||||
|
||||
"@floating-ui/vue": ["@floating-ui/vue@1.1.9", "", { "dependencies": { "@floating-ui/dom": "^1.7.4", "@floating-ui/utils": "^0.2.10", "vue-demi": ">=0.13.0" } }, "sha512-BfNqNW6KA83Nexspgb9DZuz578R7HT8MZw1CfK9I6Ah4QReNWEJsXWHN+SdmOVLNGmTPDi+fDT535Df5PzMLbQ=="],
|
||||
|
||||
"@fontsource-variable/inter": ["@fontsource-variable/inter@5.2.8", "", {}, "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ=="],
|
||||
|
||||
@@ -190,6 +225,10 @@
|
||||
|
||||
"@hapi/topo": ["@hapi/topo@6.0.2", "", { "dependencies": { "@hapi/hoek": "^11.0.2" } }, "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg=="],
|
||||
|
||||
"@headlessui/tailwindcss": ["@headlessui/tailwindcss@0.2.2", "", { "peerDependencies": { "tailwindcss": "^3.0 || ^4.0" } }, "sha512-xNe42KjdyA4kfUKLLPGzME9zkH7Q3rOZ5huFihWNWOQFxnItxPB3/67yBI8/qBfY8nwBRx5GHn4VprsoluVMGw=="],
|
||||
|
||||
"@headlessui/vue": ["@headlessui/vue@1.7.23", "", { "dependencies": { "@tanstack/vue-virtual": "^3.0.0-beta.60" }, "peerDependencies": { "vue": "^3.2.0" } }, "sha512-JzdCNqurrtuu0YW6QaDtR2PIYCKPUWq28csDyMvN4zmGccmE7lz40Is6hc3LA4HFeCI7sekZ/PQMTNmn9I/4Wg=="],
|
||||
|
||||
"@henrygd/queue": ["@henrygd/queue@1.2.0", "", {}, "sha512-jW/BLSTpcvExDhqJGxtIPgGr2O0IFF8XUNDwEbfCfhrXT8a4xztQ9Lv6U/vbYzYC0xVWn+3zv6YnLUh3bEFUKA=="],
|
||||
|
||||
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
|
||||
@@ -200,6 +239,10 @@
|
||||
|
||||
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
|
||||
|
||||
"@internationalized/date": ["@internationalized/date@3.12.2", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw=="],
|
||||
|
||||
"@internationalized/number": ["@internationalized/number@3.6.7", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-3ji1fcrT+FPAK86UqEhB/psHixYo6niWPJtt7+qRaYFynt/BaJG8GhAPimtWUpEiVSTq8ZM8L5psMxGquiB/Vg=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
@@ -210,8 +253,30 @@
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@lezer/common": ["@lezer/common@1.5.2", "", {}, "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ=="],
|
||||
|
||||
"@lezer/css": ["@lezer/css@1.3.4", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.0" } }, "sha512-N+tn9tej2hPvyKgHEApMOQfHczDJCwxrRFS3SPn9QjYN+uwHvEDnCgKRrb3mxDYxRS8sKMM8fhC3+lc04Abz5Q=="],
|
||||
|
||||
"@lezer/highlight": ["@lezer/highlight@1.2.3", "", { "dependencies": { "@lezer/common": "^1.3.0" } }, "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g=="],
|
||||
|
||||
"@lezer/html": ["@lezer/html@1.3.13", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg=="],
|
||||
|
||||
"@lezer/javascript": ["@lezer/javascript@1.5.4", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.1.3", "@lezer/lr": "^1.3.0" } }, "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA=="],
|
||||
|
||||
"@lezer/json": ["@lezer/json@1.0.3", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ=="],
|
||||
|
||||
"@lezer/lr": ["@lezer/lr@1.4.10", "", { "dependencies": { "@lezer/common": "^1.0.0" } }, "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A=="],
|
||||
|
||||
"@lezer/xml": ["@lezer/xml@1.0.6", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww=="],
|
||||
|
||||
"@lezer/yaml": ["@lezer/yaml@1.0.4", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.4.0" } }, "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw=="],
|
||||
|
||||
"@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.3", "", {}, "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA=="],
|
||||
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="],
|
||||
|
||||
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
||||
|
||||
"@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.137.0", "", { "os": "android", "cpu": "arm" }, "sha512-KDs+0VPdEmasOkpuJHW9V5WCF+cvYdMQv2Jd+aJXt+cxIx12NToRQRbXaRwUEDsZw+/jMk81Ve8ZFbjUkJTOwA=="],
|
||||
|
||||
"@oxc-parser/binding-android-arm64": ["@oxc-parser/binding-android-arm64@0.137.0", "", { "os": "android", "cpu": "arm64" }, "sha512-WhALNzfy3x/RfC6bsqX+csavuUY0yHHE7XfgPE5M542uhoBZUUoGTPG+nkMbGoG4+gcfss5s7urMyn5QBHu0sw=="],
|
||||
@@ -368,6 +433,8 @@
|
||||
|
||||
"@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.71.0", "", { "os": "win32", "cpu": "x64" }, "sha512-D2kyEIPHk/G/wiZLnwTVC/sVst+T/lKldVOjAFpgTIBUAOlry72e5OiapDbDBF4LfJLkN5ypJb/8Eu6yJzkveQ=="],
|
||||
|
||||
"@phosphor-icons/core": ["@phosphor-icons/core@2.1.1", "", {}, "sha512-v4ARvrip4qBCImOE5rmPUylOEK4iiED9ZyKjcvzuezqMaiRASCHKcRIuvvxL/twvLpkfnEODCOJp5dM4eZilxQ=="],
|
||||
|
||||
"@playwright/test": ["@playwright/test@1.61.0", "", { "dependencies": { "playwright": "1.61.0" }, "bin": { "playwright": "cli.js" } }, "sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA=="],
|
||||
|
||||
"@quansync/fs": ["@quansync/fs@1.0.0", "", { "dependencies": { "quansync": "^1.0.0" } }, "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ=="],
|
||||
@@ -446,6 +513,8 @@
|
||||
|
||||
"@radix-ui/rect": ["@radix-ui/rect@1.1.2", "", {}, "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA=="],
|
||||
|
||||
"@replit/codemirror-css-color-picker": ["@replit/codemirror-css-color-picker@6.3.0", "", { "peerDependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0" } }, "sha512-19biDANghUm7Fz7L1SNMIhK48tagaWuCOHj4oPPxc7hxPGkTVY2lU/jVZ8tsbTKQPVG7BO2CBDzs7CBwb20t4A=="],
|
||||
|
||||
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.3", "", { "os": "android", "cpu": "arm64" }, "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw=="],
|
||||
|
||||
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA=="],
|
||||
@@ -478,12 +547,64 @@
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
|
||||
|
||||
"@scalar/agent-chat": ["@scalar/agent-chat@0.12.16", "", { "dependencies": { "@ai-sdk/vue": "3.0.33", "@scalar/api-client": "3.13.1", "@scalar/components": "0.27.4", "@scalar/helpers": "0.9.0", "@scalar/icons": "0.7.3", "@scalar/json-magic": "0.12.17", "@scalar/openapi-types": "0.9.1", "@scalar/schemas": "0.7.1", "@scalar/themes": "0.16.2", "@scalar/types": "0.16.1", "@scalar/use-toasts": "0.10.2", "@scalar/validation": "0.6.0", "@scalar/workspace-store": "0.55.2", "@vueuse/core": "13.9.0", "ai": "6.0.33", "js-base64": "^3.7.8", "neverpanic": "0.0.8", "truncate-json": "3.0.1", "vue": "^3.5.30" } }, "sha512-YETdit7xhWpqdu4PMDM6AVQXID6GtJ1MSS0pmdyUFpR1xG02ABFWPsZHJ8+LQvFQXTC9ZOowtjgP8G9gTKA+KQ=="],
|
||||
|
||||
"@scalar/api-client": ["@scalar/api-client@3.13.1", "", { "dependencies": { "@headlessui/tailwindcss": "^0.2.2", "@headlessui/vue": "1.7.23", "@scalar/blocks": "0.1.2", "@scalar/components": "0.27.4", "@scalar/helpers": "0.9.0", "@scalar/icons": "0.7.3", "@scalar/oas-utils": "0.19.3", "@scalar/openapi-types": "0.9.1", "@scalar/sidebar": "0.9.27", "@scalar/snippetz": "0.9.20", "@scalar/themes": "0.16.2", "@scalar/typebox": "^0.1.3", "@scalar/types": "0.16.1", "@scalar/use-codemirror": "0.14.12", "@scalar/use-hooks": "0.4.7", "@scalar/use-toasts": "0.10.2", "@scalar/workspace-store": "0.55.2", "@vueuse/core": "13.9.0", "@vueuse/integrations": "13.9.0", "focus-trap": "^7.8.0", "fuse.js": "^7.1.0", "js-base64": "^3.7.8", "jsonc-parser": "3.3.1", "nanoid": "^5.1.6", "pretty-ms": "^9.3.0", "radix-vue": "^1.9.17", "set-cookie-parser": "3.1.0", "vue": "^3.5.30", "yaml": "^2.8.3", "zod": "^4.3.5" } }, "sha512-+UWLBGY2dFFrbCYtEBHpjezZvSg/7eJ+CZs3oWo3Rr6JmVIJ/CKN31Ev5KAfce0w5Xmf7vbaWdExZheW+3ff7Q=="],
|
||||
|
||||
"@scalar/api-reference": ["@scalar/api-reference@1.62.3", "", { "dependencies": { "@headlessui/vue": "1.7.23", "@scalar/agent-chat": "0.12.16", "@scalar/api-client": "3.13.1", "@scalar/blocks": "0.1.2", "@scalar/code-highlight": "0.4.0", "@scalar/components": "0.27.4", "@scalar/helpers": "0.9.0", "@scalar/icons": "0.7.3", "@scalar/oas-utils": "0.19.3", "@scalar/schemas": "0.7.1", "@scalar/sidebar": "0.9.27", "@scalar/snippetz": "0.9.20", "@scalar/themes": "0.16.2", "@scalar/types": "0.16.1", "@scalar/use-hooks": "0.4.7", "@scalar/use-toasts": "0.10.2", "@scalar/validation": "0.6.0", "@scalar/workspace-store": "0.55.2", "@unhead/vue": "^2.1.4", "@vueuse/core": "13.9.0", "fuse.js": "^7.1.0", "microdiff": "^1.5.0", "nanoid": "^5.1.6", "vue": "^3.5.30", "yaml": "^2.8.3" } }, "sha512-0Q4NGSVK34tR/YMeQGxDD+v2NfH87/vDPxaBXxSyxfjg3lx/pmidt+YW2vcpxpoQdaPOVVwm8iuzcg0cxFjkCg=="],
|
||||
|
||||
"@scalar/api-reference-react": ["@scalar/api-reference-react@0.9.52", "", { "dependencies": { "@scalar/api-reference": "1.62.3", "@scalar/types": "0.16.1" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-+BkephxdTM7VFH9U/joHjOHvKadfOB9sxTCrYnWoURevz6LkrvnAGKvoALbAZBS4jbvbHabfpu6uL3sqDnV1EQ=="],
|
||||
|
||||
"@scalar/asyncapi-upgrader": ["@scalar/asyncapi-upgrader@0.1.2", "", { "dependencies": { "@scalar/helpers": "0.9.0" } }, "sha512-h6NUhsctrhucrbO2XHWbTXp9EWt7eL5vvlsooUEw2bB014KSPd41JrBQ6t0h/dFdmhwxdbBI7Hmg9eZa/mlCXA=="],
|
||||
|
||||
"@scalar/blocks": ["@scalar/blocks@0.1.2", "", { "dependencies": { "@scalar/components": "0.27.4", "@scalar/helpers": "0.9.0", "@scalar/icons": "0.7.3", "@scalar/snippetz": "0.9.20", "@scalar/themes": "0.16.2", "@scalar/types": "0.16.1", "@scalar/workspace-store": "0.55.2", "@types/har-format": "^1.2.16", "js-base64": "^3.7.8", "vue": "^3.5.30" } }, "sha512-vJQFlEckV4NTn5x95Q1Ff5HeeHwAqaX/8MLOX3O63F/XFXotIAp+MS8BQG2jnlVxzQn5omRSCNug2Y5J5ETZEw=="],
|
||||
|
||||
"@scalar/code-highlight": ["@scalar/code-highlight@0.4.0", "", { "dependencies": { "hast-util-to-text": "^4.0.2", "highlight.js": "^11.11.1", "lowlight": "^3.3.0", "rehype-external-links": "^3.0.0", "rehype-format": "^5.0.1", "rehype-parse": "^9.0.1", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-stringify": "^11.0.0", "unified": "^11.0.5", "unist-util-visit": "^5.1.0" } }, "sha512-ajUQ9oq5MwVHrXGze0SZVatgSXP/WbN4qgE0usYDFt1XuJ+O56TaBsCWb043r3Y9ZnHSnK9GHreVaLOxXsbf1A=="],
|
||||
|
||||
"@scalar/components": ["@scalar/components@0.27.4", "", { "dependencies": { "@floating-ui/utils": "0.2.10", "@floating-ui/vue": "1.1.9", "@headlessui/tailwindcss": "^0.2.2", "@headlessui/vue": "1.7.23", "@scalar/code-highlight": "0.4.0", "@scalar/helpers": "0.9.0", "@scalar/icons": "0.7.3", "@scalar/themes": "0.16.2", "@scalar/use-hooks": "0.4.7", "@vueuse/core": "13.9.0", "cva": "1.0.0-beta.4", "radix-vue": "^1.9.17", "vue": "^3.5.30", "vue-component-type-helpers": "^3.2.6" } }, "sha512-1sbsMYGJcWiQ+5GouI1hHCn1VrytHPhzhHhCAZTXxi1Tw4SzsPSQuACARehXca+w2p7RK9ayGRYQ+K5bX18SaQ=="],
|
||||
|
||||
"@scalar/helpers": ["@scalar/helpers@0.9.0", "", {}, "sha512-M34CLRCttqC1bXthI/QSzQj0s5C6nrU2PFWf/vOT3RpycbiGDGQbqR+5RfFzpOIQvRqbHfNdcRbeiZBw+vCbkQ=="],
|
||||
|
||||
"@scalar/icons": ["@scalar/icons@0.7.3", "", { "dependencies": { "@phosphor-icons/core": "^2.1.1", "@types/node": "^24.1.0", "chalk": "^5.6.2", "vue": "^3.5.30" } }, "sha512-5uSUvumj6yJEAZT7/MKpgrkNl76waDXUpu0kUBBFJ83GhZirBIK+Z9SktShEvJT0+rk/j9Zaer3BHoEhYwAEBQ=="],
|
||||
|
||||
"@scalar/json-magic": ["@scalar/json-magic@0.12.17", "", { "dependencies": { "@scalar/helpers": "0.9.0", "pathe": "^2.0.3", "yaml": "^2.8.3" } }, "sha512-Vw2nrUDIjhvMP6vxFtkiiFlabJ6SyTtfn1BsOxgnr1hIB+/rkngMguiDzl5em21VjyfFGIoADia+QWKM2hdcdA=="],
|
||||
|
||||
"@scalar/oas-utils": ["@scalar/oas-utils@0.19.3", "", { "dependencies": { "@scalar/helpers": "0.9.0", "@scalar/themes": "0.16.2", "@scalar/types": "0.16.1", "@scalar/workspace-store": "0.55.2", "flatted": "^3.4.0", "vue": "^3.5.30", "yaml": "^2.8.3" } }, "sha512-+h/vLMfGj/mpr5FYgDLFIc+X75sYH2rc2MJNSvFTmmjMeerJDVINEYs+aYOx1Mn9Z3HnoP+qQohoWTJpg2gh7A=="],
|
||||
|
||||
"@scalar/openapi-types": ["@scalar/openapi-types@0.9.1", "", {}, "sha512-gkGhSkxSzADaBiNg+ZAbJuwj+ZUmzP2Pg9CWZ7ZP+0fck2WjPeDDM7aAbouAm0aQQMF9xBjSPXSA9a/qTHYaTw=="],
|
||||
|
||||
"@scalar/openapi-upgrader": ["@scalar/openapi-upgrader@0.2.9", "", { "dependencies": { "@scalar/openapi-types": "0.9.1" } }, "sha512-D5b0rGLLZgmkO9mdW2j/ND1KBlH1u3RCpr87HPxv9P9ZSr6PtM5iLqFOJq0ACiaHjY2mikCrxgDmnUEhTzRpHQ=="],
|
||||
|
||||
"@scalar/schemas": ["@scalar/schemas@0.7.1", "", { "dependencies": { "@scalar/helpers": "0.9.0", "@scalar/validation": "0.6.0" } }, "sha512-80bxEp4ZOWxOm8kqhPi3kdJ2gipz2lZBSEHStAh3Z6NZPkFAOlZZYnDFwodhY0LwTihgvv4FVNmi64gziM0F1g=="],
|
||||
|
||||
"@scalar/sidebar": ["@scalar/sidebar@0.9.27", "", { "dependencies": { "@scalar/components": "0.27.4", "@scalar/helpers": "0.9.0", "@scalar/icons": "0.7.3", "@scalar/themes": "0.16.2", "@scalar/use-hooks": "0.4.7", "@scalar/workspace-store": "0.55.2", "vue": "^3.5.30" } }, "sha512-Y2HBOuUuRSJtB4sf9T5sqZZrsT8QNYP8kWuK8lqzsujf3bv7c4ixUFRNS568EMMjr0U7Zv9xa+LP0RHem3rt4g=="],
|
||||
|
||||
"@scalar/snippetz": ["@scalar/snippetz@0.9.20", "", { "dependencies": { "@scalar/helpers": "0.9.0", "@scalar/types": "0.16.1", "js-base64": "^3.7.8", "stringify-object": "^6.0.0" } }, "sha512-A3gSBYtoTmW3m511d02vUyl/W/eabX5y/EPAwz/u88LTzdwuDrKWbZ8iY+5dkz5e5EErhNOwQDwngmOCAA91hA=="],
|
||||
|
||||
"@scalar/themes": ["@scalar/themes@0.16.2", "", { "dependencies": { "nanoid": "^5.1.6" } }, "sha512-4mAn7z2W5/ASi1OF06dqf04xjxTHnMzESC2E+qVMukJsEsV4kDlYSIfm/g5hwWxhqWsBMjorF9Dtlqd0ycJ92g=="],
|
||||
|
||||
"@scalar/typebox": ["@scalar/typebox@0.1.3", "", {}, "sha512-lU055AUccECZMIfGA0z/C1StYmboAYIPJLDFBzOO81yXBi35Pxdq+I4fWX6iUZ8qcoHneiLGk9jAUM1rA93iEg=="],
|
||||
|
||||
"@scalar/types": ["@scalar/types@0.16.1", "", { "dependencies": { "@scalar/helpers": "0.9.0", "nanoid": "^5.1.6", "type-fest": "^5.3.1", "zod": "^4.3.5" } }, "sha512-zzApf0dtEqztdY//3gmRJTgySGMpKnAVqcZltAzt95yuQPXbkSqxXDTitBLd1abeSeik+W6GFTIjffL8K+1wqQ=="],
|
||||
|
||||
"@scalar/use-codemirror": ["@scalar/use-codemirror@0.14.12", "", { "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", "@codemirror/lang-css": "^6.3.1", "@codemirror/lang-html": "^6.4.8", "@codemirror/lang-json": "^6.0.0", "@codemirror/lang-xml": "^6.0.0", "@codemirror/lang-yaml": "^6.1.2", "@codemirror/language": "^6.10.7", "@codemirror/lint": "^6.8.4", "@codemirror/state": "^6.5.0", "@codemirror/view": "^6.35.3", "@lezer/common": "^1.2.3", "@lezer/highlight": "^1.2.1", "@replit/codemirror-css-color-picker": "^6.3.0", "vue": "^3.5.30" } }, "sha512-m+0tmVOHZVAybbcNEizR3m9RYOLtU59AStRkk29J5qaW4J+tsjrz4BDMceeTpoVawL/lxp2vDvQR+w/gKC3miQ=="],
|
||||
|
||||
"@scalar/use-hooks": ["@scalar/use-hooks@0.4.7", "", { "dependencies": { "@scalar/use-toasts": "0.10.2", "@scalar/validation": "0.6.0", "@vueuse/core": "13.9.0", "cva": "1.0.0-beta.4", "tailwind-merge": "3.5.0", "vue": "^3.5.30" } }, "sha512-8zajxhnKMJuO1HF36y8TeVuSIol2pueYdRvITZTEY8TuRbnwxrvJZk25II7YpxGMORAEDr0bwNF88Dr+QUfw1w=="],
|
||||
|
||||
"@scalar/use-toasts": ["@scalar/use-toasts@0.10.2", "", { "dependencies": { "vue": "^3.5.30", "vue-sonner": "^1.3.2" } }, "sha512-1iHQFbDXv0YQRp13aa63S5EcTJ5K8T0ocnLxk+nziloPrLjKt6jdRt6vOHsLSv5sm9kFKcVKNQTQgialmKCOGA=="],
|
||||
|
||||
"@scalar/validation": ["@scalar/validation@0.6.0", "", {}, "sha512-tpmmG+/xRE2Kn9RpflU3AIyZv08v10+E1ZrJCx7z6+/91zHVxy0M73kC1LT4/8PbYNt85ywyC8+n+D99JdMcGA=="],
|
||||
|
||||
"@scalar/workspace-store": ["@scalar/workspace-store@0.55.2", "", { "dependencies": { "@scalar/asyncapi-upgrader": "0.1.2", "@scalar/helpers": "0.9.0", "@scalar/json-magic": "0.12.17", "@scalar/openapi-upgrader": "0.2.9", "@scalar/schemas": "0.7.1", "@scalar/snippetz": "0.9.20", "@scalar/typebox": "0.1.3", "@scalar/types": "0.16.1", "@scalar/validation": "0.6.0", "js-base64": "^3.7.8", "type-fest": "^5.3.1", "vue": "^3.5.30", "yaml": "^2.8.3" } }, "sha512-/8BfJkave9vmweLdzH7w2TCaUFNcLu1vmE87id0QIi08enOwOExmPsz8YpUtaKLHM2bi/R2Tj/s9Uwp9i8Lttw=="],
|
||||
|
||||
"@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="],
|
||||
|
||||
"@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="],
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@swc/helpers": ["@swc/helpers@0.5.23", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw=="],
|
||||
|
||||
"@tailwindcss/node": ["@tailwindcss/node@4.3.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "5.21.6", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.1" } }, "sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A=="],
|
||||
|
||||
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.1", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.1", "@tailwindcss/oxide-darwin-arm64": "4.3.1", "@tailwindcss/oxide-darwin-x64": "4.3.1", "@tailwindcss/oxide-freebsd-x64": "4.3.1", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.1", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.1", "@tailwindcss/oxide-linux-arm64-musl": "4.3.1", "@tailwindcss/oxide-linux-x64-gnu": "4.3.1", "@tailwindcss/oxide-linux-x64-musl": "4.3.1", "@tailwindcss/oxide-wasm32-wasi": "4.3.1", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.1", "@tailwindcss/oxide-win32-x64-msvc": "4.3.1" } }, "sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA=="],
|
||||
@@ -526,6 +647,8 @@
|
||||
|
||||
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.1", "", {}, "sha512-VZyW2Uiml5tmBZwPGrSD3Sz73OxzljQMCmzYHsUTPEuTsERf5xwa+uWb01xEzkz3ZSYTjj8NEb/mKHvgKxyZdA=="],
|
||||
|
||||
"@tanstack/vue-virtual": ["@tanstack/vue-virtual@3.13.31", "", { "dependencies": { "@tanstack/virtual-core": "3.17.3" }, "peerDependencies": { "vue": "^2.7.0 || ^3.0.0" } }, "sha512-wZMEoSf852jQqaf3Ika1J7PiBae6341LNy/2CxmIyn0XKDQXMuK41wVX+xp6G0yx8jyR95Ef+Tdr13DK7mbJtQ=="],
|
||||
|
||||
"@tauri-apps/api": ["@tauri-apps/api@2.11.0", "", {}, "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA=="],
|
||||
|
||||
"@tauri-apps/cli": ["@tauri-apps/cli@2.11.2", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.11.2", "@tauri-apps/cli-darwin-x64": "2.11.2", "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.2", "@tauri-apps/cli-linux-arm64-gnu": "2.11.2", "@tauri-apps/cli-linux-arm64-musl": "2.11.2", "@tauri-apps/cli-linux-riscv64-gnu": "2.11.2", "@tauri-apps/cli-linux-x64-gnu": "2.11.2", "@tauri-apps/cli-linux-x64-musl": "2.11.2", "@tauri-apps/cli-win32-arm64-msvc": "2.11.2", "@tauri-apps/cli-win32-ia32-msvc": "2.11.2", "@tauri-apps/cli-win32-x64-msvc": "2.11.2" }, "bin": { "tauri": "tauri.js" } }, "sha512-bk3HemqvGRoy+5D/dVMUQHKMYLglD0jVnMm/0iGMH6ufZ+p8r14m6BpIixwij3PBvZdvORUp1YifTD8QxVZ1Nw=="],
|
||||
@@ -586,18 +709,40 @@
|
||||
|
||||
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
|
||||
|
||||
"@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="],
|
||||
|
||||
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
|
||||
|
||||
"@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/har-format": ["@types/har-format@1.2.16", "", {}, "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A=="],
|
||||
|
||||
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
|
||||
|
||||
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||
|
||||
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
|
||||
|
||||
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
|
||||
|
||||
"@types/node": ["@types/node@24.13.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="],
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||
|
||||
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
|
||||
|
||||
"@types/web-bluetooth": ["@types/web-bluetooth@0.0.21", "", {}, "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="],
|
||||
|
||||
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.2", "", {}, "sha512-5jsZFwgR5rTdKwidH9Qmat75RKwqfpKlWWB1frDkljN127mwqBu8K0PYo7/hFpF03IEJpfVPpCQDY/eDx3iHvA=="],
|
||||
|
||||
"@unhead/vue": ["@unhead/vue@2.1.15", "", { "dependencies": { "hookable": "^6.0.1", "unhead": "2.1.15" }, "peerDependencies": { "vue": ">=3.5.18" } }, "sha512-SSByXfEjhzPn8gXdEdgpYqpLMPSkLUH2HVE0GxZfOtNsJ0GgOHQs0g9T67ZZ1z0kTELLKdtOtYrzrbv9+ffF7g=="],
|
||||
|
||||
"@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="],
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.2", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.0" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg=="],
|
||||
|
||||
"@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="],
|
||||
@@ -614,12 +759,40 @@
|
||||
|
||||
"@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="],
|
||||
|
||||
"@vue/compiler-core": ["@vue/compiler-core@3.5.39", "", { "dependencies": { "@babel/parser": "^7.29.7", "@vue/shared": "3.5.39", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw=="],
|
||||
|
||||
"@vue/compiler-dom": ["@vue/compiler-dom@3.5.39", "", { "dependencies": { "@vue/compiler-core": "3.5.39", "@vue/shared": "3.5.39" } }, "sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg=="],
|
||||
|
||||
"@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.39", "", { "dependencies": { "@babel/parser": "^7.29.7", "@vue/compiler-core": "3.5.39", "@vue/compiler-dom": "3.5.39", "@vue/compiler-ssr": "3.5.39", "@vue/shared": "3.5.39", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.15", "source-map-js": "^1.2.1" } }, "sha512-d0ki86iOyN8LoZPBmk5SJWNwHP19CnDDCfuo//+2WJa2g5Ke0Jay983PIBIcSSzldC68I8DrD5GrHV3OSDfodg=="],
|
||||
|
||||
"@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.39", "", { "dependencies": { "@vue/compiler-dom": "3.5.39", "@vue/shared": "3.5.39" } }, "sha512-Ce7/wvwMHai74bdszfXExdazFigYnlF9zgCmEQUcM1j0fOymlouZ7XilTYNo8oUjhlnjYOZbGrcYKuqjz89Ucw=="],
|
||||
|
||||
"@vue/reactivity": ["@vue/reactivity@3.5.39", "", { "dependencies": { "@vue/shared": "3.5.39" } }, "sha512-TpsuBJ9gGlZa5d23XcM2y8EXanz9dZeVDQBXRwzy46ItgvM+rWpzs+UVM0wcRLxGvcav0HE5jz2gNL53xlRAog=="],
|
||||
|
||||
"@vue/runtime-core": ["@vue/runtime-core@3.5.39", "", { "dependencies": { "@vue/reactivity": "3.5.39", "@vue/shared": "3.5.39" } }, "sha512-9GLtNyRvPAUMbX+7ono0RC2j0guo2LXVi8LvcmAooImACUKm0oFf0jjwbX8/H0AE/t1nxhAkn8RSl9PMCzzxZw=="],
|
||||
|
||||
"@vue/runtime-dom": ["@vue/runtime-dom@3.5.39", "", { "dependencies": { "@vue/reactivity": "3.5.39", "@vue/runtime-core": "3.5.39", "@vue/shared": "3.5.39", "csstype": "^3.2.3" } }, "sha512-7Y6aAGboKcXAZ3ECuUy7RrS5yy2r47dhTp2SKaJmYxjopImaVFaNa5Ne66NwGovsrxVAl5S5rwc7m22UG7Lmww=="],
|
||||
|
||||
"@vue/server-renderer": ["@vue/server-renderer@3.5.39", "", { "dependencies": { "@vue/compiler-ssr": "3.5.39", "@vue/shared": "3.5.39" }, "peerDependencies": { "vue": "3.5.39" } }, "sha512-yZSakiAGw85rZfG7UM8akMnIF+FmeiNk47uvHf2nVBBSe+dIKUhZuZq9+XgJhbV3nS5Z4ALH23/MpXofW+mbcw=="],
|
||||
|
||||
"@vue/shared": ["@vue/shared@3.5.39", "", {}, "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA=="],
|
||||
|
||||
"@vueuse/core": ["@vueuse/core@13.9.0", "", { "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "13.9.0", "@vueuse/shared": "13.9.0" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-ts3regBQyURfCE2BcytLqzm8+MmLlo5Ln/KLoxDVcsZ2gzIwVNnQpQOL/UKV8alUqjSZOlpFZcRNsLRqj+OzyA=="],
|
||||
|
||||
"@vueuse/integrations": ["@vueuse/integrations@13.9.0", "", { "dependencies": { "@vueuse/core": "13.9.0", "@vueuse/shared": "13.9.0" }, "peerDependencies": { "async-validator": "^4", "axios": "^1", "change-case": "^5", "drauu": "^0.4", "focus-trap": "^7", "fuse.js": "^7", "idb-keyval": "^6", "jwt-decode": "^4", "nprogress": "^0.2", "qrcode": "^1.5", "sortablejs": "^1", "universal-cookie": "^7 || ^8", "vue": "^3.5.0" }, "optionalPeers": ["async-validator", "axios", "change-case", "drauu", "focus-trap", "fuse.js", "idb-keyval", "jwt-decode", "nprogress", "qrcode", "sortablejs", "universal-cookie"] }, "sha512-SDobKBbPIOe0cVL7QxMzGkuUGHvWTdihi9zOrrWaWUgFKe15cwEcwfWmgrcNzjT6kHnNmWuTajPHoIzUjYNYYQ=="],
|
||||
|
||||
"@vueuse/metadata": ["@vueuse/metadata@13.9.0", "", {}, "sha512-1AFRvuiGphfF7yWixZa0KwjYH8ulyjDCC0aFgrGRz8+P4kvDFSdXLVfTk5xAN9wEuD1J6z4/myMoYbnHoX07zg=="],
|
||||
|
||||
"@vueuse/shared": ["@vueuse/shared@13.9.0", "", { "peerDependencies": { "vue": "^3.5.0" } }, "sha512-e89uuTLMh0U5cZ9iDpEI2senqPGfbPRTHM/0AaQkcxnpqjkZqDYP8rpfm7edOz8s+pOCOROEy1PIveSW8+fL5g=="],
|
||||
|
||||
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
|
||||
|
||||
"agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="],
|
||||
|
||||
"ai": ["ai@6.0.33", "", { "dependencies": { "@ai-sdk/gateway": "3.0.13", "@ai-sdk/provider": "3.0.2", "@ai-sdk/provider-utils": "4.0.5", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bVokbmy2E2QF6Efl+5hOJx5MRWoacZ/CZY/y1E+VcewknvGlgaiCzMu8Xgddz6ArFJjiMFNUPHKxAhIePE4rmg=="],
|
||||
|
||||
"ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
|
||||
|
||||
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
@@ -636,6 +809,8 @@
|
||||
|
||||
"axios": ["axios@1.16.1", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A=="],
|
||||
|
||||
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
|
||||
|
||||
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.17", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-HdrkN8eVG2CXxeifv/VdJ4A4RSra1DTW8dc/hdxzhGHN8QePs6gKaWM9pHPcpCoxYZJuOZ8drHmbdpLHjCYjLA=="],
|
||||
@@ -654,10 +829,18 @@
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001787", "", {}, "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg=="],
|
||||
|
||||
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
|
||||
|
||||
"chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
|
||||
|
||||
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
"character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
|
||||
|
||||
"character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
|
||||
|
||||
"character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
|
||||
|
||||
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
|
||||
|
||||
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
|
||||
@@ -670,12 +853,18 @@
|
||||
|
||||
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
|
||||
|
||||
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
|
||||
|
||||
"concurrently": ["concurrently@9.2.1", "", { "dependencies": { "chalk": "4.1.2", "rxjs": "7.8.2", "shell-quote": "1.8.3", "supports-color": "8.1.1", "tree-kill": "1.2.2", "yargs": "17.7.2" }, "bin": { "conc": "dist/bin/concurrently.js", "concurrently": "dist/bin/concurrently.js" } }, "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng=="],
|
||||
|
||||
"convert-hrtime": ["convert-hrtime@5.0.0", "", {}, "sha512-lOETlkIeYSJWcbbcvjRKGxVMXJR+8+OQb/mTPbA4ObPMytYIsUbuOE0Jzy60hjARYszq1id0j8KgVhC+WGZVTg=="],
|
||||
|
||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
|
||||
"country-flag-icons": ["country-flag-icons@1.6.17", "", {}, "sha512-Nmik0289ZVZSI3c7mJR/amg6DyY7Z59b0sTFSKayeX72mHfPzCPJygwJs2pYgQULzuAyWeCUgwAJ+Dq8OR+JFw=="],
|
||||
|
||||
"crelt": ["crelt@1.0.7", "", {}, "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="],
|
||||
@@ -684,6 +873,8 @@
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"cva": ["cva@1.0.0-beta.4", "", { "dependencies": { "clsx": "^2.1.1" }, "peerDependencies": { "typescript": ">= 4.5.5" }, "optionalPeers": ["typescript"] }, "sha512-F/JS9hScapq4DBVQXcK85l9U91M6ePeXoBMSp7vypzShoefUBxjQTo3g3935PUHgQd+IW77DjbPRIxugy4/GCQ=="],
|
||||
|
||||
"data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
@@ -692,6 +883,8 @@
|
||||
|
||||
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
|
||||
|
||||
"decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
|
||||
|
||||
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
|
||||
|
||||
"defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="],
|
||||
@@ -706,6 +899,8 @@
|
||||
|
||||
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
|
||||
|
||||
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
|
||||
|
||||
"dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="],
|
||||
|
||||
"dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="],
|
||||
@@ -754,10 +949,14 @@
|
||||
|
||||
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
|
||||
|
||||
"eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="],
|
||||
|
||||
"execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="],
|
||||
|
||||
"expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
|
||||
|
||||
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
|
||||
@@ -778,6 +977,8 @@
|
||||
|
||||
"flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="],
|
||||
|
||||
"focus-trap": ["focus-trap@7.8.0", "", { "dependencies": { "tabbable": "^6.4.0" } }, "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA=="],
|
||||
|
||||
"follow-redirects": ["follow-redirects@1.16.0", "", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="],
|
||||
|
||||
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
|
||||
@@ -788,6 +989,10 @@
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"function-timeout": ["function-timeout@1.0.2", "", {}, "sha512-939eZS4gJ3htTHAldmyyuzlrD58P03fHG49v2JfFXbV6OhvZKRC9j2yAtdHw/zrp2zXHuv05zMIy40F0ge7spA=="],
|
||||
|
||||
"fuse.js": ["fuse.js@7.4.2", "", {}, "sha512-LVbzjD4WA6UP5B1UnP8wuaXJiLnqMdM/E4fiJXTJ5haJ5b/MBNsK29h2fm6swEoQaVQjvYFWKLE2RanyZIoRVQ=="],
|
||||
|
||||
"fzf": ["fzf@0.5.2", "", {}, "sha512-Tt4kuxLXFKHy8KT40zwsUPUkg1CrsgY25FxA2U/j/0WgEDCk3ddc/zLTCCcbSHX9FcKtLuVaDGtGE/STWC+j3Q=="],
|
||||
|
||||
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
||||
@@ -798,6 +1003,8 @@
|
||||
|
||||
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
|
||||
|
||||
"get-own-enumerable-keys": ["get-own-enumerable-keys@1.0.0", "", {}, "sha512-PKsK2FSrQCyxcGHsGrLDcK0lx+0Ke+6e8KFFozA9/fIQLhQzPaRvJFdcz7+Axg3jUH/Mq+NI4xa5u/UT2tQskA=="],
|
||||
|
||||
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||
|
||||
"get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="],
|
||||
@@ -816,6 +1023,8 @@
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"guess-json-indent": ["guess-json-indent@3.0.1", "", {}, "sha512-LWZ3Vr8BG7DHE3TzPYFqkhjNRw4vYgFSsv2nfMuHklAlOfiy54/EwiDQuQfFVLxENCVv20wpbjfTayooQHrEhQ=="],
|
||||
|
||||
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||
|
||||
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||
@@ -824,14 +1033,56 @@
|
||||
|
||||
"hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="],
|
||||
|
||||
"hast-util-embedded": ["hast-util-embedded@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-is-element": "^3.0.0" } }, "sha512-naH8sld4Pe2ep03qqULEtvYr7EjrLK2QHY8KJR6RJkTUjPGObe1vnx585uzem2hGra+s1q08DZZpfgDVYRbaXA=="],
|
||||
|
||||
"hast-util-format": ["hast-util-format@1.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-embedded": "^3.0.0", "hast-util-minify-whitespace": "^1.0.0", "hast-util-phrasing": "^3.0.0", "hast-util-whitespace": "^3.0.0", "html-whitespace-sensitive-tag-names": "^3.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-yY1UDz6bC9rDvCWHpx12aIBGRG7krurX0p0Fm6pT547LwDIZZiNr8a+IHDogorAdreULSEzP82Nlv5SZkHZcjA=="],
|
||||
|
||||
"hast-util-from-html": ["hast-util-from-html@2.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.1.0", "hast-util-from-parse5": "^8.0.0", "parse5": "^7.0.0", "vfile": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw=="],
|
||||
|
||||
"hast-util-from-parse5": ["hast-util-from-parse5@8.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "hastscript": "^9.0.0", "property-information": "^7.0.0", "vfile": "^6.0.0", "vfile-location": "^5.0.0", "web-namespaces": "^2.0.0" } }, "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg=="],
|
||||
|
||||
"hast-util-has-property": ["hast-util-has-property@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-MNilsvEKLFpV604hwfhVStK0usFY/QmM5zX16bo7EjnAEGofr5YyI37kzopBlZJkHD4t887i+q/C8/tr5Q94cA=="],
|
||||
|
||||
"hast-util-is-body-ok-link": ["hast-util-is-body-ok-link@3.0.1", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-0qpnzOBLztXHbHQenVB8uNuxTnm/QBFUOmdOSsEn7GnBtyY07+ENTWVFBAnXd/zEgd9/SUG3lRY7hSIBWRgGpQ=="],
|
||||
|
||||
"hast-util-is-element": ["hast-util-is-element@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g=="],
|
||||
|
||||
"hast-util-minify-whitespace": ["hast-util-minify-whitespace@1.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-embedded": "^3.0.0", "hast-util-is-element": "^3.0.0", "hast-util-whitespace": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-L96fPOVpnclQE0xzdWb/D12VT5FabA7SnZOUMtL1DbXmYiHJMXZvFkIZfiMmTCNJHUeO2K9UYNXoVyfz+QHuOw=="],
|
||||
|
||||
"hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="],
|
||||
|
||||
"hast-util-phrasing": ["hast-util-phrasing@3.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-embedded": "^3.0.0", "hast-util-has-property": "^3.0.0", "hast-util-is-body-ok-link": "^3.0.0", "hast-util-is-element": "^3.0.0" } }, "sha512-6h60VfI3uBQUxHqTyMymMZnEbNl1XmEGtOxxKYL7stY2o601COo62AWAYBQR9lZbYXYSBoxag8UpPRXK+9fqSQ=="],
|
||||
|
||||
"hast-util-raw": ["hast-util-raw@9.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "hast-util-from-parse5": "^8.0.0", "hast-util-to-parse5": "^8.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "parse5": "^7.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw=="],
|
||||
|
||||
"hast-util-sanitize": ["hast-util-sanitize@5.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "unist-util-position": "^5.0.0" } }, "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg=="],
|
||||
|
||||
"hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="],
|
||||
|
||||
"hast-util-to-parse5": ["hast-util-to-parse5@8.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA=="],
|
||||
|
||||
"hast-util-to-text": ["hast-util-to-text@4.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "hast-util-is-element": "^3.0.0", "unist-util-find-after": "^5.0.0" } }, "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A=="],
|
||||
|
||||
"hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
|
||||
|
||||
"hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="],
|
||||
|
||||
"hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="],
|
||||
|
||||
"hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
|
||||
|
||||
"highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="],
|
||||
|
||||
"hookable": ["hookable@6.1.1", "", {}, "sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ=="],
|
||||
|
||||
"html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="],
|
||||
|
||||
"html-parse-stringify": ["html-parse-stringify@3.0.1", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="],
|
||||
|
||||
"html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
|
||||
|
||||
"html-whitespace-sensitive-tag-names": ["html-whitespace-sensitive-tag-names@3.0.1", "", {}, "sha512-q+310vW8zmymYHALr1da4HyXUQ0zgiIwIicEfotYPWGN0OJVEN/58IJ3A4GBYcEq3LGAZqKb+ugvP0GNB9CEAA=="],
|
||||
|
||||
"https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="],
|
||||
|
||||
"human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="],
|
||||
@@ -840,22 +1091,32 @@
|
||||
|
||||
"i18next-browser-languagedetector": ["i18next-browser-languagedetector@8.2.1", "", { "dependencies": { "@babel/runtime": "^7.23.2" } }, "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw=="],
|
||||
|
||||
"identifier-regex": ["identifier-regex@1.0.1", "", { "dependencies": { "reserved-identifiers": "^1.0.0" } }, "sha512-ZrYyM0sozNPZlvBvE7Oq9Bn44n0qKGrYu5sQ0JzMUnjIhpgWYE2JB6aBoFwEYdPjqj7jPyxXTMJiHDOxDfd8yw=="],
|
||||
|
||||
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||
|
||||
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
|
||||
|
||||
"indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="],
|
||||
|
||||
"is-absolute-url": ["is-absolute-url@4.0.1", "", {}, "sha512-/51/TKE88Lmm7Gc4/8btclNXWS+g50wXhYJq8HWIBAGUBnoAdRu1aXeh364t/O7wXDAcTJDP8PNuNKWUDWie+A=="],
|
||||
|
||||
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||
|
||||
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
||||
|
||||
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||
|
||||
"is-identifier": ["is-identifier@1.0.1", "", { "dependencies": { "identifier-regex": "^1.0.0", "super-regex": "^1.0.0" } }, "sha512-HQ5v4rEJ7REUV54bCd2l5FaD299SGDEn2UPoVXaTHAyGviLq2menVUD2udi3trQ32uvB6LdAh/0ck2EuizrtpA=="],
|
||||
|
||||
"is-obj": ["is-obj@3.0.0", "", {}, "sha512-IlsXEHOjtKhpN8r/tRFj2nDyTmHvcfNeu/nrRIcXE17ROeatXchkojffa1SpdqW4cr/Fj6QkEf/Gn4zf6KKvEQ=="],
|
||||
|
||||
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
|
||||
|
||||
"is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="],
|
||||
|
||||
"is-regexp": ["is-regexp@3.1.0", "", {}, "sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA=="],
|
||||
|
||||
"is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="],
|
||||
|
||||
"is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="],
|
||||
@@ -866,6 +1127,8 @@
|
||||
|
||||
"joi": ["joi@18.2.1", "", { "dependencies": { "@hapi/address": "^5.1.1", "@hapi/formula": "^3.0.2", "@hapi/hoek": "^11.0.7", "@hapi/pinpoint": "^2.0.1", "@hapi/tlds": "^1.1.1", "@hapi/topo": "^6.0.2", "@standard-schema/spec": "^1.1.0" } }, "sha512-2/OKlogiESf2Nh3TFCrRjrr9z1DRHeW0I+KReF67+4J0Ns+8hBtHRmoWAZ2OFU6I5+TWLEe6sVlSdXPjHm5UbQ=="],
|
||||
|
||||
"js-base64": ["js-base64@3.8.0", "", {}, "sha512-65kvbemyZhj+ExQt1PEFyBEjL5vAHysu1lJdW1AwhhChkO8ZBPizYk/m9GVrpbS2Je1hF+UYZ+6KywqtZV8mHw=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"jsdom": ["jsdom@29.1.1", "", { "dependencies": { "@asamuzakjp/css-color": "^5.1.11", "@asamuzakjp/dom-selector": "^7.1.1", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.3", "@exodus/bytes": "^1.15.0", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.3.5", "parse5": "^8.0.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", "undici": "^7.25.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q=="],
|
||||
@@ -874,12 +1137,16 @@
|
||||
|
||||
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
|
||||
|
||||
"json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
|
||||
|
||||
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
||||
|
||||
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
|
||||
|
||||
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||
|
||||
"jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="],
|
||||
|
||||
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
|
||||
|
||||
"kill-port-process": ["kill-port-process@4.0.2", "", { "dependencies": { "get-them-args": "1.3.2", "pid-port": "2.0.1" }, "bin": { "kill-port": "dist/bin/kill-port-process.js" } }, "sha512-fO8gc45EYJQUQWozPBmdTpsR0GDvldsmrhP2I4FPoNejwyBY4Liiwj9Is7P/5rj6k07ZQ5Ob0g0k2dqQcslW/w=="],
|
||||
@@ -916,6 +1183,10 @@
|
||||
|
||||
"lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="],
|
||||
|
||||
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
|
||||
|
||||
"lowlight": ["lowlight@3.3.0", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.0.0", "highlight.js": "~11.11.0" } }, "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ=="],
|
||||
|
||||
"lru-cache": ["lru-cache@11.3.6", "", {}, "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A=="],
|
||||
|
||||
"lucide-react": ["lucide-react@1.18.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-LZDb7H/0YfM+RJncD0hDQRCAu+vSGODqpe35TuVI8EuXaRjkczbsx7p8dY4J87F/MUSj6bpYqeI8nw8qXaAdmA=="],
|
||||
@@ -924,10 +1195,96 @@
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"make-asynchronous": ["make-asynchronous@1.1.0", "", { "dependencies": { "p-event": "^6.0.0", "type-fest": "^4.6.0", "web-worker": "^1.5.0" } }, "sha512-ayF7iT+44LXdxJLTrTd3TLQpFDDvPCBxXxbv+pMUSuHA5Q8zyAfwkRP6aHHwNVFBUFWtxAHqwNJxF8vMZLAbVg=="],
|
||||
|
||||
"markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
|
||||
"mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="],
|
||||
|
||||
"mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="],
|
||||
|
||||
"mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="],
|
||||
|
||||
"mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="],
|
||||
|
||||
"mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="],
|
||||
|
||||
"mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="],
|
||||
|
||||
"mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="],
|
||||
|
||||
"mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="],
|
||||
|
||||
"mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="],
|
||||
|
||||
"mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="],
|
||||
|
||||
"mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="],
|
||||
|
||||
"mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
|
||||
|
||||
"mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
|
||||
|
||||
"microdiff": ["microdiff@1.5.0", "", {}, "sha512-Drq+/THMvDdzRYrK0oxJmOKiC24ayUV8ahrt8l3oRK51PWt6gdtrIGrlIH3pT/lFh1z93FbAcidtsHcWbnRz8Q=="],
|
||||
|
||||
"micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
|
||||
|
||||
"micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
|
||||
|
||||
"micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="],
|
||||
|
||||
"micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="],
|
||||
|
||||
"micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="],
|
||||
|
||||
"micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="],
|
||||
|
||||
"micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg=="],
|
||||
|
||||
"micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="],
|
||||
|
||||
"micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="],
|
||||
|
||||
"micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="],
|
||||
|
||||
"micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="],
|
||||
|
||||
"micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="],
|
||||
|
||||
"micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="],
|
||||
|
||||
"micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="],
|
||||
|
||||
"micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
|
||||
|
||||
"micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="],
|
||||
|
||||
"micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="],
|
||||
|
||||
"micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="],
|
||||
|
||||
"micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="],
|
||||
|
||||
"micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="],
|
||||
|
||||
"micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
|
||||
|
||||
"micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="],
|
||||
|
||||
"micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="],
|
||||
|
||||
"micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="],
|
||||
|
||||
"micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
|
||||
|
||||
"micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="],
|
||||
|
||||
"micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
|
||||
|
||||
"micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
|
||||
|
||||
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
@@ -946,6 +1303,8 @@
|
||||
|
||||
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
||||
|
||||
"neverpanic": ["neverpanic@0.0.8", "", {}, "sha512-vVdkelrLxaow/fdWDumzNBO+jwm6X8bxeLJc34THtpj70u0C5QBkcV6CRCu2X726km7XD45N0A3QtYCla4RvKw=="],
|
||||
|
||||
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
|
||||
|
||||
"node-releases": ["node-releases@2.0.37", "", {}, "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg=="],
|
||||
@@ -970,10 +1329,14 @@
|
||||
|
||||
"oxlint": ["oxlint@1.71.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.71.0", "@oxlint/binding-android-arm64": "1.71.0", "@oxlint/binding-darwin-arm64": "1.71.0", "@oxlint/binding-darwin-x64": "1.71.0", "@oxlint/binding-freebsd-x64": "1.71.0", "@oxlint/binding-linux-arm-gnueabihf": "1.71.0", "@oxlint/binding-linux-arm-musleabihf": "1.71.0", "@oxlint/binding-linux-arm64-gnu": "1.71.0", "@oxlint/binding-linux-arm64-musl": "1.71.0", "@oxlint/binding-linux-ppc64-gnu": "1.71.0", "@oxlint/binding-linux-riscv64-gnu": "1.71.0", "@oxlint/binding-linux-riscv64-musl": "1.71.0", "@oxlint/binding-linux-s390x-gnu": "1.71.0", "@oxlint/binding-linux-x64-gnu": "1.71.0", "@oxlint/binding-linux-x64-musl": "1.71.0", "@oxlint/binding-openharmony-arm64": "1.71.0", "@oxlint/binding-win32-arm64-msvc": "1.71.0", "@oxlint/binding-win32-ia32-msvc": "1.71.0", "@oxlint/binding-win32-x64-msvc": "1.71.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.22.1", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-U1m1X+C0vDj7DC1e13IoZULzEcPczE7UOMTs8VlZGHUEIUaSTZKo5qkPsQEfzpgnQ29Pea/w3Xntk62UCecxZw=="],
|
||||
|
||||
"p-event": ["p-event@6.0.1", "", { "dependencies": { "p-timeout": "^6.1.2" } }, "sha512-Q6Bekk5wpzW5qIyUP4gdMEujObYstZl6DMMOSenwBvV0BlE5LkDwkjs5yHbZmdCEq2o4RJx4tE1vwxFVf2FG1w=="],
|
||||
|
||||
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
|
||||
|
||||
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
|
||||
|
||||
"p-timeout": ["p-timeout@6.1.4", "", {}, "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg=="],
|
||||
|
||||
"p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],
|
||||
|
||||
"package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="],
|
||||
@@ -1010,6 +1373,8 @@
|
||||
|
||||
"pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="],
|
||||
|
||||
"property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="],
|
||||
|
||||
"proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="],
|
||||
|
||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
@@ -1018,6 +1383,8 @@
|
||||
|
||||
"quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="],
|
||||
|
||||
"radix-vue": ["radix-vue@1.9.17", "", { "dependencies": { "@floating-ui/dom": "^1.6.7", "@floating-ui/vue": "^1.1.0", "@internationalized/date": "^3.5.4", "@internationalized/number": "^3.5.3", "@tanstack/vue-virtual": "^3.8.1", "@vueuse/core": "^10.11.0", "@vueuse/shared": "^10.11.0", "aria-hidden": "^1.2.4", "defu": "^6.1.4", "fast-deep-equal": "^3.1.3", "nanoid": "^5.0.7" }, "peerDependencies": { "vue": ">= 3.2.0" } }, "sha512-mVCu7I2vXt1L2IUYHTt0sZMz7s1K2ZtqKeTIxG3yC5mMFfLBG4FtE1FDeRMpDd+Hhg/ybi9+iXmAP1ISREndoQ=="],
|
||||
|
||||
"react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="],
|
||||
|
||||
"react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="],
|
||||
@@ -1038,12 +1405,34 @@
|
||||
|
||||
"redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="],
|
||||
|
||||
"rehype-external-links": ["rehype-external-links@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "hast-util-is-element": "^3.0.0", "is-absolute-url": "^4.0.0", "space-separated-tokens": "^2.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-yp+e5N9V3C6bwBeAC4n796kc86M4gJCdlVhiMTxIrJG5UHDMh+PJANf9heqORJbt1nrCbDwIlAZKjANIaVBbvw=="],
|
||||
|
||||
"rehype-format": ["rehype-format@5.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-format": "^1.0.0" } }, "sha512-zvmVru9uB0josBVpr946OR8ui7nJEdzZobwLOOqHb/OOD88W0Vk2SqLwoVOj0fM6IPCCO6TaV9CvQvJMWwukFQ=="],
|
||||
|
||||
"rehype-parse": ["rehype-parse@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-from-html": "^2.0.0", "unified": "^11.0.0" } }, "sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag=="],
|
||||
|
||||
"rehype-raw": ["rehype-raw@7.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-raw": "^9.0.0", "vfile": "^6.0.0" } }, "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww=="],
|
||||
|
||||
"rehype-sanitize": ["rehype-sanitize@6.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-sanitize": "^5.0.0" } }, "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg=="],
|
||||
|
||||
"rehype-stringify": ["rehype-stringify@10.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-to-html": "^9.0.0", "unified": "^11.0.0" } }, "sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA=="],
|
||||
|
||||
"remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="],
|
||||
|
||||
"remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="],
|
||||
|
||||
"remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="],
|
||||
|
||||
"remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="],
|
||||
|
||||
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
|
||||
|
||||
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||
|
||||
"require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="],
|
||||
|
||||
"reserved-identifiers": ["reserved-identifiers@1.2.0", "", {}, "sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw=="],
|
||||
|
||||
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||
|
||||
"restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],
|
||||
@@ -1060,6 +1449,8 @@
|
||||
|
||||
"set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="],
|
||||
|
||||
"set-cookie-parser": ["set-cookie-parser@3.1.0", "", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="],
|
||||
|
||||
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||
|
||||
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
@@ -1074,12 +1465,22 @@
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
|
||||
|
||||
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
|
||||
|
||||
"std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="],
|
||||
|
||||
"string-byte-length": ["string-byte-length@3.0.1", "", {}, "sha512-yJ8vP0HMwZ54CcA8S8mKoXbkezpZHANFtmafFo8lGxZThCQcAwRHjdFabuSLgOzxj9OFJcmssmiAvmcOK4O2Hw=="],
|
||||
|
||||
"string-byte-slice": ["string-byte-slice@3.0.1", "", {}, "sha512-GWv2K4lYyd2+AhmKH3BV+OVx62xDX+99rSLfKpaqFiQU7uOMaUY1tDjdrRD4gsrCr9lTyjMgjna7tZcCOw+Smg=="],
|
||||
|
||||
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
|
||||
|
||||
"stringify-object": ["stringify-object@6.0.0", "", { "dependencies": { "get-own-enumerable-keys": "^1.0.0", "is-identifier": "^1.0.1", "is-obj": "^3.0.0", "is-regexp": "^3.1.0" } }, "sha512-6f94vIED6vmJJfh3lyVsVWxCYSfI5uM+16ntED/Ql37XIyV6kj0mRAAiTeMMc/QLYIaizC3bUprQ8pQnDDrKfA=="],
|
||||
|
||||
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="],
|
||||
@@ -1088,10 +1489,20 @@
|
||||
|
||||
"strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="],
|
||||
|
||||
"style-mod": ["style-mod@4.1.3", "", {}, "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ=="],
|
||||
|
||||
"super-regex": ["super-regex@1.1.0", "", { "dependencies": { "function-timeout": "^1.0.1", "make-asynchronous": "^1.0.1", "time-span": "^5.1.0" } }, "sha512-WHkws2ZflZe41zj6AolvvmaTrWds/VuyeYr9iPVv/oQeaIoVxMKaushfFWpOGDT+GuBrM/sVqF8KUCYQlSSTdQ=="],
|
||||
|
||||
"supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
|
||||
|
||||
"swrv": ["swrv@1.2.0", "", { "peerDependencies": { "vue": ">=3.2.26 < 4" } }, "sha512-lH/g4UcNyj+7lzK4eRGT4C68Q4EhQ6JtM9otPRIASfhhzfLWtbZPHcMuhuba7S9YVYuxkMUGImwMyGpfbkH07A=="],
|
||||
|
||||
"symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
|
||||
|
||||
"tabbable": ["tabbable@6.5.0", "", {}, "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA=="],
|
||||
|
||||
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
|
||||
|
||||
"tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.3.1", "", {}, "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q=="],
|
||||
@@ -1100,6 +1511,8 @@
|
||||
|
||||
"taze": ["taze@19.14.1", "", { "dependencies": { "@antfu/ni": "^30.1.0", "@henrygd/queue": "^1.2.0", "cac": "^7.0.0", "ofetch": "^1.5.1", "package-manager-detector": "^1.6.0", "pathe": "^2.0.3", "pnpm-workspace-yaml": "^1.6.1", "restore-cursor": "^5.1.0", "tinyexec": "^1.2.2", "tinyglobby": "^0.2.16", "unconfig": "^7.5.0", "yaml": "^2.9.0" }, "bin": { "taze": "bin/taze.mjs" } }, "sha512-+wf/IqGReU68vBE/iJ7JCuV5QeD6zQBp9MI6YphN7bT2vf/YIHd0oVA4AJiX3uANI1hQY58MrVmDwLv0x/q3BA=="],
|
||||
|
||||
"time-span": ["time-span@5.1.0", "", { "dependencies": { "convert-hrtime": "^5.0.0" } }, "sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA=="],
|
||||
|
||||
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
|
||||
|
||||
"tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="],
|
||||
@@ -1120,6 +1533,12 @@
|
||||
|
||||
"tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="],
|
||||
|
||||
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
|
||||
|
||||
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
|
||||
|
||||
"truncate-json": ["truncate-json@3.0.1", "", { "dependencies": { "guess-json-indent": "^3.0.1", "string-byte-length": "^3.0.1", "string-byte-slice": "^3.0.1" } }, "sha512-QVsbr1WhGLq2F0oDyYbqtOXcf3gcnL8C9H5EX8bBwAr8ZWvWGJzukpPrDrWgJMrNtgDbo74BIjI4kJu3q2xQWw=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"turbo": ["turbo@2.9.18", "", { "optionalDependencies": { "@turbo/darwin-64": "2.9.18", "@turbo/darwin-arm64": "2.9.18", "@turbo/linux-64": "2.9.18", "@turbo/linux-arm64": "2.9.18", "@turbo/windows-64": "2.9.18", "@turbo/windows-arm64": "2.9.18" }, "bin": { "turbo": "bin/turbo" } }, "sha512-bwabv6PupzeavybzEoArBAkwq5fnzwf8OFnRtpHwnviFWuwJPFxtyH+aVp36TmIqK3aYYgtTJ3J0m2ysxxSzQg=="],
|
||||
@@ -1128,6 +1547,8 @@
|
||||
|
||||
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
||||
|
||||
"type-fest": ["type-fest@5.7.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg=="],
|
||||
|
||||
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
|
||||
|
||||
"ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="],
|
||||
@@ -1140,8 +1561,26 @@
|
||||
|
||||
"undici": ["undici@7.25.0", "", {}, "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ=="],
|
||||
|
||||
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
|
||||
"unhead": ["unhead@2.1.15", "", { "dependencies": { "hookable": "^6.0.1" } }, "sha512-MCt5T90mCWyr3Z6pUCdM9lVRXoMoVBlL7z7U4CYVIiaDiuzad/UCfLuMqz5MeNmpZUgoBCQnrucJimU7EZR+XA=="],
|
||||
|
||||
"unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="],
|
||||
|
||||
"unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
|
||||
|
||||
"unist-util-find-after": ["unist-util-find-after@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ=="],
|
||||
|
||||
"unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
|
||||
|
||||
"unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
|
||||
|
||||
"unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
|
||||
|
||||
"unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="],
|
||||
|
||||
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
|
||||
|
||||
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
||||
|
||||
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||
@@ -1152,12 +1591,28 @@
|
||||
|
||||
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
|
||||
|
||||
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
|
||||
|
||||
"vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="],
|
||||
|
||||
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
|
||||
|
||||
"vite": ["vite@8.0.16", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "1.0.3", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw=="],
|
||||
|
||||
"vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="],
|
||||
|
||||
"void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="],
|
||||
|
||||
"vue": ["vue@3.5.39", "", { "dependencies": { "@vue/compiler-dom": "3.5.39", "@vue/compiler-sfc": "3.5.39", "@vue/runtime-dom": "3.5.39", "@vue/server-renderer": "3.5.39", "@vue/shared": "3.5.39" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-xmZCYabFGcirU8r0fTuvl/LICc1OU620rnqepaJDL/a141ZigkG7AyaxQLdqJ02ZRYzWe6YPaDHeQx7MfknQfA=="],
|
||||
|
||||
"vue-component-type-helpers": ["vue-component-type-helpers@3.3.6", "", {}, "sha512-FkljacAwJ9BUoSUdpFe3VDy0sGigNlTH9+2zcXUWmZOjN8swiCkl3t48wOJun0OsUd2cEIda1l04tsxMiKIIrQ=="],
|
||||
|
||||
"vue-demi": ["vue-demi@0.14.10", "", { "peerDependencies": { "@vue/composition-api": "^1.0.0-rc.1", "vue": "^3.0.0-0 || ^2.6.0" }, "optionalPeers": ["@vue/composition-api"], "bin": { "vue-demi-fix": "bin/vue-demi-fix.js", "vue-demi-switch": "bin/vue-demi-switch.js" } }, "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg=="],
|
||||
|
||||
"vue-sonner": ["vue-sonner@1.3.2", "", {}, "sha512-UbZ48E9VIya3ToiRHAZUbodKute/z/M1iT8/3fU8zEbwBRE11AKuHikssv18LMk2gTTr6eMQT4qf6JoLHWuj/A=="],
|
||||
|
||||
"w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="],
|
||||
|
||||
"w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="],
|
||||
|
||||
"wait-on": ["wait-on@9.0.10", "", { "dependencies": { "axios": "^1.16.0", "joi": "^18.2.1", "lodash": "^4.18.1", "minimist": "^1.2.8", "rxjs": "^7.8.2" }, "bin": { "wait-on": "bin/wait-on" } }, "sha512-rCoJEhvMr0X6alHmwc9abbrA5ZrLZFKpFQVKPNFwl2h7DapXOGdmimIHDtLOWhT4PjhZhxFEtZoQgEXbkDWdZw=="],
|
||||
@@ -1166,6 +1621,10 @@
|
||||
|
||||
"wavesurfer.js": ["wavesurfer.js@7.12.8", "", {}, "sha512-G3nxzcC4X+ZWrLtcIV17kCWHVq3ysJCS4dS0YkGKILrQ2esAb8cScw965zKNKYxUvpiZsPK93KLWgWTYdIBQiw=="],
|
||||
|
||||
"web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="],
|
||||
|
||||
"web-worker": ["web-worker@1.5.0", "", {}, "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw=="],
|
||||
|
||||
"webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="],
|
||||
|
||||
"whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="],
|
||||
@@ -1206,10 +1665,18 @@
|
||||
|
||||
"zustand": ["zustand@5.0.14", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g=="],
|
||||
|
||||
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
|
||||
|
||||
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
||||
|
||||
"@floating-ui/core/@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
|
||||
|
||||
"@floating-ui/dom/@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
|
||||
|
||||
"@floating-ui/vue/@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
|
||||
|
||||
"@oxc-resolver/binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q=="],
|
||||
|
||||
"@oxc-resolver/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg=="],
|
||||
@@ -1222,6 +1689,18 @@
|
||||
|
||||
"@rolldown/binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
|
||||
|
||||
"@scalar/api-client/nanoid": ["nanoid@5.1.16", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ=="],
|
||||
|
||||
"@scalar/api-reference/nanoid": ["nanoid@5.1.16", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ=="],
|
||||
|
||||
"@scalar/icons/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||
|
||||
"@scalar/themes/nanoid": ["nanoid@5.1.16", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ=="],
|
||||
|
||||
"@scalar/types/nanoid": ["nanoid@5.1.16", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ=="],
|
||||
|
||||
"@scalar/use-hooks/tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
|
||||
@@ -1234,6 +1713,8 @@
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"@tanstack/vue-virtual/@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.3", "", {}, "sha512-8Np/TFELpI0ySuJoVmjvOrQYXH/8sTX0Biv9szhFhY39xOdAAY+smrMxjxOum/ux3eM8MUJQsEJ0/R0UpvC8dw=="],
|
||||
|
||||
"@tauri-apps/plugin-process/@tauri-apps/api": ["@tauri-apps/api@2.10.1", "", {}, "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw=="],
|
||||
|
||||
"@tauri-apps/plugin-updater/@tauri-apps/api": ["@tauri-apps/api@2.10.1", "", {}, "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw=="],
|
||||
@@ -1244,8 +1725,26 @@
|
||||
|
||||
"@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
|
||||
|
||||
"@vue/compiler-core/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="],
|
||||
|
||||
"@vue/compiler-core/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
|
||||
"@vue/compiler-core/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
|
||||
"@vue/compiler-sfc/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="],
|
||||
|
||||
"@vue/compiler-sfc/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
|
||||
"chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
|
||||
"hast-util-from-html/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
|
||||
|
||||
"hast-util-raw/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
|
||||
|
||||
"make-asynchronous/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
|
||||
|
||||
"mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
|
||||
|
||||
"npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
|
||||
|
||||
"playwright/playwright-core": ["playwright-core@1.60.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA=="],
|
||||
@@ -1254,6 +1753,12 @@
|
||||
|
||||
"qrcode/yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="],
|
||||
|
||||
"radix-vue/@vueuse/core": ["@vueuse/core@10.11.1", "", { "dependencies": { "@types/web-bluetooth": "^0.0.20", "@vueuse/metadata": "10.11.1", "@vueuse/shared": "10.11.1", "vue-demi": ">=0.14.8" } }, "sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww=="],
|
||||
|
||||
"radix-vue/@vueuse/shared": ["@vueuse/shared@10.11.1", "", { "dependencies": { "vue-demi": ">=0.14.8" } }, "sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA=="],
|
||||
|
||||
"radix-vue/nanoid": ["nanoid@5.1.16", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ=="],
|
||||
|
||||
"rolldown/@oxc-project/types": ["@oxc-project/types@0.133.0", "", {}, "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA=="],
|
||||
|
||||
"vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
@@ -1268,6 +1773,14 @@
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||
|
||||
"@vue/compiler-core/@babel/parser/@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
|
||||
|
||||
"@vue/compiler-sfc/@babel/parser/@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
|
||||
|
||||
"hast-util-from-html/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
|
||||
|
||||
"hast-util-raw/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
|
||||
|
||||
"qrcode/yargs/cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="],
|
||||
|
||||
"qrcode/yargs/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
|
||||
@@ -1276,6 +1789,18 @@
|
||||
|
||||
"qrcode/yargs/yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="],
|
||||
|
||||
"radix-vue/@vueuse/core/@types/web-bluetooth": ["@types/web-bluetooth@0.0.20", "", {}, "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow=="],
|
||||
|
||||
"radix-vue/@vueuse/core/@vueuse/metadata": ["@vueuse/metadata@10.11.1", "", {}, "sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw=="],
|
||||
|
||||
"@vue/compiler-core/@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
|
||||
|
||||
"@vue/compiler-core/@babel/parser/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
|
||||
|
||||
"@vue/compiler-sfc/@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
|
||||
|
||||
"@vue/compiler-sfc/@babel/parser/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
|
||||
|
||||
"qrcode/yargs/cliui/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
|
||||
|
||||
"qrcode/yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
|
||||
|
||||
@@ -102,6 +102,38 @@ provider via environment variables (e.g. `GROQ_API_KEY`, or the legacy
|
||||
`TRANSLATE_BASE_URL` / `TRANSLATE_API_KEY` / `TRANSLATE_MODEL`, which map to the
|
||||
**Custom** provider).
|
||||
|
||||
### Pinning the active provider with `LLM_DEFAULT_PROVIDER`
|
||||
|
||||
By default the LLM used for Cinematic/Autofit is the one you mark "use for
|
||||
translation" in **Settings → LLM Providers**. To force a specific provider
|
||||
regardless of that stored selection — handy for headless/CI/Docker runs or a
|
||||
shared machine — set the `LLM_DEFAULT_PROVIDER` environment variable to a
|
||||
provider id before launching the backend:
|
||||
|
||||
```
|
||||
LLM_DEFAULT_PROVIDER=groq # or openai, openrouter, cerebras, ollama, custom, …
|
||||
```
|
||||
|
||||
Resolution order for the active provider is: `LLM_DEFAULT_PROVIDER` (env) →
|
||||
your saved selection → the first provider that has a key → none. The id must be
|
||||
one OmniVoice knows (the ids shown in **Settings → LLM Providers**); an unknown
|
||||
value is ignored and resolution falls through to your saved selection. While
|
||||
this env var is set it wins over the in-app picker, so if the UI selection
|
||||
appears to have "no effect," check whether `LLM_DEFAULT_PROVIDER` is exported.
|
||||
|
||||
## LLM Skills (per-feature routing)
|
||||
|
||||
**Settings → System → LLM Skills** lists every LLM-powered feature — Cinematic &
|
||||
Autofit translation, speech-rate slot fitting, glossary auto-extract, direction
|
||||
parsing, and dictation cleanup — and lets you toggle each one or route it to a
|
||||
specific provider instead of the global active one. That way sensitive work
|
||||
(e.g. dictation cleanup) can stay on a local Ollama/LM Studio model while
|
||||
heavier jobs use a remote provider. A disabled skill degrades exactly like
|
||||
having no LLM configured: Cinematic/Autofit falls back to Fast, dictation
|
||||
cleanup passes the raw transcript through, direction parsing uses the keyword
|
||||
heuristic. Everything defaults to enabled + "use active provider", so existing
|
||||
setups behave unchanged.
|
||||
|
||||
## API keys (online MT engines)
|
||||
|
||||
The non-LLM online engines need a key, set as an environment variable before
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
# OmniVoice Studio — Install on macOS
|
||||
|
||||
This page is self-contained: follow it top to bottom and you'll end up with a
|
||||
working OmniVoice Studio install on macOS (Apple Silicon or Intel).
|
||||
working OmniVoice Studio install on macOS (Apple Silicon).
|
||||
|
||||
> **Intel Macs:** the pre-built `.app`/DMG currently ships **Apple Silicon
|
||||
> only** — on Intel, install **from source** (works fully; ASR falls back to
|
||||
> CTranslate2). A pre-built Intel bundle is tracked in
|
||||
> [#279](https://github.com/debpalash/OmniVoice-Studio/issues/279).
|
||||
> [!IMPORTANT]
|
||||
> **Intel Macs are not supported.** The app UI installs and launches, but the
|
||||
> local Python backend **cannot run**: PyTorch stopped shipping Intel-Mac
|
||||
> (macOS x86_64) wheels after 2.2.x, and OmniVoice's dependencies require a
|
||||
> newer torch — so the first-run dependency install can never succeed, from
|
||||
> the DMG *or* from source
|
||||
> ([#889](https://github.com/debpalash/OmniVoice-Studio/issues/889)). The app
|
||||
> detects this at first launch and tells you directly instead of failing with
|
||||
> a raw installer error. Your options on an Intel Mac: point the UI at a
|
||||
> remote backend running on another machine (**Settings → Sharing → Remote
|
||||
> backend**), or run OmniVoice on an Apple Silicon Mac, Windows, or Linux.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **macOS 12 (Monterey) or newer** — Apple Silicon or Intel.
|
||||
- **macOS 12 (Monterey) or newer** — Apple Silicon (Intel: UI only, see the
|
||||
note above).
|
||||
- **Python 3.11+** — `brew install python@3.11` (or use `pyenv` / the system Python if you already have ≥3.11).
|
||||
- **Bun** — `curl -fsSL https://bun.sh/install | bash`.
|
||||
- **Xcode Command Line Tools** — `xcode-select --install`.
|
||||
@@ -47,13 +55,15 @@ Pick the DMG that matches your Mac (check **Apple menu → About This Mac → Ch
|
||||
| Mac | DMG to download |
|
||||
|-----|-----------------|
|
||||
| Apple Silicon (M1/M2/M3/M4…) | `OmniVoice.Studio_<version>_aarch64.dmg` |
|
||||
| Intel | `OmniVoice.Studio_<version>_x64.dmg` |
|
||||
| Intel | `OmniVoice.Studio_<version>_x64.dmg` — **UI only**: the local backend cannot run on Intel ([#889](https://github.com/debpalash/OmniVoice-Studio/issues/889)) |
|
||||
|
||||
The architectures are **not** interchangeable: an Intel Mac cannot run the
|
||||
`aarch64` build (Rosetta 2 only translates the other direction — it lets Apple
|
||||
Silicon run Intel apps, never the reverse). If a release predates the Intel
|
||||
build target and has no `x64` DMG, use the
|
||||
[install-from-source path](#install-from-source) above instead.
|
||||
Silicon run Intel apps, never the reverse). And note the Intel caveat above:
|
||||
the `x64` DMG installs and launches, but is only useful together with a
|
||||
remote backend — the local Python backend cannot install on Intel because
|
||||
PyTorch no longer ships Intel-Mac wheels. Installing from source does not
|
||||
help; the dependency resolution fails the same way.
|
||||
|
||||
If the first launch is blocked by macOS Gatekeeper ("OmniVoice Studio cannot be
|
||||
opened because the developer cannot be verified"), see the next section — it
|
||||
@@ -123,8 +133,11 @@ without the quarantine step.
|
||||
- **Apple Silicon (M-series):** OmniVoice automatically picks the `mlx-whisper`
|
||||
and `mlx-audio` backends where available — these use the Apple Neural Engine
|
||||
and Metal Performance Shaders for ~2× the throughput of the CPU path.
|
||||
- **Intel macs:** falls back to `faster-whisper` (CTranslate2) on CPU. Still
|
||||
fast; just no ANE acceleration.
|
||||
- **Intel Macs:** the local backend is **unsupported** — PyTorch no longer
|
||||
ships Intel-Mac wheels, so the Python environment can never install
|
||||
([#889](https://github.com/debpalash/OmniVoice-Studio/issues/889)). The UI
|
||||
works only when pointed at a remote backend (**Settings → Sharing → Remote
|
||||
backend**).
|
||||
|
||||
The picker in **Settings → Engines** shows which backend is active.
|
||||
|
||||
|
||||
@@ -179,9 +179,11 @@ falling back to faster-whisper`.
|
||||
|
||||
**Cause:** `mlx-whisper` and `mlx-audio` only build for arm64 (Apple Silicon).
|
||||
|
||||
**Fix:** none needed — `faster-whisper` (CTranslate2) is the supported Intel
|
||||
path and is still fast. If you want the latest CT2 wheels, run `uv sync`
|
||||
from a fresh source checkout.
|
||||
**Fix:** none needed on Apple Silicon setups that log this transiently. Note
|
||||
that Intel Macs can no longer run the local backend at all — PyTorch dropped
|
||||
Intel-Mac wheels, so this entry only applies to historical installs (see
|
||||
[macos.md](macos.md) and
|
||||
[#889](https://github.com/debpalash/OmniVoice-Studio/issues/889)).
|
||||
|
||||
## 10. Windows: `Could not locate cudnn_ops_infer64_8.dll` during transcription
|
||||
|
||||
@@ -191,14 +193,30 @@ 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.
|
||||
from `.venv\Lib\site-packages\cudnn8_compat\` — but the step that installs that
|
||||
folder only ever lived in the dev-loop setup script, which isn't bundled into
|
||||
the packaged app. **Packaged installs never had these libraries at all**, so
|
||||
reinstalling never fixed it ([#827](https://github.com/debpalash/OmniVoice-Studio/issues/827)).
|
||||
|
||||
**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.
|
||||
**Fix:** update to the latest build and relaunch — the app's bootstrap now
|
||||
detects a CUDA machine and installs the cuDNN-8 libraries into the backend venv
|
||||
automatically at launch ([#869](https://github.com/debpalash/OmniVoice-Studio/pull/869)).
|
||||
(The check is skipped — and its negative result cached — on CPU/AMD/Apple
|
||||
machines, so non-NVIDIA launches stay instant.)
|
||||
|
||||
If the automatic install can't run (offline / restricted network), install
|
||||
manually into the backend venv, then restart:
|
||||
|
||||
```
|
||||
uv pip install --no-deps --python .venv\Scripts\python.exe --target .venv\Lib\site-packages\cudnn8_compat nvidia-cudnn-cu12==8.9.7.29
|
||||
```
|
||||
|
||||
(On Linux the target is `.venv/lib/pythonX.Y/site-packages/cudnn8_compat`.)
|
||||
|
||||
Or sidestep cuDNN 8 entirely: 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).
|
||||
|
||||
## 11. IndexTTS / CosyVoice / ChatterboxTTS clash
|
||||
|
||||
@@ -344,14 +362,58 @@ did was `generate:start (audio)`, a dub, or a dictation.
|
||||
4. **Test with a 10-second clip** first — if that returns quickly, it confirms a
|
||||
compute/VRAM limit rather than a true hang.
|
||||
|
||||
Newer builds **bound** every GPU job — whole-file transcription **and** TTS
|
||||
generation: instead of hanging forever and starving the backend, a wedged job now
|
||||
fails after a timeout with this exact guidance, and the worker pool is reset so
|
||||
capacity is restored automatically (no app restart needed). Tune the bounds with
|
||||
`OMNIVOICE_ASR_TRANSCRIBE_TIMEOUT_S` (transcription) and
|
||||
`OMNIVOICE_GENERATE_TIMEOUT_S` (generation) — both in seconds, default 300.
|
||||
**Raise** them for very long single files/generations, **lower** them to fail
|
||||
faster on a small machine.
|
||||
Newer builds **bound** every GPU job — whole-file transcription, **chunked dub
|
||||
transcription**, **and** TTS generation: instead of hanging forever and starving
|
||||
the backend, a wedged job now fails after a timeout with this exact guidance,
|
||||
and the worker pool is reset so capacity is restored automatically (no app
|
||||
restart needed). Tune the bounds with `OMNIVOICE_ASR_TRANSCRIBE_TIMEOUT_S`
|
||||
(whole-file transcription) and `OMNIVOICE_GENERATE_TIMEOUT_S` (generation) —
|
||||
both in seconds, default 300 — and `OMNIVOICE_TRANSCRIBE_CHUNK_TIMEOUT_S`
|
||||
(per-chunk dub transcription, default 120). **Raise** them for very long single
|
||||
files/generations, **lower** them to fail faster on a small machine.
|
||||
|
||||
**If transcribe timeouts keep repeating back-to-back**, pool resets aren't
|
||||
recovering the underlying hang — the wedged thread keeps its VRAM until the app
|
||||
exits. The error message will then recommend switching the ASR engine to
|
||||
**Faster-Whisper (crash-isolated subprocess)** (`faster-whisper-isolated`) in
|
||||
**Settings → Engines**: it runs transcription in a separate process that can be
|
||||
force-killed to reclaim a hung transcribe *and* its VRAM, at a small per-call
|
||||
overhead. It reuses your existing faster-whisper install (nothing extra to
|
||||
download). OmniVoice never switches engines automatically — this stays your
|
||||
call.
|
||||
|
||||
## 15. Stuck at "preparing" forever after a crash / BSOD (Windows)
|
||||
|
||||
**Symptom:** after an unclean shutdown (Windows BSOD, forced power-off), every
|
||||
launch sits on the "preparing" splash indefinitely — even though the backend is
|
||||
actually healthy (its log shows models loaded, and
|
||||
`http://127.0.0.1:3900/health` answers `{"status":"ok"}` in a browser). The
|
||||
WebView log contains:
|
||||
|
||||
```
|
||||
IPC custom protocol failed, Tauri will now use the postMessage interface instead
|
||||
TypeError: Failed to fetch
|
||||
```
|
||||
|
||||
**Cause:** the crash corrupted the WebView2 profile cache at
|
||||
`%LOCALAPPDATA%\com.debpalash.omnivoice-studio\EBWebView`. Both the IPC custom
|
||||
protocol *and* its postMessage fallback break, so the splash never hears the
|
||||
"ready" signal from the app shell (issue #879).
|
||||
|
||||
**Fix:** current builds handle this automatically — if the splash gets no IPC
|
||||
signal within ~10 s it checks the backend over plain HTTP and proceeds on its
|
||||
own; if the backend isn't up either, after ~45 s a recovery panel appears with
|
||||
**Repair and restart** (Windows), which clears the WebView cache and relaunches.
|
||||
Your voices, projects, and settings are not touched — only browser display data
|
||||
is cleared.
|
||||
|
||||
On older builds (≤ 0.3.8), or if the automatic repair fails, do it manually:
|
||||
quit OmniVoice Studio, delete the folder below, then start the app again.
|
||||
|
||||
<!-- validate: skip -->
|
||||
```powershell
|
||||
Remove-Item -Recurse -Force "$env:LOCALAPPDATA\com.debpalash.omnivoice-studio\EBWebView"
|
||||
```
|
||||
|
||||
## Dub: "translation engine needs the optional … package"
|
||||
|
||||
|
||||
@@ -49,6 +49,47 @@ Download the latest MSI from the
|
||||
run it, follow the wizard. The shortcut lands in the Start menu as
|
||||
**OmniVoice Studio**.
|
||||
|
||||
## Portable install (Windows)
|
||||
|
||||
<a id="portable-install"></a>
|
||||
|
||||
OmniVoice Studio has a **Portable** mode: instead of scattering data across
|
||||
`%APPDATA%` and `%LOCALAPPDATA%`, the whole install — Python env, model
|
||||
weights, voices, projects, settings — lives in a single
|
||||
`OmniVoiceStudio-Data` folder created **next to the executable**. Moving or
|
||||
copying the app folder (exe + that data folder together) relocates the entire
|
||||
install, USB-stick style.
|
||||
|
||||
The first-run setup screen offers Portable whenever the folder next to
|
||||
`OmniVoice Studio.exe` is writable. A default MSI install goes to
|
||||
`C:\Program Files`, which is *not* user-writable — that's why Portable shows
|
||||
as greyed out after a default install
|
||||
([#766](https://github.com/debpalash/OmniVoice-Studio/issues/766)). To enable
|
||||
it, install to a user-writable folder instead:
|
||||
|
||||
- Re-run the MSI and choose a custom destination folder in the setup wizard
|
||||
(e.g. `D:\Apps\OmniVoice`), or
|
||||
- From a terminal:
|
||||
`msiexec /i OmniVoice.Studio_<version>_x64_en-US.msi INSTALLDIR="D:\Apps\OmniVoice"`
|
||||
|
||||
On the next launch, pick **Portable** on the first-run setup screen. What
|
||||
lives next to the exe afterwards:
|
||||
|
||||
<!-- validate: skip -->
|
||||
```
|
||||
D:\Apps\OmniVoice\
|
||||
├── OmniVoice Studio.exe ← the app
|
||||
└── OmniVoiceStudio-Data\ ← the whole install, self-contained
|
||||
├── config.json ← install-mode + app settings
|
||||
├── env\ ← Python venv + backend code
|
||||
└── data\ ← voices, projects, settings DB
|
||||
└── models\ ← model weights (HF cache)
|
||||
```
|
||||
|
||||
Prefer the default Program Files install? **Installed** mode is the same app —
|
||||
data just lives in `%APPDATA%\OmniVoice` and the model cache in
|
||||
`%LOCALAPPDATA%\OmniVoice\hf_cache`.
|
||||
|
||||
## HF_TOKEN persistence
|
||||
|
||||
The **recommended path** is the in-app **Settings → API Keys** panel: it
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
# Playbook — Setting up sponsorship for an open-source project
|
||||
|
||||
> A portable, copy-to-another-repo guide for adding a tasteful sponsorship
|
||||
> system to a free/local-first OSS project. This is the exact setup shipped
|
||||
> in OmniVoice Studio (PRs #923 + #924); lift the files, swap the names, and
|
||||
> you have the same system in an afternoon.
|
||||
|
||||
## Philosophy (decide this first — it shapes everything)
|
||||
|
||||
1. **Sponsorship is a thank-you, not a paywall.** The software stays fully
|
||||
free and the same license. Tiers buy *visibility and gratitude*
|
||||
(logo placement), never gated features. Say this out loud in `SPONSORS.md`
|
||||
— it's what keeps the community's trust and separates you from a freemium
|
||||
bait-and-switch.
|
||||
2. **Tell the honest funding story.** People sponsor a *reason*, not a tip
|
||||
jar. OmniVoice's is "one developer, in the open, and the AI-agent bills
|
||||
are real." Whatever yours is (server costs, your time, signing certs),
|
||||
state it plainly and specifically. Vague "support us" underperforms a
|
||||
concrete "here's what the money pays for."
|
||||
3. **Local-first / no-infra.** No sponsor-management SaaS, no token held by
|
||||
the app, no third-party embed. The contact flow is a prefilled GitHub
|
||||
issue the user submits from their own browser — the same zero-credential
|
||||
pattern good OSS bug-reporters use. It survives forks (change one URL).
|
||||
4. **Ask at value moments, rarely.** (This is the *prompting* half — see the
|
||||
donation-moments system, a separate piece: after a successful export,
|
||||
≥N lifetime successes, long cooldown, permanent opt-out. Never nag.)
|
||||
|
||||
The two failure modes to avoid: **core-js** (console-spam nagging → community
|
||||
backlash) and **blocking modals**. The two that work: **value-moment timing**
|
||||
+ **enforced rarity** with an instant, respected exit.
|
||||
|
||||
## The pieces (what to create)
|
||||
|
||||
A complete system is six files. Placements form a natural ladder — each tier
|
||||
adds one more surface:
|
||||
|
||||
```
|
||||
SPONSORS.md ← the home: why, tiers, how-to, roster, asset rules
|
||||
README.md (## Sponsors subsection) ← logo slots + "your logo here" + link to SPONSORS.md
|
||||
.github/FUNDING.yml ← GitHub's native "Sponsor" button (Ko-fi / custom links)
|
||||
.github/ISSUE_TEMPLATE/sponsor.yml ← the "Sponsorship inquiry" issue FORM (structured fields)
|
||||
frontend/.../config/sponsors.js ← in-app single source of truth (empty array + contact URLs)
|
||||
frontend/.../SupportPage + footer ← in-app logo grid, "Become a sponsor" CTA, footer link
|
||||
```
|
||||
|
||||
### 1. `SPONSORS.md` — the home
|
||||
|
||||
Sections, in order: **Why sponsor** (the honest funding story + "where your
|
||||
money goes"), **Tiers** (a table — placements as benefits, cumulative),
|
||||
**How to become a sponsor**, **Logo/asset guidelines**, **Current sponsors**
|
||||
(a "be the first" placeholder with empty tier tables ready to fill), and a
|
||||
**Not a paywall** note.
|
||||
|
||||
Tier ladder that maps to real surfaces:
|
||||
|
||||
| Tier | Placement added |
|
||||
|------|-----------------|
|
||||
| Backer | name/handle in `SPONSORS.md` |
|
||||
| Bronze | + small logo in `SPONSORS.md` and the README Sponsors section |
|
||||
| Silver | + logo in the README and the in-app Sponsors page |
|
||||
| Gold | + prominent logo slot on the project website/landing |
|
||||
|
||||
**Leave prices as owner-input placeholders.** Use an HTML-comment marker so
|
||||
they're obvious in source and never accidentally invented by an automated
|
||||
edit: `_set by owner_ <!-- OWNER: set amounts -->`. Same for a public contact
|
||||
email — don't publish a personal address without the owner's explicit call;
|
||||
default the contact to the GitHub issue form.
|
||||
|
||||
### 2. README `## Sponsors` subsection
|
||||
|
||||
A short pitch, a logo-slot placeholder (`**Your logo here** — [become a
|
||||
sponsor](SPONSORS.md)`), and a link to `SPONSORS.md`. Wrap the logo area in
|
||||
`<!-- SPONSORS:START -->` / `<!-- SPONSORS:END -->` markers so a future script
|
||||
can auto-render logos from the config. Add a `Sponsors` entry to the top nav.
|
||||
|
||||
### 3. `.github/FUNDING.yml`
|
||||
|
||||
Turns on GitHub's native "Sponsor" button. Only list platforms you're
|
||||
actually on — don't add `github: [you]` unless GitHub Sponsors is set up.
|
||||
Ko-fi + a `custom:` list (PayPal, the SPONSORS.md link) is a fine start:
|
||||
|
||||
```yaml
|
||||
ko_fi: yourhandle
|
||||
custom:
|
||||
- "https://paypal.me/you"
|
||||
- "https://github.com/you/repo/blob/main/SPONSORS.md"
|
||||
```
|
||||
|
||||
### 4. `.github/ISSUE_TEMPLATE/sponsor.yml` — the inquiry form
|
||||
|
||||
A structured issue **form** (name/org, website, logo URL, tier interest,
|
||||
contact, acknowledgements), `labels: ["sponsor"]`. **Gotcha we hit:** if
|
||||
`config.yml` has `blank_issues_enabled: false`, a bare
|
||||
`issues/new?title=…&body=…` prefill redirects to the template chooser and
|
||||
*drops the body*. So point "Become a sponsor" at the **template route**
|
||||
instead: `issues/new?template=sponsor.yml`. That carries the form's fields
|
||||
reliably.
|
||||
|
||||
### 5. In-app config — single source of truth
|
||||
|
||||
One module the whole app reads (`config/sponsors.js` in our case):
|
||||
|
||||
```js
|
||||
export const SPONSORS = []; // { name, logoUrl, url, tier } — empty until you have sponsors
|
||||
export const SPONSOR_TIERS = ['platinum', 'gold', 'silver', 'bronze']; // display order
|
||||
export const SPONSOR_CONTACT = {
|
||||
githubIssue: `${REPO}/issues/new?template=sponsor.yml`, // the template route (see gotcha)
|
||||
kofi: KOFI_URL,
|
||||
docsUrl: `${REPO}/blob/main/SPONSORS.md`,
|
||||
};
|
||||
```
|
||||
|
||||
Adding a sponsor = one PR touching this array **and** `SPONSORS.md` (keep them
|
||||
in lockstep; a test can assert they match).
|
||||
|
||||
### 6. In-app surface — Support page section + footer link
|
||||
|
||||
- A **Sponsors section** on the Support/About page: a logo grid grouped by
|
||||
tier that renders from `SPONSORS`, with a **tasteful empty state** ("Be the
|
||||
first to sponsor — your logo here" + an outlined slot) while the array is
|
||||
empty, a **"Become a sponsor"** button opening `SPONSOR_CONTACT.githubIssue`
|
||||
via the app's external-open helper (Tauri-safe), and a one-line explainer of
|
||||
what sponsors get, linking to `SPONSORS.md`.
|
||||
- A **compact footer link/icon** that opens that section. Keep it small and
|
||||
uniform with the other footer icons.
|
||||
- Logos: lazy-loaded, max-height capped, `aria-label`ed, `rel="noreferrer"`.
|
||||
|
||||
## How to replicate on another project (checklist)
|
||||
|
||||
1. Copy `SPONSORS.md`, `.github/FUNDING.yml`, `.github/ISSUE_TEMPLATE/sponsor.yml`.
|
||||
Find-and-replace the repo slug, handle, and funding URLs. Write your own
|
||||
honest funding story + "where your money goes".
|
||||
2. Add the README `## Sponsors` subsection with the `SPONSORS:START/END`
|
||||
markers and a nav entry.
|
||||
3. If the project has an app UI: add the `sponsors.js` config (empty array),
|
||||
a Sponsors section on your support/about screen, and a footer link. Wire
|
||||
the CTA to the issue-template route. If it's a library/CLI with no UI,
|
||||
skip this — the docs + FUNDING.yml carry it.
|
||||
4. Leave prices and any public contact as `<!-- OWNER: … -->` placeholders for
|
||||
the maintainer to fill. Don't invent amounts or publish a personal email.
|
||||
5. (Optional, recommended) Add the **value-moment donation prompt** — a
|
||||
throttled, opt-out-able "support us" nudge shown only after a real success,
|
||||
never more than rarely. That's a separate component; see the donation-
|
||||
moments implementation.
|
||||
6. Add a test that `sponsors.js` and `SPONSORS.md` list the same sponsors, so
|
||||
they can't drift.
|
||||
|
||||
## What NOT to do
|
||||
|
||||
- ❌ A sponsor-management SaaS or a third-party embed (breaks local-first,
|
||||
adds a dependency, holds credentials).
|
||||
- ❌ Bare `issues/new?body=…` prefill when blank issues are disabled (body is
|
||||
dropped — use `?template=`).
|
||||
- ❌ Inventing tier prices or publishing a personal contact email in an
|
||||
automated edit — leave `OWNER:` markers.
|
||||
- ❌ Gating features behind tiers, or nagging. The software stays free; the
|
||||
ask stays a rare, respected thank-you moment.
|
||||
|
||||
---
|
||||
|
||||
*Provenance: this is the system shipped in OmniVoice Studio — `SPONSORS.md`,
|
||||
the README Sponsors section, `.github/FUNDING.yml`, `.github/ISSUE_TEMPLATE/
|
||||
sponsor.yml`, `frontend/src/config/sponsors.js`, the Support-page Sponsors
|
||||
section, and the footer link. Copy them and adapt.*
|
||||
|
Before Width: | Height: | Size: 187 KiB After Width: | Height: | Size: 331 KiB |
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 420 KiB |
|
After Width: | Height: | Size: 342 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 420 KiB |
|
Before Width: | Height: | Size: 358 KiB After Width: | Height: | Size: 426 KiB |
|
After Width: | Height: | Size: 278 KiB |
|
Before Width: | Height: | Size: 295 KiB After Width: | Height: | Size: 347 KiB |
|
After Width: | Height: | Size: 183 KiB |
|
After Width: | Height: | Size: 666 KiB |
@@ -1,8 +1,8 @@
|
||||
# Update channels (Stable / Preview)
|
||||
|
||||
OmniVoice Studio auto-updates itself in the background. You choose **which
|
||||
builds** it offers you with the update channel in **Settings → About → Update
|
||||
channel**.
|
||||
builds** it offers you with the update channel in **Settings → Updates →
|
||||
Update channel**.
|
||||
|
||||
| Channel | What you get | Who it's for |
|
||||
|---------|--------------|--------------|
|
||||
@@ -25,6 +25,33 @@ manifest:
|
||||
Both manifests are signed with the same minisign key, so a tampered build is
|
||||
rejected regardless of channel.
|
||||
|
||||
## Your data during updates
|
||||
|
||||
Your voices, projects, history, and settings live in a SQLite database
|
||||
(`omnivoice.db`) outside the app bundle, so replacing the app never touches
|
||||
them. On the **first launch of an updated build**, if the new version needs a
|
||||
database schema upgrade, OmniVoice:
|
||||
|
||||
1. **Backs up the database first** — a consistent snapshot is written next to
|
||||
it as `omnivoice.db.backup-<version>-<n>` before any migration runs. The
|
||||
newest **3** backups are kept; older ones are pruned automatically.
|
||||
(Databases over 500 MB skip the snapshot, with a log line saying so.)
|
||||
2. **Stops instead of guessing** — if a migration fails midway, the app does
|
||||
*not* start on a half-migrated database and does *not* silently restore
|
||||
anything. It shows an error naming the backup path so you (or a support
|
||||
thread) decide: retry, report the issue, or roll back by replacing
|
||||
`omnivoice.db` with the backup.
|
||||
|
||||
**Settings → Updates** shows the timestamp of the latest backup, the release
|
||||
notes of any available update, and a **What's new** reader for the shipped
|
||||
changelog — all local, no extra network calls.
|
||||
|
||||
The Python environment (`.venv`) is also updated non-destructively: dependency
|
||||
drift after an app update is reconciled **in place** with `uv sync`, and a
|
||||
failed sync keeps the previous environment working. The venv is only ever
|
||||
rebuilt when its interpreter is *confirmed* broken (structural check + a
|
||||
direct probe) or when you explicitly use **Clean & Retry**.
|
||||
|
||||
## For maintainers — how previews are built
|
||||
|
||||
Preview builds come from **`main`**, two ways:
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { gotoMode } from './_helpers';
|
||||
|
||||
/**
|
||||
* Footer-clipping guard — "buttons hidden under the footer on small windows"
|
||||
* (owner report 2026-07-02; same class as #476/#504).
|
||||
*
|
||||
* The LogsFooter is a grid row of .app-container (see index.css), so page
|
||||
* content must physically end at the footer's top edge — no card, button, or
|
||||
* action bar may render underneath it. Verified at the app's minimum window
|
||||
* size (tauri.conf.json minWidth 900 × minHeight 600), where the old fixed
|
||||
* overlay + padding reservation clipped the bottom card row.
|
||||
*/
|
||||
|
||||
const MIN_WINDOW = { width: 900, height: 600 };
|
||||
|
||||
async function footerTop(page): Promise<number> {
|
||||
const footer = page.locator('.app-container .logs-footer');
|
||||
await expect(footer).toBeVisible();
|
||||
const box = await footer.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
return box!.y;
|
||||
}
|
||||
|
||||
test.describe('LogsFooter never covers page content @ 900x600', () => {
|
||||
test.use({ viewport: MIN_WINDOW });
|
||||
|
||||
test('gallery: bottom-most voice card stays above the collapsed footer', async ({ page }) => {
|
||||
await gotoMode(page, 'gallery');
|
||||
const cards = page.locator('.archetype-card');
|
||||
await expect(cards.first()).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
const top = await footerTop(page);
|
||||
// Scroll the last card into view — with the footer in the grid flow the
|
||||
// scroll container ends at the footer's top, so the card must fit fully
|
||||
// above it once scrolled.
|
||||
const last = cards.last();
|
||||
await last.scrollIntoViewIfNeeded();
|
||||
const box = await last.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
expect(box!.y + box!.height).toBeLessThanOrEqual(top + 1); // 1px AA tolerance
|
||||
});
|
||||
|
||||
test('expanded footer still cannot cover content — scroll container shrinks instead', async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoMode(page, 'gallery');
|
||||
const cards = page.locator('.archetype-card');
|
||||
await expect(cards.first()).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// Expand the logs panel (chevron toggle in the collapsed bar).
|
||||
const toggle = page.locator('.logs-footer [title], .logs-footer button').first();
|
||||
await toggle.click();
|
||||
await expect(page.locator('.logs-footer--open')).toBeVisible();
|
||||
|
||||
const top = await footerTop(page);
|
||||
const last = cards.last();
|
||||
await last.scrollIntoViewIfNeeded();
|
||||
const box = await last.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
expect(box!.y + box!.height).toBeLessThanOrEqual(top + 1);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "omnivoice-studio",
|
||||
"version": "0.3.8",
|
||||
"version": "0.3.10",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-only",
|
||||
"type": "module",
|
||||
@@ -39,6 +39,7 @@
|
||||
"@radix-ui/react-toggle": "^1.1.12",
|
||||
"@radix-ui/react-toggle-group": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.10",
|
||||
"@scalar/api-reference-react": "^0.9.52",
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
"@tanstack/react-query": "^5.101.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
|
||||
@@ -2941,7 +2941,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "omnivoice-studio"
|
||||
version = "0.3.8"
|
||||
version = "0.3.10"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"dirs-next",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "omnivoice-studio"
|
||||
version = "0.3.8"
|
||||
version = "0.3.10"
|
||||
description = "OmniVoice Studio – AI voice cloning & dubbing desktop app"
|
||||
authors = ["Debpalash"]
|
||||
license = "AGPL-3.0-only"
|
||||
|
||||
@@ -34,11 +34,55 @@ pub fn port_in_use(port: u16) -> bool {
|
||||
pub fn backend_healthy(port: u16) -> bool {
|
||||
let url = format!("http://127.0.0.1:{}/system/info", port);
|
||||
match ureq_get_with_timeout(&url, Duration::from_millis(500)) {
|
||||
Ok(body) => body.contains("\"model_checkpoint\"") || body.contains("\"data_dir\""),
|
||||
Ok(body) => is_omnivoice_body(&body),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_omnivoice_body(body: &str) -> bool {
|
||||
body.contains("\"model_checkpoint\"") || body.contains("\"data_dir\"")
|
||||
}
|
||||
|
||||
/// The `app_version` reported by the OmniVoice backend at :port.
|
||||
/// `None` when nothing OmniVoice answers there (port free, or a foreign
|
||||
/// process). `Some("")` when it IS our backend but predates the
|
||||
/// `app_version` field — callers treat that as stale.
|
||||
pub fn running_backend_version(port: u16) -> Option<String> {
|
||||
let url = format!("http://127.0.0.1:{}/system/info", port);
|
||||
let body = ureq_get_with_timeout(&url, Duration::from_millis(500)).ok()?;
|
||||
if !is_omnivoice_body(&body) {
|
||||
return None;
|
||||
}
|
||||
Some(parse_app_version(&body).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Extract `"app_version": "X"` from a /system/info body. String-sniff on one
|
||||
/// field (consistent with `backend_healthy`) — no JSON dependency needed.
|
||||
fn parse_app_version(body: &str) -> Option<String> {
|
||||
let key = "\"app_version\"";
|
||||
let rest = &body[body.find(key)? + key.len()..];
|
||||
let rest = rest[rest.find(':')? + 1..].trim_start();
|
||||
let rest = rest.strip_prefix('"')?;
|
||||
Some(rest[..rest.find('"')?].to_string())
|
||||
}
|
||||
|
||||
/// Whether a running backend's version matches THIS app build, comparing
|
||||
/// **base** versions (any `-N` pre-release suffix stripped from both sides) so
|
||||
/// a preview build `0.3.10-4` still attaches to its `0.3.10` backend.
|
||||
///
|
||||
/// Why this exists (the "bound port blocked the newer version" report): an
|
||||
/// orphaned backend from a *previous* version keeps answering health checks
|
||||
/// after an update, so "healthy" alone made the new UI silently attach to old
|
||||
/// backend code — every fix in the update appeared to change nothing. A
|
||||
/// version-mismatched (or unversioned) OmniVoice responder is stale by
|
||||
/// definition; callers kill it and spawn the bundled backend instead.
|
||||
pub fn same_app_version(running: &str) -> bool {
|
||||
fn base(v: &str) -> &str {
|
||||
v.split('-').next().unwrap_or(v).trim()
|
||||
}
|
||||
!running.is_empty() && base(running) == base(env!("CARGO_PKG_VERSION"))
|
||||
}
|
||||
|
||||
fn ureq_get_with_timeout(url: &str, timeout: Duration) -> Result<String, String> {
|
||||
let url = url.strip_prefix("http://").ok_or("only http:// supported")?;
|
||||
let (host_port, path) = match url.find('/') {
|
||||
@@ -365,4 +409,34 @@ mod tests {
|
||||
assert!(diag.contains("Interpreter present on disk: false"));
|
||||
assert!(diag.contains("Clean & Retry"), "must give an actionable hint");
|
||||
}
|
||||
|
||||
// ── stale-backend detection (the "bound port blocked the newer version"
|
||||
// report: a healthy orphan from a previous version must NOT be
|
||||
// attached to) ─────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn parse_app_version_reads_system_info_shape() {
|
||||
let body = r#"{"app_version":"0.3.9","data_dir":"/x","model_checkpoint":"k2"}"#;
|
||||
assert_eq!(parse_app_version(body).as_deref(), Some("0.3.9"));
|
||||
// whitespace after the colon is fine
|
||||
assert_eq!(
|
||||
parse_app_version(r#"{ "app_version" : "1.2.3" }"#).as_deref(),
|
||||
Some("1.2.3")
|
||||
);
|
||||
// pre-app_version backends and foreign bodies yield None
|
||||
assert_eq!(parse_app_version(r#"{"data_dir":"/x"}"#), None);
|
||||
assert_eq!(parse_app_version("<html>not json</html>"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_app_version_matches_current_build_and_rejects_stale() {
|
||||
let ours = env!("CARGO_PKG_VERSION");
|
||||
assert!(same_app_version(ours), "own version must attach");
|
||||
// preview stamp of the same base still attaches
|
||||
assert!(same_app_version(&format!("{}-7", ours)));
|
||||
// a different (older) release is stale
|
||||
assert!(!same_app_version("0.0.1"));
|
||||
// unversioned (pre-app_version backend) is stale by definition
|
||||
assert!(!same_app_version(""));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,10 +145,29 @@ pub fn retry_bootstrap(app: tauri::AppHandle, state: tauri::State<'_, BootstrapS
|
||||
set_stage(&stage_handle, BootstrapStage::Ready);
|
||||
return;
|
||||
}
|
||||
if crate::backend::backend_healthy(backend_port()) {
|
||||
log::info!("Port {} already serving OmniVoice backend — attaching", backend_port());
|
||||
set_stage(&stage_handle, BootstrapStage::Ready);
|
||||
return;
|
||||
match crate::backend::running_backend_version(backend_port()) {
|
||||
Some(v) if crate::backend::same_app_version(&v) => {
|
||||
log::info!(
|
||||
"Port {} already serving OmniVoice backend v{} — attaching",
|
||||
backend_port(), v
|
||||
);
|
||||
set_stage(&stage_handle, BootstrapStage::Ready);
|
||||
return;
|
||||
}
|
||||
Some(v) => {
|
||||
// A healthy-but-stale backend from a previous version (the
|
||||
// classic post-update orphan). Attaching would silently run
|
||||
// OLD backend code under the new UI — replace it instead.
|
||||
log::warn!(
|
||||
"Port {} serves a stale OmniVoice backend (v{} != app v{}) — replacing it",
|
||||
backend_port(),
|
||||
if v.is_empty() { "<unknown>" } else { v.as_str() },
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
);
|
||||
crate::backend::kill_orphan_on_port(backend_port());
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
if crate::backend::port_in_use(backend_port()) {
|
||||
log::warn!("Port {} in use — taking ownership", backend_port());
|
||||
@@ -216,24 +235,40 @@ pub fn spawn_backend_and_wait(app: &tauri::AppHandle, stage_handle: &Arc<Mutex<B
|
||||
{
|
||||
venv_heal_attempted = true;
|
||||
let venv_dir = crate::setup::env_root(app).join("project").join(".venv");
|
||||
log::warn!(
|
||||
"Backend exited with a broken-venv signature ({}) — removing {} and rebuilding (#314)",
|
||||
exit_info,
|
||||
venv_dir.display()
|
||||
);
|
||||
emit_log(
|
||||
app,
|
||||
"checking",
|
||||
"Backend failed because the Python environment is broken — rebuilding it automatically",
|
||||
);
|
||||
if quarantine_broken_venv(&venv_dir) {
|
||||
set_stage(stage_handle, BootstrapStage::Checking);
|
||||
continue 'bootstrap;
|
||||
// Data-safe guard (feat/safe-updates): the signature above
|
||||
// is text matching — confirm the venv is actually broken
|
||||
// (structural check + direct interpreter probe) before
|
||||
// destroying it. A healthy venv is never deleted.
|
||||
let structural = venv_structural_problem(&venv_dir);
|
||||
let probe = venv_interpreter_probe(&venv_python_path(&venv_dir));
|
||||
if venv_rebuild_justified(structural.as_deref(), probe) {
|
||||
log::warn!(
|
||||
"Backend exited with a broken-venv signature ({}; structural={:?}, probe={:?}) — removing {} and rebuilding (#314)",
|
||||
exit_info,
|
||||
structural,
|
||||
probe,
|
||||
venv_dir.display()
|
||||
);
|
||||
emit_log(
|
||||
app,
|
||||
"checking",
|
||||
"Backend failed because the Python environment is broken — rebuilding it automatically",
|
||||
);
|
||||
if quarantine_broken_venv(&venv_dir) {
|
||||
set_stage(stage_handle, BootstrapStage::Checking);
|
||||
continue 'bootstrap;
|
||||
}
|
||||
log::error!(
|
||||
"Could not remove broken venv at {} — surfacing the failure",
|
||||
venv_dir.display()
|
||||
);
|
||||
} else {
|
||||
log::warn!(
|
||||
"Backend exit matched a broken-venv signature ({}) but the venv at {} probes healthy — keeping it (data-safe guard) and surfacing the real error",
|
||||
exit_info,
|
||||
venv_dir.display()
|
||||
);
|
||||
}
|
||||
log::error!(
|
||||
"Could not remove broken venv at {} — surfacing the failure",
|
||||
venv_dir.display()
|
||||
);
|
||||
}
|
||||
let msg = if err_tail.is_empty() {
|
||||
format!("Backend process exited ({}) — no error output captured", exit_info)
|
||||
@@ -451,6 +486,16 @@ fn refresh_project_manifests(resource_dir: &Path, project_dir: &Path) -> bool {
|
||||
log::warn!("Could not refresh pyproject.toml from bundle: {}", e);
|
||||
}
|
||||
}
|
||||
// Keep the shipped CHANGELOG.md current too — the backend's
|
||||
// GET /api/settings/changelog (Settings → Updates "What's new" viewer)
|
||||
// reads it from the project root, so an upgraded app must not show the
|
||||
// notes from whenever the install was first created. Best-effort.
|
||||
let res_changelog = res_root.join("CHANGELOG.md");
|
||||
if res_changelog.is_file() {
|
||||
if let Err(e) = fs::copy(&res_changelog, project_dir.join("CHANGELOG.md")) {
|
||||
log::warn!("Could not refresh CHANGELOG.md from bundle: {}", e);
|
||||
}
|
||||
}
|
||||
if !res_uvlock.is_file() {
|
||||
return false;
|
||||
}
|
||||
@@ -503,6 +548,26 @@ Fix: install Python 3.11+ from https://www.python.org/downloads/ (tick \"Add to
|
||||
then relaunch — OmniVoice will use your system Python. Advanced: set \
|
||||
UV_PYTHON_INSTALL_MIRROR to a reachable mirror (see docs/install/troubleshooting.md).";
|
||||
|
||||
/// #889: PyTorch stopped shipping macOS x86_64 wheels after 2.2.x, and the
|
||||
/// locked dependency set needs a far newer torch (transformers 5.x requires
|
||||
/// ≥2.6) — so `uv sync` can never resolve on an Intel Mac and the local
|
||||
/// backend is unsupported there. Surfaced *before* any venv create/sync so
|
||||
/// Intel-Mac users see this immediately instead of a raw resolver error after
|
||||
/// minutes of downloads. Deliberately NOT checked when a healthy venv already
|
||||
/// exists, so any pre-torch-bump install that still works keeps working.
|
||||
const INTEL_MAC_UNSUPPORTED_MSG: &str =
|
||||
"Intel Macs can't run the local AI backend — PyTorch no longer ships Intel-Mac (macOS x86_64) \
|
||||
builds, so the Python environment can't be installed on this machine. The app UI works, but local \
|
||||
voice generation is unavailable here. Options: point the app at a remote backend running on \
|
||||
another machine (Settings → Sharing → Remote backend), or use an Apple Silicon Mac / Windows / \
|
||||
Linux. See docs/install/macos.md (#889).";
|
||||
|
||||
/// True on macOS x86_64 builds (#889). `cfg!` (not `#[cfg]`) keeps the guard
|
||||
/// compiled — and the message testable — on every platform.
|
||||
fn intel_mac_backend_unsupported() -> bool {
|
||||
cfg!(all(target_os = "macos", target_arch = "x86_64"))
|
||||
}
|
||||
|
||||
/// Strip the bundled-runtime Python env vars before spawning any `uv`/venv/pip
|
||||
/// or venv-python subprocess (#144). On the Linux AppImage, the bundled runtime
|
||||
/// exports PYTHONHOME / PYTHONPATH (and sometimes LD_LIBRARY_PATH) pointing at
|
||||
@@ -680,6 +745,252 @@ pub fn backend_exit_indicates_broken_venv(exit_info: &str, err_tail: &str) -> bo
|
||||
|| exit_info.trim_end().ends_with(": 106")
|
||||
}
|
||||
|
||||
/// Data-safe guard for the destructive half of the #314 self-heal
|
||||
/// (feat/safe-updates): an exit-*signature* match alone is text matching on a
|
||||
/// stderr tail — before it is allowed to delete a multi-GB venv, the venv must
|
||||
/// be *confirmed* broken by direct evidence:
|
||||
///
|
||||
/// - a structural problem found by [`venv_structural_problem`] (missing
|
||||
/// pyvenv.cfg / missing or dangling python) is definitive → rebuild;
|
||||
/// - otherwise the venv's own interpreter is probed
|
||||
/// ([`venv_interpreter_probe`]): if it provably starts and imports its
|
||||
/// stdlib (`Some(true)`), the venv is NOT the problem — deleting it would
|
||||
/// destroy a working ~6 GB install to "fix" an unrelated crash, so the
|
||||
/// rebuild is refused and the real error is surfaced instead;
|
||||
/// - a failed probe (`Some(false)`) or one that couldn't even spawn (`None`)
|
||||
/// confirms the interpreter is unrunnable → rebuild.
|
||||
pub fn venv_rebuild_justified(
|
||||
structural_problem: Option<&str>,
|
||||
interpreter_probe: Option<bool>,
|
||||
) -> bool {
|
||||
if structural_problem.is_some() {
|
||||
return true;
|
||||
}
|
||||
!matches!(interpreter_probe, Some(true))
|
||||
}
|
||||
|
||||
/// Run the venv's python directly to check the interpreter can bootstrap its
|
||||
/// stdlib. `Some(true)` = healthy, `Some(false)` = starts but fails (e.g. the
|
||||
/// venv launcher's exit 106, or the 'encodings' bootstrap abort), `None` = the
|
||||
/// binary couldn't be spawned at all. Env is scrubbed (#144) so an AppImage's
|
||||
/// bundled-Python vars can't fake a failure on a healthy venv.
|
||||
fn venv_interpreter_probe(venv_py: &Path) -> Option<bool> {
|
||||
let mut cmd = Command::new(venv_py);
|
||||
scrub_python_env(&mut cmd);
|
||||
cmd.args(["-c", "import encodings"])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
match cmd.status() {
|
||||
Ok(status) => Some(status.success()),
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Linux/Windows: cuDNN 8 compat side-load ────────────────────────────────
|
||||
//
|
||||
// This used to live ONLY in scripts/setup.py, run via `bun run setup:api`
|
||||
// (dev loop only). Neither `scripts/` nor `setup.py` is bundled as a Tauri
|
||||
// resource (see tauri.conf.json's `bundle.resources`), and the real
|
||||
// packaged-install bootstrap path below never called that script — so every
|
||||
// actual installed user with an NVIDIA GPU got a venv with no cuDNN 8 compat
|
||||
// libs (#827). Ported here so the real app-data venv gets them, matching what
|
||||
// backend/main.py's cuDNN preload (#255) expects to find.
|
||||
//
|
||||
// (An earlier draft of #869 also ported setup.py's VC++ Redistributable
|
||||
// check. Dropped as dead code per review: the Tauri exe itself dynamically
|
||||
// links the MSVC CRT, so `LoadLibraryA("vcruntime140.dll")` from a *running*
|
||||
// app is a tautology — and torch's real failure mode is msvcp140.dll inside
|
||||
// the venv python process, not this one.)
|
||||
|
||||
/// Cross-platform pin, matches the wheel scripts/setup.py has always used —
|
||||
/// keep both in sync if this ever needs to move.
|
||||
const CUDNN8_COMPAT_PIN: &str = "nvidia-cudnn-cu12==8.9.7.29";
|
||||
|
||||
/// The `cudnn8_compat/` install target inside a venv's site-packages,
|
||||
/// mirroring `_find_compat_dir()` in scripts/setup.py exactly (and what
|
||||
/// backend/main.py's ctypes preload looks for). Linux's path is versioned by
|
||||
/// the venv's own Python (`lib/pythonX.Y/site-packages`), so this queries the
|
||||
/// live interpreter rather than assuming the version `uv venv` was asked for
|
||||
/// — the system-Python fallback path can hand back a different one.
|
||||
fn cudnn8_compat_dir(venv_dir: &Path, venv_py: &Path) -> Option<PathBuf> {
|
||||
if cfg!(windows) {
|
||||
return Some(venv_dir.join("Lib").join("site-packages").join("cudnn8_compat"));
|
||||
}
|
||||
let out = Command::new(venv_py)
|
||||
.args(["-c", "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')"])
|
||||
.output()
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let pyver = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
Some(
|
||||
venv_dir
|
||||
.join("lib")
|
||||
.join(format!("python{}", pyver))
|
||||
.join("site-packages")
|
||||
.join("cudnn8_compat"),
|
||||
)
|
||||
}
|
||||
|
||||
/// The subdirectory (within `cudnn8_compat/`) actually holding the shared
|
||||
/// libraries, and the filename pattern that counts as "installed" — same
|
||||
/// glob scripts/setup.py's `_count_cudnn8_libs()` uses.
|
||||
fn cudnn8_lib_dir_and_pattern(compat_dir: &Path) -> (PathBuf, &'static str, &'static str) {
|
||||
if cfg!(windows) {
|
||||
(compat_dir.join("nvidia").join("cudnn").join("bin"), "cudnn", "64_8.dll")
|
||||
} else {
|
||||
(compat_dir.join("nvidia").join("cudnn").join("lib"), "libcudnn", ".so.8")
|
||||
}
|
||||
}
|
||||
|
||||
fn count_cudnn8_libs(lib_dir: &Path, prefix: &str, suffix: &str) -> usize {
|
||||
fs::read_dir(lib_dir)
|
||||
.map(|entries| {
|
||||
entries
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| {
|
||||
let name = e.file_name();
|
||||
let name = name.to_string_lossy();
|
||||
name.starts_with(prefix) && name.ends_with(suffix)
|
||||
})
|
||||
.count()
|
||||
})
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Verdict from probing the venv's torch (see `CUDNN8_CUDA_PROBE_PY`).
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum CudnnProbe {
|
||||
/// CUDA torch build with a live CUDA device: side-load cuDNN 8.
|
||||
Install,
|
||||
/// Definitive no — CPU-only box, no NVIDIA device, or a ROCm torch build
|
||||
/// (HIP reports `torch.cuda.is_available() == True`, but the ~700 MB CUDA
|
||||
/// `nvidia-cudnn-cu12` wheel is pure waste on an AMD box, #124). Cache it
|
||||
/// so the synchronous `import torch` never taxes this venv's launches
|
||||
/// again.
|
||||
CacheNegative,
|
||||
/// The probe didn't run cleanly (torch missing / broken venv / unexpected
|
||||
/// output) — skip this launch but do NOT cache, so a transient failure
|
||||
/// can't permanently disable the side-load on a real CUDA machine.
|
||||
SkipNoCache,
|
||||
}
|
||||
|
||||
/// Prints exactly one verdict: `hip` (ROCm build — checked BEFORE
|
||||
/// `cuda.is_available()`, which HIP spoofs), `cuda` (CUDA build with a live
|
||||
/// device), or `none`.
|
||||
const CUDNN8_CUDA_PROBE_PY: &str = "import torch; print('hip' if getattr(torch.version, 'hip', None) else 'cuda' if torch.cuda.is_available() else 'none')";
|
||||
|
||||
fn classify_cuda_probe(stdout: &str) -> CudnnProbe {
|
||||
match stdout.trim() {
|
||||
"cuda" => CudnnProbe::Install,
|
||||
"hip" | "none" => CudnnProbe::CacheNegative,
|
||||
_ => CudnnProbe::SkipNoCache,
|
||||
}
|
||||
}
|
||||
|
||||
/// Marker recording a cached negative CUDA probe for this venv. Lives inside
|
||||
/// `.venv/` so a full venv rebuild ("Clean & Retry") clears it implicitly;
|
||||
/// anything that re-syncs the venv in place must call
|
||||
/// `invalidate_cudnn8_probe_cache` (the torch build may have changed).
|
||||
fn cudnn8_probe_marker(venv_dir: &Path) -> PathBuf {
|
||||
venv_dir.join(".cudnn8_probe_negative")
|
||||
}
|
||||
|
||||
/// Call after ANY operation that can change the venv's torch build (drift /
|
||||
/// repair / first-run `uv sync`, ROCm reinstall) so the next launch re-probes
|
||||
/// exactly once per venv lifetime.
|
||||
fn invalidate_cudnn8_probe_cache(venv_dir: &Path) {
|
||||
let _ = fs::remove_file(cudnn8_probe_marker(venv_dir));
|
||||
}
|
||||
|
||||
/// CTranslate2 (faster-whisper / WhisperX) needs cuDNN 8, but PyTorch 2.8+
|
||||
/// pulls in cuDNN 9. Side-loads cuDNN 8 into `cudnn8_compat/` next to the
|
||||
/// venv's other packages — backend/main.py preloads it via ctypes at import
|
||||
/// time (#255). Skipped entirely on macOS (no CUDA), on any machine without
|
||||
/// a CUDA device, and on ROCm torch builds (#124) — and a negative probe is
|
||||
/// cached per venv so CPU/AMD installs never pay the synchronous
|
||||
/// `import torch` more than once (#869 review).
|
||||
fn ensure_cudnn8_compat<R: tauri::Runtime>(
|
||||
app: &tauri::AppHandle<R>,
|
||||
uv_path: &Path,
|
||||
venv_py: &Path,
|
||||
venv_dir: &Path,
|
||||
project_dir: &Path,
|
||||
) {
|
||||
if cfg!(target_os = "macos") {
|
||||
return;
|
||||
}
|
||||
// Cached negative from a previous launch (CPU/Intel/AMD — the majority of
|
||||
// installs): return before spending any subprocess. Cleared whenever the
|
||||
// venv is rebuilt or re-synced.
|
||||
let marker = cudnn8_probe_marker(venv_dir);
|
||||
if marker.is_file() {
|
||||
return;
|
||||
}
|
||||
let Some(compat_dir) = cudnn8_compat_dir(venv_dir, venv_py) else {
|
||||
log::warn!("cuDNN 8 compat: could not resolve venv site-packages layout — skipping");
|
||||
return;
|
||||
};
|
||||
let (lib_dir, prefix, suffix) = cudnn8_lib_dir_and_pattern(&compat_dir);
|
||||
if count_cudnn8_libs(&lib_dir, prefix, suffix) >= 5 {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut cuda_check = Command::new(venv_py);
|
||||
scrub_python_env(&mut cuda_check);
|
||||
let verdict = cuda_check
|
||||
.args(["-c", CUDNN8_CUDA_PROBE_PY])
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||
.unwrap_or_default();
|
||||
match classify_cuda_probe(&verdict) {
|
||||
CudnnProbe::Install => {}
|
||||
CudnnProbe::CacheNegative => {
|
||||
log::info!(
|
||||
"cuDNN 8 compat: torch probe says '{}' — caching the negative result for this venv",
|
||||
verdict
|
||||
);
|
||||
let _ = fs::write(&marker, format!("{}\n", verdict));
|
||||
return;
|
||||
}
|
||||
CudnnProbe::SkipNoCache => {
|
||||
log::warn!("cuDNN 8 compat: torch probe failed — skipping this launch (not cached)");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
log::info!("Installing cuDNN 8 compatibility libraries for CTranslate2 (#255)");
|
||||
emit_log(app, "installing_deps", "Installing cuDNN 8 compatibility libraries for CUDA transcription…");
|
||||
let mut cmd = Command::new(uv_path);
|
||||
scrub_python_env(&mut cmd);
|
||||
apply_uv_http_env(&mut cmd);
|
||||
cmd.arg("pip")
|
||||
.arg("install")
|
||||
.arg("--no-deps")
|
||||
.arg("--target")
|
||||
.arg(&compat_dir)
|
||||
.arg("--python")
|
||||
.arg(venv_py)
|
||||
.arg(CUDNN8_COMPAT_PIN)
|
||||
.current_dir(project_dir);
|
||||
match run_streaming(app, "installing_deps", &mut cmd) {
|
||||
Ok(ref s) if s.success() => {
|
||||
log::info!("cuDNN 8 compat installed: {} libraries", count_cudnn8_libs(&lib_dir, prefix, suffix));
|
||||
}
|
||||
other => {
|
||||
log::warn!("cuDNN 8 compat install failed ({:?}) — CUDA transcription may not work", other);
|
||||
emit_log(
|
||||
app, "installing_deps",
|
||||
"cuDNN 8 compat install failed — CUDA-based transcription may not work. \
|
||||
Retry from Settings, or see docs/install/troubleshooting.md.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Prepare (and on first run, create) the Python venv that will host the
|
||||
/// backend process. Returns (venv_python, backend_source_dir).
|
||||
pub fn ensure_venv_ready<R: tauri::Runtime>(app: &tauri::AppHandle<R>, progress: Option<&Arc<Mutex<BootstrapStage>>>) -> Option<(PathBuf, PathBuf)> {
|
||||
@@ -829,6 +1140,15 @@ manually, then relaunch.",
|
||||
// #307: the source dirs above track the bundle, so the
|
||||
// dependency manifests must too — otherwise an upgrade runs
|
||||
// new code against a venv that predates newly added deps.
|
||||
//
|
||||
// Data-safety note (feat/safe-updates): this drift path — and
|
||||
// the repair path below — reconcile the venv IN PLACE via
|
||||
// `uv sync` (add/remove packages inside `.venv`); neither ever
|
||||
// deletes the venv, and a failed sync keeps the old venv (see
|
||||
// the error arm). The only venv-destroying paths are the #314
|
||||
// broken-venv heal (guarded by venv_rebuild_justified: a venv
|
||||
// whose interpreter probes healthy is never deleted) and the
|
||||
// explicit user-initiated "Clean & Retry".
|
||||
if refresh_project_manifests(res, &project_dir) {
|
||||
log::info!("uv.lock changed since the venv was synced — running uv sync (#307)");
|
||||
if let Some(p) = progress {
|
||||
@@ -851,6 +1171,9 @@ manually, then relaunch.",
|
||||
match run_streaming(app, "installing_deps", &mut drift_cmd) {
|
||||
Ok(ref s) if s.success() => {
|
||||
log::info!("Dependency drift sync complete (#307)");
|
||||
// The torch build may have changed — let
|
||||
// ensure_cudnn8_compat() re-probe once.
|
||||
invalidate_cudnn8_probe_cache(&venv_dir);
|
||||
}
|
||||
other => {
|
||||
// Don't brick a previously-working install
|
||||
@@ -870,6 +1193,10 @@ the existing venv; newly added dependencies may be missing (#307)",
|
||||
}
|
||||
}
|
||||
}
|
||||
match resolve_uv(app, &app_data, None) {
|
||||
Ok(uv_path) => ensure_cudnn8_compat(app, &uv_path, &venv_py, &venv_dir, &project_dir),
|
||||
Err(e) => log::warn!("cuDNN 8 compat: could not resolve uv: {}", e),
|
||||
}
|
||||
return Some((venv_py, backend_dir));
|
||||
}
|
||||
if matches!(uvicorn_check, Ok(ref s) if s.success()) {
|
||||
@@ -891,6 +1218,12 @@ the existing venv; newly added dependencies may be missing (#307)",
|
||||
venv_dir.display()
|
||||
);
|
||||
}
|
||||
// #889: a repair sync on an Intel Mac would just re-fail on the torch
|
||||
// resolution — surface the real reason instead of the raw uv error.
|
||||
if intel_mac_backend_unsupported() {
|
||||
fail(progress, INTEL_MAC_UNSUPPORTED_MSG);
|
||||
return None;
|
||||
}
|
||||
if let Some(p) = progress {
|
||||
set_stage(p, BootstrapStage::InstallingDeps);
|
||||
}
|
||||
@@ -915,6 +1248,10 @@ the existing venv; newly added dependencies may be missing (#307)",
|
||||
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()) {
|
||||
// The repair sync may have changed the torch build — clear any
|
||||
// cached negative CUDA probe so ensure_cudnn8_compat() below
|
||||
// re-checks once.
|
||||
invalidate_cudnn8_probe_cache(&venv_dir);
|
||||
// #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
|
||||
@@ -980,20 +1317,30 @@ the existing venv; newly added dependencies may be missing (#307)",
|
||||
return None;
|
||||
}
|
||||
}
|
||||
ensure_cudnn8_compat(app, &uv_path, &venv_py, &venv_dir, &project_dir);
|
||||
return Some((venv_py, backend_dir));
|
||||
}
|
||||
fail(progress, &format!("Repair uv sync failed: {:?}", repair_status));
|
||||
return None;
|
||||
}
|
||||
|
||||
// #889: pre-check before creating a venv or attempting any `uv sync`. A
|
||||
// first-run install on an Intel Mac can only ever end in an unresolvable
|
||||
// torch dependency, so fail fast with the honest message — before any
|
||||
// download starts.
|
||||
if intel_mac_backend_unsupported() {
|
||||
fail(progress, INTEL_MAC_UNSUPPORTED_MSG);
|
||||
return None;
|
||||
}
|
||||
|
||||
let resource_dir = app.path().resource_dir().ok()?;
|
||||
let flat = resource_dir.clone();
|
||||
let up2 = resource_dir.join("_up_").join("_up_");
|
||||
|
||||
let (resource_pyproject, resource_uvlock, resource_readme, resource_omnivoice, resource_backend) = if flat.join("pyproject.toml").is_file() {
|
||||
(flat.join("pyproject.toml"), flat.join("uv.lock"), flat.join("README.md"), flat.join("omnivoice"), flat.join("backend"))
|
||||
let (resource_pyproject, resource_uvlock, resource_readme, resource_changelog, resource_omnivoice, resource_backend) = if flat.join("pyproject.toml").is_file() {
|
||||
(flat.join("pyproject.toml"), flat.join("uv.lock"), flat.join("README.md"), flat.join("CHANGELOG.md"), flat.join("omnivoice"), flat.join("backend"))
|
||||
} else if up2.join("pyproject.toml").is_file() {
|
||||
(up2.join("pyproject.toml"), up2.join("uv.lock"), up2.join("README.md"), up2.join("omnivoice"), up2.join("backend"))
|
||||
(up2.join("pyproject.toml"), up2.join("uv.lock"), up2.join("README.md"), up2.join("CHANGELOG.md"), up2.join("omnivoice"), up2.join("backend"))
|
||||
} else {
|
||||
fail(progress, &format!(
|
||||
"Missing bootstrap resources — checked flat={} and _up_={}",
|
||||
@@ -1030,6 +1377,12 @@ the existing venv; newly added dependencies may be missing (#307)",
|
||||
let _ = fs::write(project_dir.join("README.md"), "# OmniVoice\n");
|
||||
log::warn!("No README.md in bundle — created stub");
|
||||
}
|
||||
// Shipped release notes for the Settings → Updates "What's new" viewer
|
||||
// (GET /api/settings/changelog). Optional: the endpoint degrades to
|
||||
// `available: false` when absent.
|
||||
if resource_changelog.is_file() {
|
||||
let _ = fs::copy(&resource_changelog, project_dir.join("CHANGELOG.md"));
|
||||
}
|
||||
let omnivoice_dir = project_dir.join("omnivoice");
|
||||
if resource_omnivoice.is_dir() {
|
||||
if let Err(e) = copy_dir_recursive(&resource_omnivoice, &omnivoice_dir) {
|
||||
@@ -1231,6 +1584,11 @@ mirror in Settings → region/mirrors (see docs/install/troubleshooting.md).".to
|
||||
}
|
||||
}
|
||||
|
||||
// Fresh venv, fresh sync: a stale negative-probe marker (e.g. a venv
|
||||
// recreated in place over a previous one) must not suppress the probe.
|
||||
invalidate_cudnn8_probe_cache(&venv_dir);
|
||||
ensure_cudnn8_compat(app, &uv_path, &venv_py, &venv_dir, &project_dir);
|
||||
|
||||
// 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
|
||||
@@ -1243,7 +1601,12 @@ mirror in Settings → region/mirrors (see docs/install/troubleshooting.md).".to
|
||||
apply_uv_http_env(&mut rocm_cmd);
|
||||
rocm_cmd.args(rocm_torch_reinstall_args(&rocm_url)).current_dir(&project_dir);
|
||||
let rocm_status = run_streaming(app, "installing_deps", &mut rocm_cmd);
|
||||
if !matches!(rocm_status, Ok(ref s) if s.success()) {
|
||||
if matches!(rocm_status, Ok(ref s) if s.success()) {
|
||||
// The torch build just switched to ROCm: re-probe on the next
|
||||
// launch (it reports 'hip' and re-caches the negative, so the
|
||||
// CUDA cuDNN wheel is never fetched on an AMD box, #124).
|
||||
invalidate_cudnn8_probe_cache(&venv_dir);
|
||||
} else {
|
||||
log::warn!("ROCm torch reinstall failed ({:?}); keeping default torch build", rocm_status);
|
||||
emit_log(
|
||||
app, "installing_deps",
|
||||
@@ -1278,6 +1641,18 @@ mod tests {
|
||||
assert!(removed.contains("LD_LIBRARY_PATH"), "LD_LIBRARY_PATH must be scrubbed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intel_mac_message_keeps_its_contract_phrases() {
|
||||
// #889: BootstrapSplash.jsx routes this failure to the localized
|
||||
// `bootstrap.hint_intel_mac` hint by matching the lead phrase, and the
|
||||
// message must keep pointing users at the docs + the remote-backend
|
||||
// escape hatch. Guard those load-bearing fragments against rewording.
|
||||
assert!(INTEL_MAC_UNSUPPORTED_MSG.contains("Intel Macs can't run the local AI backend"));
|
||||
assert!(INTEL_MAC_UNSUPPORTED_MSG.contains("docs/install/macos.md"));
|
||||
assert!(INTEL_MAC_UNSUPPORTED_MSG.contains("Sharing → Remote backend"));
|
||||
assert!(INTEL_MAC_UNSUPPORTED_MSG.contains("#889"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_uv_http_env_sets_timeouts_and_retries() {
|
||||
let mut cmd = Command::new("uv");
|
||||
@@ -1513,6 +1888,42 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn venv_rebuild_requires_confirmed_breakage() {
|
||||
// feat/safe-updates: an exit-signature match alone must not destroy a
|
||||
// venv. A structural problem is definitive evidence → rebuild.
|
||||
assert!(venv_rebuild_justified(Some("pyvenv.cfg is missing"), Some(true)));
|
||||
assert!(venv_rebuild_justified(Some("python executable is missing"), None));
|
||||
// No structural problem + interpreter provably healthy → NEVER delete
|
||||
// (the data-safety property this guard exists for).
|
||||
assert!(!venv_rebuild_justified(None, Some(true)));
|
||||
// Interpreter starts but can't bootstrap (exit 106 / encodings abort)
|
||||
// → confirmed broken → rebuild.
|
||||
assert!(venv_rebuild_justified(None, Some(false)));
|
||||
// Interpreter can't even be spawned → confirmed unrunnable → rebuild.
|
||||
assert!(venv_rebuild_justified(None, None));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn venv_interpreter_probe_maps_exit_status_and_spawn_failure() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
// A nonexistent binary can't spawn → None (still justifies a rebuild).
|
||||
let missing = std::env::temp_dir().join("omnivoice-test-probe-missing-python");
|
||||
assert_eq!(venv_interpreter_probe(&missing), None);
|
||||
// Fake interpreters (exit 0 = healthy, exit 106 = the venv launcher's
|
||||
// "No pyvenv.cfg" code) exercise the status mapping without needing a
|
||||
// real python on the test runner.
|
||||
let dir = temp_venv_dir("probe");
|
||||
for (name, code, expected) in [("py-ok", 0, Some(true)), ("py-106", 106, Some(false))] {
|
||||
let script = dir.join(name);
|
||||
fs::write(&script, format!("#!/bin/sh\nexit {}\n", code)).unwrap();
|
||||
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
assert_eq!(venv_interpreter_probe(&script), expected, "{}", name);
|
||||
}
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #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.
|
||||
@@ -1549,4 +1960,101 @@ mod tests {
|
||||
let v82: (u32, u32) = (82, 0);
|
||||
assert!(!(v82.0 >= 75 && v82.0 < 80), "82.x (pre-fix version) must NOT satisfy <80");
|
||||
}
|
||||
|
||||
// -- cuDNN 8 compat side-load (real prod bootstrap, not just dev) --------
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn cudnn8_compat_dir_matches_backend_main_py_layout() {
|
||||
// backend/main.py hardcodes `.venv/Lib/site-packages/cudnn8_compat` on
|
||||
// Windows (no pyver in the path) -- this must match exactly or the
|
||||
// ctypes preload never finds what we just installed.
|
||||
let venv_dir = PathBuf::from(r"C:\fake\project\.venv");
|
||||
let venv_py = venv_python_path(&venv_dir);
|
||||
let dir = cudnn8_compat_dir(&venv_dir, &venv_py).expect("windows path is pure, no subprocess needed");
|
||||
assert_eq!(dir, venv_dir.join("Lib").join("site-packages").join("cudnn8_compat"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cudnn8_lib_dir_and_pattern_matches_platform_glob() {
|
||||
// Mirrors scripts/setup.py's _cudnn8_lib_dir()/_count_cudnn8_libs() and
|
||||
// backend/main.py's _cudnn8_glob exactly -- a divergence here means the
|
||||
// Rust installer and the Python ctypes preload disagree on what counts
|
||||
// as "installed".
|
||||
let compat_dir = PathBuf::from("compat");
|
||||
let (lib_dir, prefix, suffix) = cudnn8_lib_dir_and_pattern(&compat_dir);
|
||||
if cfg!(windows) {
|
||||
assert_eq!(lib_dir, compat_dir.join("nvidia").join("cudnn").join("bin"));
|
||||
assert_eq!((prefix, suffix), ("cudnn", "64_8.dll"));
|
||||
assert!("cudnn_ops64_8.dll".starts_with(prefix) && "cudnn_ops64_8.dll".ends_with(suffix));
|
||||
} else {
|
||||
assert_eq!(lib_dir, compat_dir.join("nvidia").join("cudnn").join("lib"));
|
||||
assert_eq!((prefix, suffix), ("libcudnn", ".so.8"));
|
||||
assert!("libcudnn_ops.so.8".starts_with(prefix) && "libcudnn_ops.so.8".ends_with(suffix));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn count_cudnn8_libs_counts_only_matching_files() {
|
||||
let dir = temp_venv_dir("cudnn-count");
|
||||
let (_, prefix, suffix) = cudnn8_lib_dir_and_pattern(Path::new(""));
|
||||
// Two real matches...
|
||||
fs::write(dir.join(format!("{prefix}_a{suffix}")), b"").unwrap();
|
||||
fs::write(dir.join(format!("{prefix}_b{suffix}")), b"").unwrap();
|
||||
// ...one file that only matches the prefix, one that only matches the
|
||||
// suffix, and one totally unrelated file -- none of these should count.
|
||||
fs::write(dir.join(format!("{prefix}_only_prefix.txt")), b"").unwrap();
|
||||
fs::write(dir.join(format!("unrelated{suffix}")), b"").unwrap();
|
||||
fs::write(dir.join("readme.md"), b"").unwrap();
|
||||
assert_eq!(count_cudnn8_libs(&dir, prefix, suffix), 2);
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn count_cudnn8_libs_zero_when_dir_missing() {
|
||||
// First-run case: the compat dir doesn't exist yet -- must report 0,
|
||||
// not error, so the caller's ">= 5" threshold cleanly triggers install.
|
||||
let missing = std::env::temp_dir().join("omnivoice-test-cudnn8-does-not-exist");
|
||||
let _ = fs::remove_dir_all(&missing);
|
||||
assert_eq!(count_cudnn8_libs(&missing, "cudnn", "64_8.dll"), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_cuda_probe_gates_install_on_cuda_only() {
|
||||
// 'cuda' (CUDA build + live device) is the ONLY verdict that triggers
|
||||
// the ~700 MB nvidia-cudnn-cu12 download.
|
||||
assert_eq!(classify_cuda_probe("cuda"), CudnnProbe::Install);
|
||||
assert_eq!(classify_cuda_probe("cuda\n"), CudnnProbe::Install); // print() newline
|
||||
// ROCm torch spoofs torch.cuda.is_available(); the probe reports
|
||||
// 'hip' first so opt-in AMD installs (#124) never fetch the CUDA
|
||||
// wheel -- and the negative is cacheable.
|
||||
assert_eq!(classify_cuda_probe("hip\n"), CudnnProbe::CacheNegative);
|
||||
// Plain no-CUDA box: cache so `import torch` never re-runs at launch.
|
||||
assert_eq!(classify_cuda_probe("none"), CudnnProbe::CacheNegative);
|
||||
// Broken venv / import error / garbage: skip this launch but never
|
||||
// cache -- a transient failure must not wedge a real CUDA machine.
|
||||
assert_eq!(classify_cuda_probe(""), CudnnProbe::SkipNoCache);
|
||||
assert_eq!(
|
||||
classify_cuda_probe("Traceback (most recent call last):"),
|
||||
CudnnProbe::SkipNoCache
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cudnn8_probe_cache_marker_roundtrip() {
|
||||
let venv_dir = temp_venv_dir("cudnn-probe-cache");
|
||||
let marker = cudnn8_probe_marker(&venv_dir);
|
||||
// Must live INSIDE the venv so a full rebuild clears it implicitly.
|
||||
assert!(marker.starts_with(&venv_dir));
|
||||
assert!(!marker.is_file());
|
||||
fs::write(&marker, "none\n").unwrap();
|
||||
assert!(marker.is_file());
|
||||
// Re-sync invalidation: marker gone, next launch re-probes.
|
||||
invalidate_cudnn8_probe_cache(&venv_dir);
|
||||
assert!(!marker.is_file());
|
||||
// Idempotent when the marker is already absent.
|
||||
invalidate_cudnn8_probe_cache(&venv_dir);
|
||||
assert!(!marker.is_file());
|
||||
let _ = fs::remove_dir_all(&venv_dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,43 +257,121 @@ fn hf_hub_cache_dir() -> PathBuf {
|
||||
|
||||
use enigo::{Direction, Enigo, Key, Keyboard, Settings as EnigoSettings};
|
||||
|
||||
/// Error-kind builder the dictation widget switches on. Kinds are a plain
|
||||
/// string prefix ("a11y:" | "clipboard:" | "paste:") so the JS side can do
|
||||
/// `err.split(':')[0]` without a serde enum crossing the IPC boundary.
|
||||
fn kind_err(kind: &str, detail: impl std::fmt::Display) -> String {
|
||||
format!("{kind}:{detail}")
|
||||
}
|
||||
|
||||
/// How long the transcript must sit on the clipboard before the user's
|
||||
/// previous clipboard is restored: ~300ms covers slow paste consumers
|
||||
/// (Electron apps, remote desktops) without being user-noticeable.
|
||||
const CLIPBOARD_RESTORE_DELAY: Duration = Duration::from_millis(300);
|
||||
|
||||
/// macOS Accessibility grant check — CGEvent key synthesis silently no-ops
|
||||
/// without it. Direct FFI against ApplicationServices: one symbol, not worth
|
||||
/// a crate.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn accessibility_trusted() -> bool {
|
||||
#[link(name = "ApplicationServices", kind = "framework")]
|
||||
extern "C" {
|
||||
fn AXIsProcessTrusted() -> bool;
|
||||
}
|
||||
unsafe { AXIsProcessTrusted() }
|
||||
}
|
||||
|
||||
/// True when the app may synthesize keyboard input. On macOS this is the
|
||||
/// Accessibility grant (System Settings → Privacy & Security → Accessibility);
|
||||
/// other OSes don't gate synthetic input behind a permission, so always true.
|
||||
#[tauri::command]
|
||||
pub fn check_accessibility() -> bool {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
accessibility_trusted()
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Deep-link into the macOS Privacy → Accessibility pane so the widget can
|
||||
/// walk the user straight to the toggle an "a11y:" error asked for. No-op on
|
||||
/// other OSes (nothing to grant there).
|
||||
#[tauri::command]
|
||||
pub fn open_accessibility_settings() {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let _ = std::process::Command::new("open")
|
||||
.arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")
|
||||
.spawn();
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn simulate_paste(text: Option<String>) -> Result<(), String> {
|
||||
// macOS: fail loud BEFORE touching the clipboard if Accessibility isn't
|
||||
// granted — otherwise the ⌘V below silently goes nowhere and the caller
|
||||
// can't tell (the old fire-and-forget behavior).
|
||||
#[cfg(target_os = "macos")]
|
||||
if !accessibility_trusted() {
|
||||
return Err(kind_err("a11y", "accessibility permission not granted"));
|
||||
}
|
||||
|
||||
// Write the transcript to the clipboard natively first: the widget window
|
||||
// is intentionally unfocused on macOS (so the simulated ⌘V reaches the
|
||||
// target app), which makes the WebView clipboard APIs (navigator.clipboard
|
||||
// / execCommand('copy')) fail silently there (#287). `text` is optional so
|
||||
// call sites that already populated the clipboard keep working.
|
||||
//
|
||||
// Save what the user had there first (text only — restoring images/files
|
||||
// isn't worth the platform-specific surface) so dictation doesn't clobber
|
||||
// their clipboard.
|
||||
let mut saved: Option<String> = None;
|
||||
if let Some(t) = text {
|
||||
let mut cb = arboard::Clipboard::new()
|
||||
.map_err(|e| format!("clipboard init failed: {e}"))?;
|
||||
.map_err(|e| kind_err("clipboard", format!("init failed: {e}")))?;
|
||||
saved = cb.get_text().ok();
|
||||
cb.set_text(t)
|
||||
.map_err(|e| format!("clipboard write failed: {e}"))?;
|
||||
.map_err(|e| kind_err("clipboard", format!("write failed: {e}")))?;
|
||||
}
|
||||
|
||||
std::thread::sleep(Duration::from_millis(80));
|
||||
|
||||
let mut enigo = Enigo::new(&EnigoSettings::default())
|
||||
.map_err(|e| format!("Failed to init keyboard sim: {e}"))?;
|
||||
.map_err(|e| kind_err("paste", format!("failed to init keyboard sim: {e}")))?;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
enigo.key(Key::Meta, Direction::Press)
|
||||
.map_err(|e| format!("key press failed: {e}"))?;
|
||||
.map_err(|e| kind_err("paste", format!("key press failed: {e}")))?;
|
||||
enigo.key(Key::Unicode('v'), Direction::Click)
|
||||
.map_err(|e| format!("key click failed: {e}"))?;
|
||||
.map_err(|e| kind_err("paste", format!("key click failed: {e}")))?;
|
||||
enigo.key(Key::Meta, Direction::Release)
|
||||
.map_err(|e| format!("key release failed: {e}"))?;
|
||||
.map_err(|e| kind_err("paste", format!("key release failed: {e}")))?;
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
enigo.key(Key::Control, Direction::Press)
|
||||
.map_err(|e| format!("key press failed: {e}"))?;
|
||||
.map_err(|e| kind_err("paste", format!("key press failed: {e}")))?;
|
||||
enigo.key(Key::Unicode('v'), Direction::Click)
|
||||
.map_err(|e| format!("key click failed: {e}"))?;
|
||||
.map_err(|e| kind_err("paste", format!("key click failed: {e}")))?;
|
||||
enigo.key(Key::Control, Direction::Release)
|
||||
.map_err(|e| format!("key release failed: {e}"))?;
|
||||
.map_err(|e| kind_err("paste", format!("key release failed: {e}")))?;
|
||||
}
|
||||
|
||||
// Best-effort restore of the user's clipboard once the target app has
|
||||
// consumed the paste. Only on success — on a paste error the transcript
|
||||
// stays on the clipboard so the user can ⌘V it manually as a fallback.
|
||||
if let Some(prev) = saved {
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(CLIPBOARD_RESTORE_DELAY);
|
||||
if let Ok(mut cb) = arboard::Clipboard::new() {
|
||||
let _ = cb.set_text(prev);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -316,24 +394,32 @@ pub fn simulate_paste(text: Option<String>) -> Result<(), String> {
|
||||
///
|
||||
/// Returns `Err` if the input layer is unavailable (e.g. accessibility not
|
||||
/// granted) so the JS caller can fall back to the clipboard+paste path for
|
||||
/// that segment without double-inserting.
|
||||
/// that segment without double-inserting. Errors carry the same kind
|
||||
/// prefixes as `simulate_paste` ("a11y:" | "paste:").
|
||||
#[tauri::command]
|
||||
pub fn simulate_type(text: Option<String>, backspaces: Option<u32>) -> Result<(), String> {
|
||||
// Same a11y gate as simulate_paste — `.text()`/`.key()` go through the
|
||||
// identical CGEvent path on macOS and would silently no-op without it.
|
||||
#[cfg(target_os = "macos")]
|
||||
if !accessibility_trusted() {
|
||||
return Err(kind_err("a11y", "accessibility permission not granted"));
|
||||
}
|
||||
|
||||
let mut enigo = Enigo::new(&EnigoSettings::default())
|
||||
.map_err(|e| format!("Failed to init keyboard sim: {e}"))?;
|
||||
.map_err(|e| kind_err("paste", format!("failed to init keyboard sim: {e}")))?;
|
||||
|
||||
let n = backspaces.unwrap_or(0);
|
||||
for _ in 0..n {
|
||||
enigo
|
||||
.key(Key::Backspace, Direction::Click)
|
||||
.map_err(|e| format!("backspace failed: {e}"))?;
|
||||
.map_err(|e| kind_err("paste", format!("backspace failed: {e}")))?;
|
||||
}
|
||||
|
||||
if let Some(t) = text {
|
||||
if !t.is_empty() {
|
||||
enigo
|
||||
.text(&t)
|
||||
.map_err(|e| format!("type failed: {e}"))?;
|
||||
.map_err(|e| kind_err("paste", format!("type failed: {e}")))?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -441,3 +527,136 @@ pub fn save_text_file(path: String, contents: String) -> Result<(), String> {
|
||||
}
|
||||
std::fs::write(p, contents).map_err(|e| format!("write: {e}"))
|
||||
}
|
||||
|
||||
// ── WebView cache repair (issue #879) ─────────────────────────────────────
|
||||
//
|
||||
// After an unclean shutdown (e.g. a Windows BSOD), WebView2's profile cache
|
||||
// (%LOCALAPPDATA%\<identifier>\EBWebView) can corrupt. Tauri's IPC custom
|
||||
// protocol then fails ("IPC custom protocol failed, Tauri will now use the
|
||||
// postMessage interface instead") and the postMessage fallback can break too,
|
||||
// so the splash never hears bootstrap events even with a healthy backend.
|
||||
// The splash's recovery panel (Windows-only affordance, error-state only)
|
||||
// calls `clear_webview_cache_and_relaunch` to fix it in one click.
|
||||
//
|
||||
// Deleting EBWebView from inside a running app fails — the WebView2 browser
|
||||
// processes hold locks on the profile — so this is a two-step dance:
|
||||
// 1. the command writes a marker file next to the cache and relaunches;
|
||||
// 2. the fresh process calls `clear_webview_cache_if_marked()` at the very
|
||||
// top of `run()`, before any webview exists, and deletes the cache
|
||||
// there — retrying briefly while the old instance's WebView2 children
|
||||
// finish exiting.
|
||||
//
|
||||
// Everything below compiles on every platform (runtime `cfg!` guards, not
|
||||
// `#[cfg]`) so a macOS/Linux `cargo check` validates the whole path; the
|
||||
// behavior itself is Windows-only and the frontend never renders the button
|
||||
// elsewhere.
|
||||
|
||||
const CLEAR_WEBVIEW_MARKER: &str = ".clear-webview-cache";
|
||||
const WEBVIEW_CACHE_DIR: &str = "EBWebView";
|
||||
|
||||
/// (marker file, cache dir) under the pre-app local data dir. Mirrors
|
||||
/// `config::config_path_pre_app()` — `%LOCALAPPDATA%\<identifier>` on
|
||||
/// Windows — because step 2 runs before an `AppHandle` exists.
|
||||
fn webview_cache_paths() -> Option<(PathBuf, PathBuf)> {
|
||||
let base = dirs_next::data_local_dir()?.join(crate::config::BUNDLE_IDENTIFIER);
|
||||
Some((base.join(CLEAR_WEBVIEW_MARKER), base.join(WEBVIEW_CACHE_DIR)))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn clear_webview_cache_and_relaunch(app: tauri::AppHandle) -> Result<(), String> {
|
||||
if !cfg!(target_os = "windows") {
|
||||
return Err("WebView cache repair is only available on Windows (WebView2)".into());
|
||||
}
|
||||
let (marker, cache) = webview_cache_paths()
|
||||
.ok_or_else(|| "could not resolve the local app data directory".to_string())?;
|
||||
if let Some(parent) = marker.parent() {
|
||||
let _ = fs::create_dir_all(parent);
|
||||
}
|
||||
fs::write(&marker, b"requested by the splash recovery panel (issue #879)\n")
|
||||
.map_err(|e| format!("write {}: {e}", marker.display()))?;
|
||||
log::warn!(
|
||||
"WebView cache repair requested (#879) — relaunching to clear {}",
|
||||
cache.display()
|
||||
);
|
||||
app.restart()
|
||||
}
|
||||
|
||||
/// Startup half of the repair: if the previous run left the marker, delete
|
||||
/// the WebView2 profile cache before any webview is created. Called at the
|
||||
/// top of `run()`. One-shot by design — the marker is removed first so a
|
||||
/// failing repair can never loop across launches.
|
||||
pub fn clear_webview_cache_if_marked() {
|
||||
if !cfg!(target_os = "windows") {
|
||||
return;
|
||||
}
|
||||
let Some((marker, cache)) = webview_cache_paths() else {
|
||||
return;
|
||||
};
|
||||
if !marker.exists() {
|
||||
return;
|
||||
}
|
||||
let _ = fs::remove_file(&marker);
|
||||
if !cache.exists() {
|
||||
return;
|
||||
}
|
||||
// `app.restart()` spawns the new process before the old one has fully
|
||||
// exited, so its WebView2 children may still hold locks — retry briefly.
|
||||
const ATTEMPTS: u32 = 20;
|
||||
for attempt in 1..=ATTEMPTS {
|
||||
match fs::remove_dir_all(&cache) {
|
||||
Ok(()) => {
|
||||
log::warn!(
|
||||
"cleared WebView2 profile cache at {} (attempt {attempt}) — issue #879 repair",
|
||||
cache.display()
|
||||
);
|
||||
return;
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return,
|
||||
Err(e) if attempt < ATTEMPTS => {
|
||||
log::debug!("WebView2 cache still locked ({e}) — retrying");
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
Err(e) => {
|
||||
// Never brick startup over a failed repair: WebView2 rebuilds
|
||||
// whatever subset survived, and the user can retry.
|
||||
log::error!(
|
||||
"could not fully clear WebView2 cache at {}: {e} — continuing startup",
|
||||
cache.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod paste_error_tests {
|
||||
use super::{kind_err, CLIPBOARD_RESTORE_DELAY};
|
||||
|
||||
#[test]
|
||||
fn kind_err_prefixes_with_kind() {
|
||||
assert_eq!(kind_err("a11y", "not granted"), "a11y:not granted");
|
||||
assert_eq!(
|
||||
kind_err("clipboard", "write failed: busy"),
|
||||
"clipboard:write failed: busy"
|
||||
);
|
||||
assert_eq!(
|
||||
kind_err("paste", "key press failed"),
|
||||
"paste:key press failed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kind_survives_colons_in_detail() {
|
||||
// The widget does `err.split(':')[0]` — details containing ':' (OS
|
||||
// error strings usually do) must not corrupt the kind.
|
||||
let e = kind_err("clipboard", "init failed: os error 5");
|
||||
assert_eq!(e.split_once(':').map(|(k, _)| k), Some("clipboard"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_delay_is_about_300ms() {
|
||||
// Contract with the widget layer: previous clipboard comes back
|
||||
// ~300ms after the paste, long enough for slow paste consumers.
|
||||
assert_eq!(CLIPBOARD_RESTORE_DELAY.as_millis(), 300);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,7 +154,9 @@ pub fn load_config_pre_app() -> AppConfig {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
const BUNDLE_IDENTIFIER: &str = "com.debpalash.omnivoice-studio";
|
||||
/// Also used by `commands::webview_cache_paths` (#879) to locate the WebView2
|
||||
/// profile cache before an `AppHandle` exists.
|
||||
pub const BUNDLE_IDENTIFIER: &str = "com.debpalash.omnivoice-studio";
|
||||
|
||||
fn config_path_pre_app() -> Option<PathBuf> {
|
||||
portable_config_file()
|
||||
|
||||
@@ -205,6 +205,11 @@ mod media_permission_tests {
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
// #879: if the previous run requested a WebView cache repair (splash
|
||||
// recovery panel → clear_webview_cache_and_relaunch), perform it now —
|
||||
// before any webview exists, so WebView2 holds no locks on the profile.
|
||||
commands::clear_webview_cache_if_marked();
|
||||
|
||||
// ── Detect pill mode from CLI args OR persisted config ────────────────
|
||||
// CLI flag takes precedence. If not passed, fall back to the
|
||||
// `launch_as_widget` config field (set via tray "Switch to Pill Mode" or
|
||||
@@ -256,6 +261,8 @@ pub fn run() {
|
||||
commands::hf_cache_scan,
|
||||
commands::simulate_paste,
|
||||
commands::simulate_type,
|
||||
commands::check_accessibility,
|
||||
commands::open_accessibility_settings,
|
||||
commands::set_tray_recording,
|
||||
commands::quit_app,
|
||||
commands::save_text_file,
|
||||
@@ -263,6 +270,7 @@ pub fn run() {
|
||||
commands::set_dictation_shortcut,
|
||||
commands::get_launch_as_widget,
|
||||
commands::set_launch_as_widget,
|
||||
commands::clear_webview_cache_and_relaunch,
|
||||
])
|
||||
.setup(move |app| {
|
||||
app.handle().plugin(tauri_plugin_dialog::init())?;
|
||||
@@ -596,6 +604,17 @@ pub fn run() {
|
||||
if let Some(win) = app.get_webview_window("widget") {
|
||||
let _ = win.hide();
|
||||
}
|
||||
// Enforce the always-open-maximized contract (#881) at
|
||||
// runtime: macOS can ignore `maximized: true` from
|
||||
// tauri.conf.json at window creation when combined with the
|
||||
// Overlay title-bar style, so the config flag alone isn't
|
||||
// reliable. maximize() zooms the window — it never enters a
|
||||
// fullscreen Space. Guarded by tests/test_window_launch_state.py.
|
||||
if let Some(main_win) = app.get_webview_window("main") {
|
||||
if !main_win.is_maximized().unwrap_or(false) {
|
||||
let _ = main_win.maximize();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── WebView media-capture permissions (mic for dictation) ────
|
||||
@@ -638,13 +657,30 @@ pub fn run() {
|
||||
set_stage(&stage_handle, BootstrapStage::AwaitingSetup);
|
||||
return;
|
||||
}
|
||||
if backend::backend_healthy(backend_port()) {
|
||||
log::info!(
|
||||
"Port {} already serving OmniVoice backend — attaching",
|
||||
backend_port()
|
||||
);
|
||||
set_stage(&stage_handle, BootstrapStage::Ready);
|
||||
return;
|
||||
match backend::running_backend_version(backend_port()) {
|
||||
Some(v) if backend::same_app_version(&v) => {
|
||||
log::info!(
|
||||
"Port {} already serving OmniVoice backend v{} — attaching",
|
||||
backend_port(), v
|
||||
);
|
||||
set_stage(&stage_handle, BootstrapStage::Ready);
|
||||
return;
|
||||
}
|
||||
Some(v) => {
|
||||
// Healthy-but-stale backend from a previous version —
|
||||
// the post-update orphan that made new installs run
|
||||
// old backend code. Replace it (see backend.rs
|
||||
// same_app_version for the full story).
|
||||
log::warn!(
|
||||
"Port {} serves a stale OmniVoice backend (v{} != app v{}) — replacing it",
|
||||
backend_port(),
|
||||
if v.is_empty() { "<unknown>" } else { v.as_str() },
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
);
|
||||
backend::kill_orphan_on_port(backend_port());
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
if backend::port_in_use(backend_port()) {
|
||||
log::warn!(
|
||||
|
||||
@@ -74,6 +74,7 @@
|
||||
"../../pyproject.toml",
|
||||
"../../uv.lock",
|
||||
"../../README.md",
|
||||
"../../CHANGELOG.md",
|
||||
"../../omnivoice",
|
||||
"../../backend"
|
||||
],
|
||||
|
||||
@@ -62,6 +62,7 @@ const LazyFallback = () => <div className="app-lazy-fallback">{i18n.t('app.loadi
|
||||
import { Toaster, toast } from 'react-hot-toast';
|
||||
import { toastErrorWithReport } from './utils/errorToast';
|
||||
import { addBreadcrumb } from './utils/breadcrumbs';
|
||||
import { recordValueMoment } from './utils/donationMoments';
|
||||
import {
|
||||
POPULAR_LANGS,
|
||||
POPULAR_ISO,
|
||||
@@ -718,6 +719,7 @@ function App() {
|
||||
try {
|
||||
const finalName = await browserDownload(`${API}/audio/${sourceIdentifier}`, niceName);
|
||||
toast.success(i18n.t('app.toast_downloaded', { name: finalName }));
|
||||
recordValueMoment('export'); // success-only donation moment
|
||||
try {
|
||||
await exportRecord({
|
||||
filename: finalName,
|
||||
@@ -748,6 +750,7 @@ function App() {
|
||||
|
||||
await exportAction({ source_filename: sourceIdentifier, destination_path: destPath, mode });
|
||||
toast.success(i18n.t('app.toast_exported', { name: fallbackName }));
|
||||
recordValueMoment('export'); // success-only donation moment
|
||||
loadExportHistory();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@@ -796,6 +799,7 @@ function App() {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
await invoke('save_text_file', { path: destPath, contents: text });
|
||||
toast.success(i18n.t('app.toast_saved', { path: destPath }), { id: fallbackName });
|
||||
recordValueMoment('export'); // success-only donation moment
|
||||
try {
|
||||
await exportRecord({
|
||||
filename: fallbackName,
|
||||
@@ -822,6 +826,7 @@ function App() {
|
||||
}
|
||||
const data = await res.json();
|
||||
toast.success(i18n.t('app.toast_saved', { path: data.path }), { id: fallbackName });
|
||||
recordValueMoment('export'); // success-only donation moment
|
||||
try {
|
||||
await exportRecord({
|
||||
filename: data.display_name || fallbackName,
|
||||
@@ -844,6 +849,7 @@ function App() {
|
||||
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 });
|
||||
recordValueMoment('export'); // success-only donation moment
|
||||
try {
|
||||
await exportRecord({
|
||||
filename: finalName,
|
||||
|
||||
@@ -36,6 +36,11 @@ export async function listBatchJobs(status?: string, limit = 50): Promise<BatchJ
|
||||
return apiJson<BatchJob[]>(`/batch/jobs?${qs.toString()}`);
|
||||
}
|
||||
|
||||
/** Get a single batch job (used to resolve why a job left the active list). */
|
||||
export async function getBatchJob(id: string): Promise<BatchJob> {
|
||||
return apiJson<BatchJob>(`/batch/jobs/${id}`);
|
||||
}
|
||||
|
||||
/** Enqueue a video for batch dubbing. */
|
||||
export async function enqueueBatchJob(
|
||||
file: File,
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
AllEnginesResponse,
|
||||
EngineFamily,
|
||||
EngineHealthResponse,
|
||||
EngineSelfTestResponse,
|
||||
SelectEngineResponse,
|
||||
} from './types';
|
||||
|
||||
@@ -64,6 +65,18 @@ export async function getEngineHealth(engineId: string): Promise<EngineHealthRes
|
||||
return apiJson<EngineHealthResponse>(`/engines/${encodeURIComponent(engineId)}/health`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a bounded, real tiny-synthesis on an AVAILABLE, IN-PROCESS TTS engine —
|
||||
* proves the engine actually emits audio (duration + sample-rate + samples),
|
||||
* not just that its package imports (`is_available()` liveness). The Compat
|
||||
* Matrix's "Self-test" button calls this; only ever on user click, never on
|
||||
* Settings mount. 400 for a subprocess-isolated or not-available engine, 404
|
||||
* for a non-TTS id. Never 500s on a synth failure — it lands in `ok:false`.
|
||||
*/
|
||||
export async function selfTestEngine(engineId: string): Promise<EngineSelfTestResponse> {
|
||||
return apiPost<EngineSelfTestResponse>(`/engines/${encodeURIComponent(engineId)}/selftest`, {});
|
||||
}
|
||||
|
||||
export async function listTranslationEngines(): Promise<TranslationEnginesResponse> {
|
||||
return apiJson<TranslationEnginesResponse>('/engines/translation');
|
||||
}
|
||||
|
||||
@@ -40,6 +40,9 @@ export interface ModelList {
|
||||
models: KnownModel[];
|
||||
total_installed_bytes: number;
|
||||
hf_cache_dir: string;
|
||||
/** Free space on the cache volume — surfaced in the Model Store header so an
|
||||
* "Install all" can't silently overrun the disk. */
|
||||
disk_free_gb?: number;
|
||||
}
|
||||
|
||||
export async function listModels(): Promise<ModelList> {
|
||||
@@ -50,6 +53,12 @@ export async function installModel(repo_id: string): Promise<{ status: string; r
|
||||
return apiPost('/models/install', { repo_id });
|
||||
}
|
||||
|
||||
/** Request cancellation of an in-flight install (FDL-11). Best-effort: the
|
||||
* backend stops further retries and emits an `install_cancelled` SSE event. */
|
||||
export async function cancelInstallModel(repo_id: string): Promise<{ cancelling: string }> {
|
||||
return apiPost('/models/install/cancel', { repo_id });
|
||||
}
|
||||
|
||||
// ── Device-aware model recommendation ─────────────────────────────────────
|
||||
|
||||
interface RecommendedModel {
|
||||
|
||||
@@ -30,6 +30,9 @@ interface EngineBackend {
|
||||
available: boolean;
|
||||
reason: string | null;
|
||||
install_hint?: string | null;
|
||||
// Copy-paste-ready `export VAR=...` line for a path-gated opt-in engine
|
||||
// (IndexTTS / MOSS-v1.5 / dots.tts / Confucius4), else null/absent.
|
||||
setup_snippet?: string | null;
|
||||
last_error?: string | null;
|
||||
isolation_mode?: 'in-process' | 'subprocess';
|
||||
gpu_compat?: GPUTarget[];
|
||||
@@ -54,6 +57,12 @@ export interface SelectEngineResponse {
|
||||
family: EngineFamily;
|
||||
active: string;
|
||||
env_override: boolean;
|
||||
// Routing verdict for the picked engine on THIS host (#21) — the select echo
|
||||
// the post-select toast reads to warn on a cpu_fallback pick. Optional so a
|
||||
// legacy payload without them still types cleanly.
|
||||
routing_status?: RoutingStatus;
|
||||
effective_device?: EffectiveDevice;
|
||||
routing_reason?: string | null;
|
||||
}
|
||||
|
||||
export interface EngineHealthResponse {
|
||||
@@ -63,6 +72,20 @@ export interface EngineHealthResponse {
|
||||
latency_ms: number;
|
||||
}
|
||||
|
||||
// Real-synthesis self-test result for an available in-process TTS engine
|
||||
// (POST /engines/{id}/selftest). `ok` proves the engine emitted audio; the
|
||||
// rest quantify it. `timed_out` marks a synth that outran the bounded timeout.
|
||||
export interface EngineSelfTestResponse {
|
||||
id: string;
|
||||
ok: boolean;
|
||||
message: string;
|
||||
duration_ms: number;
|
||||
sample_rate?: number | null;
|
||||
num_samples?: number | null;
|
||||
audio_seconds?: number | null;
|
||||
timed_out?: boolean;
|
||||
}
|
||||
|
||||
// ── System / diagnostics ─────────────────────────────────────────────────
|
||||
export interface SystemInfo {
|
||||
app_version?: string;
|
||||
|
||||
@@ -13,12 +13,24 @@
|
||||
* firstrun.css so setup → install → model wizard reads as one experience.
|
||||
*/
|
||||
import { Suspense, lazy, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Brush, Check, ChevronDown, ChevronRight, Clipboard, Globe, Lightbulb } from 'lucide-react';
|
||||
import {
|
||||
Brush,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Clipboard,
|
||||
FolderOpen,
|
||||
Globe,
|
||||
Lightbulb,
|
||||
Wrench,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { copyText } from '../utils/copyText';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import i18n, { LANGUAGES } from '../i18n';
|
||||
import { useAppStore } from '../store';
|
||||
import { getApiBase } from '../utils/apiBase';
|
||||
import { startSplashWatchdog } from '../utils/splashWatchdog';
|
||||
import { Button, Progress, Select } from '../ui';
|
||||
|
||||
// First-run only: keep the setup screen out of the main bundle so every
|
||||
@@ -64,8 +76,39 @@ const STAGE_LABEL = {
|
||||
starting_backend: 'Starting backend…',
|
||||
ready: 'Ready',
|
||||
failed: 'Setup failed',
|
||||
ipc_lost: 'Startup issue detected',
|
||||
};
|
||||
|
||||
/** Race a promise against a timeout. Used for IPC calls made from the
|
||||
* recovery panel (#879): the whole point of that state is that IPC may be
|
||||
* hung, so every invoke gets a bounded wait + a manual fallback. */
|
||||
function withTimeout(promise, ms) {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error('ipc timeout')), ms)),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Platform-default log directory, computed client-side (no IPC available in
|
||||
* the recovery state). Mirrors src-tauri/src/backend.rs `backend_log_path()`.
|
||||
* The Windows form uses %LOCALAPPDATA% literally — Explorer expands it. */
|
||||
function defaultLogDirForPlatform() {
|
||||
const ua = typeof navigator !== 'undefined' ? navigator.userAgent || '' : '';
|
||||
if (ua.includes('Windows')) return '%LOCALAPPDATA%\\OmniVoice\\Logs';
|
||||
if (ua.includes('Mac')) return '~/Library/Logs/OmniVoice';
|
||||
return '~/.local/state/OmniVoice';
|
||||
}
|
||||
|
||||
/** WebView2 profile cache path shown in the manual-repair fallback (#879). */
|
||||
const WEBVIEW_CACHE_PATH_WIN = '%LOCALAPPDATA%\\com.debpalash.omnivoice-studio\\EBWebView';
|
||||
|
||||
/** True on Windows. Deliberately reads the user agent, NOT a Tauri plugin —
|
||||
* in the recovery state IPC is presumed dead, so OS detection must not
|
||||
* round-trip through it. */
|
||||
function isWindowsUA() {
|
||||
return typeof navigator !== 'undefined' && (navigator.userAgent || '').includes('Windows');
|
||||
}
|
||||
|
||||
const STEPS = [
|
||||
'checking',
|
||||
'downloading_uv',
|
||||
@@ -99,6 +142,11 @@ function detectHints(message, logs) {
|
||||
if (/seems stuck at|never reported ready/i.test(all)) hints.push('bootstrap.hint_stuck');
|
||||
if (/blocking GitHub|couldn't download Python|python-build-standalone|dns error/i.test(all))
|
||||
hints.push('bootstrap.hint_github_blocked');
|
||||
// Intel-Mac backend unsupported (#889): PyTorch ships no macOS x86_64
|
||||
// wheels, so bootstrap.rs pre-fails with this message before any sync.
|
||||
if (/Intel Macs can't run the local AI backend/i.test(all)) {
|
||||
return ['bootstrap.hint_intel_mac']; // retrying can never help — show only this
|
||||
}
|
||||
if (hints.length === 0) hints.push('bootstrap.hint_default');
|
||||
return hints;
|
||||
}
|
||||
@@ -193,6 +241,93 @@ function JourneyRail({ t }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recovery panel for the stuck-startup state (#879): the Tauri IPC layer is
|
||||
* silent AND the backend never answered /health within the recovery window.
|
||||
* Explains what happened and offers actionable exits instead of an infinite
|
||||
* spinner. The "Repair and restart" affordance is Windows-only (it clears the
|
||||
* WebView2 `EBWebView` profile cache — a Windows-specific artifact) and only
|
||||
* exists inside this error-recovery state, never as default-mode UI.
|
||||
*/
|
||||
function IpcLostRecovery({ t }) {
|
||||
const [showLogHint, setShowLogHint] = useState(false);
|
||||
const [repairing, setRepairing] = useState(false);
|
||||
const [repairFailed, setRepairFailed] = useState(false);
|
||||
|
||||
const handleOpenLogs = async () => {
|
||||
try {
|
||||
// Best effort over IPC (it may be partially alive); bounded so a hung
|
||||
// invoke can't make the button feel dead.
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
const tail = await withTimeout(invoke('read_log_tail', { source: 'backend' }), 3000);
|
||||
if (!tail?.path) throw new Error('no log path');
|
||||
const { revealItemInDir } = await import('@tauri-apps/plugin-opener');
|
||||
await withTimeout(revealItemInDir(tail.path), 3000);
|
||||
} catch {
|
||||
// IPC is dead (the expected case here) — show where the logs live.
|
||||
setShowLogHint(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRepairRestart = async () => {
|
||||
if (repairing) return;
|
||||
if (!confirm(t('bootstrap.ipc_lost_repair_confirm'))) return;
|
||||
setRepairing(true);
|
||||
try {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
// On success the process relaunches and this promise never settles;
|
||||
// the timeout only fires when the IPC layer is too broken even for
|
||||
// this one call — then we fall back to manual instructions.
|
||||
await withTimeout(invoke('clear_webview_cache_and_relaunch'), 8000);
|
||||
} catch (e) {
|
||||
if (e?.message !== 'ipc timeout') console.error('repair failed', e);
|
||||
setRepairFailed(true);
|
||||
setRepairing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="fr-rise flex flex-col gap-2.5" style={{ '--rise': 1 }}>
|
||||
<h2 className="m-0 font-mono text-[0.62rem] font-semibold uppercase tracking-[0.18em] text-fg-muted">
|
||||
{t('bootstrap.ipc_lost_title', "The app can't finish starting")}
|
||||
</h2>
|
||||
<p className="m-0 text-sm leading-relaxed text-fg-muted">{t('bootstrap.ipc_lost_body')}</p>
|
||||
{showLogHint && (
|
||||
<ErrorBox>
|
||||
{t('bootstrap.ipc_lost_log_hint', { path: defaultLogDirForPlatform() })}
|
||||
</ErrorBox>
|
||||
)}
|
||||
{repairFailed && (
|
||||
<ErrorBox>
|
||||
{t('bootstrap.ipc_lost_repair_failed', { path: WEBVIEW_CACHE_PATH_WIN })}
|
||||
</ErrorBox>
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleOpenLogs}
|
||||
leading={<FolderOpen size={12} />}
|
||||
>
|
||||
{t('bootstrap.ipc_lost_open_logs', 'Open logs')}
|
||||
</Button>
|
||||
{isWindowsUA() && (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleRepairRestart}
|
||||
disabled={repairing}
|
||||
leading={<Wrench size={12} />}
|
||||
>
|
||||
{repairing
|
||||
? t('bootstrap.ipc_lost_repairing', 'Repairing…')
|
||||
: t('bootstrap.ipc_lost_repair', 'Repair and restart')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Mono error block. */
|
||||
function ErrorBox({ children }) {
|
||||
return (
|
||||
@@ -488,7 +623,9 @@ export function BootstrapSplash({ stage, message }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isFailed ? (
|
||||
{stage === 'ipc_lost' ? (
|
||||
<IpcLostRecovery t={t} />
|
||||
) : isFailed ? (
|
||||
<section className="fr-rise flex flex-col gap-2.5" style={{ '--rise': 1 }}>
|
||||
<h2 className="m-0 font-mono text-[0.62rem] font-semibold uppercase tracking-[0.18em] text-fg-muted">
|
||||
{t('bootstrap.failed', 'Setup failed')}
|
||||
@@ -664,6 +801,29 @@ export function useBootstrapStage(pollMs = 1000) {
|
||||
let cancelled = false;
|
||||
let timer = null;
|
||||
let misses = 0;
|
||||
// IPC watchdog (#879): the poll loop below rides entirely on Tauri IPC.
|
||||
// After an unclean shutdown, a corrupted WebView cache can break BOTH the
|
||||
// IPC custom protocol and its postMessage fallback — `invoke()` then hangs
|
||||
// without ever resolving OR rejecting, so neither the stall watchdog
|
||||
// (#474) nor the miss counter below can fire, and the splash would spin
|
||||
// forever even with a healthy backend. This watchdog is IPC-independent:
|
||||
// if no `bootstrap_status` response arrives at all, it polls /health over
|
||||
// plain HTTP and either proceeds to the app ('ready') or flips to the
|
||||
// 'ipc_lost' recovery panel. Started synchronously, before the dynamic
|
||||
// import — in a corrupted-webview world even that import may stall.
|
||||
let httpForcedReady = false;
|
||||
const watchdog = startSplashWatchdog({
|
||||
healthUrl: `${getApiBase()}/health`,
|
||||
onReadyViaHttp: () => {
|
||||
if (cancelled) return;
|
||||
httpForcedReady = true;
|
||||
setState({ stage: 'ready', message: null });
|
||||
},
|
||||
onStuck: () => {
|
||||
if (cancelled || httpForcedReady) return;
|
||||
setState({ stage: 'ipc_lost', message: null });
|
||||
},
|
||||
});
|
||||
// Stall watchdog (#474): if the backend hangs in a non-terminal stage and
|
||||
// never reports `ready` (e.g. a failed Python-backend spawn on a from-source
|
||||
// build), the poll loop would otherwise spin forever and trap the user on a
|
||||
@@ -686,6 +846,7 @@ export function useBootstrapStage(pollMs = 1000) {
|
||||
(async () => {
|
||||
const tauriInvoke = await invoke();
|
||||
if (!tauriInvoke) {
|
||||
watchdog.cancel();
|
||||
setState({ stage: 'ready', message: null });
|
||||
return;
|
||||
}
|
||||
@@ -694,6 +855,12 @@ export function useBootstrapStage(pollMs = 1000) {
|
||||
try {
|
||||
const res = await tauriInvoke('bootstrap_status');
|
||||
if (cancelled) return;
|
||||
// IPC answered — the normal path owns the transition; disarm the
|
||||
// HTTP watchdog for good (#879). But if the watchdog already
|
||||
// force-transitioned to the app via HTTP health, a late-thawing
|
||||
// IPC response must not yank the user back to the splash.
|
||||
watchdog.markIpcAlive();
|
||||
if (httpForcedReady) return;
|
||||
misses = 0;
|
||||
const stage = res.stage || 'ready';
|
||||
const message = res.message || null;
|
||||
@@ -732,6 +899,10 @@ export function useBootstrapStage(pollMs = 1000) {
|
||||
if (misses < 5) {
|
||||
timer = setTimeout(tick, pollMs);
|
||||
} else {
|
||||
// Conceding 'ready' after repeated fast rejections — stop the
|
||||
// HTTP watchdog too, so it can't flip to 'ipc_lost' underneath
|
||||
// the already-mounted main UI (#879).
|
||||
watchdog.cancel();
|
||||
setState({ stage: 'ready', message: null });
|
||||
}
|
||||
}
|
||||
@@ -740,6 +911,7 @@ export function useBootstrapStage(pollMs = 1000) {
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
watchdog.cancel();
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, [pollMs]);
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* CaptureWidget pill behaviour — mocked WS + Tauri invoke.
|
||||
*
|
||||
* Covers the truthfulness rebuild: model status frames render real
|
||||
* download/load progress, "Pasted" only appears after simulate_paste resolves
|
||||
* Ok, an "a11y:"-prefixed paste failure renders the actionable Accessibility
|
||||
* error, Esc aborts without pasting, live retract-retype is opt-in (default
|
||||
* sessions never call simulate_type), the missing-Accessibility setup state
|
||||
* shows on mount, and the waveform bars move from real mic frames.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, waitFor, fireEvent, act } from '@testing-library/react';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import i18n from '../i18n';
|
||||
|
||||
// ── Hoisted mock state (vi.mock factories may only reference vi.hoisted vars) ──
|
||||
const mocks = vi.hoisted(() => {
|
||||
const state = {
|
||||
dictationEnabled: true,
|
||||
dictationMode: 'toggle',
|
||||
dictationModelId: 'sherpa-parakeet-tdt-v3', // sherpa → raw-PCM live path
|
||||
aecEnabled: false,
|
||||
loadDictationPrefs: () => {},
|
||||
};
|
||||
const holder = {
|
||||
// Per-test knobs for the Tauri invoke mock.
|
||||
a11y: true,
|
||||
paste: async () => undefined,
|
||||
calls: [],
|
||||
// Captured micCapture frame callback (the worklet feed).
|
||||
onFrame: null,
|
||||
};
|
||||
return {
|
||||
state,
|
||||
holder,
|
||||
invoke: async (cmd, args) => {
|
||||
holder.calls.push([cmd, args]);
|
||||
if (cmd === 'check_accessibility') return holder.a11y;
|
||||
if (cmd === 'simulate_paste') return holder.paste();
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../store', () => ({
|
||||
useAppStore: Object.assign((sel) => sel(mocks.state), { getState: () => mocks.state }),
|
||||
}));
|
||||
vi.mock('../api/client', () => ({
|
||||
wsUrl: (p) => `ws://test${p}`,
|
||||
apiFetch: vi.fn(async () => ({ json: async () => ({}) })),
|
||||
}));
|
||||
vi.mock('../pages/Transcriptions', () => ({ addTranscription: vi.fn() }));
|
||||
vi.mock('../utils/copyText', () => ({ copyText: vi.fn(async () => {}) }));
|
||||
vi.mock('react-hot-toast', () => ({ toast: { error: vi.fn() } }));
|
||||
vi.mock('@tauri-apps/api/core', () => ({ invoke: mocks.invoke }));
|
||||
vi.mock('@tauri-apps/api/event', () => ({ listen: vi.fn(async () => () => {}) }));
|
||||
vi.mock('@tauri-apps/api/window', () => ({
|
||||
getCurrentWindow: () => ({ hide: vi.fn(async () => {}) }),
|
||||
}));
|
||||
vi.mock('../utils/aec/micCapture', () => ({
|
||||
startMicCapture: async (stream, onFrame) => {
|
||||
mocks.holder.onFrame = onFrame;
|
||||
return async () => {};
|
||||
},
|
||||
}));
|
||||
|
||||
import CaptureWidget from './CaptureWidget';
|
||||
|
||||
// ── Browser API fakes (jsdom has neither WebSocket use here nor MediaRecorder) ──
|
||||
class FakeWebSocket {
|
||||
static CONNECTING = 0;
|
||||
static OPEN = 1;
|
||||
static CLOSING = 2;
|
||||
static CLOSED = 3;
|
||||
static instances = [];
|
||||
constructor(url) {
|
||||
this.url = url;
|
||||
this.readyState = FakeWebSocket.OPEN; // pretend the connect is instant
|
||||
this.sent = [];
|
||||
this._listeners = {};
|
||||
FakeWebSocket.instances.push(this);
|
||||
}
|
||||
addEventListener(type, fn) {
|
||||
(this._listeners[type] ||= []).push(fn);
|
||||
}
|
||||
send(d) {
|
||||
this.sent.push(d);
|
||||
}
|
||||
close() {
|
||||
if (this.readyState === FakeWebSocket.CLOSED) return;
|
||||
this.readyState = FakeWebSocket.CLOSED;
|
||||
this.onclose?.();
|
||||
}
|
||||
/** Deliver a backend JSON frame. */
|
||||
msg(obj) {
|
||||
this.onmessage?.({ data: JSON.stringify(obj) });
|
||||
}
|
||||
}
|
||||
|
||||
class FakeMediaRecorder {
|
||||
static isTypeSupported() {
|
||||
return true;
|
||||
}
|
||||
constructor() {
|
||||
this.state = 'inactive';
|
||||
}
|
||||
start() {
|
||||
this.state = 'recording';
|
||||
}
|
||||
stop() {
|
||||
this.state = 'inactive';
|
||||
}
|
||||
}
|
||||
|
||||
function withI18n(node) {
|
||||
return <I18nextProvider i18n={i18n}>{node}</I18nextProvider>;
|
||||
}
|
||||
|
||||
// Start a session via the in-page shortcut and wait for the live socket.
|
||||
async function startSession() {
|
||||
fireEvent.keyDown(window, { code: 'Space', ctrlKey: true, shiftKey: true });
|
||||
await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1));
|
||||
await screen.findByText(/Listening/);
|
||||
return FakeWebSocket.instances[0];
|
||||
}
|
||||
|
||||
describe('CaptureWidget', () => {
|
||||
beforeEach(() => {
|
||||
window.__TAURI_INTERNALS__ = {};
|
||||
mocks.holder.a11y = true;
|
||||
mocks.holder.paste = async () => undefined;
|
||||
mocks.holder.calls = [];
|
||||
mocks.holder.onFrame = null;
|
||||
FakeWebSocket.instances = [];
|
||||
global.WebSocket = FakeWebSocket;
|
||||
global.MediaRecorder = FakeMediaRecorder;
|
||||
Object.defineProperty(navigator, 'mediaDevices', {
|
||||
configurable: true,
|
||||
value: { getUserMedia: async () => ({ getTracks: () => [{ stop() {} }] }) },
|
||||
});
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete window.__TAURI_INTERNALS__;
|
||||
delete global.WebSocket;
|
||||
delete global.MediaRecorder;
|
||||
});
|
||||
|
||||
const pasteCalls = () => mocks.holder.calls.filter(([c]) => c === 'simulate_paste');
|
||||
const typeCalls = () => mocks.holder.calls.filter(([c]) => c === 'simulate_type');
|
||||
|
||||
it('renders truthful model status from {type:"status"} frames', async () => {
|
||||
render(withI18n(<CaptureWidget />));
|
||||
const ws = await startSession();
|
||||
|
||||
act(() => ws.msg({ type: 'status', stage: 'downloading', progress: 0.42 }));
|
||||
expect(screen.getByText(/Downloading voice model/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/42%/)).toBeInTheDocument();
|
||||
|
||||
act(() => ws.msg({ type: 'status', stage: 'loading' }));
|
||||
expect(screen.getByText(/Loading model/)).toBeInTheDocument();
|
||||
|
||||
act(() => ws.msg({ type: 'status', stage: 'ready' }));
|
||||
expect(screen.getByText(/Listening/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Pasted" only after simulate_paste resolved Ok', async () => {
|
||||
render(withI18n(<CaptureWidget />));
|
||||
const ws = await startSession();
|
||||
|
||||
// Offline-model shape: one utterance final, then the EOF summary.
|
||||
act(() => ws.msg({ type: 'final', text: 'hello world' }));
|
||||
act(() => ws.msg({ type: 'final', text: 'hello world' }));
|
||||
|
||||
await screen.findByText(/Pasted/);
|
||||
expect(pasteCalls().length).toBeGreaterThan(0);
|
||||
expect(pasteCalls()[0][1]).toEqual({ text: 'hello world' });
|
||||
});
|
||||
|
||||
it('an "a11y:" paste rejection renders the actionable error, never "Pasted"', async () => {
|
||||
mocks.holder.paste = async () => {
|
||||
throw 'a11y: process is not trusted';
|
||||
};
|
||||
render(withI18n(<CaptureWidget />));
|
||||
const ws = await startSession();
|
||||
|
||||
act(() => ws.msg({ type: 'final', text: 'hello world' }));
|
||||
act(() => ws.msg({ type: 'final', text: 'hello world' }));
|
||||
|
||||
await screen.findByText(/Accessibility access needed/);
|
||||
expect(screen.queryByText(/Pasted/)).not.toBeInTheDocument();
|
||||
|
||||
// The action button opens the OS Accessibility pane.
|
||||
fireEvent.click(screen.getByText('Open Settings'));
|
||||
await waitFor(() =>
|
||||
expect(mocks.holder.calls.some(([c]) => c === 'open_accessibility_settings')).toBe(true),
|
||||
);
|
||||
});
|
||||
|
||||
it('Esc during recording aborts: socket closed, nothing pasted, pill gone', async () => {
|
||||
const { container } = render(withI18n(<CaptureWidget />));
|
||||
const ws = await startSession();
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Escape' });
|
||||
await waitFor(() => expect(container.querySelector('.capture-pill')).toBeNull());
|
||||
expect(ws.readyState).toBe(FakeWebSocket.CLOSED);
|
||||
expect(pasteCalls()).toEqual([]);
|
||||
});
|
||||
|
||||
it('live retract-retype is OFF by default: partials never simulate_type', async () => {
|
||||
render(withI18n(<CaptureWidget />));
|
||||
const ws = await startSession();
|
||||
|
||||
act(() => ws.msg({ type: 'partial', text: 'hel' }));
|
||||
act(() => ws.msg({ type: 'partial', text: 'hello wor' }));
|
||||
act(() => ws.msg({ type: 'final', text: 'hello world' }));
|
||||
act(() => ws.msg({ type: 'final', text: 'hello world' }));
|
||||
|
||||
await screen.findByText(/Pasted/);
|
||||
// Committed final went through the paste path; no keystroke storms.
|
||||
expect(typeCalls()).toEqual([]);
|
||||
expect(pasteCalls().length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('the LS_LIVE_TYPING pref opts back into word-by-word typing', async () => {
|
||||
localStorage.setItem('omni_capture_live_typing', '1');
|
||||
render(withI18n(<CaptureWidget />));
|
||||
const ws = await startSession();
|
||||
|
||||
act(() => ws.msg({ type: 'partial', text: 'hello' }));
|
||||
await waitFor(() => expect(typeCalls().length).toBeGreaterThan(0));
|
||||
expect(typeCalls()[0][1]).toEqual({ text: 'hello', backspaces: 0 });
|
||||
});
|
||||
|
||||
it('a refined EOF summary is not re-pasted as a new utterance', async () => {
|
||||
render(withI18n(<CaptureWidget />));
|
||||
const ws = await startSession();
|
||||
|
||||
// Two per-utterance commits paste live…
|
||||
act(() => ws.msg({ type: 'final', text: 'Hello world.' }));
|
||||
act(() => ws.msg({ type: 'final', text: 'Second bit.' }));
|
||||
// …then the EOF summary arrives with an LLM-refined variant. Its raw
|
||||
// `text` equals the committed join, so it must finalise — never paste
|
||||
// the whole (refined) transcript a third time.
|
||||
act(() =>
|
||||
ws.msg({
|
||||
type: 'final',
|
||||
text: 'Hello world. Second bit.',
|
||||
refined_text: 'Hello world, second bit.',
|
||||
}),
|
||||
);
|
||||
|
||||
await screen.findByText(/Pasted/);
|
||||
expect(pasteCalls().map(([, a]) => a.text)).toEqual(['Hello world.', 'Second bit.']);
|
||||
});
|
||||
|
||||
it('renders the one-time Accessibility setup state when the mount probe fails', async () => {
|
||||
mocks.holder.a11y = false;
|
||||
render(withI18n(<CaptureWidget />));
|
||||
|
||||
await screen.findByText(/Allow Accessibility/);
|
||||
expect(screen.getByText('Open Settings')).toBeInTheDocument();
|
||||
// It does not pretend to record.
|
||||
expect(screen.queryByText(/Listening/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('waveform bars move from the worklet mic frames', async () => {
|
||||
const { container } = render(withI18n(<CaptureWidget />));
|
||||
await startSession();
|
||||
expect(mocks.holder.onFrame).toBeTypeOf('function');
|
||||
|
||||
// Feed ~5 frames of speech-level audio (≈100 ms at 20 ms/frame).
|
||||
for (let i = 0; i < 5; i++) mocks.holder.onFrame(new Float32Array(320).fill(0.5));
|
||||
|
||||
await waitFor(() => {
|
||||
const bars = container.querySelectorAll('.capture-pill__wave-bar');
|
||||
expect(bars.length).toBe(12);
|
||||
const heights = [...bars].map((b) => parseInt(b.style.height, 10));
|
||||
expect(Math.max(...heights)).toBeGreaterThan(12); // above the silence floor
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
// "What's new" changelog reader (feat/safe-updates).
|
||||
// Renders the app's own CHANGELOG.md (parsed by the backend into
|
||||
// {version, date, intro, sections:[{title, bullets}]}) as an accordion:
|
||||
// newest release expanded, older ones collapsed. Bullets go through the safe
|
||||
// markdown-lite inline renderer — bold leads and (#NNN) refs stay plain text.
|
||||
import { useState } from 'react';
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { InlineMd } from './MarkdownLite';
|
||||
|
||||
export default function ChangelogViewer({ releases }) {
|
||||
const [expanded, setExpanded] = useState(() =>
|
||||
releases && releases.length ? releases[0].version : null,
|
||||
);
|
||||
if (!Array.isArray(releases) || releases.length === 0) return null;
|
||||
return (
|
||||
<div className="changelog-viewer" data-testid="changelog-viewer">
|
||||
{releases.map((rel) => {
|
||||
const open = expanded === rel.version;
|
||||
return (
|
||||
<div key={rel.version} className={`changelog-viewer__rel ${open ? 'is-open' : ''}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="changelog-viewer__head"
|
||||
aria-expanded={open}
|
||||
onClick={() => setExpanded(open ? null : rel.version)}
|
||||
data-testid={`changelog-toggle-${rel.version}`}
|
||||
>
|
||||
{open ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
<span className="changelog-viewer__ver">v{rel.version}</span>
|
||||
{rel.date && <span className="changelog-viewer__date">{rel.date}</span>}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="changelog-viewer__body" data-testid={`changelog-body-${rel.version}`}>
|
||||
{rel.intro && (
|
||||
<p className="changelog-viewer__intro">
|
||||
<InlineMd text={rel.intro} />
|
||||
</p>
|
||||
)}
|
||||
{(rel.sections || []).map((sec, i) => (
|
||||
<div key={`${sec.title}-${i}`} className="changelog-viewer__section">
|
||||
{sec.title && <div className="changelog-viewer__sec-title">{sec.title}</div>}
|
||||
<ul className="changelog-viewer__bullets">
|
||||
{sec.bullets.map((b, j) => (
|
||||
<li key={j}>
|
||||
<InlineMd text={b} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import ChangelogViewer from './ChangelogViewer';
|
||||
|
||||
const RELEASES = [
|
||||
{
|
||||
version: '0.3.9',
|
||||
date: '2026-07-02',
|
||||
intro: 'The dictation release.',
|
||||
sections: [
|
||||
{ title: 'Added', bullets: ['**Dictation, rebuilt.** Live waveform. (#123)'] },
|
||||
{ title: 'Fixed', bullets: ['**CUDA works.** Compat libs install. (#827)'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
version: '0.3.8',
|
||||
date: '2026-07-01',
|
||||
intro: 'A stability-focused release.',
|
||||
sections: [{ title: 'Added', bullets: ['**Autofit.** Keeps the timing. (#838)'] }],
|
||||
},
|
||||
];
|
||||
|
||||
describe('ChangelogViewer (Settings → Updates "What\'s new")', () => {
|
||||
it('renders every release with the newest expanded and older collapsed', () => {
|
||||
render(<ChangelogViewer releases={RELEASES} />);
|
||||
expect(screen.getByTestId('changelog-toggle-0.3.9')).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(screen.getByTestId('changelog-toggle-0.3.8')).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(screen.getByTestId('changelog-body-0.3.9')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('changelog-body-0.3.8')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders intro, section titles, and bullets with bold leads as text (no HTML)', () => {
|
||||
render(<ChangelogViewer releases={RELEASES} />);
|
||||
const body = screen.getByTestId('changelog-body-0.3.9');
|
||||
expect(body).toHaveTextContent('The dictation release.');
|
||||
expect(body).toHaveTextContent('Added');
|
||||
expect(body).toHaveTextContent('Fixed');
|
||||
// The **bold lead** renders as a <strong> element, not literal asterisks.
|
||||
const strong = body.querySelector('strong');
|
||||
expect(strong).not.toBeNull();
|
||||
expect(strong.textContent).toBe('Dictation, rebuilt.');
|
||||
expect(body.textContent).not.toContain('**');
|
||||
// Refs stay plain text — no links are ever emitted.
|
||||
expect(body).toHaveTextContent('(#123)');
|
||||
expect(body.querySelector('a')).toBeNull();
|
||||
});
|
||||
|
||||
it('accordion: clicking an older release expands it; clicking again collapses', () => {
|
||||
render(<ChangelogViewer releases={RELEASES} />);
|
||||
fireEvent.click(screen.getByTestId('changelog-toggle-0.3.8'));
|
||||
expect(screen.getByTestId('changelog-body-0.3.8')).toBeInTheDocument();
|
||||
// Only one open at a time — the newest closed when the older opened.
|
||||
expect(screen.queryByTestId('changelog-body-0.3.9')).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByTestId('changelog-toggle-0.3.8'));
|
||||
expect(screen.queryByTestId('changelog-body-0.3.8')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders nothing for empty input', () => {
|
||||
const { container } = render(<ChangelogViewer releases={[]} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { openExternal } from '../api/external';
|
||||
import { KOFI_URL, PAYPAL_URL } from '../utils/donateLinks';
|
||||
import { DONATE_LINE_COUNT } from '../utils/donationMoments';
|
||||
|
||||
/**
|
||||
* DonateMomentPopover — the friendly "Clippy-like" speech bubble that
|
||||
* LogsFooter anchors above the donate heart when a donation moment fires
|
||||
* (see utils/donationMoments.js for the strict eligibility gates).
|
||||
*
|
||||
* Deliberately NO backdrop and NO focus trap: it's an aside, not a modal —
|
||||
* the user can keep working and it auto-dismisses on its own. Bounce-in
|
||||
* animation collapses to a plain fade under `prefers-reduced-motion`.
|
||||
* Chrome palette throughout so it reads as part of the footer.
|
||||
*/
|
||||
|
||||
/** How long the popover lingers before quietly dismissing itself. */
|
||||
export const DONATE_POPOVER_AUTO_DISMISS_MS = 15_000;
|
||||
|
||||
// Small pill CTA shared by the Ko-fi / PayPal buttons.
|
||||
const CTA_BTN =
|
||||
'inline-flex items-center gap-[5px] px-[10px] h-[24px] rounded-[5px] cursor-pointer ' +
|
||||
'border border-solid border-transparent text-[11px] font-semibold [font-family:inherit] ' +
|
||||
'[background:var(--chrome-accent-bg)] [color:var(--chrome-fg)] transition-colors ' +
|
||||
'hover:[border-color:var(--chrome-accent-border)] hover:[color:var(--chrome-accent)]';
|
||||
|
||||
const QUIET_BTN =
|
||||
'bg-transparent border-0 cursor-pointer p-0 text-[11px] [font-family:inherit] ' +
|
||||
'[color:var(--chrome-fg-muted)] hover:[color:var(--chrome-fg)] transition-colors';
|
||||
|
||||
export default function DonateMomentPopover({ line = 0, onLater, onOptOut }) {
|
||||
const { t } = useTranslation();
|
||||
// Rotate through the friendly lines; clamp so a stale/oversized index from
|
||||
// the event detail can never resolve to a missing i18n key.
|
||||
const lineKey = `footer_donate.line_${(Math.abs(line | 0) % DONATE_LINE_COUNT) + 1}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-label={t('footer_donate.aria')}
|
||||
data-testid="donate-moment-popover"
|
||||
className={
|
||||
'absolute bottom-[calc(100%+10px)] right-0 z-[60] w-[272px] p-[12px] rounded-[10px] ' +
|
||||
'flex flex-col gap-[10px] select-none [background:var(--chrome-bg,#1d2021)] ' +
|
||||
'border border-solid [border-color:var(--chrome-border,rgba(255,255,255,0.08))] ' +
|
||||
'shadow-[0_8px_24px_rgba(0,0,0,0.45)] ' +
|
||||
'[animation:donate-pop-in_0.45s_cubic-bezier(0.34,1.56,0.64,1)_both] ' +
|
||||
'motion-reduce:[animation:donate-fade-in_0.2s_ease-out_both]'
|
||||
}
|
||||
>
|
||||
{/* Speech-bubble tail, pointing down at the heart. */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={
|
||||
'absolute -bottom-[6px] right-[12px] w-[10px] h-[10px] rotate-45 ' +
|
||||
'[background:var(--chrome-bg,#1d2021)] ' +
|
||||
'[border-right:1px_solid_var(--chrome-border,rgba(255,255,255,0.08))] ' +
|
||||
'[border-bottom:1px_solid_var(--chrome-border,rgba(255,255,255,0.08))]'
|
||||
}
|
||||
/>
|
||||
<p className="m-0 text-[11.5px] leading-[1.55] [color:var(--chrome-fg)]">{t(lineKey)}</p>
|
||||
<div className="flex items-center gap-[6px]">
|
||||
<button
|
||||
type="button"
|
||||
className={CTA_BTN}
|
||||
aria-label={t('footer_donate.kofi_aria')}
|
||||
onClick={() => {
|
||||
openExternal(KOFI_URL);
|
||||
onLater();
|
||||
}}
|
||||
>
|
||||
<span aria-hidden="true">☕</span> {t('footer_donate.kofi')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={CTA_BTN}
|
||||
aria-label={t('footer_donate.paypal_aria')}
|
||||
onClick={() => {
|
||||
openExternal(PAYPAL_URL);
|
||||
onLater();
|
||||
}}
|
||||
>
|
||||
<span aria-hidden="true">💳</span> {t('footer_donate.paypal')}
|
||||
</button>
|
||||
<span className="flex-1" />
|
||||
<button type="button" className={QUIET_BTN} onClick={onLater}>
|
||||
{t('footer_donate.later')}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOptOut}
|
||||
className={
|
||||
'self-end bg-transparent border-0 cursor-pointer p-0 text-[10px] [font-family:inherit] ' +
|
||||
'[color:var(--chrome-fg-dim,var(--chrome-fg-muted))] hover:underline ' +
|
||||
'hover:[color:var(--chrome-fg-muted)] transition-colors'
|
||||
}
|
||||
>
|
||||
{t('footer_donate.dont_ask')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,10 +8,14 @@ import {
|
||||
CheckCircle2,
|
||||
RefreshCw,
|
||||
Layers,
|
||||
Volume2,
|
||||
Copy,
|
||||
Check,
|
||||
} from 'lucide-react';
|
||||
import { toastErrorWithReport } from '../utils/errorToast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { listEngines, getEngineHealth } from '../api/engines';
|
||||
import { listEngines, getEngineHealth, selfTestEngine } from '../api/engines';
|
||||
import { copyText } from '../utils/copyText';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { Badge, Button, Segmented, Table } from '../ui';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -128,6 +132,8 @@ function normalizeEntry(entry) {
|
||||
isolation_mode: entry.isolation_mode || 'in-process',
|
||||
gpu_compat:
|
||||
Array.isArray(entry.gpu_compat) && entry.gpu_compat.length > 0 ? entry.gpu_compat : ['cpu'],
|
||||
// Copy-paste `export VAR=...` line for a path-gated opt-in engine, or null.
|
||||
setup_snippet: entry.setup_snippet || null,
|
||||
// Routing (#21) — may be absent on a legacy/older backend payload, in
|
||||
// which case the matrix renders exactly as before (no routing badge).
|
||||
effective_device: entry.effective_device || null,
|
||||
@@ -136,6 +142,13 @@ function normalizeEntry(entry) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Human duration: "0.4s" for ≥1 s, "820 ms" below — keeps the self-test
|
||||
* result compact whether a cold model load or a warm sub-second synth. */
|
||||
function fmtDuration(ms) {
|
||||
const n = Number(ms) || 0;
|
||||
return n >= 1000 ? `${(n / 1000).toFixed(1)}s` : `${Math.round(n)} ms`;
|
||||
}
|
||||
|
||||
export default function EngineCompatibilityMatrix({
|
||||
family = 'tts',
|
||||
onSelect = null,
|
||||
@@ -144,6 +157,7 @@ export default function EngineCompatibilityMatrix({
|
||||
// without resorting to module-level vi.mock incantations.
|
||||
apiListEngines = listEngines,
|
||||
apiGetEngineHealth = getEngineHealth,
|
||||
apiSelfTestEngine = selfTestEngine,
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [data, setData] = useState(null);
|
||||
@@ -152,12 +166,17 @@ export default function EngineCompatibilityMatrix({
|
||||
const [activeFamily, setActiveFamily] = useState(family);
|
||||
// Phase 3 Plan 03-01 / TTS-05: which engine has its license dialog
|
||||
// currently open, or null. Only one dialog is ever open at a time.
|
||||
const [, setLicenseDialogFor] = useState(null);
|
||||
const [licenseDialogFor, setLicenseDialogFor] = useState(null);
|
||||
|
||||
// health state keyed by engine id:
|
||||
// { [id]: { inflight: boolean, ok?: boolean, message?: string,
|
||||
// latency_ms?: number, lastClickAt?: number } }
|
||||
const [healthByEngine, setHealthByEngine] = useState({});
|
||||
// Self-test (real tiny synthesis) state keyed by engine id, same shape as
|
||||
// health plus { duration_ms, sample_rate, audio_seconds, timed_out }.
|
||||
const [selfTestByEngine, setSelfTestByEngine] = useState({});
|
||||
// Which engine's setup snippet was just copied (transient ✓ affordance).
|
||||
const [copiedId, setCopiedId] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveFamily(family);
|
||||
@@ -230,6 +249,48 @@ export default function EngineCompatibilityMatrix({
|
||||
[apiGetEngineHealth, healthByEngine],
|
||||
);
|
||||
|
||||
const runSelfTest = useCallback(
|
||||
async (id) => {
|
||||
const now = Date.now();
|
||||
const cur = selfTestByEngine[id];
|
||||
if (cur?.inflight) return;
|
||||
if (cur?.lastClickAt && now - cur.lastClickAt < TEST_COOLDOWN_MS) {
|
||||
// Click-storm cooldown — silently ignore. The backend also serialises
|
||||
// self-tests, so this is belt-and-braces against stacked model loads.
|
||||
return;
|
||||
}
|
||||
setSelfTestByEngine((prev) => ({
|
||||
...prev,
|
||||
[id]: { inflight: true, lastClickAt: now },
|
||||
}));
|
||||
try {
|
||||
const result = await apiSelfTestEngine(id);
|
||||
setSelfTestByEngine((prev) => ({
|
||||
...prev,
|
||||
[id]: { inflight: false, lastClickAt: now, ...result },
|
||||
}));
|
||||
} catch (e) {
|
||||
setSelfTestByEngine((prev) => ({
|
||||
...prev,
|
||||
[id]: {
|
||||
inflight: false,
|
||||
ok: false,
|
||||
message: e?.message || String(e),
|
||||
lastClickAt: now,
|
||||
},
|
||||
}));
|
||||
}
|
||||
},
|
||||
[apiSelfTestEngine, selfTestByEngine],
|
||||
);
|
||||
|
||||
const copySetup = useCallback(async (id, snippet) => {
|
||||
const ok = await copyText(snippet);
|
||||
if (!ok) return;
|
||||
setCopiedId(id);
|
||||
setTimeout(() => setCopiedId((c) => (c === id ? null : c)), 1500);
|
||||
}, []);
|
||||
|
||||
const COLUMNS = [
|
||||
{ key: 'name', label: t('engines.matrixTitle').split(' ')[0] || 'Engine', flex: 3 },
|
||||
{ key: 'status', label: t('engines.status'), width: 130, align: 'center' },
|
||||
@@ -266,6 +327,9 @@ export default function EngineCompatibilityMatrix({
|
||||
if (!familyData) return null;
|
||||
|
||||
const activeBackendId = activeId ?? familyData.active;
|
||||
// TTS-05: the license dialog registered for the engine awaiting acceptance
|
||||
// (or null). Capitalized so JSX renders it as a component below.
|
||||
const LicenseDialog = licenseDialogFor ? LICENSE_DIALOGS[licenseDialogFor] : null;
|
||||
|
||||
return (
|
||||
<section className="engine-matrix flex flex-col gap-[var(--space-3,8px)]">
|
||||
@@ -319,6 +383,12 @@ export default function EngineCompatibilityMatrix({
|
||||
{backends.map((b) => {
|
||||
const isActive = b.id === activeBackendId;
|
||||
const health = healthByEngine[b.id];
|
||||
const selfTest = selfTestByEngine[b.id];
|
||||
// Real-synthesis self-test is TTS-only and meaningful only for an
|
||||
// available, in-process engine (subprocess engines keep spawn-and-
|
||||
// ping via "Test engine"; a real synth there is a sidecar cold-start).
|
||||
const canSelfTest =
|
||||
activeFamily === 'tts' && b.available && b.isolation_mode !== 'subprocess';
|
||||
return (
|
||||
<div
|
||||
key={b.id}
|
||||
@@ -383,6 +453,35 @@ export default function EngineCompatibilityMatrix({
|
||||
{t('engines.lastError', { error: b.last_error })}
|
||||
</span>
|
||||
)}
|
||||
{/* Copy-paste-ready setup line for a path-gated opt-in
|
||||
engine (IndexTTS/MOSS-v1.5/dots/Confucius4) — the
|
||||
exact `export VAR=…` so users don't hunt the docs. */}
|
||||
{b.setup_snippet && (
|
||||
<div
|
||||
className="engine-matrix__setup flex flex-col gap-[3px] mt-[2px]"
|
||||
data-testid={`setup-snippet-${b.id}`}
|
||||
>
|
||||
<span className="text-[11px] text-[color:var(--chrome-fg-muted,#888)]">
|
||||
{t('engines.setupSnippetLabel')}
|
||||
</span>
|
||||
<div className="flex items-center gap-[6px] flex-wrap">
|
||||
<code className="engine-matrix__setup-code font-mono text-[11px] px-[6px] py-[2px] rounded [background:var(--chrome-bg-inset,rgba(255,255,255,0.05))] text-[color:var(--chrome-fg,currentColor)] break-all">
|
||||
{b.setup_snippet}
|
||||
</code>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
onClick={() => copySetup(b.id, b.setup_snippet)}
|
||||
leading={
|
||||
copiedId === b.id ? <Check size={11} /> : <Copy size={11} />
|
||||
}
|
||||
aria-label={t('engines.copySetup', { engine: b.display_name })}
|
||||
>
|
||||
{copiedId === b.id ? t('engines.copied') : t('engines.copy')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
@@ -415,7 +514,7 @@ export default function EngineCompatibilityMatrix({
|
||||
shows a single "Remote" badge instead of device chips. */}
|
||||
<div
|
||||
role="cell"
|
||||
className="engine-matrix__cell engine-matrix__cell--gpu flex items-center shrink-0"
|
||||
className="engine-matrix__cell engine-matrix__cell--gpu flex flex-col items-start justify-center shrink-0 gap-[3px]"
|
||||
style={{ width: 170 }}
|
||||
>
|
||||
<div className="engine-matrix__chips inline-flex flex-wrap gap-[4px]">
|
||||
@@ -467,6 +566,22 @@ export default function EngineCompatibilityMatrix({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* Make the routing reason reachable without a hover: the
|
||||
badge `title` is invisible to keyboard + touch users, so
|
||||
surface the same string as small visible text. Shown for
|
||||
available, non-remote, non-unavailable rows that carry a
|
||||
reason (cpu_fallback always; accelerated w/ a caveat). */}
|
||||
{b.routing_reason &&
|
||||
b.available &&
|
||||
b.routing_status !== 'n/a' &&
|
||||
b.routing_status !== 'unavailable' && (
|
||||
<span
|
||||
className="engine-matrix__routing-reason text-[10px] leading-[1.25] text-[color:var(--chrome-fg-muted,#888)]"
|
||||
data-testid={`routing-reason-${b.id}`}
|
||||
>
|
||||
{b.routing_reason}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Isolation mode */}
|
||||
@@ -528,15 +643,64 @@ export default function EngineCompatibilityMatrix({
|
||||
title={health.message}
|
||||
>
|
||||
{health.ok
|
||||
? t('engines.latencyMs', { ms: health.latency_ms })
|
||||
? // A subprocess row spawns + pings its sidecar → the
|
||||
// latency is a real round-trip. An in-process row only
|
||||
// imports + `is_available()`-checks (a ~0 ms liveness
|
||||
// probe, not a synthesis test), so label it as such
|
||||
// rather than a misleading "0 ms" latency.
|
||||
b.isolation_mode === 'subprocess'
|
||||
? t('engines.latencyMs', { ms: health.latency_ms })
|
||||
: t('engines.depsOk')
|
||||
: t('engines.failed')}
|
||||
</span>
|
||||
)}
|
||||
{/* Self-test: a real tiny synthesis proving the in-process TTS
|
||||
engine emits audio (not just imports). Guarded — TTS only,
|
||||
available + in-process only, user click only, cooldown +
|
||||
backend timeout bound it. */}
|
||||
{canSelfTest && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
onClick={() => runSelfTest(b.id)}
|
||||
disabled={!!selfTest?.inflight}
|
||||
loading={!!selfTest?.inflight}
|
||||
leading={!selfTest?.inflight && <Volume2 size={11} />}
|
||||
aria-label={`Self-test ${b.display_name}`}
|
||||
>
|
||||
{selfTest?.inflight ? t('engines.selfTesting') : t('engines.selfTest')}
|
||||
</Button>
|
||||
)}
|
||||
{canSelfTest && selfTest && !selfTest.inflight && (
|
||||
<span
|
||||
className={`engine-matrix__selftest-result text-[11px] font-mono ${selfTest.ok ? 'text-[color:var(--chrome-severity-ok,#98971a)]' : 'text-[color:var(--chrome-severity-err,#cc241d)]'}`}
|
||||
data-testid={`selftest-result-${b.id}`}
|
||||
title={selfTest.message}
|
||||
>
|
||||
{selfTest.ok
|
||||
? t('engines.selfTestOk', {
|
||||
seconds: Number(selfTest.audio_seconds ?? 0).toFixed(2),
|
||||
khz: selfTest.sample_rate
|
||||
? Math.round(selfTest.sample_rate / 1000)
|
||||
: '?',
|
||||
took: fmtDuration(selfTest.duration_ms),
|
||||
})
|
||||
: selfTest.timed_out
|
||||
? t('engines.selfTestTimedOut')
|
||||
: t('engines.selfTestFailed')}
|
||||
</span>
|
||||
)}
|
||||
{onSelect && b.available && !isActive && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
onClick={() => onSelect(activeFamily, b.id)}
|
||||
onClick={async () => {
|
||||
// Await the pick, then re-fetch so the active badge,
|
||||
// Use buttons, and family-tab captions reflect the new
|
||||
// engine immediately — no manual Refresh needed (#…).
|
||||
await onSelect(activeFamily, b.id);
|
||||
reload();
|
||||
}}
|
||||
aria-label={`Use ${b.display_name}`}
|
||||
>
|
||||
{t('engines.use')}
|
||||
@@ -570,6 +734,21 @@ export default function EngineCompatibilityMatrix({
|
||||
)}
|
||||
</div>
|
||||
</Table>
|
||||
|
||||
{/* TTS-05: license-acceptance dialog for the selected engine. Mounted
|
||||
only while `licenseDialogFor` is set (one at a time). On Accept the
|
||||
dialog POSTs the acceptance then `onAccepted` reloads the matrix so
|
||||
the row flips from unavailable → available without a manual refresh. */}
|
||||
{LicenseDialog && (
|
||||
<LicenseDialog
|
||||
open
|
||||
onClose={() => setLicenseDialogFor(null)}
|
||||
onAccepted={() => {
|
||||
setLicenseDialogFor(null);
|
||||
reload();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
// ── Feature-card geometry ───────────────────────────────────────────
|
||||
// Decorative waveform bar heights (px) on each card face — 7 CSS-only bars
|
||||
// pulse via stagger-delayed scaleY keyframes (see .lp-card-wave in
|
||||
// index.css). Static under prefers-reduced-motion.
|
||||
const CARD_WAVE = [8, 15, 10, 19, 12, 16, 9];
|
||||
// Card min-track (px) handed to the grid's `repeat(auto-fit, minmax(min, 1fr))`.
|
||||
// This floor is the ONLY responsive knob: the browser derives the column count
|
||||
// from the grid's OWN rendered width (= the shell's own width under the
|
||||
// `zoom: --ui-scale` model), so columns reflow 7→…→1 with zero viewport @media.
|
||||
// A wider floor on narrow shells yields fewer, comfier columns.
|
||||
const CARD_MIN_WIDE = '200px';
|
||||
const CARD_MIN_NARROW = '240px';
|
||||
|
||||
/**
|
||||
* FeatureCard — one launchpad feature tile in the full-width grid. `--card-hue`
|
||||
* (inline) drives the accent: icon, border, waveform, badge, glow. Hover OR
|
||||
* keyboard focus raises the card forward (`lp-action-card--raised`: lift + glow
|
||||
* + top z) — the raise is class-driven from React state so pointer and keyboard
|
||||
* share one code path and tests can assert it. The waveform strip is pure
|
||||
* decoration (aria-hidden); the button's accessible name stays title + desc.
|
||||
*/
|
||||
function FeatureCard({ hue, Icon, title, desc, count, onClick, index, raised, onRaise, onSettle }) {
|
||||
// Cursor-tracked spotlight: pointer position feeds --mx/--my so the
|
||||
// .lp-glow-layer radial gradient follows the cursor (it centres itself on
|
||||
// keyboard focus, which has no pointer).
|
||||
const handleMouseMove = (e) => {
|
||||
const r = e.currentTarget.getBoundingClientRect();
|
||||
e.currentTarget.style.setProperty('--mx', `${e.clientX - r.left}px`);
|
||||
e.currentTarget.style.setProperty('--my', `${e.clientY - r.top}px`);
|
||||
};
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`lp-action-card lp-animate lp-glow-card${raised === index ? ' lp-action-card--raised' : ''}`}
|
||||
style={{ '--card-hue': hue, '--lp-i': index }}
|
||||
onClick={onClick}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseEnter={() => onRaise(index)}
|
||||
onFocus={() => onRaise(index)}
|
||||
onBlur={onSettle}
|
||||
>
|
||||
<span className="lp-glow-layer" aria-hidden="true" />
|
||||
{count > 0 && <span className="card-count">{count}</span>}
|
||||
<div className="card-icon">
|
||||
<Icon size={18} color={hue} />
|
||||
</div>
|
||||
<h3>{title}</h3>
|
||||
<p className="card-desc">{desc}</p>
|
||||
<span className="lp-card-wave" aria-hidden="true">
|
||||
{CARD_WAVE.map((h, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="lp-card-wave__bar"
|
||||
style={{ '--wave-h': `${h}px`, '--wave-i': i }}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* LaunchpadDeck — the launchpad's seven feature cards as a full-width,
|
||||
* responsive grid. PR #904 fanned them into a fixed ~780px deck that left dead
|
||||
* margins in a maximized window; this fills the content width instead and
|
||||
* reflows its column count (7→…→1 from a maximized ~2560px display down to the
|
||||
* 900×600 minimum) via `repeat(auto-fit, minmax(--lp-card-min, 1fr))` — the
|
||||
* same container-driven mechanism the rest of the launchpad uses, never a
|
||||
* viewport @media (which fires at the wrong width whenever --ui-scale ≠ 1).
|
||||
* `narrow` (the app-container's own width class, via useShellNarrow) only
|
||||
* widens the card floor so narrow shells get fewer, comfier columns. Which card
|
||||
* is raised by hover/focus lives here in React so keyboard focus shares the
|
||||
* exact same forward-lift as the pointer and tests can assert it.
|
||||
*/
|
||||
export default function LaunchpadDeck({ features, narrow = false }) {
|
||||
const [raised, setRaised] = useState(null);
|
||||
return (
|
||||
<div
|
||||
className="lp-cards"
|
||||
style={{ '--lp-card-min': narrow ? CARD_MIN_NARROW : CARD_MIN_WIDE }}
|
||||
onMouseLeave={() => setRaised(null)}
|
||||
>
|
||||
{features.map((f, i) => (
|
||||
<FeatureCard
|
||||
key={f.key}
|
||||
index={i}
|
||||
hue={f.hue}
|
||||
Icon={f.Icon}
|
||||
title={f.title}
|
||||
desc={f.desc}
|
||||
count={f.count}
|
||||
onClick={f.go}
|
||||
raised={raised}
|
||||
onRaise={setRaised}
|
||||
onSettle={() => setRaised(null)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,9 @@ import {
|
||||
FileText,
|
||||
Heart,
|
||||
Mail,
|
||||
Sparkles,
|
||||
Braces,
|
||||
Gem,
|
||||
} from 'lucide-react';
|
||||
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -23,7 +26,9 @@ import { getFrontendLogs, clearFrontendLogs } from '../utils/consoleBuffer';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppStore } from '../store';
|
||||
import NetworkToggle from './NetworkToggle';
|
||||
import { APP_VERSION } from '../utils/appVersion';
|
||||
import { APP_VERSION, whatsNewPending } from '../utils/appVersion';
|
||||
import DonateMomentPopover, { DONATE_POPOVER_AUTO_DISMISS_MS } from './DonateMomentPopover';
|
||||
import { DONATION_MOMENT_EVENT, optOutOfDonationMoments } from '../utils/donationMoments';
|
||||
|
||||
/**
|
||||
* VSCode-style bottom panel for logs. Always-visible 28 px collapsed bar
|
||||
@@ -128,13 +133,25 @@ const DISCORD_BTN =
|
||||
'rounded-[4px] bg-transparent border-0 cursor-pointer [color:#7289da] opacity-60 ' +
|
||||
'transition-[color,opacity,transform] duration-150 hover:opacity-100 hover:[color:#5865F2] hover:scale-110';
|
||||
|
||||
// API-reference button: same compact footer-icon shell as Discord/Mail but on
|
||||
// the neutral chrome-muted palette, hovering to the theme accent.
|
||||
const API_REF_BTN =
|
||||
'flex items-center justify-center w-[var(--chrome-icon-btn)] h-[var(--chrome-icon-btn)] shrink-0 ' +
|
||||
'rounded-[4px] bg-transparent border-0 cursor-pointer [color:var(--chrome-fg-muted)] opacity-70 ' +
|
||||
'transition-[color,opacity,transform] duration-150 hover:opacity-100 hover:[color:var(--chrome-accent)] hover:scale-110';
|
||||
|
||||
const DONATE_BTN =
|
||||
'flex items-center justify-center w-[var(--chrome-icon-btn)] h-[var(--chrome-icon-btn)] shrink-0 ' +
|
||||
'rounded-[4px] bg-transparent border-0 cursor-pointer [color:#d3869b] ml-[4px] ' +
|
||||
'transition-[color,transform] duration-150 hover:[color:var(--chrome-accent)] hover:scale-[1.15] ' +
|
||||
'rounded-[4px] bg-transparent border-0 cursor-pointer [color:#d3869b] ' +
|
||||
'transition-[color,transform] duration-150 hover:[color:var(--chrome-accent)] hover:scale-[1.15]';
|
||||
// Idle glow vs. the gentle attention pulse while the donation-moment popover
|
||||
// is open. Split from DONATE_BTN so exactly one animation applies at a time.
|
||||
const HEART_GLOW =
|
||||
'[animation:heart-glow_2.5s_ease-in-out_infinite] motion-reduce:[animation:none]';
|
||||
const HEART_PULSE =
|
||||
'[animation:donate-heart-pulse_1.1s_ease-in-out_infinite] motion-reduce:[animation:none]';
|
||||
|
||||
function SourcePill({ source, counts, active, onClick }) {
|
||||
function SourcePill({ source, counts, active, onClick, icon: Icon }) {
|
||||
const hasErrors = counts.error > 0;
|
||||
const hasWarns = counts.warn > 0;
|
||||
// Severity color wins over active wins over muted (matches old cascade).
|
||||
@@ -155,6 +172,7 @@ function SourcePill({ source, counts, active, onClick }) {
|
||||
onClick={onClick}
|
||||
aria-label={`${source.label} logs${hasErrors ? `, ${counts.error} errors` : hasWarns ? `, ${counts.warn} warnings` : ''}`}
|
||||
>
|
||||
{Icon && <Icon size={12} className="shrink-0" aria-hidden="true" />}
|
||||
<span className="font-medium">{source.label}</span>
|
||||
{hasErrors && <span className={BADGE_ERROR}>{counts.error}</span>}
|
||||
{!hasErrors && hasWarns && <span className={BADGE_WARN}>{counts.warn}</span>}
|
||||
@@ -208,6 +226,21 @@ export default function LogsFooter() {
|
||||
const updateStatus = useAppStore((s) => s.updateStatus);
|
||||
const updateVersion = useAppStore((s) => s.updateVersion);
|
||||
const updateReady = updateStatus === 'available' || updateStatus === 'ready';
|
||||
// One-time "What's new" affordance after an update (feat/safe-updates):
|
||||
// non-blocking footer pill, never a startup modal. First run with no
|
||||
// recorded version baselines silently; after an update the pill shows
|
||||
// until the user opens the notes (or clicks it away).
|
||||
const whatsNewSeen = useAppStore((s) => s.whatsNewSeenVersion);
|
||||
useEffect(() => {
|
||||
if (whatsNewSeen == null && APP_VERSION !== 'unknown') {
|
||||
useAppStore.getState().setWhatsNewSeenVersion(APP_VERSION);
|
||||
}
|
||||
}, [whatsNewSeen]);
|
||||
const showWhatsNew = whatsNewPending(whatsNewSeen, APP_VERSION);
|
||||
const openWhatsNew = useCallback(() => {
|
||||
useAppStore.getState().setWhatsNewSeenVersion(APP_VERSION);
|
||||
useAppStore.getState().openSettingsTab?.('updates');
|
||||
}, []);
|
||||
const [height, setHeight] = useState(() => {
|
||||
const v = Number(localStorage.getItem(LS_HEIGHT));
|
||||
return Number.isFinite(v) && v >= MIN_H && v <= MAX_H ? v : 300;
|
||||
@@ -226,10 +259,11 @@ export default function LogsFooter() {
|
||||
useEffect(() => localStorage.setItem(LS_HEIGHT, String(height)), [height]);
|
||||
useEffect(() => localStorage.setItem(LS_ACTIVE, active), [active]);
|
||||
|
||||
// Expose the current footer height as a CSS variable on :root so the
|
||||
// studio's .app-container grid + the setup-wizard wrapper both shrink
|
||||
// by exactly the right amount. Keeps sidebar + main content out from
|
||||
// under the expanded panel without any JS-driven layout math.
|
||||
// Expose the current footer height as a CSS variable on :root. Inside the
|
||||
// studio shell the footer is a grid ROW (index.css .app-container), so
|
||||
// content clearance needs no variable — but the fixed toasts/previews that
|
||||
// anchor above the footer (VoicePreview, ExportModal, …) and the
|
||||
// setup-wizard wrapper still position off --logs-footer-height.
|
||||
useEffect(() => {
|
||||
const h = collapsed ? 28 : height;
|
||||
document.documentElement.style.setProperty('--logs-footer-height', `${h}px`);
|
||||
@@ -292,6 +326,22 @@ export default function LogsFooter() {
|
||||
const notifQuery = useNotifications();
|
||||
const notifications = notifQuery.data?.notifications || [];
|
||||
|
||||
// ── Donation moment popover (see utils/donationMoments.js) ─────────────
|
||||
// The eligibility engine dispatches DONATION_MOMENT_EVENT after a rare,
|
||||
// gated value-creation success; the footer just renders the speech bubble
|
||||
// above the heart and auto-dismisses it. null = closed.
|
||||
const [donateMoment, setDonateMoment] = useState(null);
|
||||
useEffect(() => {
|
||||
const onMoment = (e) => setDonateMoment({ line: e?.detail?.line ?? 0 });
|
||||
window.addEventListener(DONATION_MOMENT_EVENT, onMoment);
|
||||
return () => window.removeEventListener(DONATION_MOMENT_EVENT, onMoment);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!donateMoment) return undefined;
|
||||
const timer = setTimeout(() => setDonateMoment(null), DONATE_POPOVER_AUTO_DISMISS_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [donateMoment]);
|
||||
|
||||
// Allow header bell to open notifications tab
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
@@ -451,6 +501,7 @@ export default function LogsFooter() {
|
||||
Expanding reveals the per-source filter tabs below. */
|
||||
<SourcePill
|
||||
source={{ id: 'logs', label: t('logs.title') }}
|
||||
icon={FileText}
|
||||
counts={mergedCounts}
|
||||
active={false}
|
||||
onClick={() => openTo(SOURCES.some((s) => s.id === active) ? active : 'backend')}
|
||||
@@ -518,6 +569,29 @@ export default function LogsFooter() {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{showWhatsNew && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="whats-new-pill"
|
||||
className={
|
||||
'shrink-0 inline-flex items-center gap-[4px] px-[7px] h-[var(--chrome-icon-btn)] rounded-[999px] cursor-pointer ' +
|
||||
'text-[10px] tracking-[0.02em] border border-[color:var(--chrome-accent)] bg-transparent ' +
|
||||
'[color:var(--chrome-accent)] hover:opacity-80 transition-opacity duration-150'
|
||||
}
|
||||
onClick={openWhatsNew}
|
||||
title={t('updates.whats_new_in', {
|
||||
version: APP_VERSION,
|
||||
defaultValue: "What's new in v{{version}}",
|
||||
})}
|
||||
aria-label={t('updates.whats_new_in', {
|
||||
version: APP_VERSION,
|
||||
defaultValue: "What's new in v{{version}}",
|
||||
})}
|
||||
>
|
||||
<Sparkles size={11} aria-hidden="true" />
|
||||
{t('update.whats_new', { defaultValue: "What's new" })}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
@@ -558,6 +632,15 @@ export default function LogsFooter() {
|
||||
)}
|
||||
</button>
|
||||
<NetworkToggle />
|
||||
<button
|
||||
type="button"
|
||||
className={API_REF_BTN}
|
||||
onClick={() => useAppStore.getState().openSettingsTab?.('openapi')}
|
||||
title={t('logs.open_api', { defaultValue: 'API reference' })}
|
||||
aria-label={t('logs.open_api_aria', { defaultValue: 'Open the OpenAPI reference' })}
|
||||
>
|
||||
<Braces size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={DISCORD_BTN}
|
||||
@@ -582,15 +665,55 @@ export default function LogsFooter() {
|
||||
>
|
||||
<Mail size={14} />
|
||||
</button>
|
||||
{/* Sponsors — a compact link (never a logo strip in the 28px bar).
|
||||
Opens the Support page, whose Sponsors section holds the logo
|
||||
grid + "Become a sponsor" affordance. */}
|
||||
<button
|
||||
type="button"
|
||||
className={DONATE_BTN}
|
||||
className={
|
||||
'shrink-0 inline-flex items-center gap-[4px] px-[7px] h-[var(--chrome-icon-btn)] rounded-[4px] ' +
|
||||
'bg-transparent border-0 cursor-pointer text-[11px] tracking-[0.02em] ' +
|
||||
'[color:var(--chrome-fg-muted)] transition-[color,opacity] duration-150 ' +
|
||||
'hover:[color:var(--chrome-accent)]'
|
||||
}
|
||||
onClick={() => useAppStore.getState().setMode?.('donate')}
|
||||
title={t('logs.support_project')}
|
||||
aria-label={t('logs.support_project_aria')}
|
||||
title={t('logs.sponsors', { defaultValue: 'Sponsors' })}
|
||||
aria-label={t('logs.sponsors_aria', {
|
||||
defaultValue: 'View sponsors and support the project',
|
||||
})}
|
||||
>
|
||||
<DonateHeart />
|
||||
<Gem size={12} aria-hidden="true" />
|
||||
{t('logs.sponsors', { defaultValue: 'Sponsors' })}
|
||||
</button>
|
||||
<div className="relative inline-flex shrink-0 ml-[4px]">
|
||||
<button
|
||||
type="button"
|
||||
className={`${DONATE_BTN} ${donateMoment ? HEART_PULSE : HEART_GLOW}`}
|
||||
onClick={() => {
|
||||
// Manual entry is unchanged: the heart always opens the full
|
||||
// donate view (and quietly retires an open popover).
|
||||
setDonateMoment(null);
|
||||
useAppStore.getState().setMode?.('donate');
|
||||
}}
|
||||
title={t('logs.support_project')}
|
||||
aria-label={t('logs.support_project_aria')}
|
||||
>
|
||||
<DonateHeart />
|
||||
</button>
|
||||
{donateMoment && (
|
||||
<DonateMomentPopover
|
||||
line={donateMoment.line}
|
||||
onLater={() => setDonateMoment(null)}
|
||||
onOptOut={() => {
|
||||
optOutOfDonationMoments();
|
||||
// Mirror into the legacy postcard flag so both engines stay
|
||||
// permanently silenced no matter which one is wired.
|
||||
useAppStore.getState().optOutOfDonation?.();
|
||||
setDonateMoment(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// Safe markdown-lite renderer for release notes / changelog bullets.
|
||||
// Everything is emitted as React TEXT nodes (strong/code wrappers only) —
|
||||
// no dangerouslySetInnerHTML, so notes from any source stay inert.
|
||||
import { inlineSegments, parseBlocks } from '../utils/markdownLite';
|
||||
|
||||
/** Inline run: **bold** / `code` / plain text (links + refs already plain). */
|
||||
export function InlineMd({ text }) {
|
||||
return (
|
||||
<>
|
||||
{inlineSegments(text).map((seg, i) =>
|
||||
seg.type === 'bold' ? (
|
||||
<strong key={i}>{seg.text}</strong>
|
||||
) : seg.type === 'code' ? (
|
||||
<code key={i}>{seg.text}</code>
|
||||
) : (
|
||||
<span key={i}>{seg.text}</span>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Block renderer for a whole notes string (updater body / release body). */
|
||||
export default function MarkdownLite({ text, className = '' }) {
|
||||
const blocks = parseBlocks(text);
|
||||
if (!blocks.length) return null;
|
||||
const out = [];
|
||||
let bullets = [];
|
||||
const flushBullets = () => {
|
||||
if (!bullets.length) return;
|
||||
out.push(
|
||||
<ul key={`ul-${out.length}`} className="md-lite__list">
|
||||
{bullets.map((b, i) => (
|
||||
<li key={i}>
|
||||
<InlineMd text={b.text} />
|
||||
</li>
|
||||
))}
|
||||
</ul>,
|
||||
);
|
||||
bullets = [];
|
||||
};
|
||||
for (const block of blocks) {
|
||||
if (block.type === 'bullet') {
|
||||
bullets.push(block);
|
||||
continue;
|
||||
}
|
||||
flushBullets();
|
||||
if (block.type === 'heading') {
|
||||
out.push(
|
||||
<div key={`h-${out.length}`} className="md-lite__heading">
|
||||
<InlineMd text={block.text} />
|
||||
</div>,
|
||||
);
|
||||
} else {
|
||||
out.push(
|
||||
<p key={`p-${out.length}`} className="md-lite__para">
|
||||
<InlineMd text={block.text} />
|
||||
</p>,
|
||||
);
|
||||
}
|
||||
}
|
||||
flushBullets();
|
||||
return <div className={`md-lite ${className}`.trim()}>{out}</div>;
|
||||
}
|
||||
@@ -85,7 +85,9 @@ export default function NetworkToggle() {
|
||||
disabled={busy}
|
||||
title={st.enabled ? t('network.sharing_on_title') : t('network.share_on_network')}
|
||||
>
|
||||
{st.enabled ? <Wifi size={12} /> : <WifiOff size={12} />}
|
||||
{/* 14px matches the footer's other right-side icons (Discord, Mail,
|
||||
donate heart) — keep them optically uniform. */}
|
||||
{st.enabled ? <Wifi size={14} /> : <WifiOff size={14} />}
|
||||
<span>
|
||||
{busy ? t('network.switching') : st.enabled ? t('network.network') : t('network.local')}
|
||||
</span>
|
||||
|
||||
@@ -27,11 +27,15 @@ const fmt = (t) => {
|
||||
* SegmentTrack — custom DOM segment editor lane for the dub timeline (#280).
|
||||
*
|
||||
* Replaces the WaveSurfer Regions plugin in the editing path. Renders one
|
||||
* absolutely-positioned box per segment inside a lane whose horizontal
|
||||
* position is derived from a single {pxPerSec, scrollLeft} source (read off
|
||||
* WaveSurfer's wrapper by the parent), so boxes stay pixel-aligned with the
|
||||
* waveform across zoom/scroll/resize. Virtualized by TIME — only the boxes
|
||||
* inside the visible window (+ buffer) are mounted.
|
||||
* absolutely-positioned box per segment, each placed in PURE LAYOUT from a
|
||||
* single {pxPerSec, scrollLeft} source (read off WaveSurfer's wrapper by the
|
||||
* parent), so boxes stay pixel-aligned with the waveform across
|
||||
* zoom/scroll/resize. The lane itself must NEVER be transform-animated: a
|
||||
* lane translateX'd on every playback tick gets promoted to a compositor
|
||||
* layer by Chromium, and composited semi-transparent paints flash
|
||||
* invisible/visible on some Windows GPU/WebView2 drivers (#373; #381 only
|
||||
* dampened it). Virtualized by TIME — only the boxes inside the visible
|
||||
* window (+ buffer) are mounted.
|
||||
*
|
||||
* Props:
|
||||
* segments sorted-by-start segment array (store shape)
|
||||
@@ -465,6 +469,13 @@ export default function SegmentTrack({
|
||||
const innerWidth = Math.max(viewWidth, Math.ceil(duration * pxPerSec));
|
||||
const playheadX = currentTime * pxPerSec - effScroll;
|
||||
const windowed = effSegments.slice(lo, hi);
|
||||
// Scroll offset baked into each box's `left` (viewport coordinates) instead
|
||||
// of a `translateX` on the lane — an animated lane transform is composited
|
||||
// by Chromium and flashes on some Windows GPU/WebView2 drivers (#373). In
|
||||
// the selfScroll fallback the viewport is a real scroll container, so boxes
|
||||
// stay in lane coordinates (offset 0). The virtualization window above
|
||||
// derives from the same effScroll, so both stay consistent by construction.
|
||||
const laneShift = selfScroll ? 0 : effScroll;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -489,14 +500,16 @@ export default function SegmentTrack({
|
||||
aria-orientation="horizontal"
|
||||
title={t('timeline.keyboard_hint')}
|
||||
style={{
|
||||
width: innerWidth,
|
||||
transform: selfScroll ? undefined : `translateX(${-effScroll}px)`,
|
||||
// selfScroll needs the full content width for the native
|
||||
// scrollbar range; in synced mode the lane is a static
|
||||
// viewport-sized strip (NO transform — see laneShift above).
|
||||
width: selfScroll ? innerWidth : '100%',
|
||||
}}
|
||||
>
|
||||
{windowed.map((s) => {
|
||||
const sid = String(s.id);
|
||||
const idx = indexById.get(sid) ?? 0;
|
||||
const left = s.start * pxPerSec;
|
||||
const left = s.start * pxPerSec - laneShift;
|
||||
const width = Math.max(2, (s.end - s.start) * pxPerSec);
|
||||
const isSel = selectedId != null && String(selectedId) === sid;
|
||||
const isFocus = focusId === sid;
|
||||
|
||||
@@ -171,6 +171,46 @@ describe('SegmentTrack — keyboard', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('SegmentTrack — compositor-safe positioning (#373)', () => {
|
||||
const baseProps = {
|
||||
segments: SEGS,
|
||||
pxPerSec: 100,
|
||||
duration: 10,
|
||||
currentTime: 0,
|
||||
onsets: [],
|
||||
};
|
||||
|
||||
it('the lane never carries a transform — at rest and after a scroll update', () => {
|
||||
// Regression guard for #373: an animated `translateX` on the lane gets
|
||||
// composited by Chromium during playback and its boxes flash on some
|
||||
// Windows GPU/WebView2 drivers. Any reintroduction must fail here.
|
||||
const { rerender } = render(<SegmentTrack {...baseProps} scrollLeft={0} />);
|
||||
expect(screen.getByRole('listbox').style.transform).toBe('');
|
||||
// Simulate a WaveSurfer autoscroll tick during playback.
|
||||
rerender(<SegmentTrack {...baseProps} scrollLeft={250} />);
|
||||
expect(screen.getByRole('listbox').style.transform).toBe('');
|
||||
});
|
||||
|
||||
it('boxes are laid out in viewport coordinates: left = start·pxPerSec − scrollLeft', () => {
|
||||
setup({ scrollLeft: 250 });
|
||||
expect(box(0).style.left).toBe('-250px'); // start 0
|
||||
expect(box(1).style.left).toBe('50px'); // start 3 → 300 − 250
|
||||
expect(box(2).style.left).toBe('250px'); // start 5 → 500 − 250
|
||||
});
|
||||
|
||||
it('selfScroll fallback keeps lane coordinates — viewport scroll must not double-shift boxes', () => {
|
||||
setup({ selfScroll: true });
|
||||
const lane = screen.getByRole('listbox');
|
||||
const viewport = lane.parentElement;
|
||||
// jsdom has no layout: stub the scroll position, then fire the event the
|
||||
// component listens to.
|
||||
Object.defineProperty(viewport, 'scrollLeft', { value: 120, configurable: true });
|
||||
fireEvent.scroll(viewport);
|
||||
expect(lane.style.transform).toBe('');
|
||||
expect(box(1).style.left).toBe('300px'); // lane coords: 3s · 100px/s
|
||||
});
|
||||
});
|
||||
|
||||
describe('SegmentTrack — pointer + selection', () => {
|
||||
it('pointerdown selects the segment (table sync)', () => {
|
||||
const { onSelectSeg } = setup();
|
||||
|
||||
@@ -30,6 +30,7 @@ import WaveformPlayer from './WaveformPlayer';
|
||||
import { useAppStore } from '../store';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { askConfirm } from '../utils/dialog';
|
||||
import { absoluteTime, timeAgo } from '../utils/relativeTime';
|
||||
|
||||
const SIDEBAR_TABS = [
|
||||
{ id: 'projects', icon: FolderOpen, accent: '#b8bb26' },
|
||||
@@ -71,20 +72,6 @@ function LazyWaveformPlayer({ height = 36, className = '', ...rest }) {
|
||||
return <div ref={holderRef} className={className} style={{ height }} aria-hidden="true" />;
|
||||
}
|
||||
|
||||
function timeAgo(ms) {
|
||||
const diff = Date.now() - ms;
|
||||
if (!isFinite(diff) || diff < 0) return '';
|
||||
const s = Math.floor(diff / 1000);
|
||||
if (s < 60) return `${s}s ago`;
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
const d = Math.floor(h / 24);
|
||||
if (d < 7) return `${d}d ago`;
|
||||
return new Date(ms).toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
export default function Sidebar(props) {
|
||||
const {
|
||||
availableTabs = ['projects', 'history', 'downloads'],
|
||||
@@ -311,11 +298,8 @@ export default function Sidebar(props) {
|
||||
<span className="history-kind history-kind--audio">
|
||||
<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)}
|
||||
<span className="history-meta" title={absoluteTime(proj.updated_at)}>
|
||||
{timeAgo(proj.updated_at)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="history-title">{proj.name}</div>
|
||||
@@ -772,7 +756,7 @@ export default function Sidebar(props) {
|
||||
>
|
||||
<KindIcon size={9} /> {item.mode}
|
||||
</span>
|
||||
<span className="history-meta">{timeAgo(item.created_at * 1000)}</span>
|
||||
<span className="history-meta">{timeAgo(item.created_at)}</span>
|
||||
</div>
|
||||
<div className="history-title">{item.filename}</div>
|
||||
<div className="history-subtitle">
|
||||
|
||||
@@ -44,7 +44,7 @@ import toast from 'react-hot-toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Menu } from '../ui';
|
||||
import { useAppStore } from '../store';
|
||||
import { evaluateDonationPrompt } from './donate/evaluateDonationPrompt';
|
||||
import { recordValueMoment } from '../utils/donationMoments';
|
||||
import {
|
||||
parseStoryText,
|
||||
hasStoryMarkers,
|
||||
@@ -570,9 +570,9 @@ export default function StoriesEditor({ profiles = [] }) {
|
||||
if (!output) throw new Error('no output produced');
|
||||
downloadUrl(audioUrl(output), output.split('/').pop());
|
||||
toast.success(t('stories.exportDone'));
|
||||
// Success-only donation prompt (#007) — a finished longform export is a
|
||||
// real deliverable. Stays out of the catch/error branch below.
|
||||
evaluateDonationPrompt('longform');
|
||||
// Success-only donation moment — a finished audiobook export is a real
|
||||
// deliverable. Stays out of the catch/error branch below.
|
||||
recordValueMoment('audiobook');
|
||||
} catch (err) {
|
||||
console.warn('Story render failed:', err);
|
||||
toast.error(t('stories.exportFailed'));
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Mic, Search, Clock, Languages } from 'lucide-react';
|
||||
import { Dialog, Input } from '../ui';
|
||||
import { toMillis } from '../utils/relativeTime';
|
||||
import { loadTranscriptions, TRANSCRIPTION_EVENT } from '../utils/transcriptionsStore';
|
||||
|
||||
/**
|
||||
@@ -27,10 +28,12 @@ export default function TranscriptionPicker({ open, onClose, onPick }) {
|
||||
}, [open]);
|
||||
|
||||
// Relative time, host-locale absolute fallback; null on unparseable timestamp.
|
||||
// toMillis keeps this unit-safe (ISO strings today; seconds/ms tolerated).
|
||||
const formatTime = (iso) => {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
const diff = Date.now() - d.getTime();
|
||||
const ms = toMillis(iso);
|
||||
if (ms == null) return null;
|
||||
const d = new Date(ms);
|
||||
const diff = Date.now() - ms;
|
||||
if (diff < 60000) return t('transcriptions.just_now');
|
||||
if (diff < 3600000) return t('transcriptions.m_ago', { count: Math.floor(diff / 60000) });
|
||||
if (diff < 86400000) return t('transcriptions.h_ago', { count: Math.floor(diff / 3600000) });
|
||||
|
||||
@@ -1,19 +1,34 @@
|
||||
// frontend/src/components/UpdatesPanel.jsx
|
||||
// Update management panel — lives under Settings → Updates. Shows live update
|
||||
// status, channel switcher, and GitHub releases (changelog/history) list.
|
||||
import { useEffect } from 'react';
|
||||
// status (with the available build's actual release notes), channel switcher,
|
||||
// the data-safety line (pre-update DB backups), the app's own "What's new"
|
||||
// changelog viewer, and the GitHub releases (changelog/history) list.
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Download, RotateCw, AlertTriangle, RefreshCw, X } from 'lucide-react';
|
||||
import {
|
||||
Download,
|
||||
RotateCw,
|
||||
AlertTriangle,
|
||||
RefreshCw,
|
||||
X,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
} 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 { fetchChangelog, fetchBackupState } from '../utils/updatesApi';
|
||||
import { APP_VERSION } from '../utils/appVersion';
|
||||
import MarkdownLite from './MarkdownLite';
|
||||
import ChangelogViewer from './ChangelogViewer';
|
||||
|
||||
export default function UpdatesPanel() {
|
||||
const { t } = useTranslation();
|
||||
const status = useAppStore((s) => s.updateStatus);
|
||||
const version = useAppStore((s) => s.updateVersion);
|
||||
const notes = useAppStore((s) => s.updateNotes);
|
||||
const error = useAppStore((s) => s.updateError);
|
||||
const progress = useAppStore((s) => s.updateProgress);
|
||||
const appVersion = useAppStore((s) => s.appVersion);
|
||||
@@ -24,10 +39,33 @@ export default function UpdatesPanel() {
|
||||
const dismissUpdate = useAppStore((s) => s.dismissUpdate);
|
||||
const dubStep = useAppStore((s) => s.dubStep);
|
||||
|
||||
const [changelog, setChangelog] = useState([]);
|
||||
const [backup, setBackup] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadReleases(channel);
|
||||
}, [channel, loadReleases]);
|
||||
|
||||
// Local-first data: the shipped changelog + the newest pre-migration DB
|
||||
// backup. Both degrade to empty on failure (the sections just hide).
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
fetchChangelog(5).then((rel) => alive && setChangelog(rel));
|
||||
fetchBackupState().then((b) => alive && setBackup(b));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Opening the panel counts as reading the notes — retire the one-time
|
||||
// footer "What's new" pill for this version (feat/safe-updates). The pill
|
||||
// compares against the build constant, so fall back to it when the Tauri
|
||||
// version isn't available (web/dev builds).
|
||||
useEffect(() => {
|
||||
const v = appVersion || (APP_VERSION !== 'unknown' ? APP_VERSION : null);
|
||||
if (v) useAppStore.getState().setWhatsNewSeenVersion?.(v);
|
||||
}, [appVersion]);
|
||||
|
||||
const busy = dubStep === 'generating';
|
||||
const onInstall = () => {
|
||||
if (busy) {
|
||||
@@ -37,6 +75,7 @@ export default function UpdatesPanel() {
|
||||
installUpdate(useAppStore.getState());
|
||||
};
|
||||
const rows = prepareReleases(releases, channel, appVersion);
|
||||
const latestBackup = backup?.available ? backup.latest : null;
|
||||
|
||||
return (
|
||||
<div className="updates-panel">
|
||||
@@ -88,6 +127,17 @@ export default function UpdatesPanel() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* The available build's actual release notes — the updater manifest
|
||||
carries them (UpdateMeta.notes); render markdown-lite safely. */}
|
||||
{status === 'available' && notes && (
|
||||
<div className="updates-panel__notes" data-testid="update-notes">
|
||||
<div className="updates-panel__notes-head">
|
||||
{t('updates.notes_for', { version: version || '' })}
|
||||
</div>
|
||||
<MarkdownLite text={notes} className="updates-panel__notes-body" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="updates-panel__channel">
|
||||
<span>{t('about.update_channel')}</span>
|
||||
<div
|
||||
@@ -116,6 +166,30 @@ export default function UpdatesPanel() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Data-safety line: the backend snapshots omnivoice.db before every
|
||||
schema migration (i.e. before the first run of an updated build). */}
|
||||
<div className="updates-panel__backup" data-testid="backup-line">
|
||||
<ShieldCheck size={12} aria-hidden="true" />
|
||||
<span>
|
||||
{t('updates.backup_line')}{' '}
|
||||
{latestBackup?.created_at
|
||||
? t('updates.backup_latest', {
|
||||
when: new Date(latestBackup.created_at * 1000).toLocaleString(),
|
||||
})
|
||||
: t('updates.backup_none')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* "What's new" — the app's own CHANGELOG.md, newest expanded. */}
|
||||
{changelog.length > 0 && (
|
||||
<div className="updates-panel__whatsnew">
|
||||
<div className="updates-panel__rel-head">
|
||||
<Sparkles size={12} aria-hidden="true" /> {t('update.whats_new')}
|
||||
</div>
|
||||
<ChangelogViewer releases={changelog} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="updates-panel__releases">
|
||||
<div className="updates-panel__rel-head">{t('updates.releases')}</div>
|
||||
{releasesStatus === 'error' && (
|
||||
@@ -145,7 +219,7 @@ export default function UpdatesPanel() {
|
||||
)}
|
||||
<span className="updates-panel__rel-date">{r.date}</span>
|
||||
</div>
|
||||
{r.notes && <pre className="updates-panel__rel-notes">{r.notes}</pre>}
|
||||
{r.notes && <MarkdownLite text={r.notes} className="updates-panel__rel-notes" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import UpdatesPanel from './UpdatesPanel';
|
||||
import { useAppStore } from '../store';
|
||||
|
||||
// Local-first backend data (changelog + backup state) — controllable per test.
|
||||
const mockChangelog = vi.fn();
|
||||
const mockBackup = vi.fn();
|
||||
vi.mock('../utils/updatesApi', () => ({
|
||||
fetchChangelog: (...a) => mockChangelog(...a),
|
||||
fetchBackupState: (...a) => mockBackup(...a),
|
||||
}));
|
||||
|
||||
const CHANGELOG = [
|
||||
{
|
||||
version: '0.3.9',
|
||||
date: '2026-07-02',
|
||||
intro: 'Headline.',
|
||||
sections: [{ title: 'Fixed', bullets: ['**A fix.** Done. (#1)'] }],
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
mockChangelog.mockResolvedValue([]);
|
||||
mockBackup.mockResolvedValue({ available: false, latest: null });
|
||||
const s = useAppStore.getState();
|
||||
s.dismissUpdate();
|
||||
s.setAppVersion('0.3.9');
|
||||
s.setWhatsNewSeenVersion(null);
|
||||
});
|
||||
|
||||
describe('UpdatesPanel — data-safety line (pre-update DB backups)', () => {
|
||||
it('shows the backup promise with the fallback when no backup exists yet', async () => {
|
||||
render(<UpdatesPanel />);
|
||||
const line = await screen.findByTestId('backup-line');
|
||||
expect(line).toHaveTextContent('Your data is backed up before every update.');
|
||||
expect(line).toHaveTextContent('The first backup is created automatically');
|
||||
});
|
||||
|
||||
it('shows the latest backup timestamp from the backend endpoint', async () => {
|
||||
const created = new Date('2026-07-02T10:00:00Z').getTime() / 1000;
|
||||
mockBackup.mockResolvedValue({
|
||||
available: true,
|
||||
latest: { path: '/data/omnivoice.db.backup-0.3.9-1', created_at: created, size_bytes: 42 },
|
||||
});
|
||||
render(<UpdatesPanel />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('backup-line')).toHaveTextContent('Latest backup:'),
|
||||
);
|
||||
expect(screen.getByTestId('backup-line')).toHaveTextContent(
|
||||
new Date(created * 1000).toLocaleString(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("UpdatesPanel — available update's release notes", () => {
|
||||
it('renders the updater metadata notes as safe markdown-lite', async () => {
|
||||
useAppStore
|
||||
.getState()
|
||||
.setUpdateAvailable('0.4.0', '### Fixed\n- **Big fix.** No more bug. (#42)');
|
||||
render(<UpdatesPanel />);
|
||||
const notes = await screen.findByTestId('update-notes');
|
||||
expect(notes).toHaveTextContent('Release notes — v0.4.0');
|
||||
const strong = notes.querySelector('strong');
|
||||
expect(strong).not.toBeNull();
|
||||
expect(strong.textContent).toBe('Big fix.');
|
||||
expect(notes.textContent).not.toContain('**');
|
||||
expect(notes).toHaveTextContent('(#42)');
|
||||
});
|
||||
|
||||
it('renders no notes block when up to date', async () => {
|
||||
render(<UpdatesPanel />);
|
||||
await screen.findByTestId('backup-line');
|
||||
expect(screen.queryByTestId('update-notes')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('UpdatesPanel — "What\'s new" changelog reader', () => {
|
||||
it('renders the shipped changelog via the accordion viewer', async () => {
|
||||
mockChangelog.mockResolvedValue(CHANGELOG);
|
||||
render(<UpdatesPanel />);
|
||||
const viewer = await screen.findByTestId('changelog-viewer');
|
||||
expect(viewer).toHaveTextContent('v0.3.9');
|
||||
expect(screen.getByTestId('changelog-body-0.3.9')).toHaveTextContent('Headline.');
|
||||
expect(mockChangelog).toHaveBeenCalledWith(5);
|
||||
});
|
||||
|
||||
it('hides the section when the changelog is unavailable', async () => {
|
||||
render(<UpdatesPanel />);
|
||||
await screen.findByTestId('backup-line');
|
||||
expect(screen.queryByTestId('changelog-viewer')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('marks the running version as seen (retires the footer pill)', async () => {
|
||||
render(<UpdatesPanel />);
|
||||
await waitFor(() => expect(useAppStore.getState().whatsNewSeenVersion).toBe('0.3.9'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('UpdatesPanel — channel switcher stays surfaced', () => {
|
||||
it('shows both channels with the current one checked', async () => {
|
||||
render(<UpdatesPanel />);
|
||||
await screen.findByTestId('backup-line');
|
||||
const radios = screen.getAllByRole('radio');
|
||||
expect(radios).toHaveLength(2);
|
||||
const checked = radios.filter((r) => r.getAttribute('aria-checked') === 'true');
|
||||
expect(checked).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,7 @@ import { cn } from '@/lib/utils';
|
||||
import { useModels, useInstallModel } from '../api/hooks';
|
||||
import { setupDownloadStreamUrl } from '../api/setup';
|
||||
import { listEngines, selectEngine } from '../api/engines';
|
||||
import { notifyEngineSelected } from '../utils/engineSelectToast';
|
||||
import { Badge, Button } from '../ui';
|
||||
|
||||
const fmtGB = (gb) => (gb == null ? '' : `${gb.toFixed(gb < 10 ? 1 : 0)} GB`);
|
||||
@@ -99,6 +100,63 @@ function formatEta(seconds) {
|
||||
return `${Math.round(seconds / 60)}m`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold one download-stream SSE event into the wizard's per-repo progress map.
|
||||
* Pure + exported for unit tests. Mirrors the Settings store's transitions, but
|
||||
* with the wizard's leaner shape ({ phase, files, agg }).
|
||||
*
|
||||
* Key fix (P1-A): an `install_error` event is STORED with its `ev.error` text
|
||||
* (the mirror-aware failure hint) and the row PERSISTS — previously the wizard
|
||||
* deleted the row on error exactly like a success, so the user saw the download
|
||||
* vanish with no reason. `install_done` still drops the row (it reverts to the
|
||||
* authoritative `installed` flag); the caller does the list refetch.
|
||||
*/
|
||||
export function reduceWizardDownloadEvent(prev, ev) {
|
||||
if (!ev || !ev.repo_id) return prev;
|
||||
const cur = prev[ev.repo_id] || { phase: 'active', files: {} };
|
||||
// Lifecycle markers gate reset; a file-level 'done' must NOT clear the repo.
|
||||
if (ev.phase === 'install_start') {
|
||||
return { ...prev, [ev.repo_id]: { phase: 'active', files: {} } };
|
||||
}
|
||||
// Success terminal → drop the transient row.
|
||||
if (ev.phase === 'install_done') {
|
||||
const next = { ...prev };
|
||||
delete next[ev.repo_id];
|
||||
return next;
|
||||
}
|
||||
// Error terminal → KEEP the row + its message so it renders with a Retry.
|
||||
if (ev.phase === 'install_error') {
|
||||
return { ...prev, [ev.repo_id]: { ...cur, phase: 'install_error', error: ev.error } };
|
||||
}
|
||||
// Authoritative overall progress (download_aggregator).
|
||||
if (ev.phase === 'aggregate') {
|
||||
return {
|
||||
...prev,
|
||||
[ev.repo_id]: {
|
||||
...cur,
|
||||
agg: {
|
||||
bytesDone: ev.bytes_done || 0,
|
||||
totalBytes: ev.total_bytes || 0,
|
||||
rate: ev.rate || 0,
|
||||
etaSeconds: ev.eta_seconds ?? null,
|
||||
filesDone: ev.files_done || 0,
|
||||
filesTotal: ev.files_total || 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
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 } };
|
||||
}
|
||||
|
||||
// LED dot tone per row state.
|
||||
const LED_TONE = {
|
||||
ok: 'bg-success shadow-[0_0_5px_1px_color-mix(in_srgb,var(--color-success)_50%,transparent)]',
|
||||
@@ -180,52 +238,13 @@ export default function WizardLibrary() {
|
||||
try {
|
||||
const ev = JSON.parse(evt.data);
|
||||
if (!ev?.repo_id) return;
|
||||
setProgress((prev) => {
|
||||
const cur = prev[ev.repo_id] || { phase: 'active', files: {} };
|
||||
// Lifecycle markers (`install_*`) gate reset/refetch; per-file tqdm
|
||||
// phases ('start'|'progress'|'done') only update byte counts — a
|
||||
// file-level 'done' must NOT clear the repo, multi-file snapshots
|
||||
// finish files long before the repo's `install_done` arrives.
|
||||
// (Full phase taxonomy: SetupProgressEvent in api/setup.ts.)
|
||||
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;
|
||||
}
|
||||
// Authoritative overall progress (download_aggregator): one windowed
|
||||
// rate + ETA + bytes_done/total_bytes for the whole repo. Preferred
|
||||
// over summing per-file events, which is unreliable under parallel/
|
||||
// segmented fetch (the source of the "8% · 1 KB/s · 0.0 MB left" bug).
|
||||
if (ev.phase === 'aggregate') {
|
||||
return {
|
||||
...prev,
|
||||
[ev.repo_id]: {
|
||||
...cur,
|
||||
agg: {
|
||||
bytesDone: ev.bytes_done || 0,
|
||||
totalBytes: ev.total_bytes || 0,
|
||||
rate: ev.rate || 0,
|
||||
etaSeconds: ev.eta_seconds ?? null,
|
||||
filesDone: ev.files_done || 0,
|
||||
filesTotal: ev.files_total || 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
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 } };
|
||||
});
|
||||
// Refetch the list once the repo finishes so the row flips to installed.
|
||||
// (The reducer is pure — the side-effect stays here.)
|
||||
if (ev.phase === 'install_done') modelsQuery.refetch();
|
||||
// Pure reducer (exported for tests). Full phase taxonomy:
|
||||
// SetupProgressEvent in api/setup.ts. install_error now PERSISTS with
|
||||
// its message instead of the row silently vanishing (P1-A).
|
||||
setProgress((prev) => reduceWizardDownloadEvent(prev, ev));
|
||||
} catch {
|
||||
/* keepalive */
|
||||
}
|
||||
@@ -253,6 +272,9 @@ export default function WizardLibrary() {
|
||||
try {
|
||||
const r = await selectEngine('tts', id);
|
||||
setEngines((e) => (e ? { ...e, active: r.active } : e));
|
||||
// Consume the routing echo: warn when the pick lands on a CPU fallback
|
||||
// on this host, otherwise confirm the switch. See notifyEngineSelected.
|
||||
notifyEngineSelected(r, t, 'tts');
|
||||
} catch (e) {
|
||||
toast.error(e?.message || 'switch failed');
|
||||
} finally {
|
||||
@@ -270,12 +292,15 @@ export default function WizardLibrary() {
|
||||
|
||||
const modelRow = (m, chip, chipTone, note) => {
|
||||
const p = progress[m.repo_id];
|
||||
// A failed install PERSISTS (P1-A): show the mirror-aware reason + a Retry
|
||||
// instead of the row silently vanishing.
|
||||
const errored = p?.phase === 'install_error';
|
||||
// Prefer the backend's authoritative aggregate; fall back to per-file sums
|
||||
// only until that event arrives (then to nulls when nothing's streaming).
|
||||
const { pct, etaSec, rate, remaining } =
|
||||
progressFromAgg(p?.agg) ||
|
||||
(p ? aggregate(p.files) : { pct: null, etaSec: null, rate: 0, remaining: null });
|
||||
const downloading = !!p;
|
||||
const downloading = !!p && !errored;
|
||||
// Live telemetry line: "5.2 MB/s · 700 MB left · ~3m". Each part only shows
|
||||
// once the SSE stream has the data, so early on it degrades to "downloading…".
|
||||
const rateStr = fmtRate(rate);
|
||||
@@ -298,7 +323,14 @@ export default function WizardLibrary() {
|
||||
chipTone={chipTone}
|
||||
size={fmtGB(m.size_gb)}
|
||||
sub={
|
||||
downloading ? (
|
||||
errored ? (
|
||||
<span className="block max-w-[280px] font-mono text-[0.64rem] leading-snug text-danger">
|
||||
{t('firstrun.lib_install_failed', {
|
||||
error: p.error,
|
||||
defaultValue: 'Install failed: {{error}}',
|
||||
})}
|
||||
</span>
|
||||
) : downloading ? (
|
||||
<span className="block h-[3px] max-w-[280px] overflow-hidden rounded-full bg-fg/[0.08]">
|
||||
<span
|
||||
className="block h-full rounded-full bg-primary transition-[width] duration-300"
|
||||
@@ -312,6 +344,10 @@ export default function WizardLibrary() {
|
||||
action={
|
||||
m.installed ? (
|
||||
<Check size={14} className="shrink-0 text-success" aria-hidden="true" />
|
||||
) : errored ? (
|
||||
<Button variant="ghost" size="sm" onClick={() => install(m.repo_id)}>
|
||||
{t('firstrun.lib_retry', 'Retry')}
|
||||
</Button>
|
||||
) : downloading ? (
|
||||
<span className="shrink-0 font-mono text-[0.64rem] tabular-nums text-primary">
|
||||
{statParts.length
|
||||
|
||||
@@ -10,21 +10,7 @@ import React, { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Search, Film, FolderOpen, Trash2, Save, Pencil, Check, X } from 'lucide-react';
|
||||
import { Button } from '../ui';
|
||||
|
||||
// Local copy of the sidebar's relative-time formatter (small, self-contained).
|
||||
function timeAgo(ms) {
|
||||
const diff = Date.now() - ms;
|
||||
if (!isFinite(diff) || diff < 0) return '';
|
||||
const s = Math.floor(diff / 1000);
|
||||
if (s < 60) return `${s}s ago`;
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
const d = Math.floor(h / 24);
|
||||
if (d < 7) return `${d}d ago`;
|
||||
return new Date(ms).toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
}
|
||||
import { absoluteTime, timeAgo } from '../utils/relativeTime';
|
||||
|
||||
export default function WorkspaceProjects({
|
||||
projects = [],
|
||||
@@ -105,11 +91,8 @@ export default function WorkspaceProjects({
|
||||
<span className="history-kind history-kind--audio">
|
||||
<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)}
|
||||
<span className="history-meta" title={absoluteTime(proj.updated_at)}>
|
||||
{timeAgo(proj.updated_at)}
|
||||
</span>
|
||||
</div>
|
||||
{editingId === proj.id ? (
|
||||
|
||||