Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60a4743a63 | ||
|
|
d5b9f17c1a | ||
|
|
cb581f38df | ||
|
|
efa750bc88 | ||
|
|
d04611cbc5 | ||
|
|
ce1f7c3b83 | ||
|
|
ca14764083 | ||
|
|
dffb02e953 |
@@ -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
|
||||
@@ -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
|
||||
@@ -40,7 +40,6 @@ 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
|
||||
```
|
||||
|
||||
|
||||
+1
-1
@@ -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 |
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -271,17 +271,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
|
||||
@@ -376,19 +365,6 @@ jobs:
|
||||
echo "choco attempt $i did not produce ffmpeg — retrying in $((i * 30))s"
|
||||
sleep $((i * 30))
|
||||
done
|
||||
# Chocolatey is one distribution channel, not the dependency. When
|
||||
# its feed is down across every retry (2026-08-13: three attempts,
|
||||
# three 'installed 0/1'), fall back to the static gyan.dev release
|
||||
# build GitHub mirror — the same binary, no feed in the path.
|
||||
if ! command -v ffmpeg >/dev/null 2>&1; then
|
||||
echo "::warning::choco feed down — falling back to static ffmpeg build"
|
||||
curl -fsSL --retry 3 -o /tmp/ffmpeg.zip \
|
||||
https://github.com/GyanD/codexffmpeg/releases/download/7.1/ffmpeg-7.1-essentials_build.zip
|
||||
unzip -q /tmp/ffmpeg.zip -d /tmp/ffmpeg
|
||||
bindir=$(dirname "$(find /tmp/ffmpeg -name ffmpeg.exe | head -1)")
|
||||
echo "$bindir" >> "$GITHUB_PATH"
|
||||
export PATH="$bindir:$PATH"
|
||||
fi
|
||||
ffmpeg -version
|
||||
|
||||
- name: System deps (Linux)
|
||||
|
||||
@@ -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
|
||||
@@ -731,62 +731,6 @@ jobs:
|
||||
find "$INSTALL" -type f -path '*backend*main.py' | grep -q . || fail "backend source main.py missing"
|
||||
echo "OK — MSI installed shell + uv + backend resources"
|
||||
|
||||
# linuxdeploy re-links .DirIcon as an ABSOLUTE symlink into the build
|
||||
# machine AFTER tauri's files-map has placed the real icon bytes — the
|
||||
# exact bug #1518 guarded against, resurfacing on the first real tag
|
||||
# build (v0.5.0). The seam tauri-action leaves us is post-upload: repack
|
||||
# the AppImage with the icon as a REGULAR FILE, re-sign it (the updater
|
||||
# signature covered the old bytes), and clobber the draft release's
|
||||
# asset + the linux signature inside latest.json. The smoke below then
|
||||
# validates the repaired artifact, not the broken one.
|
||||
- name: Repair AppImage .DirIcon, re-sign, re-upload
|
||||
if: runner.os == 'Linux'
|
||||
timeout-minutes: 10
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
# Data, not shell source (zizmor template-injection): a crafted ref
|
||||
# must never expand inside a script that holds the signing key.
|
||||
TAG: ${{ (needs.preview-gate.outputs.is_preview == 'true') && 'preview' || github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
APPIMAGE=$(find frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle/appimage -name "*.AppImage" | head -1)
|
||||
APPIMAGE=$(realpath "$APPIMAGE")
|
||||
WORK="$(mktemp -d)"; cd "$WORK"
|
||||
"$APPIMAGE" --appimage-extract >/dev/null
|
||||
ROOT="$WORK/squashfs-root"
|
||||
ICON=$(readlink -f "$ROOT/.DirIcon" 2>/dev/null || true)
|
||||
if [ -n "$ICON" ] && [ -f "$ICON" ] && case "$ICON" in "$ROOT"/*) true;; *) false;; esac; then
|
||||
echo ".DirIcon already resolves inside the bundle — no repair needed"
|
||||
exit 0
|
||||
fi
|
||||
# The real bytes are at the AppDir root (linuxdeploy put them there
|
||||
# before mislinking). Ship a regular file: nothing left to dangle.
|
||||
SRC=$(find "$ROOT" -maxdepth 1 -name "*.png" | head -1)
|
||||
[ -n "$SRC" ] || SRC=$(find "$ROOT/usr/share/icons" -name "*.png" | head -1)
|
||||
[ -n "$SRC" ] || { echo "no icon bytes found in bundle"; exit 1; }
|
||||
rm -f "$ROOT/.DirIcon"
|
||||
cp "$SRC" "$ROOT/.DirIcon"
|
||||
# Pinned immutable release + checksum: this binary runs with the
|
||||
# updater signing key and a release-write token in its environment,
|
||||
# so a mutable 'continuous' asset is not acceptable supply chain.
|
||||
AIT_URL="https://github.com/AppImage/appimagetool/releases/download/1.9.1/appimagetool-x86_64.AppImage"
|
||||
AIT_SHA256="ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0"
|
||||
curl -fsSL --retry 3 -o "$WORK/appimagetool" "$AIT_URL"
|
||||
echo "$AIT_SHA256 $WORK/appimagetool" | sha256sum -c - || { echo "appimagetool checksum mismatch"; exit 1; }
|
||||
chmod +x "$WORK/appimagetool"
|
||||
# Same FUSE-less trick the build itself uses.
|
||||
APPIMAGE_EXTRACT_AND_RUN=1 ARCH=x86_64 "$WORK/appimagetool" --no-appstream "$ROOT" "$APPIMAGE"
|
||||
cd "$GITHUB_WORKSPACE/frontend"
|
||||
bunx tauri signer sign "$APPIMAGE"
|
||||
gh release upload "$TAG" "$APPIMAGE" "$APPIMAGE.sig" --clobber --repo "$GITHUB_REPOSITORY"
|
||||
# latest.json is NOT patched here: every tauri-action leg re-uploads
|
||||
# the shared manifest, so an in-leg patch races the other platforms —
|
||||
# the repair-updater-manifest job below is the single final writer.
|
||||
echo "repacked, re-signed, re-uploaded"
|
||||
|
||||
- name: Installer smoke (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
timeout-minutes: 5
|
||||
@@ -900,50 +844,6 @@ jobs:
|
||||
# the tag (v0.3.20 shipped with only the Linux AppImage that way). `needs:
|
||||
# [build]` guarantees the release already exists; `--clobber` makes a re-run
|
||||
# idempotent. This can never create a second release.
|
||||
# The Linux leg may repack + re-sign its AppImage (see the repair step in
|
||||
# the build matrix); every tauri-action leg also re-uploads the SHARED
|
||||
# latest.json, so patching the manifest inside any leg races the others.
|
||||
# This job runs once after the whole matrix as the single final writer:
|
||||
# it makes the manifest's linux signature agree with the .sig asset that
|
||||
# actually shipped, and refuses to leave a mismatch behind.
|
||||
repair-updater-manifest:
|
||||
needs: [build, preview-gate]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Data, not shell source — same zizmor rule as the leg step.
|
||||
TAG: ${{ (needs.preview-gate.outputs.is_preview == 'true') && 'preview' || github.ref_name }}
|
||||
steps:
|
||||
- name: Align latest.json's linux signature with the shipped .sig asset
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
WORK="$(mktemp -d)"
|
||||
HAS_MANIFEST=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '[.assets[].name]|contains(["latest.json"])')
|
||||
if [ "$HAS_MANIFEST" != "true" ]; then
|
||||
echo "no latest.json on the release — nothing to align"; exit 0
|
||||
fi
|
||||
gh release download "$TAG" --pattern latest.json --output "$WORK/latest.json" --repo "$GITHUB_REPOSITORY"
|
||||
# Same fail-closed rule as the manifest: absence is checked against
|
||||
# the asset LIST; an actual download failure must fail the job, or
|
||||
# the manifest keeps a signature nobody shipped.
|
||||
HAS_SIG=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '[.assets[].name|select(endswith(".AppImage.sig"))]|length > 0')
|
||||
if [ "$HAS_SIG" != "true" ]; then
|
||||
echo "no AppImage .sig asset on the release — nothing to align"; exit 0
|
||||
fi
|
||||
gh release download "$TAG" --pattern "*.AppImage.sig" --dir "$WORK" --repo "$GITHUB_REPOSITORY"
|
||||
SIG_FILE=$(find "$WORK" -name "*.AppImage.sig" | head -1)
|
||||
[ -n "$SIG_FILE" ] || { echo "sig asset listed but download produced nothing"; exit 1; }
|
||||
NEW_SIG=$(cat "$SIG_FILE")
|
||||
CHANGED=$(python3 -c 'import json,sys; p,sig=sys.argv[1],sys.argv[2]; d=json.load(open(p)); n=sum(1 for k,v in d.get("platforms",{}).items() if k.startswith("linux") and v.get("signature")!=sig and not v.update({"signature":sig})); json.dump(d,open(p,"w"),indent=2); print(n)' "$WORK/latest.json" "$NEW_SIG")
|
||||
if [ "$CHANGED" -ge 1 ]; then
|
||||
gh release upload "$TAG" "$WORK/latest.json" --clobber --repo "$GITHUB_REPOSITORY"
|
||||
echo "aligned $CHANGED linux signature(s) with the shipped .sig"
|
||||
else
|
||||
echo "manifest already agrees with the shipped .sig — no write"
|
||||
fi
|
||||
|
||||
uninstall-scripts:
|
||||
needs: [build]
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
|
||||
|
||||
@@ -159,13 +159,6 @@ tests/probe/reports/
|
||||
# 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.
|
||||
|
||||
@@ -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$''',
|
||||
]
|
||||
|
||||
@@ -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`.
|
||||
|
||||
+42
-155
@@ -10,154 +10,54 @@ the frozen-backend fallback mirror it for their toolchains.
|
||||
|
||||
**Highlights**
|
||||
|
||||
### Changed
|
||||
|
||||
### Added
|
||||
|
||||
### Docs
|
||||
- Docker/server mode now requires an API key for remote changes and side-effectful admin checks across workers, engines, media tools, MCP, pronunciation, diagnostics, and LLM providers. (#1525) — thanks @bultodepapas!
|
||||
- The unified Support page no longer throws while opening a section in browsers or test environments without `scrollIntoView`. (#1525) — thanks @bultodepapas!
|
||||
- A faster, cleaner Dub workspace for multilingual production (#1489)
|
||||
- VoiceStudio now gives the app, desktop chrome, documentation, and package metadata one clear identity
|
||||
- A local-first creative studio: voice cloning, design, dubbing, dictation, stories, audiobooks, and transcription without a subscription meter
|
||||
- Reliability first: automatic cache repair, truthful hardware routing, safer sidecars, and actionable recovery instead of mystery failures
|
||||
- Security boundaries now match the product: native file access stays native, untrusted network destinations fail closed, and public errors keep private diagnostics local
|
||||
- RTX 40-series GPUs are used again instead of being sent to the CPU
|
||||
- A warning before a slow generation, rather than after a five-minute wait
|
||||
- The watermark can be turned off in Settings, as the docs always said
|
||||
- Your other GPU can take the work now — send individual jobs to a second machine, opt-in
|
||||
- More than one person can share one GPU machine, without shell access to it or taking turns
|
||||
- A Model Catalogue workspace: every engine and model in one place, with the defaults set there
|
||||
- Workspace tabs in the title bar, if you prefer them to the icon rail (#1412)
|
||||
- macOS support now matches what the app actually delivers
|
||||
- Linux AppImage: a blank white window on rolling distros (Mesa 26.1+) now starts normally
|
||||
- Apple Silicon: transcription no longer needs a system ffmpeg, as the docs always said — thanks @gambletan! (#1436)
|
||||
- A failed audiobook chapter says why, instead of turning red and saying nothing
|
||||
|
||||
### Fixed
|
||||
|
||||
## [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)
|
||||
- The guard that keeps transcription on the degrading ASR loader now scans the whole backend, not just the routers — a service that transcribes on a request's behalf skipped `ensure_loaded()` just as thoroughly. (#1519) — thanks @ahov520!
|
||||
- The Linux app icon is no longer blank. Every AppImage since v0.4.2 shipped `.DirIcon` as an absolute symlink into the machine that built it (`/home/runner/work/…`), so the link dangled on every user's computer and file managers, app menus and desktop integration all drew nothing. The release build now verifies the icon resolves inside the bundle before publishing. (#1518)
|
||||
- The Linux desktop entry no longer ships an empty `Categories=`, which `desktop-file-validate` rejects and menu builders skip. (#1518)
|
||||
|
||||
### Added
|
||||
- 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)
|
||||
- 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)
|
||||
|
||||
### 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)
|
||||
### Added
|
||||
|
||||
- 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" /> |
|
||||
- A machine can now join a control plane from the app: Settings → System → Remote workers → **Lend this machine's GPU**, paste the join code, done — no environment variables and no restart. The address travels with the code, so the machine reconnects on its own afterwards. (#1516)
|
||||
- Join codes and connection strings are shown as a **QR code** alongside the text, with a live expiry countdown — scan it from the other machine instead of retyping forty characters. (#1516)
|
||||
- A **Compute** control in the status bar: pick local or a remote machine, turn remote workers on or off, and mint a join code without opening Settings. It appears only once you have opted in or enrolled a machine. (#1516)
|
||||
- A worker waiting for approval can be approved from its row. The panel labelled that state before but offered no way out of it. (#1516)
|
||||
- The demo audio the app has always advertised now actually ships: previews for all seven voice-design presets, the three dictation replay clips, and the dubbing demo's source video plus four dubbed languages with subtitles. Every one of those was a dead link before — the tooling that renders them required macOS, so on Windows and Linux the files were never built. (#1517)
|
||||
- Demo assets are rendered by VoiceStudio's own engine, so the tooling runs wherever the app does, and the demos are made by the thing they demonstrate. (#1517)
|
||||
|
||||
### Changed
|
||||
|
||||
- Gallery personas now preview through the local backend, retain their complete voice-design recipe, and open directly in Voice, Stories, or Audiobook. (#1542)
|
||||
- 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.
|
||||
- Remote workers reads as a device list: status dot, address, latency, a live task meter, resident models and last-seen per machine, with housekeeping actions revealed on hover and a three-step empty state. (#1516)
|
||||
- Engines and models moved out of Settings into a new Model Catalogue workspace, reachable from the icon rail (or the title-bar tabs); Settings → Engines and Settings → Models now point there, and Settings keeps the models directory and Hugging Face mirror.
|
||||
- The Settings sidebar is keyboard-navigable: ⌘K / Ctrl+K jumps to the filter, ↑/↓ and Home/End move between categories, and Enter or ↓ from the filter drops into the list. Matching text in a filtered category name is highlighted, and group headers stay pinned while the list scrolls.
|
||||
- The Launchpad has a quieter, more spacious look: borderless feature tiles that light up on hover or keyboard focus, plain-numeral counts, hairline section rules, and one shared page column for the hero, tiles, recent files and project lists.
|
||||
@@ -177,13 +77,6 @@ the frozen-backend fallback mirror it for their toolchains.
|
||||
|
||||
### Added
|
||||
|
||||
- Gallery personas preview through the local backend, keep their full voice-design recipe, and open directly in Voice, Stories, or Audiobook — and can be saved as local profiles with validated audio references. (#1542)
|
||||
- The demo audio the app has always advertised now actually ships: previews for all seven voice-design presets, the three dictation replay clips, and the dubbing demo's source video plus four dubbed languages with subtitles. Every one of those was a dead link before — the tooling that renders them required macOS, so on Windows and Linux the files were never built. (#1517)
|
||||
- Demo assets are rendered by VoiceStudio's own engine, so the tooling runs wherever the app does, and the demos are made by the thing they demonstrate. (#1517)
|
||||
- A machine can now join a control plane from the app: Settings → System → Remote workers → **Lend this machine's GPU**, paste the join code, done — no environment variables and no restart. The address travels with the code, so the machine reconnects on its own afterwards. (#1516)
|
||||
- Join codes and connection strings are shown as a **QR code** alongside the text, with a live expiry countdown — scan it from the other machine instead of retyping forty characters. (#1516)
|
||||
- A **Compute** control in the status bar: pick local or a remote machine, turn remote workers on or off, and mint a join code without opening Settings. It appears only once you have opted in or enrolled a machine. (#1516)
|
||||
- A worker waiting for approval can be approved from its row. The panel labelled that state before but offered no way out of it. (#1516)
|
||||
- **Model Catalogue** — a workspace of its own for engines and models: browse every TTS, transcription and LLM engine with its device routing and install state, pick the default for each, and install or remove model weights, all from one screen instead of two Settings categories.
|
||||
- Remote GPU machines can now accept connections instead of dialling out, so several people can use the same box at once — each gets their own revocable connection string, with certificate-pinned TLS, a live list of who is connected, and a disconnect button. (#1496)
|
||||
- Remote GPU model downloads now use the normal Models install flow and show per-worker progress. (#1478)
|
||||
@@ -197,22 +90,14 @@ the frozen-backend fallback mirror it for their toolchains.
|
||||
- Settings → Privacy now has an **Invisible watermark** toggle. On by default, available to everyone, and it only affects audio generated after the change. (#1308)
|
||||
- A new opt-in crash-isolated TTS engine, so a native crash takes down the sidecar instead of the whole backend — thanks @paoloantinori! (#1292, #1298, #1304)
|
||||
- **PocketTTS** (Kyutai), an opt-in CPU-only engine for fast, low-latency renders in six languages (en/fr/de/pt/it/es) with zero-shot cloning from a reference clip. Enable in Settings → Engines — thanks @paoloantinori! (#1306, #1328)
|
||||
- A warning before a slow generation, rather than after a five-minute wait. (#1280)
|
||||
|
||||
### Docs
|
||||
### CI
|
||||
|
||||
- Engine acceptance: new `docs/engine-acceptance.md` documents the job map, the bar a new engine must clear, and the out-of-tree path (#1306)
|
||||
- macOS install notes and the README support table now state the real floor (#1268)
|
||||
- Contact: the project X account is listed alongside Discord (#1313)
|
||||
- `OMNIVOICE_ALLOWED_ORIGINS` is finally documented: a browser loading the UI from another machine's origin needs the backend's CORS allow-list, which neither server mode nor trusted networks touches — thanks @vanderlpp! (#1348)
|
||||
- The stdio wire protocol every engine sidecar speaks is now tested once across all nine of them, instead of against a single engine — a bug in any one sidecar's copy gets caught — thanks @paoloantinori! (#1408)
|
||||
|
||||
### Fixed
|
||||
|
||||
- AMD/ROCm hosts no longer crash ASR with "CUDA driver version is insufficient": ROCm torch reports itself as CUDA, but whisperx/faster-whisper run on CTranslate2, which is NVIDIA-only — they now take the CPU path there, and auto-detect prefers pytorch-whisper, which genuinely uses the HIP GPU. (#1529)
|
||||
- Crash reports now carry the crashed run's own stderr: the shared error log is append-only with per-run offsets, so a restart can no longer overwrite the dying process's final output with the replacement's healthy startup. (#1510)
|
||||
- Wayland: a stale portal identity no longer kills the dictation shortcut for the whole session. The desktop entry the app writes for the GlobalShortcuts portal could point at a binary that has since moved (a `cargo clean`, a relocated AppImage) — GNOME then refuses the bind with "App info not found" and the hotkey silently dies. The entry is validated and rewritten at startup now. (#1526)
|
||||
- The guard that keeps transcription on the degrading ASR loader now scans the whole backend, not just the routers — a service that transcribes on a request's behalf skipped `ensure_loaded()` just as thoroughly. (#1519) — thanks @ahov520!
|
||||
- The Linux app icon is no longer blank. Every AppImage since v0.4.2 shipped `.DirIcon` as an absolute symlink into the machine that built it (`/home/runner/work/…`), so the link dangled on every user's computer and file managers, app menus and desktop integration all drew nothing. The release build now verifies the icon resolves inside the bundle before publishing. (#1518)
|
||||
- The Linux app icon is no longer blank: the AppImage shipped `.DirIcon` as a symlink into the machine that built it, so file managers and app menus drew nothing. (#1518)
|
||||
- The Linux desktop entry no longer ships an empty `Categories=`, which `desktop-file-validate` rejects and menu builders skip. (#1518)
|
||||
- Wayland: the dictation shortcut now actually starts dictation. The desktop portal registered the key correctly — GNOME and KDE even showed it back — but every press was discarded while decoding the compositor's signal, so the hotkey did nothing on any Wayland session. (#1490)
|
||||
- The first-run "Choose a comfortable UI size" screen no longer stutters while you sit there. Applying a scale resizes the window's own viewport, which the screen was reading back to re-pick a size — so it flipped between two sizes forever without anyone touching it. (#1514)
|
||||
@@ -326,17 +211,19 @@ the frozen-backend fallback mirror it for their toolchains.
|
||||
- Translation through LM Studio works. The built-in model name was the placeholder `local-model`, which LM Studio rejects because it serves whatever you have loaded — VoiceStudio now asks it, and a 404 from a local server names the models that ARE loaded instead of telling you to check a URL that was fine — thanks @biga73! (#1332)
|
||||
- Generation that silently dropped the end of the input now says so. When an engine returns no audio for part of the text the result sounds clean and is simply short, so the only way to notice was to read along; the backend log now names the sentences that produced nothing. (#1330)
|
||||
- Dubbing: a re-rendered line that quietly came back in a default voice instead of the cloned one now says why in the backend log — the clone clips are extracted per job and a saved dub outlives them, so regenerating after cleanup loses the reference with no error. (#1331)
|
||||
- RTX 40-series GPUs are used again instead of being sent to the CPU. (#1289)
|
||||
- Apple Silicon: transcription no longer needs a system ffmpeg, as the docs always said — thanks @gambletan! (#1436)
|
||||
- A failed audiobook chapter says why, instead of turning red and saying nothing. (#1325)
|
||||
|
||||
### Docs
|
||||
|
||||
- Engine acceptance: new `docs/engine-acceptance.md` documents the job map, the bar a new engine must clear, and the out-of-tree path (#1306)
|
||||
- macOS install notes and the README support table now state the real floor (#1268)
|
||||
- Contact: the project X account is listed alongside Discord (#1313)
|
||||
- `OMNIVOICE_ALLOWED_ORIGINS` is finally documented: a browser loading the UI from another machine's origin needs the backend's CORS allow-list, which neither server mode nor trusted networks touches — thanks @vanderlpp! (#1348)
|
||||
|
||||
### CI
|
||||
|
||||
- Windows CI falls back to a static ffmpeg build when the Chocolatey feed is down, instead of failing the run. (#1542)
|
||||
- The stdio wire protocol every engine sidecar speaks is now tested once across all nine of them, instead of against a single engine — a bug in any one sidecar's copy gets caught — thanks @paoloantinori! (#1408)
|
||||
- Windows smoke tests stopped silently passing a broken ffmpeg install, and every smoke leg is now budgeted for a cold dependency install. (#1290)
|
||||
- Test suites no longer leak config paths or model-manager shutdown state into one another, which had been failing unrelated pull requests. (#1269)
|
||||
- The nightly preview build stopped refusing to publish its own healthy updater manifest when the macOS legs finished a few minutes ahead of the slowest one — Preview-channel users were silently left without new builds.
|
||||
- The nightly preview build stopped refusing to publish its own healthy updater manifest when the macOS legs finished a few minutes ahead of the slowest one — Preview-channel users were silently left without new builds.
|
||||
|
||||
## [0.4.2] — 2026-07-28
|
||||
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -1,392 +1,656 @@
|
||||
<div align="center">
|
||||
<img src="docs/logo.png" alt="VoiceStudio logo" width="120" height="120" />
|
||||
<img src="docs/logo.png" alt="VoiceStudio Logo" width="120" height="120" />
|
||||
<h1>VoiceStudio</h1>
|
||||
<p><sub>Previously OmniVoice-Studio</sub></p>
|
||||
<h3>Local voice cloning, dubbing, dictation, and long-form audio.</h3>
|
||||
<p>16 TTS engines · 11 ASR engines · 646-language catalogue · macOS, Windows, and Linux</p>
|
||||
<p><strong>Local-first.</strong> No account, API key, subscription, or usage meter for the core workflow.</p>
|
||||
<p><sub><em>previously OmniVoice-Studio</em></sub></p>
|
||||
<h3>Make voices. Tell stories. Keep the files. ♡</h3>
|
||||
<p>Clone, design, dub, dictate, and build audiobooks in one open-source desktop studio.<br/><b>Local-first by default.</b> No subscription or usage meter. Optional online services stay opt-in.</p>
|
||||
|
||||
<p>
|
||||
<a href="#install">Install</a> ·
|
||||
<a href="#quickstart">Quickstart</a> ·
|
||||
<a href="#features">Features</a> ·
|
||||
<a href="#comparison">Compare</a> ·
|
||||
<a href="#requirements">Requirements</a> ·
|
||||
<a href="#engines">Engines</a> ·
|
||||
<a href="#architecture">Architecture</a> ·
|
||||
<a href="#api">API</a> ·
|
||||
<a href="#documentation">Docs</a> ·
|
||||
<a href="#why-voicestudio">Why VoiceStudio</a> ·
|
||||
<a href="#tts-engines">Engines</a> ·
|
||||
<a href="#openai-api">API</a> ·
|
||||
<a href="#sponsor--donate">Donate</a> ·
|
||||
<a href="#contributing">Contributing</a> ·
|
||||
<a href="https://voicestudio.sh">Website</a> ·
|
||||
<a href="https://voicestudio.sh/docs">Docs</a> ·
|
||||
<a href="https://status.voicestudio.sh">Status</a> ·
|
||||
<a href="https://discord.gg/bzQavDfVV9">Discord</a> ·
|
||||
<a href="https://x.com/idebpalash">X</a> ·
|
||||
<a href="README_CN.md"><strong>简体中文</strong></a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a href="https://github.com/debpalash/VoiceStudio/stargazers"><img src="https://img.shields.io/github/stars/debpalash/VoiceStudio?style=flat-square&color=f59e0b" alt="GitHub stars" /></a>
|
||||
<a href="https://github.com/debpalash/VoiceStudio/stargazers"><img src="https://img.shields.io/github/stars/debpalash/VoiceStudio?style=flat-square&color=f59e0b" alt="Stars" /></a>
|
||||
<a href="https://github.com/debpalash/VoiceStudio/releases"><img src="https://img.shields.io/github/downloads/debpalash/VoiceStudio/total?style=flat-square&color=8b5cf6&label=downloads" alt="Total downloads" /></a>
|
||||
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/github/v/release/debpalash/VoiceStudio?style=flat-square&color=10b981" alt="Latest release" /></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-AGPL--3.0-blue?style=flat-square" alt="AGPL-3.0 license" /></a>
|
||||
<a href="https://discord.gg/bzQavDfVV9"><img src="https://img.shields.io/badge/Discord-Community-5865F2?style=flat-square&logo=discord&logoColor=white" alt="Discord community" /></a>
|
||||
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/github/v/release/debpalash/VoiceStudio?style=flat-square&color=10b981" alt="Release" /></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-AGPL--3.0-blue?style=flat-square" alt="License" /></a>
|
||||
<a href="https://github.com/debpalash/VoiceStudio/issues"><img src="https://img.shields.io/github/issues/debpalash/VoiceStudio?style=flat-square&color=ef4444" alt="Issues" /></a>
|
||||
<a href="https://discord.gg/bzQavDfVV9"><img src="https://img.shields.io/badge/Discord-Join_Community-5865F2?style=flat-square&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
<a href="https://x.com/idebpalash"><img src="https://img.shields.io/badge/X-Follow_for_updates-000000?style=flat-square&logo=x&logoColor=white" alt="Follow on X" /></a>
|
||||
<a href="https://ko-fi.com/debpalash"><img src="https://img.shields.io/badge/Ko--fi-Support_Us-FF5E5B?style=flat-square&logo=ko-fi&logoColor=white" alt="Ko-fi" /></a>
|
||||
<a href="https://paypal.me/palashCoder"><img src="https://img.shields.io/badge/PayPal-Donate-00457C?style=flat-square&logo=paypal&logoColor=white" alt="PayPal" /></a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/badge/Download-macOS_·_Windows_·_Linux-10b981?style=for-the-badge" alt="Download VoiceStudio" /></a>
|
||||
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/badge/⬇_Download-macOS_·_Windows_·_Linux-10b981?style=for-the-badge" alt="Download the latest release" /></a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a href="https://trendshift.io/repositories/28176?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-28176" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/28176/daily?language=Python" alt="debpalash%2FVoiceStudio | Trendshift" width="250" height="55"/></a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
|
||||
<div align="center">
|
||||
<img src="docs/media/0.5.0/quick-switch.gif" alt="Switching TTS engines from the VoiceStudio status bar" width="100%" />
|
||||
<img src="docs/screenshot-launchpad.png" alt="VoiceStudio — Launchpad" width="100%"/>
|
||||
</div>
|
||||
|
||||
> **Your voice is personal. Your studio should feel personal too.** VoiceStudio keeps its core workflow on your hardware: clone, design, dub, dictate, and publish in 646 languages without a subscription or usage meter. Network-backed engines and services are optional, visible choices—not hidden requirements.
|
||||
|
||||
> [!WARNING]
|
||||
> **Active beta.** Use the [latest release](https://github.com/debpalash/VoiceStudio/releases/latest) for stable work or `main` for current fixes. Report problems through [GitHub Issues](https://github.com/debpalash/VoiceStudio/issues).
|
||||
|
||||
## At a glance
|
||||
|
||||
| | VoiceStudio |
|
||||
|---|---|
|
||||
| **Workflows** | Voice cloning and design, video dubbing, dictation, stories, audiobooks, batch generation |
|
||||
| **Language catalogue** | 646 TTS languages; actual coverage and quality depend on the selected engine |
|
||||
| **Engines** | 16 TTS · 11 ASR · switch in Model Catalogue or with <kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>E</kbd> |
|
||||
| **Platforms** | macOS 13.3+ on Apple Silicon · Windows 10/11 x64 · Linux x86_64 with glibc 2.39+ |
|
||||
| **Compute** | CUDA · Apple Silicon MPS/MLX · ROCm on Linux · CPU · optional remote workers |
|
||||
| **Interfaces** | Desktop app · local REST/SSE/WebSocket API · OpenAI-compatible audio API · MCP Server |
|
||||
| **Storage** | Voices, projects, settings, and outputs stay on the machine by default |
|
||||
| **License** | AGPL-3.0; optional engines keep their own model licenses |
|
||||
|
||||
<a id="install"></a>
|
||||
|
||||
## Install
|
||||
|
||||
| Platform | Package | Guide |
|
||||
|---|---|---|
|
||||
| macOS 13.3+ | DMG, Apple Silicon | [Install on macOS](docs/install/macos.md) |
|
||||
| Windows 10/11 | MSI, x64 | [Install on Windows](docs/install/windows.md) |
|
||||
| Linux | AppImage, x86_64 with glibc 2.39+ | [Install on Linux](docs/install/linux.md) |
|
||||
| Docker | CUDA, ROCm, or CPU; worker-only GPU profiles | [Run with Docker](docs/install/docker.md) |
|
||||
|
||||
Download packages from the [latest release](https://github.com/debpalash/VoiceStudio/releases/latest). First launch creates a managed Python environment and downloads the default model. Later launches reuse both.
|
||||
|
||||
> [!NOTE]
|
||||
> On macOS, first launch needs a one-time right-click → **Open** approval. Intel Macs cannot run the local Python backend; use a [remote backend](docs/install/macos.md) instead.
|
||||
|
||||
### First voice
|
||||
|
||||
1. Launch VoiceStudio and open **Voice Cloning**.
|
||||
2. Add a clean voice sample. Three seconds works; 5–15 seconds usually gives a better prompt.
|
||||
3. Enter text, choose a language, then select **Generate**.
|
||||
|
||||
### Run from source
|
||||
|
||||
Install the [development prerequisites](.github/CONTRIBUTING.md#development-setup), then:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/debpalash/VoiceStudio.git
|
||||
cd VoiceStudio
|
||||
bun install
|
||||
bun run desktop
|
||||
```
|
||||
|
||||
Use `bun run dev` for the browser UI. See [Contributing](.github/CONTRIBUTING.md) for services, tests, and platform packages.
|
||||
|
||||
### If setup fails
|
||||
|
||||
- Run **Settings → About → Run self-check** or `uv run python backend/main.py --diagnose --deep`.
|
||||
- Check [install troubleshooting](docs/install/troubleshooting.md).
|
||||
- Save a scrubbed diagnostic bundle from the app when opening an issue.
|
||||
- For slow generation, compare [measured benchmarks](docs/benchmarks.md) and [performance settings](docs/performance.md).
|
||||
> **Active beta.** Things may break between releases — for the newest fixes, run from source. Bug reports and PRs are very welcome: [open an issue](https://github.com/debpalash/VoiceStudio/issues) or [join Discord](https://discord.gg/bzQavDfVV9).
|
||||
|
||||
<a id="features"></a>
|
||||
|
||||
## Features
|
||||
## ✨ Features
|
||||
|
||||
| Area | Included |
|
||||
|---|---|
|
||||
| **Voice Cloning** | Zero-shot synthesis from a short reference clip |
|
||||
| **Voice Design** | Create a voice from age, accent, pitch, style, and delivery instructions |
|
||||
| **Video Dubbing** | Transcribe, translate, preserve speakers, synthesize, and export video |
|
||||
| **Stories and audiobooks** | Multi-voice scripts · EPUB/PDF import · chapter rendering · `.m4b` export |
|
||||
| **[Dictation Widget](docs/features/dictation.md)** | System-wide shortcut, live transcription, optional local-LLM cleanup |
|
||||
| **Vocal Isolation** | Demucs speech/background separation |
|
||||
| **Speaker Diarization** | Pyannote and WhisperX speaker assignment |
|
||||
| **Batch Queue** | Queue large sets of audio and video jobs with per-job progress |
|
||||
| **Model Catalogue** | Install, remove, select, and route TTS, ASR, and LLM models |
|
||||
| **Remote Model Downloads** | Install models on enrolled remote workers with live progress |
|
||||
| **GPU Auto-Detect** | CUDA, MPS, ROCm, and CPU routing with per-engine checks |
|
||||
| **AI Watermark** | AudioSeal embedding and detection |
|
||||
| **MCP Server** | Synthesis and transcription tools for MCP clients |
|
||||
| **Diagnostics** | Self-checks, error journal, logs, and scrubbed support bundles |
|
||||
| **Local-first** | Core creation stays local; network-backed features are explicit opt-ins |
|
||||
| **Extensible** | Registry-based TTS, ASR, and plugin interfaces |
|
||||
Three flagships, five more headliners, and a dozen under the fold.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%"><img src="docs/media/0.5.0/catalogue.png" alt="VoiceStudio Model Catalogue" width="100%" /></td>
|
||||
<td width="50%"><img src="docs/media/0.5.0/gallery-save.png" alt="Saving a gallery voice as a local profile" width="100%" /></td>
|
||||
<td width="33%"><img src="docs/features/clone.png" alt="Voice Cloning" width="100%"/></td>
|
||||
<td width="33%"><img src="docs/features/design.png" alt="Voice Design" width="100%"/></td>
|
||||
<td width="33%"><img src="docs/features/dub.png" alt="Video Dubbing" width="100%"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><sub>Model Catalogue: engine, device, and install state</sub></td>
|
||||
<td align="center"><sub>Gallery: save a shared voice as a local profile</sub></td>
|
||||
<td align="center">🎙️ <b>Voice Cloning</b><br/><sub>3-sec clip → any voice · 646 languages · zero-shot</sub></td>
|
||||
<td align="center">🎨 <b>Voice Design</b><br/><sub>Describe it — gender, age, accent, emotion</sub></td>
|
||||
<td align="center">🎬 <b>Video Dubbing</b><br/><sub>Transcribe → translate → re-voice → MP4</sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<a id="comparison"></a>
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="20%">📖<br/><b>Audiobook</b><br/><sub>EPUB/PDF → .m4b, multi-voice cast</sub></td>
|
||||
<td align="center" width="20%">🎭<br/><b>Stories</b><br/><sub>Multi-voice script editor</sub></td>
|
||||
<td align="center" width="20%">⌨️<br/><b>Dictation Widget</b><br/><sub><kbd>⌘⇧Space</kbd> in any app</sub></td>
|
||||
<td align="center" width="20%">🔐<br/><b>Local-first</b><br/><sub>Core creation stays on your machine</sub></td>
|
||||
<td align="center" width="20%">🤖<br/><b>MCP Server</b><br/><sub>Use from Claude, Cursor, …</sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Comparison
|
||||
<details>
|
||||
<summary><b>…and 12 more</b> — isolation, diarization, batch, watermarking, diagnostics, and friends</summary>
|
||||
|
||||
VoiceStudio trades managed cloud compute for local control. This is the practical difference:
|
||||
<br/>
|
||||
|
||||
| | **VoiceStudio** | **Typical hosted voice service** |
|
||||
- 🔊 **Vocal Isolation** — Demucs-powered: splits speech from music and keeps the background bed.
|
||||
- 👥 **Speaker Diarization** — Pyannote + WhisperX auto-identify who said what.
|
||||
- 📦 **Batch Queue** — drop 50 videos, walk away; per-job progress bars.
|
||||
- 🛡️ **AI Watermark** — AudioSeal (Meta): invisible, survives compression.
|
||||
- 🔬 **Diagnostics** — self-check suite, error journal, scrubbed diagnostic bundles.
|
||||
- ⚡ **GPU Auto-Detect** — CUDA · MPS · ROCm (Linux, opt-in) · CPU; ≤8 GB VRAM auto-offloads.
|
||||
- 📥 **Remote Model Downloads** — install pinned model weights on the selected worker with live progress.
|
||||
- 🧭 **Engine routing** — preflight GPU check per engine; no silent CPU fallback.
|
||||
- 📚 **Model Catalogue** — one workspace listing every TTS/ASR/LLM engine and model: set the defaults, install or remove weights.
|
||||
- 🧩 **Extensible** — subclass `TTSBackend`, add any engine in ~50 lines.
|
||||
- 🎒 **Portable personas** — export voices as `.ovsvoice` bundles: identity + watermark.
|
||||
- ♾️ **Unlimited TTS** — sentence-chunked generation, no length cap, streaming via WebSocket.
|
||||
- 🌐 **Remote backend** — point the UI at a remote server; Tailscale-friendly, bearer auth.
|
||||
- 🧠 **Dictation + LLM** — local-LLM cleanup of transcripts, optional echo cancellation.
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<a id="quickstart"></a>
|
||||
|
||||
## ⚡ Quickstart
|
||||
|
||||
<div align="center">
|
||||
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/badge/macOS-DMG_(Apple_Silicon)-000?style=for-the-badge&logo=apple&logoColor=white" alt="Download macOS DMG" /></a>
|
||||
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/badge/Windows-MSI_(x64)-0078D4?style=for-the-badge&logo=windows&logoColor=white" alt="Download Windows MSI" /></a>
|
||||
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/badge/Linux-AppImage_(x64)-FCC624?style=for-the-badge&logo=linux&logoColor=black" alt="Download Linux AppImage" /></a>
|
||||
<br/>
|
||||
<sub><b>macOS:</b> first launch needs a one-time approval — right-click → <b>Open</b> (or System Settings → Privacy & Security → <b>"Open Anyway"</b> on macOS 15). No Terminal needed. <a href="docs/install/macos.md#gatekeeper-quarantine">Why?</a> · <b>Intel Macs:</b> local backend unsupported (<a href="https://github.com/debpalash/VoiceStudio/issues/889">#889</a>) — <a href="docs/install/macos.md">details</a>.</sub>
|
||||
</div>
|
||||
|
||||
**Install guide:** [🍎 macOS](docs/install/macos.md) · [🪟 Windows](docs/install/windows.md) · [🐧 Linux](docs/install/linux.md) · [🐳 Docker](docs/install/docker.md)
|
||||
|
||||
<details>
|
||||
<summary><b>🧰 Troubleshooting · slow generation · HF tokens · restricted networks</b></summary>
|
||||
|
||||
<br/>
|
||||
|
||||
- **Something broke?** Run the self-check — **Settings → About → "Run self-check"** (or `uv run python backend/main.py --diagnose --deep`) — then the [top 10 install errors](docs/install/troubleshooting.md). **"Save diagnostic bundle"** packages scrubbed logs for a bug report.
|
||||
- **Feels slow?** [docs/performance.md](docs/performance.md) — where the time goes and how to tune it.
|
||||
- **Want breaths, laughter, emotion?** [docs/expressive-speech.md](docs/expressive-speech.md) — what each engine can do today.
|
||||
- **HF tokens · diarization · download speed / mirrors:** [tokens](docs/setup/huggingface-token.md) · [diarization](docs/features/diarization.md) · [downloads](docs/downloading-models.md).
|
||||
- **Coming from [Real-Time-Voice-Cloning](https://github.com/CorentinJ/Real-Time-Voice-Cloning)?** [Migration guide](docs/migration/real-time-voice-cloning.md).
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<a id="why-voicestudio"></a>
|
||||
|
||||
## ⚖️ Why VoiceStudio
|
||||
|
||||
Cloud voice tools are convenient, but they put your workflow behind an account, a meter, and somebody else's infrastructure. VoiceStudio gives you a capable studio that runs on your hardware, with optional integrations when you choose them.
|
||||
|
||||
| | **ElevenLabs** | **VoiceStudio** |
|
||||
|---|---|---|
|
||||
| **Best fit** | Private, offline, self-hosted, or high-volume work | Fast setup without local model management |
|
||||
| **Data path** | Local by default; remote features are opt-in | Audio and text are processed by the provider |
|
||||
| **Cost model** | Free software; you supply the hardware | Subscription, credits, or metered API use |
|
||||
| **Setup** | Install the app and model weights | Create an account and use the web app or API |
|
||||
| **Performance** | Depends on your engine and hardware | Provider manages compute and scaling |
|
||||
| **Offline use** | Yes, after required models are installed | Usually requires a network connection |
|
||||
| **Customization** | Source, engines, models, API, and routing are open | Limited to provider options |
|
||||
| **Maintenance** | You manage updates, disk, and compute | Provider manages infrastructure |
|
||||
| **Pricing** | Subscription and usage limits | Free & open-source (AGPL-3.0) · [Commercial license](#license) for proprietary use |
|
||||
| **Voice Cloning** | ✅ 3s clip | ✅ 3s clip, zero-shot |
|
||||
| **Voice Design** | ✅ Gender, age | ✅ Gender, age, accent, pitch, style, dialect |
|
||||
| **Audiobook / Stories** | ❌ | ✅ Full audiobook editor + multi-voice stories (EPUB/PDF import, .m4b export) |
|
||||
| **Languages** | Plan/model dependent | **646** |
|
||||
| **Video Dubbing** | ✅ Cloud-only | ✅ Fully local |
|
||||
| **Data Privacy** | Audio is processed remotely | Core workflow runs locally; online services are explicit opt-ins |
|
||||
| **API Keys** | Account required | Not needed for the local workflow |
|
||||
| **GPU Support** | N/A (cloud) | CUDA · Apple Silicon · ROCm (Linux) · CPU |
|
||||
| **Desktop App** | ❌ | ✅ macOS · Windows · Linux |
|
||||
| **TTS Engines** | 1 | **14** — [full matrix](#tts-engines) |
|
||||
| **ASR Engines** | 1 | **11** — [full lineup](#asr-engines) |
|
||||
| **MCP Server** | ❌ | ✅ Use from Claude, Cursor, any MCP client |
|
||||
| **Self-check** | ❌ | ✅ Diagnostics suite, error journal, scrubbed debug bundles |
|
||||
| **Customizable** | ❌ Closed | ✅ Fork it, extend it, ship it |
|
||||
|
||||
<a id="requirements"></a>
|
||||
Professional-grade voice AI, minus the subscription and the cloud.
|
||||
|
||||
## Requirements
|
||||
<div align="center">
|
||||
<br/>
|
||||
<b>Convinced? Come build with us.</b><br/>
|
||||
<a href="https://discord.gg/bzQavDfVV9"><img src="https://img.shields.io/badge/Join_Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Join Discord" /></a>
|
||||
<br/><br/>
|
||||
</div>
|
||||
|
||||
Requirements vary by engine. These values cover the default local workflow.
|
||||
---
|
||||
|
||||
## 🖥️ System Requirements
|
||||
|
||||
| | **Minimum** | **Recommended** |
|
||||
|---|---|---|
|
||||
| **OS** | Windows 10 x64 · macOS 13.3 Apple Silicon · Linux x86_64 with glibc 2.39+ | Current supported OS release |
|
||||
| **OS** | Windows 10, macOS 13.3+ (Apple Silicon), Ubuntu 24.04+ (glibc 2.39+) | Any modern 64-bit OS |
|
||||
| **RAM** | 8 GB | 16 GB+ |
|
||||
| **Disk** | 10 GB free | 20 GB+ SSD |
|
||||
| **GPU** | Optional; CPU mode is supported | NVIDIA CUDA or Apple Silicon |
|
||||
| **VRAM** | 4 GB when using a GPU | 8 GB+; large optional engines need more |
|
||||
| **Python from source** | 3.11+ | 3.11–3.12 |
|
||||
| **VRAM (GPU)** | 4 GB (auto-offloads TTS to CPU) | 8 GB+ (NVIDIA RTX 3060+) |
|
||||
| **Disk** | 10 GB free (models + cache) | 20 GB+ SSD |
|
||||
| **Python** | 3.10+ (managed by `uv`) | 3.11–3.12 |
|
||||
| **GPU** | Optional — CPU works | NVIDIA CUDA · Apple Silicon MPS · AMD ROCm (Linux only) |
|
||||
|
||||
ROCm is Linux-only and opt-in. Windows AMD/Ryzen AI uses CPU. Systems with limited VRAM offload work to CPU when required. See [performance](docs/performance.md), [benchmarks](docs/benchmarks.md), and [engine disk usage](docs/engines/disk-usage.md).
|
||||
|
||||
<a id="engines"></a>
|
||||
|
||||
## Engines
|
||||
|
||||
Engine support is capability-specific. Check cloning, language, platform, memory, and license before choosing one. Full setup guides: [docs/engines](docs/engines/README.md).
|
||||
> [!NOTE]
|
||||
> **A GPU is optional** — the whole pipeline runs on CPU (just slower), and on ≤8 GB VRAM, TTS auto-offloads to CPU. Caveats: **AMD ROCm** is Linux-only + opt-in ([Linux](docs/install/linux.md#amd-gpu-rocm)) — Windows AMD/Ryzen AI is CPU-only ([Windows](docs/install/windows.md#gpu-support)); **macOS Intel** can't run the local backend, so point it at a remote one ([#889](https://github.com/debpalash/VoiceStudio/issues/889) · [macOS](docs/install/macos.md)).
|
||||
|
||||
<a id="tts-engines"></a>
|
||||
|
||||
### Text to speech
|
||||
### 🗣️ TTS Engines
|
||||
|
||||
**14 engines, one picker.** VoiceStudio (default, 600+ languages) is always available; seven more are opt-in and auto-detected (CosyVoice 3, GPT-SoVITS, VoxCPM2, MOSS-TTS-Nano, KittenTTS, MLX-Audio, Sherpa-ONNX), plus six lazy-installed heavyweights (IndexTTS 2.5, OmniVoice GGUF, Supertonic 3, MOSS-TTS-v1.5, dots.tts, Confucius4-TTS). Switch in **Settings → TTS Engine**; the choice applies everywhere synthesis happens.
|
||||
|
||||
<details>
|
||||
<summary><b>📊 The full matrix</b> — 14 engines × platform × clone/instruct × license</summary>
|
||||
|
||||
<br/>
|
||||
|
||||
| Engine | Languages | Clone | Instruct | Linux | macOS ARM | Windows | License |
|
||||
|---|:---:|:---:|:---:|:---:|:---:|:---:|---|
|
||||
| **VoiceStudio** (default, powered by k2-fsa/OmniVoice) | 600+ | Yes | Yes | CUDA/CPU | MPS | CUDA/CPU | [AGPL-3.0](LICENSE) app · [Apache-2.0](LICENSE-NOTICE.md) model |
|
||||
| **CosyVoice 3** | 9 + 18 dialects | Yes | Yes | CUDA/CPU | CPU | CUDA/CPU | Apache-2.0 |
|
||||
| **GPT-SoVITS** | 5 | Yes | — | CUDA/CPU | — | CUDA/CPU | MIT |
|
||||
| **VoxCPM2** | 30 | Yes | Yes | CUDA/CPU | MPS | CUDA/CPU | Apache-2.0 |
|
||||
| **MOSS-TTS-Nano** | 20 | Yes | — | CUDA/CPU | CPU | CUDA/CPU | Apache-2.0 |
|
||||
| **KittenTTS** | English | — | — | CPU | CPU | CPU | MIT |
|
||||
| **MLX-Audio** | Model-dependent | Varies | Varies | — | MLX | — | Varies |
|
||||
| **Sherpa-ONNX** | 20+ | — | — | CUDA/CPU | CPU | CUDA/CPU | Apache-2.0 |
|
||||
| **IndexTTS 2.5** ⚡ | ZH · EN · JA · ES · AR | Yes | — | CUDA/CPU | CPU | CUDA/CPU | Bilibili model license¹ |
|
||||
| **OmniVoice GGUF** ⚡ | 600+ | Yes | Yes | CUDA/CPU | MPS/CPU | CUDA/CPU | [AGPL-3.0](LICENSE) app · [Apache-2.0](LICENSE-NOTICE.md) model |
|
||||
| **OmniVoice (subprocess)** ⚡ | 600+ | Yes | Yes | CUDA/CPU | MPS | CUDA/CPU | [AGPL-3.0](LICENSE) app · [Apache-2.0](LICENSE-NOTICE.md) model |
|
||||
| **PocketTTS** ⚡ | EN · FR · DE · PT · IT · ES | Yes | — | CPU | CPU | CPU | CC-BY-4.0, gated² |
|
||||
| **Supertonic 3** ⚡ | 31 | — | — | CPU | CPU | CPU | OpenRAIL-M |
|
||||
| **MOSS-TTS-v1.5** ⚡ | 31 | Yes | — | CUDA/CPU | CPU | CUDA/CPU | Apache-2.0 |
|
||||
| **dots.tts** ⚡ | 24 | Yes | — | CUDA/CPU | CPU | — | Apache-2.0 |
|
||||
| **Confucius4-TTS** ⚡ | 14 | Yes | — | CUDA/CPU | CPU | CUDA/CPU | Apache-2.0 |
|
||||
|--------|:---------:|:-----:|:--------:|:-----:|:---------:|:-------:|:-------:|
|
||||
| **VoiceStudio** (default, powered by k2-fsa/OmniVoice) | 600+ | ✅ | ✅ | ✅ CUDA/CPU | ✅ MPS | ✅ CUDA/CPU | Built-in |
|
||||
| **CosyVoice 3** | 9 + 18 dialects | ✅ | ✅ | ✅ CUDA/CPU | ✅ MPS | ✅ CUDA/CPU | Apache-2.0 |
|
||||
| **GPT-SoVITS** | 5 | ✅ | — | ✅ CUDA/CPU | — | ✅ CUDA/CPU | MIT |
|
||||
| **VoxCPM2** | 30 | ✅ | ✅ | ✅ CUDA/CPU | ✅ MPS | ✅ CUDA/CPU | Apache-2.0 |
|
||||
| **MOSS-TTS-Nano** | 20 | ✅ | — | ✅ CUDA/CPU | ✅ CPU | ✅ CUDA/CPU | Apache-2.0 |
|
||||
| **KittenTTS** | English | — | — | ✅ CPU | ✅ CPU | ✅ CPU | MIT |
|
||||
| **MLX-Audio** (Kokoro, Qwen3-TTS, CSM, Dia, …) | Multi | Varies | Varies | ❌ | ✅ Native | ❌ | Varies |
|
||||
| **Sherpa-ONNX** | 20+ | — | — | ✅ CUDA/CPU | ✅ CPU | ✅ CUDA/CPU | Apache-2.0 |
|
||||
| **IndexTTS 2.5** ⚡ | ZH · EN · JA · ES · AR | ✅ | — | ✅ CUDA | — | ✅ CUDA | Bilibili model license¹ |
|
||||
| **OmniVoice GGUF** ⚡ | 600+ | ✅ | ✅ | ✅ CPU | ✅ CPU | ✅ CPU | Built-in |
|
||||
| **Supertonic 3** ⚡ | 31 | — | — | ✅ CPU | ✅ CPU | ✅ CPU | OpenRAIL-M |
|
||||
| **MOSS-TTS-v1.5** ⚡ (8B) | 31 | ✅ | — | ✅ CUDA/CPU | ✅ CPU | ✅ CUDA/CPU | Apache-2.0 |
|
||||
| **dots.tts** ⚡ (2B) | 24 | ✅ | — | ✅ CUDA/CPU | ✅ CPU | ❌ | Apache-2.0 |
|
||||
| **Confucius4-TTS** ⚡ | 14 | ✅ | — | ✅ CUDA/CPU | ✅ CPU | ✅ CUDA/CPU | Apache-2.0 |
|
||||
|
||||
⚡ Installed or registered on demand.
|
||||
¹ IndexTTS 2.5 requires a separate written Bilibili license above 100 million
|
||||
monthly active users or RMB 1 billion in annual revenue. Review its
|
||||
[model license](https://huggingface.co/IndexTeam/IndexTTS-2.5/blob/main/LICENSE)
|
||||
before enabling the optional sidecar.
|
||||
|
||||
¹ IndexTTS 2.5 requires a separate written Bilibili license above 100 million monthly active users or RMB 1 billion annual revenue. Review the [model license](https://huggingface.co/IndexTeam/IndexTTS-2.5/blob/main/LICENSE).
|
||||
GPT-SoVITS connects to `http://127.0.0.1:9880` by default. To use a server on
|
||||
another machine, set `OMNIVOICE_GPTSOVITS_URL` to its credential-free
|
||||
`http://` or `https://` origin and add that machine's CIDR to
|
||||
`OMNIVOICE_TRUSTED_NETWORKS`; redirects and untrusted destinations are rejected.
|
||||
|
||||
² PocketTTS shows its gated-access and CC-BY-4.0 terms before first use.
|
||||
> **CUDA** = GPU-accelerated · **MPS** = Apple Silicon Metal · **CPU** = runs everywhere, slower for large models · KittenTTS and MOSS-TTS-Nano run realtime on CPU · MLX-Audio is Apple Silicon only · ⚡ = lazy-registered (installed on first use)
|
||||
>
|
||||
> **Clone** matters beyond single-clip generation: Video Dubbing (and any Batch job with a pinned voice) needs reference-audio cloning to preserve speaker identity, so picking a Clone-less engine (KittenTTS, Sherpa-ONNX, Supertonic 3) as the active engine fails those jobs up front with an actionable message instead of silently falling back to VoiceStudio.
|
||||
>
|
||||
> **MOSS-TTS-v1.5** (8B, ~16 GB), **dots.tts** (2B, ~9 GB), and **Confucius4-TTS** are heavyweight opt-ins that run in their own isolated venv from a local clone. None claims Apple-Silicon MPS (CPU on Macs); dots.tts has no Windows path; Confucius4 wants CUDA (CPU works, ~17× realtime). Details: [MOSS-TTS-v1.5](docs/engines/moss-tts-v15.md) · [dots.tts](docs/engines/dots-tts.md) · [Confucius4-TTS](docs/engines/confucius4-tts.md).
|
||||
|
||||
Clone-less engines cannot preserve a reference speaker in dubbing or pinned-voice batch jobs. VoiceStudio rejects those jobs instead of silently changing engines. Heavy engines have separate memory and platform limits; check their engine guide first.
|
||||
</details>
|
||||
|
||||
<a id="asr-engines"></a>
|
||||
|
||||
### Speech to text
|
||||
### 🎧 ASR Engines
|
||||
|
||||
| Engine | ID | Languages | Best fit |
|
||||
|---|---|:---:|---|
|
||||
| **WhisperX** (default) | `whisperx` | ~100 | Dubbing, subtitles, word-level timing |
|
||||
| **Faster-Whisper** | `faster-whisper` | ~100 | General cross-platform transcription |
|
||||
| **Faster-Whisper (isolated)** | `faster-whisper-isolated` | ~100 | Crash-isolated batch transcription |
|
||||
| **MLX Whisper** | `mlx-whisper` | ~100 | Apple Silicon |
|
||||
| **PyTorch Whisper** | `pytorch-whisper` | ~100 | CUDA, MPS, and CPU fallback |
|
||||
| **Parakeet TDT** | `nemo-parakeet` | English + 25 EU | Fast CPU/CUDA transcription |
|
||||
| **Parakeet TDT v3 (MLX)** | `parakeet-mlx` | 25 EU | Apple Silicon dictation and word timestamps |
|
||||
| **Moonshine** | `moonshine` | English | Low-power, low-latency ONNX |
|
||||
| **FunASR** | `funasr` | 50+ | VAD and inline diarization |
|
||||
| **sherpa-onnx** (live dictation) | `sherpa-onnx-asr` | Model-dependent | Streaming CPU dictation |
|
||||
| **OpenAI-compatible** ⚠️ remote | `openai-compat-asr` | Server-dependent | Qwen3-ASR or another compatible endpoint; audio leaves the machine |
|
||||
**11 engines** — they power dictation, video dubbing, and subtitles. **WhisperX** is the cross-platform default (~100 languages, word-level timing); the rest are opt-in and auto-detected. Switch in **Model Catalogue → Engines**. Ten run fully on-device; the eleventh (OpenAI-compatible) is an optional remote client for Qwen3-ASR or any compatible server.
|
||||
|
||||
WhisperX and Faster-Whisper retry with `int8` when efficient `float16` is unavailable. Pin `ASR_COMPUTE_TYPE=int8` or `float32` only if automatic selection still fails.
|
||||
<details>
|
||||
<summary><b>📊 The full lineup</b> — 11 engines, what each is best at, and compute-type notes</summary>
|
||||
|
||||
<a id="architecture"></a>
|
||||
<br/>
|
||||
|
||||
## Architecture
|
||||
| Engine | `OMNIVOICE_ASR_BACKEND` | Languages | Best for |
|
||||
|--------|-------------------------|:---------:|----------|
|
||||
| **WhisperX** (default) | `whisperx` | ~100 | Dubbing & subtitles — word-level timing via wav2vec2 forced alignment |
|
||||
| **Faster-Whisper** | `faster-whisper` | ~100 | Fast transcription on Linux / macOS / Windows (CTranslate2) |
|
||||
| **Faster-Whisper (isolated)** | `faster-whisper-isolated` | ~100 | Same as Faster-Whisper but crash-isolated in a subprocess — an ASR crash won't take down the app |
|
||||
| **MLX Whisper** | `mlx-whisper` | ~100 | Native Apple Silicon speed (Apple MLX / Metal) |
|
||||
| **PyTorch Whisper** | `pytorch-whisper` | ~100 | CUDA / CPU fallback via 🤗 Transformers (no cuDNN 8 needed) |
|
||||
| **Parakeet TDT** | `nemo-parakeet` | English + 25 EU | SOTA accuracy at ~10× realtime even on CPU, auto language detection (NVIDIA NeMo, CUDA/CPU) |
|
||||
| **Parakeet TDT v3 (MLX)** | `parakeet-mlx` | 25 EU | The Parakeet tier for Apple Silicon — TDT word timestamps, ~2 GB unified memory, dictation-grade speed on the GPU via MLX. Install the model from **Model Catalogue → Models** and dictation prefers it automatically when your system language is one of its 25 (European) languages; other languages (CJK, Arabic, …) keep the multilingual Whisper engine so dictation coverage never regresses. |
|
||||
| **Moonshine** | `moonshine` | English | Edge / low-latency, ONNX |
|
||||
| **FunASR** | `funasr` | 50+ | All-in-one multilingual — built-in VAD + inline speaker diarization (SenseVoice) |
|
||||
| **sherpa-onnx** (live dictation) | `sherpa-onnx-asr` | 25 EU + 90+ | Live, faster-than-real-time dictation — small streaming/offline ONNX models (Parakeet TDT v3/v2, streaming Zipformer & Paraformer, Whisper Tiny), CPU, identical on macOS / Windows / Linux. Picked per-model in **Settings → Voice**. |
|
||||
| **OpenAI-compatible** ⚠️ remote | `openai-compat-asr` | Server-dependent | A path to **Qwen3-ASR** today (self-hosted server, no transformers wait), any OpenAI-compatible transcription endpoint, or OpenAI's own API — no install, configure + test the connection in **Model Catalogue → Engines** (ASR tab). Audio leaves your machine to whatever server you point it at; see [docs/engines/openai-compatible-asr.md](docs/engines/openai-compatible-asr.md). |
|
||||
|
||||
```text
|
||||
Tauri v2 desktop shell (Rust)
|
||||
│ IPC
|
||||
React + Vite UI
|
||||
│ HTTP · SSE · WebSocket on localhost:3900
|
||||
FastAPI backend
|
||||
├── TTS / ASR engine registries
|
||||
├── dubbing / audio / long-form pipelines
|
||||
├── OpenAI-compatible API and MCP server
|
||||
└── SQLite + Alembic → omnivoice_data/
|
||||
> Whisper-family engines cover ~100 languages; **FunASR / SenseVoice** adds an all-in-one multilingual path with built-in voice-activity detection and inline speaker diarization. **sherpa-onnx** powers the live dictation model picker — you talk and text appears as you speak. Every engine runs on-device — no API keys, no cloud.
|
||||
|
||||
> If Dubbing needs an ASR model that is not installed yet, it offers the recommended download in place, shows its progress, and retries transcription on the same job when the model is ready.
|
||||
|
||||
> **GPU without efficient float16?** On older NVIDIA GPUs (Maxwell/Pascal, GTX 16xx) or after a CTranslate2/cuDNN mismatch, the CTranslate2 ASR engines (WhisperX, Faster-Whisper) can't run `float16` and VoiceStudio automatically retries on `int8` — no config needed. If transcription still fails, pin the compute type with the `ASR_COMPUTE_TYPE` env var (escape hatch): `ASR_COMPUTE_TYPE=int8` (or `float32` for CPU). Set it to `int8` and restart the backend.
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
A **Tauri v2** desktop shell (Rust) wraps a **React** UI and a bundled **Python/FastAPI** backend that runs as a local sidecar on `localhost:3900`. Nothing external — every layer is on your machine.
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────┐
|
||||
│ Tauri v2 shell — Rust │
|
||||
│ window state · global dictation hotkey · system tray · │
|
||||
│ signed auto-updater (stable/preview) · single-instance · │
|
||||
│ first-run bootstrap (installs uv + Python venv) · blank guard │
|
||||
├────────────────────────────────────────────────────────────────────┤
|
||||
│ Frontend — React + Vite │
|
||||
│ Studio · Dub · Stories · Audiobook · Gallery · Dictation · │
|
||||
│ Batch · Diagnostics · MCP client — Zustand store · WS bus │
|
||||
│ ▲ IPC / HTTP + WS │
|
||||
├──────────────────────────┼─────────────────────────────────────────┤
|
||||
│ Backend — FastAPI sidecar @ localhost:3900 │
|
||||
│ 100+ REST endpoints · SSE + WebSocket streaming · │
|
||||
│ SQLite + Alembic (omnivoice_data/) · OpenAI-compatible API │
|
||||
├───────────┬───────────┬───────────┬───────────┬────────────────────┤
|
||||
│ TTS ×14 │ ASR ×11 │ Demucs │ Pyannote │ AudioSeal │
|
||||
│ clone / │ WhisperX │ vocal │ speaker │ watermark │
|
||||
│ design │ +10 more │ isolation│ diariz. │ embed / detect │
|
||||
├───────────┴───────────┴───────────┴───────────┴────────────────────┤
|
||||
│ Engine routing — per-engine GPU preflight, no silent CPU fallback │
|
||||
│ Hardware: CUDA · MPS · ROCm (Linux) · CPU (auto-detected) │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
| Layer | Path | Responsibility |
|
||||
|---|---|---|
|
||||
| Desktop shell | `frontend/src-tauri/` | Window lifecycle, tray, shortcuts, updater, sidecar bootstrap |
|
||||
| Frontend | `frontend/src/` | React UI, Zustand state, API and event clients, i18n |
|
||||
| API | `backend/api/` | REST routes, schemas, auth boundaries, streaming |
|
||||
| Core services | `backend/services/` | Generation, dubbing, audio processing, persistence |
|
||||
| Engines | `backend/engines/` | Isolated and optional engine adapters |
|
||||
| Worker system | `backend/worker/` | Authenticated remote compute and job transport |
|
||||
| Data | `omnivoice_data/` | Projects, voices, settings, logs, and SQLite state |
|
||||
| Delivery | `scripts/`, `deploy/`, `.github/workflows/` | Development, packaging, containers, releases, CI |
|
||||
- **Shell (Rust)** — native OS integration: the system-wide dictation hotkey, tray, signed auto-updater (stable + preview channels), single-instance lock, and the first-run bootstrap that installs `uv` and a Python 3.11 venv.
|
||||
- **Frontend (React)** — every workspace tab over a Zustand store, with a WebSocket event bus that live-refreshes the UI when backend data changes.
|
||||
- **Backend (FastAPI)** — the bundled Python sidecar: 100+ endpoints, SSE/WSS streaming, a SQLite DB migrated by Alembic, and the OpenAI-compatible API surface.
|
||||
- **Engines** — 14 TTS + 11 ASR, plus Demucs (isolation), Pyannote (diarization), and AudioSeal (watermark), all behind routing that GPU-preflights each engine and refuses to silently fall back to CPU.
|
||||
|
||||
### Network boundary
|
||||
<a id="openai-api"></a>
|
||||
|
||||
- The desktop talks to a loopback-only backend on `localhost:3900`.
|
||||
- Loopback API calls need no server key. Remote access requires a share PIN or API key.
|
||||
- Remote workers and OpenAI-compatible ASR are opt-in. The UI identifies when audio leaves the machine.
|
||||
- Analytics is off until consent. If enabled, it sends allowlisted, content-free usage metadata—not text, audio, file names, or projects.
|
||||
## 🔌 OpenAI-compatible API
|
||||
|
||||
<a id="api"></a>
|
||||
<div align="center">
|
||||
|
||||
## Local speech platform and OpenAI-compatible API
|
||||
|
||||
Point an OpenAI-compatible audio client at the local backend:
|
||||
**Drop-in replacement for OpenAI / ElevenLabs audio.** One line — no key, no code changes:
|
||||
|
||||
```diff
|
||||
- base_url="https://api.openai.com/v1"
|
||||
+ base_url="http://localhost:3900/v1"
|
||||
```
|
||||
|
||||
| Endpoint | Purpose |
|
||||
</div>
|
||||
|
||||
Your existing scripts, agents, and OpenAI/ElevenLabs SDK calls now run **locally** on whatever engine you have active. What the cloud can't do: `voice` takes **your own cloned-voice profile IDs**, and `model` can pin a **specific engine** per request.
|
||||
|
||||
| Endpoint | What it does |
|
||||
|---|---|
|
||||
| `POST /v1/audio/speech` | TTS to `mp3`, `opus`, `aac`, `flac`, `wav`, or `pcm`; select a profile with `voice` and an engine with `model` |
|
||||
| `POST /v1/audio/transcriptions` | STT to `json`, `text`, `verbose_json`, `srt`, or `vtt` |
|
||||
| `WS /v1/audio/transcriptions/stream` | Live PCM/WebM transcription with partial, utterance, and session-final events |
|
||||
| `GET /.well-known/voicestudio-speech` | Discover HTTP, WebSocket, MCP, and native dictation-control transports |
|
||||
| `GET /v1/audio/voices` | List local voice profiles and engines |
|
||||
| `POST /v1/audio/speech` | TTS — text in; `mp3` / `opus` / `aac` / `flac` / `wav` / `pcm` out. `model`: `tts-1`/`tts-1-hd` (active engine) or a specific one (`voxcpm2`, `cosyvoice`, `kittentts`, …). `voice`: a cloned profile ID, `default`, or an OpenAI name (`alloy`, …). `speed` supported. |
|
||||
| `POST /v1/audio/transcriptions` | STT — audio file in; `json` / `text` / `verbose_json` / `srt` / `vtt` out (`verbose_json` adds word-level timings). `whisper-1` maps to your active ASR engine. |
|
||||
| `GET /v1/audio/voices` | VoiceStudio extension — lists every voice profile and engine, so clients can discover your clones. |
|
||||
|
||||
**Speak with your own cloned voice** — list the IDs, then pass one as `voice`:
|
||||
|
||||
```sh
|
||||
# 1 — find a cloned voice's profile ID
|
||||
curl -s http://localhost:3900/v1/audio/voices | jq '.voices[] | select(.type=="profile") | {voice_id, name}'
|
||||
|
||||
# 2 — synthesize with it
|
||||
curl http://localhost:3900/v1/audio/speech \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model":"tts-1","voice":"<profile-id>","input":"Made on my own hardware.","response_format":"wav"}' \
|
||||
--output speech.wav
|
||||
```
|
||||
|
||||
```python
|
||||
from openai import OpenAI
|
||||
client = OpenAI(base_url="http://localhost:3900/v1", api_key="none") # any string — nothing checks it
|
||||
|
||||
client = OpenAI(base_url="http://localhost:3900/v1", api_key="local")
|
||||
|
||||
# TTS with your cloned voice (or "alloy" / "default"; model= can pin a specific engine)
|
||||
with client.audio.speech.with_streaming_response.create(
|
||||
model="tts-1",
|
||||
voice="<profile-id>",
|
||||
input="Made on my own hardware.",
|
||||
response_format="wav",
|
||||
) as response:
|
||||
response.stream_to_file("speech.wav")
|
||||
model="tts-1", voice="<profile-id>", input="Made on my own hardware.") as r:
|
||||
r.stream_to_file("speech.wav")
|
||||
|
||||
# STT
|
||||
print(client.audio.transcriptions.create(model="whisper-1", file=open("clip.wav", "rb")).text)
|
||||
```
|
||||
|
||||
The bundled Rust control sidecar also lets Herdr, coding agents, VS Code,
|
||||
desktop apps, and TUIs trigger the existing system-wide dictation flow or reuse
|
||||
its safe native insertion. See the [speech platform guide](docs/speech-platform.md).
|
||||
The full API reference is in **Settings → OpenAPI Reference**. For LAN,
|
||||
Tailscale, or proxy access, read [API authentication](docs/api-auth.md) before
|
||||
exposing the backend.
|
||||
Want the whole surface (100+ endpoints)? The full REST API reference is embedded in the app — **Settings → OpenAPI Reference** (Scalar-powered), or the `{}` button in the footer.
|
||||
|
||||
### Agent skills
|
||||
Calling the backend from **another machine** (LAN, Tailscale, behind a proxy)? It's loopback-only and unauthenticated by default; to reach it remotely you set a share PIN or an API key. [docs/api-auth.md](docs/api-auth.md) covers the exact headers, query params, `401`/`403`/`429` meanings, and the `OMNIVOICE_TRUSTED_NETWORKS` exemption.
|
||||
|
||||
Install the VoiceStudio skills for Claude Code, Codex, Cursor, and other [skills.sh](https://skills.sh)-compatible agents:
|
||||
### 📓 Run on Google Colab
|
||||
|
||||
```bash
|
||||
[](https://colab.research.google.com/github/debpalash/VoiceStudio/blob/main/notebooks/VoiceStudio_Studio_Colab.ipynb)
|
||||
|
||||
No local GPU? The [official notebook](notebooks/VoiceStudio_Studio_Colab.ipynb) boots the full app — web UI included — on a free Colab T4, then walks the whole feature surface (TTS, cloning, design, transcription, dubbing, audiobook, watermarking, the OpenAI-compatible API) as a guided tour with inline playback. No tunnels, no API keys.
|
||||
|
||||
### 🤝 Agent Skills
|
||||
|
||||
Teach your coding agent to speak and listen through your local VoiceStudio — one command, works with **Claude Code, Codex, Cursor, Grok, Kimi, opencode**, and any [skills.sh](https://skills.sh)-compatible agent:
|
||||
|
||||
```sh
|
||||
npx skills add debpalash/omnivoice-studio
|
||||
```
|
||||
|
||||
- `omnivoice`: synthesize speech and transcribe audio through local VoiceStudio.
|
||||
- `oss-maintainer`: the repository's open-source maintenance workflow.
|
||||
Ships two [skills](https://skills.sh):
|
||||
|
||||
### Google Colab
|
||||
- **`omnivoice`** — generate speech (including your cloned voices) and transcribe audio from any agent, free and fully offline via your local install.
|
||||
- **`oss-maintainer`** — the maintainer methodology this project is run with, for anyone running their own OSS project with an agent.
|
||||
|
||||
[](https://colab.research.google.com/github/debpalash/VoiceStudio/blob/main/notebooks/OmniVoice_Studio_Colab.ipynb)
|
||||
---
|
||||
|
||||
The [notebook](notebooks/OmniVoice_Studio_Colab.ipynb) runs the app and web UI on a Colab GPU. Colab is remote compute, so uploaded audio and project data do not remain local to your machine.
|
||||
## 🗺️ Roadmap
|
||||
|
||||
<a id="documentation"></a>
|
||||
### 🔜 Up Next
|
||||
|
||||
## Documentation
|
||||
|
||||
| Need | Read |
|
||||
|---|---|
|
||||
| Install | [macOS](docs/install/macos.md) · [Windows](docs/install/windows.md) · [Linux](docs/install/linux.md) · [Docker](docs/install/docker.md) |
|
||||
| Fix setup | [Troubleshooting](docs/install/troubleshooting.md) · [model downloads](docs/downloading-models.md) · [Hugging Face token](docs/setup/huggingface-token.md) |
|
||||
| Choose an engine | [Engine guides](docs/engines/README.md) · [benchmarks](docs/benchmarks.md) · [expressive speech](docs/expressive-speech.md) |
|
||||
| Tune hardware | [Performance](docs/performance.md) · [remote workers](docs/remote-workers.md) |
|
||||
| Build integrations | [Speech platform](docs/speech-platform.md) · [API auth](docs/api-auth.md) · [MCP](docs/mcp.md) · [examples](examples/README.md) |
|
||||
| Build VoiceStudio | [Contributing](.github/CONTRIBUTING.md) · [engine acceptance](docs/engine-acceptance.md) |
|
||||
| Track changes | [Changelog](CHANGELOG.md) · [roadmap](docs/ROADMAP.md) · [latest release](https://github.com/debpalash/VoiceStudio/releases/latest) |
|
||||
| Remove everything | [Uninstall guide](docs/install/uninstall.md) |
|
||||
|
||||
## FAQ
|
||||
- 🎬 **Lip-sync v2** — visual speech timing with wav2lip
|
||||
- 🌐 **Hosted Demo** — try VoiceStudio without installing anything
|
||||
- 🔌 **Plugin Marketplace** — community-contributed TTS engines and effects
|
||||
- 🎵 **Real-time Voice Changer** — live microphone transformation during calls
|
||||
|
||||
<details>
|
||||
<summary><strong>Does it work on Apple Silicon and Intel Macs?</strong></summary>
|
||||
<summary><b>✅ Everything shipped so far</b> — the receipts, by category</summary>
|
||||
|
||||
<br/>
|
||||
|
||||
| Category | Features |
|
||||
|----------|----------|
|
||||
| **Longform** | Audiobook editor (text/EPUB/PDF → chaptered .m4b) with multi-voice cast, expressive controls, live per-chapter progress + Stop, and a one-click sample; Stories multi-voice editor, two-pass loudnorm mastering, crash-resume for interrupted renders, pronunciation control + SSML-lite prosody |
|
||||
| **Dubbing** | Full pipeline (transcribe→translate→synthesize→mux), scene-aware splitting, lip-sync scoring, streaming TTS, per-speaker voice assignment, Smart Fit timing + second-pass QC, paste-in translations from any external tool, dedicated Dub home |
|
||||
| **Voice** | Zero-shot cloning, voice design, A/B comparison, voice preview widget, gallery with favorites/tags (its voices selectable in every picker — Studio, Audiobook, Stories, Dubbing), portable persona bundles (`.ovsvoice`), voice console workspace |
|
||||
| **Audio** | Demucs vocal isolation, per-segment gain, selective track export, stem/SRT/VTT/MP3 export, unlimited-length TTS via sentence-chunked generation |
|
||||
| **Multi-Lang** | Translate All preserves the primary language plus every extra language chip; Generate renders and exports one retained track per language with sequential GPU execution |
|
||||
| **Diarization** | Pyannote ML diarization, auto speaker clone extraction, per-speaker voice assignment |
|
||||
| **ASR** | 11 engines (WhisperX, Faster-Whisper, isolated Faster-Whisper, MLX Whisper, PyTorch Whisper, Parakeet TDT, Parakeet TDT v3 MLX, Moonshine, FunASR/SenseVoice, sherpa-onnx live dictation, OpenAI-compatible remote), crash-isolated subprocess backend |
|
||||
| **TTS** | 14 engines (VoiceStudio, CosyVoice 3, GPT-SoVITS, VoxCPM2, MOSS-TTS-Nano, KittenTTS, MLX-Audio, Sherpa-ONNX, + lazy: IndexTTS 2.5, OmniVoice GGUF, Supertonic 3, MOSS-TTS-v1.5, dots.tts, Confucius4-TTS), engine routing with GPU preflight |
|
||||
| **Infra** | Docker deployment, CUDA/MPS/ROCm auto-detect, cuDNN 8 compat, VRAM-aware model offloading, engine routing (no silent CPU fallback), diagnostics suite & error journal, restricted-network mirror support |
|
||||
| **AI Provenance** | AudioSeal invisible watermarking (SynthID-like), video logo overlay, watermark detection API |
|
||||
| **UX** | Undo/redo, keyboard shortcuts, drag-and-drop, session persistence, screen-sized first-run UI scaling, and native WebKitGTK scaling |
|
||||
| **Real-time Events** | WebSocket event bus — instant sidebar refresh on data mutations, exponential backoff reconnect |
|
||||
| **State Management** | Zustand store migration — `uiSlice`, `pillSlice`, `dubSlice`, `generateSlice`, `prefsSlice`, `glossarySlice` |
|
||||
| **Desktop** | Cross-platform Tauri installers (macOS DMG — Apple Silicon; Intel unsupported for the local backend, #889 — Windows MSI, Linux deb/AppImage), auto-update infrastructure, single-instance enforcement, close-to-tray, macOS Gatekeeper fix |
|
||||
| **Dictation** | Global system-wide hotkey (`⌘+⇧+Space`), frameless floating widget, streaming ASR via WebSocket, auto-paste, customizable hotkey, local-LLM transcript refinement |
|
||||
| **Batch Pipeline** | Full batch TTS: extract → transcribe → translate → generate → mix → export, with live progress tracking |
|
||||
| **MCP Server** | VoiceStudio as a local TTS/STT provider for Claude, Cursor, and any MCP client |
|
||||
| **Remote Backend** | Point the desktop UI at a remote backend URL with bearer auth (Tailscale-documented) |
|
||||
| **Reliability** | Stall watchdog on bootstrap splash, per-engine GPU compatibility matrix, actionable errors for non-executable engine binaries, setuptools auto-repair |
|
||||
|
||||
Apple Silicon is supported with MPS and MLX options. Intel Macs cannot run the local backend because current PyTorch wheels are unavailable; they can connect to a remote backend. See [macOS installation](docs/install/macos.md).
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>How much VRAM do I need?</strong></summary>
|
||||
---
|
||||
|
||||
A GPU is optional. Use 4 GB VRAM as the minimum for accelerated work and 8 GB+ for the default multi-stage workflow. Large optional engines can require 12–16 GB or more. Check the [benchmarks](docs/benchmarks.md) and engine guide.
|
||||
</details>
|
||||
<a id="sponsor--donate"></a>
|
||||
|
||||
<details>
|
||||
<summary><strong>Why does a longer reference clip not always improve the clone?</strong></summary>
|
||||
## 💜 Sponsor / Donate
|
||||
|
||||
Cloning is zero-shot: the clip is a prompt, not training data. Use 5–15 seconds of one speaker, close to the microphone, without music, noise, or reverb. Match the tone and pace you want in the output. For training, see [data preparation](docs/data_preparation.md) and [training](docs/training.md).
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Can I use generated audio commercially?</strong></summary>
|
||||
|
||||
Yes under VoiceStudio's AGPL-3.0 terms. Optional engines and model weights may use different licenses; review the selected engine's license before commercial use.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Does VoiceStudio collect data?</strong></summary>
|
||||
|
||||
Not unless you opt in. Analytics is off by default and skipping consent keeps it off. When enabled, the app sends allowlisted, content-free usage metadata. Text, audio, file names, voices, and projects are excluded. Change this at **Settings → Privacy**.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>How do I remove VoiceStudio and its data?</strong></summary>
|
||||
|
||||
Use `scripts/uninstall.sh` on macOS/Linux or `scripts\uninstall.ps1` on Windows. Both show a dry run before deletion. See the [uninstall guide](docs/install/uninstall.md) for every path.
|
||||
</details>
|
||||
|
||||
## Community and contributing
|
||||
|
||||
- [GitHub Issues](https://github.com/debpalash/VoiceStudio/issues) for reproducible bugs and feature requests.
|
||||
- [Discord](https://discord.gg/bzQavDfVV9) for setup help and project discussion.
|
||||
- [Good first issues](https://github.com/debpalash/VoiceStudio/labels/good%20first%20issue) for a scoped starting point.
|
||||
- [Contributing guide](.github/CONTRIBUTING.md) for setup, tests, and pull requests.
|
||||
|
||||
## Support development
|
||||
|
||||
VoiceStudio is free and has no paid tier. Donations fund development and infrastructure.
|
||||
|
||||
[Ko-fi](https://ko-fi.com/debpalash) · [PayPal](https://paypal.me/palashCoder) · [Sponsorship details](SPONSORS.md)
|
||||
|
||||
## License
|
||||
|
||||
VoiceStudio is licensed under [AGPL-3.0](LICENSE). You may run it, modify it, use it internally, and sell generated audio. If you modify VoiceStudio and provide that modified version as a network service, AGPL requires you to offer the corresponding source under the same license. A commercial license is available for proprietary embedding; contact **VoiceStudio@palash.dev**. See [LICENSE-NOTICE.md](LICENSE-NOTICE.md) for the plain-language scope.
|
||||
|
||||
Optional engines and downloaded models retain their own licenses. The bundled `omnivoice/` model remains Apache-2.0 upstream.
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
VoiceStudio builds on [OmniVoice](https://github.com/k2-fsa/OmniVoice), [WhisperX](https://github.com/m-bain/whisperX), [Demucs](https://github.com/facebookresearch/demucs), [Pyannote](https://github.com/pyannote/pyannote-audio), [CTranslate2](https://github.com/OpenNMT/CTranslate2), [AudioSeal](https://github.com/facebookresearch/audioseal), [Tauri](https://tauri.app), [Supertonic](https://huggingface.co/Supertone/supertonic-3), [Sherpa-ONNX](https://github.com/k2-fsa/sherpa-onnx), [GPT-SoVITS](https://github.com/RVC-Boss/GPT-SoVITS), and [PocketTTS](https://kyutai.org).
|
||||
One developer, real AI-agent bills. If VoiceStudio is useful to you, chipping in keeps development full-time — every dollar goes straight to the bills.
|
||||
|
||||
<div align="center">
|
||||
<strong><a href="https://github.com/debpalash/VoiceStudio/releases/latest">Download VoiceStudio</a></strong> ·
|
||||
<a href="https://github.com/debpalash/VoiceStudio">Star the project</a> ·
|
||||
<a href="https://discord.gg/bzQavDfVV9">Join Discord</a>
|
||||
|
||||
<img src="https://img.shields.io/badge/raised_%2410_of_%24200-5%25-EAB308?style=for-the-badge" alt="This month's agent-bill fund: $10 / $200" />
|
||||
|
||||
<br/><br/>
|
||||
|
||||
<a href="https://ko-fi.com/debpalash"><img src="https://img.shields.io/badge/Ko--fi-Support_❤️-FF5E5B?style=for-the-badge&logo=ko-fi&logoColor=white" alt="Ko-fi" /></a>
|
||||
|
||||
<a href="https://paypal.me/palashCoder"><img src="https://img.shields.io/badge/PayPal-Donate-00457C?style=for-the-badge&logo=paypal&logoColor=white" alt="PayPal" /></a>
|
||||
|
||||
<br/><br/>
|
||||
|
||||
<sub>Also from the maker: <a href="https://github.com/debpalash/Opal"><b>Opal</b> 💠</a> · <a href="https://github.com/debpalash/memxt"><b>memxt</b> 🧠</a> — a ⭐ helps too.</sub>
|
||||
|
||||
</div>
|
||||
|
||||
<a id="sponsors"></a>
|
||||
|
||||
### 🌟 Sponsors
|
||||
|
||||
VoiceStudio is **free** and **AGPL-3.0** — no paid tier, no SaaS revenue. Sponsors keep development going, and in return get a logo slot here, in the app, and (for top tiers) on the project website. It's a thank-you, never a paywall. **[See tiers & become a sponsor →](SPONSORS.md)**
|
||||
|
||||
<div align="center">
|
||||
|
||||
<!-- SPONSORS:START — logo slots are filled here as sponsors come aboard; see SPONSORS.md -->
|
||||
|
||||
**Your logo here** — [become a sponsor](SPONSORS.md)
|
||||
|
||||
<!-- SPONSORS:END -->
|
||||
|
||||
</div>
|
||||
|
||||
<sub>💡 GitHub also shows a **Sponsor** button at the top of this repo, wired to the same links via <a href=".github/FUNDING.yml"><code>.github/FUNDING.yml</code></a>.</sub>
|
||||
|
||||
---
|
||||
|
||||
## 💬 Community
|
||||
|
||||
<div align="center">
|
||||
<a href="https://discord.gg/bzQavDfVV9"><img src="https://img.shields.io/badge/💬_Discord-Join_Community-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Join Discord" /></a>
|
||||
<a href="https://x.com/idebpalash"><img src="https://img.shields.io/badge/𝕏_Follow-for_updates-000000?style=for-the-badge&logo=x&logoColor=white" alt="Follow on X" /></a>
|
||||
<br/>
|
||||
<sub>We respond to setup questions within hours, not days.</sub>
|
||||
</div>
|
||||
|
||||
<details>
|
||||
<summary><b>What happens in there</b></summary>
|
||||
|
||||
<br/>
|
||||
|
||||
| Channel | What happens there |
|
||||
|---------|--------------------|
|
||||
| `#announcements` | Release news and the big moments — new versions land here first |
|
||||
| `#releases` + `#changelog` | Every build and exactly what's inside it |
|
||||
| `#issues` | Bug reports as forum posts — triaged straight into GitHub issues |
|
||||
| `#ideas` | Feature requests, discussed and voted on |
|
||||
| `#discuss-ideas` | Design talk before things get built |
|
||||
| `#general` | Setup help, GPU troubleshooting, and showing off your dubs |
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<a id="contributing"></a>
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Yes please — bug fixes, new TTS engine adapters, UI improvements, docs, translations. All of it.
|
||||
|
||||
- 📖 Read the **[Contributing Guide](.github/CONTRIBUTING.md)** for setup, code style, and PR workflow
|
||||
- 🐛 Browse [good first issues](https://github.com/debpalash/VoiceStudio/labels/good%20first%20issue)
|
||||
- 💬 Join our [Discord](https://discord.gg/bzQavDfVV9) to discuss ideas or ask for help
|
||||
- 𝕏 Follow [@idebpalash](https://x.com/idebpalash) for updates and what's being built next
|
||||
|
||||
---
|
||||
|
||||
## ❓ FAQ
|
||||
|
||||
<details>
|
||||
<summary><b>Is this really as good as ElevenLabs?</b></summary>
|
||||
<br/>
|
||||
Honest answer: <b>it depends on what you're doing.</b>
|
||||
|
||||
<b>Where VoiceStudio is genuinely competitive:</b> voice cloning from a clean reference clip (state-of-the-art open diffusion TTS), language coverage (646 languages vs. their 32), and everything structural — no per-character billing, no usage caps, no audio leaving your machine, full pipeline customizability (14 TTS engines, 11 ASR engines, your choice of translation).
|
||||
|
||||
<b>Where ElevenLabs still wins:</b> out-of-the-box consistency and polish, especially for English TTS. Their one model is heavily tuned; our quality depends on which engine you pick, your hardware, and — for cloning — the reference audio (a dry, close-mic clip clones dramatically better than a noisy or echoey one).
|
||||
|
||||
<b>For dubbing specifically:</b> a dub is a chain — transcription → translation → cloning → synthesis — only as good as its weakest link on <i>your</i> source material. If parts come out incoherent, check the segment table's <i>original</i> text first: when the transcription is already wrong, switch the ASR engine or use cleaner source audio — that's usually the fix, not the voice.
|
||||
|
||||
Try it on your real material — it's free and takes one download. Many users replace ElevenLabs outright; some keep both. Both outcomes are fine with us.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Why doesn't a longer reference clip sound more like me?</b></summary>
|
||||
<br/>
|
||||
Because VoiceStudio's cloning is <b>zero-shot</b>: your clip is a <i>prompt</i> the model conditions on at generation time — it is never trained on. Feeding it 2 hours doesn't teach it your voice; past a short window the extra audio is simply not used. The dubbing pipeline's reference builder targets ~8 s and hard-caps at 15 s (<code>backend/services/speaker_clone.py</code>), and engines cap the prompt themselves (VoxCPM2 trims references to 30 s). This is different from ElevenLabs <i>Professional</i> Voice Cloning, which fine-tunes a model on hours of your audio — that's a training job, not a bigger prompt.
|
||||
|
||||
<b>What actually moves clone quality is the clip, not its length.</b> Zero-shot cloning mirrors the acoustics and delivery of the prompt, so: record 5–15 seconds (~8 s is the sweet spot) of continuous natural speech, close to the mic, in a quiet room with no reverb or music — an echoey clip clones echoey. One speaker only, and read in the tone and pace you want the output to have, because the clone copies your delivery, not just your timbre. Recording a few candidate clips and comparing results beats any amount of extra footage.
|
||||
|
||||
<b>Want audiobook-grade, trained-on-your-voice fidelity?</b> That path exists, but it's offline fine-tuning, not an in-app button: prepare a dataset of your recordings (<a href="docs/data_preparation.md">docs/data_preparation.md</a>) and fine-tune the bundled checkpoint via <code>init_from_checkpoint</code> (<a href="docs/training.md">docs/training.md</a>). Fair warning — it's a technical, command-line workflow that needs a capable GPU and hours of transcribed audio. In-app fine-tuning / long-reference "professional" cloning is on the <a href="docs/ROADMAP.md">roadmap</a> as research only; no promised date.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Does it work on Apple Silicon (M1/M2/M3/M4)?</b></summary>
|
||||
<br/>
|
||||
Yes. MPS acceleration is auto-detected. MLX-optimized Whisper models are available for faster transcription on Apple hardware. <b>Intel Macs are not supported</b>: the app UI installs, but the local Python backend cannot run because PyTorch no longer ships Intel-Mac wheels (<a href="https://github.com/debpalash/VoiceStudio/issues/889">#889</a>) — an Intel Mac can only be used with a remote backend.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>How much VRAM do I need?</b></summary>
|
||||
<br/>
|
||||
<b>4 GB minimum.</b> With ≤8 GB, the TTS model is automatically offloaded to CPU during transcription. With 8+ GB, everything runs on GPU simultaneously. No GPU at all? CPU mode works — just slower (~3× for TTS).
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Can I use this commercially?</b></summary>
|
||||
<br/>
|
||||
<b>Yes — commercial use is free</b> under the <a href="https://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0</a>: run it, sell the audio you make, dub client videos, deploy it across your team. One obligation: if you <b>modify</b> VoiceStudio and offer the modified version to others over a network, you must share that modified source under the same terms. Embedding it in a closed-source product instead? A commercial license is available — see <a href="#license">License</a>.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>What languages are supported?</b></summary>
|
||||
<br/>
|
||||
646 languages for TTS via the VoiceStudio model. Transcription (WhisperX) supports 99 languages. Translation coverage depends on the target language pair.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Can I add my own TTS engine?</b></summary>
|
||||
<br/>
|
||||
Yes. Subclass <code>TTSBackend</code> in <code>backend/services/tts_backend.py</code> and add it to the <code>_REGISTRY</code> dictionary — ~50 lines. The fourteen built-in engines all work this way; see <a href="#tts-engines">TTS Engines</a>.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Does VoiceStudio collect any data about me?</b></summary>
|
||||
<br/>
|
||||
<b>Not unless you explicitly say yes.</b> On first run the app <i>asks</i> — one screen, two equal-weight buttons, no pre-ticked box — and until you answer yes, VoiceStudio sends nothing: no analytics, no telemetry, no accounts, no phone-home. Skipping the question means no. Your text, audio, voices, and projects never leave your machine either way.
|
||||
|
||||
If you do opt in (also togglable anytime under <b>Settings → Privacy → "Help improve VoiceStudio"</b>), what's sent is anonymous, content-free usage stats: generations (engine, language, generation time, character <i>count</i>, error <i>type</i>), plus app lifecycle — an install ping, updates (version-to-version), crashes (error class and a <i>bucketed</i> uptime, never logs), error <i>types</i> (capped, deduplicated), and a single uninstall ping if you remove it. Never your text, audio, file names, or anything identifying — enforced in code by a property allowlist (<code>backend/core/analytics.py</code>), not just a promise. Every build — installer, Docker, or built from source — asks the same first-run question and stays off unless you say yes (the destination is PostHog's publishable write-only client key; skipping the question means off). Your own numbers live in <b>Settings → Usage</b>, computed locally, sent nowhere.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>How do I uninstall it / remove all its data?</b></summary>
|
||||
<br/>
|
||||
VoiceStudio is fully local — uninstalling is just deleting the app plus the folders it wrote (model cache, Python env, your voices/projects, config). Run <code>scripts/uninstall.sh</code> (macOS/Linux) or <code>scripts\uninstall.ps1</code> (Windows) — it prints every folder with its size as a dry-run first, then deletes on <code>--yes</code>. The full per-platform path list and app-removal steps are in <a href="docs/install/uninstall.md"><b>docs/install/uninstall.md</b></a>.
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<a id="license"></a>
|
||||
|
||||
## 📜 License
|
||||
|
||||
VoiceStudio is free and open-source software under the [**GNU Affero General Public License v3.0 (AGPL-3.0)**](https://www.gnu.org/licenses/agpl-3.0.html).
|
||||
|
||||
**Free for any use — including commercial and internal business use.** Run it, sell the audio you produce with it, dub your own or clients' videos, roll it out across your team — all free, no license needed. As a **network copyleft** license, AGPL adds one obligation: if you **modify** VoiceStudio and offer that modified version to others over a network, you must make the complete corresponding source of your modified version available to them under the same AGPL-3.0 terms.
|
||||
|
||||
A **commercial license** is available for organizations that want to embed VoiceStudio in a **closed-source or proprietary** product or service without the AGPL-3.0 copyleft obligations. **Pricing tiers coming soon.** Inquiries: **VoiceStudio@palash.dev**.
|
||||
|
||||
The bundled `omnivoice/` TTS model by Han Zhu remains Apache-2.0 upstream. See [`LICENSE`](LICENSE) for the full, binding terms, and [`LICENSE-NOTICE.md`](LICENSE-NOTICE.md) for the plain-language summary and scope.
|
||||
|
||||
---
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
|
||||
VoiceStudio is built on the shoulders of exceptional open-source work:
|
||||
|
||||
| Project | Role |
|
||||
|---------|------|
|
||||
| [**VoiceStudio (k2-fsa)**](https://github.com/k2-fsa/OmniVoice) | Zero-shot diffusion TTS engine — the core voice synthesis model |
|
||||
| [**WhisperX**](https://github.com/m-bain/whisperX) | Word-level speech recognition and alignment |
|
||||
| [**Demucs (Meta)**](https://github.com/facebookresearch/demucs) | Music source separation for vocal isolation |
|
||||
| [**Pyannote**](https://github.com/pyannote/pyannote-audio) | Speaker diarization — who said what |
|
||||
| [**CTranslate2**](https://github.com/OpenNMT/CTranslate2) | Optimized Transformer inference on CPU and GPU |
|
||||
| [**AudioSeal (Meta)**](https://github.com/facebookresearch/audioseal) | Invisible neural audio watermarking for AI provenance |
|
||||
| [**Tauri**](https://tauri.app) | Native desktop app framework |
|
||||
| [**Supertone / Supertonic 3**](https://huggingface.co/Supertone/supertonic-3) | ONNX TTS engine — 31 languages, CPU-efficient |
|
||||
| [**Sherpa-ONNX**](https://github.com/k2-fsa/sherpa-onnx) | WASM-ready universal TTS/ASR runtime |
|
||||
| [**GPT-SoVITS**](https://github.com/RVC-Boss/GPT-SoVITS) | Zero-shot TTS engine — 5 languages, RTF 0.014 |
|
||||
|
||||
---
|
||||
|
||||
<a id="more-from-the-maker"></a>
|
||||
|
||||
## 🧰 More local open-source from the maker
|
||||
|
||||
Like the local-first philosophy? It runs in the family — same maker, same rule: **your data stays on your machine.**
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="50%" valign="top">
|
||||
<br/>
|
||||
<a href="https://github.com/debpalash/Opal"><img src="https://raw.githubusercontent.com/debpalash/Opal/main/assets/opal_logo.png" width="96" alt="Opal logo"/></a>
|
||||
<h3><a href="https://github.com/debpalash/Opal">Opal 💠</a></h3>
|
||||
<p><b>Play everything.</b> The media player for the AI era.</p>
|
||||
<p><sub>Video, anime, comics, torrents, Jellyfin & Plex — one player for all of it, with local AI memory and context built in. Written in Zig, runs on macOS & Windows.</sub></p>
|
||||
<p>
|
||||
<a href="https://github.com/debpalash/Opal/stargazers"><img src="https://img.shields.io/github/stars/debpalash/Opal?style=flat-square&color=f59e0b" alt="Opal stars"/></a>
|
||||
<a href="https://palash.dev/opal"><img src="https://img.shields.io/badge/site-palash.dev%2Fopal-8b5cf6?style=flat-square" alt="Opal website"/></a>
|
||||
</p>
|
||||
</td>
|
||||
<td align="center" width="50%" valign="top">
|
||||
<br/>
|
||||
<a href="https://github.com/debpalash/memxt"><img src="https://raw.githubusercontent.com/debpalash/memxt/main/assets/logo-mark.svg" width="96" alt="memxt logo"/></a>
|
||||
<h3><a href="https://github.com/debpalash/memxt">memxt 🧠</a></h3>
|
||||
<p><b>The fastest benchmarked open-source AI memory system.</b></p>
|
||||
<p><sub>Local long-term memory for Claude Code and coding agents — an MCP server on SQLite + embeddings, 100% on your machine. Your agent finally remembers yesterday.</sub></p>
|
||||
<p>
|
||||
<a href="https://github.com/debpalash/memxt/stargazers"><img src="https://img.shields.io/github/stars/debpalash/memxt?style=flat-square&color=f59e0b" alt="memxt stars"/></a>
|
||||
<a href="https://github.com/debpalash/memxt#readme"><img src="https://img.shields.io/badge/docs-README-10b981?style=flat-square" alt="memxt docs"/></a>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
<br/>
|
||||
|
||||
If you read this far, you're our kind of person.<br/>
|
||||
**[⭐ Star this repo](https://github.com/debpalash/VoiceStudio)** so others can find it too.<br/>
|
||||
**[💬 Join the Discord](https://discord.gg/bzQavDfVV9)** to share what you build.<br/>
|
||||
**[❤️ Support development](https://ko-fi.com/debpalash)** — fund the AI agent bills that keep VoiceStudio shipping.
|
||||
|
||||
<br/>
|
||||
|
||||
<a href="https://star-history.com/#debpalash/VoiceStudio&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=debpalash/VoiceStudio&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=debpalash/VoiceStudio&type=Date" />
|
||||
<img alt="Star History" src="https://api.star-history.com/svg?repos=debpalash/VoiceStudio&type=Date&theme=dark" width="600" />
|
||||
</picture>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
+52
-59
@@ -37,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 种语言创作,不需要订阅,也没有用量计费。联网引擎和服务始终是清晰可见的可选项,而不是隐藏依赖。
|
||||
@@ -45,56 +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)
|
||||
|
||||
**三步克隆出你的第一个声音:**
|
||||
|
||||
1. **安装并启动。** 首次启动会自动搭建 Python 运行环境并下载模型权重——启动画面会逐步显示进度(仅首次,需要几分钟;之后即开即用)。
|
||||
2. 从启动台打开**语音克隆**,拖入任意声音的 **3 秒音频**。
|
||||
3. **输入一句话,点击生成。** 音频完全属于你——在你的设备上生成和保存,支持 646 种语言。
|
||||
|
||||
觉得慢?[docs/performance.md](docs/performance.md) 讲清了生成时间到底花在哪里、有哪些调优开关,以及“它变慢了”的三个经典原因。各引擎/设备的实测数据见 [docs/benchmarks.md](docs/benchmarks.md)。
|
||||
|
||||
> 正在从 **[CorentinJ/Real-Time-Voice-Cloning](https://github.com/CorentinJ/Real-Time-Voice-Cloning)**(现已归档)迁移过来?我们有专门的迁移指南:[docs/migration/real-time-voice-cloning.md](docs/migration/real-time-voice-cloning.md)。
|
||||
|
||||
<details>
|
||||
<summary><b>🧰 卡住了?自检、Token 与受限网络</b></summary>
|
||||
|
||||
<br/>
|
||||
|
||||
先运行内置自检——在应用中打开 **设置 → 关于 → “运行自检”**,或在源码检出目录中执行
|
||||
`uv run python backend/main.py --diagnose`(加 `--deep` 还会实际加载当前引擎进行测试)。然后查看
|
||||
[docs/install/troubleshooting.md](docs/install/troubleshooting.md) 中排名前
|
||||
10 的安装错误。运行时出错时,应用内的错误界面会直接深链到对应条目;**设置 → 关于 →
|
||||
“保存诊断包”** 会把脱敏日志与自检报告打包,方便附在 Bug 报告里。
|
||||
|
||||
Hugging Face Token 的配置见
|
||||
[docs/setup/huggingface-token.md](docs/setup/huggingface-token.md)。说话人分离相关的模型访问门槛见
|
||||
[docs/features/diarization.md](docs/features/diarization.md)。下载速度、⚡ 快速下载(Xet)状态,以及受限网络 / 镜像选项见
|
||||
[docs/downloading-models.md](docs/downloading-models.md)。
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<a id="features"></a>
|
||||
|
||||
## ✨ 功能
|
||||
@@ -162,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?
|
||||
@@ -180,8 +173,8 @@ Hugging Face Token 的配置见
|
||||
| **API 密钥** | 需要账号 | 本地流程不需要 |
|
||||
| **GPU 支持** | 不适用(云端) | CUDA · Apple Silicon · ROCm(Linux)· 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、扩展、发布 |
|
||||
@@ -221,10 +214,10 @@ Hugging Face Token 的配置见
|
||||
|
||||
### 🗣️ 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.5、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/>
|
||||
|
||||
@@ -261,10 +254,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/>
|
||||
|
||||
@@ -281,7 +274,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 GPU(Maxwell/Pascal、GTX 16xx)上,或在 CTranslate2/cuDNN 版本不匹配之后,CTranslate2 系 ASR 引擎(WhisperX、Faster-Whisper)无法运行 `float16`,VoiceStudio 会自动改用 `int8` 重试——无需配置。如果转录仍然失败,可用 `ASR_COMPUTE_TYPE` 环境变量固定计算类型(逃生舱口):`ASR_COMPUTE_TYPE=int8`(CPU 用 `float32`)。将其设为 `int8` 并重启后端。
|
||||
|
||||
@@ -581,7 +574,7 @@ VoiceStudio 站在这些杰出开源工作的肩膀上:
|
||||
|
||||
## 🧰 来自同一作者的更多本地开源项目
|
||||
|
||||
喜欢这种本地优先的理念?它是一脉相承的——同一位作者,同一条准则:**你的数据只留在你的设备上。** 全部项目见 [palash.dev](https://palash.dev)。
|
||||
喜欢这种本地优先的理念?它是一脉相承的——同一位作者,同一条准则:**你的数据只留在你的设备上。**
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
|
||||
+114
-80
@@ -17,21 +17,67 @@ Currently exposed:
|
||||
keep their own inline loopback guards.
|
||||
"""
|
||||
|
||||
import ipaddress
|
||||
import os
|
||||
import secrets
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from core.auth import (
|
||||
CredentialTransport,
|
||||
PrincipalKind,
|
||||
is_local_host,
|
||||
is_loopback,
|
||||
principal_for,
|
||||
remote_api_key,
|
||||
)
|
||||
from core.csrf import SAFE_HTTP_METHODS, cookie_csrf_allowed
|
||||
|
||||
# IPv4 + IPv6 loopback literals + the conventional `localhost` hostname.
|
||||
# `request.client.host` carries an address, not a hostname, so the literal
|
||||
# "localhost" entry is defensive — some upstream wrappers (TestClient with
|
||||
# a custom client tuple, certain reverse-proxy headers) may pass strings
|
||||
# rather than parsed addresses. We accept the broader set without weakening
|
||||
# the guard: nothing here matches a non-loopback origin.
|
||||
_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"})
|
||||
|
||||
|
||||
def _trusted_networks():
|
||||
"""CIDR networks from OMNIVOICE_TRUSTED_NETWORKS (comma-separated) treated as
|
||||
loopback-trusted — e.g. a reverse proxy or self-hosted LAN, so the API-key /
|
||||
PIN gates don't block LAN clients that can't present the credential (a proxy
|
||||
that strips the Authorization header). Read at call time (matching
|
||||
`_server_mode` / `remote_api_key`) so tests can monkeypatch the env; restart
|
||||
to apply changes in production."""
|
||||
nets = []
|
||||
for cidr in os.environ.get("OMNIVOICE_TRUSTED_NETWORKS", "").split(","):
|
||||
cidr = cidr.strip()
|
||||
if cidr:
|
||||
try:
|
||||
nets.append(ipaddress.ip_network(cidr, strict=False))
|
||||
except ValueError:
|
||||
pass # malformed entry ignored — never wedge the auth gate
|
||||
return nets
|
||||
|
||||
|
||||
def is_loopback(host):
|
||||
"""True loopback address only (127.0.0.1, ::1, localhost) — NOT a trusted
|
||||
network. Admin gates (``require_admin`` → ``/system/set-env``,
|
||||
``/api/settings/*``) use this so a trusted-network CIDR exempts consumption
|
||||
(TTS / dictation) but never the RCE-class admin surface."""
|
||||
return host in _LOOPBACK_HOSTS
|
||||
|
||||
|
||||
def is_local_host(host):
|
||||
"""Loopback address, OR on a configured trusted network. The consumption
|
||||
gates (PIN/API-key middleware, WS guard) call this so a trusted LAN/proxy is
|
||||
exempted. Admin gates use :func:`is_loopback` — NOT this — to preserve the
|
||||
two-tier privilege model: consumption trust ≠ admin trust."""
|
||||
if is_loopback(host):
|
||||
return True
|
||||
try:
|
||||
ip = ipaddress.ip_address(host)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
# Unwrap IPv4-mapped IPv6 (::ffff:192.168.1.5) so it matches IPv4 CIDRs —
|
||||
# dual-stack proxies (Caddy, Node.js) frequently pass the mapped form.
|
||||
if getattr(ip, "ipv4_mapped", None):
|
||||
ip = ip.ipv4_mapped
|
||||
return any(ip in net for net in _trusted_networks())
|
||||
|
||||
_TRUTHY = frozenset({"1", "true", "yes", "on"})
|
||||
_READ_ONLY_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
|
||||
|
||||
|
||||
def _server_mode() -> bool:
|
||||
@@ -54,13 +100,32 @@ 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 remote_api_key() -> str | None:
|
||||
"""The normalized remote-backend bearer key, or None when remote mode is
|
||||
off. Surrounding whitespace is configuration noise, never a valid secret.
|
||||
Read at call time so tests can monkeypatch the environment."""
|
||||
return os.environ.get("OMNIVOICE_API_KEY", "").strip() or None
|
||||
|
||||
|
||||
def presented_api_key(connection) -> str:
|
||||
"""Return the first non-empty normalized API key on an HTTP/WS connection.
|
||||
|
||||
Authorization wins over query, which wins over cookie. Each channel is
|
||||
stripped before fallback so whitespace in a higher-priority channel cannot
|
||||
shadow a valid lower-priority credential.
|
||||
"""
|
||||
headers = getattr(connection, "headers", None) or {}
|
||||
query = getattr(connection, "query_params", None) or {}
|
||||
cookies = getattr(connection, "cookies", None) or {}
|
||||
|
||||
auth = headers.get("authorization", "")
|
||||
supplied = auth[7:].strip() if auth.lower().startswith("bearer ") else ""
|
||||
if supplied:
|
||||
return supplied
|
||||
supplied = (query.get("api_key") or "").strip()
|
||||
if supplied:
|
||||
return supplied
|
||||
return (cookies.get("ov_key") or "").strip()
|
||||
|
||||
|
||||
def _configured_pin(request) -> str | None:
|
||||
@@ -85,34 +150,25 @@ def _admin_credential_configured(request) -> bool:
|
||||
return bool(_configured_pin(request))
|
||||
|
||||
|
||||
def _request_presents_admin_credential(
|
||||
request,
|
||||
*,
|
||||
side_effectful_get: bool = False,
|
||||
) -> bool:
|
||||
"""Whether the canonical principal carries remote admin capability.
|
||||
def _request_presents_admin_credential(request) -> bool:
|
||||
"""Whether the request carries a valid **API key** via the channels the
|
||||
middleware accepts (``Authorization: Bearer`` / ``?api_key`` / ``ov_key``
|
||||
cookie).
|
||||
|
||||
API-key and short-lived session principals may unlock server-mode admin.
|
||||
PIN and trusted-network principals remain consumption-only.
|
||||
"""
|
||||
principal = principal_for(request)
|
||||
if principal.kind not in {
|
||||
PrincipalKind.API_KEY,
|
||||
PrincipalKind.ADMIN_SESSION,
|
||||
}:
|
||||
Admin is RCE-class (``/system/set-env`` + ``/api/settings/*``), so only the
|
||||
API key — a long operator-chosen secret — unlocks it. The 6-digit share PIN
|
||||
is deliberately NOT accepted here: it is a *consumption* credential for LAN
|
||||
playback and is short enough to brute-force (10^6, no lockout), so it must
|
||||
never gate the admin surface (CodeRabbit #1213). A trusted-network CIDR
|
||||
(``is_local_host`` — also a consumption exemption) likewise never unlocks
|
||||
admin. Net: remote admin in server mode requires the API key; a PIN-only
|
||||
deployment keeps admin loopback-only. getattr-defensive so a minimal Request
|
||||
stub never raises."""
|
||||
api_key = remote_api_key() or ""
|
||||
if not api_key:
|
||||
return False
|
||||
if principal.transport not in {
|
||||
CredentialTransport.COOKIE,
|
||||
CredentialTransport.LEGACY_COOKIE,
|
||||
}:
|
||||
return True
|
||||
method = str(getattr(request, "method", "GET")).upper()
|
||||
if side_effectful_get or method not in SAFE_HTTP_METHODS:
|
||||
return cookie_csrf_allowed(
|
||||
request,
|
||||
side_effectful_get=side_effectful_get,
|
||||
)
|
||||
return True
|
||||
supplied = presented_api_key(request)
|
||||
return bool(supplied and secrets.compare_digest(supplied, api_key))
|
||||
|
||||
|
||||
def require_loopback(request: Request) -> None:
|
||||
@@ -153,7 +209,7 @@ def require_loopback(request: Request) -> None:
|
||||
return
|
||||
if _server_mode():
|
||||
method = str(getattr(request, "method", "GET")).upper()
|
||||
if method not in SAFE_HTTP_METHODS:
|
||||
if method not in _READ_ONLY_METHODS:
|
||||
# Defense in depth. Privileged routers should declare
|
||||
# ``require_admin`` directly, but a missed migration must not turn
|
||||
# into an unauthenticated Docker write primitive.
|
||||
@@ -166,31 +222,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,12 +240,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
|
||||
read_only = method in _READ_ONLY_METHODS
|
||||
if read_only and not _admin_credential_configured(request):
|
||||
return
|
||||
if _request_presents_admin_credential(request):
|
||||
return
|
||||
_admin_gate_403()
|
||||
raise HTTPException(status_code=403, detail="loopback origin or admin API key required")
|
||||
|
||||
|
||||
def require_admin_action(request: Request) -> None:
|
||||
@@ -227,12 +258,9 @@ def require_admin_action(request: Request) -> None:
|
||||
host = request.client.host if request.client else None
|
||||
if is_loopback(host):
|
||||
return
|
||||
if _server_mode() and _request_presents_admin_credential(
|
||||
request,
|
||||
side_effectful_get=True,
|
||||
):
|
||||
if _server_mode() and _request_presents_admin_credential(request):
|
||||
return
|
||||
_admin_gate_403()
|
||||
raise HTTPException(status_code=403, detail="loopback origin or admin API key required")
|
||||
|
||||
|
||||
def require_desktop(request: Request) -> None:
|
||||
@@ -279,8 +307,14 @@ def require_native_access(request: Request) -> None:
|
||||
|
||||
|
||||
def ws_remote_authorized(websocket) -> bool:
|
||||
"""Whether the canonical WS principal has a remote admin credential."""
|
||||
return principal_for(websocket).kind in {
|
||||
PrincipalKind.API_KEY,
|
||||
PrincipalKind.ADMIN_SESSION,
|
||||
}
|
||||
"""Whether a WebSocket handshake presents the remote API key.
|
||||
|
||||
Browser WebSockets cannot set an Authorization header, so the key may
|
||||
arrive as ``?api_key=`` or via the ``ov_key`` cookie that the bearer
|
||||
middleware sets on the first authenticated HTTP request. Returns False
|
||||
when remote mode is off — callers keep their loopback-only behavior.
|
||||
"""
|
||||
key = remote_api_key()
|
||||
if not key:
|
||||
return False
|
||||
return secrets.compare_digest(presented_api_key(websocket), key)
|
||||
|
||||
@@ -26,10 +26,8 @@ Design notes
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
@@ -39,7 +37,6 @@ from fastapi import APIRouter, Body, HTTPException, Query
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from core import archetypes
|
||||
from core.audio_validation import is_playable_wav, resolve_regular_file
|
||||
from core.config import OUTPUTS_DIR, VOICES_DIR
|
||||
from services import gallery
|
||||
|
||||
@@ -72,153 +69,6 @@ def _preview_key(a: dict) -> str:
|
||||
).hexdigest()[:16]
|
||||
|
||||
|
||||
def _design_profile_values(a: dict) -> tuple[str, str]:
|
||||
"""Canonical instruct + complete picker state for a designed archetype."""
|
||||
return a["instruct"], json.dumps(a["attrs"], sort_keys=True)
|
||||
|
||||
|
||||
def _profile_audio_path(ref_audio_path: object) -> Optional[Path]:
|
||||
"""Resolve only a regular, non-symlinked file inside ``VOICES_DIR``."""
|
||||
return resolve_regular_file(VOICES_DIR, ref_audio_path)
|
||||
|
||||
|
||||
def _materialized_audio_is_current(row, a: dict) -> bool:
|
||||
"""Whether an existing row still has the sample described by its metadata."""
|
||||
expected_filename = _profile_audio_filename(row["id"])
|
||||
path = _profile_audio_path(row["ref_audio_path"])
|
||||
return bool(
|
||||
row["ref_audio_path"] == expected_filename
|
||||
and is_playable_wav(path)
|
||||
and row["instruct"] == a["instruct"]
|
||||
and row["language"] == a["language"]
|
||||
and row["ref_text"] == a["sample_script"]
|
||||
and row["seed"] == _PREVIEW_SEED
|
||||
)
|
||||
|
||||
|
||||
def _profile_audio_filename(profile_id: str) -> str:
|
||||
safe_id = (
|
||||
profile_id if re.fullmatch(r"[A-Za-z0-9_-]{1,64}", profile_id or "")
|
||||
else hashlib.sha256(str(profile_id).encode("utf-8")).hexdigest()[:16]
|
||||
)
|
||||
return f"{safe_id}.wav"
|
||||
|
||||
|
||||
def _archetype_personality(a: dict) -> str:
|
||||
return f"archetype:{a['id']}"
|
||||
|
||||
|
||||
def _legacy_archetype_profile(conn, a: dict):
|
||||
"""Adopt only a row that an older archetype materializer could have made."""
|
||||
row = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE personality=? LIMIT 1",
|
||||
(a["id"],),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
expected_audio = _profile_audio_filename(row["id"])
|
||||
try:
|
||||
states_match = (
|
||||
not row["vd_states"] or json.loads(row["vd_states"]) == a["attrs"]
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
states_match = False
|
||||
if (
|
||||
row["ref_audio_path"] == expected_audio
|
||||
and row["instruct"] == a["instruct"]
|
||||
and row["language"] == a["language"]
|
||||
and row["ref_text"] == a["sample_script"]
|
||||
and row["seed"] == _PREVIEW_SEED
|
||||
and row["kind"] in (None, "", "clone", "design")
|
||||
and not row["is_locked"]
|
||||
and not row["verified_own_voice"]
|
||||
and states_match
|
||||
):
|
||||
return row
|
||||
return None
|
||||
|
||||
|
||||
def _is_materialized_archetype_row(row, a: dict) -> bool:
|
||||
"""Recognize rows owned by this materializer without trusting identity text alone."""
|
||||
try:
|
||||
states_match = json.loads(row["vd_states"]) == a["attrs"]
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return bool(
|
||||
row["personality"] == _archetype_personality(a)
|
||||
and row["kind"] == "design"
|
||||
and row["seed"] == _PREVIEW_SEED
|
||||
and row["ref_audio_path"] == _profile_audio_filename(row["id"])
|
||||
and row["instruct"] == a["instruct"]
|
||||
and row["language"] == a["language"]
|
||||
and row["ref_text"] == a["sample_script"]
|
||||
and states_match
|
||||
and not row["is_locked"]
|
||||
and not row["verified_own_voice"]
|
||||
)
|
||||
|
||||
|
||||
def _existing_archetype_profile(conn, a: dict):
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE personality=? ORDER BY created_at, id",
|
||||
(_archetype_personality(a),),
|
||||
).fetchall()
|
||||
owned = next((row for row in rows if _is_materialized_archetype_row(row, a)), None)
|
||||
return owned if owned is not None else _legacy_archetype_profile(conn, a)
|
||||
|
||||
|
||||
async def _render_profile_audio(
|
||||
a: dict, profile_id: str, *, publish: bool = True,
|
||||
) -> tuple[str, Path]:
|
||||
"""Render one validated sample, optionally staging it for a later CAS."""
|
||||
audio_filename = _profile_audio_filename(profile_id)
|
||||
safe_id = Path(audio_filename).stem
|
||||
audio_path = Path(VOICES_DIR) / audio_filename
|
||||
if publish:
|
||||
await _render_wav_atomic(a, audio_path, prefix=f".{safe_id}-")
|
||||
else:
|
||||
audio_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
audio_path = audio_path.parent / f".{safe_id}-{uuid.uuid4().hex}.staged.wav"
|
||||
try:
|
||||
await _render_archetype_wav(a, audio_path)
|
||||
if not is_playable_wav(audio_path):
|
||||
raise RuntimeError("the voice engine produced an invalid WAV")
|
||||
except BaseException:
|
||||
with __import__("contextlib").suppress(OSError):
|
||||
audio_path.unlink()
|
||||
raise
|
||||
return audio_filename, audio_path
|
||||
|
||||
|
||||
async def _render_wav_atomic(a: dict, out_path: Path, *, prefix: str = ".render-") -> Path:
|
||||
"""Render and validate a WAV before atomically replacing *out_path*."""
|
||||
audio_path = Path(out_path)
|
||||
audio_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp_path = audio_path.parent / f"{prefix}{uuid.uuid4().hex}.wav"
|
||||
try:
|
||||
await _render_archetype_wav(a, tmp_path)
|
||||
if not is_playable_wav(tmp_path):
|
||||
raise RuntimeError("the voice engine produced an invalid WAV")
|
||||
os.replace(tmp_path, audio_path)
|
||||
finally:
|
||||
with __import__("contextlib").suppress(OSError):
|
||||
tmp_path.unlink()
|
||||
return audio_path
|
||||
|
||||
|
||||
def _heal_materialized_profile(conn, row, a: dict, audio_filename: str) -> None:
|
||||
"""Repair profiles created before archetype `/use` persisted design kind."""
|
||||
instruct, vd_states = _design_profile_values(a)
|
||||
conn.execute(
|
||||
"UPDATE voice_profiles SET kind='design', instruct=?, vd_states=?, language=?, "
|
||||
"ref_text=?, seed=?, ref_audio_path=?, personality=? WHERE id=?",
|
||||
(
|
||||
instruct, vd_states, a["language"], a["sample_script"], _PREVIEW_SEED,
|
||||
audio_filename, _archetype_personality(a), row["id"],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# A non-empty script is always required — synthesizing empty text yields
|
||||
# silence. Every archetype carries a use-case script, but guard the render path
|
||||
# too so a malformed archetype can never drive a blank render.
|
||||
@@ -330,7 +180,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,11 +207,15 @@ 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)
|
||||
@@ -401,7 +255,7 @@ def _preview_source(a: dict) -> tuple[str, str]:
|
||||
"Pre-rendered preview from the voice gallery — a fixed reference "
|
||||
"rendering, not a render from your current engine."
|
||||
)
|
||||
if is_playable_wav(_PREVIEW_DIR / f"{key}.wav"):
|
||||
if (_PREVIEW_DIR / f"{key}.wav").exists():
|
||||
return "cached", ""
|
||||
if _no_voice_model_downloaded():
|
||||
return "no_model", (
|
||||
@@ -534,9 +388,9 @@ async def preview_archetype(
|
||||
)
|
||||
|
||||
cache_path = _PREVIEW_DIR / f"{key}.wav"
|
||||
if not is_playable_wav(cache_path):
|
||||
if not cache_path.exists():
|
||||
try:
|
||||
await _render_wav_atomic(a, cache_path, prefix=".preview-")
|
||||
await _render_archetype_wav(a, cache_path)
|
||||
except Exception as e: # model missing / OOM / inference failure
|
||||
logger.error("Archetype preview render failed", exc_info=True)
|
||||
# Two different failures, two different answers. Without a model
|
||||
@@ -588,122 +442,70 @@ async def use_archetype(archetype_id: str, name: Optional[str] = Query(None)):
|
||||
# Idempotent (dedup): an archetype materializes to exactly ONE voice profile.
|
||||
# Picking the same gallery voice again — from any picker (Gallery grid,
|
||||
# VoiceSelector, …) — must reuse that one row instead of rendering + inserting
|
||||
# a fresh duplicate every time. Use a namespaced personality identity so an
|
||||
# imported persona cannot collide with and be rewritten by an archetype id.
|
||||
# a fresh duplicate every time. The `personality` column already carries the
|
||||
# source archetype id (stamped by the INSERT below), so it's the natural
|
||||
# dedup key; the expensive render + INSERT only run on first use.
|
||||
with db_conn() as conn:
|
||||
existing = _existing_archetype_profile(conn, a)
|
||||
|
||||
profile_id = existing["id"] if existing is not None else str(uuid.uuid4())[:8]
|
||||
audio_path: Optional[Path] = None
|
||||
if existing is not None and _materialized_audio_is_current(existing, a):
|
||||
audio_filename = existing["ref_audio_path"]
|
||||
else:
|
||||
try:
|
||||
audio_filename, audio_path = await _render_profile_audio(
|
||||
a, profile_id, publish=existing is None,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Archetype 'use' render failed", exc_info=True)
|
||||
# Same actionable/diagnostic split as /preview — minus the gallery
|
||||
# suggestion, which cannot help here.
|
||||
if _no_voice_model_downloaded():
|
||||
detail = (
|
||||
"Creating a voice needs the voice model — no voice model is "
|
||||
"downloaded yet. Model Catalogue → Models → Download."
|
||||
)
|
||||
else:
|
||||
detail = (
|
||||
"Couldn't create a voice from this archetype — the voice engine "
|
||||
f"reported: {e}"
|
||||
)
|
||||
raise HTTPException(status_code=503, detail=detail) from e
|
||||
|
||||
existing = conn.execute(
|
||||
"SELECT id, name FROM voice_profiles WHERE personality = ? LIMIT 1",
|
||||
(a["id"],),
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
with db_conn() as conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
current = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id=?", (existing["id"],),
|
||||
).fetchone()
|
||||
owned = _existing_archetype_profile(conn, a)
|
||||
still_owned = current is not None and (
|
||||
owned is not None and owned["id"] == current["id"]
|
||||
)
|
||||
if still_owned:
|
||||
if audio_path is not None:
|
||||
destination = Path(VOICES_DIR) / audio_filename
|
||||
os.replace(audio_path, destination)
|
||||
audio_path = None
|
||||
_heal_materialized_profile(conn, current, a, audio_filename)
|
||||
existing_result = {"profile_id": current["id"], "name": current["name"]}
|
||||
else:
|
||||
existing_result = None
|
||||
if existing_result is not None:
|
||||
event_bus.emit("profiles", {"action": "updated", "id": existing_result["profile_id"]})
|
||||
return existing_result
|
||||
# The row was edited/deleted while rendering. Preserve it and use the
|
||||
# validated staged sample for a fresh canonical materialization.
|
||||
profile_id = str(uuid.uuid4())[:8]
|
||||
audio_filename = _profile_audio_filename(profile_id)
|
||||
destination = Path(VOICES_DIR) / audio_filename
|
||||
if audio_path is None:
|
||||
try:
|
||||
audio_filename, audio_path = await _render_profile_audio(a, profile_id)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Couldn't create a voice from this archetype.",
|
||||
) from e
|
||||
else:
|
||||
os.replace(audio_path, destination)
|
||||
audio_path = destination
|
||||
return {"profile_id": existing["id"], "name": existing["name"]}
|
||||
|
||||
if audio_path is None: # defensive: a new profile always rendered above
|
||||
raise RuntimeError("new archetype profile has no rendered audio")
|
||||
profile_id = str(uuid.uuid4())[:8]
|
||||
audio_filename = f"{profile_id}.wav"
|
||||
audio_path = Path(VOICES_DIR) / audio_filename
|
||||
|
||||
try:
|
||||
await _render_archetype_wav(a, audio_path)
|
||||
except Exception as e:
|
||||
logger.error("Archetype 'use' render failed", exc_info=True)
|
||||
# Same actionable/diagnostic split as /preview — minus the gallery
|
||||
# suggestion, which cannot help here.
|
||||
if _no_voice_model_downloaded():
|
||||
detail = (
|
||||
"Creating a voice needs the voice model — no voice model is "
|
||||
"downloaded yet. Model Catalogue → Models → Download."
|
||||
)
|
||||
else:
|
||||
detail = (
|
||||
"Couldn't create a voice from this archetype — the voice engine "
|
||||
f"reported: {e}"
|
||||
)
|
||||
raise HTTPException(status_code=503, detail=detail)
|
||||
|
||||
profile_name = (name or a["name"]).strip() or a["name"]
|
||||
try:
|
||||
with db_conn() as conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
# Re-check under the write connection right before inserting: a
|
||||
# concurrent /use for the same archetype may have inserted while we
|
||||
# were rendering (the pre-render SELECT above raced). Reuse that row
|
||||
# and drop our just-rendered sample instead of creating a duplicate.
|
||||
# `personality` is not globally UNIQUE, so serialize and re-check.
|
||||
dup = _existing_archetype_profile(conn, a)
|
||||
# (personality is NOT globally unique — marketplace/persona imports
|
||||
# reuse the column — so a UNIQUE index isn't an option; this closes
|
||||
# the realistic window for the single-user desktop app.)
|
||||
dup = conn.execute(
|
||||
"SELECT id, name FROM voice_profiles WHERE personality = ? LIMIT 1",
|
||||
(a["id"],),
|
||||
).fetchone()
|
||||
if dup is not None:
|
||||
duplicate_audio = dup["ref_audio_path"]
|
||||
if not _materialized_audio_is_current(dup, a):
|
||||
duplicate_audio = _profile_audio_filename(dup["id"])
|
||||
_duplicate_path = Path(VOICES_DIR) / duplicate_audio
|
||||
_duplicate_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
os.replace(audio_path, _duplicate_path)
|
||||
audio_path = None
|
||||
_heal_materialized_profile(conn, dup, a, duplicate_audio)
|
||||
with __import__("contextlib").suppress(OSError):
|
||||
if audio_path is not None:
|
||||
os.remove(audio_path)
|
||||
duplicate_result = {"profile_id": dup["id"], "name": dup["name"]}
|
||||
else:
|
||||
duplicate_result = None
|
||||
if duplicate_result is None:
|
||||
instruct, vd_states = _design_profile_values(a)
|
||||
conn.execute(
|
||||
"INSERT INTO voice_profiles "
|
||||
"(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, "
|
||||
"created_at, kind, vd_states) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'design', ?)",
|
||||
(
|
||||
profile_id, profile_name, audio_filename, a["sample_script"],
|
||||
instruct, a["language"], _PREVIEW_SEED,
|
||||
_archetype_personality(a), time.time(), vd_states,
|
||||
),
|
||||
)
|
||||
os.remove(audio_path)
|
||||
return {"profile_id": dup["id"], "name": dup["name"]}
|
||||
conn.execute(
|
||||
"INSERT INTO voice_profiles "
|
||||
"(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, created_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
profile_id, profile_name, audio_filename, a["sample_script"],
|
||||
a["instruct"], a["language"], _PREVIEW_SEED, a["id"], time.time(),
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
with __import__("contextlib").suppress(OSError):
|
||||
if audio_path is not None:
|
||||
os.remove(audio_path)
|
||||
os.remove(audio_path)
|
||||
raise
|
||||
|
||||
if duplicate_result is not None:
|
||||
event_bus.emit("profiles", {"action": "updated", "id": duplicate_result["profile_id"]})
|
||||
return duplicate_result
|
||||
event_bus.emit("profiles", {"action": "created", "id": profile_id})
|
||||
return {"profile_id": profile_id, "name": profile_name}
|
||||
|
||||
@@ -361,7 +361,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 +380,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 +508,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}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
+13
-188
@@ -103,75 +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
|
||||
|
||||
|
||||
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
|
||||
@@ -348,111 +279,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
|
||||
@@ -530,15 +356,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)
|
||||
@@ -592,15 +413,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
|
||||
|
||||
@@ -27,10 +27,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 +34,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 +47,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,79 +70,17 @@ _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:
|
||||
def _requested_pcm_sample_rate(query_params) -> int | None:
|
||||
"""Return a bounded PCM rate for ``?pcm=1``/``?aec=1`` sessions."""
|
||||
raw_pcm = query_params.get("pcm") in ("1", "true", "on")
|
||||
aec = query_params.get("aec") in ("1", "true", "on")
|
||||
if not raw_pcm and not aec:
|
||||
return None
|
||||
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)
|
||||
return sample_rate if 8000 <= sample_rate <= 96000 else 16000
|
||||
|
||||
|
||||
def _demux_aec_frame(data: bytes) -> tuple[str, bytes]:
|
||||
@@ -210,47 +137,21 @@ 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
|
||||
@@ -265,16 +166,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,
|
||||
@@ -397,7 +288,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 +422,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 +448,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 +498,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 +569,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 +610,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 +618,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 +644,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 +653,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 +697,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 +716,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 +740,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 +766,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 +777,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 +824,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 +884,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 +898,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 +912,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 +924,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
|
||||
|
||||
@@ -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)
|
||||
|
||||
+34
-326
@@ -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,38 +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. Keeping this an
|
||||
# allow-list rejects language names and private-use BCP-47 tags before they are
|
||||
# persisted as ASR overrides. 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",
|
||||
})
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
@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.
|
||||
|
||||
@@ -579,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}")
|
||||
@@ -591,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,
|
||||
@@ -619,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
|
||||
@@ -655,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(
|
||||
@@ -720,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,
|
||||
@@ -1167,12 +912,6 @@ async def dub_transcribe_stream(
|
||||
# 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] = []
|
||||
@@ -1242,13 +981,21 @@ 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.
|
||||
task = asyncio.ensure_future(run_transcribe_guarded(
|
||||
_gpu_pool, _transcribe_chunk,
|
||||
what=f"Dub chunk {i + 1}/{chunks_n}",
|
||||
@@ -1263,12 +1010,9 @@ 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).
|
||||
logger.error(
|
||||
"Transcribe chunk %d/%d timed out after %.0fs (attempt %d/%d, job=%s)",
|
||||
i + 1, chunks_n, transcribe_timeout_s, _attempt,
|
||||
@@ -1287,36 +1031,23 @@ 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.
|
||||
# A completed exception did not wedge the worker. Resetting
|
||||
# the pool here leaked a healthy executor on every ordinary
|
||||
# decode failure; run_transcribe_guarded already resets the
|
||||
# pool on the only case that needs it: a real timeout.
|
||||
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).
|
||||
@@ -1582,25 +1313,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
|
||||
@@ -1809,9 +1522,7 @@ async def dub_transcribe_stream(
|
||||
except Exception as e:
|
||||
logger.warning("speaker_clone extraction skipped: %s", e)
|
||||
|
||||
job["source_lang"] = job.get("source_lang_override") or (
|
||||
(detected_lang or "en").split("_")[0][:2] or "en"
|
||||
).lower()
|
||||
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)
|
||||
|
||||
@@ -2008,9 +1719,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_lang or "en").split("_")[0][:2] or "en"
|
||||
).lower()
|
||||
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)
|
||||
@@ -2066,8 +1775,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
|
||||
|
||||
@@ -572,7 +572,7 @@ 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."),
|
||||
@@ -607,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")
|
||||
|
||||
@@ -643,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:
|
||||
@@ -671,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)},
|
||||
@@ -917,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"]
|
||||
@@ -1599,10 +1566,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)},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
import struct
|
||||
import logging
|
||||
import time
|
||||
import asyncio
|
||||
@@ -81,62 +80,6 @@ def _prepare_oom_retry(error: Exception, *, execution_target: str) -> bool:
|
||||
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
|
||||
@@ -503,18 +446,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)
|
||||
@@ -662,11 +593,11 @@ async def dub_generate(job_id: str, req: DubRequest):
|
||||
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 +608,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
|
||||
@@ -828,38 +759,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:
|
||||
@@ -1183,7 +1091,7 @@ async def dub_generate(job_id: str, req: DubRequest):
|
||||
_num_step, req.guidance_scale, seg_speed, seg_profile, seg_effect_preset,
|
||||
),
|
||||
what="Dub generate",
|
||||
timeout=generate_timeout_s(seg.text, engine=backend),
|
||||
timeout=generate_timeout_s(seg.text),
|
||||
)
|
||||
_t_tts += time.perf_counter() - _t_tts_0
|
||||
|
||||
@@ -1256,15 +1164,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"
|
||||
|
||||
@@ -1303,15 +1208,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 +1356,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)
|
||||
@@ -1915,7 +1798,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
|
||||
|
||||
@@ -41,15 +41,6 @@ _FAMILIES = {
|
||||
"llm": (llm_backend, "llm_backend"),
|
||||
}
|
||||
|
||||
|
||||
def _family_payload(family: str, module):
|
||||
"""Public inventory plus whether an environment pin owns this family."""
|
||||
return {
|
||||
"active": module.active_backend_id(),
|
||||
"env_override": bool(os.environ.get(f"OMNIVOICE_{family.upper()}_BACKEND")),
|
||||
"backends": public_backends(module.list_backends()),
|
||||
}
|
||||
|
||||
def _is_hf_repo_id(value: str) -> bool:
|
||||
"""Validate the route's ``owner/repo`` contract in bounded time."""
|
||||
if not isinstance(value, str) or len(value) > 96 or value.count("/") != 1:
|
||||
@@ -64,25 +55,34 @@ def _is_hf_repo_id(value: str) -> bool:
|
||||
@router.get("/engines")
|
||||
def list_all_engines():
|
||||
return {
|
||||
"tts": _family_payload("tts", tts_backend),
|
||||
"asr": _family_payload("asr", asr_backend),
|
||||
"llm": _family_payload("llm", llm_backend),
|
||||
"tts": {
|
||||
"active": tts_backend.active_backend_id(),
|
||||
"backends": public_backends(tts_backend.list_backends()),
|
||||
},
|
||||
"asr": {
|
||||
"active": asr_backend.active_backend_id(),
|
||||
"backends": public_backends(asr_backend.list_backends()),
|
||||
},
|
||||
"llm": {
|
||||
"active": llm_backend.active_backend_id(),
|
||||
"backends": public_backends(llm_backend.list_backends()),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/engines/tts")
|
||||
def list_tts_backends():
|
||||
return _family_payload("tts", tts_backend)
|
||||
return {"active": tts_backend.active_backend_id(), "backends": public_backends(tts_backend.list_backends())}
|
||||
|
||||
|
||||
@router.get("/engines/asr")
|
||||
def list_asr_backends():
|
||||
return _family_payload("asr", asr_backend)
|
||||
return {"active": asr_backend.active_backend_id(), "backends": public_backends(asr_backend.list_backends())}
|
||||
|
||||
|
||||
@router.get("/engines/llm")
|
||||
def list_llm_backends():
|
||||
return _family_payload("llm", llm_backend)
|
||||
return {"active": llm_backend.active_backend_id(), "backends": public_backends(llm_backend.list_backends())}
|
||||
|
||||
|
||||
@router.get("/engines/effects/presets", response_model=EffectPresetsResponse)
|
||||
|
||||
+86
-229
@@ -1,24 +1,18 @@
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
import json
|
||||
import uuid
|
||||
import time
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional, List
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, File, Form, UploadFile, HTTPException, Query
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.responses import FileResponse, RedirectResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from core.db import db_conn
|
||||
from core.config import VOICES_DIR, OUTPUTS_DIR
|
||||
from core import event_bus
|
||||
from core.audio_validation import resolve_regular_file
|
||||
from core.file_cleanup import FileCleanupError, unlink_if_present
|
||||
from services.ffmpeg_utils import spawn_subprocess
|
||||
|
||||
@@ -366,223 +360,46 @@ async def upload_voice_clip(
|
||||
}
|
||||
|
||||
|
||||
def _stage_profile_audio(source: Path, directory: Path) -> Path:
|
||||
"""Copy an imported clip to a hidden temp file inside ``directory``.
|
||||
|
||||
The temp lives in the destination directory itself so a later
|
||||
``os.replace`` to the final name is an atomic same-filesystem rename —
|
||||
cheap enough to run while holding a DB write lock, unlike the copy.
|
||||
Callers own cleanup of the returned path if they never publish it.
|
||||
"""
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp_name = tempfile.mkstemp(
|
||||
dir=str(directory), prefix=".gallery-import-", suffix=".part",
|
||||
)
|
||||
os.close(fd)
|
||||
try:
|
||||
shutil.copy2(source, tmp_name)
|
||||
except BaseException:
|
||||
with contextlib.suppress(OSError):
|
||||
os.unlink(tmp_name)
|
||||
raise
|
||||
return Path(tmp_name)
|
||||
|
||||
|
||||
def _copy_profile_audio(source: Path, destination: Path) -> None:
|
||||
"""Copy an imported clip without exposing a partial profile audio file."""
|
||||
staged = _stage_profile_audio(source, destination.parent)
|
||||
try:
|
||||
os.replace(staged, destination)
|
||||
except BaseException:
|
||||
with contextlib.suppress(OSError):
|
||||
os.unlink(staged)
|
||||
raise
|
||||
|
||||
|
||||
def _gallery_profile_audio_filename(profile_id: str, source: Path) -> str:
|
||||
"""Return the canonical, portable filename for a My Imports profile."""
|
||||
safe_id = (
|
||||
profile_id if re.fullmatch(r"[A-Za-z0-9_-]{1,64}", profile_id or "")
|
||||
else uuid.uuid5(uuid.NAMESPACE_URL, str(profile_id)).hex[:16]
|
||||
)
|
||||
suffix = source.suffix.lower()
|
||||
if not re.fullmatch(r"\.[a-z0-9]{1,8}", suffix):
|
||||
suffix = ".wav"
|
||||
return f"{safe_id}_gallery{suffix}"
|
||||
|
||||
|
||||
def _is_materialized_gallery_profile(row, voice: dict, audio_filename: str) -> bool:
|
||||
"""Recognize only rows created by this materializer, not identity collisions."""
|
||||
return bool(
|
||||
row["personality"] == f"gallery:{voice['id']}"
|
||||
and row["ref_audio_path"] == audio_filename
|
||||
and row["ref_text"] == ""
|
||||
and row["instruct"] == ""
|
||||
and row["language"] == "Auto"
|
||||
and row["seed"] is None
|
||||
and row["kind"] == "clone"
|
||||
and not row["vd_states"]
|
||||
and row["description"] == (voice.get("description") or "")
|
||||
and not row["is_locked"]
|
||||
and not row["verified_own_voice"]
|
||||
and not row["locked_audio_path"]
|
||||
)
|
||||
|
||||
|
||||
def _existing_gallery_profile(conn, voice: dict, source: Path):
|
||||
personality = f"gallery:{voice['id']}"
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE personality=? ORDER BY created_at, id",
|
||||
(personality,),
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
expected = _gallery_profile_audio_filename(row["id"], source)
|
||||
if _is_materialized_gallery_profile(row, voice, expected):
|
||||
return row
|
||||
return None
|
||||
|
||||
|
||||
def _gallery_profile_audio_is_current(row, source: Path) -> bool:
|
||||
"""Detect missing/replaced copies without re-hashing unchanged imports."""
|
||||
destination = resolve_regular_file(VOICES_DIR, row["ref_audio_path"])
|
||||
if destination is None:
|
||||
return False
|
||||
try:
|
||||
source_stat = source.stat()
|
||||
destination_stat = destination.stat()
|
||||
# copy2 preserves mtime; size + nanosecond mtime catches ordinary edits
|
||||
# and partial writes while keeping repeated Use clicks inexpensive.
|
||||
return (
|
||||
source_stat.st_size == destination_stat.st_size
|
||||
and source_stat.st_mtime_ns == destination_stat.st_mtime_ns
|
||||
)
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _materialize_gallery_profile(
|
||||
voice_id: str, requested_name: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""Idempotently materialize/heal one My Imports clip as a clone profile."""
|
||||
personality = f"gallery:{voice_id}"
|
||||
copied_path: Optional[Path] = None
|
||||
created = False
|
||||
staged_path: Optional[Path] = None
|
||||
staged_source: Optional[Path] = None
|
||||
try:
|
||||
# Stage the (potentially large) audio copy BEFORE taking SQLite's
|
||||
# write lock: copying inside BEGIN IMMEDIATE would stall every other
|
||||
# backend writer for the whole copy. The staged temp lives in
|
||||
# VOICES_DIR itself, so publishing it inside the transaction is an
|
||||
# atomic same-filesystem os.replace. This pre-read is advisory only —
|
||||
# the locked transaction below re-reads and re-decides everything.
|
||||
copy_needed = False
|
||||
with db_conn() as conn:
|
||||
pre_row = conn.execute(
|
||||
"SELECT * FROM voice_gallery WHERE id = ?", (voice_id,),
|
||||
).fetchone()
|
||||
if pre_row is not None:
|
||||
pre_source = Path(pre_row["audio_path"])
|
||||
if pre_source.is_file():
|
||||
pre_existing = _existing_gallery_profile(conn, dict(pre_row), pre_source)
|
||||
copy_needed = pre_existing is None or not _gallery_profile_audio_is_current(
|
||||
pre_existing, pre_source,
|
||||
)
|
||||
if copy_needed:
|
||||
staged_path = _stage_profile_audio(pre_source, Path(VOICES_DIR))
|
||||
staged_source = pre_source
|
||||
|
||||
with db_conn() as conn:
|
||||
# The identity is not globally UNIQUE because personality is shared
|
||||
# with other import mechanisms. Serialize this check+insert in
|
||||
# SQLite so simultaneous Use clicks cannot both create a row.
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
row = conn.execute(
|
||||
"SELECT * FROM voice_gallery WHERE id = ?", (voice_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Voice not found")
|
||||
|
||||
voice = dict(row)
|
||||
source = Path(voice["audio_path"])
|
||||
if not source.is_file():
|
||||
raise HTTPException(status_code=404, detail="Audio file not found on disk")
|
||||
|
||||
def _install_audio(destination: Path) -> None:
|
||||
"""Publish the staged copy under the lock via atomic rename."""
|
||||
nonlocal staged_path
|
||||
if staged_path is not None and staged_source == source:
|
||||
os.replace(staged_path, destination)
|
||||
staged_path = None
|
||||
else:
|
||||
# Rare race: the gallery row changed between the advisory
|
||||
# pre-read and taking the lock, so any staged bytes may be
|
||||
# from the wrong source. Fall back to the blocking copy
|
||||
# rather than publish stale audio.
|
||||
_copy_profile_audio(source, destination)
|
||||
|
||||
existing = _existing_gallery_profile(conn, voice, source)
|
||||
if existing is not None:
|
||||
ref_filename = _gallery_profile_audio_filename(existing["id"], source)
|
||||
if not _gallery_profile_audio_is_current(existing, source):
|
||||
ref_path = Path(VOICES_DIR) / ref_filename
|
||||
_install_audio(ref_path)
|
||||
copied_path = ref_path
|
||||
conn.execute(
|
||||
"UPDATE voice_profiles SET ref_audio_path=?, ref_text='', instruct='', "
|
||||
"language='Auto', seed=NULL, description=?, kind='clone', vd_states=NULL, "
|
||||
"personality=? WHERE id=?",
|
||||
(
|
||||
ref_filename, voice["description"] or "", personality,
|
||||
existing["id"],
|
||||
),
|
||||
)
|
||||
result = {"profile_id": existing["id"], "name": existing["name"]}
|
||||
else:
|
||||
profile_id = str(uuid.uuid4())[:8]
|
||||
profile_name = (requested_name or voice["name"]).strip() or voice["name"]
|
||||
ref_filename = _gallery_profile_audio_filename(profile_id, source)
|
||||
copied_path = Path(VOICES_DIR) / ref_filename
|
||||
_install_audio(copied_path)
|
||||
conn.execute(
|
||||
"""INSERT INTO voice_profiles
|
||||
(id, name, ref_audio_path, ref_text, instruct, language, seed,
|
||||
personality, is_locked, locked_audio_path, description, kind,
|
||||
vd_states, created_at)
|
||||
VALUES (?, ?, ?, '', '', 'Auto', NULL, ?, 0, '', ?, 'clone', NULL, ?)""",
|
||||
(
|
||||
profile_id, profile_name, ref_filename, personality,
|
||||
voice["description"] or "", time.time(),
|
||||
),
|
||||
)
|
||||
created = True
|
||||
result = {"profile_id": profile_id, "name": profile_name}
|
||||
except BaseException:
|
||||
if copied_path is not None:
|
||||
with contextlib.suppress(OSError):
|
||||
copied_path.unlink()
|
||||
raise
|
||||
finally:
|
||||
# Staged but never published (failure, or a concurrent request healed
|
||||
# the profile first) — never leave .part droppings in VOICES_DIR.
|
||||
if staged_path is not None:
|
||||
with contextlib.suppress(OSError):
|
||||
os.unlink(staged_path)
|
||||
|
||||
event_bus.emit(
|
||||
"profiles", {"action": "created" if created else "updated", "id": result["profile_id"]},
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/gallery/voices/{voice_id}/save-as-profile")
|
||||
async def save_voice_as_profile(
|
||||
voice_id: str,
|
||||
profile_name: str = Query(..., description="Name for the voice profile"),
|
||||
):
|
||||
"""Save a gallery voice as a voice profile for cloning."""
|
||||
result = await asyncio.to_thread(_materialize_gallery_profile, voice_id, profile_name)
|
||||
return {"profile_id": result["profile_id"], "name": result["name"]}
|
||||
with db_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM voice_gallery WHERE id = ?", (voice_id,)
|
||||
).fetchone()
|
||||
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Voice not found")
|
||||
|
||||
profile_id = str(uuid.uuid4())[:8]
|
||||
import shutil
|
||||
|
||||
ext = os.path.splitext(row["audio_path"])[1]
|
||||
new_audio_path = os.path.join(VOICES_DIR, f"{profile_id}{ext}")
|
||||
shutil.copy(row["audio_path"], new_audio_path)
|
||||
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO voice_profiles (id, name, ref_audio_path, ref_text, instruct, language, seed, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
profile_id,
|
||||
profile_name,
|
||||
f"{profile_id}{ext}",
|
||||
row["description"] or "",
|
||||
row["character"] or "",
|
||||
"Auto",
|
||||
None,
|
||||
time.time(),
|
||||
),
|
||||
)
|
||||
event_bus.emit("profiles", {"action": "created", "id": profile_id})
|
||||
|
||||
return {"profile_id": profile_id, "name": profile_name}
|
||||
|
||||
|
||||
@router.get("/gallery/voices/{voice_id}/preview")
|
||||
@@ -598,10 +415,22 @@ def preview_voice(voice_id: str):
|
||||
|
||||
audio_path = row["audio_path"]
|
||||
|
||||
if os.path.isabs(audio_path) and os.path.exists(audio_path):
|
||||
# Serve the file from this API route so deployments mounted below a
|
||||
# path prefix do not lose that prefix while following a redirect.
|
||||
return FileResponse(audio_path)
|
||||
# Debug logging
|
||||
is_absolute = os.path.isabs(audio_path)
|
||||
path_exists = os.path.exists(audio_path) if audio_path else False
|
||||
|
||||
# If absolute path, serve directly or redirect
|
||||
if is_absolute and path_exists:
|
||||
# Get just the relative path from outputs dir
|
||||
outputs_path = str(OUTPUTS_DIR)
|
||||
if audio_path.startswith(outputs_path):
|
||||
# Remove outputs_dir prefix to get relative path within outputs
|
||||
rel_path = os.path.relpath(audio_path, outputs_path)
|
||||
# The audio_path is like: /Users/user4/.../outputs/voice_gallery/file.wav
|
||||
# rel_path becomes: voice_gallery/file.wav
|
||||
# We want to serve from /audio/ so: /audio/voice_gallery/file.wav
|
||||
return RedirectResponse(f"/audio/{rel_path}")
|
||||
return FileResponse(audio_path, media_type="audio/wav")
|
||||
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
@@ -674,5 +503,33 @@ def batch_delete_voices(body: dict):
|
||||
@router.post("/gallery/voices/{voice_id}/to-profile")
|
||||
def voice_to_profile(voice_id: str):
|
||||
"""Create a voice profile from a gallery clip."""
|
||||
result = _materialize_gallery_profile(voice_id)
|
||||
return {"success": True, "profile_id": result["profile_id"], "name": result["name"]}
|
||||
with db_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM voice_gallery WHERE id = ?", (voice_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Voice not found")
|
||||
|
||||
voice = dict(row)
|
||||
audio_path = voice["audio_path"]
|
||||
if not os.path.exists(audio_path):
|
||||
raise HTTPException(status_code=404, detail="Audio file not found on disk")
|
||||
|
||||
import shutil
|
||||
import uuid
|
||||
|
||||
profile_id = str(uuid.uuid4())[:8]
|
||||
# Copy audio to voices dir
|
||||
dest_filename = f"{profile_id}_gallery.wav"
|
||||
dest_path = os.path.join(VOICES_DIR, dest_filename)
|
||||
shutil.copy2(audio_path, dest_path)
|
||||
|
||||
import time
|
||||
now = time.time()
|
||||
conn.execute(
|
||||
"""INSERT INTO voice_profiles
|
||||
(id, name, ref_audio_path, ref_text, instruct, seed, is_locked, locked_audio_path, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(profile_id, voice["name"], dest_filename, "", None, None, 0, None, now, now),
|
||||
)
|
||||
event_bus.emit("profiles", {"action": "created", "id": profile_id})
|
||||
|
||||
return {"success": True, "profile_id": profile_id, "name": voice["name"]}
|
||||
|
||||
@@ -8,7 +8,6 @@ import asyncio
|
||||
import tempfile
|
||||
import contextlib
|
||||
import logging
|
||||
import threading
|
||||
import traceback
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, File, Form, UploadFile, HTTPException
|
||||
@@ -33,83 +32,6 @@ router = APIRouter()
|
||||
logger = logging.getLogger("omnivoice.generate")
|
||||
|
||||
|
||||
class _TempReferenceLease:
|
||||
"""Delete a request-owned reference once every abandoned reader drains."""
|
||||
|
||||
def __init__(self, path: str):
|
||||
self.path = path
|
||||
self._lock = threading.Lock()
|
||||
self._active = 0
|
||||
self._request_done = False
|
||||
self._deleted = False
|
||||
|
||||
def acquire(self):
|
||||
with self._lock:
|
||||
if self._request_done:
|
||||
raise RuntimeError("reference lease acquired after request cleanup")
|
||||
self._active += 1
|
||||
once_lock = threading.Lock()
|
||||
released = False
|
||||
|
||||
def release() -> None:
|
||||
nonlocal released
|
||||
with once_lock:
|
||||
if released:
|
||||
return
|
||||
released = True
|
||||
self._release()
|
||||
|
||||
return release
|
||||
|
||||
def _release(self) -> None:
|
||||
delete = False
|
||||
with self._lock:
|
||||
self._active -= 1
|
||||
if self._active < 0:
|
||||
raise RuntimeError("reference lease released too many times")
|
||||
if self._request_done and self._active == 0 and not self._deleted:
|
||||
self._deleted = True
|
||||
delete = True
|
||||
if delete:
|
||||
with contextlib.suppress(OSError):
|
||||
os.remove(self.path)
|
||||
|
||||
def finish_request(self) -> None:
|
||||
delete = False
|
||||
with self._lock:
|
||||
self._request_done = True
|
||||
if self._active == 0 and not self._deleted:
|
||||
self._deleted = True
|
||||
delete = True
|
||||
if delete:
|
||||
with contextlib.suppress(OSError):
|
||||
os.remove(self.path)
|
||||
|
||||
|
||||
async def _run_with_reference_lease(lease, factory):
|
||||
"""Hold an ad-hoc reference through one local GPU-pool dispatch."""
|
||||
if lease is None:
|
||||
return await factory(None)
|
||||
release = lease.acquire()
|
||||
abandoned = False
|
||||
try:
|
||||
return await factory(release)
|
||||
except GpuPoolBusyError:
|
||||
# Busy means no job started; release now. The callback may already have
|
||||
# done so, and the lease token is deliberately idempotent.
|
||||
release()
|
||||
abandoned = True
|
||||
raise
|
||||
except (asyncio.CancelledError, GpuJobTimeoutError):
|
||||
# The guard owns release now: immediately for a queued cancellation,
|
||||
# or from the worker finalizer after an in-flight job drains.
|
||||
abandoned = True
|
||||
raise
|
||||
finally:
|
||||
if not abandoned:
|
||||
release()
|
||||
|
||||
|
||||
def _profile_instruct(row):
|
||||
"""Validator-safe instruct for a stored profile row.
|
||||
|
||||
@@ -459,31 +381,6 @@ def _is_timeout_failure(e) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _is_media_process_launch_failure(exc: BaseException) -> bool:
|
||||
"""Identify an ffmpeg/ffprobe launch ENOENT without guessing from a file name."""
|
||||
if not isinstance(exc, FileNotFoundError):
|
||||
return False
|
||||
|
||||
# A regular missing reference/model file may itself be named "ffmpeg".
|
||||
# Require the innermost raise site to be Python's process launcher so that
|
||||
# basename collisions keep the normal missing-file diagnosis (#1677).
|
||||
traceback_cursor = exc.__traceback__
|
||||
if traceback_cursor is None:
|
||||
return False
|
||||
while traceback_cursor.tb_next is not None:
|
||||
traceback_cursor = traceback_cursor.tb_next
|
||||
origin_module = traceback_cursor.tb_frame.f_globals.get("__name__", "")
|
||||
if origin_module != "subprocess" and not origin_module.startswith("asyncio."):
|
||||
return False
|
||||
|
||||
filename = getattr(exc, "filename", None)
|
||||
if not filename:
|
||||
return "[winerror 2]" in str(exc).lower()
|
||||
return os.path.basename(str(filename)).lower() in {
|
||||
"ffmpeg", "ffmpeg.exe", "ffprobe", "ffprobe.exe",
|
||||
}
|
||||
|
||||
|
||||
def _oom_friendly_reraise(e):
|
||||
"""Best-effort cache flush + the user-facing OOM hint shared by both
|
||||
inference paths."""
|
||||
@@ -508,21 +405,6 @@ def _oom_friendly_reraise(e):
|
||||
# that lost its +x bit) is NOT an OOM — don't send the user to the Flush
|
||||
# button; tell them what's actually wrong.
|
||||
es = str(e)
|
||||
# #1677: Windows CreateProcess reports a missing executable as a bare
|
||||
# ``FileNotFoundError: [WinError 2] ...`` with no filename, while POSIX
|
||||
# includes the missing ffmpeg/ffprobe name. The bundled-media downloader
|
||||
# now republishes PATH as soon as it finishes, but a failed/blocked
|
||||
# download still needs an actionable recovery rather than the unknown-
|
||||
# error dead end. Keep missing reference/model files on their own path.
|
||||
for _exc in _exception_chain(e):
|
||||
if _is_media_process_launch_failure(_exc):
|
||||
raise RuntimeError(
|
||||
"A required media program couldn't be launched. Open "
|
||||
"Settings → Audio tools and use "
|
||||
"Download/Repair for the media engine, then retry. If Audio "
|
||||
"tools is already ready, repair the selected TTS engine and "
|
||||
f"restart VoiceStudio. Underlying error: {_safe_exc_text(_exc)}"
|
||||
) from e
|
||||
if isinstance(e, PermissionError) or "Permission denied" in es or "Errno 13" in es:
|
||||
raise RuntimeError(
|
||||
f"A required engine binary couldn't be executed (permission denied). "
|
||||
@@ -714,7 +596,7 @@ def _oom_friendly_reraise(e):
|
||||
) from e
|
||||
|
||||
|
||||
def _generate_timeout_s(text: str, *, execution_device=None) -> float:
|
||||
def _generate_timeout_s(text: str) -> float:
|
||||
"""Wall-clock budget for one generate, scaled to the request.
|
||||
|
||||
Thin alias for the canonical helper, which moved to
|
||||
@@ -723,7 +605,7 @@ def _generate_timeout_s(text: str, *, execution_device=None) -> float:
|
||||
as they did, silently keeping the flat 300s).
|
||||
"""
|
||||
from services.model_manager import generate_timeout_s
|
||||
return generate_timeout_s(text, execution_device=execution_device)
|
||||
return generate_timeout_s(text)
|
||||
|
||||
|
||||
def _run_inference(
|
||||
@@ -813,17 +695,15 @@ def _run_backend_inference(
|
||||
backend, text, language, ref_audio_path, ref_text, instruct, duration,
|
||||
num_step, guidance_scale, speed, denoise, postprocess_output,
|
||||
used_seed, effect_preset="broadcast",
|
||||
max_chunk_chars=None, crossfade_ms=None, *, t_shift=None,
|
||||
layer_penalty_factor=None, position_temperature=None,
|
||||
class_temperature=None, dropped_sink=None,
|
||||
max_chunk_chars=None, crossfade_ms=None, *, dropped_sink=None,
|
||||
):
|
||||
"""Engine-aware twin of :func:`_run_inference` (issue #312).
|
||||
|
||||
Runs the request through a pluggable ``TTSBackend`` adapter instead of the
|
||||
VoiceStudio model directly. A crash-isolated OmniVoice proxy advertises
|
||||
``supports_native_omnivoice_controls`` and receives the same advanced
|
||||
controls and per-call seed as the native path; other adapters keep the
|
||||
narrower protocol unchanged.
|
||||
VoiceStudio model directly. The adapter protocol is narrower than the
|
||||
VoiceStudio-native surface — engine-specific extras (``t_shift``,
|
||||
``layer_penalty_factor``, …) only exist on the native path, which is why
|
||||
VoiceStudio itself still goes through ``_run_inference``.
|
||||
"""
|
||||
import torch
|
||||
try:
|
||||
@@ -838,18 +718,6 @@ def _run_backend_inference(
|
||||
instruct=instruct, num_step=num_step, guidance_scale=guidance_scale,
|
||||
speed=speed, denoise=denoise, postprocess_output=postprocess_output,
|
||||
)
|
||||
native_proxy = bool(
|
||||
getattr(backend, "supports_native_omnivoice_controls", False)
|
||||
)
|
||||
if native_proxy:
|
||||
gen_kwargs.update({
|
||||
key: value for key, value in {
|
||||
"t_shift": t_shift,
|
||||
"layer_penalty_factor": layer_penalty_factor,
|
||||
"position_temperature": position_temperature,
|
||||
"class_temperature": class_temperature,
|
||||
}.items() if value is not None
|
||||
})
|
||||
sr = backend.sample_rate
|
||||
|
||||
# Inline [pause Nms] markers (issue #276) work for every engine — the
|
||||
@@ -859,17 +727,10 @@ def _run_backend_inference(
|
||||
has_pause = len(segments) > 1 or (segments and segments[0][1] > 0)
|
||||
|
||||
if has_pause:
|
||||
first_span = True
|
||||
|
||||
def _gen_span(span_text):
|
||||
nonlocal first_span
|
||||
# Per-span duration is left to the engine; an explicit overall
|
||||
# `duration` can't be meaningfully split across spans.
|
||||
span_kwargs = dict(gen_kwargs)
|
||||
if native_proxy and first_span and used_seed is not None:
|
||||
span_kwargs["seed"] = used_seed
|
||||
first_span = False
|
||||
return backend.generate(span_text, duration=None, **span_kwargs)
|
||||
return backend.generate(span_text, duration=None, **gen_kwargs)
|
||||
audio_out = _render_with_pauses(_gen_span, segments, sr)
|
||||
else:
|
||||
# Wave 1.2: sentence-boundary chunking for long text (see
|
||||
@@ -886,19 +747,12 @@ def _run_backend_inference(
|
||||
for i, chunk_text in enumerate(text_chunks):
|
||||
if used_seed is not None:
|
||||
torch.manual_seed(used_seed + i)
|
||||
chunk_kwargs = dict(gen_kwargs)
|
||||
if native_proxy and used_seed is not None:
|
||||
chunk_kwargs["seed"] = used_seed + i
|
||||
parts.append(backend.generate(
|
||||
chunk_text, duration=None, **chunk_kwargs
|
||||
))
|
||||
parts.append(backend.generate(chunk_text, duration=None, **gen_kwargs))
|
||||
_note_generate_progress()
|
||||
audio_out = concatenate_audio_chunks(parts, sr, _xfade_ms,
|
||||
texts=text_chunks,
|
||||
sink=dropped_sink)
|
||||
else:
|
||||
if native_proxy and used_seed is not None:
|
||||
gen_kwargs["seed"] = used_seed
|
||||
audio_out = backend.generate(text, duration=duration, **gen_kwargs)
|
||||
|
||||
return _apply_effect_chain(
|
||||
@@ -1016,6 +870,7 @@ async def _finalize_generation(
|
||||
Returns ``(watermarked_tensor, meta)`` where ``meta`` carries
|
||||
``id`` / ``filename`` / ``duration`` / ``gen_time``.
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
# Invisible AudioSeal provenance watermark on the final audio. Embedding
|
||||
# was previously only wired into the dub pipeline (dub_generate.py), so
|
||||
# plain TTS came out unmarked despite the setting being on — and the same
|
||||
@@ -1027,9 +882,12 @@ async def _finalize_generation(
|
||||
# AudioSeal embedding is CPU work that holds no VRAM, so occupying a GPU
|
||||
# worker with it only delays the next generate on 1-worker hosts.
|
||||
if not already_marked:
|
||||
from services.watermark import mark_synthetic_async
|
||||
audio_tensor = await mark_synthetic_async(
|
||||
audio_tensor, sample_rate, context="generate.finalize",
|
||||
from services.watermark import mark_synthetic
|
||||
from services.model_manager import get_watermark_pool
|
||||
audio_tensor = await loop.run_in_executor(
|
||||
get_watermark_pool(),
|
||||
functools.partial(mark_synthetic, audio_tensor, sample_rate,
|
||||
context="generate.finalize"),
|
||||
)
|
||||
gen_time = round(time.time() - start_time, 2)
|
||||
|
||||
@@ -1340,10 +1198,6 @@ async def generate_speech(
|
||||
_backend = None
|
||||
_engine_min_vram_gb = getattr(backend_cls, "min_vram_gb", 0.0)
|
||||
_routing_notice = None
|
||||
# Remote renders deliberately skip this host's capability gate. Keep the
|
||||
# local fallback call's timeout device-neutral so the closure is valid
|
||||
# without pretending the control plane describes the remote worker.
|
||||
_routing = {"effective_device": None}
|
||||
|
||||
if not _remote:
|
||||
# Single-active-engine memory discipline: hand back any OTHER resident
|
||||
@@ -1443,7 +1297,6 @@ async def generate_speech(
|
||||
|
||||
ref_audio_path = None
|
||||
cleanup_ref = False
|
||||
ref_lease = None
|
||||
used_seed = seed
|
||||
resolved_profile_id = None
|
||||
history_mode = None # profile.kind when a profile drives; else inferred at insert
|
||||
@@ -1531,7 +1384,6 @@ async def generate_speech(
|
||||
f.write(await ref_audio.read())
|
||||
ref_audio_path = f.name
|
||||
cleanup_ref = True
|
||||
ref_lease = _TempReferenceLease(ref_audio_path)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@@ -1548,19 +1400,13 @@ async def generate_speech(
|
||||
# built-in ASR fallback), so a timeout degrades to None rather than
|
||||
# failing the whole generate.
|
||||
try:
|
||||
ref_text = await _run_with_reference_lease(
|
||||
ref_lease,
|
||||
lambda release: run_on_gpu_pool_guarded(
|
||||
functools.partial(transcribe_reference, ref_audio_path),
|
||||
what="Reference transcribe",
|
||||
# Floor budget (#1190): a reference clip is seconds of audio,
|
||||
# so the length-scaled bonus never applies — but the timeout is
|
||||
# explicit here too, so no dispatch relies on a hidden default.
|
||||
timeout=_generate_timeout_s(
|
||||
"", execution_device=_routing["effective_device"]
|
||||
),
|
||||
on_abandon=release,
|
||||
)
|
||||
ref_text = await run_on_gpu_pool_guarded(
|
||||
functools.partial(transcribe_reference, ref_audio_path),
|
||||
what="Reference transcribe",
|
||||
# Floor budget (#1190): a reference clip is seconds of audio,
|
||||
# so the length-scaled bonus never applies — but the timeout is
|
||||
# explicit here too, so no dispatch relies on a hidden default.
|
||||
timeout=_generate_timeout_s(""),
|
||||
)
|
||||
# TimeoutError covers both the execution bound and pool saturation:
|
||||
# this path is best-effort either way.
|
||||
@@ -1677,7 +1523,7 @@ async def generate_speech(
|
||||
local=gpu_gateway.LocalCall(
|
||||
_remote_only_local_call(_target_label),
|
||||
what="TTS generate",
|
||||
timeout=_generate_timeout_s(text, execution_device=_routing["effective_device"]),
|
||||
timeout=_generate_timeout_s(text),
|
||||
min_vram_gb=_engine_min_vram_gb,
|
||||
),
|
||||
remote=_remote_call,
|
||||
@@ -1816,30 +1662,19 @@ async def generate_speech(
|
||||
"target_label": e.worker_label or _target_label,
|
||||
"hint": e.hint,
|
||||
})
|
||||
except Exception as exc:
|
||||
except Exception:
|
||||
# Mid-job remote failure is NOT quietly redone here: the client
|
||||
# treats a retryable error as "surface it", so the user decides
|
||||
# whether to spend the same minutes again on this machine. Like
|
||||
# the local streaming path, this in-band frame stands in for the
|
||||
# global 500 handler, so it journals the scrubbed failure and
|
||||
# names a recognized cause instead of the bare generic string
|
||||
# (#1607).
|
||||
logger.error(
|
||||
"Remote generation failed (class=%s)",
|
||||
type(exc).__name__,
|
||||
)
|
||||
from core.public_errors import stream_generation_failure
|
||||
from core import error_journal
|
||||
|
||||
error_journal.record(
|
||||
exc, route="/generate", trace=traceback.format_exc()
|
||||
)
|
||||
yield _line({"type": "error", **stream_generation_failure(exc)})
|
||||
# whether to spend the same minutes again on this machine.
|
||||
logger.error("Remote generation failed", exc_info=True)
|
||||
from core.public_errors import stream_failure
|
||||
yield _line({"type": "error", **stream_failure("generation_failed")})
|
||||
finally:
|
||||
if not render.done():
|
||||
render.cancel()
|
||||
if cleanup_ref and ref_lease is not None:
|
||||
ref_lease.finish_request()
|
||||
if cleanup_ref and ref_audio_path:
|
||||
with contextlib.suppress(OSError):
|
||||
os.remove(ref_audio_path)
|
||||
|
||||
return StreamingResponse(
|
||||
_remote_stream_events(),
|
||||
@@ -1881,17 +1716,6 @@ async def generate_speech(
|
||||
instruct=instruct, num_step=num_step,
|
||||
guidance_scale=guidance_scale, speed=speed,
|
||||
denoise=denoise, postprocess_output=postprocess_output,
|
||||
**({
|
||||
key: value for key, value in {
|
||||
"t_shift": t_shift,
|
||||
"layer_penalty_factor": layer_penalty_factor,
|
||||
"position_temperature": position_temperature,
|
||||
"class_temperature": class_temperature,
|
||||
"seed": used_seed + i if used_seed is not None else None,
|
||||
}.items() if value is not None
|
||||
} if getattr(
|
||||
_backend, "supports_native_omnivoice_controls", False
|
||||
) else {}),
|
||||
)
|
||||
sr = _backend.sample_rate
|
||||
skip = getattr(_backend, "applies_own_mastering", False)
|
||||
@@ -1955,45 +1779,33 @@ async def generate_speech(
|
||||
if _has_pause or len(_text_chunks) <= 1:
|
||||
# Single-shot pipeline, unchanged — streamed as one chunk.
|
||||
if _backend is not None:
|
||||
audio_tensor = await _run_with_reference_lease(
|
||||
ref_lease,
|
||||
lambda release: run_on_gpu_pool_guarded(
|
||||
functools.partial(
|
||||
_run_backend_inference,
|
||||
_backend, text, language, ref_audio_path, ref_text,
|
||||
instruct, duration, num_step, guidance_scale, speed,
|
||||
denoise, postprocess_output, used_seed, effect_preset,
|
||||
max_chunk_chars, crossfade_ms, t_shift=t_shift,
|
||||
layer_penalty_factor=layer_penalty_factor,
|
||||
position_temperature=position_temperature,
|
||||
class_temperature=class_temperature,
|
||||
dropped_sink=_dropped_sink,
|
||||
),
|
||||
what="TTS generate",
|
||||
min_vram_gb=_engine_min_vram_gb,
|
||||
timeout=_generate_timeout_s(text, execution_device=_routing["effective_device"]),
|
||||
on_abandon=release,
|
||||
)
|
||||
audio_tensor = await run_on_gpu_pool_guarded(
|
||||
functools.partial(
|
||||
_run_backend_inference,
|
||||
_backend, text, language, ref_audio_path, ref_text,
|
||||
instruct, duration, num_step, guidance_scale, speed,
|
||||
denoise, postprocess_output, used_seed, effect_preset,
|
||||
max_chunk_chars, crossfade_ms, dropped_sink=_dropped_sink,
|
||||
),
|
||||
what="TTS generate",
|
||||
min_vram_gb=_engine_min_vram_gb,
|
||||
timeout=_generate_timeout_s(text),
|
||||
)
|
||||
sample_rate = _backend.sample_rate
|
||||
else:
|
||||
audio_tensor = await _run_with_reference_lease(
|
||||
ref_lease,
|
||||
lambda release: run_on_gpu_pool_guarded(
|
||||
functools.partial(
|
||||
_run_inference,
|
||||
_model, text, language, ref_audio_path, ref_text,
|
||||
instruct, duration, num_step, guidance_scale, speed,
|
||||
t_shift, denoise, postprocess_output,
|
||||
layer_penalty_factor, position_temperature,
|
||||
class_temperature, used_seed, effect_preset,
|
||||
max_chunk_chars, crossfade_ms, dropped_sink=_dropped_sink,
|
||||
),
|
||||
what="TTS generate",
|
||||
min_vram_gb=_engine_min_vram_gb,
|
||||
timeout=_generate_timeout_s(text, execution_device=_routing["effective_device"]),
|
||||
on_abandon=release,
|
||||
)
|
||||
audio_tensor = await run_on_gpu_pool_guarded(
|
||||
functools.partial(
|
||||
_run_inference,
|
||||
_model, text, language, ref_audio_path, ref_text,
|
||||
instruct, duration, num_step, guidance_scale, speed,
|
||||
t_shift, denoise, postprocess_output,
|
||||
layer_penalty_factor, position_temperature,
|
||||
class_temperature, used_seed, effect_preset,
|
||||
max_chunk_chars, crossfade_ms, dropped_sink=_dropped_sink,
|
||||
),
|
||||
what="TTS generate",
|
||||
min_vram_gb=_engine_min_vram_gb,
|
||||
timeout=_generate_timeout_s(text),
|
||||
)
|
||||
sample_rate = _model.sampling_rate
|
||||
yield _line({
|
||||
@@ -2010,10 +1822,12 @@ async def generate_speech(
|
||||
# (#1190): AudioSeal embedding is CPU work that owns no
|
||||
# VRAM, and on a 1-worker host it used to serialize
|
||||
# directly ahead of the next generate.
|
||||
from services.watermark import mark_synthetic_async
|
||||
_preview = await mark_synthetic_async(
|
||||
audio_tensor, sample_rate,
|
||||
context="generate.stream_preview",
|
||||
from services.watermark import mark_synthetic
|
||||
from services.model_manager import get_watermark_pool
|
||||
_preview = await asyncio.get_running_loop().run_in_executor(
|
||||
get_watermark_pool(),
|
||||
functools.partial(mark_synthetic, audio_tensor, sample_rate,
|
||||
context="generate.stream_preview"),
|
||||
)
|
||||
yield _line({"type": "chunk", "seq": 0, "pcm": _pcm16_b64(_preview)})
|
||||
else:
|
||||
@@ -2022,27 +1836,25 @@ async def generate_speech(
|
||||
for i, chunk_text in enumerate(_text_chunks):
|
||||
# Bounded per chunk + pool-reset on hang (#730 class);
|
||||
# a timeout surfaces as an "error" event below.
|
||||
raw, preview, sample_rate = await _run_with_reference_lease(
|
||||
ref_lease,
|
||||
lambda release: run_on_gpu_pool_guarded(
|
||||
functools.partial(_render_stream_chunk, i, chunk_text),
|
||||
what="TTS generate",
|
||||
min_vram_gb=_engine_min_vram_gb,
|
||||
# Budget scaled to THIS chunk (#1190) — the flat
|
||||
# 300s here is what made long streamed renders fail
|
||||
# even after the v0.3.22 scaled budget shipped.
|
||||
timeout=_generate_timeout_s(chunk_text, execution_device=_routing["effective_device"]),
|
||||
on_abandon=release,
|
||||
)
|
||||
raw, preview, sample_rate = await run_on_gpu_pool_guarded(
|
||||
functools.partial(_render_stream_chunk, i, chunk_text),
|
||||
what="TTS generate",
|
||||
min_vram_gb=_engine_min_vram_gb,
|
||||
# Budget scaled to THIS chunk (#1190) — the flat
|
||||
# 300s here is what made long streamed renders fail
|
||||
# even after the v0.3.22 scaled budget shipped.
|
||||
timeout=_generate_timeout_s(chunk_text),
|
||||
)
|
||||
parts.append(raw)
|
||||
# Provenance-mark the streamed copy off the GPU pool
|
||||
# (#1169 mark, #1190 placement): CPU-only AudioSeal
|
||||
# work must not occupy a GPU worker between chunks.
|
||||
from services.watermark import mark_synthetic_async
|
||||
preview = await mark_synthetic_async(
|
||||
preview, sample_rate,
|
||||
context="generate.stream_preview",
|
||||
from services.watermark import mark_synthetic
|
||||
from services.model_manager import get_watermark_pool
|
||||
preview = await asyncio.get_running_loop().run_in_executor(
|
||||
get_watermark_pool(),
|
||||
functools.partial(mark_synthetic, preview, sample_rate,
|
||||
context="generate.stream_preview"),
|
||||
)
|
||||
if i == 0:
|
||||
# After the first render so lazy-loading engines
|
||||
@@ -2057,7 +1869,7 @@ async def generate_speech(
|
||||
audio_tensor = await run_on_gpu_pool_guarded(
|
||||
functools.partial(_assemble_stream_chunks, parts, sample_rate),
|
||||
what="TTS assemble",
|
||||
timeout=_generate_timeout_s(text, execution_device=_routing["effective_device"]),
|
||||
timeout=_generate_timeout_s(text),
|
||||
)
|
||||
|
||||
_, meta = await _finalize_generation(
|
||||
@@ -2086,7 +1898,7 @@ async def generate_speech(
|
||||
# Client went away mid-stream — same semantics as aborting a
|
||||
# classic /generate mid-render: nothing is saved.
|
||||
raise
|
||||
except GpuPoolBusyError as e:
|
||||
except (GpuJobTimeoutError, GpuPoolBusyError) as e:
|
||||
# In-band error frame carries the machine-readable retryable
|
||||
# marker (#1190) — an NDJSON consumer can back off instead of
|
||||
# guessing from the prose.
|
||||
@@ -2095,44 +1907,20 @@ async def generate_speech(
|
||||
failure = stream_failure("generation_busy")
|
||||
failure["retry_after"] = getattr(e, "retry_after", 30)
|
||||
yield _line({"type": "error", **failure})
|
||||
except GpuJobTimeoutError:
|
||||
# The worker started and spent its full execution budget. That
|
||||
# is compute time, not queue pressure (#1588).
|
||||
logger.error("Streaming generation exceeded its compute budget")
|
||||
from core.public_errors import stream_failure
|
||||
failure = stream_failure("generation_timeout")
|
||||
failure["retry_after"] = 30
|
||||
yield _line({"type": "error", **failure})
|
||||
except ValueError:
|
||||
logger.error("Streaming generation request rejected")
|
||||
from core.public_errors import stream_failure
|
||||
yield _line({"type": "error", **stream_failure("invalid_request")})
|
||||
except Exception as exc:
|
||||
# A streaming request answers 200 and carries its failure as an
|
||||
# in-band error frame, so it never reaches the global 500
|
||||
# handler — which is where a classic /generate failure gets its
|
||||
# scrubbed journal entry (Diagnostics / recent errors) AND its
|
||||
# classified, actionable message. Both have to be reproduced
|
||||
# here or a streaming generation failure is invisible in the
|
||||
# diagnostic bundle and opaque to the user (#1607). The raw
|
||||
# exception is NOT logged: it can carry a reference-clip path or
|
||||
# a provider secret, and only the journal scrubs before storing.
|
||||
logger.error(
|
||||
"Streaming generation failed unexpectedly (class=%s)",
|
||||
type(exc).__name__,
|
||||
)
|
||||
from core.public_errors import stream_generation_failure
|
||||
from core import error_journal
|
||||
|
||||
error_journal.record(
|
||||
exc, route="/generate", trace=traceback.format_exc()
|
||||
)
|
||||
yield _line({"type": "error", **stream_generation_failure(exc)})
|
||||
except Exception:
|
||||
logger.error("Streaming generation failed unexpectedly")
|
||||
from core.public_errors import stream_failure
|
||||
yield _line({"type": "error", **stream_failure("generation_failed")})
|
||||
finally:
|
||||
# Ownership of the temp reference clip moves to this generator
|
||||
# in stream mode (the route returns before rendering starts).
|
||||
if cleanup_ref and ref_lease is not None:
|
||||
ref_lease.finish_request()
|
||||
if cleanup_ref and ref_audio_path:
|
||||
with contextlib.suppress(OSError):
|
||||
os.remove(ref_audio_path)
|
||||
|
||||
# Routing notice (#21): known before the stream starts, so it rides the
|
||||
# same headers the classic path uses — and now also carries "your
|
||||
@@ -2170,11 +1958,7 @@ async def generate_speech(
|
||||
_backend, text, language, ref_audio_path, ref_text, instruct,
|
||||
duration, num_step, guidance_scale, speed, denoise,
|
||||
postprocess_output, used_seed, effect_preset,
|
||||
max_chunk_chars, crossfade_ms, t_shift=t_shift,
|
||||
layer_penalty_factor=layer_penalty_factor,
|
||||
position_temperature=position_temperature,
|
||||
class_temperature=class_temperature,
|
||||
dropped_sink=_dropped_text,
|
||||
max_chunk_chars, crossfade_ms, dropped_sink=_dropped_text,
|
||||
)
|
||||
else:
|
||||
_local_render = functools.partial(
|
||||
@@ -2185,18 +1969,14 @@ async def generate_speech(
|
||||
class_temperature, used_seed, effect_preset,
|
||||
max_chunk_chars, crossfade_ms, dropped_sink=_dropped_text,
|
||||
)
|
||||
audio_tensor = await _run_with_reference_lease(
|
||||
ref_lease,
|
||||
lambda release: gpu_gateway.run(
|
||||
_REMOTE_OP,
|
||||
local=gpu_gateway.LocalCall(
|
||||
_local_render, what="TTS generate",
|
||||
timeout=_generate_timeout_s(text, execution_device=_routing["effective_device"]),
|
||||
min_vram_gb=_engine_min_vram_gb,
|
||||
on_abandon=release,
|
||||
),
|
||||
decision=_decision,
|
||||
)
|
||||
audio_tensor = await gpu_gateway.run(
|
||||
_REMOTE_OP,
|
||||
local=gpu_gateway.LocalCall(
|
||||
_local_render, what="TTS generate",
|
||||
timeout=_generate_timeout_s(text),
|
||||
min_vram_gb=_engine_min_vram_gb,
|
||||
),
|
||||
decision=_decision,
|
||||
)
|
||||
# Read after generation: engines with lazy model loading report
|
||||
# their real rate only once weights are up.
|
||||
@@ -2328,8 +2108,9 @@ async def generate_speech(
|
||||
),
|
||||
)
|
||||
finally:
|
||||
if cleanup_ref and ref_lease is not None:
|
||||
ref_lease.finish_request()
|
||||
if cleanup_ref and ref_audio_path:
|
||||
with contextlib.suppress(OSError):
|
||||
os.remove(ref_audio_path)
|
||||
|
||||
def _safe_output_path(name):
|
||||
if not name:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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) ──────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -76,7 +76,6 @@ _cancelled: set[str] = set()
|
||||
_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:
|
||||
@@ -421,11 +420,16 @@ async def install_model(req: InstallModelRequest):
|
||||
f"Retry in {remaining}s or check your network."
|
||||
),
|
||||
)
|
||||
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)
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
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,
|
||||
@@ -684,51 +688,15 @@ async def install_model(req: InstallModelRequest):
|
||||
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)
|
||||
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:
|
||||
task = loop.create_task(asyncio.to_thread(_do))
|
||||
_install_tasks.add(task)
|
||||
task.add_done_callback(_install_tasks.discard)
|
||||
except Exception:
|
||||
with _active_installs_lock:
|
||||
current = _install_tasks_by_repo.get(repo_id)
|
||||
if current is None or current is task:
|
||||
_cancelled.discard(repo_id)
|
||||
_active_installs.discard(req.repo_id)
|
||||
raise
|
||||
return {"status": "install_started", "repo_id": req.repo_id}
|
||||
|
||||
|
||||
@router.post("/models/install/cancel")
|
||||
|
||||
@@ -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]:
|
||||
@@ -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.",
|
||||
|
||||
@@ -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()
|
||||
@@ -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."""
|
||||
@@ -110,7 +85,7 @@ 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.
|
||||
@@ -152,6 +127,10 @@ async def ws_tts(websocket: WebSocket):
|
||||
|
||||
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 +146,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 +258,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 +279,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 +294,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 +325,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:
|
||||
|
||||
+55
-266
@@ -23,16 +23,13 @@ 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")
|
||||
|
||||
@@ -161,19 +158,6 @@ def agent_status() -> dict:
|
||||
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.
|
||||
|
||||
@@ -192,63 +176,6 @@ def _refuse_when_env_pinned(worker_agent) -> None:
|
||||
)
|
||||
|
||||
|
||||
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.
|
||||
@@ -273,37 +200,26 @@ async def join_control_plane(request: JoinRequest) -> dict:
|
||||
# says it joined and never lends anything (CodeRabbit).
|
||||
_refuse_when_env_pinned(worker_agent)
|
||||
async with worker_agent.agent.lifecycle:
|
||||
# A rejoin replaces a working enrollment. Keep enough to put it back:
|
||||
# pinning the new certificate overwrites the old one on disk, so a
|
||||
# failed rejoin would otherwise leave the machine unable to reconnect
|
||||
# to the control plane it was already serving.
|
||||
previous = worker_agent.snapshot_enrollment()
|
||||
await worker_agent.agent.stop()
|
||||
try:
|
||||
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)
|
||||
except Exception as exc:
|
||||
worker_agent.agent.last_error = str(exc)
|
||||
await worker_agent.agent.stop()
|
||||
await worker_agent.restore_enrollment(previous)
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
worker_agent.agent.last_error = ""
|
||||
# Persisted only after the join actually worked: a machine that failed
|
||||
# to enrol must not come back up trying again forever.
|
||||
worker_agent.set_worker_mode_enabled(True)
|
||||
return worker_agent.agent.status()
|
||||
|
||||
|
||||
@@ -319,35 +235,19 @@ async def set_agent_enabled(request: EnableRequest) -> dict:
|
||||
|
||||
_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:
|
||||
if request.enabled:
|
||||
try:
|
||||
await worker_agent.agent.start()
|
||||
await worker_agent.agent.wait_until_registered()
|
||||
await _set_worker_mode(worker_agent, True)
|
||||
else:
|
||||
except Exception as exc:
|
||||
worker_agent.agent.last_error = str(exc)
|
||||
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 = ""
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
worker_agent.agent.last_error = ""
|
||||
worker_agent.set_worker_mode_enabled(True)
|
||||
else:
|
||||
await worker_agent.agent.stop()
|
||||
worker_agent.set_worker_mode_enabled(False)
|
||||
return worker_agent.agent.status()
|
||||
|
||||
|
||||
@@ -363,14 +263,9 @@ def create_enrollment(request: EnrollRequest) -> dict:
|
||||
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
|
||||
token = service.control_plane.create_enrollment(
|
||||
endpoint=request.endpoint, label=request.label, ttl_seconds=request.ttl_seconds
|
||||
)
|
||||
return {
|
||||
"token": token.encode(),
|
||||
"endpoint": token.endpoint,
|
||||
@@ -380,55 +275,23 @@ def create_enrollment(request: EnrollRequest) -> dict:
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
def update_worker(worker_id: str, request: WorkerUpdate) -> dict:
|
||||
worker = registry.get(worker_id)
|
||||
if worker is None:
|
||||
raise HTTPException(status_code=404, detail="No such worker.")
|
||||
if cancelled:
|
||||
raise asyncio.CancelledError
|
||||
return updated.to_dict()
|
||||
if request.name is not None:
|
||||
registry.rename(worker_id, request.name)
|
||||
if request.enabled is not None:
|
||||
registry.set_enabled(worker_id, request.enabled)
|
||||
if request.priority is not None:
|
||||
registry.set_priority(worker_id, request.priority)
|
||||
updated = registry.get(worker_id)
|
||||
# Keep the live copy in step, so the scheduler and its logs do not go on
|
||||
# using the name or priority this worker had when it connected.
|
||||
if updated is not None and service.control_plane.running:
|
||||
service.control_plane.pool.refresh_record(updated)
|
||||
return updated.to_dict() if updated else {}
|
||||
|
||||
|
||||
@router.post("/{worker_id}/consent")
|
||||
@@ -442,7 +305,7 @@ def grant_consent(worker_id: str) -> dict:
|
||||
|
||||
|
||||
@router.post("/{worker_id}/resume")
|
||||
async def clear_breaker(worker_id: str) -> dict:
|
||||
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
|
||||
@@ -457,53 +320,18 @@ async def clear_breaker(worker_id: str) -> dict:
|
||||
|
||||
|
||||
@router.delete("/{worker_id}")
|
||||
async def revoke_worker(worker_id: str) -> dict:
|
||||
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
|
||||
if registry.get(worker_id) is None:
|
||||
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
|
||||
registry.revoke(worker_id)
|
||||
if service.control_plane.running:
|
||||
service.control_plane.scheduler.on_disconnected(worker_id)
|
||||
service.control_plane.pool.breakers.forget_worker(worker_id)
|
||||
return {"ok": True, "revoked": worker_id}
|
||||
|
||||
|
||||
@@ -547,9 +375,7 @@ async def submit_task(request: Request, body: SubmitTaskRequest) -> dict:
|
||||
|
||||
scheduler = service.control_plane.scheduler
|
||||
try:
|
||||
submit = getattr(scheduler, "submit_async", None)
|
||||
submit = submit if callable(submit) else scheduler.submit
|
||||
submitted = submit(
|
||||
task = scheduler.submit(
|
||||
operation=body.operation,
|
||||
engine=body.engine,
|
||||
model_id=body.model_id,
|
||||
@@ -558,7 +384,6 @@ async def submit_task(request: Request, body: SubmitTaskRequest) -> dict:
|
||||
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
|
||||
|
||||
@@ -680,33 +505,8 @@ async def set_inbound_enabled(request: InboundEnableRequest) -> dict:
|
||||
"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)
|
||||
inbound_service.set_bind_host(request.bind)
|
||||
if request.port:
|
||||
inbound_service.set_bind_port(request.port)
|
||||
inbound_service.set_enabled(request.enabled)
|
||||
@@ -735,7 +535,6 @@ def issue_inbound_key(request: IssueKeyRequest) -> dict:
|
||||
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(
|
||||
@@ -745,10 +544,7 @@ def issue_inbound_key(request: IssueKeyRequest) -> dict:
|
||||
"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
|
||||
issued = inbound_service.node.keys.issue(request.label)
|
||||
return {
|
||||
"key_id": issued.key.key_id,
|
||||
"label": issued.key.label,
|
||||
@@ -759,12 +555,12 @@ def issue_inbound_key(request: IssueKeyRequest) -> dict:
|
||||
|
||||
|
||||
@router.delete("/inbound/keys/{key_id}")
|
||||
async def revoke_inbound_key(key_id: str) -> dict:
|
||||
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):
|
||||
if not inbound_service.node.keys.revoke(key_id):
|
||||
raise HTTPException(status_code=404, detail="No such key.")
|
||||
return inbound_service.node.snapshot()
|
||||
|
||||
@@ -783,7 +579,6 @@ 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(
|
||||
@@ -802,18 +597,12 @@ async def add_inbound_connection(request: ConnectRequest) -> dict:
|
||||
# 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
|
||||
await inbound_service.outbound.remove(endpoint)
|
||||
return {"connections": inbound_service.outbound.snapshot()}
|
||||
|
||||
+10
-10
@@ -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 ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
"""Lightweight validation for persisted profile WAV references.
|
||||
|
||||
This module deliberately uses only the standard library. Gallery routers import
|
||||
it during startup, so pulling in torch/torchaudio merely to validate a cached
|
||||
file would make every Gallery open pay the model stack's import cost.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import wave
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from core.path_security import UnsafePath, resolve_within, safe_filename
|
||||
|
||||
_READ_CHUNK_BYTES = 1 << 20
|
||||
_MAX_CHANNELS = 64
|
||||
_MAX_SAMPLE_RATE = 768_000
|
||||
_MAX_SAMPLE_WIDTH = 8
|
||||
|
||||
|
||||
def resolve_regular_file(root: os.PathLike[str] | str, value: object) -> Optional[Path]:
|
||||
"""Resolve a portable bare filename inside *root*, rejecting symlinks."""
|
||||
try:
|
||||
name = safe_filename(value)
|
||||
unresolved = Path(root).resolve(strict=False) / name
|
||||
if unresolved.is_symlink():
|
||||
return None
|
||||
return resolve_within(root, name)
|
||||
except (OSError, UnsafePath):
|
||||
return None
|
||||
|
||||
|
||||
def is_playable_wav(path: Optional[Path]) -> bool:
|
||||
"""Return true only for a regular, decodable WAV with audio frames."""
|
||||
if path is None:
|
||||
return False
|
||||
try:
|
||||
if not path.is_file() or path.is_symlink():
|
||||
return False
|
||||
file_size = path.stat().st_size
|
||||
with wave.open(str(path), "rb") as wav:
|
||||
channels = wav.getnchannels()
|
||||
sample_rate = wav.getframerate()
|
||||
sample_width = wav.getsampwidth()
|
||||
frame_count = wav.getnframes()
|
||||
if (
|
||||
not 0 < channels <= _MAX_CHANNELS
|
||||
or not 0 < sample_rate <= _MAX_SAMPLE_RATE
|
||||
or not 0 < sample_width <= _MAX_SAMPLE_WIDTH
|
||||
or frame_count <= 0
|
||||
):
|
||||
return False
|
||||
# ``wave.getnframes`` trusts the header. Read through the declared
|
||||
# payload so an interrupted write with a complete header but a
|
||||
# truncated data chunk cannot masquerade as playable audio.
|
||||
frame_size = channels * sample_width
|
||||
expected_bytes = frame_count * frame_size
|
||||
# A PCM payload cannot be larger than the containing file. Check
|
||||
# before calling ``readframes`` so hostile header values cannot
|
||||
# turn a tiny file into a multi-gigabyte allocation request.
|
||||
if expected_bytes > file_size:
|
||||
return False
|
||||
read_bytes = 0
|
||||
chunk_frames = max(1, min(frame_count, _READ_CHUNK_BYTES // frame_size))
|
||||
while read_bytes < expected_bytes:
|
||||
chunk = wav.readframes(chunk_frames)
|
||||
if not chunk or len(chunk) % frame_size:
|
||||
return False
|
||||
read_bytes += len(chunk)
|
||||
return read_bytes == expected_bytes
|
||||
except (MemoryError, OSError, EOFError, OverflowError, wave.Error):
|
||||
# Python 3.11's wave module rejects valid IEEE-float/WAVE_EXTENSIBLE
|
||||
# files. SoundFile is already a runtime dependency and recognizes those
|
||||
# containers; import it only on the uncommon fallback path.
|
||||
try:
|
||||
import soundfile as sf
|
||||
|
||||
with sf.SoundFile(str(path)) as audio:
|
||||
if (
|
||||
audio.format != "WAV"
|
||||
or not 0 < audio.channels <= _MAX_CHANNELS
|
||||
or not 0 < audio.samplerate <= _MAX_SAMPLE_RATE
|
||||
or len(audio) <= 0
|
||||
):
|
||||
return False
|
||||
remaining = len(audio)
|
||||
# Decode through the declared payload in byte-bounded chunks;
|
||||
# ``sf.info`` alone also trusts a truncated file's header.
|
||||
chunk_frames = max(
|
||||
1, _READ_CHUNK_BYTES // (audio.channels * 4),
|
||||
)
|
||||
while remaining:
|
||||
frames = audio.read(
|
||||
min(remaining, chunk_frames), dtype="float32", always_2d=True,
|
||||
)
|
||||
count = len(frames)
|
||||
if count <= 0:
|
||||
return False
|
||||
remaining -= count
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
__all__ = ["is_playable_wav", "resolve_regular_file"]
|
||||
@@ -1,421 +0,0 @@
|
||||
"""Canonical authentication identity for HTTP and WebSocket connections.
|
||||
|
||||
Transport parsing belongs here; authorization remains in FastAPI dependencies.
|
||||
Each ASGI scope receives exactly one secret-free :class:`AuthPrincipal` so
|
||||
middleware and route guards cannot disagree about credential precedence.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import importlib
|
||||
import os
|
||||
import secrets
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
|
||||
from services.admin_sessions import (
|
||||
AdminSessionStore,
|
||||
)
|
||||
|
||||
|
||||
_AUTH_STATE_KEY = "auth_principal"
|
||||
_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"})
|
||||
|
||||
CONSUME_CAPABILITIES = frozenset({"consume"})
|
||||
ADMIN_CAPABILITIES = frozenset({"consume", "admin"})
|
||||
LOOPBACK_CAPABILITIES = frozenset({"consume", "admin", "native"})
|
||||
|
||||
|
||||
class PrincipalKind(str, Enum):
|
||||
ANONYMOUS = "anonymous"
|
||||
LOOPBACK = "loopback"
|
||||
TRUSTED_NETWORK = "trusted_network"
|
||||
PIN = "pin"
|
||||
API_KEY = "api_key"
|
||||
ADMIN_SESSION = "admin_session"
|
||||
|
||||
|
||||
class CredentialTransport(str, Enum):
|
||||
NONE = "none"
|
||||
HEADER = "header"
|
||||
QUERY = "query"
|
||||
COOKIE = "cookie"
|
||||
LEGACY_COOKIE = "legacy_cookie"
|
||||
WS_TICKET = "ws_ticket"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthPrincipal:
|
||||
kind: PrincipalKind
|
||||
capabilities: frozenset[str]
|
||||
credential_id: str | None = None
|
||||
transport: CredentialTransport = CredentialTransport.NONE
|
||||
|
||||
def allows(self, capability: str) -> bool:
|
||||
return capability in self.capabilities
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _CredentialCandidate:
|
||||
value: str = field(repr=False)
|
||||
transport: CredentialTransport
|
||||
allow_master: bool = False
|
||||
allow_session: bool = False
|
||||
allow_ticket: bool = False
|
||||
|
||||
|
||||
def remote_api_key() -> str | None:
|
||||
"""Normalized remote operator key, read dynamically for rotation support."""
|
||||
return os.environ.get("OMNIVOICE_API_KEY", "").strip() or None
|
||||
|
||||
|
||||
def credential_matches(supplied: str | None, configured: str | None) -> bool:
|
||||
"""Constant-time credential comparison that accepts the full Unicode range."""
|
||||
if not supplied or not configured:
|
||||
return False
|
||||
return secrets.compare_digest(
|
||||
supplied.encode("utf-8", errors="surrogatepass"),
|
||||
configured.encode("utf-8", errors="surrogatepass"),
|
||||
)
|
||||
|
||||
|
||||
def _active_admin_session_store() -> AdminSessionStore:
|
||||
"""Resolve mutable process state at call time so app reloads cannot split it."""
|
||||
module = importlib.import_module("services.admin_sessions")
|
||||
return module.admin_session_store
|
||||
|
||||
|
||||
def _trusted_networks() -> tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, ...]:
|
||||
networks = []
|
||||
for value in os.environ.get("OMNIVOICE_TRUSTED_NETWORKS", "").split(","):
|
||||
value = value.strip()
|
||||
if not value:
|
||||
continue
|
||||
try:
|
||||
networks.append(ipaddress.ip_network(value, strict=False))
|
||||
except ValueError:
|
||||
# Invalid configuration never makes the gate fail open or wedge the
|
||||
# backend. It simply contributes no trusted range.
|
||||
continue
|
||||
return tuple(networks)
|
||||
|
||||
|
||||
def is_loopback(host: str | None) -> bool:
|
||||
return host in _LOOPBACK_HOSTS
|
||||
|
||||
|
||||
def is_local_host(host: str | None) -> bool:
|
||||
if is_loopback(host):
|
||||
return True
|
||||
try:
|
||||
address = ipaddress.ip_address(host)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
if getattr(address, "ipv4_mapped", None):
|
||||
address = address.ipv4_mapped
|
||||
return any(address in network for network in _trusted_networks())
|
||||
|
||||
|
||||
def _mapping_get(mapping: Mapping[str, str] | object, name: str) -> str:
|
||||
if not mapping:
|
||||
return ""
|
||||
getter = getattr(mapping, "get", None)
|
||||
if callable(getter):
|
||||
value = getter(name, "")
|
||||
if value:
|
||||
return str(value)
|
||||
# Real Starlette Headers are case-insensitive. This small fallback keeps
|
||||
# minimal request stubs and non-Starlette callers correct too.
|
||||
items = getattr(mapping, "items", None)
|
||||
if callable(items):
|
||||
for key, value in items():
|
||||
if str(key).lower() == name.lower():
|
||||
return str(value or "")
|
||||
return ""
|
||||
|
||||
|
||||
def _scope_type(connection) -> str:
|
||||
scope = getattr(connection, "scope", None)
|
||||
return str(scope.get("type", "http")) if isinstance(scope, dict) else "http"
|
||||
|
||||
|
||||
def _path(connection) -> str:
|
||||
scope = getattr(connection, "scope", None)
|
||||
if isinstance(scope, dict):
|
||||
return str(scope.get("path", ""))
|
||||
return str(getattr(connection, "url", "") or "")
|
||||
|
||||
|
||||
def _canonical_websocket_path(connection) -> str:
|
||||
"""Remove only the ASGI-configured deployment prefix from a WS path."""
|
||||
path = _path(connection)
|
||||
scope = getattr(connection, "scope", None)
|
||||
if not isinstance(scope, dict):
|
||||
return path
|
||||
root_path = str(scope.get("root_path", "") or "").rstrip("/")
|
||||
if not root_path or root_path == "/":
|
||||
return path
|
||||
root_path = "/" + root_path.lstrip("/")
|
||||
if path.startswith(root_path + "/"):
|
||||
return path[len(root_path) :]
|
||||
return path
|
||||
|
||||
|
||||
def _client_host(connection) -> str | None:
|
||||
client = getattr(connection, "client", None)
|
||||
if client is not None:
|
||||
return getattr(client, "host", None)
|
||||
scope = getattr(connection, "scope", None)
|
||||
if isinstance(scope, dict) and scope.get("client"):
|
||||
return scope["client"][0]
|
||||
return None
|
||||
|
||||
|
||||
def _credential_candidate(connection) -> _CredentialCandidate | None:
|
||||
query = getattr(connection, "query_params", None) or {}
|
||||
cookies = getattr(connection, "cookies", None) or {}
|
||||
|
||||
raw_authorization = authorization_header(connection)
|
||||
authorization = raw_authorization.strip()
|
||||
if raw_authorization.lower().startswith("bearer "):
|
||||
value = raw_authorization[7:].strip()
|
||||
if value:
|
||||
return _CredentialCandidate(
|
||||
value=value,
|
||||
transport=CredentialTransport.HEADER,
|
||||
allow_master=True,
|
||||
allow_session=True,
|
||||
)
|
||||
# Preserve the legacy normalization contract: ``Bearer`` followed
|
||||
# only by whitespace is equivalent to an empty credential channel.
|
||||
elif authorization:
|
||||
# Any non-empty explicit Authorization value is authoritative, even
|
||||
# when its scheme is unsupported or its Bearer payload is missing.
|
||||
# It must never fall through to a stale ambient cookie.
|
||||
return _CredentialCandidate(
|
||||
value=authorization,
|
||||
transport=CredentialTransport.HEADER,
|
||||
)
|
||||
|
||||
if _scope_type(connection) == "websocket":
|
||||
ticket = _mapping_get(query, "ws_ticket").strip()
|
||||
if ticket:
|
||||
return _CredentialCandidate(
|
||||
value=ticket,
|
||||
transport=CredentialTransport.WS_TICKET,
|
||||
allow_ticket=True,
|
||||
)
|
||||
|
||||
query_key = _mapping_get(query, "api_key").strip()
|
||||
if query_key:
|
||||
return _CredentialCandidate(
|
||||
value=query_key,
|
||||
transport=CredentialTransport.QUERY,
|
||||
allow_master=True,
|
||||
)
|
||||
|
||||
session = _mapping_get(cookies, "ov_session").strip()
|
||||
if session:
|
||||
return _CredentialCandidate(
|
||||
value=session,
|
||||
transport=CredentialTransport.COOKIE,
|
||||
allow_session=True,
|
||||
)
|
||||
|
||||
legacy_key = _mapping_get(cookies, "ov_key").strip()
|
||||
if legacy_key:
|
||||
return _CredentialCandidate(
|
||||
value=legacy_key,
|
||||
transport=CredentialTransport.LEGACY_COOKIE,
|
||||
allow_master=True,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def presented_api_key(connection) -> str:
|
||||
"""Compatibility extractor for the durable API-key transports only."""
|
||||
candidate = _credential_candidate(connection)
|
||||
if candidate is None or not candidate.allow_master:
|
||||
return ""
|
||||
return candidate.value
|
||||
|
||||
|
||||
def authorization_header(connection) -> str:
|
||||
headers = getattr(connection, "headers", None) or {}
|
||||
return _mapping_get(headers, "authorization")
|
||||
|
||||
|
||||
def authorization_credential_present(connection) -> bool:
|
||||
"""Whether Authorization contains an authoritative credential channel.
|
||||
|
||||
This deliberately mirrors :func:`_credential_candidate`: whitespace and
|
||||
``Bearer`` followed only by spaces are empty channels that may fall back to
|
||||
legacy migration state. Unsupported schemes and ``Bearer`` without the
|
||||
required separating space remain explicit invalid credentials.
|
||||
"""
|
||||
authorization = authorization_header(connection)
|
||||
if authorization.lower().startswith("bearer ") and not authorization[7:].strip():
|
||||
return False
|
||||
return bool(authorization.strip())
|
||||
|
||||
|
||||
def bearer_header_value(connection) -> str:
|
||||
authorization = authorization_header(connection)
|
||||
if not authorization.lower().startswith("bearer "):
|
||||
return ""
|
||||
return authorization[7:].strip()
|
||||
|
||||
|
||||
def legacy_master_cookie_valid(connection) -> bool:
|
||||
configured = remote_api_key()
|
||||
cookies = getattr(connection, "cookies", None) or {}
|
||||
supplied = _mapping_get(cookies, "ov_key").strip()
|
||||
return credential_matches(supplied, configured)
|
||||
|
||||
|
||||
def master_header_valid(connection) -> bool:
|
||||
configured = remote_api_key()
|
||||
supplied = bearer_header_value(connection)
|
||||
return credential_matches(supplied, configured)
|
||||
|
||||
|
||||
def _configured_pin(connection) -> str | None:
|
||||
app = getattr(connection, "app", None)
|
||||
state = getattr(app, "state", None) if app is not None else None
|
||||
network_share = getattr(state, "network_share", None) if state is not None else None
|
||||
pin = getattr(network_share, "pin", None) if network_share is not None else None
|
||||
return str(pin) if pin else None
|
||||
|
||||
|
||||
def _valid_pin(connection) -> bool:
|
||||
configured = _configured_pin(connection)
|
||||
if not configured:
|
||||
return False
|
||||
headers = getattr(connection, "headers", None) or {}
|
||||
query = getattr(connection, "query_params", None) or {}
|
||||
cookies = getattr(connection, "cookies", None) or {}
|
||||
supplied = (
|
||||
_mapping_get(headers, "x-omnivoice-pin").strip()
|
||||
or _mapping_get(query, "pin").strip()
|
||||
or _mapping_get(cookies, "ov_pin").strip()
|
||||
)
|
||||
return credential_matches(supplied, configured)
|
||||
|
||||
|
||||
def _attached_principal(connection) -> AuthPrincipal | None:
|
||||
scope = getattr(connection, "scope", None)
|
||||
if not isinstance(scope, dict):
|
||||
return None
|
||||
state = scope.get("state")
|
||||
if isinstance(state, dict):
|
||||
principal = state.get(_AUTH_STATE_KEY)
|
||||
return principal if isinstance(principal, AuthPrincipal) else None
|
||||
return None
|
||||
|
||||
|
||||
def _attach_principal(connection, principal: AuthPrincipal) -> AuthPrincipal:
|
||||
scope = getattr(connection, "scope", None)
|
||||
if isinstance(scope, dict):
|
||||
state = scope.setdefault("state", {})
|
||||
if isinstance(state, dict):
|
||||
state[_AUTH_STATE_KEY] = principal
|
||||
return principal
|
||||
|
||||
|
||||
def resolve_principal(
|
||||
connection,
|
||||
*,
|
||||
store: AdminSessionStore | None = None,
|
||||
) -> AuthPrincipal:
|
||||
"""Resolve and attach the single authentication decision for one scope."""
|
||||
attached = _attached_principal(connection)
|
||||
if attached is not None:
|
||||
return attached
|
||||
if store is None:
|
||||
store = _active_admin_session_store()
|
||||
|
||||
host = _client_host(connection)
|
||||
if is_loopback(host):
|
||||
return _attach_principal(
|
||||
connection,
|
||||
AuthPrincipal(PrincipalKind.LOOPBACK, LOOPBACK_CAPABILITIES),
|
||||
)
|
||||
|
||||
candidate = _credential_candidate(connection)
|
||||
configured_key = remote_api_key()
|
||||
if candidate is not None:
|
||||
principal: AuthPrincipal | None = None
|
||||
if (
|
||||
candidate.allow_master
|
||||
and credential_matches(candidate.value, configured_key)
|
||||
):
|
||||
principal = AuthPrincipal(
|
||||
PrincipalKind.API_KEY,
|
||||
ADMIN_CAPABILITIES,
|
||||
credential_id="api-key",
|
||||
transport=candidate.transport,
|
||||
)
|
||||
elif candidate.allow_session:
|
||||
session = store.resolve(candidate.value, configured_key)
|
||||
if session is not None:
|
||||
principal = AuthPrincipal(
|
||||
PrincipalKind.ADMIN_SESSION,
|
||||
session.capabilities,
|
||||
credential_id=session.credential_id,
|
||||
transport=candidate.transport,
|
||||
)
|
||||
elif candidate.allow_ticket:
|
||||
session = store.consume_ws_ticket(
|
||||
candidate.value,
|
||||
_canonical_websocket_path(connection),
|
||||
configured_key,
|
||||
)
|
||||
if session is not None:
|
||||
principal = AuthPrincipal(
|
||||
PrincipalKind.ADMIN_SESSION,
|
||||
session.capabilities,
|
||||
credential_id=session.credential_id,
|
||||
transport=candidate.transport,
|
||||
)
|
||||
if principal is not None:
|
||||
return _attach_principal(connection, principal)
|
||||
# An explicit, non-empty credential is authoritative. Do not silently
|
||||
# fall back to network or PIN trust after an invalid higher-priority
|
||||
# credential was presented.
|
||||
return _attach_principal(
|
||||
connection,
|
||||
AuthPrincipal(
|
||||
PrincipalKind.ANONYMOUS,
|
||||
frozenset(),
|
||||
transport=candidate.transport,
|
||||
),
|
||||
)
|
||||
|
||||
if is_local_host(host):
|
||||
return _attach_principal(
|
||||
connection,
|
||||
AuthPrincipal(PrincipalKind.TRUSTED_NETWORK, CONSUME_CAPABILITIES),
|
||||
)
|
||||
if _valid_pin(connection):
|
||||
return _attach_principal(
|
||||
connection,
|
||||
AuthPrincipal(
|
||||
PrincipalKind.PIN,
|
||||
CONSUME_CAPABILITIES,
|
||||
transport=CredentialTransport.HEADER,
|
||||
),
|
||||
)
|
||||
return _attach_principal(
|
||||
connection,
|
||||
AuthPrincipal(PrincipalKind.ANONYMOUS, frozenset()),
|
||||
)
|
||||
|
||||
|
||||
def principal_for(
|
||||
connection,
|
||||
*,
|
||||
store: AdminSessionStore | None = None,
|
||||
) -> AuthPrincipal:
|
||||
return _attached_principal(connection) or resolve_principal(connection, store=store)
|
||||
@@ -1,609 +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. A small direct-child supervisor bridges both lifetimes.
|
||||
|
||||
On POSIX the 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 supervisor assigns the operation, while suspended, to a nested
|
||||
kill-on-close Job. The outer desktop Job still contains both levels.
|
||||
|
||||
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
|
||||
|
||||
|
||||
def spawn_owned(argv: list[str], **kwargs: Any) -> "subprocess.Popen | OwnedPopen":
|
||||
"""Spawn an operation with a stable, independently terminable owner."""
|
||||
|
||||
drain_fd = backend_drain_fd(required=True) if os.name == "posix" else None
|
||||
control_read, control_write = os.pipe()
|
||||
result_read, result_write = os.pipe()
|
||||
control_token = control_read
|
||||
result_token = result_write
|
||||
if os.name == "nt":
|
||||
import msvcrt
|
||||
|
||||
control_token = msvcrt.get_osfhandle(control_read)
|
||||
result_token = msvcrt.get_osfhandle(result_write)
|
||||
wrapper_argv = _supervisor_argv(
|
||||
control_token,
|
||||
result_token,
|
||||
argv,
|
||||
)
|
||||
wrapper_kwargs = dict(kwargs)
|
||||
if os.name == "posix":
|
||||
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)
|
||||
else:
|
||||
# Python's Windows fd inheritance requires inheritable CRT handles.
|
||||
# All unrelated descriptors are non-inheritable by default (PEP 446).
|
||||
os.set_handle_inheritable(control_token, True)
|
||||
os.set_handle_inheritable(result_token, True)
|
||||
wrapper_kwargs["close_fds"] = False
|
||||
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())
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -52,7 +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.",
|
||||
@@ -291,9 +290,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 +323,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 +330,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:
|
||||
|
||||
@@ -17,13 +17,6 @@ _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."""
|
||||
@@ -59,10 +52,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 +69,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)
|
||||
|
||||
@@ -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 generation "
|
||||
"timeout."
|
||||
),
|
||||
"retryable": True,
|
||||
},
|
||||
"invalid_request": {
|
||||
"code": "invalid_request",
|
||||
"detail": "The generation request could not be processed.",
|
||||
@@ -74,48 +65,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,
|
||||
|
||||
@@ -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
|
||||
@@ -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.1"
|
||||
_FALLBACK_VERSION = "0.4.2"
|
||||
|
||||
|
||||
def _fallback_version() -> str:
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -28,7 +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
|
||||
@@ -165,23 +164,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
|
||||
|
||||
@@ -63,13 +63,11 @@ Restrictions:
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import traceback
|
||||
|
||||
|
||||
@@ -119,59 +117,11 @@ EMOTION_KWARGS_ALLOWLIST = frozenset({
|
||||
# ── wire protocol ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
#: Seconds between keep-alive progress frames during a long blocking call.
|
||||
_HEARTBEAT_S = 5.0
|
||||
|
||||
#: Serializes _send across threads (the heartbeat below + the main loop) so
|
||||
#: concurrent length+body writes can't interleave and corrupt the framing.
|
||||
_send_lock = threading.Lock()
|
||||
|
||||
|
||||
def _send(stream, obj: dict) -> None:
|
||||
body = json.dumps(obj, separators=(",", ":")).encode("utf-8")
|
||||
with _send_lock:
|
||||
stream.write(struct.pack("!I", len(body)))
|
||||
stream.write(body)
|
||||
stream.flush()
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _heartbeat(stdout, stage: str):
|
||||
"""Emit a progress frame every ~5s for the duration of the block.
|
||||
|
||||
IndexTTS spends the whole of a cold load and the whole of ``infer()``
|
||||
inside one blocking upstream call, saying nothing on the wire. The parent
|
||||
reads that silence two ways, and BOTH kill a perfectly healthy synthesis
|
||||
of a long passage (#1611):
|
||||
|
||||
* ``SubprocessBackend.generate`` re-arms its recv watchdog on every
|
||||
frame, so with no frames it hard-kills the sidecar at recv_timeout_s;
|
||||
* each frame also reports activity to the GPU pool's execution clock
|
||||
(#1367), so with no frames the outer generate budget expires and
|
||||
blames the hardware.
|
||||
|
||||
Raising the deadline alone therefore does not fix long-text generation —
|
||||
the sidecar has to prove it is alive. Percent climbs 1..99 because the
|
||||
upstream call exposes no real progress; it is a liveness signal, not a
|
||||
measurement.
|
||||
"""
|
||||
stop = threading.Event()
|
||||
|
||||
def _beat() -> None:
|
||||
pct = 1
|
||||
while not stop.wait(_HEARTBEAT_S):
|
||||
pct = min(pct + 1, 99)
|
||||
try:
|
||||
_send(stdout, {"op": "progress", "stage": stage, "percent": pct})
|
||||
except Exception:
|
||||
return # pipe gone — the main loop will surface it
|
||||
hb = threading.Thread(target=_beat, name=f"indextts-{stage}-heartbeat", daemon=True)
|
||||
hb.start()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
stop.set()
|
||||
hb.join(timeout=_HEARTBEAT_S + 1)
|
||||
stream.write(struct.pack("!I", len(body)))
|
||||
stream.write(body)
|
||||
stream.flush()
|
||||
|
||||
|
||||
def _recv(stream):
|
||||
@@ -210,40 +160,14 @@ def _torch_bf16_supported() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
#: Model-config filenames to look for, most-preferred first, per version.
|
||||
#: IndexTeam/IndexTTS-2.5 ships ``config.yaml``; VoiceStudio used to demand
|
||||
#: ``config_v2_5.yaml``, a name that exists in no upstream revision, so the
|
||||
#: install failed until the user hand-renamed the file (#1611). Both names are
|
||||
#: accepted now — the hand-renamed installs must keep working untouched — and
|
||||
#: the renamed one wins, because a user who created it did so deliberately.
|
||||
_CFG_NAMES = {
|
||||
"2.5": ("config_v2_5.yaml", "config.yaml"),
|
||||
"2": ("config.yaml",),
|
||||
}
|
||||
|
||||
|
||||
def _resolve_cfg_path(model_dir: str, *, version: str) -> str:
|
||||
"""First accepted config that exists in ``model_dir``.
|
||||
|
||||
Falls back to the last candidate when none exist, so the failure surfaces
|
||||
as upstream's own "no such file" naming a real expected path rather than
|
||||
a name no upstream release has ever shipped.
|
||||
"""
|
||||
names = _CFG_NAMES.get(version, _CFG_NAMES["2"])
|
||||
for name in names:
|
||||
candidate = os.path.join(model_dir, name)
|
||||
if os.path.isfile(candidate):
|
||||
return candidate
|
||||
return os.path.join(model_dir, names[-1])
|
||||
|
||||
|
||||
def _model_init_kwargs(
|
||||
repo_dir: str, *, version: str, reduced_precision: bool,
|
||||
) -> dict:
|
||||
"""Build version-specific constructor arguments for IndexTTS 2.5 or 2."""
|
||||
model_dir = os.path.join(repo_dir, "checkpoints")
|
||||
cfg_name = "config_v2_5.yaml" if version == "2.5" else "config.yaml"
|
||||
kwargs = {
|
||||
"cfg_path": _resolve_cfg_path(model_dir, version=version),
|
||||
"cfg_path": os.path.join(model_dir, cfg_name),
|
||||
"model_dir": model_dir,
|
||||
"use_cuda_kernel": False,
|
||||
"use_deepspeed": False,
|
||||
@@ -292,8 +216,7 @@ def _load_model(stdout) -> object:
|
||||
model_kw = _model_init_kwargs(
|
||||
repo_dir, version=_model_version, reduced_precision=reduced_precision,
|
||||
)
|
||||
with _heartbeat(stdout, "loading_model"):
|
||||
_model = IndexTTS2(**model_kw)
|
||||
_model = IndexTTS2(**model_kw)
|
||||
|
||||
_send(stdout, {"op": "progress", "stage": "loading_model", "percent": 100})
|
||||
return _model
|
||||
@@ -353,10 +276,7 @@ def _handle_synthesize(msg: dict, stdout) -> None:
|
||||
tmp_path = tmp.name
|
||||
try:
|
||||
infer_kw["output_path"] = tmp_path
|
||||
# A long passage keeps infer() busy for minutes with nothing on the
|
||||
# wire; without this the parent kills the sidecar mid-synthesis (#1611).
|
||||
with _heartbeat(stdout, "synthesizing"):
|
||||
model.infer(**infer_kw)
|
||||
model.infer(**infer_kw)
|
||||
pcm_b64, sr, n_samples = _wav_to_pcm_b64(tmp_path)
|
||||
finally:
|
||||
try:
|
||||
|
||||
@@ -98,7 +98,6 @@ def _platform_slug() -> str:
|
||||
darwin-x86_64
|
||||
windows-x86_64
|
||||
linux-x86_64
|
||||
linux-aarch64
|
||||
"""
|
||||
system = platform.system().lower()
|
||||
machine = platform.machine().lower()
|
||||
@@ -108,8 +107,6 @@ def _platform_slug() -> str:
|
||||
return "darwin-x86_64"
|
||||
if system == "windows":
|
||||
return "windows-x86_64"
|
||||
if system == "linux" and machine in ("arm64", "aarch64"):
|
||||
return "linux-aarch64"
|
||||
# Linux + everything else falls into the linux slug.
|
||||
return "linux-x86_64"
|
||||
|
||||
@@ -356,9 +353,6 @@ def _make_backend_class():
|
||||
display_name = "OmniVoice (GGUF, hardware-adaptive)"
|
||||
gpu_compat = ("cuda", "mps", "cpu")
|
||||
supports_voice_design = False
|
||||
# Every generate() spawns the external binary — allocations live in
|
||||
# that process, invisible to parent-side accelerator counters.
|
||||
runs_out_of_process = True
|
||||
|
||||
# 24 kHz mono Higgs Audio v2 — same as the in-process OmniVoice.
|
||||
_SAMPLE_RATE = 24_000
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
"""omnivoice-subprocess: the resident OmniVoice TTS engine in a crash-isolated
|
||||
sidecar process (#730/#1190).
|
||||
|
||||
The ``omnivoice`` engine runs in-process on CUDA, ROCm, and CPU. On MPS it is
|
||||
resolved to :class:`OmniVoiceMPSSubprocessBackend` so a fatal native allocator
|
||||
exit cannot take down the local API process.
|
||||
The default ``omnivoice`` engine runs in-process on the GPU ``ThreadPoolExecutor``.
|
||||
When a generate or load there exceeds its execution budget the pool is "reset"
|
||||
but the abandoned worker *thread* cannot be killed (Python cannot interrupt a
|
||||
native torch/MPS call), so it holds the MPS device until it finishes on its
|
||||
@@ -15,11 +13,16 @@ timeout the parent's watchdog calls ``proc.kill()``, reclaiming the child's
|
||||
VRAM/device, and the next request transparently respawns a fresh sidecar. That
|
||||
is the one thing the in-process engine structurally cannot do.
|
||||
|
||||
The explicit ``omnivoice-subprocess`` id remains available on every host for
|
||||
operators who want the same containment elsewhere.
|
||||
OPT-IN (Settings -> Engines, or ``OMNIVOICE_TTS_BACKEND=omnivoice-subprocess``);
|
||||
the in-process ``omnivoice`` stays the default so existing users see no change.
|
||||
|
||||
Tradeoff vs the in-process engine: identical model, controls, seed behavior,
|
||||
and quality, with a little extra per-call overhead (one stdio round-trip).
|
||||
Tradeoff vs the in-process engine: identical model and quality, a little extra
|
||||
per-call overhead (one stdio round-trip), and it does not carry the native
|
||||
advanced-parameter surface (``t_shift`` / ``layer_penalty_factor`` /
|
||||
``position_temperature`` / ``class_temperature``) or parent-side seed
|
||||
determinism, because the generic ``backend.generate`` path does not forward
|
||||
those. Acceptable for unattended / reaction-triggered use where reliability
|
||||
matters more than those controls.
|
||||
|
||||
Unlike IndexTTS / dots.tts / Supertonic-3, this sidecar runs under the PARENT
|
||||
interpreter (``venv_python() -> sys.executable``): the goal here is crash
|
||||
@@ -48,7 +51,7 @@ class OmniVoiceSubprocessBackend(SubprocessBackend):
|
||||
id = "omnivoice-subprocess"
|
||||
display_name = "OmniVoice (subprocess-isolated, killable on timeout)"
|
||||
_DEFAULT_SAMPLE_RATE = 24000
|
||||
gpu_compat = ("cuda", "rocm", "mps", "cpu")
|
||||
gpu_compat = ("cuda", "mps", "cpu")
|
||||
# Match OmniVoiceBackend: the measured floor below which a render that
|
||||
# should take seconds runs for minutes (the #1226/#1222 4 GB reports).
|
||||
min_vram_gb = 6.0
|
||||
@@ -99,34 +102,4 @@ class OmniVoiceSubprocessBackend(SubprocessBackend):
|
||||
return ["multi"]
|
||||
|
||||
|
||||
class OmniVoiceMPSSubprocessBackend(OmniVoiceSubprocessBackend):
|
||||
"""Effective ``omnivoice`` implementation on MPS.
|
||||
|
||||
Native torch/MPS allocator failures can terminate the process without a
|
||||
catchable Python exception. Keeping the same engine id and model surface in
|
||||
a child makes that failure recoverable while Settings, APIs, and saved
|
||||
projects continue to refer to ``omnivoice``.
|
||||
"""
|
||||
|
||||
id = "omnivoice"
|
||||
display_name = "VoiceStudio (k2-fsa/OmniVoice, 600+ languages)"
|
||||
supports_native_omnivoice_controls = True
|
||||
|
||||
def generate(self, text: str, **kw):
|
||||
from services.model_manager import make_room_before_generate
|
||||
|
||||
make_room_before_generate()
|
||||
try:
|
||||
return super().generate(text, **kw)
|
||||
except RuntimeError as exc:
|
||||
if "sidecar closed pipe mid-generate" not in str(exc):
|
||||
raise
|
||||
raise RuntimeError(
|
||||
"The isolated OmniVoice engine stopped during generation, "
|
||||
"usually because macOS reclaimed it under memory pressure. "
|
||||
"The VoiceStudio backend is still running. Close memory-heavy "
|
||||
"apps or select a smaller TTS engine, then retry."
|
||||
) from exc
|
||||
|
||||
|
||||
__all__ = ["OmniVoiceMPSSubprocessBackend", "OmniVoiceSubprocessBackend"]
|
||||
__all__ = ["OmniVoiceSubprocessBackend"]
|
||||
|
||||
@@ -50,8 +50,6 @@ OMNIVOICE_SAMPLE_RATE = 24000
|
||||
_GEN_KW_ALLOWLIST = (
|
||||
"language", "instruct", "duration", "num_step", "guidance_scale",
|
||||
"speed", "denoise", "postprocess_output", "preprocess_prompt",
|
||||
"t_shift", "layer_penalty_factor", "position_temperature",
|
||||
"class_temperature", "audio_chunk_duration", "audio_chunk_threshold",
|
||||
)
|
||||
|
||||
_model = None
|
||||
@@ -185,12 +183,6 @@ def _handle_synthesize(msg: dict, stdout) -> None:
|
||||
ref_text = msg.get("ref_text") or None
|
||||
gen_kw = {k: msg[k] for k in _GEN_KW_ALLOWLIST if k in msg}
|
||||
|
||||
seed = msg.get("seed")
|
||||
if seed is not None:
|
||||
import torch
|
||||
|
||||
torch.manual_seed(int(seed))
|
||||
|
||||
audios = model.generate(
|
||||
text=text, ref_audio=ref_audio, ref_text=ref_text, **gen_kw
|
||||
)
|
||||
|
||||
@@ -151,40 +151,6 @@ def _pocket_language(raw) -> str:
|
||||
)
|
||||
|
||||
|
||||
_TRUTHY = {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _has_24l_config(language: str) -> bool:
|
||||
"""Whether the installed pocket-tts ships a 24-layer checkpoint for
|
||||
``language`` (it/de/es/pt/fr in 2.1.0; english has none)."""
|
||||
try:
|
||||
from pocket_tts.models.tts_model import CONFIGS_DIR # type: ignore[import-not-found] # noqa: PLC0415
|
||||
except Exception as exc: # noqa: BLE001 — absence of the package is not fatal here
|
||||
# Log it, though: if a future pocket-tts moves CONFIGS_DIR, the 24L
|
||||
# opt-in would otherwise go silently inert.
|
||||
print(f"pockettts sidecar: 24l config probe failed: {exc!r}", file=sys.stderr)
|
||||
return False
|
||||
from pathlib import Path # noqa: PLC0415
|
||||
|
||||
return (Path(CONFIGS_DIR) / f"{language}_24l.yaml").is_file()
|
||||
|
||||
|
||||
def _model_config_name(language: str) -> str:
|
||||
"""Pocket-tts config name to load: the 6-layer default, or the 24-layer
|
||||
checkpoint when OMNIVOICE_POCKETTTS_24L is set and one exists for the
|
||||
language. Opt-in only — defaults keep the fast model; the 24-layer variant
|
||||
trades roughly 4x transformer compute for better prosody.
|
||||
|
||||
French is the exception: pocket-tts 2.1.0 only ships a 24-layer French
|
||||
model and load_model(language="french") raises, so French always maps to
|
||||
french_24l regardless of the env var."""
|
||||
if language == "french":
|
||||
return "french_24l"
|
||||
if os.environ.get("OMNIVOICE_POCKETTTS_24L", "").strip().lower() not in _TRUTHY:
|
||||
return language
|
||||
return f"{language}_24l" if _has_24l_config(language) else language
|
||||
|
||||
|
||||
def _load_model(stdout, language: str):
|
||||
"""Cold-construct the PocketTTS model for ``language`` (cached per language).
|
||||
Emits progress frames for the parent watchdog. Raises on failure (e.g.
|
||||
@@ -212,7 +178,7 @@ def _load_model(stdout, language: str):
|
||||
try:
|
||||
from pocket_tts import TTSModel # type: ignore[import-not-found] # noqa: PLC0415
|
||||
|
||||
model = TTSModel.load_model(language=_model_config_name(language))
|
||||
model = TTSModel.load_model(language=language)
|
||||
_MODELS[language] = model
|
||||
finally:
|
||||
stop.set()
|
||||
|
||||
+461
-807
File diff suppressed because it is too large
Load Diff
@@ -190,7 +190,6 @@ class ParseSubtitleTextRequest(BaseModel):
|
||||
class DubIngestUrlRequest(BaseModel):
|
||||
url: str
|
||||
job_id: Optional[str] = None
|
||||
source_lang: Optional[str] = None
|
||||
# When true and the URL is a caption-bearing host (YouTube, Vimeo, TED…),
|
||||
# ask yt-dlp to also download the original-language + any additional
|
||||
# sub_langs as VTT. The UI uses this to seed a transcript without running
|
||||
|
||||
@@ -1,404 +0,0 @@
|
||||
"""Process-bound credentials for the first-party remote administration UI.
|
||||
|
||||
The durable ``OMNIVOICE_API_KEY`` is an operator secret, not a browser session.
|
||||
This module exchanges it for opaque, bounded-lifetime credentials without
|
||||
depending on FastAPI or persisting a verifier to disk.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import re
|
||||
import secrets
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from types import ModuleType
|
||||
from base64 import urlsafe_b64encode
|
||||
from collections import OrderedDict
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||
|
||||
|
||||
SESSION_TTL_SECONDS = 8 * 60 * 60
|
||||
WS_TICKET_TTL_SECONDS = 30
|
||||
MAX_ADMIN_SESSIONS = 256
|
||||
MAX_WS_TICKETS = 512
|
||||
|
||||
ADMIN_SESSION_PREFIX = "ovs_admin_session_"
|
||||
WS_TICKET_PREFIX = "ovs_ws_ticket_"
|
||||
_TOKEN_BYTES = 32
|
||||
_ENCODED_TOKEN_LENGTH = 43
|
||||
_TOKEN_BODY_RE = re.compile(rf"^[A-Za-z0-9_-]{{{_ENCODED_TOKEN_LENGTH}}}$")
|
||||
_ALLOWED_WS_PATHS = frozenset(
|
||||
{"/ws/events", "/ws/transcribe", "/v1/audio/transcriptions/stream"}
|
||||
)
|
||||
_ADMIN_CAPABILITIES = frozenset({"consume", "admin"})
|
||||
_KEY_GENERATION_INFO = b"omnivoice-admin-key-generation-v1"
|
||||
|
||||
|
||||
def _hash_token(token: str, pepper: bytes) -> str:
|
||||
# These are 256-bit random values, not user-chosen passwords. A keyed,
|
||||
# process-local index is the right primitive: there is no feasible password
|
||||
# dictionary to slow down, and a copied record is unusable without the
|
||||
# store's independently generated pepper.
|
||||
return hmac.digest(pepper, token.encode("utf-8"), "sha256").hex()
|
||||
|
||||
|
||||
def _encode_token(raw: bytes) -> str:
|
||||
return urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IssuedSession:
|
||||
token: str = field(repr=False)
|
||||
expires_at: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IssuedTicket:
|
||||
token: str = field(repr=False)
|
||||
expires_at: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SessionRecord:
|
||||
credential_id: str
|
||||
capabilities: frozenset[str]
|
||||
issued_at: float
|
||||
expires_at: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _StoredSession:
|
||||
credential_id: str
|
||||
issued_monotonic: float
|
||||
expires_monotonic: float
|
||||
issued_at: float
|
||||
expires_at: float
|
||||
|
||||
def public(self) -> SessionRecord:
|
||||
return SessionRecord(
|
||||
credential_id=self.credential_id,
|
||||
capabilities=_ADMIN_CAPABILITIES,
|
||||
issued_at=self.issued_at,
|
||||
expires_at=self.expires_at,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _StoredTicket:
|
||||
session_hash: str
|
||||
path: str
|
||||
issued_monotonic: float
|
||||
expires_monotonic: float
|
||||
|
||||
|
||||
class AdminSessionStore:
|
||||
"""Thread-safe, process-local store for admin sessions and WS tickets."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
monotonic: Callable[[], float] = time.monotonic,
|
||||
wall_time: Callable[[], float] = time.time,
|
||||
token_bytes: Callable[[int], bytes] = secrets.token_bytes,
|
||||
pepper: bytes | None = None,
|
||||
session_ttl_seconds: int = SESSION_TTL_SECONDS,
|
||||
ws_ticket_ttl_seconds: int = WS_TICKET_TTL_SECONDS,
|
||||
max_sessions: int = MAX_ADMIN_SESSIONS,
|
||||
max_tickets: int = MAX_WS_TICKETS,
|
||||
) -> None:
|
||||
if session_ttl_seconds <= 0 or ws_ticket_ttl_seconds <= 0:
|
||||
raise ValueError("credential TTLs must be positive")
|
||||
if max_sessions <= 0 or max_tickets <= 0:
|
||||
raise ValueError("credential store capacities must be positive")
|
||||
self._monotonic = monotonic
|
||||
self._wall_time = wall_time
|
||||
self._token_bytes = token_bytes
|
||||
self._pepper = pepper if pepper is not None else secrets.token_bytes(32)
|
||||
if len(self._pepper) < 32:
|
||||
raise ValueError("session-store pepper must contain at least 256 bits")
|
||||
self._session_ttl = session_ttl_seconds
|
||||
self._ticket_ttl = ws_ticket_ttl_seconds
|
||||
self._max_sessions = max_sessions
|
||||
self._max_tickets = max_tickets
|
||||
self._sessions: OrderedDict[str, _StoredSession] = OrderedDict()
|
||||
self._tickets: OrderedDict[str, _StoredTicket] = OrderedDict()
|
||||
self._ticket_hashes_by_session: dict[str, set[str]] = {}
|
||||
self._key_generation: bytes | None = None
|
||||
self._lock = threading.RLock()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
snapshot = self.debug_snapshot()
|
||||
return (
|
||||
"AdminSessionStore("
|
||||
f"sessions={snapshot['sessions']}, ws_tickets={snapshot['ws_tickets']})"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_master(api_key: str | None) -> str:
|
||||
return api_key.strip() if isinstance(api_key, str) else ""
|
||||
|
||||
def _generation(self, api_key: str) -> bytes:
|
||||
return HKDF(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=32,
|
||||
salt=self._pepper,
|
||||
info=_KEY_GENERATION_INFO,
|
||||
).derive(api_key.encode("utf-8", errors="surrogatepass"))
|
||||
|
||||
def _sync_key_locked(self, api_key: str | None) -> bool:
|
||||
normalized = self._normalize_master(api_key)
|
||||
if not normalized:
|
||||
self._clear_credentials_locked()
|
||||
self._key_generation = None
|
||||
return False
|
||||
generation = self._generation(normalized)
|
||||
if self._key_generation is None:
|
||||
self._key_generation = generation
|
||||
return True
|
||||
if not hmac.compare_digest(self._key_generation, generation):
|
||||
self._clear_credentials_locked()
|
||||
self._key_generation = generation
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _valid_token(token: str | None, prefix: str) -> bool:
|
||||
if not isinstance(token, str) or not token.startswith(prefix):
|
||||
return False
|
||||
return bool(_TOKEN_BODY_RE.fullmatch(token.removeprefix(prefix)))
|
||||
|
||||
def _new_token_locked(self, prefix: str, existing: object) -> tuple[str, str]:
|
||||
for _attempt in range(8):
|
||||
raw = self._token_bytes(_TOKEN_BYTES)
|
||||
if not isinstance(raw, bytes) or len(raw) != _TOKEN_BYTES:
|
||||
raise RuntimeError("token source must return exactly 32 bytes")
|
||||
token = prefix + _encode_token(raw)
|
||||
token_hash = _hash_token(token, self._pepper)
|
||||
if token_hash not in existing:
|
||||
return token, token_hash
|
||||
raise RuntimeError("credential token source produced repeated collisions")
|
||||
|
||||
def _clear_credentials_locked(self) -> None:
|
||||
self._sessions.clear()
|
||||
self._tickets.clear()
|
||||
self._ticket_hashes_by_session.clear()
|
||||
|
||||
def _remove_ticket_locked(self, ticket_hash: str) -> _StoredTicket | None:
|
||||
ticket = self._tickets.pop(ticket_hash, None)
|
||||
if ticket is None:
|
||||
return None
|
||||
session_tickets = self._ticket_hashes_by_session.get(ticket.session_hash)
|
||||
if session_tickets is not None:
|
||||
session_tickets.discard(ticket_hash)
|
||||
if not session_tickets:
|
||||
self._ticket_hashes_by_session.pop(ticket.session_hash, None)
|
||||
return ticket
|
||||
|
||||
def _remove_session_locked(self, session_hash: str) -> _StoredSession | None:
|
||||
record = self._sessions.pop(session_hash, None)
|
||||
for ticket_hash in tuple(self._ticket_hashes_by_session.get(session_hash, ())):
|
||||
self._remove_ticket_locked(ticket_hash)
|
||||
# Defensive cleanup keeps a prior partial mutation from preserving a
|
||||
# dangling reverse-index bucket even when the session was already gone.
|
||||
self._ticket_hashes_by_session.pop(session_hash, None)
|
||||
return record
|
||||
|
||||
def _purge_locked(self, now: float) -> None:
|
||||
# TTLs are fixed per store and monotonic issue times never decrease, so
|
||||
# insertion order is expiry order. Only the expired prefix can require
|
||||
# work; the common request path examines at most one record per type.
|
||||
while self._sessions:
|
||||
session_hash = next(iter(self._sessions))
|
||||
if now < self._sessions[session_hash].expires_monotonic:
|
||||
break
|
||||
self._remove_session_locked(session_hash)
|
||||
|
||||
while self._tickets:
|
||||
ticket_hash = next(iter(self._tickets))
|
||||
if now < self._tickets[ticket_hash].expires_monotonic:
|
||||
break
|
||||
self._remove_ticket_locked(ticket_hash)
|
||||
|
||||
def _evict_sessions_locked(self) -> None:
|
||||
while len(self._sessions) >= self._max_sessions:
|
||||
self._remove_session_locked(next(iter(self._sessions)))
|
||||
|
||||
def _evict_tickets_locked(self) -> None:
|
||||
while len(self._tickets) >= self._max_tickets:
|
||||
self._remove_ticket_locked(next(iter(self._tickets)))
|
||||
|
||||
def issue(self, api_key: str) -> IssuedSession:
|
||||
normalized = self._normalize_master(api_key)
|
||||
if not normalized:
|
||||
raise ValueError("configured API key required")
|
||||
with self._lock:
|
||||
self._sync_key_locked(normalized)
|
||||
now = self._monotonic()
|
||||
wall_now = self._wall_time()
|
||||
self._purge_locked(now)
|
||||
self._evict_sessions_locked()
|
||||
token, token_hash = self._new_token_locked(ADMIN_SESSION_PREFIX, self._sessions)
|
||||
expires_monotonic = now + self._session_ttl
|
||||
expires_at = wall_now + self._session_ttl
|
||||
self._sessions[token_hash] = _StoredSession(
|
||||
credential_id=token_hash,
|
||||
issued_monotonic=now,
|
||||
expires_monotonic=expires_monotonic,
|
||||
issued_at=wall_now,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
return IssuedSession(token=token, expires_at=expires_at)
|
||||
|
||||
def resolve(self, token: str | None, api_key: str | None) -> SessionRecord | None:
|
||||
if not self._valid_token(token, ADMIN_SESSION_PREFIX):
|
||||
return None
|
||||
assert isinstance(token, str)
|
||||
with self._lock:
|
||||
if not self._sync_key_locked(api_key):
|
||||
return None
|
||||
now = self._monotonic()
|
||||
self._purge_locked(now)
|
||||
record = self._sessions.get(_hash_token(token, self._pepper))
|
||||
if record is None or now >= record.expires_monotonic:
|
||||
return None
|
||||
return record.public()
|
||||
|
||||
def revoke(self, token: str | None) -> bool:
|
||||
if not self._valid_token(token, ADMIN_SESSION_PREFIX):
|
||||
return False
|
||||
assert isinstance(token, str)
|
||||
token_hash = _hash_token(token, self._pepper)
|
||||
with self._lock:
|
||||
return self._remove_session_locked(token_hash) is not None
|
||||
|
||||
def revoke_by_credential(self, credential_id: str | None) -> bool:
|
||||
if not isinstance(credential_id, str) or len(credential_id) != 64:
|
||||
return False
|
||||
with self._lock:
|
||||
return self._remove_session_locked(credential_id) is not None
|
||||
|
||||
def issue_ws_ticket(
|
||||
self,
|
||||
session_token: str | None,
|
||||
path: str,
|
||||
api_key: str | None,
|
||||
) -> IssuedTicket:
|
||||
if path not in _ALLOWED_WS_PATHS:
|
||||
raise ValueError("WebSocket path is not allowed")
|
||||
if not self._valid_token(session_token, ADMIN_SESSION_PREFIX):
|
||||
raise PermissionError("valid admin session required")
|
||||
assert isinstance(session_token, str)
|
||||
session_hash = _hash_token(session_token, self._pepper)
|
||||
return self.issue_ws_ticket_for_credential(session_hash, path, api_key)
|
||||
|
||||
def issue_ws_ticket_for_credential(
|
||||
self,
|
||||
credential_id: str | None,
|
||||
path: str,
|
||||
api_key: str | None,
|
||||
) -> IssuedTicket:
|
||||
if path not in _ALLOWED_WS_PATHS:
|
||||
raise ValueError("WebSocket path is not allowed")
|
||||
with self._lock:
|
||||
if not isinstance(credential_id, str) or len(credential_id) != 64:
|
||||
raise PermissionError("valid admin session required")
|
||||
if not self._sync_key_locked(api_key):
|
||||
raise PermissionError("valid admin session required")
|
||||
now = self._monotonic()
|
||||
self._purge_locked(now)
|
||||
session = self._sessions.get(credential_id)
|
||||
if session is None or now >= session.expires_monotonic:
|
||||
raise PermissionError("valid admin session required")
|
||||
self._evict_tickets_locked()
|
||||
token, token_hash = self._new_token_locked(WS_TICKET_PREFIX, self._tickets)
|
||||
expires_at = self._wall_time() + self._ticket_ttl
|
||||
self._tickets[token_hash] = _StoredTicket(
|
||||
session_hash=credential_id,
|
||||
path=path,
|
||||
issued_monotonic=now,
|
||||
expires_monotonic=now + self._ticket_ttl,
|
||||
)
|
||||
self._ticket_hashes_by_session.setdefault(credential_id, set()).add(
|
||||
token_hash
|
||||
)
|
||||
return IssuedTicket(token=token, expires_at=expires_at)
|
||||
|
||||
def consume_ws_ticket(
|
||||
self,
|
||||
ticket_token: str | None,
|
||||
path: str,
|
||||
api_key: str | None,
|
||||
) -> SessionRecord | None:
|
||||
if not self._valid_token(ticket_token, WS_TICKET_PREFIX):
|
||||
return None
|
||||
assert isinstance(ticket_token, str)
|
||||
with self._lock:
|
||||
if not self._sync_key_locked(api_key):
|
||||
return None
|
||||
now = self._monotonic()
|
||||
self._purge_locked(now)
|
||||
ticket = self._remove_ticket_locked(
|
||||
_hash_token(ticket_token, self._pepper)
|
||||
)
|
||||
if ticket is None or now >= ticket.expires_monotonic or ticket.path != path:
|
||||
return None
|
||||
session = self._sessions.get(ticket.session_hash)
|
||||
if session is None or now >= session.expires_monotonic:
|
||||
return None
|
||||
return session.public()
|
||||
|
||||
def clear(self) -> None:
|
||||
with self._lock:
|
||||
self._clear_credentials_locked()
|
||||
self._key_generation = None
|
||||
|
||||
@property
|
||||
def active_session_count(self) -> int:
|
||||
with self._lock:
|
||||
self._purge_locked(self._monotonic())
|
||||
return len(self._sessions)
|
||||
|
||||
def debug_snapshot(self) -> dict[str, int]:
|
||||
with self._lock:
|
||||
self._purge_locked(self._monotonic())
|
||||
return {"sessions": len(self._sessions), "ws_tickets": len(self._tickets)}
|
||||
|
||||
|
||||
#: Synthetic ``sys.modules`` key holding the one per-process store. A module
|
||||
#: object in ``sys.modules`` is the only namespace that survives everything
|
||||
#: test suites do to this package: ``importlib.reload`` re-executes module
|
||||
#: code but never touches unrelated ``sys.modules`` entries, and the purges
|
||||
#: that pop whole ``services.*`` / ``api.*`` trees match package prefixes this
|
||||
#: underscore-prefixed top-level name is outside of.
|
||||
_ANCHOR_MODULE_NAME = "_omnivoice_admin_session_store_anchor"
|
||||
|
||||
|
||||
def _process_store() -> AdminSessionStore:
|
||||
"""Return THE per-process store, however this module was (re)imported.
|
||||
|
||||
Auth is process-global state: the copy of this module that issues a
|
||||
credential and the copy that later resolves it must always be looking at
|
||||
the same store. A bare module-level ``AdminSessionStore()`` breaks that
|
||||
the moment anything reloads or re-imports this module (fresh module dict →
|
||||
fresh store → freshly issued sessions vanish for holders of the old
|
||||
reference, and vice versa). Anchoring the instance outside the module's
|
||||
own namespace makes every copy of this module share one store.
|
||||
"""
|
||||
anchor = sys.modules.get(_ANCHOR_MODULE_NAME)
|
||||
if not isinstance(anchor, ModuleType):
|
||||
anchor = ModuleType(_ANCHOR_MODULE_NAME)
|
||||
anchor.__doc__ = "Process-global anchor for the VoiceStudio admin-session store."
|
||||
sys.modules[_ANCHOR_MODULE_NAME] = anchor
|
||||
store = getattr(anchor, "admin_session_store", None)
|
||||
if store is None:
|
||||
store = AdminSessionStore()
|
||||
anchor.admin_session_store = store
|
||||
return store
|
||||
|
||||
|
||||
admin_session_store = _process_store()
|
||||
+59
-186
@@ -88,9 +88,10 @@ def reset_pool_after_wedge(executor, *, what: str = "ASR") -> bool:
|
||||
|
||||
|
||||
# ── Consecutive-timeout streak → recommend the crash-isolated engine ────────
|
||||
# A timed-out CTranslate2/whisperx thread keeps its worker and VRAM until the
|
||||
# native call exits. When guarded transcribes keep timing out back-to-back in
|
||||
# one session, the durable fix is the crash-isolated sidecar engine
|
||||
# A pool reset restores *capacity*, but the wedged CTranslate2/whisperx thread
|
||||
# keeps its VRAM until the process exits. When guarded transcribes keep timing
|
||||
# out back-to-back in one session, resets clearly aren't recovering the
|
||||
# underlying hang — the durable fix is the crash-isolated sidecar engine
|
||||
# (services.subprocess_asr, #393), whose child process CAN be hard-killed to
|
||||
# reclaim the hung call and its VRAM. We only *recommend* it (log + error
|
||||
# message); we never switch engines automatically (owner rule: no silent
|
||||
@@ -145,18 +146,23 @@ def _isolated_engine_hint(streak: int) -> str:
|
||||
|
||||
async def run_transcribe_guarded(executor, fn, *, what: str = "ASR",
|
||||
timeout: float = ASR_TRANSCRIBE_TIMEOUT_S,
|
||||
timeout_env: str = "OMNIVOICE_ASR_TRANSCRIBE_TIMEOUT_S",
|
||||
reset_on_timeout: bool = False):
|
||||
timeout_env: str = "OMNIVOICE_ASR_TRANSCRIBE_TIMEOUT_S"):
|
||||
"""Run a blocking transcribe ``fn`` in ``executor`` with a hard wall-clock
|
||||
bound. On timeout, raise :class:`ASRTimeoutError` with guidance instead of
|
||||
letting the request hang forever.
|
||||
|
||||
``run_in_executor`` cannot cancel the underlying thread, so a timed-out
|
||||
in-process CTranslate2/whisperx call still owns its model and device. The
|
||||
default deliberately leaves that worker accounted for: swapping in a fresh
|
||||
pool and immediately retrying the same backend overlaps two native calls,
|
||||
which produced the Windows access violation in #1669. A caller backed by a
|
||||
genuinely killable process may opt into ``reset_on_timeout``.
|
||||
``run_in_executor`` cannot cancel the underlying thread, so a wedged
|
||||
transcribe (a CTranslate2 / whisperx / VAD hang seen on some Windows + CUDA
|
||||
setups, #730) keeps occupying its GPU-pool worker. With a 1–2 worker pool
|
||||
that starves every *other* request — including TTS generate — and the next
|
||||
thing the user does surfaces as "Can't reach the local backend" even though
|
||||
the process is alive. So on timeout we also ``reset()`` the pool when it
|
||||
supports it (``_ResilientGpuPool``): the wedged thread is abandoned and the
|
||||
next submit gets a fresh worker, restoring capacity without an app restart.
|
||||
The orphaned thread still holds its VRAM until the process exits, which is
|
||||
why the message still recommends a smaller ASR model / Flush as the durable
|
||||
fix. Executors without ``reset`` (a plain ThreadPoolExecutor in tests) just
|
||||
get the bound + actionable error.
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
# Same SystemExit containment as the TTS pool (#1133 class): an ASR
|
||||
@@ -165,16 +171,16 @@ async def run_transcribe_guarded(executor, fn, *, what: str = "ASR",
|
||||
try:
|
||||
result = await asyncio.wait_for(fut, timeout=timeout)
|
||||
except asyncio.TimeoutError:
|
||||
if reset_on_timeout:
|
||||
reset_pool_after_wedge(executor, what=what)
|
||||
# Free the poisoned pool so a hung transcribe can't keep starving TTS /
|
||||
# other ASR work (the "can't reach backend" symptom, #730).
|
||||
reset_pool_after_wedge(executor, what=what)
|
||||
streak = _note_transcribe_timeout()
|
||||
msg = (
|
||||
f"{what} transcription exceeded {timeout:.0f}s and was abandoned — "
|
||||
"the backend is running, but the ASR model is too heavy for the "
|
||||
"available compute. Most often the GPU is VRAM-starved: the resident "
|
||||
"TTS model and a large ASR model (large-v3) contend for memory. "
|
||||
"The native call cannot be killed safely, so its capacity remains "
|
||||
"reserved until it exits. For a durable fix Flush the "
|
||||
"Capacity was restored automatically, but for a durable fix Flush the "
|
||||
"TTS model to free VRAM, pick a smaller ASR model in "
|
||||
f"Model Catalogue → Models, or set ASR to CPU. (Raise {timeout_env} "
|
||||
"for very long transcribes.)"
|
||||
@@ -514,10 +520,12 @@ class WhisperXBackend(ASRBackend):
|
||||
def _pick_device() -> tuple[str, str]:
|
||||
# CUDA fp16 when available; otherwise CPU int8 (fastest CPU path,
|
||||
# negligible WER regression vs fp32 for whisper-large-v3).
|
||||
# _ctranslate2_cuda_ok, not torch.cuda.is_available: ROCm torch also
|
||||
# answers True there, and CTranslate2 has no HIP backend (#1529).
|
||||
if _ctranslate2_cuda_ok():
|
||||
return "cuda", "float16"
|
||||
try:
|
||||
import torch
|
||||
if torch.cuda.is_available():
|
||||
return "cuda", "float16"
|
||||
except Exception:
|
||||
pass
|
||||
return "cpu", "int8"
|
||||
|
||||
# Peak VRAM (GB) to load *and transcribe* whisper large-v3 per CTranslate2
|
||||
@@ -973,10 +981,12 @@ class FasterWhisperBackend(ASRBackend):
|
||||
# - Apple Silicon / CPU → CPU int8 (fastest on CPU, negligible
|
||||
# WER regression vs fp32 for whisper-large-v3)
|
||||
device, compute_type = "cpu", "int8"
|
||||
# _ctranslate2_cuda_ok, not torch.cuda.is_available: ROCm torch also
|
||||
# answers True there, and CTranslate2 has no HIP backend (#1529).
|
||||
if _ctranslate2_cuda_ok():
|
||||
device, compute_type = "cuda", "float16"
|
||||
try:
|
||||
import torch
|
||||
if torch.cuda.is_available():
|
||||
device, compute_type = "cuda", "float16"
|
||||
except Exception:
|
||||
pass
|
||||
logger.info(
|
||||
"faster-whisper loading %s on %s (%s)",
|
||||
self._model_name, device, compute_type,
|
||||
@@ -2319,7 +2329,7 @@ _INSTALL_HINTS: dict[str, str] = {
|
||||
"mac-ARM source installs since 0.3.22. Parakeet TDT v3 on the GPU via "
|
||||
"MLX: 25 European languages, word timestamps, ~2 GB unified memory.)"
|
||||
),
|
||||
"moonshine": "uv pip install moonshine-onnx (or moonshine-voice; edge/CPU-optimized ASR)",
|
||||
"moonshine": "pip install useful-moonshine (edge/CPU-optimized ASR)",
|
||||
"funasr": "pip install funasr (SenseVoiceSmall + FSMN-VAD; CUDA or CPU)",
|
||||
"sherpa-onnx-asr": "uv add sherpa-onnx (ONNX live dictation; CPU, cross-platform)",
|
||||
"openai-compat-asr": (
|
||||
@@ -2454,61 +2464,6 @@ def _mps_available() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _cuda_reported_available() -> bool:
|
||||
"""``torch.cuda.is_available()`` verbatim — True on real CUDA *and* HIP."""
|
||||
try:
|
||||
import torch
|
||||
|
||||
return bool(torch.cuda.is_available())
|
||||
except Exception: # noqa: BLE001 — no torch
|
||||
return False
|
||||
|
||||
|
||||
def _rocm_torch() -> bool:
|
||||
"""True when torch is the ROCm (HIP) build.
|
||||
|
||||
ROCm torch masquerades as CUDA: ``torch.cuda.is_available()`` answers True
|
||||
and tensors live on ``"cuda"`` devices, but the CUDA *runtime libraries*
|
||||
other packages ship are still NVIDIA-only. ``torch.version.hip`` is the
|
||||
one honest tell.
|
||||
"""
|
||||
try:
|
||||
import torch
|
||||
|
||||
return getattr(torch.version, "hip", None) is not None
|
||||
except Exception: # noqa: BLE001 — no torch
|
||||
return False
|
||||
|
||||
|
||||
def _ctranslate2_cuda_ok() -> bool:
|
||||
"""Whether CTranslate2 (whisperx / faster-whisper) may use ``"cuda"``.
|
||||
|
||||
CTranslate2 has NO HIP backend. On a ROCm host torch says cuda is
|
||||
available (HIP), the device string is handed to CTranslate2, and its
|
||||
NVIDIA CUDA runtime dies with "CUDA driver version is insufficient for
|
||||
CUDA runtime version" — the #1529 report, an AMD RX 7900 XTX in the
|
||||
:rocm Docker image. Real CUDA only; ROCm hosts take the CPU path here
|
||||
(auto-detect prefers pytorch-whisper there, which does use HIP).
|
||||
|
||||
Also honors the user compute-device override (Settings → Performance /
|
||||
``OMNIVOICE_DEVICE``): a host pinned to cpu (or any non-cuda family)
|
||||
must not hand CTranslate2 a CUDA device — the probe applies the
|
||||
override, so gating on its family covers every CT2 loader at once.
|
||||
"""
|
||||
try:
|
||||
from core.device_caps import detect_host_caps
|
||||
|
||||
if detect_host_caps().family != "cuda":
|
||||
return False
|
||||
except Exception: # noqa: BLE001 — fail SAFE, not fast
|
||||
# Without a working probe we can't know whether an override or a
|
||||
# ROCm build is in play — guessing "cuda" from torch here is exactly
|
||||
# the #1529 crash. CPU always works.
|
||||
logger.warning("device probe failed — CTranslate2 taking the CPU path", exc_info=True)
|
||||
return False
|
||||
return _cuda_reported_available() and not _rocm_torch()
|
||||
|
||||
|
||||
def _auto_detect() -> str:
|
||||
"""Pick the best available ASR engine **for this hardware**.
|
||||
|
||||
@@ -2540,14 +2495,6 @@ def _auto_detect() -> str:
|
||||
"""
|
||||
if _mps_available() and _probe_available(MLXWhisperBackend):
|
||||
return "mlx-whisper"
|
||||
# Same class as the Apple case, on the ROCm axis (#1529): whisperx and
|
||||
# faster-whisper are CTranslate2, which has no HIP backend — on a ROCm
|
||||
# host they run on the CPU while the GPU sits idle (and before
|
||||
# _ctranslate2_cuda_ok they died outright trying NVIDIA's runtime).
|
||||
# pytorch-whisper is a pure transformers pipeline riding torch itself,
|
||||
# so it genuinely uses the HIP GPU there.
|
||||
if _rocm_torch() and _cuda_reported_available() and _probe_available(PyTorchWhisperBackend):
|
||||
return "pytorch-whisper"
|
||||
if _probe_available(WhisperXBackend):
|
||||
return "whisperx"
|
||||
if _probe_available(FasterWhisperBackend):
|
||||
@@ -2558,10 +2505,7 @@ def _auto_detect() -> str:
|
||||
def active_backend_id() -> str:
|
||||
explicit = os.environ.get("OMNIVOICE_ASR_BACKEND")
|
||||
if explicit:
|
||||
# #1582's public spelling predates the registry name. Keep it as a
|
||||
# compatibility alias for the PyTorch-native Whisper implementation
|
||||
# that can use ROCm/HIP; every ASR consumer resolves through here.
|
||||
return "pytorch-whisper" if explicit == "omnivoice" else explicit
|
||||
return explicit
|
||||
from core import prefs
|
||||
picked = prefs.get("asr_backend")
|
||||
if picked:
|
||||
@@ -2989,7 +2933,7 @@ def _capture_prefers_parakeet() -> bool:
|
||||
return _parakeet_mlx_installed()
|
||||
|
||||
|
||||
def get_capture_asr_backend(*, skip_sherpa: bool = False) -> ASRBackend:
|
||||
def get_capture_asr_backend() -> ASRBackend:
|
||||
"""Pick the fastest ASR engine for capture / dictation.
|
||||
|
||||
Selection order:
|
||||
@@ -3014,9 +2958,6 @@ def get_capture_asr_backend(*, skip_sherpa: bool = False) -> ASRBackend:
|
||||
|
||||
Returns a cached singleton so the model stays warm between calls; the
|
||||
singleton is rebuilt if the selected sherpa model changes.
|
||||
|
||||
``skip_sherpa`` is used only to validate a token-silent Sherpa result with
|
||||
the installed capture fallback before persisting model demotion.
|
||||
"""
|
||||
global _capture_backend, _capture_backend_key
|
||||
|
||||
@@ -3025,7 +2966,7 @@ def get_capture_asr_backend(*, skip_sherpa: bool = False) -> ASRBackend:
|
||||
# call get_sherpa_dictation_backend concurrently) can't both build a model.
|
||||
with _capture_backend_lock:
|
||||
# 0. Honor an explicit sherpa dictation model selection.
|
||||
sherpa_id = None if skip_sherpa else dictation_model_id()
|
||||
sherpa_id = dictation_model_id()
|
||||
if sherpa_id:
|
||||
ok, _ = SherpaDictationBackend.is_available()
|
||||
if ok:
|
||||
@@ -3136,18 +3077,10 @@ def _offline_asr_repo(backend_id: str | None = None) -> str | None:
|
||||
bid = backend_id or active_backend_id()
|
||||
if bid == "whisperx":
|
||||
return _fw_repo(os.environ.get("ASR_MODEL_WHISPERX", "large-v3"))
|
||||
if bid == "faster-whisper":
|
||||
if bid in ("faster-whisper", "faster-whisper-isolated"):
|
||||
# The crash-isolated sidecar loads the SAME CT2 weights as in-process
|
||||
# faster-whisper (it reuses the ASR_MODEL_FASTER selection).
|
||||
return _fw_repo(os.environ.get("ASR_MODEL_FASTER", _FASTER_WHISPER_DEFAULT))
|
||||
if bid == "faster-whisper-isolated":
|
||||
# Mirror the sidecar's own resolution (_asr_sidecar/main.py):
|
||||
# ASR_MODEL_FW is a sidecar-only override, otherwise the shared
|
||||
# ASR_MODEL_FASTER selection applies — so the preflight can never
|
||||
# download a different repo than the sidecar will load.
|
||||
return _fw_repo(
|
||||
os.environ.get("ASR_MODEL_FW")
|
||||
or os.environ.get("ASR_MODEL_FASTER")
|
||||
or _FASTER_WHISPER_DEFAULT
|
||||
)
|
||||
if bid == "mlx-whisper":
|
||||
return os.environ.get("ASR_MODEL", _MLX_MODEL_DEFAULT)
|
||||
if bid == "parakeet-mlx":
|
||||
@@ -3192,10 +3125,7 @@ def _capture_whisper_repo() -> str | None:
|
||||
return os.environ.get("OMNIVOICE_PYTORCH_ASR_MODEL", _PYTORCH_ASR_DEFAULT)
|
||||
|
||||
|
||||
def _recommended_asr_model(
|
||||
purpose: str, missing_repo: str | None, *, prefer_sherpa: bool = True,
|
||||
excluded_sherpa_model_id: str | None = None,
|
||||
) -> dict | None:
|
||||
def _recommended_asr_model(purpose: str, missing_repo: str | None) -> dict | None:
|
||||
"""The catalog entry to offer in the download CTA.
|
||||
|
||||
Offline: the missing repo itself when it's in the catalog (guarantees
|
||||
@@ -3215,38 +3145,20 @@ def _recommended_asr_model(
|
||||
|
||||
by_id = {m["repo_id"]: m for m in KNOWN_MODELS}
|
||||
exact = by_id.get(missing_repo) if missing_repo else None
|
||||
|
||||
def _eligible(m: dict, *, sherpa: bool) -> bool:
|
||||
if (m.get("engine") == "sherpa-onnx") != sherpa:
|
||||
return False
|
||||
if sherpa and m.get("dictation_id") == excluded_sherpa_model_id:
|
||||
return False
|
||||
return _model_supported(m)
|
||||
|
||||
if purpose != "dictation":
|
||||
if exact is not None and _model_supported(exact):
|
||||
want_sherpa = False
|
||||
if purpose == "dictation":
|
||||
if exact is not None and exact.get("engine") == "sherpa-onnx":
|
||||
return _shape(exact)
|
||||
prefer_sherpa = False
|
||||
|
||||
if purpose == "dictation" and prefer_sherpa:
|
||||
ok, _ = SherpaDictationBackend.is_available()
|
||||
if ok:
|
||||
if exact is not None and _eligible(exact, sherpa=True):
|
||||
return _shape(exact)
|
||||
for m in KNOWN_MODELS:
|
||||
if (m.get("role") == "ASR" and _eligible(m, sherpa=True)
|
||||
and _model_curated(m)):
|
||||
return _shape(m)
|
||||
|
||||
# No usable Sherpa recommendation remains (runtime unavailable, explicit
|
||||
# fallback probe, or the sole curated entry is the demoted model). Offer
|
||||
# the exact capture fallback so download → retry cannot loop.
|
||||
if exact is not None and _eligible(exact, sherpa=False):
|
||||
want_sherpa = ok
|
||||
if not want_sherpa and exact is not None and _model_supported(exact):
|
||||
return _shape(exact)
|
||||
for m in KNOWN_MODELS:
|
||||
if m.get("role") != "ASR":
|
||||
continue
|
||||
if _eligible(m, sherpa=False) and _model_curated(m):
|
||||
if (m.get("engine") == "sherpa-onnx") != want_sherpa:
|
||||
continue
|
||||
if _model_curated(m) and _model_supported(m):
|
||||
return _shape(m)
|
||||
return None
|
||||
|
||||
@@ -3277,9 +3189,7 @@ def _repo_installed(repo: str) -> bool:
|
||||
|
||||
def asr_model_missing_error(*, purpose: str = "transcribe",
|
||||
sherpa_model_id: str | None = None,
|
||||
backend_id: str | None = None,
|
||||
skip_sherpa: bool = False,
|
||||
require_installed: bool = False) -> dict | None:
|
||||
backend_id: str | None = None) -> dict | None:
|
||||
"""None when the active ASR selection can transcribe without downloading
|
||||
anything; otherwise the typed ``{"error": "asr_model_missing", ...}``
|
||||
payload for a 409 / SSE / WS error with a download CTA.
|
||||
@@ -3291,11 +3201,6 @@ def asr_model_missing_error(*, purpose: str = "transcribe",
|
||||
``?model=`` override. Installed state comes from the same HF-cache helpers
|
||||
the model store uses (see :func:`_repo_installed`), so the answer matches
|
||||
the Model Catalogue → Models install badges.
|
||||
``skip_sherpa`` probes only the non-Sherpa capture fallback; silent-model
|
||||
recovery uses it before deciding whether persistent demotion is warranted.
|
||||
``require_installed`` makes unknown/custom selections fail closed for that
|
||||
recovery path so it can never turn the normal fail-open policy into an
|
||||
implicit model download.
|
||||
|
||||
FAIL-OPEN rule: a repo the model catalog doesn't know (a custom
|
||||
``ASR_MODEL_*`` pin, pytorch-whisper's default repo, an unrecognized
|
||||
@@ -3305,55 +3210,27 @@ def asr_model_missing_error(*, purpose: str = "transcribe",
|
||||
a broken preflight must degrade to the old behaviour, not block ASR.
|
||||
"""
|
||||
try:
|
||||
prefer_sherpa_recommendation = not skip_sherpa
|
||||
excluded_sherpa_model_id = None
|
||||
if purpose == "dictation":
|
||||
sid = None if skip_sherpa else (sherpa_model_id or dictation_model_id())
|
||||
sid = sherpa_model_id or dictation_model_id()
|
||||
if sid:
|
||||
ok, _ = SherpaDictationBackend.is_available()
|
||||
if ok:
|
||||
from services import sherpa_dictation as _sd
|
||||
spec = _sd.get_spec(sid)
|
||||
# A recognizer observed returning silence must follow the
|
||||
# same capture fallback as execution, even when the
|
||||
# frontend keeps sending its persisted `?model=` value.
|
||||
if spec is not None:
|
||||
if _sd.is_demoted(spec.id):
|
||||
excluded_sherpa_model_id = spec.id
|
||||
else:
|
||||
if _sd.is_installed(spec):
|
||||
return None
|
||||
return {
|
||||
"error": ASR_MODEL_MISSING,
|
||||
"missing_repo_id": spec.repo_id,
|
||||
"recommended": _recommended_asr_model(
|
||||
purpose, spec.repo_id,
|
||||
),
|
||||
}
|
||||
if _sd.is_installed(spec):
|
||||
return None
|
||||
return {
|
||||
"error": ASR_MODEL_MISSING,
|
||||
"missing_repo_id": spec.repo_id,
|
||||
"recommended": _recommended_asr_model(purpose, spec.repo_id),
|
||||
}
|
||||
repo = _capture_whisper_repo()
|
||||
else:
|
||||
repo = _offline_asr_repo(backend_id)
|
||||
if repo is None:
|
||||
if require_installed:
|
||||
return {
|
||||
"error": ASR_MODEL_MISSING,
|
||||
"missing_repo_id": "unresolved-capture-fallback",
|
||||
"recommended": None,
|
||||
}
|
||||
return None # explicit opt-in engine — can't (and shouldn't) preflight
|
||||
from api.routers.setup.models import get_model_catalog
|
||||
if require_installed:
|
||||
if _repo_installed(repo):
|
||||
return None
|
||||
return {
|
||||
"error": ASR_MODEL_MISSING,
|
||||
"missing_repo_id": repo,
|
||||
"recommended": _recommended_asr_model(
|
||||
purpose, repo,
|
||||
prefer_sherpa=prefer_sherpa_recommendation,
|
||||
excluded_sherpa_model_id=excluded_sherpa_model_id,
|
||||
),
|
||||
}
|
||||
if get_model_catalog().get(repo) is None:
|
||||
return None # not installable from the CTA — fail open (see docstring)
|
||||
if _repo_installed(repo):
|
||||
@@ -3361,11 +3238,7 @@ def asr_model_missing_error(*, purpose: str = "transcribe",
|
||||
return {
|
||||
"error": ASR_MODEL_MISSING,
|
||||
"missing_repo_id": repo,
|
||||
"recommended": _recommended_asr_model(
|
||||
purpose, repo,
|
||||
prefer_sherpa=prefer_sherpa_recommendation,
|
||||
excluded_sherpa_model_id=excluded_sherpa_model_id,
|
||||
),
|
||||
"recommended": _recommended_asr_model(purpose, repo),
|
||||
}
|
||||
except Exception: # noqa: BLE001 — preflight is best-effort, never a blocker
|
||||
logger.warning("ASR install preflight failed — proceeding without it",
|
||||
|
||||
@@ -742,74 +742,6 @@ def _ensure_browser_playable_mp4(video_path: str) -> str:
|
||||
return video_path
|
||||
|
||||
|
||||
async def _ensure_browser_playable_mp4_for_job(job_id: str, video_path: str) -> str:
|
||||
"""Normalize an upload through the job's cancellable process registry."""
|
||||
is_mp4 = video_path.lower().endswith(".mp4")
|
||||
vcodec, acodec = await asyncio.to_thread(_probe_codecs, video_path)
|
||||
if is_mp4 and vcodec in _BROWSER_VIDEO_CODECS and acodec in _BROWSER_AUDIO_CODECS:
|
||||
return video_path
|
||||
|
||||
target = os.path.splitext(video_path)[0] + ".mp4"
|
||||
if target == video_path:
|
||||
target = os.path.splitext(video_path)[0] + ".browser.mp4"
|
||||
run_proc = run_proc_factory(job_id)
|
||||
ffmpeg_bin = find_ffmpeg()
|
||||
|
||||
async def attempt(cmd: list[str]) -> int:
|
||||
try:
|
||||
proc, _stdout, _stderr = await run_proc(cmd, timeout=1800.0)
|
||||
return proc.returncode
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Browser-media normalization process failed for %s: %s",
|
||||
log_safe(video_path),
|
||||
log_safe(exc),
|
||||
)
|
||||
return 1
|
||||
|
||||
rc = 1
|
||||
if not is_mp4:
|
||||
rc = await attempt(
|
||||
[
|
||||
ffmpeg_bin, "-y", "-i", video_path,
|
||||
"-c:v", "copy", "-c:a", "copy",
|
||||
"-movflags", "+faststart", target,
|
||||
]
|
||||
)
|
||||
if rc == 0 and os.path.exists(target):
|
||||
target_vcodec, target_acodec = await asyncio.to_thread(_probe_codecs, target)
|
||||
if (
|
||||
target_vcodec not in _BROWSER_VIDEO_CODECS
|
||||
or target_acodec not in _BROWSER_AUDIO_CODECS
|
||||
):
|
||||
rc = 1
|
||||
else:
|
||||
rc = 1
|
||||
if rc != 0:
|
||||
rc = await attempt(
|
||||
[
|
||||
ffmpeg_bin, "-y", "-i", video_path,
|
||||
"-c:v", "libx264", "-preset", "veryfast", "-crf", "23",
|
||||
"-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "192k",
|
||||
"-movflags", "+faststart", target,
|
||||
]
|
||||
)
|
||||
if rc == 0 and os.path.exists(target) and target != video_path:
|
||||
try:
|
||||
os.remove(video_path)
|
||||
except OSError:
|
||||
pass # Best effort: the normalized target is already complete.
|
||||
return target
|
||||
logger.warning(
|
||||
"Could not transcode %s to browser-playable mp4 — the in-app "
|
||||
"video player may render this file as a black box.",
|
||||
log_safe(video_path),
|
||||
)
|
||||
return video_path
|
||||
|
||||
|
||||
# Bounded retry for transient download failures (#579/#598). yt-dlp's own
|
||||
# `retries`/`fragment_retries` cover per-fragment HTTP flakes, but a broken
|
||||
# pipe ([Errno 32]) raised while the write side of a pipe closes mid-stream
|
||||
@@ -1325,13 +1257,6 @@ async def ingest_pipeline(
|
||||
except Exception:
|
||||
dur = 0.0
|
||||
|
||||
# URL downloads already pass through this guard in yt_download_sync.
|
||||
# Uploaded videos did not, so a valid VP9/AV1/Opus upload could be
|
||||
# processed successfully but remain undecodable by the in-app WebView.
|
||||
# Codec probing/transcoding is blocking; keep it off the event loop.
|
||||
if source.get("kind") != "url" and input_type != "audio":
|
||||
video_path = await _ensure_browser_playable_mp4_for_job(job_id, video_path)
|
||||
|
||||
# Content-hash cache: reuse artifacts from previous matching jobs.
|
||||
content_hash = await asyncio.to_thread(compute_file_hash, audio_path)
|
||||
cached = find_cached_job(content_hash, job_id)
|
||||
@@ -1370,7 +1295,6 @@ async def ingest_pipeline(
|
||||
"scene_cuts": scene_cuts,
|
||||
"youtube_subs": youtube_subs_by_lang or None,
|
||||
"input_type": input_type,
|
||||
"source_lang_override": source.get("source_lang"),
|
||||
}
|
||||
if not put_and_save_job(
|
||||
job_id, full_job, filename=filename, duration=dur, content_hash=content_hash,
|
||||
@@ -1399,7 +1323,6 @@ async def ingest_pipeline(
|
||||
"scene_cuts": [],
|
||||
"youtube_subs": youtube_subs_by_lang or None,
|
||||
"input_type": input_type,
|
||||
"source_lang_override": source.get("source_lang"),
|
||||
}
|
||||
if not put_and_save_job(
|
||||
job_id, partial, filename=filename, duration=dur, content_hash=content_hash,
|
||||
|
||||
@@ -57,99 +57,6 @@ def _force_compile_requested() -> bool:
|
||||
return value.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
# ── FlashInfer opt-in (upstream k2-fsa port) ────────────────────────────────
|
||||
# Explicit power-user opt-in, CUDA-only: OMNIVOICE_FLASHINFER=1 patches the
|
||||
# OmniVoice model with flashinfer packed attention (~2x per upstream's
|
||||
# benchmarks); =graph additionally captures CUDA graphs (best at batch=1).
|
||||
# Off by default — `flashinfer` is not a shipped dependency, and an
|
||||
# optimization must never be a point of failure. Session-sticky failure
|
||||
# latch mirrors torch.compile's (#278).
|
||||
_FLASHINFER_ENV = "OMNIVOICE_FLASHINFER"
|
||||
_flashinfer_runtime_failure: Optional[str] = None
|
||||
|
||||
|
||||
def flashinfer_mode() -> str:
|
||||
"""The user's ``OMNIVOICE_FLASHINFER`` request: 'off' | 'on' | 'graph'.
|
||||
|
||||
Unknown values normalize to 'off' with a log line naming the env var, so
|
||||
a typo degrades to the default path instead of half-applying.
|
||||
"""
|
||||
value = os.environ.get(_FLASHINFER_ENV, "").strip().lower()
|
||||
if value in {"", "0", "false", "no", "off"}:
|
||||
return "off"
|
||||
if value in {"1", "true", "yes", "on"}:
|
||||
return "on"
|
||||
if value == "graph":
|
||||
return "graph"
|
||||
logger.warning(
|
||||
"%s=%r not recognized (valid: 0, 1, graph) — FlashInfer stays off.",
|
||||
_FLASHINFER_ENV, value,
|
||||
)
|
||||
return "off"
|
||||
|
||||
|
||||
def should_flashinfer(device: str) -> str:
|
||||
"""Resolve the FlashInfer request against this host: 'off' | 'on' | 'graph'.
|
||||
|
||||
Requires all of: the ``OMNIVOICE_FLASHINFER`` opt-in, device == "cuda"
|
||||
(flashinfer is CUDA-only), the ``flashinfer`` package importable, and no
|
||||
earlier runtime failure this session. Every refusal is logged with the
|
||||
reason and the knob's name — the user asked for it, so silence would read
|
||||
as "the setting doesn't work".
|
||||
"""
|
||||
mode = flashinfer_mode()
|
||||
if mode == "off":
|
||||
return "off"
|
||||
if device != "cuda":
|
||||
logger.warning(
|
||||
"%s requested but the compute device is %r — FlashInfer is "
|
||||
"CUDA-only, continuing without it.", _FLASHINFER_ENV, device,
|
||||
)
|
||||
return "off"
|
||||
if importlib.util.find_spec("flashinfer") is None:
|
||||
logger.warning(
|
||||
"%s requested but the `flashinfer` package is not installed — "
|
||||
"continuing without it. Install with: uv pip install "
|
||||
"flashinfer-python flashinfer-jit-cache "
|
||||
"--extra-index-url https://flashinfer.ai/whl/cu128/ "
|
||||
"(pick the index matching your CUDA build).", _FLASHINFER_ENV,
|
||||
)
|
||||
return "off"
|
||||
if _flashinfer_runtime_failure is not None:
|
||||
logger.info(
|
||||
"FlashInfer skipped: failed earlier this session (%s) — using the "
|
||||
"standard path.", _flashinfer_runtime_failure,
|
||||
)
|
||||
return "off"
|
||||
return mode
|
||||
|
||||
|
||||
def mark_flashinfer_runtime_failure(reason: str) -> None:
|
||||
"""Latch a FlashInfer apply/runtime failure for the rest of the process,
|
||||
same contract as ``mark_compile_runtime_failure``."""
|
||||
global _flashinfer_runtime_failure
|
||||
try:
|
||||
# Import/kernel errors embed absolute paths (wheels under the user's
|
||||
# home) — redact before latching, since the reason is logged here and
|
||||
# re-logged on every later skip.
|
||||
from core.failure import sanitize
|
||||
|
||||
reason = sanitize(reason)
|
||||
except Exception:
|
||||
# Fail closed: if the redactor itself breaks, latching the raw text
|
||||
# would defeat the redaction. Keep only the exception class (the part
|
||||
# before ':' in our "Type: message" reasons) and drop the message.
|
||||
reason = (
|
||||
f"{(reason or '').split(':', 1)[0][:80]} "
|
||||
"(details redacted: sanitizer unavailable)"
|
||||
).strip()
|
||||
_flashinfer_runtime_failure = reason or "unknown FlashInfer runtime failure"
|
||||
logger.warning(
|
||||
"FlashInfer disabled for this session after a runtime failure: %s",
|
||||
_flashinfer_runtime_failure,
|
||||
)
|
||||
|
||||
|
||||
def _cuda_arch_supported_for_compile() -> "tuple[bool, str]":
|
||||
"""Check the GPU's architecture against this torch build's arch list.
|
||||
|
||||
|
||||
@@ -177,9 +177,6 @@ class LocalCall:
|
||||
queue_timeout: Optional[float] = None
|
||||
# The engine's declared VRAM floor; only shapes the timeout message.
|
||||
min_vram_gb: float = 0.0
|
||||
# Called once a local worker abandoned by its waiter can no longer touch
|
||||
# request-owned inputs. Normal completion does not call it (#1668).
|
||||
on_abandon: Optional[Callable[[], None]] = None
|
||||
# Some remote-first callers cannot construct the local callable without
|
||||
# loading the very model they are trying to offload. Prepare it only when
|
||||
# routing/fallback actually selects this machine.
|
||||
@@ -468,7 +465,6 @@ async def _run_local(call: LocalCall, *, admit: bool = False, executor=None) ->
|
||||
queue_timeout=call.queue_timeout,
|
||||
min_vram_gb=call.min_vram_gb,
|
||||
executor=executor,
|
||||
on_abandon=call.on_abandon,
|
||||
)
|
||||
|
||||
|
||||
@@ -503,9 +499,7 @@ async def _run_remote(
|
||||
deadline = _default_deadline(call.operation, params.get("text"))
|
||||
|
||||
try:
|
||||
submit = getattr(scheduler, "submit_async", None)
|
||||
submit = submit if callable(submit) else scheduler.submit
|
||||
submitted = submit(
|
||||
task = scheduler.submit(
|
||||
operation=call.operation,
|
||||
engine=call.engine,
|
||||
model_id=call.model_id,
|
||||
@@ -514,7 +508,6 @@ async def _run_remote(
|
||||
deadline_seconds=deadline,
|
||||
pinned_worker_id=decision.worker_id,
|
||||
)
|
||||
task = await submitted if asyncio.iscoroutine(submitted) else submitted
|
||||
except QueueFull as exc:
|
||||
raise _NotDispatched(str(exc)) from exc
|
||||
|
||||
|
||||
@@ -251,33 +251,10 @@ def list_backends() -> list[dict]:
|
||||
"effective_device": "network",
|
||||
"routing_status": "n/a",
|
||||
"routing_reason": None,
|
||||
# The openai-compat family entry and the LLM Providers panel are
|
||||
# ONE system (this backend resolves through the active provider),
|
||||
# but the UI presented them as unrelated. Naming the resolved
|
||||
# provider + model here lets the catalogue row say which endpoint
|
||||
# actually answers, instead of a generic family label.
|
||||
"hint": _provider_hint(bid) if ok else None,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _provider_hint(bid: str) -> str | None:
|
||||
"""``Provider · model`` for the openai-compat row, None for everything else."""
|
||||
if bid != "openai-compat":
|
||||
return None
|
||||
try:
|
||||
from services import llm_providers
|
||||
p = llm_providers.active_provider()
|
||||
if p is None:
|
||||
return None
|
||||
model = llm_providers.resolve_model(p)
|
||||
return f"{p.display_name} · {model}" if model else p.display_name
|
||||
except Exception:
|
||||
# The hint is decoration; a provider-registry hiccup must not take
|
||||
# down the whole engines listing.
|
||||
return None
|
||||
|
||||
|
||||
def active_backend_id() -> str:
|
||||
explicit = os.environ.get("OMNIVOICE_LLM_BACKEND")
|
||||
if explicit:
|
||||
|
||||
@@ -154,17 +154,6 @@ def bundled_dir() -> str:
|
||||
return os.path.join(media_tools_dir(), f"ffbin-{_FFBIN_COMMIT[:12]}", _platform_key())
|
||||
|
||||
|
||||
def _publish_bundled_on_path() -> None:
|
||||
"""Make a newly validated bundle visible to bare-name subprocess calls."""
|
||||
directory = os.path.abspath(bundled_dir())
|
||||
current = os.environ.get("PATH", "")
|
||||
entries = current.split(os.pathsep) if current else []
|
||||
if os.path.normcase(directory) in {os.path.normcase(entry) for entry in entries if entry}:
|
||||
return
|
||||
os.environ["PATH"] = os.pathsep.join([directory, *entries])
|
||||
logger.info("Published the acquired media-tool directory on PATH")
|
||||
|
||||
|
||||
def _exe(name: str) -> str:
|
||||
return f"{name}.exe" if sys.platform == "win32" else name
|
||||
|
||||
@@ -242,21 +231,12 @@ def acquire_bundled(wait: bool = False) -> dict:
|
||||
_ops["acquire"].update(state="running", progress=0.0, error=None)
|
||||
|
||||
if all(bundled_tool_path(t) and _binary_runs(bundled_tool_path(t)) for t in TOOLS):
|
||||
# The bundle may have arrived after startup's one-time PATH publish
|
||||
# (first-run acquisition is asynchronous). Make it visible to pydub
|
||||
# and other dependencies that launch ffmpeg/ffprobe by bare name now,
|
||||
# without requiring a backend restart (#1677).
|
||||
_publish_bundled_on_path()
|
||||
_set_op("acquire", state="done", progress=1.0)
|
||||
return _op_snapshot()["acquire"]
|
||||
|
||||
def _worker():
|
||||
try:
|
||||
_do_acquire()
|
||||
# Startup cannot publish binaries which do not exist yet. The
|
||||
# background worker must complete that second half atomically with
|
||||
# installation so the very next synthesis can use the tools.
|
||||
_publish_bundled_on_path()
|
||||
_set_op("acquire", state="done", progress=1.0, error=None)
|
||||
logger.info("media-tools: bundled ffmpeg/ffprobe installed at %s", bundled_dir())
|
||||
except Exception as e:
|
||||
|
||||
@@ -4,9 +4,8 @@ import sys
|
||||
import time
|
||||
import asyncio
|
||||
import logging
|
||||
import queue
|
||||
import threading
|
||||
from concurrent.futures import Executor, Future, ThreadPoolExecutor
|
||||
from concurrent.futures import ThreadPoolExecutor, Executor
|
||||
|
||||
from utils.containment import contain_system_exit
|
||||
|
||||
@@ -398,13 +397,7 @@ def __getattr__(name: str):
|
||||
# (generation.py, tts_stream.py) were the last unguarded dispatch — and the
|
||||
# residual on-main reports all fail on generate:start (audio). This is the same
|
||||
# guard generalised so every GPU dispatch shares one recovery path.
|
||||
_GENERATE_TIMEOUT_EXPLICIT = "OMNIVOICE_GENERATE_TIMEOUT_S" in os.environ
|
||||
GPU_JOB_TIMEOUT_S = float(os.environ.get("OMNIVOICE_GENERATE_TIMEOUT_S", "300.0"))
|
||||
_CONFIGURED_GPU_JOB_TIMEOUT_S = GPU_JOB_TIMEOUT_S
|
||||
# CPU synthesis is healthy but substantially slower than accelerated inference.
|
||||
# Keep a separate, bounded floor so a short render on CPU is not abandoned at
|
||||
# the GPU-oriented five-minute deadline (#1588).
|
||||
CPU_JOB_TIMEOUT_S = float(os.environ.get("OMNIVOICE_CPU_GENERATE_TIMEOUT_S", "600.0"))
|
||||
|
||||
# Queue-wait budget — a SEPARATE, deliberately generous clock (#1190/#1202).
|
||||
# The execution bound above must never be spent waiting in line: a job queued
|
||||
@@ -507,9 +500,7 @@ class GpuPoolBusyError(TimeoutError):
|
||||
self.retry_after = max(1, int(round(retry_after)))
|
||||
|
||||
|
||||
def generate_timeout_s(
|
||||
text: "str | None", *, engine: object = None, execution_device: "str | None" = None,
|
||||
) -> float:
|
||||
def generate_timeout_s(text: "str | None") -> float:
|
||||
"""THE wall-clock execution budget for one synthesis job, scaled to input.
|
||||
|
||||
Single source of truth for every TTS dispatch (#1190/#1202). The
|
||||
@@ -525,33 +516,10 @@ def generate_timeout_s(
|
||||
CPU-class hardware, still bounded (a wedged job is caught in minutes, not
|
||||
hours).
|
||||
"""
|
||||
base = GPU_JOB_TIMEOUT_S
|
||||
try:
|
||||
from core.device_caps import detect_host_caps
|
||||
family = execution_device or detect_host_caps().family
|
||||
if execution_device is None and engine is not None:
|
||||
from services.engine_routing import resolve_routing
|
||||
compat = getattr(engine, "gpu_compat", None)
|
||||
if compat is None:
|
||||
compat = getattr(type(engine), "gpu_compat", (family, "cpu"))
|
||||
if tuple(compat) == ("cpu",):
|
||||
family = "cpu"
|
||||
else:
|
||||
family = resolve_routing(
|
||||
compat, detect_host_caps(),
|
||||
float(getattr(engine, "min_vram_gb", 0.0) or 0.0),
|
||||
)["effective_device"]
|
||||
universal_override = (
|
||||
_GENERATE_TIMEOUT_EXPLICIT
|
||||
or GPU_JOB_TIMEOUT_S != _CONFIGURED_GPU_JOB_TIMEOUT_S
|
||||
)
|
||||
if family == "cpu" and not universal_override:
|
||||
base = CPU_JOB_TIMEOUT_S
|
||||
except Exception:
|
||||
# Device probing is advisory here; the configured universal bound is
|
||||
# still safe when a platform probe is unavailable during startup.
|
||||
pass
|
||||
return base + (max(0, len(text or "") - 1200) / 40.0)
|
||||
return max(
|
||||
GPU_JOB_TIMEOUT_S,
|
||||
GPU_JOB_TIMEOUT_S + (max(0, len(text or "") - 1200) / 40.0),
|
||||
)
|
||||
|
||||
|
||||
def _retry_after_estimate(stats: dict) -> float:
|
||||
@@ -631,8 +599,7 @@ async def run_on_gpu_pool_guarded(fn, *, what: str = "GPU job",
|
||||
timeout: "float | None" = None,
|
||||
executor=None,
|
||||
queue_timeout: "float | None" = None,
|
||||
min_vram_gb: float = 0.0,
|
||||
on_abandon=None):
|
||||
min_vram_gb: float = 0.0):
|
||||
"""Run blocking ``fn`` on the GPU pool, bounding **execution** — not the
|
||||
wait for a free worker.
|
||||
|
||||
@@ -660,12 +627,6 @@ async def run_on_gpu_pool_guarded(fn, *, what: str = "GPU job",
|
||||
at 0 — the default, and correct for every non-TTS job on this pool
|
||||
(reference transcribe, watermarking, dub steps) — the under-provisioned-GPU
|
||||
wording is never used, because nothing measured says it applies (#1226).
|
||||
|
||||
``on_abandon`` is called once, after a job whose caller stopped waiting can
|
||||
no longer access its inputs. A queued job that is cancelled before it
|
||||
starts calls it immediately; a running thread calls it from ``_job``'s
|
||||
finalizer. Normal completion never calls it. This lets request-owned temp
|
||||
files outlive abandoned workers without delaying ordinary requests (#1668).
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
ex = executor if executor is not None else _get_gpu_pool()
|
||||
@@ -680,24 +641,6 @@ async def run_on_gpu_pool_guarded(fn, *, what: str = "GPU job",
|
||||
# job's model-load heartbeats (#1367). A dict, not a nonlocal: the closure
|
||||
# runs on a pool thread while the waiter reads from the event loop.
|
||||
_ident_box: dict = {}
|
||||
_abandon_lock = threading.Lock()
|
||||
_abandon_state = {
|
||||
"requested": False,
|
||||
"finished": False,
|
||||
"callback_called": False,
|
||||
}
|
||||
|
||||
def _fire_abandon_callback() -> None:
|
||||
if on_abandon is None:
|
||||
return
|
||||
with _abandon_lock:
|
||||
if _abandon_state["callback_called"]:
|
||||
return
|
||||
_abandon_state["callback_called"] = True
|
||||
try:
|
||||
on_abandon()
|
||||
except Exception: # noqa: BLE001 — cleanup cannot hide the pool result
|
||||
logger.exception("%s abandon cleanup failed", _log_safe(what))
|
||||
|
||||
def _job():
|
||||
# First thing the worker does: tell the awaiting coroutine the
|
||||
@@ -714,26 +657,8 @@ async def run_on_gpu_pool_guarded(fn, *, what: str = "GPU job",
|
||||
# Idents are reused by the OS; a stale heartbeat under this ident
|
||||
# must not vouch for some future job on the same thread.
|
||||
_MODEL_LOAD_ACTIVITY.pop(threading.get_ident(), None)
|
||||
with _abandon_lock:
|
||||
_abandon_state["finished"] = True
|
||||
abandoned = _abandon_state["requested"]
|
||||
if abandoned:
|
||||
_fire_abandon_callback()
|
||||
|
||||
concurrent_fut = ex.submit(_job)
|
||||
fut = asyncio.wrap_future(concurrent_fut, loop=loop)
|
||||
|
||||
def _abandon() -> None:
|
||||
# Keep the concurrent future so we can distinguish a job cancelled out
|
||||
# of the queue from a thread that Python cannot stop once it has begun.
|
||||
cancelled_before_start = concurrent_fut.cancel()
|
||||
with _abandon_lock:
|
||||
_abandon_state["requested"] = True
|
||||
finished = _abandon_state["finished"]
|
||||
fut.cancel()
|
||||
if cancelled_before_start or finished:
|
||||
_fire_abandon_callback()
|
||||
|
||||
fut = loop.run_in_executor(ex, _job)
|
||||
waiter = asyncio.ensure_future(started.wait())
|
||||
try:
|
||||
# Phase 1 — queue wait. Watch the future too, so a job that fails or is
|
||||
@@ -746,7 +671,6 @@ async def run_on_gpu_pool_guarded(fn, *, what: str = "GPU job",
|
||||
# Caller went away (client disconnect). We stop awaiting the job, so
|
||||
# make sure its eventual result/exception is consumed rather than
|
||||
# logged as "Future exception was never retrieved".
|
||||
_abandon()
|
||||
fut.add_done_callback(_swallow_abandoned)
|
||||
raise
|
||||
finally:
|
||||
@@ -756,7 +680,7 @@ async def run_on_gpu_pool_guarded(fn, *, what: str = "GPU job",
|
||||
# Never picked up: cancel it out of the queue (a not-yet-started
|
||||
# concurrent future cancels cleanly) and report saturation, NOT a
|
||||
# too-heavy job.
|
||||
_abandon()
|
||||
fut.cancel()
|
||||
fut.add_done_callback(_swallow_abandoned)
|
||||
stats = gpu_pool_stats(ex)
|
||||
logger.warning(
|
||||
@@ -827,14 +751,14 @@ async def run_on_gpu_pool_guarded(fn, *, what: str = "GPU job",
|
||||
# Caller went away mid-execution. The old wait_for cancelled the
|
||||
# wrapper itself; asyncio.wait does not, so do both halves here or the
|
||||
# eventual result is logged as "Future exception was never retrieved".
|
||||
_abandon()
|
||||
fut.cancel()
|
||||
fut.add_done_callback(_swallow_abandoned)
|
||||
raise
|
||||
except asyncio.TimeoutError as timeout_exc:
|
||||
# Parity with the old wait_for semantics: cancel the asyncio wrapper;
|
||||
# the worker thread keeps going regardless. Consume whatever it
|
||||
# eventually produces.
|
||||
_abandon()
|
||||
fut.cancel()
|
||||
fut.add_done_callback(_swallow_abandoned)
|
||||
# Capture the stacks BEFORE reset(): reset() replaces the executor, and
|
||||
# once the wedged thread is no longer a pool worker we can no longer
|
||||
@@ -1133,166 +1057,21 @@ def _timeout_guidance(
|
||||
# doubling the effective queue depth of a streamed multi-chunk render.
|
||||
# Giving it its own tiny pool removes that head-of-line blocking with no VRAM
|
||||
# risk, because the work was never on the device to begin with.
|
||||
_WATERMARK_STOP = object()
|
||||
|
||||
|
||||
class _WatermarkExecutor(Executor):
|
||||
"""Single daemon worker with a bounded shutdown contract.
|
||||
|
||||
``ThreadPoolExecutor`` uses non-daemon workers that Python joins at exit,
|
||||
so ``wait=False`` still delays process exit while ``wait=True`` can hang
|
||||
lifespan teardown forever. AudioSeal loading is not cooperatively
|
||||
cancellable; a daemon worker plus a bounded join is the only thread-based
|
||||
contract that both preserves in-process model warm-up and guarantees exit.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._items: queue.Queue = queue.Queue()
|
||||
self._lock = threading.Lock()
|
||||
self._shutdown = False
|
||||
self._thread: threading.Thread | None = None
|
||||
|
||||
def submit(self, fn, /, *args, **kwargs) -> Future:
|
||||
future: Future = Future()
|
||||
with self._lock:
|
||||
if self._shutdown:
|
||||
raise RuntimeError("cannot schedule new futures after shutdown")
|
||||
if self._thread is None:
|
||||
self._thread = threading.Thread(
|
||||
target=self._run,
|
||||
name="watermark_0",
|
||||
daemon=True,
|
||||
)
|
||||
self._thread.start()
|
||||
self._items.put((future, fn, args, kwargs))
|
||||
return future
|
||||
|
||||
def _run(self) -> None:
|
||||
while True:
|
||||
item = self._items.get()
|
||||
if item is _WATERMARK_STOP:
|
||||
return
|
||||
future, fn, args, kwargs = item
|
||||
if not future.set_running_or_notify_cancel():
|
||||
continue
|
||||
try:
|
||||
future.set_result(fn(*args, **kwargs))
|
||||
except (Exception, SystemExit, KeyboardInterrupt) as exc:
|
||||
future.set_exception(exc)
|
||||
|
||||
def is_stopped(self) -> bool:
|
||||
"""Whether shutdown has completed and this executor can be replaced."""
|
||||
with self._lock:
|
||||
return self._shutdown and (
|
||||
self._thread is None or not self._thread.is_alive()
|
||||
)
|
||||
|
||||
def is_shutdown(self) -> bool:
|
||||
with self._lock:
|
||||
return self._shutdown
|
||||
|
||||
def shutdown(
|
||||
self,
|
||||
wait: bool = True,
|
||||
*,
|
||||
cancel_futures: bool = False,
|
||||
timeout: float | None = None,
|
||||
) -> bool:
|
||||
with self._lock:
|
||||
self._shutdown = True
|
||||
thread = self._thread
|
||||
if cancel_futures:
|
||||
while True:
|
||||
try:
|
||||
item = self._items.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
if item is not _WATERMARK_STOP:
|
||||
item[0].cancel()
|
||||
self._items.put(_WATERMARK_STOP)
|
||||
if wait and thread is not None:
|
||||
thread.join(timeout=timeout)
|
||||
return thread is None or not thread.is_alive()
|
||||
|
||||
|
||||
_watermark_pool_singleton: "_WatermarkExecutor | None" = None
|
||||
_watermark_pool_singleton: "ThreadPoolExecutor | None" = None
|
||||
_watermark_pool_lock = threading.Lock()
|
||||
_watermark_pool_accepting = True
|
||||
|
||||
|
||||
def begin_watermark_pool_lifecycle() -> None:
|
||||
"""Open watermark submissions for a newly-started app lifespan."""
|
||||
global _watermark_pool_accepting, _watermark_pool_singleton
|
||||
with _watermark_pool_lock:
|
||||
if (
|
||||
_watermark_pool_singleton is not None
|
||||
and _watermark_pool_singleton.is_stopped()
|
||||
):
|
||||
_watermark_pool_singleton = None
|
||||
_watermark_pool_accepting = (
|
||||
_watermark_pool_singleton is None
|
||||
or not _watermark_pool_singleton.is_shutdown()
|
||||
)
|
||||
|
||||
|
||||
def get_watermark_pool() -> _WatermarkExecutor:
|
||||
def get_watermark_pool() -> ThreadPoolExecutor:
|
||||
"""Dedicated 1-worker pool for provenance marking. Built lazily so hosts
|
||||
with watermarking disabled never spawn the thread.
|
||||
|
||||
The executor is captured and returned UNDER the lock: reading the global
|
||||
again after an unlocked null-check could race shutdown_watermark_pool's
|
||||
reset and hand out None (CodeRabbit, PR #1577)."""
|
||||
global _watermark_pool_accepting, _watermark_pool_singleton
|
||||
with _watermark_pool_lock:
|
||||
if not _watermark_pool_accepting:
|
||||
if (
|
||||
_watermark_pool_singleton is not None
|
||||
and _watermark_pool_singleton.is_stopped()
|
||||
):
|
||||
_watermark_pool_singleton = None
|
||||
_watermark_pool_accepting = True
|
||||
else:
|
||||
raise RuntimeError("watermark executor is shutting down")
|
||||
if (
|
||||
_watermark_pool_singleton is not None
|
||||
and _watermark_pool_singleton.is_stopped()
|
||||
):
|
||||
_watermark_pool_singleton = None
|
||||
if _watermark_pool_singleton is None:
|
||||
_watermark_pool_singleton = _WatermarkExecutor()
|
||||
return _watermark_pool_singleton
|
||||
|
||||
|
||||
def shutdown_watermark_pool(*, timeout: float = 20.0) -> None:
|
||||
"""Drain the watermark pool at app shutdown (PR #1577).
|
||||
|
||||
Refuse queued work and wait for the active operation: Python cannot kill
|
||||
a thread inside AudioSeal loading, so returning early would let model
|
||||
initialization continue during interpreter teardown. The draining pool
|
||||
remains published until its worker stops, preventing concurrent producers
|
||||
from creating a replacement that escapes this shutdown. A process that
|
||||
keeps running after lifespan shutdown (the test suite does exactly this)
|
||||
gets a fresh pool once the old worker has actually stopped."""
|
||||
global _watermark_pool_accepting, _watermark_pool_singleton
|
||||
with _watermark_pool_lock:
|
||||
_watermark_pool_accepting = False
|
||||
pool = _watermark_pool_singleton
|
||||
if pool is not None:
|
||||
stopped = pool.shutdown(
|
||||
wait=True,
|
||||
cancel_futures=True,
|
||||
timeout=max(0.0, float(timeout)),
|
||||
)
|
||||
if stopped:
|
||||
with _watermark_pool_lock:
|
||||
if _watermark_pool_singleton is pool:
|
||||
_watermark_pool_singleton = None
|
||||
else:
|
||||
logger.warning(
|
||||
"Watermark worker exceeded the %.1fs shutdown deadline; "
|
||||
"abandoning its daemon thread",
|
||||
timeout,
|
||||
)
|
||||
with watermarking disabled never spawn the thread."""
|
||||
global _watermark_pool_singleton
|
||||
if _watermark_pool_singleton is None:
|
||||
with _watermark_pool_lock:
|
||||
if _watermark_pool_singleton is None:
|
||||
_watermark_pool_singleton = ThreadPoolExecutor(
|
||||
max_workers=1, thread_name_prefix="watermark",
|
||||
)
|
||||
return _watermark_pool_singleton
|
||||
|
||||
|
||||
model = None # type: ignore
|
||||
@@ -1597,122 +1376,6 @@ def _install_compile_fallback(_model) -> None:
|
||||
_model.generate = _generate_with_compile_fallback
|
||||
|
||||
|
||||
# ── FlashInfer runtime fallback (upstream k2-fsa port) ──────────────────────
|
||||
|
||||
|
||||
def _is_flashinfer_runtime_failure(exc: BaseException) -> bool:
|
||||
"""True when an exception originates in the FlashInfer fast path (the
|
||||
flashinfer package, our omnivoice_flashinfer patch module, or CUDA-graph
|
||||
capture/replay) rather than in the model or the request itself. Same
|
||||
chain/traceback walk as ``_is_compile_runtime_failure``."""
|
||||
import traceback as _tb
|
||||
|
||||
tb_markers = ("/flashinfer/", "omnivoice_flashinfer")
|
||||
msg_markers = ("flashinfer", "cuda graph", "cudagraph")
|
||||
seen: set[int] = set()
|
||||
cur: BaseException | None = exc
|
||||
while cur is not None and id(cur) not in seen:
|
||||
seen.add(id(cur))
|
||||
mod = type(cur).__module__ or ""
|
||||
if mod.startswith("flashinfer"):
|
||||
return True
|
||||
msg = str(cur).lower()
|
||||
if any(marker in msg for marker in msg_markers):
|
||||
return True
|
||||
try:
|
||||
for frame in _tb.extract_tb(cur.__traceback__):
|
||||
filename = (frame.filename or "").replace("\\", "/")
|
||||
if any(marker in filename for marker in tb_markers):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
if cur.__cause__ is not None:
|
||||
cur = cur.__cause__
|
||||
elif not cur.__suppress_context__:
|
||||
cur = cur.__context__
|
||||
else:
|
||||
cur = None
|
||||
return False
|
||||
|
||||
|
||||
def _unapply_flashinfer(_model) -> None:
|
||||
"""Restore the standard execution path on a FlashInfer-patched model.
|
||||
|
||||
``apply_flashinfer`` works entirely through *instance-level* state —
|
||||
MethodType-bound ``forward``/``_generate_iterative`` overrides and
|
||||
``_fi_*`` attributes — so deleting those attributes restores the class
|
||||
implementations exactly. The attention implementation is restored to the
|
||||
one captured before apply (``_fi_orig_attn_impl`` — could be
|
||||
flash_attention_2, not just sdpa), and use_cache is re-enabled."""
|
||||
llm = getattr(_model, "llm", None)
|
||||
orig_attn = getattr(_model, "_fi_orig_attn_impl", None) or "sdpa"
|
||||
if llm is not None:
|
||||
for module in llm.modules():
|
||||
if "forward" in vars(module):
|
||||
del module.forward
|
||||
for attr in ("_fi_w_qkv", "_fi_qkv_split", "_fi_rope_theta", "_fi_w_gate_up"):
|
||||
if attr in vars(module):
|
||||
delattr(module, attr)
|
||||
try:
|
||||
llm.set_attn_implementation(orig_attn)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"failed to restore %s attention after FlashInfer", orig_attn
|
||||
)
|
||||
llm.config.use_cache = True
|
||||
for attr in (
|
||||
"_fi_orig_attn_impl",
|
||||
"_generate_iterative",
|
||||
"_fi_runner",
|
||||
"_fi_graph_cache",
|
||||
"_fi_enable_cuda_graph",
|
||||
"_fi_graph_buckets",
|
||||
"_fi_overhead_budget",
|
||||
):
|
||||
if attr in vars(_model):
|
||||
delattr(_model, attr)
|
||||
|
||||
|
||||
def _install_flashinfer_fallback(_model) -> None:
|
||||
"""Wrap ``model.generate`` so a FlashInfer failure at inference time falls
|
||||
back to the standard path instead of failing the generation — the same
|
||||
contract as ``_install_compile_fallback`` (#278): an optimization must
|
||||
never turn a working generation into an error."""
|
||||
orig_generate = _model.generate
|
||||
|
||||
def _generate_with_flashinfer_fallback(*args, **kwargs):
|
||||
try:
|
||||
return orig_generate(*args, **kwargs)
|
||||
except Exception as exc:
|
||||
if not _is_flashinfer_runtime_failure(exc):
|
||||
raise
|
||||
logger.warning(
|
||||
"FlashInfer runtime failure during generation (%s: %s) — "
|
||||
"restoring the standard path and disabling FlashInfer for "
|
||||
"this session. Generation is being retried without it.",
|
||||
type(exc).__name__, exc,
|
||||
)
|
||||
from services import engine_env
|
||||
engine_env.mark_flashinfer_runtime_failure(
|
||||
f"{type(exc).__name__}: {exc}"
|
||||
)
|
||||
# Unapply BEFORE exposing the eager path: while the teardown
|
||||
# mutates modules, _model.generate still routes through the
|
||||
# thread-affinity wrapper, so a concurrent render queues behind
|
||||
# this call instead of racing the half-restored model (Greptile,
|
||||
# #1565 round 2). Only a fully restored model is published.
|
||||
_unapply_flashinfer(_model)
|
||||
_model.generate = orig_generate
|
||||
try:
|
||||
return orig_generate(*args, **kwargs)
|
||||
except Exception as plain_exc:
|
||||
# `from None`: a genuine standard-path failure must not be
|
||||
# chained to — and misread as — the FlashInfer error.
|
||||
raise plain_exc from None
|
||||
|
||||
_model.generate = _generate_with_flashinfer_fallback
|
||||
|
||||
|
||||
# ── #315: thread affinity for cudagraph-compiled models ─────────────────────
|
||||
# `torch.compile(mode="reduce-overhead")` captures CUDA graphs, and captured
|
||||
# graph state is **thread-local** (torch/_inductor/cudagraph_trees keys its
|
||||
@@ -2454,57 +2117,6 @@ def _load_model_sync():
|
||||
"to stop preloading it alongside TTS."
|
||||
) from asr_exc
|
||||
|
||||
# FlashInfer opt-in (upstream k2-fsa port): packed CFG attention +
|
||||
# fused kernels, ~2x on upstream's benchmarks. Applied INSTEAD of
|
||||
# torch.compile — both rewrite the llm's execution and they do not
|
||||
# compose. Best-effort: any apply failure latches the session off and
|
||||
# the standard path continues untouched.
|
||||
flashinfer_applied = False
|
||||
try:
|
||||
from services.engine_env import (
|
||||
mark_flashinfer_runtime_failure,
|
||||
should_flashinfer,
|
||||
)
|
||||
|
||||
fi_mode = should_flashinfer(device)
|
||||
if fi_mode != "off":
|
||||
_set_loading("compiling", "Applying FlashInfer kernels…")
|
||||
try:
|
||||
from omnivoice.models.omnivoice_flashinfer import apply_flashinfer
|
||||
|
||||
# Captured BEFORE apply so unapply (either the failure
|
||||
# branch below or the generate-time fallback) restores
|
||||
# the true prior implementation.
|
||||
_model._fi_orig_attn_impl = getattr(
|
||||
_model.llm.config, "_attn_implementation", "sdpa"
|
||||
)
|
||||
apply_flashinfer(_model, enable_cuda_graph=(fi_mode == "graph"))
|
||||
except Exception as fi_exc: # noqa: BLE001 — perf opt, never fatal
|
||||
mark_flashinfer_runtime_failure(
|
||||
f"{type(fi_exc).__name__}: {fi_exc}"
|
||||
)
|
||||
# apply_flashinfer mutates the model as it goes — a
|
||||
# failure partway leaves half-patched modules that would
|
||||
# crash the next render (Greptile, #1565). Restore fully.
|
||||
_unapply_flashinfer(_model)
|
||||
else:
|
||||
flashinfer_applied = True
|
||||
_install_flashinfer_fallback(_model)
|
||||
# BOTH modes pin inference to one thread. Graph mode for
|
||||
# the #315 reason (captured CUDA-graph state is
|
||||
# thread-local); eager mode because the FlashInfer
|
||||
# attention wrapper and packed position ids are planned
|
||||
# per generation in module state — two _gpu_pool workers
|
||||
# interleaving plan() and run() would corrupt each
|
||||
# other's layout (CodeRabbit/Greptile, #1565).
|
||||
_install_compile_thread_affinity(_model)
|
||||
logger.info(
|
||||
"FlashInfer applied (mode=%s) — torch.compile skipped "
|
||||
"for this load.", fi_mode,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("FlashInfer opt-in check failed; continuing without")
|
||||
|
||||
try:
|
||||
# plan-02 (#65): gate on Triton availability (+ user setting), not
|
||||
# just device==cuda. Triton has no Windows wheel, so the old
|
||||
@@ -2512,7 +2124,7 @@ def _load_model_sync():
|
||||
# falls back to eager there.
|
||||
from services.engine_env import should_torch_compile
|
||||
|
||||
if not flashinfer_applied and should_torch_compile(device):
|
||||
if should_torch_compile(device):
|
||||
_set_loading("compiling", "Compiling model (torch.compile)…")
|
||||
try:
|
||||
_model.llm = torch.compile(_model.llm, mode=_TORCH_COMPILE_MODE)
|
||||
@@ -2849,21 +2461,6 @@ async def preload_model():
|
||||
if model is not None:
|
||||
return # already loaded
|
||||
|
||||
# On MPS the configured ``omnivoice`` id resolves to a crash-isolated
|
||||
# sidecar. Warming the native singleton here would put the same fatal MPS
|
||||
# allocator risk back into the API process before the isolated engine is
|
||||
# ever asked to synthesize.
|
||||
try:
|
||||
from core.device_caps import detect_host_caps
|
||||
|
||||
if detect_host_caps().family == "mps":
|
||||
logger.info(
|
||||
"Native TTS preload skipped: OmniVoice uses crash isolation on this host."
|
||||
)
|
||||
return
|
||||
except Exception: # noqa: BLE001 -- preload selection must stay best-effort
|
||||
logger.debug("effective TTS preload selection failed", exc_info=True)
|
||||
|
||||
# A machine lending its GPU has no local user to warm the model FOR. This
|
||||
# preload exists to make the first /generate feel instant for the person
|
||||
# sitting in front of the app; on a headless node there is nobody sitting
|
||||
@@ -2953,10 +2550,7 @@ async def preload_model():
|
||||
"The TTS model could not be loaded. Settings → Logs → Backend "
|
||||
"has the full error."
|
||||
)
|
||||
# `sub_stage` is a public API enum and the frontend keys failure state
|
||||
# off `error`. Keep the human-readable word "failed" in the detail,
|
||||
# not in the state machine (#1695).
|
||||
_set_loading("error", detail, error=detail)
|
||||
_set_loading("failed", detail, error=detail)
|
||||
|
||||
def get_model_status():
|
||||
is_loaded = model is not None
|
||||
|
||||
@@ -79,7 +79,6 @@ class Segment:
|
||||
text: str
|
||||
speaker_id: str = "Speaker 1"
|
||||
id: str = field(default_factory=lambda: str(uuid.uuid4())[:8])
|
||||
extra: dict = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def duration(self) -> float:
|
||||
@@ -91,7 +90,6 @@ class Segment:
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
**self.extra,
|
||||
"id": self.id,
|
||||
"start": round(self.start, 2),
|
||||
"end": round(self.end, 2),
|
||||
@@ -100,46 +98,6 @@ class Segment:
|
||||
}
|
||||
|
||||
|
||||
def _merge_segment_extra(target: Segment, incoming: Segment, *, prepend: bool) -> None:
|
||||
"""Preserve editor metadata when cleanup folds ``incoming`` into ``target``."""
|
||||
for key, value in incoming.extra.items():
|
||||
target.extra.setdefault(key, value)
|
||||
|
||||
def joined(left: object, right: object) -> str:
|
||||
return _clean(f"{left or ''} {right or ''}")
|
||||
|
||||
target_original = target.extra.get("text_original")
|
||||
incoming_original = incoming.extra.get("text_original")
|
||||
if target_original is not None or incoming_original is not None:
|
||||
target.extra["text_original"] = (
|
||||
joined(incoming_original, target_original)
|
||||
if prepend
|
||||
else joined(target_original, incoming_original)
|
||||
)
|
||||
|
||||
raw_target_translations = target.extra.get("translations")
|
||||
raw_incoming_translations = incoming.extra.get("translations")
|
||||
target_translations = raw_target_translations if isinstance(raw_target_translations, dict) else {}
|
||||
incoming_translations = (
|
||||
raw_incoming_translations if isinstance(raw_incoming_translations, dict) else {}
|
||||
)
|
||||
if target_translations or incoming_translations:
|
||||
merged = {}
|
||||
languages = {
|
||||
*target_translations.keys(),
|
||||
*incoming_translations.keys(),
|
||||
}
|
||||
for language in languages:
|
||||
target_text = target_translations.get(language)
|
||||
incoming_text = incoming_translations.get(language)
|
||||
merged[language] = (
|
||||
joined(incoming_text, target_text)
|
||||
if prepend
|
||||
else joined(target_text, incoming_text)
|
||||
)
|
||||
target.extra["translations"] = merged
|
||||
|
||||
|
||||
def _clean(text: str) -> str:
|
||||
return _WS.sub(" ", (text or "").strip())
|
||||
|
||||
@@ -359,14 +317,12 @@ def _merge_short(segments: List[Segment]) -> List[Segment]:
|
||||
i += 1
|
||||
continue
|
||||
if target is prev:
|
||||
_merge_segment_extra(prev, s, prepend=False)
|
||||
prev.text = _clean(prev.text + " " + s.text)
|
||||
prev.end = max(prev.end, s.end)
|
||||
segments.pop(i)
|
||||
did_merge = True
|
||||
continue
|
||||
if target is nxt:
|
||||
_merge_segment_extra(nxt, s, prepend=True)
|
||||
nxt.text = _clean(s.text + " " + nxt.text)
|
||||
nxt.start = min(nxt.start, s.start)
|
||||
segments.pop(i)
|
||||
@@ -404,7 +360,6 @@ def _stitch_adjacent_shorts(segments: List[Segment]) -> List[Segment]:
|
||||
and b.duration <= STITCH_DUR
|
||||
and combined_dur <= MAX_DUR
|
||||
):
|
||||
_merge_segment_extra(a, b, prepend=False)
|
||||
a.text = _clean(a.text + " " + b.text)
|
||||
a.end = b.end
|
||||
segments.pop(i + 1)
|
||||
@@ -431,11 +386,6 @@ def clean_up_segments(segments: List[dict]) -> List[dict]:
|
||||
text=_clean(str(s.get("text", ""))),
|
||||
speaker_id=str(s.get("speaker_id") or "Speaker 1"),
|
||||
id=str(s.get("id") or uuid.uuid4().hex[:8]),
|
||||
extra={
|
||||
key: value
|
||||
for key, value in s.items()
|
||||
if key not in {"id", "start", "end", "text", "speaker_id"}
|
||||
},
|
||||
))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
@@ -254,31 +254,6 @@ def get_text(key: str, default: Optional[str] = None) -> Optional[str]:
|
||||
return default
|
||||
|
||||
|
||||
def get_text_state(key: str) -> tuple[bool, str]:
|
||||
"""Return ``(is_present, value)`` without hiding storage failures.
|
||||
|
||||
Rollback snapshots must distinguish a missing row from an unreadable
|
||||
database. ``get_text`` deliberately collapses those cases for ordinary
|
||||
preference reads, so transactional callers use this strict variant.
|
||||
"""
|
||||
if key == _TOKEN_KEY or key.startswith(_SECRET_PREFIX):
|
||||
raise ValueError(
|
||||
"get_text_state refuses to read an encrypted secret row; "
|
||||
"use get_hf_token()/get_secret() for secrets"
|
||||
)
|
||||
from core.db import db_conn
|
||||
|
||||
with db_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT value FROM settings WHERE key = ?", (key,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return False, ""
|
||||
if row[0] is None:
|
||||
return True, ""
|
||||
return True, str(row[0])
|
||||
|
||||
|
||||
def set_text(key: str, value: str) -> None:
|
||||
"""Persist a non-encrypted text value into the settings table.
|
||||
|
||||
@@ -299,19 +274,6 @@ def set_text(key: str, value: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
def clear_text(key: str) -> None:
|
||||
"""Remove a non-encrypted text setting, preserving a missing-row default."""
|
||||
if key == _TOKEN_KEY or key.startswith(_SECRET_PREFIX):
|
||||
raise ValueError(
|
||||
"clear_text refuses to delete an encrypted secret row; "
|
||||
"use clear_hf_token()/clear_secret() for secrets"
|
||||
)
|
||||
from core.db import db_conn
|
||||
|
||||
with db_conn() as conn:
|
||||
conn.execute("DELETE FROM settings WHERE key = ?", (key,))
|
||||
|
||||
|
||||
# ── Phase 4 Plan 04-01 (GGUF-04): per-engine quant override ────────────────
|
||||
#
|
||||
# Settings row "gguf_quant_override" holds either:
|
||||
|
||||
@@ -110,7 +110,7 @@ class SherpaModelSpec:
|
||||
# the same HF tree API on 2026-08-07 — not estimated. Every one of the seven
|
||||
# was wrong before, and in both directions, which is worse than uniformly
|
||||
# optimistic: the two Parakeets under-reported by ~3.8x (0.18 -> 0.67 GB),
|
||||
# so installing v3 quietly downloaded four times what the picker
|
||||
# so the recommended default quietly downloaded four times what the picker
|
||||
# promised on a metered or small-disk machine; but the two low-RAM
|
||||
# zipformers OVER-reported by ~3x (0.128 -> 0.044), making the fallback
|
||||
# models look bulkier than the heavyweights they exist to rescue users
|
||||
@@ -129,6 +129,7 @@ _MODELS: dict[str, SherpaModelSpec] = {
|
||||
kind="offline-transducer",
|
||||
size_gb=0.67,
|
||||
languages="25 European languages",
|
||||
recommended=True,
|
||||
heavy=True,
|
||||
model_type="nemo_transducer",
|
||||
files={
|
||||
@@ -222,7 +223,6 @@ _MODELS: dict[str, SherpaModelSpec] = {
|
||||
kind="offline-whisper",
|
||||
size_gb=0.104,
|
||||
languages="90+ languages (auto-detect)",
|
||||
recommended=True,
|
||||
files={
|
||||
"encoder": "tiny-encoder.int8.onnx",
|
||||
"decoder": "tiny-decoder.int8.onnx",
|
||||
@@ -231,7 +231,7 @@ _MODELS: dict[str, SherpaModelSpec] = {
|
||||
),
|
||||
}
|
||||
|
||||
DEFAULT_MODEL_ID = "sherpa-whisper-tiny"
|
||||
DEFAULT_MODEL_ID = "sherpa-parakeet-tdt-v3"
|
||||
|
||||
# repo_id → model id, so the model-store list (keyed by repo_id) can be
|
||||
# enriched with the dictation metadata, and so capture can map either key.
|
||||
@@ -261,16 +261,6 @@ def sherpa_available() -> tuple[bool, str]:
|
||||
return True, "ready"
|
||||
except ImportError as e:
|
||||
return False, f"sherpa-onnx not installed: {e}. Install with: uv add sherpa-onnx"
|
||||
except Exception as e: # noqa: BLE001 — an availability probe must fail closed
|
||||
# Native wheel failures surface as OSError/RuntimeError rather than
|
||||
# ImportError (missing DLL/dylib/so, loader or runtime init failure) —
|
||||
# but the set is open-ended: an extension module is free to raise
|
||||
# anything at init. This is an availability question, so ANY failure to
|
||||
# import means "not available", never an exception escaping to the
|
||||
# caller. SherpaDictationBackend.is_available() calls this directly and
|
||||
# capture_ws.ws_transcribe calls that without a guard, so an unexpected
|
||||
# type here took the WebSocket down instead of falling back (#1610).
|
||||
return False, f"sherpa-onnx unavailable ({type(e).__name__}): {e}"
|
||||
|
||||
|
||||
def _resolve_model_dir(spec: SherpaModelSpec, *, download: bool = True) -> str:
|
||||
@@ -407,10 +397,13 @@ def build_online_recognizer(spec: SherpaModelSpec, *, download: bool = True):
|
||||
# transcribe the same bytes. It is a defect inside sherpa-onnx that the app
|
||||
# cannot fix by configuration.
|
||||
#
|
||||
# Installation alone therefore cannot prove that a recognizer works. When a
|
||||
# session hears real speech and the model returns nothing, that model is
|
||||
# demoted on this machine and stops being selected. This self-corrects wherever
|
||||
# the decoder defect appears and is a no-op everywhere it does not.
|
||||
# The curated default therefore cannot be trusted to WORK just because it is
|
||||
# installed — and which platforms are affected is not knowable up front, so
|
||||
# hard-coding a different default per OS would only be a guess. Instead the app
|
||||
# learns from what it observes: when a session hears real speech and the model
|
||||
# returns nothing, that model is demoted on THIS machine and stops being
|
||||
# selected. Self-correcting wherever the breakage actually is, and a no-op
|
||||
# everywhere it isn't.
|
||||
|
||||
#: prefs key holding the list of model ids demoted on this machine.
|
||||
PREF_SILENT_MODELS = "dictation.silent_models"
|
||||
|
||||
@@ -60,7 +60,6 @@ from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
from core.config import DATA_DIR
|
||||
from core.contained_subprocess import OwnedPopen, spawn_owned
|
||||
|
||||
logger = logging.getLogger("omnivoice.sidecar_install")
|
||||
|
||||
@@ -109,11 +108,7 @@ class SidecarSpec:
|
||||
weights_repo_id: Optional[str] = None # HF repo downloaded into <checkout>/<weights_subdir>
|
||||
weights_revision: Optional[str] = None # reviewed HF commit
|
||||
weights_subdir: str = "checkpoints"
|
||||
# Model-config filenames accepted inside weights_subdir. A tuple, not a
|
||||
# single name: IndexTTS 2.5's weights repo ships config.yaml, but installs
|
||||
# predating #1611 were only usable after hand-renaming it to
|
||||
# config_v2_5.yaml, and those must keep working without a reinstall.
|
||||
weights_config_names: tuple[str, ...] = ("config.yaml",)
|
||||
weights_config_name: str = "config.yaml" # required model config inside weights_subdir
|
||||
docs_path: str = "docs/engines" # where the manual-install fallback lives
|
||||
required_bytes: int = 12 * _GIB # conservative source+venv+weights estimate for preflight
|
||||
# Called after a successful install/uninstall so the engine's memoised
|
||||
@@ -151,7 +146,7 @@ SPECS: dict[str, SidecarSpec] = {
|
||||
weights_repo_id="IndexTeam/IndexTTS-2.5",
|
||||
weights_revision="d0aa86e75bb6f3437f3831e95056fa72842d89ef",
|
||||
weights_subdir="checkpoints",
|
||||
weights_config_names=("config.yaml", "config_v2_5.yaml"),
|
||||
weights_config_name="config_v2_5.yaml",
|
||||
docs_path="docs/engines/indextts.md",
|
||||
# ~0.1 GB source + up to ~6 GB venv (torch + transformers<5) +
|
||||
# ~6 GB weights. Deliberately conservative; the preflight subtracts
|
||||
@@ -884,11 +879,11 @@ def _weights_present(spec: SidecarSpec) -> bool:
|
||||
actual = marker[:2] if len(marker) >= 2 else marker + [""]
|
||||
if actual != expected:
|
||||
return False
|
||||
return _weights_floor_ok(wdir, config_names=spec.weights_config_names)
|
||||
return _weights_floor_ok(wdir, config_name=spec.weights_config_name)
|
||||
|
||||
|
||||
def _weights_floor_ok(wdir: Path, *, config_names: tuple[str, ...] = ("config.yaml",)) -> bool:
|
||||
if not any((wdir / name).is_file() for name in config_names):
|
||||
def _weights_floor_ok(wdir: Path, *, config_name: str = "config.yaml") -> bool:
|
||||
if not (wdir / config_name).is_file():
|
||||
return False
|
||||
floor = 5 * 1024 * 1024
|
||||
try:
|
||||
@@ -974,7 +969,7 @@ def _step_fetch_weights(spec: SidecarSpec, job: dict) -> None:
|
||||
hf_progress.unregister_listener(listener_id)
|
||||
hf_progress.current_repo_id.reset(repo_token)
|
||||
|
||||
if not _weights_floor_ok(wdir, config_names=spec.weights_config_names):
|
||||
if not _weights_floor_ok(wdir, config_name=spec.weights_config_name):
|
||||
raise _StepError(
|
||||
"Weight download finished but no plausible weight files were found — "
|
||||
"the download was likely interrupted.",
|
||||
@@ -999,11 +994,6 @@ def _step_persist(spec: SidecarSpec, job: dict) -> None:
|
||||
# ── Subprocess runner with live log capture ────────────────────────────────
|
||||
|
||||
|
||||
def _install_containment_kwargs() -> dict:
|
||||
"""Nested process-group/Job ownership is supplied by ``spawn_owned``."""
|
||||
return {}
|
||||
|
||||
|
||||
def _run_logged(job: dict, argv: list[str], *, timeout: float,
|
||||
env: "dict[str, str] | None" = None) -> int:
|
||||
"""Run *argv*, streaming combined stdout+stderr lines into the job log.
|
||||
@@ -1018,11 +1008,13 @@ def _run_logged(job: dict, argv: list[str], *, timeout: float,
|
||||
killed child — a blocking ``for line in proc.stdout`` on this thread
|
||||
would hang past the timeout waiting for pipe EOF.
|
||||
"""
|
||||
# ``spawn_owned`` creates the local timeout group/Job before the operation
|
||||
# starts and links it to backend death through its control pipe.
|
||||
popen_kwargs = _install_containment_kwargs()
|
||||
popen_kwargs: dict = {}
|
||||
if os.name == "posix":
|
||||
# New session → we can kill the whole process group on timeout
|
||||
# instead of only the direct child.
|
||||
popen_kwargs["start_new_session"] = True
|
||||
try:
|
||||
proc = spawn_owned(
|
||||
proc = subprocess.Popen(
|
||||
argv,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
@@ -1057,18 +1049,29 @@ def _run_logged(job: dict, argv: list[str], *, timeout: float,
|
||||
|
||||
|
||||
def _kill_tree(proc: "subprocess.Popen") -> None:
|
||||
"""Kill an operation through its stable nested group/Job owner."""
|
||||
if isinstance(proc, OwnedPopen):
|
||||
# The retained supervisor/process-group or nested Job is the stable
|
||||
# per-operation owner. Do not fall back to a direct PID kill.
|
||||
proc.kill()
|
||||
"""Kill the child and its whole process tree, on every platform.
|
||||
|
||||
POSIX: the child was started in its own session, so SIGKILL the group.
|
||||
Windows: ``proc.kill()`` only terminates the direct child — a git/uv
|
||||
helper it spawned would keep running (and writing into the checkout)
|
||||
past our timeout — so use ``taskkill /T`` to fell the tree.
|
||||
"""
|
||||
if os.name == "posix":
|
||||
import signal
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
return
|
||||
# A test double or a legacy caller without the nested owner can only be
|
||||
# stopped through its stable direct-process handle.
|
||||
os.killpg(proc.pid, signal.SIGKILL)
|
||||
return
|
||||
except (ProcessLookupError, PermissionError, OSError):
|
||||
pass # group already gone / not ours — fall through to plain kill
|
||||
else: # Windows
|
||||
try:
|
||||
subprocess.run(
|
||||
["taskkill", "/F", "/T", "/PID", str(proc.pid)],
|
||||
capture_output=True, timeout=15,
|
||||
)
|
||||
return
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
pass # taskkill unavailable/failed — fall through to plain kill
|
||||
try:
|
||||
proc.kill()
|
||||
except OSError:
|
||||
|
||||
@@ -32,9 +32,9 @@ Threat-model summary (see Plan 02-01 frontmatter):
|
||||
AUTH-05 installed (``HFTokenRedactor``) on the root logger.
|
||||
T-02-04 — compromised sidecar emitting unexpected ops: parent allowlist
|
||||
``PARENT_INBOUND_OPS`` rejects everything else.
|
||||
T-02-05 — nested containment: a retained supervisor process group/Job owns
|
||||
each engine operation and is linked to backend death by a control
|
||||
pipe, while still permitting independent timeout teardown.
|
||||
T-02-05 — Tauri group-kill scope: ``start_new_session=True`` on Unix
|
||||
and ``CREATE_NEW_PROCESS_GROUP`` on Windows isolate the
|
||||
sidecar's process group.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -56,7 +56,6 @@ from typing import Optional
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
from core.contained_subprocess import spawn_owned
|
||||
from services.tts_backend import TTSBackend
|
||||
|
||||
logger = logging.getLogger("omnivoice.subprocess_backend")
|
||||
@@ -364,10 +363,6 @@ class SubprocessBackend(TTSBackend):
|
||||
# A duck-typed marker survives that.
|
||||
_is_subprocess_isolated: bool = True
|
||||
|
||||
# Generation happens in the sidecar: parent-side accelerator counters
|
||||
# can't see its allocations (see TTSBackend.runs_out_of_process).
|
||||
runs_out_of_process: bool = True
|
||||
|
||||
# Default sample rate; subclasses override.
|
||||
_DEFAULT_SAMPLE_RATE = 24000
|
||||
|
||||
@@ -471,6 +466,13 @@ class SubprocessBackend(TTSBackend):
|
||||
"env": env,
|
||||
"bufsize": 0, # unbuffered binary pipes
|
||||
}
|
||||
# Process-group isolation so the Tauri lib.rs group-kill in shutdown
|
||||
# doesn't escape into other children. See T-02-05.
|
||||
if sys.platform == "win32":
|
||||
kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
|
||||
else:
|
||||
kwargs["start_new_session"] = True
|
||||
|
||||
# `venv_python()` resolves the engine's interpreter, and on a cold
|
||||
# first run that is not cheap: it spawns each candidate to import the
|
||||
# engine (bounded, but tens of seconds on a slow disk), and if none is
|
||||
@@ -503,7 +505,7 @@ class SubprocessBackend(TTSBackend):
|
||||
self.id, Path(python_path).name, Path(script_path).name,
|
||||
)
|
||||
try:
|
||||
self._proc = spawn_owned([python_path, script_path], **kwargs)
|
||||
self._proc = subprocess.Popen([python_path, script_path], **kwargs)
|
||||
except OSError as exc:
|
||||
raise InvalidBinaryError(
|
||||
python_path,
|
||||
|
||||
+16
-273
@@ -300,23 +300,6 @@ class TTSBackend(ABC):
|
||||
#: 0 means "no meaningful floor" (CPU-class engines) and never warns.
|
||||
min_vram_gb: float = 0.0
|
||||
|
||||
#: True when generation allocates in ANOTHER process — a dedicated-venv
|
||||
#: sidecar (SubprocessBackend) or a spawned binary (omnivoice-gguf).
|
||||
#: Parent-process accelerator counters cannot see those allocations, so
|
||||
#: profilers/diagnostics must not attribute the parent's VRAM numbers to
|
||||
#: the engine. Duck-typed (attribute, not issubclass) for the same
|
||||
#: module-purge reason as `_is_subprocess_isolated`.
|
||||
runs_out_of_process: bool = False
|
||||
|
||||
def model_identity(self) -> Optional[str]:
|
||||
"""Which concrete model this backend would run, for adapter engines
|
||||
that host several very different models behind one backend id
|
||||
(mlx-audio, sherpa-onnx, cosyvoice). None means the engine id
|
||||
already names the model. Profilers and diagnostics use this to
|
||||
label results — without it, Kokoro-under-mlx and Dia-under-mlx
|
||||
rows are indistinguishable."""
|
||||
return None
|
||||
|
||||
@abstractmethod
|
||||
def generate(
|
||||
self,
|
||||
@@ -341,44 +324,6 @@ class TTSBackend(ABC):
|
||||
Engines that don't support this will ignore the parameter.
|
||||
"""
|
||||
|
||||
def generate_batch(
|
||||
self,
|
||||
texts: list[str],
|
||||
*,
|
||||
ref_audio=None,
|
||||
ref_text=None,
|
||||
instruct=None,
|
||||
language=None,
|
||||
duration=None,
|
||||
speed=1.0,
|
||||
**extras,
|
||||
) -> list[torch.Tensor]:
|
||||
"""Synthesize several utterances, preserving the single-item contract.
|
||||
|
||||
Engines with a native batch forward pass override this method. The
|
||||
default keeps every existing adapter correct while giving callers one
|
||||
stable seam and per-item keyword handling.
|
||||
"""
|
||||
if not texts:
|
||||
return []
|
||||
|
||||
def _item(value, index):
|
||||
return value[index] if isinstance(value, list) else value
|
||||
|
||||
return [
|
||||
self.generate(
|
||||
text,
|
||||
ref_audio=_item(ref_audio, index),
|
||||
ref_text=_item(ref_text, index),
|
||||
instruct=_item(instruct, index),
|
||||
language=_item(language, index),
|
||||
duration=_item(duration, index),
|
||||
speed=_item(speed, index),
|
||||
**extras,
|
||||
)
|
||||
for index, text in enumerate(texts)
|
||||
]
|
||||
|
||||
# ── Lifecycle (Phase 2 will enforce per-engine overrides) ──────────────
|
||||
#
|
||||
# Today every backend lazily loads its weights on first `generate()` and
|
||||
@@ -450,95 +395,6 @@ _PROMPT_CACHE_MAX = 8
|
||||
_prompt_cache: "OrderedDict[tuple, object]" = OrderedDict()
|
||||
_prompt_cache_lock = threading.Lock()
|
||||
|
||||
# Disk layer under the in-memory LRU (upstream k2-fsa VoiceClonePrompt.save/
|
||||
# load format). The in-memory cache dies with the process, so the first
|
||||
# generation of every session re-encodes each voice (~0.4 s + an ASR pass when
|
||||
# ref_text is missing). Encoded prompts are tiny (a (8, T) int token tensor +
|
||||
# transcript), so we persist them and reload across restarts. Keyed by the
|
||||
# same tuple as the memory cache — the ref file's mtime is inside the key, so
|
||||
# an edited reference never matches a stale file; stale files age out via the
|
||||
# mtime prune. Best-effort like the memory cache: any failure means "no disk
|
||||
# hit / no disk write", never a failed generation. OMNIVOICE_PROMPT_DISK_CACHE=0
|
||||
# disables the layer entirely.
|
||||
_PROMPT_DISK_CACHE_MAX = 32
|
||||
|
||||
|
||||
def _prompt_disk_dir():
|
||||
"""Return the prompt-cache directory (created on first use), or None when
|
||||
the layer is disabled or the directory can't be created."""
|
||||
if os.environ.get("OMNIVOICE_PROMPT_DISK_CACHE", "1") == "0":
|
||||
return None
|
||||
try:
|
||||
from core.config import DATA_DIR
|
||||
|
||||
path = os.path.join(str(DATA_DIR), "prompt_cache")
|
||||
os.makedirs(path, exist_ok=True)
|
||||
return path
|
||||
except Exception as e: # noqa: BLE001 — cache layer must never break synthesis
|
||||
logger.debug("prompt disk cache unavailable: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def _prompt_disk_path(cache_dir: str, key: tuple) -> str:
|
||||
import hashlib
|
||||
|
||||
digest = hashlib.sha256(repr(key).encode("utf-8")).hexdigest()[:32]
|
||||
return os.path.join(cache_dir, f"{digest}.pt")
|
||||
|
||||
|
||||
def _prompt_disk_load(key: tuple):
|
||||
"""Load a persisted prompt for ``key``, or None. Never raises."""
|
||||
cache_dir = _prompt_disk_dir()
|
||||
if cache_dir is None:
|
||||
return None
|
||||
path = _prompt_disk_path(cache_dir, key)
|
||||
if not os.path.exists(path):
|
||||
return None
|
||||
try:
|
||||
from omnivoice.models.omnivoice import VoiceClonePrompt
|
||||
|
||||
prompt = VoiceClonePrompt.load(path)
|
||||
# Freshen so the LRU prune (by mtime) keeps actively used voices.
|
||||
os.utime(path, None)
|
||||
return prompt
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("failed to load cached voice prompt %s: %s", path, e)
|
||||
try:
|
||||
os.remove(path) # corrupt/incompatible file — don't retry it forever
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _prompt_disk_save(key: tuple, prompt) -> None:
|
||||
"""Persist ``prompt`` under ``key`` and prune old entries. Never raises."""
|
||||
cache_dir = _prompt_disk_dir()
|
||||
if cache_dir is None:
|
||||
return
|
||||
path = _prompt_disk_path(cache_dir, key)
|
||||
try:
|
||||
# Unique per write: two GPU-pool threads missing the same key must not
|
||||
# interleave writes into one tmp file (os.replace stays atomic).
|
||||
import uuid
|
||||
|
||||
tmp = f"{path}.tmp.{os.getpid()}.{uuid.uuid4().hex[:8]}"
|
||||
prompt.save(tmp)
|
||||
os.replace(tmp, path)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("failed to persist voice prompt to %s: %s", path, e)
|
||||
return
|
||||
try:
|
||||
entries = [
|
||||
os.path.join(cache_dir, f)
|
||||
for f in os.listdir(cache_dir)
|
||||
if f.endswith(".pt")
|
||||
]
|
||||
entries.sort(key=lambda p: os.path.getmtime(p), reverse=True)
|
||||
for old in entries[_PROMPT_DISK_CACHE_MAX:]:
|
||||
os.remove(old)
|
||||
except OSError as e:
|
||||
logger.debug("prompt disk cache prune skipped: %s", e)
|
||||
|
||||
|
||||
def _clone_prompt_key(ref_audio: str, ref_text, preprocess_prompt: bool = True):
|
||||
try:
|
||||
@@ -577,24 +433,15 @@ def _get_clone_prompt(
|
||||
if hit is not None:
|
||||
_prompt_cache.move_to_end(key)
|
||||
return hit
|
||||
# Memory miss → disk (survives restarts). A disk hit skips the encode AND
|
||||
# the ASR transcription pass a ref_text-less reference would trigger.
|
||||
prompt = _prompt_disk_load(key)
|
||||
if prompt is None:
|
||||
try:
|
||||
# Encode outside the lock (slow). Mirrors exactly what generate()
|
||||
# would do inline for this ref (omnivoice.py:964-978), so output is
|
||||
# identical.
|
||||
prompt = model.create_voice_clone_prompt(
|
||||
ref_audio, ref_text=ref_text, preprocess_prompt=preprocess_prompt
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 — fall back, never break synthesis
|
||||
logger.warning(
|
||||
"voice-clone prompt precompute failed; using inline ref: %s", e
|
||||
)
|
||||
return None
|
||||
if store:
|
||||
_prompt_disk_save(key, prompt)
|
||||
try:
|
||||
# Encode outside the lock (slow). Mirrors exactly what generate() would
|
||||
# do inline for this ref (omnivoice.py:964-978), so output is identical.
|
||||
prompt = model.create_voice_clone_prompt(
|
||||
ref_audio, ref_text=ref_text, preprocess_prompt=preprocess_prompt
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 — fall back, never break synthesis
|
||||
logger.warning("voice-clone prompt precompute failed; using inline ref: %s", e)
|
||||
return None
|
||||
if not store:
|
||||
return prompt
|
||||
with _prompt_cache_lock:
|
||||
@@ -669,7 +516,7 @@ class OmniVoiceBackend(TTSBackend):
|
||||
|
||||
id = "omnivoice"
|
||||
display_name = "VoiceStudio (k2-fsa/OmniVoice, 600+ languages)"
|
||||
gpu_compat = ("cuda", "rocm", "mps", "cpu")
|
||||
gpu_compat = ("cuda", "mps", "cpu")
|
||||
# Derived from the pool's own per-job budget (_GPU_VRAM_PER_JOB_GB = 5.0 in
|
||||
# model_manager, itself measured from the ~1.6 GB forward + autoregressive
|
||||
# decode and the co-loaded WhisperX on the clone path), plus room for the
|
||||
@@ -755,73 +602,6 @@ class OmniVoiceBackend(TTSBackend):
|
||||
)
|
||||
return audios[0]
|
||||
|
||||
def generate_batch(self, texts: list[str], **kw) -> list[torch.Tensor]:
|
||||
"""Use OmniVoice's native variable-length batch generation.
|
||||
|
||||
Batch callers pass per-item language, duration, speed and reference
|
||||
lists. Reusable clone prompts are prepared once and handed to the
|
||||
model together; an incomplete prompt batch falls back to the proven
|
||||
single-item path instead of changing synthesis semantics.
|
||||
"""
|
||||
self._ensure_loaded()
|
||||
if not texts:
|
||||
return []
|
||||
|
||||
def _items(value):
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
return [value] * len(texts)
|
||||
|
||||
def _item_kwargs(index):
|
||||
return {
|
||||
key: value[index] if isinstance(value, list) else value
|
||||
for key, value in kw.items()
|
||||
}
|
||||
|
||||
ref_audios = _items(kw.get("ref_audio"))
|
||||
ref_texts = _items(kw.get("ref_text"))
|
||||
cache_ref = bool(kw.get("cache_ref", True))
|
||||
preprocess_prompt = bool(kw.get("preprocess_prompt", True))
|
||||
prompts = []
|
||||
if any(ref_audios):
|
||||
for ref_audio, ref_text in zip(ref_audios, ref_texts):
|
||||
if not ref_audio:
|
||||
prompts = []
|
||||
break
|
||||
prompt = _get_clone_prompt(
|
||||
self._model,
|
||||
ref_audio,
|
||||
ref_text,
|
||||
preprocess_prompt,
|
||||
store=cache_ref,
|
||||
)
|
||||
if prompt is None:
|
||||
prompts = []
|
||||
break
|
||||
prompts.append(prompt)
|
||||
|
||||
if any(ref_audios) and len(prompts) != len(texts):
|
||||
return [self.generate(text, **_item_kwargs(i))
|
||||
for i, text in enumerate(texts)]
|
||||
|
||||
gen_kw = dict(
|
||||
language=kw.get("language"),
|
||||
instruct=kw.get("instruct"),
|
||||
duration=kw.get("duration"),
|
||||
speed=kw.get("speed", 1.0),
|
||||
denoise=kw.get("denoise", True),
|
||||
postprocess_output=kw.get("postprocess_output", True),
|
||||
num_step=kw.get("num_step", 16),
|
||||
guidance_scale=kw.get("guidance_scale", 2.0),
|
||||
preprocess_prompt=preprocess_prompt,
|
||||
)
|
||||
if prompts:
|
||||
gen_kw["voice_clone_prompt"] = prompts
|
||||
else:
|
||||
gen_kw["ref_audio"] = None
|
||||
gen_kw["ref_text"] = None
|
||||
return self._model.generate(text=texts, **gen_kw)
|
||||
|
||||
def unload(self) -> None:
|
||||
"""Release the OmniVoice model (MM2-02). OmniVoice shares the singleton
|
||||
owned by ``model_manager``, so dropping our local ref isn't enough — we
|
||||
@@ -1295,8 +1075,7 @@ class KittenTTSBackend(TTSBackend):
|
||||
- English only
|
||||
- Much faster + much smaller install
|
||||
|
||||
Preset voice is chosen via `extras["voice"]` (defaults to DEFAULT_VOICE,
|
||||
"expr-voice-2-f"). Any
|
||||
Preset voice is chosen via `extras["voice"]` (defaults to "Jasper"). Any
|
||||
`ref_audio` / `instruct` / `language` arg is ignored with a log line so
|
||||
the common call-site doesn't need to know which engine it's talking to.
|
||||
"""
|
||||
@@ -1605,9 +1384,6 @@ class MLXAudioBackend(TTSBackend):
|
||||
def sample_rate(self) -> int:
|
||||
return self._sr
|
||||
|
||||
def model_identity(self) -> Optional[str]:
|
||||
return self._model_id
|
||||
|
||||
@property
|
||||
def supported_languages(self) -> list[str]:
|
||||
# Per-model; Kokoro supports 8, Qwen3 ~4, Kugel 24. Return "multi"
|
||||
@@ -1795,18 +1571,6 @@ class CosyVoiceBackend(TTSBackend):
|
||||
def supported_languages(self) -> list[str]:
|
||||
return ["zh", "en", "ja", "ko", "yue", "de", "es", "fr", "it", "ru"]
|
||||
|
||||
@staticmethod
|
||||
def _resolved_model_dir() -> str:
|
||||
return os.environ.get(
|
||||
"OMNIVOICE_COSYVOICE_MODEL",
|
||||
"pretrained_models/Fun-CosyVoice3-0.5B",
|
||||
)
|
||||
|
||||
def model_identity(self) -> Optional[str]:
|
||||
# v1/v2/v3 all live behind the one "cosyvoice" id — the directory
|
||||
# basename is the only thing that tells the models apart.
|
||||
return os.path.basename(os.path.normpath(self._resolved_model_dir()))
|
||||
|
||||
def _ensure_loaded(self):
|
||||
if self._model is not None:
|
||||
return
|
||||
@@ -1814,7 +1578,10 @@ class CosyVoiceBackend(TTSBackend):
|
||||
if not ok:
|
||||
raise RuntimeError(f"CosyVoice unavailable: {msg}")
|
||||
from cosyvoice.cli.cosyvoice import AutoModel # type: ignore[import-not-found]
|
||||
model_dir = self._resolved_model_dir()
|
||||
model_dir = os.environ.get(
|
||||
"OMNIVOICE_COSYVOICE_MODEL",
|
||||
"pretrained_models/Fun-CosyVoice3-0.5B",
|
||||
)
|
||||
logger.info("Loading CosyVoice from %s", model_dir)
|
||||
self._model = AutoModel(model_dir=model_dir)
|
||||
|
||||
@@ -2047,10 +1814,6 @@ class SherpaOnnxBackend(TTSBackend):
|
||||
self._tts = None
|
||||
self._model_dir = os.environ.get("OMNIVOICE_SHERPA_MODEL", "")
|
||||
|
||||
def model_identity(self) -> Optional[str]:
|
||||
model_dir = (self._model_dir or "").strip()
|
||||
return os.path.basename(os.path.normpath(model_dir)) if model_dir else None
|
||||
|
||||
@classmethod
|
||||
def is_available(cls) -> tuple[bool, str]:
|
||||
try:
|
||||
@@ -2398,7 +2161,6 @@ def list_backends() -> list[dict]:
|
||||
|
||||
out: list[dict] = []
|
||||
for bid, cls in _REGISTRY.items():
|
||||
cls = _effective_backend_class(bid, cls, caps.family)
|
||||
try:
|
||||
ok, msg = cls.is_available()
|
||||
except Exception:
|
||||
@@ -2472,29 +2234,10 @@ def list_backends() -> list[dict]:
|
||||
return out
|
||||
|
||||
|
||||
def _effective_backend_class(
|
||||
backend_id: str,
|
||||
backend_cls: type[TTSBackend],
|
||||
host_family: str | None = None,
|
||||
) -> type[TTSBackend]:
|
||||
"""Resolve host-specific containment without changing the configured id."""
|
||||
if backend_id != "omnivoice":
|
||||
return backend_cls
|
||||
if host_family is None:
|
||||
from core.device_caps import detect_host_caps
|
||||
|
||||
host_family = detect_host_caps().family
|
||||
if host_family != "mps":
|
||||
return backend_cls
|
||||
from engines.omnivoice_subprocess import OmniVoiceMPSSubprocessBackend
|
||||
|
||||
return OmniVoiceMPSSubprocessBackend
|
||||
|
||||
|
||||
def get_backend_class(backend_id: str) -> type[TTSBackend]:
|
||||
if backend_id not in _REGISTRY:
|
||||
raise ValueError(f"Unknown TTS backend: {backend_id!r}. Known: {list(_REGISTRY)}")
|
||||
return _effective_backend_class(backend_id, _REGISTRY[backend_id])
|
||||
return _REGISTRY[backend_id]
|
||||
|
||||
|
||||
def cloning_capable_engine_ids() -> list[str]:
|
||||
|
||||
+41
-266
@@ -20,16 +20,11 @@ Usage:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import torch
|
||||
from typing import Optional
|
||||
|
||||
from core.prefs import resolve
|
||||
|
||||
@@ -42,20 +37,6 @@ _detector = None
|
||||
_audioseal_available: Optional[bool] = None
|
||||
# Monotonic stamp of the last embed/detect, for the idle release below.
|
||||
_last_used = 0.0
|
||||
# Per-model locks for the lazy builds below: the startup prefetch thread
|
||||
# races the first embed, and both must share ONE build (a double load doubles
|
||||
# the cold-start cost the prefetch exists to hide). One lock PER MODEL — a
|
||||
# single shared lock made the ~42s generator prefetch block unrelated detector
|
||||
# loads and the idle reaper behind it. release_idle_models acquires both, in
|
||||
# this fixed order (nothing else nests them, so no cycle is possible).
|
||||
_generator_lock = threading.Lock()
|
||||
_detector_lock = threading.Lock()
|
||||
|
||||
# True when the generator exists ONLY because the startup prefetch built it
|
||||
# and no embed/detect has used it since. The idle reaper grants one extra
|
||||
# idle window before dropping such a model, so a first synthesis at minute
|
||||
# 20 still finds it warm (code-review finding 2 on the prefetch PR).
|
||||
_prefetched_unused = False
|
||||
|
||||
# 16-bit message: "OM" in ASCII = 0x4F 0x4D = 0100_1111 0100_1101
|
||||
# This is our signature — every VoiceStudio-generated audio carries it.
|
||||
@@ -69,86 +50,6 @@ OMNI_MESSAGE = [0, 1, 0, 0, 1, 1, 1, 1, 0, 1, 0, 0, 1, 1, 0, 1]
|
||||
_CHUNK_SECONDS = 30
|
||||
|
||||
|
||||
# AudioSeal vendors moshi's ``@torch_compile_lazy`` on SEANetEncoder.forward,
|
||||
# so the first EMBED — not the model load, which prefetch already warms —
|
||||
# calls torch.compile and drops into Inductor's C++ codegen. On hosts whose
|
||||
# C++ toolchain can't serve Inductor that compile raises CppCompileError, the
|
||||
# embed fail-opens, and audio ships unmarked: a macOS arm64 deployment lost
|
||||
# provenance marking on 10/10 takes while paying 30-40 s for the first failed
|
||||
# compile and 5-8 s for each later one (#1615).
|
||||
#
|
||||
# The compile is pure cost even where it succeeds. Measured on an M3 (5 s of
|
||||
# 24 kHz audio, three consecutive embeds): compiled 9.70 / 0.26 / 0.23 s vs
|
||||
# eager 0.30 / 0.28 / 0.27 s — a ~10 s first-embed tax to save ~0.03 s per
|
||||
# later embed, on CPU work that is already bounded by the 30 s chunk loop.
|
||||
# So watermarking runs eager on every platform.
|
||||
def _moshi_compile_module():
|
||||
"""AudioSeal's vendored moshi compile switch module, or None.
|
||||
|
||||
Resolved per call rather than at import: ``_check_available()`` is what
|
||||
guarantees audioseal is importable, and it runs later than this module.
|
||||
"""
|
||||
try:
|
||||
from audioseal.libs.moshi.utils import compile as moshi_compile
|
||||
except Exception: # noqa: BLE001 — any import shape change degrades, not crashes
|
||||
return None
|
||||
return moshi_compile
|
||||
|
||||
|
||||
_eager_lock = threading.Lock()
|
||||
#: Depth of nested/concurrent eager scopes, and the switch value to put back
|
||||
#: when the last one exits. One dict rather than two module scalars: the
|
||||
#: fields are only meaningful together, and only under _eager_lock.
|
||||
_eager_state: dict = {"depth": 0, "saved": None}
|
||||
_eager_guard_warned = False
|
||||
|
||||
|
||||
def _warn_missing_eager_guard() -> None:
|
||||
global _eager_guard_warned
|
||||
_eager_guard_warned = True
|
||||
logger.info(
|
||||
"audioseal's no_compile switch is unavailable — watermarking may run "
|
||||
"through torch.compile and pay (or fail) an Inductor C++ compile (#1615)."
|
||||
)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _eager_audioseal():
|
||||
"""Run the AudioSeal model eagerly, restoring the switch on the way out.
|
||||
|
||||
Upstream's own ``no_compile()`` saves and restores ``_compile_disabled``
|
||||
per call, which is not safe when two watermark calls overlap: the first to
|
||||
exit restores False while the second is still mid-embed, handing it back
|
||||
the compile this whole fix exists to avoid. So the flag is reference
|
||||
counted here — it goes True on the outermost entry and only comes back on
|
||||
the outermost exit — rather than serializing embeds behind a lock, which
|
||||
would cost real throughput on concurrent generations.
|
||||
|
||||
Degrades to a plain call if a future audioseal drops the helper
|
||||
(``tests/test_watermark_no_torch_compile_1615.py`` fails loudly on that
|
||||
upgrade rather than letting the compile creep back in).
|
||||
"""
|
||||
moshi = _moshi_compile_module()
|
||||
if moshi is None:
|
||||
if not _eager_guard_warned:
|
||||
_warn_missing_eager_guard()
|
||||
yield
|
||||
return
|
||||
with _eager_lock:
|
||||
if _eager_state["depth"] == 0:
|
||||
_eager_state["saved"] = moshi._compile_disabled
|
||||
_eager_state["depth"] += 1
|
||||
moshi._compile_disabled = True
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
with _eager_lock:
|
||||
_eager_state["depth"] -= 1
|
||||
if _eager_state["depth"] == 0:
|
||||
moshi._compile_disabled = _eager_state["saved"]
|
||||
_eager_state["saved"] = None
|
||||
|
||||
|
||||
def _iter_chunks(audio: torch.Tensor, sample_rate: int):
|
||||
"""Yield ≤ ~_CHUNK_SECONDS slices of (batch, channels, samples) audio
|
||||
along the time axis. A sub-second tail is folded into the previous chunk
|
||||
@@ -176,82 +77,28 @@ def _check_available() -> bool:
|
||||
return _audioseal_available
|
||||
|
||||
|
||||
def _get_generator(mark_prefetched: bool = False):
|
||||
"""Lazy-load the AudioSeal generator model.
|
||||
|
||||
Owns the idle-reaper grace in ONE critical section: the startup prefetch
|
||||
claims it (``mark_prefetched=True``) only when THIS call builds the model,
|
||||
and every other call (a real embed) consumes it — no call-site blocks, no
|
||||
window between two lock scopes where the claim could land on an
|
||||
already-used model.
|
||||
"""
|
||||
global _generator, _last_used, _prefetched_unused
|
||||
with _generator_lock:
|
||||
_last_used = time.monotonic()
|
||||
if _generator is None:
|
||||
from audioseal import AudioSeal
|
||||
_generator = AudioSeal.load_generator("audioseal_wm_16bits")
|
||||
_generator.eval()
|
||||
logger.info("AudioSeal generator loaded (16-bit message mode)")
|
||||
_prefetched_unused = mark_prefetched
|
||||
elif not mark_prefetched:
|
||||
_prefetched_unused = False
|
||||
return _generator
|
||||
def _get_generator():
|
||||
"""Lazy-load the AudioSeal generator model."""
|
||||
global _generator, _last_used
|
||||
_last_used = time.monotonic()
|
||||
if _generator is None:
|
||||
from audioseal import AudioSeal
|
||||
_generator = AudioSeal.load_generator("audioseal_wm_16bits")
|
||||
_generator.eval()
|
||||
logger.info("AudioSeal generator loaded (16-bit message mode)")
|
||||
return _generator
|
||||
|
||||
|
||||
def _get_detector():
|
||||
"""Lazy-load the AudioSeal detector model."""
|
||||
global _detector, _last_used
|
||||
with _detector_lock:
|
||||
_last_used = time.monotonic()
|
||||
if _detector is None:
|
||||
from audioseal import AudioSeal
|
||||
_detector = AudioSeal.load_detector("audioseal_detector_16bits")
|
||||
_detector.eval()
|
||||
logger.info("AudioSeal detector loaded (16-bit message mode)")
|
||||
return _detector
|
||||
|
||||
|
||||
def _generator_checkpoint_cached() -> bool:
|
||||
"""Return whether AudioSeal can warm without contacting Hugging Face.
|
||||
|
||||
AudioSeal 0.2 stores the checkpoint in ``<cache>/audioseal`` even though
|
||||
it uses huggingface_hub to fetch it. Keep startup local-first: an ordinary
|
||||
boot may consume that file, but must never turn prefetch into a download.
|
||||
"""
|
||||
cache_root = os.environ.get("AUDIOSEAL_CACHE_DIR") or os.environ.get(
|
||||
"XDG_CACHE_HOME"
|
||||
)
|
||||
root = Path(cache_root).expanduser() if cache_root else Path.home() / ".cache"
|
||||
return (root / "audioseal" / "generator_base.pth").is_file()
|
||||
|
||||
|
||||
def prefetch_generator(*, allow_download: bool = False) -> None:
|
||||
"""Warm the AudioSeal generator eagerly (startup background thread).
|
||||
|
||||
The first ``mark_synthetic`` otherwise pays the audioseal import plus the
|
||||
generator load inline — measured at ~42 s on a cold filesystem (2026-08-17
|
||||
macOS deployment), serialized inside the first synthesis and 3 s short of
|
||||
a 90 s client timeout. Warming here overlaps that span with the TTS model
|
||||
load. No-op when watermarking is off or audioseal is absent; a failure
|
||||
logs and leaves the lazy path to retry on first embed. Default startup is
|
||||
also cache-only; a download is allowed only when the user explicitly set
|
||||
``OMNIVOICE_PRELOAD_WATERMARK=1``.
|
||||
"""
|
||||
try:
|
||||
if not will_mark():
|
||||
logger.debug("Watermark prefetch skipped (disabled or audioseal absent)")
|
||||
return
|
||||
if not allow_download and not _generator_checkpoint_cached():
|
||||
logger.info("Watermark prefetch skipped: AudioSeal checkpoint is not cached")
|
||||
return
|
||||
_get_generator(mark_prefetched=True)
|
||||
logger.info("AudioSeal generator prefetched in the background")
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Watermark prefetch failed; the first embed will retry inline",
|
||||
exc_info=True,
|
||||
)
|
||||
_last_used = time.monotonic()
|
||||
if _detector is None:
|
||||
from audioseal import AudioSeal
|
||||
_detector = AudioSeal.load_detector("audioseal_detector_16bits")
|
||||
_detector.eval()
|
||||
logger.info("AudioSeal detector loaded (16-bit message mode)")
|
||||
return _detector
|
||||
|
||||
|
||||
def release_idle_models(idle_seconds: float, *, now: Optional[float] = None) -> bool:
|
||||
@@ -267,28 +114,14 @@ def release_idle_models(idle_seconds: float, *, now: Optional[float] = None) ->
|
||||
Returns True if anything was released. Never raises: this runs from the
|
||||
idle reaper, which must survive it.
|
||||
"""
|
||||
global _generator, _detector, _prefetched_unused
|
||||
with _generator_lock, _detector_lock:
|
||||
if _generator is None and _detector is None:
|
||||
return False
|
||||
stamp = time.monotonic() if now is None else float(now)
|
||||
if stamp - _last_used < idle_seconds:
|
||||
return False
|
||||
if _prefetched_unused:
|
||||
# The startup prefetch built the generator and nothing has used
|
||||
# it yet. Drop the grace (one extra idle window only) instead of
|
||||
# the model, so a first synthesis shortly after boot still finds
|
||||
# it warm — the exact scenario the prefetch exists for.
|
||||
_prefetched_unused = False
|
||||
logger.info(
|
||||
"Idle watermark models are prefetch-warmed but unused; "
|
||||
"granting one more idle window before releasing."
|
||||
)
|
||||
return False
|
||||
# Under the locks so a release racing the prefetch or a first embed
|
||||
# can't wipe a model the lazy path just built.
|
||||
_generator = None
|
||||
_detector = None
|
||||
global _generator, _detector
|
||||
if _generator is None and _detector is None:
|
||||
return False
|
||||
stamp = time.monotonic() if now is None else float(now)
|
||||
if stamp - _last_used < idle_seconds:
|
||||
return False
|
||||
_generator = None
|
||||
_detector = None
|
||||
logger.info("Idle timeout reached. Released the AudioSeal watermark models.")
|
||||
return True
|
||||
|
||||
@@ -367,62 +200,6 @@ def mark_synthetic(
|
||||
return marked
|
||||
|
||||
|
||||
async def mark_synthetic_async(
|
||||
waveform: torch.Tensor,
|
||||
sample_rate: int,
|
||||
*,
|
||||
context: str,
|
||||
force: bool = False,
|
||||
timeout: float | None = None,
|
||||
) -> torch.Tensor:
|
||||
"""Dispatch marking without letting a draining pool lose finished audio."""
|
||||
import asyncio
|
||||
import functools
|
||||
|
||||
from services.model_manager import (
|
||||
GpuJobTimeoutError,
|
||||
GpuPoolBusyError,
|
||||
get_watermark_pool,
|
||||
run_on_gpu_pool_guarded,
|
||||
)
|
||||
|
||||
try:
|
||||
pool = get_watermark_pool()
|
||||
except RuntimeError:
|
||||
logger.warning("Watermark skipped while the prior worker is shutting down")
|
||||
return waveform
|
||||
|
||||
job = functools.partial(
|
||||
mark_synthetic, waveform, sample_rate, context=context, force=force
|
||||
)
|
||||
try:
|
||||
if timeout is not None:
|
||||
return await run_on_gpu_pool_guarded(
|
||||
job, what="Audio watermark", timeout=timeout, executor=pool
|
||||
)
|
||||
return await asyncio.get_running_loop().run_in_executor(pool, job)
|
||||
except (GpuJobTimeoutError, GpuPoolBusyError):
|
||||
# Watermarking is provenance best-effort: a typed execution overrun or
|
||||
# queue saturation must not discard synthesis that already completed.
|
||||
logger.warning("Watermark skipped after its bounded dispatch expired")
|
||||
return waveform
|
||||
except asyncio.CancelledError:
|
||||
# A queued future is cancelled during pool teardown. Caller-driven
|
||||
# cancellation while the pool is live must retain normal semantics.
|
||||
if not pool.is_shutdown():
|
||||
raise
|
||||
logger.warning("Watermark skipped while the pool is shutting down")
|
||||
return waveform
|
||||
except RuntimeError:
|
||||
# Shutdown may begin after admission but before Executor.submit().
|
||||
# Preserve unrelated worker failures; only lifecycle rejection is
|
||||
# fail-open because finished synthesis must not be lost to teardown.
|
||||
if not pool.is_shutdown():
|
||||
raise
|
||||
logger.warning("Watermark skipped while the pool is shutting down")
|
||||
return waveform
|
||||
|
||||
|
||||
@torch.no_grad()
|
||||
def embed_watermark(
|
||||
waveform: torch.Tensor,
|
||||
@@ -466,14 +243,13 @@ def embed_watermark(
|
||||
|
||||
# AudioSeal operates at 16kHz internally; it handles resampling, but
|
||||
# we need to inform it of the source rate for correct embedding.
|
||||
with _eager_audioseal():
|
||||
watermarked = torch.cat(
|
||||
[
|
||||
generator(seg, sample_rate=sample_rate, message=msg)
|
||||
for seg in _iter_chunks(audio, sample_rate)
|
||||
],
|
||||
dim=-1,
|
||||
)
|
||||
watermarked = torch.cat(
|
||||
[
|
||||
generator(seg, sample_rate=sample_rate, message=msg)
|
||||
for seg in _iter_chunks(audio, sample_rate)
|
||||
],
|
||||
dim=-1,
|
||||
)
|
||||
|
||||
# Restore original shape
|
||||
if len(original_shape) == 2:
|
||||
@@ -484,7 +260,7 @@ def embed_watermark(
|
||||
return watermarked
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Watermark embedding failed (passing through original): %s", e, exc_info=True)
|
||||
logger.warning("Watermark embedding failed (passing through original): %s", e)
|
||||
return waveform
|
||||
|
||||
|
||||
@@ -531,13 +307,12 @@ def detect_watermark(
|
||||
# embedding does, and a splice where only part of the file is
|
||||
# VoiceStudio audio still registers (a whole-file average would dilute it).
|
||||
best_conf, decoded_msg = -1.0, None
|
||||
with _eager_audioseal():
|
||||
for seg in _iter_chunks(audio, sample_rate):
|
||||
result = detector.detect_watermark(seg, sample_rate=sample_rate, message_threshold=0.5)
|
||||
seg_conf = float(result[0]) if isinstance(result, tuple) else 0.0
|
||||
if seg_conf > best_conf:
|
||||
best_conf = seg_conf
|
||||
decoded_msg = result[1] if isinstance(result, tuple) and len(result) > 1 else None
|
||||
for seg in _iter_chunks(audio, sample_rate):
|
||||
result = detector.detect_watermark(seg, sample_rate=sample_rate, message_threshold=0.5)
|
||||
seg_conf = float(result[0]) if isinstance(result, tuple) else 0.0
|
||||
if seg_conf > best_conf:
|
||||
best_conf = seg_conf
|
||||
decoded_msg = result[1] if isinstance(result, tuple) and len(result) > 1 else None
|
||||
confidence = max(best_conf, 0.0)
|
||||
|
||||
# Decode message bits
|
||||
@@ -562,7 +337,7 @@ def detect_watermark(
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Watermark detection failed: %s", e, exc_info=True)
|
||||
logger.warning("Watermark detection failed: %s", e)
|
||||
return {
|
||||
"is_watermarked": False,
|
||||
"confidence": 0.0,
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
"""Dependency-free client for VoiceStudio's local speech platform."""
|
||||
@@ -1,278 +0,0 @@
|
||||
"""CLI/module bridge for terminals, editor extensions, and agent hooks.
|
||||
|
||||
The desktop app must be running for native dictation control. Batch
|
||||
transcription can also target a standalone or remote VoiceStudio backend.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ipaddress
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import sys
|
||||
from typing import Any
|
||||
from urllib import error, request
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
DEFAULT_CONTROL_URL = "http://127.0.0.1:3902"
|
||||
DEFAULT_ENGINE_URL = "http://127.0.0.1:3900"
|
||||
|
||||
|
||||
class SpeechClientError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class _RejectCredentialRedirect(request.HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: ARG002
|
||||
raise SpeechClientError("VoiceStudio refused a credentialed redirect")
|
||||
|
||||
|
||||
def _join_url(base_url: str, path: str) -> str:
|
||||
return f"{base_url.rstrip('/')}/{path.lstrip('/')}"
|
||||
|
||||
|
||||
def _decode_error(exc: error.HTTPError) -> str:
|
||||
try:
|
||||
body = exc.read().decode("utf-8", errors="replace")
|
||||
except Exception:
|
||||
body = ""
|
||||
try:
|
||||
detail = json.loads(body)
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
detail = body.strip()
|
||||
return f"HTTP {exc.code}: {detail or exc.reason}"
|
||||
|
||||
|
||||
def _is_loopback_host(host: str | None) -> bool:
|
||||
if not host:
|
||||
return False
|
||||
if host.lower() == "localhost":
|
||||
return True
|
||||
try:
|
||||
return ipaddress.ip_address(host).is_loopback
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _open(req: request.Request, timeout: float = 300.0) -> tuple[bytes, str]:
|
||||
target = urlsplit(req.full_url)
|
||||
scheme = target.scheme.lower()
|
||||
if scheme not in {"http", "https"}:
|
||||
raise SpeechClientError("VoiceStudio URLs must use http:// or https://")
|
||||
credentialed = bool(req.get_header("Authorization"))
|
||||
if credentialed and scheme != "https" and not _is_loopback_host(target.hostname):
|
||||
raise SpeechClientError("Remote VoiceStudio credentials require https://")
|
||||
try:
|
||||
opener = (
|
||||
request.build_opener(_RejectCredentialRedirect())
|
||||
if credentialed
|
||||
else request.build_opener()
|
||||
)
|
||||
with opener.open(req, timeout=timeout) as response: # noqa: S310
|
||||
return response.read(), response.headers.get("Content-Type", "")
|
||||
except error.HTTPError as exc:
|
||||
raise SpeechClientError(_decode_error(exc)) from exc
|
||||
except error.URLError as exc:
|
||||
raise SpeechClientError(f"VoiceStudio is unavailable: {exc.reason}") from exc
|
||||
|
||||
|
||||
def _json_request(method: str, url: str, payload: Any | None = None) -> Any:
|
||||
data = None if payload is None else json.dumps(payload).encode("utf-8")
|
||||
headers = {"Accept": "application/json"}
|
||||
if data is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
body, _ = _open(request.Request(url, data=data, headers=headers, method=method), timeout=10.0)
|
||||
try:
|
||||
return json.loads(body)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise SpeechClientError("VoiceStudio returned invalid JSON") from exc
|
||||
|
||||
|
||||
def _encode_multipart(
|
||||
*,
|
||||
filename: str,
|
||||
audio: bytes,
|
||||
fields: dict[str, str],
|
||||
boundary: str | None = None,
|
||||
) -> tuple[bytes, str]:
|
||||
boundary = boundary or f"voicestudio-{secrets.token_hex(16)}"
|
||||
marker = boundary.encode("ascii")
|
||||
parts: list[bytes] = []
|
||||
for name, value in fields.items():
|
||||
parts.extend(
|
||||
[
|
||||
b"--" + marker + b"\r\n",
|
||||
f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode(),
|
||||
value.encode("utf-8"),
|
||||
b"\r\n",
|
||||
]
|
||||
)
|
||||
safe_filename = Path(filename).name.replace('"', "") or "audio.wav"
|
||||
content_type = mimetypes.guess_type(safe_filename)[0] or "application/octet-stream"
|
||||
if Path(safe_filename).suffix.lower() in {".wav", ".wave"}:
|
||||
content_type = "audio/wav"
|
||||
parts.extend(
|
||||
[
|
||||
b"--" + marker + b"\r\n",
|
||||
(
|
||||
'Content-Disposition: form-data; name="file"; '
|
||||
f'filename="{safe_filename}"\r\n'
|
||||
).encode(),
|
||||
f"Content-Type: {content_type}\r\n\r\n".encode(),
|
||||
audio,
|
||||
b"\r\n--" + marker + b"--\r\n",
|
||||
]
|
||||
)
|
||||
return b"".join(parts), f"multipart/form-data; boundary={boundary}"
|
||||
|
||||
|
||||
def _control(args: argparse.Namespace, action: str) -> int:
|
||||
method = "GET" if action in {"status", "capabilities"} else "POST"
|
||||
path = {
|
||||
"status": "/v1/status",
|
||||
"capabilities": "/v1/capabilities",
|
||||
"start": "/v1/dictation/start",
|
||||
"stop": "/v1/dictation/stop",
|
||||
"toggle": "/v1/dictation/toggle",
|
||||
}[action]
|
||||
result = _json_request(method, _join_url(args.control_url, path))
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
def _read_audio(path: str, stdin_filename: str) -> tuple[bytes, str]:
|
||||
if path == "-":
|
||||
return sys.stdin.buffer.read(), stdin_filename
|
||||
audio_path = Path(path)
|
||||
try:
|
||||
return audio_path.read_bytes(), audio_path.name
|
||||
except OSError as exc:
|
||||
display_name = path.replace("\\", "/").rsplit("/", 1)[-1] or "audio input"
|
||||
reason = exc.strerror or type(exc).__name__
|
||||
raise SpeechClientError(f"could not read '{display_name}': {reason}") from exc
|
||||
|
||||
|
||||
def _response_text(body: bytes, content_type: str) -> str:
|
||||
decoded = body.decode("utf-8", errors="replace")
|
||||
if "json" not in content_type.lower():
|
||||
return decoded
|
||||
try:
|
||||
payload = json.loads(decoded)
|
||||
except json.JSONDecodeError:
|
||||
return decoded
|
||||
if isinstance(payload, dict) and isinstance(payload.get("text"), str):
|
||||
return payload["text"]
|
||||
return decoded
|
||||
|
||||
|
||||
def _transcribe(args: argparse.Namespace) -> int:
|
||||
audio, filename = _read_audio(args.audio, args.stdin_filename)
|
||||
fields = {
|
||||
"model": args.model,
|
||||
"response_format": args.response_format,
|
||||
}
|
||||
if args.language:
|
||||
fields["language"] = args.language
|
||||
body, content_type = _encode_multipart(filename=filename, audio=audio, fields=fields)
|
||||
headers = {"Content-Type": content_type, "Accept": "application/json, text/plain"}
|
||||
api_key = os.environ.get("OMNIVOICE_API_KEY", "").strip()
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
|
||||
output_session_id = None
|
||||
if args.insert:
|
||||
session = _json_request(
|
||||
"POST", _join_url(args.control_url, "/v1/output/sessions")
|
||||
)
|
||||
output_session_id = session["session_id"]
|
||||
|
||||
session_needs_cleanup = output_session_id is not None
|
||||
try:
|
||||
response_body, response_type = _open(
|
||||
request.Request(
|
||||
_join_url(args.engine_url, "/v1/audio/transcriptions"),
|
||||
data=body,
|
||||
headers=headers,
|
||||
method="POST",
|
||||
)
|
||||
)
|
||||
if output_session_id is not None:
|
||||
_json_request(
|
||||
"POST",
|
||||
_join_url(
|
||||
args.control_url,
|
||||
f"/v1/output/sessions/{output_session_id}/insert",
|
||||
),
|
||||
{"text": _response_text(response_body, response_type)},
|
||||
)
|
||||
session_needs_cleanup = False
|
||||
finally:
|
||||
if session_needs_cleanup:
|
||||
try:
|
||||
_json_request(
|
||||
"DELETE",
|
||||
_join_url(args.control_url, f"/v1/output/sessions/{output_session_id}"),
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
# Best-effort cleanup must not replace the original failure or
|
||||
# KeyboardInterrupt that brought control into this finally.
|
||||
pass
|
||||
|
||||
sys.stdout.buffer.write(response_body)
|
||||
if response_body and not response_body.endswith(b"\n"):
|
||||
sys.stdout.buffer.write(b"\n")
|
||||
return 0
|
||||
|
||||
|
||||
def _parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="voicestudio-speech",
|
||||
description="Control and consume VoiceStudio's local speech platform.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--control-url",
|
||||
default=os.environ.get("VOICESTUDIO_SPEECH_URL", DEFAULT_CONTROL_URL),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--engine-url",
|
||||
default=os.environ.get("VOICESTUDIO_URL", DEFAULT_ENGINE_URL),
|
||||
)
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
for command in ("status", "capabilities", "start", "stop", "toggle"):
|
||||
subparsers.add_parser(command)
|
||||
|
||||
transcribe = subparsers.add_parser("transcribe")
|
||||
transcribe.add_argument("audio", help="audio file, or - for stdin")
|
||||
transcribe.add_argument("--stdin-filename", default="audio.wav")
|
||||
transcribe.add_argument("--model", default="whisper-1")
|
||||
transcribe.add_argument("--language")
|
||||
transcribe.add_argument(
|
||||
"--format",
|
||||
dest="response_format",
|
||||
choices=("json", "text", "verbose_json", "srt", "vtt"),
|
||||
default="text",
|
||||
)
|
||||
transcribe.add_argument(
|
||||
"--insert",
|
||||
action="store_true",
|
||||
help="insert the result into the app focused when this command starts",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = _parser().parse_args(argv)
|
||||
try:
|
||||
if args.command == "transcribe":
|
||||
return _transcribe(args)
|
||||
return _control(args, args.command)
|
||||
except (SpeechClientError, KeyError) as exc:
|
||||
print(f"voicestudio-speech: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -49,38 +49,9 @@ if not os.environ.get("OMNIVOICE_ENV_FILE"):
|
||||
os.environ["OMNIVOICE_MODEL"] = "test"
|
||||
|
||||
|
||||
import functools
|
||||
import shutil
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def supports_symlinks() -> bool:
|
||||
"""True when this process may create symlinks. On Windows,
|
||||
``os.symlink`` raises OSError without Developer Mode or admin rights, so
|
||||
symlink-dependent assertions must be skipped there rather than fail."""
|
||||
probe_dir = tempfile.mkdtemp(prefix="omnivoice-symlink-probe-")
|
||||
try:
|
||||
target = os.path.join(probe_dir, "target")
|
||||
with open(target, "w", encoding="utf-8"):
|
||||
pass
|
||||
try:
|
||||
os.symlink(target, os.path.join(probe_dir, "link"))
|
||||
except (OSError, NotImplementedError):
|
||||
return False
|
||||
return True
|
||||
finally:
|
||||
shutil.rmtree(probe_dir, ignore_errors=True)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def symlinks_supported() -> bool:
|
||||
"""Bool fixture over :func:`supports_symlinks` for guarding the
|
||||
symlink-only assertions of a test while its other assertions still run."""
|
||||
return supports_symlinks()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def asr_model_installed(monkeypatch, request):
|
||||
"""Neutralize the no-ASR-installed preflight (asr_model_missing_error →
|
||||
|
||||
@@ -9,10 +9,7 @@ generation.py's proven ``_run_inference`` rather than re-implementing it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import wave
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -26,23 +23,6 @@ from core import archetypes # noqa: E402
|
||||
from api.routers import archetypes as arch_router # noqa: E402
|
||||
|
||||
|
||||
def _wav_bytes() -> bytes:
|
||||
buf = io.BytesIO()
|
||||
with wave.open(buf, "wb") as wav:
|
||||
wav.setnchannels(1)
|
||||
wav.setsampwidth(2)
|
||||
wav.setframerate(24_000)
|
||||
wav.writeframes(b"\x00\x01" * 64)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _write_wav(path: Path) -> bytes:
|
||||
data = _wav_bytes()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(data)
|
||||
return data
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def client():
|
||||
app = FastAPI()
|
||||
@@ -153,7 +133,8 @@ def test_preview_serves_cached_wav_without_model(client):
|
||||
key = arch_router._preview_key(sample)
|
||||
cache_dir = Path(arch_router._PREVIEW_DIR)
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
dummy = _write_wav(cache_dir / f"{key}.wav")
|
||||
dummy = b"RIFF\x24\x00\x00\x00WAVEfmt cached-archetype-preview"
|
||||
(cache_dir / f"{key}.wav").write_bytes(dummy)
|
||||
|
||||
r = client.get(f"/archetypes/{sample['id']}/preview")
|
||||
assert r.status_code == 200
|
||||
@@ -162,7 +143,7 @@ def test_preview_serves_cached_wav_without_model(client):
|
||||
|
||||
|
||||
# ── Materialize-on-use idempotency (dedup, no re-render) ───────────────────────
|
||||
def test_use_is_idempotent_dedup(client, tmp_path, monkeypatch, symlinks_supported):
|
||||
def test_use_is_idempotent_dedup(client, monkeypatch):
|
||||
"""The 2nd `/use` of the same archetype reuses its one materialized profile
|
||||
and does NOT render again — the guarantee that materialize-on-select in any
|
||||
voice picker can't spawn duplicate rows on repeated picks.
|
||||
@@ -170,7 +151,6 @@ def test_use_is_idempotent_dedup(client, tmp_path, monkeypatch, symlinks_support
|
||||
The render boundary (``_render_archetype_wav``) is mocked so no model/GPU is
|
||||
needed: it just drops a stub WAV where the row expects one.
|
||||
"""
|
||||
from core import event_bus
|
||||
from core.db import init_db
|
||||
|
||||
init_db() # ensure the voice_profiles table exists in the hermetic tmp DB
|
||||
@@ -179,13 +159,10 @@ def test_use_is_idempotent_dedup(client, tmp_path, monkeypatch, symlinks_support
|
||||
|
||||
async def _fake_render(a, out_path):
|
||||
render_calls["n"] += 1
|
||||
_write_wav(Path(out_path))
|
||||
Path(out_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
Path(out_path).write_bytes(b"RIFF\x24\x00\x00\x00WAVEfmt stub")
|
||||
|
||||
monkeypatch.setattr(arch_router, "_render_archetype_wav", _fake_render)
|
||||
emitted = []
|
||||
monkeypatch.setattr(
|
||||
event_bus, "emit", lambda topic, payload: emitted.append((topic, payload)),
|
||||
)
|
||||
|
||||
sample = archetypes.list_archetypes(featured=True)[0]
|
||||
|
||||
@@ -204,248 +181,6 @@ def test_use_is_idempotent_dedup(client, tmp_path, monkeypatch, symlinks_support
|
||||
from core.db import db_conn
|
||||
with db_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE personality = ?",
|
||||
(arch_router._archetype_personality(sample),),
|
||||
"SELECT id FROM voice_profiles WHERE personality = ?", (sample["id"],)
|
||||
).fetchall()
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["kind"] == "design"
|
||||
assert json.loads(rows[0]["vd_states"]) == sample["attrs"]
|
||||
|
||||
with db_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM voice_profiles WHERE id=?", (pid,)).fetchone()
|
||||
assert row["kind"] == "design"
|
||||
assert row["instruct"] == sample["instruct"]
|
||||
assert json.loads(row["vd_states"]) == sample["attrs"]
|
||||
|
||||
# A missing sample or synthesis-input drift must be repaired before the
|
||||
# existing profile is returned; Preview and Use must describe one voice.
|
||||
audio_path = arch_router._profile_audio_path(row["ref_audio_path"])
|
||||
assert audio_path is not None
|
||||
audio_path.unlink()
|
||||
repaired = client.post(f"/archetypes/{sample['id']}/use")
|
||||
assert repaired.status_code == 200 and repaired.json()["profile_id"] == pid
|
||||
assert render_calls["n"] == 2
|
||||
assert audio_path.read_bytes().startswith(b"RIFF")
|
||||
|
||||
with db_conn() as conn:
|
||||
conn.execute("UPDATE voice_profiles SET instruct='male' WHERE id=?", (pid,))
|
||||
refreshed = client.post(f"/archetypes/{sample['id']}/use")
|
||||
assert refreshed.status_code == 200
|
||||
assert refreshed.json()["profile_id"] != pid
|
||||
assert render_calls["n"] == 3
|
||||
with db_conn() as conn:
|
||||
edited = conn.execute("SELECT instruct FROM voice_profiles WHERE id=?", (pid,)).fetchone()
|
||||
assert edited["instruct"] == "male"
|
||||
|
||||
# Continue corruption checks against the new canonical materialization.
|
||||
pid = refreshed.json()["profile_id"]
|
||||
with db_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM voice_profiles WHERE id=?", (pid,)).fetchone()
|
||||
audio_path = arch_router._profile_audio_path(row["ref_audio_path"])
|
||||
assert audio_path is not None
|
||||
|
||||
audio_path.write_bytes(b"not a WAV")
|
||||
repaired_corrupt = client.post(f"/archetypes/{sample['id']}/use")
|
||||
assert repaired_corrupt.status_code == 200
|
||||
assert render_calls["n"] == 4
|
||||
|
||||
if symlinks_supported: # Windows needs Developer Mode to create symlinks
|
||||
outside = tmp_path / "outside.wav"
|
||||
outside_bytes = _write_wav(outside)
|
||||
audio_path.unlink()
|
||||
audio_path.symlink_to(outside)
|
||||
repaired_symlink = client.post(f"/archetypes/{sample['id']}/use")
|
||||
assert repaired_symlink.status_code == 200
|
||||
assert render_calls["n"] == 5
|
||||
assert not audio_path.is_symlink()
|
||||
assert outside.read_bytes() == outside_bytes
|
||||
|
||||
# A valid header with a missing payload is not playable and must self-heal.
|
||||
renders_before = render_calls["n"]
|
||||
truncated = _wav_bytes()[:44]
|
||||
audio_path.write_bytes(truncated)
|
||||
repaired_truncated = client.post(f"/archetypes/{sample['id']}/use")
|
||||
assert repaired_truncated.status_code == 200
|
||||
assert render_calls["n"] == renders_before + 1
|
||||
assert audio_path.read_bytes() != truncated
|
||||
|
||||
|
||||
def test_archetype_staged_repair_preserves_concurrently_edited_profile(
|
||||
client, monkeypatch,
|
||||
):
|
||||
"""A repair may publish only if the row still belongs to the archetype."""
|
||||
from core.config import VOICES_DIR
|
||||
from core.db import db_conn, init_db
|
||||
|
||||
init_db()
|
||||
sample = archetypes.list_archetypes(featured=True)[3]
|
||||
personality = arch_router._archetype_personality(sample)
|
||||
edited_personality = f"user-edited:{sample['id']}"
|
||||
with db_conn() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM voice_profiles WHERE personality IN (?, ?, ?)",
|
||||
(sample["id"], personality, edited_personality),
|
||||
)
|
||||
|
||||
original_id = {"value": None}
|
||||
mutation_seen = {"value": False}
|
||||
|
||||
async def racing_render(_item, path):
|
||||
destination = Path(path)
|
||||
if destination.name.endswith(".staged.wav"):
|
||||
assert original_id["value"] is not None
|
||||
with db_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE voice_profiles SET name='User edit', personality=? WHERE id=?",
|
||||
(edited_personality, original_id["value"]),
|
||||
)
|
||||
mutation_seen["value"] = True
|
||||
_write_wav(destination)
|
||||
|
||||
monkeypatch.setattr(arch_router, "_render_archetype_wav", racing_render)
|
||||
first = client.post(f"/archetypes/{sample['id']}/use")
|
||||
assert first.status_code == 200
|
||||
original_id["value"] = first.json()["profile_id"]
|
||||
|
||||
with db_conn() as conn:
|
||||
original = conn.execute(
|
||||
"SELECT ref_audio_path FROM voice_profiles WHERE id=?",
|
||||
(original_id["value"],),
|
||||
).fetchone()
|
||||
original_audio = arch_router._profile_audio_path(original["ref_audio_path"])
|
||||
assert original_audio is not None
|
||||
corrupt_bytes = b"corrupt user-owned sample"
|
||||
original_audio.write_bytes(corrupt_bytes)
|
||||
|
||||
repaired = client.post(f"/archetypes/{sample['id']}/use")
|
||||
assert repaired.status_code == 200
|
||||
repaired_id = repaired.json()["profile_id"]
|
||||
assert mutation_seen["value"]
|
||||
assert repaired_id != original_id["value"]
|
||||
|
||||
with db_conn() as conn:
|
||||
edited = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id=?", (original_id["value"],),
|
||||
).fetchone()
|
||||
canonical = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id=?", (repaired_id,),
|
||||
).fetchone()
|
||||
canonical_count = conn.execute(
|
||||
"SELECT count(*) FROM voice_profiles WHERE personality=?", (personality,),
|
||||
).fetchone()[0]
|
||||
assert edited["name"] == "User edit"
|
||||
assert edited["personality"] == edited_personality
|
||||
assert edited["instruct"] == sample["instruct"]
|
||||
assert original_audio.read_bytes() == corrupt_bytes
|
||||
assert canonical["personality"] == personality
|
||||
assert canonical["ref_audio_path"] == arch_router._profile_audio_filename(repaired_id)
|
||||
assert canonical_count == 1
|
||||
assert (Path(VOICES_DIR) / canonical["ref_audio_path"]).read_bytes() == _wav_bytes()
|
||||
assert not list(Path(VOICES_DIR).glob(f".{original_id['value']}-*.staged.wav"))
|
||||
|
||||
|
||||
def test_archetype_use_adopts_only_a_compatible_legacy_row(client, monkeypatch):
|
||||
from core.config import VOICES_DIR
|
||||
from core.db import db_conn, init_db
|
||||
|
||||
init_db()
|
||||
sample = archetypes.list_archetypes(featured=True)[1]
|
||||
legacy_id = "legacyarch"
|
||||
legacy_audio = Path(VOICES_DIR) / f"{legacy_id}.wav"
|
||||
_write_wav(legacy_audio)
|
||||
with db_conn() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM voice_profiles WHERE personality IN (?, ?)",
|
||||
(sample["id"], arch_router._archetype_personality(sample)),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO voice_profiles "
|
||||
"(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, "
|
||||
"kind, vd_states, created_at) VALUES (?, 'Legacy archetype', ?, ?, ?, ?, 42, ?, "
|
||||
"'clone', NULL, 1)",
|
||||
(
|
||||
legacy_id, legacy_audio.name, sample["sample_script"], sample["instruct"],
|
||||
sample["language"], sample["id"],
|
||||
),
|
||||
)
|
||||
|
||||
async def unexpected_render(*_args):
|
||||
raise AssertionError("a valid legacy archetype sample must be reused")
|
||||
|
||||
monkeypatch.setattr(arch_router, "_render_archetype_wav", unexpected_render)
|
||||
response = client.post(f"/archetypes/{sample['id']}/use")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["profile_id"] == legacy_id
|
||||
with db_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM voice_profiles WHERE id=?", (legacy_id,)).fetchone()
|
||||
assert row["personality"] == arch_router._archetype_personality(sample)
|
||||
assert row["kind"] == "design"
|
||||
assert json.loads(row["vd_states"]) == sample["attrs"]
|
||||
|
||||
|
||||
def test_archetype_use_does_not_rewrite_an_imported_personality_collision(
|
||||
client, monkeypatch,
|
||||
):
|
||||
from core.config import VOICES_DIR
|
||||
from core.db import db_conn, init_db
|
||||
|
||||
init_db()
|
||||
sample = archetypes.list_archetypes(featured=True)[2]
|
||||
imported_id = "importedarch"
|
||||
imported_ns_id = "importedarchns"
|
||||
imported_audio = Path(VOICES_DIR) / f"{imported_id}.wav"
|
||||
imported_ns_audio = Path(VOICES_DIR) / f"{imported_ns_id}.wav"
|
||||
original_audio = _write_wav(imported_audio)
|
||||
original_ns_audio = _write_wav(imported_ns_audio)
|
||||
with db_conn() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM voice_profiles WHERE personality IN (?, ?)",
|
||||
(sample["id"], arch_router._archetype_personality(sample)),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO voice_profiles "
|
||||
"(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, "
|
||||
"kind, is_locked, verified_own_voice, created_at) VALUES "
|
||||
"(?, 'Imported collision', ?, 'user transcript', 'male', 'Auto', NULL, ?, "
|
||||
"'clone', 1, 1, 1)",
|
||||
(imported_id, imported_audio.name, sample["id"]),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO voice_profiles "
|
||||
"(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, "
|
||||
"kind, vd_states, is_locked, verified_own_voice, created_at) VALUES "
|
||||
"(?, 'Imported namespaced collision', ?, ?, ?, ?, 42, ?, "
|
||||
"'design', NULL, 0, 0, 2)",
|
||||
(
|
||||
imported_ns_id, imported_ns_audio.name, sample["sample_script"],
|
||||
sample["instruct"], sample["language"],
|
||||
arch_router._archetype_personality(sample),
|
||||
),
|
||||
)
|
||||
|
||||
async def render(_item, path):
|
||||
_write_wav(Path(path))
|
||||
|
||||
monkeypatch.setattr(arch_router, "_render_archetype_wav", render)
|
||||
response = client.post(f"/archetypes/{sample['id']}/use")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["profile_id"] != imported_id
|
||||
with db_conn() as conn:
|
||||
imported = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id=?", (imported_id,),
|
||||
).fetchone()
|
||||
imported_ns = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id=?", (imported_ns_id,),
|
||||
).fetchone()
|
||||
created = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id=?", (response.json()["profile_id"],),
|
||||
).fetchone()
|
||||
assert imported["personality"] == sample["id"]
|
||||
assert imported["instruct"] == "male"
|
||||
assert imported["ref_text"] == "user transcript"
|
||||
assert imported_audio.read_bytes() == original_audio
|
||||
assert imported_ns["instruct"] == sample["instruct"]
|
||||
assert imported_ns["ref_text"] == sample["sample_script"]
|
||||
assert imported_ns["vd_states"] is None
|
||||
assert imported_ns_audio.read_bytes() == original_ns_audio
|
||||
assert created["personality"] == arch_router._archetype_personality(sample)
|
||||
|
||||
@@ -125,16 +125,6 @@ def test_faster_whisper_float16_unsupported_falls_back_to_int8(monkeypatch):
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "torch", fake_torch)
|
||||
|
||||
# The compute-device override gate consults the capability probe before
|
||||
# the torch mock above — pin it to a CUDA family so the fallback chain
|
||||
# under test is reachable on a cpu-only CI host.
|
||||
from core.device_caps import HostCaps
|
||||
|
||||
monkeypatch.setattr(
|
||||
"core.device_caps.detect_host_caps",
|
||||
lambda: HostCaps(family="cuda", available_families=("cuda", "cpu")),
|
||||
)
|
||||
|
||||
be = FasterWhisperBackend()
|
||||
be._ensure_model()
|
||||
|
||||
|
||||
@@ -80,10 +80,12 @@ def test_timeout_error_is_a_timeouterror_subclass():
|
||||
assert issubclass(ASRTimeoutError, TimeoutError)
|
||||
|
||||
|
||||
def test_timeout_does_not_overlap_an_in_process_native_worker():
|
||||
# #1669: reset() cannot kill the old native thread. A fresh pool let the
|
||||
# retry enter the same whisperx/CTranslate2 model concurrently and the
|
||||
# process died with 0xC0000005. Keep the old worker accounted for instead.
|
||||
def test_timeout_resets_a_resilient_pool_to_restore_capacity():
|
||||
# #730: a wedged transcribe holds its GPU-pool worker forever; with a 1-2
|
||||
# worker pool that starves TTS generate and surfaces as "can't reach
|
||||
# backend". On timeout, run_transcribe_guarded must reset() a pool that
|
||||
# supports it (the real _ResilientGpuPool) so the next submit gets a fresh
|
||||
# worker — capacity restored without an app restart.
|
||||
class _FakePool(ThreadPoolExecutor):
|
||||
def __init__(self):
|
||||
super().__init__(max_workers=1)
|
||||
@@ -103,7 +105,7 @@ def test_timeout_does_not_overlap_an_in_process_native_worker():
|
||||
await run_transcribe_guarded(pool, _hang, what="Dub", timeout=0.2)
|
||||
|
||||
asyncio.run(_go())
|
||||
assert pool.reset_calls == 0
|
||||
assert pool.reset_calls == 1
|
||||
pool.shutdown(wait=False)
|
||||
|
||||
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
"""Regression tests for the lightweight persisted-WAV trust boundary."""
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
|
||||
from core.audio_validation import is_playable_wav, resolve_regular_file
|
||||
|
||||
|
||||
def test_oversized_declared_wav_payload_is_not_treated_as_playable(tmp_path):
|
||||
"""A hostile frame count must be bounded and backed by real payload bytes."""
|
||||
path = tmp_path / "oversized.wav"
|
||||
declared_size = 0xFFFF_FFF0
|
||||
header = struct.pack(
|
||||
"<4sI4s4sIHHIIHH4sI",
|
||||
b"RIFF",
|
||||
0xFFFF_FFFF,
|
||||
b"WAVE",
|
||||
b"fmt ",
|
||||
16,
|
||||
1,
|
||||
1,
|
||||
24_000,
|
||||
48_000,
|
||||
2,
|
||||
16,
|
||||
b"data",
|
||||
declared_size,
|
||||
)
|
||||
path.write_bytes(header + b"\x00\x01")
|
||||
|
||||
assert not is_playable_wav(path)
|
||||
|
||||
|
||||
def test_profile_wav_resolution_rejects_escape_and_symlink(tmp_path, symlinks_supported):
|
||||
root = tmp_path / "voices"
|
||||
root.mkdir()
|
||||
outside = tmp_path / "outside.wav"
|
||||
outside.write_bytes(b"outside")
|
||||
|
||||
assert resolve_regular_file(root, "../outside.wav") is None
|
||||
assert resolve_regular_file(root, str(outside)) is None
|
||||
if symlinks_supported: # Windows needs Developer Mode to create symlinks
|
||||
(root / "linked.wav").symlink_to(outside)
|
||||
assert resolve_regular_file(root, "linked.wav") is None
|
||||
@@ -1,43 +1,25 @@
|
||||
"""Tests for the community gallery (marketplace) loader.
|
||||
|
||||
Covers strict item validation, manifest/cache boundaries, same-origin preview,
|
||||
and idempotent profile materialization without a model or network dependency.
|
||||
Covers the no-network surface: strict item validation (invalid presets and
|
||||
unsafe audio URLs are dropped so they can never crash synthesis or fetch from
|
||||
an arbitrary host), manifest merge/dedup, offline cache reads, filtering, and
|
||||
the prefilled submit URL. The render/download paths need the model/network and
|
||||
are exercised at runtime.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import wave
|
||||
|
||||
import pytest
|
||||
|
||||
# conftest.py puts `backend/` on sys.path and points OMNIVOICE_DATA_DIR at a
|
||||
# throwaway tmpdir before this module imports the REAL core.config (the old
|
||||
# sys.modules stub leaked at collection time and broke mixed runs).
|
||||
from fastapi import FastAPI, HTTPException, Response # noqa: E402
|
||||
from fastapi import FastAPI # noqa: E402
|
||||
from fastapi.testclient import TestClient # noqa: E402
|
||||
|
||||
from api.routers import community # noqa: E402
|
||||
|
||||
|
||||
def _wav_bytes() -> bytes:
|
||||
buf = io.BytesIO()
|
||||
with wave.open(buf, "wb") as wav:
|
||||
wav.setnchannels(1)
|
||||
wav.setsampwidth(2)
|
||||
wav.setframerate(24_000)
|
||||
wav.writeframes(b"\x00\x01" * 64)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _write_wav(path: Path) -> bytes:
|
||||
data = _wav_bytes()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(data)
|
||||
return data
|
||||
|
||||
_FIXTURE = {
|
||||
"schema_version": 1,
|
||||
"items": [
|
||||
@@ -93,51 +75,12 @@ def test_unknown_use_case_dropped():
|
||||
assert community.validate_item(_FIXTURE["items"][4]) is None
|
||||
|
||||
|
||||
def test_malformed_manifest_entries_do_not_break_other_sources():
|
||||
valid = _FIXTURE["items"][0]
|
||||
items, packs = community._merge([
|
||||
("bad/repo", {"items": 42, "packs": "not-a-list"}),
|
||||
("good/repo", {"items": [None, "not-an-item", valid], "packs": [None]}),
|
||||
])
|
||||
|
||||
assert [item["id"] for item in items] == [valid["id"]]
|
||||
assert packs == []
|
||||
|
||||
|
||||
def test_is_valid_instruct():
|
||||
assert community.is_valid_instruct("male, elderly, very low pitch")
|
||||
assert not community.is_valid_instruct("male, sultry")
|
||||
assert not community.is_valid_instruct("male, female")
|
||||
assert not community.is_valid_instruct("british accent, 四川话")
|
||||
assert not community.is_valid_instruct("")
|
||||
|
||||
|
||||
def test_preset_attrs_are_normalized_and_complete():
|
||||
item = community.validate_item(_FIXTURE["items"][0])
|
||||
assert item["instruct"] == "female, middle-aged, low pitch"
|
||||
assert item["attrs"] == {
|
||||
"Gender": "female", "Age": "middle-aged", "Pitch": "low pitch",
|
||||
"Style": "Auto", "EnglishAccent": "Auto", "ChineseDialect": "Auto",
|
||||
}
|
||||
assert item["preview_url"] == "/community/items/p1/preview"
|
||||
|
||||
|
||||
def test_remote_transcript_fields_are_bounded():
|
||||
preset = community.validate_item({
|
||||
**_FIXTURE["items"][0],
|
||||
"sample_script": " x " * (community._MAX_SAMPLE_SCRIPT_CHARS + 10),
|
||||
})
|
||||
voice = community.validate_item({
|
||||
**_FIXTURE["items"][3],
|
||||
"audio": {
|
||||
**_FIXTURE["items"][3]["audio"],
|
||||
"ref_text": " y " * (community._MAX_REF_TEXT_CHARS + 10),
|
||||
},
|
||||
})
|
||||
assert len(preset["sample_script"]) == community._MAX_SAMPLE_SCRIPT_CHARS
|
||||
assert len(voice["audio"]["ref_text"]) == community._MAX_REF_TEXT_CHARS
|
||||
|
||||
|
||||
# ── merge keeps only valid items ──────────────────────────────────────────────
|
||||
def test_merge_drops_invalid_and_dedups():
|
||||
items, packs = community._merge([("debpalash/omnivoice-gallery", _FIXTURE)])
|
||||
@@ -173,620 +116,3 @@ def test_submit_url(client):
|
||||
voice = client.get("/community/submit-url", params={"type": "voice"}).json()["url"]
|
||||
assert "preset-submission.yml" in preset and "omnivoice-gallery" in preset
|
||||
assert "voice-submission.yml" in voice
|
||||
|
||||
|
||||
# ── bounded cache freshness + stale offline fallback ─────────────────────────
|
||||
def test_stale_manifest_refreshes_then_stays_fresh(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(community, "_CACHE_DIR", tmp_path)
|
||||
source = "debpalash/omnivoice-gallery"
|
||||
cache = community._cache_path(source)
|
||||
cache.parent.mkdir(parents=True)
|
||||
cache.write_text(json.dumps(_FIXTURE), encoding="utf-8")
|
||||
os.utime(cache, (100.0, 100.0))
|
||||
|
||||
fresh = {**_FIXTURE, "updated_at": "new"}
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
community, "_fetch_remote_manifest",
|
||||
lambda src: calls.append(src) or fresh,
|
||||
)
|
||||
now = 100.0 + community._MANIFEST_MAX_AGE_S + 1
|
||||
assert community._fetch_manifest(source, False, now=now)["updated_at"] == "new"
|
||||
assert community._fetch_manifest(source, False, now=now + 1)["updated_at"] == "new"
|
||||
assert calls == [source]
|
||||
|
||||
|
||||
def test_stale_manifest_falls_back_and_throttles_offline_retry(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(community, "_CACHE_DIR", tmp_path)
|
||||
source = "debpalash/omnivoice-gallery"
|
||||
cache = community._cache_path(source)
|
||||
cache.parent.mkdir(parents=True)
|
||||
cache.write_text(json.dumps(_FIXTURE), encoding="utf-8")
|
||||
os.utime(cache, (100.0, 100.0))
|
||||
|
||||
calls = []
|
||||
def offline(src):
|
||||
calls.append(src)
|
||||
raise OSError("offline")
|
||||
monkeypatch.setattr(community, "_fetch_remote_manifest", offline)
|
||||
now = 100.0 + community._MANIFEST_MAX_AGE_S + 1
|
||||
assert community._fetch_manifest(source, False, now=now) == _FIXTURE
|
||||
assert community._fetch_manifest(source, False, now=now + 1) == _FIXTURE
|
||||
assert calls == [source]
|
||||
|
||||
|
||||
def test_manifest_fetch_is_bounded(monkeypatch):
|
||||
monkeypatch.setattr(community, "_MAX_MANIFEST_BYTES", 8)
|
||||
|
||||
class Response:
|
||||
status_code = 200
|
||||
headers = {}
|
||||
def __enter__(self): return self
|
||||
def __exit__(self, *_args): return False
|
||||
def raise_for_status(self): return None
|
||||
def iter_bytes(self): yield b'{"items":[]}'
|
||||
class Client:
|
||||
def stream(self, method, url, **kwargs):
|
||||
assert method == "GET"
|
||||
assert url.startswith("https://cdn.jsdelivr.net/")
|
||||
assert kwargs == {"follow_redirects": False}
|
||||
return Response()
|
||||
|
||||
with pytest.raises(ValueError, match="size limit"):
|
||||
community._fetch_remote_manifest("test/source", client=Client())
|
||||
|
||||
|
||||
def test_manifest_fetch_rejects_redirect_before_external_request():
|
||||
requested = []
|
||||
|
||||
class Response:
|
||||
status_code = 302
|
||||
headers = {"location": "https://evil.example/manifest.json"}
|
||||
def __enter__(self): return self
|
||||
def __exit__(self, *_args): return False
|
||||
class Client:
|
||||
def stream(self, _method, url, **_kwargs):
|
||||
requested.append(url)
|
||||
return Response()
|
||||
|
||||
with pytest.raises(ValueError, match="disallowed host"):
|
||||
community._fetch_remote_manifest("test/source", client=Client())
|
||||
assert requested == [community._manifest_url("test/source")]
|
||||
|
||||
|
||||
# ── Preview proxy ─────────────────────────────────────────────────────────────
|
||||
def test_canonical_preset_preview_delegates_same_origin(client, monkeypatch):
|
||||
from core import archetypes
|
||||
from api.routers import archetypes as arch_router
|
||||
|
||||
canonical = archetypes.list_archetypes(featured=True)[0]
|
||||
item = community.validate_item({
|
||||
**canonical, "type": "preset", "source": "starter",
|
||||
})
|
||||
monkeypatch.setattr(
|
||||
community, "_load", lambda _refresh: (["test/source"], [item], [], False),
|
||||
)
|
||||
delegated = []
|
||||
|
||||
async def preview(archetype_id, local=False):
|
||||
delegated.append((archetype_id, local))
|
||||
return Response(_wav_bytes(), media_type="audio/wav")
|
||||
|
||||
monkeypatch.setattr(arch_router, "preview_archetype", preview)
|
||||
response = client.get(f"/community/items/{item['id']}/preview")
|
||||
local = client.get(f"/community/items/{item['id']}/preview?local=true")
|
||||
|
||||
assert response.status_code == local.status_code == 200
|
||||
assert "location" not in response.headers
|
||||
assert delegated == [(item["id"], False), (item["id"], True)]
|
||||
|
||||
|
||||
def test_noncanonical_preset_preview_renders_once(client, tmp_path, monkeypatch):
|
||||
item = community.validate_item(_FIXTURE["items"][0])
|
||||
monkeypatch.setattr(community, "_CACHE_DIR", tmp_path)
|
||||
monkeypatch.setattr(
|
||||
community, "_load", lambda _refresh: (["test/source"], [item], [], False),
|
||||
)
|
||||
from api.routers import archetypes as arch_router
|
||||
calls = []
|
||||
async def render(_item, path):
|
||||
calls.append(path)
|
||||
_write_wav(Path(path))
|
||||
monkeypatch.setattr(arch_router, "_render_archetype_wav", render)
|
||||
|
||||
first = client.get("/community/items/p1/preview")
|
||||
second = client.get("/community/items/p1/preview")
|
||||
assert first.status_code == second.status_code == 200
|
||||
assert first.content == _wav_bytes()
|
||||
assert first.headers["x-omnivoice-preview-source"] == "community"
|
||||
assert len(calls) == 1
|
||||
|
||||
community._preset_preview_path(item).write_bytes(b"not audio")
|
||||
repaired = client.get("/community/items/p1/preview")
|
||||
assert repaired.status_code == 200
|
||||
assert repaired.content == _wav_bytes()
|
||||
assert len(calls) == 2
|
||||
|
||||
|
||||
def test_recorded_preview_is_served_from_same_origin(client, tmp_path, monkeypatch):
|
||||
item = community.validate_item(_FIXTURE["items"][3])
|
||||
clip = tmp_path / "voice.wav"
|
||||
expected = _write_wav(clip)
|
||||
monkeypatch.setattr(
|
||||
community, "_load", lambda _refresh: (["test/source"], [item], [], False),
|
||||
)
|
||||
monkeypatch.setattr(community, "_cached_voice_audio", lambda _item: clip)
|
||||
response = client.get("/community/items/v1/preview")
|
||||
assert response.status_code == 200
|
||||
assert response.content == expected
|
||||
|
||||
|
||||
def test_recorded_download_cap_is_atomic(tmp_path, monkeypatch):
|
||||
item = community.validate_item(_FIXTURE["items"][3])
|
||||
destination = tmp_path / "voice.wav"
|
||||
destination.write_bytes(b"existing-good-audio")
|
||||
monkeypatch.setattr(community, "_MAX_VOICE_AUDIO_BYTES", 8)
|
||||
|
||||
class Response:
|
||||
status_code = 200
|
||||
headers = {}
|
||||
def __enter__(self): return self
|
||||
def __exit__(self, *_args): return False
|
||||
def raise_for_status(self): return None
|
||||
def iter_bytes(self): yield b"123456789"
|
||||
class Client:
|
||||
def stream(self, method, url, **kwargs):
|
||||
assert method == "GET" and url.startswith("https://github.com/")
|
||||
assert kwargs == {"follow_redirects": False}
|
||||
return Response()
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
community._download_voice_audio(item, destination, client=Client())
|
||||
assert getattr(exc.value, "status_code", None) == 502
|
||||
assert destination.read_bytes() == b"existing-good-audio"
|
||||
assert not list(tmp_path.glob(".*.part"))
|
||||
|
||||
|
||||
def test_recorded_download_rejects_redirect_before_external_request(tmp_path):
|
||||
item = community.validate_item(_FIXTURE["items"][3])
|
||||
requested = []
|
||||
|
||||
class Response:
|
||||
status_code = 302
|
||||
headers = {"location": "https://evil.example/private.wav"}
|
||||
def __enter__(self): return self
|
||||
def __exit__(self, *_args): return False
|
||||
class Client:
|
||||
def stream(self, _method, url, **_kwargs):
|
||||
requested.append(url)
|
||||
return Response()
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
community._download_voice_audio(item, tmp_path / "voice.wav", client=Client())
|
||||
assert getattr(exc.value, "status_code", None) == 502
|
||||
assert requested == [item["audio"]["url"]]
|
||||
|
||||
|
||||
def test_recorded_download_follows_allowlisted_redirect(tmp_path):
|
||||
item = community.validate_item(_FIXTURE["items"][3])
|
||||
destination = tmp_path / "voice.wav"
|
||||
requested = []
|
||||
expected = _wav_bytes()
|
||||
|
||||
class Response:
|
||||
def __init__(self, status, headers, body=b""):
|
||||
self.status_code, self.headers, self.body = status, headers, body
|
||||
def __enter__(self): return self
|
||||
def __exit__(self, *_args): return False
|
||||
def raise_for_status(self): return None
|
||||
def iter_bytes(self): yield self.body
|
||||
class Client:
|
||||
def stream(self, _method, url, **_kwargs):
|
||||
requested.append(url)
|
||||
if len(requested) == 1:
|
||||
return Response(302, {"location": "https://objects.githubusercontent.com/v1.wav"})
|
||||
return Response(200, {}, expected)
|
||||
|
||||
community._download_voice_audio(item, destination, client=Client())
|
||||
assert destination.read_bytes() == expected
|
||||
assert requested == [item["audio"]["url"], "https://objects.githubusercontent.com/v1.wav"]
|
||||
|
||||
|
||||
def test_recorded_download_rejects_non_audio_bytes(tmp_path):
|
||||
item = community.validate_item(_FIXTURE["items"][3])
|
||||
destination = tmp_path / "voice.wav"
|
||||
|
||||
class Response:
|
||||
status_code = 200
|
||||
headers = {}
|
||||
def __enter__(self): return self
|
||||
def __exit__(self, *_args): return False
|
||||
def raise_for_status(self): return None
|
||||
def iter_bytes(self): yield b"this is not audio"
|
||||
class Client:
|
||||
def stream(self, _method, _url, **_kwargs): return Response()
|
||||
|
||||
with pytest.raises(HTTPException, match="valid WAV"):
|
||||
community._download_voice_audio(item, destination, client=Client())
|
||||
assert not destination.exists()
|
||||
assert not list(tmp_path.glob(".*.part"))
|
||||
|
||||
|
||||
# ── Materialization ───────────────────────────────────────────────────────────
|
||||
def test_community_use_is_idempotent_design_profile(
|
||||
client, tmp_path, monkeypatch, symlinks_supported,
|
||||
):
|
||||
from core import event_bus
|
||||
from core.db import db_conn, init_db
|
||||
from api.routers import archetypes as arch_router
|
||||
|
||||
init_db()
|
||||
item = community.validate_item(_FIXTURE["items"][0])
|
||||
item["_source_repo"] = "test/source"
|
||||
personality = community._community_personality(item)
|
||||
monkeypatch.setattr(
|
||||
community, "_load", lambda _refresh: (["test/source"], [item], [], False),
|
||||
)
|
||||
calls = []
|
||||
emitted = []
|
||||
async def render(_item, path):
|
||||
calls.append(path)
|
||||
_write_wav(Path(path))
|
||||
monkeypatch.setattr(arch_router, "_render_archetype_wav", render)
|
||||
monkeypatch.setattr(
|
||||
event_bus, "emit", lambda topic, payload: emitted.append((topic, payload)),
|
||||
)
|
||||
with db_conn() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM voice_profiles WHERE personality IN (?, ?)",
|
||||
(item["id"], personality),
|
||||
)
|
||||
|
||||
first = client.post("/community/items/p1/use")
|
||||
second = client.post("/community/items/p1/use")
|
||||
assert first.status_code == second.status_code == 200
|
||||
assert second.json()["profile_id"] == first.json()["profile_id"]
|
||||
assert len(calls) == 1
|
||||
with db_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id=?", (first.json()["profile_id"],),
|
||||
).fetchone()
|
||||
assert row["kind"] == "design"
|
||||
assert row["personality"] == personality
|
||||
assert json.loads(row["vd_states"])["Gender"] == "female"
|
||||
assert row["instruct"] == item["instruct"]
|
||||
assert emitted[-1] == (
|
||||
"profiles", {"action": "updated", "id": first.json()["profile_id"]},
|
||||
)
|
||||
|
||||
profile_audio = community._stored_profile_audio(row["ref_audio_path"])
|
||||
assert profile_audio is not None
|
||||
profile_audio.unlink()
|
||||
repaired = client.post("/community/items/p1/use")
|
||||
assert repaired.status_code == 200
|
||||
assert repaired.json()["profile_id"] == first.json()["profile_id"]
|
||||
assert profile_audio.read_bytes() == _wav_bytes()
|
||||
# The current preset preview cache repairs the profile without another
|
||||
# model render.
|
||||
assert len(calls) == 1
|
||||
|
||||
profile_audio.write_bytes(b"not a WAV")
|
||||
repaired_corrupt = client.post("/community/items/p1/use")
|
||||
assert repaired_corrupt.status_code == 200
|
||||
assert profile_audio.read_bytes() == _wav_bytes()
|
||||
|
||||
if symlinks_supported: # Windows needs Developer Mode to create symlinks
|
||||
outside = tmp_path / "outside.wav"
|
||||
outside_bytes = _write_wav(outside)
|
||||
profile_audio.unlink()
|
||||
profile_audio.symlink_to(outside)
|
||||
repaired_symlink = client.post("/community/items/p1/use")
|
||||
assert repaired_symlink.status_code == 200
|
||||
assert not profile_audio.is_symlink()
|
||||
assert outside.read_bytes() == outside_bytes
|
||||
|
||||
|
||||
def test_community_staged_repair_preserves_concurrently_edited_profile(
|
||||
client, monkeypatch,
|
||||
):
|
||||
"""A staged community repair must not reclaim a row edited mid-copy."""
|
||||
from core.config import VOICES_DIR
|
||||
from core.db import db_conn, init_db
|
||||
|
||||
init_db()
|
||||
item = community.validate_item(_FIXTURE["items"][0])
|
||||
item["_source_repo"] = "test/source"
|
||||
personality = community._community_personality(item)
|
||||
edited_personality = f"user-edited:{personality}"
|
||||
monkeypatch.setattr(
|
||||
community, "_load", lambda _refresh: (["test/source"], [item], [], False),
|
||||
)
|
||||
with db_conn() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM voice_profiles WHERE personality IN (?, ?, ?)",
|
||||
(item["id"], personality, edited_personality),
|
||||
)
|
||||
_write_wav(community._preset_preview_path(item))
|
||||
|
||||
original_id = {"value": None}
|
||||
mutation_seen = {"value": False}
|
||||
real_copy_atomic = community._copy_atomic
|
||||
|
||||
def racing_copy(source, destination):
|
||||
destination = Path(destination)
|
||||
if destination.name.endswith(".staged.wav"):
|
||||
assert original_id["value"] is not None
|
||||
with db_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE voice_profiles SET name='User edit', personality=? WHERE id=?",
|
||||
(edited_personality, original_id["value"]),
|
||||
)
|
||||
mutation_seen["value"] = True
|
||||
real_copy_atomic(Path(source), destination)
|
||||
|
||||
monkeypatch.setattr(community, "_copy_atomic", racing_copy)
|
||||
first = client.post(f"/community/items/{item['id']}/use")
|
||||
assert first.status_code == 200
|
||||
original_id["value"] = first.json()["profile_id"]
|
||||
|
||||
with db_conn() as conn:
|
||||
original = conn.execute(
|
||||
"SELECT ref_audio_path FROM voice_profiles WHERE id=?",
|
||||
(original_id["value"],),
|
||||
).fetchone()
|
||||
original_audio = community._stored_profile_audio(original["ref_audio_path"])
|
||||
assert original_audio is not None
|
||||
corrupt_bytes = b"corrupt user-owned sample"
|
||||
original_audio.write_bytes(corrupt_bytes)
|
||||
|
||||
repaired = client.post(f"/community/items/{item['id']}/use")
|
||||
assert repaired.status_code == 200
|
||||
repaired_id = repaired.json()["profile_id"]
|
||||
assert mutation_seen["value"]
|
||||
assert repaired_id != original_id["value"]
|
||||
|
||||
with db_conn() as conn:
|
||||
edited = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id=?", (original_id["value"],),
|
||||
).fetchone()
|
||||
canonical = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id=?", (repaired_id,),
|
||||
).fetchone()
|
||||
canonical_count = conn.execute(
|
||||
"SELECT count(*) FROM voice_profiles WHERE personality=?", (personality,),
|
||||
).fetchone()[0]
|
||||
assert edited["name"] == "User edit"
|
||||
assert edited["personality"] == edited_personality
|
||||
assert edited["instruct"] == item["instruct"]
|
||||
assert original_audio.read_bytes() == corrupt_bytes
|
||||
assert canonical["personality"] == personality
|
||||
assert canonical["ref_audio_path"] == community._community_profile_audio_filename(
|
||||
repaired_id, item,
|
||||
)
|
||||
assert canonical_count == 1
|
||||
assert (Path(VOICES_DIR) / canonical["ref_audio_path"]).read_bytes() == _wav_bytes()
|
||||
assert not list(Path(VOICES_DIR).glob(f".{original_id['value']}-*.staged.wav"))
|
||||
|
||||
|
||||
def test_recorded_community_use_is_idempotent_clone_profile(client, tmp_path, monkeypatch):
|
||||
from core.db import db_conn, init_db
|
||||
|
||||
init_db()
|
||||
item = community.validate_item(_FIXTURE["items"][3])
|
||||
item["_source_repo"] = "test/source"
|
||||
personality = community._community_personality(item)
|
||||
clip = tmp_path / "recorded.wav"
|
||||
_write_wav(clip)
|
||||
cache_calls = []
|
||||
monkeypatch.setattr(
|
||||
community, "_load", lambda _refresh: (["test/source"], [item], [], False),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
community, "_cached_voice_audio", lambda _item: cache_calls.append(_item["id"]) or clip,
|
||||
)
|
||||
with db_conn() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM voice_profiles WHERE personality IN (?, ?)",
|
||||
(item["id"], personality),
|
||||
)
|
||||
|
||||
first = client.post("/community/items/v1/use")
|
||||
second = client.post("/community/items/v1/use")
|
||||
assert first.status_code == second.status_code == 200
|
||||
assert second.json()["profile_id"] == first.json()["profile_id"]
|
||||
assert cache_calls == ["v1"]
|
||||
with db_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id=?", (first.json()["profile_id"],),
|
||||
).fetchone()
|
||||
assert row["kind"] == "clone"
|
||||
assert row["personality"] == personality
|
||||
assert row["vd_states"] is None and row["instruct"] == ""
|
||||
assert row["ref_text"] == ""
|
||||
|
||||
old_audio_filename = row["ref_audio_path"]
|
||||
item["audio"]["url"] = "https://raw.githubusercontent.com/test/source/main/v2.wav"
|
||||
refreshed = client.post("/community/items/v1/use")
|
||||
assert refreshed.status_code == 200
|
||||
assert refreshed.json()["profile_id"] == first.json()["profile_id"]
|
||||
assert cache_calls == ["v1", "v1"]
|
||||
with db_conn() as conn:
|
||||
refreshed_row = conn.execute(
|
||||
"SELECT ref_audio_path FROM voice_profiles WHERE id=?",
|
||||
(first.json()["profile_id"],),
|
||||
).fetchone()
|
||||
assert refreshed_row["ref_audio_path"] != old_audio_filename
|
||||
|
||||
|
||||
def test_noncanonical_builtin_id_cannot_heal_archetype_profile(client, monkeypatch):
|
||||
from core import archetypes
|
||||
from core.db import db_conn, init_db
|
||||
from api.routers import archetypes as arch_router
|
||||
|
||||
init_db()
|
||||
canonical = archetypes.list_archetypes(featured=True)[0]
|
||||
changed_instruct = "female" if canonical["instruct"] != "female" else "male"
|
||||
item = community.validate_item({
|
||||
**canonical,
|
||||
"type": "preset",
|
||||
"source": "community",
|
||||
"instruct": changed_instruct,
|
||||
})
|
||||
item["_source_repo"] = "test/source"
|
||||
personality = community._community_personality(item)
|
||||
builtin_profile_id = f"b{os.urandom(4).hex()[:7]}"
|
||||
with db_conn() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM voice_profiles WHERE personality IN (?, ?)",
|
||||
(canonical["id"], personality),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO voice_profiles (id, name, personality, instruct, kind, created_at) "
|
||||
"VALUES (?, 'Built-in profile', ?, 'sentinel', 'design', 1)",
|
||||
(builtin_profile_id, canonical["id"]),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
community, "_load", lambda _refresh: (["test/source"], [item], [], False),
|
||||
)
|
||||
async def render(_item, path):
|
||||
_write_wav(Path(path))
|
||||
monkeypatch.setattr(arch_router, "_render_archetype_wav", render)
|
||||
|
||||
response = client.post(f"/community/items/{canonical['id']}/use")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["profile_id"] != builtin_profile_id
|
||||
with db_conn() as conn:
|
||||
builtin = conn.execute(
|
||||
"SELECT instruct FROM voice_profiles WHERE id=?", (builtin_profile_id,),
|
||||
).fetchone()
|
||||
community_row = conn.execute(
|
||||
"SELECT personality FROM voice_profiles WHERE id=?",
|
||||
(response.json()["profile_id"],),
|
||||
).fetchone()
|
||||
conn.execute(
|
||||
"DELETE FROM voice_profiles WHERE id IN (?, ?)",
|
||||
(builtin_profile_id, response.json()["profile_id"]),
|
||||
)
|
||||
assert builtin["instruct"] == "sentinel"
|
||||
assert community_row["personality"] == personality
|
||||
|
||||
|
||||
def test_community_use_does_not_rewrite_an_imported_bare_id_collision(
|
||||
client, monkeypatch,
|
||||
):
|
||||
from core.config import VOICES_DIR
|
||||
from core.db import db_conn, init_db
|
||||
from api.routers import archetypes as arch_router
|
||||
|
||||
init_db()
|
||||
item = community.validate_item(_FIXTURE["items"][0])
|
||||
item["_source_repo"] = "test/source"
|
||||
personality = community._community_personality(item)
|
||||
imported_id = "importedcomm"
|
||||
imported_ns_id = "importedcommns"
|
||||
imported_audio = Path(VOICES_DIR) / f"{imported_id}.wav"
|
||||
imported_ns_audio = Path(VOICES_DIR) / f"{imported_ns_id}.wav"
|
||||
original_audio = _write_wav(imported_audio)
|
||||
original_ns_audio = _write_wav(imported_ns_audio)
|
||||
with db_conn() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM voice_profiles WHERE personality IN (?, ?)",
|
||||
(item["id"], personality),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO voice_profiles "
|
||||
"(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, "
|
||||
"kind, is_locked, verified_own_voice, created_at) VALUES "
|
||||
"(?, 'Imported collision', ?, 'user transcript', 'male', 'Auto', NULL, ?, "
|
||||
"'clone', 1, 1, 1)",
|
||||
(imported_id, imported_audio.name, item["id"]),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO voice_profiles "
|
||||
"(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, "
|
||||
"kind, vd_states, is_locked, verified_own_voice, created_at) VALUES "
|
||||
"(?, 'Imported namespaced collision', ?, ?, ?, ?, 42, ?, "
|
||||
"'design', NULL, 0, 0, 2)",
|
||||
(
|
||||
imported_ns_id, imported_ns_audio.name, item["sample_script"],
|
||||
item["instruct"], item["language"], personality,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
community, "_load", lambda _refresh: (["test/source"], [item], [], False),
|
||||
)
|
||||
|
||||
async def render(_item, path):
|
||||
_write_wav(Path(path))
|
||||
|
||||
monkeypatch.setattr(arch_router, "_render_archetype_wav", render)
|
||||
response = client.post(f"/community/items/{item['id']}/use")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["profile_id"] != imported_id
|
||||
with db_conn() as conn:
|
||||
imported = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id=?", (imported_id,),
|
||||
).fetchone()
|
||||
imported_ns = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id=?", (imported_ns_id,),
|
||||
).fetchone()
|
||||
created = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id=?", (response.json()["profile_id"],),
|
||||
).fetchone()
|
||||
assert imported["personality"] == item["id"]
|
||||
assert imported["instruct"] == "male"
|
||||
assert imported["ref_text"] == "user transcript"
|
||||
assert imported_audio.read_bytes() == original_audio
|
||||
assert imported_ns["instruct"] == item["instruct"]
|
||||
assert imported_ns["ref_text"] == item["sample_script"]
|
||||
assert imported_ns["vd_states"] is None
|
||||
assert imported_ns_audio.read_bytes() == original_ns_audio
|
||||
assert created["personality"] == personality
|
||||
|
||||
|
||||
def test_noncolliding_legacy_community_profile_is_adopted(client, monkeypatch):
|
||||
from core.config import VOICES_DIR
|
||||
from core.db import db_conn, init_db
|
||||
from api.routers import archetypes as arch_router
|
||||
|
||||
init_db()
|
||||
item = community.validate_item(_FIXTURE["items"][0])
|
||||
item["_source_repo"] = "test/source"
|
||||
personality = community._community_personality(item)
|
||||
legacy_id = f"l{os.urandom(4).hex()[:7]}"
|
||||
with db_conn() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM voice_profiles WHERE personality IN (?, ?)",
|
||||
(item["id"], personality),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO voice_profiles "
|
||||
"(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, "
|
||||
"kind, vd_states, created_at) VALUES "
|
||||
"(?, 'Legacy community profile', ?, '', ?, ?, NULL, ?, 'design', NULL, 1)",
|
||||
(legacy_id, f"{legacy_id}.wav", item["instruct"], item["language"], item["id"]),
|
||||
)
|
||||
_write_wav(Path(VOICES_DIR) / f"{legacy_id}.wav")
|
||||
monkeypatch.setattr(
|
||||
community, "_load", lambda _refresh: (["test/source"], [item], [], False),
|
||||
)
|
||||
community._preset_preview_path(item).unlink(missing_ok=True)
|
||||
rendered = []
|
||||
async def render(_item, path):
|
||||
rendered.append(path)
|
||||
_write_wav(Path(path))
|
||||
monkeypatch.setattr(arch_router, "_render_archetype_wav", render)
|
||||
|
||||
response = client.post(f"/community/items/{item['id']}/use")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["profile_id"] == legacy_id
|
||||
assert len(rendered) == 1
|
||||
with db_conn() as conn:
|
||||
adopted = conn.execute(
|
||||
"SELECT personality, kind, ref_audio_path FROM voice_profiles WHERE id=?",
|
||||
(legacy_id,),
|
||||
).fetchone()
|
||||
assert adopted["personality"] == personality
|
||||
assert adopted["kind"] == "design"
|
||||
adopted_audio = community._stored_profile_audio(adopted["ref_audio_path"])
|
||||
assert adopted_audio is not None and adopted_audio.is_file()
|
||||
|
||||
@@ -1,259 +0,0 @@
|
||||
"""Stable nested operation ownership (model-free, cross-platform seams)."""
|
||||
import ctypes
|
||||
import builtins
|
||||
import os
|
||||
import runpy
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import types
|
||||
from ctypes import wintypes
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from core import contained_subprocess as owned
|
||||
|
||||
|
||||
class _Call:
|
||||
def __init__(self, fn):
|
||||
self.fn = fn
|
||||
|
||||
def __call__(self, *args):
|
||||
return self.fn(*args)
|
||||
|
||||
|
||||
def test_supervisor_argv_uses_entry_module_for_source_and_frozen_binary(monkeypatch):
|
||||
monkeypatch.delattr(owned.sys, "frozen", raising=False)
|
||||
source = owned._supervisor_argv(3, 4, ["operation"])
|
||||
assert source[:2] == [sys.executable, str(Path(owned.__file__).parents[1] / "main.py")]
|
||||
assert source[2:] == ["--supervise", "3", "4", "--", "operation"]
|
||||
|
||||
monkeypatch.setattr(owned.sys, "frozen", True, raising=False)
|
||||
frozen = owned._supervisor_argv(3, 4, ["operation"])
|
||||
assert frozen == [sys.executable, "--supervise", "3", "4", "--", "operation"]
|
||||
|
||||
|
||||
def test_source_main_dispatches_supervisor_before_heavy_imports(monkeypatch):
|
||||
calls = []
|
||||
fake = types.ModuleType("core.contained_subprocess")
|
||||
fake.supervisor_main = lambda args: calls.append(args) or 23
|
||||
monkeypatch.setitem(sys.modules, "core.contained_subprocess", fake)
|
||||
main_path = Path(owned.__file__).parents[1] / "main.py"
|
||||
monkeypatch.setattr(
|
||||
sys,
|
||||
"argv",
|
||||
[str(main_path), "--supervise", "3", "4", "--", "operation"],
|
||||
)
|
||||
original_import = builtins.__import__
|
||||
|
||||
def guard_heavy_import(name, *args, **kwargs):
|
||||
if name == "math":
|
||||
raise AssertionError("supervisor dispatch reached application imports")
|
||||
return original_import(name, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(builtins, "__import__", guard_heavy_import)
|
||||
with pytest.raises(SystemExit, match="23"):
|
||||
runpy.run_path(str(main_path), run_name="__main__")
|
||||
assert calls == [["--supervise", "3", "4", "--", "operation"]]
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name != "posix", reason="Unix drain pipe contract")
|
||||
def test_drain_fd_is_explicitly_inherited_by_wrapper_but_not_operation(monkeypatch):
|
||||
drain_read, drain_write = os.pipe()
|
||||
monkeypatch.setenv("OMNIVOICE_DESKTOP_CONTAINED", "1")
|
||||
monkeypatch.setenv("OMNIVOICE_DESKTOP_DRAIN_FD", str(drain_write))
|
||||
owned.secure_backend_drain_fd()
|
||||
assert not os.get_inheritable(drain_write)
|
||||
implicit_probe = subprocess.check_output(
|
||||
[
|
||||
sys.executable,
|
||||
"-c",
|
||||
"import os; "
|
||||
"fd=int(os.environ['OMNIVOICE_DESKTOP_DRAIN_FD']); "
|
||||
"\ntry: os.fstat(fd); print('leaked')"
|
||||
"\nexcept OSError: print('closed')",
|
||||
],
|
||||
close_fds=False,
|
||||
text=True,
|
||||
)
|
||||
assert implicit_probe.strip() == "closed"
|
||||
script = (
|
||||
"import os,time; token=os.environ.get('OMNIVOICE_DESKTOP_DRAIN_FD'); "
|
||||
"marker=os.environ.get('OMNIVOICE_DESKTOP_CONTAINED'); "
|
||||
"\nif token is None and marker is None: state='stripped'"
|
||||
"\nelse:"
|
||||
"\n try: os.fstat(int(token)); state='leaked'"
|
||||
"\n except OSError: state='closed'"
|
||||
"\nprint(state, flush=True); time.sleep(60)"
|
||||
)
|
||||
proc = owned.spawn_owned(
|
||||
[sys.executable, "-c", script],
|
||||
stdout=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
try:
|
||||
assert proc.stdout.readline().strip() == "stripped"
|
||||
os.close(drain_write)
|
||||
drain_write = -1
|
||||
os.set_blocking(drain_read, False)
|
||||
with pytest.raises(BlockingIOError):
|
||||
os.read(drain_read, 1) # wrapper still holds the only writer
|
||||
proc.kill()
|
||||
proc.wait(timeout=5)
|
||||
deadline = time.monotonic() + 2
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
if os.read(drain_read, 1) == b"":
|
||||
break
|
||||
except BlockingIOError:
|
||||
time.sleep(0.01)
|
||||
else:
|
||||
pytest.fail("wrapper exit did not close the desktop drain writer")
|
||||
finally:
|
||||
if drain_write >= 0:
|
||||
os.close(drain_write)
|
||||
os.close(drain_read)
|
||||
if proc.poll() is None:
|
||||
proc.kill()
|
||||
proc.wait(timeout=5)
|
||||
|
||||
|
||||
def test_invalid_or_missing_desktop_drain_fd_fails_safe(monkeypatch):
|
||||
monkeypatch.setenv("OMNIVOICE_DESKTOP_CONTAINED", "1")
|
||||
monkeypatch.setenv("OMNIVOICE_DESKTOP_DRAIN_FD", "not-an-fd")
|
||||
with pytest.raises(RuntimeError, match="missing its live.*drain descriptor"):
|
||||
owned.spawn_owned([sys.executable, "-c", "print('unsafe')"])
|
||||
|
||||
monkeypatch.delenv("OMNIVOICE_DESKTOP_DRAIN_FD")
|
||||
with pytest.raises(RuntimeError, match="missing its live.*drain descriptor"):
|
||||
owned.secure_backend_drain_fd()
|
||||
|
||||
monkeypatch.delenv("OMNIVOICE_DESKTOP_CONTAINED")
|
||||
assert owned.backend_drain_fd(required=True) is None
|
||||
proc = owned.spawn_owned(
|
||||
[sys.executable, "-c", "print('standalone')"],
|
||||
stdout=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
assert proc.stdout.readline().strip() == "standalone"
|
||||
assert proc.wait(timeout=5) == 0
|
||||
|
||||
|
||||
def test_windows_operation_is_in_kill_on_close_job_before_resume(monkeypatch):
|
||||
"""The child gets no instruction before stable nested Job assignment."""
|
||||
events = []
|
||||
job_closed = threading.Event()
|
||||
job = 99
|
||||
|
||||
def close_handle(handle):
|
||||
value = getattr(handle, "value", handle)
|
||||
events.append(("close", value))
|
||||
if value == job:
|
||||
job_closed.set()
|
||||
return True
|
||||
|
||||
kernel = type("Kernel", (), {})()
|
||||
kernel.AssignProcessToJobObject = _Call(
|
||||
lambda assigned_job, process: events.append(("assign", assigned_job, process)) or True
|
||||
)
|
||||
kernel.TerminateJobObject = _Call(
|
||||
lambda assigned_job, code: events.append(("terminate", assigned_job, code)) or True
|
||||
)
|
||||
kernel.WriteFile = _Call(
|
||||
lambda handle, payload, size, written, overlap: events.append(("write", size)) or True
|
||||
)
|
||||
kernel.CloseHandle = _Call(close_handle)
|
||||
|
||||
def read_control(*_args):
|
||||
job_closed.wait(2)
|
||||
return False
|
||||
|
||||
kernel.ReadFile = _Call(read_control)
|
||||
monkeypatch.setattr(owned, "_windows_job", lambda: (job, kernel, wintypes))
|
||||
monkeypatch.setattr(
|
||||
owned,
|
||||
"_resume_windows_process",
|
||||
lambda _kernel, _types, pid: events.append(("resume", pid)),
|
||||
)
|
||||
|
||||
class Child:
|
||||
_handle = 77
|
||||
pid = 123
|
||||
|
||||
def wait(self, timeout=None):
|
||||
events.append(("wait", timeout))
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(
|
||||
owned.subprocess,
|
||||
"Popen",
|
||||
lambda *args, **kwargs: events.append(("spawn", kwargs["creationflags"])) or Child(),
|
||||
)
|
||||
|
||||
assert owned._supervise_windows(11, 12, ["operation.exe"]) == 0
|
||||
assert job_closed.wait(1)
|
||||
|
||||
names = [event[0] for event in events]
|
||||
assert names.index("assign") < names.index("resume") < names.index("wait")
|
||||
assert names.index("wait") < names.index("terminate") < names.index("write")
|
||||
|
||||
|
||||
def test_windows_assignment_failure_kills_suspended_unowned_child(monkeypatch):
|
||||
"""A child outside the nested Job must be killed through its stable handle."""
|
||||
events = []
|
||||
job_closed = threading.Event()
|
||||
job = 99
|
||||
|
||||
def close_handle(handle):
|
||||
value = getattr(handle, "value", handle)
|
||||
events.append(("close", value))
|
||||
if value == job:
|
||||
job_closed.set()
|
||||
return True
|
||||
|
||||
kernel = type("Kernel", (), {})()
|
||||
kernel.AssignProcessToJobObject = _Call(
|
||||
lambda assigned_job, process: events.append(("assign", assigned_job, process))
|
||||
or False
|
||||
)
|
||||
kernel.TerminateJobObject = _Call(
|
||||
lambda assigned_job, code: events.append(("terminate", assigned_job, code)) or True
|
||||
)
|
||||
kernel.WriteFile = _Call(
|
||||
lambda handle, payload, size, written, overlap: events.append(("write", size)) or True
|
||||
)
|
||||
kernel.CloseHandle = _Call(close_handle)
|
||||
|
||||
def read_control(*_args):
|
||||
job_closed.wait(2)
|
||||
return False
|
||||
|
||||
kernel.ReadFile = _Call(read_control)
|
||||
monkeypatch.setattr(owned, "_windows_job", lambda: (job, kernel, wintypes))
|
||||
monkeypatch.setattr(ctypes, "get_last_error", lambda: 5, raising=False)
|
||||
|
||||
class Child:
|
||||
_handle = 77
|
||||
pid = 123
|
||||
|
||||
def kill(self):
|
||||
events.append(("kill",))
|
||||
|
||||
def wait(self, timeout=None):
|
||||
events.append(("wait", timeout))
|
||||
return 1
|
||||
|
||||
monkeypatch.setattr(
|
||||
owned.subprocess,
|
||||
"Popen",
|
||||
lambda *args, **kwargs: events.append(("spawn", kwargs["creationflags"])) or Child(),
|
||||
)
|
||||
|
||||
assert owned._supervise_windows(11, 12, ["operation.exe"]) == 127
|
||||
assert job_closed.wait(1)
|
||||
|
||||
names = [event[0] for event in events]
|
||||
assert names.index("assign") < names.index("terminate") < names.index("kill")
|
||||
assert names.index("kill") < names.index("wait") < names.index("write")
|
||||
@@ -1,126 +0,0 @@
|
||||
"""macOS fallback for the os.waitid probe (#1656).
|
||||
|
||||
CPython on macOS does not expose os.waitid, so OwnedPopen's WNOWAIT dance
|
||||
crashed with AttributeError on every poll after the first spawn. These tests
|
||||
simulate that platform (monkeypatch os.waitid away) and pin the fallback:
|
||||
poll/wait/kill must work, exit codes must be real, and an already-reaped
|
||||
leader must be refused (ChildProcessError path), never signalled blind.
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from core import contained_subprocess as owned
|
||||
|
||||
|
||||
def _make_owned(argv):
|
||||
cr, cw = os.pipe()
|
||||
rr, rw = os.pipe()
|
||||
proc = subprocess.Popen(argv, start_new_session=True)
|
||||
os.close(cw)
|
||||
os.close(rw) # result writer gone: _read_result falls back to wrapper rc
|
||||
return owned.OwnedPopen(proc, cr, rr), proc
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def no_waitid(monkeypatch):
|
||||
monkeypatch.delattr(os, "waitid", raising=False)
|
||||
|
||||
|
||||
def test_poll_running_then_exited_without_waitid(no_waitid):
|
||||
h, _ = _make_owned([sys.executable, "-c", "import time; time.sleep(1.5)"])
|
||||
try:
|
||||
assert h.poll() is None, "running child must poll None"
|
||||
h._proc.wait()
|
||||
deadline = time.monotonic() + 5
|
||||
rc = None
|
||||
while rc is None and time.monotonic() < deadline:
|
||||
rc = h.poll()
|
||||
time.sleep(0.05)
|
||||
assert rc == 0
|
||||
assert h.poll() == 0
|
||||
finally:
|
||||
h._close_control()
|
||||
if h._result_fd is not None:
|
||||
os.close(h._result_fd)
|
||||
|
||||
|
||||
def test_poll_reports_real_exit_code_without_waitid(no_waitid):
|
||||
h, _ = _make_owned([sys.executable, "-c", "raise SystemExit(3)"])
|
||||
try:
|
||||
deadline = time.monotonic() + 5
|
||||
while h.poll() is None and time.monotonic() < deadline:
|
||||
time.sleep(0.05)
|
||||
assert h.poll() == 3
|
||||
finally:
|
||||
h._close_control()
|
||||
if h._result_fd is not None:
|
||||
os.close(h._result_fd)
|
||||
|
||||
|
||||
def test_wait_returns_after_kill_without_waitid(no_waitid):
|
||||
h, _ = _make_owned([sys.executable, "-c", "import time; time.sleep(30)"])
|
||||
try:
|
||||
h.kill()
|
||||
rc = h.wait(timeout=5)
|
||||
assert rc != 0
|
||||
finally:
|
||||
h._close_control()
|
||||
if h._result_fd is not None:
|
||||
os.close(h._result_fd)
|
||||
|
||||
|
||||
def test_reaped_by_own_popen_reports_code_without_waitid(no_waitid):
|
||||
h, proc = _make_owned([sys.executable, "-c", "pass"])
|
||||
try:
|
||||
proc.wait() # reaped through OUR handle: known code, not a refusal
|
||||
assert h.poll() == 0
|
||||
finally:
|
||||
h._close_control()
|
||||
if h._result_fd is not None:
|
||||
os.close(h._result_fd)
|
||||
|
||||
|
||||
def test_foreign_reaped_leader_is_refused_without_waitid(no_waitid):
|
||||
h, proc = _make_owned([sys.executable, "-c", "pass"])
|
||||
try:
|
||||
# Reap OUTSIDE this handle: Popen never learns the code, so poll must
|
||||
# refuse (None) rather than guess or signal a maybe-reused group.
|
||||
while True:
|
||||
pid, _ = os.waitpid(proc.pid, os.WNOHANG)
|
||||
if pid == proc.pid:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
assert h.poll() is None
|
||||
finally:
|
||||
h._close_control()
|
||||
if h._result_fd is not None:
|
||||
os.close(h._result_fd)
|
||||
|
||||
|
||||
def test_kill_after_pid_reuse_does_not_signal_without_waitid(no_waitid, monkeypatch):
|
||||
"""A foreign-reaped leader's reused numeric pid must not authorize killpg."""
|
||||
import signal as _signal
|
||||
|
||||
h, proc = _make_owned([sys.executable, "-c", "pass"])
|
||||
try:
|
||||
while True:
|
||||
pid, _ = os.waitpid(proc.pid, os.WNOHANG)
|
||||
if pid == proc.pid:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
# Model the numeric pid being reused: kill(pid, 0) would succeed even
|
||||
# though waitpid still reports that the original child is no longer
|
||||
# ours. The old guard therefore reached killpg and fails this test.
|
||||
monkeypatch.setattr(os, "kill", lambda _pid, _sig: None)
|
||||
signalled = []
|
||||
monkeypatch.setattr(os, "killpg", lambda pid, sig: signalled.append((pid, sig)))
|
||||
h._signal_owned_group(_signal.SIGKILL)
|
||||
assert signalled == []
|
||||
finally:
|
||||
h._close_control()
|
||||
if h._result_fd is not None:
|
||||
os.close(h._result_fd)
|
||||
@@ -1,15 +1,16 @@
|
||||
"""A dictation model that decodes nothing gets demoted, not re-selected forever.
|
||||
|
||||
On Windows, `sherpa-parakeet-tdt-v3` installs cleanly, loads without error,
|
||||
and returns an empty token list for clear speech
|
||||
`sherpa-parakeet-tdt-v3` is the curated default, and on Windows it installs
|
||||
cleanly, loads without error, and returns an empty token list for clear speech
|
||||
(both quantisations, both decoding methods, sherpa-onnx 1.13.3 and 1.13.4)
|
||||
while whisper and zipformer transcribe the same bytes. The defect is inside
|
||||
sherpa-onnx's NeMo-TDT decoder — unfixable from here by configuration.
|
||||
|
||||
Whisper Tiny is now the cross-platform default, while Parakeet remains
|
||||
selectable. Runtime demotion still protects users who select a recognizer that
|
||||
loads successfully but decodes nothing: it is demoted on this machine and the
|
||||
next session follows the capture fallback.
|
||||
Hard-coding a different default per OS would be a guess: we have evidence for
|
||||
one platform only. So the app observes instead. When a session hears real
|
||||
speech and the model returns nothing, that model is demoted ON THIS MACHINE and
|
||||
stops being auto-selected, which self-corrects wherever the breakage actually
|
||||
is and is a no-op everywhere it isn't.
|
||||
|
||||
These tests pin the demotion round trip and, critically, that the user can
|
||||
always take back control by re-picking the model.
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"""A dictation model that decodes NOTHING must fall back, not fail silently.
|
||||
|
||||
Found on Windows with `sherpa-parakeet-tdt-v3`: the model
|
||||
Found on Windows with the curated default `sherpa-parakeet-tdt-v3`: the model
|
||||
downloads, loads with zero errors, and is correctly detected as a TDT model
|
||||
(`num_durations: 5`) — then returns an empty token list for clear speech.
|
||||
Measured against the same 18.9s WAV, on the same machine, same sherpa-onnx:
|
||||
|
||||
sherpa-whisper-tiny -> "Alright, here we are. I hope that's all..."
|
||||
sherpa-zipformer-en-20m -> "ANTS BOTH IN WHAT DISGUISED THIS THAT..."
|
||||
parakeet-tdt-v3 (int8) -> ''
|
||||
parakeet-tdt-v3 (int8) -> '' <-- the curated default
|
||||
parakeet-tdt-v3 (fp32) -> ''
|
||||
parakeet-tdt-v2 (int8) -> ''
|
||||
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
from fastapi import UploadFile
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_preview_ffmpeg_does_not_block_event_loop(monkeypatch, tmp_path):
|
||||
from api.routers import dub_core
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
started = asyncio.Event()
|
||||
release = threading.Event()
|
||||
|
||||
def slow_ffmpeg(*_args, **_kwargs):
|
||||
loop.call_soon_threadsafe(started.set)
|
||||
assert release.wait(timeout=2)
|
||||
|
||||
monkeypatch.setattr(dub_core, "PREVIEW_DIR", str(tmp_path))
|
||||
monkeypatch.setattr(dub_core, "find_ffmpeg", lambda: "ffmpeg")
|
||||
monkeypatch.setattr(dub_core.subprocess, "run", slow_ffmpeg)
|
||||
upload = UploadFile(filename="preview.mp4", file=io.BytesIO(b"video"))
|
||||
|
||||
before = loop.time()
|
||||
task = asyncio.create_task(dub_core.preview_upload(upload))
|
||||
try:
|
||||
await asyncio.wait_for(started.wait(), timeout=1)
|
||||
assert loop.time() - before < 0.5
|
||||
finally:
|
||||
release.set()
|
||||
|
||||
result = await task
|
||||
assert result["audioUrl"].endswith(".wav")
|
||||
@@ -1,250 +0,0 @@
|
||||
"""Gallery-import profile materialization contracts."""
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import sqlite3
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from api.routers import gallery
|
||||
from core.db import db_conn, init_db
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def client():
|
||||
init_db()
|
||||
gallery._init_gallery_db()
|
||||
app = FastAPI()
|
||||
app.include_router(gallery.router)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _gallery_voice(
|
||||
suffix: str = ".wav", content: bytes = b"RIFF imported voice",
|
||||
) -> tuple[str, Path]:
|
||||
voice_id = f"g{uuid.uuid4().hex[:7]}"
|
||||
path = gallery.VOICE_GALLERY_DIR / f"{voice_id}{suffix}"
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(content)
|
||||
with db_conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO voice_gallery
|
||||
(id, name, character, category, source_type, source_url, audio_path,
|
||||
duration, description, tags, created_at)
|
||||
VALUES (?, ?, ?, 'import', 'youtube', ?, ?, 5.0, ?, '[]', ?)""",
|
||||
(
|
||||
voice_id, "Imported narrator", "Video title is not an instruct",
|
||||
"https://example.invalid/source", str(path),
|
||||
"Source URL/notes are not a spoken transcript", time.time(),
|
||||
),
|
||||
)
|
||||
return voice_id, path
|
||||
|
||||
|
||||
def test_save_as_profile_keeps_import_metadata_out_of_tts_fields(client):
|
||||
voice_id, _ = _gallery_voice()
|
||||
response = client.post(
|
||||
f"/gallery/voices/{voice_id}/save-as-profile",
|
||||
params={"profile_name": "Reusable import"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
with db_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id=?", (response.json()["profile_id"],),
|
||||
).fetchone()
|
||||
assert row["kind"] == "clone"
|
||||
assert row["personality"] == f"gallery:{voice_id}"
|
||||
assert row["ref_text"] == ""
|
||||
assert row["instruct"] == ""
|
||||
assert row["description"] == "Source URL/notes are not a spoken transcript"
|
||||
|
||||
|
||||
def test_to_profile_uses_live_schema_and_clone_metadata(client):
|
||||
voice_id, _ = _gallery_voice()
|
||||
response = client.post(f"/gallery/voices/{voice_id}/to-profile")
|
||||
assert response.status_code == 200
|
||||
with db_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id=?", (response.json()["profile_id"],),
|
||||
).fetchone()
|
||||
assert row["kind"] == "clone"
|
||||
assert row["personality"] == f"gallery:{voice_id}"
|
||||
assert row["ref_text"] == row["instruct"] == ""
|
||||
assert row["description"] == "Source URL/notes are not a spoken transcript"
|
||||
|
||||
|
||||
def test_both_import_routes_share_one_idempotent_profile(client, monkeypatch):
|
||||
emitted = []
|
||||
monkeypatch.setattr(
|
||||
gallery.event_bus, "emit", lambda topic, payload: emitted.append((topic, payload)),
|
||||
)
|
||||
voice_id, _ = _gallery_voice()
|
||||
first = client.post(
|
||||
f"/gallery/voices/{voice_id}/save-as-profile",
|
||||
params={"profile_name": "One reusable profile"},
|
||||
)
|
||||
repeated = client.post(
|
||||
f"/gallery/voices/{voice_id}/save-as-profile",
|
||||
params={"profile_name": "Ignored duplicate name"},
|
||||
)
|
||||
alternate = client.post(f"/gallery/voices/{voice_id}/to-profile")
|
||||
|
||||
assert first.status_code == repeated.status_code == alternate.status_code == 200
|
||||
assert {
|
||||
first.json()["profile_id"],
|
||||
repeated.json()["profile_id"],
|
||||
alternate.json()["profile_id"],
|
||||
} == {first.json()["profile_id"]}
|
||||
with db_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE personality=?",
|
||||
(f"gallery:{voice_id}",),
|
||||
).fetchall()
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["name"] == "One reusable profile"
|
||||
assert rows[0]["kind"] == "clone" and rows[0]["vd_states"] is None
|
||||
assert emitted[-1] == (
|
||||
"profiles", {"action": "updated", "id": first.json()["profile_id"]},
|
||||
)
|
||||
|
||||
|
||||
def test_gallery_profile_repairs_a_missing_copy_without_duplication(client):
|
||||
voice_id, source = _gallery_voice()
|
||||
first = client.post(f"/gallery/voices/{voice_id}/to-profile")
|
||||
assert first.status_code == 200
|
||||
with db_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id=?", (first.json()["profile_id"],),
|
||||
).fetchone()
|
||||
copied = Path(gallery.VOICES_DIR) / row["ref_audio_path"]
|
||||
copied.unlink()
|
||||
|
||||
repaired = client.post(f"/gallery/voices/{voice_id}/to-profile")
|
||||
|
||||
assert repaired.status_code == 200
|
||||
assert repaired.json()["profile_id"] == first.json()["profile_id"]
|
||||
assert copied.read_bytes() == source.read_bytes()
|
||||
|
||||
|
||||
def test_gallery_profile_does_not_rewrite_a_namespaced_import_collision(client):
|
||||
voice_id, source = _gallery_voice()
|
||||
collision_id = f"c{uuid.uuid4().hex[:7]}"
|
||||
personality = f"gallery:{voice_id}"
|
||||
collision_name = gallery._gallery_profile_audio_filename(collision_id, source)
|
||||
collision_audio = Path(gallery.VOICES_DIR) / collision_name
|
||||
collision_audio.parent.mkdir(parents=True, exist_ok=True)
|
||||
collision_audio.write_bytes(b"user-owned audio")
|
||||
with db_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO voice_profiles "
|
||||
"(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, "
|
||||
"description, kind, vd_states, is_locked, verified_own_voice, created_at) "
|
||||
"VALUES (?, 'User profile', ?, '', '', 'Auto', NULL, ?, "
|
||||
"'user-owned metadata', 'clone', NULL, 0, 0, ?)",
|
||||
(collision_id, collision_name, personality, time.time()),
|
||||
)
|
||||
|
||||
response = client.post(f"/gallery/voices/{voice_id}/to-profile")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["profile_id"] != collision_id
|
||||
with db_conn() as conn:
|
||||
collision = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id=?", (collision_id,),
|
||||
).fetchone()
|
||||
created = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id=?", (response.json()["profile_id"],),
|
||||
).fetchone()
|
||||
assert collision["description"] == "user-owned metadata"
|
||||
assert collision_audio.read_bytes() == b"user-owned audio"
|
||||
assert created["personality"] == personality
|
||||
|
||||
|
||||
def _part_files() -> set[Path]:
|
||||
return set(Path(gallery.VOICES_DIR).glob("*.part")) | set(
|
||||
Path(gallery.VOICES_DIR).glob(".*.part")
|
||||
)
|
||||
|
||||
|
||||
def test_audio_copy_never_holds_the_db_write_lock(client, monkeypatch):
|
||||
"""The bulk file copy must happen BEFORE the BEGIN IMMEDIATE transaction.
|
||||
|
||||
While the copy runs, another backend writer takes (and releases) SQLite's
|
||||
write lock. If materialization copied inside its own write transaction,
|
||||
this concurrent writer would hit `database is locked` and the test fails.
|
||||
"""
|
||||
from core.config import DB_PATH
|
||||
|
||||
voice_id, _ = _gallery_voice()
|
||||
real_copy2 = shutil.copy2
|
||||
concurrent_writes = []
|
||||
|
||||
def copy_and_probe(src, dst, **kwargs):
|
||||
probe = sqlite3.connect(DB_PATH, timeout=0.5)
|
||||
try:
|
||||
probe.execute("BEGIN IMMEDIATE")
|
||||
probe.execute(
|
||||
"UPDATE voice_gallery SET category = category WHERE id = ?",
|
||||
(voice_id,),
|
||||
)
|
||||
probe.commit()
|
||||
concurrent_writes.append(True)
|
||||
finally:
|
||||
probe.close()
|
||||
return real_copy2(src, dst, **kwargs)
|
||||
|
||||
monkeypatch.setattr(gallery.shutil, "copy2", copy_and_probe)
|
||||
|
||||
response = client.post(f"/gallery/voices/{voice_id}/to-profile")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert concurrent_writes == [True]
|
||||
assert _part_files() == set()
|
||||
|
||||
|
||||
def test_failed_copy_leaves_no_temp_droppings_or_profile_row(client, monkeypatch):
|
||||
"""A copy that dies mid-write must not leave .part files or a DB row."""
|
||||
voice_id, _ = _gallery_voice()
|
||||
|
||||
def exploding_copy(src, dst, **kwargs):
|
||||
Path(dst).write_bytes(b"partial bytes")
|
||||
raise OSError("disk full mid-copy")
|
||||
|
||||
monkeypatch.setattr(gallery.shutil, "copy2", exploding_copy)
|
||||
|
||||
with pytest.raises(OSError, match="disk full mid-copy"):
|
||||
client.post(f"/gallery/voices/{voice_id}/to-profile")
|
||||
|
||||
assert _part_files() == set()
|
||||
with db_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE personality = ?",
|
||||
(f"gallery:{voice_id}",),
|
||||
).fetchall()
|
||||
assert rows == []
|
||||
|
||||
|
||||
def test_gallery_preview_serves_outputs_file_without_root_relative_redirect(client):
|
||||
voice_id, source = _gallery_voice()
|
||||
|
||||
response = client.get(
|
||||
f"/gallery/voices/{voice_id}/preview", follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "location" not in response.headers
|
||||
assert response.content == source.read_bytes()
|
||||
|
||||
|
||||
def test_gallery_preview_preserves_non_wav_content_type(client):
|
||||
voice_id, _ = _gallery_voice(".mp3", b"ID3 imported voice")
|
||||
|
||||
response = client.get(f"/gallery/voices/{voice_id}/preview")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "audio/mpeg"
|
||||
@@ -1,71 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_abandoned_reader_keeps_adhoc_reference_until_worker_finishes(tmp_path):
|
||||
from api.routers.generation import (
|
||||
_TempReferenceLease,
|
||||
_run_with_reference_lease,
|
||||
)
|
||||
from services.model_manager import run_on_gpu_pool_guarded
|
||||
|
||||
reference = tmp_path / "reference.wav"
|
||||
reference.write_bytes(b"voice")
|
||||
lease = _TempReferenceLease(str(reference))
|
||||
started = threading.Event()
|
||||
release_worker = threading.Event()
|
||||
worker_read = threading.Event()
|
||||
|
||||
def read_reference():
|
||||
started.set()
|
||||
assert release_worker.wait(timeout=2)
|
||||
assert reference.read_bytes() == b"voice"
|
||||
worker_read.set()
|
||||
|
||||
with ThreadPoolExecutor(max_workers=1) as executor:
|
||||
task = asyncio.create_task(
|
||||
_run_with_reference_lease(
|
||||
lease,
|
||||
lambda on_abandon: run_on_gpu_pool_guarded(
|
||||
read_reference,
|
||||
executor=executor,
|
||||
timeout=1,
|
||||
on_abandon=on_abandon,
|
||||
),
|
||||
)
|
||||
)
|
||||
assert await asyncio.to_thread(started.wait, 1)
|
||||
task.cancel()
|
||||
cancelled = await asyncio.gather(task, return_exceptions=True)
|
||||
assert isinstance(cancelled[0], asyncio.CancelledError)
|
||||
|
||||
lease.finish_request()
|
||||
assert reference.exists()
|
||||
release_worker.set()
|
||||
assert await asyncio.to_thread(worker_read.wait, 1)
|
||||
|
||||
for _ in range(100):
|
||||
if not reference.exists():
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
assert not reference.exists()
|
||||
|
||||
|
||||
def test_normal_request_deletes_adhoc_reference_immediately(tmp_path):
|
||||
from api.routers.generation import _TempReferenceLease
|
||||
|
||||
reference = tmp_path / "reference.wav"
|
||||
reference.write_bytes(b"voice")
|
||||
lease = _TempReferenceLease(str(reference))
|
||||
|
||||
release = lease.acquire()
|
||||
release()
|
||||
lease.finish_request()
|
||||
|
||||
assert not reference.exists()
|
||||
@@ -1,70 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_abandon_callback_waits_for_running_worker_to_finish():
|
||||
from services.model_manager import run_on_gpu_pool_guarded
|
||||
|
||||
started = threading.Event()
|
||||
release = threading.Event()
|
||||
cleaned = threading.Event()
|
||||
|
||||
def job():
|
||||
started.set()
|
||||
assert release.wait(timeout=2)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=1) as executor:
|
||||
task = asyncio.create_task(
|
||||
run_on_gpu_pool_guarded(
|
||||
job,
|
||||
executor=executor,
|
||||
timeout=1,
|
||||
on_abandon=cleaned.set,
|
||||
)
|
||||
)
|
||||
assert await asyncio.to_thread(started.wait, 1)
|
||||
task.cancel()
|
||||
cancelled = await asyncio.gather(task, return_exceptions=True)
|
||||
assert isinstance(cancelled[0], asyncio.CancelledError)
|
||||
|
||||
assert not cleaned.is_set()
|
||||
release.set()
|
||||
assert await asyncio.to_thread(cleaned.wait, 1)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_queued_cancellation_releases_without_running_job():
|
||||
from services.model_manager import GpuPoolBusyError, run_on_gpu_pool_guarded
|
||||
|
||||
hog_started = threading.Event()
|
||||
release_hog = threading.Event()
|
||||
cleaned = threading.Event()
|
||||
queued_job_ran = threading.Event()
|
||||
|
||||
def hog():
|
||||
hog_started.set()
|
||||
assert release_hog.wait(timeout=2)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=1) as executor:
|
||||
hog_future = executor.submit(hog)
|
||||
assert hog_started.wait(timeout=1)
|
||||
try:
|
||||
with pytest.raises(GpuPoolBusyError):
|
||||
await run_on_gpu_pool_guarded(
|
||||
queued_job_ran.set,
|
||||
executor=executor,
|
||||
timeout=1,
|
||||
queue_timeout=0.05,
|
||||
on_abandon=cleaned.set,
|
||||
)
|
||||
assert cleaned.is_set()
|
||||
assert not queued_job_ran.is_set()
|
||||
finally:
|
||||
release_hog.set()
|
||||
hog_future.result(timeout=1)
|
||||
@@ -17,31 +17,18 @@ import json
|
||||
import math
|
||||
import array
|
||||
import base64
|
||||
import io
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from services.subprocess_backend import (
|
||||
RECV_TIMEOUT_S,
|
||||
SubprocessBackend,
|
||||
)
|
||||
from services.tts_backend import OmniVoiceBackend, get_backend_class, list_backends
|
||||
from engines.omnivoice_subprocess import (
|
||||
OmniVoiceMPSSubprocessBackend,
|
||||
OmniVoiceSubprocessBackend,
|
||||
)
|
||||
from services.subprocess_backend import SubprocessBackend, RECV_TIMEOUT_S
|
||||
from services.tts_backend import get_backend_class
|
||||
from engines.omnivoice_subprocess import OmniVoiceSubprocessBackend
|
||||
|
||||
|
||||
# ── stub sidecar (model-free) ──────────────────────────────────────────────
|
||||
|
||||
STUB_SIDECAR = r'''
|
||||
import sys, os, json, struct, time, math, array, base64, subprocess
|
||||
import sys, json, struct, time, math, array, base64
|
||||
|
||||
def _send(o):
|
||||
b = json.dumps(o, separators=(",", ":")).encode()
|
||||
@@ -73,20 +60,9 @@ while True:
|
||||
sys.exit(0)
|
||||
elif op == "synthesize":
|
||||
t = m.get("text", "")
|
||||
if t == "CRASH":
|
||||
os._exit(137)
|
||||
if t == "HANG":
|
||||
while True: # wedge forever; the parent must hard-kill us
|
||||
time.sleep(1)
|
||||
if t == "HANG_CHILD":
|
||||
subprocess.Popen([
|
||||
sys.executable,
|
||||
"-c",
|
||||
"import os,time; time.sleep(1); "
|
||||
"open(os.environ['OMNIVOICE_TIMEOUT_MARKER'], 'w').write('bad')",
|
||||
])
|
||||
while True:
|
||||
time.sleep(1)
|
||||
# Emit progress frames before the audio when asked, to exercise the
|
||||
# parent's progress-consuming recv loop (the cold-load fix).
|
||||
if t.startswith("PROG:"):
|
||||
@@ -122,80 +98,6 @@ def test_registry_resolves_to_subprocess_backend():
|
||||
assert get_backend_class("omnivoice-subprocess") is OmniVoiceSubprocessBackend
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("family", "expected_name"),
|
||||
[("mps", "OmniVoiceMPSSubprocessBackend"), ("cuda", "OmniVoiceBackend"),
|
||||
("cpu", "OmniVoiceBackend")],
|
||||
)
|
||||
def test_omnivoice_is_crash_isolated_only_on_mps(monkeypatch, family, expected_name):
|
||||
from core.device_caps import HostCaps
|
||||
|
||||
available = (family, "cpu") if family != "cpu" else ("cpu",)
|
||||
monkeypatch.setattr(
|
||||
"core.device_caps.detect_host_caps",
|
||||
lambda: HostCaps(family=family, available_families=available),
|
||||
)
|
||||
|
||||
resolved = get_backend_class("omnivoice")
|
||||
assert resolved.__name__ == expected_name
|
||||
if family != "mps":
|
||||
assert resolved is OmniVoiceBackend
|
||||
|
||||
|
||||
def test_engine_catalogue_reports_effective_mps_isolation(monkeypatch):
|
||||
from core.device_caps import HostCaps
|
||||
from services import tts_backend
|
||||
|
||||
monkeypatch.setattr(tts_backend, "_REGISTRY", {"omnivoice": OmniVoiceBackend})
|
||||
monkeypatch.setattr(
|
||||
"core.device_caps.detect_host_caps",
|
||||
lambda: HostCaps(family="mps", available_families=("mps", "cpu")),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"engines.omnivoice_subprocess.OmniVoiceSubprocessBackend.is_available",
|
||||
classmethod(lambda cls: (True, "ready")),
|
||||
)
|
||||
|
||||
row = next(item for item in list_backends() if item["id"] == "omnivoice")
|
||||
assert row["isolation_mode"] == "subprocess"
|
||||
|
||||
|
||||
def test_mps_startup_does_not_preload_native_model(monkeypatch):
|
||||
from core.device_caps import HostCaps
|
||||
from services import model_manager
|
||||
|
||||
monkeypatch.setattr(
|
||||
"core.device_caps.detect_host_caps",
|
||||
lambda: HostCaps(family="mps", available_families=("mps", "cpu")),
|
||||
)
|
||||
monkeypatch.setenv("OMNIVOICE_TTS_BACKEND", "omnivoice")
|
||||
monkeypatch.setattr(model_manager, "model", None)
|
||||
|
||||
async def fail_load():
|
||||
raise AssertionError("native OmniVoice must not load in the API process on MPS")
|
||||
|
||||
monkeypatch.setattr(model_manager, "_load_model_with_timeout", fail_load)
|
||||
asyncio.run(model_manager.preload_model())
|
||||
|
||||
|
||||
def test_streaming_mps_path_does_not_load_native_model(monkeypatch):
|
||||
from api.routers.tts_stream import _resolve_stream_backend
|
||||
from services import model_manager, tts_backend
|
||||
|
||||
sentinel = object()
|
||||
monkeypatch.setattr(tts_backend, "active_backend_id", lambda: "omnivoice")
|
||||
monkeypatch.setattr(
|
||||
tts_backend, "get_backend_class", lambda _id: OmniVoiceMPSSubprocessBackend,
|
||||
)
|
||||
monkeypatch.setattr(tts_backend, "get_active_tts_backend", lambda: sentinel)
|
||||
|
||||
async def fail_load():
|
||||
raise AssertionError("streaming must not load native OmniVoice on MPS")
|
||||
|
||||
monkeypatch.setattr(model_manager, "get_model", fail_load)
|
||||
assert asyncio.run(_resolve_stream_backend(None)) is sentinel
|
||||
|
||||
|
||||
def test_is_marked_subprocess_isolated():
|
||||
# list_backends() detects isolation via this duck-typed marker, not issubclass.
|
||||
assert getattr(OmniVoiceSubprocessBackend, "_is_subprocess_isolated", False) is True
|
||||
@@ -234,40 +136,6 @@ def test_base_default_recv_timeout_is_60s():
|
||||
assert _PlainBackend().recv_timeout_s == 60.0
|
||||
|
||||
|
||||
def test_sidecar_spawn_delegates_all_containment_to_nested_owner(monkeypatch, tmp_path):
|
||||
from services import subprocess_backend as backend_module
|
||||
|
||||
captured = {}
|
||||
|
||||
class StubProcess:
|
||||
stderr = io.BytesIO()
|
||||
|
||||
@staticmethod
|
||||
def poll():
|
||||
return None
|
||||
|
||||
def fake_spawn(argv, **kwargs):
|
||||
captured.update(kwargs)
|
||||
return StubProcess()
|
||||
|
||||
monkeypatch.setattr(_PlainBackend, "venv_python", classmethod(lambda cls: Path(sys.executable)))
|
||||
monkeypatch.setattr(
|
||||
_PlainBackend,
|
||||
"sidecar_script",
|
||||
classmethod(lambda cls: tmp_path / "stub.py"),
|
||||
)
|
||||
monkeypatch.setattr(backend_module, "spawn_owned", fake_spawn)
|
||||
monkeypatch.setattr(backend_module, "_ensure_reaper_running", lambda: None)
|
||||
backend = _PlainBackend()
|
||||
monkeypatch.setattr(backend, "_recv_with_timeout", lambda _timeout: {"op": "ready"})
|
||||
|
||||
try:
|
||||
backend._spawn()
|
||||
assert not ({"start_new_session", "creationflags", "preexec_fn"} & captured.keys())
|
||||
finally:
|
||||
backend._proc = None
|
||||
|
||||
|
||||
def test_omnivoice_subprocess_recv_timeout_overrides_default():
|
||||
b = OmniVoiceSubprocessBackend()
|
||||
assert b.recv_timeout_s == 300.0 # aligns with the generate budget
|
||||
@@ -337,48 +205,6 @@ def test_wedged_sidecar_is_hard_killed_and_recovers(stub_sidecar, monkeypatch):
|
||||
b.shutdown()
|
||||
|
||||
|
||||
def test_mps_proxy_survives_fatal_child_exit_and_recovers(stub_sidecar, monkeypatch):
|
||||
_use_stub(monkeypatch, stub_sidecar)
|
||||
monkeypatch.setattr(
|
||||
"services.model_manager.make_room_before_generate", lambda: None,
|
||||
)
|
||||
b = OmniVoiceMPSSubprocessBackend()
|
||||
try:
|
||||
with pytest.raises(RuntimeError, match="backend is still running"):
|
||||
b.generate("CRASH")
|
||||
assert b._proc is not None and b._proc.poll() is not None
|
||||
assert b.generate("ok").shape[1] == 24000
|
||||
finally:
|
||||
b.shutdown()
|
||||
|
||||
|
||||
def test_desktop_timeout_kills_engine_subtree_before_late_mutation(
|
||||
stub_sidecar, monkeypatch, tmp_path
|
||||
):
|
||||
marker = tmp_path / "late-engine-mutation"
|
||||
monkeypatch.setenv("OMNIVOICE_DESKTOP_CONTAINED", "1")
|
||||
drain_read, drain_write = os.pipe()
|
||||
monkeypatch.setenv("OMNIVOICE_DESKTOP_DRAIN_FD", str(drain_write))
|
||||
monkeypatch.setenv("OMNIVOICE_TIMEOUT_MARKER", str(marker))
|
||||
_use_stub(monkeypatch, stub_sidecar)
|
||||
monkeypatch.setattr(
|
||||
OmniVoiceSubprocessBackend,
|
||||
"recv_timeout_s",
|
||||
property(lambda self: 0.3),
|
||||
)
|
||||
b = OmniVoiceSubprocessBackend()
|
||||
try:
|
||||
with pytest.raises(RuntimeError):
|
||||
b.generate("HANG_CHILD")
|
||||
time.sleep(1.2)
|
||||
assert not marker.exists()
|
||||
assert b.generate("ok").shape[1] == 24000
|
||||
finally:
|
||||
b.shutdown()
|
||||
os.close(drain_write)
|
||||
os.close(drain_read)
|
||||
|
||||
|
||||
def test_generate_does_not_deadlock_when_called_on_gpu_pool_worker(stub_sidecar, monkeypatch):
|
||||
# Regression: /v1/audio/speech and /generate dispatch backend.generate() via
|
||||
# run_on_gpu_pool_guarded, i.e. ON a gpu-pool worker. generate() must NOT
|
||||
@@ -396,94 +222,3 @@ def test_generate_does_not_deadlock_when_called_on_gpu_pool_worker(stub_sidecar,
|
||||
assert tensor.shape[1] == 24000
|
||||
finally:
|
||||
b.shutdown()
|
||||
|
||||
|
||||
def test_sidecar_forwards_native_controls_and_applies_seed(monkeypatch):
|
||||
import torch
|
||||
from engines.omnivoice_subprocess import main as sidecar
|
||||
|
||||
calls = []
|
||||
seeds = []
|
||||
frames = []
|
||||
|
||||
class FakeModel:
|
||||
sampling_rate = 24000
|
||||
|
||||
def generate(self, **kwargs):
|
||||
calls.append(kwargs)
|
||||
return [torch.zeros(1, 16)]
|
||||
|
||||
monkeypatch.setattr(sidecar, "_load_model", lambda _stdout: FakeModel())
|
||||
monkeypatch.setattr(sidecar, "_send", lambda _stdout, frame: frames.append(frame))
|
||||
real_manual_seed = torch.manual_seed
|
||||
monkeypatch.setattr(
|
||||
torch, "manual_seed", lambda seed: (seeds.append(seed), real_manual_seed(seed))[1],
|
||||
)
|
||||
|
||||
sidecar._handle_synthesize({
|
||||
"text": "hello",
|
||||
"seed": 123,
|
||||
"t_shift": 0.4,
|
||||
"layer_penalty_factor": 0.2,
|
||||
"position_temperature": 0.7,
|
||||
"class_temperature": 0.8,
|
||||
"audio_chunk_duration": 10,
|
||||
"audio_chunk_threshold": 0.6,
|
||||
}, object())
|
||||
|
||||
assert seeds == [123]
|
||||
assert calls == [{
|
||||
"text": "hello",
|
||||
"ref_audio": None,
|
||||
"ref_text": None,
|
||||
"t_shift": 0.4,
|
||||
"layer_penalty_factor": 0.2,
|
||||
"position_temperature": 0.7,
|
||||
"class_temperature": 0.8,
|
||||
"audio_chunk_duration": 10,
|
||||
"audio_chunk_threshold": 0.6,
|
||||
}]
|
||||
assert frames[-1]["op"] == "audio"
|
||||
|
||||
|
||||
def test_generation_proxy_forwards_native_controls_and_seed():
|
||||
import torch
|
||||
from api.routers.generation import _run_backend_inference
|
||||
|
||||
calls = []
|
||||
|
||||
class Proxy:
|
||||
id = "omnivoice"
|
||||
display_name = "OmniVoice"
|
||||
sample_rate = 24000
|
||||
applies_own_mastering = True
|
||||
supports_native_omnivoice_controls = True
|
||||
|
||||
def generate(self, text, **kwargs):
|
||||
calls.append((text, kwargs))
|
||||
return torch.zeros(1, 240)
|
||||
|
||||
_run_backend_inference(
|
||||
Proxy(), "hello", "en", None, None, None, None,
|
||||
16, 2.0, 1.0, False, False, 321,
|
||||
t_shift=0.4, layer_penalty_factor=0.2,
|
||||
position_temperature=0.7, class_temperature=0.8,
|
||||
)
|
||||
|
||||
assert calls == [("hello", {
|
||||
"duration": None,
|
||||
"language": "en",
|
||||
"ref_audio": None,
|
||||
"ref_text": None,
|
||||
"instruct": None,
|
||||
"num_step": 16,
|
||||
"guidance_scale": 2.0,
|
||||
"speed": 1.0,
|
||||
"denoise": False,
|
||||
"postprocess_output": False,
|
||||
"t_shift": 0.4,
|
||||
"layer_penalty_factor": 0.2,
|
||||
"position_temperature": 0.7,
|
||||
"class_temperature": 0.8,
|
||||
"seed": 321,
|
||||
})]
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
"""#1618 — RAM preflight must not hard-block the machines it means to admit.
|
||||
|
||||
An "8 GB" machine reports ~7.8 GB usable (firmware/iGPU/kernel reservations),
|
||||
so comparing reported RAM against the marketing-size threshold blocked exactly
|
||||
the boundary hardware the ≥8 GB rule intends to allow. The check now applies
|
||||
``_RAM_RESERVED_ALLOWANCE`` to both thresholds, and
|
||||
``OMNIVOICE_RAM_PREFLIGHT=0`` downgrades a genuine fail to a warning.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from api.routers.setup import wizard
|
||||
|
||||
|
||||
def _ram_check(monkeypatch, ram_gb: float, env: str | None = None) -> dict:
|
||||
# Keep the preflight hermetic: stub the probes that hit the network or
|
||||
# auto-acquire media tools, so each RAM assertion stays fast and offline.
|
||||
monkeypatch.setattr(wizard, "_network_check", lambda: {
|
||||
"id": "network", "label": "Network", "status": "pass",
|
||||
"detail": "stubbed", "fix": None, "mirror_reachable": True,
|
||||
})
|
||||
import services.media_tools as media_tools
|
||||
monkeypatch.setattr(media_tools, "summary", lambda auto_acquire=True: None)
|
||||
monkeypatch.setattr(wizard, "_ram_gb", lambda: ram_gb)
|
||||
if env is None:
|
||||
monkeypatch.delenv("OMNIVOICE_RAM_PREFLIGHT", raising=False)
|
||||
else:
|
||||
monkeypatch.setenv("OMNIVOICE_RAM_PREFLIGHT", env)
|
||||
resp = wizard.preflight()
|
||||
checks = resp["checks"] if isinstance(resp, dict) else resp.checks
|
||||
for c in checks:
|
||||
c = c if isinstance(c, dict) else c.model_dump()
|
||||
if c["id"] == "ram":
|
||||
return c
|
||||
raise AssertionError("no ram check in preflight response")
|
||||
|
||||
|
||||
def test_8gb_installed_reporting_7_84_usable_is_not_blocked(monkeypatch):
|
||||
"""The #1618 report: 7.84 GB usable on an 8 GB laptop was a hard fail."""
|
||||
check = _ram_check(monkeypatch, 7.84)
|
||||
assert check["status"] != "fail"
|
||||
|
||||
|
||||
def test_boundary_at_allowance_passes_the_fail_gate(monkeypatch):
|
||||
check = _ram_check(
|
||||
monkeypatch, wizard._RAM_FAIL_GB * wizard._RAM_RESERVED_ALLOWANCE
|
||||
)
|
||||
assert check["status"] != "fail"
|
||||
|
||||
|
||||
def test_genuinely_low_ram_still_fails(monkeypatch):
|
||||
check = _ram_check(monkeypatch, 6.0)
|
||||
assert check["status"] == "fail"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("env", ["0", "false", "no"])
|
||||
def test_escape_hatch_downgrades_fail_to_warn(monkeypatch, env):
|
||||
check = _ram_check(monkeypatch, 6.0, env=env)
|
||||
assert check["status"] == "warn"
|
||||
assert "OMNIVOICE_RAM_PREFLIGHT" in (check["fix"] or "")
|
||||
|
||||
|
||||
def test_escape_hatch_not_triggered_by_other_values(monkeypatch):
|
||||
check = _ram_check(monkeypatch, 6.0, env="1")
|
||||
assert check["status"] == "fail"
|
||||
|
||||
|
||||
def test_12gb_installed_reporting_11_8_usable_passes_clean(monkeypatch):
|
||||
"""Same reservation gap at the warn threshold: 12 GB installed ≈ 11.8."""
|
||||
check = _ram_check(monkeypatch, 11.8)
|
||||
assert check["status"] == "pass"
|
||||
|
||||
|
||||
def test_warn_band_between_thresholds(monkeypatch):
|
||||
check = _ram_check(monkeypatch, 9.0)
|
||||
assert check["status"] == "warn"
|
||||
@@ -76,35 +76,6 @@ class TestUnloadOnABC:
|
||||
)
|
||||
|
||||
|
||||
def test_omnivoice_native_batch_preserves_per_item_controls():
|
||||
"""The adapter forwards variable-length batch controls to OmniVoice."""
|
||||
import torch
|
||||
|
||||
tts = _load_tts_backend_module()
|
||||
calls = []
|
||||
|
||||
class _Model:
|
||||
sampling_rate = 24000
|
||||
|
||||
def generate(self, **kwargs):
|
||||
calls.append(kwargs)
|
||||
return [torch.zeros(1, 12000), torch.zeros(1, 24000)]
|
||||
|
||||
backend = tts.OmniVoiceBackend(model=_Model())
|
||||
outputs = backend.generate_batch(
|
||||
["short", "long"],
|
||||
language=["en", "es"],
|
||||
duration=[0.5, 1.0],
|
||||
speed=[1.0, 0.8],
|
||||
)
|
||||
|
||||
assert [output.shape[-1] for output in outputs] == [12000, 24000]
|
||||
assert calls[0]["text"] == ["short", "long"]
|
||||
assert calls[0]["language"] == ["en", "es"]
|
||||
assert calls[0]["duration"] == [0.5, 1.0]
|
||||
assert calls[0]["speed"] == [1.0, 0.8]
|
||||
|
||||
|
||||
class TestUnloadDefaultBehavior:
|
||||
"""The default no-op must actually be safe to call."""
|
||||
|
||||
@@ -183,4 +154,4 @@ class TestExistingSubclassesInherit:
|
||||
assert callable(getattr(cls, "unload", None)), (
|
||||
f"{cls.__name__} has no callable unload() — even via the "
|
||||
"ABC inheritance. Did someone shadow it?"
|
||||
)
|
||||
)
|
||||
+87
-858
File diff suppressed because it is too large
Load Diff
@@ -1,61 +0,0 @@
|
||||
"""Cancellation helpers for work that cannot be stopped mid-call."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from typing import Any, TypeVar
|
||||
|
||||
_Result = TypeVar("_Result")
|
||||
|
||||
|
||||
async def drain_task(task: asyncio.Task[Any]) -> None:
|
||||
"""Wait for ``task`` even if the waiter is cancelled again."""
|
||||
while not task.done():
|
||||
try:
|
||||
await asyncio.shield(task)
|
||||
except asyncio.CancelledError:
|
||||
continue
|
||||
except BaseException:
|
||||
break
|
||||
if task.done():
|
||||
try:
|
||||
task.result()
|
||||
except BaseException:
|
||||
pass
|
||||
|
||||
|
||||
async def to_thread_and_drain_on_cancel(
|
||||
function: Callable[..., _Result], /, *args: Any
|
||||
) -> _Result:
|
||||
"""Run a blocking call without detaching it when its waiter is cancelled."""
|
||||
thread_task = asyncio.create_task(asyncio.to_thread(function, *args))
|
||||
try:
|
||||
return await asyncio.shield(thread_task)
|
||||
except asyncio.CancelledError:
|
||||
await drain_task(thread_task)
|
||||
raise
|
||||
|
||||
|
||||
async def to_thread_and_defer_cancellation(
|
||||
function: Callable[..., _Result], /, *args: Any
|
||||
) -> tuple[_Result, bool]:
|
||||
"""Finish a durable call and report cancellation after its result is known.
|
||||
|
||||
Authority writes need their event-loop publication even when the HTTP
|
||||
caller disappears while SQLite is committing. Returning the cancellation
|
||||
flag lets the caller publish that result first, then propagate cancellation.
|
||||
"""
|
||||
thread_task = asyncio.create_task(asyncio.to_thread(function, *args))
|
||||
try:
|
||||
return await asyncio.shield(thread_task), False
|
||||
except asyncio.CancelledError:
|
||||
await drain_task(thread_task)
|
||||
return thread_task.result(), True
|
||||
|
||||
|
||||
__all__ = [
|
||||
"drain_task",
|
||||
"to_thread_and_defer_cancellation",
|
||||
"to_thread_and_drain_on_cancel",
|
||||
]
|
||||
@@ -59,18 +59,10 @@ _VRAM_PER_JOB_BYTES = 5 * 1024**3
|
||||
# cpu — oversubscription just thrashes
|
||||
_ALWAYS_SERIAL = frozenset({"mps", "mlx", "cpu", ""})
|
||||
|
||||
# Absolute protocol ceiling regardless of how much memory a peer reports.
|
||||
# Beyond this the bottleneck stops being VRAM and starts being scheduler
|
||||
# overhead and host-side I/O contention. It is public because every wire
|
||||
# boundary must clamp to the same number; a UINT32_MAX heartbeat must not grow
|
||||
# a scheduler queue that local derivation would never create.
|
||||
MAX_CONCURRENT_TASKS = 4
|
||||
|
||||
|
||||
def clamp_concurrency(value: int, *, allow_zero: bool = False) -> int:
|
||||
"""Bound an advertised concurrency value to the server's safe range."""
|
||||
minimum = 0 if allow_zero else 1
|
||||
return max(minimum, min(MAX_CONCURRENT_TASKS, int(value)))
|
||||
# Absolute ceiling regardless of how much memory a card reports. Beyond this
|
||||
# the bottleneck stops being VRAM and starts being scheduler overhead and
|
||||
# host-side I/O contention.
|
||||
_MAX_DERIVED = 4
|
||||
|
||||
# Bounds on how long a parked slot is held. The caller passes the timed-out
|
||||
# job's own execution budget — the longest its thread can still legitimately be
|
||||
@@ -112,7 +104,7 @@ def derive_concurrency(
|
||||
budget = max(min_model_bytes, _VRAM_PER_JOB_BYTES)
|
||||
if budget <= 0:
|
||||
return 1
|
||||
return clamp_concurrency(int(free_memory_bytes // budget))
|
||||
return max(1, min(_MAX_DERIVED, int(free_memory_bytes // budget)))
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -157,9 +149,6 @@ class WorkerCapacity:
|
||||
resident_models: set[str] = field(default_factory=set)
|
||||
slots: dict[str, ModelSlot] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.max_concurrent_tasks = clamp_concurrency(self.max_concurrent_tasks)
|
||||
|
||||
@staticmethod
|
||||
def slot_key(engine: str, model_id: str) -> str:
|
||||
return f"{engine}:{model_id}"
|
||||
@@ -209,21 +198,6 @@ class WorkerCapacity:
|
||||
)
|
||||
slot.active += 1
|
||||
|
||||
def reserve_unknown(self) -> None:
|
||||
"""Consume worker-wide capacity for claimed work we cannot classify.
|
||||
|
||||
Reconciliation will tell the peer to cancel a terminal or unknown
|
||||
attempt, but until that cancellation lands it is still using the GPU.
|
||||
"""
|
||||
self.active_tasks += 1
|
||||
|
||||
def release_unknown(self) -> bool:
|
||||
"""Release one exact reconciled claim with no model-slot identity."""
|
||||
if self.active_tasks <= 0:
|
||||
return False
|
||||
self.active_tasks -= 1
|
||||
return True
|
||||
|
||||
def release(
|
||||
self,
|
||||
engine: str,
|
||||
@@ -293,12 +267,8 @@ class WorkerCapacity:
|
||||
) -> None:
|
||||
"""Adopt a heartbeat snapshot. The worker is the source of truth for
|
||||
what it is actually running."""
|
||||
self.active_tasks = clamp_concurrency(active_tasks, allow_zero=True)
|
||||
bounded_available = clamp_concurrency(available_slots, allow_zero=True)
|
||||
bounded_available = min(
|
||||
bounded_available, MAX_CONCURRENT_TASKS - self.active_tasks
|
||||
)
|
||||
reported_ceiling = self.active_tasks + bounded_available
|
||||
self.active_tasks = max(0, active_tasks)
|
||||
reported_ceiling = self.active_tasks + max(0, available_slots)
|
||||
if reported_ceiling > 0:
|
||||
# Adopted, not merely grown. The worker computes this as its own
|
||||
# ``max_concurrent_tasks``, so a ceiling we refuse to lower is one
|
||||
@@ -335,10 +305,4 @@ class WorkerCapacity:
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MAX_CONCURRENT_TASKS",
|
||||
"ModelSlot",
|
||||
"WorkerCapacity",
|
||||
"clamp_concurrency",
|
||||
"derive_concurrency",
|
||||
]
|
||||
__all__ = ["ModelSlot", "WorkerCapacity", "derive_concurrency"]
|
||||
|
||||
@@ -35,9 +35,6 @@ from typing import Optional
|
||||
# plane may run in a process that never loads torch). test_worker_deadlines.py
|
||||
# asserts the two agree, so a change there cannot silently drift from here.
|
||||
_GENERATE_TIMEOUT_S = float(os.environ.get("OMNIVOICE_GENERATE_TIMEOUT_S", "300.0"))
|
||||
_CPU_GENERATE_TIMEOUT_S = float(
|
||||
os.environ.get("OMNIVOICE_CPU_GENERATE_TIMEOUT_S", "600.0")
|
||||
)
|
||||
_MODEL_LOAD_EXTRA_S = float(os.environ.get("OMNIVOICE_MODEL_LOAD_TIMEOUT_S", "1800.0"))
|
||||
_HEARTBEAT_GRACE_S = float(os.environ.get("OMNIVOICE_MODEL_LOAD_HEARTBEAT_GRACE_S", "30.0"))
|
||||
|
||||
@@ -126,40 +123,20 @@ class Deadlines:
|
||||
}
|
||||
|
||||
|
||||
def _base_execution_seconds(
|
||||
text: Optional[str], *, execution_device: Optional[str] = None
|
||||
) -> float:
|
||||
def _base_execution_seconds(text: Optional[str]) -> float:
|
||||
"""Delegate to model_manager's budget; fall back to its formula.
|
||||
|
||||
The lazy import keeps this module usable in a process that has no torch —
|
||||
the control plane schedules work it never executes.
|
||||
"""
|
||||
target_device = str(execution_device or "cpu").lower()
|
||||
if target_device not in {"cpu", "cuda", "mps", "mlx", "directml", "rocm", "xpu"}:
|
||||
target_device = "cpu"
|
||||
try:
|
||||
from services import model_manager # noqa: PLC0415 — intentionally lazy
|
||||
|
||||
return float(
|
||||
model_manager.generate_timeout_s(
|
||||
text, execution_device=target_device
|
||||
)
|
||||
)
|
||||
return float(model_manager.generate_timeout_s(text))
|
||||
except Exception:
|
||||
base = _GENERATE_TIMEOUT_S
|
||||
try:
|
||||
if (
|
||||
target_device == "cpu"
|
||||
and "OMNIVOICE_GENERATE_TIMEOUT_S" not in os.environ
|
||||
):
|
||||
base = _CPU_GENERATE_TIMEOUT_S
|
||||
except Exception:
|
||||
# Capability detection is optional in the torch-free control
|
||||
# plane; retain the configured universal bounded fallback.
|
||||
pass
|
||||
return max(
|
||||
base,
|
||||
base + max(0, len(text or "") - _FREE_CHARS) / _CHARS_PER_SECOND,
|
||||
_GENERATE_TIMEOUT_S,
|
||||
_GENERATE_TIMEOUT_S + max(0, len(text or "") - _FREE_CHARS) / _CHARS_PER_SECOND,
|
||||
)
|
||||
|
||||
|
||||
@@ -170,7 +147,6 @@ def for_task(
|
||||
model_resident: bool = False,
|
||||
model_downloaded: bool = True,
|
||||
input_seconds: float = 0.0,
|
||||
execution_device: Optional[str] = None,
|
||||
) -> Deadlines:
|
||||
"""Compute the deadlines for one attempt.
|
||||
|
||||
@@ -182,9 +158,7 @@ def for_task(
|
||||
op = Operation.coerce(operation)
|
||||
multiplier, grace = _PROFILE[op]
|
||||
|
||||
execution = _base_execution_seconds(
|
||||
text, execution_device=execution_device
|
||||
) * multiplier
|
||||
execution = _base_execution_seconds(text) * multiplier
|
||||
# Media-length operations scale on duration, not characters.
|
||||
if input_seconds > 0:
|
||||
execution = max(execution, input_seconds * multiplier)
|
||||
|
||||
+105
-559
@@ -17,19 +17,15 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import errno
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
import io
|
||||
import zipfile
|
||||
import uuid
|
||||
from typing import Any, Awaitable, Callable, Optional
|
||||
|
||||
from worker.async_utils import drain_task
|
||||
from worker.errors import ErrorClass, WorkerError
|
||||
|
||||
logger = logging.getLogger("omnivoice.worker")
|
||||
@@ -48,18 +44,6 @@ INPUT_ERRORS_PARAM = "input_errors"
|
||||
# leak on the worker that unpurged artifacts were on the control plane.
|
||||
INPUT_CACHE_LIMIT_BYTES = 2 * 1024 * 1024 * 1024
|
||||
_FALLBACK_INPUT_FETCH_SECONDS = 600.0
|
||||
_STALE_INPUT_PARTIAL_SECONDS = 60 * 60.0
|
||||
|
||||
# Pruning runs in worker threads and every executor instance shares the same
|
||||
# on-disk cache, so active-path leases are process-wide and thread-safe.
|
||||
_INPUT_CACHE_LEASE_LOCK = threading.Lock()
|
||||
_INPUT_CACHE_LEASES: dict[str, int] = {}
|
||||
_INPUT_CACHE_FETCH_LEASES: dict[str, int] = {}
|
||||
_INPUT_CACHE_MUTATIONS: set[str] = set()
|
||||
# Concurrent fetches keep distinct partial files but serialize the instant a
|
||||
# verified generation is published at its content address.
|
||||
_INPUT_CACHE_FETCH_LOCKS: dict[str, asyncio.Lock] = {}
|
||||
_INPUT_CACHE_FETCH_USERS: dict[str, int] = {}
|
||||
|
||||
# on_progress(fraction: float, stage: str)
|
||||
# on_model_loading(fraction: float, detail: str)
|
||||
@@ -104,7 +88,6 @@ class TaskExecutor:
|
||||
self._on_model_loading = on_model_loading
|
||||
self._fetch_input = fetch_input
|
||||
self._input_dir = input_dir
|
||||
self._blocking_tasks: set[asyncio.Task] = set()
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
@@ -127,35 +110,33 @@ class TaskExecutor:
|
||||
"""
|
||||
operation = (assignment.operation or "tts").lower()
|
||||
params = _parse_params(assignment.params_json)
|
||||
params, leased_inputs = await self._materialize_inputs(
|
||||
params = await self._materialize_inputs(
|
||||
assignment, params, fetch_input or self._fetch_input
|
||||
)
|
||||
try:
|
||||
handler = {
|
||||
"tts": self._run_tts,
|
||||
"clone": self._run_tts,
|
||||
"audiobook": self._run_audiobook,
|
||||
"dub_segments": self._run_dub_segments,
|
||||
}.get(operation)
|
||||
if handler is None:
|
||||
raise TaskFailure(
|
||||
WorkerError(
|
||||
error_class=ErrorClass.CAPABILITY,
|
||||
code="OPERATION_UNSUPPORTED",
|
||||
message=f"This worker cannot run '{operation}' tasks.",
|
||||
hint="Run this task locally, or use a worker that supports it.",
|
||||
)
|
||||
|
||||
handler = {
|
||||
"tts": self._run_tts,
|
||||
"clone": self._run_tts,
|
||||
"audiobook": self._run_audiobook,
|
||||
"dub_segments": self._run_dub_segments,
|
||||
}.get(operation)
|
||||
if handler is None:
|
||||
raise TaskFailure(
|
||||
WorkerError(
|
||||
error_class=ErrorClass.CAPABILITY,
|
||||
code="OPERATION_UNSUPPORTED",
|
||||
message=f"This worker cannot run '{operation}' tasks.",
|
||||
hint="Run this task locally, or use a worker that supports it.",
|
||||
)
|
||||
return await handler(
|
||||
assignment,
|
||||
params,
|
||||
_Reporters(
|
||||
on_progress or self._on_progress,
|
||||
on_model_loading or self._on_model_loading,
|
||||
),
|
||||
)
|
||||
finally:
|
||||
self._release_inputs_after_active_work(leased_inputs)
|
||||
return await handler(
|
||||
assignment,
|
||||
params,
|
||||
_Reporters(
|
||||
on_progress or self._on_progress,
|
||||
on_model_loading or self._on_model_loading,
|
||||
),
|
||||
)
|
||||
|
||||
async def _run_dub_segments(self, assignment, params: dict, report: "_Reporters") -> dict:
|
||||
"""Render every requested dub line under one lease and return one bundle."""
|
||||
@@ -169,9 +150,8 @@ class TaskExecutor:
|
||||
))
|
||||
load_budget, run_budget = _budgets(assignment)
|
||||
await report.loading(0.0, f"preparing {assignment.engine}")
|
||||
backend = await self._bounded_thread(
|
||||
self._load_backend,
|
||||
assignment.engine,
|
||||
backend = await self._bounded(
|
||||
asyncio.to_thread(self._load_backend, assignment.engine),
|
||||
timeout=load_budget, code="MODEL_LOAD_TIMEOUT", what=f"Loading '{assignment.engine}'",
|
||||
)
|
||||
await report.loading(1.0, "model ready")
|
||||
@@ -179,15 +159,11 @@ class TaskExecutor:
|
||||
for index, row in enumerate(rows):
|
||||
row = dict(row)
|
||||
row["ref_audio"] = refs[index] if index < len(refs) else None
|
||||
audio = await self._bounded_thread(
|
||||
self._synthesize_dub_segment,
|
||||
backend,
|
||||
row,
|
||||
audio = await self._bounded(
|
||||
asyncio.to_thread(self._synthesize_dub_segment, backend, row),
|
||||
timeout=run_budget, code="EXECUTION_TIMEOUT", what=f"Dubbing segment {index + 1}",
|
||||
)
|
||||
payload, _meta = await self._thread_call(
|
||||
self._encode, audio, row, backend
|
||||
)
|
||||
payload, _meta = await asyncio.to_thread(self._encode, audio, row, backend)
|
||||
rendered.append((int(row.get("index", index)), payload))
|
||||
await report.progress((index + 1) / len(rows), f"segment {index + 1} of {len(rows)}")
|
||||
|
||||
@@ -208,11 +184,9 @@ class TaskExecutor:
|
||||
from services.text_normalization import normalize_for_tts
|
||||
|
||||
text = normalize_for_tts(row.get("text") or "", row.get("language"))
|
||||
seed = None
|
||||
if row.get("seed") is not None:
|
||||
import torch
|
||||
seed = int(row["seed"])
|
||||
torch.manual_seed(seed)
|
||||
torch.manual_seed(int(row["seed"]))
|
||||
kwargs = {
|
||||
"language": row.get("language") if row.get("language") != "Auto" else None,
|
||||
"ref_audio": row.get("ref_audio"), "ref_text": row.get("ref_text"),
|
||||
@@ -223,11 +197,6 @@ class TaskExecutor:
|
||||
"speed": float(row.get("speed") or 1.0), "denoise": True,
|
||||
"postprocess_output": True,
|
||||
}
|
||||
if (
|
||||
getattr(backend, "supports_native_omnivoice_controls", False)
|
||||
and seed is not None
|
||||
):
|
||||
kwargs["seed"] = seed
|
||||
audio = backend.generate(text=text, **kwargs)
|
||||
preset = row.get("effect_preset") or "broadcast"
|
||||
if preset != "raw":
|
||||
@@ -256,9 +225,8 @@ class TaskExecutor:
|
||||
load_budget, run_budget = _budgets(assignment)
|
||||
|
||||
await report.loading(0.0, f"preparing {assignment.engine}")
|
||||
backend = await self._bounded_thread(
|
||||
self._load_backend,
|
||||
assignment.engine,
|
||||
backend = await self._bounded(
|
||||
asyncio.to_thread(self._load_backend, assignment.engine),
|
||||
timeout=load_budget,
|
||||
code="MODEL_LOAD_TIMEOUT",
|
||||
what=f"Loading '{assignment.engine}'",
|
||||
@@ -266,22 +234,16 @@ class TaskExecutor:
|
||||
await report.loading(1.0, "model ready")
|
||||
|
||||
await report.progress(0.05, "synthesising")
|
||||
audio = await self._bounded_thread(
|
||||
self._synthesize,
|
||||
backend,
|
||||
text,
|
||||
params,
|
||||
audio = await self._bounded(
|
||||
asyncio.to_thread(self._synthesize, backend, text, params),
|
||||
timeout=run_budget,
|
||||
code="EXECUTION_TIMEOUT",
|
||||
what="Synthesis",
|
||||
)
|
||||
await report.progress(0.9, "encoding")
|
||||
|
||||
payload, meta = await self._bounded_thread(
|
||||
self._encode,
|
||||
audio,
|
||||
params,
|
||||
backend,
|
||||
payload, meta = await self._bounded(
|
||||
asyncio.to_thread(self._encode, audio, params, backend),
|
||||
timeout=run_budget,
|
||||
code="EXECUTION_TIMEOUT",
|
||||
what="Encoding",
|
||||
@@ -302,26 +264,19 @@ class TaskExecutor:
|
||||
))
|
||||
load_budget, run_budget = _budgets(assignment)
|
||||
await report.loading(0.0, f"preparing {assignment.engine}")
|
||||
backend = await self._bounded_thread(
|
||||
self._load_backend,
|
||||
assignment.engine,
|
||||
backend = await self._bounded(
|
||||
asyncio.to_thread(self._load_backend, assignment.engine),
|
||||
timeout=load_budget, code="MODEL_LOAD_TIMEOUT",
|
||||
what=f"Loading '{assignment.engine}'",
|
||||
)
|
||||
await report.loading(1.0, "model ready")
|
||||
await report.progress(0.05, "synthesising chapter")
|
||||
audio = await self._bounded_thread(
|
||||
self._synthesize_audiobook,
|
||||
backend,
|
||||
spans,
|
||||
voices,
|
||||
params,
|
||||
audio = await self._bounded(
|
||||
asyncio.to_thread(self._synthesize_audiobook, backend, spans, voices, params),
|
||||
timeout=run_budget, code="EXECUTION_TIMEOUT", what="Audiobook chapter",
|
||||
)
|
||||
await report.progress(0.9, "encoding")
|
||||
payload, meta = await self._thread_call(
|
||||
self._encode, audio, params, backend
|
||||
)
|
||||
payload, meta = await asyncio.to_thread(self._encode, audio, params, backend)
|
||||
await report.progress(1.0, "done")
|
||||
return {"meta": meta, "payload": payload}
|
||||
|
||||
@@ -339,10 +294,7 @@ class TaskExecutor:
|
||||
key: value for key, value in opts.to_manifest().items()
|
||||
if value is not None and key not in ("seed", "vary_repeats")
|
||||
}
|
||||
native_proxy = bool(
|
||||
getattr(backend, "supports_native_omnivoice_controls", False)
|
||||
)
|
||||
if isinstance(backend, OmniVoiceBackend) or native_proxy:
|
||||
if isinstance(backend, OmniVoiceBackend):
|
||||
extra.setdefault("num_step", 32)
|
||||
extra.setdefault("guidance_scale", 2.0)
|
||||
for key in ("emo_vector", "emo_text", "emo_alpha"):
|
||||
@@ -351,13 +303,11 @@ class TaskExecutor:
|
||||
def synth(text, index, speed=None):
|
||||
voice = voices[int(index)]
|
||||
base_seed = opts.seed if opts.seed is not None else voice.get("seed")
|
||||
seed = None
|
||||
if base_seed is not None:
|
||||
import torch
|
||||
nonce = occurrence["value"] if opts.vary_repeats else 0
|
||||
occurrence["value"] += 1
|
||||
seed = segment_seed(base_seed, text, nonce)
|
||||
torch.manual_seed(seed)
|
||||
torch.manual_seed(segment_seed(base_seed, text, nonce))
|
||||
kwargs = {
|
||||
"language": language,
|
||||
"ref_audio": voice.get("ref_audio"),
|
||||
@@ -366,8 +316,6 @@ class TaskExecutor:
|
||||
"speed": float(speed) if speed else 1.0,
|
||||
**extra,
|
||||
}
|
||||
if native_proxy and seed is not None:
|
||||
kwargs["seed"] = seed
|
||||
return backend.generate(text, **kwargs)
|
||||
|
||||
spans = [Span(voice_id=str(i), text=row.get("text", ""),
|
||||
@@ -381,9 +329,7 @@ class TaskExecutor:
|
||||
|
||||
# ── Inputs ────────────────────────────────────────────────────────────
|
||||
|
||||
async def _materialize_inputs(
|
||||
self, assignment, params: dict, fetch
|
||||
) -> tuple[dict, list[str]]:
|
||||
async def _materialize_inputs(self, assignment, params: dict, fetch) -> dict:
|
||||
"""Turn declared inputs into local files, then point the params at them.
|
||||
|
||||
The control plane sends artifact ids, never paths — its own paths mean
|
||||
@@ -406,7 +352,7 @@ class TaskExecutor:
|
||||
|
||||
refs = [ref for ref in (getattr(assignment, "inputs", None) or []) if ref.artifact_id]
|
||||
if not refs:
|
||||
return params, []
|
||||
return params
|
||||
if fetch is None:
|
||||
raise TaskFailure(
|
||||
WorkerError(
|
||||
@@ -419,24 +365,16 @@ class TaskExecutor:
|
||||
|
||||
_, run_budget = _budgets(assignment)
|
||||
local: dict[str, str] = {}
|
||||
leased: list[str] = []
|
||||
try:
|
||||
for ref in refs:
|
||||
path = await self._bounded(
|
||||
self._fetch_one(ref, fetch, retain=True),
|
||||
timeout=min(run_budget, _FALLBACK_INPUT_FETCH_SECONDS),
|
||||
code="INPUT_FETCH_TIMEOUT",
|
||||
what=f"Fetching '{ref.filename or ref.artifact_id}'",
|
||||
)
|
||||
local[ref.artifact_id] = path
|
||||
leased.append(path)
|
||||
return _rewrite_params(params, local), leased
|
||||
except BaseException:
|
||||
for path in leased:
|
||||
_release_input_cache_path(path)
|
||||
raise
|
||||
for ref in refs:
|
||||
local[ref.artifact_id] = await self._bounded(
|
||||
self._fetch_one(ref, fetch),
|
||||
timeout=min(run_budget, _FALLBACK_INPUT_FETCH_SECONDS),
|
||||
code="INPUT_FETCH_TIMEOUT",
|
||||
what=f"Fetching '{ref.filename or ref.artifact_id}'",
|
||||
)
|
||||
return _rewrite_params(params, local)
|
||||
|
||||
async def _fetch_one(self, ref, fetch, *, retain: bool = False) -> str:
|
||||
async def _fetch_one(self, ref, fetch) -> str:
|
||||
"""The local copy of one input, downloaded only if we lack it.
|
||||
|
||||
Content-addressed: the name is the hash the control plane computed, so
|
||||
@@ -444,127 +382,37 @@ class TaskExecutor:
|
||||
worker — costs no transfer at all.
|
||||
"""
|
||||
directory = self._input_dir or default_input_dir()
|
||||
await self._thread_call(_durable_makedirs, directory)
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
destination = os.path.join(directory, _cache_name(ref))
|
||||
return await self._fetch_one_owned(
|
||||
ref,
|
||||
fetch,
|
||||
directory=directory,
|
||||
destination=destination,
|
||||
retain=retain,
|
||||
)
|
||||
if _already_held(destination, ref):
|
||||
_touch(destination)
|
||||
return destination
|
||||
|
||||
async def _fetch_one_owned(
|
||||
self,
|
||||
ref,
|
||||
fetch,
|
||||
*,
|
||||
directory: str,
|
||||
destination: str,
|
||||
retain: bool,
|
||||
) -> str:
|
||||
"""Validate, fetch, and safely publish one content address."""
|
||||
await _acquire_input_cache_path(destination, fetching=True)
|
||||
leased_result = destination
|
||||
succeeded = False
|
||||
partial = f"{destination}.{uuid.uuid4().hex}.part"
|
||||
try:
|
||||
# Cache hits still hash the advertised content identity. Filename
|
||||
# plus size is not proof after disk corruption or external edits.
|
||||
if await self._thread_call(_already_held, destination, ref):
|
||||
await self._thread_call(_touch, destination)
|
||||
succeeded = True
|
||||
return destination
|
||||
|
||||
partial = f"{destination}.{uuid.uuid4().hex}.part"
|
||||
_lease_input_cache_path(partial)
|
||||
finalized = False
|
||||
try:
|
||||
try:
|
||||
await fetch(ref, partial)
|
||||
except TaskFailure:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise _input_fetch_failure(ref, exc) from exc
|
||||
|
||||
# Hashing and durability barriers can both block on a large
|
||||
# source or slow disk. Keep them off the loop and drain before
|
||||
# cleanup so Windows never unlinks a file still in use.
|
||||
await self._thread_call(_verify, partial, ref)
|
||||
gate_key = _cache_path_key(destination)
|
||||
gate = _INPUT_CACHE_FETCH_LOCKS.setdefault(
|
||||
gate_key, asyncio.Lock()
|
||||
await fetch(ref, partial)
|
||||
except TaskFailure:
|
||||
raise
|
||||
except Exception as exc:
|
||||
_discard(partial)
|
||||
raise TaskFailure(
|
||||
WorkerError(
|
||||
# Transient on purpose: an id we cannot resolve now is far
|
||||
# more often a dropped stream than a permanently missing
|
||||
# file, and one wasted retry beats failing real work.
|
||||
error_class=ErrorClass.TRANSIENT,
|
||||
code="INPUT_FETCH_FAILED",
|
||||
message=f"Could not fetch '{ref.filename or ref.artifact_id}': {exc}",
|
||||
hint="The control plane may have restarted; the task will be retried.",
|
||||
)
|
||||
_INPUT_CACHE_FETCH_USERS[gate_key] = (
|
||||
_INPUT_CACHE_FETCH_USERS.get(gate_key, 0) + 1
|
||||
)
|
||||
try:
|
||||
async with gate:
|
||||
# A concurrent fetch may have published these exact
|
||||
# bytes while this one was downloading its own partial.
|
||||
if await self._thread_call(
|
||||
_already_held, destination, ref
|
||||
):
|
||||
await self._thread_call(_discard, partial)
|
||||
finalized = True
|
||||
elif _claim_input_cache_mutation(destination):
|
||||
try:
|
||||
await self._thread_call(
|
||||
_durable_replace, partial, destination
|
||||
)
|
||||
finally:
|
||||
_finish_input_cache_mutation(destination)
|
||||
finalized = True
|
||||
else:
|
||||
# Another execution is actively reading the
|
||||
# canonical generation. Never unlink or replace
|
||||
# bytes underneath it; publish this verified fetch
|
||||
# under a leased sibling path and let a later
|
||||
# unshared fetch repair canonical.
|
||||
stem, suffix = os.path.splitext(destination)
|
||||
alternate = (
|
||||
f"{stem}.{uuid.uuid4().hex}.generation{suffix}"
|
||||
)
|
||||
await _acquire_input_cache_path(
|
||||
alternate, fetching=True
|
||||
)
|
||||
try:
|
||||
await self._thread_call(
|
||||
_durable_replace, partial, alternate
|
||||
)
|
||||
except BaseException:
|
||||
_release_input_cache_path(
|
||||
alternate, fetching=True
|
||||
)
|
||||
await self._thread_call(_discard, alternate)
|
||||
raise
|
||||
finalized = True
|
||||
_release_input_cache_path(
|
||||
destination, fetching=True
|
||||
)
|
||||
leased_result = alternate
|
||||
except OSError as exc:
|
||||
raise _input_fetch_failure(ref, exc) from exc
|
||||
finally:
|
||||
remaining = _INPUT_CACHE_FETCH_USERS[gate_key] - 1
|
||||
if remaining:
|
||||
_INPUT_CACHE_FETCH_USERS[gate_key] = remaining
|
||||
else:
|
||||
_INPUT_CACHE_FETCH_USERS.pop(gate_key, None)
|
||||
if _INPUT_CACHE_FETCH_LOCKS.get(gate_key) is gate:
|
||||
_INPUT_CACHE_FETCH_LOCKS.pop(gate_key, None)
|
||||
finally:
|
||||
if not finalized:
|
||||
await self._thread_call(_discard, partial)
|
||||
_release_input_cache_path(partial)
|
||||
) from exc
|
||||
|
||||
await self._thread_call(_prune_input_cache, directory)
|
||||
succeeded = True
|
||||
return leased_result
|
||||
finally:
|
||||
if retain and succeeded:
|
||||
_promote_input_cache_lease(leased_result)
|
||||
else:
|
||||
_release_input_cache_path(leased_result, fetching=True)
|
||||
# Off the loop: hashing a source video on the event loop thread would
|
||||
# stall every heartbeat this worker owes the control plane.
|
||||
await asyncio.to_thread(_verify, partial, ref)
|
||||
os.replace(partial, destination)
|
||||
await asyncio.to_thread(_prune_input_cache, directory)
|
||||
return destination
|
||||
|
||||
# ── Engine plumbing ───────────────────────────────────────────────────
|
||||
|
||||
@@ -612,59 +460,30 @@ class TaskExecutor:
|
||||
|
||||
@staticmethod
|
||||
def _synthesize(backend, text: str, params: dict):
|
||||
"""Render through the same seeded pipeline as local ``/generate``.
|
||||
"""Call the engine through the same serial GPU gate local jobs use.
|
||||
|
||||
Held against the idle sweep for the duration: a long generation touches
|
||||
the instance cache once, at the start, so on elapsed time alone it is
|
||||
indistinguishable from a model nobody wants any more.
|
||||
|
||||
Do not reduce this to ``backend.generate()``. The control plane sends
|
||||
a complete render contract (pinned gallery seed, synthetic reference,
|
||||
quality controls, chunking, effects); calling the adapter directly
|
||||
silently turns a selected gallery voice into a fresh random take.
|
||||
"""
|
||||
from services import tts_backend # noqa: PLC0415
|
||||
from api.routers.generation import _run_backend_inference, _run_inference # noqa: PLC0415
|
||||
|
||||
language = params.get("language")
|
||||
ref_audio = params.get("ref_audio")
|
||||
ref_text = params.get("ref_text")
|
||||
instruct = params.get("instruct")
|
||||
duration = params.get("duration")
|
||||
num_step = params.get("num_step", 16)
|
||||
guidance_scale = params.get("guidance_scale", 2.0)
|
||||
speed = params.get("speed", 1.0)
|
||||
denoise = params.get("denoise", True)
|
||||
postprocess_output = params.get("postprocess_output", True)
|
||||
used_seed = params.get("seed")
|
||||
effect_preset = params.get("effect_preset", "broadcast")
|
||||
max_chunk_chars = params.get("max_chunk_chars")
|
||||
crossfade_ms = params.get("crossfade_ms")
|
||||
kwargs = {
|
||||
key: params[key]
|
||||
for key in (
|
||||
"ref_audio",
|
||||
"ref_text",
|
||||
"instruct",
|
||||
"language",
|
||||
"duration",
|
||||
"description",
|
||||
"speed",
|
||||
)
|
||||
if params.get(key) is not None
|
||||
}
|
||||
try:
|
||||
with tts_backend.engine_in_use(backend):
|
||||
if isinstance(backend, tts_backend.OmniVoiceBackend):
|
||||
# The OSS default engine has an extended native surface;
|
||||
# preserving it is required for a gallery preview and a
|
||||
# GPU-worker take to share the same voice identity.
|
||||
return _run_inference(
|
||||
backend._model, text, language, ref_audio, ref_text,
|
||||
instruct, duration, num_step, guidance_scale, speed,
|
||||
params.get("t_shift"), denoise, postprocess_output,
|
||||
params.get("layer_penalty_factor"),
|
||||
params.get("position_temperature"),
|
||||
params.get("class_temperature"), used_seed,
|
||||
effect_preset, max_chunk_chars, crossfade_ms,
|
||||
)
|
||||
return _run_backend_inference(
|
||||
backend, text, language, ref_audio, ref_text, instruct,
|
||||
duration, num_step, guidance_scale, speed, denoise,
|
||||
postprocess_output, used_seed, effect_preset,
|
||||
max_chunk_chars, crossfade_ms,
|
||||
t_shift=params.get("t_shift"),
|
||||
layer_penalty_factor=params.get("layer_penalty_factor"),
|
||||
position_temperature=params.get("position_temperature"),
|
||||
class_temperature=params.get("class_temperature"),
|
||||
)
|
||||
return backend.generate(text, **kwargs)
|
||||
except Exception as exc:
|
||||
from worker import errors as worker_errors # noqa: PLC0415
|
||||
|
||||
@@ -707,92 +526,6 @@ class TaskExecutor:
|
||||
|
||||
# ── Bounding ──────────────────────────────────────────────────────────
|
||||
|
||||
def _release_inputs_after_active_work(self, paths: list[str]) -> None:
|
||||
"""Keep files leased while a timed-out engine thread still owns them."""
|
||||
pending = [task for task in self._blocking_tasks if not task.done()]
|
||||
if not pending:
|
||||
for path in paths:
|
||||
_release_input_cache_path(path)
|
||||
return
|
||||
remaining = {"count": len(pending)}
|
||||
|
||||
def finished(_task: asyncio.Task) -> None:
|
||||
remaining["count"] -= 1
|
||||
if remaining["count"] == 0:
|
||||
for path in paths:
|
||||
_release_input_cache_path(path)
|
||||
|
||||
for task in pending:
|
||||
task.add_done_callback(finished)
|
||||
|
||||
def _start_thread(self, function, /, *args) -> asyncio.Task:
|
||||
task = asyncio.create_task(asyncio.to_thread(function, *args))
|
||||
self._blocking_tasks.add(task)
|
||||
|
||||
def finished(completed: asyncio.Task) -> None:
|
||||
self._blocking_tasks.discard(completed)
|
||||
if not completed.cancelled():
|
||||
# Timed-out calls intentionally finish in the background. Read
|
||||
# their exception so asyncio never reports an unowned task.
|
||||
completed.exception()
|
||||
|
||||
task.add_done_callback(finished)
|
||||
return task
|
||||
|
||||
async def _thread_call(self, function, /, *args):
|
||||
task = self._start_thread(function, *args)
|
||||
try:
|
||||
return await asyncio.shield(task)
|
||||
except asyncio.CancelledError:
|
||||
await drain_task(task)
|
||||
raise
|
||||
|
||||
async def drain_active_work(self) -> None:
|
||||
"""Wait until every blocking engine call has relinquished the process."""
|
||||
cancelled = bool(
|
||||
(current := asyncio.current_task()) is not None and current.cancelling()
|
||||
)
|
||||
while self._blocking_tasks:
|
||||
for task in list(self._blocking_tasks):
|
||||
try:
|
||||
await asyncio.shield(task)
|
||||
except asyncio.CancelledError:
|
||||
# Cancellation cannot make a Python GPU thread stop. Hold
|
||||
# authority until it really exits, then propagate the
|
||||
# cancellation so callers never publish a false free slot.
|
||||
cancelled = True
|
||||
await drain_task(task)
|
||||
except BaseException:
|
||||
# The owner reports/classifies the engine exception. This
|
||||
# barrier only establishes that the thread has finished.
|
||||
pass
|
||||
if cancelled:
|
||||
raise asyncio.CancelledError
|
||||
|
||||
async def _bounded_thread(
|
||||
self, function, /, *args, timeout: float, code: str, what: str
|
||||
):
|
||||
"""Bound a blocking call without losing ownership of its live thread."""
|
||||
task = self._start_thread(function, *args)
|
||||
try:
|
||||
done, _pending = await asyncio.wait({task}, timeout=timeout)
|
||||
except asyncio.CancelledError:
|
||||
await drain_task(task)
|
||||
raise
|
||||
if done:
|
||||
return task.result()
|
||||
# A GPU call cannot be killed. Return the timeout so the scheduler can
|
||||
# park its slot, but retain the task above so terminal authority loss
|
||||
# can drain it before claiming this worker has stopped.
|
||||
raise TaskFailure(
|
||||
WorkerError(
|
||||
error_class=ErrorClass.TIMEOUT,
|
||||
code=code,
|
||||
message=f"{what} exceeded the {timeout:g}s budget for this task.",
|
||||
hint="Try a shorter input, or a worker with more headroom.",
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def _bounded(coro, *, timeout: float, code: str, what: str):
|
||||
"""Run ``coro`` under the server's budget for this phase.
|
||||
@@ -878,158 +611,6 @@ def default_input_dir() -> str:
|
||||
return os.path.join(tempfile.gettempdir(), "omnivoice-worker-inputs")
|
||||
|
||||
|
||||
def _cache_path_key(path: str) -> str:
|
||||
return os.path.normcase(os.path.abspath(path))
|
||||
|
||||
|
||||
def _lease_input_cache_path(path: str, *, fetching: bool = False) -> bool:
|
||||
key = _cache_path_key(path)
|
||||
with _INPUT_CACHE_LEASE_LOCK:
|
||||
if key in _INPUT_CACHE_MUTATIONS:
|
||||
return False
|
||||
_INPUT_CACHE_LEASES[key] = _INPUT_CACHE_LEASES.get(key, 0) + 1
|
||||
if fetching:
|
||||
_INPUT_CACHE_FETCH_LEASES[key] = (
|
||||
_INPUT_CACHE_FETCH_LEASES.get(key, 0) + 1
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def _acquire_input_cache_path(
|
||||
path: str, *, fetching: bool = False
|
||||
) -> None:
|
||||
while not _lease_input_cache_path(path, fetching=fetching):
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
|
||||
def _release_input_cache_path(path: str, *, fetching: bool = False) -> None:
|
||||
key = _cache_path_key(path)
|
||||
with _INPUT_CACHE_LEASE_LOCK:
|
||||
if fetching:
|
||||
fetch_remaining = _INPUT_CACHE_FETCH_LEASES.get(key, 0) - 1
|
||||
if fetch_remaining > 0:
|
||||
_INPUT_CACHE_FETCH_LEASES[key] = fetch_remaining
|
||||
else:
|
||||
_INPUT_CACHE_FETCH_LEASES.pop(key, None)
|
||||
remaining = _INPUT_CACHE_LEASES.get(key, 0) - 1
|
||||
if remaining > 0:
|
||||
_INPUT_CACHE_LEASES[key] = remaining
|
||||
else:
|
||||
_INPUT_CACHE_LEASES.pop(key, None)
|
||||
|
||||
|
||||
def _promote_input_cache_lease(path: str) -> None:
|
||||
"""Turn a fetcher's lease into the active execution lease it returns."""
|
||||
key = _cache_path_key(path)
|
||||
with _INPUT_CACHE_LEASE_LOCK:
|
||||
remaining = _INPUT_CACHE_FETCH_LEASES.get(key, 0) - 1
|
||||
if remaining > 0:
|
||||
_INPUT_CACHE_FETCH_LEASES[key] = remaining
|
||||
else:
|
||||
_INPUT_CACHE_FETCH_LEASES.pop(key, None)
|
||||
|
||||
|
||||
def _leased_input_cache_paths() -> set[str]:
|
||||
with _INPUT_CACHE_LEASE_LOCK:
|
||||
return set(_INPUT_CACHE_LEASES)
|
||||
|
||||
|
||||
def _claim_input_cache_mutation(path: str) -> bool:
|
||||
key = _cache_path_key(path)
|
||||
with _INPUT_CACHE_LEASE_LOCK:
|
||||
if key in _INPUT_CACHE_MUTATIONS:
|
||||
return False
|
||||
active_leases = _INPUT_CACHE_LEASES.get(
|
||||
key, 0
|
||||
) - _INPUT_CACHE_FETCH_LEASES.get(key, 0)
|
||||
# Fetchers can safely converge under the publication gate. A lease
|
||||
# already promoted to an execution may have this exact pathname open.
|
||||
if active_leases > 0:
|
||||
return False
|
||||
_INPUT_CACHE_MUTATIONS.add(key)
|
||||
return True
|
||||
|
||||
|
||||
def _finish_input_cache_mutation(path: str) -> None:
|
||||
key = _cache_path_key(path)
|
||||
with _INPUT_CACHE_LEASE_LOCK:
|
||||
_INPUT_CACHE_MUTATIONS.discard(key)
|
||||
|
||||
|
||||
def _fsync_file(path: str) -> None:
|
||||
with open(path, "r+b") as handle:
|
||||
os.fsync(handle.fileno())
|
||||
|
||||
|
||||
def _fsync_parent_directory(directory: str) -> None:
|
||||
directory_flag = getattr(os, "O_DIRECTORY", None)
|
||||
if directory_flag is None:
|
||||
return
|
||||
unsupported = {
|
||||
errno.EINVAL,
|
||||
getattr(errno, "ENOTSUP", errno.EINVAL),
|
||||
getattr(errno, "EOPNOTSUPP", errno.EINVAL),
|
||||
}
|
||||
try:
|
||||
descriptor = os.open(directory, os.O_RDONLY | directory_flag)
|
||||
except OSError as exc:
|
||||
if exc.errno in unsupported:
|
||||
return
|
||||
raise
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
except OSError as exc:
|
||||
if exc.errno not in unsupported:
|
||||
raise
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def _durable_makedirs(directory: str) -> None:
|
||||
target = os.path.abspath(directory)
|
||||
missing: list[str] = []
|
||||
current = target
|
||||
while not os.path.isdir(current):
|
||||
if os.path.exists(current):
|
||||
if os.path.isdir(current):
|
||||
break
|
||||
raise NotADirectoryError(current)
|
||||
missing.append(current)
|
||||
parent = os.path.dirname(current)
|
||||
if parent == current:
|
||||
break
|
||||
current = parent
|
||||
for path in reversed(missing):
|
||||
try:
|
||||
os.mkdir(path)
|
||||
except FileExistsError:
|
||||
if not os.path.isdir(path):
|
||||
raise
|
||||
_fsync_parent_directory(os.path.dirname(path) or ".")
|
||||
if not missing:
|
||||
_fsync_parent_directory(os.path.dirname(target) or ".")
|
||||
|
||||
|
||||
def _durable_replace(source: str, destination: str) -> None:
|
||||
_fsync_file(source)
|
||||
os.replace(source, destination)
|
||||
_fsync_parent_directory(os.path.dirname(destination) or ".")
|
||||
|
||||
|
||||
def _input_fetch_failure(ref, error: BaseException) -> TaskFailure:
|
||||
return TaskFailure(
|
||||
WorkerError(
|
||||
# Transient on purpose: an id we cannot resolve now is far more
|
||||
# often a dropped stream/disk barrier than a permanently missing
|
||||
# file, and one wasted retry beats failing real work.
|
||||
error_class=ErrorClass.TRANSIENT,
|
||||
code="INPUT_FETCH_FAILED",
|
||||
message=f"Could not fetch '{ref.filename or ref.artifact_id}': {error}",
|
||||
hint="The control plane may have restarted; the task will be retried.",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _cache_name(ref) -> str:
|
||||
"""A safe, content-addressed local name for one input.
|
||||
|
||||
@@ -1050,25 +631,13 @@ def _cache_name(ref) -> str:
|
||||
def _already_held(path: str, ref) -> bool:
|
||||
"""Do we already have this exact input?
|
||||
|
||||
The filename is content-addressed, but disks and external edits can still
|
||||
change bytes at that name. Re-hash the advertised identity before reuse.
|
||||
Size alone: the name is the content hash and the only writer is an atomic
|
||||
rename, so a file of the right size at this name cannot be different bytes.
|
||||
"""
|
||||
try:
|
||||
expected = int(getattr(ref, "size_bytes", 0) or 0)
|
||||
if not os.path.isfile(path):
|
||||
return False
|
||||
if expected and os.path.getsize(path) != expected:
|
||||
return False
|
||||
expected_hash = (getattr(ref, "sha256", "") or "").strip().lower()
|
||||
if expected_hash:
|
||||
digest = hashlib.sha256()
|
||||
with open(path, "rb") as handle:
|
||||
for block in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(block)
|
||||
if digest.hexdigest() != expected_hash:
|
||||
return False
|
||||
return True
|
||||
except OSError:
|
||||
return os.path.isfile(path) and (not expected or os.path.getsize(path) == expected)
|
||||
except OSError: # pragma: no cover
|
||||
return False
|
||||
|
||||
|
||||
@@ -1129,45 +698,22 @@ def _verify(path: str, ref) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _prune_input_cache(
|
||||
directory: str,
|
||||
limit_bytes: int = INPUT_CACHE_LIMIT_BYTES,
|
||||
now: Optional[float] = None,
|
||||
) -> None:
|
||||
"""Keep the cache bounded without deleting inputs a task is still using."""
|
||||
def _prune_input_cache(directory: str, limit_bytes: int = INPUT_CACHE_LIMIT_BYTES) -> None:
|
||||
"""Keep the input cache under its ceiling, oldest first."""
|
||||
try:
|
||||
entries = []
|
||||
total = 0
|
||||
stamp = time.time() if now is None else now
|
||||
leased = _leased_input_cache_paths()
|
||||
for name in os.listdir(directory):
|
||||
path = os.path.join(directory, name)
|
||||
if not os.path.isfile(path):
|
||||
if name.endswith(".part") or not os.path.isfile(path):
|
||||
continue
|
||||
stat = os.stat(path)
|
||||
key = _cache_path_key(path)
|
||||
is_partial = name.endswith(".part")
|
||||
if (
|
||||
is_partial
|
||||
and key not in leased
|
||||
and stamp - stat.st_mtime >= _STALE_INPUT_PARTIAL_SECONDS
|
||||
):
|
||||
os.remove(path)
|
||||
continue
|
||||
entries.append((stat.st_mtime, stat.st_size, path))
|
||||
total += stat.st_size
|
||||
# Active finals and partial transfers count toward the ceiling but
|
||||
# cannot be evicted. Young unleased .part files may belong to a
|
||||
# process that has not yet rebuilt its in-memory lease after fork;
|
||||
# the age sweep will remove them if they are crash leftovers.
|
||||
if key not in leased and not is_partial:
|
||||
entries.append((stat.st_mtime, stat.st_size, path))
|
||||
for _mtime, size, path in sorted(entries):
|
||||
if total <= limit_bytes:
|
||||
break
|
||||
try:
|
||||
os.remove(path)
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
os.remove(path)
|
||||
total -= size
|
||||
except OSError: # pragma: no cover — a full cache is not a failed task
|
||||
logger.debug("Could not prune the worker input cache", exc_info=True)
|
||||
|
||||
@@ -25,7 +25,6 @@ in the dialog that shows it once.
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import errno
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
@@ -302,31 +301,10 @@ def save_worker_key(path: str, keypair: WorkerKeypair) -> None:
|
||||
tmp = f"{path}.tmp"
|
||||
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
try:
|
||||
remaining = memoryview(keypair.private_bytes())
|
||||
while remaining:
|
||||
written = os.write(fd, remaining)
|
||||
if written <= 0:
|
||||
raise OSError("could not finish writing the worker identity key")
|
||||
remaining = remaining[written:]
|
||||
os.fsync(fd)
|
||||
except Exception:
|
||||
os.write(fd, keypair.private_bytes())
|
||||
finally:
|
||||
os.close(fd)
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
raise
|
||||
else:
|
||||
os.close(fd)
|
||||
try:
|
||||
os.replace(tmp, path)
|
||||
except Exception:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
raise
|
||||
_fsync_parent_directory(directory)
|
||||
os.replace(tmp, path)
|
||||
try:
|
||||
os.chmod(path, 0o600)
|
||||
except OSError:
|
||||
@@ -335,30 +313,6 @@ def save_worker_key(path: str, keypair: WorkerKeypair) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _fsync_parent_directory(directory: str) -> None:
|
||||
directory_flag = getattr(os, "O_DIRECTORY", None)
|
||||
if directory_flag is None:
|
||||
return
|
||||
unsupported = {
|
||||
errno.EINVAL,
|
||||
getattr(errno, "ENOTSUP", errno.EINVAL),
|
||||
getattr(errno, "EOPNOTSUPP", errno.EINVAL),
|
||||
}
|
||||
try:
|
||||
descriptor = os.open(directory, os.O_RDONLY | directory_flag)
|
||||
except OSError as exc:
|
||||
if exc.errno in unsupported:
|
||||
return
|
||||
raise
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
except OSError as exc:
|
||||
if exc.errno not in unsupported:
|
||||
raise
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def load_worker_key(path: str) -> Optional[WorkerKeypair]:
|
||||
try:
|
||||
with open(path, "rb") as fh:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -22,106 +22,20 @@ import logging
|
||||
import os
|
||||
import socket
|
||||
import ssl
|
||||
from typing import BinaryIO, Optional
|
||||
from typing import Optional
|
||||
|
||||
import grpc
|
||||
|
||||
from worker import identity, registry, tls
|
||||
from worker.async_utils import to_thread_and_drain_on_cancel
|
||||
from worker.inbound.connection_string import Connection
|
||||
from worker.inbound.listener import KEY_METADATA_KEY
|
||||
from worker.protocol.gen import worker_v1_pb2 as pb
|
||||
from worker.protocol.gen import worker_v1_pb2_grpc as pb_grpc
|
||||
from worker.transport.client import (
|
||||
MAX_MESSAGE_BYTES,
|
||||
TerminalRegistrationError,
|
||||
backoff_delay,
|
||||
)
|
||||
from worker.transport.client import MAX_MESSAGE_BYTES, backoff_delay
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_PUSH_CHUNK_BYTES = 1024 * 1024
|
||||
# Register remains provisional until the node confirms that it durably saved
|
||||
# the panel-assigned identity. Match the control plane's provisional-session
|
||||
# lifetime so a peer that stops after Register cannot strand this connector.
|
||||
_REGISTRATION_CONFIRMATION_TIMEOUT_SECONDS = 30.0
|
||||
_REMOTE_SHUTDOWN_TIMEOUT_SECONDS = 30.0
|
||||
|
||||
_FileVersion = tuple[int, int, int, int, int]
|
||||
|
||||
|
||||
def _write_all(handle: BinaryIO, payload: bytes) -> None:
|
||||
remaining = memoryview(payload)
|
||||
while remaining:
|
||||
written = handle.write(remaining)
|
||||
if not written:
|
||||
raise OSError("result write made no progress")
|
||||
remaining = remaining[written:]
|
||||
|
||||
|
||||
def _remove_quietly(path: str) -> None:
|
||||
with contextlib.suppress(OSError):
|
||||
os.remove(path)
|
||||
|
||||
|
||||
class InboundConnectionError(RuntimeError):
|
||||
"""A pasted inbound connection could not be validated or activated."""
|
||||
|
||||
|
||||
class InboundConnectionRollbackError(InboundConnectionError):
|
||||
"""A failed connection change could not restore its prior generation."""
|
||||
|
||||
|
||||
class RemoteShutdownUnavailable(InboundConnectionError):
|
||||
"""The node may retain work, but no live stream can revoke it safely."""
|
||||
|
||||
|
||||
def _file_version(stat: os.stat_result) -> _FileVersion:
|
||||
"""Fields that identify both a staged path and the bytes hashed from it."""
|
||||
return (
|
||||
int(stat.st_dev),
|
||||
int(stat.st_ino),
|
||||
int(stat.st_size),
|
||||
int(stat.st_mtime_ns),
|
||||
int(stat.st_ctime_ns),
|
||||
)
|
||||
|
||||
|
||||
def _hash_staged_input(path: str) -> tuple[int, str, _FileVersion]:
|
||||
"""Hash one stable generation without ever allocating the whole file."""
|
||||
digest = hashlib.sha256()
|
||||
received = 0
|
||||
with open(path, "rb") as handle:
|
||||
before = _file_version(os.fstat(handle.fileno()))
|
||||
while True:
|
||||
block = handle.read(_PUSH_CHUNK_BYTES)
|
||||
if not block:
|
||||
break
|
||||
received += len(block)
|
||||
digest.update(block)
|
||||
after = _file_version(os.fstat(handle.fileno()))
|
||||
if before != after or received != before[2]:
|
||||
raise RuntimeError("the staged task input changed while it was being hashed")
|
||||
return received, digest.hexdigest(), before
|
||||
|
||||
|
||||
def _validate_staged_input(path: str, expected: _FileVersion) -> None:
|
||||
"""Reject a replacement or in-place edit between hashing and streaming."""
|
||||
try:
|
||||
current = _file_version(os.stat(path))
|
||||
except OSError as exc:
|
||||
raise RuntimeError("the staged task input is no longer available") from exc
|
||||
if current != expected:
|
||||
raise RuntimeError("the staged task input changed before it could be sent")
|
||||
|
||||
|
||||
def _validate_open_staged_input(
|
||||
handle: BinaryIO, path: str, expected: _FileVersion
|
||||
) -> None:
|
||||
"""The open generation and its path must still be the bytes we hashed."""
|
||||
if _file_version(os.fstat(handle.fileno())) != expected:
|
||||
raise RuntimeError("the staged task input changed before it could be sent")
|
||||
_validate_staged_input(path, expected)
|
||||
|
||||
|
||||
def _fetch_pinned_certificate(
|
||||
@@ -156,15 +70,9 @@ class NodeConnection:
|
||||
self._connection = connection
|
||||
self._label = label or connection.host
|
||||
self._outbox: asyncio.Queue[pb.ServerMessage] = asyncio.Queue()
|
||||
self._active_session = None
|
||||
self._stub: Optional[pb_grpc.NodeServiceStub] = None
|
||||
self._worker_id = ""
|
||||
self._stop = asyncio.Event()
|
||||
self._session_closed = asyncio.Event()
|
||||
self._session_closed.set()
|
||||
self._shutdown_confirmed = asyncio.Event()
|
||||
self._registration_ready = asyncio.Event()
|
||||
self._remote_protocol_retained = False
|
||||
self._last_error = ""
|
||||
|
||||
@property
|
||||
@@ -197,10 +105,6 @@ class NodeConnection:
|
||||
attempt = 0
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except TerminalRegistrationError as exc:
|
||||
self._remote_protocol_retained = False
|
||||
self._last_error = str(exc)
|
||||
raise
|
||||
except Exception:
|
||||
attempt += 1
|
||||
self._last_error = "Connection failed; check the backend log for details."
|
||||
@@ -210,143 +114,8 @@ class NodeConnection:
|
||||
await asyncio.wait_for(self._stop.wait(), timeout=delay)
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._stop.is_set():
|
||||
return
|
||||
if self._shutdown_confirmed.is_set() and not self._remote_protocol_retained:
|
||||
self._stop.set()
|
||||
return
|
||||
if self._active_session is None:
|
||||
if self._remote_protocol_retained:
|
||||
raise RemoteShutdownUnavailable(
|
||||
"That GPU machine is offline and may still be running work. "
|
||||
"Reconnect it, then remove the connection again."
|
||||
)
|
||||
self._stop.set()
|
||||
return
|
||||
# EOF is indistinguishable from a network blip and deliberately keeps
|
||||
# node execution alive for reconnect. Send an explicit terminal frame
|
||||
# and wait for the node to drain before removal reports success.
|
||||
self._shutdown_confirmed.clear()
|
||||
await self._outbox.put(
|
||||
pb.ServerMessage(
|
||||
shutdown=pb.Shutdown(reason="This GPU-machine connection was removed.")
|
||||
)
|
||||
)
|
||||
confirmed = asyncio.create_task(self._shutdown_confirmed.wait())
|
||||
disconnected = asyncio.create_task(self._session_closed.wait())
|
||||
try:
|
||||
done, _pending = await asyncio.wait(
|
||||
{confirmed, disconnected},
|
||||
timeout=_REMOTE_SHUTDOWN_TIMEOUT_SECONDS,
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
if confirmed not in done and not self._shutdown_confirmed.is_set():
|
||||
raise RemoteShutdownUnavailable(
|
||||
"The GPU machine disconnected before it confirmed shutdown. "
|
||||
"Reconnect it, then remove the connection again."
|
||||
)
|
||||
finally:
|
||||
confirmed.cancel()
|
||||
disconnected.cancel()
|
||||
await asyncio.gather(confirmed, disconnected, return_exceptions=True)
|
||||
self._remote_protocol_retained = False
|
||||
self._stop.set()
|
||||
|
||||
async def close(self) -> None:
|
||||
"""End this process without revoking reconnectable remote work."""
|
||||
self._stop.set()
|
||||
|
||||
def confirm_remote_shutdown(self, session) -> None:
|
||||
if self._active_session is not session:
|
||||
return
|
||||
self._remote_protocol_retained = False
|
||||
self._shutdown_confirmed.set()
|
||||
|
||||
def confirm_registration(self, session) -> None:
|
||||
"""Publish readiness only after the shared servicer activated the session."""
|
||||
if self._active_session is session:
|
||||
self._registration_ready.set()
|
||||
|
||||
async def wait_until_registered(
|
||||
self, task: asyncio.Task, *, timeout: float = 30.0
|
||||
) -> None:
|
||||
"""Wait for activation or surface a terminal/background dial failure."""
|
||||
ready = asyncio.create_task(self._registration_ready.wait())
|
||||
try:
|
||||
done, _pending = await asyncio.wait(
|
||||
{ready, task}, timeout=timeout, return_when=asyncio.FIRST_COMPLETED
|
||||
)
|
||||
if ready in done:
|
||||
return
|
||||
if task in done:
|
||||
if task.cancelled():
|
||||
raise InboundConnectionError(
|
||||
"The GPU-machine connection stopped before it became ready."
|
||||
)
|
||||
exc = task.exception()
|
||||
if exc is not None:
|
||||
raise InboundConnectionError(str(exc)) from exc
|
||||
raise InboundConnectionError(
|
||||
"That GPU machine did not finish connecting in time."
|
||||
)
|
||||
finally:
|
||||
ready.cancel()
|
||||
await asyncio.gather(ready, return_exceptions=True)
|
||||
|
||||
async def probe(self) -> None:
|
||||
"""Authenticate a replacement paste without publishing a worker session."""
|
||||
try:
|
||||
certificate_pem = await asyncio.to_thread(
|
||||
_fetch_pinned_certificate, self._connection
|
||||
)
|
||||
async with self._channel(certificate_pem) as channel:
|
||||
stub = pb_grpc.NodeServiceStub(channel)
|
||||
metadata = ((KEY_METADATA_KEY, self._connection.secret),)
|
||||
stream = stub.Attach(self._outbound(), metadata=metadata)
|
||||
try:
|
||||
first = await asyncio.wait_for(
|
||||
stream.read(),
|
||||
timeout=_REGISTRATION_CONFIRMATION_TIMEOUT_SECONDS,
|
||||
)
|
||||
finally:
|
||||
stream.cancel()
|
||||
except InboundConnectionError:
|
||||
raise
|
||||
except grpc.aio.AioRpcError as exc:
|
||||
detail = exc.details() or "The GPU machine rejected this connection."
|
||||
raise InboundConnectionError(detail) from exc
|
||||
except asyncio.TimeoutError as exc:
|
||||
raise InboundConnectionError(
|
||||
"That GPU machine did not answer in time."
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
raise InboundConnectionError(str(exc)) from exc
|
||||
|
||||
if first == grpc.aio.EOF or first.WhichOneof("payload") != "register":
|
||||
raise InboundConnectionError(
|
||||
"That machine answered, but not as a VoiceStudio GPU node."
|
||||
)
|
||||
request = first.register
|
||||
validate = getattr(self._servicer, "validate_inbound_request", None)
|
||||
refusal = validate(request) if callable(validate) else None
|
||||
if refusal is not None and refusal.error.code:
|
||||
raise InboundConnectionError(
|
||||
f"{refusal.error.code}: {refusal.error.message}"
|
||||
)
|
||||
public_key = bytes(request.public_key)
|
||||
if len(public_key) != 32:
|
||||
raise InboundConnectionError("That machine sent no usable identity.")
|
||||
key_id = identity.key_id_for(public_key)
|
||||
if registry.is_revoked(key_id):
|
||||
raise InboundConnectionError(
|
||||
"This GPU machine was removed from this app. Add it again to use it."
|
||||
)
|
||||
known = registry.get_by_key_id(key_id)
|
||||
if known is not None and not self._proves_key_possession(request, known):
|
||||
raise InboundConnectionError(
|
||||
"That machine could not prove its saved identity."
|
||||
)
|
||||
|
||||
async def _connect_once(self) -> None:
|
||||
# A fresh outbox per attempt. The queue used to be built once and
|
||||
# reused, so anything a dying session left behind became the NEXT
|
||||
@@ -354,10 +123,7 @@ class NodeConnection:
|
||||
# registration it requires first, aborted the call, and the pair span
|
||||
# at full speed: on hardware this reached session epoch 2445 inside a
|
||||
# second, with the log reading "Locally aborted" over and over.
|
||||
self._worker_id = ""
|
||||
self._stub = None
|
||||
self._outbox = asyncio.Queue()
|
||||
self._active_session = None
|
||||
certificate_pem = await asyncio.to_thread(
|
||||
_fetch_pinned_certificate, self._connection
|
||||
)
|
||||
@@ -374,98 +140,33 @@ class NodeConnection:
|
||||
"That machine answered, but not as a VoiceStudio GPU node."
|
||||
)
|
||||
|
||||
response = await self._register(first.register)
|
||||
response = self._register(first.register)
|
||||
if response.error.code:
|
||||
# A refusal here is a decision, not a blip: the node is a
|
||||
# different machine than the one this key was trusted for, or
|
||||
# its version cannot work with ours. Reconnecting cannot fix
|
||||
# either. Deliver the verdict before surfacing it locally so
|
||||
# the node can retire work retained across the dead stream;
|
||||
# closing first strands that executor with nobody left able to
|
||||
# cancel it.
|
||||
await self._outbox.put(pb.ServerMessage(registered=response))
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
stream.read(),
|
||||
timeout=_REGISTRATION_CONFIRMATION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except (asyncio.TimeoutError, grpc.aio.AioRpcError):
|
||||
pass
|
||||
raise TerminalRegistrationError(
|
||||
f"{response.error.code}: {response.error.message}"
|
||||
)
|
||||
# either, so surface it rather than looping.
|
||||
raise RuntimeError(f"{response.error.code}: {response.error.message}")
|
||||
|
||||
self._worker_id = response.worker_id
|
||||
self._stub = stub
|
||||
self._last_error = ""
|
||||
await self._outbox.put(pb.ServerMessage(registered=response))
|
||||
|
||||
session = self._servicer.session_for(self._worker_id)
|
||||
if session is None:
|
||||
raise RuntimeError("the session went away before the stream opened")
|
||||
|
||||
pump = asyncio.create_task(self._pump_outbound(session))
|
||||
try:
|
||||
await self._complete_registration(stream, response, stub)
|
||||
await self._servicer.run_inbound_stream(session, _Frames(stream), self)
|
||||
finally:
|
||||
# Idempotent after activation; essential before it. A user can
|
||||
# remove this connection while the node is still persisting
|
||||
# identity, and cancellation must release the old worker's
|
||||
# scheduling gate immediately rather than wait for expiry.
|
||||
self._servicer.discard_unopened_session(
|
||||
response.worker_id, session_token=response.session_token
|
||||
)
|
||||
pump.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError, Exception):
|
||||
await pump
|
||||
self._stub = None
|
||||
|
||||
async def _complete_registration(self, stream, response, stub) -> None:
|
||||
"""Validate durable acceptance, then run the exact issued session."""
|
||||
try:
|
||||
confirmation = await asyncio.wait_for(
|
||||
stream.read(), timeout=_REGISTRATION_CONFIRMATION_TIMEOUT_SECONDS
|
||||
)
|
||||
except asyncio.TimeoutError as exc:
|
||||
raise RuntimeError(
|
||||
"That GPU machine did not confirm registration in time."
|
||||
) from exc
|
||||
except grpc.aio.AioRpcError as exc:
|
||||
detail = exc.details() or ""
|
||||
error_code = detail.partition(":")[0].strip()
|
||||
if exc.code() == grpc.StatusCode.FAILED_PRECONDITION and error_code in {
|
||||
"AUTH_FAILED",
|
||||
"LOCAL_STATE",
|
||||
"UPGRADE_REQUIRED",
|
||||
}:
|
||||
raise TerminalRegistrationError(detail) from exc
|
||||
raise
|
||||
if confirmation == grpc.aio.EOF:
|
||||
raise RuntimeError(
|
||||
"That GPU machine disconnected before confirming registration."
|
||||
)
|
||||
if confirmation.WhichOneof("payload") != "heartbeat":
|
||||
raise RuntimeError(
|
||||
"That GPU machine sent an invalid registration confirmation."
|
||||
)
|
||||
|
||||
session = self._servicer.session_for(
|
||||
response.worker_id, session_token=response.session_token
|
||||
)
|
||||
if session is None:
|
||||
raise RuntimeError("the session went away before the stream opened")
|
||||
|
||||
self._worker_id = response.worker_id
|
||||
self._stub = stub
|
||||
self._last_error = ""
|
||||
self._active_session = session
|
||||
self._remote_protocol_retained = True
|
||||
self._shutdown_confirmed.clear()
|
||||
self._session_closed.clear()
|
||||
|
||||
pump = asyncio.create_task(self._pump_outbound(session))
|
||||
try:
|
||||
await self._servicer.run_inbound_stream(
|
||||
session, _Frames(stream, first=confirmation), self
|
||||
)
|
||||
finally:
|
||||
pump.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError, Exception):
|
||||
await pump
|
||||
self._stub = None
|
||||
self._worker_id = ""
|
||||
if self._active_session is session:
|
||||
self._active_session = None
|
||||
self._session_closed.set()
|
||||
|
||||
async def _register(self, request: pb.RegisterRequest) -> pb.RegisterResponse:
|
||||
def _register(self, request: pb.RegisterRequest) -> pb.RegisterResponse:
|
||||
"""Trust on first sight, then require the same key forever after.
|
||||
|
||||
Pasting the connection string is the consent — the user went to the
|
||||
@@ -474,28 +175,14 @@ class NodeConnection:
|
||||
is a licence for a different machine to answer at that address later,
|
||||
which is why the key is bound on first contact.
|
||||
"""
|
||||
refusal = self._servicer.validate_inbound_request(request)
|
||||
if refusal is not None:
|
||||
return refusal
|
||||
worker, refusal = await to_thread_and_drain_on_cancel(
|
||||
self._authenticate_registration, request
|
||||
)
|
||||
if refusal is not None:
|
||||
return refusal
|
||||
return await self._servicer.establish_session(
|
||||
worker, request, address=self._connection.endpoint
|
||||
)
|
||||
|
||||
def _authenticate_registration(self, request: pb.RegisterRequest):
|
||||
"""Resolve inbound identity without running SQLite on the app loop."""
|
||||
public_key = bytes(request.public_key)
|
||||
if len(public_key) != 32:
|
||||
return None, self._servicer._refuse(
|
||||
return self._servicer._refuse(
|
||||
"AUTH_FAILED", "That machine sent no usable identity."
|
||||
)
|
||||
key_id = identity.key_id_for(public_key)
|
||||
if registry.is_revoked(key_id):
|
||||
return None, self._servicer._refuse(
|
||||
return self._servicer._refuse(
|
||||
"AUTH_FAILED",
|
||||
"This GPU machine was removed from this app. Add it again to use it.",
|
||||
)
|
||||
@@ -532,12 +219,14 @@ class NodeConnection:
|
||||
)
|
||||
worker = known
|
||||
if worker is None:
|
||||
return None, self._servicer._refuse(
|
||||
return self._servicer._refuse(
|
||||
"AUTH_FAILED",
|
||||
"That machine could not prove it is the one this key was added for.",
|
||||
)
|
||||
|
||||
return worker, None
|
||||
return self._servicer.register_inbound(
|
||||
worker, request, address=self._connection.endpoint
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _proves_key_possession(request: pb.RegisterRequest, known) -> bool:
|
||||
@@ -560,34 +249,9 @@ class NodeConnection:
|
||||
public_key, message, bytes(request.challenge_signature)
|
||||
)
|
||||
|
||||
def _outbound(self):
|
||||
# grpc closes request iterators itself when the peer ends a stream. An
|
||||
# async generator can still be suspended in ``Queue.get`` at that
|
||||
# point, making its concurrent ``aclose`` fail and leak teardown into
|
||||
# the next channel. A plain async iterator has no generator-finalizer
|
||||
# race and keeps the same one-frame-at-a-time backpressure.
|
||||
return _OutboundFrames(self)
|
||||
|
||||
def fence_session_egress(self, session) -> None:
|
||||
"""Drop frames copied before a replacement generation activated."""
|
||||
if self._active_session is not session:
|
||||
return
|
||||
async def _outbound(self):
|
||||
while True:
|
||||
try:
|
||||
self._outbox.get_nowait()
|
||||
except asyncio.QueueEmpty:
|
||||
break
|
||||
|
||||
def revoke_session(self, session) -> None:
|
||||
"""Synchronously fence frames already copied into the request queue."""
|
||||
if self._active_session is not session:
|
||||
return
|
||||
while True:
|
||||
try:
|
||||
self._outbox.get_nowait()
|
||||
except asyncio.QueueEmpty:
|
||||
break
|
||||
self._outbox.put_nowait(None)
|
||||
yield await self._outbox.get()
|
||||
|
||||
async def _pump_outbound(self, session) -> None:
|
||||
"""Move the servicer's per-session outbox onto the dialled stream.
|
||||
@@ -596,18 +260,8 @@ class NodeConnection:
|
||||
to cross into the request generator instead, because this side is the
|
||||
caller.
|
||||
"""
|
||||
task = asyncio.current_task()
|
||||
if task is not None:
|
||||
session.egress_tasks.add(task)
|
||||
try:
|
||||
while not session.revoked and not getattr(session, "egress_fenced", False):
|
||||
message = await session.outbox.get()
|
||||
if session.revoked or getattr(session, "egress_fenced", False):
|
||||
return
|
||||
await self._outbox.put(message)
|
||||
finally:
|
||||
if task is not None:
|
||||
session.egress_tasks.discard(task)
|
||||
while True:
|
||||
await self._outbox.put(await session.outbox.get())
|
||||
|
||||
# ── Artifacts ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -623,52 +277,32 @@ class NodeConnection:
|
||||
if stub is None:
|
||||
raise RuntimeError("that GPU machine is not connected")
|
||||
|
||||
size, digest, version = await to_thread_and_drain_on_cancel(
|
||||
_hash_staged_input, path
|
||||
)
|
||||
# Hashing and the gRPC request are separate operations. Re-resolve the
|
||||
# path immediately before handing the iterator to gRPC so a replaced
|
||||
# staging file is never described by the old generation's digest.
|
||||
await to_thread_and_drain_on_cancel(_validate_staged_input, path, version)
|
||||
size = os.path.getsize(path)
|
||||
digest = hashlib.sha256()
|
||||
with open(path, "rb") as handle:
|
||||
digest.update(handle.read())
|
||||
|
||||
declared = pb.ArtifactRef()
|
||||
declared.CopyFrom(ref)
|
||||
declared.size_bytes = size
|
||||
declared.sha256 = digest
|
||||
declared.sha256 = digest.hexdigest()
|
||||
if not declared.filename:
|
||||
declared.filename = os.path.basename(path)
|
||||
|
||||
async def chunks():
|
||||
offset = 0
|
||||
handle = await to_thread_and_drain_on_cancel(open, path, "rb")
|
||||
try:
|
||||
await to_thread_and_drain_on_cancel(
|
||||
_validate_open_staged_input, handle, path, version
|
||||
)
|
||||
while offset < size:
|
||||
data = await to_thread_and_drain_on_cancel(
|
||||
handle.read, min(_PUSH_CHUNK_BYTES, size - offset)
|
||||
)
|
||||
with open(path, "rb") as handle:
|
||||
while True:
|
||||
data = handle.read(_PUSH_CHUNK_BYTES)
|
||||
if not data:
|
||||
raise RuntimeError(
|
||||
"the staged task input changed before it could be sent"
|
||||
)
|
||||
break
|
||||
offset += len(data)
|
||||
last = offset == size
|
||||
if last:
|
||||
# Do not publish the terminal frame until both the open
|
||||
# generation and its path still match what was hashed.
|
||||
await to_thread_and_drain_on_cancel(
|
||||
_validate_open_staged_input, handle, path, version
|
||||
)
|
||||
yield pb.ArtifactChunk(
|
||||
ref=declared,
|
||||
offset=offset - len(data),
|
||||
data=data,
|
||||
last=last,
|
||||
last=offset >= size,
|
||||
)
|
||||
finally:
|
||||
await to_thread_and_drain_on_cancel(handle.close)
|
||||
|
||||
ack = await stub.PushInput(
|
||||
chunks(), metadata=((KEY_METADATA_KEY, self._connection.secret),)
|
||||
@@ -693,11 +327,7 @@ class NodeConnection:
|
||||
offset = 0
|
||||
complete = False
|
||||
try:
|
||||
handle = None
|
||||
try:
|
||||
handle = await to_thread_and_drain_on_cancel(
|
||||
open, destination, "wb"
|
||||
)
|
||||
with open(destination, "wb") as handle:
|
||||
async for chunk in stub.FetchResult(
|
||||
request, metadata=((KEY_METADATA_KEY, self._connection.secret),)
|
||||
):
|
||||
@@ -709,31 +339,27 @@ class NodeConnection:
|
||||
raise RuntimeError(
|
||||
"the result is larger than the control plane accepts"
|
||||
)
|
||||
data = bytes(chunk.data)
|
||||
await to_thread_and_drain_on_cancel(_write_all, handle, data)
|
||||
digest.update(data)
|
||||
offset += len(data)
|
||||
handle.write(chunk.data)
|
||||
digest.update(chunk.data)
|
||||
offset += len(chunk.data)
|
||||
if chunk.last:
|
||||
complete = True
|
||||
break
|
||||
finally:
|
||||
if handle is not None:
|
||||
await to_thread_and_drain_on_cancel(handle.close)
|
||||
except asyncio.CancelledError:
|
||||
await to_thread_and_drain_on_cancel(_remove_quietly, destination)
|
||||
raise
|
||||
except Exception:
|
||||
await to_thread_and_drain_on_cancel(_remove_quietly, destination)
|
||||
with contextlib.suppress(OSError):
|
||||
os.remove(destination)
|
||||
raise
|
||||
|
||||
# A truncated file that is renamed into place and called done is the
|
||||
# exact failure the upload path was hardened against; the pull
|
||||
# direction gets the same treatment.
|
||||
if not complete:
|
||||
await to_thread_and_drain_on_cancel(_remove_quietly, destination)
|
||||
with contextlib.suppress(OSError):
|
||||
os.remove(destination)
|
||||
raise RuntimeError("the result ended before its final chunk")
|
||||
if ref.sha256 and digest.hexdigest() != ref.sha256:
|
||||
await to_thread_and_drain_on_cancel(_remove_quietly, destination)
|
||||
with contextlib.suppress(OSError):
|
||||
os.remove(destination)
|
||||
raise RuntimeError(
|
||||
"the result did not match the checksum that machine declared"
|
||||
)
|
||||
@@ -742,46 +368,14 @@ class NodeConnection:
|
||||
class _Frames:
|
||||
"""Adapts a gRPC client stream to the ``async for`` the read loop expects."""
|
||||
|
||||
def __init__(self, stream, *, first=None) -> None:
|
||||
def __init__(self, stream) -> None:
|
||||
self._stream = stream
|
||||
self._first = first
|
||||
|
||||
def __aiter__(self):
|
||||
return self
|
||||
|
||||
async def __anext__(self):
|
||||
if self._first is not None:
|
||||
message = self._first
|
||||
self._first = None
|
||||
return message
|
||||
message = await self._stream.read()
|
||||
if message == grpc.aio.EOF:
|
||||
raise StopAsyncIteration
|
||||
return message
|
||||
|
||||
|
||||
class _OutboundFrames:
|
||||
"""Cancellation-safe request iterator for the inverted Attach stream."""
|
||||
|
||||
def __init__(self, connection: NodeConnection) -> None:
|
||||
self._connection = connection
|
||||
|
||||
def __aiter__(self):
|
||||
return self
|
||||
|
||||
async def __anext__(self):
|
||||
connection = self._connection
|
||||
while True:
|
||||
message = await connection._outbox.get()
|
||||
if message is None:
|
||||
raise StopAsyncIteration
|
||||
session = connection._active_session
|
||||
if session is not None:
|
||||
if session.revoked:
|
||||
raise StopAsyncIteration
|
||||
if (
|
||||
getattr(session, "egress_fenced", False)
|
||||
and message.WhichOneof("payload") != "shutdown"
|
||||
):
|
||||
continue
|
||||
return message
|
||||
|
||||
+17
-192
@@ -15,7 +15,6 @@ settings store.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -46,20 +45,6 @@ _MAX_FAILURES = 5
|
||||
_LOCKOUT_SECONDS = 60.0
|
||||
_FAILURE_WINDOW_SECONDS = 300.0
|
||||
|
||||
# ``Attach`` is the only RPC that records presence. Persisting on every
|
||||
# reconnect lets an authenticated peer turn harmless telemetry into an fsync
|
||||
# storm on the gRPC event loop, so coalesce it to a useful reporting cadence.
|
||||
_LAST_SEEN_PERSIST_INTERVAL_SECONDS = 60.0
|
||||
|
||||
# Authentication deliberately scans every stored hash in constant time. Keep
|
||||
# that work and the JSON credential file bounded even if an administrator
|
||||
# repeatedly issues replacements.
|
||||
MAX_PANEL_KEYS = 256
|
||||
|
||||
|
||||
class KeyLimitExceeded(RuntimeError):
|
||||
"""No additional panel credential can be retained safely."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class PanelKey:
|
||||
@@ -104,31 +89,6 @@ def _peer_host(peer: str) -> str:
|
||||
return peer
|
||||
|
||||
|
||||
def _fsync_parent_directory(directory: str) -> None:
|
||||
"""Make a preceding directory-entry replacement durable when supported."""
|
||||
directory_flag = getattr(os, "O_DIRECTORY", None)
|
||||
if directory_flag is None:
|
||||
return
|
||||
unsupported = {
|
||||
errno.EINVAL,
|
||||
getattr(errno, "ENOTSUP", errno.EINVAL),
|
||||
getattr(errno, "EOPNOTSUPP", errno.EINVAL),
|
||||
}
|
||||
try:
|
||||
descriptor = os.open(directory, os.O_RDONLY | directory_flag)
|
||||
except OSError as exc:
|
||||
if exc.errno in unsupported:
|
||||
return
|
||||
raise
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
except OSError as exc:
|
||||
if exc.errno not in unsupported:
|
||||
raise
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
@dataclass
|
||||
class IssuedKey:
|
||||
"""The one and only time the plaintext exists outside the caller's hands."""
|
||||
@@ -153,10 +113,6 @@ class KeyStore:
|
||||
self._connection_secrets: dict[str, str] = {}
|
||||
self._connection_fingerprints: dict[str, str] = {}
|
||||
self._failures: dict[str, _Failures] = {}
|
||||
# A failed persistence attempt must remain denied in this process but
|
||||
# still be retryable. Keeping this separate from PanelKey.revoked lets
|
||||
# the next DELETE attempt write the durable transition again.
|
||||
self._pending_revocations: set[str] = set()
|
||||
self._load()
|
||||
|
||||
# ── Persistence ───────────────────────────────────────────────────────
|
||||
@@ -214,31 +170,10 @@ class KeyStore:
|
||||
# `identity.save_worker_key` uses for the Ed25519 private key.
|
||||
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
try:
|
||||
remaining = memoryview(payload)
|
||||
while remaining:
|
||||
written = os.write(fd, remaining)
|
||||
if written <= 0:
|
||||
raise OSError("could not finish writing the inbound key file")
|
||||
remaining = remaining[written:]
|
||||
os.fsync(fd)
|
||||
except Exception:
|
||||
os.write(fd, payload)
|
||||
finally:
|
||||
os.close(fd)
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
raise
|
||||
else:
|
||||
os.close(fd)
|
||||
try:
|
||||
os.replace(tmp, self._path)
|
||||
except Exception:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
raise
|
||||
_fsync_parent_directory(directory)
|
||||
os.replace(tmp, self._path)
|
||||
try:
|
||||
os.chmod(self._path, 0o600)
|
||||
except OSError:
|
||||
@@ -261,37 +196,8 @@ class KeyStore:
|
||||
created_at=now,
|
||||
)
|
||||
with self._lock:
|
||||
previous = self._keys.get(key.key_id)
|
||||
pruned: dict[str, PanelKey] = {}
|
||||
if previous is None and len(self._keys) >= MAX_PANEL_KEYS:
|
||||
revoked = sorted(
|
||||
(
|
||||
stored
|
||||
for stored in self._keys.values()
|
||||
if stored.revoked
|
||||
and stored.key_id not in self._pending_revocations
|
||||
),
|
||||
key=lambda stored: stored.created_at,
|
||||
)
|
||||
while len(self._keys) >= MAX_PANEL_KEYS and revoked:
|
||||
stale = revoked.pop(0)
|
||||
pruned[stale.key_id] = self._keys.pop(stale.key_id)
|
||||
if len(self._keys) >= MAX_PANEL_KEYS:
|
||||
self._keys.update(pruned)
|
||||
raise KeyLimitExceeded(
|
||||
"This GPU machine already has as many panel keys as it accepts. "
|
||||
"Revoke an unused key, then try again."
|
||||
)
|
||||
self._keys[key.key_id] = key
|
||||
try:
|
||||
self._save_locked()
|
||||
except Exception:
|
||||
if previous is None:
|
||||
self._keys.pop(key.key_id, None)
|
||||
else:
|
||||
self._keys[key.key_id] = previous
|
||||
self._keys.update(pruned)
|
||||
raise
|
||||
self._save_locked()
|
||||
return IssuedKey(key=key, secret=secret)
|
||||
|
||||
def revoke(self, key_id: str) -> bool:
|
||||
@@ -300,14 +206,8 @@ class KeyStore:
|
||||
key = self._keys.get(key_id)
|
||||
if key is None or key.revoked:
|
||||
return False
|
||||
self._pending_revocations.add(key_id)
|
||||
key.revoked = True
|
||||
try:
|
||||
self._save_locked()
|
||||
except Exception:
|
||||
key.revoked = False
|
||||
raise
|
||||
self._pending_revocations.discard(key_id)
|
||||
self._save_locked()
|
||||
return True
|
||||
|
||||
def remember_worker_id(self, key_id: str, worker_id: str) -> None:
|
||||
@@ -316,46 +216,15 @@ class KeyStore:
|
||||
return
|
||||
with self._lock:
|
||||
key = self._keys.get(key_id)
|
||||
if (
|
||||
key is None
|
||||
or key.revoked
|
||||
or key_id in self._pending_revocations
|
||||
):
|
||||
raise PermissionError("the panel key was revoked during registration")
|
||||
if key.worker_id == worker_id:
|
||||
if key is None or key.worker_id == worker_id:
|
||||
return
|
||||
previous_worker_id = key.worker_id
|
||||
key.worker_id = worker_id
|
||||
try:
|
||||
self._save_locked()
|
||||
except Exception:
|
||||
# A callback retry must attempt the durable write again. If
|
||||
# the failed value remains in memory, the equality fast path
|
||||
# above accepts it as saved and the node reconnects with an id
|
||||
# that disappears on process restart.
|
||||
key.worker_id = previous_worker_id
|
||||
raise
|
||||
self._save_locked()
|
||||
|
||||
def worker_id_for(self, key_id: str) -> str:
|
||||
with self._lock:
|
||||
key = self._keys.get(key_id)
|
||||
return (
|
||||
key.worker_id
|
||||
if key is not None
|
||||
and not key.revoked
|
||||
and key_id not in self._pending_revocations
|
||||
else ""
|
||||
)
|
||||
|
||||
def is_active(self, key_id: str) -> bool:
|
||||
"""Whether this key still has authority to use an existing session."""
|
||||
with self._lock:
|
||||
key = self._keys.get(key_id)
|
||||
return (
|
||||
key is not None
|
||||
and not key.revoked
|
||||
and key_id not in self._pending_revocations
|
||||
)
|
||||
return key.worker_id if key is not None else ""
|
||||
|
||||
def list_keys(self) -> list[dict]:
|
||||
with self._lock:
|
||||
@@ -363,10 +232,7 @@ class KeyStore:
|
||||
|
||||
def any_active(self) -> bool:
|
||||
with self._lock:
|
||||
return any(
|
||||
not key.revoked and key.key_id not in self._pending_revocations
|
||||
for key in self._keys.values()
|
||||
)
|
||||
return any(not k.revoked for k in self._keys.values())
|
||||
|
||||
# ── Panel-side connection credentials ───────────────────────────────
|
||||
|
||||
@@ -375,23 +241,10 @@ class KeyStore:
|
||||
) -> None:
|
||||
"""Persist a pasted node secret outside the UI-readable settings store."""
|
||||
with self._lock:
|
||||
previous_secret = self._connection_secrets.get(endpoint)
|
||||
previous_fingerprint = self._connection_fingerprints.get(endpoint)
|
||||
self._connection_secrets[endpoint] = secret
|
||||
if fingerprint:
|
||||
self._connection_fingerprints[endpoint] = fingerprint
|
||||
try:
|
||||
self._save_locked()
|
||||
except Exception:
|
||||
if previous_secret is None:
|
||||
self._connection_secrets.pop(endpoint, None)
|
||||
else:
|
||||
self._connection_secrets[endpoint] = previous_secret
|
||||
if previous_fingerprint is None:
|
||||
self._connection_fingerprints.pop(endpoint, None)
|
||||
else:
|
||||
self._connection_fingerprints[endpoint] = previous_fingerprint
|
||||
raise
|
||||
self._save_locked()
|
||||
|
||||
def connection_secret(self, endpoint: str) -> str:
|
||||
with self._lock:
|
||||
@@ -403,19 +256,9 @@ class KeyStore:
|
||||
|
||||
def forget_connection_secret(self, endpoint: str) -> None:
|
||||
with self._lock:
|
||||
previous_secret = self._connection_secrets.get(endpoint)
|
||||
if previous_secret is None:
|
||||
return
|
||||
previous_fingerprint = self._connection_fingerprints.get(endpoint)
|
||||
self._connection_secrets.pop(endpoint, None)
|
||||
self._connection_fingerprints.pop(endpoint, None)
|
||||
try:
|
||||
if self._connection_secrets.pop(endpoint, None) is not None:
|
||||
self._connection_fingerprints.pop(endpoint, None)
|
||||
self._save_locked()
|
||||
except Exception:
|
||||
self._connection_secrets[endpoint] = previous_secret
|
||||
if previous_fingerprint is not None:
|
||||
self._connection_fingerprints[endpoint] = previous_fingerprint
|
||||
raise
|
||||
|
||||
# ── Authentication ────────────────────────────────────────────────────
|
||||
|
||||
@@ -425,9 +268,7 @@ class KeyStore:
|
||||
record = self._failures.get(peer)
|
||||
return record is not None and record.locked_until > self._now()
|
||||
|
||||
def authenticate(
|
||||
self, secret: str, *, peer: str = "", record_seen: bool = True
|
||||
) -> Optional[PanelKey]:
|
||||
def authenticate(self, secret: str, *, peer: str = "") -> Optional[PanelKey]:
|
||||
"""Return the matching live key, or None.
|
||||
|
||||
Compares against every stored key in constant time and does not stop at
|
||||
@@ -445,11 +286,7 @@ class KeyStore:
|
||||
candidate = hash_secret(secret) if secret else ""
|
||||
matched: Optional[PanelKey] = None
|
||||
for key in self._keys.values():
|
||||
if (
|
||||
key.revoked
|
||||
or key.key_id in self._pending_revocations
|
||||
or not candidate
|
||||
):
|
||||
if key.revoked or not candidate:
|
||||
continue
|
||||
if constant_time_equals(key.secret_hash, candidate):
|
||||
matched = key
|
||||
@@ -459,21 +296,9 @@ class KeyStore:
|
||||
return None
|
||||
|
||||
self._failures.pop(peer_host, None)
|
||||
if record_seen and (
|
||||
matched.last_seen_at <= 0.0
|
||||
or now - matched.last_seen_at
|
||||
>= _LAST_SEEN_PERSIST_INTERVAL_SECONDS
|
||||
):
|
||||
previous_at = matched.last_seen_at
|
||||
previous_peer = matched.last_seen_peer
|
||||
matched.last_seen_at = now
|
||||
matched.last_seen_peer = peer
|
||||
try:
|
||||
self._save_locked()
|
||||
except Exception:
|
||||
matched.last_seen_at = previous_at
|
||||
matched.last_seen_peer = previous_peer
|
||||
raise
|
||||
matched.last_seen_at = now
|
||||
matched.last_seen_peer = peer
|
||||
self._save_locked()
|
||||
return matched
|
||||
|
||||
def _record_failure_locked(self, peer: str, now: float) -> None:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user