Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6ee314ff2 | ||
|
|
e6f4c766f5 | ||
|
|
c4ea6a14b0 | ||
|
|
751f04078d | ||
|
|
2926ce615a | ||
|
|
7e64d13739 | ||
|
|
5151243ee4 | ||
|
|
eaee379dd5 | ||
|
|
0d81123954 | ||
|
|
df2da4bb4d | ||
|
|
37c8be6bfe | ||
|
|
c818d235fb | ||
|
|
09ba4feb1c | ||
|
|
08791175f9 | ||
|
|
4fa1b31eef | ||
|
|
8654bb0225 | ||
|
|
6111b8e4ae | ||
|
|
b1f322dde2 | ||
|
|
1a9a70509e | ||
|
|
e877572c1a | ||
|
|
1f03f5632c | ||
|
|
4335c8c1ea | ||
|
|
dcd8683f3a | ||
|
|
b2f94d2bf8 |
@@ -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
|
||||
@@ -5,9 +5,6 @@ description: "Local TTS, voice cloning, voice design, and video dubbing via the
|
||||
|
||||
# VoiceStudio
|
||||
|
||||
The canonical cross-agent package lives at `skills/omnivoice/SKILL.md`. This
|
||||
Claude-specific package retains the MCP lifecycle helpers and references.
|
||||
|
||||
## Overview
|
||||
|
||||
Generate audio locally via the VoiceStudio MCP server. Tools: `generate_speech`, `list_voices`, `list_personalities`, `list_languages`, `check_health`. Resources: `voice://{id}`, `history://recent`.
|
||||
@@ -169,4 +166,4 @@ The MCP server does not expose the dubbing endpoint. The full transcribe → tra
|
||||
|
||||
Backend Swagger / OpenAPI: `http://127.0.0.1:3900/docs` (when backend is up).
|
||||
|
||||
Upstream: github.com/debpalash/VoiceStudio. The app uses AGPL-3.0-only; optional engines and downloaded models retain their own licenses. See `LICENSE-NOTICE.md` in the repository.
|
||||
Upstream: github.com/debpalash/VoiceStudio — FSL-1.1-ALv2 (free for personal/internal/non-commercial; auto-converts to Apache-2.0 two years after each release).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Start the OmniVoice FastAPI backend on 127.0.0.1:3900, detached, idempotent.
|
||||
# Honors $OMNIVOICE_HOME (default ~/VoiceStudio).
|
||||
# Honors $OMNIVOICE_HOME (default ~/OmniVoice-Studio).
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 success (already running, or freshly started + healthy within 60s)
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
HOME_DIR="${OMNIVOICE_HOME:-$HOME/VoiceStudio}"
|
||||
HOME_DIR="${OMNIVOICE_HOME:-$HOME/OmniVoice-Studio}"
|
||||
URL="${OMNIVOICE_API_URL:-http://127.0.0.1:3900}"
|
||||
LOG="$HOME_DIR/backend.log"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -649,46 +649,6 @@ jobs:
|
||||
updaterJsonPreferNsis: false
|
||||
includeUpdaterJson: true
|
||||
|
||||
- name: Build per-user Windows MSI
|
||||
if: runner.os == 'Windows'
|
||||
shell: bash
|
||||
working-directory: frontend
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python ../scripts/render-per-user-wix.py \
|
||||
--source src-tauri/wix/main.wxs \
|
||||
--output src-tauri/target/wix-per-user/main.wxs
|
||||
bunx tauri build --target ${{ matrix.rust_target }} --bundles msi \
|
||||
--config src-tauri/tauri.per-user.conf.json
|
||||
DIR="src-tauri/target/${{ matrix.rust_target }}/release/bundle/msi"
|
||||
while IFS= read -r artifact; do
|
||||
safe=${artifact// (Current User)/_Current_User}
|
||||
[ "$safe" = "$artifact" ] || mv "$artifact" "$safe"
|
||||
done < <(find "$DIR" -maxdepth 1 -type f -name '*Current*User*.msi*')
|
||||
|
||||
- name: Publish per-user Windows updater channel
|
||||
if: runner.os == 'Windows'
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_TAG: ${{ (needs.preview-gate.outputs.is_preview == 'true') && 'preview' || github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
DIR="frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle/msi"
|
||||
MSI=$(find "$DIR" -name '*Current*User*.msi' -type f | head -1)
|
||||
[ -n "$MSI" ] || { echo "per-user MSI missing"; find "$DIR" -type f; exit 1; }
|
||||
[ -f "$MSI.sig" ] || { echo "per-user MSI signature missing"; exit 1; }
|
||||
VERSION=$(jq -r .version frontend/package.json)
|
||||
python scripts/build_windows_user_manifest.py \
|
||||
--repo "$GITHUB_REPOSITORY" --tag "$RELEASE_TAG" --version "$VERSION" \
|
||||
--asset "$(basename "$MSI")" --signature-file "$MSI.sig" \
|
||||
--output latest-user.json
|
||||
gh release upload "$RELEASE_TAG" "$MSI" "$MSI.sig" latest-user.json \
|
||||
--clobber --repo "$GITHUB_REPOSITORY"
|
||||
|
||||
# ── Installer smoke (Phase 0 GATE-03) ─────────────────────────────
|
||||
# Structural verification of the installed/extracted bundle. The thin
|
||||
# uv-venv installer ships NO frozen backend binary (the venv is built on
|
||||
@@ -757,9 +717,8 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
MSI=$(find frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle/msi -name "*.msi" ! -name '*Current*User*' | head -1)
|
||||
MSI=$(find frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle/msi -name "*.msi" | head -1)
|
||||
echo "Smoke-testing MSI: $MSI"
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/verify-windows-msi.ps1 -MsiPath "$(cygpath -w "$MSI")"
|
||||
# /quiet = no UI, /norestart = don't reboot the runner if a dep asks
|
||||
msiexec.exe //i "$(cygpath -w "$MSI")" //quiet //norestart
|
||||
INSTALL="/c/Program Files/VoiceStudio"
|
||||
@@ -772,16 +731,6 @@ jobs:
|
||||
find "$INSTALL" -type f -path '*backend*main.py' | grep -q . || fail "backend source main.py missing"
|
||||
echo "OK — MSI installed shell + uv + backend resources"
|
||||
|
||||
- name: Per-user installer smoke (Windows, non-admin account)
|
||||
if: runner.os == 'Windows'
|
||||
timeout-minutes: 8
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
MSI=$(find frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle/msi -name '*Current*User*.msi' | head -1)
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass \
|
||||
-File scripts/smoke-per-user-msi.ps1 -MsiPath "$(cygpath -w "$MSI")"
|
||||
|
||||
# linuxdeploy re-links .DirIcon as an ABSOLUTE symlink into the build
|
||||
# machine AFTER tauri's files-map has placed the real icon bytes — the
|
||||
# exact bug #1518 guarded against, resurfacing on the first real tag
|
||||
|
||||
@@ -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`.
|
||||
|
||||
+5
-121
@@ -10,142 +10,26 @@ the frozen-backend fallback mirror it for their toolchains.
|
||||
|
||||
**Highlights**
|
||||
|
||||
- Show estimated and measured model, dependency, cache, and temporary disk costs in the engine catalogue (#1718)
|
||||
- CosyVoice setup guidance now separates downloaded model files from the runtime that makes the engine available.
|
||||
- The backend now answers within a second of launch and narrates its startup step by step
|
||||
- Reporting a bug from an outdated build now offers the latest release first
|
||||
- The backend is only announced ready once it can actually serve, and crash-loop restarts now pace themselves
|
||||
|
||||
### Changed
|
||||
|
||||
### Added
|
||||
|
||||
- Windows releases now include an independently updatable per-user MSI that installs and uninstalls without elevation (#1713)
|
||||
- Engine status and diagnostic bundles now record loaded execution provider, device, precision, fallback stage, accelerator identity, runtime versions, and parent-process memory visibility (#1717)
|
||||
|
||||
### Docs
|
||||
|
||||
- The CosyVoice guide now states that packaged builds have no one-click runtime installer and records the exact readiness checks exposed by [Discussion 1631](https://github.com/debpalash/VoiceStudio/discussions/1631).
|
||||
- A production private-API guide now covers pinned containers, root credentials, network isolation, streaming proxies, health checks, upgrades, and benchmark evidence (#1720)
|
||||
- RX 6700 XT/gfx1031 over WSL2 ROCDXG is now explicitly unverified until a published end-to-end GPU workload proves the mapped path (#1716)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Dubbing jobs can now reuse every source-language code produced by automatic ASR detection without a 400 error on the next upload (#1737)
|
||||
- Incomplete Sherpa-ONNX model snapshots now self-repair before recognizer startup instead of failing on a missing ONNX file (#1733)
|
||||
- OmniVoice subprocess startup now allows slow packaged Windows Python runtimes to signal readiness before termination (#1711)
|
||||
- SRT files selected during source analysis now wait for speaker cloning, then replace transcript text without losing voices (#1709)
|
||||
- Windows MSI deployments can now prohibit WebView2 bootstrap with `DISABLEWEBVIEW2BOOTSTRAP=1`, and `AUTOLAUNCHAPP=0` reliably suppresses first launch (#1714)
|
||||
- Subtitle rows now provide 100 ms timing steppers and flag adjacent overlaps without requiring precise timeline dragging (#1710)
|
||||
- Repair-sync failures now retain uv's final dependency error instead of reporting only an opaque exit status (#1705)
|
||||
- YouTube ingest now retries yt-dlp's transient “page needs to be reloaded” response (#1706)
|
||||
- Dictation model readiness now follows the live Hugging Face cache selected in Settings (#1707)
|
||||
- Dictation capture now queues native events whenever its webview listener unmounts or reloads instead of emitting them to nobody (#1707)
|
||||
- Desktop-contained backends now exit when their owning app disappears instead of surviving as stale port-3900 processes (#1707)
|
||||
|
||||
## [0.5.1] — 2026-08-28
|
||||
|
||||
**Highlights**
|
||||
|
||||
- OmniVoice generation on Apple Silicon now runs in a crash-isolated child, so fatal MPS memory exits no longer take down the local backend (#1697, #1698) — thanks @ndntran14!
|
||||
- Model-load GPU exhaustion now returns a sanitized, actionable dubbing error, and readiness correctly attributes the shared model status to TTS (#1695)
|
||||
- Source-mode development now restarts an isolated backend crash without tearing down the UI, while repeated crash loops still stop loudly with diagnostics (#1690)
|
||||
- Dubbing playback now keeps an audible companion source when a WebView can render the preview picture but cannot decode its audio (#1692)
|
||||
- Model Catalogue engine rows now use the available desktop width and keep identity, runtime state, and actions from crowding one another (#1689)
|
||||
- VoiceStudio now acts as a local speech platform: other apps can trigger its native dictation or connect through versioned HTTP, WebSocket, JSON-RPC, CLI, and MCP transports (#1646)
|
||||
- A timed-out in-process dub transcription no longer starts a second WhisperX/CTranslate2 call over the abandoned native worker, preventing the overlapping access that preceded Windows `0xC0000005` exits (#1669)
|
||||
- Windows debugger termination code `0x40010004` is no longer misreported as a backend crash or charged against automatic restart recovery (#1663)
|
||||
- Studio now keeps one generation reservation across page changes, preventing a remount from stacking native jobs until the backend reports capacity busy or is killed under memory pressure (#1670)
|
||||
- Uploaded dubbing videos are normalized to browser-safe H.264/AAC before preview, preventing valid VP9, AV1, or Opus media from failing with “no supported sources” (#1644)
|
||||
- Dubbing now separates spoken and target languages, preserves translations through segment cleanup, and lets failed translations be retried or skipped without restarting the batch (#1654) — thanks @Number16BusShelter!
|
||||
- Importing replacement SRT subtitles now keeps each cue bound to the best-overlapping source speaker and clone instead of resetting every line to a random default voice (#1660) — thanks @invio-a11y!
|
||||
- Uploading a Dub preview no longer blocks every backend request while ffmpeg extracts its audio (#1667) — thanks @tfreyd!
|
||||
- Docker quick starts now require the administrator key needed through container NAT instead of starting a UI whose protected actions return 403 (#1651) — thanks @wd357dui!
|
||||
- WSL2 AMD containers now use the `/dev/dxg` ROCDXG bridge with actionable GPU diagnostics instead of silently falling back to CPU (#1655) — thanks @wd357dui!
|
||||
- Ad-hoc voice-clone references now stay alive until cancelled or timed-out GPU work actually stops reading them, so prompt caching can finish instead of failing on a deleted temp file (#1668) — thanks @tfreyd!
|
||||
- Dictation now stays bound to the app where it started and recovers locally from silent recognizer output (#1175)
|
||||
- The backend now answers within a second of launch and narrates its startup step by step (#1550)
|
||||
- Reporting a bug from an outdated build now offers the latest release first (#1547)
|
||||
- The backend is only announced ready once it can actually serve, and crash-loop restarts now pace themselves (#1548)
|
||||
- Invisible watermarking no longer stalls — or silently skips — the first take of a session (#1615)
|
||||
- Dub subtitles can be retimed, inserted, and merged in either direction from the segment table (#1612) — thanks @invio-a11y!
|
||||
|
||||
### Changed
|
||||
- Model Catalogue now uses one breathable workspace canvas with simpler pane and engine-family navigation instead of nested cards and scroll regions (#1685)
|
||||
- Linux source launchers now catch missing libxdo and GStreamer audio plugins before they can cause a linker error or an aborted, blank WebKit renderer (#1680, #1682)
|
||||
- Dictation now carries one native output session from shortcut-down through final delivery, restores text, HTML, image, or file-list clipboards only when untouched, keeps Wayland copy-safe unless current-focus insertion is explicitly enabled, and retries silent Sherpa speech only through an already-installed local ASR model (#1175)
|
||||
- The backend binds its port immediately and reports startup progress live — `/health` answers 503-with-step and a new `/startup/progress` endpoint lists every step while PyTorch, API routes, and database migrations load in the background, so "starting at step X" is never mistakable for "dead"; the desktop splash narrates each step (#1550)
|
||||
|
||||
### Added
|
||||
- A bundled Rust loopback sidecar exposes dictation start/stop/toggle, focused-output sessions, discovery, and JSON-RPC; the backend adds versioned streaming events and a dependency-free CLI bridge for Herdr, coding agents, editors, desktop apps, and TUIs (#1646)
|
||||
- Headless NVIDIA and ROCm machines can now join as worker-only Docker Compose services with no published UI and durable protocol-v2 enrollment; update both machines together before reconnecting (#1638) — thanks @jkrogers9862!
|
||||
- Linux ARM64 (Asahi Apple Silicon) support for the OmniVoice GGUF engine — a `linux-aarch64` binary built with GGML Vulkan where the toolchain allows it, so Apple GPUs accelerate generation through the open-source Honeykrisp driver instead of falling back to CPU-only (#1641)
|
||||
- One-command install on every desktop OS: `curl -fsSL https://voicestudio.sh/install | sh` (macOS/Linux/WSL) or `irm https://voicestudio.sh/install | iex` (Windows) — the URL serves the right script per platform, and Windows gains a source installer (`scripts/install.ps1`) with a 3-OS CI smoke (#1626)
|
||||
- Per-line subtitle management in the dub table: a line's end time is editable alongside its start (typing a time and dragging its timeline edge now take the same path), lines merge with the previous row as well as the next (`Ctrl/Cmd+Shift+M`), and a new line can be inserted into the gap after any row (#1612) — thanks @invio-a11y!
|
||||
- CI now enforces performance regression budgets on the hot paths — operation-count tests pin streaming TTS to one synthesis per sentence and cached dub re-mixes to zero re-synthesis; fast-path guards cover zero re-decoding and ⌈N/W⌉ native batch calls when enabled (#1594)
|
||||
- Default-engine dubbing now synthesizes several segments per forward pass instead of one call per line — the width follows the host's device headroom (1 on CPU and low-VRAM cards, up to 8), `OMNIVOICE_DUB_BATCH_WIDTH` overrides it, and engines without native batching keep the single-segment path (#1594)
|
||||
- `/ws/tts` now reports real time-to-first-audio, and its RTF measures synthesis alone so a slow client can't inflate it (#1594)
|
||||
- The locally cached AudioSeal watermark generator warms on a background thread ~35s after boot (`OMNIVOICE_PRELOAD_WATERMARK=0` opts out; explicitly setting `=1` may download it), so the first synthesis no longer serializes the audioseal import + model load inline — measured at ~42s on a cold filesystem, 3s short of a 90s client timeout (#1576) — thanks @paoloantinori!
|
||||
- Voices you've cloned stay "warm" across restarts — encoded references now persist to disk (~10 KB each), so the first generation of a session skips the re-encode and any transcription pass; `OMNIVOICE_PROMPT_DISK_CACHE=0` opts out (#1565)
|
||||
- Optional FlashInfer acceleration for the default engine on CUDA (`OMNIVOICE_FLASHINFER=1`, ~2.2x measured) — needs the optional `flashinfer-python` package; missing package or kernel failure logs why and falls back to the standard path (#1565)
|
||||
- The bug reporter notices when you're on an outdated build and offers the latest release before filing — with a "File anyway" escape hatch — and stamps a `Build status` line into every report so up-to-date reports are tellable from stale ones (#1547)
|
||||
- Settings → Performance & Device gains a compute-device override (Auto / CUDA / ROCm / XPU / MPS / CPU, or `OMNIVOICE_DEVICE`) — pin the device when auto-detect picks wrong; only devices your machine actually has are offered (#1557)
|
||||
- Opt-in 24-layer PocketTTS checkpoints via `OMNIVOICE_POCKETTTS_24L` — better prosody for it/de/es/pt at roughly 2x render time (still faster than real-time); the fast 6-layer model stays the default (#1613) — thanks @paoloantinori!
|
||||
|
||||
### Docs
|
||||
- Supported-version and install guidance now identifies 0.5.1 as the stable desktop and container release (#1687)
|
||||
- The Docker Hub overview now shows the current engine-switching demo, Model Catalogue, and gallery voice workflow (#1593)
|
||||
- The Docker Hub overview and install guide now show the v0.5 tags and the built-in API-key/share-PIN security model instead of obsolete v0.4 and no-authentication guidance (#1592)
|
||||
- The READMEs now lead with download buttons and a three-step first-clone walkthrough, and a new benchmarks page anchors measured per-engine/per-device numbers on the in-repo harness (#1555)
|
||||
- Every engine now has its own guide — 21 new pages under docs/engines plus an index covering all 16 TTS and 11 ASR engines, linked from both READMEs (#1556)
|
||||
- The OmniVoice guide now covers combining style attributes with a reference clip (consistent instruct stabilizes cloning; the reference wins conflicts), inline pronunciation control (pinyin / CMU phonemes), and corrects the claim that the default engine can't do voice design — it can, from attributes (#1565)
|
||||
|
||||
### Fixed
|
||||
- Workspaces now measure their responsive width when the post-bootstrap shell actually mounts, so native UI scaling reflows Projects and History instead of crushing the Dubbing demo into unreadable columns (#1683)
|
||||
- Dubbing keeps the source-language selector visible after a local file is chosen, so ASR can be pinned before transcription starts (#1678) — thanks @Lonki-lomki-cloud!
|
||||
- First-run media-engine downloads become available to TTS immediately without a restart, and missing media-process failures now point to repair controls (#1677) — thanks @farhataligpt-dev!
|
||||
- Source installs on AMD GPUs honour `OMNIVOICE_TORCH_VARIANT=rocm`: `bun run desktop` now swaps in the ROCm torch wheel after `uv sync` and launches the backend without re-syncing, instead of silently reverting to the CPU-only CUDA build on every start (#1665) — thanks @uberclokr!
|
||||
- `bun run desktop` on a fresh clone no longer fails with "resource path `../../frontend/dist` doesn't exist" — the dev launcher creates the placeholder Tauri resource directory before compiling (#1664) — thanks @uberclokr!
|
||||
- macOS no longer loses TTS after the first request when Python lacks `os.waitid`; subprocess ownership now uses a safe `waitpid` fallback without risking reused process groups (#1656) — thanks @paoloantinori!
|
||||
- Desktop startup, Retry, reset, uninstall, shutdown, and crash recovery now share one backend lifecycle owner; quitting interrupts first-run installers and gracefully drains then force-cleans the full backend process tree, so overlaps cannot duplicate or orphan it (#1635) — thanks @Xohaibxobi!
|
||||
- Large Stories and Audiobook projects now persist in IndexedDB instead of overflowing the `omnivoice.app` localStorage envelope, with quota-safe migration and orderly exit/reload flushing (#1636) — thanks @leodzai!
|
||||
- OmniVoice and its crash-isolated subprocess now route to AMD ROCm GPUs instead of warning and falling back to CPU (#1629) — thanks @j4r3kb!
|
||||
- Dictation now cancels pending startup work, capture resources, sockets, and timers when the capture widget closes, preventing late work against a destroyed webview (#1645)
|
||||
- Streaming generation failures now show recognized recovery guidance and appear in Diagnostics instead of only returning a generic error (#1607)
|
||||
- The worker-capacity transport test no longer races its own setup: the 1-slot limit now goes through the enrollment handshake instead of mutating client config after connect, where the server's stream-open ConfigUpdate (carrying the registered capacity of 2) could overwrite it and fake an over-accept; failed CI twice on 2026-08-21 (#1630)
|
||||
- Moving words across a speaker boundary in a dub — merging two lines and splitting them again — no longer dubs the second half in the first speaker's voice; each half now keeps the speaker, voice, direction, gain, and language of whoever actually says it (#1612) — thanks @invio-a11y!
|
||||
- Dictation on a WebView that refuses a 16 kHz audio context (WKWebView) now low-passes before downsampling, so frequencies above 8 kHz stop folding into the speech the recognizer is fed (#1610)
|
||||
- A microphone context that cannot be resumed now reports a mic error instead of leaving the dictation pill on "Listening" while capturing nothing (#1610)
|
||||
- Dictation no longer retains a whole session's audio for silent-model recovery — an open mic grew that buffer by ~115 MB an hour; the recent two minutes are kept instead (#1610)
|
||||
- The clipboard-delivery status is now translated in all 21 languages, so Wayland users — where clipboard delivery is the default — no longer see an English string (#1610)
|
||||
- A native sherpa-onnx load failure of any exception type now degrades to "engine unavailable" instead of taking the dictation WebSocket down (#1610)
|
||||
- Dictation now ships Whisper Tiny as its one cross-platform default, avoiding Parakeet's measured empty decoding on Windows while keeping Parakeet selectable behind runtime fallback (#1175)
|
||||
- Re-mixing a dub no longer decodes, rewrites, and re-reads every cached segment — same-rate cached audio is reused directly (and rejected if truncated), switching timing modes can't reuse slot-truncated audio as natural-rate, and RVC respects natural-rate modes (#1594)
|
||||
- PocketTTS French works again — pocket-tts only ships a 24-layer French model and rejected the name the sidecar asked for, so every French request failed at model load; French now always loads `french_24l` (#1613) — thanks @paoloantinori!
|
||||
- Installing IndexTTS 2.5 no longer fails claiming an interrupted download — the weights repo ships `config.yaml` and VoiceStudio demanded a `config_v2_5.yaml` that exists in no upstream release; both names are accepted, so a hand-renamed checkout keeps working (#1611) — thanks @zuiaiyutu!
|
||||
- IndexTTS 2.5 no longer has long-text generation killed at 60 seconds — the sidecar now proves it is alive every 5 seconds while `infer()` runs, and its deadline rises to 900s (`OMNIVOICE_INDEXTTS_RECV_TIMEOUT_S`) (#1611) — thanks @zuiaiyutu!
|
||||
- The OpenAI-compatible `/v1/audio/speech` route now reuses the shared cached engine for explicit `model` ids instead of constructing a fresh engine — and its sidecar/model load, a ~28s floor per call for subprocess engines — on every request, with the same single-engine-resident discipline `/generate` applies (#1614) — thanks @paoloantinori!
|
||||
- The setup wizard's RAM check no longer blocks 8 GB machines whose OS reports ~7.8 GB usable — the thresholds now tolerate reserved memory, and `OMNIVOICE_RAM_PREFLIGHT=0` turns a genuine block into a warning for those who accept the OOM risk (#1618)
|
||||
- Invisible watermarking now runs eagerly instead of through `torch.compile` — AudioSeal's lazy compile sent the first embed of every session into Inductor's C++ codegen, which failed outright on macOS hosts whose toolchain couldn't serve it and shipped the audio unmarked after a 30-40s wait; first embed drops from 9.70s to 0.26s (#1615) — thanks @paoloantinori!
|
||||
- The macOS Accessibility blocker now rechecks while visible and closes as soon as the grant is enabled instead of keeping a stale permission prompt on screen (#1609)
|
||||
- The dubbing editor's video and transcript columns can now be resized by pointer or keyboard, and the chosen split persists across launches (#1571) — thanks @invio-a11y!
|
||||
- CPU-only synthesis now gets a bounded ten-minute execution budget, and a render that exhausts it is reported as a compute timeout instead of misleading "generation capacity is busy" queue pressure (#1588) — thanks @ChienNguyen1111!
|
||||
- Rapid Launchpad ↔ Dub navigation now replaces the workspace DOM owner cleanly, so late media/waveform cleanup cannot trigger React's `insertBefore` crash (#1590) — thanks @nicolas-jacques!
|
||||
- Watermark embedding failures now log the full traceback instead of just the exception message, so a silently-unmarked-audio incident (audio passes through unmarked by design) is diagnosable from the log alone (#1576) — thanks @paoloantinori!
|
||||
- Dubbing now recovers rapid two-speaker exchanges when diarization collapses them, defaults new projects to lip sync without overwriting saved timing choices, and keeps the editor usable on narrow screens (#1584) — thanks @victordonat0!
|
||||
- `OMNIVOICE_ASR_BACKEND=omnivoice` now selects the PyTorch-native Whisper path, so the documented ROCm escape hatch no longer fails as an unknown engine (#1582) — thanks @patmansk!
|
||||
- Network Sharing from Windows MSI/portable installs now serves the bundled web interface to LAN devices instead of redirecting them to their own `localhost` (#1589) — thanks @TWIISTED-STUDIOS!
|
||||
- Exported dubbed videos now mark the dubbed language as the default audio stream while keeping Original available as an explicit choice (#1575) — thanks @invio-a11y!
|
||||
- Cloning references can no longer exhaust system memory: transcript-free clips up to 75 seconds are searched in five bounded passages, longer clips ask to be trimmed, and supplied transcripts remain capped at 20 seconds to preserve alignment (#1578) — thanks @ACKAPOB!
|
||||
- Stored artifact subpaths now resolve after moving a data directory between Windows, macOS, Linux, and Docker, while traversal and symlink escapes remain blocked (#1559) — thanks @Eman-Yousaf!
|
||||
- A remote browser hitting an API-key-configured server's admin 403 now gets the API-key login form instead of endless console 403s, while desktop and PIN-only/no-key servers keep the plain loopback error so guests are never offered a login no key can satisfy (#1568) — thanks @paoloantinori!
|
||||
- Hosted Studio no longer crashes when system information omits desktop-only RAM, CPU, or VRAM metrics
|
||||
- The crash-isolated ASR sidecar and its download preflight now agree on which model to load — setting the shared faster-whisper model variable applies to both variants instead of the sidecar quietly using a different one (#1556)
|
||||
- "Ready" now requires the deep health probe (a working database-backed route), not just the identity probe — a backend whose install broke underneath can no longer be announced up while every real request fails (#1548)
|
||||
- Supervisor restarts after repeat crashes now back off (immediate, then 5s, then 15s) instead of respawning back-to-back, so a tight crash loop can't burn the whole restart budget in seconds (#1548)
|
||||
- The Linux desktop cleanup regression test now isolates build artifacts, so an existing developer build can no longer change its result (#1566)
|
||||
|
||||
- Renaming, deleting, or revoking consent on a voice (and starring/clearing history, recording exports) now live-updates every open tab again — the sync routes' WebSocket events were silently dropped, which could look like "all my voices are gone" (#1561) — thanks @paoloantinori!
|
||||
|
||||
### CI
|
||||
- Project agents now share pinned Vite and FastAPI skills from skills.sh (#1594)
|
||||
- Weekly full-history secret scans no longer mistake the Ed25519 private-key type name for committed key material (#1591)
|
||||
|
||||
## [0.5.0] — 2026-08-13
|
||||
|
||||
@@ -358,7 +242,7 @@ the frozen-backend fallback mirror it for their toolchains.
|
||||
- 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,6 +1,4 @@
|
||||
<div align="center">
|
||||
<a href="https://trendshift.io/repositories/28176?utm_source=repository-badge&utm_medium=badge&utm_campaign=badge-repository-28176" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/28176" alt="debpalash%2FVoiceStudio | Trendshift" width="250" height="55" /></a>
|
||||
|
||||
<img src="docs/logo.png" alt="VoiceStudio logo" width="120" height="120" />
|
||||
<h1>VoiceStudio</h1>
|
||||
<p><sub>Previously OmniVoice-Studio</sub></p>
|
||||
@@ -62,7 +60,7 @@
|
||||
| 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) |
|
||||
| Docker | CUDA, ROCm, or CPU | [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.
|
||||
|
||||
@@ -105,7 +103,7 @@ Use `bun run dev` for the browser UI. See [Contributing](.github/CONTRIBUTING.md
|
||||
| **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 |
|
||||
| **Dictation Widget** | 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 |
|
||||
@@ -256,7 +254,7 @@ FastAPI backend
|
||||
|
||||
<a id="api"></a>
|
||||
|
||||
## Local speech platform and OpenAI-compatible API
|
||||
## OpenAI-compatible API
|
||||
|
||||
Point an OpenAI-compatible audio client at the local backend:
|
||||
|
||||
@@ -269,8 +267,6 @@ Point an OpenAI-compatible audio client at the local backend:
|
||||
|---|---|
|
||||
| `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 |
|
||||
|
||||
```python
|
||||
@@ -287,19 +283,14 @@ with client.audio.speech.with_streaming_response.create(
|
||||
response.stream_to_file("speech.wav")
|
||||
```
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
### Agent skills
|
||||
|
||||
Install the VoiceStudio skills for Claude Code, Codex, Cursor, and other [skills.sh](https://skills.sh)-compatible agents:
|
||||
|
||||
```bash
|
||||
npx skills add debpalash/VoiceStudio
|
||||
npx skills add debpalash/omnivoice-studio
|
||||
```
|
||||
|
||||
- `omnivoice`: synthesize speech and transcribe audio through local VoiceStudio.
|
||||
@@ -321,7 +312,7 @@ The [notebook](notebooks/OmniVoice_Studio_Colab.ipynb) runs the app and web UI o
|
||||
| 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) · [Private production API](docs/production-private-api.md) · [API auth](docs/api-auth.md) · [MCP](docs/mcp.md) · [examples](examples/README.md) |
|
||||
| Build integrations | [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) |
|
||||
|
||||
@@ -54,15 +54,6 @@ def _server_mode() -> bool:
|
||||
return os.environ.get("OMNIVOICE_SERVER_MODE", "").strip().lower() in _TRUTHY
|
||||
|
||||
|
||||
def validate_server_admin_key() -> None:
|
||||
"""Reject an explicitly blank key before a server-mode app starts."""
|
||||
raw_key = os.environ.get("OMNIVOICE_API_KEY")
|
||||
if _server_mode() and raw_key is not None and not raw_key.strip():
|
||||
raise RuntimeError(
|
||||
"OMNIVOICE_API_KEY is blank; configure a non-whitespace administrator key"
|
||||
)
|
||||
|
||||
|
||||
def _configured_pin(request) -> str | None:
|
||||
"""The active share PIN (``app.state.network_share.pin``) or None. Read via
|
||||
getattr so a bare Request stub (or a request that hit before lifespan set
|
||||
@@ -166,31 +157,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.
|
||||
|
||||
@@ -214,7 +180,7 @@ def require_admin(request: Request) -> None:
|
||||
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:
|
||||
@@ -232,7 +198,7 @@ def require_admin_action(request: Request) -> None:
|
||||
side_effectful_get=True,
|
||||
):
|
||||
return
|
||||
_admin_gate_403()
|
||||
raise HTTPException(status_code=403, detail="loopback origin or admin API key required")
|
||||
|
||||
|
||||
def require_desktop(request: Request) -> None:
|
||||
|
||||
@@ -49,13 +49,6 @@ def public_backends(entries: list[dict]) -> list[dict]:
|
||||
item["routing_reason"] = _public_routing_reason(
|
||||
item.get("routing_status"), item["routing_reason"]
|
||||
)
|
||||
evidence = item.get("execution_evidence")
|
||||
if isinstance(evidence, dict) and evidence.get("cpu_fallback_reason") is not None:
|
||||
evidence = dict(evidence)
|
||||
evidence["cpu_fallback_reason"] = _public_routing_reason(
|
||||
"cpu_fallback", evidence["cpu_fallback_reason"]
|
||||
)
|
||||
item["execution_evidence"] = evidence
|
||||
safe.append(item)
|
||||
return safe
|
||||
|
||||
|
||||
@@ -330,7 +330,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 +357,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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
+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
|
||||
|
||||
+34
-345
@@ -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,57 +410,12 @@ _ingest_gen = dub_pipeline.ingest_pipeline
|
||||
#: container so a mislabelled video can't slip past the video-skipping branch.
|
||||
_AUDIO_EXTS = {".wav", ".mp3", ".m4a", ".aac", ".flac", ".ogg", ".opus", ".wma"}
|
||||
|
||||
# Source-language choices exposed by the first-party dub UI, plus every
|
||||
# language code Whisper can write back after auto-detection. A restored job
|
||||
# may reuse that detected value as the next upload's override, so rejecting our
|
||||
# own persisted codes strands otherwise valid dubbing sessions (#1737).
|
||||
# Keeping this an allow-list still rejects language names and private-use
|
||||
# BCP-47 tags. Values are normalized to lowercase below.
|
||||
_DUB_SOURCE_LANG_CODES = frozenset({
|
||||
"af", "sq", "am", "ar", "hy", "az", "eu", "be", "bn", "bs", "bg",
|
||||
"my", "ca", "cmn-hans", "cmn-hant", "hr", "cs", "da", "nl", "en",
|
||||
"et", "fi", "fr", "gl", "ka", "de", "el", "gu", "ht", "ha", "haw",
|
||||
"he", "hi", "hu", "is", "id", "it", "ja", "jw", "kn", "kk", "km",
|
||||
"ko", "ku", "ky", "lo", "la", "lv", "lt", "mk", "ms", "ml", "mt",
|
||||
"mi", "mr", "mn", "ne", "no", "ps", "fa", "pl", "pt", "pa", "ro",
|
||||
"ru", "sm", "gd", "sr", "sn", "sd", "si", "sk", "sl", "so", "es",
|
||||
"su", "sw", "sv", "tg", "ta", "te", "th", "tr", "uk", "ur", "uz",
|
||||
"vi", "cy", "xh", "yi", "yo", "zu",
|
||||
"as", "ba", "bo", "br", "fo", "lb", "ln", "mg", "nn", "oc", "sa",
|
||||
"tk", "tl", "tt", "yue", "zh",
|
||||
})
|
||||
|
||||
|
||||
def _source_lang_override(value: str | None) -> str | None:
|
||||
"""Normalize a user-selected source language; auto/und means detect."""
|
||||
code = (value or "").strip().lower()
|
||||
if code in {"", "auto", "und"}:
|
||||
return None
|
||||
if code not in _DUB_SOURCE_LANG_CODES:
|
||||
raise HTTPException(status_code=400, detail="Invalid source language code")
|
||||
return code
|
||||
|
||||
|
||||
def _detected_source_lang(value: str | None) -> str:
|
||||
"""Normalize an ASR language without truncating valid three-letter codes."""
|
||||
code = (value or "en").split("_", 1)[0].strip().lower()
|
||||
if code in _DUB_SOURCE_LANG_CODES:
|
||||
return code
|
||||
short = code[:2]
|
||||
return short if short in _DUB_SOURCE_LANG_CODES else "en"
|
||||
|
||||
|
||||
def _resolved_source_lang(override: str | None, detected: str | None) -> str:
|
||||
"""Prefer an explicit source while preserving a valid ASR language code."""
|
||||
return override or _detected_source_lang(detected)
|
||||
|
||||
|
||||
@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.
|
||||
|
||||
@@ -598,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}")
|
||||
@@ -610,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,
|
||||
@@ -638,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
|
||||
@@ -674,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(
|
||||
@@ -739,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,
|
||||
@@ -1186,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] = []
|
||||
@@ -1261,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}",
|
||||
@@ -1282,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,
|
||||
@@ -1306,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).
|
||||
@@ -1601,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
|
||||
@@ -1828,9 +1522,7 @@ async def dub_transcribe_stream(
|
||||
except Exception as e:
|
||||
logger.warning("speaker_clone extraction skipped: %s", e)
|
||||
|
||||
job["source_lang"] = _resolved_source_lang(
|
||||
job.get("source_lang_override"), detected_lang
|
||||
)
|
||||
job["source_lang"] = ((detected_lang or "en").split("_")[0][:2] or "en").lower()
|
||||
job["full_transcript"] = " ".join(s.get("text", "") for s in final_segs)
|
||||
_save_job(job_id, job)
|
||||
|
||||
@@ -2027,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"] = _resolved_source_lang(
|
||||
job.get("source_lang_override"), detected_lang
|
||||
)
|
||||
job["source_lang"] = (detected_lang or "en").split("_")[0][:2].lower()
|
||||
|
||||
scene_cuts = job.get("scene_cuts") or []
|
||||
segments = segment_transcript(result, duration=job.get("duration", 0.0), scene_cuts=scene_cuts)
|
||||
@@ -2085,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
|
||||
|
||||
@@ -75,21 +75,6 @@ def list_tts_backends():
|
||||
return _family_payload("tts", tts_backend)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/engines/{engine_id}/disk-usage",
|
||||
dependencies=[Depends(require_admin_action)],
|
||||
)
|
||||
def engine_disk_usage(engine_id: str):
|
||||
"""Measure owned engine bytes only when a catalogue row is opened."""
|
||||
try:
|
||||
tts_backend.get_backend_class(engine_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail="Unknown TTS engine")
|
||||
from services.engine_disk_usage import disk_usage_for
|
||||
|
||||
return disk_usage_for(engine_id)
|
||||
|
||||
|
||||
@router.get("/engines/asr")
|
||||
def list_asr_backends():
|
||||
return _family_payload("asr", asr_backend)
|
||||
|
||||
+130
-316
@@ -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)
|
||||
|
||||
@@ -1288,6 +1146,9 @@ async def generate_speech(
|
||||
# classic flow, so streaming is purely a delivery channel — engine-agnostic
|
||||
# (text-level chunking, no per-engine token streaming).
|
||||
stream: bool = Form(False),
|
||||
# Explicit opt-in. The absence of this field preserves the local-first
|
||||
# /generate contract even when an administrator configured hosted values.
|
||||
hosted: bool = Form(False),
|
||||
):
|
||||
# #502: NFC-normalize the input text so decomposed (NFD) diacritics — common
|
||||
# in pasted Vietnamese and other Latin-with-marks text — are composed to the
|
||||
@@ -1298,6 +1159,36 @@ async def generate_speech(
|
||||
import unicodedata
|
||||
text = unicodedata.normalize("NFC", text)
|
||||
|
||||
if hosted:
|
||||
# Hosted execution accepts only a previously, explicitly synchronized
|
||||
# consent-verified profile. Never silently sync a local recording from
|
||||
# a synthesis request: that would make normal offline use an upload.
|
||||
if not profile_id:
|
||||
raise HTTPException(status_code=422, detail="Hosted synthesis requires a synchronized voice profile.")
|
||||
from services.hosted_voice_api import HostedSettings, HostedVoiceClient, HostedVoiceError
|
||||
try:
|
||||
settings = HostedSettings.from_environment()
|
||||
except HostedVoiceError as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
if settings is None:
|
||||
raise HTTPException(status_code=409, detail="Hosted synthesis is not configured on this device.")
|
||||
with db_conn() as conn:
|
||||
profile = conn.execute("SELECT hosted_voice_id, language FROM voice_profiles WHERE id=?", (profile_id,)).fetchone()
|
||||
if not profile:
|
||||
raise HTTPException(status_code=404, detail="Voice profile not found")
|
||||
if not profile["hosted_voice_id"]:
|
||||
raise HTTPException(status_code=422, detail="Sync this consent-verified profile to hosted before hosted synthesis.")
|
||||
client = HostedVoiceClient(settings)
|
||||
try:
|
||||
audio = await client.synthesize(
|
||||
text=text, profile_voice_id=profile["hosted_voice_id"], language=language or profile["language"],
|
||||
)
|
||||
except HostedVoiceError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
finally:
|
||||
await client.aclose()
|
||||
return StreamingResponse(io.BytesIO(audio), media_type="audio/wav", headers={"X-OmniVoice-Execution": "hosted"})
|
||||
|
||||
# ── Engine resolution (issue #312) ──────────────────────────────────────
|
||||
# The request runs on the engine selected in Settings (POST /engines/select,
|
||||
# env var OMNIVOICE_TTS_BACKEND wins), or an explicit per-request `engine`
|
||||
@@ -1340,10 +1231,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 +1330,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 +1417,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 +1433,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 +1556,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 +1695,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 +1749,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 +1812,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 +1855,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 +1869,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 +1902,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 +1931,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 +1940,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 +1991,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 +2002,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 +2141,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
|
||||
|
||||
@@ -14,6 +14,7 @@ from core import event_bus
|
||||
from core.personalities import get_personalities
|
||||
from omnivoice.utils.voice_design import heal_design_instruct, sanitize_instruct
|
||||
from core.path_security import UnsafePath, resolve_within
|
||||
from services.hosted_voice_api import HostedSettings, HostedVoiceClient, HostedVoiceError
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -184,6 +185,50 @@ def get_profile(profile_id: str):
|
||||
return dict(row)
|
||||
|
||||
|
||||
@router.post("/profiles/{profile_id}/hosted-sync")
|
||||
async def sync_profile_to_hosted(profile_id: str):
|
||||
"""Explicitly copy a consent-verified local clone to the hosted library.
|
||||
|
||||
This is deliberately not part of local profile creation: merely creating a
|
||||
profile must never upload biometric source audio. The hosted service records
|
||||
the existing spoken-consent evidence as its versioned attestation; it does
|
||||
not receive the consent recording itself.
|
||||
"""
|
||||
try:
|
||||
settings = HostedSettings.from_environment()
|
||||
except HostedVoiceError as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
if settings is None:
|
||||
raise HTTPException(status_code=409, detail="Hosted voice sync is not configured on this device.")
|
||||
with db_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, name, description, ref_text, ref_audio_path, verified_own_voice, consent_text, hosted_voice_id "
|
||||
"FROM voice_profiles WHERE id=?", (profile_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Profile not found")
|
||||
if row["hosted_voice_id"]:
|
||||
return {"profile_id": profile_id, "hosted_voice_id": row["hosted_voice_id"], "state": "already_synced"}
|
||||
if not row["verified_own_voice"] or not row["consent_text"].strip():
|
||||
raise HTTPException(status_code=422, detail="Record the voice-ownership consent statement before hosted sync.")
|
||||
reference_path = _voices_path(row["ref_audio_path"] or "")
|
||||
if not reference_path or not os.path.isfile(reference_path):
|
||||
raise HTTPException(status_code=422, detail="This profile has no local reference recording to sync.")
|
||||
client = HostedVoiceClient(settings)
|
||||
try:
|
||||
hosted_voice_id = await client.create_voice(
|
||||
name=row["name"], description=row["description"] or row["ref_text"] or "", reference_path=reference_path,
|
||||
)
|
||||
except HostedVoiceError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
finally:
|
||||
await client.aclose()
|
||||
with db_conn() as conn:
|
||||
conn.execute("UPDATE voice_profiles SET hosted_voice_id=? WHERE id=? AND hosted_voice_id=''", (hosted_voice_id, profile_id))
|
||||
persisted = conn.execute("SELECT hosted_voice_id FROM voice_profiles WHERE id=?", (profile_id,)).fetchone()["hosted_voice_id"]
|
||||
return {"profile_id": profile_id, "hosted_voice_id": persisted, "state": "synced"}
|
||||
|
||||
|
||||
@router.put("/profiles/{profile_id}")
|
||||
def update_profile(profile_id: str, patch: ProfileUpdate):
|
||||
"""Partial update — only fields set on the payload are changed."""
|
||||
|
||||
@@ -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,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())
|
||||
@@ -57,6 +57,9 @@ _BASE_SCHEMA = """
|
||||
consent_recorded_at REAL DEFAULT NULL,
|
||||
kind TEXT DEFAULT 'clone',
|
||||
vd_states TEXT DEFAULT NULL,
|
||||
-- Hosted Voice ID is opt-in synchronization metadata. Local synthesis
|
||||
-- never depends on it, so existing offline profiles remain useful.
|
||||
hosted_voice_id TEXT DEFAULT '',
|
||||
created_at REAL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS generation_history (
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -21,7 +21,6 @@ Check shape:
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
@@ -368,44 +367,10 @@ def run_diagnostics(include_network: bool = True, deep: bool = False) -> dict:
|
||||
counts = {OK: 0, WARN: 0, FAIL: 0}
|
||||
for c in checks:
|
||||
counts[c["status"]] += 1
|
||||
engine_execution = []
|
||||
for family in ("tts", "asr"):
|
||||
active = "unknown"
|
||||
try:
|
||||
module = importlib.import_module(f"services.{family}_backend")
|
||||
active = module.active_backend_id()
|
||||
row = next((item for item in module.list_backends() if item.get("id") == active), None)
|
||||
if row is not None:
|
||||
engine_execution.append({
|
||||
"family": family,
|
||||
"engine_id": active,
|
||||
**row["execution_evidence"],
|
||||
})
|
||||
except Exception: # noqa: BLE001 - evidence must not break diagnostics
|
||||
# Preserve the other family's successful evidence and make this
|
||||
# collection failure explicit without exposing exception text.
|
||||
engine_execution.append({
|
||||
"family": family,
|
||||
"engine_id": active,
|
||||
"implementation_variant": None,
|
||||
"declared_device_families": [],
|
||||
"evidence_state": "collection_failed",
|
||||
"actual_execution_provider": None,
|
||||
"actual_execution_device": None,
|
||||
"gpu_name": None,
|
||||
"gpu_architecture": None,
|
||||
"precision_or_quantization": None,
|
||||
"cpu_fallback_reason": None,
|
||||
"cpu_fallback_stage": None,
|
||||
"parent_memory_observable": None,
|
||||
"runtime_versions": {},
|
||||
})
|
||||
|
||||
return {
|
||||
"app_version": APP_VERSION,
|
||||
"platform": scrub_text(platform.platform()),
|
||||
"checks": checks,
|
||||
"engine_execution": engine_execution,
|
||||
"summary": {
|
||||
"ok": counts[FAIL] == 0,
|
||||
"passed": counts[OK],
|
||||
@@ -430,35 +395,6 @@ def format_text(report: dict) -> str:
|
||||
lines.append(f"{tag[c['status']]} {c['label']}: {c['detail']}")
|
||||
if c.get("hint"):
|
||||
lines.append(f" hint: {c['hint']}")
|
||||
if report.get("engine_execution"):
|
||||
lines.append("")
|
||||
lines.append("Engine execution evidence:")
|
||||
for item in report["engine_execution"]:
|
||||
if item.get("actual_execution_provider"):
|
||||
provider = item["actual_execution_provider"]
|
||||
elif item.get("evidence_state") == "subprocess_loaded_provider_unreported":
|
||||
provider = "loaded child; provider not reported"
|
||||
else:
|
||||
provider = "not loaded"
|
||||
precision = item.get("precision_or_quantization") or "unknown"
|
||||
device = item.get("actual_execution_device") or "unknown"
|
||||
gpu = item.get("gpu_name") or "none"
|
||||
architecture = item.get("gpu_architecture") or "unknown"
|
||||
fallback_stage = item.get("cpu_fallback_stage") or "none"
|
||||
fallback_reason = item.get("cpu_fallback_reason") or "none"
|
||||
versions = ",".join(
|
||||
f"{name}={version}"
|
||||
for name, version in sorted(item.get("runtime_versions", {}).items())
|
||||
) or "none"
|
||||
visible = "yes" if item.get("parent_memory_observable") else "no"
|
||||
lines.append(
|
||||
f" {item['family']}:{item['engine_id']} provider={provider}; "
|
||||
f"device={device}; gpu={gpu}; architecture={architecture}; "
|
||||
f"precision={precision}; fallback-stage={fallback_stage}; "
|
||||
f"fallback-reason={fallback_reason}; runtimes={versions}; "
|
||||
f"evidence-state={item.get('evidence_state', 'unknown')}; "
|
||||
f"parent-memory-visible={visible}"
|
||||
)
|
||||
s = report["summary"]
|
||||
lines.append("")
|
||||
lines.append(
|
||||
|
||||
@@ -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:
|
||||
@@ -557,7 +519,6 @@ def classify(reason: str) -> str:
|
||||
or "unable to download video" in low
|
||||
or "remote end closed" in low
|
||||
or "timed out" in low
|
||||
or "the page needs to be reloaded" in low
|
||||
):
|
||||
return "VIDEO_DOWNLOAD_NETWORK"
|
||||
# #1227: Windows Smart App Control / WDAC / AppLocker refused to load a
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
"""Terminate a desktop-contained backend when its owning shell disappears."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
from typing import BinaryIO, Callable
|
||||
|
||||
|
||||
def _watch_parent_pipe(reader: BinaryIO, exit_process: Callable[[int], None]) -> None:
|
||||
"""Block until the desktop-owned stdin pipe closes, then exit immediately."""
|
||||
try:
|
||||
while reader.read(1):
|
||||
pass
|
||||
except (OSError, ValueError):
|
||||
# A broken or already-closed parent-owned pipe is equivalent to EOF.
|
||||
pass
|
||||
exit_process(0)
|
||||
|
||||
|
||||
def arm_desktop_parent_watchdog() -> bool:
|
||||
"""Use stdin EOF as an unforgeable parent-liveness signal for desktop runs."""
|
||||
if os.environ.get("OMNIVOICE_DESKTOP_CONTAINED") != "1":
|
||||
return False
|
||||
reader = getattr(sys.stdin, "buffer", None)
|
||||
if reader is None:
|
||||
return False
|
||||
threading.Thread(
|
||||
target=_watch_parent_pipe,
|
||||
args=(reader, os._exit),
|
||||
name="desktop-parent-watchdog",
|
||||
daemon=True,
|
||||
).start()
|
||||
return True
|
||||
@@ -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,
|
||||
|
||||
@@ -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.5.0"
|
||||
|
||||
|
||||
def _fallback_version() -> str:
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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,15 +51,10 @@ 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
|
||||
# Packaged Windows hosts can spend more than the base 30 seconds starting
|
||||
# the shared Python runtime before this stdlib-only sidecar emits ready.
|
||||
# Keep the bound below the 300-second generation budget while avoiding the
|
||||
# repeated false kill captured in #1711.
|
||||
spawn_ready_timeout_s = 120.0
|
||||
|
||||
@classmethod
|
||||
def is_available(cls) -> tuple[bool, str]:
|
||||
@@ -104,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()
|
||||
|
||||
+17
-113
@@ -9,24 +9,6 @@ _backend_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
if _backend_dir not in sys.path:
|
||||
sys.path.insert(0, _backend_dir)
|
||||
|
||||
# PyInstaller re-executes this entry module when the frozen backend binary is
|
||||
# launched. Nested operation supervisors therefore dispatch here, before math,
|
||||
# logging, FastAPI, torch, or any application initialization. Source launches
|
||||
# use this same entry contract so frozen/source behavior cannot drift.
|
||||
if __name__ == "__main__" and len(sys.argv) > 1 and sys.argv[1] == "--supervise":
|
||||
from core.contained_subprocess import supervisor_main
|
||||
|
||||
raise SystemExit(supervisor_main(sys.argv[1:]))
|
||||
|
||||
# Rust clears CLOEXEC only for the backend exec. Re-arm PEP 446 immediately:
|
||||
# nested supervisors receive this descriptor solely through explicit pass_fds,
|
||||
# so a third-party close_fds=False child cannot hold the desktop drain barrier.
|
||||
from core.contained_subprocess import secure_backend_drain_fd # noqa: E402
|
||||
|
||||
secure_backend_drain_fd()
|
||||
|
||||
import math # noqa: E402
|
||||
|
||||
# Windows: run every child process (ffmpeg, engine sidecars, yt-dlp, demucs, …)
|
||||
# WITHOUT popping a console window. The backend itself is spawned console-less by
|
||||
# the Tauri shell, so on Windows each console subprocess it launches would
|
||||
@@ -77,7 +59,6 @@ os.environ.setdefault("FOR_DISABLE_CONSOLE_CTRL_HANDLER", "1")
|
||||
# (utils.hf_progress.SafeFileWrapper — same wrapper the patched hub tqdm
|
||||
# already uses for its own fp.)
|
||||
from utils.hf_progress import SafeFileWrapper as _SafeStdio # noqa: E402
|
||||
from core.parent_liveness import arm_desktop_parent_watchdog # noqa: E402
|
||||
|
||||
# Force UTF-8 stdio before wrapping (#1155): on Windows the spawned backend's
|
||||
# stdout defaults to cp1252, and any library that prints user text (kittentts
|
||||
@@ -90,11 +71,6 @@ for _stream in (sys.stdout, sys.stderr):
|
||||
except Exception: # noqa: BLE001 — pythonw/frozen builds may lack reconfigure
|
||||
pass
|
||||
|
||||
# The desktop keeps the backend's stdin pipe open for its own lifetime. EOF is
|
||||
# therefore a stable ownership signal that survives PID reuse and lets a child
|
||||
# terminate even when the shell crashes before its normal process-tree teardown.
|
||||
arm_desktop_parent_watchdog()
|
||||
|
||||
if not getattr(sys.stdout, "_is_safe_wrapper", False):
|
||||
sys.stdout = _SafeStdio(sys.stdout)
|
||||
if not getattr(sys.stderr, "_is_safe_wrapper", False):
|
||||
@@ -393,36 +369,19 @@ def _env_flag(name: str, default: bool = False) -> bool:
|
||||
_EAGER = _env_flag("OMNIVOICE_EAGER_INIT", default=("pytest" in sys.modules))
|
||||
|
||||
|
||||
def _env_float(name: str, default: float) -> float:
|
||||
"""Parse a float env override, rejecting negative and non-finite values.
|
||||
|
||||
Shared by the preload-delay / timeout knobs: NaN would silently never
|
||||
fire, a negative would fire during startup I/O, so both fall back to the
|
||||
default instead (the bug class CodeRabbit flagged on the watermark knob
|
||||
in PR #1577 — latent in the older copies too, closed here for all)."""
|
||||
raw = os.environ.get(name, "")
|
||||
try:
|
||||
value = float(raw) if raw.strip() else default
|
||||
except ValueError:
|
||||
return default
|
||||
return value if math.isfinite(value) and value >= 0 else default
|
||||
|
||||
|
||||
def _capture_preload_delay_s() -> float:
|
||||
"""Seconds after boot before the dictation (capture ASR) model warms.
|
||||
|
||||
Late enough that it never competes with startup I/O or the TTS preload;
|
||||
overridable via OMNIVOICE_CAPTURE_PRELOAD_DELAY (mostly for tests)."""
|
||||
return _env_float("OMNIVOICE_CAPTURE_PRELOAD_DELAY", 30.0)
|
||||
|
||||
def _watermark_preload_delay_s() -> float:
|
||||
"""Seconds after boot before the AudioSeal generator warm-up fires.
|
||||
|
||||
Own knob, NOT ``_capture_preload_delay_s`` + offset: a capture-specific
|
||||
env override must not retime the watermark warm too, and the two cold
|
||||
imports shouldn't fire on the same tick (CodeRabbit, PR #1577). Default
|
||||
35s sits ~5s past the capture-ASR warm for the same reason."""
|
||||
return _env_float("OMNIVOICE_PRELOAD_WATERMARK_DELAY", 35.0)
|
||||
raw = os.environ.get("OMNIVOICE_CAPTURE_PRELOAD_DELAY", "")
|
||||
try:
|
||||
v = float(raw)
|
||||
if v >= 0:
|
||||
return v
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return 30.0
|
||||
|
||||
|
||||
def _capture_preload_ram_ok(min_free_bytes: int = 4 * 1024**3) -> bool:
|
||||
@@ -439,7 +398,14 @@ def _capture_preload_ram_ok(min_free_bytes: int = 4 * 1024**3) -> bool:
|
||||
def _mcp_start_timeout_s() -> float:
|
||||
"""Seconds to wait for the MCP session manager to start before giving up
|
||||
and serving without it (#632). Overridable via OMNIVOICE_MCP_START_TIMEOUT_S."""
|
||||
return max(_env_float("OMNIVOICE_MCP_START_TIMEOUT_S", 30.0), 0.001)
|
||||
raw = os.environ.get("OMNIVOICE_MCP_START_TIMEOUT_S", "")
|
||||
try:
|
||||
v = float(raw)
|
||||
if v > 0:
|
||||
return v
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return 30.0
|
||||
|
||||
|
||||
async def _serve_mcp(session_manager, ready: "asyncio.Event", stop: "asyncio.Event") -> None:
|
||||
@@ -672,7 +638,6 @@ def _phase_a_build_inner() -> None:
|
||||
events,
|
||||
capture,
|
||||
capture_ws,
|
||||
speech_platform,
|
||||
dictation,
|
||||
openai_compat,
|
||||
tts_stream,
|
||||
@@ -692,7 +657,7 @@ def _phase_a_build_inner() -> None:
|
||||
system, profiles, exports, generation, dub_core, dub_generate,
|
||||
dub_export, dub_translate, projects, glossary, engines, tools,
|
||||
stories, setup, gallery, archetypes, describe_voice, community,
|
||||
batch, watermark, events, capture, capture_ws, speech_platform, dictation,
|
||||
batch, watermark, events, capture, capture_ws, dictation,
|
||||
openai_compat, tts_stream, marketplace, personas, sonitranslate,
|
||||
audiobook, longform_jobs, pronunciation, settings_router,
|
||||
media_tools_router, auth_router, _mcp_bindings_router, workers_router,
|
||||
@@ -887,8 +852,6 @@ async def _phase_b(app: FastAPI) -> None:
|
||||
# #1174: arm model loads for THIS run — an in-process relaunch may carry a
|
||||
# stale shutting-down flag from a previous lifespan.
|
||||
model_loads_reset_shutdown()
|
||||
from services.model_manager import begin_watermark_pool_lifecycle
|
||||
begin_watermark_pool_lifecycle()
|
||||
app.state.idle_task = asyncio.create_task(idle_worker())
|
||||
app.state.worker_task = asyncio.create_task(task_manager.worker())
|
||||
# Warm the TTS model in the background so first /generate is instant.
|
||||
@@ -942,50 +905,6 @@ async def _phase_b(app: FastAPI) -> None:
|
||||
else:
|
||||
logger.info("Capture ASR preload disabled; dictation ASR will load on first use.")
|
||||
|
||||
# Watermark: warm the AudioSeal generator in the background so the first
|
||||
# mark_synthetic doesn't serialize the audioseal import + model load
|
||||
# inside the first synthesis (measured ~42 s inline on a cold filesystem,
|
||||
# 2026-08-17 macOS report — 3 s short of the client's 90 s timeout).
|
||||
# Small model on CPU; deferred a few seconds past the capture-ASR warm so
|
||||
# the two cold imports don't contend for the same disk, and no RAM guard
|
||||
# is needed. Runs on the watermark pool — where the model is used — not
|
||||
# the shared default executor.
|
||||
if _env_flag("OMNIVOICE_PRELOAD_WATERMARK", default=True):
|
||||
async def _preload_watermark():
|
||||
await asyncio.sleep(_watermark_preload_delay_s())
|
||||
loop = asyncio.get_running_loop()
|
||||
from services import watermark as _watermark
|
||||
|
||||
# Gate BEFORE touching get_watermark_pool(): the pool is lazy so
|
||||
# hosts with watermarking disabled never spawn its thread, and
|
||||
# creating it unconditionally would break that invariant. The
|
||||
# race with a first embed is benign — pool creation is itself
|
||||
# lock-guarded.
|
||||
if not _watermark.will_mark():
|
||||
logger.debug("Watermark preload skipped (disabled or audioseal absent)")
|
||||
return
|
||||
from services.model_manager import get_watermark_pool
|
||||
|
||||
# Default startup may warm an existing local checkpoint but may
|
||||
# not fetch one. Only an explicit user opt-in permits a download.
|
||||
raw_preload = os.environ.get("OMNIVOICE_PRELOAD_WATERMARK", "")
|
||||
allow_download = raw_preload.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
try:
|
||||
await loop.run_in_executor(
|
||||
get_watermark_pool(),
|
||||
lambda: _watermark.prefetch_generator(
|
||||
allow_download=allow_download
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
# prefetch_generator swallows its own errors; this guards the
|
||||
# setup half (imports, pool construction) so a broken warm-up
|
||||
# is visible now, not as an unretrieved exception at shutdown.
|
||||
logger.warning("Watermark preload task failed", exc_info=True)
|
||||
|
||||
app.state.watermark_preload_task = asyncio.create_task(_preload_watermark())
|
||||
|
||||
# ── MCP session manager (Wave 2.2) ────────────────────────────────────
|
||||
# Run it in its OWN task owning the full enter→exit lifecycle (anyio
|
||||
# task-affinity, see _serve_mcp); only wait, with a timeout, for ready —
|
||||
@@ -1015,9 +934,6 @@ async def _phase_b(app: FastAPI) -> None:
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
from api.dependencies import validate_server_admin_key
|
||||
|
||||
validate_server_admin_key()
|
||||
# Startup watchdog (#632): a silent hang during startup (e.g. a model-load /
|
||||
# MCP deadlock on some platforms) means "Application startup complete" never
|
||||
# logs and the app sits forever with no error. If startup hasn't finished
|
||||
@@ -1164,20 +1080,8 @@ async def lifespan(app: FastAPI):
|
||||
getattr(app.state, "worker_task", None),
|
||||
getattr(app.state, "preload_task", None),
|
||||
getattr(app.state, "capture_preload_task", None),
|
||||
getattr(app.state, "watermark_preload_task", None),
|
||||
timeout=20.0,
|
||||
)
|
||||
# The watermark warm-up runs on its dedicated 1-worker pool. Cancellation
|
||||
# detaches the asyncio future but cannot kill a thread inside AudioSeal,
|
||||
# so drain it fully before lifespan teardown reports completion.
|
||||
try:
|
||||
from services.model_manager import shutdown_watermark_pool as _wm_drain
|
||||
|
||||
_wm_drain()
|
||||
except Exception:
|
||||
# Best-effort drain: a failure here must not abort the remaining
|
||||
# shutdown steps (model unload, MCP teardown) below.
|
||||
logger.warning("Watermark pool drain failed at shutdown", exc_info=True)
|
||||
# Unload the model and free GPU memory
|
||||
try:
|
||||
import services.model_manager as mm
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Opt-in hosted Voice ID on local profiles.
|
||||
|
||||
Revision ID: 0011_hosted_voice_sync
|
||||
Revises: 0010_remote_worker_schema
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision: str = "0011_hosted_voice_sync"
|
||||
down_revision: Union[str, None] = "0010_remote_worker_schema"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def _has_column(table: str, column: str) -> bool:
|
||||
rows = op.get_bind().execute(sa.text(f"PRAGMA table_info({table})")).fetchall()
|
||||
return any(row[1] == column for row in rows)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not _has_column("voice_profiles", "hosted_voice_id"):
|
||||
op.add_column("voice_profiles", sa.Column("hosted_voice_id", sa.Text(), nullable=True, server_default=""))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if _has_column("voice_profiles", "hosted_voice_id"):
|
||||
op.drop_column("voice_profiles", "hosted_voice_id")
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Mark materialized gallery archetypes as voice-design profiles.
|
||||
|
||||
Revision ID: 0012_mark_archetype_profiles_design
|
||||
Revises: 0011_hosted_voice_sync
|
||||
Create Date: 2026-08-15 00:00:00.000000
|
||||
|
||||
``POST /archetypes/{id}/use`` stores the archetype id in ``personality`` and
|
||||
also stores a locally rendered identity WAV. That WAV must not make the
|
||||
profile a clone: the archetype's instruct recipe is authoritative. Older
|
||||
rows relied on the ``kind='clone'`` default and therefore selected the clone
|
||||
generation path. This data-only migration fixes every row whose personality
|
||||
is a current archetype id, leaving unrelated persona and marketplace imports
|
||||
untouched.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
revision: str = "0012_mark_archetype_profiles_design"
|
||||
down_revision: Union[str, None] = "0011_hosted_voice_sync"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
if "voice_profiles" not in inspector.get_table_names():
|
||||
return
|
||||
columns = {column["name"] for column in inspector.get_columns("voice_profiles")}
|
||||
if not {"kind", "personality"}.issubset(columns):
|
||||
return
|
||||
|
||||
# The catalog is intentionally a value object, so checking an id against
|
||||
# its current generated list is the precise provenance test. The
|
||||
# parameterized update avoids treating any other personality string as an
|
||||
# archetype.
|
||||
from core import archetypes
|
||||
|
||||
archetype_ids = [item["id"] for item in archetypes.list_archetypes()]
|
||||
for archetype_id in archetype_ids:
|
||||
bind.exec_driver_sql(
|
||||
"UPDATE voice_profiles SET kind = 'design' "
|
||||
"WHERE personality = ? AND (kind IS NULL OR kind = '' OR kind = 'clone')",
|
||||
(archetype_id,),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Do not silently convert voice-design profiles back to clones: that would
|
||||
# reintroduce the generation mismatch for existing user data.
|
||||
pass
|
||||
@@ -0,0 +1,95 @@
|
||||
# VoiceStudio runtime adapter
|
||||
|
||||
A local gRPC server implementing the vssaas GPU-node runtime contract
|
||||
`voicestudio.runtime.v1.RuntimeAdapterService`, so a vssaas GPU Gateway can
|
||||
drive this VoiceStudio backend as its inference runtime.
|
||||
|
||||
## Boundary (deliberate non-capabilities)
|
||||
|
||||
- Binds **only** a Unix-domain socket (default `/run/voicestudio/runtime.sock`,
|
||||
override with `VOICE_STUDIO_RUNTIME_SOCKET`). No HTTP listener, no TCP.
|
||||
- Never reaches PostgreSQL, customer credentials, or arbitrary network URLs.
|
||||
`Execute` accepts **local file handles only** — absolute paths generated by
|
||||
the Gateway; any URL-shaped or relative handle is rejected as invalid input.
|
||||
- The Gateway owns leases, artifact transfer, retries, and billing. This
|
||||
adapter owns approved model loading and inference only.
|
||||
|
||||
## Running
|
||||
|
||||
```sh
|
||||
# serve (production socket):
|
||||
VOICE_STUDIO_RUNTIME_SOCKET=/run/voicestudio/runtime.sock \
|
||||
python -m backend.runtime_adapter
|
||||
|
||||
# self-check: starts the server on a private temp socket and validates the
|
||||
# same expectations the Go preflight (cmd/runtime-adapter-preflight) enforces:
|
||||
python -m backend.runtime_adapter --selfcheck
|
||||
```
|
||||
|
||||
Environment:
|
||||
|
||||
| Variable | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `VOICE_STUDIO_RUNTIME_SOCKET` | `/run/voicestudio/runtime.sock` | Unix socket path (must be absolute; parent dir must exist and not be world-writable). |
|
||||
| `VOICE_STUDIO_RUNTIME_SLOTS` | `1` | Concurrent execution slots per device. |
|
||||
|
||||
## Wire contract and generated stubs
|
||||
|
||||
`runtime_adapter.proto` is a **byte-identical vendored copy** of the vssaas
|
||||
contract `api/proto/voicestudio/runtime/v1/runtime_adapter.proto`. Do not edit
|
||||
it here; re-vendor from vssaas when the contract changes, then regenerate.
|
||||
|
||||
The `gen/` stubs are committed (same policy as `backend/worker/protocol/gen/`).
|
||||
Regenerate with:
|
||||
|
||||
```sh
|
||||
uv run python scripts/gen_runtime_adapter_protocol.py
|
||||
```
|
||||
|
||||
`tests/test_runtime_adapter_gen.py` fails if the committed stubs drift from
|
||||
the proto.
|
||||
|
||||
## Preflight expectations honoured
|
||||
|
||||
The Go preflight (`internal/gateway/preflight.go`) fails closed unless:
|
||||
|
||||
- the socket path is absolute, a real Unix socket (not a symlink), and its
|
||||
parent directory is not world-writable — `server.prepare_socket` enforces
|
||||
the same rules at bind time;
|
||||
- `Health` returns `SERVING_STATE_READY` with nonempty runtime + adapter
|
||||
versions, and `GetCapabilities` returns **identical** versions — both
|
||||
handlers read the same constants, so they cannot disagree;
|
||||
- at least one device with nonempty id/hardware class, nonzero VRAM and
|
||||
slots, `free_slots <= total_slots`, unique ids;
|
||||
- at least one model **explicitly READY** with `catalog_model_id`,
|
||||
`model_version`, `model_digest`, and ≥1 precision. A loading, installed,
|
||||
or failed model is reported with its true state and never as READY.
|
||||
|
||||
## Model identity
|
||||
|
||||
- `catalog_model_id` — the VoiceStudio TTS engine id (`omnivoice`,
|
||||
`voxcpm2`, …) from `services.tts_backend`'s registry.
|
||||
- `model_version` — an immutable catalog version comprising the installed
|
||||
Hugging Face revision (40-char commit SHA) and the first 16 hex characters
|
||||
of the attested snapshot digest. This creates a new catalog identity when
|
||||
snapshot bytes change; it never rewrites an identity retained by a Job.
|
||||
- `model_digest` — `sha256:<hex>` computed over the installed snapshot files
|
||||
(sorted relative path + per-file SHA-256), cached next to the repo cache
|
||||
keyed by (revision, file list, sizes, mtimes) so multi-GB weights are
|
||||
hashed once. See `digest.py`.
|
||||
|
||||
## Failure taxonomy
|
||||
|
||||
Stable codes (prefix `RTA_`) map onto the proto's `RuntimeFailureClass`:
|
||||
invalid input (`RTA_INPUT_*`), model load (`RTA_MODEL_LOAD_FAILED`),
|
||||
inference (`RTA_INFERENCE_*`), GPU resource (`RTA_GPU_*`), local storage
|
||||
(`RTA_STORAGE_*`), cancellation (terminal `ExecutionCanceled`), and adapter
|
||||
crash (`RTA_RUNTIME_CRASH`). See `codes.py`.
|
||||
|
||||
## Tests
|
||||
|
||||
```sh
|
||||
uv run pytest backend/tests/test_runtime_adapter_capabilities.py \
|
||||
backend/tests/test_runtime_adapter_execute.py \
|
||||
tests/test_runtime_adapter_gen.py
|
||||
```
|
||||
@@ -0,0 +1,18 @@
|
||||
"""VoiceStudio runtime adapter — the vssaas GPU-node runtime boundary.
|
||||
|
||||
Implements ``voicestudio.runtime.v1.RuntimeAdapterService`` over a private
|
||||
Unix-domain socket so a vssaas GPU Gateway can drive VoiceStudio's TTS
|
||||
engines as its inference runtime. No HTTP listener, no database access, no
|
||||
outbound network: the adapter reads and writes only the local file handles
|
||||
each ``Execute`` request carries. See ``README.md`` in this directory.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
#: Version of this adapter layer (the gRPC boundary), independent of the app
|
||||
#: version, which is reported as ``runtime_version``. Bump on any behavioral
|
||||
#: change to the adapter itself.
|
||||
ADAPTER_VERSION = "0.1.0"
|
||||
|
||||
DEFAULT_SOCKET_PATH = "/run/voicestudio/runtime.sock"
|
||||
SOCKET_ENV = "VOICE_STUDIO_RUNTIME_SOCKET"
|
||||
SLOTS_ENV = "VOICE_STUDIO_RUNTIME_SLOTS"
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Entry point: ``python -m backend.runtime_adapter``.
|
||||
|
||||
Serves the runtime adapter on a private Unix-domain socket (default
|
||||
``/run/voicestudio/runtime.sock``, override ``VOICE_STUDIO_RUNTIME_SOCKET``
|
||||
or ``--socket``). ``--selfcheck`` instead starts the server on a temp socket
|
||||
and validates the GPU Gateway preflight expectations against it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
from ._paths import ensure_backend_on_path
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
ensure_backend_on_path()
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="backend.runtime_adapter",
|
||||
description="VoiceStudio runtime adapter (vssaas GPU-node gRPC server)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--socket",
|
||||
default=None,
|
||||
help="absolute Unix socket path (default: $VOICE_STUDIO_RUNTIME_SOCKET "
|
||||
"or /run/voicestudio/runtime.sock)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--selfcheck",
|
||||
action="store_true",
|
||||
help="start on a temp socket and validate the preflight expectations",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--timeout",
|
||||
type=float,
|
||||
default=10.0,
|
||||
help="selfcheck RPC timeout in seconds (default: 10)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-prewarm",
|
||||
action="store_true",
|
||||
help="serve immediately without loading models first (the first "
|
||||
"execution then pays weight loading and compilation)",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.selfcheck:
|
||||
from .selfcheck import selfcheck # noqa: PLC0415
|
||||
|
||||
return selfcheck(timeout_s=args.timeout)
|
||||
|
||||
from .production import build_runtime_context, prewarm_engines # noqa: PLC0415
|
||||
from .server import resolve_socket_path, serve # noqa: PLC0415
|
||||
|
||||
context = build_runtime_context()
|
||||
if not args.no_prewarm:
|
||||
# Deliberately before the socket exists: the Gateway's preflight and
|
||||
# first offer should both find a runtime that can start inference at
|
||||
# once, rather than one that spends an attempt lease compiling.
|
||||
prewarm_engines(context)
|
||||
return serve(context, resolve_socket_path(args.socket))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Import-path bootstrap for running outside the FastAPI app.
|
||||
|
||||
The backend is laid out to run with ``--app-dir backend`` (imports like
|
||||
``services.tts_backend`` resolve against the ``backend/`` directory). When
|
||||
the adapter is launched as ``python -m backend.runtime_adapter`` from the
|
||||
repo root, ``backend/`` is a namespace package but not on ``sys.path`` — so
|
||||
call :func:`ensure_backend_on_path` before any ``services.*`` / ``core.*``
|
||||
import. Idempotent; mirrors ``backend/tests/conftest.py``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def ensure_backend_on_path() -> str:
|
||||
backend_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
if backend_dir not in sys.path:
|
||||
sys.path.insert(0, backend_dir)
|
||||
return backend_dir
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Stable failure codes and exception classification for Execute.
|
||||
|
||||
The vssaas API Gateway keys retry and customer-charge policy off these codes,
|
||||
so they are a wire contract: never rename an existing code, only add. Every
|
||||
code maps to exactly one proto ``RuntimeFailureClass``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from .gen import runtime_adapter_pb2 as pb2
|
||||
|
||||
# ── invalid approved input ────────────────────────────────────────────────
|
||||
INPUT_ATTEMPT_IDENTITY = "RTA_INPUT_ATTEMPT_IDENTITY"
|
||||
INPUT_ATTEMPT_DUPLICATE = "RTA_INPUT_ATTEMPT_DUPLICATE"
|
||||
INPUT_MODEL_UNKNOWN = "RTA_INPUT_MODEL_UNKNOWN"
|
||||
INPUT_MODEL_NOT_READY = "RTA_INPUT_MODEL_NOT_READY"
|
||||
INPUT_MODEL_DIGEST_MISMATCH = "RTA_INPUT_MODEL_DIGEST_MISMATCH"
|
||||
INPUT_MODEL_PRECISION = "RTA_INPUT_MODEL_PRECISION_UNSUPPORTED"
|
||||
INPUT_DEVICE_UNKNOWN = "RTA_INPUT_DEVICE_UNKNOWN"
|
||||
INPUT_HANDLE_INVALID = "RTA_INPUT_HANDLE_INVALID"
|
||||
INPUT_ARTIFACTS_INVALID = "RTA_INPUT_ARTIFACTS_INVALID"
|
||||
INPUT_CHECKSUM_MISMATCH = "RTA_INPUT_CHECKSUM_MISMATCH"
|
||||
INPUT_TEXT_EMPTY = "RTA_INPUT_TEXT_EMPTY"
|
||||
INPUT_TEXT_TOO_LARGE = "RTA_INPUT_TEXT_TOO_LARGE"
|
||||
INPUT_TEXT_ENCODING = "RTA_INPUT_TEXT_ENCODING"
|
||||
INPUT_PARAMETER_UNKNOWN = "RTA_INPUT_PARAMETER_UNKNOWN"
|
||||
INPUT_PARAMETER_TYPE = "RTA_INPUT_PARAMETER_TYPE"
|
||||
INPUT_PARAMETER_RANGE = "RTA_INPUT_PARAMETER_RANGE"
|
||||
INPUT_DEADLINE_INVALID = "RTA_INPUT_DEADLINE_INVALID"
|
||||
INPUT_REJECTED = "RTA_INPUT_REJECTED" # engine-level TTSInputError
|
||||
|
||||
# ── model load / inference ────────────────────────────────────────────────
|
||||
MODEL_LOAD_FAILED = "RTA_MODEL_LOAD_FAILED"
|
||||
MODEL_LOAD_DEADLINE = "RTA_MODEL_LOAD_DEADLINE_EXCEEDED"
|
||||
INFERENCE_FAILED = "RTA_INFERENCE_FAILED"
|
||||
INFERENCE_BAD_OUTPUT = "RTA_INFERENCE_BAD_OUTPUT"
|
||||
INFERENCE_DEADLINE = "RTA_INFERENCE_DEADLINE_EXCEEDED"
|
||||
|
||||
# ── GPU resource ──────────────────────────────────────────────────────────
|
||||
GPU_OUT_OF_MEMORY = "RTA_GPU_OUT_OF_MEMORY"
|
||||
GPU_SLOTS_EXHAUSTED = "RTA_GPU_SLOTS_EXHAUSTED"
|
||||
|
||||
# ── local storage ─────────────────────────────────────────────────────────
|
||||
STORAGE_READ_FAILED = "RTA_STORAGE_READ_FAILED"
|
||||
STORAGE_WRITE_FAILED = "RTA_STORAGE_WRITE_FAILED"
|
||||
|
||||
# ── adapter crash ─────────────────────────────────────────────────────────
|
||||
RUNTIME_CRASH = "RTA_RUNTIME_CRASH"
|
||||
|
||||
_INPUT = pb2.RUNTIME_FAILURE_CLASS_INPUT
|
||||
_MODEL_LOAD = pb2.RUNTIME_FAILURE_CLASS_MODEL_LOAD
|
||||
_INFERENCE = pb2.RUNTIME_FAILURE_CLASS_INFERENCE
|
||||
_GPU = pb2.RUNTIME_FAILURE_CLASS_GPU_RESOURCE
|
||||
_STORAGE = pb2.RUNTIME_FAILURE_CLASS_LOCAL_STORAGE
|
||||
_RUNTIME = pb2.RUNTIME_FAILURE_CLASS_RUNTIME
|
||||
|
||||
CODE_CLASS: dict[str, int] = {
|
||||
INPUT_ATTEMPT_IDENTITY: _INPUT,
|
||||
INPUT_ATTEMPT_DUPLICATE: _INPUT,
|
||||
INPUT_MODEL_UNKNOWN: _INPUT,
|
||||
INPUT_MODEL_NOT_READY: _INPUT,
|
||||
INPUT_MODEL_DIGEST_MISMATCH: _INPUT,
|
||||
INPUT_MODEL_PRECISION: _INPUT,
|
||||
INPUT_DEVICE_UNKNOWN: _INPUT,
|
||||
INPUT_HANDLE_INVALID: _INPUT,
|
||||
INPUT_ARTIFACTS_INVALID: _INPUT,
|
||||
INPUT_CHECKSUM_MISMATCH: _INPUT,
|
||||
INPUT_TEXT_EMPTY: _INPUT,
|
||||
INPUT_TEXT_TOO_LARGE: _INPUT,
|
||||
INPUT_TEXT_ENCODING: _INPUT,
|
||||
INPUT_PARAMETER_UNKNOWN: _INPUT,
|
||||
INPUT_PARAMETER_TYPE: _INPUT,
|
||||
INPUT_PARAMETER_RANGE: _INPUT,
|
||||
INPUT_DEADLINE_INVALID: _INPUT,
|
||||
INPUT_REJECTED: _INPUT,
|
||||
MODEL_LOAD_FAILED: _MODEL_LOAD,
|
||||
MODEL_LOAD_DEADLINE: _MODEL_LOAD,
|
||||
INFERENCE_FAILED: _INFERENCE,
|
||||
INFERENCE_BAD_OUTPUT: _INFERENCE,
|
||||
INFERENCE_DEADLINE: _INFERENCE,
|
||||
GPU_OUT_OF_MEMORY: _GPU,
|
||||
GPU_SLOTS_EXHAUSTED: _GPU,
|
||||
STORAGE_READ_FAILED: _STORAGE,
|
||||
STORAGE_WRITE_FAILED: _STORAGE,
|
||||
RUNTIME_CRASH: _RUNTIME,
|
||||
}
|
||||
|
||||
|
||||
class ExecutionFailure(Exception):
|
||||
"""A classified, wire-safe execution failure."""
|
||||
|
||||
def __init__(self, stable_code: str, safe_detail: str = ""):
|
||||
if stable_code not in CODE_CLASS: # programming error, not a wire case
|
||||
raise ValueError(f"unknown stable code {stable_code!r}")
|
||||
super().__init__(stable_code)
|
||||
self.stable_code = stable_code
|
||||
self.failure_class = CODE_CLASS[stable_code]
|
||||
self.safe_detail = scrub_detail(safe_detail)
|
||||
|
||||
|
||||
_PATHISH = re.compile(r"(?:[A-Za-z]:)?[/\\][^\s'\"]+")
|
||||
_MAX_DETAIL = 240
|
||||
|
||||
|
||||
def scrub_detail(detail: str) -> str:
|
||||
"""Bound and de-path a detail string before it crosses the wire.
|
||||
|
||||
Local handles are server-generated, but engine exceptions routinely embed
|
||||
checkpoint paths, cache dirs, and home directories. None of that belongs
|
||||
in an event the Gateway relays upstream.
|
||||
"""
|
||||
scrubbed = _PATHISH.sub("<path>", detail or "").strip()
|
||||
return scrubbed[:_MAX_DETAIL]
|
||||
|
||||
|
||||
_OOM_MARKERS = (
|
||||
"out of memory",
|
||||
"cuda error: out of memory",
|
||||
"mps backend out of memory",
|
||||
"hip out of memory",
|
||||
"cublas_status_alloc_failed",
|
||||
)
|
||||
|
||||
|
||||
def _is_oom(exc: BaseException) -> bool:
|
||||
if type(exc).__name__ == "OutOfMemoryError": # torch.cuda.OutOfMemoryError
|
||||
return True
|
||||
message = str(exc).lower()
|
||||
return any(marker in message for marker in _OOM_MARKERS)
|
||||
|
||||
|
||||
def _is_engine_input_error(exc: BaseException) -> bool:
|
||||
try:
|
||||
from services.tts_backend import TTSInputError # noqa: PLC0415
|
||||
except Exception:
|
||||
return False
|
||||
return isinstance(exc, TTSInputError)
|
||||
|
||||
|
||||
def classify_engine_error(exc: BaseException, phase: str) -> ExecutionFailure:
|
||||
"""Map an engine exception to a stable failure code.
|
||||
|
||||
``phase`` is ``"model_load"`` or ``"synthesis"`` — the phase the engine
|
||||
thread was in when it raised.
|
||||
"""
|
||||
if isinstance(exc, ExecutionFailure):
|
||||
return exc
|
||||
detail = f"{type(exc).__name__}: {exc}"
|
||||
if _is_oom(exc):
|
||||
return ExecutionFailure(GPU_OUT_OF_MEMORY, detail)
|
||||
if _is_engine_input_error(exc):
|
||||
return ExecutionFailure(INPUT_REJECTED, detail)
|
||||
if isinstance(exc, OSError):
|
||||
return ExecutionFailure(STORAGE_READ_FAILED, detail)
|
||||
if phase == "model_load":
|
||||
return ExecutionFailure(MODEL_LOAD_FAILED, detail)
|
||||
return ExecutionFailure(INFERENCE_FAILED, detail)
|
||||
|
||||
|
||||
def deadline_failure(phase: str) -> ExecutionFailure:
|
||||
code = MODEL_LOAD_DEADLINE if phase == "model_load" else INFERENCE_DEADLINE
|
||||
return ExecutionFailure(code, "attempt deadline exceeded")
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Stable digests for locally installed model snapshots.
|
||||
|
||||
``model_digest`` in the wire contract pins the exact bytes a READY model will
|
||||
execute with. Hugging Face snapshots are symlink farms into ``blobs/``, so the
|
||||
digest is computed over the *resolved* file contents: SHA-256 of the sorted
|
||||
sequence ``<posix relpath>\\n<file sha256>\\n``. That is stable across hosts,
|
||||
cache locations, and symlink layout, and changes whenever any weight byte or
|
||||
the file set changes.
|
||||
|
||||
Hashing multi-GB weights on every ``GetCapabilities`` call would be absurd, so
|
||||
the result is cached in a JSON sidecar keyed by a cheap fingerprint of the
|
||||
file list (relpath, size, mtime_ns). Any file change invalidates the cache and
|
||||
forces a full re-hash.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
DIGEST_PREFIX = "sha256:"
|
||||
_CHUNK = 1024 * 1024
|
||||
|
||||
|
||||
def file_sha256(path: str | os.PathLike[str]) -> str:
|
||||
hasher = hashlib.sha256()
|
||||
with open(path, "rb") as fh:
|
||||
while True:
|
||||
chunk = fh.read(_CHUNK)
|
||||
if not chunk:
|
||||
break
|
||||
hasher.update(chunk)
|
||||
return hasher.hexdigest()
|
||||
|
||||
|
||||
def _manifest(root: Path) -> list[tuple[str, int, int]]:
|
||||
"""Sorted (relpath, size, mtime_ns) for every regular file under root.
|
||||
|
||||
Follows symlinks (HF snapshot layout); a dangling symlink raises
|
||||
``FileNotFoundError`` — callers treat that as an incomplete install.
|
||||
"""
|
||||
entries: list[tuple[str, int, int]] = []
|
||||
for current, dirs, files in os.walk(root, followlinks=True):
|
||||
dirs.sort()
|
||||
for name in sorted(files):
|
||||
path = Path(current) / name
|
||||
stat = path.stat() # resolves symlinks; raises if dangling
|
||||
rel = path.relative_to(root).as_posix()
|
||||
entries.append((rel, stat.st_size, stat.st_mtime_ns))
|
||||
entries.sort()
|
||||
return entries
|
||||
|
||||
|
||||
def _fingerprint(entries: list[tuple[str, int, int]]) -> str:
|
||||
return hashlib.sha256(
|
||||
json.dumps(entries, separators=(",", ":")).encode("utf-8")
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def snapshot_digest(root: str | os.PathLike[str], cache_path: str | os.PathLike[str] | None = None) -> str:
|
||||
"""``sha256:<hex>`` digest of the snapshot at ``root``.
|
||||
|
||||
Raises ``FileNotFoundError`` for a missing/empty snapshot or dangling
|
||||
symlink and ``OSError`` for unreadable files — callers classify those as
|
||||
not-READY rather than fabricating a digest.
|
||||
"""
|
||||
root = Path(root)
|
||||
entries = _manifest(root)
|
||||
if not entries:
|
||||
raise FileNotFoundError(f"empty model snapshot: {root}")
|
||||
fingerprint = _fingerprint(entries)
|
||||
|
||||
if cache_path is not None:
|
||||
cached = _read_cache(cache_path)
|
||||
if cached is not None and cached.get("fingerprint") == fingerprint:
|
||||
digest = cached.get("digest", "")
|
||||
if isinstance(digest, str) and digest.startswith(DIGEST_PREFIX):
|
||||
return digest
|
||||
|
||||
hasher = hashlib.sha256()
|
||||
for rel, _size, _mtime in entries:
|
||||
hasher.update(rel.encode("utf-8"))
|
||||
hasher.update(b"\n")
|
||||
hasher.update(file_sha256(root / rel).encode("ascii"))
|
||||
hasher.update(b"\n")
|
||||
digest = DIGEST_PREFIX + hasher.hexdigest()
|
||||
|
||||
if cache_path is not None:
|
||||
_write_cache(cache_path, fingerprint, digest)
|
||||
return digest
|
||||
|
||||
|
||||
def _read_cache(cache_path: str | os.PathLike[str]) -> dict | None:
|
||||
try:
|
||||
with open(cache_path, encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
return data if isinstance(data, dict) else None
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _write_cache(cache_path: str | os.PathLike[str], fingerprint: str, digest: str) -> None:
|
||||
cache_path = Path(cache_path)
|
||||
payload = json.dumps({"fingerprint": fingerprint, "digest": digest})
|
||||
try:
|
||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = cache_path.with_suffix(f".tmp-{os.getpid()}")
|
||||
temporary.write_text(payload, encoding="utf-8")
|
||||
os.replace(temporary, cache_path)
|
||||
except OSError:
|
||||
pass # cache is an optimization; the digest itself is already computed
|
||||
@@ -0,0 +1,664 @@
|
||||
"""Execute/Cancel: attempt registry, validation, and the event stream.
|
||||
|
||||
One ``Execute`` call is one *attempt*. The generator emits::
|
||||
|
||||
started → progress* → exactly one of completed | failed | canceled
|
||||
|
||||
The engine call itself (``ensure_ready`` + ``generate``) runs on a daemon
|
||||
worker thread; the streaming generator polls it, emitting bounded heartbeat
|
||||
progress and enforcing the request deadline and cancellation. A blocking
|
||||
engine cannot be interrupted mid-kernel, so on cancel/deadline the thread is
|
||||
abandoned and its result discarded — the terminal event is what the Gateway
|
||||
acts on, and slot accounting is released only when the thread actually exits.
|
||||
|
||||
The adapter never turns a customer string into a filesystem path: it touches
|
||||
exactly the local handles the request carries, after validation.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from . import codes
|
||||
from ._paths import ensure_backend_on_path
|
||||
from .digest import file_sha256
|
||||
from .gen import runtime_adapter_pb2 as pb2
|
||||
from .inventory import STATE_READY
|
||||
|
||||
_MAX_TEXT_BYTES = 512_000
|
||||
_MAX_REF_AUDIO_BYTES = 100 * 1024 * 1024
|
||||
_MAX_DEADLINE_S = 24 * 3600.0
|
||||
_MAX_PROGRESS_EVENTS = 512
|
||||
|
||||
#: Typed, bounded Execute parameters → the engine ``generate()`` kwarg of the
|
||||
#: same name. Kinds: ("string", max_len) / ("integer", lo, hi) /
|
||||
#: ("number", lo, hi) / ("boolean",).
|
||||
PARAMETER_SPECS: dict[str, tuple] = {
|
||||
"language": ("string", 32),
|
||||
"ref_text": ("string", 4096),
|
||||
"instruct": ("string", 2048),
|
||||
"description": ("string", 2048),
|
||||
"speed": ("number", 0.25, 4.0),
|
||||
"guidance_scale": ("number", 0.0, 16.0),
|
||||
"num_step": ("integer", 1, 128),
|
||||
# Gallery reference voices persist their OSS design seed. Accept it at
|
||||
# the hosted runtime boundary so a selected voice produces the same take.
|
||||
"seed": ("integer", 0, 4_294_967_295),
|
||||
}
|
||||
|
||||
|
||||
# ── attempt registry ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class AttemptRecord:
|
||||
job_id: str
|
||||
attempt_id: str
|
||||
cancel: threading.Event = field(default_factory=threading.Event)
|
||||
terminal: str | None = None # "completed" | "failed" | "canceled"
|
||||
|
||||
|
||||
class AttemptRegistry:
|
||||
"""Attempt bookkeeping: admission, idempotent cancel, bounded history."""
|
||||
|
||||
def __init__(self, max_terminal: int = 4096):
|
||||
self._lock = threading.Lock()
|
||||
self._active: dict[str, AttemptRecord] = {}
|
||||
self._terminal: OrderedDict[str, AttemptRecord] = OrderedDict()
|
||||
self._max_terminal = max_terminal
|
||||
|
||||
def begin(self, job_id: str, attempt_id: str, slot_limit: int) -> AttemptRecord:
|
||||
with self._lock:
|
||||
if attempt_id in self._active or attempt_id in self._terminal:
|
||||
raise codes.ExecutionFailure(
|
||||
codes.INPUT_ATTEMPT_DUPLICATE, "attempt id already used"
|
||||
)
|
||||
if len(self._active) >= max(1, slot_limit):
|
||||
raise codes.ExecutionFailure(
|
||||
codes.GPU_SLOTS_EXHAUSTED, "no free execution slot"
|
||||
)
|
||||
record = AttemptRecord(job_id=job_id, attempt_id=attempt_id)
|
||||
self._active[attempt_id] = record
|
||||
return record
|
||||
|
||||
def finish(self, attempt_id: str, terminal: str) -> None:
|
||||
with self._lock:
|
||||
record = self._active.pop(attempt_id, None)
|
||||
if record is None:
|
||||
return
|
||||
record.terminal = terminal
|
||||
self._terminal[attempt_id] = record
|
||||
while len(self._terminal) > self._max_terminal:
|
||||
self._terminal.popitem(last=False)
|
||||
|
||||
def active_count(self) -> int:
|
||||
with self._lock:
|
||||
return len(self._active)
|
||||
|
||||
def cancel(self, job_id: str, attempt_id: str) -> int:
|
||||
"""Idempotent by attempt id; returns a proto CancelDisposition."""
|
||||
with self._lock:
|
||||
record = self._active.get(attempt_id)
|
||||
if record is not None:
|
||||
if job_id and record.job_id and job_id != record.job_id:
|
||||
return pb2.CANCEL_DISPOSITION_NOT_FOUND
|
||||
record.cancel.set()
|
||||
return pb2.CANCEL_DISPOSITION_ACCEPTED
|
||||
record = self._terminal.get(attempt_id)
|
||||
if record is not None:
|
||||
if job_id and record.job_id and job_id != record.job_id:
|
||||
return pb2.CANCEL_DISPOSITION_NOT_FOUND
|
||||
return pb2.CANCEL_DISPOSITION_ALREADY_TERMINAL
|
||||
return pb2.CANCEL_DISPOSITION_NOT_FOUND
|
||||
|
||||
|
||||
# ── request validation ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValidatedRequest:
|
||||
text: str
|
||||
output_handle: str
|
||||
output_media_type: str
|
||||
output_size_bound: int
|
||||
engine_kwargs: dict
|
||||
deadline_monotonic: float
|
||||
catalog_model_id: str
|
||||
|
||||
|
||||
def _validate_handle(handle: str, code: str = codes.INPUT_HANDLE_INVALID) -> str:
|
||||
cleaned = (handle or "").strip()
|
||||
if (
|
||||
not cleaned
|
||||
or "\x00" in cleaned
|
||||
or "://" in cleaned
|
||||
or not os.path.isabs(cleaned)
|
||||
or os.path.normpath(cleaned) != cleaned
|
||||
):
|
||||
raise codes.ExecutionFailure(code, "local handle must be an absolute path")
|
||||
return cleaned
|
||||
|
||||
|
||||
def _read_input_file(artifact, max_bytes: int) -> bytes:
|
||||
path = _validate_handle(artifact.local_handle)
|
||||
try:
|
||||
stat = os.lstat(path)
|
||||
except OSError as exc:
|
||||
raise codes.ExecutionFailure(
|
||||
codes.STORAGE_READ_FAILED, f"input handle unreadable: {type(exc).__name__}"
|
||||
)
|
||||
import stat as stat_module # noqa: PLC0415
|
||||
|
||||
if not stat_module.S_ISREG(stat.st_mode):
|
||||
raise codes.ExecutionFailure(
|
||||
codes.INPUT_HANDLE_INVALID, "input handle must be a regular file"
|
||||
)
|
||||
bound = max_bytes
|
||||
if 0 < artifact.expected_size_bytes <= max_bytes:
|
||||
bound = artifact.expected_size_bytes
|
||||
if stat.st_size > bound:
|
||||
raise codes.ExecutionFailure(
|
||||
codes.INPUT_TEXT_TOO_LARGE, "input exceeds its size bound"
|
||||
)
|
||||
try:
|
||||
with open(path, "rb") as fh:
|
||||
data = fh.read(bound + 1)
|
||||
except OSError as exc:
|
||||
raise codes.ExecutionFailure(
|
||||
codes.STORAGE_READ_FAILED, f"input read failed: {type(exc).__name__}"
|
||||
)
|
||||
if len(data) > bound:
|
||||
raise codes.ExecutionFailure(
|
||||
codes.INPUT_TEXT_TOO_LARGE, "input exceeds its size bound"
|
||||
)
|
||||
expected = (artifact.expected_sha256 or "").strip().lower().removeprefix("sha256:")
|
||||
if expected:
|
||||
import hashlib # noqa: PLC0415
|
||||
|
||||
if hashlib.sha256(data).hexdigest() != expected:
|
||||
raise codes.ExecutionFailure(
|
||||
codes.INPUT_CHECKSUM_MISMATCH, "input checksum mismatch"
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
def _typed_parameter(name: str, value) -> object:
|
||||
spec = PARAMETER_SPECS.get(name)
|
||||
if spec is None:
|
||||
raise codes.ExecutionFailure(
|
||||
codes.INPUT_PARAMETER_UNKNOWN, f"unknown parameter {name!r}"
|
||||
)
|
||||
kind = spec[0]
|
||||
which = value.WhichOneof("value")
|
||||
if kind == "string":
|
||||
if which != "string_value":
|
||||
raise codes.ExecutionFailure(
|
||||
codes.INPUT_PARAMETER_TYPE, f"parameter {name!r} must be a string"
|
||||
)
|
||||
text = value.string_value
|
||||
if len(text) > spec[1]:
|
||||
raise codes.ExecutionFailure(
|
||||
codes.INPUT_PARAMETER_RANGE, f"parameter {name!r} too long"
|
||||
)
|
||||
return text
|
||||
if kind == "integer":
|
||||
if which != "integer_value":
|
||||
raise codes.ExecutionFailure(
|
||||
codes.INPUT_PARAMETER_TYPE, f"parameter {name!r} must be an integer"
|
||||
)
|
||||
number = value.integer_value
|
||||
if not spec[1] <= number <= spec[2]:
|
||||
raise codes.ExecutionFailure(
|
||||
codes.INPUT_PARAMETER_RANGE, f"parameter {name!r} out of range"
|
||||
)
|
||||
return int(number)
|
||||
if kind == "number":
|
||||
if which == "number_value":
|
||||
number = value.number_value
|
||||
elif which == "integer_value":
|
||||
number = float(value.integer_value)
|
||||
else:
|
||||
raise codes.ExecutionFailure(
|
||||
codes.INPUT_PARAMETER_TYPE, f"parameter {name!r} must be a number"
|
||||
)
|
||||
if not spec[1] <= number <= spec[2]:
|
||||
raise codes.ExecutionFailure(
|
||||
codes.INPUT_PARAMETER_RANGE, f"parameter {name!r} out of range"
|
||||
)
|
||||
return float(number)
|
||||
if which != "boolean_value":
|
||||
raise codes.ExecutionFailure(
|
||||
codes.INPUT_PARAMETER_TYPE, f"parameter {name!r} must be a boolean"
|
||||
)
|
||||
return bool(value.boolean_value)
|
||||
|
||||
|
||||
# ── the executor ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class Executor:
|
||||
"""Validates and runs attempts against an inventory + engine provider."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
inventory,
|
||||
engine_provider,
|
||||
registry: AttemptRegistry,
|
||||
*,
|
||||
slot_limit: int = 1,
|
||||
progress_interval: float = 0.5,
|
||||
poll_interval: float = 0.02,
|
||||
clock=time.monotonic,
|
||||
):
|
||||
self._inventory = inventory
|
||||
self._engine_provider = engine_provider
|
||||
self._registry = registry
|
||||
self._slot_limit = max(1, slot_limit)
|
||||
self._progress_interval = progress_interval
|
||||
self._poll_interval = poll_interval
|
||||
self._clock = clock
|
||||
|
||||
# -- validation ----------------------------------------------------
|
||||
|
||||
def _validate(self, request) -> ValidatedRequest:
|
||||
now_ms = int(time.time() * 1000)
|
||||
if request.deadline_unix_ms <= now_ms:
|
||||
raise codes.ExecutionFailure(
|
||||
codes.INPUT_DEADLINE_INVALID, "deadline is not in the future"
|
||||
)
|
||||
budget_s = min((request.deadline_unix_ms - now_ms) / 1000.0, _MAX_DEADLINE_S)
|
||||
|
||||
model = self._validate_model(request.model)
|
||||
self._validate_device(request.device_id)
|
||||
|
||||
text_artifact, ref_artifact = self._split_inputs(request.inputs)
|
||||
output = self._single_output(request.outputs)
|
||||
output_handle = _validate_handle(output.local_handle)
|
||||
parent = os.path.dirname(output_handle)
|
||||
if not os.path.isdir(parent):
|
||||
raise codes.ExecutionFailure(
|
||||
codes.INPUT_HANDLE_INVALID, "output handle directory does not exist"
|
||||
)
|
||||
|
||||
raw = _read_input_file(text_artifact, _MAX_TEXT_BYTES)
|
||||
try:
|
||||
text = raw.decode("utf-8").strip()
|
||||
except UnicodeDecodeError:
|
||||
raise codes.ExecutionFailure(
|
||||
codes.INPUT_TEXT_ENCODING, "input text is not valid UTF-8"
|
||||
)
|
||||
if not text:
|
||||
raise codes.ExecutionFailure(codes.INPUT_TEXT_EMPTY, "input text is empty")
|
||||
|
||||
engine_kwargs: dict = {}
|
||||
for name in sorted(request.parameters):
|
||||
engine_kwargs[name] = _typed_parameter(name, request.parameters[name])
|
||||
if ref_artifact is not None:
|
||||
_read_input_file(ref_artifact, _MAX_REF_AUDIO_BYTES) # existence/bounds/checksum
|
||||
engine_kwargs["ref_audio"] = _validate_handle(ref_artifact.local_handle)
|
||||
|
||||
return ValidatedRequest(
|
||||
text=text,
|
||||
output_handle=output_handle,
|
||||
output_media_type=output.media_type or "audio/wav",
|
||||
output_size_bound=int(output.expected_size_bytes),
|
||||
engine_kwargs=engine_kwargs,
|
||||
deadline_monotonic=self._clock() + budget_s,
|
||||
catalog_model_id=request.model.catalog_model_id,
|
||||
)
|
||||
|
||||
def _validate_model(self, spec):
|
||||
wanted = (spec.catalog_model_id or "").strip()
|
||||
if not wanted:
|
||||
raise codes.ExecutionFailure(
|
||||
codes.INPUT_MODEL_UNKNOWN, "catalog model id is required"
|
||||
)
|
||||
matches = [
|
||||
model
|
||||
for model in self._inventory.models()
|
||||
if model.catalog_model_id == wanted
|
||||
]
|
||||
if not matches:
|
||||
raise codes.ExecutionFailure(codes.INPUT_MODEL_UNKNOWN, "model not present")
|
||||
model = matches[0]
|
||||
if model.state != STATE_READY:
|
||||
raise codes.ExecutionFailure(
|
||||
codes.INPUT_MODEL_NOT_READY, "model is not READY"
|
||||
)
|
||||
if spec.model_version and spec.model_version != model.model_version:
|
||||
raise codes.ExecutionFailure(
|
||||
codes.INPUT_MODEL_UNKNOWN, "model version mismatch"
|
||||
)
|
||||
if not spec.model_digest or spec.model_digest != model.model_digest:
|
||||
raise codes.ExecutionFailure(
|
||||
codes.INPUT_MODEL_DIGEST_MISMATCH, "approved model digest mismatch"
|
||||
)
|
||||
if spec.precision and spec.precision not in model.precisions:
|
||||
raise codes.ExecutionFailure(
|
||||
codes.INPUT_MODEL_PRECISION, "precision not offered by this model"
|
||||
)
|
||||
return model
|
||||
|
||||
def _validate_device(self, device_id: str) -> None:
|
||||
wanted = (device_id or "").strip()
|
||||
if not wanted:
|
||||
raise codes.ExecutionFailure(
|
||||
codes.INPUT_DEVICE_UNKNOWN, "device id is required"
|
||||
)
|
||||
known = {device.device_id for device in self._inventory.devices()}
|
||||
if wanted not in known:
|
||||
raise codes.ExecutionFailure(
|
||||
codes.INPUT_DEVICE_UNKNOWN, "device id not in inventory"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _split_inputs(inputs):
|
||||
text_artifacts, audio_artifacts = [], []
|
||||
for artifact in inputs:
|
||||
if artifact.operation != pb2.LOCAL_ARTIFACT_OPERATION_READ:
|
||||
raise codes.ExecutionFailure(
|
||||
codes.INPUT_ARTIFACTS_INVALID, "inputs must be READ artifacts"
|
||||
)
|
||||
media = artifact.media_type or ""
|
||||
if media.startswith("audio/"):
|
||||
audio_artifacts.append(artifact)
|
||||
elif media == "" or media.startswith("text/"):
|
||||
text_artifacts.append(artifact)
|
||||
else:
|
||||
raise codes.ExecutionFailure(
|
||||
codes.INPUT_ARTIFACTS_INVALID, f"unsupported input media {media!r}"
|
||||
)
|
||||
if len(text_artifacts) != 1 or len(audio_artifacts) > 1:
|
||||
raise codes.ExecutionFailure(
|
||||
codes.INPUT_ARTIFACTS_INVALID,
|
||||
"tts needs exactly one text input and at most one reference audio",
|
||||
)
|
||||
return text_artifacts[0], (audio_artifacts[0] if audio_artifacts else None)
|
||||
|
||||
@staticmethod
|
||||
def _single_output(outputs):
|
||||
if len(outputs) != 1:
|
||||
raise codes.ExecutionFailure(
|
||||
codes.INPUT_ARTIFACTS_INVALID, "tts needs exactly one output artifact"
|
||||
)
|
||||
output = outputs[0]
|
||||
if output.operation != pb2.LOCAL_ARTIFACT_OPERATION_WRITE:
|
||||
raise codes.ExecutionFailure(
|
||||
codes.INPUT_ARTIFACTS_INVALID, "output must be a WRITE artifact"
|
||||
)
|
||||
media = output.media_type or ""
|
||||
if media and not media.startswith("audio/"):
|
||||
raise codes.ExecutionFailure(
|
||||
codes.INPUT_ARTIFACTS_INVALID, f"unsupported output media {media!r}"
|
||||
)
|
||||
return output
|
||||
|
||||
# -- execution -----------------------------------------------------
|
||||
|
||||
def execute(self, request, grpc_context=None):
|
||||
"""Generator of ``pb2.ExecuteResponse``. Never raises for a
|
||||
classified failure — failures become terminal events."""
|
||||
session = _Session(self, request)
|
||||
return session.run(grpc_context)
|
||||
|
||||
|
||||
class _Session:
|
||||
def __init__(self, executor: Executor, request):
|
||||
self._x = executor
|
||||
self.request = request
|
||||
self.job_id = request.job_id
|
||||
self.attempt_id = request.attempt_id
|
||||
self.sequence = 0
|
||||
self.phase = "model_load"
|
||||
self.terminal_sent = False
|
||||
self.chars = 0
|
||||
self.gpu_ms = 0
|
||||
self.cpu_ms = 0
|
||||
self.output_audio_ms = 0
|
||||
|
||||
# event builders ---------------------------------------------------
|
||||
|
||||
def _event(self, **payload):
|
||||
self.sequence += 1
|
||||
return pb2.ExecuteResponse(
|
||||
event=pb2.ExecutionEvent(
|
||||
job_id=self.job_id,
|
||||
attempt_id=self.attempt_id,
|
||||
sequence=self.sequence,
|
||||
observed_at_unix_ms=int(time.time() * 1000),
|
||||
**payload,
|
||||
)
|
||||
)
|
||||
|
||||
def _measurements(self):
|
||||
return pb2.RuntimeMeasurements(
|
||||
normalized_input_characters=self.chars,
|
||||
output_audio_ms=self.output_audio_ms,
|
||||
gpu_execution_ms=self.gpu_ms,
|
||||
cpu_execution_ms=self.cpu_ms,
|
||||
)
|
||||
|
||||
def _failed(self, failure: codes.ExecutionFailure):
|
||||
self.terminal_sent = True
|
||||
return self._event(
|
||||
failed=pb2.ExecutionFailed(
|
||||
failure_class=failure.failure_class,
|
||||
stable_code=failure.stable_code,
|
||||
safe_detail=failure.safe_detail,
|
||||
measurements=self._measurements(),
|
||||
)
|
||||
)
|
||||
|
||||
def _canceled(self):
|
||||
self.terminal_sent = True
|
||||
return self._event(
|
||||
canceled=pb2.ExecutionCanceled(measurements=self._measurements())
|
||||
)
|
||||
|
||||
# main flow --------------------------------------------------------
|
||||
|
||||
def run(self, grpc_context):
|
||||
if not self.attempt_id.strip() or not self.job_id.strip():
|
||||
yield self._failed(
|
||||
codes.ExecutionFailure(
|
||||
codes.INPUT_ATTEMPT_IDENTITY, "job and attempt ids are required"
|
||||
)
|
||||
)
|
||||
return
|
||||
registry = self._x._registry
|
||||
try:
|
||||
record = registry.begin(self.job_id, self.attempt_id, self._x._slot_limit)
|
||||
except codes.ExecutionFailure as failure:
|
||||
yield self._failed(failure)
|
||||
return
|
||||
try:
|
||||
yield from self._run_admitted(record, grpc_context)
|
||||
finally:
|
||||
terminal = "canceled"
|
||||
if self.terminal_sent:
|
||||
terminal = self._terminal_kind or "failed"
|
||||
registry.finish(self.attempt_id, terminal)
|
||||
|
||||
_terminal_kind: str | None = None
|
||||
|
||||
def _run_admitted(self, record, grpc_context):
|
||||
try:
|
||||
validated = self._x._validate(self.request)
|
||||
except codes.ExecutionFailure as failure:
|
||||
self._terminal_kind = "failed"
|
||||
yield self._failed(failure)
|
||||
return
|
||||
except Exception as exc: # adapter bug — still a classified event
|
||||
self._terminal_kind = "failed"
|
||||
yield self._failed(
|
||||
codes.ExecutionFailure(codes.RUNTIME_CRASH, f"{type(exc).__name__}")
|
||||
)
|
||||
return
|
||||
|
||||
self.chars = len(validated.text)
|
||||
yield self._event(started=pb2.ExecutionStarted())
|
||||
|
||||
worker = _EngineWorker(self._x._engine_provider, validated, self)
|
||||
worker.start()
|
||||
|
||||
clock = self._x._clock
|
||||
next_progress = clock() + self._x._progress_interval
|
||||
progress_events = 0
|
||||
while not worker.done.wait(self._x._poll_interval):
|
||||
if record.cancel.is_set() or (
|
||||
grpc_context is not None and not grpc_context.is_active()
|
||||
):
|
||||
self._terminal_kind = "canceled"
|
||||
yield self._canceled()
|
||||
return
|
||||
now = clock()
|
||||
if now >= validated.deadline_monotonic:
|
||||
self._terminal_kind = "failed"
|
||||
yield self._failed(codes.deadline_failure(self.phase))
|
||||
return
|
||||
if now >= next_progress and progress_events < _MAX_PROGRESS_EVENTS:
|
||||
progress_events += 1
|
||||
next_progress = now + self._x._progress_interval
|
||||
permille = 100 if self.phase == "model_load" else 550
|
||||
yield self._event(
|
||||
progress=pb2.ExecutionProgress(
|
||||
progress_permille=permille, stage_code=self.phase
|
||||
)
|
||||
)
|
||||
|
||||
if record.cancel.is_set():
|
||||
self._terminal_kind = "canceled"
|
||||
yield self._canceled()
|
||||
return
|
||||
if worker.error is not None:
|
||||
self._terminal_kind = "failed"
|
||||
yield self._failed(codes.classify_engine_error(worker.error, worker.phase))
|
||||
return
|
||||
|
||||
try:
|
||||
manifest = self._write_output(worker, validated)
|
||||
except codes.ExecutionFailure as failure:
|
||||
self._terminal_kind = "failed"
|
||||
yield self._failed(failure)
|
||||
return
|
||||
self._terminal_kind = "completed"
|
||||
self.terminal_sent = True
|
||||
yield self._event(
|
||||
completed=pb2.ExecutionCompleted(
|
||||
outputs=[manifest], measurements=self._measurements()
|
||||
)
|
||||
)
|
||||
|
||||
def _write_output(self, worker, validated: ValidatedRequest):
|
||||
ensure_backend_on_path()
|
||||
tensor = worker.result
|
||||
sample_rate = worker.sample_rate
|
||||
if tensor is None or not hasattr(tensor, "numel") or tensor.numel() == 0:
|
||||
raise codes.ExecutionFailure(
|
||||
codes.INFERENCE_BAD_OUTPUT, "engine returned no audio"
|
||||
)
|
||||
if not isinstance(sample_rate, int) or sample_rate <= 0:
|
||||
raise codes.ExecutionFailure(
|
||||
codes.INFERENCE_BAD_OUTPUT, "engine reported no sample rate"
|
||||
)
|
||||
try:
|
||||
from services.audio_io import atomic_save_wav # noqa: PLC0415
|
||||
|
||||
atomic_save_wav(validated.output_handle, tensor.detach().cpu(), sample_rate)
|
||||
except codes.ExecutionFailure:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise codes.ExecutionFailure(
|
||||
codes.STORAGE_WRITE_FAILED, f"{type(exc).__name__}: {exc}"
|
||||
)
|
||||
try:
|
||||
size = os.stat(validated.output_handle).st_size
|
||||
sha = file_sha256(validated.output_handle)
|
||||
except OSError as exc:
|
||||
raise codes.ExecutionFailure(
|
||||
codes.STORAGE_WRITE_FAILED, f"{type(exc).__name__}"
|
||||
)
|
||||
if 0 < validated.output_size_bound < size:
|
||||
raise codes.ExecutionFailure(
|
||||
codes.STORAGE_WRITE_FAILED, "output exceeds its size bound"
|
||||
)
|
||||
samples = tensor.numel() if tensor.dim() == 1 else tensor.shape[-1]
|
||||
self.output_audio_ms = int(samples * 1000 / sample_rate)
|
||||
return pb2.LocalArtifactManifest(
|
||||
artifact_id=self.request.outputs[0].artifact_id,
|
||||
local_handle=validated.output_handle,
|
||||
size_bytes=size,
|
||||
sha256=sha,
|
||||
media_type=validated.output_media_type,
|
||||
duration_ms=self.output_audio_ms,
|
||||
)
|
||||
|
||||
|
||||
class _EngineWorker:
|
||||
"""Runs the engine on a daemon thread, recording phase and timings."""
|
||||
|
||||
def __init__(self, engine_provider, validated: ValidatedRequest, session: _Session):
|
||||
self._engine_provider = engine_provider
|
||||
self._validated = validated
|
||||
self._session = session
|
||||
self.done = threading.Event()
|
||||
self.error: BaseException | None = None
|
||||
self.result = None
|
||||
self.sample_rate: int | None = None
|
||||
self.phase = "model_load"
|
||||
|
||||
def start(self) -> None:
|
||||
thread = threading.Thread(
|
||||
target=self._run,
|
||||
name=f"runtime-adapter-attempt-{self._session.attempt_id}",
|
||||
daemon=True,
|
||||
)
|
||||
thread.start()
|
||||
|
||||
@staticmethod
|
||||
def _synthesize(engine, text: str, params: dict):
|
||||
"""Use the same seeded native path as OSS Gallery and ovnode workers."""
|
||||
from services import tts_backend # noqa: PLC0415
|
||||
|
||||
if isinstance(engine, tts_backend.OmniVoiceBackend):
|
||||
from api.routers.generation import _run_inference # noqa: PLC0415
|
||||
|
||||
with tts_backend.engine_in_use(engine):
|
||||
return _run_inference(
|
||||
engine._model, text, params.get("language"),
|
||||
params.get("ref_audio"), params.get("ref_text"),
|
||||
params.get("instruct"), params.get("duration"),
|
||||
params.get("num_step", 16), params.get("guidance_scale", 2.0),
|
||||
params.get("speed", 1.0), params.get("t_shift"),
|
||||
params.get("denoise", True), params.get("postprocess_output", True),
|
||||
params.get("layer_penalty_factor"),
|
||||
params.get("position_temperature"),
|
||||
params.get("class_temperature"), params.get("seed"),
|
||||
)
|
||||
return engine.generate(text, **params)
|
||||
|
||||
def _run(self) -> None:
|
||||
wall_start = time.monotonic()
|
||||
cpu_start = time.process_time()
|
||||
try:
|
||||
engine = self._engine_provider(self._validated.catalog_model_id)
|
||||
ensure_ready = getattr(engine, "ensure_ready", None)
|
||||
if callable(ensure_ready):
|
||||
ensure_ready()
|
||||
self.phase = "synthesis"
|
||||
self._session.phase = "synthesis"
|
||||
synth_start = time.monotonic()
|
||||
self.result = self._synthesize(engine, self._validated.text, self._validated.engine_kwargs)
|
||||
rate = getattr(engine, "sample_rate", None)
|
||||
self.sample_rate = int(rate) if isinstance(rate, (int, float)) and rate else None
|
||||
self._session.gpu_ms = int((time.monotonic() - synth_start) * 1000)
|
||||
except BaseException as exc: # classified later, never lost
|
||||
self.error = exc
|
||||
finally:
|
||||
self._session.cpu_ms = int((time.process_time() - cpu_start) * 1000)
|
||||
if self._session.gpu_ms == 0 and self.error is None:
|
||||
self._session.gpu_ms = int((time.monotonic() - wall_start) * 1000)
|
||||
self.done.set()
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Generated protocol stubs — DO NOT EDIT.
|
||||
|
||||
Regenerate with ``uv run python scripts/gen_runtime_adapter_protocol.py``
|
||||
after any change to ``../runtime_adapter.proto``.
|
||||
"""
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,330 @@
|
||||
from google.protobuf.internal import containers as _containers
|
||||
from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper
|
||||
from google.protobuf import descriptor as _descriptor
|
||||
from google.protobuf import message as _message
|
||||
from collections.abc import Iterable as _Iterable, Mapping as _Mapping
|
||||
from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union
|
||||
|
||||
DESCRIPTOR: _descriptor.FileDescriptor
|
||||
|
||||
class ServingState(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
|
||||
__slots__ = ()
|
||||
SERVING_STATE_UNSPECIFIED: _ClassVar[ServingState]
|
||||
SERVING_STATE_READY: _ClassVar[ServingState]
|
||||
SERVING_STATE_DEGRADED: _ClassVar[ServingState]
|
||||
SERVING_STATE_UNHEALTHY: _ClassVar[ServingState]
|
||||
|
||||
class RuntimeModelState(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
|
||||
__slots__ = ()
|
||||
RUNTIME_MODEL_STATE_UNSPECIFIED: _ClassVar[RuntimeModelState]
|
||||
RUNTIME_MODEL_STATE_INSTALLED: _ClassVar[RuntimeModelState]
|
||||
RUNTIME_MODEL_STATE_LOADING: _ClassVar[RuntimeModelState]
|
||||
RUNTIME_MODEL_STATE_READY: _ClassVar[RuntimeModelState]
|
||||
RUNTIME_MODEL_STATE_FAILED: _ClassVar[RuntimeModelState]
|
||||
|
||||
class LocalArtifactOperation(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
|
||||
__slots__ = ()
|
||||
LOCAL_ARTIFACT_OPERATION_UNSPECIFIED: _ClassVar[LocalArtifactOperation]
|
||||
LOCAL_ARTIFACT_OPERATION_READ: _ClassVar[LocalArtifactOperation]
|
||||
LOCAL_ARTIFACT_OPERATION_WRITE: _ClassVar[LocalArtifactOperation]
|
||||
|
||||
class RuntimeFailureClass(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
|
||||
__slots__ = ()
|
||||
RUNTIME_FAILURE_CLASS_UNSPECIFIED: _ClassVar[RuntimeFailureClass]
|
||||
RUNTIME_FAILURE_CLASS_INPUT: _ClassVar[RuntimeFailureClass]
|
||||
RUNTIME_FAILURE_CLASS_MODEL_LOAD: _ClassVar[RuntimeFailureClass]
|
||||
RUNTIME_FAILURE_CLASS_INFERENCE: _ClassVar[RuntimeFailureClass]
|
||||
RUNTIME_FAILURE_CLASS_GPU_RESOURCE: _ClassVar[RuntimeFailureClass]
|
||||
RUNTIME_FAILURE_CLASS_LOCAL_STORAGE: _ClassVar[RuntimeFailureClass]
|
||||
RUNTIME_FAILURE_CLASS_RUNTIME: _ClassVar[RuntimeFailureClass]
|
||||
RUNTIME_FAILURE_CLASS_CANCELED: _ClassVar[RuntimeFailureClass]
|
||||
|
||||
class CancelDisposition(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
|
||||
__slots__ = ()
|
||||
CANCEL_DISPOSITION_UNSPECIFIED: _ClassVar[CancelDisposition]
|
||||
CANCEL_DISPOSITION_ACCEPTED: _ClassVar[CancelDisposition]
|
||||
CANCEL_DISPOSITION_ALREADY_TERMINAL: _ClassVar[CancelDisposition]
|
||||
CANCEL_DISPOSITION_NOT_FOUND: _ClassVar[CancelDisposition]
|
||||
SERVING_STATE_UNSPECIFIED: ServingState
|
||||
SERVING_STATE_READY: ServingState
|
||||
SERVING_STATE_DEGRADED: ServingState
|
||||
SERVING_STATE_UNHEALTHY: ServingState
|
||||
RUNTIME_MODEL_STATE_UNSPECIFIED: RuntimeModelState
|
||||
RUNTIME_MODEL_STATE_INSTALLED: RuntimeModelState
|
||||
RUNTIME_MODEL_STATE_LOADING: RuntimeModelState
|
||||
RUNTIME_MODEL_STATE_READY: RuntimeModelState
|
||||
RUNTIME_MODEL_STATE_FAILED: RuntimeModelState
|
||||
LOCAL_ARTIFACT_OPERATION_UNSPECIFIED: LocalArtifactOperation
|
||||
LOCAL_ARTIFACT_OPERATION_READ: LocalArtifactOperation
|
||||
LOCAL_ARTIFACT_OPERATION_WRITE: LocalArtifactOperation
|
||||
RUNTIME_FAILURE_CLASS_UNSPECIFIED: RuntimeFailureClass
|
||||
RUNTIME_FAILURE_CLASS_INPUT: RuntimeFailureClass
|
||||
RUNTIME_FAILURE_CLASS_MODEL_LOAD: RuntimeFailureClass
|
||||
RUNTIME_FAILURE_CLASS_INFERENCE: RuntimeFailureClass
|
||||
RUNTIME_FAILURE_CLASS_GPU_RESOURCE: RuntimeFailureClass
|
||||
RUNTIME_FAILURE_CLASS_LOCAL_STORAGE: RuntimeFailureClass
|
||||
RUNTIME_FAILURE_CLASS_RUNTIME: RuntimeFailureClass
|
||||
RUNTIME_FAILURE_CLASS_CANCELED: RuntimeFailureClass
|
||||
CANCEL_DISPOSITION_UNSPECIFIED: CancelDisposition
|
||||
CANCEL_DISPOSITION_ACCEPTED: CancelDisposition
|
||||
CANCEL_DISPOSITION_ALREADY_TERMINAL: CancelDisposition
|
||||
CANCEL_DISPOSITION_NOT_FOUND: CancelDisposition
|
||||
|
||||
class ExecuteResponse(_message.Message):
|
||||
__slots__ = ("event",)
|
||||
EVENT_FIELD_NUMBER: _ClassVar[int]
|
||||
event: ExecutionEvent
|
||||
def __init__(self, event: _Optional[_Union[ExecutionEvent, _Mapping]] = ...) -> None: ...
|
||||
|
||||
class HealthRequest(_message.Message):
|
||||
__slots__ = ()
|
||||
def __init__(self) -> None: ...
|
||||
|
||||
class HealthResponse(_message.Message):
|
||||
__slots__ = ("state", "runtime_version", "adapter_version", "health_flags")
|
||||
STATE_FIELD_NUMBER: _ClassVar[int]
|
||||
RUNTIME_VERSION_FIELD_NUMBER: _ClassVar[int]
|
||||
ADAPTER_VERSION_FIELD_NUMBER: _ClassVar[int]
|
||||
HEALTH_FLAGS_FIELD_NUMBER: _ClassVar[int]
|
||||
state: ServingState
|
||||
runtime_version: str
|
||||
adapter_version: str
|
||||
health_flags: _containers.RepeatedScalarFieldContainer[str]
|
||||
def __init__(self, state: _Optional[_Union[ServingState, str]] = ..., runtime_version: _Optional[str] = ..., adapter_version: _Optional[str] = ..., health_flags: _Optional[_Iterable[str]] = ...) -> None: ...
|
||||
|
||||
class GetCapabilitiesRequest(_message.Message):
|
||||
__slots__ = ()
|
||||
def __init__(self) -> None: ...
|
||||
|
||||
class GetCapabilitiesResponse(_message.Message):
|
||||
__slots__ = ("runtime_version", "adapter_version", "devices", "models")
|
||||
RUNTIME_VERSION_FIELD_NUMBER: _ClassVar[int]
|
||||
ADAPTER_VERSION_FIELD_NUMBER: _ClassVar[int]
|
||||
DEVICES_FIELD_NUMBER: _ClassVar[int]
|
||||
MODELS_FIELD_NUMBER: _ClassVar[int]
|
||||
runtime_version: str
|
||||
adapter_version: str
|
||||
devices: _containers.RepeatedCompositeFieldContainer[RuntimeDevice]
|
||||
models: _containers.RepeatedCompositeFieldContainer[RuntimeModel]
|
||||
def __init__(self, runtime_version: _Optional[str] = ..., adapter_version: _Optional[str] = ..., devices: _Optional[_Iterable[_Union[RuntimeDevice, _Mapping]]] = ..., models: _Optional[_Iterable[_Union[RuntimeModel, _Mapping]]] = ...) -> None: ...
|
||||
|
||||
class RuntimeDevice(_message.Message):
|
||||
__slots__ = ("device_id", "hardware_class", "total_vram_bytes", "total_slots", "free_slots")
|
||||
DEVICE_ID_FIELD_NUMBER: _ClassVar[int]
|
||||
HARDWARE_CLASS_FIELD_NUMBER: _ClassVar[int]
|
||||
TOTAL_VRAM_BYTES_FIELD_NUMBER: _ClassVar[int]
|
||||
TOTAL_SLOTS_FIELD_NUMBER: _ClassVar[int]
|
||||
FREE_SLOTS_FIELD_NUMBER: _ClassVar[int]
|
||||
device_id: str
|
||||
hardware_class: str
|
||||
total_vram_bytes: int
|
||||
total_slots: int
|
||||
free_slots: int
|
||||
def __init__(self, device_id: _Optional[str] = ..., hardware_class: _Optional[str] = ..., total_vram_bytes: _Optional[int] = ..., total_slots: _Optional[int] = ..., free_slots: _Optional[int] = ...) -> None: ...
|
||||
|
||||
class RuntimeModel(_message.Message):
|
||||
__slots__ = ("catalog_model_id", "model_version", "model_digest", "precisions", "features", "state")
|
||||
CATALOG_MODEL_ID_FIELD_NUMBER: _ClassVar[int]
|
||||
MODEL_VERSION_FIELD_NUMBER: _ClassVar[int]
|
||||
MODEL_DIGEST_FIELD_NUMBER: _ClassVar[int]
|
||||
PRECISIONS_FIELD_NUMBER: _ClassVar[int]
|
||||
FEATURES_FIELD_NUMBER: _ClassVar[int]
|
||||
STATE_FIELD_NUMBER: _ClassVar[int]
|
||||
catalog_model_id: str
|
||||
model_version: str
|
||||
model_digest: str
|
||||
precisions: _containers.RepeatedScalarFieldContainer[str]
|
||||
features: _containers.RepeatedScalarFieldContainer[str]
|
||||
state: RuntimeModelState
|
||||
def __init__(self, catalog_model_id: _Optional[str] = ..., model_version: _Optional[str] = ..., model_digest: _Optional[str] = ..., precisions: _Optional[_Iterable[str]] = ..., features: _Optional[_Iterable[str]] = ..., state: _Optional[_Union[RuntimeModelState, str]] = ...) -> None: ...
|
||||
|
||||
class ExecuteRequest(_message.Message):
|
||||
__slots__ = ("job_id", "attempt_id", "device_id", "slot_id", "model", "parameters", "inputs", "outputs", "deadline_unix_ms", "maximum_preview_bytes")
|
||||
class ParametersEntry(_message.Message):
|
||||
__slots__ = ("key", "value")
|
||||
KEY_FIELD_NUMBER: _ClassVar[int]
|
||||
VALUE_FIELD_NUMBER: _ClassVar[int]
|
||||
key: str
|
||||
value: ParameterValue
|
||||
def __init__(self, key: _Optional[str] = ..., value: _Optional[_Union[ParameterValue, _Mapping]] = ...) -> None: ...
|
||||
JOB_ID_FIELD_NUMBER: _ClassVar[int]
|
||||
ATTEMPT_ID_FIELD_NUMBER: _ClassVar[int]
|
||||
DEVICE_ID_FIELD_NUMBER: _ClassVar[int]
|
||||
SLOT_ID_FIELD_NUMBER: _ClassVar[int]
|
||||
MODEL_FIELD_NUMBER: _ClassVar[int]
|
||||
PARAMETERS_FIELD_NUMBER: _ClassVar[int]
|
||||
INPUTS_FIELD_NUMBER: _ClassVar[int]
|
||||
OUTPUTS_FIELD_NUMBER: _ClassVar[int]
|
||||
DEADLINE_UNIX_MS_FIELD_NUMBER: _ClassVar[int]
|
||||
MAXIMUM_PREVIEW_BYTES_FIELD_NUMBER: _ClassVar[int]
|
||||
job_id: str
|
||||
attempt_id: str
|
||||
device_id: str
|
||||
slot_id: str
|
||||
model: ModelSpec
|
||||
parameters: _containers.MessageMap[str, ParameterValue]
|
||||
inputs: _containers.RepeatedCompositeFieldContainer[LocalArtifact]
|
||||
outputs: _containers.RepeatedCompositeFieldContainer[LocalArtifact]
|
||||
deadline_unix_ms: int
|
||||
maximum_preview_bytes: int
|
||||
def __init__(self, job_id: _Optional[str] = ..., attempt_id: _Optional[str] = ..., device_id: _Optional[str] = ..., slot_id: _Optional[str] = ..., model: _Optional[_Union[ModelSpec, _Mapping]] = ..., parameters: _Optional[_Mapping[str, ParameterValue]] = ..., inputs: _Optional[_Iterable[_Union[LocalArtifact, _Mapping]]] = ..., outputs: _Optional[_Iterable[_Union[LocalArtifact, _Mapping]]] = ..., deadline_unix_ms: _Optional[int] = ..., maximum_preview_bytes: _Optional[int] = ...) -> None: ...
|
||||
|
||||
class ModelSpec(_message.Message):
|
||||
__slots__ = ("catalog_model_id", "model_version", "model_digest", "precision")
|
||||
CATALOG_MODEL_ID_FIELD_NUMBER: _ClassVar[int]
|
||||
MODEL_VERSION_FIELD_NUMBER: _ClassVar[int]
|
||||
MODEL_DIGEST_FIELD_NUMBER: _ClassVar[int]
|
||||
PRECISION_FIELD_NUMBER: _ClassVar[int]
|
||||
catalog_model_id: str
|
||||
model_version: str
|
||||
model_digest: str
|
||||
precision: str
|
||||
def __init__(self, catalog_model_id: _Optional[str] = ..., model_version: _Optional[str] = ..., model_digest: _Optional[str] = ..., precision: _Optional[str] = ...) -> None: ...
|
||||
|
||||
class ParameterValue(_message.Message):
|
||||
__slots__ = ("string_value", "integer_value", "number_value", "boolean_value")
|
||||
STRING_VALUE_FIELD_NUMBER: _ClassVar[int]
|
||||
INTEGER_VALUE_FIELD_NUMBER: _ClassVar[int]
|
||||
NUMBER_VALUE_FIELD_NUMBER: _ClassVar[int]
|
||||
BOOLEAN_VALUE_FIELD_NUMBER: _ClassVar[int]
|
||||
string_value: str
|
||||
integer_value: int
|
||||
number_value: float
|
||||
boolean_value: bool
|
||||
def __init__(self, string_value: _Optional[str] = ..., integer_value: _Optional[int] = ..., number_value: _Optional[float] = ..., boolean_value: _Optional[bool] = ...) -> None: ...
|
||||
|
||||
class LocalArtifact(_message.Message):
|
||||
__slots__ = ("artifact_id", "local_handle", "operation", "expected_size_bytes", "expected_sha256", "media_type")
|
||||
ARTIFACT_ID_FIELD_NUMBER: _ClassVar[int]
|
||||
LOCAL_HANDLE_FIELD_NUMBER: _ClassVar[int]
|
||||
OPERATION_FIELD_NUMBER: _ClassVar[int]
|
||||
EXPECTED_SIZE_BYTES_FIELD_NUMBER: _ClassVar[int]
|
||||
EXPECTED_SHA256_FIELD_NUMBER: _ClassVar[int]
|
||||
MEDIA_TYPE_FIELD_NUMBER: _ClassVar[int]
|
||||
artifact_id: str
|
||||
local_handle: str
|
||||
operation: LocalArtifactOperation
|
||||
expected_size_bytes: int
|
||||
expected_sha256: str
|
||||
media_type: str
|
||||
def __init__(self, artifact_id: _Optional[str] = ..., local_handle: _Optional[str] = ..., operation: _Optional[_Union[LocalArtifactOperation, str]] = ..., expected_size_bytes: _Optional[int] = ..., expected_sha256: _Optional[str] = ..., media_type: _Optional[str] = ...) -> None: ...
|
||||
|
||||
class ExecutionEvent(_message.Message):
|
||||
__slots__ = ("job_id", "attempt_id", "sequence", "observed_at_unix_ms", "started", "progress", "preview", "completed", "failed", "canceled")
|
||||
JOB_ID_FIELD_NUMBER: _ClassVar[int]
|
||||
ATTEMPT_ID_FIELD_NUMBER: _ClassVar[int]
|
||||
SEQUENCE_FIELD_NUMBER: _ClassVar[int]
|
||||
OBSERVED_AT_UNIX_MS_FIELD_NUMBER: _ClassVar[int]
|
||||
STARTED_FIELD_NUMBER: _ClassVar[int]
|
||||
PROGRESS_FIELD_NUMBER: _ClassVar[int]
|
||||
PREVIEW_FIELD_NUMBER: _ClassVar[int]
|
||||
COMPLETED_FIELD_NUMBER: _ClassVar[int]
|
||||
FAILED_FIELD_NUMBER: _ClassVar[int]
|
||||
CANCELED_FIELD_NUMBER: _ClassVar[int]
|
||||
job_id: str
|
||||
attempt_id: str
|
||||
sequence: int
|
||||
observed_at_unix_ms: int
|
||||
started: ExecutionStarted
|
||||
progress: ExecutionProgress
|
||||
preview: PreviewChunk
|
||||
completed: ExecutionCompleted
|
||||
failed: ExecutionFailed
|
||||
canceled: ExecutionCanceled
|
||||
def __init__(self, job_id: _Optional[str] = ..., attempt_id: _Optional[str] = ..., sequence: _Optional[int] = ..., observed_at_unix_ms: _Optional[int] = ..., started: _Optional[_Union[ExecutionStarted, _Mapping]] = ..., progress: _Optional[_Union[ExecutionProgress, _Mapping]] = ..., preview: _Optional[_Union[PreviewChunk, _Mapping]] = ..., completed: _Optional[_Union[ExecutionCompleted, _Mapping]] = ..., failed: _Optional[_Union[ExecutionFailed, _Mapping]] = ..., canceled: _Optional[_Union[ExecutionCanceled, _Mapping]] = ...) -> None: ...
|
||||
|
||||
class ExecutionStarted(_message.Message):
|
||||
__slots__ = ()
|
||||
def __init__(self) -> None: ...
|
||||
|
||||
class ExecutionProgress(_message.Message):
|
||||
__slots__ = ("progress_permille", "stage_code")
|
||||
PROGRESS_PERMILLE_FIELD_NUMBER: _ClassVar[int]
|
||||
STAGE_CODE_FIELD_NUMBER: _ClassVar[int]
|
||||
progress_permille: int
|
||||
stage_code: str
|
||||
def __init__(self, progress_permille: _Optional[int] = ..., stage_code: _Optional[str] = ...) -> None: ...
|
||||
|
||||
class PreviewChunk(_message.Message):
|
||||
__slots__ = ("sequence", "media_type", "data")
|
||||
SEQUENCE_FIELD_NUMBER: _ClassVar[int]
|
||||
MEDIA_TYPE_FIELD_NUMBER: _ClassVar[int]
|
||||
DATA_FIELD_NUMBER: _ClassVar[int]
|
||||
sequence: int
|
||||
media_type: str
|
||||
data: bytes
|
||||
def __init__(self, sequence: _Optional[int] = ..., media_type: _Optional[str] = ..., data: _Optional[bytes] = ...) -> None: ...
|
||||
|
||||
class ExecutionCompleted(_message.Message):
|
||||
__slots__ = ("outputs", "measurements")
|
||||
OUTPUTS_FIELD_NUMBER: _ClassVar[int]
|
||||
MEASUREMENTS_FIELD_NUMBER: _ClassVar[int]
|
||||
outputs: _containers.RepeatedCompositeFieldContainer[LocalArtifactManifest]
|
||||
measurements: RuntimeMeasurements
|
||||
def __init__(self, outputs: _Optional[_Iterable[_Union[LocalArtifactManifest, _Mapping]]] = ..., measurements: _Optional[_Union[RuntimeMeasurements, _Mapping]] = ...) -> None: ...
|
||||
|
||||
class LocalArtifactManifest(_message.Message):
|
||||
__slots__ = ("artifact_id", "local_handle", "size_bytes", "sha256", "media_type", "duration_ms")
|
||||
ARTIFACT_ID_FIELD_NUMBER: _ClassVar[int]
|
||||
LOCAL_HANDLE_FIELD_NUMBER: _ClassVar[int]
|
||||
SIZE_BYTES_FIELD_NUMBER: _ClassVar[int]
|
||||
SHA256_FIELD_NUMBER: _ClassVar[int]
|
||||
MEDIA_TYPE_FIELD_NUMBER: _ClassVar[int]
|
||||
DURATION_MS_FIELD_NUMBER: _ClassVar[int]
|
||||
artifact_id: str
|
||||
local_handle: str
|
||||
size_bytes: int
|
||||
sha256: str
|
||||
media_type: str
|
||||
duration_ms: int
|
||||
def __init__(self, artifact_id: _Optional[str] = ..., local_handle: _Optional[str] = ..., size_bytes: _Optional[int] = ..., sha256: _Optional[str] = ..., media_type: _Optional[str] = ..., duration_ms: _Optional[int] = ...) -> None: ...
|
||||
|
||||
class ExecutionFailed(_message.Message):
|
||||
__slots__ = ("failure_class", "stable_code", "safe_detail", "measurements")
|
||||
FAILURE_CLASS_FIELD_NUMBER: _ClassVar[int]
|
||||
STABLE_CODE_FIELD_NUMBER: _ClassVar[int]
|
||||
SAFE_DETAIL_FIELD_NUMBER: _ClassVar[int]
|
||||
MEASUREMENTS_FIELD_NUMBER: _ClassVar[int]
|
||||
failure_class: RuntimeFailureClass
|
||||
stable_code: str
|
||||
safe_detail: str
|
||||
measurements: RuntimeMeasurements
|
||||
def __init__(self, failure_class: _Optional[_Union[RuntimeFailureClass, str]] = ..., stable_code: _Optional[str] = ..., safe_detail: _Optional[str] = ..., measurements: _Optional[_Union[RuntimeMeasurements, _Mapping]] = ...) -> None: ...
|
||||
|
||||
class ExecutionCanceled(_message.Message):
|
||||
__slots__ = ("measurements",)
|
||||
MEASUREMENTS_FIELD_NUMBER: _ClassVar[int]
|
||||
measurements: RuntimeMeasurements
|
||||
def __init__(self, measurements: _Optional[_Union[RuntimeMeasurements, _Mapping]] = ...) -> None: ...
|
||||
|
||||
class RuntimeMeasurements(_message.Message):
|
||||
__slots__ = ("normalized_input_characters", "input_audio_ms", "output_audio_ms", "gpu_execution_ms", "cpu_execution_ms")
|
||||
NORMALIZED_INPUT_CHARACTERS_FIELD_NUMBER: _ClassVar[int]
|
||||
INPUT_AUDIO_MS_FIELD_NUMBER: _ClassVar[int]
|
||||
OUTPUT_AUDIO_MS_FIELD_NUMBER: _ClassVar[int]
|
||||
GPU_EXECUTION_MS_FIELD_NUMBER: _ClassVar[int]
|
||||
CPU_EXECUTION_MS_FIELD_NUMBER: _ClassVar[int]
|
||||
normalized_input_characters: int
|
||||
input_audio_ms: int
|
||||
output_audio_ms: int
|
||||
gpu_execution_ms: int
|
||||
cpu_execution_ms: int
|
||||
def __init__(self, normalized_input_characters: _Optional[int] = ..., input_audio_ms: _Optional[int] = ..., output_audio_ms: _Optional[int] = ..., gpu_execution_ms: _Optional[int] = ..., cpu_execution_ms: _Optional[int] = ...) -> None: ...
|
||||
|
||||
class CancelRequest(_message.Message):
|
||||
__slots__ = ("job_id", "attempt_id", "reason_code", "deadline_unix_ms")
|
||||
JOB_ID_FIELD_NUMBER: _ClassVar[int]
|
||||
ATTEMPT_ID_FIELD_NUMBER: _ClassVar[int]
|
||||
REASON_CODE_FIELD_NUMBER: _ClassVar[int]
|
||||
DEADLINE_UNIX_MS_FIELD_NUMBER: _ClassVar[int]
|
||||
job_id: str
|
||||
attempt_id: str
|
||||
reason_code: str
|
||||
deadline_unix_ms: int
|
||||
def __init__(self, job_id: _Optional[str] = ..., attempt_id: _Optional[str] = ..., reason_code: _Optional[str] = ..., deadline_unix_ms: _Optional[int] = ...) -> None: ...
|
||||
|
||||
class CancelResponse(_message.Message):
|
||||
__slots__ = ("disposition",)
|
||||
DISPOSITION_FIELD_NUMBER: _ClassVar[int]
|
||||
disposition: CancelDisposition
|
||||
def __init__(self, disposition: _Optional[_Union[CancelDisposition, str]] = ...) -> None: ...
|
||||
@@ -0,0 +1,229 @@
|
||||
# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
|
||||
"""Client and server classes corresponding to protobuf-defined services."""
|
||||
import grpc
|
||||
import warnings
|
||||
|
||||
from . import runtime_adapter_pb2 as runtime__adapter__pb2
|
||||
|
||||
GRPC_GENERATED_VERSION = '1.81.1'
|
||||
GRPC_VERSION = grpc.__version__
|
||||
_version_not_supported = False
|
||||
|
||||
try:
|
||||
from grpc._utilities import first_version_is_lower
|
||||
_version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION)
|
||||
except ImportError:
|
||||
_version_not_supported = True
|
||||
|
||||
if _version_not_supported:
|
||||
raise RuntimeError(
|
||||
f'The grpc package installed is at version {GRPC_VERSION},'
|
||||
+ ' but the generated code in runtime_adapter_pb2_grpc.py depends on'
|
||||
+ f' grpcio>={GRPC_GENERATED_VERSION}.'
|
||||
+ f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}'
|
||||
+ f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.'
|
||||
)
|
||||
|
||||
|
||||
class RuntimeAdapterServiceStub:
|
||||
"""RuntimeAdapterService is local to a GPU Node and is never publicly exposed.
|
||||
"""
|
||||
|
||||
def __init__(self, channel):
|
||||
"""Constructor.
|
||||
|
||||
Args:
|
||||
channel: A grpc.Channel.
|
||||
"""
|
||||
self.Health = channel.unary_unary(
|
||||
'/voicestudio.runtime.v1.RuntimeAdapterService/Health',
|
||||
request_serializer=runtime__adapter__pb2.HealthRequest.SerializeToString,
|
||||
response_deserializer=runtime__adapter__pb2.HealthResponse.FromString,
|
||||
_registered_method=True)
|
||||
self.GetCapabilities = channel.unary_unary(
|
||||
'/voicestudio.runtime.v1.RuntimeAdapterService/GetCapabilities',
|
||||
request_serializer=runtime__adapter__pb2.GetCapabilitiesRequest.SerializeToString,
|
||||
response_deserializer=runtime__adapter__pb2.GetCapabilitiesResponse.FromString,
|
||||
_registered_method=True)
|
||||
self.Execute = channel.unary_stream(
|
||||
'/voicestudio.runtime.v1.RuntimeAdapterService/Execute',
|
||||
request_serializer=runtime__adapter__pb2.ExecuteRequest.SerializeToString,
|
||||
response_deserializer=runtime__adapter__pb2.ExecuteResponse.FromString,
|
||||
_registered_method=True)
|
||||
self.Cancel = channel.unary_unary(
|
||||
'/voicestudio.runtime.v1.RuntimeAdapterService/Cancel',
|
||||
request_serializer=runtime__adapter__pb2.CancelRequest.SerializeToString,
|
||||
response_deserializer=runtime__adapter__pb2.CancelResponse.FromString,
|
||||
_registered_method=True)
|
||||
|
||||
|
||||
class RuntimeAdapterServiceServicer:
|
||||
"""RuntimeAdapterService is local to a GPU Node and is never publicly exposed.
|
||||
"""
|
||||
|
||||
def Health(self, request, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def GetCapabilities(self, request, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def Execute(self, request, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def Cancel(self, request, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
|
||||
def add_RuntimeAdapterServiceServicer_to_server(servicer, server):
|
||||
rpc_method_handlers = {
|
||||
'Health': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.Health,
|
||||
request_deserializer=runtime__adapter__pb2.HealthRequest.FromString,
|
||||
response_serializer=runtime__adapter__pb2.HealthResponse.SerializeToString,
|
||||
),
|
||||
'GetCapabilities': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.GetCapabilities,
|
||||
request_deserializer=runtime__adapter__pb2.GetCapabilitiesRequest.FromString,
|
||||
response_serializer=runtime__adapter__pb2.GetCapabilitiesResponse.SerializeToString,
|
||||
),
|
||||
'Execute': grpc.unary_stream_rpc_method_handler(
|
||||
servicer.Execute,
|
||||
request_deserializer=runtime__adapter__pb2.ExecuteRequest.FromString,
|
||||
response_serializer=runtime__adapter__pb2.ExecuteResponse.SerializeToString,
|
||||
),
|
||||
'Cancel': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.Cancel,
|
||||
request_deserializer=runtime__adapter__pb2.CancelRequest.FromString,
|
||||
response_serializer=runtime__adapter__pb2.CancelResponse.SerializeToString,
|
||||
),
|
||||
}
|
||||
generic_handler = grpc.method_handlers_generic_handler(
|
||||
'voicestudio.runtime.v1.RuntimeAdapterService', rpc_method_handlers)
|
||||
server.add_generic_rpc_handlers((generic_handler,))
|
||||
server.add_registered_method_handlers('voicestudio.runtime.v1.RuntimeAdapterService', rpc_method_handlers)
|
||||
|
||||
|
||||
# This class is part of an EXPERIMENTAL API.
|
||||
class RuntimeAdapterService:
|
||||
"""RuntimeAdapterService is local to a GPU Node and is never publicly exposed.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def Health(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
'/voicestudio.runtime.v1.RuntimeAdapterService/Health',
|
||||
runtime__adapter__pb2.HealthRequest.SerializeToString,
|
||||
runtime__adapter__pb2.HealthResponse.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
|
||||
@staticmethod
|
||||
def GetCapabilities(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
'/voicestudio.runtime.v1.RuntimeAdapterService/GetCapabilities',
|
||||
runtime__adapter__pb2.GetCapabilitiesRequest.SerializeToString,
|
||||
runtime__adapter__pb2.GetCapabilitiesResponse.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
|
||||
@staticmethod
|
||||
def Execute(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_stream(
|
||||
request,
|
||||
target,
|
||||
'/voicestudio.runtime.v1.RuntimeAdapterService/Execute',
|
||||
runtime__adapter__pb2.ExecuteRequest.SerializeToString,
|
||||
runtime__adapter__pb2.ExecuteResponse.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
|
||||
@staticmethod
|
||||
def Cancel(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
'/voicestudio.runtime.v1.RuntimeAdapterService/Cancel',
|
||||
runtime__adapter__pb2.CancelRequest.SerializeToString,
|
||||
runtime__adapter__pb2.CancelResponse.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
@@ -0,0 +1,318 @@
|
||||
"""Device and model inventory reported through Health/GetCapabilities.
|
||||
|
||||
The server is written against the small protocol at the top of this module so
|
||||
tests can substitute fakes; :class:`ProductionInventory` is the real thing,
|
||||
wired to ``services.tts_backend``'s engine registry, ``services.hf_revisions``
|
||||
pinned revisions, and :mod:`runtime_adapter.digest`.
|
||||
|
||||
State rules (mirrors the Go preflight's expectations):
|
||||
|
||||
- READY is **explicit**: engine registered, availability probe passed, the
|
||||
pinned snapshot fully present on disk, and a digest computed. Anything
|
||||
less is INSTALLED / LOADING / FAILED — never READY.
|
||||
- A loading or failed model is still listed (with its true state) so the
|
||||
Gateway can observe it; only READY models are schedulable.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from . import SLOTS_ENV
|
||||
from ._paths import ensure_backend_on_path
|
||||
from .digest import snapshot_digest
|
||||
|
||||
STATE_INSTALLED = "installed"
|
||||
STATE_LOADING = "loading"
|
||||
STATE_READY = "ready"
|
||||
STATE_FAILED = "failed"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DeviceInfo:
|
||||
device_id: str
|
||||
hardware_class: str
|
||||
total_vram_bytes: int
|
||||
total_slots: int
|
||||
free_slots: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ModelInfo:
|
||||
catalog_model_id: str
|
||||
model_version: str
|
||||
model_digest: str
|
||||
precisions: tuple[str, ...] = ()
|
||||
features: tuple[str, ...] = ()
|
||||
state: str = STATE_INSTALLED
|
||||
|
||||
|
||||
#: Engines this adapter can attest as digest-pinned models: TTS engine id →
|
||||
#: curated Hugging Face repo (must be pinned in ``services.hf_revisions``).
|
||||
#: Engines without a single pinned weights repo (external API servers,
|
||||
#: multi-model muxes) are deliberately absent — they cannot be digest-pinned.
|
||||
ENGINE_MODEL_REPOS: dict[str, str] = {
|
||||
"omnivoice": "k2-fsa/OmniVoice",
|
||||
"voxcpm2": "openbmb/VoxCPM2",
|
||||
"moss-tts-nano": "OpenMOSS-Team/MOSS-TTS-Nano-100M",
|
||||
"kittentts": "KittenML/kitten-tts-mini-0.8",
|
||||
"cosyvoice": "FunAudioLLM/Fun-CosyVoice3-0.5B-2512",
|
||||
"moss-tts-v15": "OpenMOSS-Team/MOSS-TTS-v1.5",
|
||||
}
|
||||
|
||||
|
||||
def catalog_model_version(revision: str, model_digest: str) -> str:
|
||||
"""Return the immutable catalog version for an attested model snapshot.
|
||||
|
||||
A Hugging Face revision names source history, not necessarily the exact
|
||||
snapshot bytes installed on a node. The catalog version therefore carries
|
||||
a short, deterministic digest suffix. A changed snapshot becomes a new
|
||||
catalog identity instead of mutating an identity retained by Jobs.
|
||||
"""
|
||||
digest = model_digest.removeprefix("sha256:")
|
||||
if len(revision) != 40 or len(digest) != 64:
|
||||
raise ValueError("model identity requires a SHA revision and SHA-256 digest")
|
||||
return f"{revision}+sha256-{digest[:16]}"
|
||||
|
||||
|
||||
def slots_per_device(default: int = 1) -> int:
|
||||
raw = os.environ.get(SLOTS_ENV, "").strip()
|
||||
try:
|
||||
value = int(raw) if raw else default
|
||||
except ValueError:
|
||||
return default
|
||||
return max(1, min(value, 64))
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProductionInventory:
|
||||
"""Real host inventory. All heavy imports happen inside methods.
|
||||
|
||||
``models()`` is memoized for ``model_ttl_s`` under a lock: the first call
|
||||
hashes every installed snapshot (minutes for multi-GB weights, then cached
|
||||
in the on-disk digest sidecar), and Health + GetCapabilities arrive
|
||||
back-to-back. Call :meth:`warm` before serving so the first RPC never
|
||||
pays the hashing cost inside its deadline.
|
||||
"""
|
||||
|
||||
slots: int = field(default_factory=slots_per_device)
|
||||
model_ttl_s: float = 15.0
|
||||
|
||||
def __post_init__(self):
|
||||
self._model_lock = threading.Lock()
|
||||
self._model_cache: list[ModelInfo] | None = None
|
||||
self._model_cache_at = 0.0
|
||||
|
||||
def warm(self) -> None:
|
||||
self.models()
|
||||
|
||||
def devices(self, busy_slots: int = 0) -> list[DeviceInfo]:
|
||||
ensure_backend_on_path()
|
||||
devices = self._accelerators() or [self._cpu_device()]
|
||||
return [self._with_slots(device, busy_slots) for device in devices]
|
||||
|
||||
def _with_slots(self, device: DeviceInfo, busy_slots: int) -> DeviceInfo:
|
||||
free = max(0, min(device.total_slots - busy_slots, device.total_slots))
|
||||
return DeviceInfo(
|
||||
device_id=device.device_id,
|
||||
hardware_class=device.hardware_class,
|
||||
total_vram_bytes=device.total_vram_bytes,
|
||||
total_slots=device.total_slots,
|
||||
free_slots=free,
|
||||
)
|
||||
|
||||
def _accelerators(self) -> list[DeviceInfo]:
|
||||
try:
|
||||
import torch # noqa: PLC0415
|
||||
except Exception:
|
||||
return []
|
||||
found: list[DeviceInfo] = []
|
||||
try:
|
||||
if torch.cuda.is_available():
|
||||
for index in range(torch.cuda.device_count()):
|
||||
props = torch.cuda.get_device_properties(index)
|
||||
found.append(
|
||||
DeviceInfo(
|
||||
device_id=f"cuda:{index}",
|
||||
hardware_class=torch.cuda.get_device_name(index),
|
||||
total_vram_bytes=int(props.total_memory),
|
||||
total_slots=self.slots,
|
||||
free_slots=self.slots,
|
||||
)
|
||||
)
|
||||
return found
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if getattr(torch.backends, "mps", None) and torch.backends.mps.is_available():
|
||||
vram = 0
|
||||
recommended = getattr(torch.mps, "recommended_max_memory", None)
|
||||
if callable(recommended):
|
||||
try:
|
||||
vram = int(recommended())
|
||||
except Exception:
|
||||
vram = 0
|
||||
if vram <= 0:
|
||||
vram = _system_memory_bytes()
|
||||
return [
|
||||
DeviceInfo(
|
||||
device_id="mps:0",
|
||||
hardware_class="apple-silicon-mps",
|
||||
total_vram_bytes=vram,
|
||||
total_slots=self.slots,
|
||||
free_slots=self.slots,
|
||||
)
|
||||
]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
|
||||
def _cpu_device(self) -> DeviceInfo:
|
||||
# A CPU-only node is a valid (slow) execution device. total_vram_bytes
|
||||
# carries system memory so the Gateway's ">0" validity check reflects
|
||||
# real capacity rather than a made-up constant.
|
||||
import platform # noqa: PLC0415
|
||||
|
||||
return DeviceInfo(
|
||||
device_id="cpu:0",
|
||||
hardware_class=platform.processor() or platform.machine() or "cpu",
|
||||
total_vram_bytes=_system_memory_bytes(),
|
||||
total_slots=self.slots,
|
||||
free_slots=self.slots,
|
||||
)
|
||||
|
||||
def models(self) -> list[ModelInfo]:
|
||||
with self._model_lock:
|
||||
now = time.monotonic()
|
||||
if (
|
||||
self._model_cache is not None
|
||||
and now - self._model_cache_at < self.model_ttl_s
|
||||
):
|
||||
return list(self._model_cache)
|
||||
self._model_cache = self._scan_models()
|
||||
self._model_cache_at = time.monotonic()
|
||||
return list(self._model_cache)
|
||||
|
||||
def _scan_models(self) -> list[ModelInfo]:
|
||||
ensure_backend_on_path()
|
||||
from services.hf_cache_repair import repo_cache_dir # noqa: PLC0415
|
||||
from services.hf_revisions import installed_revision # noqa: PLC0415
|
||||
from services.tts_backend import get_backend_class # noqa: PLC0415
|
||||
|
||||
models: list[ModelInfo] = []
|
||||
for engine_id, repo_id in sorted(ENGINE_MODEL_REPOS.items()):
|
||||
try:
|
||||
backend_cls = get_backend_class(engine_id)
|
||||
except Exception:
|
||||
continue # engine not registered in this build
|
||||
repo_dir = repo_cache_dir(repo_id)
|
||||
try:
|
||||
revision = installed_revision(repo_id, os.path.dirname(repo_dir))
|
||||
except ValueError:
|
||||
continue # repo not in the curated catalog — cannot attest
|
||||
snapshot = os.path.join(repo_dir, "snapshots", revision)
|
||||
if not os.path.isdir(snapshot):
|
||||
continue # weights not installed at the pinned revision
|
||||
models.append(
|
||||
self._model_state(engine_id, backend_cls, repo_dir, revision, snapshot)
|
||||
)
|
||||
return models
|
||||
|
||||
def _model_state(
|
||||
self, engine_id: str, backend_cls, repo_dir: str, revision: str, snapshot: str
|
||||
) -> ModelInfo:
|
||||
base = ModelInfo(
|
||||
catalog_model_id=engine_id,
|
||||
model_version=revision,
|
||||
model_digest="",
|
||||
precisions=self._precisions(backend_cls),
|
||||
features=self._features(backend_cls),
|
||||
)
|
||||
try:
|
||||
ok, _message = backend_cls.is_available()
|
||||
except Exception:
|
||||
return _replace_state(base, STATE_FAILED)
|
||||
if not ok:
|
||||
return _replace_state(base, STATE_INSTALLED)
|
||||
if _snapshot_incomplete(repo_dir, snapshot):
|
||||
return _replace_state(base, STATE_LOADING)
|
||||
try:
|
||||
model_digest = snapshot_digest(
|
||||
snapshot,
|
||||
cache_path=os.path.join(repo_dir, f"voicestudio-digest-{revision}.json"),
|
||||
)
|
||||
except OSError:
|
||||
return _replace_state(base, STATE_LOADING)
|
||||
return ModelInfo(
|
||||
catalog_model_id=base.catalog_model_id,
|
||||
model_version=catalog_model_version(base.model_version, model_digest),
|
||||
model_digest=model_digest,
|
||||
precisions=base.precisions,
|
||||
features=base.features,
|
||||
state=STATE_READY,
|
||||
)
|
||||
|
||||
def _precisions(self, backend_cls) -> tuple[str, ...]:
|
||||
# Advisory execution precisions. fp32 always works; fp16 is offered
|
||||
# when the engine targets an accelerator this host actually has.
|
||||
compat = tuple(getattr(backend_cls, "gpu_compat", ("cpu",)))
|
||||
try:
|
||||
from core.device_caps import detect_host_caps # noqa: PLC0415
|
||||
|
||||
family = detect_host_caps().family
|
||||
except Exception:
|
||||
family = "cpu"
|
||||
if family != "cpu" and family in compat:
|
||||
return ("fp16", "fp32")
|
||||
return ("fp32",)
|
||||
|
||||
def _features(self, backend_cls) -> tuple[str, ...]:
|
||||
features = ["tts"]
|
||||
if getattr(backend_cls, "supports_cloning", False) is True:
|
||||
features.append("voice_clone")
|
||||
if getattr(backend_cls, "supports_voice_design", False):
|
||||
features.append("voice_design")
|
||||
if getattr(backend_cls, "supports_emotion", False):
|
||||
features.append("emotion")
|
||||
return tuple(features)
|
||||
|
||||
|
||||
def _replace_state(model: ModelInfo, state: str) -> ModelInfo:
|
||||
return ModelInfo(
|
||||
catalog_model_id=model.catalog_model_id,
|
||||
model_version=model.model_version,
|
||||
model_digest=model.model_digest,
|
||||
precisions=model.precisions,
|
||||
features=model.features,
|
||||
state=state,
|
||||
)
|
||||
|
||||
|
||||
def _snapshot_incomplete(repo_dir: str, snapshot: str) -> bool:
|
||||
"""A download in flight leaves ``*.incomplete`` blobs or dangling links."""
|
||||
blobs = os.path.join(repo_dir, "blobs")
|
||||
try:
|
||||
if any(name.endswith(".incomplete") for name in os.listdir(blobs)):
|
||||
return True
|
||||
except OSError:
|
||||
pass
|
||||
for current, _dirs, files in os.walk(snapshot):
|
||||
for name in files:
|
||||
path = os.path.join(current, name)
|
||||
if not os.path.exists(path): # dangling symlink
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _system_memory_bytes() -> int:
|
||||
try:
|
||||
import psutil # noqa: PLC0415
|
||||
|
||||
return int(psutil.virtual_memory().total)
|
||||
except Exception:
|
||||
try:
|
||||
return os.sysconf("SC_PAGE_SIZE") * os.sysconf("SC_PHYS_PAGES")
|
||||
except (ValueError, OSError, AttributeError):
|
||||
return 1 # still nonzero: the preflight requires > 0
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Wires the adapter to the real VoiceStudio backend.
|
||||
|
||||
Kept separate from ``server.py`` so tests can build a
|
||||
:class:`~runtime_adapter.server.RuntimeContext` from fakes without importing
|
||||
torch or the engine registry.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from . import ADAPTER_VERSION
|
||||
from ._paths import ensure_backend_on_path
|
||||
from .inventory import ProductionInventory, slots_per_device
|
||||
from .server import RuntimeContext
|
||||
|
||||
|
||||
def production_engine_provider(catalog_model_id: str):
|
||||
"""Resolve a READY catalog model id to its cached engine instance."""
|
||||
ensure_backend_on_path()
|
||||
from services.tts_backend import get_engine_instance_for # noqa: PLC0415
|
||||
|
||||
return get_engine_instance_for(catalog_model_id)
|
||||
|
||||
|
||||
def build_runtime_context() -> RuntimeContext:
|
||||
ensure_backend_on_path()
|
||||
from core.version import APP_VERSION # noqa: PLC0415
|
||||
|
||||
slots = slots_per_device()
|
||||
return RuntimeContext(
|
||||
runtime_version=APP_VERSION,
|
||||
adapter_version=ADAPTER_VERSION,
|
||||
inventory=ProductionInventory(slots=slots),
|
||||
engine_provider=production_engine_provider,
|
||||
slot_limit=slots,
|
||||
)
|
||||
|
||||
def prewarm_engines(context: RuntimeContext) -> None:
|
||||
"""Load and compile every READY model before the socket accepts work.
|
||||
|
||||
The GPU Gateway leases an attempt for a bounded window and renews it from
|
||||
execution evidence. A cold engine produces no evidence: weight loading and
|
||||
torch compilation can run for minutes emitting nothing, so the lease
|
||||
expires mid-load, the attempt is fenced, the Job requeues, and the next
|
||||
attempt pays the same cost — a loop that never yields audio.
|
||||
|
||||
Paying that cost once at startup, before the adapter is reachable, means
|
||||
the first real Execute begins inference immediately. Preflight already
|
||||
refuses a runtime with no READY model, so a failure here is reported and
|
||||
the model is dropped from the advertised set rather than being offered as
|
||||
schedulable capacity the node cannot actually serve promptly.
|
||||
"""
|
||||
ensure_backend_on_path()
|
||||
for model in context.inventory.models():
|
||||
if model.state != "ready":
|
||||
continue
|
||||
try:
|
||||
context.engine_provider(model.catalog_model_id)
|
||||
except Exception as error: # noqa: BLE001 - reported, never fatal
|
||||
print(
|
||||
f"runtime adapter: prewarm of {model.catalog_model_id} failed: {error}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
@@ -0,0 +1,199 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package voicestudio.runtime.v1;
|
||||
|
||||
option go_package = "github.com/velixio/vssaas/api/gen/runtime/v1;runtimev1";
|
||||
|
||||
// RuntimeAdapterService is local to a GPU Node and is never publicly exposed.
|
||||
service RuntimeAdapterService {
|
||||
rpc Health(HealthRequest) returns (HealthResponse);
|
||||
rpc GetCapabilities(GetCapabilitiesRequest) returns (GetCapabilitiesResponse);
|
||||
rpc Execute(ExecuteRequest) returns (stream ExecuteResponse);
|
||||
rpc Cancel(CancelRequest) returns (CancelResponse);
|
||||
}
|
||||
|
||||
message ExecuteResponse { ExecutionEvent event = 1; }
|
||||
|
||||
message HealthRequest {}
|
||||
|
||||
message HealthResponse {
|
||||
ServingState state = 1;
|
||||
string runtime_version = 2;
|
||||
string adapter_version = 3;
|
||||
repeated string health_flags = 4;
|
||||
}
|
||||
|
||||
enum ServingState {
|
||||
SERVING_STATE_UNSPECIFIED = 0;
|
||||
SERVING_STATE_READY = 1;
|
||||
SERVING_STATE_DEGRADED = 2;
|
||||
SERVING_STATE_UNHEALTHY = 3;
|
||||
}
|
||||
|
||||
message GetCapabilitiesRequest {}
|
||||
|
||||
message GetCapabilitiesResponse {
|
||||
string runtime_version = 1;
|
||||
string adapter_version = 2;
|
||||
repeated RuntimeDevice devices = 3;
|
||||
repeated RuntimeModel models = 4;
|
||||
}
|
||||
|
||||
message RuntimeDevice {
|
||||
string device_id = 1;
|
||||
string hardware_class = 2;
|
||||
uint64 total_vram_bytes = 3;
|
||||
uint32 total_slots = 4;
|
||||
uint32 free_slots = 5;
|
||||
}
|
||||
|
||||
message RuntimeModel {
|
||||
string catalog_model_id = 1;
|
||||
string model_version = 2;
|
||||
string model_digest = 3;
|
||||
repeated string precisions = 4;
|
||||
repeated string features = 5;
|
||||
RuntimeModelState state = 6;
|
||||
}
|
||||
|
||||
enum RuntimeModelState {
|
||||
RUNTIME_MODEL_STATE_UNSPECIFIED = 0;
|
||||
RUNTIME_MODEL_STATE_INSTALLED = 1;
|
||||
RUNTIME_MODEL_STATE_LOADING = 2;
|
||||
RUNTIME_MODEL_STATE_READY = 3;
|
||||
RUNTIME_MODEL_STATE_FAILED = 4;
|
||||
}
|
||||
|
||||
message ExecuteRequest {
|
||||
string job_id = 1;
|
||||
string attempt_id = 2;
|
||||
string device_id = 3;
|
||||
string slot_id = 4;
|
||||
ModelSpec model = 5;
|
||||
map<string, ParameterValue> parameters = 6;
|
||||
repeated LocalArtifact inputs = 7;
|
||||
repeated LocalArtifact outputs = 8;
|
||||
int64 deadline_unix_ms = 9;
|
||||
uint32 maximum_preview_bytes = 10;
|
||||
}
|
||||
|
||||
message ModelSpec {
|
||||
string catalog_model_id = 1;
|
||||
string model_version = 2;
|
||||
string model_digest = 3;
|
||||
string precision = 4;
|
||||
}
|
||||
|
||||
message ParameterValue {
|
||||
oneof value {
|
||||
string string_value = 1;
|
||||
int64 integer_value = 2;
|
||||
double number_value = 3;
|
||||
bool boolean_value = 4;
|
||||
}
|
||||
}
|
||||
|
||||
message LocalArtifact {
|
||||
string artifact_id = 1;
|
||||
string local_handle = 2;
|
||||
LocalArtifactOperation operation = 3;
|
||||
uint64 expected_size_bytes = 4;
|
||||
string expected_sha256 = 5;
|
||||
string media_type = 6;
|
||||
}
|
||||
|
||||
enum LocalArtifactOperation {
|
||||
LOCAL_ARTIFACT_OPERATION_UNSPECIFIED = 0;
|
||||
LOCAL_ARTIFACT_OPERATION_READ = 1;
|
||||
LOCAL_ARTIFACT_OPERATION_WRITE = 2;
|
||||
}
|
||||
|
||||
message ExecutionEvent {
|
||||
string job_id = 1;
|
||||
string attempt_id = 2;
|
||||
uint64 sequence = 3;
|
||||
int64 observed_at_unix_ms = 4;
|
||||
oneof payload {
|
||||
ExecutionStarted started = 10;
|
||||
ExecutionProgress progress = 11;
|
||||
PreviewChunk preview = 12;
|
||||
ExecutionCompleted completed = 13;
|
||||
ExecutionFailed failed = 14;
|
||||
ExecutionCanceled canceled = 15;
|
||||
}
|
||||
}
|
||||
|
||||
message ExecutionStarted {}
|
||||
|
||||
message ExecutionProgress {
|
||||
uint32 progress_permille = 1;
|
||||
string stage_code = 2;
|
||||
}
|
||||
|
||||
message PreviewChunk {
|
||||
uint64 sequence = 1;
|
||||
string media_type = 2;
|
||||
bytes data = 3;
|
||||
}
|
||||
|
||||
message ExecutionCompleted {
|
||||
repeated LocalArtifactManifest outputs = 1;
|
||||
RuntimeMeasurements measurements = 2;
|
||||
}
|
||||
|
||||
message LocalArtifactManifest {
|
||||
string artifact_id = 1;
|
||||
string local_handle = 2;
|
||||
uint64 size_bytes = 3;
|
||||
string sha256 = 4;
|
||||
string media_type = 5;
|
||||
uint64 duration_ms = 6;
|
||||
}
|
||||
|
||||
message ExecutionFailed {
|
||||
RuntimeFailureClass failure_class = 1;
|
||||
string stable_code = 2;
|
||||
string safe_detail = 3;
|
||||
RuntimeMeasurements measurements = 4;
|
||||
}
|
||||
|
||||
message ExecutionCanceled {
|
||||
RuntimeMeasurements measurements = 1;
|
||||
}
|
||||
|
||||
enum RuntimeFailureClass {
|
||||
RUNTIME_FAILURE_CLASS_UNSPECIFIED = 0;
|
||||
RUNTIME_FAILURE_CLASS_INPUT = 1;
|
||||
RUNTIME_FAILURE_CLASS_MODEL_LOAD = 2;
|
||||
RUNTIME_FAILURE_CLASS_INFERENCE = 3;
|
||||
RUNTIME_FAILURE_CLASS_GPU_RESOURCE = 4;
|
||||
RUNTIME_FAILURE_CLASS_LOCAL_STORAGE = 5;
|
||||
RUNTIME_FAILURE_CLASS_RUNTIME = 6;
|
||||
RUNTIME_FAILURE_CLASS_CANCELED = 7;
|
||||
}
|
||||
|
||||
message RuntimeMeasurements {
|
||||
uint64 normalized_input_characters = 1;
|
||||
uint64 input_audio_ms = 2;
|
||||
uint64 output_audio_ms = 3;
|
||||
uint64 gpu_execution_ms = 4;
|
||||
uint64 cpu_execution_ms = 5;
|
||||
}
|
||||
|
||||
message CancelRequest {
|
||||
string job_id = 1;
|
||||
string attempt_id = 2;
|
||||
string reason_code = 3;
|
||||
int64 deadline_unix_ms = 4;
|
||||
}
|
||||
|
||||
message CancelResponse {
|
||||
CancelDisposition disposition = 1;
|
||||
}
|
||||
|
||||
enum CancelDisposition {
|
||||
CANCEL_DISPOSITION_UNSPECIFIED = 0;
|
||||
CANCEL_DISPOSITION_ACCEPTED = 1;
|
||||
CANCEL_DISPOSITION_ALREADY_TERMINAL = 2;
|
||||
CANCEL_DISPOSITION_NOT_FOUND = 3;
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
"""``--selfcheck``: validate the Go preflight's expectations against ourselves.
|
||||
|
||||
Starts the server on a private temp socket, then runs a Python port of
|
||||
``internal/gateway/preflight.go``'s checks over the wire: socket-path safety,
|
||||
READY health with version evidence, identical versions across Health and
|
||||
GetCapabilities, valid unique devices, and at least one explicitly READY,
|
||||
digest-pinned model with a version and precisions. Prints only a bounded
|
||||
readiness summary (never handles, paths, or credentials) and exits nonzero on
|
||||
any failed expectation — the same fail-closed behavior a node deployment gets
|
||||
from ``cmd/runtime-adapter-preflight``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import stat as stat_module
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
|
||||
import grpc
|
||||
|
||||
from .gen import runtime_adapter_pb2 as pb2
|
||||
from .gen import runtime_adapter_pb2_grpc as pb2_grpc
|
||||
|
||||
_MAX_UINT32 = 2**32 - 1
|
||||
|
||||
|
||||
class PreflightError(Exception):
|
||||
"""One failed preflight expectation, with a bounded message."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PreflightSummary:
|
||||
socket_path: str
|
||||
runtime_version: str
|
||||
adapter_version: str
|
||||
device_count: int
|
||||
ready_model_count: int
|
||||
total_slots: int
|
||||
free_slots: int
|
||||
|
||||
def render(self) -> str:
|
||||
return (
|
||||
f"runtime={self.runtime_version} adapter={self.adapter_version} "
|
||||
f"devices={self.device_count} ready_models={self.ready_model_count} "
|
||||
f"slots={self.free_slots}/{self.total_slots}"
|
||||
)
|
||||
|
||||
|
||||
def validate_socket_file(socket_path: str) -> None:
|
||||
if not socket_path or not os.path.isabs(socket_path):
|
||||
raise PreflightError("socket path must be absolute")
|
||||
info = os.lstat(socket_path)
|
||||
if stat_module.S_ISLNK(info.st_mode) or not stat_module.S_ISSOCK(info.st_mode):
|
||||
raise PreflightError("endpoint must be a local Unix socket")
|
||||
parent = os.stat(os.path.dirname(socket_path))
|
||||
if not stat_module.S_ISDIR(parent.st_mode) or parent.st_mode & 0o002:
|
||||
raise PreflightError("socket directory is unsafe")
|
||||
|
||||
|
||||
def run_preflight(socket_path: str, timeout_s: float = 10.0) -> PreflightSummary:
|
||||
"""Port of ``PreflightRuntime`` + ``validateRuntimeCapabilities``."""
|
||||
validate_socket_file(socket_path)
|
||||
with grpc.insecure_channel(f"unix:{socket_path}") as channel:
|
||||
stub = pb2_grpc.RuntimeAdapterServiceStub(channel)
|
||||
try:
|
||||
health = stub.Health(pb2.HealthRequest(), timeout=timeout_s)
|
||||
except grpc.RpcError as exc:
|
||||
raise PreflightError(f"health call failed: {exc.code().name}")
|
||||
if (
|
||||
health.state != pb2.SERVING_STATE_READY
|
||||
or not health.runtime_version.strip()
|
||||
or not health.adapter_version.strip()
|
||||
):
|
||||
raise PreflightError("runtime is not ready with versioned adapter evidence")
|
||||
try:
|
||||
caps = stub.GetCapabilities(pb2.GetCapabilitiesRequest(), timeout=timeout_s)
|
||||
except grpc.RpcError as exc:
|
||||
raise PreflightError(f"capabilities call failed: {exc.code().name}")
|
||||
return _validate_capabilities(socket_path, health, caps)
|
||||
|
||||
|
||||
def _validate_capabilities(socket_path, health, caps) -> PreflightSummary:
|
||||
if not caps.runtime_version.strip() or not caps.adapter_version.strip():
|
||||
raise PreflightError("capabilities lack version evidence")
|
||||
if (
|
||||
caps.runtime_version != health.runtime_version
|
||||
or caps.adapter_version != health.adapter_version
|
||||
):
|
||||
raise PreflightError("health and capabilities versions disagree")
|
||||
if not caps.devices:
|
||||
raise PreflightError("no execution devices reported")
|
||||
total_slots = free_slots = 0
|
||||
seen_devices: set[str] = set()
|
||||
for device in caps.devices:
|
||||
if (
|
||||
not device.device_id.strip()
|
||||
or not device.hardware_class.strip()
|
||||
or device.total_vram_bytes == 0
|
||||
or device.total_slots == 0
|
||||
or device.free_slots > device.total_slots
|
||||
):
|
||||
raise PreflightError("invalid execution device reported")
|
||||
if device.device_id in seen_devices:
|
||||
raise PreflightError("duplicate execution device reported")
|
||||
seen_devices.add(device.device_id)
|
||||
if (
|
||||
total_slots + device.total_slots > _MAX_UINT32
|
||||
or free_slots + device.free_slots > _MAX_UINT32
|
||||
):
|
||||
raise PreflightError("slot total overflows protocol limit")
|
||||
total_slots += device.total_slots
|
||||
free_slots += device.free_slots
|
||||
ready = 0
|
||||
seen_models: set[tuple[str, str, str]] = set()
|
||||
for model in caps.models:
|
||||
if model.state != pb2.RUNTIME_MODEL_STATE_READY:
|
||||
continue
|
||||
if (
|
||||
not model.catalog_model_id.strip()
|
||||
or not model.model_version.strip()
|
||||
or not model.model_digest.strip()
|
||||
or not model.precisions
|
||||
):
|
||||
raise PreflightError("invalid ready model reported")
|
||||
identity = (model.catalog_model_id, model.model_version, model.model_digest)
|
||||
if identity in seen_models:
|
||||
raise PreflightError("duplicate ready model reported")
|
||||
seen_models.add(identity)
|
||||
ready += 1
|
||||
if ready == 0:
|
||||
raise PreflightError("no ready model reported")
|
||||
return PreflightSummary(
|
||||
socket_path=socket_path,
|
||||
runtime_version=health.runtime_version,
|
||||
adapter_version=health.adapter_version,
|
||||
device_count=len(caps.devices),
|
||||
ready_model_count=ready,
|
||||
total_slots=total_slots,
|
||||
free_slots=free_slots,
|
||||
)
|
||||
|
||||
|
||||
def selfcheck(timeout_s: float = 10.0) -> int:
|
||||
"""Start the production server on a temp socket and preflight it."""
|
||||
from .production import build_runtime_context # noqa: PLC0415
|
||||
from .server import create_server # noqa: PLC0415
|
||||
|
||||
context = build_runtime_context()
|
||||
warm = getattr(context.inventory, "warm", None)
|
||||
if callable(warm):
|
||||
print("selfcheck: warming model inventory (first run hashes weights)…")
|
||||
warm()
|
||||
# Short prefix: macOS caps Unix-socket paths at 103 characters and the
|
||||
# default macOS tempdir is already ~60 characters deep.
|
||||
with tempfile.TemporaryDirectory(prefix="vs-rta-") as tmp:
|
||||
os.chmod(tmp, 0o700)
|
||||
socket_path = os.path.join(tmp, "runtime.sock")
|
||||
server = create_server(context, socket_path)
|
||||
server.start()
|
||||
try:
|
||||
summary = run_preflight(socket_path, timeout_s=timeout_s)
|
||||
except PreflightError as failure:
|
||||
print(f"selfcheck: FAIL: {failure}")
|
||||
return 1
|
||||
finally:
|
||||
server.stop(grace=2).wait()
|
||||
print(f"selfcheck: OK: {summary.render()}")
|
||||
return 0
|
||||
@@ -0,0 +1,208 @@
|
||||
"""The gRPC server: Unix-domain socket only, no HTTP, no TCP.
|
||||
|
||||
``Health`` and ``GetCapabilities`` read the same version constants from one
|
||||
:class:`RuntimeContext`, so the "identical versions" preflight expectation
|
||||
holds by construction. Socket-path safety mirrors the Go preflight's checks
|
||||
(absolute path, no symlink, parent directory not world-writable) at bind time
|
||||
so an unsafe deployment fails closed on our side too.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import stat as stat_module
|
||||
import threading
|
||||
from concurrent import futures
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import grpc
|
||||
|
||||
from . import ADAPTER_VERSION, DEFAULT_SOCKET_PATH, SOCKET_ENV
|
||||
from .executor import AttemptRegistry, Executor
|
||||
from .gen import runtime_adapter_pb2 as pb2
|
||||
from .gen import runtime_adapter_pb2_grpc as pb2_grpc
|
||||
from .inventory import (
|
||||
STATE_FAILED,
|
||||
STATE_INSTALLED,
|
||||
STATE_LOADING,
|
||||
STATE_READY,
|
||||
)
|
||||
|
||||
_MODEL_STATE_TO_PB = {
|
||||
STATE_INSTALLED: pb2.RUNTIME_MODEL_STATE_INSTALLED,
|
||||
STATE_LOADING: pb2.RUNTIME_MODEL_STATE_LOADING,
|
||||
STATE_READY: pb2.RUNTIME_MODEL_STATE_READY,
|
||||
STATE_FAILED: pb2.RUNTIME_MODEL_STATE_FAILED,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class RuntimeContext:
|
||||
"""Everything the servicer needs; tests build it from fakes."""
|
||||
|
||||
runtime_version: str
|
||||
inventory: object
|
||||
engine_provider: object
|
||||
adapter_version: str = ADAPTER_VERSION
|
||||
slot_limit: int = 1
|
||||
progress_interval: float = 0.5
|
||||
poll_interval: float = 0.02
|
||||
registry: AttemptRegistry = field(default_factory=AttemptRegistry)
|
||||
|
||||
def executor(self) -> Executor:
|
||||
return Executor(
|
||||
self.inventory,
|
||||
self.engine_provider,
|
||||
self.registry,
|
||||
slot_limit=self.slot_limit,
|
||||
progress_interval=self.progress_interval,
|
||||
poll_interval=self.poll_interval,
|
||||
)
|
||||
|
||||
|
||||
class RuntimeAdapterServicer(pb2_grpc.RuntimeAdapterServiceServicer):
|
||||
def __init__(self, context: RuntimeContext):
|
||||
self._context = context
|
||||
self._executor = context.executor()
|
||||
|
||||
def Health(self, request, grpc_context):
|
||||
flags: list[str] = []
|
||||
state = pb2.SERVING_STATE_READY
|
||||
try:
|
||||
devices = self._context.inventory.devices(
|
||||
busy_slots=self._context.registry.active_count()
|
||||
)
|
||||
models = self._context.inventory.models()
|
||||
except Exception:
|
||||
return pb2.HealthResponse(
|
||||
state=pb2.SERVING_STATE_UNHEALTHY,
|
||||
runtime_version=self._context.runtime_version,
|
||||
adapter_version=self._context.adapter_version,
|
||||
health_flags=["inventory-error"],
|
||||
)
|
||||
if not devices:
|
||||
state = pb2.SERVING_STATE_UNHEALTHY
|
||||
flags.append("no-device")
|
||||
if not any(model.state == STATE_READY for model in models):
|
||||
state = max(state, pb2.SERVING_STATE_DEGRADED)
|
||||
flags.append("no-ready-model")
|
||||
return pb2.HealthResponse(
|
||||
state=state,
|
||||
runtime_version=self._context.runtime_version,
|
||||
adapter_version=self._context.adapter_version,
|
||||
health_flags=flags,
|
||||
)
|
||||
|
||||
def GetCapabilities(self, request, grpc_context):
|
||||
busy = self._context.registry.active_count()
|
||||
response = pb2.GetCapabilitiesResponse(
|
||||
runtime_version=self._context.runtime_version,
|
||||
adapter_version=self._context.adapter_version,
|
||||
)
|
||||
for device in self._context.inventory.devices(busy_slots=busy):
|
||||
response.devices.append(
|
||||
pb2.RuntimeDevice(
|
||||
device_id=device.device_id,
|
||||
hardware_class=device.hardware_class,
|
||||
total_vram_bytes=device.total_vram_bytes,
|
||||
total_slots=device.total_slots,
|
||||
free_slots=device.free_slots,
|
||||
)
|
||||
)
|
||||
for model in self._context.inventory.models():
|
||||
response.models.append(
|
||||
pb2.RuntimeModel(
|
||||
catalog_model_id=model.catalog_model_id,
|
||||
model_version=model.model_version,
|
||||
model_digest=model.model_digest,
|
||||
precisions=list(model.precisions),
|
||||
features=list(model.features),
|
||||
state=_MODEL_STATE_TO_PB.get(
|
||||
model.state, pb2.RUNTIME_MODEL_STATE_UNSPECIFIED
|
||||
),
|
||||
)
|
||||
)
|
||||
return response
|
||||
|
||||
def Execute(self, request, grpc_context):
|
||||
yield from self._executor.execute(request, grpc_context)
|
||||
|
||||
def Cancel(self, request, grpc_context):
|
||||
disposition = self._context.registry.cancel(request.job_id, request.attempt_id)
|
||||
return pb2.CancelResponse(disposition=disposition)
|
||||
|
||||
|
||||
def resolve_socket_path(explicit: str | None = None) -> str:
|
||||
return (
|
||||
(explicit or "").strip()
|
||||
or os.environ.get(SOCKET_ENV, "").strip()
|
||||
or DEFAULT_SOCKET_PATH
|
||||
)
|
||||
|
||||
|
||||
def prepare_socket(socket_path: str) -> str:
|
||||
"""Fail closed on any unsafe socket placement; remove only a stale socket."""
|
||||
if not socket_path or not os.path.isabs(socket_path):
|
||||
raise ValueError("runtime socket path must be absolute")
|
||||
parent = os.path.dirname(socket_path)
|
||||
try:
|
||||
parent_stat = os.stat(parent)
|
||||
except OSError as exc:
|
||||
raise ValueError(f"runtime socket directory is missing: {exc}") from exc
|
||||
if not stat_module.S_ISDIR(parent_stat.st_mode) or parent_stat.st_mode & 0o002:
|
||||
raise ValueError("runtime socket directory is unsafe (world-writable?)")
|
||||
try:
|
||||
existing = os.lstat(socket_path)
|
||||
except FileNotFoundError:
|
||||
return socket_path
|
||||
if stat_module.S_ISSOCK(existing.st_mode):
|
||||
os.unlink(socket_path) # stale socket from a previous run
|
||||
return socket_path
|
||||
raise ValueError("runtime socket path exists and is not a socket")
|
||||
|
||||
|
||||
def create_server(
|
||||
context: RuntimeContext, socket_path: str, *, max_workers: int | None = None
|
||||
) -> grpc.Server:
|
||||
prepare_socket(socket_path)
|
||||
workers = max_workers or max(8, context.slot_limit * 2 + 4)
|
||||
server = grpc.server(
|
||||
futures.ThreadPoolExecutor(
|
||||
max_workers=workers, thread_name_prefix="runtime-adapter"
|
||||
)
|
||||
)
|
||||
pb2_grpc.add_RuntimeAdapterServiceServicer_to_server(
|
||||
RuntimeAdapterServicer(context), server
|
||||
)
|
||||
bound = server.add_insecure_port(f"unix:{socket_path}")
|
||||
if bound == 0:
|
||||
raise RuntimeError("failed to bind the runtime adapter socket")
|
||||
return server
|
||||
|
||||
|
||||
def serve(context: RuntimeContext, socket_path: str) -> int:
|
||||
"""Run until SIGINT/SIGTERM. Returns a process exit code."""
|
||||
import signal # noqa: PLC0415
|
||||
|
||||
warm = getattr(context.inventory, "warm", None)
|
||||
if callable(warm):
|
||||
warm() # hash installed snapshots before the socket exists
|
||||
server = create_server(context, socket_path)
|
||||
server.start()
|
||||
try:
|
||||
os.chmod(socket_path, 0o660) # gateway runs under the same service identity
|
||||
except OSError:
|
||||
pass
|
||||
stop = threading.Event()
|
||||
|
||||
def _stop(_signum, _frame):
|
||||
stop.set()
|
||||
|
||||
signal.signal(signal.SIGTERM, _stop)
|
||||
signal.signal(signal.SIGINT, _stop)
|
||||
stop.wait()
|
||||
server.stop(grace=10).wait()
|
||||
try:
|
||||
os.unlink(socket_path)
|
||||
except OSError:
|
||||
pass
|
||||
return 0
|
||||
@@ -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
|
||||
|
||||
@@ -33,9 +33,7 @@ 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"}
|
||||
)
|
||||
_ALLOWED_WS_PATHS = frozenset({"/ws/events", "/ws/transcribe"})
|
||||
_ADMIN_CAPABILITIES = frozenset({"consume", "admin"})
|
||||
_KEY_GENERATION_INFO = b"omnivoice-admin-key-generation-v1"
|
||||
|
||||
|
||||
+44
-162
@@ -30,7 +30,6 @@ import re
|
||||
import contextlib
|
||||
import threading
|
||||
import time
|
||||
import weakref
|
||||
from utils.containment import contain_system_exit
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
@@ -89,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
|
||||
@@ -146,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
|
||||
@@ -166,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.)"
|
||||
@@ -305,16 +310,6 @@ class ASRBackend(ABC):
|
||||
# broken GPU path, strictly worse than the honest `cpu_fallback`.)
|
||||
gpu_compat: tuple[str, ...] = ("cpu",)
|
||||
|
||||
def execution_evidence_loaded(self) -> bool:
|
||||
"""Whether this instance has live model state worth reporting."""
|
||||
if getattr(self, "runs_out_of_process", False):
|
||||
proc = getattr(self, "_proc", None)
|
||||
return proc is not None and proc.poll() is None
|
||||
return any(
|
||||
getattr(self, attr, None) is not None
|
||||
for attr in ("_model", "_asr", "_pipeline", "_pipe", "_transcriber", "_rec")
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def is_available(cls) -> tuple[bool, str]:
|
||||
@@ -961,8 +956,6 @@ class FasterWhisperBackend(ASRBackend):
|
||||
# (after the #551 compute_type / #255 OOM→CPU fallback chain).
|
||||
self._device: str | None = None
|
||||
self._compute_type: str | None = None
|
||||
self._fallback_reason: str | None = None
|
||||
self._fallback_stage: str | None = None
|
||||
|
||||
@classmethod
|
||||
def is_available(cls) -> tuple[bool, str]:
|
||||
@@ -1040,8 +1033,6 @@ class FasterWhisperBackend(ASRBackend):
|
||||
except Exception: # noqa: BLE001 — cache clear is best-effort
|
||||
pass
|
||||
device = "cpu"
|
||||
self._fallback_reason = "CUDA memory was exhausted while loading the engine"
|
||||
self._fallback_stage = "model_load"
|
||||
candidates = _compute_type_candidates(device)
|
||||
compute_type = candidates[0]
|
||||
continue
|
||||
@@ -2369,8 +2360,6 @@ _LAST_ERRORS: dict[str, str] = {}
|
||||
# failing ASR wholesale. Per-process by design: repairing the env requires a
|
||||
# reinstall / ``uv sync --reinstall`` and an app restart anyway.
|
||||
_DEEP_IMPORT_BROKEN: dict[str, str] = {}
|
||||
_RUNTIME_EVIDENCE: dict[str, dict] = {}
|
||||
_RUNTIME_INSTANCES: weakref.WeakValueDictionary[str, "ASRBackend"] = weakref.WeakValueDictionary()
|
||||
|
||||
|
||||
def _deep_import_reason(cls: type["ASRBackend"], exc: ImportError) -> str:
|
||||
@@ -2401,7 +2390,6 @@ def list_backends() -> list[dict]:
|
||||
"""
|
||||
from core.device_caps import detect_host_caps
|
||||
from core.scrub import scrub_text
|
||||
from services.engine_evidence import snapshot as execution_snapshot
|
||||
from services.engine_routing import routing_fields
|
||||
caps = detect_host_caps()
|
||||
|
||||
@@ -2426,24 +2414,6 @@ def list_backends() -> list[dict]:
|
||||
_LAST_ERRORS[bid] = scrub_text(msg)
|
||||
isolation = "subprocess" if getattr(cls, "_is_subprocess_isolated", False) else "in-process"
|
||||
gpu_compat = getattr(cls, "gpu_compat", ("cpu",))
|
||||
routing = routing_fields(gpu_compat, caps)
|
||||
# Cached load-time facts are valid only while their exact backend still
|
||||
# owns live model state. Recompute from that instance so unload/reaping
|
||||
# cannot leave ghost GPU/provider evidence in diagnostics.
|
||||
instance = (
|
||||
_ISOLATED_INSTANCES.get(bid)
|
||||
if isolation == "subprocess"
|
||||
else _RUNTIME_INSTANCES.get(bid)
|
||||
)
|
||||
execution_evidence = execution_snapshot(
|
||||
engine_id=bid,
|
||||
engine_cls=cls,
|
||||
instance=instance,
|
||||
routing=routing,
|
||||
caps=caps,
|
||||
)
|
||||
if execution_evidence["evidence_state"] == "not_loaded":
|
||||
_RUNTIME_EVIDENCE.pop(bid, None)
|
||||
out.append({
|
||||
"id": bid,
|
||||
"display_name": cls.display_name,
|
||||
@@ -2455,14 +2425,7 @@ def list_backends() -> list[dict]:
|
||||
"last_error": _LAST_ERRORS.get(bid),
|
||||
"isolation_mode": isolation,
|
||||
"gpu_compat": list(gpu_compat),
|
||||
**routing,
|
||||
"execution_evidence": execution_evidence or execution_snapshot(
|
||||
engine_id=bid,
|
||||
engine_cls=cls,
|
||||
instance=None,
|
||||
routing=routing,
|
||||
caps=caps,
|
||||
),
|
||||
**routing_fields(gpu_compat, caps),
|
||||
})
|
||||
return out
|
||||
|
||||
@@ -2601,10 +2564,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:
|
||||
@@ -2703,21 +2663,6 @@ def load_active_asr_backend(*, asr_pipe=None) -> ASRBackend:
|
||||
raise ASRModelMissingError(missing)
|
||||
try:
|
||||
backend.ensure_loaded()
|
||||
from core.device_caps import detect_host_caps
|
||||
from services.engine_evidence import snapshot as execution_snapshot
|
||||
from services.engine_routing import routing_fields
|
||||
|
||||
cls = type(backend)
|
||||
caps = detect_host_caps()
|
||||
routing = routing_fields(getattr(cls, "gpu_compat", ("cpu",)), caps)
|
||||
_RUNTIME_EVIDENCE[bid] = execution_snapshot(
|
||||
engine_id=bid,
|
||||
engine_cls=cls,
|
||||
instance=backend,
|
||||
routing=routing,
|
||||
caps=caps,
|
||||
)
|
||||
_RUNTIME_INSTANCES[bid] = backend
|
||||
return backend
|
||||
except ImportError as e:
|
||||
# ModuleNotFoundError and its ImportError parent ("cannot import
|
||||
@@ -3047,7 +2992,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:
|
||||
@@ -3072,9 +3017,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
|
||||
|
||||
@@ -3083,7 +3025,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:
|
||||
@@ -3250,10 +3192,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
|
||||
@@ -3273,38 +3212,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
|
||||
|
||||
@@ -3335,9 +3256,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.
|
||||
@@ -3349,11 +3268,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
|
||||
@@ -3363,55 +3277,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):
|
||||
@@ -3419,11 +3305,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,
|
||||
|
||||
@@ -1,230 +0,0 @@
|
||||
"""Structured pre-install and measured disk costs for TTS engines."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
_GIB = 1024**3
|
||||
_CACHE_TTL_SECONDS = 10.0
|
||||
_measurement_cache: dict[str, tuple[float, dict]] = {}
|
||||
_measurement_lock = threading.Lock()
|
||||
|
||||
# Catalogue/build estimates. ``None`` is deliberate: unknown costs must stay
|
||||
# visible instead of being silently treated as zero.
|
||||
_ESTIMATES: dict[str, dict] = {
|
||||
"omnivoice": {
|
||||
"package_download_bytes": None,
|
||||
"unique_installed_bytes": None,
|
||||
"potentially_shared_bytes": None,
|
||||
"temporary_free_bytes": None,
|
||||
"confidence": "estimated",
|
||||
"destination": "hf_model_cache",
|
||||
"deduplication": None,
|
||||
},
|
||||
"kittentts": {
|
||||
"package_download_bytes": None,
|
||||
"unique_installed_bytes": None,
|
||||
"potentially_shared_bytes": None,
|
||||
"temporary_free_bytes": None,
|
||||
"confidence": "estimated",
|
||||
"destination": "hf_model_cache",
|
||||
"deduplication": None,
|
||||
},
|
||||
}
|
||||
_MODEL_REPOS = {
|
||||
"omnivoice": "k2-fsa/OmniVoice",
|
||||
"kittentts": "KittenML/kitten-tts-mini-0.8",
|
||||
}
|
||||
|
||||
|
||||
def _volume_root(path: Path) -> str:
|
||||
"""Mount point/drive containing a possibly not-yet-created destination."""
|
||||
try:
|
||||
current = path.expanduser().resolve()
|
||||
while not current.exists() and current.parent != current:
|
||||
current = current.parent
|
||||
device = current.stat().st_dev
|
||||
while current.parent != current and current.parent.stat().st_dev == device:
|
||||
current = current.parent
|
||||
return str(current)
|
||||
except OSError:
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _hf_cache_path() -> Path:
|
||||
configured = (
|
||||
os.environ.get("HF_HUB_CACHE")
|
||||
or os.environ.get("HUGGINGFACE_HUB_CACHE")
|
||||
or os.environ.get("HF_HOME")
|
||||
)
|
||||
return Path(configured) if configured else Path.home() / ".cache" / "huggingface"
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def _catalog_model_bytes(engine_id: str) -> int | None:
|
||||
"""Resolve the weight estimate from config/models.yaml, its source of truth."""
|
||||
repo_id = _MODEL_REPOS.get(engine_id)
|
||||
if repo_id is None:
|
||||
return None
|
||||
try:
|
||||
import yaml
|
||||
|
||||
catalog_path = Path(__file__).resolve().parents[1] / "config" / "models.yaml"
|
||||
entries = yaml.safe_load(catalog_path.read_text(encoding="utf-8"))["models"]
|
||||
model = next(item for item in entries if item["repo_id"] == repo_id)
|
||||
return round(float(model["size_gb"]) * _GIB)
|
||||
except (OSError, KeyError, StopIteration, TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _dir_size(path: Path) -> int:
|
||||
total = 0
|
||||
try:
|
||||
for root, _dirs, files in os.walk(path):
|
||||
for filename in files:
|
||||
try:
|
||||
total += os.path.getsize(os.path.join(root, filename))
|
||||
except OSError:
|
||||
continue
|
||||
except OSError:
|
||||
return 0
|
||||
return total
|
||||
|
||||
|
||||
def _sidecar_estimate(engine_id: str) -> dict | None:
|
||||
try:
|
||||
from services.sidecar_install import get_spec, managed_root
|
||||
|
||||
spec = get_spec(engine_id)
|
||||
except Exception:
|
||||
return None
|
||||
if spec is None:
|
||||
return None
|
||||
model_bytes = spec.weights_bytes
|
||||
dependency_bytes = spec.dependency_bytes
|
||||
return {
|
||||
"model_download_bytes": model_bytes,
|
||||
"package_download_bytes": dependency_bytes,
|
||||
"unique_installed_bytes": spec.required_bytes,
|
||||
"potentially_shared_bytes": spec.potentially_shared_bytes,
|
||||
"temporary_free_bytes": spec.temporary_free_bytes,
|
||||
"confidence": spec.disk_confidence,
|
||||
"destination": "engine_data",
|
||||
"destination_volume": _volume_root(managed_root(spec)),
|
||||
"deduplication": "uv_same_volume",
|
||||
}
|
||||
|
||||
|
||||
def estimate_for(engine_id: str) -> dict:
|
||||
estimate = _sidecar_estimate(engine_id) or _ESTIMATES.get(engine_id)
|
||||
if estimate is not None:
|
||||
return {
|
||||
"model_download_bytes": _catalog_model_bytes(engine_id),
|
||||
"destination_volume": _volume_root(_hf_cache_path()),
|
||||
**estimate,
|
||||
}
|
||||
return {
|
||||
"model_download_bytes": None,
|
||||
"package_download_bytes": None,
|
||||
"unique_installed_bytes": None,
|
||||
"potentially_shared_bytes": None,
|
||||
"temporary_free_bytes": None,
|
||||
"confidence": "unknown",
|
||||
"destination": "unknown",
|
||||
"destination_volume": "unknown",
|
||||
"deduplication": None,
|
||||
}
|
||||
|
||||
|
||||
def _measure_sidecar(engine_id: str) -> dict | None:
|
||||
try:
|
||||
from services.sidecar_install import get_spec, managed_checkout, managed_root
|
||||
|
||||
spec = get_spec(engine_id)
|
||||
except Exception:
|
||||
return None
|
||||
if spec is None:
|
||||
return None
|
||||
checkout = managed_checkout(spec)
|
||||
if not checkout.is_dir():
|
||||
return None
|
||||
model = _dir_size(checkout / spec.weights_subdir)
|
||||
environment = _dir_size(checkout / ".venv")
|
||||
total = _dir_size(managed_root(spec))
|
||||
shared_cache = _dir_size(managed_root(spec).parent / ".uv-cache")
|
||||
return {
|
||||
"model_bytes": model,
|
||||
"environment_bytes": environment,
|
||||
"cache_bytes": shared_cache,
|
||||
"total_owned_bytes": total,
|
||||
"confidence": "measured",
|
||||
}
|
||||
|
||||
|
||||
def _measure_model_cache(engine_id: str) -> dict | None:
|
||||
repo_id = _MODEL_REPOS.get(engine_id)
|
||||
if repo_id is None:
|
||||
return None
|
||||
try:
|
||||
from huggingface_hub import scan_cache_dir
|
||||
|
||||
repo = next((item for item in scan_cache_dir().repos if item.repo_id == repo_id), None)
|
||||
except Exception:
|
||||
return None
|
||||
if repo is None or repo.size_on_disk <= 0:
|
||||
return None
|
||||
size = int(repo.size_on_disk)
|
||||
return {
|
||||
"model_bytes": size,
|
||||
# The model lives in this cache; cache overhead is not separately
|
||||
# attributable without double-counting the same hardlinked blobs.
|
||||
"environment_bytes": None,
|
||||
"cache_bytes": 0,
|
||||
"total_owned_bytes": size,
|
||||
"confidence": "measured",
|
||||
}
|
||||
|
||||
|
||||
def actual_for(engine_id: str) -> dict:
|
||||
now = time.monotonic()
|
||||
cached = _measurement_cache.get(engine_id)
|
||||
if cached and now - cached[0] < _CACHE_TTL_SECONDS:
|
||||
return dict(cached[1])
|
||||
# A cache miss can recursively walk a sidecar and the shared uv cache.
|
||||
# Coalesce concurrent requests so callers cannot multiply that work.
|
||||
with _measurement_lock:
|
||||
now = time.monotonic()
|
||||
cached = _measurement_cache.get(engine_id)
|
||||
if cached and now - cached[0] < _CACHE_TTL_SECONDS:
|
||||
return dict(cached[1])
|
||||
actual = _measure_sidecar(engine_id) or _measure_model_cache(engine_id) or {
|
||||
"model_bytes": None,
|
||||
"environment_bytes": None,
|
||||
"cache_bytes": None,
|
||||
"total_owned_bytes": None,
|
||||
"confidence": "unknown",
|
||||
}
|
||||
_measurement_cache[engine_id] = (now, actual)
|
||||
return dict(actual)
|
||||
|
||||
|
||||
def disk_usage_for(engine_id: str) -> dict:
|
||||
"""Stable API shape consumed by the engine catalogue."""
|
||||
return {"estimate": estimate_for(engine_id), "actual": actual_for(engine_id)}
|
||||
|
||||
|
||||
def disk_summary_for(engine_id: str) -> dict:
|
||||
"""Cheap list payload; measurement is deferred until the row is opened."""
|
||||
return {
|
||||
"estimate": estimate_for(engine_id),
|
||||
"actual": {
|
||||
"model_bytes": None,
|
||||
"environment_bytes": None,
|
||||
"cache_bytes": None,
|
||||
"total_owned_bytes": None,
|
||||
"confidence": "unknown",
|
||||
},
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
"""Sanitized, reproducible execution evidence for TTS and ASR engines."""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.metadata
|
||||
import platform
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _version(distribution: str) -> str | None:
|
||||
try:
|
||||
return importlib.metadata.version(distribution)
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
return None
|
||||
|
||||
|
||||
def _value(instance: object, *names: str) -> str | None:
|
||||
for name in names:
|
||||
try:
|
||||
value = getattr(instance, name, None)
|
||||
if value is not None and not callable(value):
|
||||
text = str(value).strip()
|
||||
if text and len(text) <= 80 and "/" not in text and "\\" not in text:
|
||||
return text
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def runtime_versions(engine_id: str) -> dict[str, str]:
|
||||
"""Relevant installed library versions, never paths or environment values."""
|
||||
names = {"python": platform.python_version()}
|
||||
candidates = ["torch"]
|
||||
low = engine_id.lower()
|
||||
if "faster" in low or "whisperx" in low:
|
||||
candidates.extend(["ctranslate2", "faster-whisper"])
|
||||
if "sherpa" in low or "moonshine" in low:
|
||||
candidates.append("onnxruntime")
|
||||
if "mlx" in low:
|
||||
candidates.append("mlx")
|
||||
for name in candidates:
|
||||
if (version := _version(name)) is not None:
|
||||
names[name] = version
|
||||
return names
|
||||
|
||||
|
||||
def snapshot(
|
||||
*,
|
||||
engine_id: str,
|
||||
engine_cls: type,
|
||||
instance: object | None,
|
||||
routing: dict[str, Any],
|
||||
caps: object,
|
||||
) -> dict[str, Any]:
|
||||
"""Return fixed-shape evidence; actual fields stay null until an instance loads."""
|
||||
isolated = bool(
|
||||
getattr(engine_cls, "_is_subprocess_isolated", False)
|
||||
or getattr(engine_cls, "runs_out_of_process", False)
|
||||
)
|
||||
loaded = False
|
||||
probe_failed = False
|
||||
if instance is not None:
|
||||
try:
|
||||
contract = getattr(instance, "execution_evidence_loaded", False)
|
||||
loaded = bool(contract() if callable(contract) else contract)
|
||||
except Exception: # noqa: BLE001 - third-party lifecycle descriptors may raise
|
||||
probe_failed = True
|
||||
|
||||
actual_device = None
|
||||
provider = None
|
||||
precision = None
|
||||
if loaded:
|
||||
actual_device = _value(instance, "_device", "device", "execution_device")
|
||||
provider = _value(instance, "_provider", "provider", "execution_provider")
|
||||
precision = _value(
|
||||
instance, "_compute_type", "compute_type", "_dtype", "dtype", "quantization"
|
||||
)
|
||||
if provider is None and actual_device is not None:
|
||||
provider = actual_device
|
||||
|
||||
runtime_fallback_reason = _value(instance, "_fallback_reason", "fallback_reason") if loaded else None
|
||||
runtime_fallback_stage = _value(instance, "_fallback_stage", "fallback_stage") if loaded else None
|
||||
status = routing.get("routing_status")
|
||||
fallback = status == "cpu_fallback" or runtime_fallback_reason is not None
|
||||
evidence_state = "not_loaded"
|
||||
if probe_failed:
|
||||
evidence_state = "probe_error"
|
||||
elif loaded:
|
||||
evidence_state = "loaded"
|
||||
if isolated and provider is None and actual_device is None:
|
||||
evidence_state = "subprocess_loaded_provider_unreported"
|
||||
return {
|
||||
"implementation_variant": f"{engine_cls.__module__}.{engine_cls.__name__}",
|
||||
"declared_device_families": list(getattr(engine_cls, "gpu_compat", ("cpu",))),
|
||||
"evidence_state": evidence_state,
|
||||
"actual_execution_provider": provider,
|
||||
"actual_execution_device": actual_device,
|
||||
"gpu_name": getattr(caps, "device_name", "") or None,
|
||||
"gpu_architecture": _gpu_architecture(getattr(caps, "family", "cpu")),
|
||||
"precision_or_quantization": precision,
|
||||
"cpu_fallback_reason": runtime_fallback_reason or (routing.get("routing_reason") if fallback else None),
|
||||
"cpu_fallback_stage": runtime_fallback_stage or ("routing_preflight" if fallback else None),
|
||||
"parent_memory_observable": not isolated,
|
||||
"runtime_versions": runtime_versions(engine_id),
|
||||
}
|
||||
|
||||
|
||||
def _gpu_architecture(family: str) -> str | None:
|
||||
if family not in {"cuda", "rocm"}:
|
||||
return "apple-silicon" if family == "mps" else None
|
||||
try:
|
||||
import torch
|
||||
|
||||
if family == "rocm":
|
||||
props = torch.cuda.get_device_properties(0)
|
||||
return str(getattr(props, "gcnArchName", "") or "") or None
|
||||
major, minor = torch.cuda.get_device_capability(0)
|
||||
return f"sm_{major}{minor}"
|
||||
except Exception:
|
||||
return None
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Opt-in adapter from OSS profiles/generation to the hosted v1 contract.
|
||||
|
||||
Local VoiceStudio never calls this module unless the caller explicitly requests
|
||||
``hosted`` execution *and* all VSS_HOSTED_* settings are present. It stages
|
||||
text/reference bytes as hosted Artifacts, creates a consent-backed Voice, and
|
||||
uses durable Jobs; no local path, source recording URL, or plaintext text is
|
||||
sent in a Job snapshot.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
class HostedVoiceError(RuntimeError):
|
||||
"""A safe, user-actionable hosted adapter failure."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HostedSettings:
|
||||
base_url: str
|
||||
token: str
|
||||
project_id: str
|
||||
model_id: str
|
||||
model_version: str
|
||||
base_voice_id: str
|
||||
consent_text_version: str
|
||||
|
||||
@classmethod
|
||||
def from_environment(cls) -> "HostedSettings | None":
|
||||
values = {
|
||||
name: os.environ.get(name, "").strip()
|
||||
for name in (
|
||||
"VSS_HOSTED_API_BASE", "VSS_HOSTED_API_TOKEN",
|
||||
"VSS_HOSTED_PROJECT_ID", "VSS_HOSTED_MODEL_ID",
|
||||
"VSS_HOSTED_MODEL_VERSION", "VSS_HOSTED_BASE_VOICE_ID",
|
||||
)
|
||||
}
|
||||
if not any(values.values()):
|
||||
return None
|
||||
missing = [name for name, value in values.items() if not value]
|
||||
if missing:
|
||||
raise HostedVoiceError("Hosted execution is incomplete; configure " + ", ".join(missing) + ".")
|
||||
base_url = values["VSS_HOSTED_API_BASE"].rstrip("/")
|
||||
if not base_url.startswith(("http://", "https://")):
|
||||
raise HostedVoiceError("VSS_HOSTED_API_BASE must be an http(s) URL.")
|
||||
return cls(
|
||||
base_url=base_url, token=values["VSS_HOSTED_API_TOKEN"],
|
||||
project_id=values["VSS_HOSTED_PROJECT_ID"], model_id=values["VSS_HOSTED_MODEL_ID"],
|
||||
model_version=values["VSS_HOSTED_MODEL_VERSION"], base_voice_id=values["VSS_HOSTED_BASE_VOICE_ID"],
|
||||
consent_text_version=os.environ.get("VSS_HOSTED_CONSENT_TEXT_VERSION", "oss-spoken-consent-v1").strip() or "oss-spoken-consent-v1",
|
||||
)
|
||||
|
||||
|
||||
class HostedVoiceClient:
|
||||
def __init__(self, settings: HostedSettings, client: httpx.AsyncClient | None = None):
|
||||
self.settings = settings
|
||||
self.client = client or httpx.AsyncClient(base_url=settings.base_url, timeout=60)
|
||||
self._owns_client = client is None
|
||||
|
||||
async def aclose(self) -> None:
|
||||
if self._owns_client:
|
||||
await self.client.aclose()
|
||||
|
||||
def _headers(self, *, idempotency: bool = False) -> dict[str, str]:
|
||||
headers = {"Authorization": f"Bearer {self.settings.token}"}
|
||||
if idempotency:
|
||||
headers["Idempotency-Key"] = str(uuid.uuid4())
|
||||
return headers
|
||||
|
||||
async def _request(self, method: str, path: str, *, json: dict | None = None, headers: dict | None = None) -> httpx.Response:
|
||||
response = await self.client.request(method, path, json=json, headers=headers)
|
||||
if response.is_error:
|
||||
detail = "hosted service rejected the request"
|
||||
try:
|
||||
body = response.json()
|
||||
detail = body.get("error", {}).get("message") or body.get("detail") or detail
|
||||
except ValueError:
|
||||
pass
|
||||
raise HostedVoiceError(f"Hosted request failed ({response.status_code}): {detail}")
|
||||
return response
|
||||
|
||||
async def upload_artifact(self, *, purpose: str, media_type: str, payload: bytes) -> str:
|
||||
digest = hashlib.sha256(payload).hexdigest()
|
||||
grant = (await self._request("POST", "/v1/artifacts/upload-authorizations", json={
|
||||
"project_id": self.settings.project_id, "purpose": purpose, "media_type": media_type,
|
||||
"size_bytes": len(payload), "sha256": digest,
|
||||
}, headers=self._headers())).json()
|
||||
put_headers = {k: v for k, v in (grant.get("required_headers") or {}).items() if k.lower() not in {"host", "content-length"}}
|
||||
put_headers.setdefault("Content-Type", media_type)
|
||||
response = await self.client.request(grant.get("method", "PUT"), grant["url"], content=payload, headers=put_headers)
|
||||
if response.is_error:
|
||||
raise HostedVoiceError(f"Hosted Artifact upload failed ({response.status_code}).")
|
||||
await self._request("POST", f"/v1/artifacts/{grant['artifact_id']}/complete", json={"size_bytes": len(payload), "sha256": digest}, headers=self._headers())
|
||||
return grant["artifact_id"]
|
||||
|
||||
async def create_voice(self, *, name: str, description: str, reference_path: str) -> str:
|
||||
payload = Path(reference_path).read_bytes()
|
||||
if not payload:
|
||||
raise HostedVoiceError("The reference recording is empty.")
|
||||
suffix = Path(reference_path).suffix.lower()
|
||||
media_type = {".wav": "audio/wav", ".mp3": "audio/mpeg", ".flac": "audio/flac"}.get(suffix, "audio/wav")
|
||||
reference_id = await self.upload_artifact(purpose="reference_audio", media_type=media_type, payload=payload)
|
||||
voice = await self._request("POST", "/v1/voices", json={
|
||||
"project_id": self.settings.project_id, "display_name": name, "description": description[:1024],
|
||||
"reference_audio_artifact_id": reference_id,
|
||||
"consent": {"attestation_text_version": self.settings.consent_text_version},
|
||||
}, headers=self._headers(idempotency=True))
|
||||
return voice.json()["id"]
|
||||
|
||||
async def synthesize(self, *, text: str, profile_voice_id: str, language: str | None = None) -> bytes:
|
||||
text_artifact = await self.upload_artifact(purpose="input", media_type="text/plain", payload=text.encode("utf-8"))
|
||||
configuration = {"voice_id": self.settings.base_voice_id, "voice_reference_id": profile_voice_id, "output_format": "wav"}
|
||||
if language and language != "Auto":
|
||||
configuration["language"] = language
|
||||
job = await self._request("POST", "/v1/jobs", json={
|
||||
"project_id": self.settings.project_id, "workflow": "tts",
|
||||
"model": {"id": self.settings.model_id, "version": self.settings.model_version},
|
||||
"input": {"text_artifact_id": text_artifact}, "configuration": configuration,
|
||||
}, headers=self._headers(idempotency=True))
|
||||
job_id = job.json()["job_id"]
|
||||
deadline = time.monotonic() + 15 * 60
|
||||
while time.monotonic() < deadline:
|
||||
view = (await self._request("GET", f"/v1/jobs/{job_id}", headers=self._headers())).json()
|
||||
if view.get("state") == "succeeded":
|
||||
outputs = view.get("output_artifact_ids") or []
|
||||
if not outputs:
|
||||
raise HostedVoiceError("Hosted synthesis completed without audio output.")
|
||||
grant = (await self._request("POST", f"/v1/artifacts/{outputs[0]}/download-authorization", headers=self._headers())).json()
|
||||
audio = await self.client.request(grant.get("method", "GET"), grant["url"])
|
||||
if audio.is_error:
|
||||
raise HostedVoiceError("Hosted synthesis output could not be downloaded.")
|
||||
return audio.content
|
||||
if view.get("state") in {"failed", "canceled"}:
|
||||
raise HostedVoiceError("Hosted synthesis did not complete successfully.")
|
||||
await asyncio.sleep(0.5)
|
||||
raise HostedVoiceError("Hosted synthesis timed out waiting for its durable Job.")
|
||||
@@ -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,23 +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 _usable_model_file(path: str) -> bool:
|
||||
try:
|
||||
return os.path.isfile(path) and os.path.getsize(path) > 0
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _resolve_model_dir(spec: SherpaModelSpec, *, download: bool = True) -> str:
|
||||
@@ -288,114 +271,41 @@ def _resolve_model_dir(spec: SherpaModelSpec, *, download: bool = True) -> str:
|
||||
Restricts the fetch to the exact int8 assets we pin via ``allow_patterns``
|
||||
so we never pull the bundled fp32 weights or test wavs.
|
||||
"""
|
||||
from huggingface_hub import constants as hf_constants
|
||||
from huggingface_hub import snapshot_download
|
||||
from services.hf_revisions import revision_for
|
||||
from services.hf_revisions import installed_revision, revision_for
|
||||
|
||||
wanted = list(spec.files.values())
|
||||
cache_dir = _live_hub_cache_dir()
|
||||
# Probe the revision an existing installation actually resolved. Older
|
||||
# releases followed ``main`` and may therefore have a different snapshot;
|
||||
# retaining it preserves offline upgrades. Any network fetch still uses
|
||||
# the reviewed immutable pin.
|
||||
installed = _installed_snapshot(spec)
|
||||
if installed:
|
||||
return installed
|
||||
if not download:
|
||||
raise FileNotFoundError(f"No complete cached snapshot for {spec.repo_id}")
|
||||
|
||||
# A Windows cache can retain a snapshot entry whose target blob vanished,
|
||||
# or a zero-byte ONNX placeholder left by an interrupted download. Hub may
|
||||
# then treat that entry as already materialized and return the same broken
|
||||
# snapshot. Repair those entries before asking for another download so the
|
||||
# recognizer never receives a path to a file that does not resolve (#1733).
|
||||
from services.hf_cache_repair import (
|
||||
find_dangling_entries,
|
||||
repair_repo_cache,
|
||||
repo_cache_dir,
|
||||
)
|
||||
|
||||
if find_dangling_entries(repo_cache_dir(spec.repo_id, cache_dir)):
|
||||
repair = repair_repo_cache(spec.repo_id, cache_dir)
|
||||
installed = _installed_snapshot(spec)
|
||||
if installed:
|
||||
return installed
|
||||
if not repair.get("ok"):
|
||||
logger.warning(
|
||||
"sherpa dictation: cache repair for %s failed: %s",
|
||||
spec.repo_id,
|
||||
repair.get("error") or repair.get("outcome") or "unknown error",
|
||||
)
|
||||
|
||||
installed = installed_revision(spec.repo_id, hf_constants.HF_HUB_CACHE)
|
||||
try:
|
||||
return snapshot_download(
|
||||
repo_id=spec.repo_id,
|
||||
revision=installed,
|
||||
local_files_only=True,
|
||||
allow_patterns=wanted,
|
||||
)
|
||||
except Exception:
|
||||
if not download:
|
||||
raise
|
||||
logger.info("sherpa dictation: downloading %s on first use", spec.repo_id)
|
||||
snapshot = snapshot_download(
|
||||
return snapshot_download(
|
||||
repo_id=spec.repo_id,
|
||||
revision=revision_for(spec.repo_id),
|
||||
allow_patterns=wanted,
|
||||
cache_dir=cache_dir,
|
||||
)
|
||||
missing = [
|
||||
name for name in wanted
|
||||
if not _usable_model_file(os.path.join(snapshot, name))
|
||||
]
|
||||
if not missing:
|
||||
return snapshot
|
||||
|
||||
# Verify after the Hub reports success. This catches hosts where a broken
|
||||
# snapshot entry short-circuits snapshot_download. The generic repair
|
||||
# removes only broken entries, preserves blobs, and retries the immutable
|
||||
# installed revision.
|
||||
repair = repair_repo_cache(spec.repo_id, cache_dir)
|
||||
installed = _installed_snapshot(spec)
|
||||
if installed:
|
||||
return installed
|
||||
detail = repair.get("error") or repair.get("outcome") or "repair did not restore them"
|
||||
raise FileNotFoundError(
|
||||
f"Sherpa model cache is incomplete for {spec.repo_id}; missing "
|
||||
f"{', '.join(missing)}. Cache repair failed: {detail}. Reinstall this "
|
||||
"model from Model Catalogue."
|
||||
)
|
||||
|
||||
|
||||
def _live_hub_cache_dir() -> str:
|
||||
"""The effective hub root, evaluated after Settings restores the env."""
|
||||
direct = os.environ.get("HF_HUB_CACHE") or os.environ.get("HUGGINGFACE_HUB_CACHE")
|
||||
if direct:
|
||||
return os.path.expanduser(direct)
|
||||
home = os.environ.get("HF_HOME") or os.path.expanduser("~/.cache/huggingface")
|
||||
return os.path.join(os.path.expanduser(home), "hub")
|
||||
|
||||
|
||||
def _installed_snapshot(spec: SherpaModelSpec) -> str | None:
|
||||
"""Complete snapshot for the recorded revision in the live cache."""
|
||||
from services.hf_revisions import installed_revision
|
||||
|
||||
cache_dir = _live_hub_cache_dir()
|
||||
revision = installed_revision(spec.repo_id, cache_dir)
|
||||
snapshot = os.path.join(
|
||||
cache_dir,
|
||||
"models--" + spec.repo_id.replace("/", "--"),
|
||||
"snapshots",
|
||||
revision,
|
||||
)
|
||||
if all(
|
||||
_usable_model_file(os.path.join(snapshot, filename))
|
||||
for filename in spec.files.values()
|
||||
):
|
||||
return snapshot
|
||||
return None
|
||||
|
||||
|
||||
def is_installed(spec: SherpaModelSpec) -> bool:
|
||||
"""True if the recorded cached snapshot contains every pinned asset.
|
||||
|
||||
Do not use ``snapshot_download(local_files_only=True)`` for this probe.
|
||||
``huggingface_hub.constants.HF_HUB_CACHE`` is fixed when that module is
|
||||
first imported, while VoiceStudio can restore its cache directory later
|
||||
from the durable user settings. Resolve the live root and the recorded
|
||||
revision ourselves so readiness and loading cannot disagree after a cache
|
||||
move, desktop relaunch, or stale snapshot (#1707).
|
||||
"""
|
||||
return _installed_snapshot(spec) is not None
|
||||
"""True if every pinned asset is already present in the HF cache."""
|
||||
try:
|
||||
d = _resolve_model_dir(spec, download=False)
|
||||
except Exception:
|
||||
return False
|
||||
return all(os.path.isfile(os.path.join(d, f)) for f in spec.files.values())
|
||||
|
||||
|
||||
# ── Recognizers ──────────────────────────────────────────────────────────────
|
||||
@@ -487,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,18 +108,9 @@ 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
|
||||
weights_bytes: Optional[int] = None
|
||||
dependency_bytes: Optional[int] = None
|
||||
potentially_shared_bytes: Optional[int] = None
|
||||
temporary_free_bytes: Optional[int] = None
|
||||
disk_confidence: str = "unknown"
|
||||
# Called after a successful install/uninstall so the engine's memoised
|
||||
# venv resolution re-probes (import inside the lambda — never at module load).
|
||||
invalidate: Callable[[], None] = field(default=lambda: None)
|
||||
@@ -156,17 +146,12 @@ 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
|
||||
# whatever a partial install already put on disk.
|
||||
required_bytes=12 * _GIB,
|
||||
weights_bytes=6 * _GIB,
|
||||
dependency_bytes=6 * _GIB,
|
||||
potentially_shared_bytes=None,
|
||||
temporary_free_bytes=12 * _GIB,
|
||||
disk_confidence="estimated",
|
||||
invalidate=_indextts_invalidate,
|
||||
installed_probe=_indextts_installed,
|
||||
),
|
||||
@@ -322,25 +307,6 @@ def _dir_size_bytes(path: Path) -> int:
|
||||
return total
|
||||
|
||||
|
||||
def _preserved_install_bytes(spec: SidecarSpec, checkout: Path) -> tuple[int, int]:
|
||||
"""Return bytes preserved for the final install and dependency peak.
|
||||
|
||||
Resumable weights reduce the final download requirement, but they do not
|
||||
reduce uv's separate environment-build peak. Only source and a usable
|
||||
existing venv count against that peak.
|
||||
"""
|
||||
if not _source_present(spec, checkout):
|
||||
return 0, 0
|
||||
|
||||
weights_dir = checkout / spec.weights_subdir
|
||||
weights = _dir_size_bytes(weights_dir) if spec.weights_repo_id else 0
|
||||
venv_dir = checkout / ".venv"
|
||||
venv = _dir_size_bytes(venv_dir)
|
||||
source = max(0, _dir_size_bytes(checkout) - weights - venv)
|
||||
usable_venv = venv if _venv_python(venv_dir).is_file() else 0
|
||||
return source + usable_venv + weights, source + usable_venv
|
||||
|
||||
|
||||
def disk_free_bytes(path: Path) -> int:
|
||||
"""Free bytes on the volume backing *path* (nearest existing ancestor).
|
||||
Never raises; 0 when the volume can't be probed."""
|
||||
@@ -363,17 +329,8 @@ def disk_space_error(spec: SidecarSpec) -> Optional[str]:
|
||||
root = managed_root(spec)
|
||||
# A preserved predecessor is not a partial copy of the new install: the
|
||||
# upgrade needs its full space until the new sidecar is verified.
|
||||
checkout = managed_checkout(spec)
|
||||
# Credit only bytes the later steps preserve. An invalid layout or revision
|
||||
# marker makes _step_fetch_source delete the whole checkout.
|
||||
preserved, dependency_peak_credit = _preserved_install_bytes(spec, checkout)
|
||||
remaining = max(0, spec.required_bytes - preserved)
|
||||
if spec.temporary_free_bytes is not None:
|
||||
# Resumable model weights are unrelated to uv's dependency-build peak.
|
||||
remaining = max(
|
||||
remaining,
|
||||
max(0, spec.temporary_free_bytes - dependency_peak_credit),
|
||||
)
|
||||
already = _dir_size_bytes(managed_checkout(spec))
|
||||
remaining = max(0, spec.required_bytes - already)
|
||||
free = disk_free_bytes(root)
|
||||
if free <= 0:
|
||||
return None # can't probe → never block on missing information
|
||||
@@ -922,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:
|
||||
@@ -1012,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.",
|
||||
@@ -1037,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.
|
||||
@@ -1056,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,
|
||||
@@ -1095,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")
|
||||
@@ -363,7 +362,6 @@ class SubprocessBackend(TTSBackend):
|
||||
# be a different class object from the one the subclass closed over.
|
||||
# A duck-typed marker survives that.
|
||||
_is_subprocess_isolated: bool = True
|
||||
spawn_ready_timeout_s: float = SPAWN_READY_TIMEOUT_S
|
||||
|
||||
# Generation happens in the sidecar: parent-side accelerator counters
|
||||
# can't see its allocations (see TTSBackend.runs_out_of_process).
|
||||
@@ -472,6 +470,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
|
||||
@@ -504,7 +509,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,
|
||||
@@ -525,7 +530,7 @@ class SubprocessBackend(TTSBackend):
|
||||
# Block on the ready handshake. A sidecar that fails to emit ready
|
||||
# within SPAWN_READY_TIMEOUT_S is killed and the failure is raised.
|
||||
try:
|
||||
frame = self._recv_with_timeout(self.spawn_ready_timeout_s)
|
||||
frame = self._recv_with_timeout(SPAWN_READY_TIMEOUT_S)
|
||||
except Exception:
|
||||
self._force_kill()
|
||||
raise
|
||||
|
||||
+12
-258
@@ -341,44 +341,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
|
||||
@@ -407,13 +369,6 @@ class TTSBackend(ABC):
|
||||
# entirely (it drives the shared model_manager singleton).
|
||||
_MODEL_ATTRS: tuple[str, ...] = ("_model", "_tts")
|
||||
|
||||
def execution_evidence_loaded(self) -> bool:
|
||||
"""Whether this instance has live model state worth reporting."""
|
||||
if self.runs_out_of_process:
|
||||
proc = getattr(self, "_proc", None)
|
||||
return proc is not None and proc.poll() is None
|
||||
return any(getattr(self, attr, None) is not None for attr in self._MODEL_ATTRS)
|
||||
|
||||
def unload(self) -> None:
|
||||
"""Release the heavy model this backend holds, and free device caches.
|
||||
|
||||
@@ -457,95 +412,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:
|
||||
@@ -584,24 +450,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:
|
||||
@@ -676,7 +533,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
|
||||
@@ -762,73 +619,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
|
||||
@@ -2399,15 +2189,12 @@ def list_backends() -> list[dict]:
|
||||
# Routing is host-aware but the host caps are constant per process, so probe
|
||||
# ONCE here and resolve each engine's effective device against the same caps.
|
||||
from core.device_caps import detect_host_caps
|
||||
from services.engine_disk_usage import disk_summary_for
|
||||
from services.engine_evidence import snapshot as execution_snapshot
|
||||
from services.engine_routing import routing_fields
|
||||
caps = detect_host_caps()
|
||||
installable = _sidecar_installable_ids()
|
||||
|
||||
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:
|
||||
@@ -2434,12 +2221,6 @@ def list_backends() -> list[dict]:
|
||||
# descriptor, not a bool, so report None (= model-dependent) there
|
||||
# instead of an always-truthy false positive.
|
||||
_clone = getattr(cls, "supports_cloning", True)
|
||||
routing = routing_fields(gpu_compat, caps, getattr(cls, "min_vram_gb", 0.0))
|
||||
loaded_instance = None
|
||||
if _active_instance_id == bid:
|
||||
loaded_instance = _active_instance
|
||||
if loaded_instance is None:
|
||||
loaded_instance = _ENGINE_INSTANCES.get(cls)
|
||||
out.append({
|
||||
"id": bid,
|
||||
"display_name": cls.display_name,
|
||||
@@ -2459,7 +2240,6 @@ def list_backends() -> list[dict]:
|
||||
# in-app (Settings renders an Install button instead of leading
|
||||
# with the manual setup snippet).
|
||||
"one_click_install": bid in installable,
|
||||
"disk_usage": disk_summary_for(bid),
|
||||
"last_error": _LAST_ERRORS.get(bid),
|
||||
"isolation_mode": isolation,
|
||||
"gpu_compat": list(gpu_compat),
|
||||
@@ -2467,14 +2247,7 @@ def list_backends() -> list[dict]:
|
||||
"min_vram_gb": getattr(cls, "min_vram_gb", 0.0) or None,
|
||||
# effective_device / routing_status / routing_reason (scrubbed);
|
||||
# the reason now also carries the under-provisioned-GPU caveat.
|
||||
**routing,
|
||||
"execution_evidence": execution_snapshot(
|
||||
engine_id=bid,
|
||||
engine_cls=cls,
|
||||
instance=loaded_instance,
|
||||
routing=routing,
|
||||
caps=caps,
|
||||
),
|
||||
**routing_fields(gpu_compat, caps, getattr(cls, "min_vram_gb", 0.0)),
|
||||
})
|
||||
# #981: mlx-audio multiplexes 7+ curated models behind one backend id
|
||||
# — surface the roster + the currently-active pick so Settings can
|
||||
@@ -2495,29 +2268,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())
|
||||
@@ -0,0 +1,205 @@
|
||||
"""Shared fakes and harness for the runtime-adapter tests.
|
||||
|
||||
Not a test module (no ``test_`` prefix): imported by
|
||||
``test_runtime_adapter_capabilities.py`` and
|
||||
``test_runtime_adapter_execute.py``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import hashlib
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
|
||||
import grpc
|
||||
|
||||
from runtime_adapter.gen import runtime_adapter_pb2 as pb2
|
||||
from runtime_adapter.gen import runtime_adapter_pb2_grpc as pb2_grpc
|
||||
from runtime_adapter.inventory import (
|
||||
STATE_READY,
|
||||
DeviceInfo,
|
||||
ModelInfo,
|
||||
)
|
||||
from runtime_adapter.server import RuntimeContext, create_server
|
||||
|
||||
READY_MODEL = ModelInfo(
|
||||
catalog_model_id="fake-tts",
|
||||
model_version="a" * 40,
|
||||
model_digest="sha256:" + "b" * 64,
|
||||
precisions=("fp32",),
|
||||
features=("tts",),
|
||||
state=STATE_READY,
|
||||
)
|
||||
|
||||
DEVICE = DeviceInfo(
|
||||
device_id="cpu:0",
|
||||
hardware_class="test-cpu",
|
||||
total_vram_bytes=8 * 1024**3,
|
||||
total_slots=1,
|
||||
free_slots=1,
|
||||
)
|
||||
|
||||
|
||||
class FakeInventory:
|
||||
def __init__(self, models=None, devices=None):
|
||||
self._models = list(models) if models is not None else [READY_MODEL]
|
||||
self._devices = list(devices) if devices is not None else [DEVICE]
|
||||
|
||||
def devices(self, busy_slots: int = 0):
|
||||
return [
|
||||
DeviceInfo(
|
||||
device_id=d.device_id,
|
||||
hardware_class=d.hardware_class,
|
||||
total_vram_bytes=d.total_vram_bytes,
|
||||
total_slots=d.total_slots,
|
||||
free_slots=max(0, d.total_slots - busy_slots),
|
||||
)
|
||||
for d in self._devices
|
||||
]
|
||||
|
||||
def models(self):
|
||||
return list(self._models)
|
||||
|
||||
|
||||
class FakeEngine:
|
||||
"""Half a second of silence at 24 kHz, instantly."""
|
||||
|
||||
sample_rate = 24000
|
||||
|
||||
def __init__(self):
|
||||
self.generate_calls = []
|
||||
|
||||
def ensure_ready(self):
|
||||
pass
|
||||
|
||||
def generate(self, text, **kw):
|
||||
import torch
|
||||
|
||||
self.generate_calls.append((text, kw))
|
||||
return torch.zeros(1, 12000)
|
||||
|
||||
|
||||
class SlowEngine(FakeEngine):
|
||||
"""Sleeps through generate in small slices so tests stay responsive."""
|
||||
|
||||
def __init__(self, seconds: float = 10.0):
|
||||
super().__init__()
|
||||
self.seconds = seconds
|
||||
self.started = threading.Event()
|
||||
|
||||
def generate(self, text, **kw):
|
||||
self.started.set()
|
||||
deadline = time.monotonic() + self.seconds
|
||||
while time.monotonic() < deadline:
|
||||
time.sleep(0.01)
|
||||
return super().generate(text, **kw)
|
||||
|
||||
|
||||
class FailingEngine(FakeEngine):
|
||||
def __init__(self, exc: BaseException, phase: str = "synthesis"):
|
||||
super().__init__()
|
||||
self._exc = exc
|
||||
self._phase = phase
|
||||
|
||||
def ensure_ready(self):
|
||||
if self._phase == "model_load":
|
||||
raise self._exc
|
||||
|
||||
def generate(self, text, **kw):
|
||||
raise self._exc
|
||||
|
||||
|
||||
def make_context(engine=None, inventory=None, **kw) -> RuntimeContext:
|
||||
engine = engine if engine is not None else FakeEngine()
|
||||
engines = {READY_MODEL.catalog_model_id: engine}
|
||||
kw.setdefault("progress_interval", 0.05)
|
||||
kw.setdefault("poll_interval", 0.005)
|
||||
return RuntimeContext(
|
||||
runtime_version="1.2.3-test",
|
||||
inventory=inventory if inventory is not None else FakeInventory(),
|
||||
engine_provider=lambda model_id: engines[model_id],
|
||||
**kw,
|
||||
)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def serve_over_socket(context: RuntimeContext, tmp_path=None):
|
||||
# A pytest tmp_path routinely exceeds the 103-character Unix-socket path
|
||||
# limit on macOS, so the socket gets its own short private tempdir.
|
||||
socket_dir = tempfile.mkdtemp(prefix="vs-rta-")
|
||||
socket_path = os.path.join(socket_dir, "runtime.sock")
|
||||
server = create_server(context, socket_path)
|
||||
server.start()
|
||||
channel = grpc.insecure_channel(f"unix:{socket_path}")
|
||||
try:
|
||||
yield pb2_grpc.RuntimeAdapterServiceStub(channel), socket_path
|
||||
finally:
|
||||
channel.close()
|
||||
server.stop(grace=0).wait()
|
||||
shutil.rmtree(socket_dir, ignore_errors=True)
|
||||
|
||||
|
||||
def make_execute_request(
|
||||
tmp_path,
|
||||
text: str = "hello runtime",
|
||||
*,
|
||||
attempt_id: str = "attempt-1",
|
||||
job_id: str = "job-1",
|
||||
model: ModelInfo = READY_MODEL,
|
||||
device_id: str = "cpu:0",
|
||||
deadline_in_s: float = 30.0,
|
||||
parameters: dict | None = None,
|
||||
input_sha256: str | None = None,
|
||||
input_handle: str | None = None,
|
||||
output_handle: str | None = None,
|
||||
) -> pb2.ExecuteRequest:
|
||||
if input_handle is None:
|
||||
input_path = tmp_path / "input.txt"
|
||||
input_path.write_text(text, encoding="utf-8")
|
||||
input_handle = str(input_path)
|
||||
if input_sha256 is None and text is not None:
|
||||
input_sha256 = hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||
if output_handle is None:
|
||||
output_handle = str(tmp_path / "output.wav")
|
||||
return pb2.ExecuteRequest(
|
||||
job_id=job_id,
|
||||
attempt_id=attempt_id,
|
||||
device_id=device_id,
|
||||
slot_id="slot-0",
|
||||
model=pb2.ModelSpec(
|
||||
catalog_model_id=model.catalog_model_id,
|
||||
model_version=model.model_version,
|
||||
model_digest=model.model_digest,
|
||||
precision="fp32",
|
||||
),
|
||||
parameters=parameters or {},
|
||||
inputs=[
|
||||
pb2.LocalArtifact(
|
||||
artifact_id="in-1",
|
||||
local_handle=input_handle,
|
||||
operation=pb2.LOCAL_ARTIFACT_OPERATION_READ,
|
||||
expected_sha256=input_sha256 or "",
|
||||
media_type="text/plain",
|
||||
)
|
||||
],
|
||||
outputs=[
|
||||
pb2.LocalArtifact(
|
||||
artifact_id="out-1",
|
||||
local_handle=output_handle,
|
||||
operation=pb2.LOCAL_ARTIFACT_OPERATION_WRITE,
|
||||
media_type="audio/wav",
|
||||
)
|
||||
],
|
||||
deadline_unix_ms=int((time.time() + deadline_in_s) * 1000),
|
||||
maximum_preview_bytes=0,
|
||||
)
|
||||
|
||||
|
||||
def terminal_of(events):
|
||||
last = events[-1].event
|
||||
kind = last.WhichOneof("payload")
|
||||
assert kind in ("completed", "failed", "canceled"), kind
|
||||
return kind, last
|
||||
@@ -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,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,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)
|
||||
@@ -0,0 +1,71 @@
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from services.hosted_voice_api import HostedSettings, HostedVoiceClient, HostedVoiceError
|
||||
|
||||
|
||||
_NAMES = (
|
||||
"VSS_HOSTED_API_BASE", "VSS_HOSTED_API_TOKEN", "VSS_HOSTED_PROJECT_ID",
|
||||
"VSS_HOSTED_MODEL_ID", "VSS_HOSTED_MODEL_VERSION", "VSS_HOSTED_BASE_VOICE_ID",
|
||||
)
|
||||
|
||||
|
||||
def test_hosted_adapter_is_disabled_without_configuration(monkeypatch):
|
||||
for name in _NAMES:
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
assert HostedSettings.from_environment() is None
|
||||
|
||||
|
||||
def test_hosted_adapter_refuses_partial_configuration(monkeypatch):
|
||||
for name in _NAMES:
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
monkeypatch.setenv("VSS_HOSTED_API_BASE", "http://127.0.0.1:8080")
|
||||
with pytest.raises(HostedVoiceError, match="VSS_HOSTED_API_TOKEN"):
|
||||
HostedSettings.from_environment()
|
||||
|
||||
|
||||
def test_hosted_adapter_requires_http_endpoint(monkeypatch):
|
||||
values = {
|
||||
"VSS_HOSTED_API_BASE": "not-a-url",
|
||||
"VSS_HOSTED_API_TOKEN": "token",
|
||||
"VSS_HOSTED_PROJECT_ID": "project",
|
||||
"VSS_HOSTED_MODEL_ID": "model",
|
||||
"VSS_HOSTED_MODEL_VERSION": "v1",
|
||||
"VSS_HOSTED_BASE_VOICE_ID": "base",
|
||||
}
|
||||
for name, value in values.items():
|
||||
monkeypatch.setenv(name, value)
|
||||
with pytest.raises(HostedVoiceError, match="http"):
|
||||
HostedSettings.from_environment()
|
||||
|
||||
|
||||
def test_create_voice_uses_artifact_grants_then_canonical_voice_resource(tmp_path):
|
||||
reference = tmp_path / "reference.wav"
|
||||
reference.write_bytes(b"reference-audio")
|
||||
settings = HostedSettings("https://api.test", "token", "project", "model", "v1", "base", "oss-spoken-consent-v1")
|
||||
requests = []
|
||||
|
||||
def handler(request):
|
||||
requests.append(request)
|
||||
if request.url.path == "/v1/artifacts/upload-authorizations":
|
||||
return httpx.Response(200, json={"artifact_id": "artifact-ref", "method": "PUT", "url": "https://objects.test/ref", "required_headers": {}})
|
||||
if request.url.host == "objects.test":
|
||||
return httpx.Response(200)
|
||||
if request.url.path == "/v1/artifacts/artifact-ref/complete":
|
||||
return httpx.Response(200, json={})
|
||||
if request.url.path == "/v1/voices":
|
||||
return httpx.Response(201, json={"id": "hosted-voice"})
|
||||
return httpx.Response(404)
|
||||
|
||||
async def create():
|
||||
client = HostedVoiceClient(settings, httpx.AsyncClient(base_url=settings.base_url, transport=httpx.MockTransport(handler)))
|
||||
return await client.create_voice(name="Local profile", description="description", reference_path=str(reference))
|
||||
|
||||
assert asyncio.run(create()) == "hosted-voice"
|
||||
voice_request = next(request for request in requests if request.url.path == "/v1/voices")
|
||||
body = __import__("json").loads(voice_request.content)
|
||||
assert body["project_id"] == "project"
|
||||
assert body["reference_audio_artifact_id"] == "artifact-ref"
|
||||
assert body["consent"]["attestation_text_version"] == "oss-spoken-consent-v1"
|
||||
@@ -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,81 +136,11 @@ 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
|
||||
|
||||
|
||||
def test_omnivoice_subprocess_has_longer_spawn_budget_than_other_sidecars():
|
||||
assert _PlainBackend.spawn_ready_timeout_s == 30.0
|
||||
assert OmniVoiceSubprocessBackend.spawn_ready_timeout_s == 120.0
|
||||
|
||||
|
||||
def test_spawn_uses_backend_specific_ready_timeout(monkeypatch, tmp_path):
|
||||
_use_stub(monkeypatch, tmp_path / "unused.py")
|
||||
backend = OmniVoiceSubprocessBackend()
|
||||
observed = []
|
||||
|
||||
class StubProcess:
|
||||
stderr = io.BytesIO()
|
||||
|
||||
@staticmethod
|
||||
def poll():
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(
|
||||
"services.subprocess_backend.spawn_owned",
|
||||
lambda *_args, **_kwargs: StubProcess(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
backend,
|
||||
"_recv_with_timeout",
|
||||
lambda timeout: observed.append(timeout) or {"op": "ready"},
|
||||
)
|
||||
monkeypatch.setattr("services.subprocess_backend._ensure_reaper_running", lambda: None)
|
||||
|
||||
try:
|
||||
backend._spawn()
|
||||
finally:
|
||||
backend._proc = None
|
||||
|
||||
assert observed == [120.0]
|
||||
|
||||
|
||||
def test_omnivoice_subprocess_recv_timeout_env_override(monkeypatch):
|
||||
monkeypatch.setenv("OMNIVOICE_SIDECAR_RECV_TIMEOUT_S", "120")
|
||||
assert OmniVoiceSubprocessBackend().recv_timeout_s == 120.0
|
||||
@@ -373,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
|
||||
@@ -432,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,
|
||||
})]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user