Compare commits

..
977 changed files with 14336 additions and 157757 deletions
-60
View File
@@ -1,60 +0,0 @@
---
name: fastapi-python
description: Expert in FastAPI Python development with best practices for APIs and async operations
---
# FastAPI Python
You are an expert in FastAPI and Python backend development.
## Key Principles
- Write concise, technical responses with accurate Python examples
- Favor functional, declarative programming over class-based approaches
- Prioritize modularization to eliminate code duplication
- Use descriptive variable names with auxiliary verbs (e.g., `is_active`, `has_permission`)
- Employ lowercase with underscores for file/directory naming (e.g., `routers/user_routes.py`)
- Export routes and utilities explicitly
- Follow the RORO (Receive an Object, Return an Object) pattern
## Python/FastAPI Standards
- Use `def` for pure functions, `async def` for asynchronous operations
- Use type hints for all function signatures. Prefer Pydantic models over raw dictionaries
- Structure: exported router, sub-routes, utilities, static content, types (models, schemas)
- Use ordinary Python control flow; prefer readability over compressed one-line conditionals
## Error Handling
- Handle edge cases at function entry points
- Employ early returns for error conditions
- Place happy path logic last
- Avoid unnecessary else statements; use if-return patterns
- Implement guard clauses for preconditions
- Provide proper error logging and user-friendly messaging
## FastAPI-Specific Guidelines
- Use functional components (plain functions) and Pydantic models for input validation
- Declare routes with clear return type annotations
- Prefer lifespan context managers for managing startup and shutdown events
- Leverage middleware for logging, error monitoring, and optimization
- Use HTTPException for expected errors and model them as specific HTTP responses
- Apply Pydantic's BaseModel consistently for validation
## Performance Optimization
- Minimize blocking I/O. In `async def` handlers, use awaitable database/API clients; put synchronous SQLite or other blocking work in synchronous routes or explicitly offload it
- Implement caching with Redis or in-memory stores
- Optimize Pydantic serialization/deserialization
- Use lazy loading for large datasets
## Key Conventions
1. Rely on FastAPI's dependency injection system
2. Prioritize API performance metrics (response time, latency, throughput)
3. Structure routes and dependencies for readability and maintainability
## Dependencies
FastAPI, Pydantic v2, asyncpg/aiomysql, SQLAlchemy 2.0
-357
View File
@@ -1,357 +0,0 @@
---
name: vite
description: Expert guidance for Vite development with modern build tooling, HMR, framework integrations, and performance optimization
---
# Vite Development
You are an expert in Vite, modern JavaScript/TypeScript build tooling, and frontend development.
## Key Principles
- Leverage native ES modules for fast development
- Use Vite's opinionated defaults when possible
- Configure only what needs customization
- Understand the dev/build differences
- Optimize for both development speed and production performance
## Project Setup
### Basic Configuration
```typescript
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
open: true,
},
build: {
outDir: 'dist',
sourcemap: true,
},
});
```
### Path Aliases
```typescript
import { defineConfig } from 'vite';
export default defineConfig({
resolve: {
alias: {
'@': new URL('./src', import.meta.url).pathname,
'@components': new URL('./src/components', import.meta.url).pathname,
'@utils': new URL('./src/utils', import.meta.url).pathname,
},
},
});
```
## Environment Variables
### Usage
```typescript
// .env
VITE_API_URL=https://api.example.com
VITE_APP_TITLE=My App
// In code
const apiUrl = import.meta.env.VITE_API_URL;
const isDev = import.meta.env.DEV;
const isProd = import.meta.env.PROD;
const mode = import.meta.env.MODE;
```
### Type Definitions
```typescript
// src/vite-env.d.ts
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string;
readonly VITE_APP_TITLE: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
```
## Hot Module Replacement
### Manual HMR
```typescript
// For libraries without HMR support
if (import.meta.hot) {
import.meta.hot.accept('./module.ts', (newModule) => {
// Handle the updated module
console.log('Module updated:', newModule);
});
import.meta.hot.dispose(() => {
// Cleanup before module is replaced
});
}
```
## Asset Handling
### Static Assets
```typescript
// Import as URL
import imageUrl from './image.png';
// <img src={imageUrl} />
// Import as string (raw)
import shaderCode from './shader.glsl?raw';
// Import as worker
import Worker from './worker.ts?worker';
const worker = new Worker();
```
### Public Directory
```
public/
├── favicon.ico # Served at /favicon.ico
├── robots.txt # Served at /robots.txt
└── images/ # Served at /images/
```
## Framework Integrations
### React
```typescript
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [
react({
// Babel plugins
babel: {
plugins: ['@emotion/babel-plugin'],
},
}),
],
});
```
### Vue
```typescript
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
});
```
### Svelte
```typescript
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
export default defineConfig({
plugins: [svelte()],
});
```
## Build Optimization
### Code Splitting
```typescript
// Dynamic imports create separate chunks
const AdminPanel = lazy(() => import('./AdminPanel'));
// Manual chunks
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: {
vendor: ['react', 'react-dom'],
utils: ['lodash', 'date-fns'],
},
},
},
},
});
```
### Chunk Size Optimization
```typescript
export default defineConfig({
build: {
chunkSizeWarningLimit: 500,
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('node_modules')) {
return id.split('node_modules/')[1].split('/')[0];
}
},
},
},
},
});
```
## CSS Handling
### CSS Modules
```typescript
// styles.module.css is auto-detected
import styles from './styles.module.css';
// <div className={styles.container}>
```
### PostCSS
```javascript
// postcss.config.js
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
```
### Preprocessors
```typescript
// Automatically handled with package installed
// npm install -D sass
import './styles.scss';
```
## Proxy Configuration
```typescript
export default defineConfig({
server: {
proxy: {
'/api': {
target: 'http://localhost:4000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
'/socket.io': {
target: 'ws://localhost:4000',
ws: true,
},
},
},
});
```
## Plugin Development
```typescript
// my-vite-plugin.ts
import type { Plugin } from 'vite';
export function myPlugin(): Plugin {
return {
name: 'my-plugin',
// Hook: modify config
config(config, { mode }) {
return {
define: {
__BUILD_TIME__: JSON.stringify(new Date().toISOString()),
},
};
},
// Hook: transform code
transform(code, id) {
if (id.endsWith('.md')) {
return {
code: `export default ${JSON.stringify(code)}`,
map: null,
};
}
},
// Hook: configure dev server
configureServer(server) {
server.middlewares.use((req, res, next) => {
// Custom middleware
next();
});
},
};
}
```
## Testing with Vitest
```typescript
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
},
},
});
```
## SSR Configuration
```typescript
export default defineConfig({
build: {
ssr: true,
rollupOptions: {
input: './src/entry-server.ts',
},
},
ssr: {
external: ['express'],
noExternal: ['my-ui-library'],
},
});
```
## Library Mode
```typescript
export default defineConfig({
build: {
lib: {
entry: './src/index.ts',
name: 'MyLib',
fileName: (format) => `my-lib.${format}.js`,
},
rollupOptions: {
external: ['react', 'react-dom'],
output: {
globals: {
react: 'React',
'react-dom': 'ReactDOM',
},
},
},
},
});
```
## Best Practices
- Use `vite preview` to test production builds locally
- Keep dependencies that support ESM in regular deps
- Use `optimizeDeps.include` for CommonJS dependencies
- Enable `build.sourcemap` for debugging production
- Use `server.warmup` for faster dev server starts
+1 -4
View File
@@ -5,9 +5,6 @@ description: "Local TTS, voice cloning, voice design, and video dubbing via the
# VoiceStudio
The canonical cross-agent package lives at `skills/omnivoice/SKILL.md`. This
Claude-specific package retains the MCP lifecycle helpers and references.
## Overview
Generate audio locally via the VoiceStudio MCP server. Tools: `generate_speech`, `list_voices`, `list_personalities`, `list_languages`, `check_health`. Resources: `voice://{id}`, `history://recent`.
@@ -169,4 +166,4 @@ The MCP server does not expose the dubbing endpoint. The full transcribe → tra
Backend Swagger / OpenAPI: `http://127.0.0.1:3900/docs` (when backend is up).
Upstream: github.com/debpalash/VoiceStudio. The app uses AGPL-3.0-only; optional engines and downloaded models retain their own licenses. See `LICENSE-NOTICE.md` in the repository.
Upstream: github.com/debpalash/VoiceStudio — FSL-1.1-ALv2 (free for personal/internal/non-commercial; auto-converts to Apache-2.0 two years after each release).
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Start the OmniVoice FastAPI backend on 127.0.0.1:3900, detached, idempotent.
# Honors $OMNIVOICE_HOME (default ~/VoiceStudio).
# Honors $OMNIVOICE_HOME (default ~/OmniVoice-Studio).
#
# Exit codes:
# 0 success (already running, or freshly started + healthy within 60s)
@@ -11,7 +11,7 @@
set -euo pipefail
HOME_DIR="${OMNIVOICE_HOME:-$HOME/VoiceStudio}"
HOME_DIR="${OMNIVOICE_HOME:-$HOME/OmniVoice-Studio}"
URL="${OMNIVOICE_API_URL:-http://127.0.0.1:3900}"
LOG="$HOME_DIR/backend.log"
-30
View File
@@ -28,25 +28,8 @@ Read it before opening a proposal; the licence check in particular ends most of
- [Bun](https://bun.sh/) (frontend package manager)
- [uv](https://docs.astral.sh/uv/) (Python environment manager)
- [ffmpeg](https://ffmpeg.org/) (audio/video processing)
- [Rust / Cargo](https://rustup.rs/) (desktop shell only)
- Python 3.10+ (managed automatically by `uv`)
Linux desktop development also needs WebKitGTK/GTK development libraries. On
Debian or Ubuntu, install the same packages used by CI:
```bash
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev libgtk-3-dev libpango1.0-dev libcairo2-dev \
libsoup-3.0-dev libgdk-pixbuf-2.0-dev \
libayatana-appindicator3-dev librsvg2-dev libssl-dev libxdo-dev \
gstreamer1.0-plugins-good \
libasound2-dev build-essential curl wget file
```
See the [Linux source-build guide](../docs/install/linux.md#building-from-source)
for Fedora and Arch packages.
### Clone & Run
```bash
@@ -85,18 +68,6 @@ names: there is no `desktop=prod` (note the **hyphen** in `desktop-prod`).
Requires [Rust](https://rustup.rs/) and platform-specific Tauri dependencies — see the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/).
After installing Rust with rustup on macOS/Linux, either open a new terminal or
load Cargo into the current one before starting the desktop app:
```bash
source "$HOME/.cargo/env"
bun desktop
```
On Linux, errors such as `Package gdk-3.0 was not found`, `pango.pc` missing,
or `javascriptcoregtk-4.1` missing mean the native packages above were not
installed; changing `PKG_CONFIG_PATH` does not fix libraries that are absent.
If the app opens but stays on the **setup splash with no buttons**, the Python
backend didn't finish starting — the splash surfaces the stall reason, a log
panel, and a **Retry** button (and Settings → Logs → Backend has the full trace).
@@ -203,7 +174,6 @@ class MyEngineBackend(TTSBackend):
- **Components**: Functional components with hooks
- **State**: Zustand stores in `src/stores/`, organized by slice
- **Brand assets**: Reuse the canonical mark, palette, naming, and compatibility rules in [`docs/branding.md`](../docs/branding.md); do not redraw or rename runtime identifiers ad hoc
- **CSS**: **Utilities-first + shadcn/ui, one stylesheet.** UI is built on the shadcn/ui primitives in `src/components/ui/` (wrapped by the `src/ui/` barrel, themed to the VoiceStudio palette), composed with Tailwind v4 utility classes. **All styling now lives in a single file — `src/index.css`**: the `@theme` / `[data-theme]` token foundation plus the irreducible set utilities can't express (`@keyframes`, glassmorphism/`backdrop-filter`, pseudo-elements, `:has()`, unlayered cascade overrides, and styling hooks on library-generated DOM like virtualized rows / WaveSurfer). The per-component `.css` files were eliminated in the CSS→Tailwind/shadcn migration — **do not create new ones.** Reach for shadcn primitives + utilities; if a rule is genuinely irreducible, add it to `src/index.css` with a provenance comment. (The only other `.css` is the test-only visual harness. See `docs/shadcn-migration.md`.)
- **Naming**: `PascalCase` for components, `camelCase` for hooks and utils
+1 -1
View File
@@ -4,7 +4,7 @@
| Version | Supported |
|---------|-----------|
| 0.5.x (latest release + `main` previews) | ✅ Current — all fixes land here |
| 0.3.x (latest release + `main` previews) | ✅ Current — all fixes land here |
| 0.2.7 | ⚠️ Legacy stable — security fixes only, upgrade recommended |
| < 0.2.7 | ❌ No longer supported |
+1 -15
View File
@@ -51,13 +51,6 @@ jobs:
- os: ubuntu-latest
platform: linux-x86_64
experimental: false
- os: ubuntu-24.04-arm
platform: linux-aarch64
# Apple Silicon under Asahi Linux. Experimental: the Vulkan
# (Honeykrisp GPU) build path is new and the hosted arm64
# runner has no GPU — it validates that the binary builds;
# on-host Vulkan acceleration is exercised by users.
experimental: true
- os: windows-latest
platform: windows-x86_64
experimental: false
@@ -87,18 +80,11 @@ jobs:
# Linux-only: upstream `buildcpu.sh` enables `-DGGML_BLAS=ON` which
# requires a system BLAS implementation at cmake configure time.
- name: Linux system deps (BLAS for ggml-blas backend)
if: startsWith(matrix.platform, 'linux')
if: matrix.platform == 'linux-x86_64'
run: |
sudo apt-get update
sudo apt-get install -y libopenblas-dev pkg-config
# linux-aarch64: let the build script's Vulkan path (Honeykrisp GPU
# on Asahi) engage instead of silently falling back to CPU.
- name: Vulkan dev deps (linux-aarch64 GPU backend)
if: matrix.platform == 'linux-aarch64'
run: |
sudo apt-get install -y glslc libvulkan-dev spirv-headers
- name: Build omnivoice-tts
shell: bash
# Pass values through env (quoted) rather than ${{ }} interpolation
+5 -81
View File
@@ -11,11 +11,6 @@ on:
push:
branches: [main]
workflow_dispatch:
inputs:
windows_wix_diagnostic:
description: Run only the tiny nonpublishing Windows MSI authoring diagnostic
type: boolean
default: false
permissions:
contents: read
@@ -26,15 +21,8 @@ env:
jobs:
test:
if: ${{ !inputs.windows_wix_diagnostic }}
name: Tests (backend + frontend)
runs-on: ubuntu-22.04
env:
# Same restricted-network resilience the smoke matrix already sets. This
# job resolves the same direct-URL dependency and had none of it, which
# is why it was the one that kept dying (see scripts/uv-sync-retry.sh).
UV_HTTP_TIMEOUT: "120"
UV_HTTP_RETRIES: "5"
steps:
- uses: actions/checkout@v4
@@ -73,14 +61,7 @@ jobs:
# so their tests can exercise the real import path, not the
# "package not installed" fallback. Smoke job below stays on bare
# `uv sync` because smoke only hits /health + fixture profiles.
#
# Retried because one dependency — en-core-web-sm — resolves to a
# direct GitHub release URL, and github.com intermittently answers
# `http2 error: refused stream before processing any application
# logic`. uv's own 3 retries all land inside the same few seconds and
# fail together, which has cost otherwise-green runs (#1517, #1518).
# Backing off between whole attempts is what actually clears it.
run: bash scripts/uv-sync-retry.sh --all-extras
run: uv sync --all-extras
# HF_HUB_OFFLINE=1 is a recurrence guard, not an optimization: a test
# that reaches huggingface.co fails fast and loud instead of silently
@@ -90,7 +71,7 @@ jobs:
# interactions in tests are stubbed; anything that trips this is a
# test-isolation bug.
- name: Run pytest
run: uv run --no-sync pytest tests/ -q --tb=short
run: uv run pytest tests/ -q --tb=short
env:
HF_HUB_OFFLINE: "1"
@@ -111,14 +92,13 @@ jobs:
run: |
bash frontend/src-tauri/appimage/AppRun.test.sh
bash scripts/inject-apprun.test.sh
bash scripts/verify-apprun-bundle.test.sh
# `backend/tests/` mounts routers on bare FastAPI apps (no heavy main
# import chain) with a hermetic data dir from its conftest.py. It no
# longer stubs sys.modules, so mixed sessions with tests/ are safe;
# the separate session is kept for cheaper, clearer CI output.
- name: Run pytest (backend/tests, isolated)
run: uv run --no-sync pytest backend/tests/ -q --tb=short
run: uv run pytest backend/tests/ -q --tb=short
env:
HF_HUB_OFFLINE: "1" # same no-silent-downloads guard as tests/
@@ -195,7 +175,6 @@ jobs:
# and `cargo test --lib` runs the shell's unit tests natively on each OS.
# Full bundling stays in release.yml on tag push.
tauri-cross-platform:
if: ${{ !inputs.windows_wix_diagnostic }}
name: Tauri shell check (${{ matrix.label }})
needs: test
strategy:
@@ -278,17 +257,6 @@ jobs:
working-directory: frontend/src-tauri
run: cargo test --lib --target ${{ matrix.rust_target }} --message-format=short
# Backend-lifecycle fault-injection harness: real child processes die
# scripted deaths through the OMNIVOICE_BACKEND_CMD seam, and each
# scenario asserts the user-visible diagnosis names the actual cause
# (port conflict / traceback root cause / spawn failure / timeout /
# crash-loop exhaustion / signal 9 / deliberate replace / deferred-
# startup step). Serial: the scenarios share process-global state
# (env vars, crash store, kill-intended flag) by design.
- name: Cargo test (backend lifecycle harness)
working-directory: frontend/src-tauri
run: cargo test --test backend_lifecycle --target ${{ matrix.rust_target }} --message-format=short -- --test-threads=1
# ── Cross-platform Python runtime smoke (Phase 0 GATE-02) ───────────────
# Loads the frozen tests/fixtures/omnivoice_data/ fixture and boots the
# FastAPI app in-process via TestClient on macOS/Windows/Linux. Catches
@@ -296,7 +264,6 @@ jobs:
# job above misses. Narrow scope (tests/smoke/ only) — full pytest stays
# on Linux until Phase 1's INST-01 lands setuptools for WhisperX.
smoke-matrix:
if: ${{ !inputs.windows_wix_diagnostic }}
name: Smoke (${{ matrix.label }})
needs: test
strategy:
@@ -384,19 +351,6 @@ jobs:
echo "choco attempt $i did not produce ffmpeg — retrying in $((i * 30))s"
sleep $((i * 30))
done
# Chocolatey is one distribution channel, not the dependency. When
# its feed is down across every retry (2026-08-13: three attempts,
# three 'installed 0/1'), fall back to the static gyan.dev release
# build GitHub mirror — the same binary, no feed in the path.
if ! command -v ffmpeg >/dev/null 2>&1; then
echo "::warning::choco feed down — falling back to static ffmpeg build"
curl -fsSL --retry 3 -o /tmp/ffmpeg.zip \
https://github.com/GyanD/codexffmpeg/releases/download/7.1/ffmpeg-7.1-essentials_build.zip
unzip -q /tmp/ffmpeg.zip -d /tmp/ffmpeg
bindir=$(dirname "$(find /tmp/ffmpeg -name ffmpeg.exe | head -1)")
echo "$bindir" >> "$GITHUB_PATH"
export PATH="$bindir:$PATH"
fi
ffmpeg -version
- name: System deps (Linux)
@@ -412,7 +366,7 @@ jobs:
# backend host. The Intel-Mac leg separately pins the documented
# unsupported contract: its UI is a remote-backend client only (#889).
if: matrix.backend_supported
run: bash scripts/uv-sync-retry.sh --extra pockettts
run: uv sync --extra pockettts
- name: Verify the documented Intel Mac contract
if: ${{ !matrix.backend_supported }}
@@ -437,37 +391,7 @@ jobs:
- name: Run smoke tests
if: matrix.backend_supported
run: uv run --no-sync pytest tests/smoke/ -q --tb=short
run: uv run pytest tests/smoke/ -q --tb=short
env:
HF_HUB_OFFLINE: "1" # same no-silent-downloads guard as the main pytest job
HF_HUB_CACHE: ${{ runner.temp }}/pockettts-empty-hf-cache
# Artifact commits depend on native Windows rename/replace semantics;
# Linux emulation cannot exercise sharing rules or path parsing.
- name: Remote-worker artifact paths (Windows)
if: runner.os == 'Windows' && matrix.backend_supported
run: uv run --no-sync pytest tests/test_worker_upload_server.py tests/test_worker_server_integrity.py -q --tb=short
env:
HF_HUB_OFFLINE: "1"
HF_HUB_CACHE: ${{ runner.temp }}/worker-artifact-empty-hf-cache
windows-wix-diagnostic:
name: Windows MSI authoring (no publishing)
needs: test
if: ${{ !cancelled() && (inputs.windows_wix_diagnostic || needs.test.result == 'success') }}
runs-on: windows-2022
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v1
- name: Bundle canonical system and per-user templates with a tiny payload
shell: pwsh
run: ./scripts/diagnose-windows-wix.ps1
- name: Preserve verbose linker output and rendered authoring
if: always()
uses: actions/upload-artifact@v4
with:
name: windows-wix-diagnostic
path: wix-diagnostic-artifacts/
if-no-files-found: warn
retention-days: 3
+2 -2
View File
@@ -38,14 +38,14 @@ jobs:
cache-dependency-glob: "uv.lock"
- name: Install deps
run: bash scripts/uv-sync-retry.sh
run: uv sync
- name: Run eval suites (non-gating)
continue-on-error: true
env:
TRANSLATE_BASE_URL: ${{ secrets.EVALS_LLM_BASE_URL }}
TRANSLATE_API_KEY: ${{ secrets.EVALS_LLM_API_KEY }}
run: uv run --no-sync python tests/evals/run_evals.py --output eval-report.json
run: uv run python tests/evals/run_evals.py --output eval-report.json
- name: Upload report artifact
uses: actions/upload-artifact@v4
-127
View File
@@ -1,127 +0,0 @@
# Installer smoke — runs scripts/install.sh / scripts/install.ps1 end-to-end
# on all three desktop platforms so the one-liner installers can't rot.
#
# Gated by `paths` because a cold run downloads multi-GB wheels (torch) and
# takes ~15-30 min per OS; it only needs to fire when an installer or this
# workflow changes. The heavy Tauri bundles stay in release.yml (tag push).
name: Install smoke
on:
pull_request:
paths:
- "scripts/install.sh"
- "scripts/install.ps1"
- ".github/workflows/install-smoke.yml"
push:
branches: [main]
paths:
- "scripts/install.sh"
- "scripts/install.ps1"
- ".github/workflows/install-smoke.yml"
workflow_dispatch:
permissions:
contents: read
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
install:
name: Install (${{ matrix.os }})
runs-on: ${{ matrix.os }}
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
os: [ubuntu-22.04, macos-latest, windows-latest]
steps:
- uses: actions/checkout@v4
# Running `sh scripts/install.sh` from the repo root exercises the
# repo-root resolution (script dir is scripts/, project root one level
# up) — the exact bug that made a local run clone a duplicate repo.
# Binary mode is the default: prebuilt release asset, checksum verified.
- name: Run installer — binary (macOS/Linux)
if: runner.os != 'Windows'
run: sh scripts/install.sh
- name: Verify install — binary (macOS/Linux)
if: runner.os != 'Windows'
run: |
if [ "$(uname)" = "Darwin" ]; then
test -d "/Applications/VoiceStudio.app" || { echo "::error::VoiceStudio.app missing from /Applications"; exit 1; }
echo "✓ VoiceStudio.app installed in /Applications"
else
test -x "$HOME/.local/bin/VoiceStudio" || { echo "::error::AppImage missing from ~/.local/bin"; exit 1; }
"$HOME/.local/bin/VoiceStudio" --appimage-help >/dev/null 2>&1 || true
echo "✓ AppImage installed and executable"
fi
# Source mode stays covered end-to-end behind --source.
- name: Run installer — source (macOS/Linux)
if: runner.os != 'Windows'
run: sh scripts/install.sh --source
- name: Verify install — source (macOS/Linux)
if: runner.os != 'Windows'
working-directory: ${{ github.workspace }}
run: |
test -d .venv || { echo "::error::.venv missing"; exit 1; }
test -f frontend/dist/index.html || { echo "::error::frontend build missing"; exit 1; }
echo "✓ venv + frontend bundle present"
# Binary mode is the default; CI runs msiexec silently.
- name: Run installer — binary (Windows)
if: runner.os == 'Windows'
env:
CI: true
shell: pwsh
run: '& { $ErrorActionPreference = "Stop"; & "${{ github.workspace }}\scripts\install.ps1" }'
- name: Verify install — binary (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
$paths = @(
"HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*",
"HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*",
"HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*"
)
$key = Get-ItemProperty $paths -ErrorAction SilentlyContinue |
Where-Object { $_.DisplayName -match "VoiceStudio|OmniVoice" } |
Select-Object -First 1
if (-not $key) {
Get-ItemProperty $paths -ErrorAction SilentlyContinue |
Where-Object DisplayName | ForEach-Object { Write-Host " installed: $($_.DisplayName)" }
Write-Host "::error::MSI product not registered"; exit 1
}
Write-Host "✓ MSI product registered: $($key.DisplayName)"
# Source mode stays covered end-to-end behind -Source.
- name: Run installer — source (Windows)
if: runner.os == 'Windows'
env:
VOICESTUDIO_INSTALL_MODE: source
shell: pwsh
run: '& { $ErrorActionPreference = "Stop"; & "${{ github.workspace }}\scripts\install.ps1" }'
- name: Verify install — source (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
if (-not (Test-Path .venv)) { Write-Host "::error::.venv missing"; exit 1 }
if (-not (Test-Path frontend\dist\index.html)) { Write-Host "::error::frontend build missing"; exit 1 }
Write-Host "✓ venv + frontend bundle present"
- name: Upload install log on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: install-log-${{ matrix.os }}
path: |
/Users/runner/Library/Application Support/OmniVoice/*.log
/home/runner/.local/share/VoiceStudio/*.log
${{ runner.temp }}/VoiceStudio/**/*.log
if-no-files-found: ignore
+38 -185
View File
@@ -109,10 +109,10 @@ jobs:
version: 1.0
- name: Install Python deps
run: bash scripts/uv-sync-retry.sh
run: uv sync
- name: Run pytest
run: uv run --no-sync pytest tests/ -q --tb=short
run: uv run pytest tests/ -q --tb=short
- name: Cache bun deps
uses: actions/cache@v4
@@ -148,20 +148,15 @@ jobs:
preview-gate:
name: Preview gate
runs-on: ubuntu-22.04
permissions:
contents: read
outputs:
is_preview: ${{ steps.decide.outputs.is_preview }}
proceed: ${{ steps.decide.outputs.proceed }}
stable_tag: ${{ steps.decide.outputs.stable_tag }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 50
- id: decide
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
event="${{ github.event_name }}"
@@ -176,13 +171,6 @@ jobs:
exit 1
fi
echo "is_preview=true" >> "$GITHUB_OUTPUT"
# Resolve once before the matrix starts so every platform stamps
# against the same immutable Stable-channel snapshot.
STABLE_TAG=$(gh release view --repo "$GITHUB_REPOSITORY" --json tagName --jq .tagName)
[[ "$STABLE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
echo "::error::latest stable release has an invalid tag"; exit 1;
}
echo "stable_tag=$STABLE_TAG" >> "$GITHUB_OUTPUT"
else
echo "is_preview=false" >> "$GITHUB_OUTPUT"
fi
@@ -509,22 +497,35 @@ jobs:
echo "APPLE_TEAM_ID=$TID"
} >> "$GITHUB_ENV"
# Stamp each preview with a numeric prerelease that is strictly above the
# latest stable release. Main may intentionally retain the released
# version while AUTO_VERSION_BUMP is disabled; in that case the helper
# advances the preview base by one patch so stable users can still opt in
# and receive it. The edit is ephemeral and never committed.
# Stamp each preview build with a unique, monotonically increasing semver
# PRERELEASE so the updater actually offers it (a rolling preview that
# always reported the static 0.3.0 never looked "newer", so no update was
# ever delivered). Ephemeral, CI-only — never committed. Tauri reads the
# bundle + updater version from tauri.conf.json, so rewriting it here
# stamps the artifacts + latest.json. Under the versioning hard rule
# (owner-set 2026-06-11) main is always last-release + 1, so BASE-N is a
# prerelease of the NEXT version and semver-sorts ABOVE the last stable
# (0.3.6-N > 0.3.5) — preview users naturally upgrade past stable, and
# the Windows MSI ProductVersion (which strips the prerelease → 0.3.6)
# is also correctly above the last stable.
- name: Stamp preview version
if: needs.preview-gate.outputs.is_preview == 'true'
shell: bash
env:
STABLE_TAG: ${{ needs.preview-gate.outputs.stable_tag }}
run: |
set -euo pipefail
PREVIEW_VERSION=$(python scripts/stamp-preview-version.py \
--package-json frontend/package.json \
--stable-tag "$STABLE_TAG" \
--run-number "${{ github.run_number }}")
# package.json is the single source of truth; tauri.conf.json reads its
# version from it ("version": "../package.json"), so stamping
# package.json restamps the whole bundle.
CONF=frontend/package.json
BASE=$(jq -r .version "$CONF")
# MSI/WiX requires the semver pre-release identifier to be numeric-only
# (and <= 65535). "preview.N" hard-fails the Windows bundler, so the
# preview stamp is BASE-N — still sorts below the stable BASE for the
# updater, still unique per run.
PREVIEW_VERSION="${BASE}-${{ github.run_number }}"
tmp=$(mktemp)
jq --arg v "$PREVIEW_VERSION" '.version = $v' "$CONF" > "$tmp"
mv "$tmp" "$CONF"
echo "Stamped preview version: $PREVIEW_VERSION"
# The rolling `preview` release is REUSED every night, and macOS updater
@@ -648,47 +649,6 @@ jobs:
updaterJsonPreferNsis: false
includeUpdaterJson: true
- name: Build per-user Windows MSI
if: runner.os == 'Windows'
shell: bash
working-directory: frontend
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
set -euo pipefail
python ../scripts/render-per-user-wix.py \
--source src-tauri/wix/main.wxs \
--system-wxs src-tauri/target/${{ matrix.rust_target }}/release/wix/x64/main.wxs \
--output src-tauri/target/wix-per-user/main.wxs
bunx tauri build --target ${{ matrix.rust_target }} --bundles msi \
--config src-tauri/tauri.per-user.conf.json
DIR="src-tauri/target/${{ matrix.rust_target }}/release/bundle/msi"
while IFS= read -r artifact; do
safe=${artifact// (Current User)/_Current_User}
[ "$safe" = "$artifact" ] || mv "$artifact" "$safe"
done < <(find "$DIR" -maxdepth 1 -type f -name '*Current*User*.msi*')
- name: Publish per-user Windows updater channel
if: runner.os == 'Windows'
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ (needs.preview-gate.outputs.is_preview == 'true') && 'preview' || github.ref_name }}
run: |
set -euo pipefail
DIR="frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle/msi"
MSI=$(find "$DIR" -name '*Current*User*.msi' -type f | head -1)
[ -n "$MSI" ] || { echo "per-user MSI missing"; find "$DIR" -type f; exit 1; }
[ -f "$MSI.sig" ] || { echo "per-user MSI signature missing"; exit 1; }
VERSION=$(jq -r .version frontend/package.json)
python scripts/build_windows_user_manifest.py \
--repo "$GITHUB_REPOSITORY" --tag "$RELEASE_TAG" --version "$VERSION" \
--asset "$(basename "$MSI")" --signature-file "$MSI.sig" \
--output latest-user.json
gh release upload "$RELEASE_TAG" "$MSI" "$MSI.sig" latest-user.json \
--clobber --repo "$GITHUB_REPOSITORY"
# ── Installer smoke (Phase 0 GATE-03) ─────────────────────────────
# Structural verification of the installed/extracted bundle. The thin
# uv-venv installer ships NO frozen backend binary (the venv is built on
@@ -757,9 +717,8 @@ jobs:
shell: bash
run: |
set -euo pipefail
MSI=$(find frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle/msi -name "*.msi" ! -name '*Current*User*' | head -1)
MSI=$(find frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle/msi -name "*.msi" | head -1)
echo "Smoke-testing MSI: $MSI"
powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/verify-windows-msi.ps1 -MsiPath "$(cygpath -w "$MSI")"
# /quiet = no UI, /norestart = don't reboot the runner if a dep asks
msiexec.exe //i "$(cygpath -w "$MSI")" //quiet //norestart
INSTALL="/c/Program Files/VoiceStudio"
@@ -772,72 +731,6 @@ jobs:
find "$INSTALL" -type f -path '*backend*main.py' | grep -q . || fail "backend source main.py missing"
echo "OK — MSI installed shell + uv + backend resources"
- name: Per-user installer smoke (Windows, non-admin account)
if: runner.os == 'Windows'
timeout-minutes: 8
shell: bash
run: |
set -euo pipefail
MSI=$(find frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle/msi -name '*Current*User*.msi' | head -1)
powershell.exe -NoProfile -ExecutionPolicy Bypass \
-File scripts/smoke-per-user-msi.ps1 -MsiPath "$(cygpath -w "$MSI")"
# linuxdeploy re-links .DirIcon as an ABSOLUTE symlink into the build
# machine AFTER tauri's files-map has placed the real icon bytes — the
# exact bug #1518 guarded against, resurfacing on the first real tag
# build (v0.5.0). The seam tauri-action leaves us is post-upload: repack
# the AppImage with the icon as a REGULAR FILE, re-sign it (the updater
# signature covered the old bytes), and clobber the draft release's
# asset + the linux signature inside latest.json. The smoke below then
# validates the repaired artifact, not the broken one.
- name: Repair AppImage .DirIcon, re-sign, re-upload
if: runner.os == 'Linux'
timeout-minutes: 10
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
# Data, not shell source (zizmor template-injection): a crafted ref
# must never expand inside a script that holds the signing key.
TAG: ${{ (needs.preview-gate.outputs.is_preview == 'true') && 'preview' || github.ref_name }}
run: |
set -euo pipefail
APPIMAGE=$(find frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle/appimage -name "*.AppImage" | head -1)
APPIMAGE=$(realpath "$APPIMAGE")
WORK="$(mktemp -d)"; cd "$WORK"
"$APPIMAGE" --appimage-extract >/dev/null
ROOT="$WORK/squashfs-root"
ICON=$(readlink -f "$ROOT/.DirIcon" 2>/dev/null || true)
if [ -n "$ICON" ] && [ -f "$ICON" ] && case "$ICON" in "$ROOT"/*) true;; *) false;; esac; then
echo ".DirIcon already resolves inside the bundle — no repair needed"
exit 0
fi
# The real bytes are at the AppDir root (linuxdeploy put them there
# before mislinking). Ship a regular file: nothing left to dangle.
SRC=$(find "$ROOT" -maxdepth 1 -name "*.png" | head -1)
[ -n "$SRC" ] || SRC=$(find "$ROOT/usr/share/icons" -name "*.png" | head -1)
[ -n "$SRC" ] || { echo "no icon bytes found in bundle"; exit 1; }
rm -f "$ROOT/.DirIcon"
cp "$SRC" "$ROOT/.DirIcon"
# Pinned immutable release + checksum: this binary runs with the
# updater signing key and a release-write token in its environment,
# so a mutable 'continuous' asset is not acceptable supply chain.
AIT_URL="https://github.com/AppImage/appimagetool/releases/download/1.9.1/appimagetool-x86_64.AppImage"
AIT_SHA256="ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0"
curl -fsSL --retry 3 -o "$WORK/appimagetool" "$AIT_URL"
echo "$AIT_SHA256 $WORK/appimagetool" | sha256sum -c - || { echo "appimagetool checksum mismatch"; exit 1; }
chmod +x "$WORK/appimagetool"
# Same FUSE-less trick the build itself uses.
APPIMAGE_EXTRACT_AND_RUN=1 ARCH=x86_64 "$WORK/appimagetool" --no-appstream "$ROOT" "$APPIMAGE"
cd "$GITHUB_WORKSPACE/frontend"
bunx tauri signer sign "$APPIMAGE"
gh release upload "$TAG" "$APPIMAGE" "$APPIMAGE.sig" --clobber --repo "$GITHUB_REPOSITORY"
# latest.json is NOT patched here: every tauri-action leg re-uploads
# the shared manifest, so an in-leg patch races the other platforms —
# the repair-updater-manifest job below is the single final writer.
echo "repacked, re-signed, re-uploaded"
- name: Installer smoke (Linux)
if: runner.os == 'Linux'
timeout-minutes: 5
@@ -858,13 +751,17 @@ jobs:
"$APPIMAGE" --appimage-extract >/dev/null
ROOT="$EXTRACT_DIR/squashfs-root"
fail() { echo "FAIL — $1"; find "$ROOT" -maxdepth 5 -type f 2>/dev/null | head -40; exit 1; }
# linuxdeploy's GTK/GStreamer hooks wrap the seeded launcher as
# AppRun.wrapped. Verify the complete launcher chain, not only the
# small hook runner installed at the AppImage root.
bash "$GITHUB_WORKSPACE/scripts/verify-apprun-bundle.sh" \
"$ROOT" \
"$GITHUB_WORKSPACE/frontend/src-tauri/appimage/AppRun" \
"$GITHUB_WORKSPACE/frontend/src-tauri/target/.tauri/bundled-webkitgtk-version"
# Regression gate: beforeBundleCommand runs before Tauri creates the
# AppDir. The v0.4.2 artifact therefore silently shipped Tauri's
# stock AppRun and bypassed every WebKit/Mesa compatibility fix.
cmp -s "$ROOT/AppRun" "$GITHUB_WORKSPACE/frontend/src-tauri/appimage/AppRun" \
|| fail "custom AppRun missing from final AppImage"
[ -s "$ROOT/usr/lib/.bundled-webkitgtk-version" ] \
|| fail "bundled WebKitGTK version marker missing"
cmp -s \
"$ROOT/usr/lib/.bundled-webkitgtk-version" \
"$GITHUB_WORKSPACE/frontend/src-tauri/target/.tauri/bundled-webkitgtk-version" \
|| fail "bundled WebKitGTK version marker is stale or mismatched"
# Thin uv-venv installer: verify the AppImage carries the shell binary,
# the bundled uv sidecar, and the backend source resources.
{ [ -f "$ROOT/AppRun" ] || find "$ROOT" -type f \( -name "VoiceStudio" -o -name "omnivoice-studio" \) | grep -q .; } || fail "shell binary / AppRun missing"
@@ -951,50 +848,6 @@ jobs:
# the tag (v0.3.20 shipped with only the Linux AppImage that way). `needs:
# [build]` guarantees the release already exists; `--clobber` makes a re-run
# idempotent. This can never create a second release.
# The Linux leg may repack + re-sign its AppImage (see the repair step in
# the build matrix); every tauri-action leg also re-uploads the SHARED
# latest.json, so patching the manifest inside any leg races the others.
# This job runs once after the whole matrix as the single final writer:
# it makes the manifest's linux signature agree with the .sig asset that
# actually shipped, and refuses to leave a mismatch behind.
repair-updater-manifest:
needs: [build, preview-gate]
runs-on: ubuntu-latest
timeout-minutes: 10
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Data, not shell source — same zizmor rule as the leg step.
TAG: ${{ (needs.preview-gate.outputs.is_preview == 'true') && 'preview' || github.ref_name }}
steps:
- name: Align latest.json's linux signature with the shipped .sig asset
shell: bash
run: |
set -euo pipefail
WORK="$(mktemp -d)"
HAS_MANIFEST=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '[.assets[].name]|contains(["latest.json"])')
if [ "$HAS_MANIFEST" != "true" ]; then
echo "no latest.json on the release — nothing to align"; exit 0
fi
gh release download "$TAG" --pattern latest.json --output "$WORK/latest.json" --repo "$GITHUB_REPOSITORY"
# Same fail-closed rule as the manifest: absence is checked against
# the asset LIST; an actual download failure must fail the job, or
# the manifest keeps a signature nobody shipped.
HAS_SIG=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '[.assets[].name|select(endswith(".AppImage.sig"))]|length > 0')
if [ "$HAS_SIG" != "true" ]; then
echo "no AppImage .sig asset on the release — nothing to align"; exit 0
fi
gh release download "$TAG" --pattern "*.AppImage.sig" --dir "$WORK" --repo "$GITHUB_REPOSITORY"
SIG_FILE=$(find "$WORK" -name "*.AppImage.sig" | head -1)
[ -n "$SIG_FILE" ] || { echo "sig asset listed but download produced nothing"; exit 1; }
NEW_SIG=$(cat "$SIG_FILE")
CHANGED=$(python3 -c 'import json,sys; p,sig=sys.argv[1],sys.argv[2]; d=json.load(open(p)); n=sum(1 for k,v in d.get("platforms",{}).items() if k.startswith("linux") and v.get("signature")!=sig and not v.update({"signature":sig})); json.dump(d,open(p,"w"),indent=2); print(n)' "$WORK/latest.json" "$NEW_SIG")
if [ "$CHANGED" -ge 1 ]; then
gh release upload "$TAG" "$WORK/latest.json" --clobber --repo "$GITHUB_REPOSITORY"
echo "aligned $CHANGED linux signature(s) with the shipped .sig"
else
echo "manifest already agrees with the shipped .sig — no write"
fi
uninstall-scripts:
needs: [build]
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
+1 -1
View File
@@ -174,7 +174,7 @@ jobs:
- name: pip-audit (Python)
continue-on-error: true
run: |
bash scripts/uv-sync-retry.sh
uv sync
uv run --with pip-audit pip-audit
# Pin a floor: `bun audit` was added in bun 1.2.x, so guarantee it exists.
-16
View File
@@ -154,19 +154,3 @@ playwright-report/
# probe — generated HTML reports
tests/probe/reports/
# Local architecture/planning scratch (goal docs, review briefs, council
# reports). Working notes for whoever is driving a change, not a repo artifact.
/remote/
# OmniVoice GGUF runtime build artifacts (scripts/build-omnivoice-tts.sh).
# Only 0-byte placeholders of omnivoice-tts-* are tracked; real binaries,
# the checksums manifest and the copied libggml shared libs ship via CI.
bin/libggml*
bin/checksums.sha256
bin/omnivoice-tts-linux-aarch64
# Dubbing-demo intermediates. The .mp4/.srt/manifest.json in this directory ARE
# committed (they ship with the app); the per-language source WAVs are just the
# inputs scripts/render_dub_demo_audio.py hands to scripts/build_dub_demo.sh.
backend/assets/samples/demo/dubbing/*.src.wav
-2
View File
@@ -25,6 +25,4 @@ regexes = [
'''^hf_QWERTYUIOPasdfghjklZXCVBNM0123456789xyzAB$''',
# NLLB generation length argument, not the value of a credential.
'''^max_length=400$''',
# cryptography's Ed25519 private-key type name, not key material.
'''^Ed25519PrivateKey$''',
]
-4
View File
@@ -35,10 +35,6 @@ Binding for every AI agent (Claude, Codex, Cursor, review bots, …). CLAUDE.md
## Agent skills
Project development skills are pinned in `skills-lock.json` and installed under
`.agents/skills/`: Vite and FastAPI.
Repository rules and tracker mappings override generic skill guidance.
### Issue tracker
GitHub Issues on `debpalash/VoiceStudio`, via the `gh` CLI. See `docs/agents/issue-tracker.md`.
+25 -285
View File
@@ -3,238 +3,30 @@
All notable changes to VoiceStudio.
The format is loosely based on [Keep a Changelog](https://keepachangelog.com/).
`frontend/package.json` is the app-version source of truth; Cargo, Python, and
the frozen-backend fallback mirror it for their toolchains.
Versions track the desktop app (`tauri.conf.json` + `frontend/src-tauri/Cargo.toml`).
The bundled TTS model package (`pyproject.toml`) is versioned independently.
## [Unreleased]
**Highlights**
- Fix current-user Windows installer validation and nested resource cleanup (#730)
- Voice cloning now starts with a clear upload-or-record choice, reveals recording and reference details only when needed, and keeps sampling controls under Production Overrides (#1817)
### Changed
### Added
### Docs
### Fixed
## [0.5.2] — 2026-09-02
## [0.5.0] — 2026-08-10
**Highlights**
- Show estimated and measured model, dependency, cache, and temporary disk costs in the engine catalogue (#1718)
- Preview builds now stay newer than Stable even when automatic post-release version bumps are disabled (#1762)
- CosyVoice setup guidance now separates downloaded model files from the runtime that makes the engine available (#1761)
- MCP tools can now keep audio out of agent context by returning files and accepting base-path-confined file inputs (#1760) — thanks @agudmund!
- Hear a dub line as you type it — an opt-in live preview streams TTS for the edited segment (#1769) — thanks @mvanhorn!
- Studio gains a Convert method: re-say any clip in one of your saved voices, speech to speech, fully local (#1765) — thanks @mvanhorn!
- Hardsub video export gains an opt-in karaoke word-highlight caption style (#1764) — thanks @mvanhorn!
- The batch queue can now watch a folder: new videos dropped into it are dubbed automatically (#1768) — thanks @mvanhorn!
- The audiobook player now shows the chapter text and highlights the word being narrated (#1766) — thanks @mvanhorn!
- The dub editor gains a casting board: drag voice chips onto speakers, dropdowns stay in sync (#1767) — thanks @mvanhorn!
- VoiceStudio 0.5.0 is the release where the name, desktop chrome, documentation, and package metadata finally tell one clear story
- A local-first creative studio: voice cloning, design, dubbing, dictation, stories, audiobooks, and transcription without a subscription meter
- Reliability first: automatic cache repair, truthful hardware routing, safer sidecars, and actionable recovery instead of mystery failures
- Security boundaries now match the product: native file access stays native, untrusted network destinations fail closed, and public errors keep private diagnostics local
- RTX 40-series GPUs are used again instead of being sent to the CPU
- A warning before a slow generation, rather than after a five-minute wait
- The watermark can be turned off in Settings, as the docs always said
- Workspace tabs in the title bar, if you prefer them to the icon rail (#1412)
- macOS support now matches what the app actually delivers
- Linux AppImage: a blank white window on rolling distros (Mesa 26.1+) now starts normally
- Apple Silicon: transcription no longer needs a system ffmpeg, as the docs always said — thanks @gambletan! (#1436)
- A failed audiobook chapter says why, instead of turning red and saying nothing
### Changed
- Voice Design simplified: the 12-row fine-grained block collapses to one summary line with a five-field editor, English accent and Chinese dialect merge into a single field, and the starting-point chips now show 5 with an overflow toggle (#1793)
### Added
- The audiobook result is now a synced-lyrics player: chapter text follows playback with the current word highlighted and click-to-seek, timed from the render's own chapter durations with a karaoke-style even split — no ASR pass, fully local (#1766) — thanks @mvanhorn!
- The dub CAST strip expands into a project-level casting board: drag voice chips (clone profiles, design presets, Default) onto speaker rows — or pick from a keyboard listbox — writing the same per-speaker cast fields as the existing dropdowns (#1767) — thanks @mvanhorn!
- Studio's new Convert method turns a dropped or recorded clip into an existing voice profile's voice, with optional source-duration matching (#1765) — thanks @mvanhorn!
- Opt-in watch folder on the batch queue: pick a directory once and new videos are auto-enqueued with your last Add-to-queue settings, with pause/stop controls and copy-in-progress protection — files upload as bytes, paths never leave the app (#1768) — thanks @mvanhorn!
- Hardsub export can now burn karaoke word-highlight captions: an opt-in Line | Karaoke control renders a word-timed ASS sweep from timings persisted at transcription, with an even-split fallback for older jobs and translated tracks, plus a `GET /dub/ass/{job_id}` sidecar (#1764) — thanks @mvanhorn!
- Windows releases now include an independently updatable per-user MSI that installs and uninstalls without elevation (#1713)
- Dub segments can now stream live TTS while you edit a translated line — opt-in toggle, existing `/ws/tts` socket, shared generation admission, exports still render at full quality (#1769) — thanks @mvanhorn!
- Engine status and diagnostic bundles now record loaded execution provider, device, precision, fallback stage, accelerator identity, runtime versions, and parent-process memory visibility (#1717)
### Docs
- Local gigastt is now documented as a supported OpenAI-compatible ASR endpoint, with loopback privacy distinguished from remote servers (#1736) — thanks @ekhodzitsky!
- The CosyVoice guide now states that packaged builds have no one-click runtime installer and records the exact readiness checks exposed by [Discussion 1631](https://github.com/debpalash/VoiceStudio/discussions/1631) (#1761)
- A production private-API guide now covers pinned containers, root credentials, network isolation, streaming proxies, health checks, upgrades, and benchmark evidence (#1720)
- RX 6700 XT/gfx1031 over WSL2 ROCDXG is now explicitly unverified until a published end-to-end GPU workload proves the mapped path (#1716)
### Fixed
- The generation compute-time budget is now a Settings control (Performance & Device) instead of an env-var-only setting the timeout error recommended with no UI path — the error copy points there too, and long CPU/MPS renders get an upfront heads-up before they start (#1787)
- Windows: the backend can now start when the install path contains non-English characters (e.g. a CJK username) on a non-UTF-8 system code page — a new or broken Python environment now builds at an ASCII-safe path automatically (a healthy existing one is never relocated), and a specific error message names the cause and a working fix if the interpreter still crashes in `site` (#1783)
- Exports and other native-picker actions no longer 403 with "Invalid or expired desktop authorization" when the desktop app and backend resolve different data directories, e.g. dev mode or a custom data folder (#1781)
- Voice Design no longer lets you pick a Chinese dialect and an English accent together — the picker keeps them mutually exclusive instead of round-tripping a 400 (#1771)
- The desktop app no longer attaches to an already-running backend on version string alone: it now verifies the backend's actual code fingerprint too, so an orphaned or manually started backend reporting the current version but running older code (e.g. a stale `destination_path` export 422) gets replaced instead of adopted (#1770)
- Korean locale overhauled: 231 mistranslations corrected and all 493 missing keys translated (#1776) — thanks @j30231!
- Japanese "Cleaning…" clone status now reads as denoising instead of housekeeping (#1775) — thanks @j30231!
- The batch dubbing queue now has a UI entry point — a quiet link on the Dub landing (it was previously unreachable: the app switched on a mode nothing ever set) (#1768) — thanks @mvanhorn!
- OpenAI-compatible ASR now requires HTTPS outside loopback and refuses redirects so audio stays on the configured origin (#1736)
- Windows isolated engines now retain direct Job ownership without an extra Python supervisor process that can deadlock the child loader (#1734)
- The setup splash now waits through the backend's full startup budget instead of reporting slow Windows CUDA initialization as stuck after two minutes (#1749)
- Dubbing jobs can now reuse every source-language code produced by automatic ASR detection without a 400 error on the next upload (#1737)
- Incomplete Sherpa-ONNX model snapshots now self-repair before recognizer startup instead of failing on a missing ONNX file (#1733)
- OmniVoice subprocess startup now allows slow packaged Windows Python runtimes to signal readiness before termination (#1711)
- SRT files selected during source analysis now wait for speaker cloning, then replace transcript text without losing voices (#1709)
- Windows MSI deployments can now prohibit WebView2 bootstrap with `DISABLEWEBVIEW2BOOTSTRAP=1`, and `AUTOLAUNCHAPP=0` reliably suppresses first launch (#1714)
- Subtitle rows now provide 100 ms timing steppers and flag adjacent overlaps without requiring precise timeline dragging (#1710)
- Repair-sync failures now retain uv's final dependency error instead of reporting only an opaque exit status (#1705)
- YouTube ingest now retries yt-dlp's transient “page needs to be reloaded” response (#1706)
- Dictation model readiness now follows the live Hugging Face cache selected in Settings (#1707)
- Dictation capture now queues native events whenever its webview listener unmounts or reloads instead of emitting them to nobody (#1707)
- Desktop-contained backends now exit when their owning app disappears instead of surviving as stale port-3900 processes (#1707)
## [0.5.1] — 2026-08-28
**Highlights**
- OmniVoice generation on Apple Silicon now runs in a crash-isolated child, so fatal MPS memory exits no longer take down the local backend (#1697, #1698) — thanks @ndntran14!
- Model-load GPU exhaustion now returns a sanitized, actionable dubbing error, and readiness correctly attributes the shared model status to TTS (#1695)
- Source-mode development now restarts an isolated backend crash without tearing down the UI, while repeated crash loops still stop loudly with diagnostics (#1690)
- Dubbing playback now keeps an audible companion source when a WebView can render the preview picture but cannot decode its audio (#1692)
- Model Catalogue engine rows now use the available desktop width and keep identity, runtime state, and actions from crowding one another (#1689)
- VoiceStudio now acts as a local speech platform: other apps can trigger its native dictation or connect through versioned HTTP, WebSocket, JSON-RPC, CLI, and MCP transports (#1646)
- A timed-out in-process dub transcription no longer starts a second WhisperX/CTranslate2 call over the abandoned native worker, preventing the overlapping access that preceded Windows `0xC0000005` exits (#1669)
- Windows debugger termination code `0x40010004` is no longer misreported as a backend crash or charged against automatic restart recovery (#1663)
- Studio now keeps one generation reservation across page changes, preventing a remount from stacking native jobs until the backend reports capacity busy or is killed under memory pressure (#1670)
- Uploaded dubbing videos are normalized to browser-safe H.264/AAC before preview, preventing valid VP9, AV1, or Opus media from failing with “no supported sources” (#1644)
- Dubbing now separates spoken and target languages, preserves translations through segment cleanup, and lets failed translations be retried or skipped without restarting the batch (#1654) — thanks @Number16BusShelter!
- Importing replacement SRT subtitles now keeps each cue bound to the best-overlapping source speaker and clone instead of resetting every line to a random default voice (#1660) — thanks @invio-a11y!
- Uploading a Dub preview no longer blocks every backend request while ffmpeg extracts its audio (#1667) — thanks @tfreyd!
- Docker quick starts now require the administrator key needed through container NAT instead of starting a UI whose protected actions return 403 (#1651) — thanks @wd357dui!
- WSL2 AMD containers now use the `/dev/dxg` ROCDXG bridge with actionable GPU diagnostics instead of silently falling back to CPU (#1655) — thanks @wd357dui!
- Ad-hoc voice-clone references now stay alive until cancelled or timed-out GPU work actually stops reading them, so prompt caching can finish instead of failing on a deleted temp file (#1668) — thanks @tfreyd!
- Dictation now stays bound to the app where it started and recovers locally from silent recognizer output (#1175)
- The backend now answers within a second of launch and narrates its startup step by step (#1550)
- Reporting a bug from an outdated build now offers the latest release first (#1547)
- The backend is only announced ready once it can actually serve, and crash-loop restarts now pace themselves (#1548)
- Invisible watermarking no longer stalls — or silently skips — the first take of a session (#1615)
- Dub subtitles can be retimed, inserted, and merged in either direction from the segment table (#1612) — thanks @invio-a11y!
### Changed
- Model Catalogue now uses one breathable workspace canvas with simpler pane and engine-family navigation instead of nested cards and scroll regions (#1685)
- Linux source launchers now catch missing libxdo and GStreamer audio plugins before they can cause a linker error or an aborted, blank WebKit renderer (#1680, #1682)
- Dictation now carries one native output session from shortcut-down through final delivery, restores text, HTML, image, or file-list clipboards only when untouched, keeps Wayland copy-safe unless current-focus insertion is explicitly enabled, and retries silent Sherpa speech only through an already-installed local ASR model (#1175)
- The backend binds its port immediately and reports startup progress live — `/health` answers 503-with-step and a new `/startup/progress` endpoint lists every step while PyTorch, API routes, and database migrations load in the background, so "starting at step X" is never mistakable for "dead"; the desktop splash narrates each step (#1550)
### Added
- A bundled Rust loopback sidecar exposes dictation start/stop/toggle, focused-output sessions, discovery, and JSON-RPC; the backend adds versioned streaming events and a dependency-free CLI bridge for Herdr, coding agents, editors, desktop apps, and TUIs (#1646)
- Headless NVIDIA and ROCm machines can now join as worker-only Docker Compose services with no published UI and durable protocol-v2 enrollment; update both machines together before reconnecting (#1638) — thanks @jkrogers9862!
- Linux ARM64 (Asahi Apple Silicon) support for the OmniVoice GGUF engine — a `linux-aarch64` binary built with GGML Vulkan where the toolchain allows it, so Apple GPUs accelerate generation through the open-source Honeykrisp driver instead of falling back to CPU-only (#1641)
- One-command install on every desktop OS: `curl -fsSL https://voicestudio.sh/install | sh` (macOS/Linux/WSL) or `irm https://voicestudio.sh/install | iex` (Windows) — the URL serves the right script per platform, and Windows gains a source installer (`scripts/install.ps1`) with a 3-OS CI smoke (#1626)
- Per-line subtitle management in the dub table: a line's end time is editable alongside its start (typing a time and dragging its timeline edge now take the same path), lines merge with the previous row as well as the next (`Ctrl/Cmd+Shift+M`), and a new line can be inserted into the gap after any row (#1612) — thanks @invio-a11y!
- CI now enforces performance regression budgets on the hot paths — operation-count tests pin streaming TTS to one synthesis per sentence and cached dub re-mixes to zero re-synthesis; fast-path guards cover zero re-decoding and ⌈N/W⌉ native batch calls when enabled (#1594)
- Default-engine dubbing now synthesizes several segments per forward pass instead of one call per line — the width follows the host's device headroom (1 on CPU and low-VRAM cards, up to 8), `OMNIVOICE_DUB_BATCH_WIDTH` overrides it, and engines without native batching keep the single-segment path (#1594)
- `/ws/tts` now reports real time-to-first-audio, and its RTF measures synthesis alone so a slow client can't inflate it (#1594)
- The locally cached AudioSeal watermark generator warms on a background thread ~35s after boot (`OMNIVOICE_PRELOAD_WATERMARK=0` opts out; explicitly setting `=1` may download it), so the first synthesis no longer serializes the audioseal import + model load inline — measured at ~42s on a cold filesystem, 3s short of a 90s client timeout (#1576) — thanks @paoloantinori!
- Voices you've cloned stay "warm" across restarts — encoded references now persist to disk (~10 KB each), so the first generation of a session skips the re-encode and any transcription pass; `OMNIVOICE_PROMPT_DISK_CACHE=0` opts out (#1565)
- Optional FlashInfer acceleration for the default engine on CUDA (`OMNIVOICE_FLASHINFER=1`, ~2.2x measured) — needs the optional `flashinfer-python` package; missing package or kernel failure logs why and falls back to the standard path (#1565)
- The bug reporter notices when you're on an outdated build and offers the latest release before filing — with a "File anyway" escape hatch — and stamps a `Build status` line into every report so up-to-date reports are tellable from stale ones (#1547)
- Settings → Performance & Device gains a compute-device override (Auto / CUDA / ROCm / XPU / MPS / CPU, or `OMNIVOICE_DEVICE`) — pin the device when auto-detect picks wrong; only devices your machine actually has are offered (#1557)
- Opt-in 24-layer PocketTTS checkpoints via `OMNIVOICE_POCKETTTS_24L` — better prosody for it/de/es/pt at roughly 2x render time (still faster than real-time); the fast 6-layer model stays the default (#1613) — thanks @paoloantinori!
### Docs
- Supported-version and install guidance now identifies 0.5.1 as the stable desktop and container release (#1687)
- The Docker Hub overview now shows the current engine-switching demo, Model Catalogue, and gallery voice workflow (#1593)
- The Docker Hub overview and install guide now show the v0.5 tags and the built-in API-key/share-PIN security model instead of obsolete v0.4 and no-authentication guidance (#1592)
- The READMEs now lead with download buttons and a three-step first-clone walkthrough, and a new benchmarks page anchors measured per-engine/per-device numbers on the in-repo harness (#1555)
- Every engine now has its own guide — 21 new pages under docs/engines plus an index covering all 16 TTS and 11 ASR engines, linked from both READMEs (#1556)
- The OmniVoice guide now covers combining style attributes with a reference clip (consistent instruct stabilizes cloning; the reference wins conflicts), inline pronunciation control (pinyin / CMU phonemes), and corrects the claim that the default engine can't do voice design — it can, from attributes (#1565)
### Fixed
- Workspaces now measure their responsive width when the post-bootstrap shell actually mounts, so native UI scaling reflows Projects and History instead of crushing the Dubbing demo into unreadable columns (#1683)
- Dubbing keeps the source-language selector visible after a local file is chosen, so ASR can be pinned before transcription starts (#1678) — thanks @Lonki-lomki-cloud!
- First-run media-engine downloads become available to TTS immediately without a restart, and missing media-process failures now point to repair controls (#1677) — thanks @farhataligpt-dev!
- Source installs on AMD GPUs honour `OMNIVOICE_TORCH_VARIANT=rocm`: `bun run desktop` now swaps in the ROCm torch wheel after `uv sync` and launches the backend without re-syncing, instead of silently reverting to the CPU-only CUDA build on every start (#1665) — thanks @uberclokr!
- `bun run desktop` on a fresh clone no longer fails with "resource path `../../frontend/dist` doesn't exist" — the dev launcher creates the placeholder Tauri resource directory before compiling (#1664) — thanks @uberclokr!
- macOS no longer loses TTS after the first request when Python lacks `os.waitid`; subprocess ownership now uses a safe `waitpid` fallback without risking reused process groups (#1656) — thanks @paoloantinori!
- Desktop startup, Retry, reset, uninstall, shutdown, and crash recovery now share one backend lifecycle owner; quitting interrupts first-run installers and gracefully drains then force-cleans the full backend process tree, so overlaps cannot duplicate or orphan it (#1635) — thanks @Xohaibxobi!
- Large Stories and Audiobook projects now persist in IndexedDB instead of overflowing the `omnivoice.app` localStorage envelope, with quota-safe migration and orderly exit/reload flushing (#1636) — thanks @leodzai!
- OmniVoice and its crash-isolated subprocess now route to AMD ROCm GPUs instead of warning and falling back to CPU (#1629) — thanks @j4r3kb!
- Dictation now cancels pending startup work, capture resources, sockets, and timers when the capture widget closes, preventing late work against a destroyed webview (#1645)
- Streaming generation failures now show recognized recovery guidance and appear in Diagnostics instead of only returning a generic error (#1607)
- The worker-capacity transport test no longer races its own setup: the 1-slot limit now goes through the enrollment handshake instead of mutating client config after connect, where the server's stream-open ConfigUpdate (carrying the registered capacity of 2) could overwrite it and fake an over-accept; failed CI twice on 2026-08-21 (#1630)
- Moving words across a speaker boundary in a dub — merging two lines and splitting them again — no longer dubs the second half in the first speaker's voice; each half now keeps the speaker, voice, direction, gain, and language of whoever actually says it (#1612) — thanks @invio-a11y!
- Dictation on a WebView that refuses a 16 kHz audio context (WKWebView) now low-passes before downsampling, so frequencies above 8 kHz stop folding into the speech the recognizer is fed (#1610)
- A microphone context that cannot be resumed now reports a mic error instead of leaving the dictation pill on "Listening" while capturing nothing (#1610)
- Dictation no longer retains a whole session's audio for silent-model recovery — an open mic grew that buffer by ~115 MB an hour; the recent two minutes are kept instead (#1610)
- The clipboard-delivery status is now translated in all 21 languages, so Wayland users — where clipboard delivery is the default — no longer see an English string (#1610)
- A native sherpa-onnx load failure of any exception type now degrades to "engine unavailable" instead of taking the dictation WebSocket down (#1610)
- Dictation now ships Whisper Tiny as its one cross-platform default, avoiding Parakeet's measured empty decoding on Windows while keeping Parakeet selectable behind runtime fallback (#1175)
- Re-mixing a dub no longer decodes, rewrites, and re-reads every cached segment — same-rate cached audio is reused directly (and rejected if truncated), switching timing modes can't reuse slot-truncated audio as natural-rate, and RVC respects natural-rate modes (#1594)
- PocketTTS French works again — pocket-tts only ships a 24-layer French model and rejected the name the sidecar asked for, so every French request failed at model load; French now always loads `french_24l` (#1613) — thanks @paoloantinori!
- Installing IndexTTS 2.5 no longer fails claiming an interrupted download — the weights repo ships `config.yaml` and VoiceStudio demanded a `config_v2_5.yaml` that exists in no upstream release; both names are accepted, so a hand-renamed checkout keeps working (#1611) — thanks @zuiaiyutu!
- IndexTTS 2.5 no longer has long-text generation killed at 60 seconds — the sidecar now proves it is alive every 5 seconds while `infer()` runs, and its deadline rises to 900s (`OMNIVOICE_INDEXTTS_RECV_TIMEOUT_S`) (#1611) — thanks @zuiaiyutu!
- The OpenAI-compatible `/v1/audio/speech` route now reuses the shared cached engine for explicit `model` ids instead of constructing a fresh engine — and its sidecar/model load, a ~28s floor per call for subprocess engines — on every request, with the same single-engine-resident discipline `/generate` applies (#1614) — thanks @paoloantinori!
- The setup wizard's RAM check no longer blocks 8 GB machines whose OS reports ~7.8 GB usable — the thresholds now tolerate reserved memory, and `OMNIVOICE_RAM_PREFLIGHT=0` turns a genuine block into a warning for those who accept the OOM risk (#1618)
- Invisible watermarking now runs eagerly instead of through `torch.compile` — AudioSeal's lazy compile sent the first embed of every session into Inductor's C++ codegen, which failed outright on macOS hosts whose toolchain couldn't serve it and shipped the audio unmarked after a 30-40s wait; first embed drops from 9.70s to 0.26s (#1615) — thanks @paoloantinori!
- The macOS Accessibility blocker now rechecks while visible and closes as soon as the grant is enabled instead of keeping a stale permission prompt on screen (#1609)
- The dubbing editor's video and transcript columns can now be resized by pointer or keyboard, and the chosen split persists across launches (#1571) — thanks @invio-a11y!
- CPU-only synthesis now gets a bounded ten-minute execution budget, and a render that exhausts it is reported as a compute timeout instead of misleading "generation capacity is busy" queue pressure (#1588) — thanks @ChienNguyen1111!
- Rapid Launchpad ↔ Dub navigation now replaces the workspace DOM owner cleanly, so late media/waveform cleanup cannot trigger React's `insertBefore` crash (#1590) — thanks @nicolas-jacques!
- Watermark embedding failures now log the full traceback instead of just the exception message, so a silently-unmarked-audio incident (audio passes through unmarked by design) is diagnosable from the log alone (#1576) — thanks @paoloantinori!
- Dubbing now recovers rapid two-speaker exchanges when diarization collapses them, defaults new projects to lip sync without overwriting saved timing choices, and keeps the editor usable on narrow screens (#1584) — thanks @victordonat0!
- `OMNIVOICE_ASR_BACKEND=omnivoice` now selects the PyTorch-native Whisper path, so the documented ROCm escape hatch no longer fails as an unknown engine (#1582) — thanks @patmansk!
- Network Sharing from Windows MSI/portable installs now serves the bundled web interface to LAN devices instead of redirecting them to their own `localhost` (#1589) — thanks @TWIISTED-STUDIOS!
- Exported dubbed videos now mark the dubbed language as the default audio stream while keeping Original available as an explicit choice (#1575) — thanks @invio-a11y!
- Cloning references can no longer exhaust system memory: transcript-free clips up to 75 seconds are searched in five bounded passages, longer clips ask to be trimmed, and supplied transcripts remain capped at 20 seconds to preserve alignment (#1578) — thanks @ACKAPOB!
- Stored artifact subpaths now resolve after moving a data directory between Windows, macOS, Linux, and Docker, while traversal and symlink escapes remain blocked (#1559) — thanks @Eman-Yousaf!
- A remote browser hitting an API-key-configured server's admin 403 now gets the API-key login form instead of endless console 403s, while desktop and PIN-only/no-key servers keep the plain loopback error so guests are never offered a login no key can satisfy (#1568) — thanks @paoloantinori!
- The crash-isolated ASR sidecar and its download preflight now agree on which model to load — setting the shared faster-whisper model variable applies to both variants instead of the sidecar quietly using a different one (#1556)
- "Ready" now requires the deep health probe (a working database-backed route), not just the identity probe — a backend whose install broke underneath can no longer be announced up while every real request fails (#1548)
- Supervisor restarts after repeat crashes now back off (immediate, then 5s, then 15s) instead of respawning back-to-back, so a tight crash loop can't burn the whole restart budget in seconds (#1548)
- The Linux desktop cleanup regression test now isolates build artifacts, so an existing developer build can no longer change its result (#1566)
- Renaming, deleting, or revoking consent on a voice (and starring/clearing history, recording exports) now live-updates every open tab again — the sync routes' WebSocket events were silently dropped, which could look like "all my voices are gone" (#1561) — thanks @paoloantinori!
### CI
- Project agents now share pinned Vite and FastAPI skills from skills.sh (#1594)
- Weekly full-history secret scans no longer mistake the Ed25519 private-key type name for committed key material (#1591)
## [0.5.0] — 2026-08-13
**Highlights**
- The app is now **VoiceStudio** (previously OmniVoice-Studio) — one waveform-and-spark identity across the app, docs and installers. Your data folder, settings and Docker image paths stay put.
- **Model Catalogue** — engines and models in one workspace: every TTS, transcription and LLM engine with its device routing and install state, defaults picked there.
- Switch TTS, ASR and LLM engines from the status bar or any workspace — ready-only choices, memory status, environment-pin protection, `Ctrl/Cmd+E`. (#1530)
- Lend another machine's GPU with a join code and a QR scan — a Compute control in the status bar picks where jobs run, and several people can share one GPU box with revocable, certificate-pinned connections. (#1516, #1496)
- Server mode is locked down: admin actions require an API key (#1525), and the remote UI exchanges it for short-lived sessions that never sit in browser storage or WebSocket URLs (#1528) — thanks @bultodepapas!
- A faster, cleaner Dub workspace for multilingual production, with a production command bar and per-language cards. (#1489)
- The demo audio and video the app always advertised now actually ship, rendered by VoiceStudio's own engine. (#1517)
- Dictation works on Wayland now — the portal shortcut actually fires (#1490, #1526) — and the recording pill is back on every desktop.
- The Launchpad wears the project's signal-field waveform artwork over a quieter, borderless layout. (#1533)
- The catalogue reads as headroom, not breakage: available engines sort first, uninstalled ones say what they need (#1531), and the LLM row names the provider that actually answers (#1538).
- Gallery voices can be saved as local profiles — audio lands in your profile store with validated, content-addressed references. (#1542)
<img src="https://raw.githubusercontent.com/debpalash/VoiceStudio/main/docs/media/0.5.0/quick-switch.gif" alt="Switching TTS engines from the status bar" width="820" />
| The Model Catalogue | The Voice Gallery |
| --- | --- |
| <img src="https://raw.githubusercontent.com/debpalash/VoiceStudio/main/docs/media/0.5.0/catalogue.png" alt="Model Catalogue — engines pane" width="420" /> | <img src="https://raw.githubusercontent.com/debpalash/VoiceStudio/main/docs/media/0.5.0/gallery-save.png" alt="Voice Gallery — save a voice as a profile" width="420" /> |
### Changed
- Gallery personas now preview through the local backend, retain their complete voice-design recipe, and open directly in Voice, Stories, or Audiobook. (#1542)
- Typing and large workspace edits no longer serialize and rewrite persisted documents on every input; writes are coalesced off the interaction path — thanks @bultodepapas! (#1541)
- Support amount choices now use every theme's shared card, accent and focus tokens. (#1530)
- Sponsoring, commercial licensing and getting in touch are one page now. They answered the same question between them and each used to live somewhere else, so they are three sections on a single scroll — the footer heart, the commercial-licence links and Contact all land on it, at the section you asked for. (#1522)
- Model Catalogue switches panes with tabs instead of a two-state toggle, and the Engine Compatibility Matrix's TTS / ASR / LLM switcher is now tabs too — arrow-key navigable, and each tab still shows the engine it would use. (#1522)
- Engines you can actually use sort to the top of the compatibility matrix, and an unavailable engine's name recedes instead of the whole row fading — the status badge and GPU chips that say *why* it is unavailable stay legible. (#1522)
- Remote workers reads as a device list: status dot, address, latency, a live task meter, resident models and last-seen per machine, with housekeeping actions revealed on hover and a three-step empty state. (#1516)
- The GPU picker and the new status-bar control paint their status dots and menu surfaces from themed tokens instead of fixed palette classes, so they stop showing Gruvbox colours on Midnight and Catppuccin. (#1516)
- Dictation shows the pill again: a capture puts a small always-on-top capsule near the bottom of the screen you are working on — listening, transcribing, the result, and any error — and takes it away when the session ends. It never takes focus, so the text still lands in the app you were typing into. On Wayland the compositor decides where it sits; everywhere else it is bottom-centred.
- Engines and models moved out of Settings into a new Model Catalogue workspace, reachable from the icon rail (or the title-bar tabs); Settings → Engines and Settings → Models now point there, and Settings keeps the models directory and Hugging Face mirror.
- The Settings sidebar is keyboard-navigable: ⌘K / Ctrl+K jumps to the filter, ↑/↓ and Home/End move between categories, and Enter or ↓ from the filter drops into the list. Matching text in a filtered category name is highlighted, and group headers stay pinned while the list scrolls.
- The Launchpad has a quieter, more spacious look: borderless feature tiles that light up on hover or keyboard focus, plain-numeral counts, hairline section rules, and one shared page column for the hero, tiles, recent files and project lists.
- Linux release smoke now validates linuxdeploy's wrapped custom launcher instead of rejecting a healthy AppImage. (#1506)
- Remote GPU workers render audiobooks chapter by chapter, with automatic per-chapter local fallback and one combined notice if the worker drops out. (#1478)
- Remote GPU workers can now run a job to completion: long renders no longer die at two minutes, a worker that drops and reconnects mid-render keeps its work, and a timed-out job no longer takes the worker offline for good. Placing a job still needs the development-only `POST /workers/tasks`; wiring the app's own Synthesize button to it comes next.
- Voice, Stories, Audiobook, Gallery, Settings, profiles, and Launchpad now use compact, responsive layouts with accessible controls. (#1491)
- Dubbing's Generate Dub, Verify, and Export actions now use a compact hierarchy with visible labels, responsive reflow, and motion-safe feedback. (#1493)
- The Dub workspace now has a compact production command bar, responsive flag-based language cards, media previews in Dub History, and a narrower Projects rail. (#1489)
- VoiceStudio now uses one waveform-and-spark mark across the title bar, About screen, README, browser favicon, and every desktop/platform icon. (#1487)
- PocketTTS now asks you to review its code license, model license and gated-access conditions before first use, and explains how to unlock the model instead of showing a raw download failure — thanks @paoloantinori! (#1442)
- The repository moved to github.com/debpalash/VoiceStudio. Every link in the app, docs and scripts now points there; GitHub redirects the old URLs, and the Docker image paths, the app bundle identifier and your data folder are all deliberately unchanged. (#1394)
- The app is now **VoiceStudio** (previously OmniVoice-Studio). Only the name you see changes — your data folder, settings and the Docker image paths stay put, so upgrading needs nothing from you. On Linux the .deb is now `voicestudio`; remove the old `omnivoice-studio` package once.
@@ -244,72 +36,18 @@ the frozen-backend fallback mirror it for their toolchains.
### Added
- Gallery personas preview through the local backend, keep their full voice-design recipe, and open directly in Voice, Stories, or Audiobook — and can be saved as local profiles with validated audio references. (#1542)
- The demo audio the app has always advertised now actually ships: previews for all seven voice-design presets, the three dictation replay clips, and the dubbing demo's source video plus four dubbed languages with subtitles. Every one of those was a dead link before — the tooling that renders them required macOS, so on Windows and Linux the files were never built. (#1517)
- Demo assets are rendered by VoiceStudio's own engine, so the tooling runs wherever the app does, and the demos are made by the thing they demonstrate. (#1517)
- A machine can now join a control plane from the app: Settings → System → Remote workers → **Lend this machine's GPU**, paste the join code, done — no environment variables and no restart. The address travels with the code, so the machine reconnects on its own afterwards. (#1516)
- Join codes and connection strings are shown as a **QR code** alongside the text, with a live expiry countdown — scan it from the other machine instead of retyping forty characters. (#1516)
- A **Compute** control in the status bar: pick local or a remote machine, turn remote workers on or off, and mint a join code without opening Settings. It appears only once you have opted in or enrolled a machine. (#1516)
- A worker waiting for approval can be approved from its row. The panel labelled that state before but offered no way out of it. (#1516)
- **Model Catalogue** — a workspace of its own for engines and models: browse every TTS, transcription and LLM engine with its device routing and install state, pick the default for each, and install or remove model weights, all from one screen instead of two Settings categories.
- Remote GPU machines can now accept connections instead of dialling out, so several people can use the same box at once — each gets their own revocable connection string, with certificate-pinned TLS, a live list of who is connected, and a disconnect button. (#1496)
- Remote GPU model downloads now use the normal Models install flow and show per-worker progress. (#1478)
- Settings → System → **Remote workers** sends individual jobs to GPUs on your other machines while everything else stays here. Off by default; each machine is added with a single-use token and approved before any audio reaches it. See [docs/remote-workers.md](docs/remote-workers.md).
- First-run setup now recommends a screen-aware interface scale, with compact controls available throughout setup. (#1502)
- OrcaRouter is now available as a named OpenAI-compatible LLM provider — thanks @Marc-oss-hub! (#1499)
- IndexTTS 2.5 is available as a pinned one-click sidecar with five-language dubbing, expressive cloning, and backward-compatible IndexTTS-2 support. (#1482) — thanks @marwanlhabti5-coder!
- Voice recording now offers microphone and channel selection with a live input-level meter on every desktop platform. (#1481)
- Settings → Appearance → **Navigation style** switches the workspace switcher between the icon rail down the window edge and browser-style tabs across the title bar. Both offer the same workspaces; the choice sticks across launches, and the rail stays the default. Tab labels fold down to icons when the title bar runs out of room — the workspace you're in keeps its name. (#1412)
- Portable mode lets you choose the folder — press **Change…** on the first-run setup screen and put the whole install on an external drive. It also stops being greyed out after a default Program Files install. (#766)
- Settings → Privacy now has an **Invisible watermark** toggle. On by default, available to everyone, and it only affects audio generated after the change. (#1308)
- A new opt-in crash-isolated TTS engine, so a native crash takes down the sidecar instead of the whole backend — thanks @paoloantinori! (#1292, #1298, #1304)
- **PocketTTS** (Kyutai), an opt-in CPU-only engine for fast, low-latency renders in six languages (en/fr/de/pt/it/es) with zero-shot cloning from a reference clip. Enable in Settings → Engines — thanks @paoloantinori! (#1306, #1328)
- A warning before a slow generation, rather than after a five-minute wait. (#1280)
### Docs
### CI
- Engine acceptance: new `docs/engine-acceptance.md` documents the job map, the bar a new engine must clear, and the out-of-tree path (#1306)
- macOS install notes and the README support table now state the real floor (#1268)
- Contact: the project X account is listed alongside Discord (#1313)
- `OMNIVOICE_ALLOWED_ORIGINS` is finally documented: a browser loading the UI from another machine's origin needs the backend's CORS allow-list, which neither server mode nor trusted networks touches — thanks @vanderlpp! (#1348)
- The stdio wire protocol every engine sidecar speaks is now tested once across all nine of them, instead of against a single engine — a bug in any one sidecar's copy gets caught — thanks @paoloantinori! (#1408)
### Fixed
- AMD/ROCm hosts no longer crash ASR with "CUDA driver version is insufficient": ROCm torch reports itself as CUDA, but whisperx/faster-whisper run on CTranslate2, which is NVIDIA-only — they now take the CPU path there, and auto-detect prefers pytorch-whisper, which genuinely uses the HIP GPU. (#1529)
- Crash reports now carry the crashed run's own stderr: the shared error log is append-only with per-run offsets, so a restart can no longer overwrite the dying process's final output with the replacement's healthy startup. (#1510)
- Wayland: a stale portal identity no longer kills the dictation shortcut for the whole session. The desktop entry the app writes for the GlobalShortcuts portal could point at a binary that has since moved (a `cargo clean`, a relocated AppImage) — GNOME then refuses the bind with "App info not found" and the hotkey silently dies. The entry is validated and rewritten at startup now. (#1526)
- The guard that keeps transcription on the degrading ASR loader now scans the whole backend, not just the routers — a service that transcribes on a request's behalf skipped `ensure_loaded()` just as thoroughly. (#1519) — thanks @ahov520!
- The Linux app icon is no longer blank. Every AppImage since v0.4.2 shipped `.DirIcon` as an absolute symlink into the machine that built it (`/home/runner/work/…`), so the link dangled on every user's computer and file managers, app menus and desktop integration all drew nothing. The release build now verifies the icon resolves inside the bundle before publishing. (#1518)
- The Linux desktop entry no longer ships an empty `Categories=`, which `desktop-file-validate` rejects and menu builders skip. (#1518)
- Wayland: the dictation shortcut now actually starts dictation. The desktop portal registered the key correctly — GNOME and KDE even showed it back — but every press was discarded while decoding the compositor's signal, so the hotkey did nothing on any Wayland session. (#1490)
- The first-run "Choose a comfortable UI size" screen no longer stutters while you sit there. Applying a scale resizes the window's own viewport, which the screen was reading back to re-pick a size — so it flipped between two sizes forever without anyone touching it. (#1514)
- Transcription now moves to the next working engine when the auto-picked one passes its availability check but breaks on first real use, instead of returning an internal error — the recovery dubbing already had. Affected accurate-mode transcription, the OpenAI-compatible API, batch, dub verify, and voice-clone reference text. (#1512)
- A malformed request now gets a clear 422 instead of an internal error, and uploading a file to an endpoint that expects JSON no longer copies the whole upload into the app log — a 145 KB clip wrote roughly 500 KB of log, recording your audio in the file people paste into bug reports. (#1513)
- The Simplified Chinese (zh-CN) translation no longer mistranslates brand names and technical terms — Discord, Tailscale, Hugging Face, IPA, and LLM (Cinematic) were rendered as nonsensical literal translations, and ~250 more awkward machine-translation strings are now natural Chinese. (#1508) — thanks @anyingiit!
- Worker restart coverage now waits for the registration response to persist its identity instead of racing the client callback in CI. (#1505)
- Dub language and export selections now restore without false schema warnings, and remote-worker port 7443 is identified instead of reported as a generic timeout. (#1504)
- A configured remote backend now bypasses local first-run setup, verifies itself before app requests begin, and shows recovery instead of leaving the desktop stuck on Setup. (#1503)
- An idle voice model now actually hands its memory back. The unload emptied the GPU cache a moment before releasing the model, so it freed nothing while reporting success — a GPU machine lending its card sat on 3.6 GB indefinitely. (#1495)
- Unloading a model on an NVIDIA GPU now returns the last ~770 MB too. A single 8.5 MB cuBLAS workspace sat inside the model's memory block and kept the whole block reserved, so an idle machine held 1.2 GB instead of 470 MB no matter how often you pressed Flush Memory. (#1495)
- Flush Memory reports reserved GPU memory alongside allocated. Allocated alone reads near zero right after an unload while the GPU still shows gigabytes, which is exactly the case people were reporting. (#1495)
- The AudioSeal watermark models are released after the same idle period as everything else, instead of staying in memory for the life of the app once anything was watermarked. (#1495)
- Remote GPU workers now synthesize a dub's fresh segments as one coarse job with live progress and cancellation; fitting, assembly and RVC remain local. (#1478)
- Gallery voice previews now fall back to a local render when a downloaded clip cannot be decoded, instead of failing silently. (#1478)
- A second VoiceStudio instance can no longer silently share the remote-worker port; it keeps running locally and explains how to resolve the conflict. (#1478)
- Remote GPU jobs stay pinned to the selected worker across retries and restarts, stop when their caller leaves, and cannot return from cancellation as completed. (#1478)
- Remote GPU model labels now survive registration, legacy blank model IDs share one capacity slot, long jobs retain bounded leases, and idle cleanup cannot evict a live local render. (#1478)
- Remote GPU jobs now stop before dispatch when that worker lacks the model, offer the download there, and refresh scheduling as soon as it finishes. (#1478)
- Leaving a screen while its waveform is still loading no longer opens a bug-report prompt for a normal cancelled request. (#1498)
- An unreachable remote backend now opens a retryable recovery screen instead of sending the app into local model setup, with clear TLS, CORS, network, HTTP, and wrong-port guidance — thanks @debpalash! (#1501)
- Linux production test launches now stop their own extracted AppImage before resetting SQLite and logs. (#1494)
- Restored the pre-release version to 0.4.2 while the next release remains in preparation. (#1488)
- Large multi-language dubbing batches now use compact searchable language and track managers instead of overflowing the editor. (#1492)
- Dictation shortcuts now register and rebind through the desktop portal on Wayland, honor custom keys in focused app views, and show the effective platform keys. (#1490)
- Multi-language dubbing now translates, edits, generates, retains, and exports every selected language, and its language picker stays visible at viewport edges. (#1486)
- Dubbing's **From video** cast now uses available source-audio samples for every speaker and short line, including jobs without a pooled diarization clone. (#1484)
- Basic Dubbing translation remains available without an LLM; Cinematic and Autofit now degrade through the existing Fast translation path instead of blocking the quality choice. (#1481)
- Linux microphone recording now falls back to WAV when WebKit cannot encode MediaRecorder audio, and desktop scaling/titlebar controls remain responsive at every UI scale. (#1481)
- Dubbing can install a missing ASR model and retry the same job, navigate back through completed stages, and finish transcription under low GPU memory without producing an empty transcript. (#1481)
- Filenames and other outside data can no longer forge extra lines or terminal commands in backend and frontend diagnostic logs. (#1457)
- Backend journal, dictation reset, voice-catalog, and crash-notification failures are now visible and retryable instead of being silently ignored. (#1459)
- Backend failures keep raw tracebacks, local paths and credentials in the local log instead of returning them in API responses. (#1454)
@@ -393,17 +131,19 @@ the frozen-backend fallback mirror it for their toolchains.
- Translation through LM Studio works. The built-in model name was the placeholder `local-model`, which LM Studio rejects because it serves whatever you have loaded — VoiceStudio now asks it, and a 404 from a local server names the models that ARE loaded instead of telling you to check a URL that was fine — thanks @biga73! (#1332)
- Generation that silently dropped the end of the input now says so. When an engine returns no audio for part of the text the result sounds clean and is simply short, so the only way to notice was to read along; the backend log now names the sentences that produced nothing. (#1330)
- Dubbing: a re-rendered line that quietly came back in a default voice instead of the cloned one now says why in the backend log — the clone clips are extracted per job and a saved dub outlives them, so regenerating after cleanup loses the reference with no error. (#1331)
- RTX 40-series GPUs are used again instead of being sent to the CPU. (#1289)
- Apple Silicon: transcription no longer needs a system ffmpeg, as the docs always said — thanks @gambletan! (#1436)
- A failed audiobook chapter says why, instead of turning red and saying nothing. (#1325)
### Docs
- Engine acceptance: new `docs/engine-acceptance.md` documents the job map, the bar a new engine must clear, and the out-of-tree path (#1306)
- macOS install notes and the README support table now state the real floor (#1268)
- Contact: the project X account is listed alongside Discord (#1313)
- `OMNIVOICE_ALLOWED_ORIGINS` is finally documented: a browser loading the UI from another machine's origin needs the backend's CORS allow-list, which neither server mode nor trusted networks touches — thanks @vanderlpp! (#1348)
### CI
- Windows CI falls back to a static ffmpeg build when the Chocolatey feed is down, instead of failing the run. (#1542)
- The stdio wire protocol every engine sidecar speaks is now tested once across all nine of them, instead of against a single engine — a bug in any one sidecar's copy gets caught — thanks @paoloantinori! (#1408)
- Windows smoke tests stopped silently passing a broken ffmpeg install, and every smoke leg is now budgeted for a cold dependency install. (#1290)
- Test suites no longer leak config paths or model-manager shutdown state into one another, which had been failing unrelated pull requests. (#1269)
- The nightly preview build stopped refusing to publish its own healthy updater manifest when the macOS legs finished a few minutes ahead of the slowest one — Preview-channel users were silently left without new builds.
- The nightly preview build stopped refusing to publish its own healthy updater manifest when the macOS legs finished a few minutes ahead of the slowest one — Preview-channel users were silently left without new builds.
## [0.4.2] — 2026-07-28
+1 -4
View File
@@ -66,10 +66,7 @@ Architecture not yet mapped. Follow existing patterns found in the codebase.
<!-- GSD:skills-start source:skills/ -->
## Project Skills
- `vite` — Vite configuration, assets, HMR, builds, and Vitest guidance.
- `fastapi-python` — FastAPI and Pydantic implementation patterns.
Canonical copies live under `.agents/skills/`; `skills-lock.json` pins their sources and hashes. Claude should follow these paths directly, avoiding cross-platform symlinks.
No project skills found. Add skills to any of: `.claude/skills/`, `.agents/skills/`, `.cursor/skills/`, `.github/skills/`, or `.codex/skills/` with a `SKILL.md` index file.
<!-- GSD:skills-end -->
<!-- GSD:workflow-start source:GSD defaults -->
+4 -10
View File
@@ -10,10 +10,10 @@ Copyright 2024-present Palash Debnath and VoiceStudio contributors.
VoiceStudio is **free and open-source software, licensed under the GNU
Affero General Public License, Version 3 (AGPL-3.0)**. You are free to use,
copy, modify, and redistribute it. That **includes commercial and internal
business use** of the application itself. Model weights, tokenizers, and other
third-party assets retain their own terms; this application license does not
grant or summarize rights under those separate terms.
copy, modify, and redistribute it — and that **includes commercial and internal
business use**: run the app, use its outputs commercially, sell the audio you
produce with it, provide professional/client services with it, and deploy it
within your organization.
Because this is the **Affero** GPL, one additional obligation applies: if you
modify VoiceStudio and make that modified version available to others over
@@ -41,12 +41,6 @@ is **separately licensed under Apache License 2.0** by its upstream authors and
is not relicensed here. Apache License 2.0 is compatible with, and may be
combined under, the GNU AGPL-3.0. See `pyproject.toml`.
Downloaded model weights are not relicensed by VoiceStudio. The default
`k2-fsa/OmniVoice` model card identifies its code as Apache-2.0 and pretrained
weights as CC-BY-NC. Its `audio_tokenizer/LICENSE` contains separate Boson
Higgs Audio 2 and Meta Llama community terms. A commercial license for
VoiceStudio-owned code does not replace any of those terms.
Third-party dependencies retain their own licenses. See `Cargo.lock`,
`bun.lock`, and `uv.lock` for the resolved set.
+541 -378
View File
File diff suppressed because it is too large Load Diff
+58 -133
View File
@@ -1,7 +1,7 @@
*本文档是 [README.md](README.md) 的简体中文翻译;若与英文版有出入,以英文版为准。*
<div align="center">
<img src="docs/logo.png" alt="VoiceStudio 徽标" width="120" height="120" />
<img src="docs/logo.png" alt="VoiceStudio 徽标" width="120" />
<h1>VoiceStudio</h1>
<p><sub><em>原名 OmniVoice-Studio</em></sub></p>
<h3>创造声音,讲述故事,文件始终属于你。♡</h3>
@@ -20,7 +20,6 @@
</p>
<p>
<a href="https://github.com/debpalash/VoiceStudio/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/debpalash/VoiceStudio/ci.yml?branch=main&style=flat-square&label=CI" alt="CI 状态" /></a>
<a href="https://github.com/debpalash/VoiceStudio/stargazers"><img src="https://img.shields.io/github/stars/debpalash/VoiceStudio?style=flat-square&color=f59e0b" alt="Star 数" /></a>
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/github/v/release/debpalash/VoiceStudio?style=flat-square&color=10b981" alt="版本" /></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-AGPL--3.0-blue?style=flat-square" alt="许可证" /></a>
@@ -38,7 +37,7 @@
<br/>
<div align="center">
<img src="docs/media/0.5.0/quick-switch.gif" alt="VoiceStudio — 从状态栏快速切换 TTS 引擎" width="100%"/>
<img src="docs/screenshot-launchpad.png" alt="VoiceStudio — 启动台" width="100%"/>
</div>
> **声音很私人,创作空间也应该真正属于你。** VoiceStudio 的核心流程运行在你的硬件上:克隆、设计、配音、听写,并以 646 种语言创作,不需要订阅,也没有用量计费。联网引擎和服务始终是清晰可见的可选项,而不是隐藏依赖。
@@ -46,72 +45,6 @@
> [!WARNING]
> **活跃 Beta 阶段。** 各版本之间可能出现故障——如需最新修复,请从源码运行。非常欢迎 Bug 报告和 PR[提交 Issue](https://github.com/debpalash/VoiceStudio/issues) 或 [加入 Discord](https://discord.gg/bzQavDfVV9)。
<a id="quickstart"></a>
## ⚡ 快速开始
<div align="center">
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/badge/macOS-DMG_(Apple_Silicon)-000?style=for-the-badge&logo=apple&logoColor=white" alt="下载 macOS DMG" /></a>
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/badge/Windows-MSI_(x64)-0078D4?style=for-the-badge&logo=windows&logoColor=white" alt="下载 Windows MSI" /></a>
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/badge/Linux-AppImage_(x64)-FCC624?style=for-the-badge&logo=linux&logoColor=black" alt="下载 Linux AppImage" /></a>
<br/>
<sub>三个按钮都会打开最新发布页——在资源列表中下载对应你系统的安装包。</sub><br/>
<sub><b>macOS</b>首次启动需要一次性批准——右键点击 → <b>打开</b>macOS 15 上为 系统设置 → 隐私与安全性 → <b>“仍要打开”</b>)。无需终端。<a href="docs/install/macos.md#gatekeeper-quarantine">为什么?</a> · <b>Intel Mac</b>不支持本地后端(<a href="https://github.com/debpalash/VoiceStudio/issues/889">#889</a>)——<a href="docs/install/macos.md">详情</a>。</sub>
</div>
选择你的操作系统,按指南从头到尾操作:
- 🍎 **macOS** — [docs/install/macos.md](docs/install/macos.md)
- 🪟 **Windows** — [docs/install/windows.md](docs/install/windows.md)
- 🐧 **Linux** — [docs/install/linux.md](docs/install/linux.md)
- 🐳 **Docker** — [docs/install/docker.md](docs/install/docker.md) · [Docker Hub: `palashdeb/omnivoice-studio`](https://hub.docker.com/r/palashdeb/omnivoice-studio)
```bash
# Docker 快速运行 (CPU / 本地环回模式)
docker run -d -p 127.0.0.1:3900:3900 -v omnivoice-data:/app/omnivoice_data --name voicestudio palashdeb/omnivoice-studio:stable
```
**三步克隆出你的第一个声音:**
1. **安装并启动。** 首次启动会自动搭建 Python 运行环境并下载模型权重——启动画面会逐步显示进度(仅首次,需要几分钟;之后即开即用)。
2. 从启动台打开**语音克隆**,拖入任意声音的 **3 秒音频**
3. **输入一句话,点击生成。** 音频在你的设备上生成并保存,支持 646 种语言(商业使用前请审阅所选模型与分词器的许可条款)。
### 🎧 音频示例
在线试听 VoiceStudio 本地生成的实际音频样例:
| 工作流 | 提示词 / 参考音频 | 生成音频 |
|---|---|---|
| **声音克隆** | [demo_voice.wav](backend/assets/samples/demo_voice.wav) | [demo_clone_output.wav](backend/assets/samples/demo_clone_output.wav) |
| **声音设计** (美语新闻主播) | *"清晰、权威的美国广播级音色"* | [demo_voice_design_us_news_anchor.wav](backend/assets/samples/voice_design/demo_voice_design_us_news_anchor.wav) |
| **声音设计** (英式有声书) | *"温暖生动的英式故事讲述音色"* | [demo_voice_design_audiobook_uk_narrator.wav](backend/assets/samples/voice_design/demo_voice_design_audiobook_uk_narrator.wav) |
| **视频配音** (多语种) | [source.src.wav](backend/assets/samples/demo/dubbing/source.src.wav) | [西班牙语](backend/assets/samples/demo/dubbing/dubbed_es.src.wav) · [法语](backend/assets/samples/demo/dubbing/dubbed_fr.src.wav) · [日语](backend/assets/samples/demo/dubbing/dubbed_ja.src.wav) · [中文](backend/assets/samples/demo/dubbing/dubbed_zh.src.wav) |
觉得慢?[docs/performance.md](docs/performance.md) 讲清了生成时间到底花在哪里、有哪些调优开关,以及“它变慢了”的三个经典原因。各引擎/设备的实测数据见 [docs/benchmarks.md](docs/benchmarks.md)。
> 正在从 **[CorentinJ/Real-Time-Voice-Cloning](https://github.com/CorentinJ/Real-Time-Voice-Cloning)**(现已归档)迁移过来?我们有专门的迁移指南:[docs/migration/real-time-voice-cloning.md](docs/migration/real-time-voice-cloning.md)。
<details>
<summary><b>🧰 卡住了?自检、Token 与受限网络</b></summary>
<br/>
先运行内置自检——在应用中打开 **设置 → 关于 → “运行自检”**,或在源码检出目录中执行
`uv run python backend/main.py --diagnose`(加 `--deep` 还会实际加载当前引擎进行测试)。然后查看
[docs/install/troubleshooting.md](docs/install/troubleshooting.md) 中排名前
10 的安装错误。运行时出错时,应用内的错误界面会直接深链到对应条目;**设置 → 关于 →
“保存诊断包”** 会把脱敏日志与自检报告打包,方便附在 Bug 报告里。
Hugging Face Token 的配置见
[docs/setup/huggingface-token.md](docs/setup/huggingface-token.md)。说话人分离相关的模型访问门槛见
[docs/features/diarization.md](docs/features/diarization.md)。下载速度、⚡ 快速下载(Xet)状态,以及受限网络 / 镜像选项见
[docs/downloading-models.md](docs/downloading-models.md)。
</details>
---
<a id="features"></a>
## ✨ 功能
@@ -179,6 +112,49 @@ Hugging Face Token 的配置见
---
<a id="quickstart"></a>
## ⚡ 快速开始
<div align="center">
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/badge/macOS-DMG_(Apple_Silicon)-000?style=for-the-badge&logo=apple&logoColor=white" alt="下载 macOS DMG" /></a>
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/badge/Windows-MSI_(x64)-0078D4?style=for-the-badge&logo=windows&logoColor=white" alt="下载 Windows MSI" /></a>
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/badge/Linux-AppImage_(x64)-FCC624?style=for-the-badge&logo=linux&logoColor=black" alt="下载 Linux AppImage" /></a>
<br/>
<sub><b>macOS</b>首次启动需要一次性批准——右键点击 → <b>打开</b>macOS 15 上为 系统设置 → 隐私与安全性 → <b>“仍要打开”</b>)。无需终端。<a href="docs/install/macos.md#gatekeeper-quarantine">为什么?</a> · <b>Intel Mac</b>不支持本地后端(<a href="https://github.com/debpalash/VoiceStudio/issues/889">#889</a>)——<a href="docs/install/macos.md">详情</a>。</sub>
</div>
选择你的操作系统,按指南从头到尾操作:
- 🍎 **macOS** — [docs/install/macos.md](docs/install/macos.md)
- 🪟 **Windows** — [docs/install/windows.md](docs/install/windows.md)
- 🐧 **Linux** — [docs/install/linux.md](docs/install/linux.md)
- 🐳 **Docker** — [docs/install/docker.md](docs/install/docker.md) · [Docker Hub: `palashdeb/omnivoice-studio`](https://hub.docker.com/r/palashdeb/omnivoice-studio)
觉得慢?[docs/performance.md](docs/performance.md) 讲清了生成时间到底花在哪里、有哪些调优开关,以及“它变慢了”的三个经典原因。
> 正在从 **[CorentinJ/Real-Time-Voice-Cloning](https://github.com/CorentinJ/Real-Time-Voice-Cloning)**(现已归档)迁移过来?我们有专门的迁移指南:[docs/migration/real-time-voice-cloning.md](docs/migration/real-time-voice-cloning.md)。
<details>
<summary><b>🧰 卡住了?自检、Token 与受限网络</b></summary>
<br/>
先运行内置自检——在应用中打开 **设置 → 关于 → “运行自检”**,或在源码检出目录中执行
`uv run python backend/main.py --diagnose`(加 `--deep` 还会实际加载当前引擎进行测试)。然后查看
[docs/install/troubleshooting.md](docs/install/troubleshooting.md) 中排名前
10 的安装错误。运行时出错时,应用内的错误界面会直接深链到对应条目;**设置 → 关于 →
“保存诊断包”** 会把脱敏日志与自检报告打包,方便附在 Bug 报告里。
Hugging Face Token 的配置见
[docs/setup/huggingface-token.md](docs/setup/huggingface-token.md)。说话人分离相关的模型访问门槛见
[docs/features/diarization.md](docs/features/diarization.md)。下载速度、⚡ 快速下载(Xet)状态,以及受限网络 / 镜像选项见
[docs/downloading-models.md](docs/downloading-models.md)。
</details>
---
<a id="why-voicestudio"></a>
## 💡 为什么选择 VoiceStudio
@@ -197,8 +173,8 @@ Hugging Face Token 的配置见
| **API 密钥** | 需要账号 | 本地流程不需要 |
| **GPU 支持** | 不适用(云端) | CUDA · Apple Silicon · ROCmLinux)· CPU |
| **桌面应用** | ❌ | ✅ macOS · Windows · Linux |
| **TTS 引擎** | 1 | **16** — [完整矩阵](#tts-engines) |
| **ASR 引擎** | 1 | **11** — [完整阵容](#asr-engines) |
| **TTS 引擎** | 1 | **14** — [完整矩阵](#tts-engines) |
| **ASR 引擎** | 1 | **10** — [完整阵容](#asr-engines) |
| **MCP 服务器** | ❌ | ✅ 可从 Claude、Cursor 及任何 MCP 客户端使用 |
| **自检** | ❌ | ✅ 诊断套件、错误日志、脱敏调试包 |
| **可定制** | ❌ 闭源 | ✅ 随你 Fork、扩展、发布 |
@@ -234,24 +210,14 @@ Hugging Face Token 的配置见
> [!IMPORTANT]
> **macOS Intelx86_64)不支持本地后端:** 应用 UI 可以安装,但 Python 后端无法运行,因为 PyTorch 已不再发布 Intel Mac 轮子([#889](https://github.com/debpalash/VoiceStudio/issues/889))。Intel Mac 用户仍可让 UI 指向另一台机器上的远程后端——参见 [docs/install/macos.md](docs/install/macos.md)。
<a id="hardware-recommendations"></a>
### 💡 按硬件推荐引擎配置
| 硬件配置 | 推荐 TTS 引擎 | 推荐 ASR 语音识别 | 优势 |
|---|---|---|---|
| **Apple Silicon (M1M4)** | [MLX-Audio](docs/engines/mlx-audio.md) · [OmniVoice](docs/engines/omnivoice.md) (MPS) | [MLX Whisper](docs/engines/mlx-whisper.md) · [Parakeet MLX](docs/engines/parakeet-mlx.md) | 原生统一内存,macOS 上延迟最低、性能最强 |
| **NVIDIA 显卡 (8 GB+ 显存)** | [OmniVoice](docs/engines/omnivoice.md) · [CosyVoice 3](docs/engines/cosyvoice.md) | [WhisperX](docs/engines/whisperx.md) | 极致零样本克隆品质、字级时间戳对齐与说话人分离 |
| **低显存 / 仅 CPU 设备** | [PocketTTS](docs/engines/pockettts.md) · [Sherpa-ONNX](docs/engines/sherpa-onnx.md) · [KittenTTS](docs/engines/kittentts.md) | [Moonshine](docs/engines/moonshine.md) · [Faster-Whisper](docs/engines/faster-whisper.md) (`int8`) | 超低内存占用,针对 CPU 指令集深度优化 |
<a id="tts-engines"></a>
### 🗣️ TTS 引擎
**16 个引擎,一个选择器。** VoiceStudio(默认,支持 600+ 语言)始终可用;另有七个引擎可选装并自动检测(CosyVoice 3、GPT-SoVITS、VoxCPM2、MOSS-TTS-Nano、KittenTTS、MLX-Audio、Sherpa-ONNX),外加个按需延迟安装的引擎(IndexTTS 2.5、OmniVoice GGUF、OmniVoice 子进程版、PocketTTS、Supertonic 3、MOSS-TTS-v1.5、dots.tts、Confucius4-TTS)。在 **设置 → TTS 引擎** 中切换;所选引擎将应用于所有语音合成场景。**每个引擎都有独立指南:[docs/engines](docs/engines/README.md)(英文)。**
**14 个引擎,一个选择器。** VoiceStudio(默认,支持 600+ 语言)始终可用;另有七个引擎可选装并自动检测(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)。在 **设置 → TTS 引擎** 中切换;所选引擎将应用于所有语音合成场景。
<details>
<summary><b>📊 完整矩阵</b>——16 个引擎 × 平台 × 克隆/指令 × 许可证</summary>
<summary><b>📊 完整矩阵</b>——14 个引擎 × 平台 × 克隆/指令 × 许可证</summary>
<br/>
@@ -265,17 +231,13 @@ Hugging Face Token 的配置见
| **KittenTTS** | 英语 | — | — | ✅ CPU | ✅ CPU | ✅ CPU | MIT |
| **MLX-Audio**Kokoro、Qwen3-TTS、CSM、Dia 等) | 多语言 | 因模型而异 | 因模型而异 | ❌ | ✅ 原生 | ❌ | 因模型而异 |
| **Sherpa-ONNX** | 20+ | — | — | ✅ CUDA/CPU | ✅ CPU | ✅ CUDA/CPU | Apache-2.0 |
| **IndexTTS 2.5** ⚡ | 中文 · 英语 · 日语 · 西班牙语 · 阿拉伯语 | ✅ | — | ✅ CUDA | — | ✅ CUDA | Bilibili 模型许可¹ |
| **IndexTTS 2** ⚡ | 多语言 | ✅ | — | ✅ CUDA | — | ✅ CUDA | Apache-2.0 |
| **OmniVoice GGUF** ⚡ | 600+ | ✅ | ✅ | ✅ CPU | ✅ CPU | ✅ CPU | 内置 |
| **Supertonic 3** ⚡ | 31 | — | — | ✅ CPU | ✅ CPU | ✅ CPU | OpenRAIL-M |
| **MOSS-TTS-v1.5** ⚡(8B | 31 | ✅ | — | ✅ CUDA/CPU | ✅ CPU | ✅ CUDA/CPU | Apache-2.0 |
| **dots.tts** ⚡(2B | 24 | ✅ | — | ✅ CUDA/CPU | ✅ CPU | ❌ | Apache-2.0 |
| **Confucius4-TTS** ⚡ | 14 | ✅ | — | ✅ CUDA/CPU | ✅ CPU | ✅ CUDA/CPU | Apache-2.0 |
¹ 若月活跃用户超过 1 亿,或年收入超过人民币 10 亿元,使用 IndexTTS 2.5
前必须另行取得 Bilibili 的书面许可。启用可选边车前,请审阅其
[模型许可](https://huggingface.co/IndexTeam/IndexTTS-2.5/blob/main/LICENSE)。
> **CUDA** = GPU 加速 · **MPS** = Apple Silicon Metal · **CPU** = 随处可运行,大模型较慢 · KittenTTS 和 MOSS-TTS-Nano 可在 CPU 上实时运行 · MLX-Audio 仅限 Apple Silicon · ⚡ = 延迟注册(首次使用时安装)
>
> **克隆**能力的意义不止于单段生成:视频配音(以及任何固定了声音的批量任务)需要参考音频克隆来保持说话人身份,因此把不支持克隆的引擎(KittenTTS、Sherpa-ONNX、Supertonic 3)设为当前引擎时,这些任务会在开始前就给出可操作的失败提示,而不是静默回退到 VoiceStudio。
@@ -288,10 +250,10 @@ Hugging Face Token 的配置见
### 🎧 ASR 引擎
**11 个引擎**——它们驱动听写、视频配音和字幕。**WhisperX** 是跨平台的默认引擎(约 100 种语言,词级时间对齐);其余引擎均为可选装并自动检测。在 **设置 → 引擎** 中切换。个完全在本地设备上运行;第十个(OpenAI 兼容)是可选的远程客户端,可用于 Qwen3-ASR 或任何兼容的服务器。
**10 个引擎**——它们驱动听写、视频配音和字幕。**WhisperX** 是跨平台的默认引擎(约 100 种语言,词级时间对齐);其余引擎均为可选装并自动检测。在 **设置 → 引擎** 中切换。个完全在本地设备上运行;第十个(OpenAI 兼容)是可选的远程客户端,可用于 Qwen3-ASR 或任何兼容的服务器。
<details>
<summary><b>📊 完整阵容</b>——11 个引擎、各自的强项与计算类型说明</summary>
<summary><b>📊 完整阵容</b>——10 个引擎、各自的强项与计算类型说明</summary>
<br/>
@@ -308,7 +270,7 @@ Hugging Face Token 的配置见
| **sherpa-onnx**(实时听写) | `sherpa-onnx-asr` | 25 种欧洲语言 + 90+ | 实时、快于实时的听写——小体积流式/离线 ONNX 模型(Parakeet TDT v3/v2、流式 Zipformer 与 Paraformer、Whisper Tiny),CPU 运行,macOS / Windows / Linux 表现完全一致。在 **设置 → 语音** 中按模型选择。 |
| **OpenAI 兼容** ⚠️ 远程 | `openai-compat-asr` | 取决于服务器 | 当下通往 **Qwen3-ASR** 的路径(自托管服务器,无需等 transformers 支持)、任何 OpenAI 兼容的转录端点,或 OpenAI 官方 API——无需安装,在 **设置 → 引擎**(ASR 标签页)中配置并测试连接。音频会离开你的设备,发送到你指定的任何服务器;参见 [docs/engines/openai-compatible-asr.md](docs/engines/openai-compatible-asr.md)。 |
> Whisper 系列引擎覆盖约 100 种语言;**FunASR / SenseVoice** 额外提供一条多语言一体化路径,内置语音活动检测与行内说话人分离。**sherpa-onnx** 驱动实时听写的模型选择器——你边说,文字边出现。除可选的 OpenAI 兼容远程客户端外,所有引擎都在本地设备上运行——无需 API 密钥,无需云端。
> Whisper 系列引擎覆盖约 100 种语言;**FunASR / SenseVoice** 额外提供一条多语言一体化路径,内置语音活动检测与行内说话人分离。**sherpa-onnx** 驱动实时听写的模型选择器——你边说,文字边出现。每个引擎都在本地设备上运行——无需 API 密钥,无需云端。
> **GPU 不支持高效 float16** 在较老的 NVIDIA GPUMaxwell/Pascal、GTX 16xx)上,或在 CTranslate2/cuDNN 版本不匹配之后,CTranslate2 系 ASR 引擎(WhisperX、Faster-Whisper)无法运行 `float16`VoiceStudio 会自动改用 `int8` 重试——无需配置。如果转录仍然失败,可用 `ASR_COMPUTE_TYPE` 环境变量固定计算类型(逃生舱口):`ASR_COMPUTE_TYPE=int8`CPU 用 `float32`)。将其设为 `int8` 并重启后端。
@@ -365,9 +327,9 @@ print(result.text)
### 📓 在 Google Colab 上运行
[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/debpalash/VoiceStudio/blob/main/notebooks/OmniVoice_Studio_Colab.ipynb)
[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/debpalash/VoiceStudio/blob/main/notebooks/VoiceStudio_Studio_Colab.ipynb)
没有本地 GPU?官方笔记本([notebooks/OmniVoice_Studio_Colab.ipynb](notebooks/OmniVoice_Studio_Colab.ipynb))可在免费的 Colab T4 上启动完整应用(包含 Web 界面):在笔记本内直接构建前端,用 uv 安装后端(复用 Colab 预装的 CUDA PyTorch),并通过 Colab 内置端口代理打开界面。无需第三方隧道,也无需任何 API 密钥。随后还有一套覆盖全部主要功能的 API 导览,全部可在笔记本内直接播放:多语言 TTS、声音克隆与声音设计、已保存的声音档案、语音转写、AI 水印检测、OpenAI 兼容 API、多角色故事、带章节的 m4b 有声书,以及一个附带人声分离音轨的迷你视频配音。
没有本地 GPU?官方笔记本([notebooks/VoiceStudio_Studio_Colab.ipynb](notebooks/VoiceStudio_Studio_Colab.ipynb))可在免费的 Colab T4 上启动完整应用(包含 Web 界面):在笔记本内直接构建前端,用 uv 安装后端(复用 Colab 预装的 CUDA PyTorch),并通过 Colab 内置端口代理打开界面。无需第三方隧道,也无需任何 API 密钥。随后还有一套覆盖全部主要功能的 API 导览,全部可在笔记本内直接播放:多语言 TTS、声音克隆与声音设计、已保存的声音档案、语音转写、AI 水印检测、OpenAI 兼容 API、多角色故事、带章节的 m4b 有声书,以及一个附带人声分离音轨的迷你视频配音。
### 🤝 智能体技能(Agent Skills
@@ -379,36 +341,6 @@ npx skills add debpalash/omnivoice-studio
内含两个 [skills](https://skills.sh)**`omnivoice`**——让任何智能体通过你的本地安装进行语音合成与转录(包括你克隆的声音),免费且离线;以及 **`oss-maintainer`**——本项目所遵循的维护者方法论,适合任何用智能体运营自己开源项目的人。
### 🔌 模型上下文协议(MCP 服务器)
VoiceStudio 在 `http://localhost:3900/mcp` 挂载了 MCP 服务,可供 Claude Desktop、Cursor 与自主智能体调用:
```json
{
"mcpServers": {
"voicestudio": {
"url": "http://localhost:3900/mcp"
}
}
}
```
对于需要 stdio 管道传输的客户端,请使用内置的本地桥接脚本(`docs/mcp.json`):
```json
{
"mcpServers": {
"voicestudio": {
"command": "python",
"args": ["-m", "backend.mcp_shim"],
"cwd": "/path/to/VoiceStudio"
}
}
}
```
支持 `generate_speech``clone_voice``transcribe` 等工具与流式文件输出模式,详见 [docs/mcp.md](docs/mcp.md)。
---
## 🗺️ 路线图
@@ -434,10 +366,10 @@ VoiceStudio 在 `http://localhost:3900/mcp` 挂载了 MCP 服务,可供 Claude
| **多语言** | 多语言批量选择器、顺序 GPU 执行的批量配音队列 |
| **说话人分离** | Pyannote 机器学习分离、自动说话人克隆提取、逐说话人声音分配 |
| **ASR** | 9 个引擎(WhisperX、Faster-Whisper、隔离版 Faster-Whisper、MLX Whisper、PyTorch Whisper、Parakeet TDT、Moonshine、FunASR/SenseVoice、sherpa-onnx 实时听写)、崩溃隔离的子进程后端 |
| **TTS** | 14 个引擎(VoiceStudio、CosyVoice 3、GPT-SoVITS、VoxCPM2、MOSS-TTS-Nano、KittenTTS、MLX-Audio、Sherpa-ONNX+ 延迟安装:IndexTTS 2.5、OmniVoice GGUF、Supertonic 3、MOSS-TTS-v1.5、dots.tts、Confucius4-TTS)、带 GPU 预检的引擎路由 |
| **TTS** | 14 个引擎(VoiceStudio、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)、带 GPU 预检的引擎路由 |
| **基础设施** | Docker 部署、CUDA/MPS/ROCm 自动检测、cuDNN 8 兼容、显存感知模型卸载、引擎路由(绝不静默回退 CPU)、诊断套件与错误日志、受限网络镜像支持 |
| **AI 溯源** | AudioSeal 不可见水印(类似 SynthID)、视频徽标叠加、水印检测 API |
| **用户体验** | 撤销/重做、键盘快捷键、拖放、会话持久化、首次启动按屏幕推荐界面缩放,以及原生 WebKitGTK 缩放 |
| **用户体验** | 撤销/重做、键盘快捷键、拖放、会话持久化、毛玻璃设计系统、Linux/WebKitGTK 的 UI 缩放修复 |
| **实时事件** | WebSocket 事件总线——数据变更时即时刷新侧边栏、指数退避重连 |
| **状态管理** | Zustand 状态迁移——`uiSlice``pillSlice``dubSlice``generateSlice``prefsSlice``glossarySlice` |
| **桌面** | 跨平台 Tauri 安装程序(macOS DMG——Apple SiliconIntel 不支持本地后端,#889——Windows MSI、Linux deb/AppImage)、自动更新基础设施、单实例约束、关闭最小化到托盘、macOS Gatekeeper 修复 |
@@ -599,13 +531,6 @@ VoiceStudio **免费**且采用 **AGPL-3.0** 许可——没有付费版,没
VoiceStudio 完全本地运行——卸载就是删除应用及其写入的文件夹(模型缓存、Python 环境、你的声音/项目、配置)。运行 <code>scripts/uninstall.sh</code>macOS/Linux)或 <code>scripts\uninstall.ps1</code>Windows)——它会先以干跑方式列出每个文件夹及其大小,加 <code>--yes</code> 才会真正删除。完整的各平台路径列表和应用移除步骤见 <a href="docs/install/uninstall.md"><b>docs/install/uninstall.md</b></a>。
</details>
## 🛡️ 负责任使用与安全
VoiceStudio 在个人硬件上提供零样本语音克隆与语音创作能力。我们提倡负责任的技术使用:
- **明确授权:** 严禁在未经说话人本人知情并明确授权的情况下克隆其声音。
- **AI 溯源:** VoiceStudio 默认集成 [AudioSeal](https://github.com/facebookresearch/audioseal) 不可见神经音频水印,在完全不影响听感音质的前提下精准标记合成语音。
- **本地隐私:** 默认本地工作流下,所有音频、声音档案、项目与转录文本始终保存在你的本地设备上;仅当你主动配置远程工作节点或第三方 ASR 端点时,相应数据才会传输到对应服务。
---
<a id="license"></a>
@@ -645,7 +570,7 @@ VoiceStudio 站在这些杰出开源工作的肩膀上:
## 🧰 来自同一作者的更多本地开源项目
喜欢这种本地优先的理念?它是一脉相承的——同一位作者,同一条准则:**你的数据只留在你的设备上。** 全部项目见 [palash.dev](https://palash.dev)。
喜欢这种本地优先的理念?它是一脉相承的——同一位作者,同一条准则:**你的数据只留在你的设备上。**
<table>
<tr>
+1 -1
View File
@@ -1,5 +1,5 @@
<div align="center">
<img src="docs/logo.png" alt="VoiceStudio Logo" width="96" height="96" />
<img src="docs/logo.png" alt="VoiceStudio Logo" width="96" />
<h1>Sponsor VoiceStudio</h1>
<p><b>Keep the open-source ElevenLabs alternative free, local, and shipping.</b></p>
</div>
-10
View File
@@ -41,16 +41,6 @@ hiddenimports = [
# even though pyproject.toml ships the package. Guarded by
# tests/test_socks_proxy.py.
'socksio',
# Remote GPU workers (backend/worker/). The feature is opt-in, so every
# import of it is deliberately deferred to the moment it is switched on —
# inside `lifespan` and inside `ControlPlane.start()`. That keeps the cost
# off users who never enable it, but it also means a frozen build has no
# static import chain to follow, so the modules must be named here or the
# feature raises ModuleNotFoundError only in the installers.
'grpc', 'grpc.aio',
'worker.service', 'worker.agent',
'worker.transport.server', 'worker.transport.client',
'worker.protocol.gen.worker_v1_pb2', 'worker.protocol.gen.worker_v1_pb2_grpc',
# Core
'uuid', 'asyncio',
+119 -121
View File
@@ -6,10 +6,7 @@ composed at the route or router level without surprises.
Currently exposed:
- `require_loopback`: 403 unless the request came from a loopback origin
(read-only bootstrap is allowed in explicit server mode; mutations still
require the admin API key see `_server_mode`).
- `require_admin`: method-aware admin gate for privileged routers.
- `require_admin_action`: strict admin gate for side-effectful GET actions.
(bypassed in explicit server mode see `_server_mode`).
- `require_native_access`: true-loopback-only access to the host filesystem;
unlike `require_loopback`, it is never bypassed by server mode.
- `ws_remote_authorized`: whether a WebSocket handshake from a non-loopback
@@ -17,19 +14,64 @@ Currently exposed:
keep their own inline loopback guards.
"""
import ipaddress
import os
import secrets
from fastapi import HTTPException, Request
from core.auth import (
CredentialTransport,
PrincipalKind,
is_local_host,
is_loopback,
principal_for,
remote_api_key,
)
from core.csrf import SAFE_HTTP_METHODS, cookie_csrf_allowed
# IPv4 + IPv6 loopback literals + the conventional `localhost` hostname.
# `request.client.host` carries an address, not a hostname, so the literal
# "localhost" entry is defensive — some upstream wrappers (TestClient with
# a custom client tuple, certain reverse-proxy headers) may pass strings
# rather than parsed addresses. We accept the broader set without weakening
# the guard: nothing here matches a non-loopback origin.
_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"})
def _trusted_networks():
"""CIDR networks from OMNIVOICE_TRUSTED_NETWORKS (comma-separated) treated as
loopback-trusted e.g. a reverse proxy or self-hosted LAN, so the API-key /
PIN gates don't block LAN clients that can't present the credential (a proxy
that strips the Authorization header). Read at call time (matching
`_server_mode` / `remote_api_key`) so tests can monkeypatch the env; restart
to apply changes in production."""
nets = []
for cidr in os.environ.get("OMNIVOICE_TRUSTED_NETWORKS", "").split(","):
cidr = cidr.strip()
if cidr:
try:
nets.append(ipaddress.ip_network(cidr, strict=False))
except ValueError:
pass # malformed entry ignored — never wedge the auth gate
return nets
def is_loopback(host):
"""True loopback address only (127.0.0.1, ::1, localhost) — NOT a trusted
network. Admin gates (``require_loopback`` ``/system/set-env``,
``/api/settings/*``) use this so a trusted-network CIDR exempts consumption
(TTS / dictation) but never the RCE-class admin surface."""
return host in _LOOPBACK_HOSTS
def is_local_host(host):
"""Loopback address, OR on a configured trusted network. The consumption
gates (PIN/API-key middleware, WS guard) call this so a trusted LAN/proxy is
exempted. Admin gates use :func:`is_loopback` NOT this to preserve the
two-tier privilege model: consumption trust admin trust."""
if is_loopback(host):
return True
try:
ip = ipaddress.ip_address(host)
except (ValueError, TypeError):
return False
# Unwrap IPv4-mapped IPv6 (::ffff:192.168.1.5) so it matches IPv4 CIDRs —
# dual-stack proxies (Caddy, Node.js) frequently pass the mapped form.
if getattr(ip, "ipv4_mapped", None):
ip = ip.ipv4_mapped
return any(ip in net for net in _trusted_networks())
_TRUTHY = frozenset({"1", "true", "yes", "on"})
@@ -54,15 +96,6 @@ def _server_mode() -> bool:
return os.environ.get("OMNIVOICE_SERVER_MODE", "").strip().lower() in _TRUTHY
def validate_server_admin_key() -> None:
"""Reject an explicitly blank key before a server-mode app starts."""
raw_key = os.environ.get("OMNIVOICE_API_KEY")
if _server_mode() and raw_key is not None and not raw_key.strip():
raise RuntimeError(
"OMNIVOICE_API_KEY is blank; configure a non-whitespace administrator key"
)
def _configured_pin(request) -> str | None:
"""The active share PIN (``app.state.network_share.pin``) or None. Read via
getattr so a bare Request stub (or a request that hit before lifespan set
@@ -74,45 +107,40 @@ def _configured_pin(request) -> str | None:
def _admin_credential_configured(request) -> bool:
"""Whether an API key or share PIN is configured.
The PIN cannot authorize admin access, but its presence means the operator
opted out of bare-server discovery. Remote admin then remains closed until
they configure and present the long API key.
"""
if remote_api_key():
"""Whether the operator has set ANY credential gate — the remote API key or
a share PIN. When neither is set, server mode leaves admin open (the Docker
issue #261 flow the image depends on)."""
if os.environ.get("OMNIVOICE_API_KEY"):
return True
return bool(_configured_pin(request))
def _request_presents_admin_credential(
request,
*,
side_effectful_get: bool = False,
) -> bool:
"""Whether the canonical principal carries remote admin capability.
def _request_presents_admin_credential(request) -> bool:
"""Whether the request carries a valid **API key** via the channels the
middleware accepts (``Authorization: Bearer`` / ``?api_key`` / ``ov_key``
cookie).
API-key and short-lived session principals may unlock server-mode admin.
PIN and trusted-network principals remain consumption-only.
"""
principal = principal_for(request)
if principal.kind not in {
PrincipalKind.API_KEY,
PrincipalKind.ADMIN_SESSION,
}:
Admin is RCE-class (``/system/set-env`` + ``/api/settings/*``), so only the
API key a long operator-chosen secret unlocks it. The 6-digit share PIN
is deliberately NOT accepted here: it is a *consumption* credential for LAN
playback and is short enough to brute-force (10^6, no lockout), so it must
never gate the admin surface (CodeRabbit #1213). A trusted-network CIDR
(``is_local_host`` also a consumption exemption) likewise never unlocks
admin. Net: remote admin in server mode requires the API key; a PIN-only
deployment keeps admin loopback-only. getattr-defensive so a minimal Request
stub never raises."""
api_key = os.environ.get("OMNIVOICE_API_KEY") or ""
if not api_key:
return False
if principal.transport not in {
CredentialTransport.COOKIE,
CredentialTransport.LEGACY_COOKIE,
}:
return True
method = str(getattr(request, "method", "GET")).upper()
if side_effectful_get or method not in SAFE_HTTP_METHODS:
return cookie_csrf_allowed(
request,
side_effectful_get=side_effectful_get,
)
return True
headers = getattr(request, "headers", None) or {}
query = getattr(request, "query_params", None) or {}
cookies = getattr(request, "cookies", None) or {}
auth = headers.get("authorization", "")
supplied = auth[7:].strip() if auth.lower().startswith("bearer ") else ""
if not supplied:
supplied = query.get("api_key") or cookies.get("ov_key") or ""
return bool(supplied and secrets.compare_digest(supplied, api_key))
def require_loopback(request: Request) -> None:
@@ -135,9 +163,9 @@ def require_loopback(request: Request) -> None:
unenforceable, so the gate can't require true loopback. It then applies the
admin-credential rule instead:
- No credential configured (no API key, no PIN) read-only requests are
open, matching the #261 Docker bootstrap flow. State-changing requests
fail closed even if a route accidentally kept this legacy dependency.
- No credential configured (no API key, no PIN) open, matching the #261
Docker flow where the operator reaches ``/system/*`` off the bridge
gateway with nothing set.
- A credential IS configured the request must present the **API key**.
This keeps the two-tier privilege model intact under server mode:
``OMNIVOICE_TRUSTED_NETWORKS`` is a *consumption* exemption
@@ -145,20 +173,14 @@ def require_loopback(request: Request) -> None:
NEVER by itself unlock the admin surface (``/system/set-env`` RCE-class
and ``/api/settings/*``). The 6-digit share PIN is a consumption credential
too and does not gate admin, so a PIN-only deployment keeps admin
loopback-only; remote admin requires the long API key. See
docs/api-auth.md (#1213).
loopback-only; remote admin requires the (long) API key. A LAN client in a
trusted CIDR or one holding only the PIN gets 403 here even though it
sails through the consumption gates. See docs/api-auth.md (#1213).
"""
host = request.client.host if request.client else None
if is_loopback(host):
return
if _server_mode():
method = str(getattr(request, "method", "GET")).upper()
if method not in SAFE_HTTP_METHODS:
# Defense in depth. Privileged routers should declare
# ``require_admin`` directly, but a missed migration must not turn
# into an unauthenticated Docker write primitive.
require_admin(request)
return
if not _admin_credential_configured(request):
return
if _request_presents_admin_credential(request):
@@ -166,31 +188,6 @@ def require_loopback(request: Request) -> None:
raise HTTPException(status_code=403, detail="loopback origin required")
def _admin_gate_403() -> None:
"""Raise the admin-gate 403 with a detail that states what would ACTUALLY
satisfy the gate. The bundled UI routes any 403 whose detail mentions
"admin api key" to the API-key login form (frontend ``client.ts``; the
literal contract is locked by ``tests/test_auth_gate_detail_lockstep.py``),
so the wording must not name a key where presenting one cannot help.
The detail names the key only when the gate would accept one: server mode
WITH an API key configured. Every other rejection desktop mode (the
credential checks in the callers only run under server mode) and a
server-mode deployment with only a share PIN or nothing configured keeps
the plain loopback detail, because only loopback can use admin there.
Naming the key in those cases would trap a LAN-share guest in a login
form that can never succeed (#1213, #1525; PR #1569 review).
"""
raise HTTPException(
status_code=403,
detail=(
"loopback origin or admin API key required"
if _server_mode() and remote_api_key()
else "loopback origin required"
),
)
def require_admin(request: Request) -> None:
"""Gate RCE/filesystem-capable admin routers.
@@ -209,30 +206,12 @@ def require_admin(request: Request) -> None:
return
if _server_mode():
method = str(getattr(request, "method", "GET")).upper()
read_only = method in SAFE_HTTP_METHODS
if read_only and not _admin_credential_configured(request):
read_only = method in {"GET", "HEAD", "OPTIONS"}
if read_only and not os.environ.get("OMNIVOICE_API_KEY", "").strip():
return
if _request_presents_admin_credential(request):
return
_admin_gate_403()
def require_admin_action(request: Request) -> None:
"""Gate an administrative action even when its HTTP method is read-only.
A small number of legacy GET endpoints have real side effects. For example,
an engine health check may spawn a sidecar process. Such routes cannot use
:func:`require_admin`'s bare-server discovery exception.
"""
host = request.client.host if request.client else None
if is_loopback(host):
return
if _server_mode() and _request_presents_admin_credential(
request,
side_effectful_get=True,
):
return
_admin_gate_403()
raise HTTPException(status_code=403, detail="loopback origin or admin API key required")
def require_desktop(request: Request) -> None:
@@ -253,10 +232,9 @@ def require_local(request: Request) -> None:
trusted network. The consumption-tier companion to :func:`require_loopback`:
use on routes a trusted-network client (LAN/proxy) should reach without a PIN
or API key e.g. the dictation model/prefs endpoints that pair with the
dictation WebSocket. Admin routes stay on :func:`require_admin`.
dictation WebSocket. Admin routes stay on :func:`require_loopback`.
In server mode this consumption gate is a no-op. Admin dependencies remain
method-aware and independent from this exemption."""
In server mode the gate is a no-op (same as :func:`require_loopback`)."""
host = request.client.host if request.client else None
if is_local_host(host):
return
@@ -278,9 +256,29 @@ def require_native_access(request: Request) -> None:
raise HTTPException(status_code=403, detail="native filesystem access requires loopback origin")
def remote_api_key() -> str | None:
"""The remote-backend bearer key (Wave 2.3), or None when remote mode is
off. Read at call time so tests can monkeypatch the env."""
return os.environ.get("OMNIVOICE_API_KEY") or None
def ws_remote_authorized(websocket) -> bool:
"""Whether the canonical WS principal has a remote admin credential."""
return principal_for(websocket).kind in {
PrincipalKind.API_KEY,
PrincipalKind.ADMIN_SESSION,
}
"""Whether a WebSocket handshake presents the remote API key.
Browser WebSockets cannot set an Authorization header, so the key may
arrive as ``?api_key=`` or via the ``ov_key`` cookie that the bearer
middleware sets on the first authenticated HTTP request. Returns False
when remote mode is off callers keep their loopback-only behavior.
"""
key = remote_api_key()
if not key:
return False
auth = websocket.headers.get("authorization", "")
supplied = auth[7:].strip() if auth.lower().startswith("bearer ") else ""
if not supplied:
supplied = (
websocket.query_params.get("api_key")
or websocket.cookies.get("ov_key")
or ""
)
return secrets.compare_digest(supplied, key)
-7
View File
@@ -49,13 +49,6 @@ def public_backends(entries: list[dict]) -> list[dict]:
item["routing_reason"] = _public_routing_reason(
item.get("routing_status"), item["routing_reason"]
)
evidence = item.get("execution_evidence")
if isinstance(evidence, dict) and evidence.get("cpu_fallback_reason") is not None:
evidence = dict(evidence)
evidence["cpu_fallback_reason"] = _public_routing_reason(
"cpu_fallback", evidence["cpu_fallback_reason"]
)
item["execution_evidence"] = evidence
safe.append(item)
return safe
+65 -400
View File
@@ -15,33 +15,22 @@ Design notes
* Previews are cached on disk keyed by a hash of (instruct, language), so two
archetypes that resolve to the same voice share a cache file and the cold
render only happens once per distinct voice.
* That same key names the pre-rendered clips in the opt-in voice gallery
(``services.gallery``), which is consulted BEFORE the engine so a fresh
install can hear voices before the 2.4 GB checkpoint finishes downloading.
Gallery files win over a local render of the same key but only for
``/preview``. ``/use`` always renders locally: the WAV it keeps in
``VOICES_DIR`` is the reference audio a cloned voice is built from, and a
downloaded MP3 must never become that.
"""
from __future__ import annotations
import hashlib
import json
import logging
import os
import re
import time
import uuid
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Body, HTTPException, Query
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import FileResponse
from core import archetypes
from core.audio_validation import is_playable_wav, resolve_regular_file
from core.config import OUTPUTS_DIR, VOICES_DIR
from services import gallery
logger = logging.getLogger("omnivoice.archetypes")
@@ -72,153 +61,6 @@ def _preview_key(a: dict) -> str:
).hexdigest()[:16]
def _design_profile_values(a: dict) -> tuple[str, str]:
"""Canonical instruct + complete picker state for a designed archetype."""
return a["instruct"], json.dumps(a["attrs"], sort_keys=True)
def _profile_audio_path(ref_audio_path: object) -> Optional[Path]:
"""Resolve only a regular, non-symlinked file inside ``VOICES_DIR``."""
return resolve_regular_file(VOICES_DIR, ref_audio_path)
def _materialized_audio_is_current(row, a: dict) -> bool:
"""Whether an existing row still has the sample described by its metadata."""
expected_filename = _profile_audio_filename(row["id"])
path = _profile_audio_path(row["ref_audio_path"])
return bool(
row["ref_audio_path"] == expected_filename
and is_playable_wav(path)
and row["instruct"] == a["instruct"]
and row["language"] == a["language"]
and row["ref_text"] == a["sample_script"]
and row["seed"] == _PREVIEW_SEED
)
def _profile_audio_filename(profile_id: str) -> str:
safe_id = (
profile_id if re.fullmatch(r"[A-Za-z0-9_-]{1,64}", profile_id or "")
else hashlib.sha256(str(profile_id).encode("utf-8")).hexdigest()[:16]
)
return f"{safe_id}.wav"
def _archetype_personality(a: dict) -> str:
return f"archetype:{a['id']}"
def _legacy_archetype_profile(conn, a: dict):
"""Adopt only a row that an older archetype materializer could have made."""
row = conn.execute(
"SELECT * FROM voice_profiles WHERE personality=? LIMIT 1",
(a["id"],),
).fetchone()
if row is None:
return None
expected_audio = _profile_audio_filename(row["id"])
try:
states_match = (
not row["vd_states"] or json.loads(row["vd_states"]) == a["attrs"]
)
except (TypeError, ValueError):
states_match = False
if (
row["ref_audio_path"] == expected_audio
and row["instruct"] == a["instruct"]
and row["language"] == a["language"]
and row["ref_text"] == a["sample_script"]
and row["seed"] == _PREVIEW_SEED
and row["kind"] in (None, "", "clone", "design")
and not row["is_locked"]
and not row["verified_own_voice"]
and states_match
):
return row
return None
def _is_materialized_archetype_row(row, a: dict) -> bool:
"""Recognize rows owned by this materializer without trusting identity text alone."""
try:
states_match = json.loads(row["vd_states"]) == a["attrs"]
except (TypeError, ValueError):
return False
return bool(
row["personality"] == _archetype_personality(a)
and row["kind"] == "design"
and row["seed"] == _PREVIEW_SEED
and row["ref_audio_path"] == _profile_audio_filename(row["id"])
and row["instruct"] == a["instruct"]
and row["language"] == a["language"]
and row["ref_text"] == a["sample_script"]
and states_match
and not row["is_locked"]
and not row["verified_own_voice"]
)
def _existing_archetype_profile(conn, a: dict):
rows = conn.execute(
"SELECT * FROM voice_profiles WHERE personality=? ORDER BY created_at, id",
(_archetype_personality(a),),
).fetchall()
owned = next((row for row in rows if _is_materialized_archetype_row(row, a)), None)
return owned if owned is not None else _legacy_archetype_profile(conn, a)
async def _render_profile_audio(
a: dict, profile_id: str, *, publish: bool = True,
) -> tuple[str, Path]:
"""Render one validated sample, optionally staging it for a later CAS."""
audio_filename = _profile_audio_filename(profile_id)
safe_id = Path(audio_filename).stem
audio_path = Path(VOICES_DIR) / audio_filename
if publish:
await _render_wav_atomic(a, audio_path, prefix=f".{safe_id}-")
else:
audio_path.parent.mkdir(parents=True, exist_ok=True)
audio_path = audio_path.parent / f".{safe_id}-{uuid.uuid4().hex}.staged.wav"
try:
await _render_archetype_wav(a, audio_path)
if not is_playable_wav(audio_path):
raise RuntimeError("the voice engine produced an invalid WAV")
except BaseException:
with __import__("contextlib").suppress(OSError):
audio_path.unlink()
raise
return audio_filename, audio_path
async def _render_wav_atomic(a: dict, out_path: Path, *, prefix: str = ".render-") -> Path:
"""Render and validate a WAV before atomically replacing *out_path*."""
audio_path = Path(out_path)
audio_path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = audio_path.parent / f"{prefix}{uuid.uuid4().hex}.wav"
try:
await _render_archetype_wav(a, tmp_path)
if not is_playable_wav(tmp_path):
raise RuntimeError("the voice engine produced an invalid WAV")
os.replace(tmp_path, audio_path)
finally:
with __import__("contextlib").suppress(OSError):
tmp_path.unlink()
return audio_path
def _heal_materialized_profile(conn, row, a: dict, audio_filename: str) -> None:
"""Repair profiles created before archetype `/use` persisted design kind."""
instruct, vd_states = _design_profile_values(a)
conn.execute(
"UPDATE voice_profiles SET kind='design', instruct=?, vd_states=?, language=?, "
"ref_text=?, seed=?, ref_audio_path=?, personality=? WHERE id=?",
(
instruct, vd_states, a["language"], a["sample_script"], _PREVIEW_SEED,
audio_filename, _archetype_personality(a), row["id"],
),
)
# A non-empty script is always required — synthesizing empty text yields
# silence. Every archetype carries a use-case script, but guard the render path
# too so a malformed archetype can never drive a blank render.
@@ -330,7 +172,7 @@ async def _render_archetype_wav(a: dict, out_path: Path) -> None:
# GPU pool and brick the backend (#730 class). Budget comes from the shared
# length-scaled helper (#1190) instead of the flat 300s default.
from services.model_manager import generate_timeout_s
_budget = generate_timeout_s(text, engine=model)
_budget = generate_timeout_s(text)
audio_tensor = await run_on_gpu_pool_guarded(
lambda: _infer(_PREVIEW_SEED), what="Archetype preview generate",
timeout=_budget)
@@ -357,60 +199,21 @@ async def _render_archetype_wav(a: dict, out_path: Path) -> None:
# Runs on the dedicated watermark pool (#1190): AudioSeal embedding is CPU
# work that holds no VRAM, so it must not occupy a GPU worker ahead of the
# next generate on 1-worker hosts.
from services.watermark import mark_synthetic_async
audio_tensor = await mark_synthetic_async(
audio_tensor, model.sampling_rate,
context="archetypes.render",
from services.watermark import mark_synthetic
from services.model_manager import get_watermark_pool
import functools
audio_tensor = await run_on_gpu_pool_guarded(
functools.partial(mark_synthetic, audio_tensor, model.sampling_rate,
context="archetypes.render"),
what="Archetype watermark",
timeout=generate_timeout_s(""),
executor=get_watermark_pool(),
)
out_path.parent.mkdir(parents=True, exist_ok=True)
_safe_torchaudio_save(str(out_path), audio_tensor, model.sampling_rate)
def _no_voice_model_downloaded() -> bool:
"""True only on a *positive* "no TTS weights on this machine" answer.
Fails open on purpose: the cache probes are best-effort (a user-managed
clone outside the HF layout is invisible to them), and telling someone with
a working engine to go download a model is worse than saying nothing. Only
a catalog we could read, with not one TTS repo cached, earns the offline
message.
"""
try:
from api.routers.setup.models import get_model_catalog, is_cached
tts = [m for m in get_model_catalog().all if m.get("role") == "TTS"]
return bool(tts) and not any(is_cached(m["repo_id"]) for m in tts)
except Exception:
return False
def _preview_source(a: dict) -> tuple[str, str]:
"""Which path ``/preview`` will take for *a*, and what to tell the user.
Replaces the old "see Settings → Logs → Backend" advice, which asked a user
who wanted to hear a voice to go read a log file. The three states that
actually differ are: we already have the audio (gallery), we can make it
(render say so, it takes a moment), and we can neither fetch nor make it
(no model the one state with an action attached).
"""
key = _preview_key(a)
if gallery.cached_preview(key) is not None:
return "gallery", (
"Pre-rendered preview from the voice gallery — a fixed reference "
"rendering, not a render from your current engine."
)
if is_playable_wav(_PREVIEW_DIR / f"{key}.wav"):
return "cached", ""
if _no_voice_model_downloaded():
return "no_model", (
"You're offline and no voice model is downloaded yet — "
"Model Catalogue → Models → Download."
)
return "rendering", "Rendering this preview on your machine — it may take a moment."
# ── Read endpoints (no model) ─────────────────────────────────────────────────
# NOTE: declare the literal `/archetypes/categories` before `/archetypes/{id}`
# so it isn't swallowed by the path-parameter route.
@@ -420,37 +223,6 @@ def list_categories():
return archetypes.categories()
# ── Voice-gallery (pre-rendered previews) ─────────────────────────────────────
# Declared above `/archetypes/{archetype_id}` for the same reason as
# `/categories`: keep literal paths out of the path-parameter route's reach.
@router.get("/archetypes/previews/status")
def preview_gallery_status():
"""Consent state, coverage and freshness for the Settings line."""
return gallery.status()
@router.put("/archetypes/previews")
async def set_preview_gallery(enabled: bool = Body(..., embed=True)):
"""Turn pre-rendered previews on or off.
Turning it ON is the user's explicit yes to an outbound call, and is the
only thing that ever starts one there is no on-install background fetch.
The featured set is pulled right here so the yes has a visible effect;
failures are silent by design (``fetch_featured`` swallows them) and leave
previews rendering locally.
"""
state = gallery.set_enabled(enabled)
if enabled:
state = await gallery.fetch_featured()
return state
@router.post("/archetypes/previews/check")
async def check_preview_gallery():
"""Manual "check now" — bypasses the 24 h throttle, never the signature."""
return await gallery.check_for_updates(force=True)
@router.get("/archetypes")
def list_archetypes_endpoint(
q: Optional[str] = None,
@@ -490,77 +262,33 @@ def get_archetype_endpoint(archetype_id: str):
# ── Render endpoints (model-gated) ────────────────────────────────────────────
@router.get("/archetypes/{archetype_id}/preview/state")
def preview_archetype_state(archetype_id: str):
"""Where the next ``/preview`` for this archetype would come from.
Touches neither the model nor the network, so a picker can label a voice
("may take a moment", "download a model first") *before* it commits to a
request that may take 40 seconds or fail.
"""
a = archetypes.get_archetype(archetype_id)
if a is None:
raise HTTPException(status_code=404, detail="Archetype not found")
source, message = _preview_source(a)
return {"source": source, "message": message}
@router.get("/archetypes/{archetype_id}/preview")
async def preview_archetype(
archetype_id: str,
local: bool = Query(False, description="Bypass gallery audio after a client decode failure"),
):
"""Serve a short preview clip — from the gallery, the cache, or the engine."""
async def preview_archetype(archetype_id: str):
"""Serve a short preview clip — pre-rendered if cached, else render once."""
a = archetypes.get_archetype(archetype_id)
if a is None:
raise HTTPException(status_code=404, detail="Archetype not found")
key = _preview_key(a)
# Gallery first, and only for /preview: these bytes are audio we can prove
# the provenance of, so they beat a local render of the same key. A miss
# (offline, disabled, key not published) is silent — we just render.
gallery_path = None if local else gallery.cached_preview(key)
if gallery_path is None and not local:
gallery_path = await gallery.fetch_preview(key)
if gallery_path is not None:
# Nothing else in the app polls, so the daily refresh hangs off the
# request that proves previews are being used. Fire-and-forget.
gallery.maybe_refresh_in_background()
return FileResponse(
str(gallery_path),
media_type="audio/mpeg",
headers={"Cache-Control": "no-cache",
"X-OmniVoice-Preview-Source": "gallery"},
)
cache_path = _PREVIEW_DIR / f"{key}.wav"
if not is_playable_wav(cache_path):
cache_path = _PREVIEW_DIR / f"{_preview_key(a)}.wav"
if not cache_path.exists():
try:
await _render_wav_atomic(a, cache_path, prefix=".preview-")
await _render_archetype_wav(a, cache_path)
except Exception as e: # model missing / OOM / inference failure
logger.error("Archetype preview render failed", exc_info=True)
# Two different failures, two different answers. Without a model
# there is nothing to read in a log — there is something to do.
if _no_voice_model_downloaded():
detail = (
"You're offline and no voice model is downloaded yet — "
"Model Catalogue → Models → Download. (Or turn on pre-rendered "
"voice previews in Model Catalogue → Models.)"
)
else:
detail = (
"Couldn't render a preview right now — the voice engine "
f"reported: {e}"
)
raise HTTPException(status_code=503, detail=detail)
raise HTTPException(
status_code=503,
detail=(
"Couldn't render a preview right now — the voice engine is "
f"unavailable. See Settings → Logs → Backend. Error: {e}"
),
)
# no-cache (not no-store): the URL is stable but its bytes change when an
# archetype's preview is re-rendered, so force the client to revalidate
# against the ETag instead of serving a stale cached clip indefinitely.
return FileResponse(
str(cache_path),
media_type="audio/wav",
headers={"Cache-Control": "no-cache",
"X-OmniVoice-Preview-Source": "local"},
headers={"Cache-Control": "no-cache"},
)
@@ -572,11 +300,6 @@ async def use_archetype(archetype_id: str, name: Optional[str] = Query(None)):
preview) and inserts a ``voice_profiles`` row carrying the archetype's
instruct + language. The profile then shows up everywhere voices are
picked (Dub / Generate / Clone).
Never sourced from the voice gallery, no matter how cheap that would be:
this WAV lands in ``VOICES_DIR`` as the profile's reference audio, so a
downloaded, lossily-encoded MP3 would silently become the sample every
future clone of this voice is built from. It renders locally or it fails.
"""
a = archetypes.get_archetype(archetype_id)
if a is None:
@@ -588,122 +311,64 @@ async def use_archetype(archetype_id: str, name: Optional[str] = Query(None)):
# Idempotent (dedup): an archetype materializes to exactly ONE voice profile.
# Picking the same gallery voice again — from any picker (Gallery grid,
# VoiceSelector, …) — must reuse that one row instead of rendering + inserting
# a fresh duplicate every time. Use a namespaced personality identity so an
# imported persona cannot collide with and be rewritten by an archetype id.
# a fresh duplicate every time. The `personality` column already carries the
# source archetype id (stamped by the INSERT below), so it's the natural
# dedup key; the expensive render + INSERT only run on first use.
with db_conn() as conn:
existing = _existing_archetype_profile(conn, a)
profile_id = existing["id"] if existing is not None else str(uuid.uuid4())[:8]
audio_path: Optional[Path] = None
if existing is not None and _materialized_audio_is_current(existing, a):
audio_filename = existing["ref_audio_path"]
else:
try:
audio_filename, audio_path = await _render_profile_audio(
a, profile_id, publish=existing is None,
)
except Exception as e:
logger.error("Archetype 'use' render failed", exc_info=True)
# Same actionable/diagnostic split as /preview — minus the gallery
# suggestion, which cannot help here.
if _no_voice_model_downloaded():
detail = (
"Creating a voice needs the voice model — no voice model is "
"downloaded yet. Model Catalogue → Models → Download."
)
else:
detail = (
"Couldn't create a voice from this archetype — the voice engine "
f"reported: {e}"
)
raise HTTPException(status_code=503, detail=detail) from e
existing = conn.execute(
"SELECT id, name FROM voice_profiles WHERE personality = ? LIMIT 1",
(a["id"],),
).fetchone()
if existing is not None:
with db_conn() as conn:
conn.execute("BEGIN IMMEDIATE")
current = conn.execute(
"SELECT * FROM voice_profiles WHERE id=?", (existing["id"],),
).fetchone()
owned = _existing_archetype_profile(conn, a)
still_owned = current is not None and (
owned is not None and owned["id"] == current["id"]
)
if still_owned:
if audio_path is not None:
destination = Path(VOICES_DIR) / audio_filename
os.replace(audio_path, destination)
audio_path = None
_heal_materialized_profile(conn, current, a, audio_filename)
existing_result = {"profile_id": current["id"], "name": current["name"]}
else:
existing_result = None
if existing_result is not None:
event_bus.emit("profiles", {"action": "updated", "id": existing_result["profile_id"]})
return existing_result
# The row was edited/deleted while rendering. Preserve it and use the
# validated staged sample for a fresh canonical materialization.
profile_id = str(uuid.uuid4())[:8]
audio_filename = _profile_audio_filename(profile_id)
destination = Path(VOICES_DIR) / audio_filename
if audio_path is None:
try:
audio_filename, audio_path = await _render_profile_audio(a, profile_id)
except Exception as e:
raise HTTPException(
status_code=503, detail="Couldn't create a voice from this archetype.",
) from e
else:
os.replace(audio_path, destination)
audio_path = destination
return {"profile_id": existing["id"], "name": existing["name"]}
if audio_path is None: # defensive: a new profile always rendered above
raise RuntimeError("new archetype profile has no rendered audio")
profile_id = str(uuid.uuid4())[:8]
audio_filename = f"{profile_id}.wav"
audio_path = Path(VOICES_DIR) / audio_filename
try:
await _render_archetype_wav(a, audio_path)
except Exception as e:
logger.error("Archetype 'use' render failed", exc_info=True)
raise HTTPException(
status_code=503,
detail=(
"Couldn't create a voice from this archetype — the voice engine "
f"is unavailable. See Settings → Logs → Backend. Error: {e}"
),
)
profile_name = (name or a["name"]).strip() or a["name"]
try:
with db_conn() as conn:
conn.execute("BEGIN IMMEDIATE")
# Re-check under the write connection right before inserting: a
# concurrent /use for the same archetype may have inserted while we
# were rendering (the pre-render SELECT above raced). Reuse that row
# and drop our just-rendered sample instead of creating a duplicate.
# `personality` is not globally UNIQUE, so serialize and re-check.
dup = _existing_archetype_profile(conn, a)
# (personality is NOT globally unique — marketplace/persona imports
# reuse the column — so a UNIQUE index isn't an option; this closes
# the realistic window for the single-user desktop app.)
dup = conn.execute(
"SELECT id, name FROM voice_profiles WHERE personality = ? LIMIT 1",
(a["id"],),
).fetchone()
if dup is not None:
duplicate_audio = dup["ref_audio_path"]
if not _materialized_audio_is_current(dup, a):
duplicate_audio = _profile_audio_filename(dup["id"])
_duplicate_path = Path(VOICES_DIR) / duplicate_audio
_duplicate_path.parent.mkdir(parents=True, exist_ok=True)
os.replace(audio_path, _duplicate_path)
audio_path = None
_heal_materialized_profile(conn, dup, a, duplicate_audio)
with __import__("contextlib").suppress(OSError):
if audio_path is not None:
os.remove(audio_path)
duplicate_result = {"profile_id": dup["id"], "name": dup["name"]}
else:
duplicate_result = None
if duplicate_result is None:
instruct, vd_states = _design_profile_values(a)
conn.execute(
"INSERT INTO voice_profiles "
"(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, "
"created_at, kind, vd_states) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'design', ?)",
(
profile_id, profile_name, audio_filename, a["sample_script"],
instruct, a["language"], _PREVIEW_SEED,
_archetype_personality(a), time.time(), vd_states,
),
)
os.remove(audio_path)
return {"profile_id": dup["id"], "name": dup["name"]}
conn.execute(
"INSERT INTO voice_profiles "
"(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
profile_id, profile_name, audio_filename, a["sample_script"],
a["instruct"], a["language"], _PREVIEW_SEED, a["id"], time.time(),
),
)
except Exception:
with __import__("contextlib").suppress(OSError):
if audio_path is not None:
os.remove(audio_path)
os.remove(audio_path)
raise
if duplicate_result is not None:
event_bus.emit("profiles", {"action": "updated", "id": duplicate_result["profile_id"]})
return duplicate_result
event_bus.emit("profiles", {"action": "created", "id": profile_id})
return {"profile_id": profile_id, "name": profile_name}
+30 -137
View File
@@ -25,7 +25,6 @@ import json
import logging
import os
import re
import shutil
import uuid
from collections.abc import Awaitable, Callable
@@ -361,7 +360,7 @@ LONGFORM_NUM_STEP = 32
LONGFORM_GUIDANCE_SCALE = 2.0
def _seed_segment_rng(base_seed, text: str, nonce: int = 0) -> int | None:
def _seed_segment_rng(base_seed, text: str, nonce: int = 0) -> None:
"""Apply a profile's pinned seed to this synth call (#1139).
``_resolve_voice`` has always fetched the profile ``seed`` but only the
@@ -380,13 +379,11 @@ def _seed_segment_rng(base_seed, text: str, nonce: int = 0) -> int | None:
must cover /generate and here together, not one path.
"""
if base_seed is None:
return None
return
import torch
from services.audiobook import segment_seed
seed = segment_seed(base_seed, text, nonce)
torch.manual_seed(seed)
return seed
torch.manual_seed(segment_seed(base_seed, text, nonce))
def _base_seed(opts: ExpressiveOptions, voice: dict):
@@ -510,21 +507,16 @@ def _build_synth(
"get_model": get_model, "language": language, "opts": opts}
backend = cls()
native_proxy = bool(getattr(cls, "supports_native_omnivoice_controls", False))
extra = (_omnivoice_sampling_kwargs(opts) if native_proxy
else _generic_extra_kwargs(opts))
extra = _generic_extra_kwargs(opts)
next_nonce = _make_occ_counter(opts)
def synth(text, voice_id, speed=None):
v = resolve(voice_id)
seed = _seed_segment_rng(_base_seed(opts, v), text, next_nonce())
call_extra = dict(extra)
if native_proxy and seed is not None:
call_extra["seed"] = seed
_seed_segment_rng(_base_seed(opts, v), text, next_nonce())
return backend.generate(
text, language=language, ref_audio=v["ref_audio"],
ref_text=v["ref_text"], instruct=v["instruct"], duration=None,
speed=float(speed) if speed else 1.0, **call_extra,
speed=float(speed) if speed else 1.0, **extra,
)
return {"mode": "generic", "resolve": resolve, "engine_id": engine_id,
"synth": synth, "sample_rate": backend.sample_rate}
@@ -689,87 +681,6 @@ def _render_chapter_cached(chapter, synth, sr, engine_id, resolve, cache_dir, le
"cached": seg_cache.hits}
def _remote_chapter_call(chapter, *, engine_id, default_voice, voice_map,
language, lexicon, opts, cache_dir):
"""Build one opaque remote chapter task without loading a local TTS model."""
import hashlib
from services import gpu_gateway
from services.text_normalization import normalize_for_tts
from services.watermark import is_enabled as watermark_enabled
rows, voices, refs = [], [], []
for span in chapter.spans:
profile_id = _map_span_voice(span.voice_id, default_voice, voice_map)
voice = _resolve_voice(profile_id)
rows.append({
"text": normalize_for_tts(span.text, language),
"pause_ms_after": span.pause_ms_after,
"speed": getattr(span, "speed", None),
})
refs.append(voice.get("ref_audio"))
voices.append({
"ref_text": voice.get("ref_text"), "instruct": voice.get("instruct"),
"seed": voice.get("seed"),
})
params = {
"spans": rows, "voices": voices, "ref_audio": refs,
"language": language, "lexicon": lexicon,
"expressive": opts.to_manifest(), "watermark": bool(watermark_enabled()),
}
signature = hashlib.sha256(json.dumps(params, sort_keys=True, default=str).encode()).hexdigest()
wav_path = os.path.join(cache_dir, f"remote-{signature}.wav")
def decode(result):
import soundfile as sf
if not os.path.exists(wav_path):
partial = f"{wav_path}.part"
shutil.copyfile(result.path, partial)
os.replace(partial, wav_path)
info = sf.info(wav_path)
return wav_path, float(info.duration), False, None
return gpu_gateway.RemoteCall(
engine=engine_id, operation="audiobook", params=params,
idempotency_key=f"audiobook:{signature}", decode=decode,
), wav_path
async def _run_chapter(chapter, *, operation="audiobook", decision, job, default_voice, language, opts,
voice_map, lexicon, cache_dir):
"""Run one chapter through the gateway; local preparation stays lazy."""
from services import gpu_gateway
from services.tts_backend import active_backend_id
engine_id = active_backend_id()
remote, remote_cache = _remote_chapter_call(
chapter, engine_id=engine_id, default_voice=default_voice,
voice_map=voice_map, language=language, lexicon=lexicon,
opts=opts, cache_dir=cache_dir,
)
if decision.remote and os.path.exists(remote_cache):
import soundfile as sf
info = sf.info(remote_cache)
return remote_cache, float(info.duration), True, None
async def prepare_local():
synth, sr, resolve, local_engine = await _prepare_synth(
default_voice, language=language, opts=opts, voice_map=voice_map
)
return gpu_gateway.LocalCall(
fn=lambda: _render_chapter_cached(
chapter, synth, sr, local_engine, resolve, cache_dir, lexicon,
language, opts, voice_map,
),
what="Audiobook chapter",
)
return await gpu_gateway.run(
operation, local=gpu_gateway.LocalCall(prepare=prepare_local),
remote=remote, decision=decision, job=job,
)
class AudiobookPreviewRequest(ExpressiveMixin):
text: str
chapter_index: int = 0
@@ -789,7 +700,7 @@ async def audiobook_preview(req: AudiobookPreviewRequest) -> dict:
cache (the later full render reuses it) and a re-preview is instant.
"""
from core.config import OUTPUTS_DIR
from services import gpu_gateway
from services.model_manager import _gpu_pool
plan = parse_audiobook_script(req.text, default_voice=req.default_voice)
if not plan.chapters:
@@ -803,11 +714,16 @@ async def audiobook_preview(req: AudiobookPreviewRequest) -> dict:
os.makedirs(cache_dir, exist_ok=True)
resolved_lang = _resolve_default_language(req.language, req.default_voice)
opts = _expressive_opts(req)
decision = gpu_gateway.decide("audiobook")
wav_path, dur, was_cached, _seg_stats = await _run_chapter(
chapter, decision=decision, job=None, default_voice=req.default_voice,
language=resolved_lang, opts=opts, voice_map=req.voice_map,
lexicon=req.lexicon, cache_dir=cache_dir,
synth, sr, resolve, engine_id = await _prepare_synth(
req.default_voice,
language=resolved_lang,
opts=opts,
voice_map=req.voice_map,
)
loop = asyncio.get_running_loop()
wav_path, dur, was_cached, _seg_stats = await loop.run_in_executor(
_gpu_pool, _render_chapter_cached, chapter, synth, sr, engine_id, resolve, cache_dir,
req.lexicon, resolved_lang, opts, req.voice_map,
)
return {
"output": os.path.relpath(wav_path, OUTPUTS_DIR), # served via /audio
@@ -846,7 +762,7 @@ async def _render_longform_sse(
from core.config import OUTPUTS_DIR
from core.failure import build_failure, build_failure_event
from services.ffmpeg_utils import find_ffmpeg, run_ffmpeg
from services import gpu_gateway
from services.model_manager import _gpu_pool
opts = opts or ExpressiveOptions()
@@ -921,11 +837,13 @@ async def _render_longform_sse(
cache_dir = os.path.join(OUTPUTS_DIR, "longform_cache")
os.makedirs(cache_dir, exist_ok=True)
prune_cache_dir(cache_dir) # bound disk before this job adds its chapters
loop = asyncio.get_running_loop()
try:
resolved_lang = _resolve_default_language(language, default_voice)
operation = "audiobook" if job_type == "audiobook" else "longform"
decision = gpu_gateway.decide(operation)
chapter_run = gpu_gateway.JobRun(operation)
synth, sr, resolve, engine_id = await _prepare_synth(
default_voice, language=resolved_lang, opts=opts, voice_map=voice_map
)
total = len(plan.chapters)
chapter_files: list[str] = []
@@ -959,11 +877,10 @@ async def _render_longform_sse(
interrupted = True
break
try:
wav_path, dur, was_cached, seg_stats = await _run_chapter(
chapter, operation=operation, decision=decision, job=chapter_run,
default_voice=default_voice, language=resolved_lang,
opts=opts, voice_map=voice_map, lexicon=lexicon,
cache_dir=cache_dir,
wav_path, dur, was_cached, seg_stats = await loop.run_in_executor(
_gpu_pool, _render_chapter_cached,
chapter, synth, sr, engine_id, resolve, cache_dir, lexicon,
resolved_lang, opts, voice_map,
)
except Exception as e: # isolate a bad chapter — keep going
logger.warning("[%s] chapter %d (%s) failed to render",
@@ -1001,11 +918,6 @@ async def _render_longform_sse(
ev["cached_segments"] = seg_stats["cached"]
yield _emit(ev)
route_notice = chapter_run.notice()
if route_notice is not None:
yield _emit({"type": "routing_notice", "status": route_notice[0],
"reason": route_notice[1]})
if interrupted:
logger.info("[%s] client disconnected — stopped after %d/%d chapters",
job_id, len(chapter_files), total)
@@ -1121,25 +1033,6 @@ async def _render_longform_sse(
yield _emit({"type": "error", "error": "render failed (see backend log)"})
async def _public_longform_stream(plan, **render_kwargs):
"""Keep generator diagnostics local if setup fails before its own guard."""
try:
async for event in _render_longform_sse(plan, **render_kwargs):
yield event
except asyncio.CancelledError:
raise
except Exception as exc:
from core.public_errors import public_failure
error = public_failure(
logger,
"Longform response stream failed",
exc,
response="Render failed; check the backend log for details.",
)
yield f"data: {json.dumps({'type': 'error', 'error': error})}\n\n"
@router.post("/audiobook")
async def audiobook_synthesize(req: AudiobookRequest, request: Request = None):
"""Synthesize a chapterized audiobook from a script, streaming SSE progress."""
@@ -1148,7 +1041,7 @@ async def audiobook_synthesize(req: AudiobookRequest, request: Request = None):
# to a direct in-process call, e.g. a unit test); its disconnect poll is what
# lets Stop cancel the render mid-book (#1216).
return StreamingResponse(
_public_longform_stream(
_render_longform_sse(
plan, default_voice=req.default_voice, language=req.language,
fmt=req.format, bitrate=req.bitrate,
loudness=req.loudness, cover_path=req.cover_path, metadata=req.metadata,
@@ -1209,7 +1102,7 @@ async def longform_render(req: LongformRenderRequest, request: Request = None):
chapters.append(Chapter(title=c.title or f"Chapter {i + 1}", spans=spans))
plan = AudiobookPlan(chapters=chapters)
return StreamingResponse(
_public_longform_stream(
_render_longform_sse(
plan, default_voice=req.default_voice, language=req.language,
fmt=req.format, bitrate=req.bitrate,
loudness=req.loudness, cover_path=req.cover_path, metadata=req.metadata,
@@ -1302,7 +1195,7 @@ async def resume_longform(job_id: str, request: Request = None):
# unrendered ones synthesize. Using a fresh id means the request's job_id
# never names a work dir / output file (defence-in-depth path-injection).
return StreamingResponse(
_public_longform_stream(
_render_longform_sse(
plan, default_voice=p.get("default_voice"), language=p.get("language"),
fmt=p.get("fmt", "m4b"), bitrate=p.get("bitrate", "128k"),
loudness=p.get("loudness"), cover_path=p.get("cover_path"),
-231
View File
@@ -1,231 +0,0 @@
"""Short-lived credentials for the first-party remote administration UI."""
from __future__ import annotations
import math
import threading
import time
from collections import OrderedDict, deque
from collections.abc import Callable
from datetime import UTC, datetime
from typing import Literal
from fastapi import APIRouter, HTTPException, Request, Response
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from core.auth import (
CredentialTransport,
PrincipalKind,
authorization_credential_present,
legacy_master_cookie_valid,
master_header_valid,
principal_for,
remote_api_key,
)
from core.csrf import cookie_csrf_allowed, effective_scheme
from services.admin_sessions import (
SESSION_TTL_SECONDS,
WS_TICKET_TTL_SECONDS,
admin_session_store,
)
router = APIRouter(prefix="/api/auth", tags=["auth"])
_FAILED_EXCHANGE_LIMIT = 10
_FAILED_EXCHANGE_WINDOW_SECONDS = 60
_MAX_TRACKED_CLIENTS = 1024
class _ExchangeAttemptLimiter:
"""Bounded per-client sliding window for failed pre-auth exchanges."""
def __init__(
self,
*,
monotonic: Callable[[], float] = time.monotonic,
limit: int = _FAILED_EXCHANGE_LIMIT,
window_seconds: int = _FAILED_EXCHANGE_WINDOW_SECONDS,
max_clients: int = _MAX_TRACKED_CLIENTS,
) -> None:
if limit <= 0 or window_seconds <= 0 or max_clients <= 0:
raise ValueError("rate-limit bounds must be positive")
self._monotonic = monotonic
self._limit = limit
self._window_seconds = window_seconds
self._max_clients = max_clients
self._attempts: OrderedDict[str, deque[float]] = OrderedDict()
self._lock = threading.Lock()
def register_failure(self, client_id: str) -> int | None:
now = self._monotonic()
cutoff = now - self._window_seconds
with self._lock:
failures = self._attempts.setdefault(client_id, deque())
while failures and failures[0] <= cutoff:
failures.popleft()
self._attempts.move_to_end(client_id)
while len(self._attempts) > self._max_clients:
self._attempts.popitem(last=False)
if len(failures) >= self._limit:
return max(
1,
math.ceil(self._window_seconds - (now - failures[0])),
)
failures.append(now)
return None
def clear(self, client_id: str) -> None:
with self._lock:
self._attempts.pop(client_id, None)
def reset(self) -> None:
with self._lock:
self._attempts.clear()
_exchange_attempt_limiter = _ExchangeAttemptLimiter()
class SessionRequest(BaseModel):
transport: Literal["cookie", "bearer"]
class WebSocketTicketRequest(BaseModel):
path: str
def _secure_cookie(request: Request) -> bool:
# Same effective-scheme logic as the exact-origin CSRF check: the resolved
# scope first (uvicorn's trusted-proxy rewrite), upgraded — never
# downgraded — by X-Forwarded-Proto for TLS-terminating proxies uvicorn
# doesn't trust (Tailscale Serve into Docker, etc.). Spoofing the header on
# a plain-http hop can only ADD the Secure flag, which fails safe: the
# browser drops such a cookie, so the spoofer only breaks their own
# session. See core.csrf.effective_scheme for the full analysis.
return effective_scheme(request) == "https"
def _set_session_cookie(response: Response, request: Request, token: str, expires_at: float) -> None:
response.set_cookie(
"ov_session",
token,
max_age=SESSION_TTL_SECONDS,
expires=datetime.fromtimestamp(expires_at, tz=UTC),
path="/",
secure=_secure_cookie(request),
httponly=True,
samesite="strict",
)
def _expire_cookie(response: Response, request: Request, name: str) -> None:
response.delete_cookie(
name,
path="/",
secure=_secure_cookie(request),
httponly=name == "ov_session",
samesite="strict",
)
def _client_id(request: Request) -> str:
host = request.client.host if request.client else "unknown"
return str(host).strip().lower()[:255] or "unknown"
def _reject_master_exchange(request: Request) -> None:
retry_after = _exchange_attempt_limiter.register_failure(_client_id(request))
if retry_after is not None:
raise HTTPException(
status_code=429,
detail="Too many authentication attempts",
headers={"Retry-After": str(retry_after)},
)
raise HTTPException(status_code=401, detail="API key required")
@router.post("/session")
def create_session(payload: SessionRequest, request: Request) -> Response:
configured = remote_api_key()
if not configured:
raise HTTPException(status_code=401, detail="API key required")
authorization_present = authorization_credential_present(request)
header_authorized = master_header_valid(request)
legacy_authorized = legacy_master_cookie_valid(request)
migrating_legacy = False
if authorization_present:
if not header_authorized:
_reject_master_exchange(request)
elif legacy_authorized:
if payload.transport != "cookie" or not cookie_csrf_allowed(request):
raise HTTPException(status_code=403, detail="browser origin rejected")
migrating_legacy = True
else:
_reject_master_exchange(request)
_exchange_attempt_limiter.clear(_client_id(request))
issued = admin_session_store.issue(configured)
if payload.transport == "bearer":
return JSONResponse(
{
"token": issued.token,
"expires_at": issued.expires_at,
"expires_in": SESSION_TTL_SECONDS,
},
status_code=201,
)
response = Response(status_code=204)
_set_session_cookie(response, request, issued.token, issued.expires_at)
if migrating_legacy or request.cookies.get("ov_key"):
_expire_cookie(response, request, "ov_key")
return response
@router.delete("/session", status_code=204)
def delete_session(request: Request) -> Response:
principal = principal_for(request)
if principal.kind is PrincipalKind.ADMIN_SESSION:
if (
principal.transport is CredentialTransport.COOKIE
and not cookie_csrf_allowed(request)
):
raise HTTPException(status_code=403, detail="browser origin rejected")
admin_session_store.revoke_by_credential(principal.credential_id)
response = Response(status_code=204)
_expire_cookie(response, request, "ov_session")
return response
@router.post("/ws-ticket")
def create_ws_ticket(payload: WebSocketTicketRequest, request: Request) -> JSONResponse:
principal = principal_for(request)
if principal.kind is not PrincipalKind.ADMIN_SESSION:
raise HTTPException(status_code=403, detail="admin session required")
if (
principal.transport is CredentialTransport.COOKIE
and not cookie_csrf_allowed(request)
):
raise HTTPException(status_code=403, detail="browser origin rejected")
try:
ticket = admin_session_store.issue_ws_ticket_for_credential(
principal.credential_id,
payload.path,
remote_api_key(),
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from None
except PermissionError:
raise HTTPException(status_code=401, detail="admin session required") from None
return JSONResponse(
{
"ticket": ticket.token,
"expires_at": ticket.expires_at,
"expires_in": WS_TICKET_TTL_SECONDS,
},
status_code=201,
)
+19 -213
View File
@@ -103,93 +103,6 @@ def _set_progress(job, stage, percent=0, **extra):
job["progress"] = {"stage": stage, "percent": percent, **extra}
#: Override for the native dub batch width. Set to 1 to disable batching.
BATCH_WIDTH_ENV = "OMNIVOICE_DUB_BATCH_WIDTH"
#: Hard ceiling on the override — a batch this wide is already amortizing
#: almost all of the per-call setup, and beyond it the failure mode is an OOM
#: that costs more than the saving.
_MAX_BATCH_WIDTH = 16
# Bound each allocation while persisting multipart uploads. Video inputs can
# be many gigabytes; `await UploadFile.read()` with no size used to mirror the
# entire file in process memory before writing it back out.
_UPLOAD_CHUNK_BYTES = 1024 * 1024
async def _save_upload(upload: UploadFile, destination: str) -> None:
try:
with open(destination, "wb") as output:
while chunk := await upload.read(_UPLOAD_CHUNK_BYTES):
output.write(chunk)
except BaseException:
try:
unlink_if_present(destination)
except FileCleanupError:
logger.warning("Could not remove incomplete batch upload", exc_info=True)
raise
def _native_batch_width(backend) -> int:
"""How many segments to render in one native batch on THIS host.
A native batch widens the forward pass, so the width cannot be a constant.
The default engine declares ``min_vram_gb = 6.0`` for a SINGLE job; an
unconditional 8-wide batch would OOM the 4-8 GB CUDA cards and the MPS
Macs where the per-segment path succeeds today turning a throughput
optimization into a regression on exactly the hardware that already
struggles (#1616 is a 4 GB card reporting capacity failures). Default
behaviour must not get riskier on a host, so the width is derived from
measured headroom and falls back to 1 (no batching) when unknown.
CPU hosts get 1: batching there buys no kernel amortization and only
multiplies peak RAM.
"""
override = os.environ.get(BATCH_WIDTH_ENV, "").strip()
if override:
try:
return max(1, min(_MAX_BATCH_WIDTH, int(override)))
except (TypeError, ValueError):
logger.warning(
"%s=%r is not an integer — deriving the batch width from the host instead.",
BATCH_WIDTH_ENV, override,
)
try:
from core.device_caps import detect_host_caps
caps = detect_host_caps()
except Exception: # noqa: BLE001 — an unprobeable host takes the safe path
return 1
if caps.family == "cpu" or not caps.vram_gb:
return 1
headroom = caps.vram_gb - float(getattr(backend, "min_vram_gb", 0.0) or 0.0)
if headroom < 2.0:
return 1
if headroom < 6.0:
return 2
if headroom < 12.0:
return 4
return 8
def _batch_timeout_s(texts: list[str], backend) -> float:
"""Execution budget for one native batch.
Not the sum of the per-item budgets: ``generate_timeout_s`` returns a
floor (300s GPU / 600s CPU) plus per-length overage, so summing it across
eight items yields a ~2400s budget and a wedged batch would hold a
GPU-pool worker for forty minutes before the reset this file depends on
(#730). One floor covers wedge detection for the whole call; only the
length-driven overage is genuinely additive.
"""
from services.model_manager import generate_timeout_s
floor = generate_timeout_s("", engine=backend)
overage = sum(
max(0.0, generate_timeout_s(text, engine=backend) - floor) for text in texts
)
return floor + overage
async def _run_batch_pipeline(job_id: str, job: dict):
"""Full batch dub pipeline: extract → transcribe → translate → generate → mix → export."""
import subprocess
@@ -238,17 +151,14 @@ async def _run_batch_pipeline(job_id: str, job: dict):
# ── 2. Transcribe ─────────────────────────────────────────────────
_set_progress(job, "transcribe", 0)
from services.asr_backend import load_active_asr_backend
from services.asr_backend import get_active_asr_backend
from services.model_manager import _gpu_pool, _cpu_pool, run_on_gpu_pool_guarded
from services.segmentation import (
segment_transcript, assign_speakers_heuristic,
)
def _transcribe():
# `load_*`, not `get_*`: the plain selector returns engines whose
# shallow probe passed but whose deep import chain is broken, failing
# the whole batch job at `.transcribe()` instead of degrading (#1185).
backend = load_active_asr_backend()
backend = get_active_asr_backend()
result = backend.transcribe(audio_path, word_timestamps=True)
detected_lang = result.get("language", "en")
segments = segment_transcript(result, duration=duration)
@@ -281,7 +191,7 @@ async def _run_batch_pipeline(job_id: str, job: dict):
# ── Engine resolution (issue #312 class) ────────────────────────────
# Batch used to hardcode VoiceStudio via get_model() regardless of the
# engine selected in Model Catalogue → Engines. require_cloning only when a
# engine selected in Settings → Engines. require_cloning only when a
# specific voice is pinned (job["voice_id"]) — an unpinned job is fine on
# any active engine. Resolved ONCE for the whole job (every language
# below shares the same active engine); an uncaught ValueError here
@@ -366,111 +276,6 @@ async def _run_batch_pipeline(job_id: str, job: dict):
full_audio = torch.zeros(1, total_samples)
total_segs = len(translated_segments)
# Native engines can amortize encoder/decoder setup across a small
# batch. Keep the adapter seam optional: engines without a real batch
# implementation inherit TTSBackend.generate_batch(), which preserves
# the established one-segment behavior below.
from services.tts_backend import TTSBackend
batched_audio: dict[int, torch.Tensor] = {}
has_native_batch = type(backend).generate_batch is not TTSBackend.generate_batch
if has_native_batch:
from services.text_normalization import normalize_for_tts
batch_ref_audio = None
batch_ref_text = None
if job.get("voice_id"):
from core.db import db_conn
from core.config import VOICES_DIR as _VD
with db_conn() as conn:
row = conn.execute(
"SELECT * FROM voice_profiles WHERE id=?",
(job["voice_id"],),
).fetchone()
if row:
if row["is_locked"] and row["locked_audio_path"]:
batch_ref_audio = os.path.join(_VD, row["locked_audio_path"])
elif row["ref_audio_path"]:
batch_ref_audio = os.path.join(_VD, row["ref_audio_path"])
batch_ref_text = row["ref_text"]
batch_width = _native_batch_width(backend)
async def _prefetch_batch(first_index: int) -> None:
"""Render the batch beginning at ``first_index`` into
``batched_audio``.
Rendered on demand rather than prerendering the whole track:
the tensors are popped as they are placed, so peak host memory
is one batch instead of every segment of the language and
the progress bar tracks placement instead of running to the
end and restarting at segment 1.
"""
if job["status"] == "cancelled":
return
batch_rows = []
index = first_index
while index < total_segs and len(batch_rows) < batch_width:
seg = translated_segments[index]
if (seg.get("end", 0) - seg.get("start", 0) > 0.05
and seg.get("text", "").strip()):
batch_rows.append((index, seg))
index += 1
if len(batch_rows) < 2:
return # nothing to amortize — the per-segment path is equal
batch_indices = [index for index, _ in batch_rows]
batch_texts = [
normalize_for_tts(row.get("text", "").strip(), target_lang)
for _, row in batch_rows
]
batch_durations = [
row.get("end", 0) - row.get("start", 0)
for _, row in batch_rows
]
def _render_native_batch():
generated = backend.generate_batch(
batch_texts,
language=target_lang,
ref_audio=batch_ref_audio,
ref_text=batch_ref_text,
duration=batch_durations,
num_step=16,
guidance_scale=2.0,
speed=1.0,
denoise=True,
postprocess_output=True,
)
if len(generated) != len(batch_indices):
raise RuntimeError(
f"native batch returned {len(generated)} outputs for "
f"{len(batch_indices)} segments"
)
rendered = []
for audio_out in generated:
if not getattr(backend, "applies_own_mastering", False):
audio_out = apply_mastering(audio_out, sample_rate=sr)
rendered.append(normalize_audio(audio_out, target_dBFS=-2.0))
return rendered
try:
rendered = await run_on_gpu_pool_guarded(
_render_native_batch,
what="Batch generate",
timeout=_batch_timeout_s(batch_texts, backend),
)
batched_audio.update(zip(batch_indices, rendered))
except TimeoutError:
# Do not immediately queue the same expensive work again:
# the timed-out pool task may still be holding the device.
raise
except Exception as e:
logger.warning(
"Native TTS batch failed for segments %s-%s; falling back per segment: %s",
batch_indices[0] + 1,
batch_indices[-1] + 1,
e,
)
for i, seg in enumerate(translated_segments):
if job["status"] == "cancelled":
return
@@ -548,15 +353,10 @@ async def _run_batch_pipeline(job_id: str, job: dict):
# Budget is the shared length-scaled one (#1190): a long segment
# on CPU-class hardware no longer dies on the flat 300s.
from services.model_manager import generate_timeout_s
if has_native_batch and i not in batched_audio:
await _prefetch_batch(i)
if i in batched_audio:
audio_tensor = batched_audio.pop(i)
else:
audio_tensor = await run_on_gpu_pool_guarded(
_gen, what="Batch generate",
timeout=generate_timeout_s(seg_text, engine=backend),
)
audio_tensor = await run_on_gpu_pool_guarded(
_gen, what="Batch generate",
timeout=generate_timeout_s(seg_text),
)
# Fit to slot
target_samples_seg = int(seg_duration * sr)
@@ -610,15 +410,19 @@ async def _run_batch_pipeline(job_id: str, job: dict):
# unmarked while the interactive dub pipeline marked every segment.
# One whole-track embed (chunked internally, #1045) is equivalent to
# dub_generate's per-segment marks: the 16-bit message repeats
# throughout. Never raises (degrades to unmarked on failure, same as
# every producer).
# throughout. Runs in the GPU pool like generate's finalize; never
# raises (degrades to unmarked on failure, same as every producer).
# Dispatched to the dedicated watermark pool, not the GPU pool (#1190):
# AudioSeal embedding is CPU work that holds no VRAM, and a whole-track
# embed is long enough that occupying a GPU worker with it stalled the
# next language's segments on 1-worker hosts.
from services.watermark import mark_synthetic_async
full_audio = await mark_synthetic_async(
full_audio, sr, context="batch.dub_track",
from services.watermark import mark_synthetic
from services.model_manager import get_watermark_pool
import functools
full_audio = await loop.run_in_executor(
get_watermark_pool(),
functools.partial(mark_synthetic, full_audio, sr,
context="batch.dub_track"),
)
# Same assembly pattern as dub_generate.py:390 — `full_audio` is a
@@ -708,7 +512,9 @@ async def enqueue_batch_job(
ext = os.path.splitext(video.filename or "video.mp4")[1] or ".mp4"
video_path = os.path.join(batch_dir, f"{job_id}{ext}")
await _save_upload(video, video_path)
with open(video_path, "wb") as f:
content = await video.read()
f.write(content)
job = {
"id": job_id,
+5 -24
View File
@@ -8,11 +8,8 @@ raw audio bytes and get back transcribed text immediately. Used by:
The MCP server's future `transcribe_audio` tool
CLI consumers that just want speech-to-text
The ASR engine is whatever `load_active_asr_backend()` returns WhisperX
by default, or MLX Whisper on Apple Silicon when configured. The *loader*,
not the bare selector: it also runs `ensure_loaded()` and falls through to
the next healthy engine when the selected one has a broken deep import chain
(#1185), which the shallow `is_available()` probe cannot see.
The ASR engine is whatever `get_active_asr_backend()` returns WhisperX
by default, or MLX Whisper on Apple Silicon when configured.
"""
from __future__ import annotations
@@ -100,12 +97,8 @@ async def transcribe_audio(
if use_accurate:
# Accurate mode: full WhisperX with forced alignment —
# for when the user explicitly wants word-level timing.
# `load_*`, not `get_*`: the selector alone hands back an
# engine whose shallow probe passed but whose deep import
# chain is broken, which then 500s at `.transcribe()`. The
# loader degrades to the next healthy engine (#1185).
from services.asr_backend import load_active_asr_backend
backend = load_active_asr_backend()
from services.asr_backend import get_active_asr_backend
backend = get_active_asr_backend()
result = backend.transcribe(tmp.name, word_timestamps=True)
else:
# Fast mode (default): use the fastest available engine
@@ -117,11 +110,7 @@ async def transcribe_audio(
return result, backend.id
from services.model_manager import _gpu_pool
from services.asr_backend import (
ASRModelMissingError,
ASRTimeoutError,
run_transcribe_guarded,
)
from services.asr_backend import ASRTimeoutError, run_transcribe_guarded
t0 = time.perf_counter()
try:
result, engine_id = await run_transcribe_guarded(
@@ -132,14 +121,6 @@ async def transcribe_audio(
# silent hang the UI reads as "can't reach the local backend".
logger.warning("Capture transcription timed out: %s", e)
raise HTTPException(status_code=504, detail=str(e))
except ASRModelMissingError as e:
# Degraded past the broken engine onto one with no weights on
# disk — same typed 409 (+ download CTA) as the preflight above,
# never a 500 and never a silent multi-GB auto-download.
raise HTTPException(
status_code=409,
detail={**e.payload, "message": asr_model_missing_detail(e.payload)},
)
elapsed = round(time.perf_counter() - t0, 2)
# Normalize result shape
+71 -332
View File
@@ -9,9 +9,7 @@ Protocol:
Client sends binary audio frames (16-bit PCM or WebM/Opus blobs)
Server sends JSON messages:
Raw PCM mode (``?pcm=1&sr=16000``) is the container-free fallback for
WebViews without MediaRecorder. Opt-in AEC mode
(``?aec=1[&sr=16000]``, parity Action 8b): for dictating
Opt-in AEC mode (``?aec=1[&sr=16000]``, parity Action 8b): for dictating
while the app plays audio. Frames must be raw int16 mono PCM, each tagged
with a 1-byte prefix 0x00 = microphone, 0x01 = playback reference. The
server runs an NLMS echo canceller, cleaning the mic against the reference
@@ -27,10 +25,6 @@ Protocol:
"detail": "..."} error ("detail"
kept for legacy)
Sherpa ``final`` frames additionally carry
``"final_kind": "utterance"|"summary"``. Utterances are mid-session
commits; the summary is the authoritative whole-session result at EOF.
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.
@@ -38,14 +32,10 @@ Protocol:
from __future__ import annotations
import asyncio
import json
import logging
import math
import os
import tempfile
import time
import uuid
from typing import Any
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
@@ -55,9 +45,6 @@ from services.text_polish import polish_text
router = APIRouter()
logger = logging.getLogger("omnivoice.capture_ws")
SPEECH_PROTOCOL = "voicestudio.speech.v1"
PLATFORM_STREAM_PATH = "/v1/audio/transcriptions/stream"
# How often (seconds) to run transcription on the accumulated buffer.
# Shorter = more responsive but more GPU load.
PARTIAL_INTERVAL_S = float(os.environ.get("OMNIVOICE_STREAM_INTERVAL", "2.0"))
@@ -81,81 +68,6 @@ _AEC_NEAR = 0x00 # microphone frame (clean it, then buffer for ASR)
_AEC_FAR = 0x01 # playback reference frame (feed the echo model only)
# Client-supplied ``?sr=`` values outside the range real capture devices use
# are replaced with 16 kHz. The rate sizes server-side state — RecoveryTail
# multiplies it by RECOVERY_TAIL_SECONDS to compute its byte ceiling — so an
# absurd rate must never be believed: it would re-open the unbounded-memory
# path the recovery-tail cap closed.
SR_MIN, SR_MAX = 8000, 96000
def _is_end_control(text: str | None) -> bool:
"""Accept the versioned JSON control frame and the legacy ``EOF`` frame."""
if text == "EOF":
return True
if not text:
return False
try:
message = json.loads(text)
except (TypeError, json.JSONDecodeError):
return False
return isinstance(message, dict) and message.get("type") == "input_audio.end"
class _PlatformWebSocket:
"""Add v1 session metadata without changing the legacy WebSocket contract."""
def __init__(self, websocket: WebSocket):
self._websocket = websocket
self.session_id = uuid.uuid4().hex
def __getattr__(self, name: str) -> Any:
return getattr(self._websocket, name)
async def send_json(self, data: Any, mode: str = "text") -> None:
if isinstance(data, dict):
data = dict(data)
data.setdefault("protocol", SPEECH_PROTOCOL)
data.setdefault("session_id", self.session_id)
if data.get("type") == "final":
data.setdefault("final_kind", "summary")
await self._websocket.send_json(data, mode=mode)
def _bounded_sample_rate(query_params) -> int:
try:
sample_rate = int(query_params.get("sr", "16000"))
except (TypeError, ValueError):
return 16000
return sample_rate if SR_MIN <= sample_rate <= SR_MAX else 16000
def _requested_pcm_sample_rate(query_params) -> int | None:
"""Return the bounded rate when the client transport is raw PCM.
Sherpa clients omit ``pcm=1`` because the selected model already defines
that transport. If the model is demoted or its runtime is unavailable, the
legacy recognizer fallback must still decode those same bytes as PCM.
"""
raw_pcm = query_params.get("pcm") in ("1", "true", "on")
aec = query_params.get("aec") in ("1", "true", "on")
sherpa_pcm = False
requested_model = query_params.get("model")
if requested_model:
try:
from services.sherpa_dictation import is_sherpa_model
sherpa_pcm = is_sherpa_model(requested_model)
except Exception: # noqa: BLE001
# A broken sherpa install must not decide the framing question —
# sherpa_pcm stays False and the session negotiates the
# MediaRecorder path; availability is re-probed (and reported)
# when the model is actually selected.
sherpa_pcm = False
if not raw_pcm and not aec and not sherpa_pcm:
return None
return _bounded_sample_rate(query_params)
def _demux_aec_frame(data: bytes) -> tuple[str, bytes]:
"""Split a prefixed AEC binary frame into ``(kind, pcm)``.
@@ -210,50 +122,24 @@ def _select_sherpa_spec(websocket: WebSocket):
from services import sherpa_dictation as sd
except Exception:
return None
def _usable_spec(model_id):
spec = sd.get_spec(model_id)
if spec is not None and sd.is_demoted(spec.id):
logger.warning(
"dictation model %s is demoted — using the capture ASR fallback",
spec.id,
)
return None
return spec
requested = websocket.query_params.get("model")
if requested:
return _usable_spec(requested) # explicit selection (may be unavailable)
return sd.get_spec(requested) # explicit selection (may be None if bad)
# Fall back to the persisted dictation pref.
try:
from services.asr_backend import dictation_model_id
mid = dictation_model_id()
except Exception:
mid = None
return _usable_spec(mid) if mid else None
return sd.get_spec(mid) if mid else None
@router.websocket(PLATFORM_STREAM_PATH)
@router.websocket("/ws/transcribe")
async def ws_transcribe(websocket: WebSocket):
"""Stream audio in, get partial + final transcription out."""
is_platform_stream = websocket.url.path == PLATFORM_STREAM_PATH
if is_platform_stream:
websocket = _PlatformWebSocket(websocket)
# A browser can reach localhost regardless of the page's own origin.
# Reject ambient cross-site WebSocket handshakes before the loopback-host
# shortcut or accept(), while keeping native clients (no Origin header)
# and configured/same-origin browser UIs working (#1646 review).
origin = websocket.headers.get("origin")
if origin:
from core.csrf import origin_allowed
if not origin_allowed(websocket):
await websocket.close(code=1008, reason="browser origin not allowed")
return
# Loopback origin guard — refuse anything not from 127.0.0.1, ::1, or
# localhost. Privileged HTTP routers use Depends(require_admin) at router
# level; WebSocket dependency injection differs across FastAPI versions, so we
# localhost. HTTP routers use Depends(require_loopback) at router level;
# WebSocket dependency injection differs across FastAPI versions, so we
# inline the check before accept(). Without it, any local process could
# stream the user's microphone over this endpoint.
# Wave 2.3 (remote backend): a non-loopback client that presents the
@@ -265,16 +151,6 @@ async def ws_transcribe(websocket: WebSocket):
return
await websocket.accept()
if is_platform_stream:
await websocket.send_json({
"type": "session.started",
"input_format": (
"audio/pcm;encoding=s16le;channels=1"
if _requested_pcm_sample_rate(websocket.query_params) is not None
else "audio/webm;codecs=opus"
),
"sample_rate": _bounded_sample_rate(websocket.query_params),
})
# Live-dictation engine selection. When a sherpa-onnx model is selected
# (via ?model= or the dictation.model_id pref) AND sherpa is installed,
@@ -334,11 +210,12 @@ async def ws_transcribe(websocket: WebSocket):
# identical legacy behaviour. When on, frames are 1-byte-tagged raw PCM
# and the cleaned mic stream is muxed via stdlib wave (not ffmpeg).
aec = None
pcm_sr = _requested_pcm_sample_rate(websocket.query_params)
pcm_sr: int | None = None
if websocket.query_params.get("aec") in ("1", "true", "on"):
try:
pcm_sr = int(websocket.query_params.get("sr", "16000"))
from services.aec import NlmsEchoCanceller
aec = NlmsEchoCanceller(sample_rate=pcm_sr or 16000)
aec = NlmsEchoCanceller(sample_rate=pcm_sr)
logger.info("AEC enabled for dictation session (sr=%d)", pcm_sr)
except Exception as e:
# Bad sr or import failure → fall back to plain dictation.
@@ -397,7 +274,7 @@ async def ws_transcribe(websocket: WebSocket):
total_bytes += len(data)
last_audio_time = time.monotonic()
continue
if _is_end_control(msg.get("text")):
if msg.get("text") == "EOF":
# Client signals end-of-audio but stays connected for `final`.
running = False
break
@@ -531,64 +408,6 @@ SHERPA_OFFLINE_SILENCE_S = float(os.environ.get("OMNIVOICE_SHERPA_OFFLINE_SILENC
SHERPA_OFFLINE_RMS_FLOOR = float(os.environ.get("OMNIVOICE_SHERPA_OFFLINE_RMS", "0.01"))
#: Seconds of audio retained for silent-model recovery. Recovery only needs
#: enough speech to prove the model is broken and to re-transcribe what was
#: said; retaining the whole session grew ~115 MB/hour at 16 kHz on an open
#: mic, unbounded, and only ever got read when the fallback fired.
RECOVERY_TAIL_DEFAULT_SECONDS = 120.0
RECOVERY_TAIL_MAX_SECONDS = 300.0
def _bounded_recovery_tail_seconds(value: str | None) -> float:
"""Parse the recovery tail override without allowing unbounded buffers."""
try:
seconds = float(value) if value is not None else RECOVERY_TAIL_DEFAULT_SECONDS
except (TypeError, ValueError):
return RECOVERY_TAIL_DEFAULT_SECONDS
if not math.isfinite(seconds) or seconds <= 0:
return RECOVERY_TAIL_DEFAULT_SECONDS
return min(seconds, RECOVERY_TAIL_MAX_SECONDS)
RECOVERY_TAIL_SECONDS = _bounded_recovery_tail_seconds(
os.environ.get("OMNIVOICE_DICTATION_RECOVERY_TAIL_S")
)
class RecoveryTail:
"""The most recent ``RECOVERY_TAIL_SECONDS`` of session audio.
Keeps the *tail* rather than the head: a long dictation's useful speech is
what the user just said, and the silent-model check cares about how much
audio the session carried overall which ``total_bytes`` still reports
truthfully after trimming.
"""
__slots__ = ("_buf", "_max", "total_bytes")
def __init__(self, sample_rate: int, seconds: float = RECOVERY_TAIL_SECONDS):
# int16 mono → 2 bytes/sample. Floor of one frame so a nonsense rate
# or seconds value can't produce a zero-length buffer.
self._max = max(2, int(seconds * max(1, sample_rate)) * 2)
self._buf = bytearray()
self.total_bytes = 0
def extend(self, pcm: bytes) -> None:
self._buf.extend(pcm)
self.total_bytes += len(pcm)
excess = len(self._buf) - self._max
if excess > 0:
# int16 mono: trim whole samples only. A split frame can carry an
# odd byte count, and an odd trim would leave the tail starting
# mid-sample — every later sample byte-shifted, and the recovery
# transcription fed noise.
excess += excess % 2
del self._buf[:excess]
def tail(self) -> bytes:
return bytes(self._buf)
def is_model_silent(text: str, heard_speech: bool, pcm_bytes: int) -> bool:
"""True when the dictation model produced NO text despite real speech.
@@ -615,74 +434,19 @@ def _pcm16_to_f32(pcm: bytes):
return np.frombuffer(pcm, dtype=np.int16).astype(np.float32) / 32768.0
def _pcm16_rms(pcm: bytes) -> float:
samples = _pcm16_to_f32(pcm)
if not len(samples):
return 0.0
return float((samples * samples).mean() ** 0.5)
async def _recover_silent_sherpa(
spec, pcm: bytes, pcm_sr: int,
) -> tuple[str, list[dict]]:
"""Retry a token-silent Sherpa session through an installed local ASR."""
logger.warning(
"dictation model %s decoded NOTHING from %.1fs of speech-level audio "
"— falling back to the capture ASR engine for this session",
spec.id, len(pcm) / float(max(1, pcm_sr) * 2),
)
try:
from services.asr_backend import asr_model_missing_error
fallback_missing = await asyncio.to_thread(
asr_model_missing_error,
purpose="dictation",
skip_sherpa=True,
require_installed=True,
)
if fallback_missing is not None:
logger.warning(
"dictation silent-model fallback is not installed (%s); "
"skipping recovery to avoid an automatic download",
fallback_missing.get("missing_repo_id", "unknown"),
)
return "", []
result = await _transcribe_buffer_full(
[pcm], pcm_sr=pcm_sr, skip_sherpa=True,
)
text = polish_text(_result_text(result))
if not text:
return "", []
# The RMS gate can fire on fan/keyboard noise. Only another recognizer
# producing words proves the audio held speech and makes persistent
# demotion safe.
try:
from services.sherpa_dictation import demote_model
if await asyncio.to_thread(demote_model, spec.id):
logger.error(
"dictation model %s demoted on this machine — it will no longer be "
"auto-selected. Pick it again in Settings to give it another chance.",
spec.id,
)
except Exception:
logger.exception("silent-model demotion failed")
segments = (result or {}).get("segments") or [
{"start": 0.0, "end": None, "text": text}
]
return text, segments
except Exception:
logger.exception("dictation silent-model fallback failed")
return "", []
async def _sherpa_session(websocket: WebSocket):
"""Shared WS setup for the sherpa handlers.
"""Shared WS receive setup for the sherpa handlers.
Returns ``(pcm_sr, aec)``: the bounded PCM sample rate for the session
and the echo canceller when ``?aec=1`` requested one (``None`` otherwise
or when AEC setup fails).
Returns ``(get_frame, state)`` where ``get_frame`` is an async callable
that yields the next near-end (mic) PCM bytes, ``b""`` for a keepalive/ref
frame, or ``None`` on EOF/disconnect. ``state`` carries sample rate, AEC,
and the disconnect flag for the caller's finaliser.
"""
pcm_sr = _bounded_sample_rate(websocket.query_params)
pcm_sr = 16000
try:
pcm_sr = int(websocket.query_params.get("sr", "16000"))
except (TypeError, ValueError):
pcm_sr = 16000
aec = None
if websocket.query_params.get("aec") in ("1", "true", "on"):
try:
@@ -720,7 +484,7 @@ async def _recv_pcm_frame(websocket: WebSocket, aec):
return "skip", b""
return "near", aec.process_near_end(payload)
return "near", data
if _is_end_control(msg.get("text")):
if msg.get("text") == "EOF":
return "eof", b""
return "skip", b""
@@ -791,8 +555,6 @@ async def _run_sherpa_streaming(websocket: WebSocket, spec):
last_partial = ""
committed: list[str] = [] # finalized utterances this session
session_pcm = RecoveryTail(pcm_sr) # bounded audio for silent-model recovery
heard_speech = False
client_disconnected = False
async def _send(payload) -> bool:
@@ -834,9 +596,6 @@ async def _run_sherpa_streaming(websocket: WebSocket, spec):
break
if kind == "skip":
continue
session_pcm.extend(pcm)
if not heard_speech and _pcm16_rms(pcm) >= SHERPA_OFFLINE_RMS_FLOOR:
heard_speech = True
text, endpoint = await asyncio.to_thread(_decode_after_feed, pcm)
if endpoint:
# Commit this utterance (polished — it gets pasted); reset
@@ -845,7 +604,6 @@ async def _run_sherpa_streaming(websocket: WebSocket, spec):
if text:
committed.append(text)
await _send({"type": "final", "text": text,
"final_kind": "utterance",
"segments": [{"start": 0.0, "end": None, "text": text}],
"language": "auto", "engine": backend.id})
rec.reset(stream)
@@ -872,28 +630,7 @@ async def _run_sherpa_streaming(websocket: WebSocket, spec):
# 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]
model_silent = is_model_silent(full, heard_speech, session_pcm.total_bytes)
if model_silent:
recovered, recovered_segments = await _recover_silent_sherpa(
spec, session_pcm.tail(), pcm_sr,
)
if recovered:
full = recovered
segments = recovered_segments
if not client_disconnected:
payload = {"type": "final", "text": full, "final_kind": "summary",
"segments": segments,
"language": "auto", "engine": backend.id}
if model_silent:
payload["engine"] = "capture-asr-fallback" if full else backend.id
payload["model_silent"] = spec.id
payload["warning"] = (
f"The selected dictation model ({spec.id}) produced no text from your "
"speech. Switched to the fallback engine for this session — pick a "
"different model in Settings → Dictation."
)
if full:
# Hard-bounded refinement (~4s): never delays this summary `final`
# beyond OMNIVOICE_REFINE_TIMEOUT_S even with a dead LLM endpoint.
@@ -902,9 +639,14 @@ async def _run_sherpa_streaming(websocket: WebSocket, spec):
refined = await maybe_refine_async(full)
except Exception:
refined = None
payload = {"type": "final", "text": full, "segments": segments,
"language": "auto", "engine": backend.id}
if refined and refined != full:
payload["refined_text"] = refined
await _send(payload)
await _send(payload)
else:
await _send({"type": "final", "text": "", "segments": [],
"language": "auto", "engine": backend.id})
try:
await websocket.close()
except Exception:
@@ -941,7 +683,7 @@ async def _run_sherpa_offline(websocket: WebSocket, spec):
# whisper/zipformer transcribe the same bytes). Keep the whole session's
# audio and whether any of it was speech-level, so the finaliser can tell
# "user said nothing" (fine) from "model produced nothing" (broken).
session_pcm = RecoveryTail(pcm_sr)
session_pcm = bytearray()
heard_speech = False
running = True
client_disconnected = False
@@ -960,6 +702,12 @@ async def _run_sherpa_offline(websocket: WebSocket, spec):
client_disconnected = True
return False
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):
@@ -978,7 +726,7 @@ async def _run_sherpa_offline(websocket: WebSocket, spec):
continue
buf.extend(pcm)
session_pcm.extend(pcm)
if not heard_speech and _pcm16_rms(pcm) >= SHERPA_OFFLINE_RMS_FLOOR:
if not heard_speech and _rms(pcm) >= SHERPA_OFFLINE_RMS_FLOOR:
heard_speech = True
last_audio = time.monotonic()
except WebSocketDisconnect:
@@ -1004,7 +752,6 @@ async def _run_sherpa_offline(websocket: WebSocket, spec):
if text:
committed.append(text)
await _send({"type": "final", "text": text,
"final_kind": "utterance",
"segments": [{"start": 0.0, "end": None, "text": text}],
"language": "auto", "engine": backend.id})
@@ -1016,8 +763,8 @@ async def _run_sherpa_offline(websocket: WebSocket, spec):
continue
snapshot = bytes(buf)
if len(snapshot) > sil_bytes and \
_pcm16_rms(snapshot[-sil_bytes:]) < SHERPA_OFFLINE_RMS_FLOOR:
if _pcm16_rms(snapshot[:-sil_bytes]) >= SHERPA_OFFLINE_RMS_FLOOR:
_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
@@ -1063,18 +810,39 @@ async def _run_sherpa_offline(websocket: WebSocket, spec):
# quiet user — hand the session to the capture ASR backend so the user
# still gets their words, and say which model let them down. Bounded to
# this session; the pref is left alone so the user stays in control.
model_silent = is_model_silent(full, heard_speech, session_pcm.total_bytes)
model_silent = is_model_silent(full, heard_speech, len(session_pcm))
if model_silent:
recovered, recovered_segments = await _recover_silent_sherpa(
spec, session_pcm.tail(), pcm_sr,
logger.warning(
"dictation model %s decoded NOTHING from %.1fs of speech-level audio "
"— falling back to the capture ASR engine for this session",
spec.id, len(session_pcm) / float(max(1, pcm_sr) * 2),
)
if recovered:
full = recovered
segments = recovered_segments
# Demote it so the NEXT session doesn't repeat this round trip. The
# curated default can be broken on a platform we never tested (the
# NeMo-TDT decoder is, on Windows), and observing it beats guessing.
try:
from services.sherpa_dictation import demote_model
if demote_model(spec.id):
logger.error(
"dictation model %s demoted on this machine — it will no longer be "
"auto-selected. Pick it again in Settings to give it another chance.",
spec.id,
)
except Exception:
logger.exception("silent-model demotion failed")
try:
result = await _transcribe_buffer_full([bytes(session_pcm)], pcm_sr=pcm_sr)
fb_text = polish_text((result or {}).get("text", "") or "")
if fb_text:
full = fb_text
segments = (result or {}).get("segments") or [
{"start": 0.0, "end": None, "text": fb_text}
]
except Exception:
logger.exception("dictation silent-model fallback failed")
if not client_disconnected:
payload = {"type": "final", "text": full, "final_kind": "summary",
"segments": segments,
payload = {"type": "final", "text": full, "segments": segments,
"language": "auto", "engine": backend.id}
if model_silent:
# The client surfaces this so a silently-broken model can't look
@@ -1102,35 +870,6 @@ async def _run_sherpa_offline(websocket: WebSocket, spec):
pass
def _result_text(result: dict | None) -> str:
"""Normalize text from every ASR backend result shape.
Some backends return a top-level ``text`` value, while WhisperX, Faster
Whisper, Moonshine, and OpenAI-compatible ASR expose only ``segments`` and
``chunks``. Dictation partials and finals must interpret both contracts the
same way.
"""
if not isinstance(result, dict):
return ""
text = result.get("text")
if isinstance(text, str) and text.strip():
return text.strip()
for key in ("segments", "chunks"):
items = result.get(key)
if not isinstance(items, (list, tuple)):
continue
text = " ".join(
str(item.get("text", "")).strip()
for item in items
if isinstance(item, dict) and item.get("text")
).strip()
if text:
return text
return ""
async def _transcribe_buffer(chunks: list[bytes], *, pcm_sr: int | None = None) -> str:
"""Quick partial transcription of the current audio buffer."""
@@ -1145,7 +884,7 @@ async def _transcribe_buffer(chunks: list[bytes], *, pcm_sr: int | None = None)
def _run():
backend = get_capture_asr_backend()
result = backend.transcribe(tmp, word_timestamps=False)
return _result_text(result)
return result.get("text", "")
# Bound dictation transcribes (#730): a wedged whisperx/CTranslate2 call
# must not hold its GPU-pool worker forever and starve TTS / other ASR
@@ -1159,9 +898,7 @@ async def _transcribe_buffer(chunks: list[bytes], *, pcm_sr: int | None = None)
pass
async def _transcribe_buffer_full(
chunks: list[bytes], *, pcm_sr: int | None = None, skip_sherpa: bool = False,
) -> dict:
async def _transcribe_buffer_full(chunks: list[bytes], *, pcm_sr: int | None = None) -> dict:
"""Full transcription with timing info for the final result."""
tmp = _pcm16_to_wav(b"".join(chunks), pcm_sr) if pcm_sr else _chunks_to_wav(chunks)
if tmp is None:
@@ -1173,13 +910,15 @@ async def _transcribe_buffer_full(
from services.asr_backend import get_capture_asr_backend, run_transcribe_guarded
def _run():
backend = get_capture_asr_backend(skip_sherpa=skip_sherpa)
backend = get_capture_asr_backend()
t0 = time.perf_counter()
result = backend.transcribe(tmp, word_timestamps=False)
elapsed = round(time.perf_counter() - t0, 2)
segments = result.get("segments", [])
full_text = _result_text(result)
full_text = result.get("text", "")
if not full_text and segments:
full_text = " ".join(s.get("text", "") for s in segments).strip()
# Wave 1.1: strip Whisper hallucination loops from the final
# text (the string that gets auto-pasted). Segments keep the
+97 -726
View File
@@ -20,26 +20,18 @@ Design / safety
from __future__ import annotations
import asyncio
import contextlib
import hashlib
import json
import logging
import os
import re
import shutil
import tempfile
import time
import uuid
from pathlib import Path
from typing import Optional
from urllib.parse import urljoin, urlparse
from urllib.parse import urlparse
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import FileResponse
from core import archetypes
from core.audio_validation import is_playable_wav, resolve_regular_file
from core.config import DATA_DIR, VOICES_DIR
from core.config import DATA_DIR
logger = logging.getLogger("omnivoice.community")
router = APIRouter()
@@ -50,32 +42,9 @@ _ALLOWED_AUDIO_HOSTS = {
"cdn.jsdelivr.net", "github.com", "raw.githubusercontent.com",
"objects.githubusercontent.com", "release-assets.githubusercontent.com",
}
_ALLOWED_MANIFEST_HOSTS = {"cdn.jsdelivr.net"}
_VALID_TOKENS = set(archetypes._VD._INSTRUCT_ALL_VALID)
_USE_CASE_IDS = {c["id"] for c in archetypes.USE_CASES}
_SOURCE_RE = re.compile(
r"^[A-Za-z0-9._-]{1,100}/[A-Za-z0-9._-]{1,100}$",
) # owner/repo only
_ITEM_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,128}$")
_SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
# A gallery open may touch this loader several times (grid, preview, use). Keep
# a successful response for six hours, then revalidate it once. On a network
# failure the readable stale copy remains usable and its check time advances,
# preventing every offline gallery open from waiting through the same timeout.
_MANIFEST_MAX_AGE_S = 6 * 60 * 60
_MAX_MANIFEST_BYTES = 4 << 20
_MAX_SAMPLE_SCRIPT_CHARS = 2_000
_MAX_REF_TEXT_CHARS = 4_000
# Community voice submissions are documented as short clean WAV clips. The cap
# comfortably covers 15 s of uncompressed 96 kHz stereo PCM while preventing a
# remote manifest from turning Preview into an unbounded disk/memory download.
_MAX_VOICE_AUDIO_BYTES = 32 << 20
_ATTR_NAMES = (
"Gender", "Age", "Pitch", "Style", "EnglishAccent", "ChineseDialect",
)
_SOURCE_RE = re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$") # owner/repo only
# ── Config: which content repos to load ───────────────────────────────────────
@@ -83,18 +52,14 @@ def configured_sources() -> list[str]:
"""Gallery sources, in priority order. Env var > config file > default."""
env = os.environ.get("OMNIVOICE_GALLERY_SOURCES")
if env:
sources = [s.strip() for s in env.split(",")]
valid = [s for s in sources if _SOURCE_RE.fullmatch(s)]
return valid or list(_DEFAULT_SOURCES)
return [s.strip() for s in env.split(",") if s.strip()]
cfg = Path(DATA_DIR) / "gallery_sources.json"
if cfg.exists():
try:
data = json.loads(cfg.read_text(encoding="utf-8"))
srcs = data.get("sources")
if isinstance(srcs, list) and srcs:
valid = [s for s in srcs if isinstance(s, str) and _SOURCE_RE.fullmatch(s)]
if valid:
return valid
return [str(s) for s in srcs]
except Exception:
logger.warning("gallery_sources.json unreadable; using default")
return list(_DEFAULT_SOURCES)
@@ -116,51 +81,9 @@ def _safe_audio_url(url: str) -> bool:
return False
def _safe_manifest_url(url: str) -> bool:
try:
parsed = urlparse(url or "")
return parsed.scheme == "https" and parsed.hostname in _ALLOWED_MANIFEST_HOSTS
except Exception:
return False
def normalize_preset_instruct(instruct: str) -> Optional[tuple[str, dict]]:
"""Normalize one validator-safe tag per design category.
Membership in the vocabulary is not enough: ``male, female`` contains two
individually valid tokens but the engine rejects the pair as conflicting.
Build the frontend's full ``vd_states`` shape at this trust boundary too,
so Magic Wand never inherits stale sliders from the previous voice.
"""
attrs = {name: "Auto" for name in _ATTR_NAMES}
normalized: list[str] = []
seen_categories: set[int] = set()
for raw in re.split("[," + chr(0xFF0C) + "]", str(instruct or "")):
token = raw.strip().lower()
if not token or token not in _VALID_TOKENS:
return None
category = archetypes._VD._instruct_category_index(token)
if category < 0 or category in seen_categories:
return None
seen_categories.add(category)
# The picker represents the universal gender/age/pitch/style axes in
# English even for Chinese speech; dialect remains Chinese-only.
canonical = archetypes._VD._INSTRUCT_ZH_TO_EN.get(token, token)
attrs[_ATTR_NAMES[category]] = canonical
normalized.append(canonical)
if not normalized:
return None
# Accent and Chinese dialect are separate taxonomy buckets but the engine
# deliberately forbids mixing them in a single design.
if 4 in seen_categories and 5 in seen_categories:
return None
return ", ".join(normalized), attrs
def is_valid_instruct(instruct: str) -> bool:
return normalize_preset_instruct(instruct) is not None
toks = [t.strip() for t in (instruct or "").split(",") if t.strip()]
return bool(toks) and all(t in _VALID_TOKENS for t in toks)
def validate_item(raw: dict) -> Optional[dict]:
@@ -170,203 +93,62 @@ def validate_item(raw: dict) -> Optional[dict]:
it = dict(raw)
if it.get("type") not in ("preset", "voice"):
return None
if not isinstance(it.get("id"), str) or not _ITEM_ID_RE.fullmatch(it["id"]):
if not it.get("id") or not it.get("name"):
return None
if not isinstance(it.get("name"), str) or not it["name"].strip():
return None
it["name"] = it["name"].strip()[:80]
if it.get("use_case") not in _USE_CASE_IDS:
return None
raw_facets = it.get("facets")
if not isinstance(raw_facets, dict):
raw_facets = {}
language = it.get("language")
if not isinstance(language, str) or not language.strip():
language = raw_facets.get("lang", "English")
it["language"] = language.strip() if isinstance(language, str) and language.strip() else "English"
facets = dict(raw_facets)
if it["type"] == "preset":
normalized = normalize_preset_instruct(it.get("instruct", ""))
if normalized is None:
return None # unknown/conflicting tokens would crash synthesis
it["instruct"], it["attrs"] = normalized
attrs = it["attrs"]
facets.update({
"gender": None if attrs["Gender"] == "Auto" else attrs["Gender"],
"age": None if attrs["Age"] == "Auto" else attrs["Age"],
"pitch": None if attrs["Pitch"] == "Auto" else attrs["Pitch"],
"accent": None if attrs["EnglishAccent"] == "Auto" else attrs["EnglishAccent"],
"whisper": attrs["Style"] == "whisper",
"lang": it["language"],
})
sample_script = it.get("sample_script")
it["sample_script"] = (
sample_script.strip()[:_MAX_SAMPLE_SCRIPT_CHARS]
if isinstance(sample_script, str) else ""
)
else:
audio = it.get("audio")
if not isinstance(audio, dict) or not _safe_audio_url(audio.get("url", "")):
return None
expected = audio.get("sha256")
if expected is not None:
expected = str(expected).lower()
if not _SHA256_RE.fullmatch(expected):
return None
audio = {**audio, "sha256": expected}
ref_text = audio.get("ref_text")
audio = {
**audio,
"ref_text": (
ref_text.strip()[:_MAX_REF_TEXT_CHARS]
if isinstance(ref_text, str) else ""
),
}
it["audio"] = audio
facets.setdefault("gender", None)
facets.setdefault("age", None)
facets.setdefault("pitch", None)
facets.setdefault("accent", None)
facets.setdefault("whisper", False)
facets.setdefault("lang", it["language"])
it["facets"] = facets
if it["type"] == "preset" and not is_valid_instruct(it.get("instruct", "")):
return None # would crash synthesis — drop it
if it["type"] == "voice" and not _safe_audio_url((it.get("audio") or {}).get("url", "")):
return None
it.setdefault("facets", {})
it.setdefault("icon", archetypes._USE_ICON.get(it["use_case"], "Sparkles"))
it.setdefault("language", it.get("facets", {}).get("lang", "English"))
it["is_community"] = it.get("source") != "starter"
it["preview_url"] = f"/community/items/{it['id']}/preview"
return it
def _merge(manifests: list[tuple[str, Optional[dict]]]) -> tuple[list, list]:
items, packs, seen = [], [], set()
for src, m in manifests:
if not isinstance(m, dict):
if not m:
continue
raw_items = m.get("items")
for raw in raw_items if isinstance(raw_items, list) else []:
for raw in (m.get("items") or []):
v = validate_item(raw)
if v and v["id"] not in seen:
v["_source_repo"] = src
seen.add(v["id"])
items.append(v)
raw_packs = m.get("packs")
for p in raw_packs if isinstance(raw_packs, list) else []:
for p in (m.get("packs") or []):
if isinstance(p, dict):
packs.append({**p, "_source_repo": src})
return items, packs
def _read_manifest_cache(cache: Path) -> Optional[dict]:
try:
if cache.stat().st_size > _MAX_MANIFEST_BYTES:
return None
data = json.loads(cache.read_text(encoding="utf-8"))
return data if isinstance(data, dict) else None
except (OSError, ValueError, TypeError):
return None
def _write_bytes_atomic(path: Path, data: bytes) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=f".{path.name}-", suffix=".part")
try:
with os.fdopen(fd, "wb") as handle:
handle.write(data)
handle.flush()
os.fsync(handle.fileno())
os.replace(tmp, path)
except BaseException:
with contextlib.suppress(OSError):
os.unlink(tmp)
raise
def _fetch_remote_manifest(source: str, *, client=None) -> dict:
"""Fetch one bounded manifest, validating every redirect before request."""
import httpx
if not _SOURCE_RE.fullmatch(source or ""):
raise ValueError("invalid gallery source")
owned_client = client is None
http = client or httpx.Client(timeout=15.0, follow_redirects=False)
current_url = _manifest_url(source)
payload = bytearray()
try:
fetched = False
for _redirect in range(6):
if not _safe_manifest_url(current_url):
raise ValueError("gallery manifest URL is not from an allowed host")
with http.stream("GET", current_url, follow_redirects=False) as response:
if response.status_code in (301, 302, 303, 307, 308):
location = response.headers.get("location")
next_url = urljoin(current_url, location or "")
if not location or not _safe_manifest_url(next_url):
raise ValueError("gallery manifest redirected to a disallowed host")
current_url = next_url
continue
response.raise_for_status()
length = response.headers.get("content-length")
if length:
try:
declared_length = int(length)
except ValueError:
declared_length = None
if declared_length is not None and declared_length > _MAX_MANIFEST_BYTES:
raise ValueError("gallery manifest exceeded the size limit")
for chunk in response.iter_bytes():
if not chunk:
continue
if len(payload) + len(chunk) > _MAX_MANIFEST_BYTES:
raise ValueError("gallery manifest exceeded the size limit")
payload.extend(chunk)
fetched = True
break
if not fetched:
raise ValueError("gallery manifest followed too many redirects")
finally:
if owned_client:
http.close()
if not payload:
raise ValueError("gallery manifest was empty")
data = json.loads(payload)
if not isinstance(data, dict):
raise ValueError("gallery manifest is not a JSON object")
return data
def _fetch_manifest(
source: str, refresh: bool, *, now: Optional[float] = None,
) -> Optional[dict]:
"""Return a fresh manifest, with a throttled stale-cache offline fallback."""
def _fetch_manifest(source: str, refresh: bool) -> Optional[dict]:
"""Return a source's manifest from cache, or fetch + cache it. None if both fail."""
cache = _cache_path(source)
cached = _read_manifest_cache(cache)
checked_at = time.time() if now is None else float(now)
if not refresh and cached is not None:
if not refresh and cache.exists():
try:
if checked_at - cache.stat().st_mtime < _MANIFEST_MAX_AGE_S:
return cached
except OSError:
pass # treat a stat race as stale and try the source once
return json.loads(cache.read_text(encoding="utf-8"))
except Exception:
pass
try:
data = _fetch_remote_manifest(source)
encoded = json.dumps(
data, ensure_ascii=False, separators=(",", ":"),
).encode("utf-8")
if len(encoded) > _MAX_MANIFEST_BYTES:
raise ValueError("gallery manifest exceeded the cache size limit")
_write_bytes_atomic(cache, encoded)
# Tests inject their own clock; production's value equals wall time.
os.utime(cache, (checked_at, checked_at))
import httpx
with httpx.Client(timeout=15.0, follow_redirects=True) as client:
resp = client.get(_manifest_url(source))
resp.raise_for_status()
data = resp.json()
cache.parent.mkdir(parents=True, exist_ok=True)
cache.write_text(json.dumps(data), encoding="utf-8")
return data
except Exception as e: # offline / 404 / bad json
logger.warning("manifest fetch failed for %s: %s", source, e)
if cached is not None:
# This mtime is a last-*check* marker. Advancing it on failure keeps
# an offline app responsive while guaranteeing another check after
# the bounded freshness interval.
with contextlib.suppress(OSError):
os.utime(cache, (checked_at, checked_at))
return cached
if cache.exists():
try:
return json.loads(cache.read_text(encoding="utf-8"))
except Exception:
pass
return None
@@ -432,385 +214,6 @@ def community_submit_url(item_type: str = Query("preset", alias="type"), source:
return {"url": f"https://github.com/{src}/issues/new?template={template}"}
def _find_item(items: list[dict], item_id: str) -> dict:
if not _ITEM_ID_RE.fullmatch(item_id or ""):
raise HTTPException(status_code=404, detail="Item not found in the gallery.")
item = next((it for it in items if it["id"] == item_id), None)
if item is None:
raise HTTPException(status_code=404, detail="Item not found in the gallery.")
return item
def _canonical_archetype(item: dict) -> Optional[dict]:
"""The built-in archetype represented exactly by a marketplace preset."""
if item.get("type") != "preset":
return None
canonical = archetypes.get_archetype(item["id"])
if canonical is None:
return None
if (canonical.get("instruct") != item.get("instruct")
or canonical.get("language") != item.get("language")):
return None
remote_script = (item.get("sample_script") or "").strip()
if remote_script and remote_script != (canonical.get("sample_script") or "").strip():
return None
return canonical
def _preset_preview_path(item: dict) -> Path:
fingerprint = hashlib.sha256(
json.dumps({
"instruct": item.get("instruct"),
"language": item.get("language"),
"sample_script": item.get("sample_script"),
}, sort_keys=True).encode("utf-8")
).hexdigest()[:16]
return _CACHE_DIR / "previews" / f"{item['id']}-{fingerprint}.wav"
def _voice_audio_fingerprint(item: dict) -> str:
audio = item.get("audio") or {}
return hashlib.sha256(
f"{audio.get('url', '')}|{audio.get('sha256', '')}".encode("utf-8")
).hexdigest()[:16]
def _voice_audio_path(item: dict) -> Path:
return _CACHE_DIR / "audio" / f"{item['id']}-{_voice_audio_fingerprint(item)}.wav"
async def _render_preset_atomic(item: dict, out_path: Path) -> Path:
if is_playable_wav(out_path):
return out_path
from api.routers.archetypes import _render_archetype_wav
out_path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp_name = tempfile.mkstemp(dir=str(out_path.parent), prefix=".preview-", suffix=".wav")
os.close(fd)
tmp = Path(tmp_name)
try:
await _render_archetype_wav({
"instruct": item["instruct"],
"language": item.get("language", "English"),
"sample_script": (
(item.get("sample_script") or "").strip()
or "Hello — this is a preview of this voice."
),
}, tmp)
if not is_playable_wav(tmp):
raise RuntimeError("the voice engine produced an invalid preview WAV")
os.replace(tmp, out_path)
return out_path
finally:
with contextlib.suppress(OSError):
tmp.unlink()
def _download_voice_audio(item: dict, out_path: Path, *, client=None) -> None:
"""Stream one allow-listed voice clip into an atomic, size-bounded file."""
audio = item.get("audio") or {}
url = audio.get("url", "")
if not _safe_audio_url(url):
raise HTTPException(status_code=400, detail="Voice audio URL is not from an allowed host.")
import httpx
owned_client = client is None
http = client or httpx.Client(timeout=30.0, follow_redirects=False)
out_path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp_name = tempfile.mkstemp(dir=str(out_path.parent), prefix=".voice-", suffix=".part")
total = 0
digest = hashlib.sha256()
try:
with os.fdopen(fd, "wb") as handle:
current_url = url
downloaded = False
for _redirect in range(6):
with http.stream("GET", current_url, follow_redirects=False) as response:
if response.status_code in (301, 302, 303, 307, 308):
location = response.headers.get("location")
next_url = urljoin(current_url, location or "")
if not location or not _safe_audio_url(next_url):
raise HTTPException(
status_code=502,
detail="Community voice audio redirected to a disallowed host.",
)
current_url = next_url
continue
response.raise_for_status()
length = response.headers.get("content-length")
if length:
try:
if int(length) > _MAX_VOICE_AUDIO_BYTES:
raise HTTPException(
status_code=502,
detail="Community voice audio exceeded the download size limit.",
)
except ValueError:
# A non-numeric Content-Length header is the
# server's problem, not a reason to refuse the
# download — the streamed byte counter below
# still enforces the same cap on what actually
# arrives.
pass
for chunk in response.iter_bytes():
if not chunk:
continue
total += len(chunk)
if total > _MAX_VOICE_AUDIO_BYTES:
raise HTTPException(
status_code=502,
detail="Community voice audio exceeded the download size limit.",
)
digest.update(chunk)
handle.write(chunk)
downloaded = True
break
if not downloaded:
raise HTTPException(
status_code=502,
detail="Community voice audio followed too many redirects.",
)
if total == 0:
raise HTTPException(status_code=502, detail="Community voice audio was empty.")
expected = audio.get("sha256")
if expected and digest.hexdigest() != expected:
raise HTTPException(
status_code=502,
detail="Downloaded voice failed its integrity check.",
)
handle.flush()
os.fsync(handle.fileno())
if not is_playable_wav(Path(tmp_name)):
raise HTTPException(
status_code=502, detail="Community voice audio was not a valid WAV.",
)
os.replace(tmp_name, out_path)
except BaseException:
with contextlib.suppress(OSError):
os.unlink(tmp_name)
raise
finally:
if owned_client:
http.close()
def _cached_voice_audio(item: dict) -> Path:
path = _voice_audio_path(item)
if is_playable_wav(path):
return path
with contextlib.suppress(OSError):
path.unlink()
_download_voice_audio(item, path)
return path
def _copy_atomic(source: Path, destination: Path) -> None:
destination.parent.mkdir(parents=True, exist_ok=True)
fd, tmp_name = tempfile.mkstemp(
dir=str(destination.parent), prefix=f".{destination.name}-", suffix=".part",
)
try:
with os.fdopen(fd, "wb") as out, source.open("rb") as src:
shutil.copyfileobj(src, out)
out.flush()
os.fsync(out.fileno())
os.replace(tmp_name, destination)
except BaseException:
with contextlib.suppress(OSError):
os.unlink(tmp_name)
raise
@router.get("/community/items/{item_id}/preview")
async def community_preview(
item_id: str,
local: bool = Query(False, description="Bypass canonical gallery audio after decode failure"),
):
"""Serve every community preview through the authenticated same-origin API."""
_, items, _, _ = await asyncio.to_thread(_load, False)
item = _find_item(items, item_id)
canonical = _canonical_archetype(item)
if canonical is not None:
# Reuse the signed-gallery/local-render fallback and cache owned by the
# canonical endpoint rather than synthesizing the same preset twice.
# Delegate in-process: a root-relative HTTP redirect drops supported
# reverse-proxy path prefixes such as ``https://host/api``.
from api.routers.archetypes import preview_archetype
return await preview_archetype(canonical["id"], local=local)
try:
if item["type"] == "preset":
path = await _render_preset_atomic(item, _preset_preview_path(item))
else:
path = await asyncio.to_thread(_cached_voice_audio, item)
except HTTPException:
raise
except Exception as exc:
logger.warning("Community preview unavailable (%s)", type(exc).__name__)
raise HTTPException(
status_code=503, detail="This community voice preview is unavailable right now.",
) from exc
return FileResponse(
path, media_type="audio/wav",
headers={"Cache-Control": "no-cache", "X-OmniVoice-Preview-Source": "community"},
)
def _profile_fields(item: dict) -> tuple[str, str, Optional[str], Optional[int]]:
if item["type"] == "preset":
return "design", item["instruct"], json.dumps(item["attrs"]), 42
return "clone", "", None, None
def _community_profile_audio_filename(profile_id: str, item: dict) -> str:
safe_id = (
profile_id if re.fullmatch(r"[A-Za-z0-9_-]{1,64}", profile_id or "")
else hashlib.sha256(str(profile_id).encode("utf-8")).hexdigest()[:16]
)
if item["type"] == "voice":
# The manifest URL/checksum fingerprint makes a changed submission
# invalidate its already-materialized clone without a schema change.
return f"{safe_id}-community-{_voice_audio_fingerprint(item)}.wav"
return f"{safe_id}.wav"
def _stored_profile_audio(ref_audio_path: object) -> Optional[Path]:
return resolve_regular_file(VOICES_DIR, ref_audio_path)
def _community_audio_is_current(row, item: dict, ref_text: str) -> bool:
path = _stored_profile_audio(row["ref_audio_path"])
expected_filename = _community_profile_audio_filename(row["id"], item)
if row["ref_audio_path"] != expected_filename or not is_playable_wav(path):
return False
kind, instruct, _vd_states, seed = _profile_fields(item)
inputs_match = (
row["instruct"] == instruct
and row["language"] == item.get("language", "Auto")
and row["ref_text"] == ref_text
and row["seed"] == seed
)
if not inputs_match:
return False
return True
async def _materialize_item_audio(
item: dict, profile_id: str, *, publish: bool = True,
) -> tuple[str, Path]:
"""Copy the current manifest audio, optionally staging it for a later CAS."""
audio_filename = _community_profile_audio_filename(profile_id, item)
destination = Path(VOICES_DIR) / audio_filename
audio_path = destination
if not publish:
destination.parent.mkdir(parents=True, exist_ok=True)
audio_path = destination.parent / f".{Path(audio_filename).stem}-{uuid.uuid4().hex}.staged.wav"
if item["type"] == "preset":
cached = await _render_preset_atomic(item, _preset_preview_path(item))
else:
cached = await asyncio.to_thread(_cached_voice_audio, item)
await asyncio.to_thread(_copy_atomic, cached, audio_path)
return audio_filename, audio_path
def _community_personality(item: dict) -> str:
source = item.get("_source_repo")
if not isinstance(source, str) or not _SOURCE_RE.fullmatch(source):
source = _DEFAULT_SOURCES[0]
return f"community:{source}:{item['id']}"
def _is_materialized_community_row(row, item: dict) -> bool:
if (
row["personality"] != _community_personality(item)
or row["is_locked"] or row["verified_own_voice"]
):
return False
if item["type"] == "voice":
safe_id = Path(_community_profile_audio_filename(row["id"], item)).name.split(
"-community-", 1,
)[0]
return bool(
row["kind"] == "clone"
and row["seed"] is None
and not row["vd_states"]
and row["instruct"] == ""
and row["language"] == item.get("language", "Auto")
and row["ref_text"] == (item.get("audio") or {}).get("ref_text", "")
and re.fullmatch(
rf"{re.escape(safe_id)}-community-[0-9a-f]{{16}}\.wav",
row["ref_audio_path"] or "",
)
)
try:
states = json.loads(row["vd_states"])
except (TypeError, ValueError):
return False
return bool(
row["kind"] == "design"
and row["seed"] == 42
and row["ref_audio_path"] == _community_profile_audio_filename(row["id"], item)
and row["instruct"] == item["instruct"]
and row["language"] == item.get("language", "Auto")
and row["ref_text"] == (item.get("sample_script") or "")
and states == item["attrs"]
)
def _existing_community_profile(conn, item: dict, personality: str):
candidates = conn.execute(
"SELECT * FROM voice_profiles WHERE personality=? ORDER BY created_at, id",
(personality,),
).fetchall()
existing = next(
(row for row in candidates if _is_materialized_community_row(row, item)), None,
)
if existing is not None:
return existing
# Old builds stored the bare item id. Import formats preserve arbitrary
# personality text too, so adopt only the exact shape the old materializer
# wrote; otherwise a remote item id could rewrite a user's imported voice.
if archetypes.get_archetype(item["id"]) is None:
legacy = conn.execute(
"SELECT * FROM voice_profiles WHERE personality=? LIMIT 1",
(item["id"],),
).fetchone()
if legacy is not None:
kind, instruct, _vd_states, _seed = _profile_fields(item)
ref_text = item.get("sample_script") or (item.get("audio") or {}).get(
"ref_text", "",
)
if (
legacy["ref_audio_path"] == f"{legacy['id']}.wav"
and legacy["kind"] == kind
and legacy["instruct"] == instruct
and legacy["language"] == item.get("language", "Auto")
and legacy["ref_text"] == ref_text
and legacy["seed"] is None
and not legacy["vd_states"]
and not legacy["is_locked"]
and not legacy["verified_own_voice"]
):
return legacy
return None
def _heal_existing_profile(
conn, row, item: dict, ref_text: str, personality: str, audio_filename: str,
) -> None:
kind, instruct, vd_states, seed = _profile_fields(item)
conn.execute(
"UPDATE voice_profiles SET kind=?, instruct=?, vd_states=?, language=?, "
"ref_text=?, seed=?, personality=?, ref_audio_path=? WHERE id=?",
(
kind, instruct, vd_states, item.get("language", "Auto"), ref_text,
seed, personality, audio_filename, row["id"],
),
)
@router.post("/community/items/{item_id}/use")
async def community_use(item_id: str, name: Optional[str] = Query(None)):
"""Materialize a community item into a reusable voice profile.
@@ -820,108 +223,76 @@ async def community_use(item_id: str, name: Optional[str] = Query(None)):
``voice_profiles`` row usable everywhere voices are picked.
"""
_, items, _, _ = await asyncio.to_thread(_load, False)
item = _find_item(items, item_id)
canonical = _canonical_archetype(item)
if canonical is not None:
from api.routers.archetypes import use_archetype
return await use_archetype(canonical["id"], name)
item = next((it for it in items if it["id"] == item_id), None)
if item is None:
raise HTTPException(status_code=404, detail="Item not found in the gallery.")
import time
import uuid
from core import event_bus
from core.db import db_conn
from core.config import VOICES_DIR
ref_text = item.get("sample_script") or (item.get("audio") or {}).get("ref_text", "")
personality = _community_personality(item)
with db_conn() as conn:
existing = _existing_community_profile(conn, item, personality)
profile_id = existing["id"] if existing is not None else str(uuid.uuid4())[:8]
audio_path: Optional[Path] = None
if existing is not None and _community_audio_is_current(existing, item, ref_text):
audio_filename = existing["ref_audio_path"]
else:
try:
audio_filename, audio_path = await _materialize_item_audio(
item, profile_id, publish=existing is None,
)
except HTTPException:
raise
except Exception as e:
logger.error("Community 'use' failed", exc_info=True)
raise HTTPException(
status_code=503, detail="Couldn't add this voice right now.",
) from e
if existing is not None:
with db_conn() as conn:
conn.execute("BEGIN IMMEDIATE")
current = conn.execute(
"SELECT * FROM voice_profiles WHERE id=?", (existing["id"],),
).fetchone()
owned = _existing_community_profile(conn, item, personality)
still_owned = current is not None and (
_is_materialized_community_row(current, item)
or (owned is not None and owned["id"] == current["id"])
)
if still_owned:
if audio_path is not None:
destination = Path(VOICES_DIR) / audio_filename
os.replace(audio_path, destination)
audio_path = None
_heal_existing_profile(
conn, current, item, ref_text, personality, audio_filename,
)
existing_result = {"profile_id": current["id"], "name": current["name"]}
else:
existing_result = None
if existing_result is not None:
event_bus.emit("profiles", {"action": "updated", "id": existing_result["profile_id"]})
return existing_result
profile_id = str(uuid.uuid4())[:8]
audio_filename = _community_profile_audio_filename(profile_id, item)
destination = Path(VOICES_DIR) / audio_filename
if audio_path is None:
audio_filename, audio_path = await _materialize_item_audio(item, profile_id)
else:
os.replace(audio_path, destination)
audio_path = destination
if audio_path is None: # defensive: a new profile always materialized above
raise RuntimeError("new community profile has no materialized audio")
profile_id = str(uuid.uuid4())[:8]
audio_filename = f"{profile_id}.wav"
audio_path = Path(VOICES_DIR) / audio_filename
profile_name = (name or item["name"]).strip() or item["name"]
kind, instruct, vd_states, seed = _profile_fields(item)
instruct = item.get("instruct", "") if item["type"] == "preset" else ""
ref_text = item.get("sample_script") or (item.get("audio") or {}).get("ref_text", "")
try:
if item["type"] == "preset":
from api.routers.archetypes import _render_archetype_wav
pseudo = {
"instruct": instruct,
"language": item.get("language", "English"),
"sample_script": ref_text or "Hello — this is a preview of this voice.",
}
await _render_archetype_wav(pseudo, audio_path)
else: # voice — download the reference clip (off the event loop)
await asyncio.to_thread(_download_voice_audio, item, audio_path)
except HTTPException:
raise
except Exception as e:
logger.error("Community 'use' failed", exc_info=True)
raise HTTPException(status_code=503, detail=f"Couldn't add this voice right now. Error: {e}")
try:
# A community "preset" is a synthetic designed voice (rendered from an
# instruct string) → kind='design'; a "voice" carries a real reference
# clip → kind='clone'. Setting kind makes the persona-gallery
# synthetic-only gating work (§R3) instead of defaulting all imports to
# 'clone'.
kind = "design" if item["type"] == "preset" else "clone"
with db_conn() as conn:
conn.execute("BEGIN IMMEDIATE")
duplicate = _existing_community_profile(conn, item, personality)
if duplicate is not None:
duplicate_audio = duplicate["ref_audio_path"]
if not _community_audio_is_current(duplicate, item, ref_text):
duplicate_audio = _community_profile_audio_filename(duplicate["id"], item)
duplicate_path = Path(VOICES_DIR) / duplicate_audio
_copy_atomic(audio_path, duplicate_path)
_heal_existing_profile(
conn, duplicate, item, ref_text, personality, duplicate_audio,
)
with contextlib.suppress(OSError):
audio_path.unlink()
duplicate_result = {"profile_id": duplicate["id"], "name": duplicate["name"]}
else:
duplicate_result = None
if duplicate_result is None:
conn.execute(
"INSERT INTO voice_profiles "
"(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, "
"created_at, kind, vd_states) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(profile_id, profile_name, audio_filename, ref_text, instruct,
item.get("language", "Auto"), seed, personality, time.time(), kind, vd_states),
)
conn.execute(
"INSERT INTO voice_profiles "
"(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, created_at, kind) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(profile_id, profile_name, audio_filename, ref_text, instruct,
item.get("language", "Auto"), None, item["id"], time.time(), kind),
)
except Exception:
with contextlib.suppress(OSError):
audio_path.unlink()
with __import__("contextlib").suppress(OSError):
os.remove(audio_path)
raise
if duplicate_result is not None:
event_bus.emit("profiles", {"action": "updated", "id": duplicate_result["profile_id"]})
return duplicate_result
event_bus.emit("profiles", {"action": "created", "id": profile_id})
return {"profile_id": profile_id, "name": profile_name}
def _download_voice_audio(item: dict, out_path: Path) -> None:
import hashlib
audio = item.get("audio") or {}
url = audio.get("url", "")
if not _safe_audio_url(url):
raise HTTPException(status_code=400, detail="Voice audio URL is not from an allowed host.")
import httpx
with httpx.Client(timeout=30.0, follow_redirects=True) as client:
resp = client.get(url)
resp.raise_for_status()
data = resp.content
expected = audio.get("sha256")
if expected and hashlib.sha256(data).hexdigest() != expected:
raise HTTPException(status_code=502, detail="Downloaded voice failed its integrity check.")
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_bytes(data)
+67 -396
View File
@@ -134,82 +134,6 @@ _save_job = dub_pipeline.save_job
# paste (or a mis-aimed binary) burn CPU in the parser.
_MAX_SUBTITLE_PASTE_CHARS = 2_000_000
_SRT_REPLACED_FIELDS = {
"id",
"start",
"end",
"text",
"text_original",
"translations",
"translate_error",
"translate_degraded",
}
def _best_overlapping_segment(cue: dict, existing: list[dict]) -> dict | None:
"""Return the prior segment with the strongest temporal overlap."""
cue_start = float(cue.get("start") or 0.0)
cue_end = float(cue.get("end") or cue_start)
cue_mid = (cue_start + cue_end) / 2.0
best = None
best_key = None
for index, segment in enumerate(existing):
start = float(segment.get("start") or 0.0)
end = float(segment.get("end") or start)
overlap = min(cue_end, end) - max(cue_start, start)
if overlap <= 0:
continue
midpoint_distance = abs(cue_mid - ((start + end) / 2.0))
key = (overlap, -midpoint_distance, -index)
if best_key is None or key > best_key:
best = segment
best_key = key
return best
def _carry_srt_voice_metadata(
cues: list[dict],
existing: list[dict],
segment_clones: dict | None,
speaker_clones: dict | None = None,
) -> tuple[list[dict], dict]:
"""Replace subtitle content while retaining the source cast assignment."""
source_clones = dict(segment_clones or {})
source_speaker_clones = dict(speaker_clones or {})
# Replacement cues get new positional ids. Starting from the old map would
# let an unmatched cue whose new id happens to equal an old id inherit an
# unrelated reference. Only explicitly overlap-matched references survive.
clones = {}
merged_segments = []
for new_id, cue in enumerate(cues):
prior = _best_overlapping_segment(cue, existing)
metadata = {
key: value
for key, value in (prior or {}).items()
if key not in _SRT_REPLACED_FIELDS
}
merged = {
**metadata,
"id": new_id,
"start": cue.get("start", 0.0),
"end": cue.get("end", 0.0),
"text": cue.get("text", ""),
"text_original": cue.get("text", ""),
}
if not merged.get("speaker_id"):
merged["speaker_id"] = cue.get("speaker_id") or "Speaker 1"
if prior is not None:
prior_id = str(prior.get("id", ""))
clone = source_clones.get(prior_id)
if clone is None:
clone = source_speaker_clones.get(prior.get("speaker_id"))
if clone is not None:
clones[str(new_id)] = clone
if merged.get("profile_id") == f"auto-seg:{prior_id}":
merged["profile_id"] = f"auto-seg:{new_id}"
merged_segments.append(merged)
return merged_segments, clones
@router.post("/dub/parse-subtitle-text")
def dub_parse_subtitle_text(req: ParseSubtitleTextRequest):
@@ -310,32 +234,7 @@ async def dub_import_srt(job_id: str, file: UploadFile = File(...)):
else:
segments = result.segments
prior_segments = [
segment for segment in (job.get("segments") or []) if isinstance(segment, dict)
]
segments, segment_clones = _carry_srt_voice_metadata(
segments,
prior_segments,
job.get("segment_clones"),
job.get("speaker_clones"),
)
job["segments"] = segments
job["segment_clones"] = segment_clones
# A pooled speaker clone is keyed only by a display label. Replacement
# cues can reuse that label without overlapping the original speaker, so
# retain matched pooled references as segment-specific clones above and
# drop the global map before rebuilding the cast.
job["speaker_clones"] = {}
if segment_clones:
from services.speaker_clone import build_cast_sources
job["cast_sources"] = build_cast_sources(
segments,
None,
segment_clones,
)
else:
job.pop("cast_sources", None)
# `source_lang` stays whatever the user (or the upload step) set; we
# don't try to language-detect off the cue text — that's noisy and the
# user usually knows what their .srt is.
@@ -452,13 +351,12 @@ async def preview_upload(video: UploadFile = File(...)):
safe_name = f"{uuid.uuid4().hex[:12]}"
vid_path = os.path.join(PREVIEW_DIR, f"{safe_name}{ext}")
wav_path = os.path.join(PREVIEW_DIR, f"{safe_name}.wav")
payload = await video.read()
def _write_and_extract() -> bool:
with open(vid_path, "wb") as f:
f.write(payload)
if ext in {".wav", ".mp3", ".m4a", ".aac"}:
return False
with open(vid_path, "wb") as f:
f.write(await video.read())
has_audio = False
if ext not in [".wav", ".mp3", ".m4a", ".aac"]:
try:
ffmpeg_cmd = [
find_ffmpeg(), "-y", "-i", vid_path,
@@ -470,16 +368,10 @@ async def preview_upload(video: UploadFile = File(...)):
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
timeout=300,
)
return True
has_audio = True
except Exception as e:
logger.warning("FFmpeg extraction failed: %s", log_safe(e))
return False
# File writes and ffmpeg are blocking operations. Keep them on the bounded
# CPU pool so a large preview cannot stall unrelated API requests (#1667).
has_audio = await asyncio.get_running_loop().run_in_executor(
_cpu_pool, _write_and_extract
)
pass
return {
"url": f"/preview/{safe_name}{ext}",
@@ -518,52 +410,12 @@ _ingest_gen = dub_pipeline.ingest_pipeline
#: container so a mislabelled video can't slip past the video-skipping branch.
_AUDIO_EXTS = {".wav", ".mp3", ".m4a", ".aac", ".flac", ".ogg", ".opus", ".wma"}
# Source-language choices exposed by the first-party dub UI, plus every
# language code Whisper can write back after auto-detection. A restored job
# may reuse that detected value as the next upload's override, so rejecting our
# own persisted codes strands otherwise valid dubbing sessions (#1737).
# Keeping this an allow-list still rejects language names and private-use
# BCP-47 tags. Values are normalized to lowercase below.
_DUB_SOURCE_LANG_CODES = frozenset({
"af", "sq", "am", "ar", "hy", "az", "eu", "be", "bn", "bs", "bg",
"my", "ca", "cmn-hans", "cmn-hant", "hr", "cs", "da", "nl", "en",
"et", "fi", "fr", "gl", "ka", "de", "el", "gu", "ht", "ha", "haw",
"he", "hi", "hu", "is", "id", "it", "ja", "jw", "kn", "kk", "km",
"ko", "ku", "ky", "lo", "la", "lv", "lt", "mk", "ms", "ml", "mt",
"mi", "mr", "mn", "ne", "no", "ps", "fa", "pl", "pt", "pa", "ro",
"ru", "sm", "gd", "sr", "sn", "sd", "si", "sk", "sl", "so", "es",
"su", "sw", "sv", "tg", "ta", "te", "th", "tr", "uk", "ur", "uz",
"vi", "cy", "xh", "yi", "yo", "zu",
"as", "ba", "bo", "br", "fo", "lb", "ln", "mg", "nn", "oc", "sa",
"tk", "tl", "tt", "yue", "zh",
})
def _source_lang_override(value: str | None) -> str | None:
"""Normalize a user-selected source language; auto/und means detect."""
code = (value or "").strip().lower()
if code in {"", "auto", "und"}:
return None
if code not in _DUB_SOURCE_LANG_CODES:
raise HTTPException(status_code=400, detail="Invalid source language code")
return code
def _detected_source_lang(value: str | None) -> str:
"""Normalize an ASR language without truncating valid three-letter codes."""
code = (value or "en").split("_", 1)[0].strip().lower()
if code in _DUB_SOURCE_LANG_CODES:
return code
short = code[:2]
return short if short in _DUB_SOURCE_LANG_CODES else "en"
@router.post("/dub/upload")
async def dub_upload(
video: UploadFile = File(...),
job_id: Optional[str] = Form(None),
input_type: str = Form("video"),
source_lang: Optional[str] = Form(None),
):
"""Accept a media upload, write to disk, queue background prep task.
@@ -593,7 +445,6 @@ async def dub_upload(
detail=f"Audio-only dubbing needs an audio file ({', '.join(sorted(_AUDIO_EXTS))}); got '{ext or 'no extension'}'.",
)
source_lang_override = _source_lang_override(source_lang)
os.makedirs(job_dir, exist_ok=True)
video_path = os.path.join(job_dir, f"original{ext}")
@@ -605,13 +456,7 @@ async def dub_upload(
await task_manager.add_task(
task_id, "prep",
_ingest_gen, job_id, job_dir,
{
"kind": "file",
"path": video_path,
"input_type": input_type,
"source_lang": source_lang_override,
},
filename,
{"kind": "file", "path": video_path, "input_type": input_type}, filename,
)
return JSONResponse(
status_code=202,
@@ -633,7 +478,6 @@ async def dub_ingest_url(req: DubIngestUrlRequest, request: Request):
status_code=400,
detail="URL must start with http:// or https://. Paste a full video link (e.g. https://youtube.com/watch?v=…) or drop a local file instead.",
)
source_lang_override = _source_lang_override(req.source_lang)
try:
import yt_dlp # noqa: F401
@@ -669,7 +513,6 @@ async def dub_ingest_url(req: DubIngestUrlRequest, request: Request):
"fetch_subs": bool(req.fetch_subs),
"sub_langs": req.sub_langs or None,
"cookie_file": cookie_path,
"source_lang": source_lang_override,
}
try:
await task_manager.add_task(
@@ -714,7 +557,7 @@ _prep_event_helper = dub_pipeline.prep_event # alias; we keep the module-local
#: into one reference, which is how "made up" clone voices happen).
CLONE_SKIP_HEURISTIC_MSG = (
"auto voice cloning skipped: speaker labels are gap-based estimates — "
"set up diarization (Model Catalogue → Models → pyannote) for per-speaker clones"
"set up diarization (Settings → Models → pyannote) for per-speaker clones"
)
@@ -734,118 +577,6 @@ def _clamp_num_speakers(value) -> Optional[int]:
return value if 1 <= value <= 20 else None
def _recover_from_phrase_embeddings(
diar_pipe,
diarized_segments: list[dict],
*,
phrases: list[dict],
requested_speakers: int | None,
audio_target: str,
segments: list[dict],
words: list,
):
"""Recover rapid turns when pyannote collapses a two-speaker exchange.
Uses ASR phrase boundaries and the embedding/audio components already
loaded by speaker-diarization-3.1. Weak or imbalanced clusters are rejected
so ordinary single-speaker recordings remain untouched. Returns
``(segments, separation)`` or ``None``.
"""
present = {
str(seg.get("speaker_id")) for seg in diarized_segments
if seg.get("speaker_id")
}
if len(present) > 1:
return None
usable_phrases = [
phrase for phrase in phrases
if phrase.get("text")
and float(phrase.get("end", 0.0)) - float(phrase.get("start", 0.0)) >= 0.75
]
if len(usable_phrases) < 4:
return None
requested = int(requested_speakers) if requested_speakers else 2
if requested != 2:
return None
embedding = getattr(diar_pipe, "_embedding", None)
audio = getattr(diar_pipe, "_audio", None)
if embedding is None or audio is None:
return None
try:
import numpy as np
from pyannote.core import Segment as _PyannoteSegment
from sklearn.cluster import AgglomerativeClustering
vectors = []
durations = []
for phrase in usable_phrases:
start, end = float(phrase["start"]), float(phrase["end"])
duration = end - start
waveform, _ = audio.crop(
audio_target, _PyannoteSegment(start, end),
duration=duration, mode="pad",
)
vector = np.asarray(embedding(waveform[None])).reshape(-1)
if not np.isfinite(vector).all():
return None
vectors.append(vector)
durations.append(duration)
matrix = np.vstack(vectors)
labels = np.asarray(AgglomerativeClustering(
n_clusters=2, metric="cosine", linkage="average",
).fit_predict(matrix))
if len(set(labels.tolist())) != 2:
return None
counts = [int(np.sum(labels == cluster)) for cluster in (0, 1)]
cluster_durations = [
float(sum(duration for duration, label in zip(durations, labels) if label == cluster))
for cluster in (0, 1)
]
if min(counts) < 2 or min(cluster_durations) < 1.5:
return None
normalized = matrix / np.maximum(np.linalg.norm(matrix, axis=1, keepdims=True), 1e-8)
similarities = normalized @ normalized.T
within, cross = [], []
for left in range(len(labels)):
for right in range(left + 1, len(labels)):
target = within if labels[left] == labels[right] else cross
target.append(float(similarities[left, right]))
if not within or not cross:
return None
separation = float(np.mean(within) - np.mean(cross))
min_separation = 0.12 if requested_speakers == 2 else 0.18
if separation < min_separation:
logger.info(
"phrase-embedding speaker recovery rejected (separation=%.3f < %.3f)",
separation, min_separation,
)
return None
speaker_map = {}
turns = []
for phrase, label in zip(usable_phrases, labels.tolist()):
if label not in speaker_map:
speaker_map[label] = f"Speaker {len(speaker_map) + 1}"
turns.append({
"start": float(phrase["start"]),
"end": float(phrase["end"]),
"speaker": speaker_map[label],
})
# Assignment mutates segment dictionaries. Work on copies so a recovery
# rejected by the final two-speaker check cannot leak partial labels
# into the ordinary pyannote result.
assigned = assign_speakers_from_turns([dict(item) for item in segments], turns)
recovered = resplit_segments_by_turns(assigned, words, turns)
if len({item.get("speaker_id") for item in recovered if item.get("speaker_id")}) < 2:
return None
return recovered, separation
except Exception:
logger.exception("phrase-embedding speaker recovery failed")
return None
@router.get("/dub/transcribe-stream/{job_id}")
async def dub_transcribe_stream(
job_id: str,
@@ -1038,16 +769,6 @@ async def dub_transcribe_stream(
preflight_payload = _missing
if _missing is None:
try:
# Free recoverable TTS VRAM before ASR chooses its
# device. Probing first falsely routed Whisper to
# CPU even when this offload made CUDA viable.
try:
await asyncio.get_running_loop().run_in_executor(
_cpu_pool, offload_tts_for_asr
)
_tts_offloaded["v"] = True
except Exception as e:
logger.warning("offload_tts_for_asr failed (continuing): %s", e)
# The PyTorch-Whisper backend lazily builds its own pipeline
# when no preloaded `_asr_pipe` is present (issue #255), so it
# no longer needs OMNIVOICE_PRELOAD_TTS_ASR=1.
@@ -1177,20 +898,24 @@ async def dub_transcribe_stream(
"chunk_s": transcribe_chunk_s,
})
# Free VRAM: move TTS model to CPU so WhisperX + VAD can fit.
# Only offloads when free GPU memory is < 4 GB (e.g. laptop GPUs).
# Non-fatal: an offload failure must not drop the stream (#255) —
# transcription can still proceed (it just has less headroom).
try:
await loop.run_in_executor(_cpu_pool, offload_tts_for_asr)
# Restore is now owed on every exit path, not just success (#1191).
_tts_offloaded["v"] = True
except Exception as e:
logger.warning("offload_tts_for_asr failed (continuing): %s", e)
all_segments: list[dict] = []
# Words (global-timeline) retained so diarization can re-split a segment
# that spans two speakers' turns at the word boundary (#486).
all_words: list = []
# Preserve the ASR backend's natural phrase boundaries before
# segment_transcript merges short neighboring phrases. Pyannote 3.1
# occasionally collapses rapid exchanges into one dominant speaker; in
# that narrow case these phrase spans give its own WeSpeaker embedding
# model clean candidate utterances for a conservative recovery pass.
asr_phrase_segments: list[dict] = []
detected_lang = None
next_seg_id = 0
chunk_errors: list[str] = []
chunk_error_codes: list[str] = []
# Speaker turns from an ASR backend that diarizes inline (FunASR cam++).
# When present, _diarize() uses them and skips pyannote (Phase 2, #182).
asr_speaker_turns: list[dict] = []
@@ -1235,20 +960,10 @@ async def dub_transcribe_stream(
continue
turns.append({"start": s0 + offset, "end": s1 + offset, "speaker": spk})
return {"chunks": shifted, "language": r.get("language"), "speaker_turns": turns}
except Exception as exc:
# Keep diagnostics local and fixed-shape. In particular,
# CUDA OOM is a distinct, actionable recovery class rather
# than the generic "no segments" dead end.
is_memory = isinstance(exc, torch.OutOfMemoryError)
logger.error(
"Chunk transcription failed (backend=%s; class=%s; details withheld)",
_asr_backend.id,
type(exc).__name__,
)
except Exception:
logger.error("Chunk transcription failed (backend=%s)", _asr_backend.id)
from core.public_errors import stream_failure
failure = stream_failure(
"transcription_memory" if is_memory else "transcription_failed"
)
failure = stream_failure("transcription_failed")
return {
"chunks": [],
"language": None,
@@ -1256,13 +971,22 @@ async def dub_transcribe_stream(
"error_code": failure["code"],
}
# Retry an ordinary completed failure once. A timed-out native call
# is different: its thread is still executing and must not overlap
# a retry against the same backend (#1669).
# Retry a failed/timed-out chunk once on a fresh pool before giving
# up. Otherwise a transient wedge on the FIRST chunk (whisperx often
# cold-loads its model there, the #730 hang) drops that whole window
# and the transcript is "missing the beginning, only middle+end".
# The retry reuses the same audio window, so a recovered chunk fills
# the hole instead of leaving silent gaps.
part = None
timed_out = False
for _attempt in range(1, _CHUNK_TRANSCRIBE_ATTEMPTS + 1):
# Run as a task and poll so pings keep the EventSource alive.
# 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}",
@@ -1277,12 +1001,10 @@ async def dub_transcribe_stream(
try:
part = task.result()
except ASRTimeoutError:
# Python cannot kill an in-process native transcribe. Do
# not swap pools and retry over the still-running call:
# concurrent whisperx/CTranslate2 access caused the native
# Windows access violation in #1669. Stop this transcript;
# the worker remains honestly occupied until it exits.
timed_out = True
# 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_timeout_s, _attempt,
@@ -1301,36 +1023,20 @@ async def dub_transcribe_stream(
# error-part; the timeout path already reset the pool).
if part is not None and not part.get("error"):
break
if timed_out:
break
if not timed_out and _attempt < _CHUNK_TRANSCRIBE_ATTEMPTS:
if _attempt < _CHUNK_TRANSCRIBE_ATTEMPTS:
logger.warning(
"Retrying transcribe chunk %d/%d after failure/timeout (next attempt %d/%d, job=%s)",
i + 1, chunks_n, _attempt + 1, _CHUNK_TRANSCRIBE_ATTEMPTS, log_safe(job_id),
)
# A completed exception did not leave native work behind,
# so retrying this same audio window is safe.
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"])
if part.get("error_code"):
chunk_error_codes.append(part["error_code"])
logger.warning("Chunk %d/%d error: %s", i + 1, chunks_n, log_safe(part["error"]))
if timed_out:
break
if detected_lang is None and part.get("language"):
detected_lang = part["language"]
asr_speaker_turns.extend(part.get("speaker_turns") or [])
for _phrase in part.get("chunks", []) or []:
_pts = _phrase.get("timestamp") or (None, None)
_ptext = (_phrase.get("text") or "").strip()
try:
_ps, _pe = float(_pts[0]), float(_pts[1])
except (TypeError, ValueError, IndexError):
continue
if _ptext and _pe > _ps:
asr_phrase_segments.append({
"start": _ps, "end": _pe, "text": _ptext,
})
chunk_segs = segment_transcript(part, duration=t1, scene_cuts=scene_cuts)
# Same word source segment_transcript used (already global-timeline),
# kept for the post-diarization speaker re-split (#486).
@@ -1391,9 +1097,7 @@ async def dub_transcribe_stream(
seen.add(s)
uniq.append(s)
if uniq:
# Chunk failures already carry a complete recovery message.
# Do not prepend another generic sentence to it.
detail = " | ".join(uniq[:3])
detail = "Transcription produced no segments. " + " | ".join(uniq[:3])
# Add the actionable hint for a recognized failure class
# (e.g. pkg_resources missing → install setuptools).
hint = build_failure(" ".join(uniq), stage="transcribe", include_diagnostic=False).get("hint")
@@ -1406,10 +1110,7 @@ async def dub_transcribe_stream(
"check that the source has an audible speech track."
)
logger.error("transcribe yielded 0 segments (job=%s): %s", log_safe(job_id), log_safe(detail))
payload = {"detail": detail, "retryable": True}
if chunk_error_codes:
payload["code"] = chunk_error_codes[0]
yield _sse_event("error", payload)
yield _sse_event("error", {"detail": detail, "retryable": True})
yield _sse_event("done", {})
return
@@ -1486,7 +1187,7 @@ async def dub_transcribe_stream(
f"unavailable, so the ASR engine's built-in speaker "
f"turns were used and the detected count may differ "
f"from the {num_speakers} you set. Set up diarization "
f"(Model Catalogue → Models → pyannote) to enforce an exact "
f"(Settings → Models → pyannote) to enforce an exact "
f"speaker count."
)
return resplit, {
@@ -1596,25 +1297,7 @@ 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).
resplit = resplit_segments_by_diarization(assigned, all_words, diar)
recovered = _recover_from_phrase_embeddings(
diar_pipe,
resplit,
phrases=asr_phrase_segments,
requested_speakers=num_speakers,
audio_target=asr_audio_target,
segments=all_segments,
words=all_words,
)
if recovered is not None:
recovered_segments, separation = recovered
logger.info(
"Recovered rapid two-speaker exchange from ASR phrase embeddings "
"(phrases=%d, separation=%.3f).",
len(asr_phrase_segments), separation,
)
return recovered_segments, None, "phrase_embeddings"
return resplit, None, "pyannote"
return resplit_segments_by_diarization(assigned, all_words, diar), None, "pyannote"
except Exception as e:
logger.exception("Diarization failed")
# Inline ASR turns beat the silence-gap heuristic as a crash
@@ -1685,11 +1368,7 @@ async def dub_transcribe_stream(
# new target language and have the ORIGINAL speaker speak it — the
# central pro-grade dubbing promise.
try:
from services.speaker_clone import (
auto_profile_id,
build_cast_sources,
extract_speaker_clones,
)
from services.speaker_clone import extract_speaker_clones, auto_profile_id
vocals_for_clone = job.get("vocals_path") or asr_audio_target
clones = {}
if labels_source == "heuristic":
@@ -1791,9 +1470,7 @@ async def dub_transcribe_stream(
except Exception as e:
logger.warning("per-segment clone refs skipped: %s", e)
cast_sources = build_cast_sources(final_segs, clones, seg_clones)
job["cast_sources"] = cast_sources
if cast_sources:
if clones or seg_clones:
if clones:
job["speaker_clones"] = clones
# Default each segment's profile_id to its detected speaker's
@@ -1815,17 +1492,20 @@ async def dub_transcribe_stream(
if s.get("profile_id"):
continue
spk = s.get("speaker_id") or "Speaker 1"
if spk in cast_sources:
# Keep one UI-visible value for pooled and per-segment
# sources. Generation resolves this line's own clip
# first and falls back to the speaker's best clip.
if spk in clones:
s["profile_id"] = auto_profile_id(spk)
continue
# No per-speaker clone for this speaker (too little usable
# audio overall) but this single line was long enough for
# its own ref — fall back to the per-segment id. The editor
# can't render it, but generation still clones correctly.
sid = str(s.get("id", ""))
if sid and sid in seg_clones:
s["profile_id"] = f"auto-seg:{sid}"
except Exception as e:
logger.warning("speaker_clone extraction skipped: %s", e)
job["source_lang"] = job.get("source_lang_override") or _detected_source_lang(
detected_lang
)
job["source_lang"] = ((detected_lang or "en").split("_")[0][:2] or "en").lower()
job["full_transcript"] = " ".join(s.get("text", "") for s in final_segs)
_save_job(job_id, job)
@@ -1854,10 +1534,7 @@ async def dub_transcribe_stream(
"segments": final_segs,
"source_lang": job["source_lang"],
"full_transcript": job["full_transcript"],
# The client only needs labels and durations. Never send host
# paths or reference transcripts through this public event.
"speaker_clones": job.get("cast_sources", {}),
"cast_sources": job.get("cast_sources", {}),
"speaker_clones": job.get("speaker_clones", {}),
})
yield _sse_event("done", {})
@@ -1990,11 +1667,8 @@ async def dub_transcribe(job_id: str, num_speakers: Optional[int] = None):
# / mlx / pytorch based on what's installed + user preference. Works
# identically on all platforms; the older mlx-vs-pytorch branching
# here duplicated the logic in asr_backend.py and skipped WhisperX.
# `load_*`, not `get_*`: the plain selector hands back engines whose
# shallow probe passed but whose deep import chain is broken, which
# then dies at `.transcribe()`. The loader degrades (#1185).
from services.asr_backend import load_active_asr_backend
_asr = load_active_asr_backend(asr_pipe=getattr(_model, "_asr_pipe", None))
from services.asr_backend import get_active_asr_backend
_asr = get_active_asr_backend(asr_pipe=getattr(_model, "_asr_pipe", None))
try:
try:
logger.info("Transcribing full audio via %s ...", _asr.id)
@@ -2022,9 +1696,7 @@ async def dub_transcribe(job_id: str, num_speakers: Optional[int] = None):
except Exception as e:
logger.warning("Failed to unload ASR backend: %s", e)
job["source_lang"] = job.get("source_lang_override") or _detected_source_lang(
detected_lang
)
job["source_lang"] = (detected_lang or "en").split("_")[0][:2].lower()
scene_cuts = job.get("scene_cuts") or []
segments = segment_transcript(result, duration=job.get("duration", 0.0), scene_cuts=scene_cuts)
@@ -2080,8 +1752,7 @@ async def dub_transcribe(job_id: str, num_speakers: Optional[int] = None):
# Bound the whole-file transcribe (#730): a wedged whisperx/CTranslate2
# call would otherwise hold its GPU-pool worker forever and starve
# every other request into a "can't reach backend". run_transcribe_guarded
# leaves an unkillable native worker accounted for on timeout so a
# retry cannot overlap it (#1669).
# also resets the pool on timeout so capacity is restored.
segments_result = await run_transcribe_guarded(_gpu_pool, _transcribe, what="Dub")
except asyncio.CancelledError:
job["aborted"] = True
+20 -164
View File
@@ -23,7 +23,6 @@ from services.ffmpeg_utils import (
find_ffmpeg,
run_ffmpeg,
)
from services.karaoke_ass import build_ass, scale_words
from services.video_retime import (
DRIFT_TOLERANCE_S,
RetimeError,
@@ -404,27 +403,6 @@ def _write_burn_srt(job: dict, exports_dir: str, stamp: str, dual: bool,
return sub_path
def _write_burn_ass(job: dict, exports_dir: str, stamp: str,
fitted_segments: "list[dict] | None" = None,
lang: "str | None" = None) -> str | None:
"""Karaoke variant of ``_write_burn_srt``: word-timed ASS via ``build_ass``.
Same text/timing resolution (``_segments_for_lang`` + fitted-cue overlay,
which also scales per-word times onto the fitted timeline); the basename
is plain ASCII under exports_dir so it is ffmpeg-filter-safe. Returns
None if there are no segments to render.
"""
segments = _segments_for_lang(job, lang)
if not segments:
return None
if fitted_segments:
segments = _apply_fitted_times(segments, fitted_segments)
sub_path = os.path.join(exports_dir, f"burn_subs_{stamp}.ass")
with open(sub_path, "w", encoding="utf-8") as f:
f.write(build_ass(segments))
return sub_path
def _ffmpeg_filter_escape(path: str) -> str:
"""Escape a path for use inside an ffmpeg filter value (subtitles=...).
@@ -537,20 +515,6 @@ def _apply_fitted_times(segments: list[dict], fitted: list[dict]) -> list[dict]:
patched = dict(seg)
patched["start"] = float(cue["start"])
patched["end"] = float(cue["end"])
# Karaoke burn-in: persisted word times live on the original timeline;
# scale them linearly onto the fitted cue span so the highlight sweep
# follows the retimed audio. Degenerate spans drop the words — export
# then falls back to an even split over the fitted span. Inert for
# SRT/VTT, which never read ``words``.
if isinstance(seg.get("words"), list) and seg.get("words"):
scaled = scale_words(
seg["words"], seg.get("start", 0.0), seg.get("end", 0.0),
patched["start"], patched["end"],
)
if scaled is not None:
patched["words"] = scaled
else:
patched.pop("words", None)
out.append(patched)
return out
@@ -608,12 +572,11 @@ def _build_audio_export_cmd(
async def dub_download(
job_id: str,
preserve_bg: bool = Query(True, description="Mix background noise into dubbed tracks"),
default_track: str = Query("", description="Default audio track; omitted selects the first dubbed track"),
default_track: str = Query("original"),
include_tracks: str = Query("", description="Comma-separated list of tracks to include (e.g. 'original,de,es'). Empty = include all."),
save_authorization: str = Header("", alias="X-VoiceStudio-Path-Authorization"),
burn_subs: bool = Query(False, description="Burn subtitles into the video stream (forces re-encode). Uses dual-subtitle layout when dual=1."),
dual: bool = Query(False, description="When burn_subs=1, render translated on top of italicised original."),
karaoke: bool = Query(False, description="When burn_subs=1, burn a word-timed karaoke highlight (ASS) instead of line subtitles. Ignored when dual=1 (dual karaoke is unsupported — the line burn renders instead)."),
out_format: str = Query("m4a", description="Audio-only jobs (#119): output container — wav, m4a, mp3, or flac. Ignored for video jobs."),
):
# Strict allowlist on the path param BEFORE it reaches any filesystem
@@ -644,18 +607,6 @@ async def dub_download(
for key, value in filtered_tracks.items()
}
# A dub export should play the dub without requiring player-specific track
# selection. Keep ``original`` as an explicit opt-in, but when callers omit
# the preference choose the first generated dub consistently (#1575).
if (
filtered_tracks
and not (default_track == "original" and include_original)
and default_track not in filtered_tracks
):
default_track = next(iter(filtered_tracks))
elif not filtered_tracks and include_original:
default_track = "original"
if not filtered_tracks and not include_original:
raise HTTPException(status_code=400, detail="No tracks selected for export")
@@ -680,17 +631,12 @@ async def dub_download(
fmt = (out_format or "m4a").lower()
if fmt not in _AUDIO_FORMAT_CODECS:
fmt = "m4a"
# Keep route/job data out of the filesystem and logging trust boundary.
# The selected format reaches the path only through literal branches.
if fmt == "wav":
output_name = f"dubbed_audio_{stamp}.wav"
elif fmt == "mp3":
output_name = f"dubbed_audio_{stamp}.mp3"
elif fmt == "flac":
output_name = f"dubbed_audio_{stamp}.flac"
else:
output_name = f"dubbed_audio_{stamp}.m4a"
out_path = os.path.join(exports_dir, output_name)
# lang_code is already constrained to an existing track key, but
# allowlist-sanitize it before it reaches the output path so a path
# component can never carry separators/traversal (same pattern as
# safe_name below).
safe_lang = "".join(c for c in lang_code if c.isalnum() or c in "-_") or "track"
out_path = os.path.join(exports_dir, f"dubbed_audio_{safe_lang}_{stamp}.{fmt}")
bg = _optional_dub_artifact(job.get("no_vocals_path"), job_id) if preserve_bg else None
cmd = _build_audio_export_cmd(ffmpeg, track_info["path"], bg, out_path, fmt)
try:
@@ -708,28 +654,15 @@ async def dub_download(
)
if not os.path.exists(out_path) or os.path.getsize(out_path) == 0:
raise HTTPException(status_code=500, detail="ffmpeg audio export produced no output file")
logger.info("Dub audio export completed (%d bytes)", os.path.getsize(out_path))
logger.info("Dub audio export wrote %s (%d bytes)", out_path, os.path.getsize(out_path))
# Response metadata must not become a second path-like sink for job or
# request data. Keep the user-selected format through explicit literal
# branches; source names and language keys never enter the label.
if fmt == "wav":
dl_name = f"dubbed_audio_{stamp}.wav"
elif fmt == "mp3":
dl_name = f"dubbed_audio_{stamp}.mp3"
elif fmt == "flac":
dl_name = f"dubbed_audio_{stamp}.flac"
else:
dl_name = f"dubbed_audio_{stamp}.m4a"
base_name = os.path.splitext(job.get("filename", "output"))[0]
safe_name = "".join(c for c in base_name if c.isalnum() or c in "-_ ").strip() or "output"
dl_name = f"dubbed_{safe_name}_{safe_lang}_{stamp}.{fmt}"
media_type = _MEDIA_TYPES.get(f".{fmt}", "audio/mp4")
save_path = _consume_native_save(save_authorization)
if save_path:
# Keep the request-derived download label out of the filesystem
# trust boundary. It is response metadata, not a source or
# destination path (CodeQL, #1575).
result = _native_save(out_path, save_path, "dubbed_audio", media_type=media_type)
result["display_name"] = dl_name
return result
return _native_save(out_path, save_path, dl_name, media_type=media_type)
return FileResponse(
out_path, media_type=media_type,
headers={"Content-Disposition": content_disposition(dl_name)},
@@ -766,18 +699,7 @@ async def dub_download(
fitted_segments = _fitted_segments_for(job, default_track) if default_track and default_track != "original" else None
# Burn the DEFAULT track's text (P1.2) — it's the audio the viewer hears.
_burn_lang = default_track if default_track and default_track != "original" else None
# Karaoke (word-highlight) burn writes an ASS instead of the line SRT.
# Dual layout keeps the line burn — dual karaoke is out of scope, matching
# the disabled control in the Export drawer. The default (karaoke off)
# takes exactly the legacy SRT path.
sub_path = None
sub_is_ass = False
if burn_subs:
if karaoke and not dual:
sub_path = _write_burn_ass(job, exports_dir, stamp, fitted_segments=fitted_segments, lang=_burn_lang)
sub_is_ass = sub_path is not None
if sub_path is None:
sub_path = _write_burn_srt(job, exports_dir, stamp, dual, fitted_segments=fitted_segments, lang=_burn_lang)
sub_path = _write_burn_srt(job, exports_dir, stamp, dual, fitted_segments=fitted_segments, lang=_burn_lang) if burn_subs else None
# ── Smart Fit video retime (two-tier) ─────────────────────────────────
# Tier 1 (≤48 chunks): single filter_complex graph inlined into the mux
@@ -877,16 +799,14 @@ async def dub_download(
esc = _ffmpeg_filter_escape(sub_path)
# Burn AFTER any retime so cues (already on the fitted timeline for
# Smart Fit) land on the retimed video. Without retime this reduces
# to the legacy `[0:v]subtitles=…[vsub]` graph. Karaoke burns the
# word-timed ASS through the ass filter at the same graph position.
# to the legacy `[0:v]subtitles=…[vsub]` graph.
if video_map.startswith("["):
sub_src = video_map
elif retimed_idx is not None:
sub_src = f"[{retimed_idx}:v]"
else:
sub_src = "[0:v]"
_sub_filter = "ass" if sub_is_ass else "subtitles"
filter_parts.append(f"{sub_src}{_sub_filter}='{esc}'[vsub]")
filter_parts.append(f"{sub_src}subtitles='{esc}'[vsub]")
video_map = "[vsub]"
if stretch_entry:
orig_dur = float(stretch_entry.get("orig_duration") or job.get("duration") or 0.0)
@@ -967,10 +887,7 @@ async def dub_download(
if default_track == "original" and include_original:
cmd += ["-disposition:a:0", "default"]
else:
# A stale/missing language preference still means "play a dub", not
# "silently fall back to the source". The first processed dub is the
# deterministic fallback; ``original`` above remains explicit.
target_idx = tracks_to_process[0]["stream_idx"] if tracks_to_process else 0
target_idx = 0
for t in tracks_to_process:
if t['lang_code'] == default_track:
target_idx = t["stream_idx"]
@@ -1517,20 +1434,13 @@ async def dub_qc_pass(job_id: str, lang: str = Query(None), drift_threshold: flo
)
def _recognize():
# `load_*`, not `get_*`: the plain selector returns engines whose
# shallow probe passed but whose deep import chain is broken, which
# then 500s at `.transcribe()`. The loader degrades (#1185).
from services.asr_backend import load_active_asr_backend
backend = load_active_asr_backend()
from services.asr_backend import get_active_asr_backend
backend = get_active_asr_backend()
result = backend.transcribe(wav_path, word_timestamps=False)
return result.get("segments", []), backend.id
try:
from services.asr_backend import (
ASRModelMissingError,
ASRTimeoutError,
run_transcribe_guarded,
)
from services.asr_backend import ASRTimeoutError, run_transcribe_guarded
from services.model_manager import _get_gpu_pool
recognized, engine_id = await run_transcribe_guarded(
_get_gpu_pool(), _recognize, what="QC",
@@ -1539,13 +1449,6 @@ async def dub_qc_pass(job_id: str, lang: str = Query(None), drift_threshold: flo
# Backend is alive; ASR just couldn't finish in time. 504, not 500/connection.
logger.warning("dub QC ASR pass timed out")
raise HTTPException(status_code=504, detail=str(e))
except ASRModelMissingError as e:
# Degraded onto an engine with no weights on disk — typed 409 with the
# download CTA, matching the preflight above.
raise HTTPException(
status_code=409,
detail={**e.payload, "message": asr_model_missing_detail(e.payload)},
)
except Exception as e:
logger.exception("dub QC ASR pass failed")
raise HTTPException(status_code=500, detail=f"QC transcription failed: {e}")
@@ -1649,10 +1552,7 @@ async def dub_download_audio(
return _native_save(wav_path, save_path, dl_name, media_type="audio/wav")
return FileResponse(
wav_path, media_type="audio/wav",
headers={
"Cache-Control": "no-store",
"Content-Disposition": content_disposition(dl_name),
},
headers={"Content-Disposition": content_disposition(dl_name)},
)
@@ -1794,50 +1694,6 @@ async def dub_export_vtt(
)
@router.get("/dub/ass/{job_id}")
@router.get("/dub/ass/{job_id}/{filename}")
async def dub_export_ass(
job_id: str,
lang: str = Query(None, description="Track language code. Same text/timing resolution as /dub/srt, rendered as a karaoke (word-highlight) ASS sidecar."),
):
"""Karaoke ASS sidecar — the same script the karaoke burn-in renders.
Raw text body like /dub/srt and /dub/vtt (the Tauri side writes the file
itself; no ?save_path= variant see the comment above /dub/srt).
"""
_job_dir_or_400(job_id)
lang = _safe_lang_or_400(lang)
job = _get_job(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
segments = _segments_for_lang(job, lang)
if not segments:
raise HTTPException(status_code=400, detail="No transcript segments available")
# Same strategy-aware cue timing as /dub/srt. The fitted overlay also
# scales word times; the stretch_video cue path has no per-word record,
# so words are dropped and build_ass even-splits over the new spans.
fitted = _fitted_segments_for(job, lang)
if fitted:
segments = _apply_fitted_times(segments, fitted)
else:
cues = _fitted_cue_times(job, lang)
if cues:
segments = [
{**{k: v for k, v in seg.items() if k != "words"}, "start": s, "end": e}
for seg, (s, e) in zip(segments, cues)
]
base_name = os.path.splitext(job.get('filename', 'video'))[0]
dl_name = f"subtitles_{base_name}_karaoke.ass"
return Response(
content=build_ass(segments),
media_type="text/plain",
headers={"Content-Disposition": content_disposition(dl_name)},
)
@router.get("/dub/export-segments/{job_id}")
async def dub_export_segments_zip(job_id: str, lang: str = Query(None)):
import zipfile
+57 -384
View File
@@ -1,12 +1,9 @@
import os
import re
import json
import struct
import logging
import time
import asyncio
import shutil
import zipfile
import torch
import torchaudio
from fastapi import APIRouter, HTTPException
@@ -16,8 +13,7 @@ from core.config import DUB_DIR, VOICES_DIR, dub_seg_path
from core.tasks import task_manager
from schemas.requests import DubRequest
from services.model_manager import _gpu_pool, run_on_gpu_pool_guarded
from services.tts_backend import resolve_generation_backend, active_backend_id
from services import gpu_gateway
from services.tts_backend import resolve_generation_backend
from services.audio_dsp import apply_mastering, normalize_audio, apply_effects_chain, get_effect_chain
from services.audio_io import atomic_save_wav, _safe_torchaudio_save
from services.ffmpeg_utils import (
@@ -33,7 +29,6 @@ from services.rvc import apply_rvc, is_enabled as rvc_is_enabled
from services.incremental import segment_fingerprint, fit_fingerprint
from services.fit_planner import UNDERRUN_TOLERANCE, FitParams, plan_fit
from services.watermark import mark_synthetic
from services.speaker_clone import auto_profile_id
from api.routers.dub_core import _get_job, _save_job
from omnivoice.utils.voice_design import heal_design_instruct
@@ -49,94 +44,6 @@ logger = logging.getLogger("omnivoice.dub")
MAX_STRETCH_RATIO = 1.8
def _prepare_oom_retry(error: Exception, *, execution_target: str) -> bool:
"""Prepare one *local* low-step retry after a genuine device OOM.
The cache being flushed must belong to the device that raised the error.
A remote worker owns its own recovery policy; flushing this process's CUDA
cache after a remote failure both stalls the wrong GPU and can evict an
unrelated local job. Keep this guard at the retry chokepoint so a future
``dub_segments`` producer cannot accidentally inherit the old behaviour.
Returns ``False`` for non-OOM errors. Remote OOMs are deliberately raised
unchanged: the worker may classify/retry them, but this process must not.
"""
is_oom = (
isinstance(error, torch.cuda.OutOfMemoryError)
or "out of memory" in str(error).lower()
or "CUDA error" in str(error)
)
if not is_oom:
return False
if execution_target != "local":
raise error
import gc
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
torch.mps.empty_cache()
return True
def _cached_payload_intact(path: str, info) -> bool:
"""Cheap truth check on a cached WAV whose header we are about to trust.
The natural-rate fast path hands the mixer a PATH instead of decoded
audio, so a cache whose header reads fine but whose payload is truncated
would only fail later, during assembly after the timing plan (Smart Fit,
video stretch) had been computed from the header's frame count. The plan
would then describe audio that no longer exists and the segment would be
replaced by slot-length silence, leaving the persisted video plan and the
rendered track disagreeing.
Comparing the declared frame count against the physical ``data`` chunk
catches that without decoding: a truncated file cannot hold the samples
its header claims. Anything failing here falls through to the decoding path, which
already degrades to a warning plus silence. Formats with no fixed
bits-per-sample (compressed caches) are left to the decoder as before.
"""
try:
bits = int(getattr(info, "bits_per_sample", 0) or 0)
frames = int(getattr(info, "num_frames", 0) or 0)
channels = int(getattr(info, "num_channels", 0) or 0)
if bits <= 0 or frames <= 0 or channels <= 0:
# Undecidable metadata fails CLOSED (review on #1620): these caches
# are PCM WAVs this module wrote itself, so anything else is
# unexpected — and the decode path this falls through to handles
# every format the fast path would have.
return False
payload = frames * channels * (bits // 8)
if payload <= 0:
return False
# A WAV may carry JUNK/LIST metadata before data, so its header is not
# necessarily 44 bytes. Locate the data chunk instead of counting
# metadata as audio; otherwise an extended header can mask truncation.
file_size = os.path.getsize(path)
with open(path, "rb") as wav:
header = wav.read(12)
if len(header) != 12 or header[:4] != b"RIFF" or header[8:12] != b"WAVE":
return False
offset = 12
while offset + 8 <= file_size:
wav.seek(offset)
chunk_id = wav.read(4)
chunk_size_raw = wav.read(4)
if len(chunk_id) != 4 or len(chunk_size_raw) != 4:
return False
chunk_size = struct.unpack("<I", chunk_size_raw)[0]
data_offset = offset + 8
if chunk_id == b"data":
return chunk_size >= payload and file_size >= data_offset + payload
offset = data_offset + chunk_size + (chunk_size % 2)
return False
except Exception: # noqa: BLE001 — an unstattable cache is the decoder's problem
return False
def _underrun_min_rate() -> float:
"""Floor for the underrun fill (audio slowed toward its slot, never below
this rate). Default 0.85 stays natural-sounding; OMNIVOICE_UNDERRUN_MIN_RATE=1.0
@@ -267,7 +174,7 @@ CONSISTENT_MIN_REF_S = 3.0
def _speaker_key_matches(speaker_id: str, key: str) -> bool:
"""Same matching rule the `auto:` branch has always used: the safe-name
slug first (`auto_profile_id`), the raw speaker id as fallback."""
return auto_profile_id(speaker_id) == f"auto:{key}" or speaker_id == key
return speaker_id.lower().replace(" ", "_") == key or speaker_id == key
def _find_speaker_clone(clones: dict, key: str):
@@ -292,7 +199,7 @@ def _speaker_key_for_segment(job: dict, sid) -> str | None:
for row in job.get("segments") or []:
if isinstance(row, dict) and str(row.get("id", "")) == str(sid):
spk = row.get("speaker_id") or "Speaker 1"
return auto_profile_id(spk)[len("auto:"):]
return spk.lower().replace(" ", "_")
return None
@@ -409,75 +316,6 @@ def resolve_consistent_ref(job: dict, speaker_key: str, memo: dict | None = None
return ref
def _remote_voice(job: dict, profile_id: str | None, seg_id, voice_match: str,
memo: dict) -> tuple[str | None, str | None, bool, str | None, int | None]:
"""Resolve a dub binding without touching the TTS model."""
ref_audio = ref_text = instruct = None
seed = None
single_use = False
if profile_id and profile_id.startswith("auto-seg:"):
sid = profile_id[len("auto-seg:"):]
info = (job.get("segment_clones") or {}).get(sid)
shared = False
if voice_match == "consistent" and sid == str(seg_id):
key = _speaker_key_for_segment(job, sid)
alternate = resolve_consistent_ref(job, key, memo) if key else None
if alternate:
info = alternate
shared = True
if info:
ref_audio, ref_text = info.get("ref_audio"), info.get("ref_text")
single_use = not shared
elif profile_id and profile_id.startswith("auto:"):
key = profile_id[len("auto:"):]
if voice_match == "consistent":
info = resolve_consistent_ref(job, key, memo)
else:
info = ((job.get("segment_clones") or {}).get(str(seg_id))
or _find_speaker_clone(job.get("speaker_clones") or {}, key))
single_use = str(seg_id) in (job.get("segment_clones") or {})
if info:
ref_audio, ref_text = info.get("ref_audio"), info.get("ref_text")
elif profile_id:
with db_conn() as conn:
row = conn.execute("SELECT * FROM voice_profiles WHERE id=?", (profile_id,)).fetchone()
if row:
seed = row["seed"]
if row["is_locked"] and row["locked_audio_path"]:
ref_audio = os.path.join(VOICES_DIR, row["locked_audio_path"])
ref_text = row["ref_text"]
elif row["instruct"] and not row["is_locked"]:
try:
vd_states = row["vd_states"]
except (KeyError, IndexError):
vd_states = None
instruct = heal_design_instruct(row["instruct"], vd_states)
else:
ref_audio = os.path.join(VOICES_DIR, row["ref_audio_path"])
ref_text = row["ref_text"]
return ref_audio, ref_text, single_use, instruct, seed
def _decode_remote_dub(result: gpu_gateway.RemoteResult) -> dict[int, str]:
"""Extract the worker bundle into a task-scoped directory, path-safely."""
target = os.path.join(DUB_DIR, ".remote", result.task_id)
os.makedirs(target, exist_ok=True)
paths: dict[int, str] = {}
with zipfile.ZipFile(result.path) as archive:
for member in archive.infolist():
match = re.fullmatch(r"segments/(\d+)\.wav", member.filename)
if not match:
raise ValueError(f"unexpected dub artifact member: {member.filename}")
index = int(match.group(1))
destination = os.path.join(target, f"{index}.wav")
partial = f"{destination}.part"
with archive.open(member) as source, open(partial, "wb") as output:
shutil.copyfileobj(source, output)
os.replace(partial, destination)
paths[index] = destination
return paths
router = APIRouter()
@router.post("/dub/generate/{job_id}")
@@ -492,7 +330,7 @@ async def dub_generate(job_id: str, req: DubRequest):
# ── Engine resolution (issue #312 class) ────────────────────────────────
# Dub used to hardcode VoiceStudio via get_model() regardless of the engine
# selected in Model Catalogue → Engines — a SILENT fallback. Every real dub
# selected in Settings → Engines — a SILENT fallback. Every real dub
# segment's ref_audio resolves to either an auto:<speaker>/auto-seg:<id>
# clone cut from the source video or a saved voice-profile row (see
# `_gen` below), so require_cloning=True: an engine that can't clone
@@ -503,18 +341,6 @@ async def dub_generate(job_id: str, req: DubRequest):
backend = await resolve_generation_backend(require_cloning=True)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
from core.failure import is_gpu_oom
if not is_gpu_oom(e):
raise
from core.public_errors import public_exception_response
payload = public_exception_response(
e,
fallback="The TTS model could not be loaded.",
)
raise HTTPException(status_code=503, detail=payload["detail"]) from e
async def _stream(task_id):
total = len(req.segments)
@@ -661,12 +487,11 @@ async def dub_generate(job_id: str, req: DubRequest):
# every segment of that speaker for the whole run.
voice_match = (req.voice_match or "per_line").lower()
_consistent_ref_memo: dict = {}
remote_audio: dict[int, str] = {}
# Strategy-transition guard: concise, stretch_video and smart_fit all
# re-mix *natural-rate* per-segment WAVs. If the previous run used
# strict_slot, the on-disk WAVs are slot-squeezed ("slotted") — the
# missing tails cannot be recovered by a re-mix. Force one full regen;
# afterwards partial regen / fit-only re-mix (regen_only=[]) is safe.
# Strategy-transition guard: smart_fit re-mixes the *natural-rate*
# per-segment WAVs from disk. If the previous run used strict_slot,
# the on-disk WAVs are slot-squeezed ("slotted") — reusing them would
# double-compress. Force one full regen; afterwards seg_wav_kind is
# "natural" and partial regen / fit-only re-mix (regen_only=[]) work.
# Jobs predating this field have unknown kind → also regen once.
# P1.3: the kind is per-track now (each language renders under its own
# strategy); the flat job["seg_wav_kind"] is only consulted for jobs
@@ -677,7 +502,7 @@ async def dub_generate(job_id: str, req: DubRequest):
_wav_kind = (
_kind_map.get(lang_code) if isinstance(_kind_map, dict) else job.get("seg_wav_kind")
)
if strategy != "strict_slot" and regen_only is not None and _wav_kind != "natural":
if strategy == "smart_fit" and regen_only is not None and _wav_kind != "natural":
regen_only = None
# Manifest: stable segment id per current index. Per-segment WAVs are
# named by stable id (dub_seg_path) so regen reuses the right audio after
@@ -702,86 +527,6 @@ async def dub_generate(job_id: str, req: DubRequest):
_t_cache = 0.0
_t_tts = 0.0
# One coarse remote lease for every segment that actually needs fresh
# synthesis. Assembly, fitting and the separately-pooled RVC pass stay
# here; the worker returns a single verified bundle of segment WAVs.
decision = gpu_gateway.decide("dub_segments")
if decision.remote:
remote_rows: list[dict] = []
remote_refs: list[str | None] = []
for i, seg in enumerate(req.segments):
seg_id = seg_ids[i] if i < len(seg_ids) else f"seg_{i}"
if (regen_only is not None and seg_id not in regen_only) or not seg.text.strip():
continue
ref_audio, ref_text, ref_single_use, profile_instruct, seed = _remote_voice(
job, seg.profile_id or None, seg_id, voice_match, _consistent_ref_memo
)
ref_audio = warn_if_ref_missing(
ref_audio, job_id=job_id, seg_id=seg_id, where="remote dub render"
)
seg_instruct = seg.instruct or req.instruct or profile_instruct
seg_speed = seg.speed if seg.speed is not None else req.speed
if seg.direction and seg.direction.strip():
try:
from services.director import parse as _parse_direction
direction = _parse_direction(seg.direction)
extra = direction.instruct_prompt()
if extra:
seg_instruct = f"{seg_instruct}, {extra}" if seg_instruct else extra
bias = direction.rate_bias()
if bias and abs(bias - 1.0) > 0.01 and strategy == "strict_slot":
seg_speed = (seg_speed or 1.0) * bias
except Exception:
logger.debug("direction parse skipped for remote segment %s", seg_id,
exc_info=True)
remote_rows.append({
"index": i, "text": seg.text,
"language": seg.target_lang or req.language,
"ref_text": ref_text, "ref_single_use": ref_single_use,
"instruct": seg_instruct,
"duration": (seg.end - seg.start) if strategy == "strict_slot" else None,
"num_step": 8 if req.preview else req.num_step,
"guidance_scale": req.guidance_scale, "speed": seg_speed,
"effect_preset": seg.effect_preset or "broadcast",
"seed": seed,
# RVC changes the waveform locally after TTS, so that path
# is marked at the existing post-RVC chokepoint below.
"watermark": not rvc_is_enabled(),
})
remote_refs.append(ref_audio)
if remote_rows:
states: asyncio.Queue = asyncio.Queue()
call = gpu_gateway.RemoteCall(
engine=active_backend_id(), operation="dub_segments",
params={"segments": remote_rows, "ref_audio": remote_refs},
decode=_decode_remote_dub,
)
dub_run = gpu_gateway.JobRun("dub_segments")
run = asyncio.create_task(gpu_gateway.run(
"dub_segments", local=gpu_gateway.LocalCall(fn=lambda: {}),
remote=call, decision=decision, job=dub_run,
on_state=states.put_nowait,
))
while not run.done():
if task_manager.is_cancelled(task_id):
run.cancel()
try:
await run
except asyncio.CancelledError:
pass
yield f"data: {json.dumps({'type': 'cancelled', 'segments_processed': 0})}\n\n"
return
try:
state = await asyncio.wait_for(states.get(), timeout=0.25)
except asyncio.TimeoutError:
continue
fraction = float(state.get("progress") or 0.0)
yield f"data: {json.dumps({'type': 'progress', 'current': round(fraction * total, 2), 'total': total, 'text': state.get('stage') or state.get('phase')})}\n\n"
remote_audio = await run
notice = dub_run.notice()
if notice is not None:
yield f"data: {json.dumps({'type': 'routing_notice', 'status': notice[0], 'reason': notice[1]})}\n\n"
for i, seg in enumerate(req.segments):
seg_id = seg_ids[i] if i < len(seg_ids) else f"seg_{i}"
@@ -790,8 +535,7 @@ async def dub_generate(job_id: str, req: DubRequest):
yield f"data: {json.dumps({'type': 'cancelled', 'segments_processed': i})}\n\n"
return
if not remote_audio:
yield f"data: {json.dumps({'type': 'progress', 'current': i, 'total': total, 'text': seg.text[:50]})}\n\n"
yield f"data: {json.dumps({'type': 'progress', 'current': i, 'total': total, 'text': seg.text[:50]})}\n\n"
seg_duration = seg.end - seg.start
if seg_duration <= 0.05 or not seg.text.strip():
@@ -828,38 +572,15 @@ async def dub_generate(job_id: str, req: DubRequest):
if os.path.exists(seg_wav_path):
try:
_t_cache_0 = time.perf_counter()
# Natural-rate caches are already the exact assembly
# input. Keep the durable path in the manifest so the
# mixer decodes it once; the old path decoded here,
# wrote an identical mix_<id> scratch WAV, then decoded
# that copy again. Header-only inspection preserves
# the resample fallback for caches made by an engine
# with a different sample rate.
if strategy != "strict_slot":
try:
cached_info = torchaudio.info(seg_wav_path)
except Exception:
cached_info = None
if (
cached_info is not None
and int(cached_info.sample_rate) == int(backend.sample_rate)
and _cached_payload_intact(seg_wav_path, cached_info)
):
all_segment_wavs.append(
(seg.start, seg.end, seg_wav_path, backend.sample_rate)
)
sync_scores.append(getattr(seg, 'sync_ratio', None) or 1.0)
_t_cache += time.perf_counter() - _t_cache_0
continue
cached_wav, cached_sr = torchaudio.load(seg_wav_path)
if cached_sr != backend.sample_rate:
import torchaudio.functional as AF
cached_wav = AF.resample(cached_wav, cached_sr, backend.sample_rate)
# strict_slot persists slot-sized buffers. Every other
# strategy consumes natural-rate audio and lets the mix
# loop fit it to the current timeline.
if strategy == "strict_slot":
# Pad/trim to slot — except smart_fit, whose mix
# loop needs the natural-rate length to compute the
# audio/video split (the seg_wav_kind guard above
# guarantees these cached WAVs are natural-rate).
if strategy != "smart_fit":
target_samples = int(seg_duration * backend.sample_rate)
current_samples = cached_wav.shape[-1]
if target_samples > current_samples:
@@ -890,8 +611,7 @@ async def dub_generate(job_id: str, req: DubRequest):
sync_scores.append(1.0)
continue
def _gen(text, lang, instruct_str, dur_s, nstep, cfg, spd, profile_id, effect_preset,
*, execution_target="local"):
def _gen(text, lang, instruct_str, dur_s, nstep, cfg, spd, profile_id, effect_preset):
# Normalize once at the segment's text→engine choke point
# (covers the OOM-retry generate below too, which reuses this
# closure's `text`). Pref-gated, idempotent, never raises.
@@ -969,19 +689,7 @@ async def dub_generate(job_id: str, req: DubRequest):
# editor's Voice dropdown can actually render ("From
# Video → Speaker N"). `seg_id` is closed over from
# the per-segment loop below.
segment_speaker_key = _speaker_key_for_segment(job, seg_id)
# Legacy jobs may not persist diarized segment rows.
# Preserve their established per-line preference; only
# suppress it when current metadata proves the user
# explicitly selected a different speaker.
selected_is_segment_speaker = (
segment_speaker_key is None or segment_speaker_key == key
)
seg_ref = (
(job.get("segment_clones") or {}).get(str(seg_id))
if selected_is_segment_speaker
else None
)
seg_ref = (job.get("segment_clones") or {}).get(str(seg_id))
if seg_ref:
ref_audio = seg_ref.get("ref_audio")
ref_text = seg_ref.get("ref_text")
@@ -990,13 +698,6 @@ async def dub_generate(job_id: str, req: DubRequest):
auto = _find_speaker_clone(
job.get("speaker_clones") or {}, key
)
if auto is None:
# Short lines may have no line-specific clip.
# Reuse this speaker's best source instead of
# silently reverting to the engine default.
auto = resolve_consistent_ref(
job, key, _consistent_ref_memo
)
if auto:
ref_audio = auto.get("ref_audio")
ref_text = auto.get("ref_text")
@@ -1061,7 +762,19 @@ async def dub_generate(job_id: str, req: DubRequest):
)
return normalize_audio(mastered_audio, target_dBFS=-2.0)
except Exception as e:
if not _prepare_oom_retry(e, execution_target=execution_target):
is_oom = (
isinstance(e, torch.cuda.OutOfMemoryError)
or "out of memory" in str(e).lower()
or "CUDA error" in str(e)
)
import gc
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
torch.mps.empty_cache()
if not is_oom:
raise
retry_steps = min(nstep, 8)
@@ -1167,24 +880,14 @@ async def dub_generate(job_id: str, req: DubRequest):
# Budget from the shared length-scaled helper (#1190): a long
# dub segment used to die on the flat 300s even after v0.3.22.
from services.model_manager import generate_timeout_s
if i in remote_audio:
audio_tensor, remote_sr = torchaudio.load(remote_audio[i])
try:
os.unlink(remote_audio[i])
except OSError:
pass
if remote_sr != backend.sample_rate:
import torchaudio.functional as AF
audio_tensor = AF.resample(audio_tensor, remote_sr, backend.sample_rate)
else:
audio_tensor = await run_on_gpu_pool_guarded(
lambda: _gen(
seg.text, seg_lang, seg_instruct, _dur_for_tts,
_num_step, req.guidance_scale, seg_speed, seg_profile, seg_effect_preset,
),
what="Dub generate",
timeout=generate_timeout_s(seg.text, engine=backend),
)
audio_tensor = await run_on_gpu_pool_guarded(
lambda: _gen(
seg.text, seg_lang, seg_instruct, _dur_for_tts,
_num_step, req.guidance_scale, seg_speed, seg_profile, seg_effect_preset,
),
what="Dub generate",
timeout=generate_timeout_s(seg.text),
)
_t_tts += time.perf_counter() - _t_tts_0
# Check abort immediately after GPU work completes
@@ -1256,15 +959,12 @@ async def dub_generate(job_id: str, req: DubRequest):
if rvc_sr == backend.sample_rate:
audio_tensor = rvc_wav
if strategy == "strict_slot":
target_samples = int(seg_duration * backend.sample_rate)
current_samples = audio_tensor.shape[-1]
if target_samples > current_samples:
audio_tensor = torch.nn.functional.pad(
audio_tensor, (0, target_samples - current_samples)
)
elif current_samples > target_samples:
audio_tensor = audio_tensor[..., :target_samples]
target_samples = int(seg_duration * backend.sample_rate)
current_samples = audio_tensor.shape[-1]
if target_samples > current_samples:
audio_tensor = torch.nn.functional.pad(audio_tensor, (0, target_samples - current_samples))
elif current_samples > target_samples:
audio_tensor = audio_tensor[..., :target_samples]
except Exception as e:
yield f"data: {json.dumps({'type': 'warning', 'segment': i, 'message': f'RVC skipped: {str(e)[:120]}'})}\n\n"
@@ -1276,9 +976,8 @@ async def dub_generate(job_id: str, req: DubRequest):
# no double-mark. Cached-reuse audio is already marked;
# silence/zero slots carry no speech to mark, so neither is
# re-watermarked.
if i not in remote_audio or rvc_is_enabled():
audio_tensor = mark_synthetic(audio_tensor, backend.sample_rate,
context="dub_generate.segment")
audio_tensor = mark_synthetic(audio_tensor, backend.sample_rate,
context="dub_generate.segment")
seg_wav_path = _seg_lang_path(seg_id)
try:
@@ -1303,15 +1002,7 @@ async def dub_generate(job_id: str, req: DubRequest):
pass
_release_audio_tensors()
except Exception as e:
# A task-stream error bypasses the global exception handler.
# Never publish engine exception text here: allocator errors
# carry process tables and arbitrary failures can carry paths,
# tokens, or source text. The shared helper enriches recognized
# classes using VoiceStudio-owned constants only.
from core.public_errors import stream_generation_failure
error_detail = stream_generation_failure(e)["detail"]
yield f"data: {json.dumps({'type': 'error', 'segment': i, 'error': error_detail})}\n\n"
yield f"data: {json.dumps({'type': 'error', 'segment': i, 'error': str(e)})}\n\n"
sr = backend.sample_rate
all_segment_wavs.append(_store_mix_wav(seg.start, seg.end, torch.zeros(1, max(0, int(seg_duration * sr))), sr, f"mix_{seg_id}"))
sync_scores.append(1.0)
@@ -1459,21 +1150,7 @@ async def dub_generate(job_id: str, req: DubRequest):
seg_gain = getattr(seg_ref, "gain", None) if seg_ref is not None else None
seg_gain = seg_gain if seg_gain is not None else 1.0
seg_gain = max(0.0, min(2.0, seg_gain))
try:
wav = _load_entry_wav((start, end, wav_path, sr), sr)
except Exception as e:
# A WAV header can be readable while its payload is
# truncated. Direct cache reuse deliberately defers the
# decode to assembly, so preserve the old recovery contract
# here: warn and fill this slot with silence instead of
# aborting the entire dub.
warning = {
"type": "warning",
"segment": i,
"message": f"cached seg lost, padding silence: {str(e)[:120]}",
}
yield f"data: {json.dumps(warning)}\n\n"
wav = torch.zeros(1, max(0, int((end - start) * sr)))
wav = _load_entry_wav((start, end, wav_path, sr), sr)
adjusted = wav * seg_gain
if adjusted.ndim == 2 and adjusted.shape[0] > 1:
adjusted = adjusted.mean(dim=0, keepdim=True)
@@ -1851,17 +1528,12 @@ async def preview_segment(job_id: str, req: SegmentPreviewRequest):
pid = req.profile_id
if pid and pid.startswith("auto:"):
key = pid[len("auto:"):]
info = None
if (
req.segment_id is not None
and _speaker_key_for_segment(job, req.segment_id) == key
):
info = (job.get("segment_clones") or {}).get(str(req.segment_id))
if info is None:
info = resolve_consistent_ref(job, key)
if info:
ref_audio = info.get("ref_audio")
ref_text = info.get("ref_text")
clones = job.get("speaker_clones") or {}
for spk, info in clones.items():
if spk.lower().replace(" ", "_") == key or spk == key:
ref_audio = info.get("ref_audio")
ref_text = info.get("ref_text")
break
pid = None
instruct_str = req.instruct
@@ -1915,7 +1587,7 @@ async def preview_segment(job_id: str, req: SegmentPreviewRequest):
from services.model_manager import generate_timeout_s
audio_tensor = await run_on_gpu_pool_guarded(
_gen, what="Dub preview generate",
timeout=generate_timeout_s(req.text, engine=backend),
timeout=generate_timeout_s(req.text),
)
sr = backend.sample_rate
@@ -1930,3 +1602,4 @@ async def preview_segment(job_id: str, req: SegmentPreviewRequest):
"X-Audio-Duration": str(round(audio_tensor.shape[-1] / sr, 2)),
},
)
+48 -67
View File
@@ -25,7 +25,7 @@ from huggingface_hub import utils as hf_utils
from huggingface_hub.errors import HFValidationError
from pydantic import BaseModel
from api.dependencies import require_admin, require_admin_action, require_desktop
from api.dependencies import require_loopback
from core import prefs
from services import tts_backend, asr_backend, llm_backend, translation_engines
from services.audio_dsp import list_effect_presets
@@ -41,15 +41,6 @@ _FAMILIES = {
"llm": (llm_backend, "llm_backend"),
}
def _family_payload(family: str, module):
"""Public inventory plus whether an environment pin owns this family."""
return {
"active": module.active_backend_id(),
"env_override": bool(os.environ.get(f"OMNIVOICE_{family.upper()}_BACKEND")),
"backends": public_backends(module.list_backends()),
}
def _is_hf_repo_id(value: str) -> bool:
"""Validate the route's ``owner/repo`` contract in bounded time."""
if not isinstance(value, str) or len(value) > 96 or value.count("/") != 1:
@@ -64,40 +55,34 @@ def _is_hf_repo_id(value: str) -> bool:
@router.get("/engines")
def list_all_engines():
return {
"tts": _family_payload("tts", tts_backend),
"asr": _family_payload("asr", asr_backend),
"llm": _family_payload("llm", llm_backend),
"tts": {
"active": tts_backend.active_backend_id(),
"backends": public_backends(tts_backend.list_backends()),
},
"asr": {
"active": asr_backend.active_backend_id(),
"backends": public_backends(asr_backend.list_backends()),
},
"llm": {
"active": llm_backend.active_backend_id(),
"backends": public_backends(llm_backend.list_backends()),
},
}
@router.get("/engines/tts")
def list_tts_backends():
return _family_payload("tts", tts_backend)
@router.get(
"/engines/{engine_id}/disk-usage",
dependencies=[Depends(require_admin_action)],
)
def engine_disk_usage(engine_id: str):
"""Measure owned engine bytes only when a catalogue row is opened."""
try:
tts_backend.get_backend_class(engine_id)
except ValueError:
raise HTTPException(status_code=404, detail="Unknown TTS engine")
from services.engine_disk_usage import disk_usage_for
return disk_usage_for(engine_id)
return {"active": tts_backend.active_backend_id(), "backends": public_backends(tts_backend.list_backends())}
@router.get("/engines/asr")
def list_asr_backends():
return _family_payload("asr", asr_backend)
return {"active": asr_backend.active_backend_id(), "backends": public_backends(asr_backend.list_backends())}
@router.get("/engines/llm")
def list_llm_backends():
return _family_payload("llm", llm_backend)
return {"active": llm_backend.active_backend_id(), "backends": public_backends(llm_backend.list_backends())}
@router.get("/engines/effects/presets", response_model=EffectPresetsResponse)
@@ -128,10 +113,7 @@ def list_translation_engines():
}
@router.post(
"/engines/translation/{engine_id}/install",
dependencies=[Depends(require_admin)],
)
@router.post("/engines/translation/{engine_id}/install")
async def install_translation_engine(engine_id: str):
entry = translation_engines.get_engine(engine_id)
if not entry:
@@ -167,10 +149,7 @@ async def install_translation_engine(engine_id: str):
}
@router.delete(
"/engines/translation/{engine_id}",
dependencies=[Depends(require_admin)],
)
@router.delete("/engines/translation/{engine_id}")
async def uninstall_translation_engine(engine_id: str):
entry = translation_engines.get_engine(engine_id)
if not entry:
@@ -199,7 +178,7 @@ async def uninstall_translation_engine(engine_id: str):
# Sidecar engines (dedicated venv + source checkout + weights, isolated from
# the parent's transformers>=5.3) used to require four manual terminal steps.
# These routes drive services.sidecar_install: POST starts a resumable
# background job, GET polls its step-by-step status (the Model Catalogue → Engines
# background job, GET polls its step-by-step status (the Settings → Engines
# Install button polls this), DELETE removes an app-managed install.
#
# Path namespace: /engines/sidecar/{engine_id}/… — NOT /engines/{engine_id}/…
@@ -209,16 +188,15 @@ async def uninstall_translation_engine(engine_id: str):
# POST /engines/sonitranslate/install). Mirrors the
# /engines/translation/{engine_id}/install namespace pattern.
#
# Desktop-only: installing spawns git/uv against mutable source and writes an
# editable environment. An API key does not make that supply-chain path safe to
# trigger remotely. The job runs fine in packaged builds: the venv lives under
# the user data dir, not inside the signed app bundle, and uv resolves via
# OMNIVOICE_BUNDLED_UV/PATH.
# Loopback-gated: installing spawns subprocesses (git/uv) and writes to the
# data directory — only the local desktop frontend may trigger it. The job
# runs fine in packaged builds: the venv lives under the user data dir, not
# inside the signed app bundle, and uv resolves via OMNIVOICE_BUNDLED_UV/PATH.
@router.post(
"/engines/sidecar/{engine_id}/install",
dependencies=[Depends(require_admin), Depends(require_desktop)],
dependencies=[Depends(require_loopback)],
)
def install_sidecar_engine(engine_id: str):
"""Start (or report) the one-click install for a sidecar engine.
@@ -244,7 +222,7 @@ def install_sidecar_engine(engine_id: str):
@router.get(
"/engines/sidecar/{engine_id}/install/status",
dependencies=[Depends(require_admin)],
dependencies=[Depends(require_loopback)],
)
def sidecar_install_status(engine_id: str):
"""Step-by-step status of the sidecar install job (poll while running).
@@ -265,7 +243,7 @@ def sidecar_install_status(engine_id: str):
@router.delete(
"/engines/sidecar/{engine_id}/install",
dependencies=[Depends(require_admin)],
dependencies=[Depends(require_loopback)],
)
def uninstall_sidecar_engine(engine_id: str):
"""Remove an app-managed sidecar install (checkout + venv + weights) and
@@ -296,22 +274,29 @@ def uninstall_sidecar_engine(engine_id: str):
# frame. Result includes wall-clock latency so the UI can render
# "1234 ms — pong" inline next to the button.
#
# Admin-gated (T-02-13): only the local desktop frontend or an authenticated
# server-mode administrator may trigger a sidecar spawn through this endpoint.
# Loopback-gated (T-02-13): only the local desktop frontend may trigger
# a sidecar spawn through this endpoint.
# Engine instances cached for the lifetime of the FastAPI process so that
# repeated health checks don't spawn a new SubprocessBackend (each spawn
# allocates a sidecar venv probe + atexit hook). The cache is keyed by
# class to survive registry-sandbox tests that rebind ids transiently.
#
# It now lives in services.tts_backend — the worker executor needs the same
# warm instances and cannot import an API router without inverting the
# layering. This name is the SAME dict object, kept so the existing consumers
# (engine_memory eviction, model_lifecycle inventory/unload) go on working
# unchanged; rebinding it here would fork the cache in two.
_ENGINE_INSTANCES: dict[type, object] = tts_backend._ENGINE_INSTANCES
_ENGINE_INSTANCES: dict[type, object] = {}
_get_engine_instance = tts_backend.get_engine_instance
def _get_engine_instance(cls):
"""Return a cached singleton instance of ``cls``.
SubprocessBackend's ``__init__`` registers an atexit shutdown hook,
so re-instantiating per request would leak handler entries (and on
real engines, additional sidecar processes the first time the lock
is acquired). One instance per process is the right move.
"""
inst = _ENGINE_INSTANCES.get(cls)
if inst is None:
inst = cls()
_ENGINE_INSTANCES[cls] = inst
return inst
def _resolve_engine_class(engine_id: str):
@@ -333,7 +318,7 @@ def _resolve_engine_class(engine_id: str):
@router.get(
"/engines/{engine_id}/health",
dependencies=[Depends(require_admin_action)],
dependencies=[Depends(require_loopback)],
)
def engine_health(engine_id: str):
"""Spawn-and-ping a SubprocessBackend; ``is_available()`` for the rest.
@@ -407,7 +392,7 @@ def engine_health(engine_id: str):
# hanging the Settings panel. The orphaned worker is best-effort daemon.
# * A process-wide lock serialises self-tests so a click-storm can't stack
# concurrent model loads.
# * Only ever on user click (POST) — never on Settings load. Admin-gated.
# * 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.
@@ -474,7 +459,7 @@ class SelfTestResponse(BaseModel):
@router.post(
"/engines/{engine_id}/selftest",
response_model=SelfTestResponse,
dependencies=[Depends(require_admin)],
dependencies=[Depends(require_loopback)],
)
def engine_selftest(engine_id: str):
"""Run a bounded, real synthesis on an available in-process TTS engine.
@@ -573,11 +558,7 @@ class SelectEngineResponse(BaseModel):
routing_reason: str | None = None
@router.post(
"/engines/select",
response_model=SelectEngineResponse,
dependencies=[Depends(require_admin)],
)
@router.post("/engines/select", response_model=SelectEngineResponse)
def select_engine(req: SelectEngineRequest):
"""Persist a family's engine pick to prefs.json. Refuses unknown backends,
backends whose deps aren't installed, AND backends that cannot run on THIS
@@ -608,7 +589,7 @@ def select_engine(req: SelectEngineRequest):
# #981: mlx-audio multiplexes 7+ curated models behind one backend id —
# persist the model pick alongside the backend id so the UI can actually
# select which curated model gets loaded (previously it always defaulted
# to Kokoro no matter what the user downloaded in Model Catalogue → Models).
# to Kokoro no matter what the user downloaded in Settings → Models).
if req.family == "tts" and req.backend_id == "mlx-audio" and req.model_id is not None:
known_keys = tts_backend.MLXAudioBackend.CURATED_MODELS
# Accept a curated key OR a raw HF repo id ("owner/name") — the same
+86 -229
View File
@@ -1,24 +1,18 @@
import asyncio
import contextlib
import json
import logging
import os
import re
import shutil
import tempfile
import time
import json
import uuid
import time
import asyncio
import logging
from typing import Optional, List
from pathlib import Path
from typing import List, Optional
from fastapi import APIRouter, File, Form, UploadFile, HTTPException, Query
from fastapi.responses import FileResponse
from fastapi.responses import FileResponse, RedirectResponse
from pydantic import BaseModel
from core.db import db_conn
from core.config import VOICES_DIR, OUTPUTS_DIR
from core import event_bus
from core.audio_validation import resolve_regular_file
from core.file_cleanup import FileCleanupError, unlink_if_present
from services.ffmpeg_utils import spawn_subprocess
@@ -366,223 +360,46 @@ async def upload_voice_clip(
}
def _stage_profile_audio(source: Path, directory: Path) -> Path:
"""Copy an imported clip to a hidden temp file inside ``directory``.
The temp lives in the destination directory itself so a later
``os.replace`` to the final name is an atomic same-filesystem rename
cheap enough to run while holding a DB write lock, unlike the copy.
Callers own cleanup of the returned path if they never publish it.
"""
directory.mkdir(parents=True, exist_ok=True)
fd, tmp_name = tempfile.mkstemp(
dir=str(directory), prefix=".gallery-import-", suffix=".part",
)
os.close(fd)
try:
shutil.copy2(source, tmp_name)
except BaseException:
with contextlib.suppress(OSError):
os.unlink(tmp_name)
raise
return Path(tmp_name)
def _copy_profile_audio(source: Path, destination: Path) -> None:
"""Copy an imported clip without exposing a partial profile audio file."""
staged = _stage_profile_audio(source, destination.parent)
try:
os.replace(staged, destination)
except BaseException:
with contextlib.suppress(OSError):
os.unlink(staged)
raise
def _gallery_profile_audio_filename(profile_id: str, source: Path) -> str:
"""Return the canonical, portable filename for a My Imports profile."""
safe_id = (
profile_id if re.fullmatch(r"[A-Za-z0-9_-]{1,64}", profile_id or "")
else uuid.uuid5(uuid.NAMESPACE_URL, str(profile_id)).hex[:16]
)
suffix = source.suffix.lower()
if not re.fullmatch(r"\.[a-z0-9]{1,8}", suffix):
suffix = ".wav"
return f"{safe_id}_gallery{suffix}"
def _is_materialized_gallery_profile(row, voice: dict, audio_filename: str) -> bool:
"""Recognize only rows created by this materializer, not identity collisions."""
return bool(
row["personality"] == f"gallery:{voice['id']}"
and row["ref_audio_path"] == audio_filename
and row["ref_text"] == ""
and row["instruct"] == ""
and row["language"] == "Auto"
and row["seed"] is None
and row["kind"] == "clone"
and not row["vd_states"]
and row["description"] == (voice.get("description") or "")
and not row["is_locked"]
and not row["verified_own_voice"]
and not row["locked_audio_path"]
)
def _existing_gallery_profile(conn, voice: dict, source: Path):
personality = f"gallery:{voice['id']}"
rows = conn.execute(
"SELECT * FROM voice_profiles WHERE personality=? ORDER BY created_at, id",
(personality,),
).fetchall()
for row in rows:
expected = _gallery_profile_audio_filename(row["id"], source)
if _is_materialized_gallery_profile(row, voice, expected):
return row
return None
def _gallery_profile_audio_is_current(row, source: Path) -> bool:
"""Detect missing/replaced copies without re-hashing unchanged imports."""
destination = resolve_regular_file(VOICES_DIR, row["ref_audio_path"])
if destination is None:
return False
try:
source_stat = source.stat()
destination_stat = destination.stat()
# copy2 preserves mtime; size + nanosecond mtime catches ordinary edits
# and partial writes while keeping repeated Use clicks inexpensive.
return (
source_stat.st_size == destination_stat.st_size
and source_stat.st_mtime_ns == destination_stat.st_mtime_ns
)
except OSError:
return False
def _materialize_gallery_profile(
voice_id: str, requested_name: Optional[str] = None,
) -> dict:
"""Idempotently materialize/heal one My Imports clip as a clone profile."""
personality = f"gallery:{voice_id}"
copied_path: Optional[Path] = None
created = False
staged_path: Optional[Path] = None
staged_source: Optional[Path] = None
try:
# Stage the (potentially large) audio copy BEFORE taking SQLite's
# write lock: copying inside BEGIN IMMEDIATE would stall every other
# backend writer for the whole copy. The staged temp lives in
# VOICES_DIR itself, so publishing it inside the transaction is an
# atomic same-filesystem os.replace. This pre-read is advisory only —
# the locked transaction below re-reads and re-decides everything.
copy_needed = False
with db_conn() as conn:
pre_row = conn.execute(
"SELECT * FROM voice_gallery WHERE id = ?", (voice_id,),
).fetchone()
if pre_row is not None:
pre_source = Path(pre_row["audio_path"])
if pre_source.is_file():
pre_existing = _existing_gallery_profile(conn, dict(pre_row), pre_source)
copy_needed = pre_existing is None or not _gallery_profile_audio_is_current(
pre_existing, pre_source,
)
if copy_needed:
staged_path = _stage_profile_audio(pre_source, Path(VOICES_DIR))
staged_source = pre_source
with db_conn() as conn:
# The identity is not globally UNIQUE because personality is shared
# with other import mechanisms. Serialize this check+insert in
# SQLite so simultaneous Use clicks cannot both create a row.
conn.execute("BEGIN IMMEDIATE")
row = conn.execute(
"SELECT * FROM voice_gallery WHERE id = ?", (voice_id,),
).fetchone()
if row is None:
raise HTTPException(status_code=404, detail="Voice not found")
voice = dict(row)
source = Path(voice["audio_path"])
if not source.is_file():
raise HTTPException(status_code=404, detail="Audio file not found on disk")
def _install_audio(destination: Path) -> None:
"""Publish the staged copy under the lock via atomic rename."""
nonlocal staged_path
if staged_path is not None and staged_source == source:
os.replace(staged_path, destination)
staged_path = None
else:
# Rare race: the gallery row changed between the advisory
# pre-read and taking the lock, so any staged bytes may be
# from the wrong source. Fall back to the blocking copy
# rather than publish stale audio.
_copy_profile_audio(source, destination)
existing = _existing_gallery_profile(conn, voice, source)
if existing is not None:
ref_filename = _gallery_profile_audio_filename(existing["id"], source)
if not _gallery_profile_audio_is_current(existing, source):
ref_path = Path(VOICES_DIR) / ref_filename
_install_audio(ref_path)
copied_path = ref_path
conn.execute(
"UPDATE voice_profiles SET ref_audio_path=?, ref_text='', instruct='', "
"language='Auto', seed=NULL, description=?, kind='clone', vd_states=NULL, "
"personality=? WHERE id=?",
(
ref_filename, voice["description"] or "", personality,
existing["id"],
),
)
result = {"profile_id": existing["id"], "name": existing["name"]}
else:
profile_id = str(uuid.uuid4())[:8]
profile_name = (requested_name or voice["name"]).strip() or voice["name"]
ref_filename = _gallery_profile_audio_filename(profile_id, source)
copied_path = Path(VOICES_DIR) / ref_filename
_install_audio(copied_path)
conn.execute(
"""INSERT INTO voice_profiles
(id, name, ref_audio_path, ref_text, instruct, language, seed,
personality, is_locked, locked_audio_path, description, kind,
vd_states, created_at)
VALUES (?, ?, ?, '', '', 'Auto', NULL, ?, 0, '', ?, 'clone', NULL, ?)""",
(
profile_id, profile_name, ref_filename, personality,
voice["description"] or "", time.time(),
),
)
created = True
result = {"profile_id": profile_id, "name": profile_name}
except BaseException:
if copied_path is not None:
with contextlib.suppress(OSError):
copied_path.unlink()
raise
finally:
# Staged but never published (failure, or a concurrent request healed
# the profile first) — never leave .part droppings in VOICES_DIR.
if staged_path is not None:
with contextlib.suppress(OSError):
os.unlink(staged_path)
event_bus.emit(
"profiles", {"action": "created" if created else "updated", "id": result["profile_id"]},
)
return result
@router.post("/gallery/voices/{voice_id}/save-as-profile")
async def save_voice_as_profile(
voice_id: str,
profile_name: str = Query(..., description="Name for the voice profile"),
):
"""Save a gallery voice as a voice profile for cloning."""
result = await asyncio.to_thread(_materialize_gallery_profile, voice_id, profile_name)
return {"profile_id": result["profile_id"], "name": result["name"]}
with db_conn() as conn:
row = conn.execute(
"SELECT * FROM voice_gallery WHERE id = ?", (voice_id,)
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Voice not found")
profile_id = str(uuid.uuid4())[:8]
import shutil
ext = os.path.splitext(row["audio_path"])[1]
new_audio_path = os.path.join(VOICES_DIR, f"{profile_id}{ext}")
shutil.copy(row["audio_path"], new_audio_path)
conn.execute(
"""
INSERT INTO voice_profiles (id, name, ref_audio_path, ref_text, instruct, language, seed, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
profile_id,
profile_name,
f"{profile_id}{ext}",
row["description"] or "",
row["character"] or "",
"Auto",
None,
time.time(),
),
)
event_bus.emit("profiles", {"action": "created", "id": profile_id})
return {"profile_id": profile_id, "name": profile_name}
@router.get("/gallery/voices/{voice_id}/preview")
@@ -598,10 +415,22 @@ def preview_voice(voice_id: str):
audio_path = row["audio_path"]
if os.path.isabs(audio_path) and os.path.exists(audio_path):
# Serve the file from this API route so deployments mounted below a
# path prefix do not lose that prefix while following a redirect.
return FileResponse(audio_path)
# Debug logging
is_absolute = os.path.isabs(audio_path)
path_exists = os.path.exists(audio_path) if audio_path else False
# If absolute path, serve directly or redirect
if is_absolute and path_exists:
# Get just the relative path from outputs dir
outputs_path = str(OUTPUTS_DIR)
if audio_path.startswith(outputs_path):
# Remove outputs_dir prefix to get relative path within outputs
rel_path = os.path.relpath(audio_path, outputs_path)
# The audio_path is like: /Users/user4/.../outputs/voice_gallery/file.wav
# rel_path becomes: voice_gallery/file.wav
# We want to serve from /audio/ so: /audio/voice_gallery/file.wav
return RedirectResponse(f"/audio/{rel_path}")
return FileResponse(audio_path, media_type="audio/wav")
raise HTTPException(
status_code=404,
@@ -674,5 +503,33 @@ def batch_delete_voices(body: dict):
@router.post("/gallery/voices/{voice_id}/to-profile")
def voice_to_profile(voice_id: str):
"""Create a voice profile from a gallery clip."""
result = _materialize_gallery_profile(voice_id)
return {"success": True, "profile_id": result["profile_id"], "name": result["name"]}
with db_conn() as conn:
row = conn.execute("SELECT * FROM voice_gallery WHERE id = ?", (voice_id,)).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Voice not found")
voice = dict(row)
audio_path = voice["audio_path"]
if not os.path.exists(audio_path):
raise HTTPException(status_code=404, detail="Audio file not found on disk")
import shutil
import uuid
profile_id = str(uuid.uuid4())[:8]
# Copy audio to voices dir
dest_filename = f"{profile_id}_gallery.wav"
dest_path = os.path.join(VOICES_DIR, dest_filename)
shutil.copy2(audio_path, dest_path)
import time
now = time.time()
conn.execute(
"""INSERT INTO voice_profiles
(id, name, ref_audio_path, ref_text, instruct, seed, is_locked, locked_audio_path, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(profile_id, voice["name"], dest_filename, "", None, None, 0, None, now, now),
)
event_bus.emit("profiles", {"action": "created", "id": profile_id})
return {"success": True, "profile_id": profile_id, "name": voice["name"]}
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -9,13 +9,13 @@ from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from api.dependencies import require_admin
from api.dependencies import require_loopback
from services import mcp_bindings
router = APIRouter(
prefix="/api/mcp",
tags=["mcp"],
dependencies=[Depends(require_admin)],
dependencies=[Depends(require_loopback)],
)
+2 -2
View File
@@ -12,10 +12,10 @@ import logging
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from api.dependencies import require_admin
from api.dependencies import require_loopback
logger = logging.getLogger("omnivoice.api")
router = APIRouter(dependencies=[Depends(require_admin)])
router = APIRouter(dependencies=[Depends(require_loopback)])
class CustomPathRequest(BaseModel):
+12 -49
View File
@@ -160,9 +160,7 @@ _OPENAI_VOICE_ALIASES = {
def _resolve_engine(model_id: str):
"""Map an OpenAI model name to a VoiceStudio backend."""
from services.tts_backend import (
get_backend_class, get_active_tts_backend, get_engine_instance_for,
)
from services.tts_backend import get_backend_class, get_active_tts_backend
# Accept OpenAI model names as pass-through to the active engine.
if model_id in ("tts-1", "tts-1-hd"):
@@ -179,18 +177,8 @@ def _resolve_engine(model_id: str):
)
from services.tts_backend import OmniVoiceBackend
if cls is OmniVoiceBackend:
# OmniVoice only ever runs as the shared active engine — the
# explicit-omnivoice request is the active-engine request.
return get_active_tts_backend()
# Cached singleton, not a fresh cls(): SubprocessBackend engines would
# spawn a sidecar process and reload their model on EVERY request, and
# register a new atexit hook each time (get_engine_instance's contract).
# No router-local cache on top of it: the shared cache is keyed by
# CLASS precisely so id rebinds/evictions can't serve a stale instance,
# and cross-engine memory discipline is create_speech's
# evict_other_tts_engines call (the same seam /generate uses) — not a
# bespoke unload here.
return get_engine_instance_for(model_id)
return cls()
except ValueError:
raise HTTPException(
status_code=400,
@@ -400,15 +388,6 @@ async def create_speech(req: SpeechRequest):
# VRAM eviction runs in get_model()'s warm-return path now, covering every
# native TTS generate (this route, WS TTS, dub, batch, audiobook).
# Single-active-engine memory discipline (MM2-01), the same call /generate
# makes before its load: hand back every OTHER resident TTS engine's model
# before this one warms up, so switching `model` ids across requests —
# explicit id → explicit id, or explicit id → the tts-1/omnivoice aliases —
# can't stack multi-GB engines/sidecars. No-op when nothing else is
# resident; opt out with OMNIVOICE_SINGLE_ENGINE_RESIDENT=0.
from services.engine_memory import evict_other_tts_engines
await evict_other_tts_engines(backend.id)
# ── #1033/#1037/#1014: warm the engine under the LOAD budget before the
# generate clock starts. The T4 verification (#1014) measured a fresh
# install's first /v1/audio/speech burning its whole 300s generate budget
@@ -436,7 +415,7 @@ async def create_speech(req: SpeechRequest):
detail=(
f"TTS engine '{backend.id}' did not finish loading within its "
f"model-load budget — on a first run this usually means the weight "
f"download is slow or stalled (check Model Catalogue → Models for "
f"download is slow or stalled (check Settings → Models for "
f"progress), not that generation failed. Retry once the model "
f"shows as installed."
),
@@ -475,7 +454,7 @@ async def create_speech(req: SpeechRequest):
from services.model_manager import generate_timeout_s
wav, sr = await run_on_gpu_pool_guarded(
lambda: _run_tts(backend, text, kw), what="OpenAI TTS generate",
timeout=generate_timeout_s(text, engine=backend))
timeout=generate_timeout_s(text))
except Exception as e:
# #1172/#1173: typed failures get their real status + actionable
# message (400 bad input / 503 broken engine binary) instead of a
@@ -538,10 +517,9 @@ async def create_transcription(
):
"""Transcribe audio to text. Compatible with OpenAI's POST /v1/audio/transcriptions."""
from services.asr_backend import (
ASRModelMissingError,
asr_model_missing_detail,
asr_model_missing_error,
load_active_asr_backend,
get_active_asr_backend,
)
# TTS-only install: no ASR model on disk → actionable 409, BEFORE any
@@ -568,25 +546,18 @@ async def create_transcription(
raise HTTPException(status_code=400, detail=f"Could not read audio file: {e}")
try:
backend = get_active_asr_backend()
# Run transcription in the thread pool to avoid blocking the event loop,
# bounded so a stuck/starved ASR returns a 504 with guidance instead of
# hanging the request forever (see run_transcribe_guarded).
from services.asr_backend import run_transcribe_guarded
word_ts = response_format == "verbose_json"
# `load_active_asr_backend`, not `get_active_asr_backend`: the latter is
# a pure selector, so a backend whose shallow `is_available()` probe
# passes but whose deep import chain is broken (whisperx →
# ctranslate2 failing to dlopen on a hardened kernel) reached
# `.transcribe()` and 500'd, even with a healthy engine next in line.
# The loader does select + ensure_loaded + degrade (#1185). It loads
# weights, so it belongs inside the pool with the transcribe call —
# never on the event loop.
def _run():
backend = load_active_asr_backend()
return backend.transcribe(tmp_path, word_timestamps=word_ts)
result = await run_transcribe_guarded(_gpu_pool, _run, what="OpenAI")
result = await run_transcribe_guarded(
_gpu_pool,
lambda: backend.transcribe(tmp_path, word_timestamps=word_ts),
what="OpenAI",
)
# Extract the full text from segments
segments = result.get("segments", [])
@@ -654,14 +625,6 @@ async def create_transcription(
except HTTPException:
raise
except ASRModelMissingError as e:
# A degraded-to candidate has no weights on disk. Same typed 409 the
# preflight above raises — never a 500, and never a silent multi-GB
# auto-download.
raise HTTPException(
status_code=409,
detail={**e.payload, "message": asr_model_missing_detail(e.payload)},
)
except TimeoutError as e:
# ASRTimeoutError (subclass): backend alive, ASR too heavy for compute.
logger.warning("OpenAI transcription timed out: %s", e)
+10 -10
View File
@@ -7,7 +7,7 @@ CRUD for the DB-backed, per-language pronunciation dictionary the
before synthesis (see ``services/pronunciation.apply_pronunciation`` and the
generate path), so a saved entry actually changes the audio on every engine.
Endpoints (admin-gated; loopback or authenticated server mode):
Endpoints (loopback-only, like the dictation router):
GET /pronunciation list every entry
POST /pronunciation create one entry
PUT /pronunciation/{entry_id} update an entry (partial)
@@ -30,12 +30,12 @@ from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from api.dependencies import require_admin
from api.dependencies import require_loopback
from core.db import db_conn
from services.pronunciation import apply_pronunciation, entries_for_language
logger = logging.getLogger("omnivoice.pronunciation")
router = APIRouter(dependencies=[Depends(require_admin)])
router = APIRouter()
_VALID_TYPES = ("respelling", "ipa", "cmu")
_ALL_LANG = "*"
@@ -133,7 +133,7 @@ class PronImportRequest(BaseModel):
# ── CRUD ─────────────────────────────────────────────────────────────────────
@router.get("/pronunciation")
@router.get("/pronunciation", dependencies=[Depends(require_loopback)])
def list_entries():
with db_conn() as conn:
rows = conn.execute(
@@ -143,7 +143,7 @@ def list_entries():
return [_row_to_dict(r) for r in rows]
@router.post("/pronunciation")
@router.post("/pronunciation", dependencies=[Depends(require_loopback)])
def create_entry(entry: PronEntry):
term = entry.term.strip()
if not term:
@@ -171,7 +171,7 @@ def create_entry(entry: PronEntry):
return _row_to_dict(row)
@router.put("/pronunciation/{entry_id}")
@router.put("/pronunciation/{entry_id}", dependencies=[Depends(require_loopback)])
def update_entry(entry_id: str, patch: PronEntryUpdate):
with db_conn() as conn:
existing = conn.execute(
@@ -226,7 +226,7 @@ def update_entry(entry_id: str, patch: PronEntryUpdate):
return _row_to_dict(row)
@router.delete("/pronunciation/{entry_id}")
@router.delete("/pronunciation/{entry_id}", dependencies=[Depends(require_loopback)])
def delete_entry(entry_id: str):
with db_conn() as conn:
cur = conn.execute("DELETE FROM pronunciation_entries WHERE id = ?", (entry_id,))
@@ -236,7 +236,7 @@ def delete_entry(entry_id: str):
# ── Dry-run + import/export ───────────────────────────────────────────────────
@router.post("/pronunciation/test")
@router.post("/pronunciation/test", dependencies=[Depends(require_loopback)])
def test_substitution(req: PronTestRequest):
"""Show the post-substitution text for ``req.text`` — no model call.
@@ -258,7 +258,7 @@ def test_substitution(req: PronTestRequest):
}
@router.get("/pronunciation/export")
@router.get("/pronunciation/export", dependencies=[Depends(require_loopback)])
def export_entries():
"""Every entry as a JSON-serializable list (round-trips ``/import``)."""
with db_conn() as conn:
@@ -273,7 +273,7 @@ def export_entries():
]}
@router.post("/pronunciation/import")
@router.post("/pronunciation/import", dependencies=[Depends(require_loopback)])
def import_entries(req: PronImportRequest):
"""Bulk-add entries. ``replace=true`` clears the table first.
+7 -88
View File
@@ -20,7 +20,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from core.logging_utils import log_safe
from api.dependencies import require_admin, require_admin_action
from api.dependencies import require_admin
logger = logging.getLogger("omnivoice.api.settings")
@@ -92,8 +92,8 @@ def get_hf_token_state(fresh: bool = Query(False)):
# ── Performance settings (INST-12) ────────────────────────────────────────
# Threat T-02-04: same admin guard as the hf-token endpoints via the
# router-level `require_admin` dep.
# Threat T-02-04: same loopback guard as the hf-token endpoints via the
# router-level `require_loopback` dep.
_TORCH_COMPILE_KEY = "perf.torch_compile_disabled"
@@ -133,83 +133,6 @@ def set_torch_compile_disabled(body: _TorchCompileBody):
return _torch_compile_state()
# ── Compute-device override (Settings → Performance) ──────────────────────
class _ComputeDeviceBody(BaseModel):
value: str = Field(..., description="auto | cuda | rocm | xpu | mps | cpu")
def _compute_device_state() -> dict:
"""Everything the Performance panel needs to render the device control:
the resolved pick (env > prefs > auto), what this process actually applied
at probe time (differs after a change until restart caps are immutable
per process), what auto would pick, and which families exist here."""
from core import device_caps
caps = device_caps.detect_host_caps()
env_pin = (os.environ.get("OMNIVOICE_DEVICE") or "").strip().lower()
auto_family = next(
(f for f in ("cuda", "rocm", "xpu", "mps") if f in caps.available_families),
"cpu",
)
value = device_caps.requested_device_override()
return {
"value": value,
"applied": caps.requested_family,
"restart_required": value != caps.requested_family,
# The running process asked for a family it doesn't have (env pin on
# the wrong machine, hardware removed): auto is in effect, and a
# restart would not change that — the panel says so instead of
# pretending the pick took.
"override_ignored": (
caps.requested_family not in ("auto", caps.family)
),
"effective_family": caps.family,
"auto_family": auto_family,
"available_families": list(caps.available_families),
"env_pinned": env_pin in device_caps.DEVICE_OVERRIDE_CHOICES and env_pin != "",
"choices": list(device_caps.DEVICE_OVERRIDE_CHOICES),
}
@router.get("/compute-device")
def get_compute_device():
"""Current compute-device override state (Settings → Performance)."""
return _compute_device_state()
@router.put("/compute-device")
def set_compute_device(body: _ComputeDeviceBody):
"""Persist the compute-device pick. Applied by the capability probe at
the next backend start (host caps are immutable per process same
restart contract as the rest of the Performance tab). ``OMNIVOICE_DEVICE``
always wins over this pick; the UI shows the pin instead of pretending."""
from core import device_caps, prefs
value = (body.value or "").strip().lower()
if value not in device_caps.DEVICE_OVERRIDE_CHOICES:
raise HTTPException(
status_code=400,
detail=f"Unknown device '{value}'. Valid: {', '.join(device_caps.DEVICE_OVERRIDE_CHOICES)}",
)
caps = device_caps.detect_host_caps()
if value not in ("auto", "cpu") and value not in caps.available_families:
raise HTTPException(
status_code=400,
detail=(
f"'{value}' is not available on this host "
f"(have: {', '.join(caps.available_families)})"
),
)
try:
prefs.set_("compute_device", value)
except Exception:
logger.exception("set_compute_device failed")
raise HTTPException(status_code=500, detail="Failed to persist setting")
return _compute_device_state()
# ── Generation-history retention (Studio takes rail) ──────────────────────
@@ -558,10 +481,7 @@ def _local_models(base_url: str, api_key: str):
return None
@router.get(
"/llm-providers/{provider_id}/models",
dependencies=[Depends(require_admin_action)],
)
@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).
@@ -1035,10 +955,9 @@ def set_asr_openai_compat(body: _ASROpenAICompatBody):
from services import asr_backend, settings_store
if body.base_url is not None:
try:
url = asr_backend.normalize_openai_compat_asr_base_url(body.base_url)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
url = body.base_url.strip().rstrip("/")
if url and not url.startswith(("http://", "https://")):
raise HTTPException(status_code=400, detail="Base URL must start with http(s)://")
settings_store.set_text(asr_backend._ASR_OPENAI_COMPAT_BASE_URL_KEY, url)
if body.model is not None:
settings_store.set_text(
+7 -81
View File
@@ -13,7 +13,6 @@ import json
import logging
import os
import sys
import threading
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
@@ -70,14 +69,6 @@ def clear_install_cooldowns() -> None:
# cancelled, and clears the cooldown so a cancel isn't rate-limited.
_cancelled: set[str] = set()
# One worker per repo. Repeated clicks and feature-level recovery can converge
# on the same install; starting a second snapshot_download against the same HF
# cache is wasteful and can corrupt the user-visible progress stream.
_active_installs: set[str] = set()
_active_installs_lock = threading.Lock()
_install_tasks: set[asyncio.Task] = set()
_install_tasks_by_repo: dict[str, asyncio.Task] = {}
def _download_max_workers() -> int:
"""Parallel-FILES worker count for snapshot_download (FDL-02). Default 8 —
@@ -297,19 +288,17 @@ def _validate_snapshot_has_weights(repo_id: str, snapshot_path: str) -> None:
f"{repo_id}: download finished but no model weights were found in the "
"snapshot (largest file "
f"{biggest} bytes). The download was likely interrupted — delete the "
"model in Model Catalogue → Models and install it again."
"model in Settings → Models and install it again."
)
@router.get("/setup/download-stream")
async def setup_download_stream(target: str | None = None):
async def setup_download_stream():
"""SSE: forward every HuggingFace download tqdm update as a JSON event."""
queue: asyncio.Queue = asyncio.Queue(maxsize=512)
loop = asyncio.get_running_loop()
def listener(event):
if target and event.get("target", "local") != target:
return
try:
loop.call_soon_threadsafe(_safe_put, queue, event)
except RuntimeError:
@@ -343,7 +332,6 @@ async def setup_download_stream(target: str | None = None):
class InstallModelRequest(BaseModel):
repo_id: str
target: str | None = None
@@ -393,21 +381,6 @@ async def install_model(req: InstallModelRequest):
+ ", ".join(m["repo_id"] for m in KNOWN_MODELS)
),
)
target = (req.target or "").strip()
if target != "local":
from services import gpu_gateway # noqa: PLC0415
from worker import routing # noqa: PLC0415
decision = routing.decide()
if target and target != "local" and (
not decision.remote or decision.worker_id != target
):
raise HTTPException(status_code=409, detail="The selected GPU target changed; try again.")
if decision.remote:
try:
return await gpu_gateway.download(req.repo_id, decision=decision)
except gpu_gateway.GatewayError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
# Cooldown guard — don't retry if the same model just failed.
import time as _time_check
_sweep_cooldowns(_time_check.time()) # bound the dict (MM2-06)
@@ -425,7 +398,7 @@ async def install_model(req: InstallModelRequest):
def _do():
token = hf_progress.current_repo_id.set(req.repo_id)
target_token = hf_progress.current_target.set("local")
_cancelled.discard(req.repo_id) # clear any stale cancel from a prior run
hf_progress.emit({
"repo_id": req.repo_id,
"filename": req.repo_id,
@@ -520,7 +493,6 @@ async def install_model(req: InstallModelRequest):
return
download_aggregator.start(
req.repo_id,
target=target or "local",
total_bytes=_summary["to_download_bytes"],
files_total=max(0, _summary["n_files"] - _summary["n_cached"]),
)
@@ -534,7 +506,7 @@ async def install_model(req: InstallModelRequest):
# No preflight (older/gated repo, mirror without dry-run, etc.):
# fall back to today's fill-in-as-files-appear behaviour.
logger.info("model install %s: preflight unavailable (%s)", req.repo_id, _pf_err)
download_aggregator.start(req.repo_id, target=target or "local")
download_aggregator.start(req.repo_id)
hf_progress.emit({
"repo_id": req.repo_id,
"filename": req.repo_id,
@@ -634,7 +606,7 @@ async def install_model(req: InstallModelRequest):
# Flush the overall bar to 100% with the true byte total (FDL-06):
# under Xet the per-file byte bars don't surface completion, so the
# aggregator can sit below 100% even though every file landed.
download_aggregator.complete(req.repo_id, target=target or "local")
download_aggregator.complete(req.repo_id)
logger.info("model install done: %s", req.repo_id)
hf_progress.emit({
"repo_id": req.repo_id,
@@ -678,59 +650,13 @@ async def install_model(req: InstallModelRequest):
})
finally:
_cancelled.discard(req.repo_id)
download_aggregator.finish(req.repo_id, target=target or "local")
download_aggregator.finish(req.repo_id)
hf_progress.current_repo_id.reset(token)
hf_progress.current_target.reset(target_token)
with _active_installs_lock:
_active_installs.discard(req.repo_id)
with _active_installs_lock:
if req.repo_id in _active_installs:
return {"status": "already_running", "repo_id": req.repo_id}
_active_installs.add(req.repo_id)
# Admission and task publication are one atomic generation boundary:
# cancellation can never observe an admitted install without its task.
_cancelled.discard(req.repo_id)
try:
task = loop.create_task(asyncio.to_thread(_do))
_install_tasks.add(task)
_install_tasks_by_repo[req.repo_id] = task
except Exception:
_active_installs.discard(req.repo_id)
raise
def install_finished(completed: asyncio.Task) -> None:
with _active_installs_lock:
_install_tasks.discard(completed)
if _install_tasks_by_repo.get(req.repo_id) is completed:
_install_tasks_by_repo.pop(req.repo_id, None)
task.add_done_callback(install_finished)
loop.create_task(asyncio.to_thread(_do))
return {"status": "install_started", "repo_id": req.repo_id}
async def cancel_install_and_wait(repo_id: str) -> None:
"""Request cancellation and retain authority until its thread exits."""
from worker.async_utils import drain_task # noqa: PLC0415
with _active_installs_lock:
_cancelled.add(repo_id)
_install_cooldowns.pop(repo_id, None)
task = _install_tasks_by_repo.get(repo_id)
if task is None:
return
try:
# asyncio.to_thread cannot stop snapshot_download mid-file. Cancelling
# its wrapper would only detach the thread, so wait until the blocking
# call observes the flag or naturally returns.
await drain_task(task)
finally:
with _active_installs_lock:
current = _install_tasks_by_repo.get(repo_id)
if current is None or current is task:
_cancelled.discard(repo_id)
@router.post("/models/install/cancel")
async def cancel_install(req: InstallModelRequest):
"""Request cancellation of an in-flight install (FDL-11).
+42 -115
View File
@@ -90,36 +90,6 @@ def get_model_catalog() -> ModelCatalog:
# ── Platform Detection ─────────────────────────────────────────────────────
def _target_worker():
"""Selected live remote worker, or None when the catalog targets local."""
try:
from worker import routing, service # noqa: PLC0415
decision = routing.decide()
plane = service.control_plane
return plane.pool.get(decision.worker_id) if decision.remote and plane.pool else None
except Exception:
return None
def _target_host() -> dict | None:
"""Selected remote worker host, or None when the catalog targets local."""
live = _target_worker()
return dict(live.record.host or {}) if live is not None else None
def _target_repo_inventory() -> tuple[str, set[str]] | None:
"""Selected worker id and the catalog repositories it reports on disk."""
live = _target_worker()
if live is None:
return None
downloaded: set[str] = set()
for capability in live.record.capabilities or []:
if capability.get("downloaded"):
downloaded.update(str(repo) for repo in capability.get("repo_ids") or [])
return live.id, downloaded
def _current_platform_tags() -> list[str]:
"""Return platform tags that the current host supports.
@@ -130,25 +100,6 @@ def _current_platform_tags() -> list[str]:
``rocm`` (AMD HIP builds), and ``cpu`` (no GPU acceleration at all
Apple Silicon is NOT tagged cpu; it curates via ``darwin-arm64``).
"""
target = _target_host()
if target is not None:
target_os = {"windows": "win32", "darwin": "darwin"}.get(
str(target.get("os") or "").lower(), "linux"
)
arch = str(target.get("arch") or "").lower()
arch = {"amd64": "x86_64", "aarch64": "arm64"}.get(arch, arch)
tags = [target_os, f"{target_os}-{arch}"]
backend = ""
if target.get("gpus"):
backend = str(target["gpus"][0].get("backend") or "").lower()
if backend:
tags.append(backend)
if backend == "rocm":
tags.append("cuda")
if not backend and not (target_os == "darwin" and arch == "arm64"):
tags.append("cpu")
return tags
tags = [sys.platform]
arch = _platform.machine()
tags.append(f"{sys.platform}-{arch}")
@@ -504,52 +455,35 @@ def list_models():
Uses a 10 s response cache to avoid repeated ``scan_cache_dir()`` disk
walks when the frontend polls.
"""
platform_tags = _current_platform_tags()
remote_inventory = _target_repo_inventory()
target_key = remote_inventory[0] if remote_inventory else "local"
cache_key = "models:" + target_key + ":" + ",".join(sorted(platform_tags))
cached_response = _cached(cache_key)
cached_response = _cached("models")
if cached_response is not None:
return cached_response
cached_by_repo: dict[str, dict] = {}
if remote_inventory is not None:
for model in KNOWN_MODELS:
if model["repo_id"] in remote_inventory[1]:
cached_by_repo[model["repo_id"]] = {
"size_on_disk": int(float(model.get("size_gb") or 0) * _GIB),
"last_accessed": None,
"nb_files": 0,
}
else:
try:
from huggingface_hub import scan_cache_dir
info = scan_cache_dir()
for entry in info.repos:
cached_by_repo[entry.repo_id] = {
"size_on_disk": entry.size_on_disk,
"last_accessed": entry.last_accessed,
"nb_files": entry.nb_files,
}
except Exception as e:
# WinError-448 fallback (#117/#118): use a direct disk scan so installed
# models still show as installed instead of offering a re-download.
logger.warning("scan_cache_dir failed (%s); using disk fallback", e)
cached_by_repo = _scan_cache_on_disk()
try:
from huggingface_hub import scan_cache_dir
info = scan_cache_dir()
for entry in info.repos:
cached_by_repo[entry.repo_id] = {
"size_on_disk": entry.size_on_disk,
"last_accessed": entry.last_accessed,
"nb_files": entry.nb_files,
}
except Exception as e:
# WinError-448 fallback (#117/#118): use a direct disk scan so installed
# models still show as installed instead of offering a re-download.
logger.warning("scan_cache_dir failed (%s); using disk fallback", e)
cached_by_repo = _scan_cache_on_disk()
out = []
host_tags = set(platform_tags)
host_tags = set(_current_platform_tags())
for m in KNOWN_MODELS:
cached = cached_by_repo.get(m["repo_id"])
on_disk = (
m["repo_id"] in remote_inventory[1]
if remote_inventory is not None
else cached is not None and cached["size_on_disk"] > 0
)
on_disk = cached is not None and cached["size_on_disk"] > 0
# A size-positive cache can still be a truncated download (config landed,
# weight shard didn't). Treat that as not-installed + incomplete so the
# wizard re-offers the download instead of stranding the user (#622).
incomplete = on_disk and remote_inventory is None and not cache_is_complete(m)
incomplete = on_disk and not cache_is_complete(m)
out.append({
**m,
"installed": on_disk and not incomplete,
@@ -564,14 +498,14 @@ def list_models():
response = {
"models": out,
"total_installed_bytes": sum(m["size_on_disk_bytes"] for m in out),
"hf_cache_dir": "" if remote_inventory is not None else hf_cache_dir(),
"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": None if remote_inventory is not None else round(disk_free_bytes() / _GIB, 1),
"platform_tags": platform_tags,
"disk_free_gb": round(disk_free_bytes() / _GIB, 1),
"platform_tags": _current_platform_tags(),
}
_set_cache(cache_key, response)
_set_cache("models", response)
return response
@@ -584,19 +518,18 @@ def recommendations():
TTS model is required; the ASR picks here are the optional "best for your
system" set the wizard and Settings surface for on-demand install.
"""
is_mac_arm = sys.platform == "darwin" and _platform.machine() == "arm64"
is_mac_intel = sys.platform == "darwin" and _platform.machine() == "x86_64"
is_linux = sys.platform.startswith("linux")
is_windows = sys.platform == "win32"
tags = set(_current_platform_tags())
target_os = "darwin" if "darwin" in tags else "win32" if "win32" in tags else "linux"
target_arch = next((tag.split("-", 1)[1] for tag in tags if tag.startswith(target_os + "-")), _platform.machine())
is_mac_arm = target_os == "darwin" and target_arch == "arm64"
is_mac_intel = target_os == "darwin" and target_arch == "x86_64"
is_linux = target_os == "linux"
is_windows = target_os == "win32"
has_cuda = "cuda" in tags and "rocm" not in tags
has_rocm = "rocm" in tags
# Device label — used as the card title.
if is_mac_arm:
device_label = f"Apple Silicon ({target_arch})"
device_label = f"Apple Silicon ({_platform.machine()})"
elif is_mac_intel:
device_label = "macOS Intel (x86_64)"
elif is_windows:
@@ -604,7 +537,7 @@ def recommendations():
elif is_linux:
device_label = "Linux x64" + (" + CUDA" if has_cuda else " + ROCm" if has_rocm else "")
else:
device_label = f"{target_os} / {target_arch}"
device_label = f"{sys.platform} / {_platform.machine()}"
# Curated preset for this host, in catalog order (required entries lead).
curated = [
@@ -641,30 +574,24 @@ def recommendations():
"instant English TTS."
)
remote_inventory = _target_repo_inventory()
cached_ids: set[str] = set()
if remote_inventory is not None:
cached_ids = remote_inventory[1]
else:
try:
from huggingface_hub import scan_cache_dir
info = scan_cache_dir()
cached_ids = {
entry.repo_id for entry in info.repos if entry.size_on_disk > 0
}
except Exception as e:
# WinError-448 fallback (#117/#118): recommend based on the disk scan.
logger.debug("scan_cache_dir failed (%s); using disk fallback", e)
cached_ids = set(_scan_cache_on_disk().keys())
try:
from huggingface_hub import scan_cache_dir
info = scan_cache_dir()
cached_ids = {
entry.repo_id for entry in info.repos if entry.size_on_disk > 0
}
except Exception as e:
# WinError-448 fallback (#117/#118): recommend based on the disk scan.
logger.debug("scan_cache_dir failed (%s); using disk fallback", e)
cached_ids = set(_scan_cache_on_disk().keys())
entries = []
for meta in curated:
rid = meta["repo_id"]
# Mirror /models: a truncated cache (weights missing) is not installed, so
# the wizard counts it toward the remaining download instead of "all set".
installed = rid in cached_ids and (
remote_inventory is not None or cache_is_complete(meta)
)
installed = rid in cached_ids and cache_is_complete(meta)
entries.append({
"repo_id": rid,
"label": meta.get("label", rid),
@@ -680,8 +607,8 @@ def recommendations():
return {
"device": {
"os": target_os,
"arch": target_arch,
"os": sys.platform,
"arch": _platform.machine(),
"is_mac_arm": is_mac_arm,
"is_mac_intel": is_mac_intel,
"is_linux": is_linux,
+8 -24
View File
@@ -62,11 +62,6 @@ def setup_status():
_MIN_NVIDIA_DRIVER = 555
_RAM_FAIL_GB = 8
_RAM_WARN_GB = 12
# Installed DIMMs never fully reach the OS: firmware, integrated graphics and
# kernel reservations shave off up to ~7% (an "8 GB" Windows laptop reports
# ~7.8 GB usable). Thresholds are compared with this allowance applied so the
# machines a threshold is meant to admit aren't blocked by that gap (#1618).
_RAM_RESERVED_ALLOWANCE = 0.93
def _run_cmd(args: list[str], timeout: float = 2.0) -> tuple[int, str]:
@@ -188,7 +183,7 @@ def _hf_endpoint_host() -> tuple[str, int]:
"""Host/port of the Hugging Face endpoint actually in effect.
Mirror-aware: restricted-network users (e.g. behind the Great Firewall)
point HF_ENDPOINT at a mirror via Model Catalogue Models Hugging Face
point HF_ENDPOINT at a mirror via Settings Models Hugging Face
mirror. Probing hardcoded huggingface.co would fail them even when their
configured mirror works fine.
"""
@@ -286,7 +281,7 @@ def _network_check() -> dict:
"id": "network", "label": "Network (configured endpoint)",
"status": "warn",
"detail": "The configured Hugging Face endpoint could not be validated.",
"fix": "Review the endpoint in Model Catalogue → Models, then re-check.",
"fix": "Review the endpoint in Settings → Models, then re-check.",
"mirror_reachable": False,
}
net_ok = _probe_network(net_host, net_port)
@@ -357,28 +352,17 @@ def preflight():
# ── RAM
ram = _ram_gb()
# Escape hatch (#1618): a preflight should inform, not brick setup —
# OMNIVOICE_RAM_PREFLIGHT=0 downgrades the hard block to a warning for
# users who accept the OOM risk. Same opt-out shape as
# OMNIVOICE_ASR_VRAM_PREFLIGHT.
ram_gate = os.environ.get(
"OMNIVOICE_RAM_PREFLIGHT", "1"
).strip().lower() not in ("0", "false", "no")
if ram == 0:
ram_status, ram_detail, ram_fix = (
"warn", "Could not detect system RAM.",
"Install psutil in the backend environment or ignore this warning.",
)
elif ram < _RAM_FAIL_GB * _RAM_RESERVED_ALLOWANCE:
elif ram < _RAM_FAIL_GB:
ram_status, ram_detail, ram_fix = (
"fail" if ram_gate else "warn",
f"{ram:.1f} GB total (need ≥ {_RAM_FAIL_GB} GB)",
"The app will OOM on first dub. Close other apps or upgrade RAM."
if ram_gate else
"RAM check disabled via OMNIVOICE_RAM_PREFLIGHT=0 — dubbing may "
"OOM on this machine.",
"fail", f"{ram:.1f} GB total (need ≥ {_RAM_FAIL_GB} GB)",
"The app will OOM on first dub. Close other apps or upgrade RAM.",
)
elif ram < _RAM_WARN_GB * _RAM_RESERVED_ALLOWANCE:
elif ram < _RAM_WARN_GB:
ram_status, ram_detail, ram_fix = (
"warn", f"{ram:.1f} GB total ({_RAM_WARN_GB}+ GB recommended)",
"Long videos may hit swap. Keep other apps closed during dubbing.",
@@ -512,10 +496,10 @@ def preflight():
elif _rs == "unavailable":
r_status, r_detail, r_fix = "fail", (
f"{_eng} can't run on this host: {_why or 'needs a GPU this machine lacks'}"), (
"Select an engine with a CPU path in Model Catalogue → Engines.")
"Select an engine with a CPU path in Settings → Engines.")
else: # "none" / unknown
r_status, r_detail, r_fix = "warn", "No active TTS engine resolved for routing.", (
"Pick an engine in Model Catalogue → Engines.")
"Pick an engine in Settings → Engines.")
checks.append({
"id": "gpu_routing", "label": "Active engine routing",
"status": r_status, "detail": r_detail, "fix": r_fix,
-160
View File
@@ -1,160 +0,0 @@
"""Discovery contract for VoiceStudio's local speech platform.
Interfaces should discover this document instead of hard-coding whichever
dictation route the desktop happens to use. Endpoint URLs are relative so the
same response works on loopback, a tailnet GPU host, and a reverse proxy.
"""
from __future__ import annotations
import os
from typing import Literal
from fastapi import APIRouter
from pydantic import BaseModel, Field
from core.version import APP_VERSION
router = APIRouter(tags=["Speech Platform"])
SPEECH_PROTOCOL = "voicestudio.speech.v1"
STREAM_PATH = "/v1/audio/transcriptions/stream"
class EndpointCapability(BaseModel):
path: str
transport: Literal["http", "websocket", "mcp-streamable-http", "mcp-stdio"]
method: str | None = None
protocol: str | None = None
class StreamInputCapability(BaseModel):
framing: Literal["binary"] = "binary"
formats: list[str]
default_format: str
sample_rate_query: str = "sr"
end_control: dict[str, str]
class StreamOutputCapability(BaseModel):
framing: Literal["json"] = "json"
events: list[str]
final_kinds: list[str]
class SpeechFeatureCapabilities(BaseModel):
batch_transcription: bool = True
streaming_transcription: bool = True
partial_transcripts: bool = True
utterance_finals: bool = True
session_summary: bool = True
word_timestamps: bool = True
local_refinement: bool = True
acoustic_echo_cancellation: bool = True
native_dictation_control: bool = False
class SpeechAuthCapabilities(BaseModel):
loopback: Literal["none"] = "none"
remote: Literal["bearer"] = "bearer"
header: str = "Authorization: Bearer <OMNIVOICE_API_KEY>"
browser_session_endpoint: str = "/api/auth/session"
websocket_ticket_endpoint: str = "/api/auth/ws-ticket"
websocket_ticket_query_parameter: Literal["ws_ticket"] = "ws_ticket"
class SpeechCapabilities(BaseModel):
schema_: Literal["voicestudio.speech-capabilities"] = Field(
default="voicestudio.speech-capabilities",
serialization_alias="schema",
)
protocol: Literal["voicestudio.speech.v1"] = SPEECH_PROTOCOL
protocol_version: Literal["1.0"] = "1.0"
service: str = "VoiceStudio"
service_version: str = APP_VERSION
local_first: bool = True
endpoints: dict[str, EndpointCapability]
stream_input: StreamInputCapability
stream_output: StreamOutputCapability
features: SpeechFeatureCapabilities
authentication: SpeechAuthCapabilities
def speech_capabilities() -> SpeechCapabilities:
"""Return the stable, side-effect-free integration contract."""
endpoints = {
"capabilities": EndpointCapability(
path="/.well-known/voicestudio-speech",
transport="http",
method="GET",
),
"batch_transcription": EndpointCapability(
path="/v1/audio/transcriptions",
transport="http",
method="POST",
protocol="openai.audio.transcriptions",
),
"streaming_transcription": EndpointCapability(
path=STREAM_PATH,
transport="websocket",
protocol=SPEECH_PROTOCOL,
),
"mcp": EndpointCapability(
path="/mcp",
transport="mcp-streamable-http",
method="POST",
protocol="mcp",
),
"mcp_stdio": EndpointCapability(
path="python -m backend.mcp_shim",
transport="mcp-stdio",
protocol="mcp",
),
}
native_control = False
try:
control_port = int(os.environ.get("VOICESTUDIO_SPEECH_CONTROL_PORT", ""))
except (TypeError, ValueError):
control_port = 0
if 0 < control_port <= 65535:
native_control = True
endpoints["native_dictation_control"] = EndpointCapability(
path=f"http://127.0.0.1:{control_port}/v1/capabilities",
transport="http",
method="GET",
protocol=SPEECH_PROTOCOL,
)
return SpeechCapabilities(
endpoints=endpoints,
stream_input=StreamInputCapability(
formats=[
"audio/pcm;encoding=s16le;channels=1",
"audio/webm;codecs=opus",
],
default_format="audio/webm;codecs=opus",
end_control={"type": "input_audio.end"},
),
stream_output=StreamOutputCapability(
events=["session.started", "status", "partial", "final", "error"],
final_kinds=["utterance", "summary"],
),
features=SpeechFeatureCapabilities(
native_dictation_control=native_control,
),
authentication=SpeechAuthCapabilities(),
)
@router.get(
"/.well-known/voicestudio-speech",
response_model=SpeechCapabilities,
response_model_by_alias=True,
)
@router.get(
"/v1/audio/capabilities",
response_model=SpeechCapabilities,
response_model_by_alias=True,
)
async def get_speech_capabilities() -> SpeechCapabilities:
"""Advertise batch, streaming, and agent-facing speech transports."""
return speech_capabilities()
+7 -81
View File
@@ -11,7 +11,7 @@ from core.prefs import set_ as prefs_set, delete as prefs_delete
from services import network_share
from services import tailscale as _tailscale
from api.schemas import SysinfoResponse, SystemInfoResponse, ModelStatusResponse
from api.dependencies import is_loopback, require_admin, require_admin_action
from api.dependencies import is_loopback, require_admin
from fastapi.responses import FileResponse, StreamingResponse
import torch
import shutil
@@ -203,22 +203,8 @@ def system_info():
"""
try:
_ffmpeg = find_ffmpeg()
from services import model_manager as _mm
from core import prefs as _prefs_mod
return {
"app_version": APP_VERSION,
"generate_timeout_s": _mm.GPU_JOB_TIMEOUT_S,
"cpu_generate_timeout_s": _mm.CPU_JOB_TIMEOUT_S,
# #1787 review fix: a saved prefs.json value for either key can be
# silently shadowed by an external env var (os.environ.setdefault
# in core.prefs.restore_env is a no-op when one is already
# present) — the Settings panel must say so rather than promise a
# restart will apply a value that never will.
"generate_timeout_shadowed": _prefs_mod.is_env_shadowed(
"OMNIVOICE_GENERATE_TIMEOUT_S"),
"cpu_generate_timeout_shadowed": _prefs_mod.is_env_shadowed(
"OMNIVOICE_CPU_GENERATE_TIMEOUT_S"),
"code_fingerprint": os.environ.get("OMNIVOICE_BUILD_FINGERPRINT", ""),
"data_dir": DATA_DIR,
"outputs_dir": OUTPUTS_DIR,
"crash_log_path": CRASH_LOG_PATH,
@@ -254,11 +240,6 @@ def system_info():
logger.exception("system_info failed — returning safe defaults")
return {
"app_version": APP_VERSION,
"generate_timeout_s": 300.0,
"cpu_generate_timeout_s": 600.0,
"generate_timeout_shadowed": False,
"cpu_generate_timeout_shadowed": False,
"code_fingerprint": os.environ.get("OMNIVOICE_BUILD_FINGERPRINT", ""),
"data_dir": DATA_DIR,
"outputs_dir": OUTPUTS_DIR,
"crash_log_path": str(CRASH_LOG_PATH),
@@ -563,10 +544,9 @@ async def flush_memory(unload_model: bool = False):
if unload_model:
import services.model_manager as mm
async with mm._model_lock:
# Also drops the clone-prompt side cache, which this path used to
# leave resident — an "unload" that kept the encoded reference
# tensors belonging to the model it just released (#1495).
freed_model = mm.unload_shared_model()
if mm.model is not None:
mm.model = None
freed_model = True
# Multi-pass GC to break reference cycles
gc.collect(generation=2)
@@ -575,25 +555,15 @@ async def flush_memory(unload_model: bool = False):
free_vram()
# Snapshot after flush. Two numbers, because one of them is a lie by
# omission: `memory_allocated` counts live tensors only, so it reads ~0
# after an unload while nvidia-smi still shows gigabytes — which is exactly
# the report we keep getting ("flush says it worked, the GPU says it
# didn't"). `memory_reserved` is what the caching allocator holds from the
# driver, and the gap between reserved and the driver's own figure is the
# CUDA context plus kernel workspaces, which no in-process call can return.
# Snapshot after flush
vram_after = 0.0
vram_reserved = 0.0
try:
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
driver = getattr(torch.mps, "driver_allocated_memory", None)
if driver:
vram_after = driver() / (1024**3)
current = getattr(torch.mps, "current_allocated_memory", None)
vram_reserved = (current() / (1024**3)) if current else vram_after
elif torch.cuda.is_available():
vram_after = torch.cuda.memory_allocated() / (1024**3)
vram_reserved = torch.cuda.memory_reserved() / (1024**3)
except Exception:
pass
@@ -604,7 +574,6 @@ async def flush_memory(unload_model: bool = False):
"unloaded_model": freed_model,
"ram_after": round(ram_after, 2),
"vram_after": round(vram_after, 2),
"vram_reserved": round(vram_reserved, 2),
}
@@ -867,14 +836,6 @@ PERSISTENT_KEYS = {
# the Rust sidecar reads OMNIVOICE_PORT at startup and the backend derives
# the LAN-share/UI ports from the others.
"OMNIVOICE_PORT", "OMNIVOICE_SHARE_PORT", "OMNIVOICE_UI_PORT",
# Per-job compute-time budgets (#1787). Both are captured at import time
# by services/model_manager.py (GPU_JOB_TIMEOUT_S / CPU_JOB_TIMEOUT_S), so
# a value saved here takes effect on the NEXT backend restart — same
# contract as OMNIVOICE_PORT above. Restored into os.environ during the
# "env_prefs" startup step (main.py), which runs before model_manager is
# first imported ("ml_imports"), so the restored value is what the module
# captures. The Settings UI must say so (RestartBadge).
"OMNIVOICE_GENERATE_TIMEOUT_S", "OMNIVOICE_CPU_GENERATE_TIMEOUT_S",
}
# Sidecar-engine install dirs (OMNIVOICE_INDEXTTS_DIR, …). The one-click
@@ -892,16 +853,6 @@ except Exception: # pragma: no cover — defensive: env panel > installer wirin
# being set so a bad value never reaches uvicorn / the share listener.
_PORT_KEYS = {"OMNIVOICE_PORT", "OMNIVOICE_SHARE_PORT", "OMNIVOICE_UI_PORT"}
# Keys whose value is a wall-clock compute-time budget in seconds (#1787).
# Validated the same way as _PORT_KEYS: reject anything that isn't a
# positive number before it reaches services/model_manager.py. Upper bound is
# generous — long enough that a legitimate multi-hour, audiobook-length CPU
# render is never blocked — but still bounded, so a fat-fingered extra digit
# (300 -> 3000000) can't turn a wedged job into one that silently occupies a
# worker for days before the guard ever fires.
_TIMEOUT_KEYS = {"OMNIVOICE_GENERATE_TIMEOUT_S", "OMNIVOICE_CPU_GENERATE_TIMEOUT_S"}
_MAX_GENERATE_TIMEOUT_S = 21600.0 # 6 hours
@router.post("/system/set-env")
async def set_env_var(body: dict):
@@ -945,22 +896,6 @@ async def set_env_var(body: dict):
status_code=400,
detail=f"Invalid port for {key}: must be between 1024 and 65535.",
)
if key in _TIMEOUT_KEYS:
try:
timeout_n = float(value)
except (TypeError, ValueError):
raise HTTPException(
status_code=400,
detail=f"Invalid timeout for {key}: '{value}' is not a number.",
)
if not (0 < timeout_n <= _MAX_GENERATE_TIMEOUT_S):
raise HTTPException(
status_code=400,
detail=(
f"Invalid timeout for {key}: must be greater than 0 "
f"and at most {_MAX_GENERATE_TIMEOUT_S:.0f} seconds."
),
)
os.environ[key] = value
logger.info("Environment variable set (length=%d)", len(value))
@@ -1003,13 +938,7 @@ async def set_env_var(body: dict):
else:
prefs_delete(prefs_key)
# #1787 review fix: tell the caller up front when the value just saved is
# being shadowed by an external env var — set at THIS process's startup,
# before our own prefs restore ran, so it predicts the next restart too.
# A response that just said {"set": True} let the Settings panel promise
# a restart would apply a value that never will.
from core.prefs import is_env_shadowed
return {"key": key, "set": bool(value), "shadowed": is_env_shadowed(key)}
return {"key": key, "set": bool(value)}
@router.post("/clean-audio")
@@ -1148,10 +1077,7 @@ async def diagnostic_bundle(network: bool = Query(False, description="Include th
# ── Self-check diagnostics ────────────────────────────────────────────────
@router.get(
"/system/diagnose",
dependencies=[Depends(require_admin_action)],
)
@router.get("/system/diagnose")
async def system_diagnose(
network: bool = Query(True, description="Include the HuggingFace hub reachability probe"),
deep: bool = Query(False, description="Also load the active engine and synthesize a short utterance (may cold-load the model — minutes on first run)"),
+19 -113
View File
@@ -10,8 +10,7 @@ as they're generated. This unlocks:
Protocol:
Client sends JSON: {"text": "...", "voice": "profile_id", ...}
Server sends binary audio chunks (PCM16 @ 24kHz mono) as generated
Server sends JSON: {"type": "done", "duration_s": 4.2,
"gen_time_s": 1.1, "ttfa_ms": 180.0, "rtf": 0.262}
Server sends JSON: {"type": "done", "duration_s": 4.2, "gen_time_s": 1.1}
Server sends JSON: {"type": "error", "detail": "..."}
The chunked delivery targets <100ms time-to-first-audio (TTFA) on warm models.
@@ -34,30 +33,6 @@ logger = logging.getLogger("omnivoice.tts_stream")
# Smaller chunks = lower latency but more WebSocket overhead.
CHUNK_SAMPLES = int(os.environ.get("OMNIVOICE_STREAM_CHUNK", "4800"))
# Module seam for deterministic latency-contract tests. Keep every timing
# sample on the same monotonic clock.
_perf_counter = time.perf_counter
async def _resolve_stream_backend(engine_id: str | None):
"""Resolve the live-stream engine without bypassing host isolation."""
from services.tts_backend import (
OmniVoiceBackend,
active_backend_id,
get_active_tts_backend,
get_backend_class,
)
if engine_id:
return get_backend_class(engine_id)()
cls = get_backend_class(active_backend_id())
if cls is OmniVoiceBackend:
from services.model_manager import get_model
return get_active_tts_backend(model=await get_model())
return get_active_tts_backend()
class StreamTTSRequest(BaseModel):
"""Client request for streaming TTS."""
@@ -87,11 +62,6 @@ async def ws_tts(websocket: WebSocket):
await websocket.accept()
logger.info("TTS streaming WebSocket connected")
# Said once per socket, not once per utterance: a conversational client
# sends many requests down one connection and a repeated notice would be
# noise. See `_announce_local_only`.
announced_local_only = False
try:
while True:
# Wait for a text request from the client
@@ -110,48 +80,15 @@ async def ws_tts(websocket: WebSocket):
})
continue
t0 = _perf_counter()
t0 = time.perf_counter()
text = data["text"]
# Remote GPU: this socket stays on this machine, and says so.
#
# /generate's port trades progressive playback for the remote
# render — the classic path was always a single wait, so spending
# it on a faster GPU is a straight win. This route is the opposite
# shape: it exists to put audio in the user's ear before the
# sentence has finished synthesizing, and sending each utterance to
# a worker would pay queue admission, a round trip and cold-load
# risk per utterance, for the one surface where latency IS the
# feature.
#
# Silence would be worse than the limitation: the header badge
# would read "gpu2" while this machine does 100% of the work, the
# same class of lie the op-aware picker exists to stop. Said once
# per socket — a conversational client sends many requests down one
# connection — and BEFORE engine resolution, so an engine that
# cannot load still tells the user where it would have run.
if not announced_local_only:
announced_local_only = True
try:
from worker import routing as worker_routing
target = worker_routing.decide(op="tts")
except Exception: # noqa: BLE001 — advisory; never break audio
target = None
if target is not None and target.remote:
from core.scrub import scrub_text as _scrub
await websocket.send_json({
"type": "routing",
"status": "local_stream",
"reason": _scrub(
f"{target.label} is your GPU target, but live "
f"streaming runs on this machine"
),
})
try:
# Resolve engine
from services.tts_backend import (
get_active_tts_backend,
get_backend_class,
)
engine_id = data.get("engine")
# #1224: leave a breadcrumb when memory is already tight before
# a heavy load. /generate has done this since the 16 GB-Mac
@@ -167,7 +104,13 @@ async def ws_tts(websocket: WebSocket):
log_if_low(f"TTS stream load ({engine_id or 'active engine'})")
except Exception:
pass
backend = await _resolve_stream_backend(engine_id)
if engine_id:
cls = get_backend_class(engine_id)
backend = cls()
else:
from services.model_manager import get_model
model = await get_model()
backend = get_active_tts_backend(model=model)
# ── Routing gate (#21 — no silent CPU fallback). WebSockets have
# no response headers, so this uses frames: an error frame +
@@ -273,11 +216,6 @@ async def ws_tts(websocket: WebSocket):
from services.model_manager import run_on_gpu_pool_guarded
def _generate(sentence_text):
# Timed INSIDE the pool worker: the guarded dispatch below
# can queue behind other jobs, and queue wait is not
# synthesis (review on #1620) — under contention it would
# inflate rtf without the engine slowing at all.
_synth_t0 = _perf_counter()
from services.audio_dsp import apply_mastering, normalize_audio
from services.watermark import mark_synthetic
wav = backend.generate(sentence_text, **kw)
@@ -299,19 +237,12 @@ async def ws_tts(websocket: WebSocket):
# watermark._iter_chunks), which is inherent to marking
# ultra-short clips, not a coverage gap.
wav = mark_synthetic(wav, sr_actual, context="tts_stream.sentence")
return wav, sr_actual, _perf_counter() - _synth_t0
return wav, sr_actual
import torch
total_samples = 0
sr = backend.sample_rate
started = False
first_audio_at: float | None = None
# Synthesis time only. The wall clock below also carries socket
# delivery and the per-chunk event-loop yields, so deriving RTF
# from it reports "how slow was the client" as if it were engine
# throughput — on a slow consumer that inflates RTF without the
# engine having changed at all.
synth_time = 0.0
for sentence in sentences:
# Bounded + pool-reset on hang so a wedged generate can't
@@ -321,12 +252,11 @@ async def ws_tts(websocket: WebSocket):
# Length-scaled budget per sentence (#1190) — the flat 300s
# default is gone from every dispatch.
from services.model_manager import generate_timeout_s
wav_tensor, sr, sentence_synth_s = await run_on_gpu_pool_guarded(
wav_tensor, sr = await run_on_gpu_pool_guarded(
functools.partial(_generate, sentence),
what="TTS generate",
timeout=generate_timeout_s(sentence, engine=backend),
timeout=generate_timeout_s(sentence),
)
synth_time += sentence_synth_s
if not started:
# Send metadata after the first generation so
@@ -353,49 +283,25 @@ async def ws_tts(websocket: WebSocket):
end = min(sent_samples + CHUNK_SAMPLES, n_samples)
chunk = pcm_bytes[sent_samples * 2: end * 2]
await websocket.send_bytes(chunk)
if first_audio_at is None:
# TTFA ends when the first audio bytes have been
# handed to the socket. The previous log used the
# whole-render duration and called it TTFA.
first_audio_at = _perf_counter()
sent_samples = end
# Yield to event loop between chunks for responsiveness
await asyncio.sleep(0)
total_samples += n_samples
finished_at = _perf_counter()
wall_time_raw = max(0.0, finished_at - t0)
synth_time_raw = max(0.0, synth_time)
gen_time = round(wall_time_raw, 3)
gen_time = round(time.perf_counter() - t0, 3)
duration = round(total_samples / sr, 3)
ttfa_ms = (
round(max(0.0, first_audio_at - t0) * 1000.0, 1)
if first_audio_at is not None
else None
)
# RTF is a render metric: synthesis seconds per audio second.
rtf = (
round(synth_time_raw / (total_samples / sr), 3)
if total_samples > 0
else None
)
await websocket.send_json({
"type": "done",
"duration_s": duration,
"gen_time_s": gen_time,
"ttfa_ms": ttfa_ms,
"rtf": rtf,
"samples": total_samples,
"sample_rate": sr,
"engine": backend.id,
})
logger.info(
"TTS stream: %.1fs audio in %.1fs (TTFA=%s, RTF=%s)",
duration,
gen_time,
f"{ttfa_ms:.0f}ms" if ttfa_ms is not None else "n/a",
f"{rtf:.3f}" if rtf is not None else "n/a",
"TTS stream: %.1fs audio in %.1fs (TTFA=%.0fms)",
duration, gen_time, gen_time * 1000,
)
except Exception as e:
-378
View File
@@ -1,378 +0,0 @@
"""Speech-to-speech voice changer — Studio's Convert method (POST /convert).
The user drops (or records) a source clip, picks an existing voice profile,
and gets the same words back in that profile's voice: the active ASR backend
transcribes the clip (no word timestamps the text is all we need), the
active TTS engine re-synthesizes it conditioned on the profile's reference
audio, and by default the take is pitch-preservingly time-stretched
(ffmpeg atempo, clamped to one well-behaved 0.52.0 stage) so it lands near
the source clip's duration.
Deliberately reuses the /generate choke points instead of re-deriving them:
* profile row conditioning via ``generation._resolve_profile_conditioning``
(lock wins, ``kind`` authoritative, #533 language fill),
* engine resolution via ``services.tts_backend.resolve_generation_backend``
(never a silent OmniVoice fallback; ``require_cloning=True`` refuses
clone-less engines with the actionable switch-engine message),
* synthesis via ``generation._run_backend_inference`` on the guarded GPU
pool (#730 bound + reset; busy/timeout → retryable 503),
* provenance + persistence via ``services.watermark.mark_synthetic_async``
and ``generation._finalize_generation`` (watermark WAV in OUTPUTS_DIR
history row retention prune), marked AFTER the stretch so the take users
keep carries exactly one whole-take mark.
Local-first: no network calls; ASR-model-less installs get the same typed
409 download CTA as /transcribe; a backend mid-shutdown surfaces the global
503 ``[shutting_down]`` (ModelLoadInterruptedByShutdown main.py handler).
Reachability matches /generate: loopback bind by default, with the shared
network-share PIN / API-key middleware gating any non-loopback exposure.
"""
from __future__ import annotations
import asyncio
import functools
import logging
import os
import tempfile
import time
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
router = APIRouter()
logger = logging.getLogger("omnivoice.convert")
#: ffmpeg's atempo filter is well-behaved in [0.5, 2.0] per stage. Convert
#: clamps to ONE stage by design: needing more than 2× either way means the
#: synthesized speech differs so much from the source that "matching" it
#: would produce chipmunk/slow-motion artifacts worse than the mismatch.
ATEMPO_MIN = 0.5
ATEMPO_MAX = 2.0
#: Within this relative tolerance the durations already match — stretching
#: would resample the whole take for an inaudible gain.
_MATCH_TOLERANCE = 0.02
#: Convert clips are short conversational inputs, not long-form media. Stream
#: them to disk in bounded chunks so a network-share client cannot make the
#: backend materialize an arbitrarily large multipart upload in memory.
_MAX_SOURCE_AUDIO_BYTES = 64 * 1024 * 1024
_UPLOAD_CHUNK_BYTES = 1024 * 1024
async def _copy_source_upload(audio: UploadFile, destination) -> int:
"""Stream ``audio`` into ``destination`` with the Convert upload cap."""
total = 0
while True:
chunk = await audio.read(_UPLOAD_CHUNK_BYTES)
if not chunk:
return total
total += len(chunk)
if total > _MAX_SOURCE_AUDIO_BYTES:
raise HTTPException(
status_code=413,
detail="Source audio is too large (maximum 64 MB).",
)
destination.write(chunk)
def _clamped_tempo_ratio(tts_duration_s: float, source_duration_s: float) -> "float | None":
"""The atempo ratio that fits the take into the source duration, or None.
ratio > 1 speeds the take up (it came out longer than the source),
ratio < 1 slows it down. Clamped to a single atempo stage's [0.5, 2.0];
None when either duration is unusable or they already match.
"""
if not source_duration_s or source_duration_s <= 0:
return None
if not tts_duration_s or tts_duration_s <= 0:
return None
ratio = tts_duration_s / source_duration_s
if abs(ratio - 1.0) <= _MATCH_TOLERANCE:
return None
return min(ATEMPO_MAX, max(ATEMPO_MIN, ratio))
async def _match_source_duration(audio_tensor, sample_rate: int, source_duration_s: float):
"""Best-effort pitch-preserving stretch of the take toward the source
clip's duration. Returns the input unchanged when no stretch is needed
or ffmpeg fails a duration mismatch is better than a failed convert."""
n_samples = int(audio_tensor.shape[-1])
ratio = _clamped_tempo_ratio(n_samples / sample_rate, source_duration_s)
if ratio is None:
return audio_tensor
target_samples = max(1, int(round(n_samples / ratio)))
from services.ffmpeg_utils import _pitch_preserving_stretch
try:
return await _pitch_preserving_stretch(audio_tensor, target_samples, sample_rate)
except Exception as e: # noqa: BLE001 — stretch is opt-in polish, never fatal
logger.warning("duration match skipped — atempo stretch failed: %s", e)
return audio_tensor
async def _transcribe_source(tmp_path: str, *, source_lease=None) -> dict:
"""Active-ASR transcription of the uploaded clip (no word timestamps).
Mirrors POST /transcribe: typed 409 + download CTA before any backend
is constructed (never a silent multi-GB auto-download), the guarded GPU
pool dispatch (#730), 504 on timeout, and the same 409 when the loader
degrades onto an engine with no weights on disk (#1185).
"""
from services.asr_backend import (
ASRModelMissingError,
ASRTimeoutError,
asr_model_missing_detail,
asr_model_missing_error,
run_transcribe_guarded,
)
missing = await asyncio.to_thread(asr_model_missing_error, purpose="transcribe")
if missing is not None:
raise HTTPException(
status_code=409,
detail={**missing, "message": asr_model_missing_detail(missing)},
)
def _run():
# `load_*`, not `get_*`: the loader runs ensure_loaded() and degrades
# past an engine whose deep import chain is broken (#1185).
from services.asr_backend import load_active_asr_backend
backend = load_active_asr_backend()
return backend.transcribe(tmp_path, word_timestamps=False)
from services.model_manager import _gpu_pool
release = source_lease.acquire() if source_lease is not None else None
abandoned = False
try:
return await run_transcribe_guarded(
_gpu_pool,
_run,
what="Voice convert",
on_abandon=release,
)
except asyncio.CancelledError:
# The guard now owns the lease token until the native worker drains.
abandoned = True
raise
except ASRTimeoutError as e:
abandoned = True
logger.warning("Convert transcription timed out: %s", e)
raise HTTPException(status_code=504, detail=str(e))
except ASRModelMissingError as e:
raise HTTPException(
status_code=409,
detail={**e.payload, "message": asr_model_missing_detail(e.payload)},
)
finally:
if release is not None and not abandoned:
release()
@router.post("/convert")
async def convert_speech(
audio: UploadFile = File(...),
profile_id: str = Form(...),
match_duration: bool = Form(True),
):
"""Convert a spoken clip into an existing voice profile's voice.
Multipart form: ``audio`` (the source clip), ``profile_id`` (an existing
voice profile), optional ``match_duration`` (default on atempo the take
toward the source clip's length, clamped to 0.52.0×).
Returns JSON ``{audio_url, text, duration_s, id}`` the take is saved to
OUTPUTS_DIR and served from the ``/audio`` mount like every other take.
"""
from core.db import db_conn
from api.routers.generation import _resolve_profile_conditioning, _TempReferenceLease
# ── Profile first: strict 404, unlike /generate's silent skip — Convert
# has no meaning without a target voice.
with db_conn() as conn:
row = conn.execute(
"SELECT * FROM voice_profiles WHERE id=?", (profile_id,)
).fetchone()
if not row:
raise HTTPException(
status_code=404,
detail="That voice profile doesn't exist. It may have been deleted from another tab.",
)
cond = _resolve_profile_conditioning(row)
# ── Save the upload before loading an engine. Every ASR backend (and
# ffprobe) needs a file path; the bounded streaming copy rejects oversized
# network-share requests without materializing them in process memory or
# starting heavyweight model work.
ext = os.path.splitext(audio.filename or "audio.wav")[1] or ".wav"
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=ext)
source_lease = None
try:
try:
await _copy_source_upload(audio, tmp)
finally:
tmp.close()
source_lease = _TempReferenceLease(tmp.name)
# ── Engine gate before ASR/TTS work: the shared resolver refuses a
# clone-less engine with the actionable switch-engine message (→ 400),
# and a backend mid-shutdown raises ModelLoadInterruptedByShutdown out
# of the model load → the global 503 [shutting_down] handler.
from services.tts_backend import resolve_generation_backend
try:
backend = await resolve_generation_backend(
require_cloning=True, cloning_purpose="voice conversion",
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
result = await _transcribe_source(tmp.name, source_lease=source_lease)
segments = result.get("segments", [])
text = result.get("text", "")
if not text and segments:
text = " ".join(s.get("text", "") for s in segments).strip()
# Same final-text hygiene as /transcribe: strip Whisper hallucination
# loops, then deterministic polish (leading capital + terminal
# punctuation) so the TTS input reads as typed text.
from services.refinement import collapse_repetitive_artifacts
from services.text_polish import polish_text
text = polish_text(collapse_repetitive_artifacts(text))
if not text or not text.strip():
raise HTTPException(
status_code=422,
detail=(
"No speech was recognized in the source clip, so there is "
"nothing to convert. Record or drop a clip with clear, "
"audible speech and try again."
),
)
# #308/#1032 parity with /generate: a clone profile saved without a
# transcript conditions better when its reference clip is transcribed,
# and that transcript is cached onto the row so it happens ONCE, not
# per convert. Best-effort exactly like /generate — a timeout/failure
# degrades to ref_text=None and the engine's own fallback. The ASR
# model is already warm here (the source transcribe above just used it).
if cond["ref_audio_path"] and not cond["ref_text"]:
from api.routers.generation import (
_generate_timeout_s,
_persist_profile_ref_text,
)
from services.asr_backend import transcribe_reference
from services.model_manager import run_on_gpu_pool_guarded
try:
cond["ref_text"] = await run_on_gpu_pool_guarded(
functools.partial(transcribe_reference, cond["ref_audio_path"]),
what="Reference transcribe",
timeout=_generate_timeout_s(""),
)
except TimeoutError as e:
logger.warning(
"reference transcribe hung (%s); using engine ASR fallback", e,
)
cond["ref_text"] = None
if cond["ref_text"] and cond["persist_ref_text"]:
_persist_profile_ref_text(profile_id, cond["ref_text"])
# Source duration for the optional match: the container's own length
# (ffprobe), falling back to the last ASR segment end. Best-effort —
# None just skips the stretch.
source_duration_s = None
if match_duration:
from services.ffmpeg_utils import probe_duration
source_duration_s = await probe_duration(
tmp.name, allowed_root=os.path.dirname(tmp.name),
)
if not source_duration_s and segments:
source_duration_s = max((s.get("end", 0) or 0) for s in segments) or None
# ── Same text choke point as /generate: engine-agnostic normalization
# (numbers→words, junk strip) on the fully resolved language.
from services.text_normalization import normalize_for_tts
language = cond["language"]
text = normalize_for_tts(text, language)
used_seed = cond["seed"]
if used_seed is None:
import random
used_seed = random.randint(0, 2**31 - 1)
from api.routers.generation import (
_finalize_generation,
_generate_timeout_s,
_run_backend_inference,
)
from services.model_manager import (
GpuJobTimeoutError,
GpuPoolBusyError,
run_on_gpu_pool_guarded,
)
start_time = time.time()
_render = functools.partial(
_run_backend_inference,
backend, text, language, cond["ref_audio_path"], cond["ref_text"],
cond["instruct"],
None, # duration — the model picks; match_duration owns pacing
16, 2.0, # num_step / guidance_scale (the /generate defaults)
1.0, # speed
True, True, # denoise / postprocess_output
used_seed,
)
try:
audio_tensor = await run_on_gpu_pool_guarded(
_render,
what="Voice convert",
timeout=_generate_timeout_s(text),
min_vram_gb=getattr(type(backend), "min_vram_gb", 0.0),
)
except GpuPoolBusyError as e:
raise HTTPException(
status_code=503, detail=str(e),
headers={"Retry-After": str(e.retry_after),
"X-OmniVoice-Retryable": "true"},
) from e
except GpuJobTimeoutError as e:
raise HTTPException(
status_code=503, detail=str(e),
headers={"Retry-After": "30", "X-OmniVoice-Retryable": "true"},
) from e
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
sample_rate = backend.sample_rate
if match_duration and source_duration_s:
audio_tensor = await _match_source_duration(
audio_tensor, sample_rate, source_duration_s,
)
# Provenance mark AFTER the stretch (one whole-take mark on the audio
# the user actually keeps), then the shared finalize tail — WAV in
# OUTPUTS_DIR, self-healing history row, retention prune, event emit.
from services.watermark import mark_synthetic_async
audio_tensor = await mark_synthetic_async(
audio_tensor, sample_rate, context="convert.finalize",
)
_, meta = await _finalize_generation(
audio_tensor, sample_rate, text=text, history_mode="convert",
ref_audio_path=cond["ref_audio_path"], language=language,
instruct=cond["instruct"], resolved_profile_id=profile_id,
used_seed=used_seed, start_time=start_time,
already_marked=True,
)
return {
"id": meta["id"],
"audio_url": f"/audio/{meta['filename']}",
"text": text,
"duration_s": meta["duration"],
"gen_time_s": meta["gen_time"],
}
finally:
if source_lease is not None:
source_lease.finish_request()
else:
try:
os.unlink(tmp.name)
except OSError:
pass
-819
View File
@@ -1,819 +0,0 @@
"""Remote worker management API.
Deliberately small. The council's warning about the original design was that
seven strategies times three execution modes times priorities times weights
times per-model concurrency is a configuration surface nobody can test and
every knob is a compatibility promise forever. So this exposes what a user
actually needs to run their other GPU: see workers, add one, name it, prefer
one, pause one, remove one.
Two things here are not conveniences and must not be softened:
* **Consent is explicit and per worker.** Audio, reference voices, and text
leave the machine for a worker, so each one is approved individually. There
is no global "trust all workers".
* **A token is shown exactly once.** Only its hash is stored, so it cannot be
re-displayed which is the point.
One endpoint here is not part of that surface: `POST /workers/tasks` submits a
single task and waits for it, and exists only because the scheduler otherwise
has no caller at all outside the tests. It is marked dev-only everywhere it
appears and is replaced by the GPU gateway.
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from api.dependencies import require_admin
from worker import registry, routing, service
from worker.async_utils import drain_task, to_thread_and_defer_cancellation
logger = logging.getLogger("omnivoice.worker")
# How often an awaiting request checks whether its caller is still there.
# Starlette does not cancel a handler when the client hangs up, so polling is
# the only way the "cancel what nobody is waiting for" rule can fire before
# the task's own deadline does.
_DISCONNECT_POLL_SECONDS = 1.0
# Management is admin-gated: these endpoints mint join tokens and revoke
# machines, so Docker writes require the API key while desktop stays loopback.
router = APIRouter(prefix="/workers", tags=["workers"], dependencies=[Depends(require_admin)])
class EnableRequest(BaseModel):
enabled: bool
class EnrollRequest(BaseModel):
label: str = Field("", max_length=120)
endpoint: str = Field("", max_length=256)
ttl_seconds: int = Field(900, ge=60, le=24 * 3600)
class JoinRequest(BaseModel):
"""A join code, as pasted (or scanned) from the control plane."""
token: str = Field(..., max_length=4096)
class TargetRequest(BaseModel):
"""`local`, or the id of an enrolled worker."""
target: str = Field(..., max_length=64)
class WorkerUpdate(BaseModel):
name: str | None = Field(None, max_length=120)
enabled: bool | None = None
priority: int | None = Field(None, ge=0, le=100)
class SubmitTaskRequest(BaseModel):
"""One unit of work for a remote worker. **Dev only** — see `submit_task`."""
engine: str = Field(..., max_length=64)
operation: str = Field("tts", max_length=32)
model_id: str = Field("", max_length=128)
params: dict = Field(default_factory=dict)
# Mandatory, and deliberately without a default: the sweeper fails a task
# on its deadline only while it is QUEUED, so one submitted without a
# deadline while no worker is online waits forever with nothing left in
# the system that would ever time it out.
deadline_seconds: float = Field(..., gt=0, le=6 * 3600)
idempotency_key: str | None = Field(None, max_length=128)
class _ClientGone(Exception):
"""The caller hung up while its task was still running."""
class _WaitExpired(Exception):
"""The task did not reach a terminal state inside its deadline."""
@router.get("")
def list_workers() -> dict:
"""Everything the workers panel renders, in one call."""
return service.control_plane.snapshot()
@router.get("/target")
def get_target(op: str = "") -> dict:
"""What the GPU picker shows: the choice, the resolved answer, the options.
`active` is the same answer the generation path uses, so the badge cannot
claim work goes somewhere the router will not send it. Pass `op` for the
surface being rendered omitting it answers for the target as a whole,
which is what the picker's own menu asks.
"""
return routing.status(op=op.strip() or None)
@router.post("/target")
def set_target(request: TargetRequest) -> dict:
"""Choose where work runs. Exactly one target is active at a time."""
chosen = request.target.strip() or routing.LOCAL
if chosen != routing.LOCAL:
worker = registry.get(chosen)
if worker is None or worker.revoked:
raise HTTPException(status_code=404, detail="No such worker.")
routing.set_target_id(chosen)
return routing.status()
@router.post("/enabled")
async def set_enabled(request: EnableRequest) -> dict:
"""Turn the feature on or off.
Off means off: the control plane stops, the listening socket closes, and
the app is exactly what it was before the toggle existed.
"""
service.set_remote_workers_enabled(request.enabled)
if request.enabled:
try:
await service.control_plane.start()
except Exception as exc:
service.control_plane.startup_error = str(exc)
raise HTTPException(status_code=409, detail=str(exc)) from exc
else:
await service.control_plane.stop()
return service.control_plane.snapshot()
@router.get("/agent")
def agent_status() -> dict:
"""The other side of the same feature: is THIS machine lending its GPU?
Separate from `GET /workers`, which answers for the control plane. A
machine can legitimately be both a desktop that borrows a laptop's GPU
and lends its own to a colleague so neither status can stand in for the
other.
"""
from worker import agent as worker_agent # noqa: PLC0415
return worker_agent.agent.status()
@router.get("/agent/readiness", include_in_schema=False, response_model=None)
def agent_readiness() -> JSONResponse:
"""Container readiness: 200 only after this process registered as a worker."""
from worker import agent as worker_agent # noqa: PLC0415
readiness = worker_agent.agent.readiness()
return JSONResponse(
status_code=200 if readiness["ready"] else 503,
content=readiness,
headers={} if readiness["ready"] else {"Retry-After": "2"},
)
def _refuse_when_env_pinned(worker_agent) -> None:
"""OMNIVOICE_WORKER_MODE wins over the setting everywhere else.
`worker_mode_enabled()` reads the variable first and `status()` reports the
machine as env-pinned, so a route that changed worker mode anyway would
contradict both: it writes a setting nothing consults, and the next restart
undoes whatever the user just saw happen.
"""
if worker_agent.agent.status()["env_pinned"]:
raise HTTPException(
status_code=409,
detail=(
"OMNIVOICE_WORKER_MODE controls this machine's worker mode. Unset it "
"and restart VoiceStudio to manage it from here."
),
)
async def _finish_cleanup(awaitable):
"""Run rollback to completion even if its HTTP task was cancelled."""
task = asyncio.create_task(awaitable)
await drain_task(task)
return task.result()
async def _set_worker_mode(worker_agent, enabled: bool) -> None:
_result, cancelled = await to_thread_and_defer_cancellation(
worker_agent.set_worker_mode_enabled, enabled
)
if cancelled:
raise asyncio.CancelledError
async def _restore_agent_transaction(
worker_agent, previous: dict, *, was_running: bool
) -> None:
"""Restore durable enrollment/settings and the exact prior live state."""
try:
await _finish_cleanup(worker_agent.agent.stop())
await _finish_cleanup(worker_agent.restore_enrollment(previous))
if was_running and not worker_agent.agent.running:
await _finish_cleanup(worker_agent.agent.start())
elif not was_running and worker_agent.agent.running:
await _finish_cleanup(worker_agent.agent.stop())
except worker_agent.EnrollmentRollbackError:
raise
except BaseException as exc:
message = (
"The previous worker state could not be restored safely. "
"Worker mode remains stopped; fix its enrollment/settings storage, then retry."
)
with contextlib.suppress(BaseException):
await _finish_cleanup(worker_agent.agent.stop())
worker_agent.agent.last_error = message
raise worker_agent.EnrollmentRollbackError(message) from exc
def _raise_agent_transaction_failure(
worker_agent, operation: BaseException, rollback: BaseException | None
) -> None:
if isinstance(operation, asyncio.CancelledError):
if rollback is not None:
logger.error(
"Worker rollback failed during request cancellation",
exc_info=(type(rollback), rollback, rollback.__traceback__),
)
raise operation
if rollback is not None:
raise HTTPException(status_code=409, detail=str(rollback)) from rollback
if isinstance(operation, Exception):
worker_agent.agent.last_error = str(operation)
raise HTTPException(status_code=409, detail=str(operation)) from operation
raise operation
@router.post("/agent/join")
async def join_control_plane(request: JoinRequest) -> dict:
"""Redeem a join code and start working for that control plane.
This is the endpoint that makes the feature reachable. Joining used to mean
setting OMNIVOICE_WORKER_MODE and OMNIVOICE_WORKER_TOKEN in the environment
and relaunching the app a step most users will never take, on the machine
that is usually the least convenient to configure by hand.
The code is single-use and short-lived, so a failure here is nearly always
"expired" or "wrong address"; it is returned verbatim rather than as a bare
409, because the user's next action depends on which one it was.
"""
from worker import agent as worker_agent # noqa: PLC0415
token = request.token.strip()
if not token:
raise HTTPException(status_code=422, detail="Paste the join code first.")
# Same rule as the toggle below: joining ENABLES worker mode, so under
# OMNIVOICE_WORKER_MODE it would write a setting the rest of the app
# ignores — and with the variable set to 0, hand the user a machine that
# says it joined and never lends anything (CodeRabbit).
_refuse_when_env_pinned(worker_agent)
async with worker_agent.agent.lifecycle:
try:
previous, cancelled = await to_thread_and_defer_cancellation(
worker_agent.snapshot_enrollment
)
except worker_agent.EnrollmentStateError as exc:
worker_agent.agent.last_error = str(exc)
raise HTTPException(status_code=409, detail=str(exc)) from exc
if cancelled:
raise asyncio.CancelledError
was_running = worker_agent.agent.running
# A rejoin stops a working agent before the replacement is accepted.
# Stop, acceptance and the durable setting are one transaction: every
# failure, including cancellation, restores both trust and live state.
try:
await worker_agent.agent.stop()
await worker_agent.agent.start(token_text=token)
# Success is the control plane ACCEPTING this worker, not the
# connection being scheduled — see wait_until_registered.
await worker_agent.agent.wait_until_registered()
await _set_worker_mode(worker_agent, True)
except BaseException as exc:
rollback_exc = None
try:
await _restore_agent_transaction(
worker_agent, previous, was_running=was_running
)
except BaseException as rollback_error:
rollback_exc = rollback_error
_raise_agent_transaction_failure(worker_agent, exc, rollback_exc)
worker_agent.agent.last_error = ""
return worker_agent.agent.status()
@router.post("/agent/enabled")
async def set_agent_enabled(request: EnableRequest) -> dict:
"""Start or stop lending this machine, without forgetting the enrollment.
Off stops the agent and clears the setting, so nothing dials out; the
pinned certificate stays, which is what lets "on" resume without asking for
another code.
"""
from worker import agent as worker_agent # noqa: PLC0415
_refuse_when_env_pinned(worker_agent)
async with worker_agent.agent.lifecycle:
try:
previous, cancelled = await to_thread_and_defer_cancellation(
worker_agent.snapshot_enrollment
)
except worker_agent.EnrollmentStateError as exc:
worker_agent.agent.last_error = str(exc)
raise HTTPException(status_code=409, detail=str(exc)) from exc
if cancelled:
raise asyncio.CancelledError
was_running = worker_agent.agent.running
try:
if request.enabled:
await worker_agent.agent.start()
await worker_agent.agent.wait_until_registered()
await _set_worker_mode(worker_agent, True)
else:
await worker_agent.agent.stop()
await _set_worker_mode(worker_agent, False)
except BaseException as exc:
rollback_exc = None
try:
await _restore_agent_transaction(
worker_agent, previous, was_running=was_running
)
except BaseException as rollback_error:
rollback_exc = rollback_error
_raise_agent_transaction_failure(worker_agent, exc, rollback_exc)
worker_agent.agent.last_error = ""
return worker_agent.agent.status()
@router.post("/enrollments")
def create_enrollment(request: EnrollRequest) -> dict:
"""Mint a single-use join token.
The plaintext is returned once and never stored the response is the only
time it exists outside the worker that redeems it.
"""
if not service.control_plane.running:
raise HTTPException(
status_code=409,
detail="Remote workers are turned off. Enable them in Settings → System → Remote workers first.",
)
try:
token = service.control_plane.create_enrollment(
endpoint=request.endpoint,
label=request.label,
ttl_seconds=request.ttl_seconds,
)
except service.EndpointCertificateError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
return {
"token": token.encode(),
"endpoint": token.endpoint,
"fingerprint": token.cert_fingerprint,
"expires_at": token.expires_at,
"shown_once": True,
}
def _persist_worker_update(
worker_id: str, request: WorkerUpdate
):
"""Write policy on a worker thread; live publication stays loop-owned."""
return registry.update_policy(
worker_id,
name=request.name,
enabled=request.enabled,
priority=request.priority,
)
@router.patch("/{worker_id}")
async def update_worker(worker_id: str, request: WorkerUpdate) -> dict:
pool = service.control_plane.pool if service.control_plane.running else None
live = None
was_pending = False
if pool is not None:
# Quiesce dispatch before releasing authority for the SQLite write.
# The publication after the await restores the exact prior state, so a
# concurrent registration handoff remains quiesced for its own reason.
with registry.authority_guard():
live = pool.get(worker_id)
if live is not None:
was_pending = live.registration_pending
live.registration_pending = True
updated = None
cancelled = False
try:
updated, cancelled = await to_thread_and_defer_cancellation(
_persist_worker_update, worker_id, request
)
finally:
if pool is not None:
with registry.authority_guard():
if updated is not None:
# Pool state, including the cached record the scheduler
# reads, belongs to the app's event loop.
pool.refresh_record(updated)
current = pool.get(worker_id)
if current is live:
current.registration_pending = was_pending
if updated is None:
if cancelled:
raise asyncio.CancelledError
raise HTTPException(status_code=404, detail="No such worker.")
if cancelled:
raise asyncio.CancelledError
return updated.to_dict()
@router.post("/{worker_id}/consent")
def grant_consent(worker_id: str) -> dict:
"""Record the user's explicit yes to sending their audio to this machine."""
if registry.get(worker_id) is None:
raise HTTPException(status_code=404, detail="No such worker.")
registry.grant_consent(worker_id)
worker = registry.get(worker_id)
return worker.to_dict() if worker else {}
@router.post("/{worker_id}/resume")
async def clear_breaker(worker_id: str) -> dict:
"""Clear a paused worker's circuit breakers.
The user fixed the machine and knows it a breaker with no manual clear is
the quarantine trap the reputation system had.
"""
if not service.control_plane.running:
raise HTTPException(status_code=409, detail="Remote workers are turned off.")
breakers = service.control_plane.pool.breakers
for breaker in breakers.open_breakers(worker_id):
breaker.force_close()
return {"ok": True}
@router.delete("/{worker_id}")
async def revoke_worker(worker_id: str) -> dict:
"""Remove a worker — which means revoke its key, not hide the row.
Its in-flight work is released so it can be retried elsewhere rather than
waiting out a lease on a machine that will never answer again.
"""
pool = service.control_plane.pool if service.control_plane.running else None
live = None
was_pending = False
if pool is not None:
with registry.authority_guard():
live = pool.get(worker_id)
if live is not None:
was_pending = live.registration_pending
live.registration_pending = True
try:
revoked, cancelled = await to_thread_and_defer_cancellation(
registry.revoke, worker_id
)
except BaseException:
if pool is not None:
with registry.authority_guard():
current = pool.get(worker_id)
if current is live:
current.registration_pending = was_pending
raise
if not revoked:
if pool is not None:
with registry.authority_guard():
current = pool.get(worker_id)
if current is live:
current.registration_pending = was_pending
if cancelled:
raise asyncio.CancelledError
raise HTTPException(status_code=404, detail="No such worker.")
# The tombstone committed before any egress/session mutation. Everything
# below is loop-owned and published under the same scheduler authority read
# used by next_assignment(), so no task can bind in the handoff window.
with registry.authority_guard():
if service.control_plane.running:
if service.control_plane.servicer is not None:
service.control_plane.servicer.revoke_worker_sessions(worker_id)
service.control_plane.scheduler.on_disconnected(worker_id)
service.control_plane.pool.breakers.forget_worker(worker_id)
if cancelled:
raise asyncio.CancelledError
return {"ok": True, "revoked": worker_id}
@router.get("/tasks")
def list_tasks(limit: int = 50) -> dict:
"""Recent remote tasks, for the queue view."""
if not service.control_plane.running:
return {"tasks": [], "queue_depth": 0}
from worker import task_store # noqa: PLC0415
return {
"queue_depth": service.control_plane.scheduler.queue_depth,
"tasks": [t.to_dict() for t in task_store.list_tasks(limit=min(200, max(1, limit)))],
}
@router.post("/tasks")
async def submit_task(request: Request, body: SubmitTaskRequest) -> dict:
"""Run one task on a remote worker and wait for it. **DEV ONLY.**
This is the producer the remote pipeline never had: until it existed the
scheduler had no caller outside the test suite, so picking a remote GPU
changed the badge and nothing else every job still ran locally. It is
the smallest thing that makes remote execution observable end to end, not
the shipping surface: the GPU gateway takes over routing real generation
and this endpoint goes with it.
Loopback-only and behind the same opt-in as the rest of the feature, so a
user who never enabled remote workers cannot reach it at all.
"""
from worker.lifecycle import TaskState # noqa: PLC0415
from worker.scheduler import QueueFull, SchedulerStopped # noqa: PLC0415
if not service.remote_workers_enabled() or not service.control_plane.running:
raise HTTPException(status_code=409, detail="Remote workers are turned off.")
if not routing.supports_operation(body.operation):
raise HTTPException(
status_code=400,
detail=f"'{body.operation}' does not run on a remote worker yet.",
)
scheduler = service.control_plane.scheduler
try:
submit = getattr(scheduler, "submit_async", None)
submit = submit if callable(submit) else scheduler.submit
submitted = submit(
operation=body.operation,
engine=body.engine,
model_id=body.model_id,
params=body.params,
idempotency_key=body.idempotency_key or None,
deadline_seconds=body.deadline_seconds,
pinned_worker_id=routing.decide().worker_id or None,
)
task = await submitted if asyncio.iscoroutine(submitted) else submitted
except QueueFull as exc:
raise HTTPException(status_code=429, detail=str(exc)) from exc
settled = None
reason = "the request was interrupted"
try:
settled = await _await_terminal(
request, scheduler, task.task_id, timeout=body.deadline_seconds
)
except _ClientGone:
reason = "the client disconnected"
raise HTTPException(status_code=499, detail="The client stopped waiting.") from None
except _WaitExpired:
reason = "the task passed its deadline"
raise HTTPException(
status_code=504,
detail=f"The task did not finish within {body.deadline_seconds:g}s.",
) from None
except SchedulerStopped as exc:
# Deliberately no cancel: the worker was never told to stop and may
# still be rendering, so claiming the task is cancelled would be a
# statement about someone else's GPU that we cannot make.
reason = None
raise HTTPException(status_code=503, detail=str(exc)) from None
finally:
# Nothing else will stop it: a worker holds its slot — often its only
# one — until the control plane says otherwise, and the sweeper only
# enforces deadlines on tasks that are still queued. Swallowed because
# a failure here would replace the caller's real error with a 500.
if settled is None and reason is not None:
try:
await service.control_plane.cancel(task.task_id, reason=reason)
except Exception:
logger.exception("Could not cancel abandoned remote task %s", task.task_id)
payload = settled.to_dict()
if settled.state is TaskState.COMPLETED:
return payload
# A failure that answered 200 would be indistinguishable from success to
# anything that does not read `state` — which is the whole point of this
# endpoint existing before the gateway does.
raise HTTPException(
status_code=409 if settled.state is TaskState.CANCELLED else 502, detail=payload
)
async def _await_terminal(request: Request, scheduler, task_id: str, *, timeout: float):
"""Wait for a terminal task, giving up if the caller does first."""
waiter = asyncio.ensure_future(scheduler.wait(task_id, timeout=timeout))
while True:
done, _pending = await asyncio.wait({waiter}, timeout=_DISCONNECT_POLL_SECONDS)
if done:
try:
settled = waiter.result()
except (asyncio.TimeoutError, TimeoutError) as exc:
raise _WaitExpired() from exc
if settled is None or not settled.state.terminal:
raise _WaitExpired()
return settled
if await request.is_disconnected():
waiter.cancel()
raise _ClientGone()
@router.post("/tasks/{task_id}/cancel")
async def cancel_task(task_id: str) -> dict:
if not service.control_plane.running:
raise HTTPException(status_code=409, detail="Remote workers are turned off.")
cancelled = await service.control_plane.cancel(task_id, reason="cancelled by user")
if not cancelled:
raise HTTPException(status_code=404, detail="No such active task.")
return {"ok": True}
# ── Inbound mode ───────────────────────────────────────────────────────────
#
# The other direction: this machine accepts connections from panels, or dials
# out to nodes that do. Outbound enrollment above is unchanged and remains the
# default — see docs/adr/inbound-node-mode.md for why this exists alongside it
# rather than replacing it.
class InboundEnableRequest(BaseModel):
enabled: bool
# Widening the bind is a separate decision from turning the feature on,
# so it is a separate field with a safe default rather than a flag that
# rides along with `enabled`.
bind: str = ""
port: int = 0
class IssueKeyRequest(BaseModel):
label: str = Field(default="", max_length=64)
class ConnectRequest(BaseModel):
connection_string: str = Field(min_length=1, max_length=512)
@router.get("/inbound")
def inbound_status() -> dict:
from worker.inbound import service as inbound_service # noqa: PLC0415
return {
**inbound_service.node.snapshot(),
"connections": inbound_service.outbound.snapshot(),
}
@router.post("/inbound/enabled")
async def set_inbound_enabled(request: InboundEnableRequest) -> dict:
from worker.inbound import service as inbound_service # noqa: PLC0415
if inbound_service.enabled_override() is not None:
raise HTTPException(
status_code=409,
detail=(
"Accept connections is controlled by OMNIVOICE_INBOUND_NODE on this "
"machine. Change that environment setting and restart VoiceStudio."
),
)
requested_bind = (
inbound_service.normalise_bind_host(request.bind)
if request.bind
else inbound_service.bind_host()
)
requested_port = request.port or inbound_service.bind_port()
if (
request.enabled
and inbound_service.node.running
and (
requested_bind != inbound_service.bind_host()
or requested_port != inbound_service.node.port
)
):
# start() is intentionally idempotent while a listener owns its
# socket. Persisting a new endpoint here would make the UI report a
# narrower/different bind while the original socket stayed live.
raise HTTPException(
status_code=409,
detail=(
"Turn off Accept connections before changing its bind address "
"or port."
),
)
if request.bind:
inbound_service.set_bind_host(requested_bind)
if request.port:
inbound_service.set_bind_port(request.port)
inbound_service.set_enabled(request.enabled)
if inbound_service.enabled():
await inbound_service.node.start()
if inbound_service.node.startup_error:
logger.error("Inbound worker listener failed to start; details withheld.")
raise HTTPException(
status_code=409,
detail=(
"The inbound worker listener could not start; "
"check the backend log for details."
),
)
else:
await inbound_service.node.stop()
return inbound_service.node.snapshot()
@router.post("/inbound/keys")
def issue_inbound_key(request: IssueKeyRequest) -> dict:
"""Mint one panel's key and return the string it pastes.
The secret is in this response and nowhere else afterwards only its hash
is stored, so it cannot be shown again, only replaced.
"""
from worker.inbound import service as inbound_service # noqa: PLC0415
from worker.inbound.keys import KeyLimitExceeded # noqa: PLC0415
if not inbound_service.node.running:
raise HTTPException(
status_code=409,
detail=(
"This machine is not accepting connections yet. Turn on "
"Settings → System → Remote workers → Accept connections first."
),
)
try:
issued = inbound_service.node.keys.issue(request.label)
except KeyLimitExceeded as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
return {
"key_id": issued.key.key_id,
"label": issued.key.label,
"connection_string": inbound_service.node.connection_string(issued.secret),
"exposed": inbound_service.is_exposed(),
"shown_once": True,
}
@router.delete("/inbound/keys/{key_id}")
async def revoke_inbound_key(key_id: str) -> dict:
"""Revoke one panel. Everyone else stays connected — the whole reason keys
are per panel rather than one shared node key."""
from worker.inbound import service as inbound_service # noqa: PLC0415
if not await inbound_service.node.revoke_key(key_id):
raise HTTPException(status_code=404, detail="No such key.")
return inbound_service.node.snapshot()
@router.post("/inbound/sessions/{session_id}/disconnect")
def disconnect_inbound_session(session_id: str) -> dict:
from worker.inbound import service as inbound_service # noqa: PLC0415
if not inbound_service.node.log.kick(session_id):
raise HTTPException(status_code=404, detail="That connection has already ended.")
return inbound_service.node.snapshot()
@router.post("/inbound/connections")
async def add_inbound_connection(request: ConnectRequest) -> dict:
"""Paste a connection string from a GPU machine and dial it."""
from worker.inbound import service as inbound_service # noqa: PLC0415
from worker.inbound.connection_string import InvalidConnectionString # noqa: PLC0415
from worker.inbound.connector import InboundConnectionError # noqa: PLC0415
if not service.control_plane.running:
raise HTTPException(
status_code=409,
detail=(
"Remote workers are turned off. Enable them in "
"Settings → System → Remote workers first."
),
)
try:
connection = await inbound_service.outbound.add(
request.connection_string, service.control_plane.servicer
)
except InvalidConnectionString as exc:
# 400 with the parser's own words: every one of these otherwise
# surfaces as "cannot connect", which is what a firewall, a wrong port
# and a dead node all say too.
raise HTTPException(status_code=400, detail=str(exc)) from exc
except InboundConnectionError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
return {"endpoint": connection.endpoint, "connections": inbound_service.outbound.snapshot()}
@router.delete("/inbound/connections/{endpoint}")
async def remove_inbound_connection(endpoint: str) -> dict:
from worker.inbound import service as inbound_service # noqa: PLC0415
from worker.inbound.connector import InboundConnectionError # noqa: PLC0415
try:
await inbound_service.outbound.remove(endpoint)
except InboundConnectionError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
return {"connections": inbound_service.outbound.snapshot()}
-15
View File
@@ -26,21 +26,6 @@ class SystemInfoResponse(BaseModel):
model_config = ConfigDict(extra="allow")
app_version: str = ""
# Effective compute-time budgets (seconds) for one synthesis job — the
# values services/model_manager.py's GPU_JOB_TIMEOUT_S / CPU_JOB_TIMEOUT_S
# captured at backend import time (#1787). A value just saved via
# /system/set-env is NOT reflected here until the next restart.
generate_timeout_s: float = 300.0
cpu_generate_timeout_s: float = 600.0
# True when an external env var (shell, `.env`, Docker, …) is currently
# shadowing a prefs.json save for this key — see core.prefs.is_env_shadowed.
generate_timeout_shadowed: bool = False
cpu_generate_timeout_shadowed: bool = False
# #1770: the desktop attach handshake's code fingerprint — whatever
# Tauri set OMNIVOICE_BUILD_FINGERPRINT to when it spawned this process,
# echoed back verbatim. Blank when unset (dev mode, a manually started
# backend). See frontend/src-tauri/src/backend.rs::code_fingerprint_is_current.
code_fingerprint: str = ""
data_dir: str
outputs_dir: str
crash_log_path: str
Binary file not shown.
@@ -1,3 +0,0 @@
1
00:00:00,000 --> 00:00:13,720
VoiceStudio es una aplicación de escritorio para clonación de voz, doblaje de vídeo y diseño de voz. Funciona completamente en tu máquina. Sin cuentas, sin nube, sin claves de API. Solo abre la aplicación y comienza a crear.
Binary file not shown.
@@ -1,3 +0,0 @@
1
00:00:00,000 --> 00:00:15,000
VoiceStudio est une application de bureau pour le clonage de voix, le doublage vidéo et la conception vocale. Elle fonctionne entièrement sur votre machine. Pas de compte, pas de cloud, pas de clé d'API. Ouvrez l'application et commencez à créer.
Binary file not shown.
@@ -1,3 +0,0 @@
1
00:00:00,000 --> 00:00:16,560
VoiceStudioは、ボイスクローン、ビデオ吹き替え、ボイスデザインのためのデスクトップアプリです。すべてお使いのコンピュータ上で動作します。アカウント、クラウド、APIキーは不要です。アプリを開けば、すぐに制作を始められます。
Binary file not shown.
@@ -1,3 +0,0 @@
1
00:00:00,000 --> 00:00:13,200
VoiceStudio 是一款桌面应用,用于语音克隆、视频配音和声音设计。它完全在你的电脑上运行。无需账户,无需云端,无需 API 密钥。打开应用即可开始创作。
@@ -1,47 +0,0 @@
{
"version": "0.3.0",
"rendered_by": "omnivoice engine + ffmpeg showwaves",
"rendered_at": "2026-08-12T19:47:29Z",
"license": "MIT (synthetic, no third-party IP)",
"source": {
"code": "en",
"label": "English",
"video": "source.mp4",
"srt": "source.srt",
"script": "VoiceStudio is a desktop app for voice cloning, video dubbing, and voice design. It runs entirely on your machine. No accounts, no cloud, no API keys. Just open the app and start creating."
},
"dubbed": [
{
"code": "es",
"label": "Español",
"video": "dubbed_es.mp4",
"srt": "dubbed_es.srt",
"dir": "ltr",
"script": "VoiceStudio es una aplicación de escritorio para clonación de voz, doblaje de vídeo y diseño de voz. Funciona completamente en tu máquina. Sin cuentas, sin nube, sin claves de API. Solo abre la aplicación y comienza a crear."
},
{
"code": "fr",
"label": "Français",
"video": "dubbed_fr.mp4",
"srt": "dubbed_fr.srt",
"dir": "ltr",
"script": "VoiceStudio est une application de bureau pour le clonage de voix, le doublage vidéo et la conception vocale. Elle fonctionne entièrement sur votre machine. Pas de compte, pas de cloud, pas de clé d'API. Ouvrez l'application et commencez à créer."
},
{
"code": "zh",
"label": "中文",
"video": "dubbed_zh.mp4",
"srt": "dubbed_zh.srt",
"dir": "ltr",
"script": "VoiceStudio 是一款桌面应用,用于语音克隆、视频配音和声音设计。它完全在你的电脑上运行。无需账户,无需云端,无需 API 密钥。打开应用即可开始创作。"
},
{
"code": "ja",
"label": "日本語",
"video": "dubbed_ja.mp4",
"srt": "dubbed_ja.srt",
"dir": "ltr",
"script": "VoiceStudioは、ボイスクローン、ビデオ吹き替え、ボイスデザインのためのデスクトップアプリです。すべてお使いのコンピュータ上で動作します。アカウント、クラウド、APIキーは不要です。アプリを開けば、すぐに制作を始められます。"
}
]
}
Binary file not shown.
Binary file not shown.
@@ -1,3 +0,0 @@
1
00:00:00,000 --> 00:00:11,400
VoiceStudio is a desktop app for voice cloning, video dubbing, and voice design. It runs entirely on your machine. No accounts, no cloud, no API keys. Just open the app and start creating.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+10 -28
View File
@@ -159,16 +159,17 @@ models:
- repo_id: "csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8"
label: "Parakeet TDT v3 (sherpa-onnx — dictation, 25 EU langs)"
role: ASR
size_gb: 0.67
size_gb: 0.18
engine: sherpa-onnx
dictation_id: sherpa-parakeet-tdt-v3
tag: offline
note: "Multilingual European-language dictation. CPU, int8 ONNX. Requires sherpa-onnx."
curated_on: [all]
note: "Recommended live-dictation default. CPU, int8 ONNX. Requires sherpa-onnx."
- repo_id: "csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8"
label: "Parakeet TDT v2 (sherpa-onnx — dictation, English)"
role: ASR
size_gb: 0.66
size_gb: 0.17
engine: sherpa-onnx
dictation_id: sherpa-parakeet-tdt-v2
tag: offline
@@ -177,7 +178,7 @@ models:
- repo_id: "csukuangfj/sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20"
label: "Zipformer Bilingual (sherpa-onnx — streaming, zh+en)"
role: ASR
size_gb: 0.2
size_gb: 0.13
engine: sherpa-onnx
dictation_id: sherpa-zipformer-bilingual-zh-en
tag: streaming
@@ -186,7 +187,7 @@ models:
- repo_id: "csukuangfj/sherpa-onnx-streaming-paraformer-bilingual-zh-en"
label: "Paraformer Bilingual (sherpa-onnx — streaming, zh+en)"
role: ASR
size_gb: 0.24
size_gb: 0.115
engine: sherpa-onnx
dictation_id: sherpa-paraformer-bilingual-zh-en
tag: streaming
@@ -195,7 +196,7 @@ models:
- repo_id: "csukuangfj/sherpa-onnx-streaming-zipformer-en-20M-2023-02-17"
label: "Zipformer Streaming EN 20M (sherpa-onnx — streaming, English)"
role: ASR
size_gb: 0.044
size_gb: 0.128
engine: sherpa-onnx
dictation_id: sherpa-zipformer-en-20m
tag: streaming
@@ -204,7 +205,7 @@ models:
- repo_id: "csukuangfj/sherpa-onnx-streaming-zipformer-zh-14M-2023-02-23"
label: "Zipformer Streaming ZH 14M (sherpa-onnx — streaming, Chinese)"
role: ASR
size_gb: 0.025
size_gb: 0.074
engine: sherpa-onnx
dictation_id: sherpa-zipformer-zh-14m
tag: streaming
@@ -213,12 +214,11 @@ models:
- repo_id: "csukuangfj/sherpa-onnx-whisper-tiny"
label: "Whisper Tiny (sherpa-onnx — dictation, 90+ langs)"
role: ASR
size_gb: 0.104
size_gb: 0.116
engine: sherpa-onnx
dictation_id: sherpa-whisper-tiny
tag: offline
curated_on: [all]
note: "Recommended cross-platform dictation default (auto-detect). CPU, int8 ONNX. Requires sherpa-onnx."
note: "Multilingual offline dictation (auto-detect). CPU, int8 ONNX. Requires sherpa-onnx."
# ── Diarisation ───────────────────────────────────────────────────────
@@ -242,24 +242,6 @@ models:
size_gb: 0.08
curated_on: [all]
- repo_id: "openbmb/VoxCPM2"
label: "VoxCPM2 (30 languages, voice cloning and design)"
role: TTS
size_gb: 5.0
curated_on: [cuda]
- repo_id: "FunAudioLLM/Fun-CosyVoice3-0.5B-2512"
label: "CosyVoice 3 0.5B (multilingual zero-shot)"
role: TTS
size_gb: 9.8
curated_on: [cuda]
- repo_id: "lj1995/GPT-SoVITS"
label: "GPT-SoVITS pretrained weights"
role: TTS
size_gb: 2.0
curated_on: [cuda]
# ── mlx-audio engines (Apple Silicon only) ────────────────────────────
- repo_id: "mlx-community/Kokoro-82M-bf16"
-106
View File
@@ -1,106 +0,0 @@
"""Lightweight validation for persisted profile WAV references.
This module deliberately uses only the standard library. Gallery routers import
it during startup, so pulling in torch/torchaudio merely to validate a cached
file would make every Gallery open pay the model stack's import cost.
"""
from __future__ import annotations
import os
import wave
from pathlib import Path
from typing import Optional
from core.path_security import UnsafePath, resolve_within, safe_filename
_READ_CHUNK_BYTES = 1 << 20
_MAX_CHANNELS = 64
_MAX_SAMPLE_RATE = 768_000
_MAX_SAMPLE_WIDTH = 8
def resolve_regular_file(root: os.PathLike[str] | str, value: object) -> Optional[Path]:
"""Resolve a portable bare filename inside *root*, rejecting symlinks."""
try:
name = safe_filename(value)
unresolved = Path(root).resolve(strict=False) / name
if unresolved.is_symlink():
return None
return resolve_within(root, name)
except (OSError, UnsafePath):
return None
def is_playable_wav(path: Optional[Path]) -> bool:
"""Return true only for a regular, decodable WAV with audio frames."""
if path is None:
return False
try:
if not path.is_file() or path.is_symlink():
return False
file_size = path.stat().st_size
with wave.open(str(path), "rb") as wav:
channels = wav.getnchannels()
sample_rate = wav.getframerate()
sample_width = wav.getsampwidth()
frame_count = wav.getnframes()
if (
not 0 < channels <= _MAX_CHANNELS
or not 0 < sample_rate <= _MAX_SAMPLE_RATE
or not 0 < sample_width <= _MAX_SAMPLE_WIDTH
or frame_count <= 0
):
return False
# ``wave.getnframes`` trusts the header. Read through the declared
# payload so an interrupted write with a complete header but a
# truncated data chunk cannot masquerade as playable audio.
frame_size = channels * sample_width
expected_bytes = frame_count * frame_size
# A PCM payload cannot be larger than the containing file. Check
# before calling ``readframes`` so hostile header values cannot
# turn a tiny file into a multi-gigabyte allocation request.
if expected_bytes > file_size:
return False
read_bytes = 0
chunk_frames = max(1, min(frame_count, _READ_CHUNK_BYTES // frame_size))
while read_bytes < expected_bytes:
chunk = wav.readframes(chunk_frames)
if not chunk or len(chunk) % frame_size:
return False
read_bytes += len(chunk)
return read_bytes == expected_bytes
except (MemoryError, OSError, EOFError, OverflowError, wave.Error):
# Python 3.11's wave module rejects valid IEEE-float/WAVE_EXTENSIBLE
# files. SoundFile is already a runtime dependency and recognizes those
# containers; import it only on the uncommon fallback path.
try:
import soundfile as sf
with sf.SoundFile(str(path)) as audio:
if (
audio.format != "WAV"
or not 0 < audio.channels <= _MAX_CHANNELS
or not 0 < audio.samplerate <= _MAX_SAMPLE_RATE
or len(audio) <= 0
):
return False
remaining = len(audio)
# Decode through the declared payload in byte-bounded chunks;
# ``sf.info`` alone also trusts a truncated file's header.
chunk_frames = max(
1, _READ_CHUNK_BYTES // (audio.channels * 4),
)
while remaining:
frames = audio.read(
min(remaining, chunk_frames), dtype="float32", always_2d=True,
)
count = len(frames)
if count <= 0:
return False
remaining -= count
return True
except Exception:
return False
__all__ = ["is_playable_wav", "resolve_regular_file"]
-421
View File
@@ -1,421 +0,0 @@
"""Canonical authentication identity for HTTP and WebSocket connections.
Transport parsing belongs here; authorization remains in FastAPI dependencies.
Each ASGI scope receives exactly one secret-free :class:`AuthPrincipal` so
middleware and route guards cannot disagree about credential precedence.
"""
from __future__ import annotations
import ipaddress
import importlib
import os
import secrets
from collections.abc import Mapping
from dataclasses import dataclass, field
from enum import Enum
from services.admin_sessions import (
AdminSessionStore,
)
_AUTH_STATE_KEY = "auth_principal"
_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"})
CONSUME_CAPABILITIES = frozenset({"consume"})
ADMIN_CAPABILITIES = frozenset({"consume", "admin"})
LOOPBACK_CAPABILITIES = frozenset({"consume", "admin", "native"})
class PrincipalKind(str, Enum):
ANONYMOUS = "anonymous"
LOOPBACK = "loopback"
TRUSTED_NETWORK = "trusted_network"
PIN = "pin"
API_KEY = "api_key"
ADMIN_SESSION = "admin_session"
class CredentialTransport(str, Enum):
NONE = "none"
HEADER = "header"
QUERY = "query"
COOKIE = "cookie"
LEGACY_COOKIE = "legacy_cookie"
WS_TICKET = "ws_ticket"
@dataclass(frozen=True)
class AuthPrincipal:
kind: PrincipalKind
capabilities: frozenset[str]
credential_id: str | None = None
transport: CredentialTransport = CredentialTransport.NONE
def allows(self, capability: str) -> bool:
return capability in self.capabilities
@dataclass(frozen=True)
class _CredentialCandidate:
value: str = field(repr=False)
transport: CredentialTransport
allow_master: bool = False
allow_session: bool = False
allow_ticket: bool = False
def remote_api_key() -> str | None:
"""Normalized remote operator key, read dynamically for rotation support."""
return os.environ.get("OMNIVOICE_API_KEY", "").strip() or None
def credential_matches(supplied: str | None, configured: str | None) -> bool:
"""Constant-time credential comparison that accepts the full Unicode range."""
if not supplied or not configured:
return False
return secrets.compare_digest(
supplied.encode("utf-8", errors="surrogatepass"),
configured.encode("utf-8", errors="surrogatepass"),
)
def _active_admin_session_store() -> AdminSessionStore:
"""Resolve mutable process state at call time so app reloads cannot split it."""
module = importlib.import_module("services.admin_sessions")
return module.admin_session_store
def _trusted_networks() -> tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, ...]:
networks = []
for value in os.environ.get("OMNIVOICE_TRUSTED_NETWORKS", "").split(","):
value = value.strip()
if not value:
continue
try:
networks.append(ipaddress.ip_network(value, strict=False))
except ValueError:
# Invalid configuration never makes the gate fail open or wedge the
# backend. It simply contributes no trusted range.
continue
return tuple(networks)
def is_loopback(host: str | None) -> bool:
return host in _LOOPBACK_HOSTS
def is_local_host(host: str | None) -> bool:
if is_loopback(host):
return True
try:
address = ipaddress.ip_address(host)
except (TypeError, ValueError):
return False
if getattr(address, "ipv4_mapped", None):
address = address.ipv4_mapped
return any(address in network for network in _trusted_networks())
def _mapping_get(mapping: Mapping[str, str] | object, name: str) -> str:
if not mapping:
return ""
getter = getattr(mapping, "get", None)
if callable(getter):
value = getter(name, "")
if value:
return str(value)
# Real Starlette Headers are case-insensitive. This small fallback keeps
# minimal request stubs and non-Starlette callers correct too.
items = getattr(mapping, "items", None)
if callable(items):
for key, value in items():
if str(key).lower() == name.lower():
return str(value or "")
return ""
def _scope_type(connection) -> str:
scope = getattr(connection, "scope", None)
return str(scope.get("type", "http")) if isinstance(scope, dict) else "http"
def _path(connection) -> str:
scope = getattr(connection, "scope", None)
if isinstance(scope, dict):
return str(scope.get("path", ""))
return str(getattr(connection, "url", "") or "")
def _canonical_websocket_path(connection) -> str:
"""Remove only the ASGI-configured deployment prefix from a WS path."""
path = _path(connection)
scope = getattr(connection, "scope", None)
if not isinstance(scope, dict):
return path
root_path = str(scope.get("root_path", "") or "").rstrip("/")
if not root_path or root_path == "/":
return path
root_path = "/" + root_path.lstrip("/")
if path.startswith(root_path + "/"):
return path[len(root_path) :]
return path
def _client_host(connection) -> str | None:
client = getattr(connection, "client", None)
if client is not None:
return getattr(client, "host", None)
scope = getattr(connection, "scope", None)
if isinstance(scope, dict) and scope.get("client"):
return scope["client"][0]
return None
def _credential_candidate(connection) -> _CredentialCandidate | None:
query = getattr(connection, "query_params", None) or {}
cookies = getattr(connection, "cookies", None) or {}
raw_authorization = authorization_header(connection)
authorization = raw_authorization.strip()
if raw_authorization.lower().startswith("bearer "):
value = raw_authorization[7:].strip()
if value:
return _CredentialCandidate(
value=value,
transport=CredentialTransport.HEADER,
allow_master=True,
allow_session=True,
)
# Preserve the legacy normalization contract: ``Bearer`` followed
# only by whitespace is equivalent to an empty credential channel.
elif authorization:
# Any non-empty explicit Authorization value is authoritative, even
# when its scheme is unsupported or its Bearer payload is missing.
# It must never fall through to a stale ambient cookie.
return _CredentialCandidate(
value=authorization,
transport=CredentialTransport.HEADER,
)
if _scope_type(connection) == "websocket":
ticket = _mapping_get(query, "ws_ticket").strip()
if ticket:
return _CredentialCandidate(
value=ticket,
transport=CredentialTransport.WS_TICKET,
allow_ticket=True,
)
query_key = _mapping_get(query, "api_key").strip()
if query_key:
return _CredentialCandidate(
value=query_key,
transport=CredentialTransport.QUERY,
allow_master=True,
)
session = _mapping_get(cookies, "ov_session").strip()
if session:
return _CredentialCandidate(
value=session,
transport=CredentialTransport.COOKIE,
allow_session=True,
)
legacy_key = _mapping_get(cookies, "ov_key").strip()
if legacy_key:
return _CredentialCandidate(
value=legacy_key,
transport=CredentialTransport.LEGACY_COOKIE,
allow_master=True,
)
return None
def presented_api_key(connection) -> str:
"""Compatibility extractor for the durable API-key transports only."""
candidate = _credential_candidate(connection)
if candidate is None or not candidate.allow_master:
return ""
return candidate.value
def authorization_header(connection) -> str:
headers = getattr(connection, "headers", None) or {}
return _mapping_get(headers, "authorization")
def authorization_credential_present(connection) -> bool:
"""Whether Authorization contains an authoritative credential channel.
This deliberately mirrors :func:`_credential_candidate`: whitespace and
``Bearer`` followed only by spaces are empty channels that may fall back to
legacy migration state. Unsupported schemes and ``Bearer`` without the
required separating space remain explicit invalid credentials.
"""
authorization = authorization_header(connection)
if authorization.lower().startswith("bearer ") and not authorization[7:].strip():
return False
return bool(authorization.strip())
def bearer_header_value(connection) -> str:
authorization = authorization_header(connection)
if not authorization.lower().startswith("bearer "):
return ""
return authorization[7:].strip()
def legacy_master_cookie_valid(connection) -> bool:
configured = remote_api_key()
cookies = getattr(connection, "cookies", None) or {}
supplied = _mapping_get(cookies, "ov_key").strip()
return credential_matches(supplied, configured)
def master_header_valid(connection) -> bool:
configured = remote_api_key()
supplied = bearer_header_value(connection)
return credential_matches(supplied, configured)
def _configured_pin(connection) -> str | None:
app = getattr(connection, "app", None)
state = getattr(app, "state", None) if app is not None else None
network_share = getattr(state, "network_share", None) if state is not None else None
pin = getattr(network_share, "pin", None) if network_share is not None else None
return str(pin) if pin else None
def _valid_pin(connection) -> bool:
configured = _configured_pin(connection)
if not configured:
return False
headers = getattr(connection, "headers", None) or {}
query = getattr(connection, "query_params", None) or {}
cookies = getattr(connection, "cookies", None) or {}
supplied = (
_mapping_get(headers, "x-omnivoice-pin").strip()
or _mapping_get(query, "pin").strip()
or _mapping_get(cookies, "ov_pin").strip()
)
return credential_matches(supplied, configured)
def _attached_principal(connection) -> AuthPrincipal | None:
scope = getattr(connection, "scope", None)
if not isinstance(scope, dict):
return None
state = scope.get("state")
if isinstance(state, dict):
principal = state.get(_AUTH_STATE_KEY)
return principal if isinstance(principal, AuthPrincipal) else None
return None
def _attach_principal(connection, principal: AuthPrincipal) -> AuthPrincipal:
scope = getattr(connection, "scope", None)
if isinstance(scope, dict):
state = scope.setdefault("state", {})
if isinstance(state, dict):
state[_AUTH_STATE_KEY] = principal
return principal
def resolve_principal(
connection,
*,
store: AdminSessionStore | None = None,
) -> AuthPrincipal:
"""Resolve and attach the single authentication decision for one scope."""
attached = _attached_principal(connection)
if attached is not None:
return attached
if store is None:
store = _active_admin_session_store()
host = _client_host(connection)
if is_loopback(host):
return _attach_principal(
connection,
AuthPrincipal(PrincipalKind.LOOPBACK, LOOPBACK_CAPABILITIES),
)
candidate = _credential_candidate(connection)
configured_key = remote_api_key()
if candidate is not None:
principal: AuthPrincipal | None = None
if (
candidate.allow_master
and credential_matches(candidate.value, configured_key)
):
principal = AuthPrincipal(
PrincipalKind.API_KEY,
ADMIN_CAPABILITIES,
credential_id="api-key",
transport=candidate.transport,
)
elif candidate.allow_session:
session = store.resolve(candidate.value, configured_key)
if session is not None:
principal = AuthPrincipal(
PrincipalKind.ADMIN_SESSION,
session.capabilities,
credential_id=session.credential_id,
transport=candidate.transport,
)
elif candidate.allow_ticket:
session = store.consume_ws_ticket(
candidate.value,
_canonical_websocket_path(connection),
configured_key,
)
if session is not None:
principal = AuthPrincipal(
PrincipalKind.ADMIN_SESSION,
session.capabilities,
credential_id=session.credential_id,
transport=candidate.transport,
)
if principal is not None:
return _attach_principal(connection, principal)
# An explicit, non-empty credential is authoritative. Do not silently
# fall back to network or PIN trust after an invalid higher-priority
# credential was presented.
return _attach_principal(
connection,
AuthPrincipal(
PrincipalKind.ANONYMOUS,
frozenset(),
transport=candidate.transport,
),
)
if is_local_host(host):
return _attach_principal(
connection,
AuthPrincipal(PrincipalKind.TRUSTED_NETWORK, CONSUME_CAPABILITIES),
)
if _valid_pin(connection):
return _attach_principal(
connection,
AuthPrincipal(
PrincipalKind.PIN,
CONSUME_CAPABILITIES,
transport=CredentialTransport.HEADER,
),
)
return _attach_principal(
connection,
AuthPrincipal(PrincipalKind.ANONYMOUS, frozenset()),
)
def principal_for(
connection,
*,
store: AdminSessionStore | None = None,
) -> AuthPrincipal:
return _attached_principal(connection) or resolve_principal(connection, store=store)
-716
View File
@@ -1,716 +0,0 @@
"""Nested subprocess ownership for desktop-managed backend operations.
The desktop owns the backend with an OS process group/Job. Engine and
installer operations also need an independently terminable subtree: killing
only their direct child on a timeout leaves uv/git/model workers holding pipes
and mutating files.
On POSIX a small supervisor is the unreaped leader of a nested process group.
A control-pipe EOF (including kernel EOF when the backend dies) kills that
group; the parent also drains the group before reaping its stable leader. On
Windows the backend retains a nested kill-on-close Job directly and assigns
the suspended operation before resuming it. The outer desktop Job remains the
terminal fallback.
Standalone/server launches use the same nested owner, preserving their
independently terminable subtree without relying on ``taskkill`` or discovery.
"""
from __future__ import annotations
import os
import signal
import struct
import subprocess
import sys
import threading
import time
from pathlib import Path
from typing import Any, Optional
_RESULT = struct.Struct("!i")
_DESKTOP_MARKER = "OMNIVOICE_DESKTOP_CONTAINED"
_DRAIN_FD_ENV = "OMNIVOICE_DESKTOP_DRAIN_FD"
def backend_drain_fd(*, required: bool = False) -> Optional[int]:
"""Validated Rust-owned drain writer inherited by the desktop backend."""
if os.name != "posix" or os.environ.get(_DESKTOP_MARKER) != "1":
return None
try:
fd = int(os.environ[_DRAIN_FD_ENV])
os.fstat(fd)
except (KeyError, ValueError, OSError) as exc:
if required:
raise RuntimeError(
"desktop backend is missing its live nested-operation drain descriptor"
) from exc
return None
return fd
def secure_backend_drain_fd() -> None:
"""Restore CLOEXEC after Rust's one intentional backend inheritance."""
fd = backend_drain_fd(required=True)
if fd is not None:
os.set_inheritable(fd, False)
class OwnedPopen:
"""Popen-compatible handle for a desktop-owned nested operation."""
def __init__(
self,
proc: subprocess.Popen,
control_fd: int,
result_fd: int,
) -> None:
self._proc = proc
self._control_fd: Optional[int] = control_fd
self._result_fd: Optional[int] = result_fd
self._returncode: Optional[int] = None
self._lock = threading.RLock()
# Popen callers use these directly (protocol pipes and log drains).
self.stdin = proc.stdin
self.stdout = proc.stdout
self.stderr = proc.stderr
@property
def pid(self) -> int:
return self._proc.pid
@property
def args(self) -> Any:
return self._proc.args
@property
def returncode(self) -> Optional[int]:
return self._returncode
def _close_control(self) -> None:
fd, self._control_fd = self._control_fd, None
if fd is not None:
try:
os.close(fd)
except OSError:
# Cleanup is idempotent; another teardown path already closed it.
pass
def _read_result(self, fallback: int) -> int:
fd, self._result_fd = self._result_fd, None
if fd is None:
return fallback
try:
payload = b""
while len(payload) < _RESULT.size:
chunk = os.read(fd, _RESULT.size - len(payload))
if not chunk:
break
payload += chunk
return _RESULT.unpack(payload)[0] if len(payload) == _RESULT.size else fallback
except OSError:
return fallback
finally:
try:
os.close(fd)
except OSError:
# The descriptor may have been closed by cancellation cleanup.
pass
def _posix_exited_unreaped(self) -> bool:
flags = os.WEXITED | os.WNOHANG | os.WNOWAIT
info = os.waitid(os.P_PID, self.pid, flags)
return info is not None and info.si_pid != 0
def _posix_exited_reaping(self) -> Optional[int]:
"""macOS fallback for :meth:`_posix_exited_unreaped` (#1656).
CPython on macOS does not expose ``os.waitid`` (HAVE_WAITID is not set
in its build), so the WNOWAIT probe is unavailable there. This
fallback *reaps* the wrapper with ``waitpid(WNOHANG)``: it returns
the wrapper's exit code once it has exited, None while it is still
running, and raises ``ChildProcessError`` when another owner already
reaped it (the same refusal the waitid probe gives).
Reaping earlier than the WNOWAIT dance loses the pre-reap group kill
in :meth:`poll`; that is safe because the supervisor's control-pipe
EOF already terminates the whole nested group (#1635 design).
"""
pid, status = os.waitpid(self.pid, os.WNOHANG)
if pid != self.pid:
return None
rc = os.waitstatus_to_exitcode(status)
# Publish on the underlying Popen so its own wait()/poll() no-op.
self._proc.returncode = rc
return rc
def _posix_exit_state_reaping(self) -> Optional[int]:
""":meth:`_posix_exited_reaping` plus one concession: if the leader
was already reaped through *this* Popen (``_proc.returncode`` known),
report that code rather than refusing reaping by our own handle is
not the foreign reaper the ECHILD refusal exists for."""
try:
return self._posix_exited_reaping()
except ChildProcessError:
return self._proc.returncode
def _signal_owned_group(self, sig: int) -> None:
# The numeric group is safe only while its direct-child leader remains
# ours and unreaped. ECHILD therefore refuses rather than guessing.
try:
os.waitid(os.P_PID, self.pid, os.WEXITED | os.WNOHANG | os.WNOWAIT)
except ChildProcessError:
return
except AttributeError:
# macOS CPython has no os.waitid (#1656). waitpid still proves
# that this exact numeric pid is our live child: ECHILD refuses a
# foreign-reaped/reused pid, while pid == self.pid records an exit
# without ever signalling the now-unowned process-group number.
try:
pid, status = os.waitpid(self.pid, os.WNOHANG)
except ChildProcessError:
return
if pid == self.pid:
self._proc.returncode = os.waitstatus_to_exitcode(status)
return
try:
os.killpg(self.pid, sig)
except ProcessLookupError:
# The owned group exited between the waitid probe and the signal.
pass
def poll(self) -> Optional[int]:
with self._lock:
if self._returncode is not None:
return self._returncode
if os.name == "posix":
try:
if hasattr(os, "waitid"):
if not self._posix_exited_unreaped():
return None
self._signal_owned_group(signal.SIGKILL)
wrapper_rc = self._proc.wait()
else:
# macOS CPython: no os.waitid (#1656) — the reaping
# probe already terminated/killed nothing; the group
# is torn down by the control-pipe EOF in _close_control.
wrapper_rc = self._posix_exit_state_reaping()
if wrapper_rc is None:
return None
except ChildProcessError:
# Never signal a potentially reused group after another
# owner reaped the stable leader.
return None
else:
wrapper_rc = self._proc.poll()
if wrapper_rc is None:
return None
self._close_control()
self._returncode = self._read_result(wrapper_rc)
return self._returncode
def wait(self, timeout: Optional[float] = None) -> int:
deadline = None if timeout is None else time.monotonic() + timeout
while True:
rc = self.poll()
if rc is not None:
return rc
if deadline is not None and time.monotonic() >= deadline:
raise subprocess.TimeoutExpired(self.args, timeout)
time.sleep(0.01)
def terminate(self) -> None:
with self._lock:
if self._returncode is not None:
return
self._close_control()
if os.name == "posix":
self._signal_owned_group(signal.SIGTERM)
else:
# Closing the control pipe asks the supervisor to terminate
# its nested Job. The stable wrapper handle is a fallback.
try:
self._proc.terminate()
except OSError:
# The wrapper exited after the return-code check.
pass
def kill(self) -> None:
with self._lock:
if self._returncode is not None:
return
self._close_control()
if os.name == "posix":
self._signal_owned_group(signal.SIGKILL)
else:
try:
self._proc.kill()
except OSError:
# The wrapper exited after the return-code check.
pass
def __getattr__(self, name: str) -> Any:
return getattr(self._proc, name)
def __del__(self) -> None:
self._close_control()
fd, self._result_fd = self._result_fd, None
if fd is not None:
try:
os.close(fd)
except OSError:
# Finalization may race explicit wait or cancellation cleanup.
pass
class WindowsJobPopen:
"""Popen-compatible handle whose child tree lives in a retained Job.
Windows Job handles already provide the stable ownership that POSIX needs
a supervisor process group for. Keeping the handle in the backend means an
abrupt backend exit closes it in the kernel and kills the whole operation
tree, without inserting a second Python process in the sidecar loader path
(#1734).
"""
def __init__(self, proc: subprocess.Popen, job: Any, kernel32: Any) -> None:
self._proc = proc
self._job = job
self._kernel32 = kernel32
self._lock = threading.RLock()
self.stdin = proc.stdin
self.stdout = proc.stdout
self.stderr = proc.stderr
@property
def pid(self) -> int:
return self._proc.pid
@property
def args(self) -> Any:
return self._proc.args
@property
def returncode(self) -> Optional[int]:
return self._proc.returncode
def _close_job(self, *, terminate: bool) -> None:
job, self._job = self._job, None
if job is None:
return
try:
if terminate:
self._kernel32.TerminateJobObject(job, 1)
finally:
self._kernel32.CloseHandle(job)
def poll(self) -> Optional[int]:
with self._lock:
rc = self._proc.poll()
if rc is None:
return None
# A successful direct child may leave helpers behind. Match the
# supervisor contract by draining the retained Job before return.
self._close_job(terminate=True)
return rc
def wait(self, timeout: Optional[float] = None) -> int:
try:
rc = self._proc.wait(timeout=timeout)
except subprocess.TimeoutExpired:
raise
with self._lock:
self._close_job(terminate=True)
return rc
def terminate(self) -> None:
with self._lock:
self._close_job(terminate=True)
def kill(self) -> None:
self.terminate()
def __getattr__(self, name: str) -> Any:
return getattr(self._proc, name)
def __del__(self) -> None:
try:
self._close_job(terminate=True)
except Exception:
pass # interpreter shutdown; closing the OS handle is best-effort
def _spawn_windows_owned(argv: list[str], kwargs: dict[str, Any]) -> WindowsJobPopen:
"""Start *argv* suspended, assign its tree to a Job, then resume it."""
import ctypes
job, kernel32, wintypes = _windows_job()
child: Optional[subprocess.Popen] = None
popen_kwargs = dict(kwargs)
supplied_env = popen_kwargs.get("env")
operation_env = dict(os.environ if supplied_env is None else supplied_env)
operation_env.pop(_DRAIN_FD_ENV, None)
operation_env.pop(_DESKTOP_MARKER, None)
popen_kwargs["env"] = operation_env
supplied_flags = int(popen_kwargs.pop("creationflags", 0))
popen_kwargs["creationflags"] = supplied_flags | 0x08000000 | 0x00000004
try:
child = subprocess.Popen(argv, **popen_kwargs)
assign = kernel32.AssignProcessToJobObject
assign.argtypes = (wintypes.HANDLE, wintypes.HANDLE)
assign.restype = wintypes.BOOL
if not assign(job, wintypes.HANDLE(child._handle)):
raise OSError(ctypes.get_last_error(), "AssignProcessToJobObject")
_resume_windows_process(kernel32, wintypes, child.pid)
return WindowsJobPopen(child, job, kernel32)
except BaseException:
kernel32.TerminateJobObject(job, 1)
if child is not None:
try:
child.kill()
except OSError:
pass # the suspended child may already have exited
try:
child.wait(timeout=5)
except (OSError, subprocess.TimeoutExpired):
pass # Job termination remains the authoritative cleanup
kernel32.CloseHandle(job)
raise
def spawn_owned(
argv: list[str], **kwargs: Any
) -> "subprocess.Popen | OwnedPopen | WindowsJobPopen":
"""Spawn an operation with a stable, independently terminable owner."""
if os.name == "nt":
return _spawn_windows_owned(argv, kwargs)
drain_fd = backend_drain_fd(required=True)
control_read, control_write = os.pipe()
result_read, result_write = os.pipe()
wrapper_argv = _supervisor_argv(
control_read,
result_write,
argv,
)
wrapper_kwargs = dict(kwargs)
wrapper_kwargs["start_new_session"] = True
pass_fds = [control_read, result_write]
if drain_fd is not None:
pass_fds.append(drain_fd)
if wrapper_kwargs.get("env") is not None:
wrapper_env = dict(wrapper_kwargs["env"])
wrapper_env[_DESKTOP_MARKER] = "1"
wrapper_env[_DRAIN_FD_ENV] = str(drain_fd)
wrapper_kwargs["env"] = wrapper_env
wrapper_kwargs["pass_fds"] = tuple(pass_fds)
try:
proc = subprocess.Popen(wrapper_argv, **wrapper_kwargs)
except BaseException:
# The finally block exclusively owns the child-side endpoints. Closing
# them here as well risks closing a reused descriptor in another thread.
for fd in (control_write, result_read):
try:
os.close(fd)
except OSError:
# A partial spawn may already have closed a parent-side endpoint.
pass
raise
finally:
for fd in (control_read, result_write):
try:
os.close(fd)
except OSError:
# Popen may have consumed an inherited child-side endpoint.
pass
return OwnedPopen(proc, control_write, result_read)
def _supervisor_argv(
control_token: int,
result_token: int,
argv: list[str],
) -> list[str]:
prefix = [sys.executable]
if not getattr(sys, "frozen", False):
prefix.append(str(Path(__file__).resolve().parents[1] / "main.py"))
return [
*prefix,
"--supervise",
str(control_token),
str(result_token),
"--",
*map(str, argv),
]
def _write_result(fd: int, returncode: int) -> None:
try:
os.write(fd, _RESULT.pack(int(returncode)))
except OSError:
# The caller may have cancelled and closed its result reader.
pass
finally:
try:
os.close(fd)
except OSError:
# Writing or cancellation may already have closed the descriptor.
pass
def _operation_env() -> dict[str, str]:
env = os.environ.copy()
# The operation intentionally does not own the Rust drain writer. Avoid
# exposing a stale numeric token which nested code could mistake as valid.
env.pop(_DRAIN_FD_ENV, None)
env.pop(_DESKTOP_MARKER, None)
return env
def _supervise_posix(control_fd: int, result_fd: int, argv: list[str]) -> int:
def cancel_on_eof() -> None:
try:
while os.read(control_fd, 1):
pass
except OSError:
# Closing the control descriptor is itself a cancellation signal.
pass
os.killpg(os.getpgrp(), signal.SIGKILL)
threading.Thread(target=cancel_on_eof, daemon=True).start()
try:
child = subprocess.Popen(argv, close_fds=True, env=_operation_env())
rc = child.wait()
except OSError:
rc = 127
_write_result(result_fd, rc)
# Drain children which outlived the operation before the stable group
# leader exits. SIGKILL intentionally includes this supervisor.
os.killpg(os.getpgrp(), signal.SIGKILL)
return rc # unreachable
def _windows_job() -> tuple[Any, Any, Any]:
import ctypes
import ctypes.wintypes as wintypes
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
kernel32.CloseHandle.argtypes = (wintypes.HANDLE,)
kernel32.CloseHandle.restype = wintypes.BOOL
kernel32.TerminateJobObject.argtypes = (wintypes.HANDLE, wintypes.UINT)
kernel32.TerminateJobObject.restype = wintypes.BOOL
kernel32.ReadFile.argtypes = (
wintypes.HANDLE,
ctypes.c_void_p,
wintypes.DWORD,
ctypes.POINTER(wintypes.DWORD),
ctypes.c_void_p,
)
kernel32.ReadFile.restype = wintypes.BOOL
kernel32.WriteFile.argtypes = (
wintypes.HANDLE,
ctypes.c_void_p,
wintypes.DWORD,
ctypes.POINTER(wintypes.DWORD),
ctypes.c_void_p,
)
kernel32.WriteFile.restype = wintypes.BOOL
create = kernel32.CreateJobObjectW
create.argtypes = (ctypes.c_void_p, wintypes.LPCWSTR)
create.restype = wintypes.HANDLE
job = create(None, None)
if not job:
raise OSError(ctypes.get_last_error(), "CreateJobObjectW")
class BasicLimits(ctypes.Structure):
_fields_ = [
("PerProcessUserTimeLimit", ctypes.c_longlong),
("PerJobUserTimeLimit", ctypes.c_longlong),
("LimitFlags", wintypes.DWORD),
("MinimumWorkingSetSize", ctypes.c_size_t),
("MaximumWorkingSetSize", ctypes.c_size_t),
("ActiveProcessLimit", wintypes.DWORD),
("Affinity", ctypes.c_size_t),
("PriorityClass", wintypes.DWORD),
("SchedulingClass", wintypes.DWORD),
]
class IoCounters(ctypes.Structure):
_fields_ = [(name, ctypes.c_ulonglong) for name in (
"ReadOperationCount", "WriteOperationCount", "OtherOperationCount",
"ReadTransferCount", "WriteTransferCount", "OtherTransferCount",
)]
class ExtendedLimits(ctypes.Structure):
_fields_ = [
("BasicLimitInformation", BasicLimits),
("IoInfo", IoCounters),
("ProcessMemoryLimit", ctypes.c_size_t),
("JobMemoryLimit", ctypes.c_size_t),
("PeakProcessMemoryUsed", ctypes.c_size_t),
("PeakJobMemoryUsed", ctypes.c_size_t),
]
info = ExtendedLimits()
info.BasicLimitInformation.LimitFlags = 0x00002000 # KILL_ON_JOB_CLOSE
set_info = kernel32.SetInformationJobObject
set_info.argtypes = (wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, wintypes.DWORD)
set_info.restype = wintypes.BOOL
if not set_info(job, 9, ctypes.byref(info), ctypes.sizeof(info)):
error = ctypes.get_last_error()
kernel32.CloseHandle(job)
raise OSError(error, "SetInformationJobObject")
return job, kernel32, wintypes
def _resume_windows_process(kernel32: Any, wintypes: Any, pid: int) -> None:
import ctypes
class ThreadEntry(ctypes.Structure):
_fields_ = [
("dwSize", wintypes.DWORD),
("cntUsage", wintypes.DWORD),
("th32ThreadID", wintypes.DWORD),
("th32OwnerProcessID", wintypes.DWORD),
("tpBasePri", wintypes.LONG),
("tpDeltaPri", wintypes.LONG),
("dwFlags", wintypes.DWORD),
]
kernel32.CreateToolhelp32Snapshot.argtypes = (wintypes.DWORD, wintypes.DWORD)
kernel32.CreateToolhelp32Snapshot.restype = wintypes.HANDLE
kernel32.Thread32First.argtypes = (wintypes.HANDLE, ctypes.POINTER(ThreadEntry))
kernel32.Thread32First.restype = wintypes.BOOL
kernel32.Thread32Next.argtypes = (wintypes.HANDLE, ctypes.POINTER(ThreadEntry))
kernel32.Thread32Next.restype = wintypes.BOOL
kernel32.OpenThread.argtypes = (wintypes.DWORD, wintypes.BOOL, wintypes.DWORD)
kernel32.OpenThread.restype = wintypes.HANDLE
kernel32.ResumeThread.argtypes = (wintypes.HANDLE,)
kernel32.ResumeThread.restype = wintypes.DWORD
snapshot = kernel32.CreateToolhelp32Snapshot(0x00000004, 0)
invalid = ctypes.c_void_p(-1).value
if snapshot == invalid:
raise OSError(ctypes.get_last_error(), "CreateToolhelp32Snapshot")
try:
entry = ThreadEntry(dwSize=ctypes.sizeof(ThreadEntry))
found = kernel32.Thread32First(snapshot, ctypes.byref(entry))
while found:
if entry.th32OwnerProcessID == pid:
thread = kernel32.OpenThread(0x0002, False, entry.th32ThreadID)
if not thread:
raise OSError(ctypes.get_last_error(), "OpenThread")
try:
if kernel32.ResumeThread(thread) == 0xFFFFFFFF:
raise OSError(ctypes.get_last_error(), "ResumeThread")
return
finally:
kernel32.CloseHandle(thread)
found = kernel32.Thread32Next(snapshot, ctypes.byref(entry))
finally:
kernel32.CloseHandle(snapshot)
raise OSError("suspended operation thread was not found")
def _supervise_windows(control_fd: int, result_fd: int, argv: list[str]) -> int:
import ctypes
job, kernel32, wintypes = _windows_job()
cancelled = threading.Event()
job_lock = threading.Lock()
job_open = True
def terminate_job() -> None:
with job_lock:
if job_open:
kernel32.TerminateJobObject(job, 1)
def cancel_on_eof() -> None:
byte = ctypes.create_string_buffer(1)
count = wintypes.DWORD()
while kernel32.ReadFile(
wintypes.HANDLE(control_fd), byte, 1, ctypes.byref(count), None
) and count.value:
pass
kernel32.CloseHandle(wintypes.HANDLE(control_fd))
cancelled.set()
terminate_job()
threading.Thread(target=cancel_on_eof, daemon=True).start()
child: Optional[subprocess.Popen] = None
rc = 127
try:
child = subprocess.Popen(
argv,
close_fds=True,
env=_operation_env(),
creationflags=0x08000000 | 0x00000004, # NO_WINDOW | SUSPENDED
)
assign = kernel32.AssignProcessToJobObject
assign.argtypes = (wintypes.HANDLE, wintypes.HANDLE)
assign.restype = wintypes.BOOL
if not assign(job, wintypes.HANDLE(child._handle)):
raise OSError(ctypes.get_last_error(), "AssignProcessToJobObject")
if cancelled.is_set():
terminate_job()
else:
_resume_windows_process(kernel32, wintypes, child.pid)
rc = child.wait()
# A successful direct child may leave helpers behind; terminate the
# nested stable Job before reporting completion.
terminate_job()
except OSError:
terminate_job()
if child is not None:
try:
# Assignment itself may have failed, leaving this suspended
# process outside the nested Job. Terminate it through its
# stable process handle before waiting; never strand an
# unassigned operation or rely on the outer desktop Job.
child.kill()
except OSError:
# The suspended child may have exited during Job teardown.
pass
try:
child.wait(timeout=5)
except (OSError, subprocess.TimeoutExpired):
# The outer desktop Job remains the terminal containment fallback.
pass
finally:
payload = _RESULT.pack(int(rc))
payload_buffer = ctypes.create_string_buffer(payload)
written = wintypes.DWORD()
kernel32.WriteFile(
wintypes.HANDLE(result_fd),
payload_buffer,
len(payload),
ctypes.byref(written),
None,
)
kernel32.CloseHandle(wintypes.HANDLE(result_fd))
with job_lock:
job_open = False
kernel32.CloseHandle(job)
return rc
def supervisor_main(args: list[str]) -> int:
if len(args) < 5 or args[0] != "--supervise" or args[3] != "--":
return 2
control_fd = int(args[1])
result_fd = int(args[2])
argv = args[4:]
secure_backend_drain_fd()
if os.name == "posix":
return _supervise_posix(control_fd, result_fd, argv)
return _supervise_windows(control_fd, result_fd, argv)
def _main() -> int:
return supervisor_main(sys.argv[1:])
if __name__ == "__main__":
raise SystemExit(_main())
-140
View File
@@ -1,140 +0,0 @@
"""Exact-origin CSRF checks for ambient browser authentication."""
from __future__ import annotations
import os
from urllib.parse import SplitResult, urlsplit
CSRF_HEADER = "x-voicestudio-csrf"
CSRF_VALUE = "1"
SAFE_HTTP_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
_FORWARDED_PROTO_HEADER = "x-forwarded-proto"
def effective_scheme(connection) -> str:
"""Scheme of the client-facing hop: the resolved scope, TLS-upgraded by proxy evidence.
Behind a TLS-terminating proxy (Tailscale Serve the flagship remote-GPU
deployment in docs/remote-gpu.md nginx, Caddy, ...) the browser talks
``https`` while the backend hop is plain ``http``. uvicorn's
ProxyHeadersMiddleware (on by default in both launch paths: ``uvicorn.run``
in backend/main.py and the Docker ``python -m uvicorn`` entrypoint) already
rewrites the ASGI scope from ``X-Forwarded-Proto``, but only when the peer
is in ``--forwarded-allow-ips`` (default: loopback). That covers Serve on
bare metal, and we prefer that signal the scope is consulted first but
it misses Docker (the proxy connects from the bridge gateway) and any other
non-loopback proxy topology, so the header is honored here as well.
Spoofing analysis why honoring it never weakens a check: the upgrade is
one-way. ``https``/``wss`` as the first forwarded value promotes ``http``
to ``https``; every other value is ignored, so a forged header can never
downgrade a genuine TLS hop. For the exact-origin comparison the host:port
half of the tuple is untouched, a browser cannot attach X-Forwarded-Proto
cross-site without a CORS preflight this API never grants, and a
non-browser client able to forge the header can already forge Origin
itself it gains nothing. For cookies the upgrade can only ADD the Secure
flag (a Secure cookie set over plain http is simply dropped by the
browser the spoofer only breaks their own session), never strip it.
"""
url = getattr(connection, "url", None)
scheme = getattr(url, "scheme", None)
if not scheme:
scope = getattr(connection, "scope", None)
scheme = scope.get("scheme", "http") if isinstance(scope, dict) else "http"
scheme = {"ws": "http", "wss": "https"}.get(scheme, scheme)
if scheme != "https":
headers = getattr(connection, "headers", None) or {}
forwarded = (
headers.get(_FORWARDED_PROTO_HEADER, "") if hasattr(headers, "get") else ""
)
if forwarded.split(",")[0].strip().lower() in {"https", "wss"}:
scheme = "https"
return scheme
def _origin_tuple(value: str | None) -> tuple[str, str, int | None] | None:
if not value or value == "null":
return None
try:
parsed: SplitResult = urlsplit(value)
port = parsed.port
except (TypeError, ValueError):
return None
if (
not parsed.scheme
or not parsed.hostname
or parsed.username is not None
or parsed.password is not None
or parsed.path not in ("", "/")
or parsed.query
or parsed.fragment
):
return None
scheme = parsed.scheme.lower()
if scheme not in {"http", "https", "tauri"}:
return None
if port is None:
if scheme == "http":
port = 80
elif scheme == "https":
port = 443
return scheme, parsed.hostname.lower(), port
def configured_allowed_origins() -> frozenset[tuple[str, str, int | None]]:
raw_port = os.environ.get("OMNIVOICE_UI_PORT", "3901")
try:
ui_port = int(raw_port)
except (TypeError, ValueError):
ui_port = 3901
values = os.environ.get(
"OMNIVOICE_ALLOWED_ORIGINS",
f"http://localhost:{ui_port},http://127.0.0.1:{ui_port},"
"tauri://localhost,http://tauri.localhost",
).split(",")
return frozenset(
origin
for value in values
if (origin := _origin_tuple(value.strip())) is not None
)
def _destination_origin(connection) -> tuple[str, str, int | None] | None:
scheme = effective_scheme(connection)
url = getattr(connection, "url", None)
netloc = getattr(url, "netloc", None)
if netloc:
return _origin_tuple(f"{scheme}://{netloc}")
scope = getattr(connection, "scope", None)
headers = getattr(connection, "headers", None) or {}
if not isinstance(scope, dict):
return None
host = headers.get("host", "") if hasattr(headers, "get") else ""
return _origin_tuple(f"{scheme}://{host}")
def origin_allowed(connection) -> bool:
headers = getattr(connection, "headers", None) or {}
origin_value = headers.get("origin", "") if hasattr(headers, "get") else ""
presented = _origin_tuple(origin_value)
if presented is None:
return False
return presented == _destination_origin(connection) or presented in configured_allowed_origins()
def cookie_csrf_allowed(connection, *, side_effectful_get: bool = False) -> bool:
headers = getattr(connection, "headers", None) or {}
marker = headers.get(CSRF_HEADER, "") if hasattr(headers, "get") else ""
if marker != CSRF_VALUE or not origin_allowed(connection):
return False
method = getattr(connection, "method", None)
if method is None:
scope = getattr(connection, "scope", None)
method = scope.get("method", "GET") if isinstance(scope, dict) else "GET"
method = str(method).upper()
if side_effectful_get or method in SAFE_HTTP_METHODS:
fetch_site = headers.get("sec-fetch-site", "") if hasattr(headers, "get") else ""
return fetch_site == "same-origin"
return True
-102
View File
@@ -176,108 +176,6 @@ _BASE_SCHEMA = """
created_at REAL
);
CREATE INDEX IF NOT EXISTS idx_pron_lang ON pronunciation_entries(language);
-- Remote GPU workers (docs/remote-workers.md). Opt-in: an install with no
-- remote workers never writes a row here and behaves exactly as before.
--
-- `public_key` is the worker's identity — a server-assigned id is a name,
-- not proof, so every reconnect is verified against this key. Revocation
-- is a persisted fact (not in-memory state) precisely so a restart of the
-- control plane cannot silently readmit a worker the user removed.
CREATE TABLE IF NOT EXISTS remote_workers (
id TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
key_id TEXT NOT NULL,
public_key BLOB NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
revoked INTEGER NOT NULL DEFAULT 0,
revoked_at REAL,
priority INTEGER NOT NULL DEFAULT 50,
endpoint TEXT NOT NULL DEFAULT '',
host_json TEXT NOT NULL DEFAULT '{}',
capabilities_json TEXT NOT NULL DEFAULT '[]',
max_concurrent_tasks INTEGER NOT NULL DEFAULT 1,
-- Bumped on every successful (re)connect. Messages stamped with an
-- older epoch are from a session we have already replaced.
session_epoch INTEGER NOT NULL DEFAULT 0,
consent_granted_at REAL,
created_at REAL NOT NULL,
last_seen_at REAL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_remote_workers_key ON remote_workers(key_id);
-- Single-use join tokens. Only the hash is stored: the plaintext exists
-- once, in the dialog that shows it.
CREATE TABLE IF NOT EXISTS remote_worker_enrollments (
token_id TEXT PRIMARY KEY,
secret_hash TEXT NOT NULL,
endpoint TEXT NOT NULL DEFAULT '',
cert_fingerprint TEXT NOT NULL DEFAULT '',
label TEXT NOT NULL DEFAULT '',
created_at REAL NOT NULL,
expires_at REAL NOT NULL,
used_at REAL,
used_by_worker TEXT
);
-- Tasks dispatched to remote workers. Unlike the local `jobs` table (whose
-- startup sweep marks anything in-flight as failed), these must SURVIVE a
-- control-plane restart: the desktop app quits while a remote GPU keeps
-- rendering, and the worker is the source of truth for what is still
-- running. Reconciliation on reconnect rebuilds live state from here.
CREATE TABLE IF NOT EXISTS remote_tasks (
id TEXT PRIMARY KEY,
-- Client-supplied; deduplicates client retries before the worker
-- protocol is involved at all.
idempotency_key TEXT,
operation TEXT NOT NULL,
engine TEXT NOT NULL DEFAULT '',
model_id TEXT NOT NULL DEFAULT '',
params_json TEXT NOT NULL DEFAULT '{}',
priority INTEGER NOT NULL DEFAULT 0,
state TEXT NOT NULL DEFAULT 'queued',
max_attempts INTEGER NOT NULL DEFAULT 3,
excluded_json TEXT NOT NULL DEFAULT '[]',
error_json TEXT,
-- Written BEFORE RESULT_ACK is sent. If the server dies between
-- receiving a result and acknowledging it, the worker redelivers and
-- this row is what makes the second delivery a no-op instead of a
-- silently lost multi-minute render.
result_ref TEXT,
result_json TEXT,
project_id TEXT,
created_at REAL NOT NULL,
updated_at REAL NOT NULL,
deadline_at REAL,
-- Deliberate additive-reconcile exception to the alembic rule: remote
-- task recovery must work in bundled installs where alembic may be
-- unavailable, and this nullable affinity column is additive-only.
pinned_worker_id TEXT,
finished_at REAL
);
CREATE INDEX IF NOT EXISTS idx_remote_tasks_state ON remote_tasks(state, priority, created_at);
CREATE UNIQUE INDEX IF NOT EXISTS idx_remote_tasks_idem ON remote_tasks(idempotency_key)
WHERE idempotency_key IS NOT NULL;
CREATE TABLE IF NOT EXISTS remote_task_attempts (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
worker_id TEXT NOT NULL,
session_epoch INTEGER NOT NULL DEFAULT 0,
attempt_number INTEGER NOT NULL DEFAULT 1,
state TEXT NOT NULL DEFAULT 'assigned',
progress REAL NOT NULL DEFAULT 0,
stage TEXT NOT NULL DEFAULT '',
error_json TEXT,
created_at REAL NOT NULL,
accepted_at REAL,
started_at REAL,
finished_at REAL,
lease_expires_at REAL,
grace_expires_at REAL
);
CREATE INDEX IF NOT EXISTS idx_remote_attempts_task ON remote_task_attempts(task_id);
CREATE INDEX IF NOT EXISTS idx_remote_attempts_worker ON remote_task_attempts(worker_id, state);
"""
# Only tables/columns this module is allowed to ALTER. Prevents SQL injection via
-100
View File
@@ -177,23 +177,6 @@ def gfx_for_hsa_override(value: str) -> str | None:
#: The ROCm kernel driver interface. Its absence, or its presence without
#: permission, are the two commonest reasons a ROCm host silently runs on CPU.
_KFD_DEVICE = "/dev/kfd"
_DXG_DEVICE = "/dev/dxg"
_DXG_RUNTIME_PATHS = (
"/usr/lib/libdxcore.so",
"/usr/lib/librocdxg.so",
"/usr/share/rocdxg/dids.conf",
)
def _rocm_requires_dxg_detection(version: object) -> bool:
"""Whether WSL's ROCDXG bridge still needs its explicit opt-in."""
try:
parts = str(version).split(".")
return (int(parts[0]), int(parts[1])) < (7, 13)
except (IndexError, TypeError, ValueError):
# Unknown versions get the conservative advice. The variable is
# harmless on newer runtimes and necessary on every older one.
return True
def why_no_gpu(torch) -> tuple[str, ...]:
@@ -247,40 +230,6 @@ def why_no_gpu(torch) -> tuple[str, ...]:
# /dev/kfd only exists on Linux; on any other platform its absence
# says nothing, so don't invent a reason.
if sys.platform.startswith("linux"):
if not os.path.exists(_KFD_DEVICE) and os.path.exists(_DXG_DEVICE):
if not os.access(_DXG_DEVICE, os.R_OK | os.W_OK):
return (
f"ROCm {hip} is installed and {_DXG_DEVICE} exists, "
"but this process cannot open it — pass "
"--device /dev/dxg to the WSL container",
)
dxg_detection = os.environ.get("HSA_ENABLE_DXG_DETECTION", "").strip()
if dxg_detection == "0":
return (
f"ROCm {hip} is installed and {_DXG_DEVICE} is reachable, "
"but HSA_ENABLE_DXG_DETECTION=0 explicitly disables the "
"WSL GPU bridge; remove it or set it to 1",
)
if _rocm_requires_dxg_detection(hip) and dxg_detection != "1":
return (
f"ROCm {hip} is installed and {_DXG_DEVICE} is "
"reachable, but this pre-7.13 runtime requires "
"HSA_ENABLE_DXG_DETECTION=1 inside WSL containers",
)
missing = [
path for path in _DXG_RUNTIME_PATHS if not os.path.exists(path)
]
if missing:
return (
f"ROCm {hip} can reach {_DXG_DEVICE}, but the WSL "
"ROCDXG runtime mounts are incomplete; missing: "
f"{', '.join(missing)}",
)
return (
f"ROCm {hip} and the WSL ROCDXG bridge are reachable, "
"but no GPU was enumerated — verify the AMD Windows "
"driver, librocdxg/ROCm compatibility, and host `rocminfo`",
)
if not os.path.exists(_KFD_DEVICE):
return (
f"ROCm {hip} is installed but {_KFD_DEVICE} is not "
@@ -425,33 +374,6 @@ class HostCaps:
probe_ok: bool = True
"""``False`` only when torch could not be imported (degraded CPU-only)."""
requested_family: str = "auto"
"""The user's compute-device override as requested — ``"auto"`` when none.
``family`` reflects what was actually honored: an override that names a
family this host doesn't have is noted and ignored, never obeyed blindly."""
#: Every value the compute-device override accepts. "auto" = today's
#: priority pick; "cpu" is always honorable (invariant: cpu is always
#: available); accelerator names are honored only when detected.
DEVICE_OVERRIDE_CHOICES: tuple[str, ...] = ("auto", "cuda", "rocm", "xpu", "mps", "cpu")
def requested_device_override() -> str:
"""The user's compute-device pick: ``OMNIVOICE_DEVICE`` env > the Settings
choice (``compute_device`` in prefs.json) > ``"auto"``. Env wins so
power-users can pin a device without the UI silently undoing it (same
resolution order as engine selection, #981). Unknown values normalize to
``"auto"`` the probe must never raise."""
try:
from core import prefs
raw = prefs.resolve("compute_device", env="OMNIVOICE_DEVICE", default="auto")
except Exception:
raw = os.environ.get("OMNIVOICE_DEVICE", "auto")
val = str(raw or "auto").strip().lower()
return val if val in DEVICE_OVERRIDE_CHOICES else "auto"
def _probe() -> HostCaps:
"""Run the probe once. Enumerates every failure branch from the spec's
@@ -464,7 +386,6 @@ def _probe() -> HostCaps:
available_families=("cpu",),
notes=("torch not importable; treating host as CPU-only",),
probe_ok=False,
requested_family=requested_device_override(),
)
notes: list[str] = []
@@ -586,26 +507,6 @@ def _probe() -> HostCaps:
# available_families: every detected accelerator + cpu, deduped, cpu last.
available: tuple[DeviceFamily, ...] = tuple(dict.fromkeys([*detected, "cpu"]))
# User override (Settings → Performance, or OMNIVOICE_DEVICE): honored
# only when the named family actually exists on this host — an override
# can steer, it cannot invent hardware. Applied here, at the single
# choke point, so routing, model loads (get_best_device delegates its
# family decision here), and every badge inherit it for free.
requested = requested_device_override()
if requested != "auto":
if requested in available:
if requested != family:
notes.append(
f"compute device pinned to '{requested}' by user override "
f"(auto would pick '{family}')"
)
family = requested # type: ignore[assignment]
else:
notes.append(
f"requested compute device '{requested}' is not available on "
f"this host (have: {', '.join(available)}) — using '{family}'"
)
return HostCaps(
family=family,
available_families=available,
@@ -614,7 +515,6 @@ def _probe() -> HostCaps:
driver=driver,
notes=tuple(notes),
probe_ok=True,
requested_family=requested,
)
+2 -66
View File
@@ -21,7 +21,6 @@ Check shape:
"""
from __future__ import annotations
import importlib
import os
import platform
import shutil
@@ -201,12 +200,12 @@ def _check_engines() -> dict:
return _check(
"engines", "TTS engines", FAIL,
f"{detail} - active engine '{active}' is unavailable: {reason}",
active_row.get("install_hint") or "Pick a different engine in Model Catalogue > Engines.",
active_row.get("install_hint") or "Pick a different engine in Settings > Engines.",
)
if not available:
return _check(
"engines", "TTS engines", FAIL, detail,
"No usable TTS engine. Install one from Model Catalogue > Engines.",
"No usable TTS engine. Install one from Settings > Engines.",
)
return _check("engines", "TTS engines", OK, detail)
@@ -368,44 +367,10 @@ def run_diagnostics(include_network: bool = True, deep: bool = False) -> dict:
counts = {OK: 0, WARN: 0, FAIL: 0}
for c in checks:
counts[c["status"]] += 1
engine_execution = []
for family in ("tts", "asr"):
active = "unknown"
try:
module = importlib.import_module(f"services.{family}_backend")
active = module.active_backend_id()
row = next((item for item in module.list_backends() if item.get("id") == active), None)
if row is not None:
engine_execution.append({
"family": family,
"engine_id": active,
**row["execution_evidence"],
})
except Exception: # noqa: BLE001 - evidence must not break diagnostics
# Preserve the other family's successful evidence and make this
# collection failure explicit without exposing exception text.
engine_execution.append({
"family": family,
"engine_id": active,
"implementation_variant": None,
"declared_device_families": [],
"evidence_state": "collection_failed",
"actual_execution_provider": None,
"actual_execution_device": None,
"gpu_name": None,
"gpu_architecture": None,
"precision_or_quantization": None,
"cpu_fallback_reason": None,
"cpu_fallback_stage": None,
"parent_memory_observable": None,
"runtime_versions": {},
})
return {
"app_version": APP_VERSION,
"platform": scrub_text(platform.platform()),
"checks": checks,
"engine_execution": engine_execution,
"summary": {
"ok": counts[FAIL] == 0,
"passed": counts[OK],
@@ -430,35 +395,6 @@ def format_text(report: dict) -> str:
lines.append(f"{tag[c['status']]} {c['label']}: {c['detail']}")
if c.get("hint"):
lines.append(f" hint: {c['hint']}")
if report.get("engine_execution"):
lines.append("")
lines.append("Engine execution evidence:")
for item in report["engine_execution"]:
if item.get("actual_execution_provider"):
provider = item["actual_execution_provider"]
elif item.get("evidence_state") == "subprocess_loaded_provider_unreported":
provider = "loaded child; provider not reported"
else:
provider = "not loaded"
precision = item.get("precision_or_quantization") or "unknown"
device = item.get("actual_execution_device") or "unknown"
gpu = item.get("gpu_name") or "none"
architecture = item.get("gpu_architecture") or "unknown"
fallback_stage = item.get("cpu_fallback_stage") or "none"
fallback_reason = item.get("cpu_fallback_reason") or "none"
versions = ",".join(
f"{name}={version}"
for name, version in sorted(item.get("runtime_versions", {}).items())
) or "none"
visible = "yes" if item.get("parent_memory_observable") else "no"
lines.append(
f" {item['family']}:{item['engine_id']} provider={provider}; "
f"device={device}; gpu={gpu}; architecture={architecture}; "
f"precision={precision}; fallback-stage={fallback_stage}; "
f"fallback-reason={fallback_reason}; runtimes={versions}; "
f"evidence-state={item.get('evidence_state', 'unknown')}; "
f"parent-memory-visible={visible}"
)
s = report["summary"]
lines.append("")
lines.append(
+8 -34
View File
@@ -23,17 +23,9 @@ logger = logging.getLogger("omnivoice.events")
_listeners: list[asyncio.Queue] = []
_lock = asyncio.Lock()
# The loop that serves /ws/events, captured on first use. Sync FastAPI
# endpoints (rename/delete profile, revoke consent) run in threadpool workers
# where `asyncio.get_running_loop()` raises, which used to silently drop their
# events — the UI then never refetched the voice list (#1158 class).
_serving_loop: asyncio.AbstractEventLoop | None = None
async def subscribe() -> asyncio.Queue:
"""Register a new listener. Returns a Queue that receives event dicts."""
global _serving_loop
_serving_loop = asyncio.get_running_loop()
q: asyncio.Queue = asyncio.Queue(maxsize=64)
async with _lock:
_listeners.append(q)
@@ -65,29 +57,11 @@ def emit(kind: str, payload: dict[str, Any] | None = None) -> None:
}
event_str = json.dumps(event)
try:
caller_loop = asyncio.get_running_loop()
loop = asyncio.get_running_loop()
loop.create_task(_broadcast(event_str))
except RuntimeError:
caller_loop = None
target_loop = _serving_loop or caller_loop
if target_loop is None:
# No serving loop yet — nobody to notify; dropping is correct.
# No event loop running (unlikely in FastAPI context but safe)
logger.debug("No event loop — event dropped: %s", kind)
return
try:
if caller_loop is target_loop:
target_loop.create_task(_broadcast(event_str))
else:
# Sync endpoints and async producers on a foreign loop must both
# hand off: the lock and listener queues belong to serving_loop.
target_loop.call_soon_threadsafe(_schedule_broadcast, event_str)
except RuntimeError:
# The serving loop closed between capture and use (app shutdown).
logger.debug("Event loop closed — event dropped: %s", kind)
def _schedule_broadcast(event_str: str) -> None:
"""Run `_broadcast` on the serving loop; called via call_soon_threadsafe."""
asyncio.get_running_loop().create_task(_broadcast(event_str))
async def _broadcast(event_str: str) -> None:
@@ -99,11 +73,11 @@ async def _broadcast(event_str: str) -> None:
q.put_nowait(event_str)
except asyncio.QueueFull:
# Slow consumer — drop oldest, then push. Not a race (#1163):
# every queue op runs on the single event loop (a foreign
# thread's emit() hands off via call_soon_threadsafe first),
# and there is no await between the QueueFull and this
# get_nowait/put_nowait pair — no consumer can interleave, so
# get_nowait cannot raise QueueEmpty here.
# every queue op runs on the single event loop, and there is
# no await between the QueueFull and this get_nowait/put_nowait
# pair — no consumer can interleave, so get_nowait cannot raise
# QueueEmpty here. emit() from a foreign thread drops the event
# before ever touching a queue (see the RuntimeError branch).
try:
q.get_nowait()
q.put_nowait(event_str)
+2 -69
View File
@@ -52,35 +52,6 @@ _REDACTED_VALUE = "***REDACTED***"
# One-line "what to do" per docs-taxonomy key. Keys mirror error_docs_map's
# taxonomy; the docs URL itself stays owned by error_docs_map.
_HINTS: dict[str, str] = {
"GPU_OOM": "Close other GPU-heavy apps or unload models, then retry. You can also choose CPU in Settings → Performance & Device or select a smaller TTS engine.",
"WORKER_AT_CAPACITY": "Wait for a running job on that worker to finish, or choose another available worker and retry.",
"MODEL_NOT_INSTALLED": "Install or enable this engine on the worker machine, then refresh its capabilities and retry.",
"MODEL_NOT_DOWNLOADED": "Open Models, install this model on the selected worker, then retry when the download completes.",
"INSUFFICIENT_MEMORY": "Choose a worker with more free GPU memory, unload another model there, or use a smaller model and retry.",
"OPERATION_UNSUPPORTED": "Choose a worker whose capability list includes this operation, or run the job locally.",
"ACCEPT_TIMEOUT": "Check that the worker is responsive and not overloaded, then reconnect it and retry.",
"MODEL_LOAD_TIMEOUT": "Check the worker's model download and load status, then retry after the model is ready.",
"EXECUTION_TIMEOUT": "Check the worker for a stalled engine or GPU error, restart that engine if needed, then retry.",
"PROGRESS_LEASE_EXPIRED": "Check the worker connection and engine log, reconnect or restart the worker, then retry the job.",
"RESULT_DELIVERY_TIMEOUT": "Check the connection and free disk space on both machines, then reconnect the worker and retry.",
"INPUT_FETCH_TIMEOUT": "Check the connection to the worker and retry; keep both machines awake until the reference file finishes transferring.",
"INPUT_FETCH_FAILED": "Check that the source file still exists and both machines are connected, then submit the job again.",
"RESULT_UPLOAD_FAILED": "Check the worker connection and free disk space on this machine, reconnect the worker, then retry.",
"WORKER_FAILED": "Open the selected worker's log for the underlying error, fix it there, then reconnect and retry.",
"SESSION_EXPIRED": "Reconnect the worker; if it cannot renew its session, remove it and enroll it again.",
"STALE_EPOCH": "Reconnect the worker so it receives the current session, then retry the job.",
"STALE_ATTEMPT": "Refresh the job state and retry only if the current attempt has not already completed elsewhere.",
"UPGRADE_REQUIRED": "Update VoiceStudio on the machine named in the error, then reconnect the worker.",
"WORKER_REVOKED": "Add the worker again from Settings → System → Remote workers to create a new trusted enrollment.",
"AUTH_FAILED": "Remove this worker, generate a new enrollment token, and add it again.",
"INVALID_TASK_PARAMS": "Review the job inputs, correct the invalid or missing value named in the error, and submit it again.",
"MODEL_REF_REJECTED": "Select a model from VoiceStudio's catalog on that worker instead of a path or custom model reference.",
"RESULT_TOO_LARGE": "Shorten or split the job so each result is smaller, then render the parts separately.",
"ARTIFACT_TOO_LARGE": "Shorten or split the job so each uploaded artifact is smaller, then render the parts separately.",
"OFFSET_MISMATCH": "Reconnect the worker and retry the upload from the byte count reported by the control plane.",
"SIZE_MISMATCH": "Reconnect the worker and retry the result upload; if it repeats, restart the worker before rerendering.",
"DIGEST_MISMATCH": "Retry the result upload; if it repeats, check the worker's disk and network for corruption, then rerender.",
"UPLOAD_INCOMPLETE": "Reconnect the worker and resume the result upload from the byte count reported by the control plane.",
"PKG_RESOURCES_MISSING": "Run `uv pip install --reinstall 'setuptools>=75,<80'` in the backend venv (a plain install is skipped when setuptools' metadata is present but its pkg_resources files were removed by antivirus). Restart after.",
"GATEKEEPER_QUARANTINE": "Clear the macOS quarantine flag (xattr -cr the app), then reopen.",
"APPIMAGE_WEBKIT_WHITESCREEN": "Launch with WEBKIT_DISABLE_DMABUF_RENDERER=1 set.",
@@ -94,7 +65,7 @@ _HINTS: dict[str, str] = {
# fail with "file not found" for exactly the users most likely to need it
# (greptile on #1377). tests/test_failure_classify.py pins these literals
# to the constraint file so they cannot drift when the pins bump.
"TRANSFORMERS_IMPORT": "Your transformers install is incomplete, or a package it loads models through (torchaudio, torchvision) is missing or mismatched with your torch — a torch/torchvision version mismatch fails with exactly this wording. Reinstall them together at the pinned versions (`uv pip install --python .venv --reinstall torch==2.8.0 torchaudio==2.8.0 torchvision==0.23.0 transformers` in the project folder), then restart the backend. If only transcription is affected, switching ASR to faster-whisper (Model Catalogue → Models) also works around it.",
"TRANSFORMERS_IMPORT": "Your transformers install is incomplete, or a package it loads models through (torchaudio, torchvision) is missing or mismatched with your torch — a torch/torchvision version mismatch fails with exactly this wording. Reinstall them together at the pinned versions (`uv pip install --python .venv --reinstall torch==2.8.0 torchaudio==2.8.0 torchvision==0.23.0 transformers` in the project folder), then restart the backend. If only transcription is affected, switching ASR to faster-whisper (Settings → Models) also works around it.",
"WINDOWS_APP_CONTROL_BLOCKED": "Windows refused to load a file VoiceStudio needs — an Application Control policy (Smart App Control, WDAC, or AppLocker) blocked it. On a personal PC: Windows Security → App & browser control → Smart App Control → Off (Windows only lets you turn it off once — re-enabling requires a Windows reset), then restart VoiceStudio. On a managed/work PC, ask IT to allow the VoiceStudio install folder.",
"WINDOWS_PAGING_FILE_TOO_SMALL": "Windows ran out of virtual memory while mapping the model into memory — its paging file is smaller than the model needs. This is not the same as your RAM being full, and closing other apps usually won't fix it: Windows has to be allowed to back the mapping. Set a bigger paging file — Settings → System → About → Advanced system settings → Performance → Settings → Advanced → Virtual memory → Change: untick \"Automatically manage\", pick your system drive, choose \"Custom size\" and set both Initial and Maximum to at least 32768 MB (more than the model's size), then OK and restart Windows. A smaller/quantized engine (OmniVoice GGUF, Supertonic-3) also avoids the large mapping entirely.",
"MEDIA_TOOL_MISSING": "VoiceStudio's media engine (ffmpeg/ffprobe) wasn't on the system path when a component went looking for it. Open Settings → Audio tools and use Download/Repair to fetch the bundled copy, then retry — a restart picks it up for everything. If you'd rather use a system install, install ffmpeg (macOS: `brew install ffmpeg`; Windows: `winget install Gyan.FFmpeg`; Linux: your package manager) and restart VoiceStudio, or point FFMPEG_PATH / OMNIVOICE_FFPROBE_PATH at the binaries in Settings.",
@@ -118,7 +89,7 @@ _HINTS: dict[str, str] = {
# told the reporter to reinstall transformers — advice that cannot work,
# because nothing is wrong with their install. Checked first so the cause
# wins over the symptom.
"MODEL_DOWNLOAD_INTERRUPTED": "A model download was cut off mid-request, and the component it was fetching then failed to load. Nothing is wrong with your install — reinstalling won't help, and the partial download is resumed rather than restarted. Just retry. If it keeps happening, check your connection (and any VPN, proxy or HF mirror setting); if only transcription is affected, switching ASR to faster-whisper in Model Catalogue → Models avoids the pipeline that downloads this component.",
"MODEL_DOWNLOAD_INTERRUPTED": "A model download was cut off mid-request, and the component it was fetching then failed to load. Nothing is wrong with your install — reinstalling won't help, and the partial download is resumed rather than restarted. Just retry. If it keeps happening, check your connection (and any VPN, proxy or HF mirror setting); if only transcription is affected, switching ASR to faster-whisper in Settings → Models avoids the pipeline that downloads this component.",
"BROKEN_VENV": "The Python backend environment was moved or damaged. VoiceStudio rebuilds it automatically on the next launch; if it keeps failing, use Clean & Retry on the setup screen.",
"MODEL_CACHE_CORRUPT": "A model file is missing or damaged — a download that stopped part-way, a broken link to downloaded data, or a file changed on disk after it arrived (interrupted renames and antivirus interference both cause this). VoiceStudio repairs it automatically and retries the load once, re-downloading the damaged file where a resume would not have replaced it. If the error persists, quit VoiceStudio, delete the model's models--<org>--<name> folder inside the Hugging Face cache, and restart — the model re-downloads automatically.",
# HF_MIRROR_UNREACHABLE has a DYNAMIC hint (it names the configured mirror)
@@ -291,9 +262,6 @@ def append_hf_mirror_hint(text: str) -> str:
# must NOT be added: its bare "timed out" trigger would stamp a "video server"
# hint on a model-load timeout that leaks through the 500 handler.
_CONTEXT_FREE_HINT_CLASSES = frozenset({
# Device allocator signatures are specific enough to attach the shared
# recovery without exposing CUDA's process table or filesystem paths.
"GPU_OOM",
"SOCKS_PROXY_SUPPORT_MISSING",
"SSL_HANDSHAKE_FAILURE",
# Its trigger is an exact OpenSSL string, so it cannot be confused with
@@ -327,38 +295,6 @@ def append_hint(text: str) -> str:
return f"{text}{hint}" if hint else text
_GPU_OOM_SIGNATURES = (
"cuda out of memory",
"cuda error: out of memory",
"cuda_error_out_of_memory",
"mps backend out of memory",
"hip out of memory",
"out of memory on device",
)
def is_gpu_oom(error: BaseException | str) -> bool:
"""Recognize device OOMs through wrappers without importing torch."""
pending: list[BaseException] = [error] if isinstance(error, BaseException) else []
seen: set[int] = set()
while pending:
current = pending.pop()
if id(current) in seen:
continue
seen.add(id(current))
if type(current).__name__ == "OutOfMemoryError":
return True
if any(signature in str(current).lower() for signature in _GPU_OOM_SIGNATURES):
return True
if current.__cause__ is not None:
pending.append(current.__cause__)
if current.__context__ is not None:
pending.append(current.__context__)
if isinstance(error, str):
return any(signature in error.lower() for signature in _GPU_OOM_SIGNATURES)
return False
def classify(reason: str) -> str:
"""Map a failure reason to a docs-taxonomy key, or "" when unknown.
@@ -366,8 +302,6 @@ def classify(reason: str) -> str:
backend log / diagnostic names the same class the UI deeplink will use.
"""
low = (reason or "").lower()
if is_gpu_oom(low):
return "GPU_OOM"
if "pkg_resources" in low:
return "PKG_RESOURCES_MISSING"
if "quarantine" in low or "is damaged" in low or "gatekeeper" in low:
@@ -557,7 +491,6 @@ def classify(reason: str) -> str:
or "unable to download video" in low
or "remote end closed" in low
or "timed out" in low
or "the page needs to be reloaded" in low
):
return "VIDEO_DOWNLOAD_NETWORK"
# #1227: Windows Smart App Control / WDAC / AppLocker refused to load a
+9 -38
View File
@@ -3,11 +3,10 @@ isn't empty on initial launch. Runs once; skips silently if any
profiles already exist.
"""
import filecmp
import logging
import os
import shutil
import time
import logging
from core.db import get_db
from core.config import VOICES_DIR
@@ -24,15 +23,16 @@ DEMO_PROFILE_NAME = "VoiceStudio Demo Voice"
# Must match the actual spoken content of backend/assets/samples/demo_voice.wav.
# Regenerated by scripts/build_demos.sh — update both files in lockstep.
DEMO_REF_TEXT = (
"Hey. I'm the VoiceStudio demo voice. I was made right here, on your "
"machine: private, local, and ready whenever you are."
"Hi, I'm the VoiceStudio demo voice. Everything you hear me say from now on "
"was synthesized on your own machine. No cloud, no account, just you and "
"the model."
)
_DEMO_DESCRIPTION = (
"An original warm, low cinematic voice bundled with VoiceStudio. Clone it "
"to hear how the engine sounds on your machine, then replace it with your "
"own recording when you're ready."
"A neutral reference voice bundled with VoiceStudio. Clone it to hear how "
"the engine sounds on your machine, then replace it with your own "
"recording when you're ready."
)
@@ -43,14 +43,8 @@ def _backfill_demo_metadata(conn):
try:
conn.execute(
"UPDATE voice_profiles SET description=?, is_demo=1, ref_text=? "
"WHERE id=? AND (is_demo=0 OR description!=? OR ref_text!=?)",
(
_DEMO_DESCRIPTION,
DEMO_REF_TEXT,
DEMO_PROFILE_ID,
_DEMO_DESCRIPTION,
DEMO_REF_TEXT,
),
"WHERE id=? AND (is_demo=0 OR description='' OR ref_text!=?)",
(_DEMO_DESCRIPTION, DEMO_REF_TEXT, DEMO_PROFILE_ID, DEMO_REF_TEXT),
)
conn.commit()
except Exception as e:
@@ -58,34 +52,11 @@ def _backfill_demo_metadata(conn):
logger.debug("Demo backfill skipped: %s", e)
def _refresh_demo_audio(conn):
"""Keep the canonical demo profile in sync with the bundled render."""
try:
row = conn.execute(
"SELECT 1 FROM voice_profiles WHERE id=? AND is_demo=1",
(DEMO_PROFILE_ID,),
).fetchone()
if not row or not os.path.isfile(_DEMO_AUDIO):
return
os.makedirs(VOICES_DIR, exist_ok=True)
dest = os.path.join(VOICES_DIR, f"{DEMO_PROFILE_ID}.wav")
if not os.path.isfile(dest) or not filecmp.cmp(
_DEMO_AUDIO, dest, shallow=False
):
shutil.copy2(_DEMO_AUDIO, dest)
logger.info("Refreshed bundled demo voice audio")
except Exception as e:
# The demo must never make startup fail; a fresh seed below can still
# repair it once the schema and data directory are available.
logger.debug("Demo audio refresh skipped: %s", e)
def seed_sample_project():
"""Create the demo voice profile if no profiles exist yet."""
conn = get_db()
try:
_backfill_demo_metadata(conn)
_refresh_demo_audio(conn)
count = conn.execute("SELECT COUNT(*) FROM voice_profiles").fetchone()[0]
if count > 0:
return # Not first run — skip
-34
View File
@@ -1,34 +0,0 @@
"""Terminate a desktop-contained backend when its owning shell disappears."""
from __future__ import annotations
import os
import sys
import threading
from typing import BinaryIO, Callable
def _watch_parent_pipe(reader: BinaryIO, exit_process: Callable[[int], None]) -> None:
"""Block until the desktop-owned stdin pipe closes, then exit immediately."""
try:
while reader.read(1):
pass
except (OSError, ValueError):
# A broken or already-closed parent-owned pipe is equivalent to EOF.
pass
exit_process(0)
def arm_desktop_parent_watchdog() -> bool:
"""Use stdin EOF as an unforgeable parent-liveness signal for desktop runs."""
if os.environ.get("OMNIVOICE_DESKTOP_CONTAINED") != "1":
return False
reader = getattr(sys.stdin, "buffer", None)
if reader is None:
return False
threading.Thread(
target=_watch_parent_pipe,
args=(reader, os._exit),
name="desktop-parent-watchdog",
daemon=True,
).start()
return True
+16 -43
View File
@@ -7,7 +7,6 @@ only the unguessable capability token crosses loopback HTTP.
from __future__ import annotations
import json
import logging
import os
import re
import secrets
@@ -15,8 +14,6 @@ import stat
from core.config import DATA_DIR
logger = logging.getLogger("omnivoice.path_authorization")
_TOKEN_RE = re.compile(r"[0-9a-f]{64}\Z")
_KINDS = {
"models_dir",
@@ -43,49 +40,25 @@ def consume(token: str, expected_kind: str) -> str:
if expected_kind not in _KINDS or not _TOKEN_RE.fullmatch(token or ""):
raise PathAuthorizationError("Invalid or expired desktop authorization")
root = _AUTH_DIR
# Distinguish "the store exists but this token isn't in it" (expired /
# already consumed / never issued — normal, no server-side signal) from
# "the store doesn't exist at all" (the desktop app and this backend are
# very likely pointed at different data directories, e.g. a dev backend
# started without OMNIVOICE_DATA_DIR, or a stale custom data folder — see
# #1781). The client-facing message is byte-identical either way (never
# leak local filesystem paths, or even which case occurred, over HTTP —
# CWE-200); the mismatch case additionally gets a server log line so it's
# diagnosable instead of a silent 403. That log line is deliberately
# path-free too (CWE-532: per-user filesystem paths, e.g. a home
# directory username, are sensitive and don't belong in application
# logs) — it names the failure mode, not the directory.
try:
entries = os.scandir(root)
except FileNotFoundError as exc:
logger.warning(
"path authorization store does not exist; the desktop app and "
"this backend likely resolved different data directories "
"(see #1781)"
)
raise PathAuthorizationError("Invalid or expired desktop authorization") from exc
except OSError as exc:
raise PathAuthorizationError("Invalid or expired desktop authorization") from exc
candidate = None
try:
with entries:
for entry in entries:
if not _TOKEN_RE.fullmatch(entry.name.removesuffix(".json")):
continue
if not entry.is_file(follow_symlinks=False):
continue
try:
with open(entry.path, "r", encoding="utf-8") as handle:
probe = json.load(handle)
except (OSError, UnicodeError, json.JSONDecodeError):
continue # Ignore corrupt/stale capabilities; they authorize nothing.
if isinstance(probe, dict) and secrets.compare_digest(
str(probe.get("token", "")), token
):
candidate = entry.path
break
for entry in os.scandir(root):
if not _TOKEN_RE.fullmatch(entry.name.removesuffix(".json")):
continue
if not entry.is_file(follow_symlinks=False):
continue
try:
with open(entry.path, "r", encoding="utf-8") as handle:
probe = json.load(handle)
except (OSError, UnicodeError, json.JSONDecodeError):
continue # Ignore corrupt/stale capabilities; they authorize nothing.
if isinstance(probe, dict) and secrets.compare_digest(
str(probe.get("token", "")), token
):
candidate = entry.path
break
if candidate is None:
raise PathAuthorizationError("Invalid or expired desktop authorization")
raise OSError("capability not found")
claimed = os.path.join(root, f".consuming-{os.getpid()}-{secrets.token_hex(16)}")
os.replace(candidate, claimed)
except OSError as exc:
+6 -21
View File
@@ -10,20 +10,8 @@ from __future__ import annotations
import ntpath
import os
import re
from pathlib import Path
_WINDOWS_RESERVED_NAMES = frozenset({"CON", "PRN", "AUX", "NUL"}) | frozenset(
f"{prefix}{number}" for prefix in ("COM", "LPT") for number in range(1, 10)
)
# Both separator families, so a stored sub-path splits into the same components
# on every host. Windows accepts ``/`` as a real separator, so splitting on
# ``os.sep`` alone left ``"job/out.mp4"`` as a single component there while the
# identical value split cleanly on POSIX. POSIX input never reaches this with a
# backslash — it is rejected as a foreign separator before the split.
_PATH_SEPARATORS = re.compile(r"[\\/]")
class UnsafePath(ValueError):
"""Raised when a path crosses its allowed filesystem boundary."""
@@ -40,10 +28,6 @@ def safe_filename(value: object) -> str:
or os.path.isabs(name)
or ntpath.isabs(name)
or ntpath.basename(name) != name
or name.endswith((" ", "."))
or re.search(r"[\x00-\x1f]", name)
or name.split(".", 1)[0].upper() in _WINDOWS_RESERVED_NAMES
or len(name.encode("utf-8")) > 240
):
raise UnsafePath("expected a bare filename")
return name
@@ -59,10 +43,11 @@ def resolve_within(root: os.PathLike[str] | str, value: os.PathLike[str] | str)
raw = os.fspath(value) if value is not None else ""
if not isinstance(raw, str) or not raw:
raise UnsafePath("path is empty")
# Treat both separator families as structural on every host while still
# rejecting Windows drive paths before rebuilding relative components.
if os.sep != "\\" and bool(ntpath.splitdrive(raw)[0]):
raise UnsafePath("path uses a drive")
# Treat both separator families as structural on every host. Otherwise a
# Windows traversal string is an innocent-looking filename when validated
# on Linux (and can become dangerous after persisted data is moved).
if os.sep != "\\" and ("\\" in raw or bool(ntpath.splitdrive(raw)[0])):
raise UnsafePath("path uses a foreign separator or drive")
root_path = Path(root).expanduser().resolve(strict=False)
root_text = str(root_path)
if os.path.isabs(raw):
@@ -75,7 +60,7 @@ def resolve_within(root: os.PathLike[str] | str, value: os.PathLike[str] | str)
# containment proof explicit to static analysis, this rejects empty,
# dot, parent, drive, and separator-bearing components before Path sees
# any persisted/request-derived string.
parts = _PATH_SEPARATORS.split(raw)
parts = raw.split(os.sep)
clean_parts: list[str] = []
for part in parts:
clean = os.path.basename(part)
-50
View File
@@ -91,53 +91,3 @@ def resolve(key: str, *, env: Optional[str] = None, default: Any = None) -> Any:
if v:
return v
return get(key, default)
# ── external-override detection (#1787 review fix) ──────────────────────────
# restore_env() below uses os.environ.setdefault(), so a value already present
# in the process's environment (shell profile, `.env`, Docker `-e`, systemd
# unit, …) silently wins over anything saved in prefs.json — the setdefault
# call is a no-op. That is the right behavior (env stays authoritative,
# matching resolve()'s contract above), but a Settings control that persists a
# value to prefs.json must not tell the user it "took effect after restart"
# when an external source will keep shadowing it on every future restart too.
#
# _EXTERNALLY_PROVIDED records, once per process start, every bare key that
# was ALREADY present in os.environ the moment restore_env() ran — i.e.
# before our own setdefault() calls could have put it there, and before any
# value our Settings UI ever wrote (Settings only ever writes prefs.json plus
# the CURRENT process's os.environ; it never touches a shell profile or `.env`
# file). Snapshotting unconditionally — not only for keys prefs.json already
# has an entry for — means is_env_shadowed() also answers correctly for a key
# a user is about to save for the FIRST time. Membership is stable for the
# life of the process (nothing removes an inherited env var), and since a
# plain restart re-inherits the same shell / container environment, it is
# also a reliable predictor for the NEXT start: if the external source is
# still exporting the key, the next restart will be shadowed again the same
# way.
_EXTERNALLY_PROVIDED: frozenset[str] = frozenset()
def restore_env(data: dict) -> None:
"""Restore ``env.*`` prefs into ``os.environ`` (startup only).
Called once from main.py's ``env_prefs`` step, before any user code reads
``os.environ``. Snapshots which keys were already externally provided
see :func:`is_env_shadowed` then applies every saved ``env.*`` pref via
``setdefault`` (never overriding an explicitly-set env var).
"""
global _EXTERNALLY_PROVIDED
_EXTERNALLY_PROVIDED = frozenset(os.environ.keys())
for k, v in data.items():
if not k.startswith("env.") or not v:
continue
os.environ.setdefault(k[len("env."):], str(v))
def is_env_shadowed(key: str) -> bool:
"""Whether *key* was already present in the environment from a source
other than our own prefs restore, as of the last time :func:`restore_env`
ran. If prefs.json holds (or will hold) a saved value for *key*, that
value is being silently ignored and will be again on the next restart
unless the external source is removed."""
return key in _EXTERNALLY_PROVIDED
-60
View File
@@ -28,15 +28,6 @@ def stream_failure(code: str) -> dict[str, object]:
"detail": "Generation capacity is busy. Try again shortly.",
"retryable": True,
},
"generation_timeout": {
"code": "generation_timeout",
"detail": (
"Generation exceeded the compute-time limit. The backend is "
"still running; try a shorter passage, or raise the "
"compute-time budget in Settings → Performance & Device."
),
"retryable": True,
},
"invalid_request": {
"code": "invalid_request",
"detail": "The generation request could not be processed.",
@@ -52,15 +43,6 @@ def stream_failure(code: str) -> dict[str, object]:
"detail": "Transcription failed. Check the selected ASR engine and try again.",
"retryable": True,
},
"transcription_memory": {
"code": "transcription_memory",
"detail": (
"Transcription ran out of GPU memory. Close other GPU apps or "
"Flush models, then try again; VoiceStudio will use CPU when "
"the remaining GPU memory is too low."
),
"retryable": True,
},
"transcription_timeout": {
"code": "transcription_timeout",
"detail": (
@@ -74,48 +56,6 @@ def stream_failure(code: str) -> dict[str, object]:
return dict(failures.get(code, failures["generation_failed"]))
def stream_generation_failure(error: BaseException | object) -> dict[str, object]:
"""``generation_failed`` stream metadata, enriched with the actual cause.
The bare "Generation failed. Check the selected engine and try again." is
the floor for an *unrecognized* failure. When the private exception DOES
classify to a known failure class a corrupt model cache, an unreachable
Hugging Face mirror, a missing ffmpeg/ffprobe, a Windows paging-file limit,
a SOCKS/TLS proxy problem, the stable VoiceStudio-owned remediation for
that class is appended so the user can self-diagnose instead of guessing
which engine or which failure. This is the same enrichment the classic
(non-streaming) ``/generate`` 500 already gets via
:func:`public_exception_response`; the in-band streaming error frame
replaces the global 500 handler for a streaming request and used to bypass
it entirely (#1607).
Only VoiceStudio-owned constants are copied never a substring of
``error`` (Constitution I). Never raises: a diagnosis failure must not
replace the failure being diagnosed.
"""
payload = stream_failure("generation_failed")
try:
enriched = public_exception_response(error, fallback=str(payload["detail"]))
except Exception:
return payload
hint = enriched.get("hint")
if hint:
payload["detail"] = enriched["detail"]
payload["hint"] = hint
topic = enriched.get("docs_topic")
if topic:
payload["docs_topic"] = topic
try:
from core import error_docs_map
url = error_docs_map.ERROR_DOCS.get(topic, "")
except Exception:
url = ""
if url:
payload["docs_url"] = url
return payload
def public_failure(
logger: logging.Logger,
log_message: str,
-125
View File
@@ -1,125 +0,0 @@
"""Startup progress ledger — what the backend is doing before it can serve.
Why this exists: the project's #1 lifetime failure class is "can't reach the
local backend", and a large slice of it was never a dead backend at all —
just one that couldn't say "I'm starting, currently loading PyTorch" because
nothing listened until every heavy import and migration finished. main.py now
binds the socket early and defers the heavy work; this module is the shared
state the early `/health` + `/startup/progress` endpoints report from while
that work runs.
Thread-safety: the deferred init runs Phase A in an executor thread while the
event loop serves probes, so every mutation and snapshot takes the lock.
"""
from __future__ import annotations
import threading
import time
# Execution order matters only for display; the ledger records whatever order
# steps actually begin in. Keep ids stable — the desktop shell field-sniffs
# them and tests pin them.
STEPS: "dict[str, str]" = {
"env_prefs": "Restoring settings…",
"native_preload": "Preparing GPU libraries…",
"ml_imports": "Loading ML runtime (PyTorch)…",
"api_routes": "Loading API routes…",
"db_migrate": "Preparing database…",
"services_start": "Starting background services…",
}
_lock = threading.Lock()
_t0 = time.monotonic()
_current: "str | None" = None
_done: "list[tuple[str, float]]" = [] # (step_id, seconds it took)
_started_at: float = 0.0
_ready = False
_error: "dict | None" = None
def begin_step(step_id: str) -> None:
global _current, _started_at
with _lock:
_finish_current_locked()
_current = step_id
_started_at = time.monotonic()
def _finish_current_locked() -> None:
global _current
if _current is not None:
_done.append((_current, round(time.monotonic() - _started_at, 2)))
_current = None
def mark_ready() -> None:
global _ready
with _lock:
_finish_current_locked()
_ready = True
def fail(message: str) -> None:
"""Record a startup failure against the step that was running."""
global _error
with _lock:
_error = {"step": _current, "message": str(message)[:500]}
def is_ready() -> bool:
with _lock:
return _ready
def current_step() -> "tuple[str | None, str | None]":
"""(step_id, human label) of the active step, or (None, None)."""
with _lock:
if _current is None:
return None, None
return _current, STEPS.get(_current, _current)
def snapshot() -> dict:
"""The `/startup/progress` body. Always safe to call, never raises."""
with _lock:
if _error is not None:
status = "failed"
elif _ready:
status = "ready"
else:
status = "starting"
states = {sid: "pending" for sid in STEPS}
for sid, _t in _done:
states[sid] = "done"
if _current is not None:
states[_current] = "active"
if _error is not None and _error.get("step"):
states[_error["step"]] = "failed"
durations = dict(_done)
return {
"status": status,
"step": _current,
"label": STEPS.get(_current, _current) if _current else None,
"steps": [
{
"id": sid,
"label": label,
"state": states.get(sid, "pending"),
**({"t": durations[sid]} if sid in durations else {}),
}
for sid, label in STEPS.items()
],
"elapsed_s": round(time.monotonic() - _t0, 2),
"error": _error,
}
def _reset_for_tests() -> None:
global _current, _ready, _error, _started_at
with _lock:
_current = None
_done.clear()
_ready = False
_error = None
_started_at = 0.0
+1 -1
View File
@@ -144,6 +144,6 @@ def _drop_invalid_path_keys() -> None:
logger.warning(
"%s from the saved env file points at an unusable path (%s) — "
"ignoring it for this run and falling back to the default "
"location. Fix or clear it in Model Catalogue → Models.", key, val,
"location. Fix or clear it in Settings → Models.", key, val,
)
os.environ.pop(key, None)
+1 -1
View File
@@ -24,7 +24,7 @@ from pathlib import Path
# tests/test_app_version.py::test_all_version_files_in_lockstep and bumped by
# release.yml's version-bump job, so it stays equal to
# pyproject/tauri.conf/Cargo/package.json.
_FALLBACK_VERSION = "0.5.2"
_FALLBACK_VERSION = "0.5.0"
def _fallback_version() -> str:
+3 -19
View File
@@ -85,27 +85,11 @@ def _get_model():
global _model
if _model is None:
from faster_whisper import WhisperModel
# Same weights as in-process faster-whisper: ASR_MODEL_FASTER selects
# for BOTH variants, ASR_MODEL_FW stays as a sidecar-only override.
# Before this, the sidecar read only ASR_MODEL_FW while the download
# preflight read ASR_MODEL_FASTER — set one and the other variant (or
# the preflight) quietly used a different model.
name = (
os.environ.get("ASR_MODEL_FW")
or os.environ.get("ASR_MODEL_FASTER")
or "large-v3"
)
name = os.environ.get("ASR_MODEL_FW", "large-v3")
try:
# The probe honors the user compute-device override and the
# ROCm/CT2 incompatibility (#1529) — the child must agree with
# the parent's device decision, not re-derive its own.
from core.device_caps import detect_host_caps
device = "cuda" if detect_host_caps().family == "cuda" else "cpu"
import torch
device = "cuda" if torch.cuda.is_available() else "cpu"
except Exception:
# Fail SAFE: guessing "cuda" from torch here would bypass a cpu
# override and hand CTranslate2 HIP-flavoured cuda on ROCm
# (#1529). CPU always works; say why in the sidecar log.
print("asr-sidecar: device probe failed — using cpu", file=sys.stderr, flush=True)
device = "cpu"
# Degrade fp16 → int8 rather than crash on GPUs without efficient fp16
# (older Maxwell/Pascal, GTX 16xx, CTranslate2/cuDNN mismatch) (#551).
+14 -97
View File
@@ -1,7 +1,7 @@
"""IndexTTS 2.5/2 sidecar package (Phase 2 Plan 02-03).
"""IndexTTS-2 sidecar package (Phase 2 Plan 02-03).
IndexTTS-2 runs in its own subprocess + dedicated venv with
``transformers<5``, isolated from the VoiceStudio parent process which
``transformers<5``, isolated from the OmniVoice parent process which
pins ``transformers>=5.3``. Closes issue #42 — the canonical
``OffloadedCache`` ImportError driven by the transformers v4 v5
incompatibility by making the two libraries live in separate OS
@@ -28,9 +28,6 @@ packages. The parent only ever spawns it as a subprocess.
from __future__ import annotations
import logging
import math
import os
import re
from typing import TYPE_CHECKING
from services.subprocess_backend import SubprocessBackend
@@ -41,56 +38,11 @@ if TYPE_CHECKING:
logger = logging.getLogger("omnivoice.indextts")
_LANGUAGE_ALIASES = {
"zh": "zh",
"chinese": "zh",
"mandarin": "zh",
"en": "en",
"english": "en",
"ja": "ja",
"jp": "ja",
"japanese": "ja",
"es": "es",
"spanish": "es",
"español": "es",
"ar": "ar",
"arabic": "ar",
}
def _normalize_indextts25_language(value, text: str) -> str:
"""Map VoiceStudio locale labels to IndexTTS 2.5's required token."""
raw = str(value or "").strip().lower().replace("_", "-")
base = raw.split("-", 1)[0]
resolved = _LANGUAGE_ALIASES.get(raw) or _LANGUAGE_ALIASES.get(base)
if resolved:
return resolved
# Auto/empty requests still need an explicit 2.5 language. Script
# detection is deterministic and local; ambiguous Latin text defaults EN.
if re.search(r"[\u0600-\u06ff\u0750-\u077f]", text):
return "ar"
if re.search(r"[\u3040-\u30ff]", text):
return "ja"
if re.search(r"[\u3400-\u9fff]", text):
return "zh"
return "en"
def _duration_factor(text: str, language: str, duration: float) -> float:
"""Map VoiceStudio's absolute duration to IndexTTS 2.5's relative scale."""
from services.speech_rate import expected_duration
natural_s = expected_duration(text, language)
if natural_s <= 0:
return 1.0
return max(0.5, min(float(duration) / natural_s, 2.0))
class IndexTTS2Backend(SubprocessBackend):
"""IndexTTS 2.5 (Bilibili) — isolated subprocess with IndexTTS-2 fallback.
"""IndexTTS2 (Bilibili) — runs in its own subprocess + dedicated venv.
Plan 02-03 migrated IndexTTS off the in-process import path because
IndexTTS pins ``transformers<5`` while VoiceStudio pins
IndexTTS pins ``transformers<5`` while OmniVoice pins
``transformers>=5.3``. The two cannot share a Python interpreter
without one of them blowing up at import time (issue #42 — the
canonical ``OffloadedCache`` ImportError). Running IndexTTS in a
@@ -109,22 +61,22 @@ class IndexTTS2Backend(SubprocessBackend):
Installation (transparent to existing v0.2.7 users ENGINE-07)::
git clone --branch indextts-2.5 https://github.com/index-tts/index-tts.git
git clone https://github.com/index-tts/index-tts.git
cd index-tts && uv pip install -e . # NOT uv sync --all-extras
hf download IndexTeam/IndexTTS-2.5 --local-dir=checkpoints
hf download IndexTeam/IndexTTS-2 --local-dir=checkpoints
Set ``OMNIVOICE_INDEXTTS_DIR`` to the repo root. VoiceStudio will
Set ``OMNIVOICE_INDEXTTS_DIR`` to the repo root. OmniVoice will
create ``backend/engines/indextts/.venv`` lazily on first launch if
no venv exists yet the user's existing
``${OMNIVOICE_INDEXTTS_DIR}/.venv`` is preferred if present, so no
re-install is needed.
License: bilibili Model Use License. A separate license is required
above the upstream 100M-MAU or RMB 1B annual-revenue thresholds.
License: Custom (Bilibili) free for research/non-commercial.
Commercial use requires contacting indexspeech@bilibili.com.
"""
id = "indextts2"
display_name = "IndexTTS 2.5 (multilingual emotion-controlled cloning)"
display_name = "IndexTTS2 (emotion control, duration control, zero-shot)"
supports_voice_design = False # requires ref audio for timbre
supports_emotion = True # graded emo_vector / emo_text / emo_alpha (#1208)
_DEFAULT_SAMPLE_RATE = 24000
@@ -148,7 +100,7 @@ class IndexTTS2Backend(SubprocessBackend):
)
if not is_indextts_installed():
return False, (
"IndexTTS 2.5 venv not found. Set OMNIVOICE_INDEXTTS_DIR to "
"IndexTTS-2 venv not found. Set OMNIVOICE_INDEXTTS_DIR to "
"your IndexTTS clone (the directory containing checkpoints/) "
"and restart VoiceStudio. See docs/engines/indextts.md for the "
"full install walk-through."
@@ -165,23 +117,6 @@ class IndexTTS2Backend(SubprocessBackend):
from engines.indextts.bootstrap import resolve_indextts_venv
return resolve_indextts_venv()
@property
def recv_timeout_s(self) -> float:
# IndexTTS was the only sidecar left on the 60s class default while
# pockettts and omnivoice-subprocess both raised theirs. infer() is one
# blocking upstream call, so a long passage legitimately outruns 60s and
# the parent's watchdog killed a healthy synthesis (#1611). main.py also
# heartbeats during infer(), which is what actually proves liveness —
# this deadline is the ceiling for a sidecar that has gone genuinely
# silent. OMNIVOICE_INDEXTTS_RECV_TIMEOUT_S tunes it.
try:
v = float(os.environ.get("OMNIVOICE_INDEXTTS_RECV_TIMEOUT_S", "900"))
except (ValueError, TypeError):
return 900.0
if not math.isfinite(v): # reject inf/nan so the deadline can't be disabled
return 900.0
return max(30.0, v)
@classmethod
def sidecar_script(cls):
from engines.indextts.bootstrap import INDEXTTS_SIDECAR_SCRIPT
@@ -196,13 +131,8 @@ class IndexTTS2Backend(SubprocessBackend):
@property
def supported_languages(self) -> list[str]:
configured = os.environ.get("OMNIVOICE_INDEXTTS_DIR")
if configured and not os.path.isfile(
os.path.join(configured, "indextts", "infer_v2_5.py")
):
# Preserve truthful metadata for user-managed IndexTTS-2 checkouts.
return ["zh", "en"]
return ["zh", "en", "ja", "es", "ar"]
# Primarily Chinese + English with multilingual prompt handling.
return ["zh", "en"]
# ── parent-side emotion / duration arbitration ─────────────────────
#
@@ -241,8 +171,7 @@ class IndexTTS2Backend(SubprocessBackend):
if description and not emo_text and not emo_vector and not emo_audio:
emo_text = description
language = _normalize_indextts25_language(kw.get("language"), text)
forwarded: dict = {"ref_audio": ref_audio, "lang": language}
forwarded: dict = {"ref_audio": ref_audio}
# Duration control — codec frame rate ≈ 21 Hz.
duration = kw.get("duration")
@@ -250,18 +179,6 @@ class IndexTTS2Backend(SubprocessBackend):
target_tokens = int(float(duration) * 21)
if target_tokens > 0:
forwarded["target_tokens"] = target_tokens
duration_factor = kw.get("duration_factor")
if duration_factor is not None:
forwarded["duration_factor"] = max(0.5, min(float(duration_factor), 2.0))
elif duration is not None:
# IndexTTS 2.5 replaced absolute semantic-token control with a
# relative duration factor. Use the same language-aware natural
# reading estimate as the dubbing fit planner so the public
# ``duration`` control remains effective on 2.5; the sidecar
# drops this factor when it detects a legacy IndexTTS-2 checkout.
forwarded["duration_factor"] = _duration_factor(
text, language, float(duration)
)
if (
emo_vector

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